@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.
- package/CHANGELOG.md +112 -0
- package/LICENSE +21 -0
- package/README.md +195 -2
- package/dist/cjs/AttemptStore.d.ts +359 -0
- package/dist/cjs/AttemptStore.d.ts.map +1 -0
- package/dist/cjs/AttemptStore.js +731 -0
- package/dist/cjs/AttemptStore.js.map +7 -0
- package/dist/cjs/Heartbeat.d.ts +46 -0
- package/dist/cjs/Heartbeat.d.ts.map +1 -0
- package/dist/cjs/Heartbeat.js +28 -0
- package/dist/cjs/Heartbeat.js.map +7 -0
- package/dist/cjs/Migrations.d.ts +42 -0
- package/dist/cjs/Migrations.d.ts.map +1 -0
- package/dist/cjs/Migrations.js +55 -0
- package/dist/cjs/Migrations.js.map +7 -0
- package/dist/cjs/Ownership.d.ts +275 -0
- package/dist/cjs/Ownership.d.ts.map +1 -0
- package/dist/cjs/Ownership.js +129 -0
- package/dist/cjs/Ownership.js.map +7 -0
- package/dist/cjs/RunStore.d.ts +571 -0
- package/dist/cjs/RunStore.d.ts.map +1 -0
- package/dist/cjs/RunStore.js +1079 -0
- package/dist/cjs/RunStore.js.map +7 -0
- package/dist/cjs/RunStoreMetrics.d.ts +151 -0
- package/dist/cjs/RunStoreMetrics.d.ts.map +1 -0
- package/dist/cjs/RunStoreMetrics.js +91 -0
- package/dist/cjs/RunStoreMetrics.js.map +7 -0
- package/dist/cjs/index.d.ts +47 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +43 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/Boundary.d.ts +80 -0
- package/dist/cjs/internal/Boundary.d.ts.map +1 -0
- package/dist/cjs/internal/Boundary.js +92 -0
- package/dist/cjs/internal/Boundary.js.map +7 -0
- package/dist/cjs/internal/Companion.d.ts +44 -0
- package/dist/cjs/internal/Companion.d.ts.map +1 -0
- package/dist/cjs/internal/Companion.js +50 -0
- package/dist/cjs/internal/Companion.js.map +7 -0
- package/dist/cjs/internal/SpanOutcome.d.ts +37 -0
- package/dist/cjs/internal/SpanOutcome.d.ts.map +1 -0
- package/dist/cjs/internal/SpanOutcome.js +62 -0
- package/dist/cjs/internal/SpanOutcome.js.map +7 -0
- package/dist/cjs/migrations/0001_initial.d.ts +15 -0
- package/dist/cjs/migrations/0001_initial.d.ts.map +1 -0
- package/dist/cjs/migrations/0001_initial.js +110 -0
- package/dist/cjs/migrations/0001_initial.js.map +7 -0
- package/dist/cjs/migrations/0002_lineage.d.ts +27 -0
- package/dist/cjs/migrations/0002_lineage.d.ts.map +1 -0
- package/dist/cjs/migrations/0002_lineage.js +43 -0
- package/dist/cjs/migrations/0002_lineage.js.map +7 -0
- package/dist/cjs/migrations/0003_execution_revisions.d.ts +15 -0
- package/dist/cjs/migrations/0003_execution_revisions.d.ts.map +1 -0
- package/dist/cjs/migrations/0003_execution_revisions.js +77 -0
- package/dist/cjs/migrations/0003_execution_revisions.js.map +7 -0
- package/dist/cjs/migrations/0004_waiting_request.d.ts +32 -0
- package/dist/cjs/migrations/0004_waiting_request.d.ts.map +1 -0
- package/dist/cjs/migrations/0004_waiting_request.js +42 -0
- package/dist/cjs/migrations/0004_waiting_request.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/test/TestRunStore.d.ts +17 -0
- package/dist/cjs/test/TestRunStore.d.ts.map +1 -0
- package/dist/cjs/test/TestRunStore.js +42 -0
- package/dist/cjs/test/TestRunStore.js.map +7 -0
- package/dist/esm/AttemptStore.d.ts +359 -0
- package/dist/esm/AttemptStore.d.ts.map +1 -0
- package/dist/esm/AttemptStore.js +835 -0
- package/dist/esm/AttemptStore.js.map +1 -0
- package/dist/esm/Heartbeat.d.ts +46 -0
- package/dist/esm/Heartbeat.d.ts.map +1 -0
- package/dist/esm/Heartbeat.js +46 -0
- package/dist/esm/Heartbeat.js.map +1 -0
- package/dist/esm/Migrations.d.ts +42 -0
- package/dist/esm/Migrations.d.ts.map +1 -0
- package/dist/esm/Migrations.js +56 -0
- package/dist/esm/Migrations.js.map +1 -0
- package/dist/esm/Ownership.d.ts +275 -0
- package/dist/esm/Ownership.d.ts.map +1 -0
- package/dist/esm/Ownership.js +299 -0
- package/dist/esm/Ownership.js.map +1 -0
- package/dist/esm/RunStore.d.ts +571 -0
- package/dist/esm/RunStore.d.ts.map +1 -0
- package/dist/esm/RunStore.js +1287 -0
- package/dist/esm/RunStore.js.map +1 -0
- package/dist/esm/RunStoreMetrics.d.ts +151 -0
- package/dist/esm/RunStoreMetrics.d.ts.map +1 -0
- package/dist/esm/RunStoreMetrics.js +120 -0
- package/dist/esm/RunStoreMetrics.js.map +1 -0
- package/dist/esm/index.d.ts +47 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +47 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/Boundary.d.ts +80 -0
- package/dist/esm/internal/Boundary.d.ts.map +1 -0
- package/dist/esm/internal/Boundary.js +109 -0
- package/dist/esm/internal/Boundary.js.map +1 -0
- package/dist/esm/internal/Companion.d.ts +44 -0
- package/dist/esm/internal/Companion.d.ts.map +1 -0
- package/dist/esm/internal/Companion.js +53 -0
- package/dist/esm/internal/Companion.js.map +1 -0
- package/dist/esm/internal/SpanOutcome.d.ts +37 -0
- package/dist/esm/internal/SpanOutcome.d.ts.map +1 -0
- package/dist/esm/internal/SpanOutcome.js +48 -0
- package/dist/esm/internal/SpanOutcome.js.map +1 -0
- package/dist/esm/migrations/0001_initial.d.ts +15 -0
- package/dist/esm/migrations/0001_initial.d.ts.map +1 -0
- package/dist/esm/migrations/0001_initial.js +88 -0
- package/dist/esm/migrations/0001_initial.js.map +1 -0
- package/dist/esm/migrations/0002_lineage.d.ts +27 -0
- package/dist/esm/migrations/0002_lineage.d.ts.map +1 -0
- package/dist/esm/migrations/0002_lineage.js +33 -0
- package/dist/esm/migrations/0002_lineage.js.map +1 -0
- package/dist/esm/migrations/0003_execution_revisions.d.ts +15 -0
- package/dist/esm/migrations/0003_execution_revisions.d.ts.map +1 -0
- package/dist/esm/migrations/0003_execution_revisions.js +58 -0
- package/dist/esm/migrations/0003_execution_revisions.js.map +1 -0
- package/dist/esm/migrations/0004_waiting_request.d.ts +32 -0
- package/dist/esm/migrations/0004_waiting_request.d.ts.map +1 -0
- package/dist/esm/migrations/0004_waiting_request.js +37 -0
- package/dist/esm/migrations/0004_waiting_request.js.map +1 -0
- package/dist/esm/test/TestRunStore.d.ts +17 -0
- package/dist/esm/test/TestRunStore.d.ts.map +1 -0
- package/dist/esm/test/TestRunStore.js +19 -0
- package/dist/esm/test/TestRunStore.js.map +1 -0
- package/docs/README.md +142 -0
- package/docs/api.md +901 -0
- package/docs/concepts/durable-values.md +140 -0
- package/docs/concepts/execution-revisions.md +53 -0
- package/docs/concepts/fencing.md +146 -0
- package/docs/concepts/leases.md +154 -0
- package/docs/concepts/liveness-evidence.md +142 -0
- package/docs/guides/cancel-a-run.md +156 -0
- package/docs/guides/claim-and-finish-a-run.md +175 -0
- package/docs/guides/compose-the-stores.md +142 -0
- package/docs/guides/observe-outcomes.md +182 -0
- package/docs/guides/record-step-attempts.md +170 -0
- package/docs/guides/recover-a-stalled-run.md +173 -0
- package/docs/guides/testing.md +141 -0
- package/docs/installation.md +109 -0
- package/docs/quickstart.md +201 -0
- package/docs/troubleshooting.md +255 -0
- package/package.json +183 -3
- package/src/AttemptStore.ts +1195 -0
- package/src/Heartbeat.ts +47 -0
- package/src/Migrations.ts +59 -0
- package/src/Ownership.ts +407 -0
- package/src/RunStore.ts +2063 -0
- package/src/RunStoreMetrics.ts +165 -0
- package/src/index.ts +51 -0
- package/src/internal/Boundary.ts +130 -0
- package/src/internal/Companion.ts +64 -0
- package/src/internal/SpanOutcome.ts +71 -0
- package/src/migrations/0001_initial.ts +114 -0
- package/src/migrations/0002_lineage.ts +38 -0
- package/src/migrations/0003_execution_revisions.ts +69 -0
- package/src/migrations/0004_waiting_request.ts +39 -0
- package/src/test/TestRunStore.ts +22 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Quickstart"
|
|
3
|
+
description: "Drive one run through its whole durable lifecycle: create, claim, activate, record a step attempt, finish it, and settle the run, against an in-memory database."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
This quickstart takes one run from `pending` to `completed` and records a step
|
|
9
|
+
attempt along the way. The stores are the production ones. Only the database is
|
|
10
|
+
in memory, so the script runs with no files and no configuration.
|
|
11
|
+
|
|
12
|
+
By the end you will have used every operation the durable engine uses on a
|
|
13
|
+
normal run, and read back the row a restart would have found.
|
|
14
|
+
|
|
15
|
+
## Prerequisites
|
|
16
|
+
|
|
17
|
+
- Node.js 26.4.0 or later.
|
|
18
|
+
- The package installed. Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
|
|
19
|
+
|
|
20
|
+
## Name the owner
|
|
21
|
+
|
|
22
|
+
An owner identity is the fence on every write this process makes. It is three
|
|
23
|
+
fields, and all three are compared in the same SQL statement as the mutation
|
|
24
|
+
they guard:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import type { OwnerId } from "@smthrs/run-store/Ownership"
|
|
28
|
+
|
|
29
|
+
const owner: OwnerId = { hostId: "quickstart-host", pid: 4102, nonce: "3f9c-8b21" }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`hostId` says which machine, `pid` says which process, and `nonce` distinguishes
|
|
33
|
+
one incarnation of that process from the next. A production host mints this
|
|
34
|
+
through `OwnerIdentity` in [`@smthrs/engine-store`](/api/engine-store) rather
|
|
35
|
+
than writing it down.
|
|
36
|
+
|
|
37
|
+
## Create the run and claim it
|
|
38
|
+
|
|
39
|
+
`create` inserts a `pending` row whose `stateJson` is the executable state a
|
|
40
|
+
resume would re-enter. Claiming is two phase: `claim` reserves the row, and
|
|
41
|
+
`activate` turns the reservation into ownership.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { RunStore } from "@smthrs/run-store"
|
|
45
|
+
import * as Clock from "effect/Clock"
|
|
46
|
+
import * as Effect from "effect/Effect"
|
|
47
|
+
|
|
48
|
+
const claimRun = Effect.gen(function*() {
|
|
49
|
+
const runs = yield* RunStore.RunStore
|
|
50
|
+
|
|
51
|
+
yield* runs.create("quickstart-1", JSON.stringify({ cursor: 0 }))
|
|
52
|
+
|
|
53
|
+
const pending = yield* runs.get("quickstart-1")
|
|
54
|
+
const expected = {
|
|
55
|
+
status: pending.status,
|
|
56
|
+
owner: pending.owner,
|
|
57
|
+
heartbeatAtMs: pending.heartbeatAtMs
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const nowMs = yield* Clock.currentTimeMillis
|
|
61
|
+
const claim = yield* runs.claim("quickstart-1", expected, owner, nowMs)
|
|
62
|
+
if (claim._tag !== "Claimed") return yield* Effect.die(`claim lost: ${claim._tag}`)
|
|
63
|
+
|
|
64
|
+
const activation = yield* runs.activate("quickstart-1", owner, claim.claimedAtMs, expected)
|
|
65
|
+
if (activation._tag !== "Activated") return yield* Effect.die(`activation lost: ${activation._tag}`)
|
|
66
|
+
})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`expected` is the row you just read, restated as the three fields a claim
|
|
70
|
+
guards. The compare-and-swap admits the write only while the row still matches,
|
|
71
|
+
so a peer that claimed the run between your read and your write loses the race
|
|
72
|
+
instead of overwriting it.
|
|
73
|
+
|
|
74
|
+
## Record a step attempt
|
|
75
|
+
|
|
76
|
+
An attempt is addressed by `(runId, stepKeyDigest, attempt)` and is fenced on
|
|
77
|
+
the run you own. `put` starts it, `heartbeat` proves it is still moving and
|
|
78
|
+
saves a checkpoint, and `finish` records how it ended:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { AttemptStore } from "@smthrs/run-store"
|
|
82
|
+
|
|
83
|
+
const runStep = Effect.gen(function*() {
|
|
84
|
+
const attempts = yield* AttemptStore.AttemptStore
|
|
85
|
+
const startedAtMs = yield* Clock.currentTimeMillis
|
|
86
|
+
|
|
87
|
+
yield* attempts.put({
|
|
88
|
+
runId: "quickstart-1",
|
|
89
|
+
stepKeyDigest: "step-fetch",
|
|
90
|
+
attempt: 0,
|
|
91
|
+
state: "running",
|
|
92
|
+
startedAtMs,
|
|
93
|
+
meta: { step: "fetch" }
|
|
94
|
+
}, owner)
|
|
95
|
+
|
|
96
|
+
yield* attempts.heartbeat(
|
|
97
|
+
"quickstart-1",
|
|
98
|
+
"step-fetch",
|
|
99
|
+
0,
|
|
100
|
+
owner,
|
|
101
|
+
yield* Clock.currentTimeMillis,
|
|
102
|
+
{ cursor: 1 }
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
yield* attempts.finish({
|
|
106
|
+
runId: "quickstart-1",
|
|
107
|
+
stepKeyDigest: "step-fetch",
|
|
108
|
+
attempt: 0,
|
|
109
|
+
state: "completed",
|
|
110
|
+
finishedAtMs: yield* Clock.currentTimeMillis,
|
|
111
|
+
outcome: { rows: 12 }
|
|
112
|
+
}, owner)
|
|
113
|
+
})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The sixth argument to `heartbeat` is a checkpoint: the value the step would
|
|
117
|
+
resume from. Every attempt value crosses an admission boundary that copies it as
|
|
118
|
+
inert JSON before persistence can yield, so a getter, a cycle, or a `toJSON`
|
|
119
|
+
method is refused rather than persisted. See
|
|
120
|
+
[Durable values](./concepts/durable-values.md).
|
|
121
|
+
|
|
122
|
+
## Settle the run
|
|
123
|
+
|
|
124
|
+
`transitionOwned` is the only way to move a run you own. A terminal target
|
|
125
|
+
clears the owner, the heartbeat, and any claim, and stamps `finished_at_ms`:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const settleRun = Effect.gen(function*() {
|
|
129
|
+
const runs = yield* RunStore.RunStore
|
|
130
|
+
const outcome = yield* runs.transitionOwned(
|
|
131
|
+
"quickstart-1",
|
|
132
|
+
owner,
|
|
133
|
+
"completed",
|
|
134
|
+
JSON.stringify({ cursor: 1 })
|
|
135
|
+
)
|
|
136
|
+
if (outcome._tag !== "Transitioned") return yield* Effect.die(`fence lost: ${outcome._tag}`)
|
|
137
|
+
return yield* runs.get("quickstart-1")
|
|
138
|
+
})
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Run it
|
|
142
|
+
|
|
143
|
+
`TestRunStore.layer` provides both stores over a fresh in-memory SQLite
|
|
144
|
+
database with the migrations already applied:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import * as TestRunStore from "@smthrs/run-store/test/TestRunStore"
|
|
148
|
+
|
|
149
|
+
const main = claimRun.pipe(
|
|
150
|
+
Effect.andThen(runStep),
|
|
151
|
+
Effect.andThen(settleRun),
|
|
152
|
+
Effect.provide(TestRunStore.layer),
|
|
153
|
+
Effect.scoped
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
console.log(await Effect.runPromise(main))
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Run the file with your TypeScript runner. The settled row prints with its owner
|
|
160
|
+
cleared and both lifecycle stamps written:
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
{
|
|
164
|
+
runId: 'quickstart-1',
|
|
165
|
+
status: 'completed',
|
|
166
|
+
createdAtMs: 1757000000000,
|
|
167
|
+
startedAtMs: 1757000000000,
|
|
168
|
+
finishedAtMs: 1757000000004,
|
|
169
|
+
owner: null,
|
|
170
|
+
heartbeatAtMs: null,
|
|
171
|
+
claim: null,
|
|
172
|
+
claimedAtMs: null,
|
|
173
|
+
parentRunId: null,
|
|
174
|
+
cancelRequestedAtMs: null,
|
|
175
|
+
lineageId: null,
|
|
176
|
+
roundOrdinal: null,
|
|
177
|
+
stateJson: '{"cursor":1}'
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## What just happened
|
|
182
|
+
|
|
183
|
+
The run row moved `pending` to `running` to `completed`, and every write on the
|
|
184
|
+
way was admitted only because the full `(hostId, pid, nonce)` fence still
|
|
185
|
+
matched the row. Had a second process taken the run over between your
|
|
186
|
+
`activate` and your `transitionOwned`, the transition would have reported
|
|
187
|
+
`FenceLost` and changed nothing: the fence is compared in the same statement as
|
|
188
|
+
the mutation, so there is no window between the check and the write.
|
|
189
|
+
|
|
190
|
+
The `stateJson` you passed to `transitionOwned` is what a restart would
|
|
191
|
+
re-enter. It is stored and returned byte for byte, with nothing rewritten in
|
|
192
|
+
between.
|
|
193
|
+
|
|
194
|
+
## Next steps
|
|
195
|
+
|
|
196
|
+
- [Claim a run and finish it](./guides/claim-and-finish-a-run.md): the same
|
|
197
|
+
lifecycle with heartbeat supervision and the atomic single-call form.
|
|
198
|
+
- [Fencing and ownership](./concepts/fencing.md): why every refused write is a
|
|
199
|
+
named outcome instead of an error.
|
|
200
|
+
- [Test against the real stores](./guides/testing.md): the layer used here,
|
|
201
|
+
turned into a test habit.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Troubleshooting"
|
|
3
|
+
description: "Every typed failure and refused outcome these stores report, what causes each one, and what to change: invalid input, corrupt rows, lost fences, refused guards, unconfirmed evidence, and clock mistakes."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The stores answer on two channels, and which one you are on decides what to do
|
|
7
|
+
next.
|
|
8
|
+
|
|
9
|
+
- A **refused outcome** is a success value. `FenceLost`, `AlreadyClaimed`,
|
|
10
|
+
`GuardFailed`, and the rest mean the store worked correctly and your write did
|
|
11
|
+
not win. Handle it; do not retry it blindly.
|
|
12
|
+
- A **typed error**, `RunStoreError` or `AttemptStoreError`, means a defect:
|
|
13
|
+
invalid input, a corrupt durable row, or a database that failed.
|
|
14
|
+
|
|
15
|
+
Both carry a `method` naming the operation and a `message` prefixed with the
|
|
16
|
+
code, so `invalid_run: RunStore.claim: run input is invalid` says where to look
|
|
17
|
+
before you read the cause. Causes carry field names, lengths, and validity flags,
|
|
18
|
+
never the value that failed. Every code and outcome is listed in the
|
|
19
|
+
[API reference](./api.md).
|
|
20
|
+
|
|
21
|
+
## invalid_run and invalid_attempt
|
|
22
|
+
|
|
23
|
+
**What happened.** An input did not meet the durable contract, and nothing was
|
|
24
|
+
written. The cause names the field and what was wrong with it. The common ones:
|
|
25
|
+
|
|
26
|
+
- **An invalid snapshot.** `RunSnapshot` requires own enumerable data fields
|
|
27
|
+
`status`, `owner`, and `heartbeatAtMs`. A `RunRow` returned by `get` is valid
|
|
28
|
+
as `expected`; extra own data fields are ignored. Missing or inherited
|
|
29
|
+
required fields, enumerable accessors on any key, and non-plain prototypes
|
|
30
|
+
are refused. So is a partial ownership pair: `running` requires both an
|
|
31
|
+
owner and a heartbeat, and every other status requires neither.
|
|
32
|
+
- **A timestamp that is not a non-negative safe integer.** Every `nowMs`,
|
|
33
|
+
`claimedAtMs`, `startedAtMs`, and `finishedAtMs` is checked on its own.
|
|
34
|
+
- **A lease reading ahead of the store's clock.** `claim`, `claimAndOwn`,
|
|
35
|
+
`steal`, `heartbeat`, and `recoverClaim` refuse a `nowMs` more than
|
|
36
|
+
`heartbeatSkewAllowance` ahead of the injected `Clock`, with the detail "runs
|
|
37
|
+
ahead of the store clock by more than the heartbeat skew allowance".
|
|
38
|
+
- **An identifier that is not durable text.** Run ids, step key digests, lineage
|
|
39
|
+
ids, owner host ids and nonces, and attempt state names must be non-empty, at
|
|
40
|
+
most 1,024 UTF-16 units, free of NUL, and free of lone surrogates.
|
|
41
|
+
- **`toStatus: "pending"`.** A run does not go back to unstarted.
|
|
42
|
+
- **A malformed `TransitionGuard`.** `cancelRequested` accepts only `"absent"`
|
|
43
|
+
or `"present"`, and the guard object takes no other key.
|
|
44
|
+
- **A value the JSON boundary refused.** The complaint names the rule: "contains
|
|
45
|
+
an accessor", "contains a cycle", "contains a non-plain object", "contains a
|
|
46
|
+
sparse or accessor array member", "contains an enumerable symbol", "contains a
|
|
47
|
+
non-finite number", "contains unbounded or ill-formed text", "exceeds the
|
|
48
|
+
maximum JSON depth of 128", or "contains more than 100000 JSON values".
|
|
49
|
+
- **A checkpoint over the byte ceiling**, "exceeds the JSON byte limit". Only
|
|
50
|
+
checkpoints have one.
|
|
51
|
+
- **An invalid store policy**, reported by `AttemptStore.makeWith` and
|
|
52
|
+
`layerWith`: `inProgressStates` must be a non-empty array of unique durable
|
|
53
|
+
names, `maxCheckpointBytes` must be between 1 and
|
|
54
|
+
`AttemptStore.maximumCheckpointBytes`, and `putMode` must be `"insert"` or
|
|
55
|
+
`"upsert"`.
|
|
56
|
+
|
|
57
|
+
**What to change.** Fix the input. For run state and attempt values that means
|
|
58
|
+
building plain data: no class instances, no `toJSON`, no getters. For a policy it
|
|
59
|
+
means fixing the layer, because the failure arrives when the store is built, not
|
|
60
|
+
when the first bad value does. See [Durable values](./concepts/durable-values.md).
|
|
61
|
+
|
|
62
|
+
## decode_failed
|
|
63
|
+
|
|
64
|
+
**What happened.** A row came back that these stores could not have written. The
|
|
65
|
+
cause distinguishes two cases: "could not decode flows_runs row" is a schema
|
|
66
|
+
mismatch, and "flows_runs row violates durable invariants" is a row whose
|
|
67
|
+
ownership, claim, or state columns disagree with each other, for example an owner
|
|
68
|
+
without a heartbeat or a `running` row with no owner.
|
|
69
|
+
|
|
70
|
+
**What to change.** Find the other writer. The invariants are also SQL `CHECK`
|
|
71
|
+
constraints, so a row that breaks them was written by raw SQL, a foreign tool, or
|
|
72
|
+
a partially applied migration. Re-run the migration set, or repair the row. Do
|
|
73
|
+
not widen the schema: the check exists so a half-owned row is never handed back
|
|
74
|
+
as executable state.
|
|
75
|
+
|
|
76
|
+
## constraint
|
|
77
|
+
|
|
78
|
+
**What happened.** The database refused the write. The usual cause is
|
|
79
|
+
`create` with a `parentRunId` or a foreign key that names a run that does not
|
|
80
|
+
exist, since `flows_runs.parent_run_id` and `flows_attempts.run_id` are both
|
|
81
|
+
foreign keys.
|
|
82
|
+
|
|
83
|
+
**What to change.** Create the parent run first, or drop the `parentRunId`
|
|
84
|
+
option. A lineage is ordinary metadata; the ancestry edge is not.
|
|
85
|
+
|
|
86
|
+
## not_found_row
|
|
87
|
+
|
|
88
|
+
**What happened.** `RunStore.get` was called for a run id with no row. It is the
|
|
89
|
+
one read that fails rather than reporting absence, because callers use it to
|
|
90
|
+
follow a fence they already believe in.
|
|
91
|
+
|
|
92
|
+
**What to change.** Treat it as "this run was never created", not as a race.
|
|
93
|
+
Every compare-and-swap reports `NotFound` as a value instead, and
|
|
94
|
+
`AttemptStore.get` returns `Option.none()`.
|
|
95
|
+
|
|
96
|
+
## persistence_failed
|
|
97
|
+
|
|
98
|
+
**What happened.** Either the database operation failed, or the composition is
|
|
99
|
+
running `RunStore.layerNoop()`, whose `create` and `get` fail with the message
|
|
100
|
+
"no run store in this environment".
|
|
101
|
+
|
|
102
|
+
**What to change.** If `get` fails immediately on a fresh database, the
|
|
103
|
+
migrations did not run: `Migrations.layer` has to be provided beneath both
|
|
104
|
+
stores, and `Layer.provideMerge` is what keeps the client and the writer in
|
|
105
|
+
context. See
|
|
106
|
+
[Compose the stores into a host](./guides/compose-the-stores.md). If it is the
|
|
107
|
+
stub, provide a real store, or override the one operation the test needs.
|
|
108
|
+
|
|
109
|
+
## unknown
|
|
110
|
+
|
|
111
|
+
**What happened.** `AttemptStore.layerNoop()` is in the composition. Every
|
|
112
|
+
operation fails with "the store is unavailable in this environment".
|
|
113
|
+
|
|
114
|
+
**What to change.** Provide `AttemptStore.layer` or `layerWith`, or pass the
|
|
115
|
+
override that supplies the outcome your test wants.
|
|
116
|
+
|
|
117
|
+
## Every attempt write reports FenceLost after the run settles
|
|
118
|
+
|
|
119
|
+
**What happened.** `AttemptStore` carries no ownership of its own. `put`,
|
|
120
|
+
`heartbeat`, `finish`, and `patch` each require that the run is `running` and
|
|
121
|
+
owned by the caller. A terminal transition clears the owner columns, so from that
|
|
122
|
+
moment every attempt write on that run reports `FenceLost`.
|
|
123
|
+
|
|
124
|
+
**What to change.** Record what you know about an attempt before you settle the
|
|
125
|
+
run. This is deliberate: it is what stops a displaced owner's late write from
|
|
126
|
+
rewriting the winning row. See
|
|
127
|
+
[Record a step attempt](./guides/record-step-attempts.md).
|
|
128
|
+
|
|
129
|
+
## GuardFailed instead of Transitioned
|
|
130
|
+
|
|
131
|
+
**What happened.** You still own the run, and the guard predicate refused the
|
|
132
|
+
write. `{ cancelRequested: "absent" }` means a cancellation was requested;
|
|
133
|
+
`{ cancelRequested: "present" }` means none was.
|
|
134
|
+
|
|
135
|
+
**What to change.** Take the branch the guard is pointing at. A `GuardFailed`
|
|
136
|
+
completion means the run should settle as `cancelled` instead. Ownership is
|
|
137
|
+
checked first, so this is never a fence problem: `FenceLost` would have been
|
|
138
|
+
reported for that. See [Cancel a run](./guides/cancel-a-run.md).
|
|
139
|
+
|
|
140
|
+
## FenceLost while you believe you own the run
|
|
141
|
+
|
|
142
|
+
**What happened.** Another owner holds the row, or the run is no longer
|
|
143
|
+
`running`. The fence is the complete `(hostId, pid, nonce)` triple, so an
|
|
144
|
+
identity that differs only by `nonce` is a different owner: a restarted process
|
|
145
|
+
does not inherit its predecessor's runs.
|
|
146
|
+
|
|
147
|
+
**What to change.** Stop working on the run. `Ownership.heartbeatLoop` exists to
|
|
148
|
+
make that automatic: race it against the owned work with `Effect.raceFirst` and a
|
|
149
|
+
lost fence interrupts the work. See [The heartbeat lease](./concepts/leases.md).
|
|
150
|
+
|
|
151
|
+
## EvidenceRequired from claimAndOwn
|
|
152
|
+
|
|
153
|
+
**What happened.** Your snapshot is still current, the row's different owner has
|
|
154
|
+
a stale heartbeat, and you supplied no matching `LivenessEvidence`. No
|
|
155
|
+
compare-and-swap ran.
|
|
156
|
+
|
|
157
|
+
**What to change.** Re-reading and retrying cannot make progress. Supply evidence
|
|
158
|
+
built at the same `nowMs`, or use `steal` followed by `activate`. A row whose
|
|
159
|
+
heartbeat is still fresh reports `HeartbeatFresh` instead, and evidence cannot
|
|
160
|
+
satisfy that predicate. See
|
|
161
|
+
[Take over a stalled run](./guides/recover-a-stalled-run.md).
|
|
162
|
+
|
|
163
|
+
## LivenessUnconfirmed rather than SnapshotChanged
|
|
164
|
+
|
|
165
|
+
**What happened.** The evidence did not match the recorded owner, the host
|
|
166
|
+
relation its `kind` requires, or the call's `nowMs`, so the store refused before
|
|
167
|
+
any comparison ran. `SnapshotChanged` is reserved for matching evidence whose
|
|
168
|
+
comparison lost to a row that moved.
|
|
169
|
+
|
|
170
|
+
**What to change.** Check three things, in this order: `expectedOwner` must equal
|
|
171
|
+
the owner on the snapshot; `checkedAtMs` must equal the call's `nowMs` exactly,
|
|
172
|
+
so evidence probed at T is refused at T plus one millisecond; and `kind` must fit
|
|
173
|
+
the hosts, since `same-host-pid-dead` is accepted only on the owner's own host
|
|
174
|
+
and `cross-host-unreachable-stale` only from a different one. See
|
|
175
|
+
[Liveness evidence](./concepts/liveness-evidence.md).
|
|
176
|
+
|
|
177
|
+
## HeartbeatFresh for an owner you know is dead
|
|
178
|
+
|
|
179
|
+
**What happened.** The persisted heartbeat is still inside
|
|
180
|
+
`heartbeatStaleAfter`. The store judges the row, not the process, and no evidence
|
|
181
|
+
overrides that cutoff.
|
|
182
|
+
|
|
183
|
+
**What to change.** Wait for the lease to expire. If the delay is the problem,
|
|
184
|
+
the answer is a liveness check that can say more than the lease, not a shorter
|
|
185
|
+
window: `Ownership.sameHostPidProbe` asks the operating system. One trap comes
|
|
186
|
+
with it: an owner recorded under the claimant's own pid always probes as alive,
|
|
187
|
+
so an embedded host that re-creates its engine in the same process should keep
|
|
188
|
+
`Ownership.leaseLiveness`, whose timeout does expire.
|
|
189
|
+
|
|
190
|
+
## AlreadyClaimed that never clears
|
|
191
|
+
|
|
192
|
+
**What happened.** A claimant wrote the claim columns and never activated. The
|
|
193
|
+
row is not owned and not claimable, and `claim` reports `AlreadyClaimed` for
|
|
194
|
+
everyone.
|
|
195
|
+
|
|
196
|
+
**What to change.** The owner of a failed activation should call `abandonClaim`,
|
|
197
|
+
which is why the two-phase path releases its claim when activation loses. For a
|
|
198
|
+
claimant that will not come back, `recoverClaim` clears the claim once it is
|
|
199
|
+
older than `heartbeatStaleAfter` and evidence confirms the claimant is gone. A
|
|
200
|
+
claim still inside the window reports `ClaimFresh`.
|
|
201
|
+
|
|
202
|
+
## Conflict from a replayed put
|
|
203
|
+
|
|
204
|
+
**What happened.** An attempt row already exists at `(runId, stepKeyDigest,
|
|
205
|
+
attempt)` with different content, and the store is first-writer-wins.
|
|
206
|
+
|
|
207
|
+
**What to change.** A byte-equivalent replay reports `ExistingSame` instead and
|
|
208
|
+
is safe, so a `Conflict` means two writers disagree about what this attempt is.
|
|
209
|
+
Object key order is ignored in that comparison; array order is not. Fix the
|
|
210
|
+
caller, or set `putMode: "upsert"` if overwriting an in-progress row is genuinely
|
|
211
|
+
what you want. A terminal attempt stays immutable in both modes.
|
|
212
|
+
|
|
213
|
+
## StateChanged from heartbeat or finish
|
|
214
|
+
|
|
215
|
+
**What happened.** You still own the run, and the attempt's `state` is no longer
|
|
216
|
+
one of the store's `inProgressStates`. Something already finished it, and a
|
|
217
|
+
second `finish` reports the same thing rather than overwriting the winning row.
|
|
218
|
+
|
|
219
|
+
**What to change.** Treat the attempt as settled. If your executor uses more than
|
|
220
|
+
one running state, declare them all in `inProgressStates` when you build the
|
|
221
|
+
store.
|
|
222
|
+
|
|
223
|
+
## requestCancel reports Terminal or AlreadyRequested
|
|
224
|
+
|
|
225
|
+
**What happened.** `Terminal` means the run settled before the request could be
|
|
226
|
+
recorded, and its `status` says which ending it lost to. `AlreadyRequested` means
|
|
227
|
+
a request was already on the row, and `requestedAtMs` is the original time rather
|
|
228
|
+
than the one you passed.
|
|
229
|
+
|
|
230
|
+
**What to change.** Neither is an error. A repeat request is harmless by design:
|
|
231
|
+
the column is first-writer-wins so a retry cannot move the recorded intent. A
|
|
232
|
+
terminal run records nothing, because it has no owner left to observe the intent.
|
|
233
|
+
|
|
234
|
+
## heartbeat reports Updated but the stamp did not move
|
|
235
|
+
|
|
236
|
+
**What happened.** Heartbeats are monotonic. The write is
|
|
237
|
+
`MAX(heartbeat_at_ms, :nowMs)`, so a pulse delayed past a newer one from the same
|
|
238
|
+
owner still reports `Updated`: the fence held and the write proved liveness, but
|
|
239
|
+
the stamp is not moved backwards where it would make a live run look stale to a
|
|
240
|
+
peer.
|
|
241
|
+
|
|
242
|
+
**What to change.** Nothing, unless your `nowMs` readings are genuinely going
|
|
243
|
+
backwards. That is the sign of a caller mixing clock sources.
|
|
244
|
+
|
|
245
|
+
## Two clocks in one row
|
|
246
|
+
|
|
247
|
+
**What happened.** Lifecycle stamps come from the injected Effect `Clock`, and
|
|
248
|
+
`nowMs` comes from the caller. A composition that reads `Date.now()` for `nowMs`
|
|
249
|
+
while the store runs on a `TestClock` produces a row with readings from two
|
|
250
|
+
unrelated timelines, and the lease operations then refuse the reading as ahead of
|
|
251
|
+
the store's clock.
|
|
252
|
+
|
|
253
|
+
**What to change.** Take `nowMs` from `Clock.currentTimeMillis`. That is also
|
|
254
|
+
what makes the whole store driveable under `TestClock`. See
|
|
255
|
+
[Test against the real stores](./guides/testing.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,186 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smthrs/run-store",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.0.0-rc.3",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"smthrs": {
|
|
7
|
+
"group": "engine"
|
|
8
|
+
},
|
|
9
|
+
"description": "Effect services for flows run state, attempts, and ownership arbitration",
|
|
10
|
+
"homepage": "https://github.com/smithersai/smithers",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/smithersai/smithers.git",
|
|
14
|
+
"directory": "packages/smithers/flows/run-store"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/smithersai/smithers/issues"
|
|
18
|
+
},
|
|
19
|
+
"tags": [
|
|
20
|
+
"typescript",
|
|
21
|
+
"effect",
|
|
22
|
+
"durability",
|
|
23
|
+
"ownership"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"typescript",
|
|
27
|
+
"effect",
|
|
28
|
+
"durability",
|
|
29
|
+
"ownership"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=26.4.0"
|
|
33
|
+
},
|
|
34
|
+
"sideEffects": [],
|
|
35
|
+
"exports": {
|
|
36
|
+
"./package.json": "./package.json",
|
|
37
|
+
".": {
|
|
38
|
+
"import": {
|
|
39
|
+
"types": "./dist/esm/index.d.ts",
|
|
40
|
+
"default": "./dist/esm/index.js"
|
|
41
|
+
},
|
|
42
|
+
"require": {
|
|
43
|
+
"types": "./dist/cjs/index.d.ts",
|
|
44
|
+
"default": "./dist/cjs/index.js"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"./internal/*": null,
|
|
48
|
+
"./migrations/*": null,
|
|
49
|
+
"./*/index": null,
|
|
50
|
+
"./AttemptStore": {
|
|
51
|
+
"import": {
|
|
52
|
+
"types": "./dist/esm/AttemptStore.d.ts",
|
|
53
|
+
"default": "./dist/esm/AttemptStore.js"
|
|
54
|
+
},
|
|
55
|
+
"require": {
|
|
56
|
+
"types": "./dist/cjs/AttemptStore.d.ts",
|
|
57
|
+
"default": "./dist/cjs/AttemptStore.js"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"./Heartbeat": {
|
|
61
|
+
"import": {
|
|
62
|
+
"types": "./dist/esm/Heartbeat.d.ts",
|
|
63
|
+
"default": "./dist/esm/Heartbeat.js"
|
|
64
|
+
},
|
|
65
|
+
"require": {
|
|
66
|
+
"types": "./dist/cjs/Heartbeat.d.ts",
|
|
67
|
+
"default": "./dist/cjs/Heartbeat.js"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"./Migrations": {
|
|
71
|
+
"import": {
|
|
72
|
+
"types": "./dist/esm/Migrations.d.ts",
|
|
73
|
+
"default": "./dist/esm/Migrations.js"
|
|
74
|
+
},
|
|
75
|
+
"require": {
|
|
76
|
+
"types": "./dist/cjs/Migrations.d.ts",
|
|
77
|
+
"default": "./dist/cjs/Migrations.js"
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"./Ownership": {
|
|
81
|
+
"import": {
|
|
82
|
+
"types": "./dist/esm/Ownership.d.ts",
|
|
83
|
+
"default": "./dist/esm/Ownership.js"
|
|
84
|
+
},
|
|
85
|
+
"require": {
|
|
86
|
+
"types": "./dist/cjs/Ownership.d.ts",
|
|
87
|
+
"default": "./dist/cjs/Ownership.js"
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"./RunStore": {
|
|
91
|
+
"import": {
|
|
92
|
+
"types": "./dist/esm/RunStore.d.ts",
|
|
93
|
+
"default": "./dist/esm/RunStore.js"
|
|
94
|
+
},
|
|
95
|
+
"require": {
|
|
96
|
+
"types": "./dist/cjs/RunStore.d.ts",
|
|
97
|
+
"default": "./dist/cjs/RunStore.js"
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"./RunStoreMetrics": {
|
|
101
|
+
"import": {
|
|
102
|
+
"types": "./dist/esm/RunStoreMetrics.d.ts",
|
|
103
|
+
"default": "./dist/esm/RunStoreMetrics.js"
|
|
104
|
+
},
|
|
105
|
+
"require": {
|
|
106
|
+
"types": "./dist/cjs/RunStoreMetrics.d.ts",
|
|
107
|
+
"default": "./dist/cjs/RunStoreMetrics.js"
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"./index": {
|
|
111
|
+
"import": {
|
|
112
|
+
"types": "./dist/esm/index.d.ts",
|
|
113
|
+
"default": "./dist/esm/index.js"
|
|
114
|
+
},
|
|
115
|
+
"require": {
|
|
116
|
+
"types": "./dist/cjs/index.d.ts",
|
|
117
|
+
"default": "./dist/cjs/index.js"
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"./test/TestRunStore": {
|
|
121
|
+
"import": {
|
|
122
|
+
"types": "./dist/esm/test/TestRunStore.d.ts",
|
|
123
|
+
"default": "./dist/esm/test/TestRunStore.js"
|
|
124
|
+
},
|
|
125
|
+
"require": {
|
|
126
|
+
"types": "./dist/cjs/test/TestRunStore.d.ts",
|
|
127
|
+
"default": "./dist/cjs/test/TestRunStore.js"
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"files": [
|
|
132
|
+
"src/**/*.ts",
|
|
133
|
+
"dist/**/*.js",
|
|
134
|
+
"dist/**/*.js.map",
|
|
135
|
+
"dist/**/*.d.ts",
|
|
136
|
+
"dist/**/*.d.ts.map",
|
|
137
|
+
"dist/**/package.json",
|
|
138
|
+
"LICENSE",
|
|
139
|
+
"README.md",
|
|
140
|
+
"CHANGELOG.md",
|
|
141
|
+
"docs/"
|
|
142
|
+
],
|
|
143
|
+
"publishConfig": {
|
|
144
|
+
"access": "public",
|
|
145
|
+
"provenance": true,
|
|
146
|
+
"tag": "next"
|
|
147
|
+
},
|
|
148
|
+
"dependencies": {
|
|
149
|
+
"@smthrs/database": "1.0.0-rc.3",
|
|
150
|
+
"@smthrs/journal": "1.0.0-rc.3",
|
|
151
|
+
"@smthrs/observability": "1.0.0-rc.3",
|
|
152
|
+
"@smthrs/canonical": "1.0.0-rc.3"
|
|
153
|
+
},
|
|
154
|
+
"devDependencies": {
|
|
155
|
+
"@effect/language-service": "0.87.2",
|
|
156
|
+
"@effect/vitest": "4.0.0-rc.115",
|
|
157
|
+
"@eslint/js": "9.39.5",
|
|
158
|
+
"@types/node": "26.4.1",
|
|
159
|
+
"@vitest/coverage-v8": "5.0.0",
|
|
160
|
+
"dprint": "0.57.1",
|
|
161
|
+
"effect": "4.0.0-rc.115",
|
|
162
|
+
"esbuild": "0.28.2",
|
|
163
|
+
"eslint": "9.39.5",
|
|
164
|
+
"eslint-import-resolver-typescript": "4.4.5",
|
|
165
|
+
"eslint-plugin-import": "2.32.0",
|
|
166
|
+
"eslint-plugin-jsdoc": "64.3.4",
|
|
167
|
+
"eslint-plugin-unicorn": "65.0.0",
|
|
168
|
+
"madge": "8.0.0",
|
|
169
|
+
"typescript": "5.9.3",
|
|
170
|
+
"typescript-eslint": "8.69.0",
|
|
171
|
+
"vitest": "5.0.0"
|
|
172
|
+
},
|
|
173
|
+
"peerDependencies": {
|
|
174
|
+
"effect": "4.0.0-rc.115"
|
|
175
|
+
},
|
|
176
|
+
"scripts": {
|
|
177
|
+
"lint": "eslint src --max-warnings=0 && dprint check",
|
|
178
|
+
"format": "dprint fmt",
|
|
179
|
+
"build": "node scripts/build.mjs",
|
|
180
|
+
"check": "tsc -b tsconfig.json && tsc -p tsconfig.test.json --noEmit",
|
|
181
|
+
"circular": "node scripts/circular.mjs",
|
|
182
|
+
"test": "vitest",
|
|
183
|
+
"coverage": "node ../database/scripts/test-matrix.mjs",
|
|
184
|
+
"test:matrix": "node ../database/scripts/test-matrix.mjs"
|
|
185
|
+
}
|
|
6
186
|
}
|