Skip to content

AIR CLI Migration: --download-to flag for logs - #6153

Open
riddhibhagwat-db wants to merge 9 commits into
air-clifrom
air-download-to
Open

AIR CLI Migration: --download-to flag for logs#6153
riddhibhagwat-db wants to merge 9 commits into
air-clifrom
air-download-to

Conversation

@riddhibhagwat-db

@riddhibhagwat-db riddhibhagwat-db commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Changes & Why

air logs --download-to DIR was a registered flag that just errored ("not implemented yet"). It exists so you can pull a failed multi-node run's logs to disk for offline inspection, instead of streaming one node at a time to your terminal. Without it, debugging a 2+ node failure meant running air logs --node N repeatedly and copy-pasting.

Design decisions and their reasons:

  • MLflow-only, no Bricklens fallback. Bricklens is a streaming API with no bulk-download surface; MLflow stores the actual chunk artifacts. So the download path goes straight to MLflow — no try/catch dance needed (unlike streaming).
  • Parallel across nodes. A 8-node run downloading serially would be needlessly slow, so it fans out with errgroup bounded to 8 workers.
  • One up-front layout probe. The logs/node_N vs logs/attempt_X/node_N layout is run-wide, so it's discovered once rather than per-node (per-worker discovery would serialize on the probe and multiply the timeout).
  • A single node's failure isn't fatal. One node missing logs shouldn't abort the whole download — it's logged at debug and skipped.

Tests

Unit tests:

  • TestResolveNodeCount — node-count math: GPU_1xA10×2→2, GPU_1xH100×4→4, GPU_8xH100×16→2; non-AIR run errors
  • TestDownloadNodeLogWritesConcatenatedChunks — full byte path (artifacts/list → credentials-for-read → presigned fetch), file written to logs/node_0.log with concatenated content
  • TestDownloadAllNodeLogs — 2-node parallel download, both files present in the node→path map
  • TestDownloadLogsAllNodes — end-to-end entry point, both node files written
  • TestDownloadLogsSingleNode — --node 1 writes only node 1 (asserts node 0 absent)
  • TestDownloadLogsOutOfRangeNode — --node 5 on a 2-node run → clear error

Acceptance tests

  • download-to reports no logs when none are available — exact error text + exit 1
  • download-to with an out-of-range node is rejected — "node 5 does not exist: run has 2 node(s), indexed 0 to 1" + exit 1

Manual verification:

Screenshot 2026-08-04 at 10 30 46 AM Screenshot 2026-08-04 at 10 31 03 AM

How to review this PR:

Breakdown: ~700 lines total; 437 are tests, 275 is production code
Careful reads requested on the following lines:
logmlflow.go, line 261:

  • the artifact path is now passed as a scalar rather than []string, because the Go SDK serializes map-query values with fmt.Sprintf("%v", …) and was sending the literal [logs/node_0/logs-0.chunk.txt]. The backend signs that bogus path and returns 200, so this only surfaced as a 404 on the download itself
  • The Python CLI passes a list here deliberately (its SDK expands it into path=…&path=…) and warns that a scalar "may be handled inconsistently by intermediaries". Worth a second opinion on whether Go should use a tagged struct to emit the genuinely repeated param instead of the scalar.

logdownload.go, line 46:

  • rejects an accelerator count that isn't a whole multiple of per-node GPUs
  • the earlier truncating division turned GPU_8xH100×4 into zero nodes and a false "no logs," so just need to sanity-check that the divisibility invariant holds for every accelerator type

logdownload.go:25/:70 and logs.go:147 Error Classification:

  • An out-of-range --node is a user error and must map to INVALID_ARGS/PERMANENT (not a retryable INTERNAL_ERROR), which is why it flows through the errNodeOutOfRange sentinel rather than a string match.

logdownload.go lines 103-109:

  • per-node failures must be reported before the len(nodeLogs) == 0 early return, otherwise a run whose every node failed silently prints "No logs available" and hides the cause
  • Relatedly, :139 propagates context.Canceled/DeadlineExceeded out of the errgroup so Ctrl-C doesn't get reported as "no logs," while other per-node errors are collected as warnings so one bad node doesn't abort the whole download.

logdownload.go:178 and :199-208: bytes are streamed verbatim via io.Copy, chunk 0 is assumed when the listing comes back empty, and a 0 byte result removes the file and reports "no logs" rather than leaving an empty artifact

Derive a run's node count from its accelerator type and count (accelerators are
allocated in whole nodes), reusing jobCompute + parseGPUType + gpusPerNode. This
is the first piece of `air logs --download-to`.

Co-authored-by: Isaac
downloadAllNodeLogs downloads the given nodes' logs concurrently (errgroup,
bounded to downloadConcurrency) from the MLflow artifact store, probing the
attempt-prefix layout once up front. downloadNodeLog concatenates a node's chunk
files into dir/logs/node_<n>.log. Reuses the existing MLflow helpers
(discoverAttemptPrefix, constructLogPath, listLogChunks, downloadChunkLines).

Co-authored-by: Isaac
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Approval status: pending

/acceptance/experimental/air/ - needs approval

6 files changed
Suggested: @vinchenzo-db
Also eligible: @apeforest, @bfontain, @lu-wang-dl, @panchalhp-db, @maggiewang-db, @ben-hansen-db, @pardis-beikzadeh-db

/experimental/air/ - needs approval

7 files changed
Suggested: @vinchenzo-db
Also eligible: @apeforest, @bfontain, @lu-wang-dl, @panchalhp-db, @maggiewang-db, @ben-hansen-db, @pardis-beikzadeh-db

Any maintainer (@andrewnester, @anton-107, @denik, @pietern, @shreyas-goenka, @simonfaltum, @renaudhartert-db, @janniklasrose, @lennartkats-db) can approve all areas.
See OWNERS for ownership rules.

downloadLogs resolves the run's node count, downloads the requested node(s)
in parallel, and prints a per-node summary. An explicit --node downloads just
that node (rejected if out of range); omitted downloads all nodes. Replaces the
"--download-to is not implemented yet" rejection in logs.go and threads the new
downloadTo / nodeSet fields through logRequest.

Adds the logs-download acceptance scenario (no-logs + out-of-range node; the full
byte download is unit-covered since the pre-signed URL host is dynamic) and drops
the stale rejection cases.

Co-authored-by: Isaac
Correctness:
- A run with no logs now reports it and exits 0, the same as the streaming path,
  instead of a retryable INTERNAL_ERROR and exit 1.
- An out-of-range --node is classified INVALID_ARGS/PERMANENT via a sentinel,
  not a transient failure, and is no longer double-wrapped.
- resolveNodeCount rejects an accelerator count that isn't a whole number of
  nodes; it previously truncated to 0 nodes and reported "no logs".
- Cancellation propagates out of the parallel download instead of looking like a
  node with no logs.
- Chunks stream byte-for-byte to the output file rather than round-tripping
  through lines, which rewrote line endings and capped long lines.
- An empty chunk listing falls back to chunk 0, as the streaming path does.
- Per-node failures are surfaced as warnings, so a truncated log set no longer
  looks complete.
- Reject --lines/--minutes with --download-to instead of ignoring them.

Tests: explicit --node 0, non-divisible and zero accelerator counts, no-logs
parity with the streaming path, partial failure, cancellation, and the
attempt-prefixed layout.

Co-authored-by: Isaac
@riddhibhagwat-db riddhibhagwat-db changed the title Air download to AIR CLI Migration: --download-to flag for logs Aug 3, 2026
@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: f0c9276

Run: 30974875401

Env 💚​RECOVERED 🙈​SKIP ✅​pass 🙈​skip Time
💚​ aws linux 4 4 322 1070 4:51
💚​ aws windows 4 4 324 1068 7:39
💚​ azure linux 4 4 322 1069 5:08
💚​ azure windows 4 4 324 1067 10:11
💚​ gcp linux 1 5 321 1071 4:58
💚​ gcp windows 1 5 323 1069 7:51
8 interesting tests: 4 RECOVERED, 4 SKIP
Test Name aws linux aws windows azure linux azure windows gcp linux gcp windows
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
🙈​ TestAccept/bundle/invariant/no_drift 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_endpoints/drift/recreated_same_name 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_indexes/recreate/embedding_dimension 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/ssh/connection 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
💚​ TestFetchRepositoryInfoAPI_FromRepo 💚​R 💚​R 💚​R 💚​R 🙈​S 🙈​S
💚​ TestFetchRepositoryInfoAPI_FromRepo/root 💚​R 💚​R 💚​R 💚​R
💚​ TestFetchRepositoryInfoAPI_FromRepo/subdir 💚​R 💚​R 💚​R 💚​R
Top 6 slowest tests (at least 2 minutes):
duration env testname
9:08 azure windows TestAccept
6:34 gcp windows TestAccept
6:32 aws windows TestAccept
3:09 azure linux TestAccept
2:53 gcp linux TestAccept
2:52 aws linux TestAccept

The pre-signed-URL request passed the artifact path as a []string in a map
query. The SDK formats map values with %v, so the path was sent as the literal
"[logs/node_0/logs-0.chunk.txt]". The backend signs that path and returns 200,
so every chunk download 404'd instead.

This also silently broke --download-to: the per-node failure was recorded but
the warnings printed after the no-logs early return, so a run with logs reported
"No logs available". Report failures first.

Co-authored-by: Isaac

@ben-hansen-db ben-hansen-db left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, just a few comments to address

Comment thread experimental/air/cmd/logdownload.go Outdated
os.Remove(outPath)
return "", err
}
break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize there could be subtle bug here, where chunk X might succeed but chunk X +1 fails and then that isn't reported.

Maybe something to surface when that happens

return outPath, fmt.Errorf("truncated at chunk %d: %w", chunk.index, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also is there a retry on failed download of a chunk?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reimplemented this to skip a failed chunk and keep going rather than stopping at the first gap, matching the Python CLI's behavior.

The original logic was the wrong tradeoff: if chunk 1 of 20 fails, we'd lose chunks 2–20 including the tail, which is usually where the failure signature is. Skipping preserves everything else, and the gap is no longer silent: downloadNodeLog returns both the partial path and an error naming the missing chunk indices, so the summary reports the node and marks it. Cancellation still aborts immediately since every remaining chunk would fail anyway, and a node where every chunk failed returns an error rather than an empty result. Also added tests for a mid-log gap (asserts chunk 0 + chunk 2 with chunk 1 failing), the partial-download error, and the all-chunks-failed case.

return renderError(ctx, cmd, "INTERNAL_ERROR", "TRANSIENT", true,
fmt.Errorf("failed to download logs for run %d: %w", req.runID, err))
}
if !success {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude flagged this case.

Downloading a workload's logs where the run hasn't completed exits 1 rather than exits 0 b/c resultState is empty.

Comment thread experimental/air/cmd/logdownload.go Outdated
// Reported before the no-logs check, so a run whose every node failed explains
// why instead of looking like a run that never logged.
for _, node := range sortedNodeKeys(failures) {
cmdio.LogString(ctx, fmt.Sprintf("warning: node %d logs could not be downloaded: %s", node, failures[node]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corner case I want to make sure we address.

2-node FAILED run, both nodes 404 or fail for some reason

Could there be a case where stdout does: "No logs available for run X. Run terminated in state FAILED"
And the above warning is missed so it seems there were no logs but they just failed to download?

this probably matters more for json case tbh where agent gets confused and thinks there's no logs

A chunk failing after earlier ones succeeded broke out of the loop and returned
the path with no error, so a log cut short was listed as a normal success. The
user got a short file with no indication it was incomplete.

Return the path and an error naming the chunk; the caller keeps the partial log
and reports it, and the summary marks the node incomplete.

Co-authored-by: Isaac
Stopping at the first bad chunk cost every chunk after it, including the tail
where a run's failure signature usually is. Skip the failed chunk, keep walking,
and name the gaps in the returned error so the log is never silently short.

Matches the Python CLI, which skips a failed chunk and concatenates the rest.

Co-authored-by: Isaac
A one-shot fetch can run against an active run, which has no result state yet,
so succeeded() was false and the command exited 1 even though the logs printed
fine. Only a terminal run's outcome should decide the exit code.

Affects --download-to and the two pre-existing one-shot paths (the MLflow
fallback and a past retry's static view).

Co-authored-by: Isaac
A run whose nodes all failed to download reported "No logs available", which
tells the caller the run produced nothing. The per-node warnings go to stderr,
so a -o json consumer reading stdout saw only the misleading ERROR event.

Return an error in that case, so JSON mode renders an error envelope on stdout.

Co-authored-by: Isaac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants