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

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,156 @@
1
+ ---
2
+ title: "Cancel a run"
3
+ description: "Record cancellation intent from any observer, read it from the owner, and settle the run under a transition guard so a completion and a cancellation can never both win."
4
+ sidebar:
5
+ order: 4
6
+ ---
7
+
8
+ Cancellation is two halves that never share a fence. Any observer records the
9
+ intent with `requestCancel`. Only the owner acts on it, with a guarded
10
+ `transitionOwned`. Nothing interrupts the owner for you: the request is durable
11
+ intent, and the owner decides where its flow can stop cleanly.
12
+
13
+ ## Record the request
14
+
15
+ For a logical run that can hand off, use `requestCancelLineage`: a completed
16
+ round is not necessarily a completed job. It resolves every round and records
17
+ intent atomically, including a successor that exists when the transaction wins.
18
+ The engine's handoff transaction refuses to admit a successor if cancellation
19
+ won first. `requestCancel` remains the lower-level, single-round operation used
20
+ by owners coordinating lifecycle transitions and child cleanup.
21
+
22
+ ```ts
23
+ import { RunStore } from "@smthrs/run-store"
24
+ import * as Clock from "effect/Clock"
25
+ import * as Effect from "effect/Effect"
26
+
27
+ const ask = (runId: string) =>
28
+ Effect.gen(function*() {
29
+ const runs = yield* RunStore.RunStore
30
+ const nowMs = yield* Clock.currentTimeMillis
31
+ return yield* runs.requestCancelLineage(runId, nowMs)
32
+ })
33
+ ```
34
+
35
+ Neither request operation takes an owner, on purpose. An operator, a parent run, or a
36
+ control plane asks for a cancellation without holding the run, and the write is
37
+ first-writer-wins, so asking twice is harmless.
38
+
39
+ | Outcome | What it means |
40
+ | ------------------ | --------------------------------------------------------------------------------------------- |
41
+ | `CancelRequested` | This call wrote `cancel_requested_at_ms`, and `requestedAtMs` is the value it wrote. |
42
+ | `AlreadyRequested` | A request was already recorded. `requestedAtMs` is the original time, not the one you passed. |
43
+ | `Terminal` | The run had already settled, so nothing was recorded. `status` says which ending it lost to. |
44
+ | `NotFound` | No such run row. |
45
+
46
+ For each round, the status predicate is part of the same statement that writes the column, so a
47
+ run that settles between your read and your request loses the write instead of
48
+ racing it. A settled run records nothing because it has no owner left to observe
49
+ the intent, and a reader that took the column as live intent would go on
50
+ cancelling children the finished parent had already dealt with.
51
+
52
+ For `requestCancelLineage`, the outcome summarizes the transaction: any newly
53
+ recorded request yields `CancelRequested`; otherwise an existing request yields
54
+ `AlreadyRequested`. `Terminal` means every round is settled and reports the
55
+ latest round's status. Terminal predecessor rows are never rewritten.
56
+
57
+ `nowMs` is taken literally and is not bounded by `heartbeatSkewAllowance`: this
58
+ timestamp is request data rather than a lease predicate, so the only rule is
59
+ that it must be a non-negative safe integer. See
60
+ [The heartbeat lease](../concepts/leases.md).
61
+
62
+ ## Read the request as the owner
63
+
64
+ The request is a column on the run row, so the owner reads it with `get`:
65
+
66
+ ```ts
67
+ const cancelRequested = (runId: string) =>
68
+ Effect.gen(function*() {
69
+ const runs = yield* RunStore.RunStore
70
+ const row = yield* runs.get(runId)
71
+ return row.cancelRequestedAtMs !== null
72
+ })
73
+ ```
74
+
75
+ Poll it at a point where the flow can stop without leaving work half done.
76
+ [`examples/src/19-cancel-and-child-cleanup.ts`](https://github.com/smithersai/smithers/blob/main/examples/src/19-cancel-and-child-cleanup.ts)
77
+ polls exactly this column and cleans up its children before it settles.
78
+
79
+ ## Settle under a guard
80
+
81
+ A `TransitionGuard` is an extra predicate compiled into the same `UPDATE` as the
82
+ ownership fence, so a request that arrives while you are deciding cannot slip
83
+ between a check and a terminal write.
84
+
85
+ ```ts
86
+ import type { OwnerId } from "@smthrs/run-store/Ownership"
87
+
88
+ const settleCancelled = (runId: string, owner: OwnerId, state: unknown) =>
89
+ Effect.gen(function*() {
90
+ const runs = yield* RunStore.RunStore
91
+ return yield* runs.transitionOwned(runId, owner, "cancelled", JSON.stringify(state), {
92
+ cancelRequested: "present"
93
+ })
94
+ })
95
+ ```
96
+
97
+ `{ cancelRequested: "present" }` admits the write only while a request is on the
98
+ row, so a run is never recorded as cancelled without one. Put the mirror image
99
+ on the completion:
100
+
101
+ ```ts
102
+ const settleCompleted = (runId: string, owner: OwnerId, state: unknown) =>
103
+ Effect.gen(function*() {
104
+ const runs = yield* RunStore.RunStore
105
+ return yield* runs.transitionOwned(runId, owner, "completed", JSON.stringify(state), {
106
+ cancelRequested: "absent"
107
+ })
108
+ })
109
+ ```
110
+
111
+ Together the two guards make the pair exclusive in SQL rather than in your
112
+ control flow.
113
+
114
+ `GuardFailed` means you still own the run and the predicate refused the write.
115
+ That is a different situation from `FenceLost`, which means another owner holds
116
+ the run: the store checks ownership first and only reports `GuardFailed` for a
117
+ row you genuinely own.
118
+
119
+ ## Let the guard tell you
120
+
121
+ A guard on the mid-flight progress write turns the next checkpoint into the
122
+ notification:
123
+
124
+ ```ts
125
+ const recordProgress = (runId: string, owner: OwnerId, state: unknown) =>
126
+ Effect.gen(function*() {
127
+ const runs = yield* RunStore.RunStore
128
+ return yield* runs.transitionOwned(runId, owner, "running", JSON.stringify(state), {
129
+ cancelRequested: "absent"
130
+ })
131
+ })
132
+ ```
133
+
134
+ While no request exists the write reports `Transitioned`. The moment one lands,
135
+ the same call reports `GuardFailed` and the owner learns about the request
136
+ without a separate read.
137
+
138
+ ## What a cancellation does not do
139
+
140
+ - It does not interrupt the owner. A request is a row, not a signal, and an
141
+ owner that never reads it runs to completion.
142
+ - It does not clear the column. `transitionOwned` clears the owner, the
143
+ heartbeat, and the claim, and never touches `cancel_requested_at_ms`, so a
144
+ settled run keeps the record of what was asked.
145
+ - It does not decide the ending. A run whose owner honored a request settles as
146
+ `cancelled`; one that finished first settles as `completed`, and the next
147
+ request reports `Terminal`.
148
+
149
+ ## Next steps
150
+
151
+ - [Claim a run and finish it](./claim-and-finish-a-run.md): the transition this
152
+ guard rides on.
153
+ - [Fencing and ownership](../concepts/fencing.md): why every refused write is a
154
+ named outcome.
155
+ - [Troubleshooting](../troubleshooting.md): what to change when a guard or a
156
+ request keeps refusing.
@@ -0,0 +1,175 @@
1
+ ---
2
+ title: "Claim a run and finish it"
3
+ description: "Take ownership of a run, keep its lease alive while you work, record progress, and settle it: the two-phase claim, the atomic form, heartbeat supervision, and the fenced transition."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ This is the path a durable executor walks on every run it picks up. Each step
9
+ is a compare-and-swap you can lose, and losing is normal.
10
+
11
+ ## Read the row and choose the snapshot
12
+
13
+ Every claim guards three fields. Read the row; you can restate those three as
14
+ the `expected` snapshot:
15
+
16
+ ```ts
17
+ import type { RunRow, RunSnapshot } from "@smthrs/run-store/RunStore"
18
+
19
+ const snapshot = (row: RunRow): RunSnapshot => ({
20
+ status: row.status,
21
+ owner: row.owner,
22
+ heartbeatAtMs: row.heartbeatAtMs
23
+ })
24
+ ```
25
+
26
+ The three-field helper is optional: a complete `RunRow` returned by `get` can
27
+ also be passed directly as `expected`. The store copies only the three guarded
28
+ fields; extra own data fields are ignored. Missing or inherited required fields,
29
+ enumerable accessors on any key, and non-plain prototypes are refused.
30
+
31
+ ## Claim, then activate
32
+
33
+ `claim` reserves a `pending` or `suspended` row and hands back the
34
+ `claimedAtMs` that is your fence token for the second step. `activate` trades
35
+ that token for ownership:
36
+
37
+ ```ts
38
+ import { RunStore } from "@smthrs/run-store"
39
+ import type { OwnerId } from "@smthrs/run-store/Ownership"
40
+ import * as Clock from "effect/Clock"
41
+ import * as Effect from "effect/Effect"
42
+
43
+ const take = (runId: string, owner: OwnerId) =>
44
+ Effect.gen(function*() {
45
+ const runs = yield* RunStore.RunStore
46
+ const row = yield* runs.get(runId)
47
+ const expected = snapshot(row)
48
+ const nowMs = yield* Clock.currentTimeMillis
49
+
50
+ const claim = yield* runs.claim(runId, expected, owner, nowMs)
51
+ if (claim._tag !== "Claimed") return claim
52
+
53
+ const activation = yield* runs.activate(runId, owner, claim.claimedAtMs, expected)
54
+ if (activation._tag !== "Activated") {
55
+ yield* runs.abandonClaim(runId, owner, claim.claimedAtMs)
56
+ }
57
+ return activation
58
+ })
59
+ ```
60
+
61
+ Take `nowMs` from `Clock.currentTimeMillis`, not from `Date.now()`. It is the
62
+ value the store persists as `claimed_at_ms`, and a reading more than
63
+ `heartbeatSkewAllowance` ahead of the store's own clock fails with
64
+ `invalid_run`.
65
+
66
+ Release the claim when activation loses. The gap between the two calls exists
67
+ so a caller can do work that must not be repeated, such as recording in the
68
+ journal who is taking the run over. If that work or the activation fails, a
69
+ claim nobody is using would block the next sweeper until it went stale.
70
+
71
+ ## Or take it in one call
72
+
73
+ When there is nothing to do between reserving and owning, `claimAndOwn` is one
74
+ compare-and-swap:
75
+
76
+ ```ts
77
+ const takeAtomically = (runId: string, owner: OwnerId) =>
78
+ Effect.gen(function*() {
79
+ const runs = yield* RunStore.RunStore
80
+ const row = yield* runs.get(runId)
81
+ const nowMs = yield* Clock.currentTimeMillis
82
+ return yield* runs.claimAndOwn(runId, snapshot(row), owner, nowMs)
83
+ })
84
+ ```
85
+
86
+ It admits `pending`, `suspended`, and `running` rows. Re-entering a run you
87
+ already own needs nothing extra. Replacing a _different_ running owner needs
88
+ matching liveness evidence as the fifth argument, and reports
89
+ `EvidenceRequired` when the snapshot is still current, the other owner's
90
+ heartbeat is stale, and no evidence was supplied. Re-reading and retrying
91
+ cannot make progress from there; see
92
+ [Take over a stalled run](./recover-a-stalled-run.md).
93
+
94
+ ## Keep the lease alive
95
+
96
+ An owner that stops writing `heartbeat_at_ms` is stealable after
97
+ `heartbeatStaleAfter`. `Ownership.heartbeatLoop` pulses until the fence is lost
98
+ and then interrupts itself, so racing it against the work makes losing
99
+ ownership interrupt the work:
100
+
101
+ ```ts
102
+ import { Ownership } from "@smthrs/run-store"
103
+
104
+ const runOwned = <A, E>(
105
+ runId: string,
106
+ owner: OwnerId,
107
+ work: Effect.Effect<A, E, RunStore.RunStore>
108
+ ) => Effect.raceFirst(work, Ownership.heartbeatLoop(runId, owner))
109
+ ```
110
+
111
+ Use `Effect.raceFirst` rather than `Effect.race`: the loop signals a lost fence
112
+ by interrupting itself, and `raceFirst` settles on the first outcome of either
113
+ side, interruption included.
114
+
115
+ ## Record progress without giving up ownership
116
+
117
+ `transitionOwned` with the target `running` keeps the owner and rewrites only
118
+ the executable state. It is how a mid-flight run persists what a resume would
119
+ re-enter:
120
+
121
+ ```ts
122
+ const checkpointRun = (runId: string, owner: OwnerId, state: unknown) =>
123
+ Effect.gen(function*() {
124
+ const runs = yield* RunStore.RunStore
125
+ return yield* runs.transitionOwned(runId, owner, "running", JSON.stringify(state))
126
+ })
127
+ ```
128
+
129
+ Omit the state argument to change status alone. Passing it rewrites
130
+ `state_json`; the column is never partially merged.
131
+
132
+ ## Settle it
133
+
134
+ A terminal transition clears the owner, the heartbeat, and any claim, and
135
+ stamps `finished_at_ms`:
136
+
137
+ ```ts
138
+ const finish = (runId: string, owner: OwnerId, state: unknown) =>
139
+ Effect.gen(function*() {
140
+ const runs = yield* RunStore.RunStore
141
+ const outcome = yield* runs.transitionOwned(
142
+ runId,
143
+ owner,
144
+ "completed",
145
+ JSON.stringify(state),
146
+ { cancelRequested: "absent" }
147
+ )
148
+ return outcome
149
+ })
150
+ ```
151
+
152
+ The guard is optional and is compiled into the same `UPDATE`, so a cancellation
153
+ request that arrives while you are deciding cannot slip between a check and a
154
+ terminal write. `GuardFailed` means you still own the run and the guard refused
155
+ it, which is a different situation from `FenceLost`.
156
+
157
+ `suspended` is the other useful target: it clears ownership without stamping
158
+ `finished_at_ms`, parking the run so any host may claim it again.
159
+
160
+ ## The outcomes you have to handle
161
+
162
+ | Outcome | Which call | What it means |
163
+ | ------------------ | ------------------------------- | ---------------------------------------------------------------------- |
164
+ | `NotFound` | any | No such run row. |
165
+ | `AlreadyClaimed` | `claim`, `claimAndOwn`, `steal` | Another process holds the claim columns. |
166
+ | `HeartbeatFresh` | `claim`, `claimAndOwn`, `steal` | The row is `running` under a live lease. |
167
+ | `SnapshotChanged` | claim family, `activate` | The row moved between your read and your write. |
168
+ | `EvidenceRequired` | `claimAndOwn` | The snapshot is current and its stale owner needs evidence to replace. |
169
+ | `ClaimLost` | `activate`, `abandonClaim` | The claim columns no longer match your token. |
170
+ | `FenceLost` | `heartbeat`, `transitionOwned` | Another owner holds the run. Stop working on it. |
171
+ | `GuardFailed` | `transitionOwned` | You own the run; the guard predicate refused the write. |
172
+
173
+ Every one of these is a success value. `RunStoreError` is reserved for invalid
174
+ input, corrupt rows, and database failures, so a caller can retry contention
175
+ without retrying corruption. See [Troubleshooting](../troubleshooting.md).
@@ -0,0 +1,142 @@
1
+ ---
2
+ title: "Compose the stores into a host"
3
+ description: "Wire RunStore and AttemptStore over a SQL driver: the layer order that satisfies migrations, the attempt-store policy options, the stubs, and where the composed storage ladder already exists."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ Both stores require a `SqlClient` and a `DurableWriter`, and both require their
9
+ tables to exist. This guide builds that composition from the bottom up.
10
+
11
+ ## Check whether you need to build it
12
+
13
+ If you are running the Smithers engine, the composition already exists and
14
+ building a second one over the same database is a way to get two engines
15
+ fighting over one set of rows. [`@smthrs/engine-store`](/api/engine-store)
16
+ composes these stores with the journal, the step cache, and the durable engine
17
+ state, and `@smthrs/flows/NodeRuntime` builds that ladder over one SQLite file.
18
+ Reach for the layers here when you are writing a host, an adapter, or a sweeper
19
+ that has to agree with the engine about ownership.
20
+
21
+ ## The layer order
22
+
23
+ Migrations must finish before either store is exposed, and both stores need the
24
+ writer and the client:
25
+
26
+ ```ts
27
+ import * as DurableWriter from "@smthrs/database/DurableWriter"
28
+ import * as NodeDatabase from "@smthrs/database/node/NodeDatabase"
29
+ import { AttemptStore, Migrations, RunStore } from "@smthrs/run-store"
30
+ import * as Layer from "effect/Layer"
31
+
32
+ export const storage = (filename: string) =>
33
+ Layer.mergeAll(RunStore.layer, AttemptStore.layer).pipe(
34
+ Layer.provideMerge(Migrations.layer),
35
+ Layer.provideMerge(DurableWriter.layer()),
36
+ Layer.provideMerge(NodeDatabase.layer({ filename }))
37
+ )
38
+ ```
39
+
40
+ `Layer.provideMerge` is what keeps the database and the writer in the output
41
+ context, so the rest of the host can reach them too.
42
+
43
+ The SQLite and PostgreSQL adapters share this package's migrations and
44
+ statements. The composition must satisfy the `DurableWriter` serialization
45
+ contract: concurrent write transactions cannot both commit from snapshots
46
+ that exclude each other's writes. Use `NodeDatabase`, `BunDatabase`, or
47
+ `PostgresDatabase` from `@smthrs/database`; a bare PostgreSQL READ COMMITTED
48
+ client does not provide the required serialization.
49
+
50
+ Store writes nested inside `DurableWriter.write` join it as savepoints. A
51
+ transient database conflict retries the entire outer transaction. Persistence
52
+ errors retain a payload-free `DatabaseError` in their cause chain so redaction
53
+ does not remove that retry classification. SQL text and bound values are not
54
+ included.
55
+
56
+ ## Compose the migration set, do not run it twice
57
+
58
+ `Migrations.layer` installs this package's tables and nothing else. A host that
59
+ also runs the journal, the step cache, or the engine state should compose the
60
+ migration sets instead, so one migrator applies them in one pass:
61
+
62
+ ```ts
63
+ import * as DatabaseMigrations from "@smthrs/database/Migrations"
64
+ import { Migrations } from "@smthrs/run-store"
65
+
66
+ const runAll = DatabaseMigrations.run([Migrations.set /* , the other packages' sets */])
67
+ ```
68
+
69
+ `Migrations.set` reserves id block 1000, so its ids can never collide with
70
+ another package's. `@smthrs/engine-store` already composes the full set list.
71
+
72
+ ## Choose the attempt-store policy
73
+
74
+ `AttemptStore.layer` takes the defaults: only `running` counts as in progress,
75
+ checkpoints cap at 1 MiB, and `put` is first-writer-wins. `layerWith` takes a
76
+ policy instead:
77
+
78
+ ```ts
79
+ import { AttemptStore } from "@smthrs/run-store"
80
+
81
+ const attempts = AttemptStore.layerWith({
82
+ inProgressStates: ["running", "retrying"],
83
+ maxCheckpointBytes: 4 * 1024 * 1024,
84
+ putMode: "upsert"
85
+ })
86
+ ```
87
+
88
+ - `inProgressStates` names the states that mean "this attempt is still moving".
89
+ `heartbeat` and `finish` fence on membership, and `finish` refuses them as
90
+ targets. The names must be unique, non-empty durable text.
91
+ - `maxCheckpointBytes` must be between 1 and `AttemptStore.maximumCheckpointBytes`.
92
+ - `putMode: "upsert"` lets a re-`put` overwrite an attempt that is still in
93
+ progress and report `Upserted`. A terminal attempt stays immutable in both
94
+ modes.
95
+
96
+ All three are validated, detached, and frozen when the store is built, so an
97
+ invalid policy fails at composition time with `AttemptStoreError`.
98
+
99
+ ## Stubs for compositions that must not persist
100
+
101
+ Both services ship an explicit absence, so a composition that cannot reach a
102
+ database still type-checks and still says so out loud:
103
+
104
+ | Layer | Behavior |
105
+ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
106
+ | `RunStore.layerNoop()` | `create` and `get` fail with `persistence_failed`. Every compare-and-swap reports its "you lost" outcome: `NotFound`, or `ClaimLost` for `activate` and `abandonClaim`. |
107
+ | `AttemptStore.layerNoop()` | Every operation fails with the `unknown` code. |
108
+
109
+ Both take partial overrides, which is how a test replaces one operation without
110
+ building a whole store:
111
+
112
+ ```ts
113
+ import { RunStore } from "@smthrs/run-store"
114
+ import * as Effect from "effect/Effect"
115
+
116
+ const alwaysFenced = RunStore.layerNoop({
117
+ heartbeat: () => Effect.succeed({ _tag: "FenceLost" })
118
+ })
119
+ ```
120
+
121
+ ## Verify the composition
122
+
123
+ Read a run back through the layer you built. If migrations did not run, `get`
124
+ fails with `persistence_failed` rather than returning nothing:
125
+
126
+ ```ts
127
+ import { RunStore } from "@smthrs/run-store"
128
+ import * as Effect from "effect/Effect"
129
+
130
+ const check = Effect.gen(function*() {
131
+ const runs = yield* RunStore.RunStore
132
+ yield* runs.create("composition-check", "{}")
133
+ return yield* runs.get("composition-check")
134
+ })
135
+ ```
136
+
137
+ ## Next steps
138
+
139
+ - [Claim a run and finish it](./claim-and-finish-a-run.md): what to do with the
140
+ composed services.
141
+ - [Test against the real stores](./testing.md): the in-memory layer that skips
142
+ all of this.
@@ -0,0 +1,182 @@
1
+ ---
2
+ title: "Observe store outcomes"
3
+ description: "Read the fencing counters and spans the stores emit: the three counters and their attributes, what a span carries, how to wire an exporter, and how to assert on a counter in a test."
4
+ sidebar:
5
+ order: 6
6
+ ---
7
+
8
+ A lost fence is a normal success value, so it never reaches an error channel and
9
+ never appears in a log by default. The stores publish it as telemetry instead:
10
+ every claim, heartbeat, and transition updates an attributed counter and closes a
11
+ span that says how it ended. Wire an exporter once and "who keeps stealing this
12
+ run" becomes a query rather than a grep.
13
+
14
+ ## The three counters
15
+
16
+ `RunStoreMetrics` defines the handles, and `RunStore` updates them as it decides
17
+ each outcome. Every driver that writes through the store lands in the same
18
+ counters.
19
+
20
+ | Counter | Attributes | What it counts |
21
+ | ----------------------- | --------------- | ------------------------------------------------------------------ |
22
+ | `flows_run_claims` | `op`, `outcome` | Every ownership compare-and-swap, across all six claim operations. |
23
+ | `flows_run_heartbeats` | `outcome` | Every fenced ownership heartbeat. |
24
+ | `flows_run_transitions` | `outcome`, `to` | Every fenced owned transition, by requested target status. |
25
+
26
+ `op` is the operation in snake case: `claim`, `claim_and_own`, `activate`,
27
+ `abandon_claim`, `recover_claim`, or `steal`. `outcome` is the result tag in
28
+ snake case, so `HeartbeatFresh` is recorded as `heartbeat_fresh`. `to` on a
29
+ transition is the target status you asked for, whether or not the write won.
30
+ A call that fails or is interrupted still counts, under `outcome="failure"` or
31
+ `outcome="interrupt"`, so a database refusing writes shows up as a failure rate
32
+ rather than a silent drop.
33
+
34
+ Four of those series are the ones worth alerting on:
35
+
36
+ - `flows_run_heartbeats{outcome="fence_lost"}` is the fencing event: an owner
37
+ discovered another process holds its run.
38
+ - `flows_run_claims{op="activate",outcome="claim_lost"}` means a held claim was
39
+ recovered or replaced before its activation ran.
40
+ - `flows_run_heartbeats{outcome="failure"}` is a lease write the database
41
+ refused. `heartbeatLoop` also logs `run heartbeat write failed` on the first
42
+ failure of an outage, and `run lease lapsed; interrupting owned work` when
43
+ the write tolerance runs out.
44
+ - `flows_run_claims{op="steal",outcome="claimed"}` is a takeover. A steady rate
45
+ of these is a host that keeps dying, or two hosts with clocks far enough apart
46
+ to take runs from each other.
47
+
48
+ A terminal transition also advances `runThroughput` from
49
+ [`@smthrs/observability`](/api/observability), published as
50
+ `flows_run_throughput`, so finished runs are counted in the same place as the
51
+ rest of the runtime. See
52
+ [Read the runtime metrics](/pkg/observability/guides/read-runtime-metrics).
53
+
54
+ Throughput advances only after the outermost managed `DurableWriter.write`
55
+ commits. Rolled-back savepoints and retried transactions do not count; the
56
+ final committed transition counts once. Use the shared writer for this metric:
57
+ raw SQL transactions do not provide a managed commit notification.
58
+
59
+ ## Wire an exporter
60
+
61
+ This package ships no exporter. It defines counters and updates them, so they
62
+ appear in whatever registry the composition provides:
63
+
64
+ ```ts
65
+ import * as Layer from "effect/Layer"
66
+ import * as Metric from "effect/Metric"
67
+
68
+ const registry = Layer.succeed(Metric.MetricRegistry)(new Map())
69
+ ```
70
+
71
+ Provide `@smthrs/observability`'s exporter layer instead when you want the
72
+ counters to leave the process. Nothing about the stores changes either way: an
73
+ unprovided registry costs a map write and is dropped.
74
+
75
+ ## Read a counter directly
76
+
77
+ Each exported record maps an outcome tag to an attributed view of its counter, so
78
+ reading one is a lookup rather than a filter:
79
+
80
+ ```ts
81
+ import { RunStoreMetrics } from "@smthrs/run-store"
82
+ import * as Effect from "effect/Effect"
83
+ import * as Metric from "effect/Metric"
84
+
85
+ const fenceLosses = Effect.map(
86
+ Metric.value(RunStoreMetrics.heartbeat.FenceLost),
87
+ (state) => state.count
88
+ )
89
+ ```
90
+
91
+ The same shape works for `RunStoreMetrics.claim`, `claimAndOwn`, `activate`,
92
+ `abandonClaim`, `recoverClaim`, `steal`, and `transition`. Each record's keys are
93
+ exactly the outcome tags of the matching operation, so a new outcome cannot be
94
+ added without a key to count it under.
95
+
96
+ `transition` is the one exception to "look it up and read it": `RunStore` adds the
97
+ `to` attribute at the update site, because the target status is call input rather
98
+ than a closed set the metrics module should restate. Add the same attribute to
99
+ read a single target:
100
+
101
+ ```ts
102
+ const completedTransitions = Metric.value(
103
+ Metric.withAttributes(RunStoreMetrics.transition.Transitioned, { to: "completed" })
104
+ )
105
+ ```
106
+
107
+ ## Read the spans
108
+
109
+ Every store operation runs inside a span named after it, `RunStore.claim`,
110
+ `AttemptStore.finish`, and so on. Each span carries the identity it operated
111
+ on, the acting host when the call is owner or claimant fenced, and an `outcome`
112
+ written when the span closes. A success carries its outcome tag in snake case,
113
+ or `success` for operations with no domain outcome. A failed effect carries
114
+ `failure`, and an interrupted one carries `interrupt`, so no span ever closes
115
+ without saying how it ended. Identity attributes are written after the inputs
116
+ are validated, so a call rejected as invalid input closes with `outcome` alone.
117
+
118
+ | Span | Identity | Acting host | `outcome` on success |
119
+ | ------------------------------- | ----------------------------------- | ---------------- | ---------------------------------------------------------------------------------- |
120
+ | `RunStore.create` | `runId` | none | `success` |
121
+ | `RunStore.get` | `runId` | none | `success` |
122
+ | `RunStore.lineage` | `runId` | none | `success` |
123
+ | `RunStore.latestRound` | `runId` | none | `success` |
124
+ | `RunStore.requestCancel` | `runId` | none | request-cancel tag |
125
+ | `RunStore.requestCancelLineage` | `runId` | none | request-cancel tag |
126
+ | `RunStore.acknowledgeCancel` | `runId` | `ownerHostId` | `success` |
127
+ | `RunStore.claim` | `runId` | `claimantHostId` | claim tag |
128
+ | `RunStore.claimAndOwn` | `runId` | `ownerHostId` | claim-and-own tag |
129
+ | `RunStore.activate` | `runId` | `claimantHostId` | activate tag |
130
+ | `RunStore.abandonClaim` | `runId` | `claimantHostId` | abandon-claim tag |
131
+ | `RunStore.recoverClaim` | `runId` | `observerHostId` | recover-claim tag |
132
+ | `RunStore.heartbeat` | `runId` | `ownerHostId` | heartbeat tag |
133
+ | `RunStore.transitionOwned` | `runId`, `to` | `ownerHostId` | transition tag |
134
+ | `RunStore.steal` | `runId` | `claimantHostId` | steal tag |
135
+ | `AttemptStore.put` | `runId`, `stepKeyDigest`, `attempt` | `ownerHostId` | `inserted`, `upserted`, `existing_same`, `conflict`, `fence_lost`, `run_not_found` |
136
+ | `AttemptStore.get` | `runId`, `stepKeyDigest`, `attempt` | none | `success` |
137
+ | `AttemptStore.heartbeat` | `runId`, `stepKeyDigest`, `attempt` | `ownerHostId` | `updated`, `fence_lost`, `not_found`, `state_changed` |
138
+ | `AttemptStore.finish` | `runId`, `stepKeyDigest`, `attempt` | `ownerHostId` | `finished`, `fence_lost`, `not_found`, `state_changed` |
139
+ | `AttemptStore.patch` | `runId`, `stepKeyDigest`, `attempt` | `ownerHostId` | `patched`, `not_found`, `fence_lost` |
140
+
141
+ A `RunStore` outcome tag is the snake case form of the matching result type's
142
+ `_tag`, `cancel_requested` for `requestCancel` or `fence_lost` for `heartbeat`.
143
+ `RunStore.acknowledgeCancel` returns a boolean rather than a tagged result, so
144
+ its span closes with `success` whether or not the guarded update matched. The
145
+ `SpanAnnotations` test in this package asserts the exact attribute set of each
146
+ `AttemptStore` span and of `acknowledgeCancel`; change both together.
147
+
148
+ Failure causes are published to logs, spans, and telemetry, so they carry field
149
+ names, lengths, and validity flags and never the value that failed. A
150
+ `decode_failed` cause names the invariants the row broke, not its executable
151
+ state. See [Durable values](../concepts/durable-values.md).
152
+
153
+ ## Assert on a counter in a test
154
+
155
+ Provide a registry, run the work, and read the count:
156
+
157
+ ```ts
158
+ import { RunStore, RunStoreMetrics } from "@smthrs/run-store"
159
+ import * as TestRunStore from "@smthrs/run-store/test/TestRunStore"
160
+ import * as Effect from "effect/Effect"
161
+ import * as Metric from "effect/Metric"
162
+
163
+ const lostFences = Effect.gen(function*() {
164
+ const runs = yield* RunStore.RunStore
165
+ yield* runs.heartbeat("missing-run", { hostId: "host-a", pid: 1, nonce: "n" }, 0)
166
+ return yield* Metric.value(RunStoreMetrics.heartbeat.NotFound)
167
+ }).pipe(
168
+ Effect.provide(TestRunStore.layer),
169
+ Effect.provideService(Metric.MetricRegistry, new Map()),
170
+ Effect.scoped
171
+ )
172
+ ```
173
+
174
+ Counting outcomes is how a test proves a race resolved the way it claims: a
175
+ takeover test that asserts one `steal` `claimed` and one `heartbeat`
176
+ `fence_lost` has pinned both halves of the exchange.
177
+
178
+ ## Next steps
179
+
180
+ - [Test against the real stores](./testing.md): the layer used here.
181
+ - [Troubleshooting](../troubleshooting.md): what each outcome means when it is
182
+ the one you did not expect.