Skip to content

Commit c9913ee

Browse files
committed
Add Valkey operations handbook
Operational reference for the Valkey persistence tier and the in-process worker cache that sits in front of it. Written for operators, on-call engineers, and contributors who need to reason about the storage tier without first reading the code. README.md — entry point, scope, conventions topology.md — what's deployed, sizing math, configuration knobs lifecycle.md — actor state machine, four workflows, worker cache and eligibility model operations.md — failure modes with inline recovery, common admin operations, open risks
1 parent 505b3a4 commit c9913ee

4 files changed

Lines changed: 1234 additions & 0 deletions

File tree

docs/valkey/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Valkey Operations Handbook
2+
3+
Operational reference for Agent Substrate's persistence tier — the Valkey
4+
cluster that stores actor and worker state, plus the in-process worker
5+
cache that sits in front of it inside `ate-api-server`.
6+
7+
This handbook is for operators, on-call engineers, and contributors who
8+
need to reason about the storage tier without first reading the code. It
9+
is intentionally narrow: it covers what's deployed, how state moves
10+
through it, and what to do when it breaks. It does not retell Valkey's
11+
own documentation.
12+
13+
## Pages
14+
15+
- [`topology.md`](./topology.md) — what's deployed, how the pieces fit
16+
together, sizing math for MVP and target scale, identity / TLS wiring,
17+
and the configuration knobs we have not set yet.
18+
- [`lifecycle.md`](./lifecycle.md) — the actor state machine, the four
19+
lifecycle workflows (Create, Resume, Suspend, Pause), the
20+
worker-cache subscription model, and the per-actor scheduling
21+
eligibility rules.
22+
- [`operations.md`](./operations.md) — failure modes with inline
23+
recovery for each, common admin operations, and the short list of
24+
open operational risks.
25+
26+
## Conventions
27+
28+
- Source citations use **path + symbol name** (e.g.
29+
`cmd/ateapi/internal/store/store.go` (`Interface`)), never line
30+
numbers. Line numbers drift on every refactor; symbol references
31+
survive.
32+
- Code examples assume `valkey-cli` is invoked with the full TLS flag
33+
set; for brevity the pages use a `vcli` alias defined in
34+
[`operations.md`](./operations.md).
35+
- Diagrams are mermaid blocks; GitHub renders them inline.
36+
37+
## Updating
38+
39+
Update the handbook whenever you learn something new about how the
40+
storage tier behaves — new failure modes, recovery steps that worked
41+
(or didn't) during a real incident, configuration changes, surprising
42+
behavior from upgrades. A handbook that captures the *why* of a past
43+
decision is more valuable than one that perfectly describes the
44+
current code, because the code is already self-describing.
45+
46+
Substantive changes belong in their own PR rather than folded into a
47+
feature change, so the doc review gets focused attention. Trivial
48+
touch-ups (renamed flag, updated path) can ride with the code change
49+
that prompted them.

docs/valkey/lifecycle.md

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
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

Comments
 (0)