SRD-070 — The instance checkpoint, save/restore and restart recovery¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-07-26 |
| Owner | Ruslan Gabitov |
| Implements | ADR-033 v.1 §2.1–§2.3, §2.5 (restart recovery), §2.7 (the Repository growth), §2.8 (the lease — carried in the record from this first slice), §2.9 |
| Upstream | ADR-001 v.6, ADR-013 v.2, ADR-017 v.1 |
The first ADR-033 slice: the canonical value codec, the checkpoint document, loop-side capture at lifecycle transitions, the Repository contract growth (CAS + lease + opaque payload), and restart recovery (re-enter-the-node semantics, overdue timers fire once). Dehydration/wake-on-trigger (SRD-071) and operator-driven suspend/resume build on this record.
§1 Background (the state inventory, verified)¶
- The runtime state splits three ways: the immutable per-instance
template (
snapshot.Snapshot— nodes/flows/properties/data-objects/ correlation keys,internal/instance/snapshot/snapshot.go:18), the loop-confined maps (loopState,internal/instance/loop.go:20—position,waiting,msgIdx,parked,tasks,jobs,calls,scopes,miGroups,ledgers,sweeps,conds), and the per-track fields (track,internal/instance/track.go:160—steps(position = last element,track.go:709),prevlineage,scopePath/scopeSeg,msgDefIDs,taskID,loopCounter,state). - The data plane is one map:
scope.Scope.scopes—DataPath → (name → data.Data)(internal/scope/scope.go:28); the scope tree is implicit in the path strings. Conversation keys live incorrelator.keys(internal/instance/correlation.go:21); the compensation ledger inloopState.ledgerswith per-entry data snapshots (ADR-026). - No value serialization exists:
data.ValueoffersGet/Update/Cloneonly (pkg/model/data/value.go:8); the concrete kinds arevalues.Variable[T],Array[T],Record,Map[T]; the only JSON in the model layer iserrs.ApplicationError. - The version is not stamped on the instance: it exists only on
ProcessRegistration(pkg/thresher/registration.go:16) — an instance/snapshot cannot name the version it runs (snapshot.Snapshotcarries no version field). - The Repository is a dormant slot: the engine never calls
Save/Load/ListInFlight;InstanceRecord.State/Statusare never populated (pkg/repository/repository.go:34, wired atpkg/thresher/options.go:122with no non-test consumer). - Timer deadlines are runtime-only: the absolute
nextlives in the livetimeWaitergoroutine (internal/eventproc/eventhub/waiters/timer.go:29) — nothing on the track records when a parked timer fires; re-entering a Duration timer would restart the duration (drift), which ADR-033 §2.1.5 forbids. InstanceHandle.Suspend/Resumeare reserved stubs (pkg/thresher/handle.go:127); the instance run-state enum isCreated/Active/Completed/Terminating/Terminated(internal/instance/lifecycle.go:20).
§2 Requirements¶
§2.1 Functional¶
- FR-1 — the version pin.
snapshot.SnapshotgainsVersion int, stamped byRegisterProcesswhen the registration mints it; child launches (invoker) andNewFromEventcarry it. The checkpoint names(ProcessID, Version); recovery resolves exactly that registered version or fails THAT instance loud (ObjectNotFound, naming both). - FR-2 — the canonical value codec (new package
internal/instance/checkpoint, unexported codec files). Encodes adata.Dataset into tagged JSON and back: scalars by kind tag (bool,string,float64+ the integer family as recorded Go kind,time.Timeas RFC3339Nano),Array/Record/Mapby structural traversal (theCollection/Record/Mapcapabilities), nested composition included. Decode rebuildsvalues.*kinds and re-wrapsParameter/ItemAwareElementwith their recorded name/state. An unencodable payload (a native-structVariable[T]without structural capabilities, a functor, a channel) is a loud classified error naming the scope path and datum — never a silent skip (ADR-033 §2.1.3). - FR-3 — the checkpoint document (schema-versioned,
Schema: 1): identity (instance id, parent instance id + call node id when a child), the FR-1 pin, status, the scope table (every openDataPath→ encoded data), conversation keys, the ledger (per scope path: ordinals + encoded snapshots + handler refs), and the track table — live tracks only (statesCreated/Ready/ WaitForEvent/AwaitSync): id, lineage (prev), scope path + segment, state, current node id,loopCounter,taskID,msgDefIDs, and the wait descriptor where one exists (this slice: the timer's absolute deadline + remaining cycles, captured at arming — the waiter's computednextrecorded through a new park-time hook). Ended/merged tracks are NOT persisted — their contribution lives in the scope data; join reachability recomputes from live positions. The record envelope carries the lease (owner engine id + incarnation + expiry) and the record version for CAS (ADR-033 §2.8 — present from this slice; single-engine semantics exercise it, multi-engine contention tests ride SRD-071+). - FR-4 — capture on the loop. A
checkpoint()step on the instance loop builds the document fromloopState+ tracks + scope (goroutine-confined = consistent cut, no locks) after applying: instance activation,evEnded(a node completed), the wait parks (evWaiting/evTaskWaiting/evJobWaiting), scope open/close, and the terminal transitions. Unsupported in-flight constructs defer the checkpoint loudly, not fatally: non-emptycalls,miGroupsorsweepsskip the write and emit the degradation fact (FR-8) — the instance runs on volatile; the next supported transition retries. (Full-fidelity capture of those constructs is SRD-071+ territory; refusing silently or killing the instance are both wrong.) - FR-5 — the Repository growth (
pkg/repository):InstanceRecordbecomes{ID, Status, Payload []byte, RecVersion int64, Lease{Owner string, Incarnation int64, Expiry time.Time}};StatusgainsStatusSuspended(reserved for suspend/resume);Savebecomes CAS (RecVersionmust match the stored one; mismatch is a classified error under a newerrs.ConcurrentUpdateclass constant (the CAS-conflict vocabulary the fencing checks match on));ListInFlightreturns non-terminal records with expired-or-absent leases only.memrepoimplements the full contract (value copies, no more store-by-reference). - FR-6 — restore.
instance.Restore(doc, snapshot, deps)rebuilds an instance without running start seeding: reopen the recorded scope paths, decode + recommit the data, rebuild correlator keys and ledgers, recreate the live tracks at their recorded nodes (re-enter semantics — each track respawns entering its current node fresh: subscriptions re-register from the node's definitions, a user task re-announces under a fresh task id, a job re-enqueues — the ADR-033 §2.3 at-least-once effects; a timer wait re-arms at the RECORDED deadline, not a re-evaluated one — overdue fires once, immediately; remaining cycles continue from the recorded count). - FR-7 — restart recovery.
thresher.Run(after the hub starts):ListInFlight→ per instance: claim the lease (CAS),Load, decode, resolve the pinned registered version (FR-1),Restore, run. Every failure is per-instance and loud (an operator-visible fact + the instance left unclaimed/failed) and never blocks the rest. An engine with no repository configured (the zero-config default has memrepo — in-process, empty at boot) recovers nothing and starts clean. - FR-8 — observability (ADR-013 v.2 open phases; lifecycle
volume):
KindInstanceStategainsRecovered(Info — recovery completed for an instance, details: process id/version, live-track count) andCheckpointDeferred(Warn override — the FR-4 degradation, details naming the deferring construct). No per-checkpoint fact (ADR-033 §2.9).
§2.2 Non-functional¶
- NFR-1 — checkpoint capture runs on the loop: the write itself is handed off (the ADR-033 §2.2 write mode; this slice ships the synchronous default only, the policy seam arrives when a second mode exists).
- NFR-2 — validate-all-params; no
Must*in library paths; loud classified errors per the errs idiom. - NFR-3 — masking: checkpoint facts carry names/counts, never payload values.
- NFR-4 — coverage:
make cigreen; diff-coverage ≥95% (aim 100%); touched functions ≥80%.
§3 Models (shapes)¶
// internal/instance/checkpoint — the document (Schema 1)
type Document struct {
Schema int
InstanceID string
ParentID string // + CallNodeID — child linkage, informational
CallNodeID string
ProcessID string
Version int // the FR-1 pin
Status string
Scopes []ScopeRecord // path + encoded data
ConvKeys map[string]string
Ledgers []LedgerRecord // path, ordinal, handler ref, snapshot
Tracks []TrackRecord // live tracks only
}
type TrackRecord struct {
ID, State, NodeID string
Prev []string
ScopePath, ScopeSeg string
LoopCounter int
TaskID string
MsgDefIDs []string
Timer *TimerDescriptor // deadline + cycles left, nil unless timer-parked
}
// pkg/repository — the grown contract (FR-5)
type Lease struct{ Owner string; Incarnation int64; Expiry time.Time }
type InstanceRecord struct {
ID string
Status Status // + StatusSuspended
Payload []byte // the schema-versioned document, opaque
RecVersion int64 // CAS
Lease Lease
}
Worked trace (T-6's shape): a process parks on a 2h Duration timer →
the park transition checkpoints (deadline recorded absolute); the
engine stops; a new engine over the same repository starts →
ListInFlight → claim → restore → the track re-enters the timer node
and re-arms at the recorded deadline; InstanceState/Recovered
observed; the clock advances past the deadline → the timer fires once
→ the instance completes. The same trace with the deadline already
past at recovery: the timer fires immediately, once.
§4 Analysis & decisions¶
- §4.1 Live-tracks-only persistence. Ended/merged tracks exist for the history projection; their execution effect is already in the committed scope data. Persisting them would double the document for a projection that recovery legitimately resets (history restarts at recovery — documented).
- §4.2 Re-enter-the-node, plus one recorded exception. Re-entering
reuses the nodes' own arming code (no parallel restore-arming
logic) and realizes at-least-once effects. The single exception is
the timer deadline — re-evaluation would restart Durations, so the
descriptor overrides the waiter's computed
next(ADR-033 §2.5's overdue-fires-once needs the original deadline). - §4.3 Defer-don't-die on unsupported constructs. Killing an instance because its checkpoint cannot capture an in-flight MI group would make persistence a regression; silently skipping would fake durability. The Warn fact + retry-next-transition keeps execution intact and the degradation visible until SRD-071+ closes the fidelity gap.
- §4.4 The codec lives with the checkpoint, not on the values.
Adding
MarshalJSONtovalues.*would freeze a wire format into the public value API; the checkpoint codec owns the tagged format privately and can version it with the document schema. -
§4.5 The lease rides from day one (ADR-033 §2.8): the record shape with CAS + lease is the contract every adapter implements once — retrofitting fencing later would be a breaking storage migration.
-
§4.6 The hub is derived state. No hub-side registration is ever checkpointed; recovery rebuilds the hub by re-entering the wait nodes (fresh hub, same arming code). Per kind: message — re-registers under the same catch-definition ids with the restored conversation keys; crash-window deliveries return via sender retry + correlation dedup (§2.3). Timer — re-arms at the recorded deadline (the §4.2 exception); nothing depends on lost hub state. Conditional — self-healing: re-arming re-runs the initial evaluation over restored data, so a condition that turned true during downtime fires immediately on recovery. Signal — ephemeral by the standard's broadcast semantics: a signal in the crash window or during downtime is lost exactly as for a late-arming catcher in normal operation (a durable inbox is deliberately out of scope). Instantiating starters — process- level, rebuilt at engine boot when definitions re-register, which is why recovery runs after the hub starts (FR-7). In-flight hub buffers — volatile, covered by the same at-least-once posture.
§6 Test scenarios¶
| # | Test | Verifies |
|---|---|---|
| T-1 | codec unit (internal/instance/checkpoint) |
FR-2: round-trip for every canonical kind incl. nesting (record-in-map, array-of-records), integer-family fidelity, time precision; loud on an uncodable payload naming path+datum |
| T-2 | document capture (internal/instance) |
FR-3/FR-4: a parked instance's document carries scopes/keys/ledger/live tracks + the timer descriptor; ended tracks absent; deferral on in-flight calls/MI/sweeps emits CheckpointDeferred and skips the write |
| T-3 | repository contract (pkg/repository/memrepo) |
FR-5: CAS accept/reject, lease fields round-trip, ListInFlight honors terminal status and live leases, value-copy semantics |
| T-4 | restore unit (internal/instance) |
FR-6: scopes/data/keys/ledger rebuilt equal; live tracks respawn at their nodes; version mismatch fails loud |
| T-5 | version pin (pkg/thresher) |
FR-1: the snapshot carries the registration's version; recovery against an unregistered version fails that instance loud, others recover |
| T-6 | restart recovery e2e (pkg/thresher) |
FR-7/FR-8: park on timer → stop → new engine, same repo → Recovered fact → the timer fires at the recorded deadline (and the overdue variant fires once immediately); a user-task park recovers by re-announce; a message park re-subscribes and completes on send; a conditional wait whose condition turned true during downtime fires on recovery (§4.6) |
| T-7 | lease/CAS (pkg/thresher + memrepo) |
FR-5/FR-7: recovery claims; a second engine's stale save is rejected (fenced) |
§7 Milestones¶
- M1 — the codec. FR-2; T-1.
feat(checkpoint): the canonical value codec (SRD-070 M1). - M2 — the document, the pin, the repository growth. FR-1/FR-3/
FR-5; T-3 + the capture halves of T-2.
feat(checkpoint): the checkpoint document and the grown Repository (SRD-070 M2). - M3 — loop capture + facts. FR-4/FR-8; T-2 complete.
feat(instance): consistent-cut checkpoints on the loop (SRD-070 M3). - M4 — restore + restart recovery + e2e. FR-6/FR-7; T-4…T-7;
CHANGELOG + README notes; the restart-recovery example
(
examples/restart-recovery— two engines over one shared memrepo in one process demonstrate the crash/recover trace).feat(thresher): restart recovery over the Repository (SRD-070 M4).
§8 Cross-doc¶
- Implements ADR-033 v.1 §2.1–§2.3/§2.5/§2.7–§2.9 (first slice).
- Upstream: ADR-001 v.6, ADR-013 v.2, ADR-017 v.1.
- SRD-071 (dehydration/wake-on-trigger) and operator-driven suspend/resume build on this record; #84 (timer durability) closes with the timer-descriptor recovery landing here + SRD-071. (Suspend/resume was written here as "SRD-072"; that number was taken by typed value extraction before the doc was written, so the work is named rather than numbered — the only edit made to this accepted document, because the reference pointed a reader at an unrelated SRD.)
§9 Definition of Done¶
- [x] FR-1…FR-8 implemented; every §6 test exists and passes.
- [x]
make cigreen; diff-coverage ≥95% (aim 100%); touched functions ≥80%. - [x] The example runs (crash/recover trace, exit 0); READMEs/examples
index/CHANGELOG synced (+ the new
docs/guides/operating/persistence.md, refitted into the developer manual at the master merge;extending/repository.mdsynced to the grown contract). - [x] §10 filled.
§10 Implementation summary¶
Landed on feat/persistence-state in four milestones, as planned:
| Milestone | Commit | Scope |
|---|---|---|
| M1 | d0acad5 |
FR-2: the canonical value codec (tagged JSON, typed composite rebuilders); T-1 |
| M2 | 9c855b4 |
FR-1/FR-3/FR-5: the version pin, the Schema-1 document, errs.ConcurrentUpdate, the grown Repository + memrepo (CAS, lease, copies); T-3 + the schema/pin halves |
| M3 | 006932e |
FR-4/FR-8: loop-side consistent-cut capture (WithCheckpointing), scope.OpenPaths/OwnData, waiters.TimerPlan + the park-time stash, defer-don't-die, CheckpointDeferred/Recovered phases; T-2 |
| M4 | 7e13c69 |
FR-6/FR-7: instance.Restore (re-enter semantics), the waiters.DeadlineHinter seam, WithRepository-armed checkpointing + WithLeaseTTL, restart recovery in Run with CAS claims; T-4…T-7; the example, the persistence guide, README/CHANGELOG sync |
- Verification: post-M4
make ciexit 0 (incl.-race); diff-coverage 95.1% of 932 changed lines (min 95%); theexamples/restart-recoverysmoke exit 0. - Deltas vs the draft — engine notes:
- Stable element ids are part of deployment parity. Recovery
resolves recorded node ids against the recovering engine's
registration; models built from the same source WITHOUT pinned
ids (
foundation.WithID/ a serialized model) mint different ids and recovery refuses loud. Recorded in the tests, the example and the guide; the BPMN-XML workstream supplies persistent ids naturally. - The timer hint replaces parsing entirely (not just the computed deadline): a past absolute Time would fail the parse validation, so a hinted waiter never parses.
- The stash rides before the wait declaration:
evWaitinglets the loop checkpoint immediately, so the descriptor must exist before the emit (an ordering race caught under-race). - Capture reads ride the track mutex (both sides): the loop captures while a track goroutine may be mid-arming.
- The checkpoint arms only under an EXPLICIT
WithRepository(repoSet) — the zero-config default stays volatile with zero overhead, sharpening FR-4's arming story. - Scope note: ledger
foldednesting flattens into sibling records (full folded fidelity rides SRD-071+), as §4 planned.
Open questions¶
None — §4 resolves the design points inline.
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 | 2026-07-26 | Ruslan Gabitov | Initial draft — ADR-033's first slice: the canonical value codec (tagged JSON over the structural capabilities; loud on uncodable payloads), the Schema-1 checkpoint document (scopes, conversation keys, ledger, live tracks with the timer deadline descriptor; ended tracks excluded), the version pin onto the snapshot (closing the restore-attribution gap), the grown Repository contract (opaque payload, CAS record version, the §2.8 lease from day one, StatusSuspended reserved), loop-side consistent-cut capture with defer-don't-die on in-flight calls/MI/sweeps (CheckpointDeferred at Warn), restore with re-enter-the-node semantics (one exception: timers re-arm at the recorded deadline, overdue fires once), restart recovery in thresher.Run (per-instance loud failures, Recovered at Info), and the two-engines-one-repo example. |