Repository navigation
fix: fence a node that never saw a write's fence, and decode peer images chunk by chunk - #19
Merged
Merged
Conversation
…ence A node that joins, or gaps, after a strong write was announced cannot hold that fence, so it served the key from its own index. Every event carries the writer's oldest open write; when the first one a node sees from a writer is older than that event, the node answers every key from the origin until a later floor passes it. a_joiner_that_missed_a_fence_answers_from_the_origin failed 6 of 6 runs before this change.
…e feed ring The ring is cut to one 60 kB gossip frame first: a strong write (Pending plus outcome, about 230 bytes for a 44-byte key) leaves a window of about 260 writes, against about 400 single-event writes before the fence, and even the smallest valid event stops the ring near 2,700 events. Doubling FEED_CAPACITY would change neither the gap horizon nor memory, so it stays; a test pins that the byte budget binds first.
Groupnet's bootstrap worker runs a follower's transfer steps one at a time and refreshes the donor's claim only between them. VerifyImage hashed and decoded the whole image in one step: for the 800k-row, 116 MB image of fleet_bootstrap::peer_bootstrap_transfers_a_production_scale_index that took 2.6-2.8 s under load (hash 21 ms, decode 2781 ms), past what remained of the 3 s claim, so the follower released a live donor (DonorUnavailable) and listed the origin. The same test fails that way on ae04f11, before the fence. Each chunk is now hashed and decoded as it lands, and verification only finishes both.
a_node_left_alone_is_ready asserted the node was not ready from the moment the donor latched its index, before the donor's declaration could reach it over gossip. Until it arrives the node holds the donor's earlier declaration, without an index, and is ready by design; the full suite caught that window once. The probe is now read after the declaration, as not_ready_until_indexed already reads the latch before the probe.
…y suspected donor
coconutbird
marked this pull request as ready for review
November 12, 2026 15:15
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.
Follow-ups to #18 (the pre-write fence for strong writes).
A node that never saw a fence
A node that joins, or gaps, after a strong write was fenced never holds that announcement, so it cannot fence the key.
sync::tests::a_joiner_that_missed_a_fence_answers_from_the_originreproduces it: a writer fencesk, completesm, and its acknowledgement retires the feed pastk's announcement. A joiner then listskbefore the write lands, applies the writer's next write, and gets its serve-lease. Before this change, the joiner servedkfrom its stale row: the test failed in 6 of 6 runs.The fix uses the floor that every event already carries (the writer's oldest open announcement):
This is a fence on reads that is only used in that window: a writer has a write open across the moment a node starts to see its feed. The strong_rw checker is unchanged.
Feed ring capacity
FEED_CAPACITYstays at 4096. The ring is also cut to one gossip frame (max_delta_frame_bytes, 60 kB), and that limit binds first. A strong write of a 44-byte key is a 72-byte announcement and a 149-byte outcome, so the window holds about 261 strong writes (523 events, measured). Before the fence it held about 400 single-event writes. Even the smallest valid event (18 bytes) stops the ring near 2,700 events. So doubling the cap would change neither the gap horizon nor memory per node: about 60 kB of encoded events in the node's own ring, and up to 60 kB per peer frame in its gossip state.the_frame_budget_binds_the_feed_ring_before_its_event_cappins that. The comment and the README now state the real window.The production-scale bootstrap flake
fleet_bootstrap::peer_bootstrap_transfers_a_production_scale_indexis not new. On ae04f11 (main before #18), I ran the full--test itthree times while I built and tested on the same machine. The test failed in 2 of the 3 runs, the same way: the joiner released the donor withDonorUnavailableand listed the origin. The paced builder tests also failed in those runs.Cause: Groupnet's bootstrap worker runs a follower's transfer steps one at a time. It refreshes the donor's claim (3 s TTL) only between steps.
VerifyImagehashed and decoded the whole 116 MB image in one step. Timed under load, that step took 2.6–2.8 s (hash 21 ms, decode 2,781 ms). The step that followed found the claim expired and released a live donor.Each chunk is now hashed and decoded as it lands (
index::fleet::decoder), and verification only finishes both. Alone, the joiner installed in 7,955 ms of transfer, with the decode inside it.The paced builder flake
fleet_bootstrap::concurrent_start_follower_outwaits_its_recovery_budget_for_a_paced_buildersometimes scanned the origin twice under CPU load. Probe logs showed the cause. SWIM briefly suspected the loaded donor, and Groupnet's native claim snapshot keeps only claims of Alive members. A claim refresh then read the live donor's claim as absent, and the follower abandoned the transfer. napbat/groupnet#14 (merged as e7c7b38) reads a suspected donor's present claim as a missed sample, bounded by the last claim's expiry. A dead or departed donor still aborts at once. This PR bumps groupnet to e7c7b38. No s3cache code change was needed.A second suspected cause, an offer refused with
TransferAborted(Continuity)because a member was Suspect at the donor's capture, did not appear in 20 loaded probe runs. It is unconfirmed and not changed.Verification (local)
cargo fmt --check,cargo clippy --locked --all-targets --all-features -- -D warningsandRUSTDOCFLAGS='-D warnings' cargo doc --locked --all-features --no-depsare clean.fleet_bootstraprun as load, and those 10 load runs also passed (14 of 14 tests each).cargo test --locked --workspace --all-features --test itpassed twice: 95 passed, 0 failed, 10 ignored.strong_rw::workload::a_crashed_pod_restarts_on_its_warm_tier --exactpassed 5 times in a row.