@smthrs/run-store 0.0.0-stage → 1.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +21 -0
  3. package/README.md +195 -2
  4. package/dist/cjs/AttemptStore.d.ts +359 -0
  5. package/dist/cjs/AttemptStore.d.ts.map +1 -0
  6. package/dist/cjs/AttemptStore.js +731 -0
  7. package/dist/cjs/AttemptStore.js.map +7 -0
  8. package/dist/cjs/Heartbeat.d.ts +46 -0
  9. package/dist/cjs/Heartbeat.d.ts.map +1 -0
  10. package/dist/cjs/Heartbeat.js +28 -0
  11. package/dist/cjs/Heartbeat.js.map +7 -0
  12. package/dist/cjs/Migrations.d.ts +42 -0
  13. package/dist/cjs/Migrations.d.ts.map +1 -0
  14. package/dist/cjs/Migrations.js +55 -0
  15. package/dist/cjs/Migrations.js.map +7 -0
  16. package/dist/cjs/Ownership.d.ts +275 -0
  17. package/dist/cjs/Ownership.d.ts.map +1 -0
  18. package/dist/cjs/Ownership.js +129 -0
  19. package/dist/cjs/Ownership.js.map +7 -0
  20. package/dist/cjs/RunStore.d.ts +571 -0
  21. package/dist/cjs/RunStore.d.ts.map +1 -0
  22. package/dist/cjs/RunStore.js +1079 -0
  23. package/dist/cjs/RunStore.js.map +7 -0
  24. package/dist/cjs/RunStoreMetrics.d.ts +151 -0
  25. package/dist/cjs/RunStoreMetrics.d.ts.map +1 -0
  26. package/dist/cjs/RunStoreMetrics.js +91 -0
  27. package/dist/cjs/RunStoreMetrics.js.map +7 -0
  28. package/dist/cjs/index.d.ts +47 -0
  29. package/dist/cjs/index.d.ts.map +1 -0
  30. package/dist/cjs/index.js +43 -0
  31. package/dist/cjs/index.js.map +7 -0
  32. package/dist/cjs/internal/Boundary.d.ts +80 -0
  33. package/dist/cjs/internal/Boundary.d.ts.map +1 -0
  34. package/dist/cjs/internal/Boundary.js +92 -0
  35. package/dist/cjs/internal/Boundary.js.map +7 -0
  36. package/dist/cjs/internal/Companion.d.ts +44 -0
  37. package/dist/cjs/internal/Companion.d.ts.map +1 -0
  38. package/dist/cjs/internal/Companion.js +50 -0
  39. package/dist/cjs/internal/Companion.js.map +7 -0
  40. package/dist/cjs/internal/SpanOutcome.d.ts +37 -0
  41. package/dist/cjs/internal/SpanOutcome.d.ts.map +1 -0
  42. package/dist/cjs/internal/SpanOutcome.js +62 -0
  43. package/dist/cjs/internal/SpanOutcome.js.map +7 -0
  44. package/dist/cjs/migrations/0001_initial.d.ts +15 -0
  45. package/dist/cjs/migrations/0001_initial.d.ts.map +1 -0
  46. package/dist/cjs/migrations/0001_initial.js +110 -0
  47. package/dist/cjs/migrations/0001_initial.js.map +7 -0
  48. package/dist/cjs/migrations/0002_lineage.d.ts +27 -0
  49. package/dist/cjs/migrations/0002_lineage.d.ts.map +1 -0
  50. package/dist/cjs/migrations/0002_lineage.js +43 -0
  51. package/dist/cjs/migrations/0002_lineage.js.map +7 -0
  52. package/dist/cjs/migrations/0003_execution_revisions.d.ts +15 -0
  53. package/dist/cjs/migrations/0003_execution_revisions.d.ts.map +1 -0
  54. package/dist/cjs/migrations/0003_execution_revisions.js +77 -0
  55. package/dist/cjs/migrations/0003_execution_revisions.js.map +7 -0
  56. package/dist/cjs/migrations/0004_waiting_request.d.ts +32 -0
  57. package/dist/cjs/migrations/0004_waiting_request.d.ts.map +1 -0
  58. package/dist/cjs/migrations/0004_waiting_request.js +42 -0
  59. package/dist/cjs/migrations/0004_waiting_request.js.map +7 -0
  60. package/dist/cjs/package.json +1 -0
  61. package/dist/cjs/test/TestRunStore.d.ts +17 -0
  62. package/dist/cjs/test/TestRunStore.d.ts.map +1 -0
  63. package/dist/cjs/test/TestRunStore.js +42 -0
  64. package/dist/cjs/test/TestRunStore.js.map +7 -0
  65. package/dist/esm/AttemptStore.d.ts +359 -0
  66. package/dist/esm/AttemptStore.d.ts.map +1 -0
  67. package/dist/esm/AttemptStore.js +835 -0
  68. package/dist/esm/AttemptStore.js.map +1 -0
  69. package/dist/esm/Heartbeat.d.ts +46 -0
  70. package/dist/esm/Heartbeat.d.ts.map +1 -0
  71. package/dist/esm/Heartbeat.js +46 -0
  72. package/dist/esm/Heartbeat.js.map +1 -0
  73. package/dist/esm/Migrations.d.ts +42 -0
  74. package/dist/esm/Migrations.d.ts.map +1 -0
  75. package/dist/esm/Migrations.js +56 -0
  76. package/dist/esm/Migrations.js.map +1 -0
  77. package/dist/esm/Ownership.d.ts +275 -0
  78. package/dist/esm/Ownership.d.ts.map +1 -0
  79. package/dist/esm/Ownership.js +299 -0
  80. package/dist/esm/Ownership.js.map +1 -0
  81. package/dist/esm/RunStore.d.ts +571 -0
  82. package/dist/esm/RunStore.d.ts.map +1 -0
  83. package/dist/esm/RunStore.js +1287 -0
  84. package/dist/esm/RunStore.js.map +1 -0
  85. package/dist/esm/RunStoreMetrics.d.ts +151 -0
  86. package/dist/esm/RunStoreMetrics.d.ts.map +1 -0
  87. package/dist/esm/RunStoreMetrics.js +120 -0
  88. package/dist/esm/RunStoreMetrics.js.map +1 -0
  89. package/dist/esm/index.d.ts +47 -0
  90. package/dist/esm/index.d.ts.map +1 -0
  91. package/dist/esm/index.js +47 -0
  92. package/dist/esm/index.js.map +1 -0
  93. package/dist/esm/internal/Boundary.d.ts +80 -0
  94. package/dist/esm/internal/Boundary.d.ts.map +1 -0
  95. package/dist/esm/internal/Boundary.js +109 -0
  96. package/dist/esm/internal/Boundary.js.map +1 -0
  97. package/dist/esm/internal/Companion.d.ts +44 -0
  98. package/dist/esm/internal/Companion.d.ts.map +1 -0
  99. package/dist/esm/internal/Companion.js +53 -0
  100. package/dist/esm/internal/Companion.js.map +1 -0
  101. package/dist/esm/internal/SpanOutcome.d.ts +37 -0
  102. package/dist/esm/internal/SpanOutcome.d.ts.map +1 -0
  103. package/dist/esm/internal/SpanOutcome.js +48 -0
  104. package/dist/esm/internal/SpanOutcome.js.map +1 -0
  105. package/dist/esm/migrations/0001_initial.d.ts +15 -0
  106. package/dist/esm/migrations/0001_initial.d.ts.map +1 -0
  107. package/dist/esm/migrations/0001_initial.js +88 -0
  108. package/dist/esm/migrations/0001_initial.js.map +1 -0
  109. package/dist/esm/migrations/0002_lineage.d.ts +27 -0
  110. package/dist/esm/migrations/0002_lineage.d.ts.map +1 -0
  111. package/dist/esm/migrations/0002_lineage.js +33 -0
  112. package/dist/esm/migrations/0002_lineage.js.map +1 -0
  113. package/dist/esm/migrations/0003_execution_revisions.d.ts +15 -0
  114. package/dist/esm/migrations/0003_execution_revisions.d.ts.map +1 -0
  115. package/dist/esm/migrations/0003_execution_revisions.js +58 -0
  116. package/dist/esm/migrations/0003_execution_revisions.js.map +1 -0
  117. package/dist/esm/migrations/0004_waiting_request.d.ts +32 -0
  118. package/dist/esm/migrations/0004_waiting_request.d.ts.map +1 -0
  119. package/dist/esm/migrations/0004_waiting_request.js +37 -0
  120. package/dist/esm/migrations/0004_waiting_request.js.map +1 -0
  121. package/dist/esm/test/TestRunStore.d.ts +17 -0
  122. package/dist/esm/test/TestRunStore.d.ts.map +1 -0
  123. package/dist/esm/test/TestRunStore.js +19 -0
  124. package/dist/esm/test/TestRunStore.js.map +1 -0
  125. package/docs/README.md +142 -0
  126. package/docs/api.md +901 -0
  127. package/docs/concepts/durable-values.md +140 -0
  128. package/docs/concepts/execution-revisions.md +53 -0
  129. package/docs/concepts/fencing.md +146 -0
  130. package/docs/concepts/leases.md +154 -0
  131. package/docs/concepts/liveness-evidence.md +142 -0
  132. package/docs/guides/cancel-a-run.md +156 -0
  133. package/docs/guides/claim-and-finish-a-run.md +175 -0
  134. package/docs/guides/compose-the-stores.md +142 -0
  135. package/docs/guides/observe-outcomes.md +182 -0
  136. package/docs/guides/record-step-attempts.md +170 -0
  137. package/docs/guides/recover-a-stalled-run.md +173 -0
  138. package/docs/guides/testing.md +141 -0
  139. package/docs/installation.md +109 -0
  140. package/docs/quickstart.md +201 -0
  141. package/docs/troubleshooting.md +255 -0
  142. package/package.json +183 -3
  143. package/src/AttemptStore.ts +1195 -0
  144. package/src/Heartbeat.ts +47 -0
  145. package/src/Migrations.ts +59 -0
  146. package/src/Ownership.ts +407 -0
  147. package/src/RunStore.ts +2063 -0
  148. package/src/RunStoreMetrics.ts +165 -0
  149. package/src/index.ts +51 -0
  150. package/src/internal/Boundary.ts +130 -0
  151. package/src/internal/Companion.ts +64 -0
  152. package/src/internal/SpanOutcome.ts +71 -0
  153. package/src/migrations/0001_initial.ts +114 -0
  154. package/src/migrations/0002_lineage.ts +38 -0
  155. package/src/migrations/0003_execution_revisions.ts +69 -0
  156. package/src/migrations/0004_waiting_request.ts +39 -0
  157. package/src/test/TestRunStore.ts +22 -0
@@ -0,0 +1,140 @@
1
+ ---
2
+ title: "Durable values"
3
+ description: "How run state, checkpoints, errors, outcomes, and metadata cross the persistence boundary: inert copies, shape limits rather than byte limits, the one configurable ceiling, and why nothing is redacted."
4
+ sidebar:
5
+ order: 4
6
+ ---
7
+
8
+ Every value these stores persist is executable resume data. A run's
9
+ `stateJson` is what a restarted process re-enters; an attempt's checkpoint is
10
+ where a retried step picks up. That single fact decides every rule on this
11
+ page.
12
+
13
+ ## Values are copied inert before anything can yield
14
+
15
+ A JavaScript object handed to a store is not trusted to hold still. Before
16
+ persistence can yield, the boundary walks the value and builds a detached copy,
17
+ reading only own enumerable data properties. What it refuses:
18
+
19
+ - **Accessors.** A getter would run application code inside the persistence
20
+ path, and could return different values to the validator and to the writer.
21
+ - **`toJSON` and non-plain objects.** A prototype other than `Object.prototype`
22
+ or `null` is refused, so a class instance never persists as whatever its
23
+ serializer felt like emitting.
24
+ - **Cycles**, sparse arrays, enumerable non-index array members, enumerable
25
+ symbols, and non-finite numbers.
26
+ - **Ill-formed text.** A lone UTF-16 surrogate or an embedded NUL is refused,
27
+ because it would not survive the round trip through SQLite unchanged.
28
+
29
+ The copy is frozen, so what was validated is what gets written. Stored rows are
30
+ validated again on the way out: a row edited by something other than these
31
+ stores fails with `decode_failed` rather than being handed back as state.
32
+
33
+ ## The bounds are on shape, not on size
34
+
35
+ Run state, metadata, errors, and outcomes have no byte ceiling, on purpose.
36
+ These rows are what a resume re-enters, so a multi-megabyte state a flow
37
+ legitimately produced has to persist or the run cannot continue. What is
38
+ bounded is shape, and the limits are public constants:
39
+
40
+ | Constant | Value | Applies to |
41
+ | ------------------------------- | ------- | --------------------- |
42
+ | `RunStore.maximumRunJsonDepth` | 128 | Executable run state. |
43
+ | `RunStore.maximumRunJsonNodes` | 100,000 | Executable run state. |
44
+ | `AttemptStore.maximumJsonDepth` | 128 | Every attempt value. |
45
+ | `AttemptStore.maximumJsonNodes` | 100,000 | Every attempt value. |
46
+
47
+ Depth bounds the recursion the boundary itself performs. Node and member counts
48
+ bound the work one admission can cost. A value past either is refused with
49
+ `invalid_run` or `invalid_attempt` and a complaint naming the limit it broke.
50
+
51
+ ## The one byte ceiling is the checkpoint
52
+
53
+ A checkpoint is the exception, because it is a convenience rather than a
54
+ requirement: a step that cannot checkpoint still runs, it just restarts from
55
+ the beginning. So it takes a policy:
56
+
57
+ ```ts
58
+ import { AttemptStore } from "@smthrs/run-store"
59
+
60
+ const attempts = AttemptStore.layerWith({ maxCheckpointBytes: 4 * 1024 * 1024 })
61
+ ```
62
+
63
+ `Options.maxCheckpointBytes` defaults to 1 MiB and may not exceed
64
+ `AttemptStore.maximumCheckpointBytes`, which is 16 MiB. A larger value is
65
+ refused when the store is built, not when the first oversized checkpoint
66
+ arrives.
67
+
68
+ ## Identifiers are bounded durable text
69
+
70
+ Run ids, step key digests, lineage ids, owner host ids and nonces, and attempt
71
+ state names are all validated the same way: non-empty, at most 1,024 UTF-16
72
+ units, no NUL, and no lone surrogate. `U+FFFD` is ordinary text and is
73
+ accepted; an astral character round-trips byte for byte.
74
+
75
+ ## Timestamps are checked independently
76
+
77
+ Every timestamp must be a non-negative safe integer, and each is range-checked
78
+ on its own. None is compared against another. An attempt whose `finishedAtMs`
79
+ precedes its `startedAtMs` persists exactly as written, because the store does
80
+ not adjudicate the caller's timeline: it records what the caller observed.
81
+
82
+ The two exceptions are the ones that are lease predicates rather than records.
83
+ Heartbeats are monotonic, and lease readings are bounded by the skew allowance.
84
+ Both are covered in [The heartbeat lease](./leases.md).
85
+
86
+ ## Nothing is redacted
87
+
88
+ The stores rewrite no field on the way through. A redactor here would silently
89
+ change what the flow re-reads on resume, which corrupts the run rather than
90
+ protecting it: a field named `token` in executable state is state, and a run
91
+ that resumes without it is broken.
92
+
93
+ `Schema.Redacted` hides inspection but allows schema JSON encoding by default.
94
+ `Schema.encodeSync(Schema.toCodecJson(schema))` unwraps a default `Redacted`
95
+ field, and the store persists the resulting credential bytes. Refuse JSON
96
+ encoding explicitly for values that must never be persisted:
97
+
98
+ ```ts
99
+ const RuntimeState = Schema.Struct({
100
+ apiKey: Schema.Redacted(Schema.String, { disallowJsonEncode: true })
101
+ })
102
+ ```
103
+
104
+ This rejects serialization; it does not omit the field. For resumable state,
105
+ use a persistence schema that excludes credentials and stores a secret
106
+ reference instead:
107
+
108
+ ```ts
109
+ const PersistedState = Schema.Struct({
110
+ secretRef: Schema.String,
111
+ pageToken: Schema.String
112
+ })
113
+ ```
114
+
115
+ Resolve `secretRef` from a secret provider on resume. Persist only the
116
+ reference and flow data, never the resolved credential. Values that must not
117
+ be published are handled on the journal-event and export surfaces, which is
118
+ where the observability boundary lives.
119
+
120
+ ### The run file is a secret store
121
+
122
+ Two columns keep run values verbatim because resume re-reads them byte for
123
+ byte:
124
+
125
+ - `flows_runs.state_json` holds the run's payload and result.
126
+ - `flows_attempts.outcome_json` holds each action's encoded result, which an
127
+ action replays instead of executing again.
128
+
129
+ A credential in a payload, or spliced into an action's success value, is
130
+ stored in cleartext in the run's SQLite file (`.flows/*.db`). Handle that file
131
+ as a secret store: restrict its permissions, keep it out of commits, bug
132
+ reports, and artifacts, and delete it when the run is no longer needed. The
133
+ journal table is redacted on the write path; the fault suite's case 22 scans
134
+ every table and fails on a credential in any other column.
135
+
136
+ The stores hold that line in their own diagnostics as well. Failure causes
137
+ reach logs, spans, and telemetry, so they carry field names, lengths, and
138
+ validity flags, and never the rejected payload. Malformed timestamp causes
139
+ contain only the field name and a fixed validation complaint. Clock-skew
140
+ diagnostics include timestamp readings only after numeric validation.
@@ -0,0 +1,53 @@
1
+ ---
2
+ title: "Execution revisions and cancellation acknowledgement"
3
+ description: "Database-owned monotonic run revisions, retained deletion evidence, and fenced acknowledgement of cancellation intent."
4
+ ---
5
+
6
+ Migration `0003_execution_revisions` adds `flows_run_source`,
7
+ `flows_run_changes`, and nullable `cancel_acknowledgement_json` on `flows_runs`.
8
+ It is additive: old rows remain intact and receive a baseline revision in
9
+ creation-time/run-ID order. That ordering does not claim to reconstruct their
10
+ unknown historical mutations. Old acknowledgement values remain null.
11
+
12
+ The source table holds a random 128-bit database identity and a non-negative
13
+ safe-integer revision. Triggers advance it for every inserted, updated or deleted
14
+ run row, including claims, activation, heartbeat, wait fields, cancellation,
15
+ parent/lineage changes and terminal settlement. Trigger effects roll back with
16
+ the mutation. Safe-integer overflow refuses the mutation instead of wrapping.
17
+ Run IDs are immutable; deletion followed by creation is a new, higher revision.
18
+
19
+ `flows_run_changes` holds the latest revision and deletion flag for each run ID.
20
+ Multiple mutations coalesce; revisions are not journal event sequence numbers.
21
+ Deleted IDs retain their tombstones indefinitely. No ordinary retention pass or
22
+ consumer acknowledgement removes them. This policy protects consumers offline
23
+ for any duration without assuming a consumer lease. It costs one retained entry
24
+ per distinct deleted ID. Recreating an ID replaces the tombstone with a newer
25
+ live revision. A future compaction policy must first provide registered-consumer
26
+ watermarks and a durable rebuild protocol.
27
+
28
+ `RunStore.acknowledgeCancel(runId, owner, nowMs)` records the first observing
29
+ owner and time. The SQL mutation requires both durable intent and an exact
30
+ running owner fence. It returns true for a matching owner, preserving the first
31
+ acknowledgement on repetition, or false when intent or ownership is absent.
32
+ Invalid inputs and storage failures use the existing `RunStoreError` channel.
33
+ `makeNoop` returns false for this operation.
34
+
35
+ Intent, acknowledgement, and terminal cancellation are distinct facts. A request
36
+ can exist before the owner observes it. An acknowledgement can exist while user
37
+ cleanup is still running. Neither acknowledgement nor a cancelled lifecycle
38
+ proves an arbitrary external effect was undone.
39
+
40
+ The engine owns the public snapshot and change-feed ports that consume this
41
+ tracking state. Its wait payload already lives on the run row, so parking and
42
+ waking advance the same revision. Engine-store's additive migration binds
43
+ durable spawn-parent changes to the affected child's revision as well.
44
+
45
+ The identity survives connection and process restarts. Backups copy that identity;
46
+ independently writing divergent copies or restoring an older revision requires
47
+ explicit source rotation and projection rebuild by the host. Revisions are local
48
+ to one database. They do not establish a transaction with the separate control
49
+ database, and a projection's applied watermark can lag the engine's watermark.
50
+
51
+ See [engine observation and listing](https://engine-store.smithers.sh/guides/observe-executions/)
52
+ for bounded snapshots, keyset cursor semantics, live-page concurrency and the
53
+ revision comparison that rejects stale projection updates.
@@ -0,0 +1,146 @@
1
+ ---
2
+ title: "Fencing and ownership"
3
+ description: "How a run row decides which process may mutate it: the OwnerId fence, the exact-snapshot compare-and-swap, the two claim paths, and why competition is a success value rather than an error."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ A durable run outlives the process that started it. Two processes over one
9
+ database will at some point both believe they should be running the same run,
10
+ and one of them has to lose without corrupting anything. That arbitration is
11
+ what this package is for.
12
+
13
+ ## The fence is an identity, not a lock
14
+
15
+ `OwnerId` is three fields, defined by [`@smthrs/journal`](/api/journal) and
16
+ re-exported from `Ownership` because it is the same token the journal accepts
17
+ on durable appends:
18
+
19
+ ```ts
20
+ import type { OwnerId } from "@smthrs/run-store/Ownership"
21
+
22
+ const owner: OwnerId = { hostId: "host-a", pid: 4102, nonce: "3f9c-8b21" }
23
+ ```
24
+
25
+ `hostId` names the machine, `pid` names the process, and `nonce` distinguishes
26
+ one incarnation of that process from the next. The nonce is what makes a
27
+ restarted process a different owner from its predecessor, and what lets two
28
+ engines composed inside one process tell each other apart.
29
+
30
+ Nothing is locked. Every owned write asks the injected `Consensus` strategy
31
+ whether the complete triple still holds the run inside the same serialized
32
+ write transaction as the mutation, and the run row is updated only after the
33
+ strategy answers:
34
+
35
+ ```ts
36
+ const held = yield * consensus.guard(runId, owner) // fails fence_lost otherwise
37
+ yield * sql`UPDATE flows_runs SET ... WHERE run_id = ${runId}`
38
+ ```
39
+
40
+ The durable writer serializes write transactions, so there is no window
41
+ between checking who owns the run and writing to it, and a displaced owner's
42
+ late write fails instead of racing. `RunStore.layer` arbitrates through the
43
+ journal's database-backed `SqlConsensus`, whose lease lives in
44
+ `flows_consensus_leases`; `RunStore.layerWith` takes any strategy, and the
45
+ journal must be built over the same instance so both agree on who holds the
46
+ run.
47
+
48
+ ## The snapshot is the compare-and-swap
49
+
50
+ `RunSnapshot` is the exact triple a claim guards: `status`, `owner`, and
51
+ `heartbeatAtMs`. You read the row, restate those three fields, and hand them
52
+ back with your write. The store admits the write only while the row still
53
+ matches, and then only if the strategy grants the claim: the row admits, the
54
+ lease decides, and a refusal is reported from the row exactly as a failed
55
+ compare-and-swap always was.
56
+
57
+ ```ts
58
+ import type { RunRow, RunSnapshot } from "@smthrs/run-store/RunStore"
59
+
60
+ const snapshot = (row: RunRow): RunSnapshot => ({
61
+ status: row.status,
62
+ owner: row.owner,
63
+ heartbeatAtMs: row.heartbeatAtMs
64
+ })
65
+ ```
66
+
67
+ Partial snapshots are refused as invalid input rather than treated as a miss.
68
+ `running` requires both an owner and a heartbeat; every other status requires
69
+ neither. That invariant is also a SQL `CHECK` on the table, so no writer,
70
+ including one issuing raw SQL, can leave a half-owned row behind.
71
+
72
+ ## Two ways to take a run
73
+
74
+ Claiming is deliberately two phase for the engine's path, and atomic for the
75
+ control plane's.
76
+
77
+ **Two phase.** `claim` writes the claim columns (`claim_host_id`, `claim_pid`,
78
+ `claim_nonce`, `claimed_at_ms`) on a `pending` or `suspended` row and returns
79
+ the `claimedAtMs` that becomes your fence token. `activate` then trades that
80
+ token for ownership: it sets `status = 'running'`, writes the owner columns and
81
+ the first heartbeat, and clears the claim. The gap between them is where a
82
+ caller does work that must not be repeated, such as recording in the journal
83
+ who is taking the run. If activation loses, `abandonClaim` releases the
84
+ reservation so the next sweeper is not blocked by a claim nobody is using.
85
+
86
+ **Atomic.** `claimAndOwn` does both in one compare-and-swap. It admits
87
+ `pending`, `suspended`, and `running` rows, and replacing a different running
88
+ owner additionally requires matching liveness evidence. Use it when there is no
89
+ work to do between reserving and owning.
90
+
91
+ `steal` is the third entry, and it produces a claim rather than ownership: a
92
+ successful steal writes the claim columns of a stale running row, and the
93
+ caller follows with `activate`. That is why the takeover path and the ordinary
94
+ claim path converge on the same second step.
95
+
96
+ Every transition the strategy grants — `claimed`, `activated`, `released`,
97
+ `stolen`, `expired` — is appended as a `flows.consensus.*` fact on the run's
98
+ `run-store` companion journal stream in the same transaction, beside the run's
99
+ own stream rather than in it. Heartbeats renew the strategy's lease and never
100
+ enter the journal.
101
+
102
+ ## Competition is a value, failure is an error
103
+
104
+ Every operation splits its answers into two channels, and the split is the
105
+ contract:
106
+
107
+ - **Success values** describe the race. `AlreadyClaimed`, `HeartbeatFresh`,
108
+ `SnapshotChanged`, `FenceLost`, `ClaimLost`, `ClaimFresh`, `ClaimChanged`,
109
+ `EvidenceRequired`, and `LivenessUnconfirmed` all mean the store worked
110
+ correctly and your write did not win.
111
+ - **`RunStoreError`** describes a defect. Invalid input (`invalid_run`), a
112
+ corrupt durable row (`decode_failed`), a constraint violation
113
+ (`constraint`), a missing row on a direct read (`not_found_row`), and a
114
+ database failure (`persistence_failed`).
115
+
116
+ A caller can therefore retry contention without retrying corruption, and can
117
+ type its error channel without swallowing races. Each outcome and its cause is
118
+ listed in [Troubleshooting](../troubleshooting.md).
119
+
120
+ ## Leaving a run
121
+
122
+ `transitionOwned` is the only way to move a run you own, and the target decides
123
+ what happens to the ownership columns:
124
+
125
+ - `running` keeps the owner and updates only the executable state. It is how a
126
+ run records progress mid-flight.
127
+ - `suspended` clears the owner, the heartbeat, and the claim, and leaves
128
+ `finished_at_ms` null. The run is parked and claimable again.
129
+ - `completed`, `failed`, and `cancelled` clear the same columns and stamp
130
+ `finished_at_ms`. A terminal row is never claimed, activated, or reopened.
131
+ - `pending` is refused with `invalid_run`. A run does not go back to unstarted.
132
+
133
+ A transition may also carry a `TransitionGuard`, an extra predicate evaluated
134
+ in the same write transaction as the fence check. `{ cancelRequested:
135
+ "absent" }` is the "do not finalize a run somebody asked to cancel" rule,
136
+ expressed inside the serialized transaction rather than as a read-then-write
137
+ race. See [Cancel a run](../guides/cancel-a-run.md).
138
+
139
+ ## Attempts inherit the run's fence
140
+
141
+ `AttemptStore` never carries its own ownership. `put`, `heartbeat`, `finish`,
142
+ and `patch` each embed an `EXISTS` subquery over `flows_runs` requiring that
143
+ the run is `running` and owned by the caller. One consequence is worth knowing
144
+ before you debug it: once a run reaches a terminal status its owner columns are
145
+ cleared, so every later attempt write on that run reports `FenceLost`. A
146
+ delayed write from a displaced owner cannot rewrite the winning row.
@@ -0,0 +1,154 @@
1
+ ---
2
+ title: "The heartbeat lease"
3
+ description: "The four heartbeat durations and how they relate, the two clock sources that stamp one run row, the skew allowance the store enforces, and what a wall-clock lease can and cannot promise."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ An owner proves it is still working by writing `heartbeat_at_ms`. A peer that
9
+ wants the run reads that stamp and decides whether the owner is gone. Every
10
+ number in that exchange is a constant this package defines, and they are
11
+ related rather than independently chosen.
12
+
13
+ ## The four durations
14
+
15
+ They live in `@smthrs/run-store/Heartbeat` and are re-exported from
16
+ `Ownership`. All four are `Duration` values:
17
+
18
+ | Constant | Value | What it governs |
19
+ | ------------------------- | ---------- | ----------------------------------------------------------------------------------------- |
20
+ | `heartbeatInterval` | 1 second | How often the supervision loop pulses. |
21
+ | `heartbeatStaleAfter` | 30 seconds | How old a persisted heartbeat must be before a peer may steal the run. |
22
+ | `heartbeatSkewAllowance` | 10 seconds | How far the owner's wall clock may lag a peer's before the lease reasoning stops holding. |
23
+ | `heartbeatWriteTolerance` | 19 seconds | How long an owner may keep working through failing or stalled heartbeat writes. |
24
+
25
+ The last one is derived, not chosen:
26
+ `heartbeatStaleAfter - heartbeatSkewAllowance - heartbeatInterval`. A peer may
27
+ steal the run the instant the persisted heartbeat is `heartbeatStaleAfter` old.
28
+ The peer's clock may already read `heartbeatSkewAllowance` later than the
29
+ owner's, so an owner that tolerated write failures for the full staleness
30
+ window would still be running side effects when the steal was admitted.
31
+ Subtracting the skew allowance and an extra pulse interval reserves one
32
+ interval to reconfirm ownership before interrupting.
33
+
34
+ They live in a leaf module because `Ownership` imports `RunStore` and
35
+ `RunStore` needs the staleness cutoff for its own predicates. Neither could own
36
+ the constants without the other restating them, and a restated constant is a
37
+ constant that drifts.
38
+
39
+ ## The supervision loop
40
+
41
+ `Ownership.heartbeatLoop` is the owner's half of the exchange. It pulses
42
+ forever and interrupts itself when the fence is gone, so race it against the
43
+ owned work with `Effect.raceFirst`, which settles on the first outcome of
44
+ either side, interruption included:
45
+
46
+ ```ts
47
+ import { Ownership, RunStore } from "@smthrs/run-store"
48
+ import * as Effect from "effect/Effect"
49
+
50
+ const owned = <A, E>(
51
+ runId: string,
52
+ owner: Ownership.OwnerId,
53
+ work: Effect.Effect<A, E, RunStore.RunStore>
54
+ ): Effect.Effect<A, E, RunStore.RunStore> => Effect.raceFirst(work, Ownership.heartbeatLoop(runId, owner))
55
+ ```
56
+
57
+ The loop pulses every `heartbeatInterval`, reading the injected Effect `Clock`,
58
+ so a test drives it with `TestClock` and never waits in wall time. It
59
+ distinguishes two failures on purpose:
60
+
61
+ - **A lost fence** is any heartbeat outcome other than `Updated`. That is
62
+ durable evidence that another owner holds the run, so the loop interrupts
63
+ immediately and takes the raced work with it.
64
+ - **A failed write** is not evidence of anything. The persisted heartbeat is
65
+ still on the row and no peer may steal the run until it is
66
+ `heartbeatStaleAfter` old, so transient database errors are tolerated for
67
+ `heartbeatWriteTolerance`.
68
+
69
+ When that budget expires, an independent deadline calls `RunStore.reconfirm`,
70
+ bounded by one `heartbeatInterval`. It delegates to the same `Consensus`
71
+ strategy as ordinary heartbeats and mirrors the renewed stamp in the same
72
+ transaction. Reconfirm succeeds only when the owner still matches and there
73
+ is no pending claim. A peer that reserved the stale lease therefore wins
74
+ even before it activates.
75
+
76
+ Reconfirmation keeps its one-heartbeat-interval deadline. When it succeeds,
77
+ supervision resets the confirmed-pulse time from the timestamp sent to the
78
+ store. `onReconfirm(unconfirmedMs)` must finish within the remaining renewed
79
+ `heartbeatWriteTolerance` budget; reconfirmation time is subtracted, and receipt
80
+ completion does not extend the persisted lease. A blocked or failed receipt
81
+ lapses the lease. The engine journals
82
+ `run-decision` with decision `lease-reconfirmed` and detail `{ unconfirmedMs }`;
83
+ `runs show` reports a warning. Owner identity and journal generation do not
84
+ change. A lost lease, write error, or timeout calls `onLapse` and interrupts
85
+ the pulse loop and its pending write, including when that write never returns. A successful pulse
86
+ re-arms the deadline from the timestamp supplied to the store, not the time the
87
+ write completes. The deadline reads the current clock after each wait; delayed
88
+ successes and failures cannot extend the lease beyond that persisted timestamp's
89
+ budget. Until the first successful pulse, the budget starts when supervision
90
+ begins.
91
+
92
+ ## Heartbeats only move forward
93
+
94
+ `heartbeat` writes `MAX(heartbeat_at_ms, :nowMs)`. A pulse that arrives after a
95
+ newer one from the same owner still reports `Updated`, because the fence held
96
+ and the write proved liveness, but it does not move the stamp backwards and
97
+ make a live run look stale to a peer's cutoff. The same rule applies to attempt
98
+ heartbeats.
99
+
100
+ ## Two clocks stamp one row
101
+
102
+ A run row carries readings from two different sources, and the split is
103
+ deliberate.
104
+
105
+ **The caller's `nowMs`** is used by the operations that judge or record a
106
+ lease: `claim`, `claimAndOwn`, `steal`, `heartbeat`, `reconfirm`, `requestCancel`, and
107
+ `recoverClaim`. It is the cutoff their predicates compare against and the value
108
+ they persist, and it is taken literally.
109
+
110
+ **The Effect `Clock`** stamps the lifecycle. `create` writes `created_at_ms`,
111
+ `activate` writes `started_at_ms` and `heartbeat_at_ms`, and `transitionOwned`
112
+ writes `finished_at_ms`.
113
+
114
+ One row can therefore hold readings from both, so a composition must give both
115
+ sources the same reading. In practice that means taking `nowMs` from
116
+ `Clock.currentTimeMillis` rather than from `Date.now()`, which is also what
117
+ makes the whole store driveable under `TestClock`.
118
+
119
+ ## The skew allowance is enforced
120
+
121
+ Every `nowMs` is validated as a non-negative safe integer. The lease operations
122
+ (`claim`, `claimAndOwn`, `steal`, `heartbeat`, `reconfirm`, and `recoverClaim`) then bound it
123
+ from above: a reading more than `heartbeatSkewAllowance` ahead of the store's
124
+ own `Clock` fails with `invalid_run` before any predicate runs.
125
+
126
+ No composition produces such a reading honestly, because the caller and the
127
+ store share a process, and it is the one lever that steals a fresh owner or
128
+ pins a lease past the cutoff. A reading behind the clock is admitted: it makes
129
+ every staleness judgment more conservative, and the monotonic heartbeat absorbs
130
+ it.
131
+
132
+ Two operations sit outside that bound on purpose. `requestCancel` keeps its
133
+ literal reading because its timestamp is request data rather than a lease
134
+ predicate. The `claimedAtMs` fence tokens passed to `activate`, `abandonClaim`,
135
+ and `recoverClaim` are compared against the row rather than bounded, because
136
+ they are tokens the store itself issued.
137
+
138
+ Inside the allowance the reading is trusted completely: the store cannot tell a
139
+ slow clock from a lie. That is the right contract for an in-process library over
140
+ a local SQLite file whose caller can issue raw SQL anyway, and it must not
141
+ cross a trust boundary.
142
+
143
+ ## What the lease does not promise
144
+
145
+ Beyond `heartbeatSkewAllowance` of clock offset between two hosts, a peer can
146
+ be admitted while the previous owner is still running. Durable writes stay safe
147
+ regardless, because they are fenced by the consensus strategy's commit-time
148
+ guard and the displaced owner's writes fail rather than corrupt. Non-durable external side
149
+ effects, such as an HTTP call or a spawned process, can genuinely overlap.
150
+
151
+ That is inherent to any wall-clock lease. A caller that cannot tolerate any
152
+ overlap needs a fencing token at the side effect itself, not a larger timeout.
153
+ The next lever after the lease is evidence: see
154
+ [Liveness evidence](./liveness-evidence.md).
@@ -0,0 +1,142 @@
1
+ ---
2
+ title: "Liveness evidence"
3
+ description: "The three kinds of evidence that admit a takeover, why evidence binds to one instant, the difference between a LivenessCheck and a LivenessProbe, and why every probe failure is read as life."
4
+ sidebar:
5
+ order: 3
6
+ ---
7
+
8
+ An expired lease says the owner stopped writing. It does not say the owner
9
+ stopped working. Evidence is the second lever: a claim about the recorded owner
10
+ that the store can check before it admits a takeover.
11
+
12
+ ## Three kinds, three scopes
13
+
14
+ `Ownership.LivenessEvidence` carries the owner it is about, the instant it was
15
+ observed, and what kind of claim it is:
16
+
17
+ ```ts
18
+ import type { LivenessEvidence } from "@smthrs/run-store/Ownership"
19
+
20
+ const evidence: LivenessEvidence = {
21
+ expectedOwner: { hostId: "host-a", pid: 4102, nonce: "3f9c" },
22
+ checkedAtMs: 1757000030000,
23
+ kind: "same-host-pid-dead"
24
+ }
25
+ ```
26
+
27
+ Each kind is admitted only where it means something:
28
+
29
+ - **`same-host-pid-dead`** is a local process probe. A pid names a process only
30
+ inside one process namespace, so the store accepts this kind only when the
31
+ observer and the recorded owner share a `hostId`.
32
+ - **`cross-host-unreachable-stale`** is a reachability judgment the deployment
33
+ makes. It is accepted only when the hosts differ: on the owner's own host it
34
+ would be an unprobed guess dressed as evidence.
35
+ - **`lease-expired`** asserts only that the persisted heartbeat is older than
36
+ the staleness cutoff. That is the one claim the store can verify for itself,
37
+ and `steal` does verify it: the write refuses any row whose `heartbeat_at_ms`
38
+ is still inside the window. It is therefore accepted from any host.
39
+
40
+ Two of the three are collected outside this package, because the store never
41
+ probes a process or a network itself. It validates what it is handed.
42
+
43
+ ## Evidence binds to one instant
44
+
45
+ `evidence.checkedAtMs` must equal the `nowMs` of the call that consumes it,
46
+ exactly. A probe taken at T is refused by a call made at T plus one
47
+ millisecond, and the caller has to build fresh evidence.
48
+
49
+ That rule is what stops a probe from being replayed. Without it, a caller could
50
+ hold one "the owner is dead" observation and spend it against a run whose owner
51
+ came back. `claimAndOwn`, `recoverClaim`, and `steal` all enforce it.
52
+
53
+ `steal` also separates two refusals that look alike from outside.
54
+ `LivenessUnconfirmed` means the evidence did not match the snapshot's owner,
55
+ the host relation its `kind` requires, or `nowMs`, so no compare-and-swap ran
56
+ at all. `SnapshotChanged` is reserved for matching evidence whose comparison
57
+ lost to a row that moved. A caller can tell "I was not allowed to try" from "I
58
+ tried and lost".
59
+
60
+ ## A check answers, a probe produces
61
+
62
+ Two types sit either side of the store, and they answer different questions.
63
+
64
+ **`LivenessCheck`** is the question the arbitration asks before it decides to
65
+ steal: is the recorded owner still working? It takes the owner and a
66
+ `LivenessContext` carrying the claimant, the persisted `heartbeatAtMs`, and the
67
+ `nowMs` the decision is made against, and answers a boolean. Answering `true`
68
+ refuses the takeover. The engine consults it only for a run whose lease has
69
+ already expired.
70
+
71
+ **`LivenessProbe`** is the evidence factory: given the expected owner, the
72
+ claimant, and the instant, it produces `LivenessEvidence` or `undefined` for
73
+ `steal`, `claimAndOwn`, and `recoverClaim` to verify.
74
+
75
+ The package ships two checks.
76
+
77
+ `Ownership.leaseLiveness(staleAfter?)` is the default and the honest floor: an
78
+ owner is alive for as long as its persisted heartbeat is younger than the
79
+ cutoff, and gone once it is not. It is the weakest answer, and the one every
80
+ host can give. A fresh process with no application code at all can reclaim a
81
+ hard-killed owner's runs once the lease it stopped renewing expires. Browser
82
+ compositions keep it, because a tab has no process table to ask.
83
+
84
+ `Ownership.sameHostPidProbe` asks the operating system instead. It is what a
85
+ Node host hands the engine, so this snippet also installs
86
+ [`@smthrs/flows`](/api/flows):
87
+
88
+ ```ts
89
+ import type * as NodeRuntime from "@smthrs/flows/NodeRuntime"
90
+ import { Ownership } from "@smthrs/run-store"
91
+
92
+ const options: NodeRuntime.Options = {
93
+ filename: ".smithers/smithers.db",
94
+ workspaceRoot: "/srv/workspace",
95
+ owner: { hostId: "host-a" },
96
+ isAlive: Ownership.sameHostPidProbe
97
+ }
98
+ ```
99
+
100
+ It exists because the lease alone is only a timeout: two engine processes over
101
+ one database steal each other's running rows `heartbeatStaleAfter` after any
102
+ heartbeat stall, whether that stall was a dead process, a stop-the-world pause,
103
+ or a disk that blocked too long. The probe answers the question the lease is
104
+ standing in for.
105
+
106
+ ## Every unknown answer is life
107
+
108
+ `sameHostPidProbe` sends no signal. `process.kill(pid, 0)` performs only the
109
+ delivery checks, and exactly three answers matter:
110
+
111
+ - It returns: the process exists. The owner is alive.
112
+ - It throws `ESRCH`: no such process. This is the sole death answer.
113
+ - It throws anything else, including `EPERM` and `EINVAL`, or the pid is not a
114
+ positive safe integer: the owner is treated as alive, because an unknown
115
+ answer is not death.
116
+
117
+ Failing closed is the whole point. A check that returns `false` without asking
118
+ says "that owner is gone" about an owner it never looked at, and the engine
119
+ steals runs out of live processes on the strength of it. That is why the
120
+ engine's `NodeRuntime` requires `isAlive` rather than defaulting it.
121
+
122
+ Two limits are inherent to asking a pid, and they bound what reclaim can
123
+ promise:
124
+
125
+ - **An owner recorded with the claimant's own pid is always alive.** A previous
126
+ incarnation of this process, or a second engine composed inside it, differs
127
+ from the claimant only by `nonce`, and the process it names is this one. Such
128
+ a row is never stolen while the process lives, so an embedded host that
129
+ re-creates its engine in place should keep `leaseLiveness`, whose timeout does
130
+ expire.
131
+ - **A reused pid reports the unrelated process that now holds it.** The dead
132
+ owner's row stays refused for as long as that process lives, which delays
133
+ reclaim rather than breaking it: the row is still `running` under an expired
134
+ lease, and the next probe after the pid is free reclaims it.
135
+
136
+ A recorded owner on another host is never probed. The answer is `false`, which
137
+ adds no evidence and lets the expired lease decide, so a host that dies for
138
+ good does not strand its runs where no other machine could ever produce
139
+ evidence about its pids.
140
+
141
+ For the sequence a takeover actually runs, see
142
+ [Take over a stalled run](../guides/recover-a-stalled-run.md).