Worker lifecycle
A worker in Substrate is a pre-warmed gVisor sandbox pod - a member of a
WorkerPool Deployment, sitting around with ateom-gvisor running and a
pause container, waiting for an actor to be restored into it.
Workers are simpler than actors: just two states (plus “gone”).
States
stateDiagram-v2
[*] --> Idle: pod gets a PodIP<br/>(syncer.CreateWorker)
Idle --> Assigned: ResumeActor:<br/>AssignWorker step
Assigned --> Idle: SuspendActor:<br/>FinalizeSuspended step
Assigned --> [*]: pod deleted<br/>(syncer cleanup)
Idle --> [*]: pod deleted<br/>(syncer cleanup)
note right of Idle
actor_id == ""
Eligible to host any
actor from its WorkerPool.
end note
note right of Assigned
actor_id == "{some-id}"
Hosting one actor.
Released back to Idle on suspend.
end note
Worker record in Redis
A worker is just a JSON proto at key worker:<ns>:<pool>:<pod>:
| Field | Idle worker | Assigned worker |
|---|---|---|
worker_namespace, worker_pool, worker_pod | always set | always set |
ip, worker_pod_uid, version | always set | always set |
actor_id | "" | actor ID |
actor_namespace | "" | actor’s namespace |
actor_template | "" | template name |
cmd/ateapi/internal/store/ateredis/ateredis.go:40-80
The WorkerPoolSyncer keeps this fresh
flowchart LR
K[K8s pod informer] -- AddFunc<br/>(PodIP assigned) --> C[CreateWorker<br/>actor_id=""]
K -- DeleteFunc<br/>(pod gone) --> CHK{worker had<br/>actor_id?}
CHK -- yes --> R[releaseActorOnDeadWorker<br/>actor → SUSPENDED]
CHK -- no --> D[DeleteWorker]
R --> D
C -.-> RED[(Redis)]
D -.-> RED
R -.-> RED
releaseActorOnDeadWorker runs before DeleteWorker so the actor is
restored to SUSPENDED while the worker record is still readable.
Eligibility to create a worker is keyed on pod.Status.PodIP != "", not
on the Ready condition.
cmd/ateapi/internal/controlapi/syncer.go:28-192
How workers get assigned
When ResumeActor runs, AssignWorkerStep does a randomized shuffle over
idle workers in the target WorkerPool (matching the actor’s
ActorTemplate.WorkerPoolRef) and picks one. If none are available it
retries up to 5 times with exponential backoff (10ms initial). After that:
FailedPrecondition.
This is the bypass-the-K8s-scheduler trick: pod allocation is just a Redis shuffle, not a scheduler decision.
cmd/ateapi/internal/controlapi/workflow_resume.go:78-158
How workers get released
When SuspendActor finishes, FinalizeSuspendedStep zeros out the
worker’s actor fields and writes it back to Redis. The worker is
immediately eligible to host a different actor.
cmd/ateapi/internal/controlapi/workflow_suspend.go:174-239
What happens when a worker pod dies
The syncer’s DeleteFunc fires on pod removal. If the worker was hosting
an actor, the syncer forces the actor back to SUSPENDED without
producing a snapshot (the actor’s existing LastSnapshot, if any, is
still valid). Then the worker record is deleted.
This is a recovery path, not a planned transition.
Pre-warming: where workers come from
The WorkerPool CRD owns a Deployment of N replicas. atecontroller
reconciles changes to that CRD into Deployment spec changes. Pods come up,
start ateom-gvisor, get a PodIP, and the syncer registers them as
Idle workers.
flowchart LR WP["WorkerPool CRD<br/>(replicas: 10)"] --> CTR[atecontroller] CTR --> DEP[K8s Deployment<br/>10 worker pods] DEP --> P1[Pod 1: ateom-gvisor] DEP --> P2[Pod 2: ateom-gvisor] DEP --> PN[...] P1 -- has PodIP --> SYNC[WorkerPoolSyncer] P2 -- has PodIP --> SYNC PN -- has PodIP --> SYNC SYNC -- CreateWorker --> RED[(Redis: Idle worker)]
internal/controllers/workerpool_controller.go:52-176
Worker pools are isolation boundaries
Actors of a given ActorTemplate can only be assigned to workers from the
pool referenced by template.WorkerPoolRef. Different pools never share
workers. This is the unit at which:
- You can have different worker base images per pool.
- You can scale capacity independently per workload class.
Related
- Actor lifecycle - the dual state machine on the actor side.
- Resume actor · Suspend actor
- WorkerPool · Workers (Cut 3).