Repository navigation
fix(core): stop JSONL stores losing records to concurrent writers - #738
jussielo-amd wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
A confirmed shrink-and-regrow race can make lock-free readers return fabricated records or transient errors.
1 open issue
What changed in this PR
This PR addresses concurrent-writer record loss in the core JSONL stores shared by rocm and rocmd, while reducing append and recent-read costs.
Changes:
- Serializes store mutations with per-store locks and replaces whole-file appends with append-mode writes.
- Adds torn-tail recovery, backward scanning for recent records, and concurrency regression tests.
- Clarifies locking guarantees and documents the new lock usage.
Verification here used a standalone race model; project tests were not run in this review environment.
| File | Description |
|---|---|
crates/rocm-core/src/lib.rs |
Clarifies that write_atomic does not serialize writers. |
crates/rocm-core/src/automation.rs |
Implements store locking, append recovery, reverse reads, and regression tests. |
apps/rocm/src/main.rs |
Updates the prune-lock documentation to account for store locks. |
Review effort: Balanced
ca2a651 to
6dfcb16
Compare
… the fallout A Copilot review on #738 found that cutting a torn tail in place and then appending as a separate write could let a lock-free reverse-scan reader observe the store shrink and regrow mid-scan, splicing bytes read at two different moments into something that parses as a record nobody wrote. Torn-tail repair now folds the cut into the append itself: the clean prefix plus the new record are written as one atomic rename over the store (`write_atomic`), so a reader only ever sees the pre-repair length or the post-append length. Removing the old rollback-on-failed-write (needed so a repair-in-place couldn't itself race a reader) opened a narrower gap: a write that failed one byte short of its own trailing `\n` left a complete, valid record on disk while still reporting `Err`, which would make a caller that retries on error write a duplicate. Appending now checks what actually landed before deciding, and only reports failure when the record genuinely didn't make it. Also fixes two pre-existing doc comments (process.rs, examine.feature) that still described appends as rewriting the whole store each time, and deduplicates the Windows file-identity test helper into test_support.rs. A torn-tail repair that can't complete its rename (sustained disk pressure, or a Windows handle without delete-sharing) still has no fallback and keeps retrying the same whole-store rewrite; tracked as a follow-up in #748 rather than folded into this fix. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
… the fallout A Copilot review on #738 found that cutting a torn tail in place and then appending as a separate write could let a lock-free reverse-scan reader observe the store shrink and regrow mid-scan, splicing bytes read at two different moments into something that parses as a record nobody wrote. Torn-tail repair now folds the cut into the append itself: the clean prefix plus the new record are written as one atomic rename over the store (`write_atomic`), so a reader only ever sees the pre-repair length or the post-append length. Removing the old rollback-on-failed-write (needed so a repair-in-place couldn't itself race a reader) opened a narrower gap: a write that failed one byte short of its own trailing `\n` left a complete, valid record on disk while still reporting `Err`, which would make a caller that retries on error write a duplicate. Appending now checks what actually landed before deciding, and only reports failure when the record genuinely didn't make it. Also fixes two pre-existing doc comments (process.rs, examine.feature) that still described appends as rewriting the whole store each time, and deduplicates the Windows file-identity test helper into test_support.rs. A torn-tail repair that can't complete its rename (sustained disk pressure, or a Windows handle without delete-sharing) still has no fallback and keeps retrying the same whole-store rewrite; tracked as a follow-up in #748 rather than folded into this fix. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
bf8b333 to
4127f3f
Compare
… the fallout A Copilot review on #738 found that cutting a torn tail in place and then appending as a separate write could let a lock-free reverse-scan reader observe the store shrink and regrow mid-scan, splicing bytes read at two different moments into something that parses as a record nobody wrote. Torn-tail repair now folds the cut into the append itself: the clean prefix plus the new record are written as one atomic rename over the store (`write_atomic`), so a reader only ever sees the pre-repair length or the post-append length. Removing the old rollback-on-failed-write (needed so a repair-in-place couldn't itself race a reader) opened a narrower gap: a write that failed one byte short of its own trailing `\n` left a complete, valid record on disk while still reporting `Err`, which would make a caller that retries on error write a duplicate. Appending now checks what actually landed before deciding, and only reports failure when the record genuinely didn't make it. Also fixes two pre-existing doc comments (process.rs, examine.feature) that still described appends as rewriting the whole store each time, and deduplicates the Windows file-identity test helper into test_support.rs. A torn-tail repair that can't complete its rename (sustained disk pressure, or a Windows handle without delete-sharing) still has no fallback and keeps retrying the same whole-store rewrite; tracked as a follow-up in #748 rather than folded into this fix. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
4127f3f to
87fcf8c
Compare
nowycondro
left a comment
There was a problem hiding this comment.
🔴 Automated review · pr-review-watcher · 87fcf8c
Review — no blocking findings
Full review of the whole change.
Implements the concurrency fix described in ROCMAI-721/ROCMAI-665 (both already named in the PR title/commits); the deferred follow-up work (store rotation, tail-only proposal reads, and the torn-tail-repair-has-no-fallback edge case) is explicitly scoped out to a tracked follow-up, consistent with the code.
Blocking
None
Non-blocking
FileLock's doc comment doesn't mention its new primary use site —crates/rocm-core/src/process.rs(~line 385-392, unchanged by this PR)
The doc comment enumeratesFileLock's use sites as "the daemon autostart decision and the managed-serve GPU select-then-claim," both check-then-act races. This PR adds a third, now very prominent use (serializing JSONL-store append/rewrite inautomation.rsviajsonl_lock_path) that is a different shape (plain mutual exclusion over a write, not a check-then-act race) and isn't reflected in the enumeration. Confirmed the doc text is unchanged in this diff and the new use site is real and heavily used.
Confidence 70 · mechanical · Fix: extend the doc's use-site list in process.rs to include the per-store JSONL locks.
Decisions for the author
inspect_tail's backward-chunk scan duplicatesReverseLines's scanning logic — non-blocking-improvement
Both independently implement "find the last\nscanning backwards in fixed-size chunks" (crates/rocm-core/src/automation.rs). They agree today and each is tested (ReverseLinesexhaustively viareverse_lines_matches_a_forward_split_at_every_chunk_size;inspect_tail's multi-chunk path only via the torn-tail case), but a future change to one could silently diverge from the other. Consider havinginspect_tailreuseReverseLinesfor its one-line lookup.reads_racing_appends_never_failwould also pass against the pre-fix code — non-blocking-improvement
The test (automation.rs) asserts concurrent reads never error while appends race, but the pre-PR code's every-append-is-an-atomic-rename behavior also never let a reader observe a torn write, so this test doesn't by itself distinguish the fix from a revert. The in-code comment already discloses this ("this is a smoke test... pinned deterministically byreaders_skip_an_unterminated_last_line_that_does_not_parse"), so it reads as an honest, self-aware smoke test rather than a hidden gap — noted only as something the author may want to strengthen (e.g. shrinking the scan chunk size under test to make a torn read more probable), not as a defect.
Positive signals
- The file-identity tests (
append_of_a_torn_tail_replaces_rather_than_truncates_the_file_in_place, Unix inode / Windows volume-serial+file-index) correctly recognize that a byte-content assertion alone cannot distinguish an atomic-rename repair from an in-place truncate-then-rewrite, and add the one check that can. append_or_tolerate_a_missing_newline's "did it actually land" check for a write that fails exactly at its own trailing newline is a genuinely subtle case (it avoids a caller-side retry creating a duplicate record), and it is backed by a dedicated, directly-tested pure function (landed_despite_the_error) rather than only being exercised indirectly.- Every doc comment this change adds or edits (module header,
jsonl_lock_path,append_jsonl_record,read_range,write_atomic, themain.rslock-reachability note) was checked against the implementation and is accurate — including the specific, checkable claim that no code path appends to a store, or calls into another lock, while holding a differentFileLock.
Deployment notes
- Mixed-version rollout: a
rocm/rocmdbinary built before this change does not take the new per-store lock, so while an old and a new binary write the same store concurrently (e.g. an oldrocmdstill running through an upgrade), the old side's writes can still silently drop a record. This is already called out in the PR description; both sides need to be on this version for the fix to hold. - Each store gains a sibling zero-byte
*.jsonl.lockfile in the data directory (same pattern as the existinglaunch.lock/autostart.lock); no migration is needed since it's created on first use.
What this covered
Full review of both PR commits (base 7eb4bfacd9 to head 87fcf8c50c), all 7 changed files read completely at the PR head: crates/rocm-core/src/automation.rs (the 1672-line core of the change, plus the pre-change version for comparison), apps/rocm/src/main.rs, crates/rocm-core/src/lib.rs, crates/rocm-core/src/process.rs (including FileLock's implementation), crates/rocm-core/src/config.rs, crates/rocm-core/src/test_support.rs, and tests/e2e-cucumber/features/examine.feature. Also read every call site of the three store-accessor functions across apps/rocm and apps/rocmd to confirm no caller bypasses the new lock, and every other FileLock::acquire call site in the tree to confirm no nested-acquire/deadlock path exists between the new per-store locks and the pre-existing launch/autostart/install locks.
Four independent fan-out passes ran (core automation.rs logic and its cross-file doc claims; the mechanical windows_file_identity relocation; this repo's own AGENTS.md adherence; file history/blame plus code-comment staleness across the touched files and a repo-wide search for stale references to the old behavior), consolidated against direct tracing of the lock/call-graph. CI was checked twice, since one job (windows-build-and-test) was still in progress at the first check; all checks are green at the reviewed head. The PR description and its two named tickets were read after the findings above were reached, and are consistent with the code and with each other.
Did not run: no test or mutation check was executed directly in this review; it relies on CI's green cargo test --workspace --all-targets and the PR's own stated mutation check (removing either lock makes proposal_status_updates_racing_appends_lose_neither fail). The history pass found only one prior commit touching automation.rs (a pure extraction), so there was no earlier concurrency-related review history to compare against. Not reconciled against this PR's own GitHub review threads or comments.
tomastola
left a comment
There was a problem hiding this comment.
Thanks for the thorough fix — the cross-process regression test that re-executes the test binary, and the inode / file-identity tests that tell "replaced by rename" apart from "rewritten in place", are both excellent. Two pieces of new behaviour are not exercised by any test, though, so requesting changes on those.
Blocking
1. The retry loop in load_recent_jsonl_records is never exercised — crates/rocm-core/src/automation.rs:491, :502-509
Setting ATTEMPTS to 1 leaves every automation:: test green (cargo test -p rocm-core automation::, 27/27). a_scan_that_finds_the_file_shorter_than_it_was_still_succeeds calls scan_recent_jsonl_records directly with a stale length, so it never reaches the retry. The description lists this behaviour ("A scan that fails while the file's length moved under it is retried from the new end"), but nothing tests it.
Suggested test: add a seam between the length read and the scan (or inject a shrink between the two file_len() calls) and assert that the load still succeeds after a retry.
2. The "tolerate a missing newline" wiring is untested; only its pure decision function is — automation.rs:310-342
Replacing the body of append_or_tolerate_a_missing_newline with a plain file.write_all(line).context(..) leaves every test green. landed_despite_the_error is tested. The code around it is not: the read-back from start, the .max(start), and the error folding. That untested code is what decides whether a caller that retries on error writes a duplicate.
A real partial write is practical to produce. The cross-process test already re-executes the test binary; a Unix child run the same way, with RLIMIT_FSIZE set one byte short of the record and SIGXFSZ ignored, gets a short write. Assert that the append returns Ok and the store holds exactly one copy of the record.
Non-blocking
- No test races an append against a torn-tail repair (
automation.rs:229-234,:266). Removing the lock fromappend_jsonl_recordleaves bothconcurrent_appends_from_many_{threads,processes}green, because append mode alone keeps small appends from interleaving. Onlyproposal_status_updates_racing_appends_lose_neithercatches it, and only for the proposals store. Nothing seeds a torn tail and then runs concurrent appenders, which is the scenario the lock's doc comment gives as its reason. Seeding a torn tail in the events or audit store before the threaded appenders would cover it. - The blank unterminated-tail branch is untested (
automation.rs:407). Droppingis_blank(&tail) ||leaves all tests green. A whitespace-only tail would then be cut by a whole-store rewrite instead of being kept. - Redundant code.
records.truncate(limit)atautomation.rs:543can never change anything: the tail push adds at most one record, the loop stops atlimit, and everylimit == 0caller returns early.unterminated &&inparse_jsonl_records(:451,:460) is also redundant: when the bytes end in\n, the last split segment is empty and is skipped as blank first. Removing either leaves all tests green. Remove them, or comment why they stay as guards. - #744 is stacked on this PR's first commit only. Its
automation.rsstill hasrepair_unterminated_tail/truncate_file, the in-place truncation this PR's second commit removed. #744 also makes the new "never rotated or capped" comments (process.rs:640,process.rs:1868-1869,examine.feature:370,automation.rs:25) false once it lands. It needs a rebase onto this head before either merges. PerAGENTS.md, a stacked PR should also stay in draft. - The base is behind
main, and #735 there markswrite_atomicas not permanent. This PR adds a newwrite_atomiccall site (the torn-tail repair,automation.rs:287) and a doc paragraph on it (lib.rs:499-502). After rebasing, make sure whatever replaceswrite_atomiccovers this call site too. - The first commit's message describes a design the second commit removed (c081bcd: "A failed write is rolled back to the previous length, and a torn last line … cut by the next append"). Squash the two commits or reword that message.
Decisions for the author
- No Gherkin scenario, although the change creates new paths in the data directory.
AGENTS.md§3 asks for one when a change alters the files or paths the CLI creates, and every audit write now createsevents.jsonl.lock. The description's reasoning is sound: the race window is too small for a CLI-level scenario, and the lock files follow the existinglaunch.lock/autostart.lockpattern. Please confirm that maintainers accept that reasoning. - The torn-tail repair renames over a store the appender still holds open (
automation.rs:267-287). #748 already tracks the failure on volumes without POSIX rename semantics, where the repair fails every time, every later append to that store fails, androcmdtreats an append error as fatal. One cheap mitigation fits in this PR:drop(file)beforewrite_atomicin theTornarm, which is safe because the store lock is still held. Also, the description says the repair uses "the same handle semantics as the proposal-status rewrite", but the status rewrite has no handle open when it renames. - Append-mode writes give up #666's "a crash or full disk never leaves a truncated store" guarantee. That trade is reasonable: you get O(1) appends and no lost records, at the cost of tail-repair logic on a rarely run path. The module doc states it, but the description doesn't mention that it reverses #666. "fsync durability, unchanged" reads as though nothing about crash safety moved.
- The store lock blocks with no timeout. Only local file I/O runs under it, so the risk is low. Still, before this change nothing could block an append; now a stopped holder or a hung network mount stalls CLI commands and
rocmd's watcher tick.rocmdtakes the lock synchronously inside atokio::select!arm, so it cannot respond to shutdown while it waits.
Deployment notes
- An old
rocmdstill running after an upgrade takes no lock and can still drop records, as the description says. Restartrocmdtogether with the CLI upgrade. - New zero-byte
events.jsonl.lockandproposals.jsonl.lockfiles appear next to the stores under theaudit/andautomations/data directories.
juhovainio
left a comment
There was a problem hiding this comment.
I read through this and found no blocking correctness defects: the lock coverage on appends and the proposal status rewrite, the reader tolerance for a torn tail, the rename-based repair and the reverse scan all hold up, and CI is green. I'm requesting changes because a few things should be settled before merge:
- Scenario coverage for the reader change. The PR's justification for having no Gherkin scenario covers the race, which is non-deterministic at the CLI level. It does not cover the other user-observable change:
rocm statusand the dashboard now skip a torn tail, and corrupt lines older than thelimitwindow are no longer reported. That behaviour is deterministic (seed a store with a torn tail, run the command). Please add a small scenario, or name the unit tests that prove it in the PR text. - Write-failed-but-landed path. Only the pure
landed_despite_the_erroris tested; the glue around it is not. See the inline comment. - Ticket wording. The PR says it fixes ROCMAI-665, but that ticket also asks for rotation and tail-only proposal reads, which are deferred to ROCMAI-729. "Fixes 721; partially addresses 665" would avoid closing it early.
- Docs. I only had the diff, so please grep README,
docs/anddocs/testing.mdfor stale wording such as "append-only" or "rewrites the whole file". - Commit history. The first commit message still describes rollback and truncate, which the second commit replaced. Fine if squashed; misleading otherwise.
Also worth considering: FileLock::acquire has no timeout, so a stalled holder now blocks callers. Please confirm no append runs on an async worker without spawn_blocking.
Smaller notes are inline.
| .open(path) | ||
| .with_context(|| format!("failed to open {}", path.display()))?; | ||
| match inspect_tail(path, &file)? { | ||
| TailState::Torn(tail_start) => { |
There was a problem hiding this comment.
Torn-tail repair reads the whole clean prefix into memory and rewrites it. If the rename fails, every later append to this store retries the same rewrite and errors (known, #748). Since rotation is deferred, a bounded fallback may be worth having before stores grow large.
There was a problem hiding this comment.
Added one concrete, low-risk mitigation in scope for this PR: drop(file) before write_atomic in the Torn arm, making the hand-off from the pre-repair handle to the replacement file explicit rather than implicit (it already worked without this, since std's default Windows share mode includes delete-sharing, but this states the intent directly rather than leaning on that default). A full bounded fallback for a rename that can't complete stays out of scope for this PR, tracked in #748 along with the rest of that limitation.
An automated review on #738 noted that FileLock's doc comment listed the daemon autostart and managed-launch GPU claim as its use sites but not the per-store JSONL lock this PR added. Reworded both the struct doc and the module header to name it alongside the others, and hedged the list with "e.g." rather than letting it read as an exhaustive call-site catalog (it already wasn't one: it never listed the `uv` install lock either, and main.rs's own lock-reachability note is the authoritative enumeration). Signed-off-by: Jussi Elo <jussi.elo@amd.com>
An automated review on #738 noted that inspect_tail's own backward chunk-scan loop duplicated ReverseLines's "find the last line scanning backwards in fixed-size chunks" logic as a second, independently maintained implementation. The segment inspect_tail wants (whatever follows the last `\n`) is exactly ReverseLines's first yielded line, so it now calls that instead. Reusing it meant giving next_line() a way to report where the line it returns actually starts: it now yields `(start, bytes)`, with `start` derived from the struct's own tracked read position rather than from a length the caller measured before the scan began (which can go stale if the store shrinks out-of-band mid-scan — the first draft of this reuse got that wrong, caught by a pre-commit review round-trip, before it could affect inspect_tail's torn-tail repair offset). Strengthened reverse_lines_matches_a_forward_split_at_every_chunk_size to also check every yielded line's reported start, and added reverse_lines_tolerates_a_shrink_before_the_first_read to pin that a shrink before any chunk has been read (not guarded, since there is no prior `pending` to misalign against) still reports the real content and offset rather than the stale one. Also corrected two comments nearby (the shrink guard's own, and read_range's) that described the guard's condition and its callers' behavior less precisely than the code. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
tomastola
left a comment
There was a problem hiding this comment.
Re-review of 87fcf8c..c4a045e. The two new commits reuse ReverseLines in inspect_tail and update the FileLock docs. Neither addresses the two blocking findings from the previous review, and both still hold at c4a045e.
Blocking (still open)
1. The retry loop in load_recent_jsonl_records is never exercised — crates/rocm-core/src/automation.rs:485, :496-503
Still open. At c4a045e, setting ATTEMPTS to 1 leaves all 28 automation:: tests passing (cargo test -p rocm-core --lib automation::). The new reverse_lines_tolerates_a_shrink_before_the_first_read tests ReverseLines directly, so like a_scan_that_finds_the_file_shorter_than_it_was_still_succeeds it never goes through the retry. Suggested test, unchanged: add a seam between the length read and the scan (or force a shrink between the two file_len() calls), and assert that the load succeeds after a retry.
2. The "tolerate a missing newline" wiring is untested; only its pure decision function is — append_or_tolerate_a_missing_newline, automation.rs:310-342
Still open. Replacing its body with a plain file.write_all(line).with_context(..) leaves all 28 tests passing. The untested part is the read-back from start after a failed write, which decides whether a caller that retries on error writes a duplicate. Suggested test, unchanged: on Unix, re-execute the test binary as a child (as the cross-process test already does) with RLIMIT_FSIZE set one byte short of the record and SIGXFSZ ignored. Then assert that the append returns Ok and the store holds exactly one copy.
New commits
No new problems. The (start, bytes) offsets from ReverseLines::next_line are covered: an off-by-one in the start computation fails five tests, including the strengthened reverse_lines_matches_a_forward_split_at_every_chunk_size and the new shrink test. Reusing ReverseLines in inspect_tail keeps the torn-tail repair offset unchanged.
Non-blocking (unchanged from the previous review)
- No test races an append against a torn-tail repair, which is the reason the append lock gives for existing (
automation.rs:229-234). - The blank unterminated-tail branch (
is_blank(&tail) ||ininspect_tail) is untested. records.truncate(limit)inscan_recent_jsonl_recordsandunterminated &&inparse_jsonl_recordsare redundant.- #744 is still stacked on this PR's first commit, and it brings back the in-place truncate that this PR removed.
- The base is behind
main(#735), and this PR adds a newwrite_atomiccall site. - The first commit's message still describes the rollback design that the second commit replaced.
The author decisions and deployment notes from the previous review are unchanged.
nowycondro
left a comment
There was a problem hiding this comment.
🔴 Automated review · pr-review-watcher · c4a045e
Review
Read the full diff from merge-base 7eb4bfacd9 to head c4a045e2 (all 7 changed files), with particular attention to crates/rocm-core/src/automation.rs (read completely at head, 1718 lines) and to the two commits added since the previous automated pass (efafdba7, c4a045e2). The locking design — a per-store .lock file serializing append/rewrite, append-mode writes replacing whole-file rewrites, and rename-based torn-tail repair instead of in-place truncation — holds up under direct reading, and the concurrency regression tests (multi-thread and multi-process) exercise the actual failure mode the PR describes. Two test-coverage gaps already raised on this PR remain open at this exact head; I independently re-verified both by reading the current code rather than taking the open status on faith.
Blocking
- The retry loop in
load_recent_jsonl_recordshas no test that drives it —crates/rocm-core/src/automation.rs:480-504
The function reads the file length, scans, and on failure retries up toATTEMPTS = 3times if the length changed in between (lines 496-503). Every test that exercises a stale/shrunk length (a_scan_that_finds_the_file_shorter_than_it_was_still_succeeds,reverse_lines_tolerates_a_shrink_before_the_first_read) callsscan_recent_jsonl_recordsorReverseLinesdirectly, bypassing this wrapper entirely, so the retry-and-succeed path this function exists for is never actually run by the suite. Confirmed by reading the test module end to end — no test seeds a shrink between the loop's twofile_len()calls.
Confidence 85 · logic · Fix: add a seam (or a test-only hook) between the length read and the scan so a test can shrink the file mid-call and assert the retry still succeeds. append_or_tolerate_a_missing_newline's wiring around a failed write is untested; only its pure decision function is —crates/rocm-core/src/automation.rs:310-342(decision function at 349-353, tested at 1672-1700)
landed_despite_the_errorhas a direct unit test, but the surrounding glue — the read-back fromstartafterwrite_allfails, the.max(start), and which of the two errors gets surfaced — has no test inducing an actual partial write. That glue is what decides whether a caller retrying on error ends up writing a duplicate record.
Confidence 85 · logic · Fix: on Unix, re-execute the test binary as a child (the cross-process concurrency test already does this) with a tightRLIMIT_FSIZEandSIGXFSZignored to force a one-byte-short write, then assert the append still returnsOkand the store holds exactly one copy.
Non-blocking
None beyond what's already open on this PR's review threads (a couple of redundant guards, and no test that races an append against an in-flight torn-tail repair) — re-read at this head and still accurate, not repeating the details here.
Decisions for the author
- Append-mode writes trade away the earlier "a crash or full disk never leaves a truncated store" guarantee for O(1) appends and no dropped records, moving the cost to a rarely-run tail-repair path — tradeoff. The module doc states this plainly; it's a reasonable trade given the unbounded-growth problem being fixed, but worth the author explicitly confirming nothing downstream still assumes the old guarantee.
- Store rotation, tail-only proposal reads, and the no-fallback case for a rename that fails on a filesystem without POSIX rename semantics are explicitly deferred to follow-up work rather than this PR — tradeoff. Scope looks reasonable for a focused concurrency fix; flagging only so a reader of this review knows it was a deliberate cut, not an oversight.
Positive signals
- The new
inspect_tail→ReverseLinesreuse (the latest commit) removes the duplicate backward-scan implementation that existed at the previous head, and the refactor is verified by a strengthened version ofreverse_lines_matches_a_forward_split_at_every_chunk_size(now checking exact line offsets) plus a newreverse_lines_tolerates_a_shrink_before_the_first_readtest — the offset arithmetic checks out against the prior loop-based implementation it replaced. - The inode/file-identity tests (
append_of_a_torn_tail_replaces_rather_than_truncates_the_file_in_place, both Unix and Windows variants) correctly recognize that a byte-content assertion alone can't distinguish a rename-based repair from an in-place truncate-then-rewrite, and add the one check that can. - The
FileLockdoc comment inprocess.rsand the lock-reachability note inapps/rocm/src/main.rswere both updated in this round to name the new JSONL-store lock use site, and both read as accurate against the actual call sites.
Deployment notes
- CI as of this review: all checks green except
E2E testsandwindows-build-and-test, both still pending/in progress at the time of this review — no failing checks observed. - An old
rocm/rocmdbinary built before this change takes no per-store lock, so a mixed-version rollout (e.g. an oldrocmdstill running through an upgrade) can still silently drop a record from an old-side write; the PR description already calls this out. - Each JSONL store gains a sibling zero-byte
*.jsonl.lockfile in its data directory, following the existing lock-file pattern elsewhere in the codebase; created on first use, no migration needed.
What this covered
Full diff 7eb4bfacd9...c4a045e2, all 7 changed files, with crates/rocm-core/src/automation.rs read completely at head and the two newest commits diffed in isolation against the previous head to confirm they introduce no regressions. Also checked docs/*.md, docs/testing.md, and README.md for stale wording about the old whole-file-rewrite behavior this PR replaces — found none.
Did not run: no Rust toolchain (cargo/rustc) is available in this environment, so no tests, clippy, or build were run directly here; CI's own green cargo test/clippy runs are taken at face value per the checks above. Findings rest on direct reading of the code and tests, not execution.
Not reconciled against this PR's existing review threads as a goal in itself — the two blocking items above happen to match open points raised earlier on this PR, but were independently re-derived by reading the current code at this exact head rather than copied from those threads.
c4a045e to
24d266b
Compare
tomastola
left a comment
There was a problem hiding this comment.
Re-review of c4a045e..24d266b. This push only rebases onto main (bfa3c04). git range-diff shows all four commits unchanged, so neither blocking finding from the previous review has been addressed. I re-ran both checks at 24d266b to confirm.
Blocking (still open)
1. No test exercises the retry loop in load_recent_jsonl_records (crates/rocm-core/src/automation.rs:485, :496-503)
Setting ATTEMPTS to 1 still leaves all 28 automation:: tests passing (cargo test -p rocm-core --lib automation::). The suggested fix is unchanged: add a seam between the length read and the scan, so a test can shrink the file between the two file_len() calls. Then assert that the load succeeds after a retry.
2. The read-back in append_or_tolerate_a_missing_newline is untested (automation.rs:310-342)
Only the pure decision function landed_despite_the_error has a test. Replacing the body with a plain file.write_all(line).with_context(..) still passes all 28 tests; the only signal is an unused-variable warning for start. The suggested fix is unchanged: on Unix, re-run the test binary as a child process, as the cross-process test already does. Set RLIMIT_FSIZE one byte short of the record and ignore SIGXFSZ. Then assert that the append returns Ok and the store holds exactly one copy.
Resolved by this push
- The base is no longer behind
main. #735 is now included, and the newwrite_atomiccall site builds against it. - #744 is now stacked on all four of this PR's commits, not just the first. It no longer brings back the in-place truncate: it repairs a torn tail by renaming, the same as this PR.
Non-blocking (unchanged)
- No test races an append against a torn-tail repair. That race is the reason the append lock gives for existing (
automation.rs:229-234). - The blank unterminated-tail branch (
is_blank(&tail) ||ininspect_tail) is untested. records.truncate(limit)inscan_recent_jsonl_recordsandunterminated &&inparse_jsonl_recordsare redundant.- The first commit's message still describes the rollback design that the second commit replaced.
nowycondro
left a comment
There was a problem hiding this comment.
🔴 Automated review · pr-review-watcher · 24d266b
Review — needs work
Full review of the whole change (all 7 files, read completely at the PR head against the merge-base with origin/main).
Fixes ROCMAI-721 (confirmed via the tracker: an open bug describing exactly this concurrent-writer data-loss). Explicitly does not close the related ROCMAI-665, deferring store rotation and tail-only proposal reads to ROCMAI-729 — a reasonable, honestly-scoped split.
Blocking
- The PR's own "Verification" section names three specific test/scenario artifacts that do not exist anywhere in this diff or the repository at head — and the two code gaps those claims paper over are the highest-risk untested behavior in the change.
path/file:linefor each:crates/rocm-core/src/automation.rs(test module, lines ~1061-1719): the description states "A torn-tail repair racing concurrent appenders ... is seeded and stressed the same way as the other concurrency tests, and passes." No such test exists — every#[test]in the file was enumerated; the two multi-thread/multi-process tests (concurrent_appends_from_many_threads_lose_no_records:1061,concurrent_appends_from_many_processes_lose_no_records:1105) never produce a torn tail, so every append in them takes theClean/ValidUnterminatedpath, which plain OS-level append-mode writes (O_APPEND/FILE_APPEND_DATA) already make safe without the new per-store lock. The actual hazard the lock exists to prevent — a reader's reverse scan observing the store mid rename-based repair — is argued convincingly in code comments and the module doc, but is exercised by no concurrent test.crates/rocm-core/src/automation.rs:496-501(theattempt < ATTEMPTS && file_len()? != lenretry branch inload_recent_jsonl_records, confirmed new to this PR by diffing against the pre-PR file, which has no such loop): the description claims "two Unix tests force a genuine short write viaRLIMIT_FSIZEwithSIGXFSZignored."git grep -n "RLIMIT|SIGXFSZ"across the entire repository at this head returns nothing.tests/e2e-cucumber/features/automations.feature: the description cites a "new@id:automations-tolerates-torn-audit-tail(automations-06)" scenario. This file has zero diff in this PR (git diff --name-statusconfirms it is not among the 7 changed files) and contains no such scenario ID at base or head.- Evidence: direct
git grep/git diffagainst merge-basebfa3c043a3and head24d266b7, re-confirmed live immediately before publishing this review. Confidence 90 — the artifacts' absence is certain; that the two underlying behaviors are genuinely untested elsewhere follows from an exhaustive enumeration of every test function in the file. - Fix: either land the three described tests/scenario so the diff matches "Verification," or correct that section to stop claiming coverage that isn't there. Given the torn-tail-repair-vs-reader race is exactly what an earlier commit of this same PR was flagged for (independently confirmed via
git logthat commit0342aa9, "repair a torn JSONL tail by rename, not truncate," fixed the underlying code before this head — so the production fix itself looks sound), closing the test gap is the more valuable of the two remedies: it is the one guard against a future regression of the exact bug this PR exists to fix. - Category: architectural (test-coverage + description-accuracy).
Non-blocking
- Minor tense mismatch in an updated comment —
tests/e2e-cucumber/features/examine.feature:371("Unbounded, the logs grew by the payload's size every minute, forever") mixes past tense with "forever," where the near-identical sibling edit incrates/rocm-core/src/process.rs:645uses present tense ("grows... forever"), which reads more clearly. Confidence 70 · mechanical · Fix: match the tense used inprocess.rs.
Decisions for the author
write_automation_proposals's "caller must hold the lock" contract is enforced only by doc comment, not the type system — tradeoff. Today it has exactly one call site (insideupdate_automation_proposal_status, which does hold the lock), so there's no live bug, but a future internal caller could violate the invariant silently. Threading a lock-guard reference through the signature would make it structural at the cost of ceremony for a function with one caller today.- This PR knowingly reverses the "a crash can never leave a torn tail" guarantee the old whole-file-rewrite design had (replaced with append-mode writes plus torn-tail repair) — tradeoff, already unusually well-disclosed in the description itself (including a cross-link to the original guarantee and a tracked known-limitation issue for the no-fallback-under-disk-pressure case). Flagging only so the author/reviewers explicitly confirm this is the accepted trade, not because the disclosure is lacking.
- No Gherkin scenario races the lock itself — non-blocking-improvement / already-justified. The description argues convincingly that a microsecond-scale race can't be forced reliably through black-box CLI process spawns, and substitutes a deterministic cross-process unit test instead. That reasoning holds; no scenario is needed for the race itself (distinct from the untested torn-tail-vs-reader interaction above, which is a gap in the unit-level coverage, not a missing e2e scenario).
Positive signals
- The core safety invariant — this module never shrinks a store in place, so a lock-free reader can never observe a mid-repair transient state — is correctly implemented via rename-based repair through
write_atomic; traced concretely (stable fd pinned to its open-time inode, repairs swap inodes via atomic rename) with no interleaving found that produces an invalid read. is_torn_recordis shared verbatim between the writer'sinspect_tailand the readers' parsing paths, preventing classification drift between the two sides of the same contract.landed_despite_the_errorprecisely narrows "tolerate a failed write" to exactly "missing only the trailing newline," backed by a focused, readable dedicated unit test.ReverseLinesis tested unusually rigorously: a forward-split equivalence check across 8 chunk sizes and 8 content shapes (including a line crossing a chunk boundary), plus dedicated tests for a shrink before vs. after the first chunk read.- Every doc-comment update this PR makes (in
lib.rs,process.rs,main.rs,examine.feature) was independently checked against the actual code and found accurate — careful, correctly cross-referenced documentation work. - The
windows_file_identitymove fromconfig.rsinto sharedtest_support.rsis a clean, justified (genuinely used by ≥2 test modules), byte-for-byte-preserving refactor with no orphaned imports. - No breaking public API changes across any function this PR touches; lock acquisition order is correct everywhere checked (acquired before any mutation, held via RAII, no nested-acquire hazard, no cross-store lock-path collision).
Deployment notes
- Mixed-version rollout risk, author-disclosed and worth repeating: a
rocm/rocmdbinary from before this change does not take the per-store lock. While an old and new binary run side by side during a rolling upgrade, the old one's writes can still silently drop a record; the fix is only complete once every writer is on this version. - New
*.jsonl.locksibling files appear beside each store (same pattern as the existinglaunch.lock/autostart.lock) — no new operational concern expected, but worth knowing for anyone enumerating the data directory.
What this covered
Full read of all 7 changed files at PR head 24d266b73222f1f8bd2d3b42390d564bdc52f4fc against merge-base bfa3c043a344c9090024972d3a28db4d552b3b67, via three independent fan-out workers (core automation.rs logic; the supporting doc-comment/refactor files; agent-instruction/history/prior-changes/code-comment passes), plus my own direct verification of the two highest-stakes claims — the torn-tail-repair-vs-reader race, and whether the flagship concurrency tests actually depend on the new lock — and a full grep/diff audit of every specific, checkable claim in the PR's "Verification" section.
CI: all checks green per live statusCheckRollup (build-and-test, clippy, windows-build-and-test, E2E tests, coverage ratchet, commit-signatures, CodeQL, etc. all SUCCESS) — no failing-check finding.
Did not run: no local cargo build/test/clippy invocation in this environment (relied on direct code reading plus CI's own green results); Step 7 design questions were reasoned through directly rather than spawned to separate workers, given the volume of concrete evidence already in hand.
Not reconciled against any discussion on this PR. One incidental exposure to note for transparency: an early tool call made only to read PR review metadata (authors and commit SHAs, to detect whether this re-review would duplicate one already done at the current head) returned raw JSON whose preview incidentally included a fragment of one automated reviewer's comment text. That fragment was not used as input to any finding above — the corresponding code concern (the torn-tail-repair race) was independently re-derived here from reading automation.rs directly, and separately confirmed via git log that it was fixed at the code level by commit 0342aa9 before the current head, which is exactly the point of Blocking finding #1 above (the fix landed; the regression test for it did not). No other review thread, comment, or prior round's report on this PR was read.
…eview found Two human reviewers and this repo's automated pr-review-watcher independently flagged the same two blocking test-coverage gaps on #738, plus several smaller ones: - load_recent_jsonl_records's retry-after-a-failed-scan path was never exercised: every existing shrink test called scan_recent_jsonl_records or ReverseLines directly, bypassing the wrapper's retry entirely. Driving it needs the shrink to land strictly between two of ReverseLines's own chunked reads, which in turn needs a scan spanning more than one physical chunk — hence the new test_hooks seam (mirroring uv.rs's own test-hook pattern) and the pathologically long seeded record. - append_or_tolerate_a_missing_newline's glue around a real failed write was untested — only the pure landed_despite_the_error predicate had a direct test. Two new Unix tests force a genuine short write via RLIMIT_FSIZE (SIGXFSZ ignored): one where the write lands everything but its trailing newline (must still report Ok, with no duplicate), and one with zero slack, where the very first write(2) call lands nothing at all (must report a real failure, not fold it into a false success — closing a gap the first test alone couldn't catch). - No test raced a torn-tail repair against concurrent appenders, the exact scenario the per-store lock's own doc comment gives as its reason to exist; both existing concurrency tests started from an empty store. - A whitespace-only unterminated tail has no test pinning that it's kept and terminated rather than cut as torn debris (TailState::ValidUnterminated documents this, but nothing exercised it). - FileLock's doc comment didn't mention the per-store JSONL lock. - Two "redundant" guards (records.truncate(limit), the unterminated && check in parse_jsonl_records) are each provably dead in the current call graph, but are real safety nets against a future direct caller bypassing the guard that makes them dead today — kept, with a comment saying so instead of being removed. - drop(file) added before the Torn arm's write_atomic call in append_jsonl_record: the rename already succeeds with the old handle open (std's default Windows share mode includes delete-sharing), but this states that intent explicitly instead of leaning on the default. Every new test and comment went through several rounds of independent fresh review before landing; early drafts had real bugs (a stale-length offset computation, a chunk-size change that didn't achieve what it claimed, an overclaimed "only reader" comment, a signal/rlimit restore gap) caught and fixed before being committed. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
…it tail A reviewer on #738 pointed out that naming the existing unit tests (readers_skip_an_unterminated_last_line_that_does_not_parse, load_recent_stops_before_corrupt_lines_older_than_the_window) doesn't discharge AGENTS.md §3's scenario requirement for user-observable CLI behavior — those tests prove the internal helper, not the command. rocm automations is the production reader of the audit store specifically (confirmed: outside test code, render_automations_text is the only caller of load_recent_audit_events in the workspace), and it's a genuinely reachable, user-facing command — so this is the right level to pin the torn-tail-skip half of the behavior at. The corrupt-line-outside-the-window half stays a named unit test, since rocm automations uses a fixed internal limit rather than a user-supplied one, so there's no CLI-level lever to place a corrupt line on one side of the window or the other. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
… the fallout A Copilot review on #738 found that cutting a torn tail in place and then appending as a separate write could let a lock-free reverse-scan reader observe the store shrink and regrow mid-scan, splicing bytes read at two different moments into something that parses as a record nobody wrote. Torn-tail repair now folds the cut into the append itself: the clean prefix plus the new record are written as one atomic rename over the store (`write_atomic`), so a reader only ever sees the pre-repair length or the post-append length. Removing the old rollback-on-failed-write (needed so a repair-in-place couldn't itself race a reader) opened a narrower gap: a write that failed one byte short of its own trailing `\n` left a complete, valid record on disk while still reporting `Err`, which would make a caller that retries on error write a duplicate. Appending now checks what actually landed before deciding, and only reports failure when the record genuinely didn't make it. Also fixes two pre-existing doc comments (process.rs, examine.feature) that still described appends as rewriting the whole store each time, and deduplicates the Windows file-identity test helper into test_support.rs. A torn-tail repair that can't complete its rename (sustained disk pressure, or a Windows handle without delete-sharing) still has no fallback and keeps retrying the same whole-store rewrite; tracked as a follow-up in #748 rather than folded into this fix. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
958c70c to
1980a00
Compare
An automated review on #738 noted that FileLock's doc comment listed the daemon autostart and managed-launch GPU claim as its use sites but not the per-store JSONL lock this PR added. Reworded both the struct doc and the module header to name it alongside the others, and hedged the list with "e.g." rather than letting it read as an exhaustive call-site catalog (it already wasn't one: it never listed the `uv` install lock either, and main.rs's own lock-reachability note is the authoritative enumeration). Signed-off-by: Jussi Elo <jussi.elo@amd.com>
An automated review on #738 noted that inspect_tail's own backward chunk-scan loop duplicated ReverseLines's "find the last line scanning backwards in fixed-size chunks" logic as a second, independently maintained implementation. The segment inspect_tail wants (whatever follows the last `\n`) is exactly ReverseLines's first yielded line, so it now calls that instead. Reusing it meant giving next_line() a way to report where the line it returns actually starts: it now yields `(start, bytes)`, with `start` derived from the struct's own tracked read position rather than from a length the caller measured before the scan began (which can go stale if the store shrinks out-of-band mid-scan — the first draft of this reuse got that wrong, caught by a pre-commit review round-trip, before it could affect inspect_tail's torn-tail repair offset). Strengthened reverse_lines_matches_a_forward_split_at_every_chunk_size to also check every yielded line's reported start, and added reverse_lines_tolerates_a_shrink_before_the_first_read to pin that a shrink before any chunk has been read (not guarded, since there is no prior `pending` to misalign against) still reports the real content and offset rather than the stale one. Also corrected two comments nearby (the shrink guard's own, and read_range's) that described the guard's condition and its callers' behavior less precisely than the code. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
…eview found Two human reviewers and this repo's automated pr-review-watcher independently flagged the same two blocking test-coverage gaps on #738, plus several smaller ones: - load_recent_jsonl_records's retry-after-a-failed-scan path was never exercised: every existing shrink test called scan_recent_jsonl_records or ReverseLines directly, bypassing the wrapper's retry entirely. Driving it needs the shrink to land strictly between two of ReverseLines's own chunked reads, which in turn needs a scan spanning more than one physical chunk — hence the new test_hooks seam (mirroring uv.rs's own test-hook pattern) and the pathologically long seeded record. - append_or_tolerate_a_missing_newline's glue around a real failed write was untested — only the pure landed_despite_the_error predicate had a direct test. Two new Unix tests force a genuine short write via RLIMIT_FSIZE (SIGXFSZ ignored): one where the write lands everything but its trailing newline (must still report Ok, with no duplicate), and one with zero slack, where the very first write(2) call lands nothing at all (must report a real failure, not fold it into a false success — closing a gap the first test alone couldn't catch). - No test raced a torn-tail repair against concurrent appenders, the exact scenario the per-store lock's own doc comment gives as its reason to exist; both existing concurrency tests started from an empty store. - A whitespace-only unterminated tail has no test pinning that it's kept and terminated rather than cut as torn debris (TailState::ValidUnterminated documents this, but nothing exercised it). - FileLock's doc comment didn't mention the per-store JSONL lock. - Two "redundant" guards (records.truncate(limit), the unterminated && check in parse_jsonl_records) are each provably dead in the current call graph, but are real safety nets against a future direct caller bypassing the guard that makes them dead today — kept, with a comment saying so instead of being removed. - drop(file) added before the Torn arm's write_atomic call in append_jsonl_record: the rename already succeeds with the old handle open (std's default Windows share mode includes delete-sharing), but this states that intent explicitly instead of leaning on the default. Every new test and comment went through several rounds of independent fresh review before landing; early drafts had real bugs (a stale-length offset computation, a chunk-size change that didn't achieve what it claimed, an overclaimed "only reader" comment, a signal/rlimit restore gap) caught and fixed before being committed. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
…it tail A reviewer on #738 pointed out that naming the existing unit tests (readers_skip_an_unterminated_last_line_that_does_not_parse, load_recent_stops_before_corrupt_lines_older_than_the_window) doesn't discharge AGENTS.md §3's scenario requirement for user-observable CLI behavior — those tests prove the internal helper, not the command. rocm automations is the production reader of the audit store specifically (confirmed: outside test code, render_automations_text is the only caller of load_recent_audit_events in the workspace), and it's a genuinely reachable, user-facing command — so this is the right level to pin the torn-tail-skip half of the behavior at. The corrupt-line-outside-the-window half stays a named unit test, since rocm automations uses a fixed internal limit rather than a user-supplied one, so there's no CLI-level lever to place a corrupt line on one side of the window or the other. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
|
Addressed the review feedback from @tomastola, @juhovainio, and the automated reviewer at head 1980a00. Blocking findings (both confirmed open at the previous head, now fixed):
Non-blocking findings:
Scenario coverage: @juhovainio noted that naming unit tests doesn't discharge AGENTS.md §3's scenario requirement for user-observable behavior. Added Ticket wording: PR description now says "Fixes ROCMAI-721; partially addresses ROCMAI-665" rather than implying 665 closes on merge. On the All CI checks are green at the current head (1980a00), including |
The automation events, audit and proposals stores were each updated by reading the whole file, changing it in memory and renaming a rewritten copy over the original, with no lock. The rocm CLI and rocmd write these stores concurrently, so two overlapping writers kept only the last rename and the other record was dropped with no error. Two threads of one process could also collide on the same temp file name. Every mutation of a store now holds a sibling `<store>.lock` file (rocm_core::FileLock: flock on Linux, LockFileEx on Windows): - Appending a record is a single append-mode write of one line under that lock. It no longer reads or rewrites the file, so its cost no longer grows with the store (ROCMAI-665). A torn last line left by a killed writer, or by a write that failed partway, is cut by the next append (see the next commit for how). - A proposal status update keeps its whole-file rewrite, but its full read-modify-write now runs under the same lock, so it can neither drop a concurrently appended proposal nor strand one in a replaced file. Readers stay lock-free. An unterminated last line that is not valid JSON (an append caught mid-write) is skipped instead of failing the read, and the proposals reader works on bytes so a cut inside a UTF-8 character cannot fail the whole file. load_recent_jsonl_records now scans backwards from the end and stops after `limit` records instead of parsing the whole store (ROCMAI-665). Regression tests reproduce the loss first: N threads and N separate processes (the test binary re-executed) appending to all three stores, and status updates racing appends. All three failed before this change (e.g. 86 of 120 audit records survived six writer processes) and pass after it. Size caps/rotation for the stores and tail-only proposal reads are left for a follow-up. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
… the fallout A Copilot review on #738 found that cutting a torn tail in place and then appending as a separate write could let a lock-free reverse-scan reader observe the store shrink and regrow mid-scan, splicing bytes read at two different moments into something that parses as a record nobody wrote. Torn-tail repair now folds the cut into the append itself: the clean prefix plus the new record are written as one atomic rename over the store (`write_atomic`), so a reader only ever sees the pre-repair length or the post-append length. Removing the old rollback-on-failed-write (needed so a repair-in-place couldn't itself race a reader) opened a narrower gap: a write that failed one byte short of its own trailing `\n` left a complete, valid record on disk while still reporting `Err`, which would make a caller that retries on error write a duplicate. Appending now checks what actually landed before deciding, and only reports failure when the record genuinely didn't make it. Also fixes two pre-existing doc comments (process.rs, examine.feature) that still described appends as rewriting the whole store each time, and deduplicates the Windows file-identity test helper into test_support.rs. A torn-tail repair that can't complete its rename (sustained disk pressure, or a Windows handle without delete-sharing) still has no fallback and keeps retrying the same whole-store rewrite; tracked as a follow-up in #748 rather than folded into this fix. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
An automated review on #738 noted that FileLock's doc comment listed the daemon autostart and managed-launch GPU claim as its use sites but not the per-store JSONL lock this PR added. Reworded both the struct doc and the module header to name it alongside the others, and hedged the list with "e.g." rather than letting it read as an exhaustive call-site catalog (it already wasn't one: it never listed the `uv` install lock either, and main.rs's own lock-reachability note is the authoritative enumeration). Signed-off-by: Jussi Elo <jussi.elo@amd.com>
An automated review on #738 noted that inspect_tail's own backward chunk-scan loop duplicated ReverseLines's "find the last line scanning backwards in fixed-size chunks" logic as a second, independently maintained implementation. The segment inspect_tail wants (whatever follows the last `\n`) is exactly ReverseLines's first yielded line, so it now calls that instead. Reusing it meant giving next_line() a way to report where the line it returns actually starts: it now yields `(start, bytes)`, with `start` derived from the struct's own tracked read position rather than from a length the caller measured before the scan began (which can go stale if the store shrinks out-of-band mid-scan — the first draft of this reuse got that wrong, caught by a pre-commit review round-trip, before it could affect inspect_tail's torn-tail repair offset). Strengthened reverse_lines_matches_a_forward_split_at_every_chunk_size to also check every yielded line's reported start, and added reverse_lines_tolerates_a_shrink_before_the_first_read to pin that a shrink before any chunk has been read (not guarded, since there is no prior `pending` to misalign against) still reports the real content and offset rather than the stale one. Also corrected two comments nearby (the shrink guard's own, and read_range's) that described the guard's condition and its callers' behavior less precisely than the code. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
…eview found Two human reviewers and this repo's automated pr-review-watcher independently flagged the same two blocking test-coverage gaps on #738, plus several smaller ones: - load_recent_jsonl_records's retry-after-a-failed-scan path was never exercised: every existing shrink test called scan_recent_jsonl_records or ReverseLines directly, bypassing the wrapper's retry entirely. Driving it needs the shrink to land strictly between two of ReverseLines's own chunked reads, which in turn needs a scan spanning more than one physical chunk — hence the new test_hooks seam (mirroring uv.rs's own test-hook pattern) and the pathologically long seeded record. - append_or_tolerate_a_missing_newline's glue around a real failed write was untested — only the pure landed_despite_the_error predicate had a direct test. Two new Unix tests force a genuine short write via RLIMIT_FSIZE (SIGXFSZ ignored): one where the write lands everything but its trailing newline (must still report Ok, with no duplicate), and one with zero slack, where the very first write(2) call lands nothing at all (must report a real failure, not fold it into a false success — closing a gap the first test alone couldn't catch). - No test raced a torn-tail repair against concurrent appenders, the exact scenario the per-store lock's own doc comment gives as its reason to exist; both existing concurrency tests started from an empty store. - A whitespace-only unterminated tail has no test pinning that it's kept and terminated rather than cut as torn debris (TailState::ValidUnterminated documents this, but nothing exercised it). - FileLock's doc comment didn't mention the per-store JSONL lock. - Two "redundant" guards (records.truncate(limit), the unterminated && check in parse_jsonl_records) are each provably dead in the current call graph, but are real safety nets against a future direct caller bypassing the guard that makes them dead today — kept, with a comment saying so instead of being removed. - drop(file) added before the Torn arm's write_atomic call in append_jsonl_record: the rename already succeeds with the old handle open (std's default Windows share mode includes delete-sharing), but this states that intent explicitly instead of leaning on the default. Every new test and comment went through several rounds of independent fresh review before landing; early drafts had real bugs (a stale-length offset computation, a chunk-size change that didn't achieve what it claimed, an overclaimed "only reader" comment, a signal/rlimit restore gap) caught and fixed before being committed. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
…it tail A reviewer on #738 pointed out that naming the existing unit tests (readers_skip_an_unterminated_last_line_that_does_not_parse, load_recent_stops_before_corrupt_lines_older_than_the_window) doesn't discharge AGENTS.md §3's scenario requirement for user-observable CLI behavior — those tests prove the internal helper, not the command. rocm automations is the production reader of the audit store specifically (confirmed: outside test code, render_automations_text is the only caller of load_recent_audit_events in the workspace), and it's a genuinely reachable, user-facing command — so this is the right level to pin the torn-tail-skip half of the behavior at. The corrupt-line-outside-the-window half stays a named unit test, since rocm automations uses a fixed internal limit rather than a user-supplied one, so there's no CLI-level lever to place a corrupt line on one side of the window or the other. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
1980a00 to
35f3638
Compare
nowycondro
left a comment
There was a problem hiding this comment.
🔴 Automated review · pr-review-watcher · 35f3638
Review — needs work
Full review of the whole change (79f6e08c...35f36382, base main). Fixes ROCMAI-721 (concurrent-writer record loss in the JSONL stores); partially addresses ROCMAI-665 (O(n) append-cost redesign), explicitly deferring its store-rotation/tail-read-proposals scope to ROCMAI-729. All three ticket relationships were checked against Jira and match the change's actual content.
Blocking
-
An append can report failure even though the record landed, letting a retrying caller write a duplicate —
crates/rocm-core/src/automation.rs:345-350, contradicts the contract stated at:252-256.
append_or_tolerate_a_missing_newlineis built specifically so a write that fails but still lands a complete record is reported as success rather than error, "so a caller that retries on error cannot write a duplicate." But ifwrite_allfails and the follow-up stat/read-back used to check what landed (:330-337) also fails (e.g. a second I/O error on a failing disk), the function falls into theErr(check_error)arm and returnsErrregardless of whether the record actually landed. That breaks the function's own documented guarantee ("seeingErrhere means this specific record did not land") for this one sub-case, and a caller retrying on error per that contract can duplicate a record it already wrote. Low likelihood (needs two I/O faults in a row), but it sits directly in the path this PR's design exists to make safe.
Confidence 85 · logic · Fix: narrow the doc's claim to exclude this sub-case, or return a distinguishable "landed status unknown" error so callers can choose not to retry on it. -
No test distinguishes a torn last line from a corrupt line anywhere else in the proposals store —
crates/rocm-core/src/automation.rs:450-479(guard at:470), reached viaload_automation_proposals/update_automation_proposal_status.
parse_jsonl_records's new byte-level parsing only tolerates an unparseable line when it is both unterminated and the file's last line (index + 1 == line_count && is_torn_record(line)); this position-aware exception is new in this PR. Every existing corruption test (readers_skip_an_unterminated_last_line_that_does_not_parse,proposal_reads_skip_a_torn_line_cut_inside_a_utf8_character,an_unterminated_last_line_of_the_wrong_shape_is_reported_not_dropped) places the bad line last. Deleting theindex + 1 == line_counthalf of the guard — so any unparseable line anywhere is silently skipped — would still pass every one of those tests, yet would letupdate_automation_proposal_status's whole-file rewrite silently drop a corrupted non-final proposal line from disk, the opposite of the "wrong-shape data must be reported, never dropped" guarantee this same file documents at:571-573.
Confidence 85 · logic (missing coverage for behaviour this change introduces) · Fix: add a proposals-store test with a corrupt/invalid line before the last line and assert both the load and the status-update fail, with the on-disk file unchanged.
Non-blocking
-
write_atomic's new doc bullet understates a hazard it's documenting —crates/rocm-core/src/lib.rs:357-360vs. the temp-name scheme at:367-371.
The new bullet says overlapping read-modify-write cycles merely "lose one update (last rename wins)." Two callers in the same process within the same millisecond share the temp path<path>.<ext>.tmp-<millis>-<pid>; concurrentfs::writes truncating that one path can publish mixed bytes, and the losing rename fails withENOENTrather than just losing an update.automation.rs:1139itself separately notes this exact same-process temp-name collision as a bug the per-store lock fixes — the doc bullet should say so too. Low severity here since every JSONL caller now holds the store lock, so this only matters for non-JSONLwrite_atomiccallers (e.g.config.rs,service_record.rs) that this PR doesn't touch.
Confidence 70 · mechanical · Fix: add the same-process/same-millisecond case to the bullet. -
reads_racing_appends_never_failwould still pass with the fix reverted —crates/rocm-core/src/automation.rs:1591-1619.
The test's stores stay well under one 64 KiB scan chunk, soReverseLinesnever crosses a chunk boundary and a small append is essentially never observed half-written on a local filesystem in-process. The test can effectively only fail on a panic. Its own comment already calls it a smoke test and points elsewhere for the real pinning test, so this is a naming/strength mismatch rather than a coverage gap.
Confidence 70 · test-coverage · Fix: rename to reflect that it's a smoke test, or make the fixture large enough / records big enough that a genuine partial-write race becomes observable. -
The new torn-audit-tail scenario's rationale is duplicated near-verbatim in three places —
tests/e2e-cucumber/features/automations.feature:59-78,tests/e2e-cucumber/tests/e2e/automations_steps.rs:112-120and:207-215.
All three repeat the same ".unwrap_or_default()means exit code proves nothing" point and the same caller inventory (rocm automations, the TUI automations-manager screen,rocm chat/TUI slashrocm_command, rocmd's MCP tool) — a list the feature file itself admits "isn't meant as an exhaustive map of every caller." Three copies of a caller list is a maintenance liability: the next caller added or removed won't get all three updated.
Confidence 85 · mechanical · Fix: keep the rationale once (in the feature file) and trim the step-definition comments to what each step does. -
prune_managed_service_records's doc keeps extending a hand-maintained, already-incomplete list of lock sites —apps/rocm/src/main.rs:4566-4576.
This PR had to add the new per-store locks to an existing "the only otherFileLock::acquirecalls in the tree are..." comment in an unrelated function, in another crate. Every claim added is true, but the list already omits at least one other call site documented elsewhere in the same file (main.rs:8504), and the next lock added anywhere will again require remembering to update this comment, with no mechanism to catch a miss.
Confidence 70 · mechanical · Fix: state the invariant ("nothing reachable from either phase acquires this lock path; other locks use different files") instead of maintaining an exhaustive list.
Decisions for the author
- Generic JSONL-store machinery living inside
automation.rs— tradeoff. Locking, the reverse-scan reader, and torn-tail repair (roughly half of this file's 2153 lines) are store-agnostic machinery with nothing automation-specific about them, sitting in a file whosedocs/architecture.mdcharter is "the built-in automation-watcher catalog... and its on-disk event/proposal/audit records." That charter does cover this file's own store I/O, so this isn't a clear guardrail violation, but a future JSONL-backed store elsewhere in the tree (there's an open, related PR #610 generalizingwrite_atomic) would have to duplicate or import from here rather than from a neutral module. Worth confirming this placement is deliberate rather than just where the diff happened to land. FileLock::acquirehas no timeout — non-blocking-improvement. A wedged writer stalls every subsequent append to that store forever. This matches the existing pattern forlaunch.lock/autostart.lockelsewhere in the tree, so it isn't a new risk this PR introduces, just one it now applies to higher-frequency store writes (the gpu-metrics watcher appends roughly once a minute).- Proposals reads stay O(store size) — tradeoff, already explicitly scoped to ROCMAI-729 in the PR's own text. Flagging only that the asymmetry with the now-O(limit) events/audit readers isn't called out inline in the proposals reader's own doc comment.
Positive signals
append_jsonl_record/update_automation_proposal_statusacquire the store lock before opening the file and drop the file handle before the lock (explicitdrop(file)atautomation.rs:296, not incidental ordering), closing exactly the "append lands in a copy already replaced by rename" race the design exists to prevent.is_torn_recorddeliberately tests "not valid JSON" rather than "not a valid record," so complete-but-wrong-shape data is still surfaced as an error instead of being silently dropped by a rewrite — backed by a test (an_unterminated_last_line_of_the_wrong_shape_is_reported_not_dropped) that asserts the on-disk bytes are byte-identical after the rewrite refuses.- The reverse-scan reader parses on raw bytes rather than
String, so a record cut mid-way through a multi-byte UTF-8 character can't fail the whole read — verified by a dedicated test that cuts exactly inside a UTF-8 continuation byte. - The short-write edge-case tests use real, forced OS-level short writes (
RLIMIT_FSIZE/SIGXFSZ), not mocks, for both the "landed despite the error" and "genuine failure" cases.
Deployment notes
- Mixed-version rollout window: during a rolling upgrade, any
rocm/rocmdinstance still on the pre-fix binary keeps using the old lock-free path and can still drop a concurrent writer's record until every instance is upgraded. Already called out in the PR description; repeating here for whoever schedules the rollout. - New
*.jsonl.locksibling files appear next to each of the three stores in the data directory, following the existinglaunch.lock/autostart.lockpattern — no action needed, just worth knowing for anyone enumerating or backing up the data directory. - Already-tracked, not new: GitHub issue #748 (confirmed via lookup, filed against this same PR during an earlier review round) documents that a torn-tail repair which can't complete its replacing rename — sustained disk pressure, or a Windows handle without delete-sharing — has no fallback, and every subsequent append to that store then retries the same whole-store rewrite. Explicitly accepted as a known tradeoff with a follow-up ticket; not a new finding from this pass.
What this covered
Every file in the diff, read completely: crates/rocm-core/src/automation.rs in full (both at pr-738-head and the pre-change version for comparison), crates/rocm-core/src/process.rs's FileLock plus its doc-only diff, crates/rocm-core/src/lib.rs's write_atomic body plus its diff, crates/rocm-core/src/test_support.rs and crates/rocm-core/src/config.rs diffs, apps/rocm/src/main.rs's changed doc comment and its surrounding function, and all three e2e cucumber files (automations.feature, examine.feature, automations_steps.rs). Also read: the repo's AGENTS.md in full, the relevant docs/architecture.md passages for automation.rs's ownership, coverage-floors.toml, and this file's own git history. Reviewed against 79f6e08c42b9b2e6cf7f21edad059c96cb9983f1...35f36382764428ff38760238bf323e2de636817c.
Two independent fan-out workers ran: one over the core automation.rs/process.rs/lib.rs/test_support.rs concurrency logic, one over the secondary files and e2e tests. A third planned worker (for the agent-instruction-adherence/history/code-comments source-reading passes) could not be launched — a concurrent-subagent-slot ceiling shared across sessions in this environment was hit on both the first attempt and one retry — so those three passes were run directly in this driver context instead, blind (before reading either fan-out worker's findings), rather than as an independent subagent; that makes them less independent than the two code passes, though still blind to the change's own framing.
CI was read via gh pr view --json statusCheckRollup and taken at face value: every check (build-and-test, windows-build-and-test, clippy, fmt, Test (affected crates), Coverage (rocm-dash crates, ratcheted), E2E tests, Commit signatures + sign-off, CodeQL, etc.) reports SUCCESS as of the reviewed head commit; none failing.
Not reconciled against any discussion, prior review, or review thread on this PR — this review was conducted blind per the method and did not read any existing review comments.

tests/e2e-cucumber/expectations.tomlfor the fixed ticket ID and removed/narrowed any now-stale xfail rows. (No rows reference ROCMAI-721 or ROCMAI-665.)docs/architecture.md. (No new subcommand or subsystem; the change stays insidecrates/rocm-core/src/automation.rs.)Why
Fixes ROCMAI-721; partially addresses ROCMAI-665, which shares its root cause. 665 also asks for store rotation and tail-only proposal reads, both deferred to ROCMAI-729, so this PR does not close it.
The three rocm-core JSONL stores (automation events, audit events, review proposals) were updated by reading the whole file, changing it in memory, and renaming a rewritten copy over the original via
write_atomic, with no lock. TherocmCLI androcmdwrite these stores concurrently: both append to the audit store, androcmsets proposal status whilerocmd's watchers append new proposals. Two writers whose read-modify-write cycles overlapped kept only the last rename, and the other record was dropped with no error, log line or test signal. Two threads of one process could additionally collide on the same temp-file name, because it is qualified only by pid and millisecond.The same design made every append cost O(store size): one full read plus one full rewrite per record. With the gpu-metrics watcher on,
rocmdrecords about one event a minute into both the events and audit stores, so each grows without bound and every append gets slower. Reads had the same problem, sinceload_recent_jsonl_recordsparsed the whole file to keep the lastlimitrecords.Reproduced before the fix with the new regression tests: six writer processes appending 20 records each to the audit store left 86 of the 120 records. Status updates racing 100 proposal appends left 29 of the 101 proposals. Eight threads appending concurrently failed outright on the shared temp-file name.
What changes
One lock per store. Every mutation of a store now holds a sibling
<store>.lockfile through the existingrocm_core::FileLock, which wrapsstd::fs::File::lock(flockon Linux,LockFileExon Windows) and excludes threads and processes alike.rocm statusor the dashboard. Complete JSON of the wrong shape is still reported as an error, exactly as before. The proposals reader now parses bytes instead of aString, so a record cut inside a multi-byte UTF-8 character cannot fail the whole file.load_recent_jsonl_records(events and audit) scans backwards from the end in 64 KiB chunks and stops afterlimitrecords. Its cost now trackslimitrather than the store size. A scan that fails while the file's length moved under it is retried from the new end; nothing this module's own writers do can shrink a store in place, so that case is for a store edited or truncated out-of-band, not a concurrent writer. One behaviour shifts as a consequence of the backward scan: a corrupt line older than the requested window is no longer reported, while one inside the window still is.write_atomic's doc comment now states that it serializes nothing, and theprune_managed_service_recordscomment that lists everyFileLock::acquirecall in the tree now includes these locks.Two doc comments outside this file (
process.rs, and a comment inexamine.feature) that still described every append as rewriting the whole store are corrected to match the append-mode write path. The Windows file-identity test helper used to prove the torn-tail repair replaces rather than truncates the store is shared fromtest_support.rsrather than duplicated.Mixed versions: a
rocmorrocmdbinary from before this change does not take the lock. While an old and a new binary run side by side, for example an oldrocmdstill running after an upgrade, the old one's rewrites can still drop a record. The fix is complete once both sides are on this version.Verification
automation.rs): N threads appending to all three stores; N separate processes, made by re-executing the test binary, appending to all three stores; and proposal status updates racing proposal appends. Each asserts the exact set of records afterwards. All three failed onmainas described above and pass with the fix. The two concurrency tests and the status race also passed 15 consecutive local runs.proposal_status_updates_racing_appends_lose_neitherfail. Both locks are therefore covered by a test.landed_despite_the_error) decides whether a failed write still landed a complete record, so the duplicate-avoidance behaviour above does not depend on inducing a real write failure to exercise its decision logic.ReverseLinesagainst a forward split at chunk sizes 1 to 8 (and against its own reported line offsets), the scan window, and a scan that finds the file shorter than it measured.load_recent_jsonl_records's retry-after-a-failed-scan path, andappend_or_tolerate_a_missing_newline's glue around a write that actually fails rather than the pure decision function alone, are each driven directly: a test seam forces an out-of-band shrink strictly between two ofReverseLines's own chunked reads (not just before the scan starts, which is already tolerated without a retry), and two Unix tests force a genuine short write viaRLIMIT_FSIZEwithSIGXFSZignored — one where the write lands everything but its trailing newline (the append must still succeed, with no duplicate and no silently-complete write), and one where the limit leaves no room for even the first byte (the append must report a real failure, not fold it into a false success).cargo test --workspace --all-targets --no-fail-fast: 4419 passed, 0 failed.cargo build --workspace --all-targetsandcargo test -p rocm-core --libboth stay clean with the torn-tail repair and the duplicate-avoidance check in place.cargo clippy --workspace --all-targets -- -D warningsandcargo fmt --all --check: clean.python scripts/smoke_local.py: ok.cargo xtask e2e -- -n "service-cleanup-|automations-": 13 scenarios passed. This includes@id:service-cleanup-names-a-file-it-could-not-remove, which asserts that a prune is recorded in the audit store, so it runs the new append path end to end, and the new@id:automations-tolerates-torn-audit-tail(automations-06), which seeds a torn audit-log tail and assertsrocm automationsstill lists the record before it.No scenario for the race itself. What it fixes is a race between writers that sits inside a window of microseconds. A scenario that races whole CLI processes would rarely hit that window, because process start-up dominates, so it could not fail reliably before the fix. The cross-process unit test reproduces the race deterministically instead, by calling the same
rocm_corefunctions the CLI androcmdcall from separate processes. The new*.jsonl.lockfiles are zero-byte rendezvous files beside the stores in the data directory, the same pattern as the existinglaunch.lockandautostart.lock.A separate, deterministic, user-observable behaviour does have one, covered by
@id:automations-tolerates-torn-audit-tail(automations-06):render_automations_textreads the audit store, directly surfacing the raw text via therocm automationsCLI subcommand and the TUI's automations-manager screen (which shells out torocm automations listas a subprocess) — it's also reachable in-process throughrocm chat's and the TUI's slash-commandrocm_commandtool and through rocmd's own MCProcm_commandtool (a further subprocess), though neither of those shows the raw text the same way.render_automations_textalready never exits non-zero on a read error — it swallows one via.unwrap_or_default()— so what this scenario pins is that the clean record ahead of a torn tail still shows up, rather than that error wiping out the whole audit section the way it would without this PR's read-path skip. The other half of the same behaviour change — a corrupt line older than the read window no longer being reported — stays a named unit test (load_recent_stops_before_corrupt_lines_older_than_the_window) rather than a second scenario, sincerocm automationsuses a fixed internal limit rather than a user-supplied one, so there is no CLI-level lever to place a corrupt line on one side of the window or the other.Windows is exercised by
windows-build-and-test(a local cross-compile was not possible without a MinGW toolchain). Onwindows-latest, every new test passes, including the cross-process writers, the status-update race, and the torn-tail repair, which replaces the store via rename through the same handle semantics as the proposal-status rewrite. The Windows-specific choices rest on these documented semantics:FileLockisLockFileExand already guardslaunch.lockandautostart.lockon Windows.FILE_APPEND_DATAonly, and std documents that writes land at the current end of file.FILE_SHARE_DELETEby default, so a lock-free reader does not block a rename — the proposal-status rewrite's, or a torn-tail repair's.Scope
Deferred to ROCMAI-729:
legacy-<at>-<index>), which a backward scan cannot compute.load_automation_proposals,load_recent_automation_proposalsandfind_automation_proposaltherefore still read the whole file.Known limitation, tracked in #748: a torn-tail repair that cannot complete its rename — sustained disk pressure, since the repair now needs free space roughly equal to the whole store rather than one line, or a Windows handle without delete-sharing — has no fallback, and every subsequent append to that store keeps retrying the same whole-store rewrite rather than degrading the way the old in-place truncate did.
One guarantee this reverses: the previous whole-file-rewrite-per-append design meant a crash or full disk could never leave a store with a truncated or malformed trailing line — the rename that published a change was all-or-nothing. Append-mode writes trade that away for O(1) appends and no dropped records: a crash or a failed write mid-append can now leave a torn tail, handled by folding it into the next append (see the torn-tail paragraph above) rather than by the store never being able to have one. Issue #666 is this earlier guarantee; this PR knowingly moves away from it.
Out of scope: fsync durability, unchanged from before.