feat(axl): read a compact execution log, and diff two of them - #1482
gregmagolan wants to merge 2 commits into
Conversation
`bazel.execution_log.read(path = ...)` decodes a finished
--execution_log_compact_file from disk into the same `ExecLogEntry`
iterator the live build path yields. The decoder already existed; what
was missing was pointing it at a file nobody is still writing.
On top of it, `aspect execlog diff` and `aspect execlog list`: the first
built-in tasks that run no Bazel command.
`diff` takes two logs from builds that should have been identical and
reports every action present in both whose action key moved, naming
which of args / env / inputs / tools / platform changed and, for inputs,
the files whose digests differ. The propagation reads the way you want:
Genrule //gen:banner
inputs: changed gen/greeting.txt
Genrule //gen:report
inputs: changed bazel-out/.../gen/banner.txt
A source moved, so the action reading it moved, so the action reading
its output moved. //gen:stable is absent, because it did not.
Memory is the whole design. A 20 MB compact log expands to gigabytes
flattened and a diff needs two, so nothing here holds a flattened log:
pass one keeps a five-hash fingerprint per spawn, and only the spawns
whose fingerprints differ are resolved in pass two. Two passes over a
compressed file beat one flattened log in memory, and pass two is
skipped when nothing differs.
Ported from the Go execlog-diff, which streams and diffs concurrently;
this is the same analysis with a bounded working set.
Errors: a missing file or a non-zstd one fails on the calling thread
with a traceback. Truncation part-way through ends iteration and sets
`error()` — except at a block boundary, which zstd reports as a clean
end. Documented on `spawn_from_path`, with what closing it would take.
6 Rust tests, 14 AXL tests. The Rust ones found a real hazard: in
fibre's SPMC every Receiver clone is a subscriber the sender must not
lap, so holding the stream's own clone while consuming deadlocks on any
log longer than the channel bound. Production drops it; the test now
does too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Aspect Workflows Tasks📅 Sun Sep 20 18:27:55 UTC 2026 Task Results
Reproduce❌ delivery (delivery-uncacheable · delivery-gha-debug · delivery-gha)Install ⏱ Last updated Sun Sep 20 18:55:57 UTC 2026 · 📊 GitHub API quota 0/7,700 (0% used, resets in 59m) |
CI's format and buildifier tasks. No behavior change: line wrapping in `stream/execlog.rs` and blank lines in two `.axl` files. Note for next time: `aspect buildifier --scope=all` does not catch the `.axl` files, because the bare binary's `-r .` tree walk auto-discovers only BUILD, MODULE.bazel, *.bzl and *.star. Pass them explicitly, or let `--scope=changed` do it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two builds that should have been identical were not. Which action moved, and why?
That question is answerable from the compact execution log and almost nobody answers it, because the log is a zstd frame of protobufs that reference each other by id, and reconstructing it is a project.
execlog-diff(Go) does it. This brings the same analysis into the CLI as an AXL task, and adds the one runtime primitive it needed.The runtime bit
bazel.execution_log.read(path = ...)decodes a finished--execution_log_compact_fileinto the sameExecLogEntryiterator the live build path already yields. The decoder existed; what was missing was pointing it at a file nobody is still writing.Decoding runs on a background thread with blocking sends, so a log larger than memory can be walked as long as the loop body does not keep every entry. Unlike the live path, nothing is dropped under back-pressure: the build already finished, so there is nothing to slow down, and a dropped entry would make a diff wrong rather than slow.
aspect execlog diffA source file moved, so the action reading it moved, so the action reading its output moved.
//gen:stableis absent from the report because it did not change. That chain is the thing you actually want when a cache hit rate drops and nobody knows why.Five dimensions of the action key are compared —
args,env,inputs,tools,platform— and every one that moved is reported rather than the first, because an action with two problems otherwise sends you round the loop twice.envis the one that finds non-hermeticity:Exits 0 by default, because a changed action is a finding rather than an error.
--fail-on-changemakes it fail a CI step that asserts two builds are identical.aspect execlog listis the--listequivalent: mnemonic, label, runner, cache hit, with--output=jsonadding each action's computed key.Memory is the design
A 20 MB compact log expands to gigabytes once every input set is flattened, and a diff needs two of them. The Go tool solves this by streaming and diffing concurrently. AXL is single-threaded, so it solves it by never holding a flattened log:
Fingerprintper spawn. A fingerprint is five hashes and a label, so it scales with the number of actions, not the number of files.Two passes over a compressed file are far cheaper than one flattened log in memory.
--summary-onlystops after the first pass when even that is too much.What I got wrong, and what zstd will not tell us
Truncation is usually, not always, detected. A log cut off part-way through sets
error()and ends the stream early, so a caller that checks does not compare a prefix and call it clean. The detection comes from zstd reportingincomplete framewhen it runs out of input mid-block. When the cut lands exactly on a block boundary, the decoder sees a clean end and the entries before it are a structurally valid stream.Measured against a real 134 KB log: cuts at 40%, 50%, 60%, 75%, 90% and 99% were all reported; cuts at 10% and 25% were not. Closing that gap needs zstd's end-of-frame marker, which
zstd::Decoderdoes not expose — it needszstd_safe::DCtxdirectly. Documented onspawn_from_path. Worth doing if a partial log is ever mistaken for a real answer in practice.The Rust tests found a real hazard. In
fibre's SPMC broadcast, everyReceiverclone is an independent subscriber whose tail the sender must not lap. My first test helper held the stream (and therefore its internal receiver clone) while consuming, which deadlocked the producer on any log longer than the 1000-entry channel bound. Production already drops it — the Starlarkread()doesdrop(stream)right after taking the receiver — but the test did not, and hung. Both now do the same thing, with the reason written down.Other limits worth knowing
directoryoutput whose contents changed but whose path did not will not be flagged. The Go tool resolves those; this does not yet.label!mnemonic, which can collide with its siblings.only in first/only in secondcounts make that visible, but the tool cannot stop you.Tests
6 Rust (
cargo test -p axl-runtime --lib engine::bazel::stream::execlog): a complete log yields every entry with no failure; a non-zstd file, a too-short file and a missing file each fail on the calling thread with a message naming the problem; a corrupt frame sets the failure slot; a truncated log yields a prefix.14 AXL (
tests axl, 1005 total, exit 0): input-set flattening flat, transitive, cyclic, with a dangling id, and with Bazel's id-0 "no inputs"; the maps differ separating changed from added from removed; the list differ reporting the index so an inserted flag reads as a shift; and the hash not colliding across element boundaries, which is what stops a diff missing a real change.Verified by hand end to end against a clone of
aspect-starters/shellwith three genrules: one whose source changed, one downstream of it, and one that did not change. All three classify correctly, and theenvandargsdimensions were confirmed separately with--action_envand acmdedit.Changes are visible to end-users: yes
builtins/aspect/README.md)Suggested release notes:
aspect execlog diff <before> <after>: compare two compact execution logs and explain every action that would not have been a cache hit, naming the arguments, environment variables or input files behind each one.aspect execlog list <log>: list the actions in a compact execution log, with--output=json.bazel.execution_log.read(path = ...): read a compact execution log from disk in AXL.Test plan
🤖 Generated with Claude Code