Repository navigation
Conversation
write_approval_log fsynced one JSONL line at a time, and write_ledger_entry did the same on the HMAC ledger, so committing N dispositions paid 2*N fsyncs where 2 suffice. N is the size of one commit, not the ledger: `vhir approve --review` reads pending-reviews.json uncapped and adds auto-coupled timeline events and cascaded IOCs, so a portal sweep commits hundreds to thousands at once. Measured at N=1000 on ext4: 27.78 s -> 0.11 s. Add write_approval_log_batch() and write_ledger_entries(), which do one unlock/open/write/fsync/lock cycle per batch. Records are serialised before the file is opened, so a malformed record cannot leave a half-written batch. The single-entry functions remain as one-element wrappers, so every existing caller and test keeps working. The twelve call-site loops in approve.py and reject.py now accumulate and make one call each. Three behaviour changes the reviewer should weigh: 1. Durability granularity is coarser. Previously each line was individually fsynced, so an interruption mid-commit left every line written so far durable; now the batch is durable only at its single fsync, so the same interruption loses that commit's whole log block rather than a prefix. Case data is still saved and fsynced first, and this block was already labelled best-effort, but the window is wider than it was. 2. On an OSError the batch reports every id in it as unlogged, where the per-item loop reported only those that actually failed. This over-reports a custody-log gap rather than under-reporting it, and affects only the text of the "Approval log failed for: ..." warning. 3. Entry ts values within one commit now cluster within microseconds instead of being spread out by per-line fsync latency. They remain per-record, non-decreasing, and in the same order. 428 pre-existing tests pass unchanged on Python 3.10 and 3.12, plus 10 new tests covering batch fsync counts, ordering, field parity with the single-entry writer, empty batches and file relocking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
This PR is a draft because it needs a decision on the durability-granularity trade-off. That decision is written up on its own, with the options laid out, in #51 — probably the better place to discuss it than this diff. |
Resolve the conflicts in tests/test_approve_reject.py by keeping both sides: import os (this branch) and sys (upstream), this branch's test_reject_batch_fsyncs_log_once in TestReject, and upstream's tests for items staged during the approval wait. approve.py and case_io.py merged cleanly; the batched log and ledger writes now run after upstream's _keep_landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
elhoim
marked this pull request as ready for review
October 4, 2026 22:57
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
BLUF
vhir approve --reviewreads pending-reviews.json uncapped and adds auto-coupled timeline events and cascaded IOCs, so a portal sweep commits hundreds to thousands at once.write_approval_log_batch()andwrite_ledger_entries()do one unlock/open/fsync/lock cycle per batch; the eight approval-log loops in approve.py, the four in reject.py and the ledger loop now accumulate and make one call each. Single-entry functions stay as one-element wrappers.main@b2324ac(2026-10-05): the bug is still present —write_approval_logand_write_verification_entriesare unchanged onmainand still do per-item work. The branch now hasmainmerged in.Priority: MEDIUM
The hot construct
write_approval_log(case_io.py:294-307) does a full durability cycle for one JSONL line:It is called from eight loops in
approve.py(:223, :479, :492, :504, :518, :1310, :1324, :1330) and four inreject.py(:153, :298, :307, :322).write_ledger_entry(verification.py:45-54) repeats the same pattern on the HMAC ledger, once per approved item, from_write_verification_entries— so a commit pays 2*N fsyncs, not N.Why it costs what it costs
fsync()is a device round-trip, ~10-15 ms on a spinning-rust-backed or LVM-on-ext4 volume, and nothing about it amortises: the cost is per call, not per byte. N is the number of dispositions in one commit, not the length of the ledger — appending stays O(1) even as approvals.jsonl reaches 100k lines._review_modereadspending-reviews.jsonwholesale with no cap, andapproved_idsadditionally accumulates auto-created timeline events and cascaded IOCs, so a realistic portal sweep is a few hundred to a few thousand items in a single commit. That is seconds to tens of seconds of pure durability stalls, on the tool's most-run write path, while the examiner watches.The correct cost is two fsyncs total. The surrounding code already treats the batch as the consistency unit: Step 1 saves findings/timeline/iocs through a single
_atomic_writewith one fsync before any log line is written, and the log block is explicitly labelled best-effort with failures merely collected intolog_failures.The fix
case_io.write_approval_log_batch(case_dir, records)— serialises every record before the file is opened (so a malformed record cannot leave a half-written batch), then one chmod 0o644, oneopen(..., "a"), onewrite, oneflush, onefsync, one chmod 0o444.verification.write_ledger_entries(case_id, entries)— same shape, onemkdir, one open/fsync, one chmod 0o600.write_approval_logandwrite_ledger_entryremain, as one-element wrappers, so every existing caller and test keeps working._write_verification_entriesbuilds its entry list and makes one ledger call.No new module, no new dependency, no shared framework.
Measurement
N = 1000 dispositions, median of 3 timed runs after one warm-up,
time.perf_counter, ext4/LVM (not tmpfs), run throughuv run --with-editablewithcase_io.__file__printed to prove worktree code.Behaviour preservation
write_approval_logrecord (ts excluded), an empty batch creates no file, the log is relocked to 0o444 after each batch; and the same for the ledger at 0o600.cmd_rejectpath with 20 findings and counts fsyncs againstapprovals.jsonl's own (st_dev, st_ino). Red on the original code:ruff check .clean,ruff format --check .clean (63 files).Trade-off the maintainer should decide on deliberately
Durability granularity. Previously each log line was individually fsynced, so a SIGKILL mid-commit left every already-written line durable. Now the batch is durable only after its single fsync, so the same kill loses the whole commit's log block instead of a prefix. The invariant that matters is unchanged — case data is still saved and fsynced first, and in both the old and new code a crash mid-commit leaves the log incomplete relative to the data — but the granularity is coarser, and on a chain-of-custody tool that is the maintainer's call, not mine.
Two smaller observable changes:
WARNING: Approval log failed for: ...line.tsvalues within one commit now cluster within microseconds instead of being spread out by per-line fsync latency (previously ~15 s of drift across 1000 items). Values remain non-decreasing and in the same order.Independent review notes
This change was reviewed adversarially by a second pass that re-ran the benchmarks. Where that review contradicted or qualified the account above, it is recorded here rather than quietly edited out:
failure-reporting granularity it names,
tsvalues within a commit now cluster within microsecondsinstead of being spread by per-line fsync latency (order and monotonicity unchanged), and the
durability granularity below is not mentioned in the commit message at all.
so a SIGKILL mid-commit left every already-written line durable. Now the batch is durable only at its
single fsync, so the same interruption loses the whole commit's log block rather than a prefix. Case
data is still saved and fsynced first, and the log block was already labelled best-effort — but on a
chain-of-custody tool this is the maintainer's call, which is why this PR is a draft.
Deliberately not done here
A shared
_append_jsonl_once(path, lines, unlock_mode, lock_mode)helper would subsume both new batch functions and also the audit-trail append in execute.py:150-178 (open(..., "a")per exec record), collapsing three copies of the unlock/open/write/fsync/relock idiom into one. I deliberately did NOT build it: a new shared module or utility would make this PR a prerequisite for every other PR that used it, which destroys the maintainer's ability to cherry-pick these findings independently and in any order. The two batch functions here are local to the modules that already own those files.Separately, if the maintainer ever wants bounded loss on very large commits, chunked fsync (every K records) is a one-line change inside
write_approval_log_batchand keeps most of the 254x.CI status
The checks on this PR will show
action_required: GitHub gates workflow runs on pull requests fromfirst-time fork contributors until a maintainer approves them. They need an Approve and run click.
The CI job was reproduced locally after merging
main@b2324ac(761 tests onmain), using the same steps as.github/workflows/ci.yml:pip install -e ".[dev]" && pytest tests/— Python 3.10pip install -e ".[dev]" && pytest tests/— Python 3.11pip install -e ".[dev]" && pytest tests/— Python 3.12ruff check .+ruff format --check .(latest ruff, unpinned as in CI)🤖 Generated with Claude Code