Skip to content

fix(replication): repair chunks that audits find rotted - #242

Open
grumbach wants to merge 2 commits into
WithAutonomi:mainfrom
grumbach:fix/audit-read-quarantines-corrupt-chunks
Open

grumbach wants to merge 2 commits into
WithAutonomi:mainfrom
grumbach:fix/audit-read-quarantines-corrupt-chunks

Conversation

@grumbach

@grumbach grumbach commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Linear issue

Closes V2-1382

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

Compatibility

  • Wire: none
  • Storage: none. A chunk file proven not to match its address is removed, exactly as the fetch path already does.
  • API: none

Semver impact

  • breaking
  • feature
  • fix

Test evidence

Why: every audit reads chunk bytes with get_raw, which does not check them. A node whose chunk file has gone bad on disk keeps committing it and fails every subtree audit that lands on it, and only a fetch of that exact key repairs it. In production, all 186 subtree round-1 DigestMismatch failures our fleet logged between 2026-09-25 and 2026-10-01 were against 10 community nodes, none against our own 774 (212,871 passes). The node behind most of them answered our fetches with its own Chunk verification failed, which is this state.

What changes:

  • Responder: round 1 already hashes every leaf it reads, so it now reports the committed chunks whose bytes do not hash to their key. The proof still carries what was read, so the auditor's verdict is unchanged.
  • The reported keys go to a queue in the chunk store, worked by one recheck worker on the replication engine, which shutdown waits for rather than aborting mid-recheck. Each recheck is the existing verifying read: it re-reads the file under the shard write lane, removes it only if it is still wrong, stops the node claiming the key so its next commitment leaves it out and replication can repair it, and re-queues a legacy copy if there is one.
  • The queue is FIFO, deduplicated against both the queue and the key being rechecked, and capped at 1024 keys, the most one round-1 proof can cover. Reporting never waits; a key that does not fit is dropped and the next audit that reads it reports it again. So however many audits report rot, and however long a quarantine waits for a stalled write lane, cleanup holds at most one blocking thread. The round-1 task keeps its admission permit until it finishes, as on main. Holding that permit through the cleanup was the alternative, but round 1 never takes a write lane, so two proofs over rotted chunks in a stalled shard would then have stopped the node answering every subtree audit.
  • Auditor: the reference copy in the responsible-chunk, possession and prune lanes is read with the verifying get, so a rotted local copy is taken out of service and that key skipped instead of failing an honest peer. A responsible audit in which no key could be checked is idle rather than a pass.

New tests:

  • a_stalled_quarantine_holds_one_blocking_thread_however_many_reports_arrive: holds a shard's real write lane while the worker's quarantine waits on it, has 32 more reports of the rotted chunk arrive, and requires at most one blocking task in flight, every report returned, and the queue worked through once the lane frees. With each report running its own recheck, as on the previous head of this PR, it fails with 33 rechecks held blocking threads behind one stalled write lane.
  • corrupt_reports_are_deduplicated_and_capped and the_corrupt_queue_holds_everything_one_proof_can_report: dedup against the queue and the key in flight, the full-queue drop, the restart rule, and that everything a maximal proof (max_subtree_leaves(MAX_COMMITMENT_KEY_COUNT) keys) can report fits an empty queue.
  • a_node_whose_chunks_rotted_takes_them_out_of_service_after_an_audit (e2e): rots a node's chunk files, audits it over the live wire, and requires the audit to fail, at least one rotted chunk it read to leave service, and every chunk removed to be unclaimed. With the engine's worker not started it fails with no rotted chunk the audit read was taken out of service.
  • round_one_takes_a_rotted_committed_chunk_out_of_service, a_rotted_local_copy_neither_fails_nor_passes_the_peer, a_rotted_local_copy_is_not_used_to_judge_holders, recheck_corrupt_takes_a_rotted_file_out_of_service_and_spares_a_good_one, round_one_over_intact_chunks_keeps_every_chunk: unchanged from the previous head (the first four fail with the change reverted); the first now goes through the queue and worker.

Suites, on this head:

  • cargo test --lib --features test-utils: 1235 passed.
  • poc_audit_handler_live 16/16, poc_commitment_audit_attacks 19/19, poc_shutdown_lmdb_drain 1/1, poc_bootstrap_stall 3/3, migration_reclaims_disk 2/2, migration_shared_volume 5/5, migration_crash_safety 5 passed (3 ignored, as on main).
  • cargo test --test e2e --features test-utils -- --test-threads=1: 111 passed, 3 ignored (as on main), including the 6 subtree-audit tests.
  • cargo clippy --all-targets --all-features -- -D warnings on Rust 1.98 and 1.99, cargo fmt --check, cargo doc --all-features with warnings denied, RUSTFLAGS="-D warnings" cargo check --lib --tests --no-default-features on 1.99, and the 1.95 MSRV cargo check --all-targets --all-features --locked: clean.
  • Run locally on macOS (arm64).

Dev testnet: not run here. For the testnet: keep verify_on_read=true (the default; turning it off also turns off this repair). A rotted chunk shows as Subtree audit: committed key … does not hash to its address on the audited node, then Removed corrupt chunk file …; replication will repair it. A … chunk(s) found rotted were not queued for a recheck warning means a burst outran the queue. Removal is not repair by itself: the evidence to look for is the key being fetched back from a replica and the next audit over it passing, plus intact controls staying in place.

New dependency

none

ADR

https://github.com/WithAutonomi/ant-node/blob/main/docs/adr/ADR-0014-file-based-chunk-store-and-lmdb-retirement.md: a chunk whose bytes are proven wrong stops being claimed, so ordinary replication can repair it. This PR applies that existing rule to the reads audits make. The subtree audit's wire and proof formats are unchanged (https://github.com/WithAutonomi/ant-node/blob/main/docs/adr/ADR-0002-gossip-triggered-contiguous-subtree-audit.md); the auditor lanes now skip a corrupt local reference instead of judging a peer against it.

Mitigation / rollback

Revert the commits. Nothing persisted or on the wire changes. The effects are that chunks already proven corrupt are removed so replication can refetch them, and that auditors skip a corrupt local reference instead of judging a peer against it.

Comment thread src/replication/mod.rs Outdated
processing,
response_send,
);
drop(guard);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Keep corruption cleanup bounded

Dropping guard here releases admission while this request still performs disk reads, hashing, and quarantine writes in recheck_corrupt().

If quarantine stalls on a shard lock or slow filesystem, further challenges against the retained commitment can read the same corrupt file and start additional cleanup tasks. Different peer identities bypass the per-peer cooldown. The work budget charges only proof generation, and TaskTracker does not impose a concurrency limit, so cleanup can accumulate beyond the configured responder limit and occupy the shared blocking pool, delaying unrelated storage operations.

Please retain the guard until cleanup finishes (the response has already been sent), or hand cleanup to a bounded queue that deduplicates keys. A regression test could stall quarantine and submit further challenges to verify that pending cleanup stays bounded.

This finding follows from the admission and storage code paths; I did not reproduce blocking-pool exhaustion end to end. Validation on this commit: 1,232 library tests, 16 live audit-handler tests, and 19 adversarial commitment-audit tests passed.

@dirvine dirvine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review of 3f4337c3c037ae1844a1c13993504559650fc7f1

P2 — Keep corruption cleanup inside a bounded admission path

At src/replication/mod.rs:5279-5285, the round-one responder sends the reply, explicitly drops its admission guard, then awaits the corrupt-key rechecks. Those rechecks perform verifying disk reads and can await a shard write lane and quarantine I/O. They are no longer counted by either the global or per-peer responder limit.

If a write lane stalls while raw reads can still complete, subsequent admitted proofs can start more cleanup work despite earlier cleanup being outstanding. This can exceed the intended concurrency limit and place additional work in the shared blocking pool. Per-proof subtree geometry and the existing byte-budget/cooldown limit individual work and admission rate; they do not bound the outstanding cleanup count. FileStore::get_raw still reads the file directly, so a pending quarantine does not inherently prevent another retained-commitment proof from encountering it.

This corroborates the existing inline concern; I have not added a duplicate inline comment. This is a source-traced failure mode, not a measured production DoS or an executed resource-exhaustion reproduction.

Please keep the guard through cleanup after sending the response, or move cleanup into a separately bounded worker/queue with a defined full-queue policy (and preferably per-key deduplication). Add a regression which stalls cleanup and checks that further requests cannot accumulate cleanup beyond the chosen cap. A timeout alone is insufficient if it releases admission while already-started blocking work continues.

What passed

Executed locally on the reviewed head:

  • cargo test --locked --lib --features test-utils: 1,232 passed, 0 failed.
  • cargo test --locked --features test-utils --test poc_audit_handler_live --test poc_commitment_audit_attacks: 16 live-handler tests and 19 adversarial commitment-audit tests passed.
  • Focused corruption/intact, storage, pointer-audit and budget tests also passed; these overlap the library suite, not additional unique test counts.
  • Diff whitespace and format checks passed. GitHub's non-skipped checks were successful; optional Claude review was skipped.

The corruption handling itself looks sound: proof bytes remain honest, quarantine rechecks under the write lane before removal, and an auditor with no usable local references returns Idle rather than giving an undeserved pass or judging a peer using rotten bytes.

Testnet scope

Use verify_on_read=true (the default); disabling it also disables this repair trigger's verification. Quarantine/exclusion is not itself proof of successful repair: demonstrate valid refetch from an available replica, a later successful audit, intact/concurrent-repair controls, and the corrupt-auditor-reference case. Include stalled-cleanup admission in the test matrix. No testnet was launched as part of this review.

Panel reconciliation

Completed panel: storage seat APPROVE (no introduced storage-safety defect found); protocol/concurrency seat non-blocking for a controlled testnet, with hardening before broad rollout; independent GLM-5.2 APPROVE-WITH-CONCERNS. All identify the missing aggregate cleanup concurrency cap. The first two timed-out workers are not counted as completed seats. The replacement storage seat consulted an existing same-head review reference as well as source, so it is corroboration rather than fully blinded review.

Final adjudication: REQUEST_CHANGES on the P2 bound above, with explicit severity dissent from the protocol and GLM seats. Their rate-bound and per-task sequential-work observations are valid, but do not establish a bound on outstanding tasks when cleanup stalls. Being the tail of an already-tracked task rather than a separately spawned task does not change that: releasing admission allows new tracked tasks to overlap the old tails. Nor do passing tests reproduce this stalled-cleanup case. I therefore do not adopt the protocol seat's stronger claims that resource exhaustion is excluded or the risk is bounded in practice.

This is not a finding of data loss or incorrect peer punishment, and it does not prohibit a maintainer-approved isolated diagnostic testnet. It is a request to close the admission gap before calling the PR ready for acceptance/rollout. A dedicated bounded cleanup queue would preserve proof-response capacity better than holding the proof permit indefinitely; ensure queue admission itself does not create an unbounded population of waiting tasks.

Every audit reads chunk bytes through ChunkStore::get_raw, which does not
check them against their address. A node whose chunk file has gone bad on
disk therefore never notices from audits: it keeps committing the key and
fails every subtree audit whose selected block contains it, and only a
fetch of that exact key (the verifying get path) would take the file out of
service and let replication repair it. In the other direction, an auditor
whose own reference copy has gone bad fails honest peers in the
responsible-chunk, possession and prune lanes.

Responder: round 1 already hashes every leaf it reads, so a chunk leaf whose
plain hash differs from its key is now reported in Round1Work::corrupt_keys.
Once the reply has been sent and the admission permit released, the
replication engine passes each one to the new ChunkStore::recheck_corrupt,
which runs the existing verifying read: it re-reads the file under the shard
write lane, removes it only if it is still wrong, drops the key from the view
the next commitment is built from, and re-queues a legacy copy if there is
one. The proof still carries the bytes that were read, so the auditor's
verdict is unchanged.

Auditor: the reference copy in the responsible-chunk, possession and prune
lanes is now read with ChunkStore::get instead of get_raw, so a rotted local
copy is taken out of service and the key skipped instead of failing the
peer. A responsible audit in which no key could be checked against a good
local copy is now idle rather than a pass, and keys_checked counts only the
keys actually verified.

Both sides follow the node's verify_on_read setting, like every other
verifying read.
Round 1 of the subtree audit reports the committed chunks whose bytes no
longer hash to their key, and the responder took each one out of service with
a verifying read after sending its reply and releasing its admission permit.
That cleanup ran outside every responder limit. The verifying read ends in a
quarantine that waits for the chunk's shard write lane on a blocking-pool
thread, and while that lane is stalled further proofs keep reading the same
rotted file: an auditor can pin a retained commitment that still contains it,
and a new peer identity escapes the per-peer cooldown. Each of those proofs
parked another blocking thread, with nothing capping how many.

The chunk store now keeps the reported keys in a FIFO queue, deduplicated
against both the queue and the key being rechecked, and capped at 1024 keys,
the most a single round-1 proof can cover. Reporting never waits. A key that
does not fit is dropped, and the next audit that reads it reports it again,
since it stays on disk and committed until it is removed. The replication
engine runs one worker that rechecks the queue a key at a time, so however
many audits report rot and however long a lane stalls, the rechecks hold at
most one blocking thread between them. The worker stops at the shutdown token
between rechecks, and is tracked with the engine's detached storage work, so
shutdown waits for a recheck in progress rather than aborting it. Keys still
queued at shutdown stay on disk and committed, so after a restart the next
audit that reads them reports them again.

Holding the round-1 permit through the cleanup would also have bounded it.
But round 1 never takes a write lane, so two proofs over rotted chunks in a
stalled shard would then have stopped the node answering every subtree audit
until the lane freed. The round-1 task keeps its permit until it finishes, as
it did before the cleanup was added, and only queues the keys.

A regression test holds a shard's real write lane while 32 reports arrive and
requires at most one blocking task in flight; with each report running its
own recheck, as before, it sees 33. Another checks that everything the
protocol's largest subtree can report fits an empty queue. A new end-to-end
test rots a node's chunk files, audits it over the live wire, and requires
the audit to fail and at least one rotted chunk it read to leave service. Three assertions in this branch's tests gain failure
messages for the assert_is_empty lint that Rust 1.99 added and main now
passes.
@grumbach
grumbach force-pushed the fix/audit-read-quarantines-corrupt-chunks branch from 3f4337c to ebfdd04 Compare October 2, 2026 05:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants