You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When we pause an actor, the actor is forced to resume on the same node. However, at the time of resume, the node may not have capacity for the actor anymore. Let's discuss how we can recover from this situation in the near term.
My thoughts for the desired near term flow:
Resume() returns an out of capacity error
User needs to trigger Suspend() in order to offload the snapshot to remote storage.
User can then retry Resume()
In the future, we could improve on this by automatically doing steps 2+3 as part of the initial Resume() call.
Implementation Plan: Near-Term Flow for Resume from Pause Failures
To recover from out-of-capacity failures when resuming an Actor from STATUS_PAUSED, we will implement the near-term flow (Pause -> out of capacity -> Suspend -> Resume on any node).
Key Design Decisions
Explicit Error Signaling: Resume() returns codes.ResourceExhausted when a node lacks capacity, avoiding silent cloud storage uploads or SLA violations.
Service Separation (NodeStorage): Paused actors have no active ateom pod. Instead of adding a filesystem operation to AteomHerder, we introduce a dedicated NodeStorage gRPC service on atelet for node-level filesystem offloading.
Add service NodeStorage with rpc OffloadLocalCheckpoint. Allows ateapi to upload local checkpoint files directly from disk without an active ateom pod.
Implement OffloadLocalCheckpoint to read checkpoint files from ateompath.LocalCheckpointsDir, upload them to remote storage, and delete the local directory.
dialer.go: Add DialForNode(nodeName string) to AteletDialer (extracting pod lookup/connection logic from DialForWorker) since paused actors have no worker pod name.
In AssignWorkerStep, returncodes.ResourceExhausted ("no free workers available matching scheduling constraints") when scheduler.Schedule() fails due to capacity.
I'm not sure if we need a separate service. There is some overlap with existing Checkpoint/Resume calls wrt managing local snapshots, and we'll probably need to ensure some concurrency control among all 3 methods. The existing service dials into atelet, not the ateom pod.
We will have some intermediate state where the local snapshot is deleted but the LocalSnapshotInfo hasn't been updated yet. We should think about the failure modes and recovery path.
The offload mechanism (enabling SuspendActor on a PAUSED actor) is tracked in #791 and currently being implemented by Dmitry Berkovich (@dberkov).
I will follow up by reviewing those PRs. Once they land, I'll work on standardizing the out-of-capacity error in ResumeActor (codes.ResourceExhausted) and adding an end-to-end test for the full pause-to-resume capacity recovery flow.
Phases 1–3 landed under #791, though not as written above (the offload RPC went on the existing AteomHerder rather than a new service). Remaining work is phases 4 and 5, revised below.
Phase 4 — Unambiguous capacity signal
Return codes.ResourceExhausted from assignWorkerAttempt for scheduling.ErrNoCapacity (currently returns FailedPrecondition in workflow_resume.go).
We can't return FailedPrecondition as-is: it is overloaded on this RPC, since it also covers "the actor isn't in a resumable state" (workflow_resume.go) and the two demand opposite client responses.
We considered keeping FailedPrecondition and carrying an AIP-193 ErrorInfo reason to disambiguate, which would have left the router untouched. It doesn't work: ateapi's ServerUnaryInterceptor rebuilds every error as status.Error(code, message) and drops details.
FailedPrecondition is currently the de-facto capacity signal, so three consumers move with it:
The router's park predicate (resumer.go): add ResourceExhausted so parking is unchanged.
The HTTP mapping (errors.go): fold ResourceExhausted into the FailedPrecondition/Aborted branch (503 + description). Its current "actor %s rate limited" message was written speculatively since nothing in substrate produced the code until now.
The no_capacity metric bucket (extproc/metrics.go): move it to the new code.
Accepted risk: we don't exclusively own ResourceExhausted (grpc-go emits it on message-size violations), so a passthrough would be parked once before failing. Not reachable in practice given the payload sizes on this path.
Phase 5 — Relocation test
The test goes in functional_test.go with a synthetic two-node fleet, building on the harness TestSuspendActor_FromPaused already uses.
Activity
ILLUM1N0X commentedon Aug 3, 2026
Implementation Plan: Near-Term Flow for Resume from Pause Failures
To recover from out-of-capacity failures when resuming an Actor from
STATUS_PAUSED, we will implement the near-term flow (Pause-> out of capacity ->Suspend->Resumeon any node).Key Design Decisions
Resume()returnscodes.ResourceExhaustedwhen a node lacks capacity, avoiding silent cloud storage uploads or SLA violations.NodeStorage): Paused actors have no activeateompod. Instead of adding a filesystem operation toAteomHerder, we introduce a dedicatedNodeStoragegRPC service onateletfor node-level filesystem offloading.Implementation Phases
NodeStorageService (internal/proto/ateletpb/atelet.proto)service NodeStoragewithrpc OffloadLocalCheckpoint. Allowsateapito upload local checkpoint files directly from disk without an activeateompod.NodeStorageServer (cmd/atelet/main.go)OffloadLocalCheckpointto read checkpoint files fromateompath.LocalCheckpointsDir, upload them to remote storage, and delete the local directory.STATUS_PAUSEDin Suspend Workflow (cmd/ateapi/internal/controlapi/)dialer.go: AddDialForNode(nodeName string)toAteletDialer(extracting pod lookup/connection logic fromDialForWorker) since paused actors have no worker pod name.workflow_suspend.go:STATUS_PAUSED. When paused, calldialer.DialForNode(nodeName)and invokeNodeStorage.OffloadLocalCheckpoint.LocalSnapshotInfoupon enteringSTATUS_SUSPENDED, removing the node scheduling constraint soResume()can schedule anywhere.cmd/ateapi/internal/controlapi/workflow_resume.go)AssignWorkerStep, returncodes.ResourceExhausted("no free workers available matching scheduling constraints") whenscheduler.Schedule()fails due to capacity.cmd/ateapi/internal/controlapi/)workflow_suspend_test.goand an e2e test infunctional_test.goverifying:Pause-> capacity failure ->Suspend-> relocationResume.msau42 commentedon Aug 6, 2026
Overall approach lgtm. Just a few comments:
msau42 commentedon Aug 7, 2026
Related issue: #791
BenTheElder commentedon Aug 7, 2026
Longer term I think we should consider direct p2p instead of bouncing through GCS, for latency reasons. I also filed #798
ILLUM1N0X commentedon Aug 10, 2026
The offload mechanism (enabling
SuspendActoron aPAUSEDactor) is tracked in #791 and currently being implemented by Dmitry Berkovich (@dberkov).I will follow up by reviewing those PRs. Once they land, I'll work on standardizing the out-of-capacity error in
ResumeActor(codes.ResourceExhausted) and adding an end-to-end test for the full pause-to-resume capacity recovery flow.ILLUM1N0X commentedon Aug 12, 2026
Phases 1–3 landed under #791, though not as written above (the offload RPC went on the existing
AteomHerderrather than a new service). Remaining work is phases 4 and 5, revised below.Phase 4 — Unambiguous capacity signal
Return
codes.ResourceExhaustedfromassignWorkerAttemptforscheduling.ErrNoCapacity(currently returnsFailedPreconditioninworkflow_resume.go).We can't return
FailedPreconditionas-is: it is overloaded on this RPC, since it also covers "the actor isn't in a resumable state" (workflow_resume.go) and the two demand opposite client responses.We considered keeping
FailedPreconditionand carrying an AIP-193ErrorInforeason to disambiguate, which would have left the router untouched. It doesn't work: ateapi'sServerUnaryInterceptorrebuilds every error asstatus.Error(code, message)and drops details.FailedPreconditionis currently the de-facto capacity signal, so three consumers move with it:resumer.go): addResourceExhaustedso parking is unchanged.errors.go): foldResourceExhaustedinto theFailedPrecondition/Abortedbranch (503 + description). Its current "actor %s rate limited" message was written speculatively since nothing in substrate produced the code until now.no_capacitymetric bucket (extproc/metrics.go): move it to the new code.Accepted risk: we don't exclusively own
ResourceExhausted(grpc-go emits it on message-size violations), so a passthrough would be parked once before failing. Not reachable in practice given the payload sizes on this path.Phase 5 — Relocation test
The test goes in
functional_test.gowith a synthetic two-node fleet, building on the harnessTestSuspendActor_FromPausedalready uses.TestResumeActor_RelocatesAfterSuspendFromPaused:createTemplate+createWorkerPod(..., "worker-1", "node1", "pool1").node1, worker-1 freed.node1has no free worker.createWorkerPod(..., "worker-2", "node2", "pool1")→ free capacity exists, but on the wrong node.ResumeActor(A)→ assertResourceExhausted.SuspendActor(A)→ assert SUSPENDED andLocalSnapshotInfo == nil.ResumeActor(A)→ assert it succeeds and the worker assignment isworker-2onnode2.ateapi: return ResourceExhausted when no worker is free (agent-substr…