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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +21 -0
  3. package/README.md +195 -2
  4. package/dist/cjs/AttemptStore.d.ts +359 -0
  5. package/dist/cjs/AttemptStore.d.ts.map +1 -0
  6. package/dist/cjs/AttemptStore.js +731 -0
  7. package/dist/cjs/AttemptStore.js.map +7 -0
  8. package/dist/cjs/Heartbeat.d.ts +46 -0
  9. package/dist/cjs/Heartbeat.d.ts.map +1 -0
  10. package/dist/cjs/Heartbeat.js +28 -0
  11. package/dist/cjs/Heartbeat.js.map +7 -0
  12. package/dist/cjs/Migrations.d.ts +42 -0
  13. package/dist/cjs/Migrations.d.ts.map +1 -0
  14. package/dist/cjs/Migrations.js +55 -0
  15. package/dist/cjs/Migrations.js.map +7 -0
  16. package/dist/cjs/Ownership.d.ts +275 -0
  17. package/dist/cjs/Ownership.d.ts.map +1 -0
  18. package/dist/cjs/Ownership.js +129 -0
  19. package/dist/cjs/Ownership.js.map +7 -0
  20. package/dist/cjs/RunStore.d.ts +571 -0
  21. package/dist/cjs/RunStore.d.ts.map +1 -0
  22. package/dist/cjs/RunStore.js +1079 -0
  23. package/dist/cjs/RunStore.js.map +7 -0
  24. package/dist/cjs/RunStoreMetrics.d.ts +151 -0
  25. package/dist/cjs/RunStoreMetrics.d.ts.map +1 -0
  26. package/dist/cjs/RunStoreMetrics.js +91 -0
  27. package/dist/cjs/RunStoreMetrics.js.map +7 -0
  28. package/dist/cjs/index.d.ts +47 -0
  29. package/dist/cjs/index.d.ts.map +1 -0
  30. package/dist/cjs/index.js +43 -0
  31. package/dist/cjs/index.js.map +7 -0
  32. package/dist/cjs/internal/Boundary.d.ts +80 -0
  33. package/dist/cjs/internal/Boundary.d.ts.map +1 -0
  34. package/dist/cjs/internal/Boundary.js +92 -0
  35. package/dist/cjs/internal/Boundary.js.map +7 -0
  36. package/dist/cjs/internal/Companion.d.ts +44 -0
  37. package/dist/cjs/internal/Companion.d.ts.map +1 -0
  38. package/dist/cjs/internal/Companion.js +50 -0
  39. package/dist/cjs/internal/Companion.js.map +7 -0
  40. package/dist/cjs/internal/SpanOutcome.d.ts +37 -0
  41. package/dist/cjs/internal/SpanOutcome.d.ts.map +1 -0
  42. package/dist/cjs/internal/SpanOutcome.js +62 -0
  43. package/dist/cjs/internal/SpanOutcome.js.map +7 -0
  44. package/dist/cjs/migrations/0001_initial.d.ts +15 -0
  45. package/dist/cjs/migrations/0001_initial.d.ts.map +1 -0
  46. package/dist/cjs/migrations/0001_initial.js +110 -0
  47. package/dist/cjs/migrations/0001_initial.js.map +7 -0
  48. package/dist/cjs/migrations/0002_lineage.d.ts +27 -0
  49. package/dist/cjs/migrations/0002_lineage.d.ts.map +1 -0
  50. package/dist/cjs/migrations/0002_lineage.js +43 -0
  51. package/dist/cjs/migrations/0002_lineage.js.map +7 -0
  52. package/dist/cjs/migrations/0003_execution_revisions.d.ts +15 -0
  53. package/dist/cjs/migrations/0003_execution_revisions.d.ts.map +1 -0
  54. package/dist/cjs/migrations/0003_execution_revisions.js +77 -0
  55. package/dist/cjs/migrations/0003_execution_revisions.js.map +7 -0
  56. package/dist/cjs/migrations/0004_waiting_request.d.ts +32 -0
  57. package/dist/cjs/migrations/0004_waiting_request.d.ts.map +1 -0
  58. package/dist/cjs/migrations/0004_waiting_request.js +42 -0
  59. package/dist/cjs/migrations/0004_waiting_request.js.map +7 -0
  60. package/dist/cjs/package.json +1 -0
  61. package/dist/cjs/test/TestRunStore.d.ts +17 -0
  62. package/dist/cjs/test/TestRunStore.d.ts.map +1 -0
  63. package/dist/cjs/test/TestRunStore.js +42 -0
  64. package/dist/cjs/test/TestRunStore.js.map +7 -0
  65. package/dist/esm/AttemptStore.d.ts +359 -0
  66. package/dist/esm/AttemptStore.d.ts.map +1 -0
  67. package/dist/esm/AttemptStore.js +835 -0
  68. package/dist/esm/AttemptStore.js.map +1 -0
  69. package/dist/esm/Heartbeat.d.ts +46 -0
  70. package/dist/esm/Heartbeat.d.ts.map +1 -0
  71. package/dist/esm/Heartbeat.js +46 -0
  72. package/dist/esm/Heartbeat.js.map +1 -0
  73. package/dist/esm/Migrations.d.ts +42 -0
  74. package/dist/esm/Migrations.d.ts.map +1 -0
  75. package/dist/esm/Migrations.js +56 -0
  76. package/dist/esm/Migrations.js.map +1 -0
  77. package/dist/esm/Ownership.d.ts +275 -0
  78. package/dist/esm/Ownership.d.ts.map +1 -0
  79. package/dist/esm/Ownership.js +299 -0
  80. package/dist/esm/Ownership.js.map +1 -0
  81. package/dist/esm/RunStore.d.ts +571 -0
  82. package/dist/esm/RunStore.d.ts.map +1 -0
  83. package/dist/esm/RunStore.js +1287 -0
  84. package/dist/esm/RunStore.js.map +1 -0
  85. package/dist/esm/RunStoreMetrics.d.ts +151 -0
  86. package/dist/esm/RunStoreMetrics.d.ts.map +1 -0
  87. package/dist/esm/RunStoreMetrics.js +120 -0
  88. package/dist/esm/RunStoreMetrics.js.map +1 -0
  89. package/dist/esm/index.d.ts +47 -0
  90. package/dist/esm/index.d.ts.map +1 -0
  91. package/dist/esm/index.js +47 -0
  92. package/dist/esm/index.js.map +1 -0
  93. package/dist/esm/internal/Boundary.d.ts +80 -0
  94. package/dist/esm/internal/Boundary.d.ts.map +1 -0
  95. package/dist/esm/internal/Boundary.js +109 -0
  96. package/dist/esm/internal/Boundary.js.map +1 -0
  97. package/dist/esm/internal/Companion.d.ts +44 -0
  98. package/dist/esm/internal/Companion.d.ts.map +1 -0
  99. package/dist/esm/internal/Companion.js +53 -0
  100. package/dist/esm/internal/Companion.js.map +1 -0
  101. package/dist/esm/internal/SpanOutcome.d.ts +37 -0
  102. package/dist/esm/internal/SpanOutcome.d.ts.map +1 -0
  103. package/dist/esm/internal/SpanOutcome.js +48 -0
  104. package/dist/esm/internal/SpanOutcome.js.map +1 -0
  105. package/dist/esm/migrations/0001_initial.d.ts +15 -0
  106. package/dist/esm/migrations/0001_initial.d.ts.map +1 -0
  107. package/dist/esm/migrations/0001_initial.js +88 -0
  108. package/dist/esm/migrations/0001_initial.js.map +1 -0
  109. package/dist/esm/migrations/0002_lineage.d.ts +27 -0
  110. package/dist/esm/migrations/0002_lineage.d.ts.map +1 -0
  111. package/dist/esm/migrations/0002_lineage.js +33 -0
  112. package/dist/esm/migrations/0002_lineage.js.map +1 -0
  113. package/dist/esm/migrations/0003_execution_revisions.d.ts +15 -0
  114. package/dist/esm/migrations/0003_execution_revisions.d.ts.map +1 -0
  115. package/dist/esm/migrations/0003_execution_revisions.js +58 -0
  116. package/dist/esm/migrations/0003_execution_revisions.js.map +1 -0
  117. package/dist/esm/migrations/0004_waiting_request.d.ts +32 -0
  118. package/dist/esm/migrations/0004_waiting_request.d.ts.map +1 -0
  119. package/dist/esm/migrations/0004_waiting_request.js +37 -0
  120. package/dist/esm/migrations/0004_waiting_request.js.map +1 -0
  121. package/dist/esm/test/TestRunStore.d.ts +17 -0
  122. package/dist/esm/test/TestRunStore.d.ts.map +1 -0
  123. package/dist/esm/test/TestRunStore.js +19 -0
  124. package/dist/esm/test/TestRunStore.js.map +1 -0
  125. package/docs/README.md +142 -0
  126. package/docs/api.md +901 -0
  127. package/docs/concepts/durable-values.md +140 -0
  128. package/docs/concepts/execution-revisions.md +53 -0
  129. package/docs/concepts/fencing.md +146 -0
  130. package/docs/concepts/leases.md +154 -0
  131. package/docs/concepts/liveness-evidence.md +142 -0
  132. package/docs/guides/cancel-a-run.md +156 -0
  133. package/docs/guides/claim-and-finish-a-run.md +175 -0
  134. package/docs/guides/compose-the-stores.md +142 -0
  135. package/docs/guides/observe-outcomes.md +182 -0
  136. package/docs/guides/record-step-attempts.md +170 -0
  137. package/docs/guides/recover-a-stalled-run.md +173 -0
  138. package/docs/guides/testing.md +141 -0
  139. package/docs/installation.md +109 -0
  140. package/docs/quickstart.md +201 -0
  141. package/docs/troubleshooting.md +255 -0
  142. package/package.json +183 -3
  143. package/src/AttemptStore.ts +1195 -0
  144. package/src/Heartbeat.ts +47 -0
  145. package/src/Migrations.ts +59 -0
  146. package/src/Ownership.ts +407 -0
  147. package/src/RunStore.ts +2063 -0
  148. package/src/RunStoreMetrics.ts +165 -0
  149. package/src/index.ts +51 -0
  150. package/src/internal/Boundary.ts +130 -0
  151. package/src/internal/Companion.ts +64 -0
  152. package/src/internal/SpanOutcome.ts +71 -0
  153. package/src/migrations/0001_initial.ts +114 -0
  154. package/src/migrations/0002_lineage.ts +38 -0
  155. package/src/migrations/0003_execution_revisions.ts +69 -0
  156. package/src/migrations/0004_waiting_request.ts +39 -0
  157. package/src/test/TestRunStore.ts +22 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The heartbeat lease constants, re-exported from the journal's consensus
3
+ * module.
4
+ *
5
+ * A leaf module on purpose. `RunStore` needs the staleness cutoff for its
6
+ * claim predicates and `Ownership` needs all four for the supervision loop,
7
+ * but `Ownership` imports `RunStore`, so neither could own the constants
8
+ * without the other restating them. The definitions themselves live in
9
+ * `@smthrs/journal`'s `Consensus`: every consensus strategy judges R5
10
+ * staleness against the same cutoff this store uses, and one definition is
11
+ * what keeps that structural.
12
+ *
13
+ * @since 0.1.0
14
+ */
15
+
16
+ export {
17
+ /**
18
+ * How often the supervision loop pulses to renew the owner's lease.
19
+ *
20
+ * @since 0.1.0
21
+ * @category constants
22
+ */
23
+ heartbeatInterval,
24
+ /**
25
+ * How far the owner's wall clock may run behind a peer's before the lease
26
+ * reasoning stops holding.
27
+ *
28
+ * @since 0.1.0
29
+ * @category constants
30
+ */
31
+ heartbeatSkewAllowance,
32
+ /**
33
+ * How old a persisted heartbeat must be before a peer may steal the run.
34
+ *
35
+ * @since 0.1.0
36
+ * @category constants
37
+ */
38
+ heartbeatStaleAfter,
39
+ /**
40
+ * How long the owner may keep working through failing or stalled heartbeat
41
+ * writes.
42
+ *
43
+ * @since 0.1.0
44
+ * @category constants
45
+ */
46
+ heartbeatWriteTolerance
47
+ } from "@smthrs/journal/Consensus"
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Run and attempt schema migrations.
3
+ *
4
+ * This package owns `flows_runs` and `flows_attempts`. It reserves migration
5
+ * id block `1000` so its ids can never collide with the journal's or the step
6
+ * cache's — see `@smthrs/database`'s `Migrations` for how the blocks compose.
7
+ *
8
+ * The SQL `RunStore` arbitrates ownership through `@smthrs/journal`'s
9
+ * `SqlConsensus`, whose lease table the journal's migration set creates, so
10
+ * {@link run} and {@link layer} install the journal's set ahead of this one.
11
+ * {@link set} stays scoped to the run-store tables for compositions that list
12
+ * every set themselves, such as `@smthrs/engine-store/Migrations`.
13
+ *
14
+ * @since 0.1.0
15
+ */
16
+
17
+ import * as DatabaseMigrations from "@smthrs/database/Migrations"
18
+ import * as JournalMigrations from "@smthrs/journal/Migrations"
19
+ import * as Layer from "effect/Layer"
20
+ import { initial } from "./migrations/0001_initial.ts"
21
+ import { lineage } from "./migrations/0002_lineage.ts"
22
+ import { executionRevisions } from "./migrations/0003_execution_revisions.ts"
23
+ import { waitingRequest } from "./migrations/0004_waiting_request.ts"
24
+
25
+ /**
26
+ * The run store's namespaced migration set, for composition with the other
27
+ * storage packages.
28
+ *
29
+ * @category migrations
30
+ * @since 0.1.0
31
+ */
32
+ export const set: DatabaseMigrations.MigrationSet = {
33
+ namespace: "run-store",
34
+ idOffset: DatabaseMigrations.idBlock,
35
+ migrations: {
36
+ "0001_initial": initial,
37
+ "0002_lineage": lineage,
38
+ "0003_execution_revisions": executionRevisions,
39
+ "0004_waiting_request": waitingRequest
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Creates the run and attempt schema, and the journal schema whose consensus
45
+ * lease table the SQL `RunStore` fences through.
46
+ *
47
+ * @category migrations
48
+ * @since 0.1.0
49
+ */
50
+ export const run = DatabaseMigrations.run([JournalMigrations.set, set])
51
+
52
+ /**
53
+ * Layer that runs run-store migrations before exposing the database to the run
54
+ * and attempt services.
55
+ *
56
+ * @category layers
57
+ * @since 0.1.0
58
+ */
59
+ export const layer = Layer.effectDiscard(run)
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Run ownership arbitration: liveness evidence, probes, and heartbeat
3
+ * supervision.
4
+ *
5
+ * The identity being arbitrated — {@link OwnerId} — is defined by
6
+ * `@smthrs/journal`, because it is the fencing token the journal accepts on
7
+ * durable appends. It is re-exported here so ownership callers keep reading it
8
+ * as one vocabulary.
9
+ *
10
+ * @since 0.1.0
11
+ */
12
+
13
+ import { LivenessEvidence } from "@smthrs/journal/Consensus"
14
+ import { OwnerId } from "@smthrs/journal/OwnerId"
15
+ import { Cause, Clock, Duration, Effect, Semaphore } from "effect"
16
+ import { heartbeatInterval, heartbeatStaleAfter, heartbeatWriteTolerance } from "./Heartbeat.ts"
17
+ import { RunStore } from "./RunStore.ts"
18
+
19
+ export {
20
+ /**
21
+ * A process identity scoped to a host and a unique ownership nonce, defined
22
+ * by `@smthrs/journal` as the fence on durable appends.
23
+ *
24
+ * @since 0.1.0
25
+ * @category models
26
+ */
27
+ OwnerId
28
+ }
29
+
30
+ export {
31
+ /**
32
+ * Evidence that the owner in an exact run snapshot is no longer live.
33
+ *
34
+ * Defined by `@smthrs/journal`'s `Consensus`, because R5 — steal requires
35
+ * staleness plus liveness evidence — is a consensus rule every strategy
36
+ * validates. Two of the three kinds are collected outside the store:
37
+ * `same-host-pid-dead` is a local process probe, and
38
+ * `cross-host-unreachable-stale` is a reachability judgement the deployment
39
+ * makes. `lease-expired` is different — it asserts only that the persisted
40
+ * heartbeat is older than the staleness cutoff, which is the one claim the
41
+ * strategy can check for itself, and `steal` checks it: the takeover
42
+ * refuses any lease whose heartbeat is still inside the window. It is
43
+ * therefore accepted from a claimant on any host, while the other two stay
44
+ * bound to the host relation that makes them meaningful. The observation
45
+ * must carry the exact instant supplied to the consuming operation:
46
+ * `checkedAtMs` must equal that operation's `nowMs`. A caller that probes
47
+ * at T and calls at T+1 is refused and must build fresh evidence.
48
+ *
49
+ * @since 0.1.0
50
+ * @category models
51
+ */
52
+ LivenessEvidence
53
+ }
54
+
55
+ /**
56
+ * Evidence-factory signature a composition uses for ownership arbitration.
57
+ *
58
+ * A `LivenessProbe` returns `LivenessEvidence` or `undefined` for `steal`,
59
+ * `claimAndOwn`, and `recoverClaim` to verify. {@link LivenessCheck} is the
60
+ * separate alive-or-not question the engine asks before it decides to steal.
61
+ * `RunStore` validates supplied evidence and never probes a process or network
62
+ * itself.
63
+ *
64
+ * @since 0.1.0
65
+ * @category models
66
+ */
67
+ export type LivenessProbe<E = never, R = never> = (
68
+ expectedOwner: OwnerId,
69
+ claimant: OwnerId,
70
+ checkedAtMs: number
71
+ ) => Effect.Effect<LivenessEvidence | undefined, E, R>
72
+
73
+ /**
74
+ * What a liveness check knows about the run it is asked about, beyond the
75
+ * owner recorded on it.
76
+ *
77
+ * The lease is here because it is the only liveness signal every deployment
78
+ * has: `heartbeatAtMs` is the last heartbeat the owner persisted, `nowMs` is
79
+ * the reading the arbitration is made against, and `claimant` is the identity
80
+ * that would take the run over. A check that wants to probe a pid compares
81
+ * hosts with {@link sameHostIncarnation} first; a check that has nothing but
82
+ * the lease uses {@link leaseLiveness}.
83
+ *
84
+ * @since 0.1.0
85
+ * @category models
86
+ */
87
+ export interface LivenessContext {
88
+ readonly claimant: OwnerId
89
+ readonly heartbeatAtMs: number | null
90
+ readonly nowMs: number
91
+ }
92
+
93
+ /**
94
+ * The question ownership arbitration asks before it steals a run: is the
95
+ * recorded owner still working?
96
+ *
97
+ * Answering `true` refuses the takeover. The engine consults this only for a
98
+ * run whose lease has already expired, so a check that has no better evidence
99
+ * than the lease answers `false` there — which is exactly what
100
+ * {@link leaseLiveness} does.
101
+ *
102
+ * @since 0.1.0
103
+ * @category models
104
+ */
105
+ export type LivenessCheck = (
106
+ expectedOwner: OwnerId,
107
+ context: LivenessContext
108
+ ) => Effect.Effect<boolean>
109
+
110
+ /**
111
+ * Whether two owner identities are incarnations on the same host.
112
+ *
113
+ * The predicate a probe applies before it inspects a pid: `owner.pid` names a
114
+ * process in the claimant's own process namespace only when the hosts match,
115
+ * so a cross-host probe that read it would be answering about an unrelated
116
+ * process. Exposed because the check that needs it — a
117
+ * `process.kill(pid, 0)` probe — belongs to a platform package rather than
118
+ * here.
119
+ *
120
+ * @since 0.1.0
121
+ * @category ownership
122
+ */
123
+ export const sameHostIncarnation = (
124
+ expectedOwner: OwnerId,
125
+ claimant: OwnerId
126
+ ): boolean => expectedOwner.hostId === claimant.hostId
127
+
128
+ /**
129
+ * The default liveness check: the lease, and nothing else.
130
+ *
131
+ * An owner is treated as alive for as long as its persisted heartbeat is
132
+ * younger than `staleAfter`, and as gone once it is not. That is the weakest
133
+ * honest answer, and it is the one every host can give: a fresh process with
134
+ * no application code at all can reclaim a hard-killed owner's runs once the
135
+ * lease it stopped renewing has expired. A deployment that can say more — a
136
+ * pid probe on the owner's host, an orchestrator that reports pod liveness —
137
+ * supplies its own {@link LivenessCheck} and refuses the takeover for longer.
138
+ *
139
+ * An owner with no recorded heartbeat holds no lease and is reported gone; the
140
+ * steal it enables is still gated by the store's own snapshot compare-and-swap.
141
+ *
142
+ * @since 0.1.0
143
+ * @category ownership
144
+ */
145
+ export const leaseLiveness = (
146
+ staleAfter: Duration.Input = heartbeatStaleAfter
147
+ ): LivenessCheck => {
148
+ const staleAfterMs = Duration.toMillis(staleAfter)
149
+ return (_expectedOwner, context) =>
150
+ Effect.succeed(
151
+ context.heartbeatAtMs !== null &&
152
+ context.heartbeatAtMs >= context.nowMs - staleAfterMs
153
+ )
154
+ }
155
+
156
+ /**
157
+ * The Node hosts' liveness check: does the owner's process still exist?
158
+ *
159
+ * {@link leaseLiveness} is the honest floor — every host can read a persisted
160
+ * heartbeat — but it is only a timeout, so two engine processes over one
161
+ * database steal each other's running rows `heartbeatStaleAfter` after any
162
+ * heartbeat stall: a stop-the-world pause, a swapped-out process, a disk that
163
+ * blocked longer than the window. This answers the question the lease is
164
+ * standing in for, by asking the operating system whether the recorded pid is
165
+ * still there.
166
+ *
167
+ * `process.kill(pid, 0)` sends no signal; it performs only the delivery
168
+ * checks. Three answers matter:
169
+ *
170
+ * - It returns: the process exists and is signalable. The owner is alive.
171
+ * - It throws `ESRCH`: no such process exists. This is the sole death answer,
172
+ * so the owner is gone.
173
+ * - It throws anything else, including `EPERM`, `EINVAL`, or an unrecognized
174
+ * value: the owner is treated as alive because an unknown answer is not
175
+ * death.
176
+ *
177
+ * This `ESRCH`-only death rule deliberately aligns with
178
+ * `@smthrs/platform-node`'s `HostLiveness`.
179
+ *
180
+ * A pid is only meaningful inside one process namespace, so a recorded owner
181
+ * on another host is never probed: the answer is `false` and the arbitration
182
+ * falls back to the evidence that does cross hosts — the expired lease, which
183
+ * `RunStore.steal` verifies for itself. The engine consults this check only
184
+ * for a run whose lease has ALREADY expired, so answering `false` here does
185
+ * not weaken anything; it declines to add evidence.
186
+ *
187
+ * Node hosts only. It is not part of the browser promise: this entry point
188
+ * bundles for the browser because it never imports a `node:` built-in, and a
189
+ * browser composition has no process table to ask, so it keeps
190
+ * {@link leaseLiveness}.
191
+ *
192
+ * `@smthrs/platform-node`'s `HostLiveness.isAlive` asks the same question of
193
+ * the same process table and differs in one deliberate place: it answers
194
+ * `true` for an owner on another host, which refuses the steal outright, while
195
+ * this check answers `false` and lets the expired lease decide. The difference
196
+ * matters after a host dies for good — under the refusing answer its runs are
197
+ * never reclaimed by anyone, because no other machine can ever produce
198
+ * evidence about its pids.
199
+ *
200
+ * Two limits are inherent to asking a pid, and they bound what reclaim can
201
+ * promise. Both are shared with `HostLiveness.isAlive`.
202
+ *
203
+ * - An owner recorded with the CLAIMANT'S OWN pid is always alive. A previous
204
+ * incarnation of this process, or a second engine composed inside it, differs
205
+ * from the claimant only by `nonce`, and the process it names is this one.
206
+ * Such a row is never stolen while the process lives, so an embedded host
207
+ * that re-creates its engine in place should keep {@link leaseLiveness},
208
+ * whose timeout does expire. Reading same-pid-different-nonce as death is
209
+ * not the alternative: it would let two engines in one process — the exact
210
+ * shape this check exists to arbitrate — steal each other's live runs.
211
+ * - A pid the operating system has REUSED reports the unrelated process that
212
+ * now holds it. The dead owner's row stays refused for as long as that
213
+ * process lives, which delays reclaim rather than breaking it: the row is
214
+ * still `running` under an expired lease, and the next probe after the pid
215
+ * is free reclaims it.
216
+ *
217
+ * @since 0.1.0
218
+ * @category ownership
219
+ */
220
+ export const sameHostPidProbe: LivenessCheck = (expectedOwner, context) =>
221
+ Effect.sync(() => {
222
+ if (!sameHostIncarnation(expectedOwner, context.claimant)) return false
223
+ if (!Number.isSafeInteger(expectedOwner.pid) || expectedOwner.pid <= 0) return true
224
+ try {
225
+ process.kill(expectedOwner.pid, 0)
226
+ return true
227
+ } catch (error) {
228
+ return (error as { readonly code?: string | undefined } | null)?.code !== "ESRCH"
229
+ }
230
+ })
231
+
232
+ export {
233
+ /**
234
+ * How often the supervision loop pulses to renew the owner's lease.
235
+ *
236
+ * @since 0.1.0
237
+ * @category constants
238
+ */
239
+ heartbeatInterval,
240
+ /**
241
+ * How far the owner's wall clock may run behind a peer's before the lease
242
+ * reasoning stops holding.
243
+ *
244
+ * @since 0.1.0
245
+ * @category constants
246
+ */
247
+ heartbeatSkewAllowance,
248
+ /**
249
+ * How old a persisted heartbeat must be before a peer may steal the run.
250
+ *
251
+ * @since 0.1.0
252
+ * @category constants
253
+ */
254
+ heartbeatStaleAfter,
255
+ /**
256
+ * How long the owner may keep working through failing or stalled heartbeat writes.
257
+ *
258
+ * @since 0.1.0
259
+ * @category constants
260
+ */
261
+ heartbeatWriteTolerance
262
+ } from "./Heartbeat.ts"
263
+
264
+ /**
265
+ * Hooks a {@link heartbeatLoop} caller may supply.
266
+ *
267
+ * @since 1.0.0
268
+ * @category models
269
+ */
270
+ export interface HeartbeatLoopOptions {
271
+ /** Called after the same unclaimed owner reconfirms an expired heartbeat budget. */
272
+ readonly onReconfirm?: ((unconfirmedMs: number) => Effect.Effect<void>) | undefined
273
+ /** Called with the unconfirmed milliseconds when the lease lapses, before the loop interrupts itself. */
274
+ readonly onLapse?: ((unconfirmedMs: number) => Effect.Effect<void>) | undefined
275
+ }
276
+
277
+ /**
278
+ * Runs heartbeats until the persisted ownership fence is lost, then interrupts
279
+ * itself. Race this effect with owned work so structured concurrency
280
+ * interrupts the work when ownership disappears.
281
+ *
282
+ * Each pulse drives the injected `Consensus` strategy's `heartbeat` through
283
+ * `RunStore.heartbeat`, which renews the strategy's lease and mirrors the
284
+ * recorded stamp onto the run row in the same transaction. Heartbeats are
285
+ * lease evidence, never journal events.
286
+ *
287
+ * Pulses are delayed by `heartbeatInterval` and read the Effect `Clock`, so the
288
+ * loop is fully driveable with `TestClock`.
289
+ *
290
+ * A lost fence — any outcome other than `Updated` — is durable evidence and
291
+ * interrupts immediately. A failed heartbeat *write* is not: the persisted
292
+ * heartbeat is still there and no other process may steal the run until it is
293
+ * `heartbeatStaleAfter` old, so transient write errors are tolerated for
294
+ * `heartbeatWriteTolerance` — deliberately shorter than the steal cutoff by a
295
+ * pulse plus `heartbeatSkewAllowance`. The reserved pulse bounds a final
296
+ * reconfirmation; a pending claim or changed owner fails that check. Past that allowance
297
+ * the fence still protects durable writes but non-durable side effects may
298
+ * overlap; see {@link heartbeatWriteTolerance}.
299
+ *
300
+ * An independent deadline races the pulse loop. At expiry it reconfirms the
301
+ * same owner with no pending claim, bounded by one heartbeat interval. A
302
+ * renewed lease continues supervision; loss, error, or timeout interrupts
303
+ * even when an ordinary heartbeat write never returns. Each successful
304
+ * pulse re-arms the deadline from the timestamp sent to the store, not from
305
+ * its completion time. The deadline re-reads the clock after waking, so delayed
306
+ * writes cannot hide expiry behind a stale clock reading.
307
+ *
308
+ * `options.onReconfirm` records a successful reconfirmation after a gap. Its
309
+ * deadline is the remaining renewed lease budget, measured from the timestamp
310
+ * sent to the store. A blocked or failed receipt lapses the lease instead of
311
+ * stranding supervision; receipt completion never grants another lease budget.
312
+ * `options.onLapse` runs once, before the self-interrupt, when reconfirmation
313
+ * fails: it receives how long the lease went unconfirmed, so the caller can
314
+ * record why its work stopped. A lost fence does not call it.
315
+ *
316
+ * @since 0.1.0
317
+ * @category supervision
318
+ */
319
+ export const heartbeatLoop = (
320
+ runId: string,
321
+ owner: OwnerId,
322
+ options: HeartbeatLoopOptions = {}
323
+ ): Effect.Effect<never, never, RunStore> =>
324
+ Effect.gen(function*() {
325
+ const runStore = yield* RunStore
326
+ const toleranceMs = Duration.toMillis(heartbeatWriteTolerance)
327
+ const intervalMs = Duration.toMillis(heartbeatInterval)
328
+ let lastConfirmedPulseMs = yield* Clock.currentTimeMillis
329
+ let failing = false
330
+ // Both overdue timers may wake after a host stall. Serialize expiry and
331
+ // recheck the budget so they cannot reconfirm or journal the same gap twice.
332
+ const expiry = yield* Semaphore.make(1)
333
+ const expire = () =>
334
+ expiry.withPermit(Effect.gen(function*() {
335
+ // A competing expiry may have held the permit across a write. Take
336
+ // fresh evidence after acquisition, never the waiting timer's reading.
337
+ const nowMs = yield* Clock.currentTimeMillis
338
+ if (nowMs - lastConfirmedPulseMs < toleranceMs) return
339
+ const unconfirmedMs = nowMs - lastConfirmedPulseMs
340
+ const renewed = yield* runStore.reconfirm(runId, owner, Math.floor(nowMs)).pipe(
341
+ // Only the ownership check consumes the old lease's reserved pulse.
342
+ Effect.timeout(heartbeatInterval),
343
+ Effect.flatMap((outcome) =>
344
+ Effect.gen(function*() {
345
+ if (outcome._tag !== "Updated") return false
346
+ lastConfirmedPulseMs = Math.max(lastConfirmedPulseMs, Math.floor(nowMs))
347
+ failing = false
348
+ const confirmedAtMs = yield* Clock.currentTimeMillis
349
+ const remainingMs = lastConfirmedPulseMs + toleranceMs - confirmedAtMs
350
+ if (remainingMs <= 0) return false
351
+ // The receipt is still required and bounded, but ownership was
352
+ // already renewed. Spending only its old reserved pulse would
353
+ // interrupt live work despite a fresh, fenced persisted lease.
354
+ yield* (options.onReconfirm?.(unconfirmedMs) ?? Effect.void).pipe(Effect.timeout(remainingMs))
355
+ return true
356
+ })
357
+ ),
358
+ Effect.catchCause((cause) =>
359
+ Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause as Cause.Cause<never>) : Effect.succeed(false)
360
+ )
361
+ )
362
+ if (renewed) return
363
+ yield* Effect.logWarning("run lease lapsed; interrupting owned work").pipe(
364
+ Effect.annotateLogs({ runId, unconfirmedMs }),
365
+ Effect.andThen(options.onLapse?.(unconfirmedMs) ?? Effect.void),
366
+ Effect.andThen(Effect.interrupt)
367
+ )
368
+ }))
369
+ const deadline = Effect.gen(function*() {
370
+ while (true) {
371
+ const nowMs = yield* Clock.currentTimeMillis
372
+ const remainingMs = lastConfirmedPulseMs + toleranceMs - nowMs
373
+ if (remainingMs <= 0) {
374
+ yield* expire()
375
+ continue
376
+ }
377
+ // Check alongside pulse intervals, with a shorter final wait when needed.
378
+ yield* Effect.sleep(Math.min(remainingMs, intervalMs))
379
+ }
380
+ })
381
+ const pulses = Effect.sleep(heartbeatInterval).pipe(
382
+ Effect.andThen(Clock.currentTimeMillis.pipe(Effect.map(Math.floor))),
383
+ Effect.flatMap((nowMs) =>
384
+ nowMs - lastConfirmedPulseMs >= toleranceMs ? expire() : runStore.heartbeat(runId, owner, nowMs).pipe(
385
+ Effect.flatMap((outcome) =>
386
+ outcome._tag === "Updated"
387
+ ? Effect.sync(() => {
388
+ lastConfirmedPulseMs = Math.max(lastConfirmedPulseMs, nowMs)
389
+ failing = false
390
+ })
391
+ : Effect.interrupt
392
+ ),
393
+ // A failed write keeps the confirmed lease; the independent deadline
394
+ // supervises its expiry. Warn on the first failure of an outage.
395
+ Effect.catch((error) => {
396
+ if (failing) return Effect.void
397
+ failing = true
398
+ return Effect.logWarning("run heartbeat write failed").pipe(
399
+ Effect.annotateLogs({ runId, code: error.code })
400
+ )
401
+ })
402
+ )
403
+ ),
404
+ Effect.forever
405
+ )
406
+ return yield* Effect.raceFirst(pulses, deadline)
407
+ })