Skip to content

fix: fence a node that never saw a write's fence, and decode peer images chunk by chunk - #19

Merged
coconutbird merged 7 commits into
mainfrom
fix/fence-followups
Nov 12, 2026
Merged

coconutbird merged 7 commits into
mainfrom
fix/fence-followups

Conversation

@coconutbird

@coconutbird coconutbird commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

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_origin reproduces it: a writer fences k, completes m, and its acknowledgement retires the feed past k's announcement. A joiner then lists k before the write lands, applies the writer's next write, and gets its serve-lease. Before this change, the joiner served k from 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):

  • The first event a node sees of a writer's life marks where its view of that feed starts. A gap marks the same at the end of the gap.
  • If that event's floor is older than where the view starts, the writer has an open write that this node never saw. The node then answers every key, and every LIST, from the origin. It also offers no index image.
  • A later event whose floor reaches the start of the view lifts that. If the writer crashes first, the node counts it as a lease granter and serves from the origin anyway. The writer's next life is a gap, and the gap rebuilds the index.

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_CAPACITY stays 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_cap pins that. The comment and the README now state the real window.

The production-scale bootstrap flake

fleet_bootstrap::peer_bootstrap_transfers_a_production_scale_index is not new. On ae04f11 (main before #18), I ran the full --test it three 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 with DonorUnavailable and 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. VerifyImage hashed 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_builder sometimes 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 warnings and RUSTDOCFLAGS='-D warnings' cargo doc --locked --all-features --no-deps are clean.
  • The paced builder test passed 10 of 10 runs. Each run had a concurrent full fleet_bootstrap run as load, and those 10 load runs also passed (14 of 14 tests each).
  • The full cargo test --locked --workspace --all-features --test it passed twice: 95 passed, 0 failed, 10 ignored.
  • strong_rw::workload::a_crashed_pod_restarts_on_its_warm_tier --exact passed 5 times in a row.

…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.
@coconutbird
coconutbird marked this pull request as ready for review November 12, 2026 15:15
@coconutbird
coconutbird merged commit e4555e7 into main Nov 12, 2026
3 checks passed
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.

1 participant