Repository navigation
sandboxd: move a running claim to a named peer with cocoon live migration - #445
Merged
Merged
Conversation
…nd a contract suite
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.
…d and no cap refused it
…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.
…and a retried admission
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.
… move's known limits
The hibernate gate already refused it, so the sweep's own skip showed only as a sweep blocked on Transition.
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.
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.
This was referenced Nov 5, 2026
Merged
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.
Implements #443:
POST /v1/sandboxes/{id}/movehands 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.evacuatestays the cold path and never uses this.movenever 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.
MigrateCocoonis set tov0.7.7, and a dev build passes the check. It also needs the CH fork at35b098c65260or 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, asmetastore.CAS.pendingpendingfinalfinalcommittedcommittedstartedpendingorfinalabortedThe rules that keep one guest:
final.committedorstarted, and drops it onabortedor a missing record.aborted, resume, target drop, journal clear, record delete.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_msis a request field, default 2000, floor above 400. Moves needmeta_storewith acell, which holds the record, andmesh.cluster_key. They do not needarchive_failoveror an S3checkpoint_store: the rounds go straight to the peer. Usage gainsmove_inandmove_out.Limits
root_dirandrun_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.Lookup.docs/cluster.mdlists the rest: acommittedrecord whose target never returns stays as one row, and withoutsync_claimsa host crash can roll back the journal, after which the source drops the claim.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 maina08f5529. Gates ran on this head:Hardware: two bare-metal nodes over a 57 MB/s link, cocoon master-3b885b8, CH
35b098c65260, a common path string through a symlink.4a17e6a6downtime_ms237 and 272;consumed, then row deleted 1 ms later, thenfinal19 ms after that; new row 32.9 s after the answerfinal, replicatedaborted, guest intact on the source, new row and replica 22 s laterconsumedafter the consume (see limits)759047d1archive_failover, no S3: config load, one move each waymove_concurrencywithout a cell orcluster_key; 200, 3 rounds,downtime_ms367 and 279committed, SIGKILL source atfinalstarted; resumes on the source,aborted5ab54e7fdowntime_ms257 and 227aborted, guest intact on the sourcefinal, exit 0.8 sc2485d3edowntime_ms172 and 221, about 14 s per calldowntime_ms194 and 23470911ac5committed, 2 runsstartedfinaland atcommittedfinald8d1ac8dcommitted, both restart ordersvm startharmless, guest intact25e1f08emax_claims, replicated claim's rowfinal, new row after one intervalA client running exec every 20 ms saw a longest gap of 352 ms against a
downtime_msof 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 startused 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, andTestMoveTargetStoppingBeforeItsStartKeepsTheGuestForItsRestartfails 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
committedrecord 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 ofmove_concurrencyunder 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.