Repository navigation
Description
cocoon now moves a running Cloud Hypervisor VM between nodes with its memory and disks (cocoonstack/cocoon#303: vm migrate capture|receive|abort). Cocoon captures and restores. The caller moves the files, picks the round count and decides which node owns the VM. This issue is that caller: a root-only move of one running claim to a named peer. It is a design for discussion; no code yet.
Cold and hot moves stay separate
Cold: POST /v1/drain?evacuate=1 (today) |
Hot: POST /v1/sandboxes/{id}/move (this issue) |
|
|---|---|---|
| What moves | every claim of a draining node | one running claim |
| How | hibernate, archive to the checkpoint store, release the row, a peer adopts | memory and disk rounds straight to the target, the target restores and starts |
| Guest after the move | stopped until the next call wakes it | running, with its processes and memory |
| Target | any peer that answers | the one the caller names |
| When it cannot move | the claim is skipped and keeps serving | the call fails |
evacuate does not change and never uses move. move never hibernates. A caller that wants hot moves calls move per running claim, then evacuate for what is left.
Decided
- Requires
archive_failover. The hand-off is a registry compare-and-set, under the existing node lease and fence. - Transfer. Peer to peer over the control port with the bearer token, like
checkpoint_peer_heal, and it needsmesh.cluster_keyin the same way. Files are opaque: sandboxd sends the files and the order each round's--output jsonlists, and reads none of them. - Fail fast. A move that cannot go ahead or fails before the hand-off aborts the series, so the source guest runs again. The target deletes what it received, and the call answers an error with the claim where it was. There is no hibernate fallback and no retry on another peer. The caller decides.
Flow
- Admit on the target. The target admits the move like a new claim and holds a reservation through the transfer (see Counting). A refusal answers the call at once.
- Rounds. Baseline, then a few deltas while each round shrinks. If the final round would exceed a downtime budget, the move aborts.
- Final round. The source guest is paused. Calls that reach the guest answer 503
sandbox moving to another node; retry, as during an evacuation. Fork, checkpoint, promote, hibernate and the replica round are held off for the whole series, because asnapshot saveends it. - Receive. The target runs
vm migrate receive, so its guest is paused, and journals the claim. - Hand-off. The target moves the row from the source to itself in one compare-and-set. This is the point of no return.
- Start. The target runs
vm startand records the claim's start. - Drop. The source drops the claim and runs
vm rm --force. The client meets a 404 there and runsLookup, as after an evacuation.
Connections do not survive: exec and pty streams, port forwards and egress connections through the doors end. Only the guest's processes and memory carry over.
The row, and why a new registry call
Checked against main 7fd17a2:
- A running claim has a row only when it is replicated (
replicate_seconds). An unreplicated running claim has none:archiveRowedneeds an archive checkpoint, and replica rows needHasReplica. - The target cannot take the source's row while the source's lease is live.
Takemoves another node's row only after that lease expired, andPutis owner-guarded. evacuategets around this withReleaseand thenTake. In between the row has no owner, and any node'sLookupmay adopt it fromrec.Checkpoint. That is safe for an archive, whose checkpoint is the claim's latest state. It is not safe for a live move: the source guest holds state newer than any replica, and a third node adopting the replica would start a stale copy.
Proposed: Registry.Hand(ctx, id, expect, from, to). It moves the live row from holds at epoch expect to to at a new epoch, in one compare-and-set, with no ownerless step. The target calls it with the epoch the source sent. Every later write of the source then fails, so the source's next replica Replace gets ErrLost and drops the claim even if the target's notice never arrives. It needs the PostgreSQL implementation, the in-memory archivestest implementation and the shared contract test.
Rows with no checkpoint. An unreplicated claim would need a row that names no checkpoint for the length of the move. Every row path assumes a checkpoint today. For example, reconcileOwnRow treats a claim as referencing a row when sb.ArchiveCk == rec.Checkpoint, which is trivially true for two empty strings. Adopt, purge, the intent settle, the replica round, unfence and the checkpoint sweep would each need a rule. Proposed for the first version: replicated claims only. Their row already exists, and every path understands it.
Counting
Each limit is per node today: node max_claims (len(m.claimed)), tenant max_claims (tenantLive, also per node), max_running (claims holding a VM, with a wake in flight counted) and the memory_limit gauge. Adoption skips the caps because a refusal could strand a claim. A move cannot strand one, because the source still holds it, so the target checks them all.
Claimed, running and tenant counts on source S and target T:
| Step | S | T |
|---|---|---|
| Before | 1 / 1 / 1 | 0 / 0 / 0 |
| T admits | 1 / 1 / 1 | reserved 1 / 1 / 1 |
| Rounds, final, receive | 1 / 1 / 1 | reserved 1 / 1 / 1 |
| Hand-off | 1 / 1 / 1 | claim 1 / 1 / 1, swapped under one m.mu |
| S drops | 0 / 0 / 0 | 1 / 1 / 1 |
- The reservation holds until the hand-off. Without it, other claims or another move could take T's slot during a transfer of seconds, and the move would fail after the guest paused. The reservation is a counter that
quotaErrandrunningErradd in. It is not an entry inm.claimed, so listings, reaps and sweeps never see it. - S does not let go before the hand-off. An abort resumes the guest on S, so S must not give its slot to a new claim first.
- The cluster counts the claim twice during the move, which is true: S runs the guest while T receives it. It can make nodes refuse earlier, never over-admit.
- After the hand-off, S over-counts until it drops the claim, which takes at most until its next refused write if T's notice is lost.
memory_limitcannot be reserved, because it is a host gauge. T checks it at admission and again before the receive, and fails the move if it is over. A claim admitted between the two checks is the window left.- Usage. S records an end when it drops the claim and T records a start at
vm start, so the paused window is not billed twice. Event names follow the usage folding rules and land with the docs.
Failures
| Where | Outcome |
|---|---|
| Admission refused, or a round fails | vm migrate abort on S, the guest runs, T drops its reservation and files; the call fails |
| Final round or receive fails | the same |
| S and T lose each other before the hand-off | both time out: S aborts, T removes what it received; a restart of T removes a received VM no claim owns, as reconcile does for any stale VM |
| T crashes after the hand-off | the claim is T's; T's restart reconciles and starts it, S stays paused until it sees the row moved, then removes its VM |
| S crashes during the rounds | T's receive or hand-off fails and T drops what it has. S journals the move before the baseline, and its restart runs vm migrate abort for a journaled move, which resumes a paused guest and deletes the series; without that, the guest would stay paused while reading running, the window #433 records for hibernate |
First version scope
| Claim | First version | Why |
|---|---|---|
none lane, replicated pool, CH, run dir on XFS |
yes | |
| Run dir on tmpfs (high-density pools) | refused | cocoon needs reflink |
| With volumes | refused | cocoon refuses hot-attached disks |
| Egress lane | later | see below |
| Firecracker | refused | cocoon is CH only |
| Hibernated or archived | refused, 409 | that is the cold path |
Egress lane, later. It cannot hibernate today because cocoon resumes the guest before the new tap can be locked. A receive leaves the guest paused, so the tap can be locked before vm start. The received VM gets new NICs with new MACs, though, and the guest's network recovery after that is not checked yet.
Prerequisites
- A cocoon release that contains review: solo whole-repo round on d5d7887 (config seed fix, cuts, owner calls) #303. The CH fork at
35b098c65260or newer; a VM keeps the VMM it started with, so older VMs need a restart first. - Same-model nodes. cocoon did not test TSC across hosts of different frequency.
- sandboxd is the first real caller. cocoon's runs used the same
root_dirandrun_diron both nodes, so different paths must be tested.
Out of scope
- Automatic placement or rebalancing, and moves triggered by memory pressure: the caller decides where a claim goes.
- Keeping open connections across a move, or SDK retries beyond today's
Lookup.
Open questions
- First version for replicated claims only, as proposed above, or the row with no checkpoint now?
- The downtime budget for the final round: fixed, configurable, or a request field?
- A cap on concurrent moves per node: reuse the maintenance budget, or a separate one?
Acceptance
- Two bare-metal nodes: move and move back with memory stamps and disk checksums intact, and the guest's downtime measured directly.
- Fault injection: kill the source, the target and the transfer at each step above, and show one owner, the counts on both nodes back to their expected values, and no leftover VM or files.
- Each refusal in the scope table answers before any pause.
- Warm claim and release on both nodes unchanged in an interleaved A/B, because the reservation counter sits on the admission path.
Activity
CMGS commentedon Nov 4, 2026
Decisions on the open questions:
max_running.Still open: whether the first version covers only replicated claims. A claim is replicated when its pool entry sets
replicate_seconds. Each interval its guest pauses for a memory and disk capture that goes to the checkpoint store, and its registry row names that replica. Such a claim already has a row, soHandjust moves it. Any other running claim has no row, so the alternative is a row that lives only for the length of the move and names no checkpoint. That row needs a rule on each path that assumes a checkpoint:reconcileOwnRowmust not count an empty checkpoint as a reference;A source lost mid-move then loses an unreplicated claim, as it does today.
CMGS commentedon Nov 4, 2026
Design change: a move does not use the archive registry. A checkpoint row records where a stored checkpoint lives and who may restore it. A move hands a live guest from one node to another. These are two concepts, so the move gets its own record. It reuses the registry's compare-and-set and epoch code and the node lease, but none of the rows. This replaces
Registry.Handand the row with no checkpoint, and covers replicated and unreplicated claims the same way.The move record
It lives in its own table beside the archive rows, with a PostgreSQL implementation, an in-memory test implementation and one contract suite. One record per claim:
id,from,to,state,epoch,updated_at. States, each change a compare-and-set:pendingpendingfinalfinalcommittedpendingorfinalabortedcommittedandabortedexclude each other. That replaces the row hand-off:final, so it never starts a guest restored from a round the source kept running after.abortedwrite lands. A write that findscommittedmeans the claim moved: the source drops it and removes its VM. While the database is down, a guest paused in the final round stays paused. The caller sees the move fail, and the guest resumes once the abort lands.committedstarts the received VM, and with anything else removes it. A source restart withcommitteddrops the claim, and withpendingorfinalwritesaborted, then resumes. A source journals the move locally beforepending, so its restart knows to look.committedrecord once the source has dropped the claim. Anabortedrecord is deleted by the source. Any node's hourly sweep removes a record left past a bound, as for departed owners' rows.Requires
archive_failover, as decided, for the meta store and the node lease. It does not use the archive rows.Replicated claims
A replicated claim's archive row names its latest replica. Left in place, that row lets a third node adopt the stale replica once the source's lease expires, while the target runs the claim. So the source deletes the claim's row and replica before the final round. The target's first replica round puts a new row, one interval after the start, like any new claim. An abort before
finallets the source's next round put the row again. In both cases the claim goes one interval without a replica, the same as a claim just made.Unchanged
The counting table, the failure matrix apart from the rows above, the fail-fast rule, the downtime budget as a request field and the separate reloadable move budget all stand as written above.
Open question 1 is closed: the first version moves replicated and unreplicated claims alike.
CMGS commentedon Nov 4, 2026
PR #445 implements this. The design moved after these comments: the record gained a
startedstate, a source resumes only on proof that its move stayed, and only the source deletes a record.docs/cluster.mdin the PR is the current statement.CMGS commentedon Nov 4, 2026
The first version no longer requires
archive_failover: a move needsmeta_storewith acellfor its record andmesh.cluster_key. S3 and the node lease stay requirements of archived and replicated claims only.CMGS commentedon Nov 5, 2026
Follow-up proposal after #445: recover exec and terminal sessions across a live move. The connection may close; the SDK finds the owner and attaches to the same guest task. The expected result is a pause in output, with the same process, shell state and terminal after it.
The move's ownership protocol stays as implemented. Its current contract is docs/cluster.md at the merge.
What needs to change. Guest memory carries silkd's task state, but it does not carry the source sandboxd's relay sockets. Today a foreground exec is killed when writing to a disconnected client fails; a silent command can still finish. A pty disconnect kills its shell. Detached processes and persistent shell sessions already outlive connections, but their existing interfaces do not provide complete stream recovery.
Task lifetime. silkd owns the task independently of the relay. A transport loss detaches the client. Explicit cancel or close ends the task; an abandoned attachment has a bounded grace period before cleanup. Completed tasks retain their output and exit status for a bounded period. Existing detached jobs keep their existing lifetime policy. The first increment should make this behavior explicit in the API, then wire it into SDK recovery.
Task IDs must distinguish task lifetimes; a reused PID must never attach a caller to a different process. Keep the task table, replay buffers and attachment state in the guest, where the move already carries them. No new session registry or per-frame database write is needed.
Reconnect flow.
Lookup. A cached connection to the old owner is no longer usable.The lost
startedreply needs the same rule. Allocate an operation ID before sending a start request and register it before spawning the task. Duplicate starts with that ID return the existing task; a conflicting request is rejected. Recovery looks up the ID without creating it. An absent or expired record reports an unknown outcome and never starts a replacement command.Stream recovery.
These guarantees cover reconnection while the same guest survives, including a live move. They do not promise exactly-once effects after a guest loss, restore from an older checkpoint, or SDK process crash.
Delivery and acceptance.
Measure the time until the first resumed output separately from VM downtime. Exercise the writer replacement race and repeated moves, and check that tasks, relays and buffers are reclaimed after close or expiry.
Keeping the client's TCP/WebSocket open would require a stable proxy outside the moving nodes, plus this same backend recovery protocol. That can follow if needed. Egress TCP/NAT state and arbitrary application streams stay outside this stage.
CMGS commentedon Nov 5, 2026
Update after #455 (merged 14b4692): the move's contract changed in six places. docs/cluster.md at that commit is current.
archive_failovertoo. Before, a node without archives released the claim (204) while the move still committed, so the released sandbox ran on the target (8d0d678).vm migrate abortanswers with the move's own error, not 503 "move registry down", and the abort keeps retrying (b1b7fd8).move_concurrencyslot until the 2-minute idle drop (58204dc). The move transport is installed before reconcile (8b580a9).Each change ran on two hardware nodes against the pre-#455 binary as a negative control. The move's crash windows (source or target killed at a round, the final capture, the commit, the start) also ran: each one ends with exactly one owner.
CMGS commentedon Nov 5, 2026
Still outstanding before live move is used in production: a boundary-scale run. Everything measured so far moved an idle 512 MiB guest between two nodes, at 180-330 ms downtime with a baseline round of about 11.5 s. Three things are untested: an 8-16 GiB guest under a memory-writing load (whether the deltas converge inside
max_downtime_ms, and whether a round hits its per-round budget of 1 minute plus 10 MB/s of transfer time), two moves in flight atmove_concurrency2, and a move off a loaded node. #463 tracks the hashing cost that run should break out of the final round. #457 and #459-#462 did not change the move contract described above.CMGS commentedon Nov 6, 2026
Implemented by #445:
POST /v1/sandboxes/{id}/movemoves one running claim to a named peer with cocoon's live migration. It differs from this design in one point: a move keeps its own record in the meta store (acellandmesh.cluster_keyare needed) instead of a registry compare-and-set, so it does not requirearchive_failoveror an S3checkpoint_store. docs/cluster.md describes it; #463 tracks the hashing cost on a large guest, and the boundary-scale move test is still to run.