fix(replication): back off and report once when a round finds no holder - #214
Conversation
A pending key whose presence probe finds no holder was put back with a flat 15s delay and a `warn!` every single time, with no notion of how many times it had already failed. On the beta cohort one node produced 1,153,502 lines from 2,072 distinct keys in ten hours — 557 per key, 98.8% of every WARN the whole cohort emitted in 24 hours. The keys were not a backlog being worked through. They were the same keys, re-asked of the same peers, receiving the same answer. `PENDING_VERIFY_MAX_AGE` does not bound it: the entry is evicted at 30 minutes, a neighbour re-hints the key, and it is re-admitted with a fresh `created_at`. One sampled key ran through fifteen such residencies. Split deferral into two methods that mean different things. `defer_pending` keeps the flat delay for deferrals that are not a failed round — the write-blocked capacity gate defers without asking anyone, so nothing was learned about the key. `defer_unresolved` is the failed-round path: it increments a new per-entry `unresolved_retries` and returns the attempt number alongside a delay that doubles from the base and saturates at five minutes. Both no-holder sites warn on the first failure and drop to `debug!` after, so the report is once per episode rather than once per retry. The eviction that ends an episode also resets the count, so a key that becomes unresolvable again is reported again. The per-cycle total is carried in the verification cycle summary as `no_holders=`, which keeps the scale of a backlog visible. Inside one residency a stuck key is now probed roughly ten times instead of roughly a hundred and ten, cutting the redundant verification traffic — ~14,500 key references per round to seven peers, at the volumes observed — as well as the log. This makes the symptom proportionate. It does not address the cause: the node was a first start claiming a slice of the keyspace its routing table could not resolve, which is the cold-start half of V2-883 that saorsa-core#152 left open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR-0011 was taken by capacity-gated source discovery on main. Also states explicitly why the write-blocked gate keeps the flat defer_pending path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dirvine
left a comment
There was a problem hiding this comment.
Reviewed exact head cbf54072083e486aa09802d9088f2c3de5de1163.
The backoff implementation is correct across the lifecycle paths I checked: no-holder retries follow 15/30/60/120/240 seconds and then the 300-second cap; capacity deferrals remain flat and do not consume the unresolved counter; duplicate hints do not reset the timer; fetch failure preserves the entry state; and eviction/re-admission correctly starts a fresh episode. Queue selection excludes deferred entries, so the change reduces repeated traffic without consuming cycle capacity.
Local verification:
cargo fmt --all --check— passed- replication scheduling tests — 51/51 passed
- replication module tests — 577/577 passed
- focused PoC tests with
test-utils— 10/10 passed cargo clippy --all-targets --all-features -- -D warnings— passed
The six-seat review produced no correctness blocker. Five seats completed; one release/API seat timed out. Two reviewers noted the same non-blocking observability edge case: if QuorumInconclusive consumes attempt 1, the first later no-holder result is logged at debug rather than warn, and its attempt count includes the inconclusive round. This follows the shared-counter design documented in ADR-0012 and does not affect retry behaviour or the aggregate no_holders= counter.
Minor documentation/API notes, not blockers:
- ADR-0012 says
no_holders=appears only for slow cycles; it is also present in fast-cycle debug summaries. The intended info-level operational visibility is still accurate. VerificationEntryis public, so adding its public field can break downstream struct literals; the PR's semver note could state that more explicitly.
All completed GitHub checks are green. The Windows test job is still running at review time; merge should remain gated on it.
Verdict: APPROVE.
|
Nice piece of work — the field evidence makes the case on its own, and the 15→300s ladder lands at roughly nine attempts per 30-minute residency, which feels like the right shape. The Two questions before it goes in:
Two smaller ones, take or leave: |
Review found that `QuorumInconclusive` advanced `unresolved_retries` without logging, so a key whose first round was inconclusive reached the no-holder branch at attempt 2 and only ever got a `debug!` line — losing the one warning the change exists to keep. That is the common path, not a corner: a key entering `PaidForList` after its first quorum round takes the local-paid fast path next cycle. The count and the report answer different questions. "How many consecutive rounds failed" drives the backoff, and an inconclusive quorum legitimately advances it. "Have we told anyone" may only be consumed by a round that actually found no holder. Split them: `no_holder_reported` on the entry, claimed through `claim_no_holder_report` at the two no-holder sites, and never by the inconclusive or capacity-gate paths. Also clear both on a round that did find a holder. `promote_pending_to_fetch` leaves the entry pending when the fetch queue is full, and that entry was carrying its old failure count and backoff despite the round having succeeded. "Once per episode" is now literal rather than nearly true. Tests pin the three separations this rests on: a non-reporting round advances the count without consuming the warning, a flat `defer_pending` does neither, and a duplicate hint merges into the live entry rather than replacing it — the last guarding a silent revert, since a refactor that replaced instead of merging would undo the backoff with every other test still green. `VERIFICATION_RETRY_BACKOFF_MAX` joins the config invariant test beside `CAPACITY_BLOCKED_RETRY`, and the ADR is corrected: `no_holders=` appears in both cycle summaries, not only the slow one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — all four taken, in 1. Inconclusive consuming the warning. You're right, and it's worse than a corner case: after a quorum round the key enters Took the separate-flag route for the reason you gave: the two facts are independent. The cheaper alternative — putting the inconclusive branch on flat 2. Pinning the re-hint. Added 3. Reset on the promote-miss path. Taken rather than left — on inspection it was more than a doc gap. One related spot you didn't mention, which I've left alone on purpose: 4. Config invariant. Added beside Also picked up @dirvine's two: the ADR claimed 950 lib tests pass, fmt and both clippy configurations clean. |
Linear issue
V2-1049
Risk tier
Proposed T2 because the retry cadence for unresolved keys is network-facing: it changes how often this node sends verification requests to its close group. The surface is narrow — it only affects keys that have already failed at least one round, and only lengthens the interval — so I'd suggest the tier's dev-testnet requirement is satisfied by the beta evidence below rather than a fresh run, but that is the reviewer's call.
Compatibility
unresolved_retrieslives on the in-memoryVerificationEntry; nothing is persisted.ReplicationQueues::defer_pendingkeeps its signature and behaviour. Newdefer_unresolved,claim_no_holder_report,clear_unresolvedandDeferralOutcomeare additive.VerificationEntrygains two public fields, which is the breaking part — see below.Log wording: the two no-holder messages are unified on
has no responding holders yet. The network-verification site previously readhas no holders yet. Any saved search matching the old string needs updating.Semver impact
Changed from
featureafter review.VerificationEntryispub, all its fields arepub, and it carries no#[non_exhaustive], so addingunresolved_retriesandno_holder_reportedbreaks any downstream struct literal — the two in-repo integration tests this PR had to fix are the demonstration. Nothing outside this repository is known to construct one, so if the lib target is considered internal a reviewer may reasonably downgrade this tofeature; I have marked it by the letter of the rule rather than by the expected blast radius.Worth a separate decision:
#[non_exhaustive]onVerificationEntrywould stop this recurring. It is itself breaking, so it belongs with a deliberate bump rather than smuggled in here.Test evidence
cargo test— 946 lib tests pass, plus all integration and doc tests.cargo fmt --all --checkclean. Both clippy configurations clean:--all-targets --all-features -D warnings, and the repo-standard-D clippy::panic -D clippy::unwrap_used -D clippy::expect_used.Nine new unit tests in
replication::scheduling, plus one inreplication::config:repeated_deferrals_back_off_and_saturate_at_the_cap— the 15/30/60/120/240 sequence, then 64 further attempts pinned at the cap (guards the shift against overflowing into a short delay).re_admission_after_eviction_restarts_the_backoff— eviction ends the episode, so the next hint warns again.flat_defer_does_not_advance_the_unresolved_backoff— ten capacity-gate deferrals do not consume the first-failure warning. This is the interaction with fix(replication): stop a write-blocked node probing its close group #207 and is the test I would most want reviewed.backoff_never_retries_faster_than_the_caller_base— a base above the cap is not shortened.defer_unresolved_reports_none_for_unknown_key.deferred_pending_key_is_not_ready_until_retry_timeis unchanged and still asserts flat behaviour fordefer_pending.Added in the review round:
a_non_reporting_round_does_not_consume_the_no_holder_warning— an inconclusive quorum advances the count, and the first actual no-holder result still warns at attempt 2.finding_a_holder_clears_the_backoff_and_rearms_the_warning— a successful round resets both, so a key held up only by a full fetch queue does not inherit an earlier backoff.a_duplicate_hint_does_not_reset_the_backoff_or_the_retry_time— pins the merge-not-replace behaviour ofadd_pending_verifythat the whole fix rests on.verification_retry_backoff_max_is_between_the_request_timeout_and_the_entry_lifetimeinconfig.rs, beside theCAPACITY_BLOCKED_RETRYinvariant.Field evidence (V2-1049). Measured on the beta cohort,
beta-nodes-*, 24h to 2026-08-21 18:00 UTC:Measured re-log cadence was 16–25s, matching the flat 15s defer plus a 0.6–10s probe round. Runs between gaps were exactly 30 minutes —
PENDING_VERIFY_MAX_AGE— with one sampled key running through fifteen residencies. Replayingis_responsibleagainst that node's reconstructed routing table shows it claimed ~1,451 of those keys at bootstrap and only 98 once the table converged, which matches the observed residual stuck set exactly. None of the 2,072 keys was ever fetched.Expected effect at that shape: ~10 probes per key per residency instead of ~110, and 1 WARN per key per episode instead of ~110.
New dependency
none
ADR
https://github.com/jacderida/ant-node/blob/fix/unresolved-verification-retry-backoff/docs/adr/ADR-0012-unresolved-verification-retry-backoff.md
ADR-0012 — Back off and report once when a verification round finds no holder, added in this PR. Numbered 0012 because ADR-0011 is taken by capacity-gated source discovery (#207), which this cross-references: that ADR's write-blocked gate is deliberately left on the flat
defer_pendingpath.Mitigation / rollback
Revert the commit. There is no persisted state, no wire change, and no migration — a node running the previous binary behaves exactly as before. To soften rather than revert, lower
VERIFICATION_RETRY_BACKOFF_MAX; setting it toVERIFICATION_REQUEST_TIMEOUTrestores today's flat 15s cadence while keeping the log-once behaviour.Note on scope
This makes the symptom proportionate; it does not fix the cause. The node in V2-1049 was a first start that claimed a slice of the keyspace its routing table could not resolve — the cold-start half of V2-883, which saorsa-core#152 explicitly left out of scope ("speeding up bucket refresh for a brand-new node with no snapshot"). That is tracked separately.