@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,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.
|