Skip to content

Running claims survive node loss: periodic snapshot replication behind a node lease - #366

Merged
CMGS merged 36 commits into
mainfrom
feat/363-snapshot-replication
Oct 18, 2026
Merged

CMGS merged 36 commits into
mainfrom
feat/363-snapshot-replication

Conversation

@CMGS

@CMGS CMGS commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Closes #363.

Builds on #364 (S3 chunked store, merged); #365 builds on this branch.

What

A pool entry gets replicate_seconds (0 = off). Every interval each running claim of the pool is checkpointed through the existing capture path (pause, memory and disk, resume, publish) as a hidden replica record, the claim's registry row is moved onto it, and the previous replica is deleted. If the node is lost, another node adopts the row once the owner's node lease has expired and restores the claim from the replica through the archive wake, memory and processes included.

  • Config: requires archive_failover and a none-lane pool (checked at load); config-owned like storage (PUT /v1/pools rejects it). Restart only: a claim is replicated by its pool's setting from the moment it is claimed, so a reload that changes it is refused.
  • Registry: new table sandboxd_nodes(cell, node, lease_until), created at start when missing. Registry.Renew is one upsert per node on the database clock. Take moves another node's row only when its owner holds no live lease, in the same statement as the epoch compare-and-set; Sweepable lists a foreign expired row only for an owner without a lease. Memberlist liveness is no longer consulted (OwnerAlive, WithArchiveOwners, ownerGone and mesh.Alive are removed); one rule covers archived rows and replica rows.
  • Writes: a replica round, and the archive of a claim that holds a replica, write through Registry.Replace, a compare-and-set on the row's epoch and owner. A row another node took or deleted refuses the write, so a round that outlived an adoption cannot recreate the row; the refusal reads the row: one this node still owns only had a stale epoch (refreshed), an absent or foreign row drops the claim and its VM. Owner-guarded upserts remain only for the first write of a row (no epoch held: the first replica, the archive of a claim with no row) and the re-arm of the owner's own tombstone.
  • Rounds journal ReplicaPending before the registry write and delete the checkpoint only when the write is refused; an unknown outcome is settled by reading the row (next round, release, archive, wake, hourly sweep, restart). The sweep, the pin set and orphan reclaim know the pending checkpoint.
  • Replica rows carry no retention deadline (a round never refreshes a hibernated claim's row, so a frozen deadline let the sweep purge it); retention starts at the archive Put or at adoption. The rows of a node that never returns stay until someone adopts them: a bounded residue of one row and up to two checkpoints per claim (the row's, plus a committed pending replica or a previous replica the node died before deleting). Those are archive checkpoints no row names, which the operator's S3 reclaim can delete.
  • Restart reclaim: an archive and replica checkpoint records the node that published it (node in its meta), and a restarting node reclaims only orphans it published, after re-reading their rows. A node that restarts with a lapsed lease can be adopted from during its own reconcile, and it must never delete the adopter's fresh checkpoint.
  • Self-fencing: a node whose renewal is overdue by 12 s (lease 15 s, renew 5 s, margin 3 s) answers 503 lease lost; retry on relay, exec, ports and preview of its replicated claims, answers 404 on /owner, closes every open stream of those claims, and destroys nothing. A claim is replicated when its policy pool replicates it, decided when the claim is added, so a stream opened before the first replica is covered. The fence is one atomic state (flag and channel). A node starts fenced; after a renewal it confirms each replicated claim with one compare-and-set Take beside the renew loop, forgets a claim another node holds (VM removed in the background), stays fenced if a claim of its own keeps refusing, and lifts the fence by compare-and-set on the state it saw, tripping again if more than 12 s passed since that renewal started.
  • Lifecycle runs through the phase-1 code: release consumes the row, then deletes replica and tombstone; hibernate keeps the row on the last replica; archive puts over the row and deletes the replica; lease expiry drops row and replica before the reap destroys the claim; restart keeps its own live row and settles what a crashed round left.

Why

Phase 1 (#353) covers archived claims. A running claim never touched the registry, so a partitioned owner that kept serving could race an adopter. The lease makes adoption and self-fencing agree on one clock.

Contract

  • A restore resumes from the last committed replica with memory and processes. Everything after it, and the connections open at the time, is lost: up to the longer of the interval and one replication pass, plus the 5 s tick, plus one more pass, since a claim can run first in one pass and last in the next. A pass covers all the node's due claims of the replicating pools with bounded concurrency and a new pass cannot start before the previous one ends, so it grows with claim count and guest memory (an interval of 30 s with a 90 s pass can lose about 185 s). Hardware measured 33.1 s and 37 s lost at a 30 s interval on the old store.
  • Never two serving copies: the fence closes 3 s before another node may adopt, partitions included, while the node's clock and the database's run at the same rate to within that margin over 12 s.
  • Availability: this chooses consistency over availability for replicated claims. A registry outage longer than the fence threshold (12 s) stops service of replicated claims on every node until PostgreSQL returns, because no node can prove ownership. Claims of pools that do not replicate are unaffected; an archived claim of a replicating pool is gated like a running one while the node is fenced.
  • Not covered: claims with volumes, and hibernated, archived and failed claims, are not replicated.

Hot-path cost

Claim path: one map lookup per claim added on a failover node. Data plane: resolving a claim of a pool that does not replicate reads one plain field and no atomic; a replicated claim adds one atomic load of the fence state and its stream one watcher goroutine. Nothing per frame. Per node: one lease upsert every 5 s and one in-memory claim scan every 5 s. Per replicated claim per interval: one capture pause, an upload of the changed chunks plus a manifest and a meta (#364), one registry read, one write, and one soft delete of the previous replica. Rounds share the maintenance budget with refill. The first round after a wake or adoption uploads the whole claim again, and that is the long pass in the loss bound. The chunks only a superseded replica names stay in the bucket until the operator's reclaim runs (docs/deploy.md → Reclaiming S3 storage); with replication, that is the fastest-growing part of the store.

Tests

Registry contract on PostgreSQL and the in-memory fake (take waits out a live lease, sweepable spares a live owner, renew extends, cell-scoped leases, upgrade adds the nodes table, replace is a compare-and-set); replication round (publish, move row, drop previous, failed capture, refused and lost put replies, a round that outlives an adoption, registry outage, due/skip rules, retire); lifecycle (release, hibernate, archive, expiry, restart variants, sweep, delete marker); self-fence (open port, passive, preview and relay streams of a replicated claim close when the fence trips, including one opened before the first replica; an unreplicated claim's stay open; new streams work after unfence), the lift race, a slow or contested confirmation, and recovery with two managers on one registry and store; config, reload (restart-only), setpools and server mappings. Each new test was mutation-checked.

Hardware acceptance

On the chunked store (stacked with #364 and #365)

Two bare-metal nodes (one cell), PostgreSQL registry, SeaweedFS 4.48, cocoon v0.7.3, replicate_seconds 30.

scenario result
B1: replication, 3 claims × 11 rounds PASS. The first round uploads 640 MiB, later rounds 304–384 MiB. A round (staging start to row commit) takes 1.8–3.1 s, against about 13.5 s on the old store; the guest pause is 67–269 ms. Record prefixes stayed at 6 (= live rows), and GET /v1/checkpoints stayed at 0.15–0.20 s
B2: node loss (SIGKILL A, VMs removed) PASS. B adopted both claims 13.3 s after the kill, once A's lease expired; the first exec after the wake took 580 ms. The running claim's counters, processes and data were restored; the hibernated claim came back from its last replica. B re-replicated both: the first round in full (640 MiB), then churn
B3: partition (registry and mesh cut from A) PASS. A's last success 06:54:38.226, then 503 lease lost; retry; B adopted at 41.384, first success 41.907 (a 522 ms wake): no overlap. The unreplicated claim on A answered 113 × 200 during the cut. After the heal, A unfenced and forgot the claim
B4: restart race PASS. After B adopted A's claims and archived one of them (meta node=b), A restarted with its old journal: it dropped both claims ("another node holds it"), reclaimed nothing, and the bucket's meta set and all 1,424 keys were unchanged. The archived claim then woke from B's checkpoint (599 ms) with its data intact

On the old store (before stacking)

Two bare-metal nodes (A, B), PostgreSQL registry, S3-compatible store, replicate_seconds 30, sandboxd at 0fe9d7d (e4d69f3 adds docs only).

scenario result
S1 node loss (SIGKILL A) PASS. B adopted 12.2 s after the kill (0.35 s after A's lease expired); guest processes and files restored from memory; B re-replicated the woken claim (two new rows, owner B), the claim was alive three intervals later, release 204 left no row and an empty bucket. A restarted and dropped its copy ("another node holds it")
S2 partition (registry and mesh cut from A) PASS. A's last success 00:33:05.717, first 503 lease lost; retry at 06.169; B adopted at 09.239, first success 16.816: no overlap. After the heal A unfenced and forgot the claim; B kept and re-replicated it, 145 calls with 0 errors
S2b silent registry drop + mesh cut PASS. A's last success 00:35:40.641, B's first 00:35:52.150
S3 registry outage, 20 s PASS. Replicated claims answered 503 from the last good renewal + 12 s until the next renewal (25 each node); unreplicated claims 57/57 OK on each node; no adoption
S4 cost and restore Guest pause per round 73-233 ms; a round about 13.5 s, 10,752 MiB uploaded (without #362); adoption 12.35 s; first exec after restore 8.0 s (full fetch without #362); 37 s of state lost at a 30 s interval, within the documented bound
client cancel during a wake logged at debug only, 0 error lines
e2e (on fb4abb5, same paths) PASS 2/2, 22.9 s / 22.0 s vs main 33.5 s / 26.6 s

A first run on fb4abb5 found that the adopter destroyed the woken claim on its next round (the wake left the consumed row's epoch on the claim); fixed in 05d49f1 with a regression test and rerun above.

Observation: a node killed mid-round leaves that round's partial upload in the store: chunks and possibly a manifest, with no meta.json. The operator's reclaim rule removes them once they age past the grace.

Review

CMGS added 26 commits October 18, 2026 15:48
Add sandboxd_nodes(cell, node, lease_until) and Registry.Renew, one upsert
per node on the database clock. Take moves another node's row only once its
owner holds no live lease, in the same statement as the epoch compare-and-set,
and Sweepable lists a foreign expired row only for an owner without a lease.
Open adds the nodes table to a database that already has the archives table.
Config load rejects it unless archive_failover is on and the pool is on the
none lane. It is config-owned like storage: PUT /v1/pools refuses it, the
pool seed hash ignores it, and a reload reports it as changed.
A pool with replicate_seconds snapshots each running claim through the
existing capture path (pause, memory and disk, resume, publish) as a hidden
replica record, puts the claim's registry row on it, then deletes the previous
replica. A failed round logs a warning and retries next interval. Rounds run
on the refill/maintenance budget and start none while the node is fenced.

Adoption now rests on the node lease alone. Take refuses another node's row
while its owner holds a live lease, in the same statement as the epoch
compare-and-set, so the memberlist ownerAlive gate, WithArchiveOwners and
mesh.Alive are gone. The adopter restores the claim from the replica through
the archive wake, memory included.

Each node renews its lease every 5 s for 15 s. A lease outlives a missed
renewal, and the fence trips 3 s before it ends (12 s after the last
renewal's start), so one lost renewal never fences while two do before any
adopter may take the row. A fenced node answers 503 "lease lost; retry" on
the relay, exec, ports and preview of its replicated claims and destroys
nothing; a node starts fenced. The first successful renewal reads the rows of
its replicated claims: rows still its own are served again, rows adopted
elsewhere or consumed by a finished adopter drop the claim and remove its VM.

Lifecycle: release consumes the row, then deletes replica and tombstone;
hibernate leaves the row on the last replica; archive puts over the row and
deletes the replica (its rollback now restores epoch 0, since the abandon
deletes the row); a lease expiry drops row and replica before the reap
destroys the claim; a restart keeps its own live row, takes the checkpoint a
crashed round left on it, and drops a claim whose row another node holds or a
finished adopter consumed; turning replicate_seconds off retires the rows.
dropMoved is folded into dropAdopted, which now removes the local VM.

Hot path: the claim path and data plane gain one atomic load. Per node, one
lease upsert every 5 s and one in-memory scan of the claims every 5 s; per
replicated claim per interval, one capture pause, upload, row read, row write
and delete.
Extend the failover section of cluster.md with the node lease, the
replication round, restore, lifecycle and self-fencing; add replicate_seconds
and the lease to deploy.md; document the lease rule on /owner and the 503
lease-lost answer in sandboxd-api.md.
A parked keep-alive relay runs later RPCs back to back, and port and preview
streams run for as long as the client keeps them, so none of them passes the
claim-resolution gate again. WakeAgentSocket now returns a fence channel that
the relay watches, DialPort and PreviewDial wrap their conns with it, and the
channel closes when the fence trips; unfence installs a fresh one. An
unreplicated claim gets none. Replicated state is an atomic on the sandbox,
so resolution takes no mutex.
A round whose Put reply is lost left the claim with no record of the
checkpoint the row now names, and deleted that checkpoint. If the partition
went on, another node adopted a row whose checkpoint was gone and the
returning owner then dropped its live VM: irreversible loss from one fault,
and on the first round the claim was not even fenced.

A round now persists ReplicaPending before the Put, deletes the checkpoint
only on ErrLost, and leaves an unknown outcome to settle: the next round,
release, archive, wake, the hourly sweep and a restart read the row. A row on
the pending checkpoint promotes it; another owner's row drops the claim; an
absent row means the adopter finished when the checkpoint is gone, and a
never-committed Put when it is still there. A claim with a pending intent
counts as replicated for the fence.
A replica row carried the retention deadline of its Put. A hibernated claim,
which no round refreshes, saw that deadline pass while its owner was
partitioned or down; another node's departed-owner sweep then purged the
checkpoint and the row, and the returning owner dropped the claim. Expired
rows were also refused for adoption.

Replica rows now have no deadline. Retention starts where the claim leaves
the node's care: the archive Put sets it as before, and adoption sets the
adopter pool's window when the row has none. The rows of a node that never
returns stay until someone adopts them.
…fence

The renew loop ran unfence inline, so long Transition waits or VM removals
there stopped renewals; the lease lapsed, an adopter took the row, and the
unconditional Store(false) then let both nodes serve.

Unfence now runs beside the renew loop, which keeps renewing. For each
replicated claim it takes the row for this node in one compare-and-set, which
the row lock serializes against adopters, retrying once on a stale epoch; a
lost row forgets the claim at once and removes its VM in the background. The
fence lifts only when all of that finished inside ttl-margin of the renewal
that started it; otherwise the node stays fenced and the next renewal tries
again.
AgentSocket had no fence check, so /owner kept answering 200 for a claim every
other call on the node answered with 503, and the Lookup scatter kept binding
to it. The node now answers 404 there and the scatter moves on; once the lease
has expired another node adopts the claim.
The pool's replicate setting is read by the claim's policy key, as
capture_trim is, so a claim cloned from a template promoted out of a
replicating pool follows that pool. A replicated claim that gains volumes
retires its replica instead of leaving an adoptable stale row. A retire that
raced an archive finds no replica left and leaves the archive row alone
(the guard is in dropReplica; this adds the tests).
…r answer

State the fence guarantee with its precise conditions, the retention rule of
replica rows, the unfence confirmation, and the /owner answer of a fenced
node; align the lease, replicate_seconds and archive_failover comments with
what the code does.
A round that outlived an adoption could Put after the adopter had woken the
claim and deleted its row; the upsert inserted a fresh row owned by the old
node, and its next renewal confirmed that row and lifted the fence while the
adopter served. Registry.Replace is a compare-and-set on the row at the held
epoch and owner (with epoch 0, the first replica, it is Put), and a replicated
claim's archive writes through it too, since the same hole applied to a row
the claim already had. A refused write reads the row: a row this node still
owns only had a stale epoch and is refreshed; an absent or foreign row means
the claim moved and its copy and VM are dropped.
…one atomic

A stream resolved before a claim's first replica got no fence channel and
survived the fence, because replicated was derived from ReplicaCk. A claim is
now replicated when its policy pool replicates it (or already holds a replica
from a journal), decided once at add time into a plain field, so an
unreplicated claim reads nothing on the data plane and a replicated one reads
a single atomic pointer: the fence state, which carries both the flag and the
channel and is replaced on trip and on lift.

replicate_seconds becomes restart-only, so the decision cannot go stale: a
reload that changes it is refused with the pool named. A node that starts
fenced lifts the fence only after its first confirmation, so the adopter's
wake of an adopted claim of a replicating pool waits for that too. An
unreplicated relay, port or preview connection no longer allocates a closure
for the fence. CloseOnFence moves above the unexported functions.
A fence trip that fired between the window check and Store(false) found the
node already fenced and did nothing, and the lift then ran past the lease with
no timer left to trip it. The lift now replaces the fenced state this renewal
saw, only if it is still current, and trips again when the window closed before
it landed; the earlier window check goes, since the lift carries it.
…streams

The fence state is initialised in every manager and a claim is replicated by
pool policy, which needs archive_failover, so a journal that still holds
replica_ck after failover was turned off no longer reaches a nil fence.
…g checkpoint

An hourly sweep that reached reconcileOwnRow while a round's Put outcome was
unsettled saw a row on a checkpoint the claim did not name and deleted both;
the settle then found the row absent with a held epoch, took the claim as
moved and destroyed a running claim no other node held. The pending checkpoint
now counts as referenced by the sweep, is pinned, and is skipped by orphan
reclaim at restart.
…enced

After two refusals on a row whose owner was still this node, confirmOwnership
fell through to false and unfence destroyed a claim it owns. It now returns an
error, so the node stays fenced and the next renewal retries; only an absent
row or another node's row drops the claim.
…d align the docs

forgetAdopted logged under pool.dropAdopted and a var pair used a tuple
declaration. The docs now state which registry writes are compare-and-sets and
which stay owner-guarded upserts, the loss bound as the time since the last
successful round, the 404 of /owner on a fenced node, the fence as decided by
pool policy with its real hot-path cost, replicate_seconds as restart-only, and
that an open relay of a replicating pool's claim closes whether or not it has
a replica yet.
A client that cancels while its request wakes a sandbox made wakeGuest log
the failed dial at error level although the wake completes. A dial that fails
with context.Canceled while the request context is canceled is the client
leaving and logs at debug; every other dial failure stays an error.
…a left at 0 costs

A reload that changes replicate_seconds is refused, so the failover docs no
longer say a reload turns it off. A claim that holds a replica when the node
restarts with replication off stays replicated for the fence until the next
restart: during a registry outage it answers 503, an availability cost only.
At a 30 s interval hardware lost 33.1 s: a round starts up to an interval plus
the 5 s tick after the previous one and commits only when its capture, upload
and registry write finish. State that bound instead of the interval alone.
wakeArchived consumes the row at the epoch it took, but commitWake left that
epoch on the claim. The first replica round of the woken running claim then
wrote through Replace at the consumed epoch, found no live row, and the
refused-write settle destroyed the claim: every adopted-and-woken claim, and
every archive-then-wake, in a replicating pool. commitWake now clears the
epoch with the checkpoint, as consumeArchive does, and its rollback restores
both. With no epoch held the round upserts, which also revives this node's own
tombstone when its delete failed.
CMGS added 9 commits October 18, 2026 15:48
A claim is due when its last round started an interval ago, and a new pass
cannot start while the previous one runs, so the gap between two round starts
is the longer of the interval and one pass. State the bound with that term.
Reconcile runs before the lease loop, so a node adopting during it can move
a claim's row and commit its own replica after the start-of-reconcile read.
The reclaim now reads the candidates' rows right before deleting and skips
one whose row names the checkpoint or belongs to another node; a failed
read skips the reclaim until the next restart.
A round uploads only changed chunks and soft-deletes the previous replica, so
its unique chunks live through the store grace plus a sweep. The first round
after a wake or adoption is a full upload. A dead owner can also leave a
committed pending or previous replica, whose live metas pin their chunks.
sandboxd no longer sweeps the S3 store, so a superseded replica's unique
chunks and a dead owner's replica records stay until the operator reclaims
them.
The checkpoint meta records the publishing node for archive and replica
captures. The restart reclaim skips a checkpoint another node published, which
closes the window where an adopter has published but not yet registered its
checkpoint when the re-read finds no row. A meta without a node, published by
an older build, is skipped too: crash residue from an older build leaks rather
than risking a delete.
A second slow confirmation can start before the delay is removed and holds the fence for up to one lease TTL; the old wait of two renew periods made the test flaky (1 in 1800 runs under load, synctest deadlock on exit).
@CMGS
CMGS force-pushed the feat/363-snapshot-replication branch from 3a60473 to d882187 Compare October 18, 2026 07:52
@CMGS
CMGS changed the base branch from feat/362-s3-chunks to main October 18, 2026 07:52
@CMGS CMGS closed this Oct 18, 2026
@CMGS CMGS reopened this Oct 18, 2026
The teardowns were queued until every replicated claim was confirmed, so a registry error on a later claim returned early and dropped the queue; the forgotten claim's VM, already off the claim table and journal, then ran until the next restart.
@CMGS
CMGS merged commit 422ea8a into main Oct 18, 2026
1 check passed
@CMGS
CMGS deleted the feat/363-snapshot-replication branch October 18, 2026 09:16
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.

Running claims survive node loss: periodic snapshot replication with node leases (#350 phase 2)

1 participant