Repository navigation
Conversation
A fetch responder admits two outstanding requests per requesting peer and twelve overall, and refuses the rest at admission without a reply. The requester then waits its whole 30 s fetch timeout, and saorsa-core charges the responder a connection-timeout trust event it did not earn. As measured for WithAutonomi#252 from the responders' cumulative admission counters, this was 36.6% of inbound fetch requests on 0.21.0 and 44.3% on 0.22.0, and the WARN line counted 3.38M refusals in the 25 hours of the 2026-10-11 join wave. A requester now says it understands a busy reply, and a responder that refuses such a request answers `FetchResponse::Busy` at once, within bounds. Wire: `FetchRequest` keeps its serialized layout. A requester that understands busy replies appends a 5-byte capability trailer ("antf" and a flags byte) after the postcard message. Older nodes decode with `from_bytes`, which ignores it, so they refuse silently as before. A current node reads it with `take_from_bytes`. `FetchResponse::Busy` is appended after the existing variants, whose discriminants are unchanged, and is only sent in answer to a request with the trailer, so an older requester never receives it. Responder: a busy reply needs a free send permit (48), the per-peer allowance (two back to back, then one a second, at most two in flight) and the global one (32 back to back, then 16 a second; every message is signed, so about 5 KiB each). It is sent off the serial message loop, abandoned after 2 s and raced against shutdown. Anything outside those bounds is the old silence. The refusal WARN line keeps its text and counters and gains `capability` and `reply` fields; new cumulative counters report each busy-reply outcome. Requester: the fetch worker's requests ask for busy replies. A busy answer for the requested key carries no trust event. The attempt waits 5-10 s (jittered, never past its own fetch timeout) while holding its fetch slot, then ends as a source failure, so the queue moves on exactly as after a timeout. The busy holder is put last when choosing where to start or retry a key while its cooldown runs: the wait, doubling with each further busy answer up to 60 s. Serving once resets that doubling, and so do 10 minutes without a busy answer. Merge after WithAutonomi#252: its per-source share removes most per-peer over-share, and the refusals that remain are what this answers. A test-only fetch serve gate, the same one WithAutonomi#252 adds, makes the end-to-end tests deterministic. ADR-0025 records the decision. BREAKING CHANGE: `replication::protocol::FetchRequest` gains a public `busy_reply` field (not serialized) and `FetchResponse` gains a `Busy` variant, so code constructing the struct or matching the enum exhaustively must be updated. The wire format stays compatible with older nodes in both directions.
`SourceBusyTracker::record_busy` never shortens a cooldown that is already running, but it returned the newly computed cooldown, so when an earlier, longer one was still running the requester's debug line reported less than the holder was really put last for. It now returns what is left of the cooldown in force. ant-node only logs the return value, so scheduling and wire behaviour do not change; callers of the public method now get the remaining cooldown in force. The never-shortened test now checks the value too. The same debug line said the holder was passed over "for new keys", but the cooldown applies both when a key starts and when it moves on to another holder. It now says the holder is put last. ADR-0025: - the context adds the refusals WithAutonomi#252 leaves at holders it currently counts as silent, and says WithAutonomi#252's ADR defers a busy reply rather than naming it as the remaining piece; - the trade-off on holders that answer busy to everything says they are put behind every holder that is not cooling, on retries too, and names the `fetch_busy_reply_send_pool_full` counter in full; - the requester section states the cooldown as 1, 2, 4 and 8 times the jittered wait drawn for each busy answer, capped at 60 s, says that a later busy answer never shortens a running cooldown, and gives the table's eviction order when every holder in it is cooling; - the request-rate trade-off is given for any fetch timeout, not only the default 30 s; - the requester end-to-end test is described as filling that requester's own two-request share at the holder; - `fetch_busy_sources_tracked` is listed as a gauge, apart from the cumulative counters; - the positive consequence notes that the busy wait can be cut short by the attempt's own fetch timeout; - the rejected retry-rotation option states its cost as the mechanism (one request per round trip instead of one per fetch timeout) rather than an estimate; - the order section records that this change and WithAutonomi#252 conflict and have not been built or measured together yet; - the real-node measurements are rerun on upstream main a6e3388, and the local simulation, which is not published, is named only as design input rather than offered as validation.
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.
A node that refuses a fetch at admission now tells the requester
Busyat once, when the request asked for it and the reply fits within the responder's limits, instead of staying silent. That silence cost every refused requester a 30 s wait and charged the refusing holder a connection-timeout trust event it had not earned.The problem. A fetch responder admits two outstanding requests per requesting peer and twelve overall. It refuses the rest at admission (
Fetch response dropped at admission) and sends nothing back. Then:ConnectionTimeoutagainst the holder, which only did what its limits say;#252's description reports, from the responders' cumulative admission counters, that 36.6% of inbound fetch requests on 0.21.0 and 44.3% on 0.22.0 were refused this way, and that the WARN line counted 3.38M refusals in the 25-hour window containing the 2026-10-11 join wave of 177 new nodes. #252 keeps a requester within each answering holder's share, which removes most per-peer over-share. Refusals remain for a busy reply to answer: a share still held by requests the requester already gave up on (which #252's description leaves to a busy reply), a twelve-slot pool full of other peers' requests, and a holder #252 currently counts as silent, which it may send requests past the share until an answer clears the mark or the mark expires and the holder is probed under the share again. Older requesters stay on the old silence until they upgrade.
The change.
Wire. A requester that understands busy replies appends a 5-byte capability trailer (
antfand a flags byte) after the encodedFetchRequest.FetchRequest's own layout is unchanged. Older nodes decode with postcard'sfrom_bytes, which ignores trailing bytes, so they read the key and keep refusing silently.FetchResponse::Busy { key }is appended after the existing variants, whose discriminants are unchanged. It is sent only in answer to a request carrying the trailer, so an older requester never receives it.Responder. Every message is signed (about 5 KiB with the ML-DSA-65 key and signature), so busy replies are bounded:
Anything outside those bounds is the old silence. The refusal WARN line keeps its text and counters and gains
capabilityandreplyfields.Requester. The fetch worker's requests ask for busy replies. A
Busyfor the requested key carries no trust event; one for any other key is charged as malformed, like every key mismatch. The attempt waits 5-10 s with jitter, never past its own fetch timeout, still holding its fetch slot, then ends as a source failure. So the queue moves on to the next holder exactly as after a timeout, and a busy answer cannot become a fast retry loop. While its cooldown runs, the busy holder is put last when starting or retrying keys, behind every holder that is not cooling; when all are cooling, the first is still tried, so a holder is never withheld. The cooldown is 1, 2, 4, then 8 times the full jittered wait drawn for that answer, even when the attempt's deadline cut the sleep short, capped at 60 s, and a later busy answer never shortens a running cooldown. Serving once resets the multiplier, and so do 10 minutes without a busy answer. The table holds 4096 holders; when it is full, a holder that is not cooling is forgotten first, or, if all are cooling, the one whose cooldown ends soonest.Merge after #252. #252 bounds how many fetches one requester has open at one answering holder. Without it, a requester with many fetch slots still over-shares, and most of those refusals exceed the per-peer busy allowance and stay silent. The two conflict in
src/replication/mod.rsand have not been built or measured together yet. Once #252 merges, this is rebased onto it, the cooldown moves into #252's source choice, and the gates and the real-node measurements below are rerun.What it does not do.
Linear issue
Closes V2-1514
Risk tier
Compatibility
FetchResponse::Busyvariant, appended, and an optional 5-byte capability trailer after aFetchRequestmessage. Older nodes ignore the trailer and are never sentBusy. Pinned by tests that decode with mirrors of the previous types.FetchRequestgains a publicbusy_replyfield (not serialized),FetchResponsegains a variant, and there is a new publicreplication::fetch_busymodule. Also new and public:ReplicationQueues::retry_fetch_withandprotocol::{FETCH_CAPABILITY_MAGIC, FETCH_CAPABILITY_BUSY_REPLY}. Additive test-only hooks undertest-utils:ReplicationEngine::{close_fetch_serve_gate_for_test, open_fetch_serve_gate_for_test, fetch_source_cooling_for_test, fetch_source_last_busy_for_test, fetch_admitted_for_test, fetch_pool_full_refusals_for_test}.Semver impact
Test evidence
The gates and the real-node runs below were run on upstream main a6e3388 (0.23.0) with this change applied, which is the code and dependency tree CI tests. The full end-to-end suite and the real-node runs were made before the second commit's source change. That change makes the new public
SourceBusyTracker::record_busyreturn the remaining cooldown in force; ant-node uses that value only in a debug line, so node scheduling and wire behaviour are unchanged. After it, the formatting, Clippy, doc and ADR checks, the unit tests, the no-logging build and the two busy-reply end-to-end tests were rerun.Dev testnet: not run yet. As a T3 change, this needs dev-testnet evidence on the build rebased onto #252 before merge.
Real nodes, against upstream main.
tests/e2e/fetch_busy_ab.rsis opt-in:ANT_FETCH_BUSY_AB=1 cargo test --test e2e --features test-utils fetch_busy_ab -- --nocapture --test-threads=1. The same file and the same test-only serve gate were run on main a6e3388. Every run fetched every chunk.The first scenario was run once per build. On the previous main, 8d25f0c, the same run took 60.4 s with 14 and 4 requesters charging the first two holders, against 10.5 s with none for this PR. The charge counts in the second row are read from the trust scores below: each charge takes a neutral 0.5 down by about 0.06 and then a little less each time, so 0.29 is four charges and 0.23 is six.
Raw output
End to end (
tests/e2e/fetch_busy_reply.rs), deterministic through a test-only fetch serve gate, the same one #252 adds:Busyat once, and one that does not ask is refused silently;Unit tests (16 new in
fetch_busy, 9 inprotocol, 2 inmod):Busy;Simulation. A local, unpublished discrete-event model of the 177-node join wave and 24 edge cases informed the design, including the 5-10 s wait. Its source and raw output are not part of this PR, so its results are not offered as review evidence.
Gates run locally, on main a6e3388 with this change:
cargo fmt --all -- --checkcargo clippy --all-targets --all-features -- -D warnings, on stable and on Rust 1.99 (CI's toolchain)cargo clippy -- -D warningsRUSTDOCFLAGS="--deny=warnings" cargo doc --no-depscargo test --lib --features test-utils: 1215 passedRUSTFLAGS="-D warnings" cargo check --lib --no-default-featuresandRUSTFLAGS="-D warnings" cargo test --lib --no-default-features: 1170 passedcargo test --test e2e --features test-utils -- --test-threads=1: 123 passed, plus the threelive_testnettests that are ignored on mainpoc_d1_bounded_queues,poc_bootstrap_stall)scripts/adr-governance.pyagainst mainReview. Codex-assisted:
New dependency
none
ADR
https://github.com/grumbach/ant-node/blob/fetch-busy-reply/docs/adr/ADR-0025-fetch-busy-reply.md
Mitigation / rollback
Ship a release without it; capability is negotiated per request, so nodes on either side of a mixed fleet keep working.