@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,1287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenced run persistence and ownership compare-and-swap operations.
|
|
3
|
+
*
|
|
4
|
+
* Operations that accept or verify `LivenessEvidence` (`claim`,
|
|
5
|
+
* `claimAndOwn`, `steal`, `heartbeat`, `requestCancel`, and `recoverClaim`)
|
|
6
|
+
* take the caller's `nowMs` and judge it literally, including the lease cutoff
|
|
7
|
+
* and exact `evidence.checkedAtMs === nowMs` rule. The lease operations
|
|
8
|
+
* (`claim`, `claimAndOwn`, `steal`, `heartbeat`, and `recoverClaim`) also
|
|
9
|
+
* refuse a `nowMs` that runs ahead of the injected Effect `Clock` by more than
|
|
10
|
+
* `heartbeatSkewAllowance`: no composition produces one honestly, because the
|
|
11
|
+
* caller and the store share a process, and such a reading is the one lever
|
|
12
|
+
* that steals a fresh owner or pins a lease past the cutoff. A reading behind
|
|
13
|
+
* the clock is admitted, since it only makes staleness judgments more
|
|
14
|
+
* conservative and the monotonic heartbeat absorbs it. `requestCancel` keeps
|
|
15
|
+
* the literal reading because its timestamp is request data, never a lease
|
|
16
|
+
* predicate. Lifecycle stamps use the Effect `Clock`: `create` writes
|
|
17
|
+
* `created_at_ms`, `activate` writes `started_at_ms` and `heartbeat_at_ms`,
|
|
18
|
+
* and `transitionOwned` writes `finished_at_ms`. A row can therefore carry
|
|
19
|
+
* readings from two clocks. Within the allowance the store trusts the caller's
|
|
20
|
+
* clock, which is right for an in-process library over local SQLite and must
|
|
21
|
+
* not cross a trust boundary.
|
|
22
|
+
*
|
|
23
|
+
* Arbitration itself — who holds the claim, who holds the lease, whether the
|
|
24
|
+
* fence still stands — is delegated to `@smthrs/journal`'s injected
|
|
25
|
+
* `Consensus` strategy. The run row mirrors the strategy's answer in the same
|
|
26
|
+
* write transaction, and every ownership transition the strategy grants is
|
|
27
|
+
* appended to the run's `run-store` companion journal stream in that
|
|
28
|
+
* transaction (see {@link OwnershipTransition}); heartbeats renew the lease
|
|
29
|
+
* and never enter the journal.
|
|
30
|
+
*
|
|
31
|
+
* @since 0.1.0
|
|
32
|
+
*/
|
|
33
|
+
import * as Dialect from "@smthrs/database/Dialect";
|
|
34
|
+
import { afterCommit, DatabaseError, DurableWriter, fromSqlError } from "@smthrs/database/DurableWriter";
|
|
35
|
+
import { Consensus, matchesEvidence, sameOwner } from "@smthrs/journal/Consensus";
|
|
36
|
+
import { isCompanionRunId } from "@smthrs/journal/JournalEvent";
|
|
37
|
+
import { OwnerId } from "@smthrs/journal/OwnerId";
|
|
38
|
+
import * as SqlConsensus from "@smthrs/journal/SqlConsensus";
|
|
39
|
+
import * as ObservabilityMetric from "@smthrs/observability/Metric";
|
|
40
|
+
import { Clock, Context, Duration, Effect, Layer, Metric, Schema } from "effect";
|
|
41
|
+
import * as SqlClient from "effect/unstable/sql/SqlClient";
|
|
42
|
+
import { heartbeatSkewAllowance, heartbeatStaleAfter } from "./Heartbeat.js";
|
|
43
|
+
import * as Boundary from "./internal/Boundary.js";
|
|
44
|
+
import * as Companion from "./internal/Companion.js";
|
|
45
|
+
import { observeExit, observeOutcome } from "./internal/SpanOutcome.js";
|
|
46
|
+
import * as RunStoreMetrics from "./RunStoreMetrics.js";
|
|
47
|
+
/** JSON text carrying an arbitrary decoded value. */
|
|
48
|
+
const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
|
|
49
|
+
/**
|
|
50
|
+
* A UTF-16 surrogate without its partner. The journal uses the same ES2022
|
|
51
|
+
* compatible check because `String.prototype.isWellFormed` needs ES2024.
|
|
52
|
+
*/
|
|
53
|
+
const loneSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
54
|
+
/** A non-empty identifier whose UTF-16 encoding survives SQLite unchanged. */
|
|
55
|
+
const wellFormedIdentifier = (value) => value.length > 0 && !loneSurrogate.test(value);
|
|
56
|
+
const NonNegativeSafeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
|
|
57
|
+
const durableIdentifier = Schema.NonEmptyString.check(Schema.isMaxLength(Boundary.maximumIdentifierLength), Schema.makeFilter((value) => wellFormedIdentifier(value) && Boundary.isDurableText(value), { title: "durableIdentifier" }));
|
|
58
|
+
/**
|
|
59
|
+
* Maximum nesting admitted for executable run state.
|
|
60
|
+
*
|
|
61
|
+
* @since 1.0.0-rc.0
|
|
62
|
+
* @category constants
|
|
63
|
+
*/
|
|
64
|
+
export const maximumRunJsonDepth = 128;
|
|
65
|
+
/**
|
|
66
|
+
* Maximum values and members admitted for executable run state.
|
|
67
|
+
*
|
|
68
|
+
* @since 1.0.0-rc.0
|
|
69
|
+
* @category constants
|
|
70
|
+
*/
|
|
71
|
+
export const maximumRunJsonNodes = 100_000;
|
|
72
|
+
const runJsonLimits = {
|
|
73
|
+
maxDepth: maximumRunJsonDepth,
|
|
74
|
+
maxMembers: maximumRunJsonNodes,
|
|
75
|
+
maxNodes: maximumRunJsonNodes
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Stable run states understood by the durability layer.
|
|
79
|
+
*
|
|
80
|
+
* @since 0.1.0
|
|
81
|
+
* @category models
|
|
82
|
+
*/
|
|
83
|
+
export const RunStatus = Schema.Literals([
|
|
84
|
+
"pending",
|
|
85
|
+
"running",
|
|
86
|
+
"suspended",
|
|
87
|
+
"completed",
|
|
88
|
+
"failed",
|
|
89
|
+
"cancelled"
|
|
90
|
+
]);
|
|
91
|
+
/**
|
|
92
|
+
* The run states a run never leaves.
|
|
93
|
+
*
|
|
94
|
+
* Named because two rules are written against exactly this set: a settled run
|
|
95
|
+
* refuses new cancellation intent ({@link RequestCancelOutcome}), and a
|
|
96
|
+
* settled row is never claimed, activated, or swept.
|
|
97
|
+
*
|
|
98
|
+
* @since 0.1.0
|
|
99
|
+
* @category models
|
|
100
|
+
*/
|
|
101
|
+
export const TerminalRunStatus = Schema.Literals(["completed", "failed", "cancelled"]);
|
|
102
|
+
/**
|
|
103
|
+
* Whether a status is one a run never leaves.
|
|
104
|
+
*
|
|
105
|
+
* @since 0.1.0
|
|
106
|
+
* @category models
|
|
107
|
+
*/
|
|
108
|
+
export const isTerminalRunStatus = (status) => status === "completed" || status === "failed" || status === "cancelled";
|
|
109
|
+
/**
|
|
110
|
+
* Stable failure codes surfaced by `RunStore`.
|
|
111
|
+
*
|
|
112
|
+
* @since 0.1.0
|
|
113
|
+
* @category errors
|
|
114
|
+
*/
|
|
115
|
+
export const RunStoreErrorCode = Schema.Literals([
|
|
116
|
+
"invalid_run",
|
|
117
|
+
"not_found_row",
|
|
118
|
+
"constraint",
|
|
119
|
+
"decode_failed",
|
|
120
|
+
"persistence_failed"
|
|
121
|
+
]);
|
|
122
|
+
/**
|
|
123
|
+
* A normalized run persistence failure.
|
|
124
|
+
*
|
|
125
|
+
* Compare-and-swap competition is represented by successful outcome values,
|
|
126
|
+
* never by this error channel.
|
|
127
|
+
*
|
|
128
|
+
* The identity string equals the defining module path, which is how every
|
|
129
|
+
* tagged identity in this package is named.
|
|
130
|
+
*
|
|
131
|
+
* @since 0.1.0
|
|
132
|
+
* @category errors
|
|
133
|
+
*/
|
|
134
|
+
export class RunStoreError extends Schema.TaggedError()("@smthrs/run-store/RunStoreError", {
|
|
135
|
+
code: RunStoreErrorCode,
|
|
136
|
+
method: Schema.String,
|
|
137
|
+
message: Schema.String,
|
|
138
|
+
cause: Schema.Unknown
|
|
139
|
+
}) {
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* An extra compare-and-swap predicate over first-class run metadata.
|
|
143
|
+
*
|
|
144
|
+
* A guard is the seam for lifecycle rules a harness must enforce atomically —
|
|
145
|
+
* `{ cancelRequested: "absent" }` is the "do not finalize a run someone asked
|
|
146
|
+
* to cancel" rule, expressed as SQL rather than as a read-then-write race.
|
|
147
|
+
*
|
|
148
|
+
* @since 0.1.0
|
|
149
|
+
* @category models
|
|
150
|
+
*/
|
|
151
|
+
export const TransitionGuard = Schema.Struct({
|
|
152
|
+
cancelRequested: Schema.optional(Schema.Literals(["absent", "present"]))
|
|
153
|
+
});
|
|
154
|
+
/**
|
|
155
|
+
* An ownership transition the consensus strategy granted on a run.
|
|
156
|
+
*
|
|
157
|
+
* `claimed` and `stolen` reserve the run for a claimant, `activated` promotes
|
|
158
|
+
* a claim to ownership, `released` gives a claim or ownership back, and
|
|
159
|
+
* `expired` clears a dead claimant's stale claim. Each is appended as a
|
|
160
|
+
* `flows.consensus.<transition>` fact, with payload `{ runId, owner,
|
|
161
|
+
* grantedAtMs }`, to the run's companion stream
|
|
162
|
+
* `JournalEvent.companionRunId("run-store", runId)` in the transaction that
|
|
163
|
+
* made it; a rolled-back transition leaves no fact. Heartbeats are not
|
|
164
|
+
* transitions and never enter the journal.
|
|
165
|
+
*
|
|
166
|
+
* The facts live beside the run's own stream rather than in it, so a consumer
|
|
167
|
+
* that reads the run's stream by position never sees one, and a rewind or
|
|
168
|
+
* compaction of that stream never truncates one. They are recorded when the
|
|
169
|
+
* caller runs with a `Journal` in its context.
|
|
170
|
+
*
|
|
171
|
+
* @since 1.0.0
|
|
172
|
+
* @category models
|
|
173
|
+
*/
|
|
174
|
+
export const OwnershipTransition = Schema.Literals(["claimed", "activated", "released", "stolen", "expired"]);
|
|
175
|
+
/**
|
|
176
|
+
* The name of the run store's journal companion stream: every fact the store
|
|
177
|
+
* records about a run goes to `JournalEvent.companionRunId(companionStream,
|
|
178
|
+
* runId)`.
|
|
179
|
+
*
|
|
180
|
+
* @since 1.0.0
|
|
181
|
+
* @category constants
|
|
182
|
+
*/
|
|
183
|
+
export const companionStream = Companion.stream;
|
|
184
|
+
/**
|
|
185
|
+
* The journal event type an ownership transition is appended under.
|
|
186
|
+
*
|
|
187
|
+
* @since 1.0.0
|
|
188
|
+
* @category constants
|
|
189
|
+
*/
|
|
190
|
+
export const ownershipEventType = (transition) => `flows.consensus.${transition}`;
|
|
191
|
+
/**
|
|
192
|
+
* Service tag for fenced run persistence.
|
|
193
|
+
*
|
|
194
|
+
* The identity string equals the defining module path, which is how every
|
|
195
|
+
* service in this package is named.
|
|
196
|
+
*
|
|
197
|
+
* @since 0.1.0
|
|
198
|
+
* @category services
|
|
199
|
+
*/
|
|
200
|
+
export class RunStore extends Context.Service()("@smthrs/run-store/RunStore") {
|
|
201
|
+
}
|
|
202
|
+
const claimed = (claimedAtMs) => ({ _tag: "Claimed", claimedAtMs });
|
|
203
|
+
const notFound = { _tag: "NotFound" };
|
|
204
|
+
const alreadyClaimed = { _tag: "AlreadyClaimed" };
|
|
205
|
+
const heartbeatFresh = { _tag: "HeartbeatFresh" };
|
|
206
|
+
const snapshotChanged = { _tag: "SnapshotChanged" };
|
|
207
|
+
const evidenceRequired = { _tag: "EvidenceRequired" };
|
|
208
|
+
const activated = { _tag: "Activated" };
|
|
209
|
+
const claimLost = { _tag: "ClaimLost" };
|
|
210
|
+
const abandoned = { _tag: "Abandoned" };
|
|
211
|
+
const recovered = { _tag: "Recovered" };
|
|
212
|
+
const claimFresh = { _tag: "ClaimFresh" };
|
|
213
|
+
const claimChanged = { _tag: "ClaimChanged" };
|
|
214
|
+
const livenessUnconfirmed = { _tag: "LivenessUnconfirmed" };
|
|
215
|
+
const updated = { _tag: "Updated" };
|
|
216
|
+
const fenceLost = { _tag: "FenceLost" };
|
|
217
|
+
const transitioned = { _tag: "Transitioned" };
|
|
218
|
+
const guardFailed = { _tag: "GuardFailed" };
|
|
219
|
+
/**
|
|
220
|
+
* The staleness cutoff in milliseconds, derived from the one definition rather
|
|
221
|
+
* than restated. `Ownership` imports `RunStore`, so the shared constants live
|
|
222
|
+
* in the `Heartbeat` leaf both can reach.
|
|
223
|
+
*/
|
|
224
|
+
const heartbeatStaleAfterMs = Duration.toMillis(heartbeatStaleAfter);
|
|
225
|
+
const heartbeatSkewAllowanceMs = Duration.toMillis(heartbeatSkewAllowance);
|
|
226
|
+
const terminalStatuses = new Set(["completed", "failed", "cancelled"]);
|
|
227
|
+
const DatabaseRunRow = Schema.Struct({
|
|
228
|
+
runId: durableIdentifier,
|
|
229
|
+
status: RunStatus,
|
|
230
|
+
createdAtMs: NonNegativeSafeInt,
|
|
231
|
+
startedAtMs: Schema.NullOr(NonNegativeSafeInt),
|
|
232
|
+
finishedAtMs: Schema.NullOr(NonNegativeSafeInt),
|
|
233
|
+
ownerHostId: Schema.NullOr(durableIdentifier),
|
|
234
|
+
ownerPid: Schema.NullOr(NonNegativeSafeInt),
|
|
235
|
+
ownerNonce: Schema.NullOr(durableIdentifier),
|
|
236
|
+
heartbeatAtMs: Schema.NullOr(NonNegativeSafeInt),
|
|
237
|
+
claimHostId: Schema.NullOr(durableIdentifier),
|
|
238
|
+
claimPid: Schema.NullOr(NonNegativeSafeInt),
|
|
239
|
+
claimNonce: Schema.NullOr(durableIdentifier),
|
|
240
|
+
claimedAtMs: Schema.NullOr(NonNegativeSafeInt),
|
|
241
|
+
parentRunId: Schema.NullOr(durableIdentifier),
|
|
242
|
+
cancelRequestedAtMs: Schema.NullOr(NonNegativeSafeInt),
|
|
243
|
+
lineageId: Schema.NullOr(durableIdentifier),
|
|
244
|
+
roundOrdinal: Schema.NullOr(NonNegativeSafeInt),
|
|
245
|
+
stateJson: Schema.String
|
|
246
|
+
});
|
|
247
|
+
const runStoreError = (method, code, message, cause) => new RunStoreError({
|
|
248
|
+
code,
|
|
249
|
+
method,
|
|
250
|
+
message: `${code}: RunStore.${method}: ${message}`,
|
|
251
|
+
cause
|
|
252
|
+
});
|
|
253
|
+
/**
|
|
254
|
+
* The normalized database failure behind a strategy or journal failure, found
|
|
255
|
+
* by following `cause` chains the way the durable writer's retry
|
|
256
|
+
* classification does. A wrapper that carries no database failure at all —
|
|
257
|
+
* a closed journal, say — has no category to report.
|
|
258
|
+
*/
|
|
259
|
+
const databaseFailure = (cause) => {
|
|
260
|
+
let current = cause;
|
|
261
|
+
for (let depth = 0; depth < 8 && typeof current === "object" && current !== null; depth++) {
|
|
262
|
+
if (current instanceof DatabaseError)
|
|
263
|
+
return current;
|
|
264
|
+
current = current.cause;
|
|
265
|
+
}
|
|
266
|
+
return undefined;
|
|
267
|
+
};
|
|
268
|
+
const persistenceError = (method, cause) => {
|
|
269
|
+
if (Schema.is(RunStoreError)(cause))
|
|
270
|
+
return cause;
|
|
271
|
+
const database = cause instanceof DatabaseError ? cause : databaseFailure(cause.cause);
|
|
272
|
+
const code = database?.code === "constraint"
|
|
273
|
+
? "constraint"
|
|
274
|
+
: "persistence_failed";
|
|
275
|
+
return runStoreError(method, code, "database operation failed", {
|
|
276
|
+
category: code,
|
|
277
|
+
// Keep the classification for outer transaction retries, never driver
|
|
278
|
+
// causes that may contain executable state or bound SQL values.
|
|
279
|
+
cause: new DatabaseError({ code: database?.code ?? "unknown" })
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
const invalidRunError = (method, causeOrField, detail = "violates the durable contract") => runStoreError(method, "invalid_run", "run input is invalid", typeof causeOrField === "string" ? { field: causeOrField, detail } : causeOrField);
|
|
283
|
+
const stateAdmission = (value) => Boundary.admitJsonText(value, runJsonLimits);
|
|
284
|
+
const isJsonString = (value) => Schema.decodeUnknownResult(UnknownFromJsonString)(value)._tag === "Success";
|
|
285
|
+
const validOwner = (value) => Schema.is(OwnerId)(value) &&
|
|
286
|
+
Boundary.isDurableText(value.hostId) &&
|
|
287
|
+
Boundary.isDurableText(value.nonce) &&
|
|
288
|
+
Number.isSafeInteger(value.pid) &&
|
|
289
|
+
value.pid >= 0;
|
|
290
|
+
/** Caller timestamps are SQLite-safe, non-negative integer millisecond readings. */
|
|
291
|
+
const validTimestamp = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
292
|
+
const inertRecord = (input, allowed) => {
|
|
293
|
+
if (typeof input !== "object" || input === null)
|
|
294
|
+
return undefined;
|
|
295
|
+
try {
|
|
296
|
+
const prototype = Object.getPrototypeOf(input);
|
|
297
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
298
|
+
return undefined;
|
|
299
|
+
const fields = Object.create(null);
|
|
300
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
301
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, key);
|
|
302
|
+
if (descriptor === undefined || !descriptor.enumerable)
|
|
303
|
+
continue;
|
|
304
|
+
if (!("value" in descriptor) || (allowed !== undefined && (typeof key !== "string" || !allowed.has(key)))) {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
if (typeof key === "string")
|
|
308
|
+
fields[key] = descriptor.value;
|
|
309
|
+
}
|
|
310
|
+
return fields;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const snapshotOwner = (method, field, input) => Effect.suspend(() => {
|
|
317
|
+
const fields = inertRecord(input, new Set(["hostId", "pid", "nonce"]));
|
|
318
|
+
if (fields === undefined) {
|
|
319
|
+
return Effect.fail(invalidRunError(method, field, "must be an inert owner record"));
|
|
320
|
+
}
|
|
321
|
+
const owner = Object.freeze({
|
|
322
|
+
hostId: fields.hostId,
|
|
323
|
+
pid: fields.pid,
|
|
324
|
+
nonce: fields.nonce
|
|
325
|
+
});
|
|
326
|
+
return validOwner(owner)
|
|
327
|
+
? Effect.succeed(owner)
|
|
328
|
+
: Effect.fail(invalidRunError(method, field, "must be a valid owner identity"));
|
|
329
|
+
});
|
|
330
|
+
const snapshotRunId = (method, input) => typeof input === "string" && wellFormedIdentifier(input) && Boundary.isDurableText(input)
|
|
331
|
+
? Effect.succeed(input)
|
|
332
|
+
: Effect.fail(invalidRunError(method, "runId", "must be well-formed durable text"));
|
|
333
|
+
const snapshotTimestamp = (method, field, input) => typeof input === "number" && validTimestamp(input)
|
|
334
|
+
? Effect.succeed(input)
|
|
335
|
+
: Effect.fail(invalidRunError(method, field, "must be a non-negative safe integer"));
|
|
336
|
+
/**
|
|
337
|
+
* Admits a caller's lease reading. Once admitted the reading is authoritative:
|
|
338
|
+
* it is the cutoff the lease predicate compares against and the stamp the row
|
|
339
|
+
* keeps. The store refuses only a reading that runs ahead of its own `Clock`
|
|
340
|
+
* by more than `heartbeatSkewAllowance`, because no composition produces one
|
|
341
|
+
* honestly (the caller and the store share a process) and it is the one lever
|
|
342
|
+
* that steals a fresh owner or pins a lease past the cutoff. A reading behind
|
|
343
|
+
* the clock is admitted: it makes every staleness judgment more conservative,
|
|
344
|
+
* and `heartbeat`'s monotonic write absorbs it.
|
|
345
|
+
*/
|
|
346
|
+
const snapshotLeaseReading = (method, field, input, cause) => Effect.gen(function* () {
|
|
347
|
+
const nowMs = yield* snapshotTimestamp(method, field, input);
|
|
348
|
+
const clockMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
|
|
349
|
+
if (nowMs > clockMs + heartbeatSkewAllowanceMs) {
|
|
350
|
+
return yield* Effect.fail(invalidRunError(method, {
|
|
351
|
+
...cause,
|
|
352
|
+
field,
|
|
353
|
+
nowMs,
|
|
354
|
+
clockMs,
|
|
355
|
+
detail: "runs ahead of the store clock by more than the heartbeat skew allowance"
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
return nowMs;
|
|
359
|
+
});
|
|
360
|
+
const snapshotState = (method, input, cause) => {
|
|
361
|
+
const admitted = stateAdmission(input);
|
|
362
|
+
return admitted.ok
|
|
363
|
+
? Effect.succeed(admitted.value)
|
|
364
|
+
: Effect.fail(invalidRunError(method, cause, admitted.complaint));
|
|
365
|
+
};
|
|
366
|
+
const snapshotCreateOptions = (input) => Effect.suspend(() => {
|
|
367
|
+
const value = input ?? {};
|
|
368
|
+
const fields = inertRecord(value, new Set(["parentRunId", "lineageId", "roundOrdinal"]));
|
|
369
|
+
if (fields === undefined) {
|
|
370
|
+
return Effect.fail(invalidRunError("create", "options", "must be an inert data record"));
|
|
371
|
+
}
|
|
372
|
+
const rawParent = fields.parentRunId;
|
|
373
|
+
const rawLineage = fields.lineageId;
|
|
374
|
+
const rawRound = fields.roundOrdinal;
|
|
375
|
+
const parentRunId = rawParent === undefined ? null : rawParent;
|
|
376
|
+
const lineageId = rawLineage === undefined ? null : rawLineage;
|
|
377
|
+
const roundOrdinal = rawRound === undefined ? null : rawRound;
|
|
378
|
+
if ((parentRunId !== null &&
|
|
379
|
+
(typeof parentRunId !== "string" ||
|
|
380
|
+
!wellFormedIdentifier(parentRunId) ||
|
|
381
|
+
!Boundary.isDurableText(parentRunId))) ||
|
|
382
|
+
(lineageId !== null &&
|
|
383
|
+
(typeof lineageId !== "string" ||
|
|
384
|
+
!wellFormedIdentifier(lineageId) ||
|
|
385
|
+
!Boundary.isDurableText(lineageId))) ||
|
|
386
|
+
(roundOrdinal !== null && (typeof roundOrdinal !== "number" || !validTimestamp(roundOrdinal))) ||
|
|
387
|
+
(lineageId === null) !== (roundOrdinal === null))
|
|
388
|
+
return Effect.fail(invalidRunError("create", "options", "contains invalid ancestry or lineage"));
|
|
389
|
+
return Effect.succeed(Object.freeze({ parentRunId, lineageId, roundOrdinal }));
|
|
390
|
+
});
|
|
391
|
+
/**
|
|
392
|
+
* Structural admission for a value typed as a required-field record. `RunRow`
|
|
393
|
+
* extends `RunSnapshot`, so a row from `get` must pass wherever an expected
|
|
394
|
+
* snapshot is typed: extra own data fields are ignored, while a non-plain
|
|
395
|
+
* prototype, any enumerable accessor, or a missing or inherited required field
|
|
396
|
+
* is still refused. The required fields come from one guarded own-descriptor snapshot.
|
|
397
|
+
*/
|
|
398
|
+
const inertFields = (input, required) => {
|
|
399
|
+
const fields = inertRecord(input);
|
|
400
|
+
return fields !== undefined && required.every((key) => Object.hasOwn(fields, key)) ? fields : undefined;
|
|
401
|
+
};
|
|
402
|
+
const snapshotExpected = (method, input) => Effect.gen(function* () {
|
|
403
|
+
const fields = inertFields(input, ["status", "owner", "heartbeatAtMs"]);
|
|
404
|
+
if (fields === undefined) {
|
|
405
|
+
return yield* Effect.fail(invalidRunError(method, "expected", "must be an inert snapshot record"));
|
|
406
|
+
}
|
|
407
|
+
const status = fields.status;
|
|
408
|
+
const rawOwner = fields.owner;
|
|
409
|
+
const heartbeatAtMs = fields.heartbeatAtMs;
|
|
410
|
+
if (!Schema.is(RunStatus)(status) ||
|
|
411
|
+
(heartbeatAtMs !== null && (typeof heartbeatAtMs !== "number" || !validTimestamp(heartbeatAtMs)))) {
|
|
412
|
+
return yield* Effect.fail(invalidRunError(method, "expected", "contains an invalid status or heartbeat"));
|
|
413
|
+
}
|
|
414
|
+
const owner = rawOwner === null ? null : yield* snapshotOwner(method, "expected.owner", rawOwner);
|
|
415
|
+
if (status === "running") {
|
|
416
|
+
if (owner === null || heartbeatAtMs === null) {
|
|
417
|
+
return yield* Effect.fail(invalidRunError(method, "expected", "violates ownership invariants"));
|
|
418
|
+
}
|
|
419
|
+
return Object.freeze({ status, owner, heartbeatAtMs });
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
if (owner !== null || heartbeatAtMs !== null) {
|
|
423
|
+
return yield* Effect.fail(invalidRunError(method, "expected", "violates ownership invariants"));
|
|
424
|
+
}
|
|
425
|
+
return Object.freeze({ status, owner, heartbeatAtMs });
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
const snapshotEvidence = (method, input) => Effect.gen(function* () {
|
|
429
|
+
const fields = inertRecord(input, new Set(["expectedOwner", "checkedAtMs", "kind"]));
|
|
430
|
+
if (fields === undefined) {
|
|
431
|
+
return yield* Effect.fail(invalidRunError(method, "evidence", "must be an inert evidence record"));
|
|
432
|
+
}
|
|
433
|
+
const expectedOwner = yield* snapshotOwner(method, "evidence.expectedOwner", fields.expectedOwner);
|
|
434
|
+
const checkedAtMs = yield* snapshotTimestamp(method, "evidence.checkedAtMs", fields.checkedAtMs);
|
|
435
|
+
const kind = fields.kind;
|
|
436
|
+
if (kind !== "same-host-pid-dead" &&
|
|
437
|
+
kind !== "cross-host-unreachable-stale" &&
|
|
438
|
+
kind !== "lease-expired")
|
|
439
|
+
return yield* Effect.fail(invalidRunError(method, "evidence.kind"));
|
|
440
|
+
return Object.freeze({ expectedOwner, checkedAtMs, kind });
|
|
441
|
+
});
|
|
442
|
+
const snapshotGuard = (input) => Effect.suspend(() => {
|
|
443
|
+
if (input === undefined)
|
|
444
|
+
return Effect.succeed(undefined);
|
|
445
|
+
const fields = inertRecord(input, new Set(["cancelRequested"]));
|
|
446
|
+
if (fields === undefined) {
|
|
447
|
+
return Effect.fail(invalidRunError("transitionOwned", "guard", "must be an inert exact guard"));
|
|
448
|
+
}
|
|
449
|
+
const cancelRequested = fields.cancelRequested;
|
|
450
|
+
if (cancelRequested !== undefined && cancelRequested !== "absent" && cancelRequested !== "present") {
|
|
451
|
+
return Effect.fail(invalidRunError("transitionOwned", "guard.cancelRequested"));
|
|
452
|
+
}
|
|
453
|
+
return Effect.succeed(Object.freeze(cancelRequested === undefined ? {} : { cancelRequested }));
|
|
454
|
+
});
|
|
455
|
+
const ownerFromColumns = (hostId, pid, nonce) => {
|
|
456
|
+
if (hostId === null && pid === null && nonce === null)
|
|
457
|
+
return null;
|
|
458
|
+
if (hostId !== null && pid !== null && nonce !== null) {
|
|
459
|
+
// DatabaseRunRow already admitted all three columns through the stricter
|
|
460
|
+
// durable identifier and safe-integer schemas.
|
|
461
|
+
return Object.freeze({ hostId, pid, nonce });
|
|
462
|
+
}
|
|
463
|
+
return undefined;
|
|
464
|
+
};
|
|
465
|
+
const rowMatchesClaim = (row, claimant, claimedAtMs) => row.claimHostId === claimant.hostId &&
|
|
466
|
+
row.claimPid === claimant.pid &&
|
|
467
|
+
row.claimNonce === claimant.nonce &&
|
|
468
|
+
row.claimedAtMs === claimedAtMs;
|
|
469
|
+
/**
|
|
470
|
+
* The exact predicate the ownership compare-and-swap used to compile into its
|
|
471
|
+
* UPDATE, evaluated in the serialized write transaction instead: the row must
|
|
472
|
+
* still match the caller's expected snapshot.
|
|
473
|
+
*/
|
|
474
|
+
const rowMatchesSnapshot = (row, expected) => row.status === expected.status &&
|
|
475
|
+
row.ownerHostId === (expected.owner?.hostId ?? null) &&
|
|
476
|
+
row.ownerPid === (expected.owner?.pid ?? null) &&
|
|
477
|
+
row.ownerNonce === (expected.owner?.nonce ?? null) &&
|
|
478
|
+
row.heartbeatAtMs === expected.heartbeatAtMs;
|
|
479
|
+
const rowOwnedBy = (row, owner) => row.status === "running" &&
|
|
480
|
+
row.ownerHostId === owner.hostId &&
|
|
481
|
+
row.ownerPid === owner.pid &&
|
|
482
|
+
row.ownerNonce === owner.nonce;
|
|
483
|
+
const claimColumnsFree = (row) => row.claimHostId === null && row.claimPid === null && row.claimNonce === null && row.claimedAtMs === null;
|
|
484
|
+
const decodeRunRow = (method, runId, input) => Schema.decodeUnknownEffect(DatabaseRunRow)(input).pipe(Effect.mapError(() => runStoreError(method, "decode_failed", "could not decode flows_runs row", { runId, stage: "row-schema" })), Effect.flatMap((row) => {
|
|
485
|
+
const owner = ownerFromColumns(row.ownerHostId, row.ownerPid, row.ownerNonce);
|
|
486
|
+
const claim = ownerFromColumns(row.claimHostId, row.claimPid, row.claimNonce);
|
|
487
|
+
const admittedState = stateAdmission(row.stateJson);
|
|
488
|
+
const stateJsonValid = isJsonString(row.stateJson);
|
|
489
|
+
const invalidOwner = owner === undefined ||
|
|
490
|
+
(owner === null && row.heartbeatAtMs !== null) ||
|
|
491
|
+
(owner !== null && row.heartbeatAtMs === null) ||
|
|
492
|
+
(row.status === "running" ? owner === null : owner !== null);
|
|
493
|
+
const invalidClaim = claim === undefined ||
|
|
494
|
+
(claim === null && row.claimedAtMs !== null) ||
|
|
495
|
+
(claim !== null && row.claimedAtMs === null);
|
|
496
|
+
if (invalidOwner || invalidClaim || !admittedState.ok) {
|
|
497
|
+
// The cause is published to logs, spans, and telemetry. Executable
|
|
498
|
+
// state may carry credentials, so report only the invariants read here.
|
|
499
|
+
return Effect.fail(runStoreError(method, "decode_failed", "flows_runs row violates durable invariants", {
|
|
500
|
+
runId,
|
|
501
|
+
status: row.status,
|
|
502
|
+
heartbeatAtMs: row.heartbeatAtMs,
|
|
503
|
+
claimedAtMs: row.claimedAtMs,
|
|
504
|
+
hasClaimColumns: row.claimHostId !== null || row.claimPid !== null || row.claimNonce !== null,
|
|
505
|
+
hasOwnerColumns: row.ownerHostId !== null || row.ownerPid !== null || row.ownerNonce !== null,
|
|
506
|
+
stateJsonValid
|
|
507
|
+
}));
|
|
508
|
+
}
|
|
509
|
+
return Effect.succeed(Object.freeze({
|
|
510
|
+
runId: row.runId,
|
|
511
|
+
status: row.status,
|
|
512
|
+
createdAtMs: row.createdAtMs,
|
|
513
|
+
startedAtMs: row.startedAtMs,
|
|
514
|
+
finishedAtMs: row.finishedAtMs,
|
|
515
|
+
owner,
|
|
516
|
+
heartbeatAtMs: row.heartbeatAtMs,
|
|
517
|
+
claim,
|
|
518
|
+
claimedAtMs: row.claimedAtMs,
|
|
519
|
+
parentRunId: row.parentRunId,
|
|
520
|
+
cancelRequestedAtMs: row.cancelRequestedAtMs,
|
|
521
|
+
lineageId: row.lineageId,
|
|
522
|
+
roundOrdinal: row.roundOrdinal,
|
|
523
|
+
stateJson: row.stateJson
|
|
524
|
+
}));
|
|
525
|
+
}));
|
|
526
|
+
/**
|
|
527
|
+
* Rows that belong to the logical run containing `runId`: every round that
|
|
528
|
+
* names the resolved lineage, plus the root itself when its own lineage
|
|
529
|
+
* columns are null or name that lineage. A same-named run in an independent
|
|
530
|
+
* lineage is excluded. Shared by every lineage read and write so membership
|
|
531
|
+
* cannot drift between them.
|
|
532
|
+
*/
|
|
533
|
+
const lineageMembership = (sql, runId) => sql `(
|
|
534
|
+
lineage_id = (SELECT COALESCE(lineage_id, run_id) FROM flows_runs WHERE run_id = ${runId})
|
|
535
|
+
OR (
|
|
536
|
+
run_id = (SELECT COALESCE(lineage_id, run_id) FROM flows_runs WHERE run_id = ${runId})
|
|
537
|
+
AND (
|
|
538
|
+
lineage_id IS NULL
|
|
539
|
+
OR lineage_id = (SELECT COALESCE(lineage_id, run_id) FROM flows_runs WHERE run_id = ${runId})
|
|
540
|
+
)
|
|
541
|
+
)
|
|
542
|
+
)`;
|
|
543
|
+
/**
|
|
544
|
+
* Names the outcome of a guarded cancellation UPDATE that matched nothing.
|
|
545
|
+
*
|
|
546
|
+
* `rows` are the lineage members in round order; an absent set is `NotFound`.
|
|
547
|
+
* Status is read before the request column, so a settled run reports how it
|
|
548
|
+
* ended even when its own closing request is still on the row. A live
|
|
549
|
+
* unrequested row cannot survive the UPDATE inside one serialized writer
|
|
550
|
+
* transaction, so it is a persistence invariant failure rather than a retry.
|
|
551
|
+
*/
|
|
552
|
+
const classifyCancellationMiss = (method, runId, rows) => Effect.gen(function* () {
|
|
553
|
+
let outcome = notFound;
|
|
554
|
+
for (const row of rows) {
|
|
555
|
+
const status = yield* Schema.decodeUnknownEffect(RunStatus)(row.status).pipe(Effect.mapError((cause) => runStoreError(method, "decode_failed", "could not decode flows_runs status", cause)));
|
|
556
|
+
if (isTerminalRunStatus(status)) {
|
|
557
|
+
// A terminal predecessor never hides a live round's request.
|
|
558
|
+
if (outcome._tag !== "AlreadyRequested")
|
|
559
|
+
outcome = { _tag: "Terminal", status };
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (row.requestedAtMs === null) {
|
|
563
|
+
return yield* Effect.fail(runStoreError(method, "persistence_failed", "guarded cancellation update missed a live unrequested row", {
|
|
564
|
+
runId,
|
|
565
|
+
stage: "write-invariant"
|
|
566
|
+
}));
|
|
567
|
+
}
|
|
568
|
+
outcome = { _tag: "AlreadyRequested", requestedAtMs: Number(row.requestedAtMs) };
|
|
569
|
+
}
|
|
570
|
+
return outcome;
|
|
571
|
+
});
|
|
572
|
+
const selectRun = (sql, runId, mode = "single") => sql `
|
|
573
|
+
SELECT
|
|
574
|
+
run_id AS "runId",
|
|
575
|
+
status AS "status",
|
|
576
|
+
created_at_ms AS "createdAtMs",
|
|
577
|
+
started_at_ms AS "startedAtMs",
|
|
578
|
+
finished_at_ms AS "finishedAtMs",
|
|
579
|
+
owner_host_id AS "ownerHostId",
|
|
580
|
+
owner_pid AS "ownerPid",
|
|
581
|
+
owner_nonce AS "ownerNonce",
|
|
582
|
+
heartbeat_at_ms AS "heartbeatAtMs",
|
|
583
|
+
claim_host_id AS "claimHostId",
|
|
584
|
+
claim_pid AS "claimPid",
|
|
585
|
+
claim_nonce AS "claimNonce",
|
|
586
|
+
claimed_at_ms AS "claimedAtMs",
|
|
587
|
+
parent_run_id AS "parentRunId",
|
|
588
|
+
cancel_requested_at_ms AS "cancelRequestedAtMs",
|
|
589
|
+
lineage_id AS "lineageId",
|
|
590
|
+
round_ordinal AS "roundOrdinal",
|
|
591
|
+
state_json AS "stateJson"
|
|
592
|
+
FROM flows_runs
|
|
593
|
+
WHERE ${mode === "latest"
|
|
594
|
+
? sql `run_id = COALESCE(
|
|
595
|
+
(SELECT run_id FROM flows_runs
|
|
596
|
+
WHERE lineage_id = (SELECT COALESCE(lineage_id, run_id) FROM flows_runs WHERE run_id = ${runId})
|
|
597
|
+
ORDER BY round_ordinal DESC LIMIT 1),
|
|
598
|
+
(SELECT run_id FROM flows_runs WHERE run_id = ${runId})
|
|
599
|
+
)`
|
|
600
|
+
: mode === "lineage"
|
|
601
|
+
? lineageMembership(sql, runId)
|
|
602
|
+
: sql `run_id = ${runId}`}
|
|
603
|
+
ORDER BY COALESCE(round_ordinal, 0), run_id
|
|
604
|
+
`;
|
|
605
|
+
const classifyClaimLoss = (row, nowMs) => {
|
|
606
|
+
if (row === undefined)
|
|
607
|
+
return notFound;
|
|
608
|
+
if (row.claimHostId !== null)
|
|
609
|
+
return alreadyClaimed;
|
|
610
|
+
if (row.status === "running" &&
|
|
611
|
+
row.heartbeatAtMs !== null &&
|
|
612
|
+
row.heartbeatAtMs >= nowMs - heartbeatStaleAfterMs) {
|
|
613
|
+
return heartbeatFresh;
|
|
614
|
+
}
|
|
615
|
+
return snapshotChanged;
|
|
616
|
+
};
|
|
617
|
+
const evidenceMatches = (expected, claimant, checkedAtMs, evidence) => expected.status === "running" &&
|
|
618
|
+
expected.owner !== null &&
|
|
619
|
+
matchesEvidence(expected.owner, claimant, checkedAtMs, evidence);
|
|
620
|
+
/**
|
|
621
|
+
* Constructs the production `RunStore` implementation.
|
|
622
|
+
*
|
|
623
|
+
* `state_json` is executable state: it is decoded and re-entered on every
|
|
624
|
+
* resume, so it is persisted and returned byte-for-byte. Nothing rewrites it
|
|
625
|
+
* on the way through — a redactor here would silently change what the flow
|
|
626
|
+
* re-reads (issue #72). `Schema.Redacted` hides inspection but allows schema
|
|
627
|
+
* JSON encoding by default. Use `Schema.Redacted(Schema.String, {
|
|
628
|
+
* disallowJsonEncode: true })` to refuse JSON encoding, or exclude credentials
|
|
629
|
+
* from the persistence schema and store a secret reference resolved from a
|
|
630
|
+
* secret provider on resume. Publication hygiene belongs on journal-event
|
|
631
|
+
* and export surfaces.
|
|
632
|
+
*
|
|
633
|
+
* `state_json` and `flows_attempts.outcome_json` therefore hold unredacted
|
|
634
|
+
* run inputs and results, so the run's SQLite file (`.flows/*.db`) must be
|
|
635
|
+
* handled as a secret store.
|
|
636
|
+
*
|
|
637
|
+
* @since 0.1.0
|
|
638
|
+
* @category constructors
|
|
639
|
+
*/
|
|
640
|
+
export const make = Effect.gen(function* () {
|
|
641
|
+
const sql = yield* Effect.service(SqlClient.SqlClient);
|
|
642
|
+
const writer = yield* DurableWriter;
|
|
643
|
+
const consensus = yield* Consensus;
|
|
644
|
+
const write = (method, effect) => writer.write(effect).pipe(Effect.mapError((cause) => persistenceError(method, cause)));
|
|
645
|
+
/** Appends an ownership transition to the run's companion stream (see {@link OwnershipTransition}). */
|
|
646
|
+
const recordTransition = (runId, transition, owner, grantedAtMs) => Companion.append(runId, ownershipEventType(transition), {
|
|
647
|
+
owner: { hostId: owner.hostId, pid: owner.pid, nonce: owner.nonce },
|
|
648
|
+
grantedAtMs
|
|
649
|
+
});
|
|
650
|
+
// A bare SELECT needs no write transaction and no replay; only the error
|
|
651
|
+
// vocabulary stays shared with `write`.
|
|
652
|
+
const read = (method, effect) => effect.pipe(Effect.mapError((cause) => persistenceError(method, fromSqlError(cause))));
|
|
653
|
+
const create = Effect.fn("RunStore.create")((runIdInput, stateInput, optionsInput) => Effect.gen(function* () {
|
|
654
|
+
const runId = yield* snapshotRunId("create", runIdInput);
|
|
655
|
+
if (isCompanionRunId(runId)) {
|
|
656
|
+
return yield* Effect.fail(invalidRunError("create", "runId", "names a reserved journal companion stream"));
|
|
657
|
+
}
|
|
658
|
+
const { lineageId, parentRunId, roundOrdinal } = yield* snapshotCreateOptions(optionsInput);
|
|
659
|
+
// This cause is published to logs, spans, and telemetry. Executable
|
|
660
|
+
// state may carry credentials, so include its shape but never its text.
|
|
661
|
+
const stateJson = yield* snapshotState("create", stateInput, {
|
|
662
|
+
runId,
|
|
663
|
+
parentRunId,
|
|
664
|
+
lineageId,
|
|
665
|
+
roundOrdinal,
|
|
666
|
+
stateJsonLength: typeof stateInput === "string" ? stateInput.length : null,
|
|
667
|
+
stateJsonValid: isJsonString(stateInput)
|
|
668
|
+
});
|
|
669
|
+
yield* Effect.annotateCurrentSpan({ runId });
|
|
670
|
+
const createdAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
|
|
671
|
+
yield* write("create", sql `
|
|
672
|
+
INSERT INTO flows_runs (
|
|
673
|
+
run_id,
|
|
674
|
+
status,
|
|
675
|
+
created_at_ms,
|
|
676
|
+
started_at_ms,
|
|
677
|
+
finished_at_ms,
|
|
678
|
+
owner_host_id,
|
|
679
|
+
owner_pid,
|
|
680
|
+
owner_nonce,
|
|
681
|
+
heartbeat_at_ms,
|
|
682
|
+
claim_host_id,
|
|
683
|
+
claim_pid,
|
|
684
|
+
claim_nonce,
|
|
685
|
+
claimed_at_ms,
|
|
686
|
+
parent_run_id,
|
|
687
|
+
lineage_id,
|
|
688
|
+
round_ordinal,
|
|
689
|
+
state_json
|
|
690
|
+
) VALUES (
|
|
691
|
+
${runId},
|
|
692
|
+
'pending',
|
|
693
|
+
${createdAtMs},
|
|
694
|
+
NULL,
|
|
695
|
+
NULL,
|
|
696
|
+
NULL,
|
|
697
|
+
NULL,
|
|
698
|
+
NULL,
|
|
699
|
+
NULL,
|
|
700
|
+
NULL,
|
|
701
|
+
NULL,
|
|
702
|
+
NULL,
|
|
703
|
+
NULL,
|
|
704
|
+
${parentRunId},
|
|
705
|
+
${lineageId},
|
|
706
|
+
${roundOrdinal},
|
|
707
|
+
${stateJson}
|
|
708
|
+
)
|
|
709
|
+
`.pipe(Effect.asVoid));
|
|
710
|
+
}).pipe(observeExit));
|
|
711
|
+
const get = Effect.fn("RunStore.get")((runIdInput) => Effect.gen(function* () {
|
|
712
|
+
const runId = yield* snapshotRunId("get", runIdInput);
|
|
713
|
+
yield* Effect.annotateCurrentSpan({ runId });
|
|
714
|
+
const rows = yield* read("get", selectRun(sql, runId));
|
|
715
|
+
return rows[0] === undefined
|
|
716
|
+
? yield* Effect.fail(runStoreError("get", "not_found_row", `run ${runId} was not found`, { runId }))
|
|
717
|
+
: yield* decodeRunRow("get", runId, rows[0]);
|
|
718
|
+
}).pipe(observeExit));
|
|
719
|
+
const lineage = Effect.fn("RunStore.lineage")((runIdInput) => Effect.gen(function* () {
|
|
720
|
+
const runId = yield* snapshotRunId("lineage", runIdInput);
|
|
721
|
+
yield* Effect.annotateCurrentSpan({ runId });
|
|
722
|
+
const rows = yield* read("lineage", selectRun(sql, runId, "lineage"));
|
|
723
|
+
return yield* Effect.forEach(rows, (row) => decodeRunRow("lineage", row.runId, row));
|
|
724
|
+
}).pipe(observeExit));
|
|
725
|
+
const latestRound = Effect.fn("RunStore.latestRound")((runIdInput) => Effect.gen(function* () {
|
|
726
|
+
const runId = yield* snapshotRunId("latestRound", runIdInput);
|
|
727
|
+
yield* Effect.annotateCurrentSpan({ runId });
|
|
728
|
+
const rows = yield* read("latestRound", selectRun(sql, runId, "latest"));
|
|
729
|
+
return rows[0] === undefined
|
|
730
|
+
? yield* Effect.fail(runStoreError("latestRound", "not_found_row", `run ${runId} was not found`, { runId }))
|
|
731
|
+
: yield* decodeRunRow("latestRound", rows[0].runId, rows[0]);
|
|
732
|
+
}).pipe(observeExit));
|
|
733
|
+
const requestCancel = Effect.fn("RunStore.requestCancel")((runIdInput, nowMsInput) => Effect.gen(function* () {
|
|
734
|
+
const runId = yield* snapshotRunId("requestCancel", runIdInput);
|
|
735
|
+
const nowMs = yield* snapshotTimestamp("requestCancel", "nowMs", nowMsInput);
|
|
736
|
+
yield* Effect.annotateCurrentSpan({ runId });
|
|
737
|
+
return yield* write("requestCancel", Effect.gen(function* () {
|
|
738
|
+
// The status predicate is part of the compare-and-swap rather than
|
|
739
|
+
// a read-then-write check: a run that settles between a caller's
|
|
740
|
+
// read and this UPDATE must lose the write, not race it. The writer
|
|
741
|
+
// serializes the whole transaction, so a miss is classified by one
|
|
742
|
+
// read of the same columns and never retried.
|
|
743
|
+
const rows = yield* sql `
|
|
744
|
+
UPDATE flows_runs
|
|
745
|
+
SET cancel_requested_at_ms = ${nowMs}
|
|
746
|
+
WHERE run_id = ${runId}
|
|
747
|
+
AND cancel_requested_at_ms IS NULL
|
|
748
|
+
AND status NOT IN ('completed', 'failed', 'cancelled')
|
|
749
|
+
RETURNING cancel_requested_at_ms AS "requestedAtMs"
|
|
750
|
+
`;
|
|
751
|
+
if (rows[0] !== undefined) {
|
|
752
|
+
return { _tag: "CancelRequested", requestedAtMs: Number(rows[0].requestedAtMs) };
|
|
753
|
+
}
|
|
754
|
+
const current = yield* sql `
|
|
755
|
+
SELECT cancel_requested_at_ms AS "requestedAtMs", status AS "status"
|
|
756
|
+
FROM flows_runs WHERE run_id = ${runId}
|
|
757
|
+
`;
|
|
758
|
+
return yield* classifyCancellationMiss("requestCancel", runId, current);
|
|
759
|
+
}));
|
|
760
|
+
}).pipe(observeOutcome()));
|
|
761
|
+
const requestCancelLineage = Effect.fn("RunStore.requestCancelLineage")((runIdInput, nowMsInput) => Effect.gen(function* () {
|
|
762
|
+
const runId = yield* snapshotRunId("requestCancelLineage", runIdInput);
|
|
763
|
+
const nowMs = yield* snapshotTimestamp("requestCancelLineage", "nowMs", nowMsInput);
|
|
764
|
+
yield* Effect.annotateCurrentSpan({ runId });
|
|
765
|
+
return yield* write("requestCancelLineage", Effect.gen(function* () {
|
|
766
|
+
// One set-based guarded UPDATE over the lineage: cost follows the
|
|
767
|
+
// members' metadata, never their state payloads, and a settled
|
|
768
|
+
// history of any length adds no statements.
|
|
769
|
+
const rows = yield* sql `
|
|
770
|
+
UPDATE flows_runs
|
|
771
|
+
SET cancel_requested_at_ms = ${nowMs}
|
|
772
|
+
WHERE ${lineageMembership(sql, runId)}
|
|
773
|
+
AND cancel_requested_at_ms IS NULL
|
|
774
|
+
AND status NOT IN ('completed', 'failed', 'cancelled')
|
|
775
|
+
RETURNING cancel_requested_at_ms AS "requestedAtMs"
|
|
776
|
+
`;
|
|
777
|
+
if (rows[0] !== undefined) {
|
|
778
|
+
return { _tag: "CancelRequested", requestedAtMs: Number(rows[0].requestedAtMs) };
|
|
779
|
+
}
|
|
780
|
+
// A previous request on any live round dominates a terminal
|
|
781
|
+
// predecessor; otherwise the latest round's ending is reported.
|
|
782
|
+
const members = yield* sql `
|
|
783
|
+
SELECT cancel_requested_at_ms AS "requestedAtMs", status AS "status"
|
|
784
|
+
FROM flows_runs
|
|
785
|
+
WHERE ${lineageMembership(sql, runId)}
|
|
786
|
+
ORDER BY COALESCE(round_ordinal, 0), run_id
|
|
787
|
+
`;
|
|
788
|
+
return yield* classifyCancellationMiss("requestCancelLineage", runId, members);
|
|
789
|
+
}));
|
|
790
|
+
}).pipe(observeOutcome()));
|
|
791
|
+
const claim = Effect.fn("RunStore.claim")((runIdInput, expectedInput, claimantInput, nowMsInput) => Effect.gen(function* () {
|
|
792
|
+
const runId = yield* snapshotRunId("claim", runIdInput);
|
|
793
|
+
const expected = yield* snapshotExpected("claim", expectedInput);
|
|
794
|
+
const claimant = yield* snapshotOwner("claim", "claimant", claimantInput);
|
|
795
|
+
const nowMs = yield* snapshotLeaseReading("claim", "nowMs", nowMsInput, { runId });
|
|
796
|
+
yield* Effect.annotateCurrentSpan({ runId, claimantHostId: claimant.hostId });
|
|
797
|
+
return yield* write("claim", Effect.gen(function* () {
|
|
798
|
+
// `claim` never admits a running run, so it needs no staleness
|
|
799
|
+
// test: `pending` and `suspended` exclude it. `claimAndOwn` is the
|
|
800
|
+
// method that genuinely needs one, because it does admit `running`.
|
|
801
|
+
// The snapshot predicate is evaluated inside the serialized write
|
|
802
|
+
// transaction, so it is exactly the compare-and-swap it replaces.
|
|
803
|
+
const row = (yield* selectRun(sql, runId))[0];
|
|
804
|
+
const admissible = row !== undefined &&
|
|
805
|
+
(row.status === "pending" || row.status === "suspended") &&
|
|
806
|
+
rowMatchesSnapshot(row, expected) &&
|
|
807
|
+
claimColumnsFree(row);
|
|
808
|
+
if (!admissible)
|
|
809
|
+
return classifyClaimLoss(row, nowMs);
|
|
810
|
+
const grant = yield* consensus.claim(runId, claimant, nowMs);
|
|
811
|
+
if (grant._tag === "Rejected") {
|
|
812
|
+
// The strategy is authoritative: the row admitted what the lease
|
|
813
|
+
// refused, and the loss is classified from the row exactly as a
|
|
814
|
+
// failed compare-and-swap always was.
|
|
815
|
+
return classifyClaimLoss(row, nowMs);
|
|
816
|
+
}
|
|
817
|
+
yield* sql `
|
|
818
|
+
UPDATE flows_runs
|
|
819
|
+
SET
|
|
820
|
+
claim_host_id = ${claimant.hostId},
|
|
821
|
+
claim_pid = ${claimant.pid},
|
|
822
|
+
claim_nonce = ${claimant.nonce},
|
|
823
|
+
claimed_at_ms = ${grant.grantedAtMs}
|
|
824
|
+
WHERE run_id = ${runId}
|
|
825
|
+
`;
|
|
826
|
+
yield* recordTransition(runId, "claimed", claimant, grant.grantedAtMs);
|
|
827
|
+
return claimed(grant.grantedAtMs);
|
|
828
|
+
}));
|
|
829
|
+
}).pipe(observeOutcome((outcome) => RunStoreMetrics.claim[outcome._tag], Metric.withAttributes(RunStoreMetrics.claims, { op: "claim" }))));
|
|
830
|
+
const claimAndOwn = Effect.fn("RunStore.claimAndOwn")((runIdInput, expectedInput, ownerInput, nowMsInput, evidenceInput) => Effect.gen(function* () {
|
|
831
|
+
const runId = yield* snapshotRunId("claimAndOwn", runIdInput);
|
|
832
|
+
const expected = yield* snapshotExpected("claimAndOwn", expectedInput);
|
|
833
|
+
const owner = yield* snapshotOwner("claimAndOwn", "owner", ownerInput);
|
|
834
|
+
const nowMs = yield* snapshotLeaseReading("claimAndOwn", "nowMs", nowMsInput, { runId });
|
|
835
|
+
const evidence = evidenceInput === undefined
|
|
836
|
+
? undefined
|
|
837
|
+
: yield* snapshotEvidence("claimAndOwn", evidenceInput);
|
|
838
|
+
yield* Effect.annotateCurrentSpan({ runId, ownerHostId: owner.hostId });
|
|
839
|
+
return yield* Effect.suspend(() => {
|
|
840
|
+
if (expected.status === "running" &&
|
|
841
|
+
!sameOwner(expected.owner, owner) &&
|
|
842
|
+
!(evidence !== undefined && evidenceMatches(expected, owner, nowMs, evidence))) {
|
|
843
|
+
return read("claimAndOwn", selectRun(sql, runId)).pipe(Effect.map((current) => {
|
|
844
|
+
const row = current[0];
|
|
845
|
+
// Classify the lease before upgrading an unchanged stale
|
|
846
|
+
// snapshot. Evidence cannot displace a fresh heartbeat, so
|
|
847
|
+
// naming evidence first would advise a retry the SQL must refuse.
|
|
848
|
+
const loss = classifyClaimLoss(row, nowMs);
|
|
849
|
+
return loss === snapshotChanged && row !== undefined && rowMatchesSnapshot(row, expected)
|
|
850
|
+
? evidenceRequired
|
|
851
|
+
: loss;
|
|
852
|
+
}));
|
|
853
|
+
}
|
|
854
|
+
return write("claimAndOwn", Effect.gen(function* () {
|
|
855
|
+
const row = (yield* selectRun(sql, runId))[0];
|
|
856
|
+
const admissible = row !== undefined &&
|
|
857
|
+
(row.status === "pending" || row.status === "suspended" || row.status === "running") &&
|
|
858
|
+
rowMatchesSnapshot(row, expected) &&
|
|
859
|
+
claimColumnsFree(row) &&
|
|
860
|
+
(row.status !== "running" ||
|
|
861
|
+
row.heartbeatAtMs === null ||
|
|
862
|
+
row.heartbeatAtMs < nowMs - heartbeatStaleAfterMs);
|
|
863
|
+
if (!admissible)
|
|
864
|
+
return classifyClaimLoss(row, nowMs);
|
|
865
|
+
const expectedOwner = expected.owner;
|
|
866
|
+
let grant;
|
|
867
|
+
let transition = "claimed";
|
|
868
|
+
if (expectedOwner === null) {
|
|
869
|
+
grant = yield* consensus.claim(runId, owner, nowMs);
|
|
870
|
+
}
|
|
871
|
+
else if (sameOwner(expectedOwner, owner)) {
|
|
872
|
+
// Re-owning one's own stale run: the stale lease is released
|
|
873
|
+
// and re-granted under a fresh generation (R4).
|
|
874
|
+
yield* consensus.release(runId, owner);
|
|
875
|
+
yield* recordTransition(runId, "released", owner, null);
|
|
876
|
+
grant = yield* consensus.claim(runId, owner, nowMs);
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
// Admission above proved the evidence matches, so the steal is
|
|
880
|
+
// the strategy's evidence-gated claim.
|
|
881
|
+
grant = yield* consensus.steal(runId, owner, nowMs, evidence);
|
|
882
|
+
transition = "stolen";
|
|
883
|
+
}
|
|
884
|
+
if (grant._tag === "Rejected")
|
|
885
|
+
return classifyClaimLoss(row, nowMs);
|
|
886
|
+
const activation = yield* consensus.activate(runId, owner, grant.grantedAtMs, nowMs);
|
|
887
|
+
if (activation._tag === "Lost") {
|
|
888
|
+
// The strategy revoked the grant it just made — its lease moved
|
|
889
|
+
// on under the transaction — so the fresh grant is released and
|
|
890
|
+
// the loss classified from the row.
|
|
891
|
+
yield* consensus.release(runId, owner);
|
|
892
|
+
return classifyClaimLoss(row, nowMs);
|
|
893
|
+
}
|
|
894
|
+
yield* recordTransition(runId, transition, owner, grant.grantedAtMs);
|
|
895
|
+
yield* recordTransition(runId, "activated", owner, grant.grantedAtMs);
|
|
896
|
+
yield* sql `
|
|
897
|
+
UPDATE flows_runs
|
|
898
|
+
SET
|
|
899
|
+
status = 'running',
|
|
900
|
+
started_at_ms = COALESCE(started_at_ms, ${nowMs}),
|
|
901
|
+
finished_at_ms = NULL,
|
|
902
|
+
owner_host_id = ${owner.hostId},
|
|
903
|
+
owner_pid = ${owner.pid},
|
|
904
|
+
owner_nonce = ${owner.nonce},
|
|
905
|
+
heartbeat_at_ms = ${nowMs}
|
|
906
|
+
WHERE run_id = ${runId}
|
|
907
|
+
`;
|
|
908
|
+
return activated;
|
|
909
|
+
}));
|
|
910
|
+
});
|
|
911
|
+
}).pipe(observeOutcome((outcome) => RunStoreMetrics.claimAndOwn[outcome._tag], Metric.withAttributes(RunStoreMetrics.claims, { op: "claim_and_own" }))));
|
|
912
|
+
const activate = Effect.fn("RunStore.activate")((runIdInput, claimantInput, claimedAtMsInput, expectedInput) => Effect.gen(function* () {
|
|
913
|
+
const runId = yield* snapshotRunId("activate", runIdInput);
|
|
914
|
+
const claimant = yield* snapshotOwner("activate", "claimant", claimantInput);
|
|
915
|
+
const claimedAtMs = yield* snapshotTimestamp("activate", "claimedAtMs", claimedAtMsInput);
|
|
916
|
+
const expected = yield* snapshotExpected("activate", expectedInput);
|
|
917
|
+
yield* Effect.annotateCurrentSpan({ runId, claimantHostId: claimant.hostId });
|
|
918
|
+
const activatedAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
|
|
919
|
+
return yield* write("activate", Effect.gen(function* () {
|
|
920
|
+
const row = (yield* selectRun(sql, runId))[0];
|
|
921
|
+
if (row === undefined || !rowMatchesClaim(row, claimant, claimedAtMs))
|
|
922
|
+
return claimLost;
|
|
923
|
+
const clearClaim = sql `
|
|
924
|
+
UPDATE flows_runs
|
|
925
|
+
SET
|
|
926
|
+
claim_host_id = NULL,
|
|
927
|
+
claim_pid = NULL,
|
|
928
|
+
claim_nonce = NULL,
|
|
929
|
+
claimed_at_ms = NULL
|
|
930
|
+
WHERE run_id = ${runId}
|
|
931
|
+
AND claim_host_id = ${claimant.hostId}
|
|
932
|
+
AND claim_pid = ${claimant.pid}
|
|
933
|
+
AND claim_nonce = ${claimant.nonce}
|
|
934
|
+
AND claimed_at_ms = ${claimedAtMs}
|
|
935
|
+
`;
|
|
936
|
+
if (!rowMatchesSnapshot(row, expected)) {
|
|
937
|
+
// The claim is intact but the guarded snapshot moved on: clear
|
|
938
|
+
// the claim, exactly as the compare-and-swap always did, and
|
|
939
|
+
// release the strategy's matching claim so the two stay aligned.
|
|
940
|
+
yield* consensus.release(runId, claimant);
|
|
941
|
+
yield* recordTransition(runId, "released", claimant, claimedAtMs);
|
|
942
|
+
yield* clearClaim;
|
|
943
|
+
return snapshotChanged;
|
|
944
|
+
}
|
|
945
|
+
const activation = yield* consensus.activate(runId, claimant, claimedAtMs, activatedAtMs);
|
|
946
|
+
if (activation._tag === "Lost") {
|
|
947
|
+
// The strategy no longer holds this claim though the row still
|
|
948
|
+
// shows it; the strategy is authoritative, so the stale claim
|
|
949
|
+
// columns are cleared and the claim reported lost.
|
|
950
|
+
yield* clearClaim;
|
|
951
|
+
return claimLost;
|
|
952
|
+
}
|
|
953
|
+
yield* sql `
|
|
954
|
+
UPDATE flows_runs
|
|
955
|
+
SET
|
|
956
|
+
status = 'running',
|
|
957
|
+
started_at_ms = COALESCE(started_at_ms, ${activatedAtMs}),
|
|
958
|
+
finished_at_ms = NULL,
|
|
959
|
+
owner_host_id = ${claimant.hostId},
|
|
960
|
+
owner_pid = ${claimant.pid},
|
|
961
|
+
owner_nonce = ${claimant.nonce},
|
|
962
|
+
heartbeat_at_ms = ${activatedAtMs},
|
|
963
|
+
claim_host_id = NULL,
|
|
964
|
+
claim_pid = NULL,
|
|
965
|
+
claim_nonce = NULL,
|
|
966
|
+
claimed_at_ms = NULL
|
|
967
|
+
WHERE run_id = ${runId}
|
|
968
|
+
`;
|
|
969
|
+
yield* recordTransition(runId, "activated", claimant, claimedAtMs);
|
|
970
|
+
return activated;
|
|
971
|
+
}));
|
|
972
|
+
}).pipe(observeOutcome((outcome) => RunStoreMetrics.activate[outcome._tag], Metric.withAttributes(RunStoreMetrics.claims, { op: "activate" }))));
|
|
973
|
+
const abandonClaim = Effect.fn("RunStore.abandonClaim")((runIdInput, claimantInput, claimedAtMsInput) => Effect.gen(function* () {
|
|
974
|
+
const runId = yield* snapshotRunId("abandonClaim", runIdInput);
|
|
975
|
+
const claimant = yield* snapshotOwner("abandonClaim", "claimant", claimantInput);
|
|
976
|
+
const claimedAtMs = yield* snapshotTimestamp("abandonClaim", "claimedAtMs", claimedAtMsInput);
|
|
977
|
+
yield* Effect.annotateCurrentSpan({ runId, claimantHostId: claimant.hostId });
|
|
978
|
+
return yield* write("abandonClaim", Effect.gen(function* () {
|
|
979
|
+
const rows = yield* sql `
|
|
980
|
+
UPDATE flows_runs
|
|
981
|
+
SET
|
|
982
|
+
claim_host_id = NULL,
|
|
983
|
+
claim_pid = NULL,
|
|
984
|
+
claim_nonce = NULL,
|
|
985
|
+
claimed_at_ms = NULL
|
|
986
|
+
WHERE run_id = ${runId}
|
|
987
|
+
AND claim_host_id = ${claimant.hostId}
|
|
988
|
+
AND claim_pid = ${claimant.pid}
|
|
989
|
+
AND claim_nonce = ${claimant.nonce}
|
|
990
|
+
AND claimed_at_ms = ${claimedAtMs}
|
|
991
|
+
RETURNING run_id AS "runId"
|
|
992
|
+
`;
|
|
993
|
+
if (rows.length === 0)
|
|
994
|
+
return claimLost;
|
|
995
|
+
yield* consensus.release(runId, claimant);
|
|
996
|
+
yield* recordTransition(runId, "released", claimant, claimedAtMs);
|
|
997
|
+
return abandoned;
|
|
998
|
+
}));
|
|
999
|
+
}).pipe(observeOutcome((outcome) => RunStoreMetrics.abandonClaim[outcome._tag], Metric.withAttributes(RunStoreMetrics.claims, { op: "abandon_claim" }))));
|
|
1000
|
+
const recoverClaim = Effect.fn("RunStore.recoverClaim")((runIdInput, staleClaimantInput, claimedAtMsInput, observerInput, nowMsInput, evidenceInput) => Effect.gen(function* () {
|
|
1001
|
+
const runId = yield* snapshotRunId("recoverClaim", runIdInput);
|
|
1002
|
+
const staleClaimant = yield* snapshotOwner("recoverClaim", "staleClaimant", staleClaimantInput);
|
|
1003
|
+
const claimedAtMs = yield* snapshotTimestamp("recoverClaim", "claimedAtMs", claimedAtMsInput);
|
|
1004
|
+
const observer = yield* snapshotOwner("recoverClaim", "observer", observerInput);
|
|
1005
|
+
const nowMs = yield* snapshotLeaseReading("recoverClaim", "nowMs", nowMsInput, { runId, claimedAtMs });
|
|
1006
|
+
const evidence = yield* snapshotEvidence("recoverClaim", evidenceInput);
|
|
1007
|
+
yield* Effect.annotateCurrentSpan({ runId, observerHostId: observer.hostId });
|
|
1008
|
+
return yield* Effect.suspend(() => {
|
|
1009
|
+
if (!matchesEvidence(staleClaimant, observer, nowMs, evidence)) {
|
|
1010
|
+
return Effect.succeed(livenessUnconfirmed);
|
|
1011
|
+
}
|
|
1012
|
+
return write("recoverClaim", Effect.gen(function* () {
|
|
1013
|
+
const row = (yield* selectRun(sql, runId))[0];
|
|
1014
|
+
if (row === undefined)
|
|
1015
|
+
return notFound;
|
|
1016
|
+
if (!rowMatchesClaim(row, staleClaimant, claimedAtMs))
|
|
1017
|
+
return claimChanged;
|
|
1018
|
+
if (claimedAtMs >= nowMs - heartbeatStaleAfterMs)
|
|
1019
|
+
return claimFresh;
|
|
1020
|
+
const outcome = yield* consensus.recover(runId, staleClaimant, claimedAtMs, observer, nowMs, evidence);
|
|
1021
|
+
if (outcome._tag === "Rejected") {
|
|
1022
|
+
// The strategy refused what the row admitted: as the strategy
|
|
1023
|
+
// knows it, the claim has moved on.
|
|
1024
|
+
return claimChanged;
|
|
1025
|
+
}
|
|
1026
|
+
yield* sql `
|
|
1027
|
+
UPDATE flows_runs
|
|
1028
|
+
SET
|
|
1029
|
+
claim_host_id = NULL,
|
|
1030
|
+
claim_pid = NULL,
|
|
1031
|
+
claim_nonce = NULL,
|
|
1032
|
+
claimed_at_ms = NULL
|
|
1033
|
+
WHERE run_id = ${runId}
|
|
1034
|
+
`;
|
|
1035
|
+
yield* recordTransition(runId, "expired", staleClaimant, claimedAtMs);
|
|
1036
|
+
return recovered;
|
|
1037
|
+
}));
|
|
1038
|
+
});
|
|
1039
|
+
}).pipe(observeOutcome((outcome) => RunStoreMetrics.recoverClaim[outcome._tag], Metric.withAttributes(RunStoreMetrics.claims, { op: "recover_claim" }))));
|
|
1040
|
+
const renewLease = (method) => Effect.fn(`RunStore.${method}`)((runIdInput, ownerInput, nowMsInput) => Effect.gen(function* () {
|
|
1041
|
+
const runId = yield* snapshotRunId(method, runIdInput);
|
|
1042
|
+
const owner = yield* snapshotOwner(method, "owner", ownerInput);
|
|
1043
|
+
const nowMs = yield* snapshotLeaseReading(method, "nowMs", nowMsInput, { runId });
|
|
1044
|
+
yield* Effect.annotateCurrentSpan({ runId, ownerHostId: owner.hostId });
|
|
1045
|
+
return yield* write(method, Effect.gen(function* () {
|
|
1046
|
+
// The pulse drives the strategy's lease renewal; the row mirrors
|
|
1047
|
+
// the stamp the strategy recorded. The lease timestamp is
|
|
1048
|
+
// monotonic: a heartbeat that arrives late — delayed past a newer
|
|
1049
|
+
// one from the same owner — never moves it backwards and never
|
|
1050
|
+
// makes a live run look stale to `claimAndOwn`/`steal`'s cutoff. A
|
|
1051
|
+
// late clock reading still reports `Updated`: the fence held, and
|
|
1052
|
+
// the write proves liveness regardless of which caller clock
|
|
1053
|
+
// reading it carried. Prior art: Temporal's shard `rangeID` only
|
|
1054
|
+
// ever advances (`reference/temporal/service/history/shard/context_impl.go`,
|
|
1055
|
+
// `renewRangeLocked`). Heartbeats never enter the journal.
|
|
1056
|
+
const outcome = yield* consensus[method](runId, owner, nowMs);
|
|
1057
|
+
if (outcome._tag === "Lost") {
|
|
1058
|
+
const current = yield* selectRun(sql, runId);
|
|
1059
|
+
return current.length === 0 ? notFound : fenceLost;
|
|
1060
|
+
}
|
|
1061
|
+
// The mirror write is verified: a renewed lease over a row this
|
|
1062
|
+
// owner no longer holds is a lease/row disagreement, and a success
|
|
1063
|
+
// that wrote nothing would hide it. RETURNING keeps the old row
|
|
1064
|
+
// answer — `FenceLost` when the row moved on, `NotFound` when it
|
|
1065
|
+
// is gone.
|
|
1066
|
+
const rows = yield* sql `
|
|
1067
|
+
UPDATE flows_runs
|
|
1068
|
+
SET heartbeat_at_ms = ${Dialect.greatest(sql)}(heartbeat_at_ms, ${outcome.heartbeatAtMs})
|
|
1069
|
+
WHERE run_id = ${runId}
|
|
1070
|
+
AND status = 'running'
|
|
1071
|
+
AND owner_host_id = ${owner.hostId}
|
|
1072
|
+
AND owner_pid = ${owner.pid}
|
|
1073
|
+
AND owner_nonce = ${owner.nonce}
|
|
1074
|
+
RETURNING run_id AS "runId"
|
|
1075
|
+
`;
|
|
1076
|
+
if (rows.length > 0)
|
|
1077
|
+
return updated;
|
|
1078
|
+
const current = yield* selectRun(sql, runId);
|
|
1079
|
+
return current.length === 0 ? notFound : fenceLost;
|
|
1080
|
+
}));
|
|
1081
|
+
}).pipe(observeOutcome((outcome) => RunStoreMetrics.heartbeat[outcome._tag], RunStoreMetrics.heartbeats)));
|
|
1082
|
+
const heartbeat = renewLease("heartbeat");
|
|
1083
|
+
const reconfirm = renewLease("reconfirm");
|
|
1084
|
+
const transitionOwned = Effect.fn("RunStore.transitionOwned")((runIdInput, ownerInput, toStatusInput, stateInput, guardInput) => Effect.gen(function* () {
|
|
1085
|
+
const runId = yield* snapshotRunId("transitionOwned", runIdInput);
|
|
1086
|
+
const owner = yield* snapshotOwner("transitionOwned", "owner", ownerInput);
|
|
1087
|
+
if (!Schema.is(RunStatus)(toStatusInput) || toStatusInput === "pending") {
|
|
1088
|
+
return yield* Effect.fail(invalidRunError("transitionOwned", "toStatus"));
|
|
1089
|
+
}
|
|
1090
|
+
const toStatus = toStatusInput;
|
|
1091
|
+
const state = stateInput === undefined
|
|
1092
|
+
? null
|
|
1093
|
+
: yield* snapshotState("transitionOwned", stateInput, {
|
|
1094
|
+
runId,
|
|
1095
|
+
stateJsonLength: typeof stateInput === "string" ? stateInput.length : null,
|
|
1096
|
+
stateJsonValid: isJsonString(stateInput)
|
|
1097
|
+
});
|
|
1098
|
+
const guard = yield* snapshotGuard(guardInput);
|
|
1099
|
+
yield* Effect.annotateCurrentSpan({ runId, ownerHostId: owner.hostId, to: toStatus });
|
|
1100
|
+
const transitionedAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
|
|
1101
|
+
const outcome = yield* write("transitionOwned", Effect.gen(function* () {
|
|
1102
|
+
// The fence check is the strategy's guard (R3); the row check keeps
|
|
1103
|
+
// the mirror honest. Both run in the serialized write transaction,
|
|
1104
|
+
// so a concurrent cancellation request or reclaim can never slip
|
|
1105
|
+
// between check and write.
|
|
1106
|
+
const held = yield* consensus.guard(runId, owner).pipe(Effect.as(true), Effect.catch((cause) => cause.code === "fence_lost" ? Effect.succeed(false) : Effect.fail(cause)));
|
|
1107
|
+
const row = (yield* selectRun(sql, runId))[0];
|
|
1108
|
+
if (row === undefined)
|
|
1109
|
+
return notFound;
|
|
1110
|
+
if (!held || !rowOwnedBy(row, owner))
|
|
1111
|
+
return fenceLost;
|
|
1112
|
+
const guardBlocked = (guard?.cancelRequested === "absent" && row.cancelRequestedAtMs !== null) ||
|
|
1113
|
+
(guard?.cancelRequested === "present" && row.cancelRequestedAtMs === null);
|
|
1114
|
+
if (guardBlocked)
|
|
1115
|
+
return guardFailed;
|
|
1116
|
+
if (toStatus === "running") {
|
|
1117
|
+
yield* sql `
|
|
1118
|
+
UPDATE flows_runs
|
|
1119
|
+
SET
|
|
1120
|
+
status = 'running',
|
|
1121
|
+
finished_at_ms = NULL,
|
|
1122
|
+
state_json = COALESCE(${state}, state_json)
|
|
1123
|
+
WHERE run_id = ${runId}
|
|
1124
|
+
`;
|
|
1125
|
+
return transitioned;
|
|
1126
|
+
}
|
|
1127
|
+
yield* sql `
|
|
1128
|
+
UPDATE flows_runs
|
|
1129
|
+
SET
|
|
1130
|
+
status = ${toStatus},
|
|
1131
|
+
finished_at_ms = ${terminalStatuses.has(toStatus) ? transitionedAtMs : null},
|
|
1132
|
+
owner_host_id = NULL,
|
|
1133
|
+
owner_pid = NULL,
|
|
1134
|
+
owner_nonce = NULL,
|
|
1135
|
+
heartbeat_at_ms = NULL,
|
|
1136
|
+
claim_host_id = NULL,
|
|
1137
|
+
claim_pid = NULL,
|
|
1138
|
+
claim_nonce = NULL,
|
|
1139
|
+
claimed_at_ms = NULL,
|
|
1140
|
+
state_json = COALESCE(${state}, state_json)
|
|
1141
|
+
WHERE run_id = ${runId}
|
|
1142
|
+
`;
|
|
1143
|
+
yield* consensus.release(runId, owner);
|
|
1144
|
+
yield* recordTransition(runId, "released", owner, null);
|
|
1145
|
+
if (terminalStatuses.has(toStatus)) {
|
|
1146
|
+
yield* afterCommit(Metric.update(ObservabilityMetric.runThroughput, 1), sql);
|
|
1147
|
+
}
|
|
1148
|
+
return transitioned;
|
|
1149
|
+
}));
|
|
1150
|
+
return outcome;
|
|
1151
|
+
}).pipe(observeOutcome((outcome) => Metric.withAttributes(RunStoreMetrics.transition[outcome._tag], {
|
|
1152
|
+
to: toStatusInput
|
|
1153
|
+
}), Metric.withAttributes(RunStoreMetrics.transitions, { to: toStatusInput }))));
|
|
1154
|
+
const steal = Effect.fn("RunStore.steal")((runIdInput, expectedInput, claimantInput, nowMsInput, evidenceInput) => Effect.gen(function* () {
|
|
1155
|
+
const runId = yield* snapshotRunId("steal", runIdInput);
|
|
1156
|
+
const expected = yield* snapshotExpected("steal", expectedInput);
|
|
1157
|
+
const claimant = yield* snapshotOwner("steal", "claimant", claimantInput);
|
|
1158
|
+
const nowMs = yield* snapshotLeaseReading("steal", "nowMs", nowMsInput, { runId });
|
|
1159
|
+
const evidence = yield* snapshotEvidence("steal", evidenceInput);
|
|
1160
|
+
yield* Effect.annotateCurrentSpan({ runId, claimantHostId: claimant.hostId });
|
|
1161
|
+
return yield* Effect.suspend(() => {
|
|
1162
|
+
if (!evidenceMatches(expected, claimant, nowMs, evidence)) {
|
|
1163
|
+
return Effect.succeed(livenessUnconfirmed);
|
|
1164
|
+
}
|
|
1165
|
+
return write("steal", Effect.gen(function* () {
|
|
1166
|
+
const row = (yield* selectRun(sql, runId))[0];
|
|
1167
|
+
const admissible = row !== undefined &&
|
|
1168
|
+
rowMatchesSnapshot(row, expected) &&
|
|
1169
|
+
claimColumnsFree(row) &&
|
|
1170
|
+
row.heartbeatAtMs !== null &&
|
|
1171
|
+
row.heartbeatAtMs < nowMs - heartbeatStaleAfterMs;
|
|
1172
|
+
if (!admissible)
|
|
1173
|
+
return classifyClaimLoss(row, nowMs);
|
|
1174
|
+
const grant = yield* consensus.steal(runId, claimant, nowMs, evidence);
|
|
1175
|
+
if (grant._tag === "Rejected") {
|
|
1176
|
+
// The strategy refused what the row admitted — for example its
|
|
1177
|
+
// lease still records a fresh heartbeat — and is authoritative.
|
|
1178
|
+
return classifyClaimLoss(row, nowMs);
|
|
1179
|
+
}
|
|
1180
|
+
yield* sql `
|
|
1181
|
+
UPDATE flows_runs
|
|
1182
|
+
SET
|
|
1183
|
+
claim_host_id = ${claimant.hostId},
|
|
1184
|
+
claim_pid = ${claimant.pid},
|
|
1185
|
+
claim_nonce = ${claimant.nonce},
|
|
1186
|
+
claimed_at_ms = ${grant.grantedAtMs}
|
|
1187
|
+
WHERE run_id = ${runId}
|
|
1188
|
+
`;
|
|
1189
|
+
yield* recordTransition(runId, "stolen", claimant, grant.grantedAtMs);
|
|
1190
|
+
return claimed(grant.grantedAtMs);
|
|
1191
|
+
}));
|
|
1192
|
+
});
|
|
1193
|
+
}).pipe(observeOutcome((outcome) => RunStoreMetrics.steal[outcome._tag], Metric.withAttributes(RunStoreMetrics.claims, { op: "steal" }))));
|
|
1194
|
+
const acknowledgeCancel = Effect.fn("RunStore.acknowledgeCancel")((id, ownerInput, time) => Effect.gen(function* () {
|
|
1195
|
+
const runId = yield* snapshotRunId("acknowledgeCancel", id);
|
|
1196
|
+
const owner = yield* snapshotOwner("acknowledgeCancel", "owner", ownerInput);
|
|
1197
|
+
const observedAtMs = yield* snapshotTimestamp("acknowledgeCancel", "nowMs", time);
|
|
1198
|
+
yield* Effect.annotateCurrentSpan({ runId, ownerHostId: owner.hostId });
|
|
1199
|
+
const acknowledgement = JSON.stringify({ observedAtMs, owner });
|
|
1200
|
+
return yield* write("acknowledgeCancel", sql `
|
|
1201
|
+
UPDATE flows_runs
|
|
1202
|
+
SET cancel_acknowledgement_json = COALESCE(cancel_acknowledgement_json, ${acknowledgement})
|
|
1203
|
+
WHERE run_id = ${runId} AND status = 'running' AND cancel_requested_at_ms IS NOT NULL
|
|
1204
|
+
AND owner_host_id = ${owner.hostId} AND owner_pid = ${owner.pid} AND owner_nonce = ${owner.nonce}
|
|
1205
|
+
RETURNING run_id
|
|
1206
|
+
`.pipe(Effect.map((rows) => rows.length > 0)));
|
|
1207
|
+
}).pipe(observeExit));
|
|
1208
|
+
return RunStore.of({
|
|
1209
|
+
create,
|
|
1210
|
+
get,
|
|
1211
|
+
lineage,
|
|
1212
|
+
latestRound,
|
|
1213
|
+
requestCancel,
|
|
1214
|
+
acknowledgeCancel,
|
|
1215
|
+
requestCancelLineage,
|
|
1216
|
+
claim,
|
|
1217
|
+
claimAndOwn,
|
|
1218
|
+
activate,
|
|
1219
|
+
abandonClaim,
|
|
1220
|
+
recoverClaim,
|
|
1221
|
+
heartbeat,
|
|
1222
|
+
reconfirm,
|
|
1223
|
+
transitionOwned,
|
|
1224
|
+
steal
|
|
1225
|
+
});
|
|
1226
|
+
});
|
|
1227
|
+
/**
|
|
1228
|
+
* Constructs a stub `RunStore` whose direct operations fail and whose
|
|
1229
|
+
* compare-and-swap operations report typed losses until overridden.
|
|
1230
|
+
*
|
|
1231
|
+
* @since 0.1.0
|
|
1232
|
+
* @category constructors
|
|
1233
|
+
*/
|
|
1234
|
+
export const makeNoop = (overrides = {}) => {
|
|
1235
|
+
const unavailable = (method) => Effect.fail(runStoreError(method, "persistence_failed", "no run store in this environment", method));
|
|
1236
|
+
return RunStore.of({
|
|
1237
|
+
create: Effect.fn("RunStore.create")(() => unavailable("create")),
|
|
1238
|
+
get: Effect.fn("RunStore.get")(() => unavailable("get")),
|
|
1239
|
+
lineage: Effect.fn("RunStore.lineage")(() => unavailable("lineage")),
|
|
1240
|
+
latestRound: Effect.fn("RunStore.latestRound")(() => unavailable("latestRound")),
|
|
1241
|
+
requestCancel: Effect.fn("RunStore.requestCancel")(() => Effect.succeed(notFound)),
|
|
1242
|
+
acknowledgeCancel: Effect.fn("RunStore.acknowledgeCancel")(() => Effect.succeed(false)),
|
|
1243
|
+
requestCancelLineage: Effect.fn("RunStore.requestCancelLineage")(() => unavailable("requestCancelLineage")),
|
|
1244
|
+
claim: Effect.fn("RunStore.claim")(() => Effect.succeed(notFound)),
|
|
1245
|
+
claimAndOwn: Effect.fn("RunStore.claimAndOwn")(() => Effect.succeed(notFound)),
|
|
1246
|
+
activate: Effect.fn("RunStore.activate")(() => Effect.succeed(claimLost)),
|
|
1247
|
+
abandonClaim: Effect.fn("RunStore.abandonClaim")(() => Effect.succeed(claimLost)),
|
|
1248
|
+
recoverClaim: Effect.fn("RunStore.recoverClaim")(() => Effect.succeed(notFound)),
|
|
1249
|
+
heartbeat: Effect.fn("RunStore.heartbeat")(() => Effect.succeed(notFound)),
|
|
1250
|
+
reconfirm: Effect.fn("RunStore.reconfirm")(() => Effect.succeed(notFound)),
|
|
1251
|
+
transitionOwned: Effect.fn("RunStore.transitionOwned")(() => Effect.succeed(notFound)),
|
|
1252
|
+
steal: Effect.fn("RunStore.steal")(() => Effect.succeed(notFound)),
|
|
1253
|
+
...overrides
|
|
1254
|
+
});
|
|
1255
|
+
};
|
|
1256
|
+
/**
|
|
1257
|
+
* Provides a stub `RunStore`.
|
|
1258
|
+
*
|
|
1259
|
+
* @since 0.1.0
|
|
1260
|
+
* @category layers
|
|
1261
|
+
*/
|
|
1262
|
+
export const layerNoop = (overrides = {}) => Layer.succeed(RunStore)(makeNoop(overrides));
|
|
1263
|
+
/**
|
|
1264
|
+
* Provides the database-backed `RunStore` over an explicitly injected
|
|
1265
|
+
* consensus strategy.
|
|
1266
|
+
*
|
|
1267
|
+
* Arbitration — claims, activation, steals, heartbeats, and fence checks — is
|
|
1268
|
+
* delegated to the `Consensus` service in context; the run row mirrors the
|
|
1269
|
+
* outcome in the same transaction. Provide the SAME strategy instance the
|
|
1270
|
+
* journal fences through (`SqlJournal.layerWith`), because
|
|
1271
|
+
* `Consensus.layerLocal` keeps its leases per layer build.
|
|
1272
|
+
*
|
|
1273
|
+
* @since 1.0.0
|
|
1274
|
+
* @category layers
|
|
1275
|
+
*/
|
|
1276
|
+
export const layerWith = Layer.effect(RunStore, make);
|
|
1277
|
+
/**
|
|
1278
|
+
* Provides the database-backed `RunStore` arbitrated by the database-backed
|
|
1279
|
+
* strategy, `SqlConsensus`, over the same client — the strategy
|
|
1280
|
+
* `SqlJournal.layer` fences through. Use {@link layerWith} to inject a
|
|
1281
|
+
* different strategy.
|
|
1282
|
+
*
|
|
1283
|
+
* @since 0.1.0
|
|
1284
|
+
* @category layers
|
|
1285
|
+
*/
|
|
1286
|
+
export const layer = layerWith.pipe(Layer.provide(SqlConsensus.layer));
|
|
1287
|
+
//# sourceMappingURL=RunStore.js.map
|