Skip to content

sandboxd: move a running claim to a named peer with cocoon live migration - #445

Merged
CMGS merged 53 commits into
mainfrom
feat/live-move
Nov 4, 2026
Merged

CMGS merged 53 commits into
mainfrom
feat/live-move

Conversation

@CMGS

@CMGS CMGS commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Implements #443: POST /v1/sandboxes/{id}/move hands one running claim to a named peer with cocoon's live migration. The guest keeps its processes and memory and stops only for the final round.

evacuate stays the cold path and never uses this. move never hibernates. If it cannot go ahead it fails fast, cleans up, and leaves the claim where it was; the caller decides what next.

Needs a cocoon release containing cocoonstack/cocoon#303, which is merged but in no tag yet. MigrateCocoon is set to v0.7.7, and a dev build passes the check. It also needs the CH fork at 35b098c65260 or newer. Until a node has both, a move answers 409 and nothing else changes.

How it works

The move has its own record in the meta store (table sandboxd_moves), not an archive row. A checkpoint row says where a stored checkpoint lives; a move hands over a live guest. Only the compare-and-set code is shared, as metastore.CAS.

From To Who When
(none) pending source after the target admitted the move, before the baseline
pending final source the final round paused the guest
final committed target after the receive; the point of no return
committed started target after it journaled the start
pending or final aborted source before it resumes the guest

The rules that keep one guest:

  • The target commits only from final.
  • The source resumes only on proof: its own record, still open or aborted. A missing record or another node's means the claim moved on, so the source drops it.
  • A target with a received claim starts it on committed or started, and drops it on aborted or a missing record.
  • Only the source deletes a record, and only an ended one.
  • An abort runs in the order aborted, resume, target drop, journal clear, record delete.
  • Before the final round a replicated claim's row is consumed, its fields cleared and journaled, then the row deleted. No node adopts a tombstone, and a source cut off from the registry changes nothing, so its fence still finds a peer that adopted the claim. The target's first round puts a new row.

Counting. The target resolves the tenant, then admits a move like a new claim (node max_claims, max_running, tenant cap, memory_limit, move_concurrency) and holds a reservation until its commit swaps it for the claim under one lock. The source counts the claim until it drops it, so the cluster counts a moving claim twice, which can refuse earlier but never over-admits.

Config and API. move_concurrency (default 2, reloadable) counts both directions. max_downtime_ms is a request field, default 2000, floor above 400. Moves need meta_store with a cell, which holds the record, and mesh.cluster_key. They do not need archive_failover or an S3 checkpoint_store: the rounds go straight to the peer. Usage gains move_in and move_out.

Limits

  • Refused with 409: egress lane, volumes, hibernated, archived, failed. The egress lane may come later: a receive leaves the guest paused, so the tap can be locked first, but the guest's network recovery with new MACs is unchecked.
  • Both nodes need the same path strings in cocoon's root_dir and run_dir. A common symlink to different disks works. Different strings fail at the receive, after the pause, with a 502, and the guest resumes on the source.
  • Same-model nodes: cocoon did not test TSC across hosts of different frequency.
  • Connections do not survive; the client meets a 404 on the source and runs Lookup.
  • docs/cluster.md lists the rest: a committed record whose target never returns stays as one row, and without sync_claims a host crash can roll back the journal, after which the source drops the claim.
  • A crash or lost reply right after the row is consumed leaves the source's own tombstone. The guest is safe, but replica rounds are refused until the hourly sweep puts the row back on the replica, so a node loss in that gap loses the claim. S35 hit this with a SIGKILL in the 1 ms window: the row stayed consumed through 15 refused rounds and one restart. The unit test shows the sweep restoring it; it did not fire on hardware.
  • A capture killed before the series id is journaled can leave the guest paused, the same window pool: accepted windows from the 2026-11 audit, with reopen triggers #433 records for hibernate.

Hot path

A claim gains len(m.incoming) in the claimed and running checks, under the lock it already holds; the tenant count loops only when a reservation exists. BenchmarkQuotaErr: 23.46-23.69 ns/op with none, 23.25-23.47 ns/op with two. No warm claim A/B was run on hardware.

Verification

Head 4a17e6a6, on main a08f5529. Gates ran on this head:

lint 0-issues lines: 10
gotest rc=0
asl -forwarder=false rc=0 on sandboxd, darwin and linux; the other modules rc=0 with forwarder on
moves/pg, archives/pg ok against PostgreSQL 17
rust darwin silkd 161/0/3, linux 162/0/3; boot/init darwin 15/0/0, linux 17/0/0
py sdk/python 278, sdk/openai 10, sdk/langchain 15

Hardware: two bare-metal nodes over a 57 MB/s link, cocoon master-3b885b8, CH 35b098c65260, a common path string through a symlink.

Run, tree What Result
S35, 4a17e6a6 replicated claim, both ways 200, 3 rounds, downtime_ms 237 and 272; consumed, then row deleted 1 ms later, then final 19 ms after that; new row 32.9 s after the answer
S35 SIGKILL target at final, replicated 502, aborted, guest intact on the source, new row and replica 22 s later
S35 SIGKILL source after the row delete; after the consume claim kept and guest intact both times; row back 22 s later after the delete; stuck consumed after the consume (see limits)
S35 PostgreSQL cut from the source only, past the lease, during a move the target node adopts at +12.8 s and serves; on restore the source drops the claim and removes its VM within about 1 s; one guest serves
S34, 759047d1 no archive_failover, no S3: config load, one move each way loads, refuses move_concurrency without a cell or cluster_key; 200, 3 rounds, downtime_ms 367 and 279
S34 same config, SIGKILL target at committed, SIGKILL source at final intact on the target, started; resumes on the source, aborted
S34 tenant at its cap on a target with a cold tenant cache 429 in 53 ms, guest intact on the source, no residue; at cap 2 the same move answers 200
S33, 5ab54e7f one move each way 200, 3 rounds, downtime_ms 257 and 227
S33 SIGTERM source in rounds, exit 5.6 s restart writes aborted, guest intact on the source
S33 SIGTERM source at final, exit 0.8 s the in-flight move finished, one guest on the target, no cold boot
S33 SIGTERM target in rounds, exit 5.6 s 502 at 7.7 s, guest intact on the source, target clean after restart
S33 SIGTERM target around its commit, 12 tries every claim intact, no restart, no failure; see below
S32, c2485d3e idle guest, both ways, sparse 10 GiB disk 200, 3 rounds, downtime_ms 172 and 221, about 14 s per call
S32 8 MiB/s fsync writer, both ways 200, downtime_ms 194 and 234
S32 hot writer (300 MiB loop), default budget, 3 runs 503 at about 41 s, never went final, guest intact
S32 VMM SIGKILL during rounds 409, no cold boot until the move ended, no false success
S31, 70911ac5 SIGKILL target at committed, 2 runs claim intact on the target, record reaches started
S31 SIGKILL source at final and at committed resumes on the source; drops, guest on the target
S31 PostgreSQL stopped after final 503, paused on both; on return exactly one guest runs
S31 fork, hibernate, PATCH volumes during a move 503 in under 2 ms
S30, d8d1ac8d both nodes killed at committed, both restart orders guest intact on the target
S30 restart with the claim journaled and the VM running, 3 runs vm start harmless, guest intact
S29, 25e1f08e counts on both nodes, refusal at max_claims, replicated claim's row match the table; 429 in 12 ms with no residue; row gone before final, new row after one interval

A client running exec every 20 ms saw a longest gap of 352 ms against a downtime_ms of 280 (S29).

Rerun decision after rebasing on #444. #444 makes sandboxd wait for running cocoon commands on stop and refuse new ones after that, and every earlier fault leg used SIGKILL. So S33 ran graceful stops on the rebased tree. The rows from S29-S34 did not rerun on this head; the commit since S34 changes only the replicated claim's row handling, which S35 ran.

One interplay with #444 is fixed here but not proved on hardware. A target stopping between its commit and its vm start used to read the refused command as a guest that cannot start, and failed an intact claim. It now keeps the journaled claim for its restart, and TestMoveTargetStoppingBeforeItsStartKeepsTheGuestForItsRestart fails without the fix. S33 hit that window 0 of 12 times: stop waits 5 s for the commit handler before the engine refuses commands, so the start ran first each time. Two routes could reach it and neither ran: a stop under about 0.6 s after a restart with a journaled claim, and a stop about 5 s before a receive ends.

Not run on hardware: lost replies on the row consume and delete, the hourly sweep restoring a stuck row (unit tests), the refused start above, a committed record whose target never returns, a same-source retry racing an old abort, a chain of three nodes (those are unit tests), the hourly sweep (it did not fire in a run), and a reload of move_concurrency under load. A real egress or moved-guest network path is out of scope with the lane refused.

Two adversarial reads and hardware together found real bugs on the way, including one that lost the claim on both nodes and one that resumed a guest the target was running. Each fix has a regression test that fails without it, and the safety rules above were mutation-tested.

CMGS added 30 commits November 4, 2026 22:14
cocoon's hypervisor rejects ondemand for a series with deltas.
…e archive registry

Moves need archive_failover and an encrypted mesh; a node that sets move_concurrency without them refuses to load, one that leaves it unset just cannot move.
The target admits the move like a new claim and holds a reservation that quotaErr and runningErr count until its commit swaps it for the claim under one m.mu. The source journals the move, sends the rounds, drops a replicated claim's row before the final round, and resolves any failure through the move record before it resumes the guest.
A source with move_out keeps the claim gated until the record answers: committed drops it, pending or final is aborted before the guest resumes. A target with move_in starts the received VM only when the record committed to it, and keeps only those claims' staging dirs.
…s two minutes

A target's hourly sweep deleted a committed record older than an hour. A source that came back after that read the missing record as an aborted move and resumed its guest while the target ran the claim. Now a target only aborts stale pending or final records.

The final capture, its transfer and the commit ran with no deadline, so a hung target kept the source's guest paused until TCP gave up. They now share a two minute budget, after which the source resolves the move through its record.
…cord

A committed record is the proof a target needs to start its guest and a source needs to drop its claim. Overwriting it let a later move erase that proof while either side still depended on it.
…resumes only on proof

A source deleted the committed record as soon as it dropped the claim, and a target holding MoveIn read the missing record as an abort and removed the only guest, after a lost commit reply or a crash before its journal landed. A source also read a missing record, or another node's, as an abort, so one back from an outage resumed a claim that had moved on.

The target now clears MoveIn before it writes started, and the source deletes only started and aborted records, so no record goes while the target still needs it. An abort writes aborted, resumes, drops the target, clears the journal and only then deletes, so a source holding a journal never finds its own abort missing and may read a missing record as a move. A target holding a claim on a pending or final record writes aborted instead of leaving it, because a commit whose reply was lost could still land after its read.
Both succeeded on the source while the guest was paused for the final round, after or beside the record the target received, so the write vanished with the source's claim. The final flag and the claim snapshot are ordered by m.mu, and a write that holds Transition lands before the move can take it.
…at outlives its reservation fails

The reservation was touched only around a round, so a round over two minutes lost it mid-stream. The second touch ignored the loss, so the round answered 204 and left files in a staging dir nothing owned, and the commit failed after the guest paused.
… removed VM

MigrateAbort failed on the VM the failover removed, so the retry loop never ended and kept a budget slot and Transition. With the claim gone there is no guest to resume.
A target that stopped reading kept a non-final round waiting until TCP gave up, with the source's slot held. A round now has one minute plus its size at 10 MB/s, and the final round stays under the two minute budget of the final phase.
… claims

resumeMoves started goroutines that drop claims while Reconcile still read m.claimed without its lock, which the race detector caught under load. Resuming last keeps Reconcile's pass single threaded. The restart test now waits for the VM removal that follows the claim's drop, and lossyMoves keeps its unexported method below the exported set.
… at once

Such a budget can never fit, so an idle guest copied for 21 s on hardware before the 503.
After a source crash the move answered 409 until the reservation idled out: 409 at +13 s and 200 at +183 s on hardware. A source runs one move of a claim at a time, so an admission with the same from replaces the reservation and resets the staging dir. A different from still answers 409.

Replacement lets an old stream or commit outlive its reservation, so a late round no longer removes the retry's staging dir, and a commit swaps only the reservation it started from.
When the commit answer was lost the 200 carried downtime_ms 0 on hardware. The pause start now lives on the move, and both paths read it at the moment the target's answer came back or was lost.
On hardware a SIGKILL of the target right after its commit lost the claim 2 of 2 while the call answered 200. The test covers that sequence: the source finishes, the record stays committed, and the restarted target starts the guest.
While the registry was down settleMoveIn slept with Transition free and nothing gated a received claim, since the gates read the moving map, which is empty on a target. The idle sweep hibernated the paused guest, and startMoveIn then cold-booted it with HibernateSnap still set. Captures, hibernates, reaps, evacuation, release, renew, env writes, dials and wakes now treat MoveIn like a move in flight; operator dials and instance metadata answer ErrPaused as they do for any paused guest.
…ts next move

A committed record whose target was lost, or whose started write failed, answered 409 to every later hot move of the claim, forever, even from a node that adopted it. Holding the claim with no MoveIn and no journal of its own proves the target already started it, so Begin's refusal now advances the record to started and begins again, once.
The hibernate gate already refused it, so the sweep's own skip showed only as a sweep blocked on Transition.
CMGS added 18 commits November 4, 2026 22:14
url.Parse reads node-1 in node-1:8080 as a scheme, so Admit, Commit and Drop skipped the http prefix and answered 502 on a hostname advertise_addr, which docs/deploy.md allows. The client now uses the peer package's URL.
…er side

checkVolumeRunLocked had no move gate, so PATCH volumes passed during the rounds and on a received claim, attaching a disk cocoon's capture refuses or reaching a paused guest.
A VMM that exited during the rounds was cold-booted while the move went on with the old series, so the move could commit a guest that had lost its memory. Liveness, like the replica round, now reads one predicate: running-shaped, not archiving, not moving. The move fails on its next capture, and liveness handles the exit once the move has ended.
A hot writer on hardware took 66 s to reach its 503. From the second delta, one not smaller than the delta before it and still too large cannot converge, so the move ends there.
Hibernate, fork, promote and checkpoint took Transition and so waited out the final round before answering. lockTransition refuses a claim mid-move, locks, and checks again.
…guard markMoveStarted

moveBlocker reads midMoveLocked, env writes read pausedForMoveLocked so a writer never reaches a paused guest, dialPassive and SetInstanceMetadata read runningShaped, resumeMoves calls retryMoveOut, and the reap tests the move gate after the deadline.
MoveIn becomes a marker, the source node is logged from the record or the reservation. MoveOut shrinks to its epoch and series and embeds in outMove. MigrateRound drops the round and paused fields nothing reads, the source stops scrubbing a field the target scrubs, and engine/migrate.go loses an err shadow.
peerCall takes a body and a timeout, zero leaving the deadline to the caller. The round route answers 204, so SendRound now accepts 200 and 204 rather than any 2xx.
…re Reach and dialEgress

The blob delete no longer runs under Transition while the guest is paused. On a crash in between, Reconcile's orphan reclaim finds it through the loaded claim.
A round lists a sparse disk at its apparent size, so the baseline counted about 11.8 GB where 1.9 GB crossed the wire. On hardware the 2000 ms budget predicted 1.84 s and paused for 10.8 s, 3 of 3. SendRound now returns the bytes it wrote, and the rate and the last round's size come from them. The round's deadline stays on the listed size, an upper bound known before the send.
Hardware's hot guest shrank each delta a little, 519 MB then 513 MB, so the not-smaller test never fired and a budget of 1000 answered 503 only at 61.6 s. Wire bytes, from the second delta.
A VMM that died mid-move answered 500, which the documented list lacks.
…tart

Since #444 a cocoon command asked for after Close answers an error. A target stopping between its commit and its vm start read that as a guest that cannot start, and failed a claim whose guest was intact, so its next start cold-booted it without its memory. The journal now keeps the claim and the restart starts the guest.
CMGS added 4 commits November 4, 2026 22:49
A delete whose reply was lost, or a crash right after one, left the claim naming a row that was gone. The next replica round or the restart read that as another node holding the claim and removed the only guest while the target had not committed. With the fields cleared first, the claim names no row whatever the delete did: the restart deletes a row it still finds, and the next round puts a new one.
The admission reaches the target on the root route, so no token resolve fills the tenant cache. A tenant the cache had not seen read as uncapped, so a move could pass the tenant's max_claims, and a removed tenant was admitted too.
…t archive_failover

archive_failover carries an S3 checkpoint store and a node lease for restoring archived and replicated claims. A move sends its rounds straight to the peer and uses neither; it only needs PostgreSQL for its record. Tying the two kept nodes without S3 from moving at all.
…elds

Clearing the fields first made a claim forget its row when the delete never ran. A peer that adopted the replica during the outage then went unnoticed: unfence only checks claims with a replica, and the next round's Put succeeded once the adopter deleted its tombstone, so both nodes served the claim. A tombstone is the positive proof: no node adopts one, a refused consume names an adoption, and until it lands the claim keeps its fields and the fence stays in charge.
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