fix(gateway): collect the tombstones the cluster has finished with - #1099
Merged
Conversation
kvinwang
force-pushed
the
fix/gateway-kv-tombstone-gc
branch
6 times, most recently
from
August 23, 2026 06:17
50e4627 to
4e672e8
Compare
kvinwang
force-pushed
the
fix/gateway-kv-tombstone-gc
branch
from
August 23, 2026 06:26
4e672e8 to
c0dd80b
Compare
Nothing in this repository ever called wavekv's tombstone collector, so every deleted KV record — one `inst/` tombstone per deregistered CVM, plus one per operator override the delete withdraws — stayed on disk for the life of the deployment, riding in every sync digest and every full re-exchange on every node. Run `collect_tombstone_garbage` on both stores, beside the periodic persist and WAL-sync tasks. Safety comes from wavekv's ack watermark, not from this trigger: a tombstone goes only once every known peer has acknowledged the delete, at which point no peer holds the live record and nothing can resurrect it. (v1's TTL API was removed in 2.0 for exactly that resurrection.) The trigger still has to land in the same window cluster-wide: the state digest covers tombstones — it must, or it could not catch a replica that lost one — so a node that has collected and one that has not report different digests while both are correct, and wavekv's divergence detector answers with a full re-exchange that reinstates the tombstone for the next cycle to drop again. Free-running or per-node timers pay that forever. So collect once every `tombstone_gc_writes` replicated writes (default 10000, zero disables), per store. The trigger is a pure function of the replicated write count — the sum of every origin's ack watermark — which converges on every node within a sync round, so the cluster crosses each boundary together without any node reading a clock; a wall-clock-aligned tick would hold only while every host's clock stayed stepped-together. The cadence also follows write volume, which is what produces tombstones in the first place. The pace must be one number cluster-wide, so an operator override lives in the KV itself (`SetTombstoneGcConfig`/`GetTombstoneGcConfig` admin RPCs, `global/tombstone_gc_config`): it replicates to every node and takes precedence over the per-node config-file default. A corrupt override fails closed — the round is skipped rather than collected on the local default, which would be exactly the phase drift the shared pace exists to prevent. A node that has not collected since the task started treats the next check as due, so a single-node gateway — which has nobody to resurrect from, and where nothing else would ever collect — and a store that never writes again both shed the backlog they already hold.
kvinwang
force-pushed
the
fix/gateway-kv-tombstone-gc
branch
from
August 23, 2026 06:31
c0dd80b to
83fad87
Compare
kvinwang
marked this pull request as ready for review
August 23, 2026 06:33
This was referenced Aug 23, 2026
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.
Problem
Nothing in this repository ever called wavekv's tombstone collector. Every delete leaves a tombstone, and they are never removed:
wavekv 2.1 says the same thing from its side, on
collect_tombstone_garbage:So a gateway accumulates, permanently and on disk, one
inst/tombstone per deregistered CVM — plus one per operator override the same delete withdraws. They are replicated state: each one also rides in every sync digest and in every full re-exchange, on every node. A gateway fronting a fleet that churns CVMs pays this for the life of the deployment.Fix
Run
collect_tombstone_garbageon both stores, spawned beside the periodic persist and WAL-sync tasks — the same set of work the store needs on a schedule whether or not this node has peers. A single-node gateway has nobody to resurrect from, so wavekv collects every tombstone it holds there, and it is the deployment where nothing else ever would.Safety comes from wavekv's ack watermark, not from the trigger: a tombstone goes only once every known peer has acknowledged the delete, at which point no peer holds the live record and nothing can resurrect it. The trigger only decides when each node stops to scan — which is a cluster-coordination problem, not a correctness one, and is the part worth reviewing.
The trigger counts replicated writes; no node reads a clock. The state digest covers tombstones — it has to, or it could not catch a replica that lost one — so a node that has collected and a node that has not report different digests while both are correct. wavekv's detector reads that as silent divergence after
digest_check_roundsquiescent rounds and forces a full re-exchange, after which the laggard's tombstone comes back and the collector drops it again next cycle. wavekv documents this oscillation as an accepted cost and does not fix it, so the embedder has to keep the window in which nodes disagree small.Free-running per-node timers put two nodes half an interval apart on average — far past that threshold. A wall-clock-aligned tick (this PR's first shape) closes the window, but only while every host's clock stays stepped-together: alignment computed once at startup is undone for good by a later NTP step, e.g. a gateway starting before chrony's first sync. So the trigger is instead a pure function of replicated state: collect once every
tombstone_gc_writesreplicated writes (default 10000, zero disables), per store, where the count is the sum of every origin's ack watermark. That count converges on every node within a sync round, so the cluster crosses each boundary together — the window is one check period plus one sync round, with no assumption about time. The cadence also follows write volume, which is what produces tombstones in the first place: the busy ephemeral store collects roughly hourly, the quiet persistent one much more rarely.A node that has not collected since its task started treats the next check as due, so a restarted node and a store that never writes again both shed the backlog they already hold. In a cluster that backlog collection runs alone, so anything it frees comes back via one digest repair and is shed for good at the next shared boundary -- one bounded repair per restart.
Peer removal resets the baseline.
RemovePeerdrops the removed origin's ack watermark, so the write count steps back permanently -- a baseline earned before the removal would gate collection until the cluster re-earns writes it no longer remembers, which on a quiet store means never. The task watches the peer set and starts over from the backlog case when it shrinks; removal replicates, so every node's reset lands within a sync round of the others. (Transient dips from digest repair are just waited out -- retransmission restores them.)The pace is one number cluster-wide, stored in the KV
Nodes collecting on different boundaries are back to collecting on their own phase. So the config-file value (
core.sync.tombstone_gc_writes) is only a default: an operator override stored in the KV itself (SetTombstoneGcConfig/GetTombstoneGcConfigadmin RPCs,global/tombstone_gc_config) replicates to every node and takes precedence. A corrupt override fails closed — the round is skipped rather than collected on the local default, which would be exactly the phase drift the shared pace exists to prevent.Why there is no TTL
The obvious shape for this knob is a tombstone TTL. That API existed in wavekv 1.x as
cleanup_expired_tombstones(ttl)and 2.0 deleted it, naming this repository's failure mode:The replacement gates on coverage instead of on time: a tombstone authored by origin
nat seqsmay go only once every known peer reportspeer_acks[p][n] >= s. The supporting invariants are what make that safe rather than optimistic:acks[n]is "the largest seq of originn's writes this node provably covers", not an estimate.peer_acksis documented as "volatile, and safe to lose or under-report" — losing one delays collection, never advances it.So a freshly written tombstone is collected only when every peer has already applied the delete — at which point no peer holds the live record and there is nothing to push back. A TTL is strictly weaker: it drops the tombstone on a guess about elapsed time, and a peer offline longer than the TTL resurrects the record.
tombstone_gc_writestherefore trades disk against how often each node stops to scan its data map. It has no bearing on correctness.Verification
cargo test -p dstack-gateway— 280 passed (after rebasing ontonext, which grew the suite).Nine new tests:
a_single_node_collects_the_tombstones_it_has_finished_withload_all_instancesreturning empty; collection removes ita_tombstone_a_peer_has_not_acknowledged_is_kepta_tombstone_is_collected_on_coverage_not_on_ageKvStores exchanging real sync envelopes: the peer holds the live record, the delete is not collectable, after the round trip it is, and a further round trip does not bring it backtombstone_collection_triggers_on_write_count_boundaries_not_on_timethe_replicated_write_count_advances_with_writes_and_converges_between_peersremoving_a_peer_permanently_lowers_the_replicated_write_countRemovePeerdeletes the removed origin's watermark from the count — the permanent regression the baseline reset exists fora_removed_node_returning_with_old_state_resurrects_collected_deletesa_tombstone_gc_override_is_absent_by_default_and_replicatesa_corrupt_tombstone_gc_override_does_not_read_as_the_defaultOpen questions for review
Is
10000writes the right default pace? Any value is correct; it trades disk against scan frequency. On the busy ephemeral store it works out to roughly hourly under a ~100-instance fleet; on the quiet persistent store, much rarer — which also means the persistent backlog mostly drains via the collect-on-start path rather than boundary crossings.I could not construct a resurrection through a supported path.Answered: there is one, and it is now pinned by a test. The construction is a node removed viaRemoveNodethat later returns with its old data directory. Removal vacates the watermark, collection drops the tombstone, and the returning node still holds the record live. Ordinary rounds do not leak it back — its request re-teaches the responder coverage of its origin, so nothing it authored is re-sent. What completes the route is wavekv's own divergence repair: the digests disagree for as long as the zombie exists (no tombstone and no ack filter can reconcile them), so afterdigest_check_roundsthe repair firesreset_peer_coverageon the returning node and its next request is a full dump, which merges the record back as live — a deregistered CVM re-entering every node's WireGuard config.a_removed_node_returning_with_old_state_resurrects_collected_deletesdemonstrates both halves.Two consequences. Operationally, removal must mean decommission: a removed node's data directory must never come back (note that a merely-restarted node re-registers itself via
register_peer_url, so "removed but still powered" is an accident waiting to happen; a wiped data directory or a fresh node id is safe). Structurally, the fix is to lock a removed sender out at the sync boundary — refused beforehandle_envelope, it cannot push the dump. That needs a removal marker that survives collection, i.e. a live record rather than the__peer_addrtombstone (which this very GC eventually collects); it is a self-contained node-retirement feature and lands as fix(gateway): keep a removed node out until an operator re-admits it #1100.Retired peers make some tombstones uncollectable forever. wavekv documents it:
remove_peerdropsacks[removed], and once every node has retired a peer, the watermark for tombstones that peer authored is unknowable. Out of scope here, but it means collection is best-effort, not a bound.