Repository navigation
Resuming a suspended actor detaches it when its node has no room - #2881
Conversation
511eeec to
15b56ad
Compare
15b56ad to
4ff459e
Compare
| // The runtime is not relaxed here: moving the guest state is worth an | ||
| // upload, giving it up is not. | ||
| constraints.RequiredNodes = nil | ||
| if hasRoom, err := w.anyWorkerHasRoom(ctx, constraints); err != nil || !hasRoom { |
There was a problem hiding this comment.
Could this room check move out of detachFromFullNode and into ResumeActor? Something like:
if shouldDetachFromNode(actor, err) {
// Claim a worker on another node first.
assigned, worker, err = w.assignWorkerOnOtherNode(...)
if err != nil {
return nil, false, err // still ResourceExhausted when no node has room
}
// Then detach from the full node.
if err := w.detachFromFullNode(...); err != nil {
// release the claim
return nil, false, err
}
}Claiming the place before the detach closes the race noted at line 66, where two actors detached for the same place leave the loser durable-only. It also leaves detachFromFullNode with only the detach to do.
There was a problem hiding this comment.
🤖 Before I make this change, a question for you and Michelle Au (@msau42).
DetachActor is synchronous. If the snapshot isn't durable yet, atelet uploads it before returning, and the resume waits for the whole upload. The upload runs on the RPC's context, so if the router's resume budget cancels the request, the upload is canceled too, and the next resume starts it over. With a claim taken first, every such attempt would also hold and release a worker for the length of the upload.
What if resume moves an actor only when its latest snapshot is already COMPLETED? Then DetachActor only prunes, which is fast. Claiming the worker first then costs almost nothing, and there's no upload to cancel. An actor suspended within the last --snapshot-upload-delay (5m by default) would get ResourceExhausted until atelet's background upload lands and is reported, and would move on the next resume after that.
The cost is that a recently suspended actor can't move right away. Is that acceptable for GA? If not, I'll claim first and keep the synchronous upload, and note the latency in the description.
There was a problem hiding this comment.
-
I don't think we should wait for async upload at least with our current async period of 5 mins. We could force promote and resume faster than that.
-
Claiming before
DetachActorvs. keeping the current order (anyWorkerHasRoom->DetachActor->ensureWorkerAssigned): I'd recommend keeping the current order (checking room first, detaching while stillSUSPENDED, then claiming inensureWorkerAssigned) rather than claiming a worker beforeDetachActor:
- If ateapi crashes between
assignOnOtherNodeanddetach, then there will need to be some logic to handle the discrepency between assigned_node and snapshots. - Keeping the actor
SUSPENDEDand unclaimed untilDetachActor+recordDetachcomplete preserves clean re-entrancy at every boundary. Even if two actors race for the last free worker and both detach, the loser ends in the normal durable-onlySUSPENDEDstate (Locality = "",AssignedNode = "",Status = COMPLETED), which is still forward progress and lets the next retry schedule it onto any node without callingDetachActoragain.
There was a problem hiding this comment.
🤖 Thanks, that settles both. I'll keep the synchronous upload and the current order: room check, detach while SUSPENDED, then claim in ensureWorkerAssigned. That's what the branch does now, so no code change.
One follow-up I'd file rather than fix here: the upload runs on the RPC's context, so a resume the router cancels also cancels its upload, and the next attempt starts over. For a snapshot that takes longer than the router's budget to upload, only atelet's background upload would ever finish it. atelet could keep a foreground detach's upload going after the caller leaves.
4ff459e to
4102625
Compare
4102625 to
7a93a68
Compare
Michelle Au (msau42)
left a comment
There was a problem hiding this comment.
will also let Zoe review
d988cb0 to
5c269e9
Compare
| // record this leaves behind. An UpdateActor takes no lease, so a conflict | ||
| // is retried on a fresh read. | ||
| stored, err := w.recordDetach(ctx, actorRef, actor, node, state) | ||
| for attempt := 1; isUpdateConflict(err) && attempt < recordDetachAttempts; attempt++ { |
There was a problem hiding this comment.
🤖 should-fix 🟡 – The other workflows don't retry a version conflict in place. They return Aborted and let the caller re-enter the workflow, which picks up from the stored record. This step can do the same: write the record once and return the error.
There was a problem hiding this comment.
🤖 Done. Zoe Zhao (@zoez7) Michelle Au (@msau42), this reverses the bounded retry msau42 asked for, so flagging it for both of you.
The step now writes the record once. A conflict returns Aborted, and the next resume re-enters from the stored record, detaches again, and records the state atelet returns. A test runs that sequence. The re-read before the detach stays, for msau42's stale-actor point.
The case the retry covered: if the actor's node has room by the time the resume re-enters, the resume restores there from the upload instead of detaching again. The record then keeps the upload PENDING, since the detach cancelled atelet's report. The description says so. The window is a concurrent UpdateActor landing during the detach, which takes no lease, so it should be rare.
There was a problem hiding this comment.
A possible follow-up that closes the remaining gap is have a foreground DetachActor mark atelet's report due instead of cancelling it, so a record write that loses a conflict is corrected by the report atelet already retries.
There was a problem hiding this comment.
Returning ABORTED is fine to be consistent with the other workflows. The important part was the re-read.
5c269e9 to
d856c35
Compare
A suspended actor whose latest snapshot is local to a node was pinned to that node, so its resume failed with ResourceExhausted while the node was full, even when other nodes had room. Until atelet detached the actor on its own after the detach delay, nothing lifted the pin. Resume still assigns a worker pinned to the node first. When that reports no room, and a worker on another node has room, it calls DetachActor on the actor's node, which makes the latest snapshot durable and deletes the local copies. It records the detach, which marks the upload COMPLETED and clears the locality and the assigned node. The resume then assigns a worker on any node and restores the actor from the durable snapshot. Trying the pinned assignment first keeps the common path unchanged and leaves a claim from an interrupted attempt to assignment's own handling. A worker of another runtime counts as room: the actor restores there at ROOTFS, without its process memory, as a durable-only actor already does, and assignment still prefers a worker that keeps it. The free place is not claimed before the detach, so two actors detached for it race in their assignments, and the loser is left durable-only. The step re-reads the actor before it starts, and records the detach of every copy on the node up to the last assigned generation, so a state atelet returns with no durable snapshot still applies. A concurrent update fails the record write with Aborted; the next resume detaches again, and atelet returns the same state. A failed detach fails the resume and leaves the actor on its node instead of crashing it, since the actor can still resume there.
15998a3
d856c35 to
15998a3
Compare
|
🤖 Re-requesting review: the merge queue failed because #2879 landed after my last rebase and now rejects workers without a default runtime. I rebased and gave the test workers a runtime. Test fixtures only, no change to the code you approved. |
…s lookup Following agent-substrate#2883 and agent-substrate#2881, every suspended actor with a retained local snapshot has AssignedNode set and passes RequiredNodes = [assignedNode] into scheduling.Schedule on ResumeActor. Without a node index, Schedule calls s.source.Workers(), copying the entire cluster worker slice and scanning every worker in O(W) time on each node-pinned resume. Index cached workers by node name in workercache.Cache and use O(1) node lookup in scheduling.Schedule when RequiredNodes is set: - Add byNode index and WorkersByNode(node) in cmd/ateapi/internal/workercache/workercache.go, maintained across relist, Create/Update/Delete watch events, and Forget. - Add workerNodeLookup in cmd/ateapi/internal/scheduling/scheduling.go and query nodeLookup.WorkersByNode(node) when Constraints.RequiredNodes is non-empty, avoiding full-fleet slice allocation and linear scanning. - Add TestCache_WorkersByNode, TestScheduleRequiredNodesUsesNodeLookup, and BenchmarkScheduleRequiredNodes. Benchmark results (go test -run='^$' -bench='BenchmarkScheduleRequiredNodes' -benchmem -count=5 ./cmd/ateapi/internal/scheduling/...): BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1727404 600.6 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1822207 623.1 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 2066503 626.2 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1887322 684.0 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 2061903 665.5 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 83085 15061 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 82180 17377 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 72000 14448 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 71266 14944 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 86091 14398 ns/op 4160 B/op 2 allocs/op
…s lookup Following agent-substrate#2883 and agent-substrate#2881, every suspended actor with a retained local snapshot has AssignedNode set and passes RequiredNodes = [assignedNode] into scheduling.Schedule on ResumeActor. Without a node index, Schedule calls s.source.Workers(), copying the entire cluster worker slice and scanning every worker in O(W) time on each node-pinned resume. Index cached workers by node name in workercache.Cache and use O(1) node lookup in scheduling.Schedule when RequiredNodes is set: - Add byNode index and WorkersByNode(node) in cmd/ateapi/internal/workercache/workercache.go, maintained across relist, Create/Update/Delete watch events, and Forget. - Add workerNodeLookup in cmd/ateapi/internal/scheduling/scheduling.go and query nodeLookup.WorkersByNode(node) when Constraints.RequiredNodes is non-empty, avoiding full-fleet slice allocation and linear scanning. - Add TestCache_WorkersByNode, TestScheduleRequiredNodesUsesNodeLookup, and BenchmarkScheduleRequiredNodes. Benchmark results (go test -run='^$' -bench='BenchmarkScheduleRequiredNodes' -benchmem -count=5 ./cmd/ateapi/internal/scheduling/...): BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1802881 605.2 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 2008159 770.8 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1000000 1217 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1927760 839.7 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1705620 588.8 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 79581 17176 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 85873 14841 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 80368 15142 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 77570 14585 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 94237 17063 ns/op 4160 B/op 2 allocs/op
…s lookup Following agent-substrate#2883 and agent-substrate#2881, every suspended actor with a retained local snapshot has AssignedNode set and passes RequiredNodes = [assignedNode] into scheduling.Schedule on ResumeActor. Without a node index, Schedule calls s.source.Workers(), copying the entire cluster worker slice and scanning every worker in O(W) time on each node-pinned resume. Index cached workers by node name in workercache.Cache and use O(1) node lookup in scheduling.Schedule when RequiredNodes is set: - Add byNode index and WorkersByNode(node) in cmd/ateapi/internal/workercache/workercache.go, maintained across relist, Create/Update/Delete watch events, and Forget. - Add workerNodeLookup in cmd/ateapi/internal/scheduling/scheduling.go and query nodeLookup.WorkersByNode(node) when Constraints.RequiredNodes is non-empty, avoiding full-fleet slice allocation and linear scanning. - Add TestCache_WorkersByNode, TestScheduleRequiredNodesUsesNodeLookup, and BenchmarkScheduleRequiredNodes. Benchmark results (go test -run='^$' -bench='BenchmarkScheduleRequiredNodes' -benchmem -count=5 ./cmd/ateapi/internal/scheduling/...): BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1802881 605.2 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 2008159 770.8 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1000000 1217 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1927760 839.7 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1705620 588.8 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 79581 17176 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 85873 14841 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 80368 15142 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 77570 14585 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 94237 17063 ns/op 4160 B/op 2 allocs/op
…s lookup Following agent-substrate#2883 and agent-substrate#2881, every suspended actor with a retained local snapshot has AssignedNode set and passes RequiredNodes = [assignedNode] into scheduling.Schedule on ResumeActor. Without a node index, Schedule calls s.source.Workers(), copying the entire cluster worker slice and scanning every worker in O(W) time on each node-pinned resume. Index cached workers by node name in workercache.Cache and use O(1) node lookup in scheduling.Schedule when RequiredNodes is set: - Add byNode index and WorkersByNode(node string) ([]*ateapipb.Worker, error) in cmd/ateapi/internal/workercache/workercache.go, maintained across relist, Create/Update/Delete watch events, and Forget, and returning an error when the cache is not ready. - Add workerNodeLookup and workersForSchedule in cmd/ateapi/internal/scheduling/scheduling.go to query nodeLookup.WorkersByNode(node) when Constraints.RequiredNodes is non-empty, avoiding full-fleet slice allocation and linear scanning. - Add TestCache_WorkersByNode, TestScheduleRequiredNodesUsesNodeLookup, and BenchmarkScheduleRequiredNodes. Benchmark results (go test -run='^$' -bench='BenchmarkScheduleRequiredNodes' -benchmem -count=5 ./cmd/ateapi/internal/scheduling/...): BenchmarkScheduleRequiredNodes/NodeIndex=true-8 849672 1203 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1000000 1157 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1086208 1237 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1000000 1248 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=true-8 1000124 1165 ns/op 96 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 35792 31085 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 37682 30128 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 39175 30108 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 36140 32745 ns/op 4160 B/op 2 allocs/op BenchmarkScheduleRequiredNodes/NodeIndex=false-8 25226 80084 ns/op 4160 B/op 2 allocs/op
… request times out (agent-substrate#2940) Fixes agent-substrate#2910. `DetachActor` ran its detach on the RPC's context. When a resume moved an actor off a full node (agent-substrate#2881) and the caller left mid-upload, for example when the router's resume budget ran out, the upload was cancelled and the next resume started it over. A snapshot slower to upload than that budget never moved. And because `DetachActor` cancelled atelet's report, nothing corrected the record if the caller failed to write it. Now the detach runs on the tracker's context and always makes atelet's report due. ```mermaid sequenceDiagram participant A as ate-api-server participant L as atelet participant O as object storage A->>L: DetachActor L->>O: upload snapshot Note over A: resume budget runs out L-->>A: Canceled Note over L: the detach keeps running O-->>L: upload done L->>L: delete local copies L->>A: ReportActorSnapshotState A->>A: record the detach ``` - If the detach finishes in time, `DetachActor` returns its state as before. Otherwise it returns the caller's error and the detach runs on. A second call waits for it. - Every detach makes the report due, including when the upload succeeds and the prune fails. - The upload timeout from agent-substrate#2831 now bounds a `DetachActor` call's upload, since the caller's context no longer does. - atelet's shutdown waits for a detach that outlived its call. Terminate cancels it and waits, as agent-substrate#2943 does for any detach. #### API changes - `Atelet.DetachActor` (atelet internal). The proto comment now says atelet reports the detach and that the detach outlives the call. Comment only, with the regenerated `atelet_grpc.pb.go`. #### Worth knowing - Each move now sends one redundant report. It gets `Aborted` while the actor is RESUMING, then `NotFound` once it runs elsewhere, and atelet drops it. - agent-substrate#2831's subtest asserted that a `DetachActor` call ignores the upload timeout. It now asserts the timeout applies.
A suspended actor with a local snapshot is pinned to its node. While that node was full, its resume failed with
ResourceExhaustedeven when other nodes had room, until atelet detached the actor on its own after--snapshot-detach-delay. Resume now detaches the actor itself, withDetachActorfrom #2852, and restores it on another node.Part of #589. This covers a full node. A node that is gone, the scale-down case, is still open.
How the move works
Assignment still runs pinned to the node first. When it finds no room there, and a worker on another node has room, resume:
DetachActoron the pinned node. atelet uploads the latest snapshot and deletes the local copies.COMPLETEDand clears the locality andassigned_node.DetachActoris synchronous. If the snapshot isn't durable yet, the resume waits for atelet to upload it.What the restore keeps
On a worker of the same runtime, the restore keeps the snapshot's fidelity. On a worker of another runtime, a MEMORY snapshot restores at ROOTFS, as for any durable-only actor since #2880. Process memory is lost, but rootfs writes and volumes are kept. ROOTFS and VOLUMES snapshots keep their fidelity on any worker of the same sandbox class. A template can't yet refuse the downgrade; #2818 would add that.
When the actor doesn't move
ResourceExhaustedas before, without calling atelet.DetachActorcan't reach atelet, so the resume returnsUnavailableand the actor stays pinned.sequenceDiagram participant C as client participant A as ate-api-server participant L1 as atelet on node1 (full) participant O as object storage participant L2 as atelet on node2 C->>A: ResumeActor A->>A: assign on node1: no room A->>A: node2 has room A->>L1: DetachActor L1->>O: upload latest snapshot L1->>L1: delete local copies L1-->>A: state with latest_durable only A->>A: mark COMPLETED, clear locality and assigned_node A->>A: reload restore source, assign on node2 A->>L2: Restore(durable snapshot) L2->>O: download L2-->>A: ok A-->>C: RUNNING on node2Design choices
Also worth knowing
Aborted, and the next resume detaches again; atelet returns the same state. If the node has room by then, the resume restores there from the upload instead, and the record keeps the uploadPENDING, because the detach cancelled atelet's report.DetachActorerrors map as the tag workflow mapsPromoteSnapshoterrors, plus atelet'sAbortedpassed through.no_capacitythenassigned. A dashboard that readsno_capacityas a failed resume will count these.TestResumeActor_RelocatesAfterDetachcheckedResourceExhaustedat a point where node2 already had room, which now detaches. The check moved to before node2 exists.DetachActorreturns the named actor's last checkpointed snapshot, not the last of any actor.