|
| 1 | +# Lifecycle |
| 2 | + |
| 3 | +This page describes how state moves through the storage tier — the |
| 4 | +actor state machine, the four lifecycle workflows that drive it, the |
| 5 | +worker side (creation, assignment, release, deletion), and the worker |
| 6 | +cache that makes scheduling fast. |
| 7 | + |
| 8 | +## Actor state machine |
| 9 | + |
| 10 | +```mermaid |
| 11 | +stateDiagram-v2 |
| 12 | + [*] --> SUSPENDED: CreateActor |
| 13 | +
|
| 14 | + SUSPENDED --> RESUMING: ResumeActor |
| 15 | + RESUMING --> RUNNING |
| 16 | +
|
| 17 | + RUNNING --> SUSPENDING: SuspendActor |
| 18 | + SUSPENDING --> SUSPENDED |
| 19 | +
|
| 20 | + RUNNING --> PAUSING: PauseActor |
| 21 | + PAUSING --> PAUSED |
| 22 | +
|
| 23 | + PAUSED --> RESUMING: ResumeActor |
| 24 | +
|
| 25 | + RUNNING --> SUSPENDED: syncer · worker pod deleted |
| 26 | +
|
| 27 | + SUSPENDED --> [*]: DeleteActor |
| 28 | +``` |
| 29 | + |
| 30 | +The two "at rest" states differ in durability and resume cost: |
| 31 | + |
| 32 | +- **SUSPENDED** — snapshot lives in durable object storage. Survives |
| 33 | + node loss. Slower resume (network pull of the snapshot). |
| 34 | +- **PAUSED** — snapshot lives on the node where the actor was |
| 35 | + running. Faster resume from local cache, but lost if that node |
| 36 | + fails. The actor record carries |
| 37 | + `latest_snapshot_info.local.node_vms_with_local_snapshots` so the |
| 38 | + scheduler can prefer a worker on the same node for the next resume. |
| 39 | + |
| 40 | +The transitional states (`RESUMING`, `SUSPENDING`, `PAUSING`) only |
| 41 | +exist for the duration of the corresponding workflow. An actor stuck |
| 42 | +in a transitional state past the lock TTL (30 s) is stranded — see |
| 43 | +[`operations.md`](./operations.md). |
| 44 | + |
| 45 | +## Worker side |
| 46 | + |
| 47 | +### Source of truth: the syncer |
| 48 | + |
| 49 | +Worker records in Valkey are not created or deleted by application |
| 50 | +calls — they're driven entirely by the Kubernetes pod lifecycle of |
| 51 | +worker pods. The `WorkerPoolSyncer` (`cmd/ateapi/internal/controlapi/syncer.go`) |
| 52 | +watches worker pods via a K8s informer and reflects pod events into |
| 53 | +the store: |
| 54 | + |
| 55 | +- Pod **Added** (eligible) → `CreateWorker` |
| 56 | +- Pod **Updated** (IP changed; in practice unreachable) → `UpdateWorker` |
| 57 | +- Pod **Deleted** (or `DeletionTimestamp` set) → |
| 58 | + `releaseActorOnDeadWorker` (resets the actor bound to the dying |
| 59 | + worker, if any) followed by `DeleteWorker` |
| 60 | + |
| 61 | +The syncer is the only writer for worker create/delete; the lifecycle |
| 62 | +workflows below only mutate the **assignment** fields |
| 63 | +(`actor_id` / `actor_namespace` / `actor_template`) of existing |
| 64 | +worker records. |
| 65 | + |
| 66 | +### Worker cache |
| 67 | + |
| 68 | +The `cmd/ateapi/internal/workercache` package keeps an in-process |
| 69 | +mirror of all worker records inside every `ate-api-server` pod. The |
| 70 | +cache exists so `AssignWorkerStep` does not pay an O(N) `ListWorkers` |
| 71 | +scan against Valkey on every actor resume — it reads `Workers()` in |
| 72 | +microseconds. |
| 73 | + |
| 74 | +How it stays in sync (list + watch + relist): |
| 75 | + |
| 76 | +1. **Initial sync.** On startup, `Cache.Start` subscribes to |
| 77 | + `WatchWorkers` (Valkey pub/sub), then runs `ListWorkers` once to |
| 78 | + populate the map. `Workers()` returns "not ready" until this |
| 79 | + completes. |
| 80 | +2. **Live updates.** Every `CreateWorker` / `UpdateWorker` / |
| 81 | + `DeleteWorker` against the store publishes a `WorkerEvent` on the |
| 82 | + `worker-changes` channel. The cache's subscriber goroutine |
| 83 | + receives events and applies them to the map. Events are compared |
| 84 | + by `version`; a stale event cannot overwrite a fresher cache |
| 85 | + entry. |
| 86 | +3. **Periodic relist.** Re-runs `ListWorkers` on a schedule to catch |
| 87 | + anything pub/sub missed (slow consumer, dropped events, transient |
| 88 | + subscription drop). |
| 89 | +4. **Disconnect resync.** If the subscriber's channel closes, the |
| 90 | + cache marks itself not-ready and re-runs the initial sync with |
| 91 | + exponential backoff (1 s → 30 s cap, 5 attempts). |
| 92 | + |
| 93 | +The cache is **per API-server pod**. Each pod independently |
| 94 | +subscribes to the cluster-wide pub/sub channel, so all pods see the |
| 95 | +same events. Pods stay eventually-consistent with each other on the |
| 96 | +order of pub/sub latency (sub-second steady state). |
| 97 | + |
| 98 | +`Workers()` returns pointers directly into the cache map. Callers |
| 99 | +that need to mutate a worker proto (e.g. setting `actor_id` during |
| 100 | +assignment) must `proto.Clone` first to avoid corrupting the cache. |
| 101 | + |
| 102 | +### Eligibility — which workers can run which actor |
| 103 | + |
| 104 | +Not every free worker can run any actor. The scheduler filters via |
| 105 | +**`eligibleWorkerPools`** (in |
| 106 | +`cmd/ateapi/internal/controlapi/workflow_resume.go`), which intersects |
| 107 | +three constraints: |
| 108 | + |
| 109 | +1. **Sandbox class match.** Snapshots are not portable across sandbox |
| 110 | + classes (gvisor, microvm, etc.). The pool's `Spec.SandboxClass` |
| 111 | + must equal the template's `Spec.SandboxClass`. Hard gate. |
| 112 | +2. **Template's worker-selector.** A K8s `LabelSelector` on the |
| 113 | + `ActorTemplate.Spec.WorkerSelector` that must match the pool's |
| 114 | + labels. |
| 115 | +3. **Actor's worker-selector.** A `Selector` (match-labels only) on |
| 116 | + the `Actor.WorkerSelector` field, set at CreateActor and |
| 117 | + updatable. Also matched against the pool's labels. |
| 118 | + |
| 119 | +The result is a set of `(namespace, name)` pairs of eligible worker |
| 120 | +pools. `AssignWorkerStep` then picks a free worker whose |
| 121 | +`(WorkerNamespace, WorkerPool)` is in that set. |
| 122 | + |
| 123 | +Note: today eligibility is **per-pool**, not per-individual-worker. |
| 124 | +There is no per-worker label scheduling yet — every worker in an |
| 125 | +eligible pool is treated equivalently except for the locality |
| 126 | +preference (next section). |
| 127 | + |
| 128 | +### Locality preference |
| 129 | + |
| 130 | +Within the eligible-pool set, the scheduler prefers a worker on a |
| 131 | +node that already has the actor's local snapshot. The actor's |
| 132 | +`latest_snapshot_info.local.node_vms_with_local_snapshots` carries |
| 133 | +the list of node IDs that hold the snapshot; `findFreeWorker` |
| 134 | +filters candidate workers to that node set if non-empty. If no |
| 135 | +candidate workers are on a matching node, the resume falls back to |
| 136 | +any free worker in the eligible pool set (and the snapshot will be |
| 137 | +pulled from durable storage during `Restore`). |
| 138 | + |
| 139 | +## The four workflows |
| 140 | + |
| 141 | +All four workflows live in `cmd/ateapi/internal/controlapi/` and are |
| 142 | +orchestrated via a generic step engine in `workflow.go`. Resume, |
| 143 | +Suspend, and Pause all acquire a per-actor distributed lock |
| 144 | +(`lock:actor:<id>`, 30 s TTL, 28 s workflow timeout) before running |
| 145 | +their step sequence. |
| 146 | + |
| 147 | +### CreateActor |
| 148 | + |
| 149 | +Trigger: client API call. No lock; relies on storage-level CAS. |
| 150 | + |
| 151 | +Steps: |
| 152 | + |
| 153 | +1. Validate request, fetch the `ActorTemplate` from the K8s lister. |
| 154 | +2. Construct an `Actor` proto with `version=1`, |
| 155 | + `status=STATUS_SUSPENDED`. |
| 156 | +3. `CreateActor` (storage `SET NX`) — returns `ErrAlreadyExists` if |
| 157 | + the id is taken. |
| 158 | +4. `GetActor` to return the stored record. |
| 159 | + |
| 160 | +Failure modes covered in [`operations.md`](./operations.md). |
| 161 | + |
| 162 | +### ResumeActor (SUSPENDED or PAUSED → RUNNING) |
| 163 | + |
| 164 | +Trigger: client API call. Acquires `lock:actor:<id>`. |
| 165 | + |
| 166 | +Steps: |
| 167 | + |
| 168 | +1. **LoadActorForResume** — `GetActor` + `Get ActorTemplate`. |
| 169 | +2. **AssignWorker** — compute `eligibleWorkerPools`, read |
| 170 | + `workerCache.Workers()`, look for an existing assignment from a |
| 171 | + previous failed attempt (idempotency), otherwise call |
| 172 | + `findFreeWorker` (filtered to eligible pools + node-locality |
| 173 | + preference) and pick a random match. Update worker (CAS) with |
| 174 | + the new assignment, update actor to `RESUMING` with pod |
| 175 | + coordinates. |
| 176 | +3. **CallAteletRestore** — gRPC `Restore` (or `Run` if no snapshot |
| 177 | + exists) against the chosen worker's atelet. The atelet either |
| 178 | + loads from the local cache (if the node has the snapshot) or |
| 179 | + pulls from durable storage. |
| 180 | +4. **FinalizeRunning** — refresh actor record, set status `RUNNING` |
| 181 | + (CAS). |
| 182 | + |
| 183 | +A successful resume ends with actor `RUNNING`, worker `actor_id` = |
| 184 | +this actor, the worker record reflecting the assignment, and a pub/sub |
| 185 | +event from the `UpdateWorker` so other API server pods' caches see |
| 186 | +the change. |
| 187 | + |
| 188 | +`AssignWorker` has exponential backoff (5 attempts) on CAS conflicts. |
| 189 | +`FinalizeRunning` does not — a single CAS conflict here strands the |
| 190 | +actor in `RESUMING` and requires an external retry. |
| 191 | + |
| 192 | +### SuspendActor (RUNNING → SUSPENDED) |
| 193 | + |
| 194 | +Trigger: client API call. Acquires `lock:actor:<id>`. |
| 195 | + |
| 196 | +Steps: |
| 197 | + |
| 198 | +1. **LoadActorForSuspend**. |
| 199 | +2. **MarkSuspending** — update actor to `SUSPENDING`, record an |
| 200 | + `in_progress_snapshot` URI (constructed from template's snapshot |
| 201 | + location + actor id + timestamp). |
| 202 | +3. **CallAteletSuspend** — gRPC `Checkpoint` to the actor's atelet. |
| 203 | + The atelet writes the snapshot to durable storage at the |
| 204 | + `in_progress_snapshot` URI. If the worker pod has vanished |
| 205 | + (`ErrWorkerPodNotFound`), the step skips with a warning — the |
| 206 | + actor record will still be transitioned, but no fresh snapshot |
| 207 | + is written. |
| 208 | +4. **FinalizeSuspended** — refresh actor, release the worker |
| 209 | + (`UpdateWorker` clearing `actor_id`), set actor status |
| 210 | + `SUSPENDED`, promote `in_progress_snapshot` to the |
| 211 | + `latest_snapshot_info.external` field, clear pod coordinates. |
| 212 | + |
| 213 | +Suspend ends with the worker free (pub/sub event broadcast) and the |
| 214 | +actor pointing at a fresh durable snapshot. Suspend is the only path |
| 215 | +that produces a durably-stored snapshot. |
| 216 | + |
| 217 | +### PauseActor (RUNNING → PAUSED) |
| 218 | + |
| 219 | +Trigger: client API call. Acquires `lock:actor:<id>`. |
| 220 | + |
| 221 | +Steps mirror Suspend but the snapshot is **kept local on the node** |
| 222 | +rather than uploaded to durable storage: |
| 223 | + |
| 224 | +1. **LoadActorForPause**. |
| 225 | +2. **MarkPausing** — update actor to `PAUSING`. |
| 226 | +3. **CallAteletPause** — gRPC `Pause` to the actor's atelet, which |
| 227 | + snapshots in-place on the node. |
| 228 | +4. **FinalizePaused** — release the worker, set actor status |
| 229 | + `PAUSED`, record the node in |
| 230 | + `latest_snapshot_info.local.node_vms_with_local_snapshots`, |
| 231 | + clear pod coordinates. |
| 232 | + |
| 233 | +Pause is faster than Suspend (no network upload) and produces no |
| 234 | +durable artifact. A subsequent ResumeActor will prefer a worker on a |
| 235 | +node in the local-snapshot set; if none are available, the resume |
| 236 | +falls back to a worker without locality and the snapshot is |
| 237 | +unavailable (PAUSED actors that lose their node go to |
| 238 | +`SUSPENDED`-without-snapshot via syncer-driven release; the actor |
| 239 | +record's snapshot fields no longer reference a valid blob). |
| 240 | + |
| 241 | +### DeleteActor (SUSPENDED → ∅) |
| 242 | + |
| 243 | +Trigger: client API call. No lock; relies on storage-level CAS with |
| 244 | +status precondition. |
| 245 | + |
| 246 | +`DeleteActor` does a `WATCH`/`GET`/check `status == SUSPENDED`/ |
| 247 | +`MULTI`/`DEL` against the actor key. Returns |
| 248 | +`ErrFailedPrecondition` if the actor is not SUSPENDED; |
| 249 | +`ErrPersistenceRetry` if another writer changed the key during the |
| 250 | +check. |
| 251 | + |
| 252 | +Snapshot URIs in the actor record are **not cleaned up** by |
| 253 | +`DeleteActor` — durable snapshots leak in object storage. Tracked |
| 254 | +under operations risks. |
| 255 | + |
| 256 | +## Worker lifecycle, end to end |
| 257 | + |
| 258 | +Putting the pieces together for a typical "worker pod gets created, |
| 259 | +gets used, gets deleted" cycle: |
| 260 | + |
| 261 | +1. **Pod created** in K8s (manually or by an autoscaler controller). |
| 262 | +2. K8s informer in syncer sees the Add event, calls `CreateWorker` |
| 263 | + on the store. |
| 264 | +3. `CreateWorker` writes to Valkey AND publishes |
| 265 | + `WorkerEvent{Created, worker}` on `worker-changes`. |
| 266 | +4. Every API-server pod's worker cache receives the event and adds |
| 267 | + the worker to its map. Worker is now visible to all schedulers. |
| 268 | +5. A `ResumeActor` call lands; `AssignWorker` reads |
| 269 | + `workerCache.Workers()`, picks this worker, `UpdateWorker` is |
| 270 | + called with the new assignment. |
| 271 | +6. `UpdateWorker` writes to Valkey AND publishes |
| 272 | + `WorkerEvent{Updated, worker}`. Caches everywhere update. |
| 273 | +7. Actor lifecycle continues; eventually `SuspendActor` releases the |
| 274 | + worker via another `UpdateWorker`/`WorkerEvent{Updated}` and the |
| 275 | + worker is idle again. |
| 276 | +8. Steps 5–7 may repeat many times — the worker is reused across |
| 277 | + actors. |
| 278 | +9. Eventually the pod is deleted (scale-down, node maintenance, |
| 279 | + crash). K8s informer sees the Delete event, syncer calls |
| 280 | + `releaseActorOnDeadWorker` then `DeleteWorker`. |
| 281 | +10. `DeleteWorker` removes the key from Valkey AND publishes |
| 282 | + `WorkerEvent{Deleted, worker}`. Caches everywhere remove the |
| 283 | + worker. Worker is no longer visible to any scheduler. |
| 284 | + |
| 285 | +Pub/sub is the load-bearing mechanism keeping all caches in step |
| 286 | +with reality. Operational concerns around this — missed events, |
| 287 | +broadcast amplification at scale, subscriber backpressure — are in |
| 288 | +[`operations.md`](./operations.md). |
0 commit comments