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