fix(replication): bound the fresh-offer backlog and send offers without copying - #233
Open
mickvandijke wants to merge 5 commits into
Open
mickvandijke wants to merge 5 commits into
mickvandijke wants to merge 5 commits into
Conversation
Under sustained client writes on a real network, nodes grew to 1-2 GiB within an hour. Heap profiles on the testnet attribute ~80% of live memory to encoded FreshReplicationOffer messages queued in the fresh-write drainer: every accepted PUT was encoded immediately (chunk plus proof, ~4-5 MiB) and its per-peer send tasks then waited for one of MAX_CONCURRENT_REPLICATION_SENDS (3) permits while pinning that buffer. When WAN sends hold permits longer than writes arrive, nothing bounded the backlog, so the number of encoded offers kept growing. - FreshWriteEvent no longer carries the chunk bytes; the chunk is on disk already and the drainer reads it back when it is ready to send. - The drainer acquires a pending-offer permit (MAX_PENDING_FRESH_OFFERS) before reading and encoding, and the permit lives with the encoded buffer until the last per-peer send drops it. A backlog now waits as small queued events instead of chunk-sized buffers. - The direct ReplicationEngine::replicate_fresh entry point takes the same permit so tests and callers share the bound. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two of the copies each queued fresh offer carried were avoidable inside this crate: - The chunk read from storage now moves into FreshReplicationOffer instead of being copied, and the offer is dropped as soon as it has been encoded, so only the encoded bytes stay alive while sends queue. - ReplicationMessage::encode serializes into a buffer sized from postcard's serialized_size. A doubling Vec left chunk-sized messages with up to twice their length in capacity, retained by every queued offer for as long as it waited for a send permit. The remaining copies per in-flight send live in saorsa-core (payload clone per channel attempt, signing re-serialization, wire frame) and saorsa-transport (stream buffer copy in SendStream::write). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With saorsa-core accepting `impl Into<Bytes>` on `send_message`, the encoded fresh offer is now held as `Bytes` and each per-peer send attempt hands out a reference-counted handle instead of cloning the multi-MiB buffer. Together with the exactly-sized frame and the transport's owned-buffer write, an in-flight send now costs one frame instead of the previous four copies. Adds ADR-0016 describing the bounded fresh-offer backlog and the copy-free send path across ant-node, saorsa-core and saorsa-transport. Pins: ant-protocol ac25b717, saorsa-core e1f1ef92, saorsa-transport 3bd451d2 (all on perf/replication-send-path). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PaidNotify carries the paid-list evidence the paid close group needs to repair a key later. Since the pending-offer permit was introduced it was sent from replicate_fresh, i.e. only after the drainer had waited for a permit, so a chunk backlog also delayed the evidence. Send it as soon as a write is dequeued (and from the direct replicate_fresh entry point), before any permit wait; only the bulk chunk offers are back-pressured. Nothing is dropped by the permit: the fresh-write queue is unbounded and FIFO, permits are released whenever a send terminates, and each offer keeps the same fan-out, retries and delayed possession check. ADR-0016 now says so explicitly and records the measured download-latency cost. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mickvandijke
marked this pull request as ready for review
September 22, 2026 15:07
… evidence Review follow-up. Sending PaidNotify before the permit wait only helped the head-of-line event: the drainer was one serial loop, so every write queued behind a blocked permit still had its PaidNotify and PaidForList insert delayed by chunk back-pressure. - The fresh-write drainer now never waits for a permit. For every event, at arrival rate, it records PaidForList(self) and sends PaidNotify, then forwards the event to a new offer dispatcher, the only stage that takes a pending-offer permit. - The dispatcher reads the chunk back with `get_raw` (it was content-checked when stored), retries a failed read up to MAX_FRESH_READ_ATTEMPTS times with the permit released in between, and skips only a chunk that is no longer stored. - The offer pipeline is one function (`dispatch_fresh_offer`) shared by the dispatcher and the direct `replicate_fresh` entry point; the 8-argument helper and its clippy allow are gone. - Chunk-carrying protocol fields are encoded as byte strings (`serde_bytes`), which has the same postcard layout as a u8 sequence (unit-tested) but sizes and serializes in one memcpy pass; an oversized body is now refused before anything is allocated. - PaidNotify shares one `Bytes` buffer across its recipients. - A new e2e test drives the PUT pipeline through the real channel with a missing-chunk event queued ahead of a real one; the harness keeps the fresh-write sender so the drainer stays alive in tests. Pins: ant-protocol 79a68a80, saorsa-core 9baba9c5, saorsa-transport 0f55bfc7 (all perf/replication-send-path). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
Summary
Under sustained client uploads on a 60-node testnet, nodes grew to 1–2 GiB within an hour. Heap profiles attributed ~80% of live memory to encoded
FreshReplicationOffermessages queued in the fresh-write drainer: every accepted PUT was encoded immediately (~4–5 MiB) and its per-peer send tasks then waited for one ofMAX_CONCURRENT_REPLICATION_SENDS(3) permits while pinning that buffer, with nothing bounding the backlog.Five commits:
fix(replication): bound encoded fresh offers waiting behind send permits —
FreshWriteEventcarries only key + proof; the drainer takes aMAX_PENDING_FRESH_OFFERS(8) permit before reading the chunk back and encoding, and the permit lives with the encoded offer until its last send finishes.perf(replication): cut copies of fresh offers on the send path — the chunk moves into the offer instead of being copied;
ReplicationMessage::encodeserializes into an exactly-sized buffer (a doublingVechad left chunk-sized messages with up to 2× capacity retained while queued).perf(replication): share fresh offers with the transport as Bytes — with saorsa-core accepting
impl Into<Bytes>, each send attempt hands out a reference-counted handle; together with the exactly-sized frame and the transport's owned-buffer write, an in-flight send costs one frame instead of four copies. Adds ADR-0016.fix(replication): send PaidNotify before waiting for an offer permit — first step of decoupling the paid-list evidence from chunk back-pressure.
fix(replication): two-stage fresh replication with un-gated paid-list evidence (review follow-up) — the fresh-write drainer now never waits for a permit: for every event, at arrival rate, it records
PaidForList(self)and sendsPaidNotify, then forwards the event to a new offer dispatcher, the only permit-gated stage. The dispatcher reads the chunk back withget_raw(the chunk was content-checked when stored), retries a failed read up toMAX_FRESH_READ_ATTEMPTStimes with the permit released in between, and skips only a chunk that is no longer stored. Chunk-carrying protocol fields are encoded as byte strings (serde_bytes; identical postcard layout, unit-tested), so sizing and encoding are single memcpy passes and an oversized body is refused before allocation.PaidNotifyshares oneBytesbuffer across recipients. The offer pipeline is one function used by both the dispatcher and the directreplicate_freshentry point. A new e2e test drives the PUT pipeline through the real channel, including a missing-chunk event ahead of a real one.Nothing is ever dropped by the permit: both queues are unbounded and FIFO, permits are released whenever a send terminates, and every offer keeps the same fan-out, retries and delayed possession check — only the bulk chunk transfer is deferred under load.
Pins: ant-protocol 79a68a80, saorsa-core 9baba9c5, saorsa-transport 0f55bfc7 (all
perf/replication-send-path).Linear issue
Closes V2-TBD
Risk tier
Compatibility
FreshWriteEventdrops itsdatafield andfresh::replicate_freshtakes the chunk by value plus a pending-offer permit (crate-internal callers);ReplicationEngine::replicate_freshis unchanged.Semver impact
Test evidence
cargo test --lib -- replication: 591 passed (includes the exact-capacity encoding test and theserde_byteswire-equivalence test).cargo test --features test-utils --test e2e -- fresh replication paid: 39 passed, including the newfresh_write_pipeline_replicates_queued_writes_and_skips_missing_chunks.ant-testnet/state/comparisons/web-support-memory-diag-0921/.-D warningson the whole crate trips a pre-existingDurationlint insrc/storage/migration_signal.rswith newer clippy (unrelated).Testnet comparison, 2026-09-22 (this stack vs current
web-support). Two 60-node DigitalOcean fleets with an identical layout (8 regions, 18/60 nodes behind symmetric NAT), 4 native uploaders (50 MiB files, own wallets) + 1 continuous sha256-verified downloader each, started together and measured for 60 minutes. Baseline: ant-node50167d39+ ant-client04c64fa. New: ant-nodef55b7df8, saorsa-coree1f1ef92, saorsa-transport3bd451d2, ant-protocolac25b717, ant-clientf11c557(pins only, so the client code is identical on both sides).The baseline's warnings are the unbounded replication fan-out at work: 17,774 dial failures, 2,795 "Paid notify dropped at admission — paid-list evidence lost", 1,877 possession-probe timeouts, ~2,800 channel-send failures. The new stack logged no paid-notify drops, 134 dial failures and 286 probe timeouts. The 8% slower downloads on the new stack are the expected cost of deferring replication under load: every download fetched a file uploaded within the previous minute, which now has fewer replicas at that moment (
MAX_PENDING_FRESH_OFFERStrades memory for burst absorption). Raw data:ant-testnet/state/comparisons/web-support-send-path-vs-base-60m-0922/.New dependency
serde_bytes 0.11(already present in the dependency graph transitively; now a direct dependency for the byte-string encoding of chunk payload fields)ADR
https://github.com/WithAutonomi/ant-node/blob/perf/replication-send-path/docs/adr/ADR-0016-bounded-fresh-offers-and-copy-free-sends.md
Mitigation / rollback
Revert the branch (or re-pin the three git deps to 1764950d / 02dd65fc / 3ed66a9c). Wire format is unchanged, so a partial rollout or rollback is safe. If the pending-offer cap ever throttles replication too hard, raising
MAX_PENDING_FRESH_OFFERStrades memory for burst absorption without touching the wire.🤖 Generated with Claude Code