@smthrs/journal 0.0.0-stage → 1.0.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +149 -0
- package/LICENSE +21 -0
- package/README.md +206 -2
- package/dist/cjs/CallFact.d.ts +129 -0
- package/dist/cjs/CallFact.d.ts.map +1 -0
- package/dist/cjs/CallFact.js +106 -0
- package/dist/cjs/CallFact.js.map +7 -0
- package/dist/cjs/Consensus.d.ts +440 -0
- package/dist/cjs/Consensus.d.ts.map +1 -0
- package/dist/cjs/Consensus.js +251 -0
- package/dist/cjs/Consensus.js.map +7 -0
- package/dist/cjs/EngineEvent.d.ts +890 -0
- package/dist/cjs/EngineEvent.d.ts.map +1 -0
- package/dist/cjs/EngineEvent.js +383 -0
- package/dist/cjs/EngineEvent.js.map +7 -0
- package/dist/cjs/ExecutionFact.d.ts +216 -0
- package/dist/cjs/ExecutionFact.d.ts.map +1 -0
- package/dist/cjs/ExecutionFact.js +303 -0
- package/dist/cjs/ExecutionFact.js.map +7 -0
- package/dist/cjs/Journal.d.ts +617 -0
- package/dist/cjs/Journal.d.ts.map +1 -0
- package/dist/cjs/Journal.js +190 -0
- package/dist/cjs/Journal.js.map +7 -0
- package/dist/cjs/JournalEvent.d.ts +421 -0
- package/dist/cjs/JournalEvent.d.ts.map +1 -0
- package/dist/cjs/JournalEvent.js +281 -0
- package/dist/cjs/JournalEvent.js.map +7 -0
- package/dist/cjs/JournalGeneration.d.ts +34 -0
- package/dist/cjs/JournalGeneration.d.ts.map +1 -0
- package/dist/cjs/JournalGeneration.js +72 -0
- package/dist/cjs/JournalGeneration.js.map +7 -0
- package/dist/cjs/JournalMetrics.d.ts +72 -0
- package/dist/cjs/JournalMetrics.d.ts.map +1 -0
- package/dist/cjs/JournalMetrics.js +55 -0
- package/dist/cjs/JournalMetrics.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 +58 -0
- package/dist/cjs/Migrations.js.map +7 -0
- package/dist/cjs/OwnerId.d.ts +40 -0
- package/dist/cjs/OwnerId.d.ts.map +1 -0
- package/dist/cjs/OwnerId.js +40 -0
- package/dist/cjs/OwnerId.js.map +7 -0
- package/dist/cjs/Projection.d.ts +29 -0
- package/dist/cjs/Projection.d.ts.map +1 -0
- package/dist/cjs/Projection.js +25 -0
- package/dist/cjs/Projection.js.map +7 -0
- package/dist/cjs/RedactedLogger.d.ts +121 -0
- package/dist/cjs/RedactedLogger.d.ts.map +1 -0
- package/dist/cjs/RedactedLogger.js +235 -0
- package/dist/cjs/RedactedLogger.js.map +7 -0
- package/dist/cjs/Redaction.d.ts +350 -0
- package/dist/cjs/Redaction.d.ts.map +1 -0
- package/dist/cjs/Redaction.js +928 -0
- package/dist/cjs/Redaction.js.map +7 -0
- package/dist/cjs/SqlConsensus.d.ts +44 -0
- package/dist/cjs/SqlConsensus.d.ts.map +1 -0
- package/dist/cjs/SqlConsensus.js +243 -0
- package/dist/cjs/SqlConsensus.js.map +7 -0
- package/dist/cjs/SqlJournal.d.ts +173 -0
- package/dist/cjs/SqlJournal.d.ts.map +1 -0
- package/dist/cjs/SqlJournal.js +1568 -0
- package/dist/cjs/SqlJournal.js.map +7 -0
- package/dist/cjs/StepFact.d.ts +66 -0
- package/dist/cjs/StepFact.d.ts.map +1 -0
- package/dist/cjs/StepFact.js +68 -0
- package/dist/cjs/StepFact.js.map +7 -0
- package/dist/cjs/index.d.ts +95 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +63 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/JournalInternalFault.d.ts +37 -0
- package/dist/cjs/internal/JournalInternalFault.d.ts.map +1 -0
- package/dist/cjs/internal/JournalInternalFault.js +43 -0
- package/dist/cjs/internal/JournalInternalFault.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 +54 -0
- package/dist/cjs/migrations/0001_initial.js.map +7 -0
- package/dist/cjs/migrations/0002_checkpoints.d.ts +27 -0
- package/dist/cjs/migrations/0002_checkpoints.d.ts.map +1 -0
- package/dist/cjs/migrations/0002_checkpoints.js +48 -0
- package/dist/cjs/migrations/0002_checkpoints.js.map +7 -0
- package/dist/cjs/migrations/0003_startup_index.d.ts +15 -0
- package/dist/cjs/migrations/0003_startup_index.d.ts.map +1 -0
- package/dist/cjs/migrations/0003_startup_index.js +40 -0
- package/dist/cjs/migrations/0003_startup_index.js.map +7 -0
- package/dist/cjs/migrations/0004_dedup.d.ts +15 -0
- package/dist/cjs/migrations/0004_dedup.d.ts.map +1 -0
- package/dist/cjs/migrations/0004_dedup.js +57 -0
- package/dist/cjs/migrations/0004_dedup.js.map +7 -0
- package/dist/cjs/migrations/0005_run_event_type.d.ts +15 -0
- package/dist/cjs/migrations/0005_run_event_type.d.ts.map +1 -0
- package/dist/cjs/migrations/0005_run_event_type.js +40 -0
- package/dist/cjs/migrations/0005_run_event_type.js.map +7 -0
- package/dist/cjs/migrations/0006_consensus.d.ts +22 -0
- package/dist/cjs/migrations/0006_consensus.d.ts.map +1 -0
- package/dist/cjs/migrations/0006_consensus.js +84 -0
- package/dist/cjs/migrations/0006_consensus.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/test/Notifying.d.ts +65 -0
- package/dist/cjs/test/Notifying.d.ts.map +1 -0
- package/dist/cjs/test/Notifying.js +62 -0
- package/dist/cjs/test/Notifying.js.map +7 -0
- package/dist/cjs/test/TestJournal.d.ts +43 -0
- package/dist/cjs/test/TestJournal.d.ts.map +1 -0
- package/dist/cjs/test/TestJournal.js +43 -0
- package/dist/cjs/test/TestJournal.js.map +7 -0
- package/dist/esm/CallFact.d.ts +129 -0
- package/dist/esm/CallFact.d.ts.map +1 -0
- package/dist/esm/CallFact.js +96 -0
- package/dist/esm/CallFact.js.map +1 -0
- package/dist/esm/Consensus.d.ts +440 -0
- package/dist/esm/Consensus.d.ts.map +1 -0
- package/dist/esm/Consensus.js +454 -0
- package/dist/esm/Consensus.js.map +1 -0
- package/dist/esm/EngineEvent.d.ts +890 -0
- package/dist/esm/EngineEvent.d.ts.map +1 -0
- package/dist/esm/EngineEvent.js +614 -0
- package/dist/esm/EngineEvent.js.map +1 -0
- package/dist/esm/ExecutionFact.d.ts +216 -0
- package/dist/esm/ExecutionFact.d.ts.map +1 -0
- package/dist/esm/ExecutionFact.js +374 -0
- package/dist/esm/ExecutionFact.js.map +1 -0
- package/dist/esm/Journal.d.ts +617 -0
- package/dist/esm/Journal.d.ts.map +1 -0
- package/dist/esm/Journal.js +323 -0
- package/dist/esm/Journal.js.map +1 -0
- package/dist/esm/JournalEvent.d.ts +421 -0
- package/dist/esm/JournalEvent.d.ts.map +1 -0
- package/dist/esm/JournalEvent.js +517 -0
- package/dist/esm/JournalEvent.js.map +1 -0
- package/dist/esm/JournalGeneration.d.ts +34 -0
- package/dist/esm/JournalGeneration.d.ts.map +1 -0
- package/dist/esm/JournalGeneration.js +65 -0
- package/dist/esm/JournalGeneration.js.map +1 -0
- package/dist/esm/JournalMetrics.d.ts +72 -0
- package/dist/esm/JournalMetrics.d.ts.map +1 -0
- package/dist/esm/JournalMetrics.js +79 -0
- package/dist/esm/JournalMetrics.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 +59 -0
- package/dist/esm/Migrations.js.map +1 -0
- package/dist/esm/OwnerId.d.ts +40 -0
- package/dist/esm/OwnerId.d.ts.map +1 -0
- package/dist/esm/OwnerId.js +33 -0
- package/dist/esm/OwnerId.js.map +1 -0
- package/dist/esm/Projection.d.ts +29 -0
- package/dist/esm/Projection.d.ts.map +1 -0
- package/dist/esm/Projection.js +13 -0
- package/dist/esm/Projection.js.map +1 -0
- package/dist/esm/RedactedLogger.d.ts +121 -0
- package/dist/esm/RedactedLogger.d.ts.map +1 -0
- package/dist/esm/RedactedLogger.js +445 -0
- package/dist/esm/RedactedLogger.js.map +1 -0
- package/dist/esm/Redaction.d.ts +350 -0
- package/dist/esm/Redaction.d.ts.map +1 -0
- package/dist/esm/Redaction.js +1584 -0
- package/dist/esm/Redaction.js.map +1 -0
- package/dist/esm/SqlConsensus.d.ts +44 -0
- package/dist/esm/SqlConsensus.d.ts.map +1 -0
- package/dist/esm/SqlConsensus.js +252 -0
- package/dist/esm/SqlConsensus.js.map +1 -0
- package/dist/esm/SqlJournal.d.ts +173 -0
- package/dist/esm/SqlJournal.d.ts.map +1 -0
- package/dist/esm/SqlJournal.js +1858 -0
- package/dist/esm/SqlJournal.js.map +1 -0
- package/dist/esm/StepFact.d.ts +66 -0
- package/dist/esm/StepFact.d.ts.map +1 -0
- package/dist/esm/StepFact.js +50 -0
- package/dist/esm/StepFact.js.map +1 -0
- package/dist/esm/index.d.ts +95 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +95 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/JournalInternalFault.d.ts +37 -0
- package/dist/esm/internal/JournalInternalFault.d.ts.map +1 -0
- package/dist/esm/internal/JournalInternalFault.js +28 -0
- package/dist/esm/internal/JournalInternalFault.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 +32 -0
- package/dist/esm/migrations/0001_initial.js.map +1 -0
- package/dist/esm/migrations/0002_checkpoints.d.ts +27 -0
- package/dist/esm/migrations/0002_checkpoints.d.ts.map +1 -0
- package/dist/esm/migrations/0002_checkpoints.js +38 -0
- package/dist/esm/migrations/0002_checkpoints.js.map +1 -0
- package/dist/esm/migrations/0003_startup_index.d.ts +15 -0
- package/dist/esm/migrations/0003_startup_index.d.ts.map +1 -0
- package/dist/esm/migrations/0003_startup_index.js +18 -0
- package/dist/esm/migrations/0003_startup_index.js.map +1 -0
- package/dist/esm/migrations/0004_dedup.d.ts +15 -0
- package/dist/esm/migrations/0004_dedup.d.ts.map +1 -0
- package/dist/esm/migrations/0004_dedup.js +38 -0
- package/dist/esm/migrations/0004_dedup.js.map +1 -0
- package/dist/esm/migrations/0005_run_event_type.d.ts +15 -0
- package/dist/esm/migrations/0005_run_event_type.d.ts.map +1 -0
- package/dist/esm/migrations/0005_run_event_type.js +18 -0
- package/dist/esm/migrations/0005_run_event_type.js.map +1 -0
- package/dist/esm/migrations/0006_consensus.d.ts +22 -0
- package/dist/esm/migrations/0006_consensus.d.ts.map +1 -0
- package/dist/esm/migrations/0006_consensus.js +69 -0
- package/dist/esm/migrations/0006_consensus.js.map +1 -0
- package/dist/esm/test/Notifying.d.ts +65 -0
- package/dist/esm/test/Notifying.d.ts.map +1 -0
- package/dist/esm/test/Notifying.js +62 -0
- package/dist/esm/test/Notifying.js.map +1 -0
- package/dist/esm/test/TestJournal.d.ts +43 -0
- package/dist/esm/test/TestJournal.d.ts.map +1 -0
- package/dist/esm/test/TestJournal.js +27 -0
- package/dist/esm/test/TestJournal.js.map +1 -0
- package/docs/README.md +164 -0
- package/docs/api.md +639 -0
- package/docs/concepts/compaction.md +157 -0
- package/docs/concepts/idempotency.md +127 -0
- package/docs/concepts/owner-fence.md +124 -0
- package/docs/concepts/redaction.md +218 -0
- package/docs/concepts/state-event-authority.md +159 -0
- package/docs/concepts/two-channels.md +167 -0
- package/docs/guides/commit-state-and-entry.md +114 -0
- package/docs/guides/compact-a-run.md +134 -0
- package/docs/guides/fold-a-projection.md +106 -0
- package/docs/guides/read-a-run.md +139 -0
- package/docs/guides/redact-log-output.md +94 -0
- package/docs/guides/testing.md +124 -0
- package/docs/guides/write-lifecycle-events.md +110 -0
- package/docs/installation.md +133 -0
- package/docs/quickstart.md +143 -0
- package/docs/troubleshooting.md +214 -0
- package/package.json +263 -3
- package/src/CallFact.ts +117 -0
- package/src/Consensus.ts +683 -0
- package/src/EngineEvent.ts +823 -0
- package/src/ExecutionFact.ts +433 -0
- package/src/Journal.ts +688 -0
- package/src/JournalEvent.ts +654 -0
- package/src/JournalGeneration.ts +77 -0
- package/src/JournalMetrics.ts +89 -0
- package/src/Migrations.ts +62 -0
- package/src/OwnerId.ts +42 -0
- package/src/Projection.ts +31 -0
- package/src/RedactedLogger.ts +483 -0
- package/src/Redaction.ts +1784 -0
- package/src/SqlConsensus.ts +344 -0
- package/src/SqlJournal.ts +2732 -0
- package/src/StepFact.ts +69 -0
- package/src/index.ts +109 -0
- package/src/internal/JournalInternalFault.ts +40 -0
- package/src/migrations/0001_initial.ts +41 -0
- package/src/migrations/0002_checkpoints.ts +46 -0
- package/src/migrations/0003_startup_index.ts +19 -0
- package/src/migrations/0004_dedup.ts +43 -0
- package/src/migrations/0005_run_event_type.ts +19 -0
- package/src/migrations/0006_consensus.ts +83 -0
- package/src/test/Notifying.ts +101 -0
- package/src/test/TestJournal.ts +52 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default database-backed consensus strategy.
|
|
3
|
+
*
|
|
4
|
+
* This is the run store's former `flows_runs` ownership compare-and-swap
|
|
5
|
+
* relocated: the owner tuple, the two-phase claim columns, claim recovery, and
|
|
6
|
+
* the grant timestamp are the private schema of one strategy — the
|
|
7
|
+
* `flows_consensus_leases` table — instead of a public contract on the run
|
|
8
|
+
* row. The run row still mirrors the outcome, but the lease is what
|
|
9
|
+
* arbitrates.
|
|
10
|
+
*
|
|
11
|
+
* Commit-time admission (R3) is exact: `guard` is a plain SELECT that joins
|
|
12
|
+
* the caller's open write transaction — `DurableWriter` serializes write
|
|
13
|
+
* transactions, so no reclaim can commit between the guard read and the
|
|
14
|
+
* statements beside it — and every mutating operation runs through
|
|
15
|
+
* `DurableWriter.write`, joining an enclosing transaction as a savepoint.
|
|
16
|
+
* Prior art: Temporal's shard `rangeID` check
|
|
17
|
+
* (`reference/temporal/service/history/shard/context_impl.go`,
|
|
18
|
+
* `renewRangeLocked`).
|
|
19
|
+
*
|
|
20
|
+
* Governing design: `packages/smithers/flows/journal/docs/concepts/owner-fence.md`.
|
|
21
|
+
*
|
|
22
|
+
* @since 1.0.0
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as Dialect from "@smthrs/database/Dialect"
|
|
26
|
+
import { DurableWriter } from "@smthrs/database/DurableWriter"
|
|
27
|
+
import * as Duration from "effect/Duration"
|
|
28
|
+
import * as Effect from "effect/Effect"
|
|
29
|
+
import * as Layer from "effect/Layer"
|
|
30
|
+
import * as SqlClient from "effect/unstable/sql/SqlClient"
|
|
31
|
+
import {
|
|
32
|
+
type ActivateOutcome,
|
|
33
|
+
type Claimed,
|
|
34
|
+
type ClaimOutcome,
|
|
35
|
+
Consensus,
|
|
36
|
+
ConsensusError,
|
|
37
|
+
fenceLost,
|
|
38
|
+
type HeartbeatOutcome,
|
|
39
|
+
heartbeatStaleAfter,
|
|
40
|
+
type LivenessEvidence,
|
|
41
|
+
matchesEvidence,
|
|
42
|
+
type RecoverOutcome,
|
|
43
|
+
type Rejected,
|
|
44
|
+
type RejectionReason,
|
|
45
|
+
type Service
|
|
46
|
+
} from "./Consensus.ts"
|
|
47
|
+
import type { OwnerId } from "./OwnerId.ts"
|
|
48
|
+
|
|
49
|
+
const staleAfterMs = Duration.toMillis(heartbeatStaleAfter)
|
|
50
|
+
|
|
51
|
+
const rejected = (reason: RejectionReason): Rejected => ({ _tag: "Rejected", reason })
|
|
52
|
+
const claimed = (grantedAtMs: number): Claimed => ({ _tag: "Claimed", grantedAtMs })
|
|
53
|
+
const lost = { _tag: "Lost" } as const
|
|
54
|
+
const activated = { _tag: "Activated" } as const
|
|
55
|
+
const recovered = { _tag: "Recovered" } as const
|
|
56
|
+
|
|
57
|
+
interface LeaseRow {
|
|
58
|
+
readonly owner_host_id: string | null
|
|
59
|
+
readonly owner_pid: number | null
|
|
60
|
+
readonly owner_nonce: string | null
|
|
61
|
+
readonly claim_host_id: string | null
|
|
62
|
+
readonly claim_pid: number | null
|
|
63
|
+
readonly claim_nonce: string | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Constructs the database-backed strategy over the context's SQL client and
|
|
68
|
+
* durable writer.
|
|
69
|
+
*
|
|
70
|
+
* @since 1.0.0
|
|
71
|
+
* @category constructors
|
|
72
|
+
*/
|
|
73
|
+
export const make: Effect.Effect<Service, never, DurableWriter | SqlClient.SqlClient> = Effect.gen(function*() {
|
|
74
|
+
const sql = yield* Effect.service(SqlClient.SqlClient)
|
|
75
|
+
const writer = yield* DurableWriter
|
|
76
|
+
|
|
77
|
+
const strategyError = (cause: unknown): ConsensusError =>
|
|
78
|
+
new ConsensusError({
|
|
79
|
+
code: "persistence_failed",
|
|
80
|
+
message: "consensus lease operation failed",
|
|
81
|
+
cause
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// The original cause is preserved through the wrapper so an enclosing
|
|
85
|
+
// `DurableWriter.write`'s retry classification still sees a transient
|
|
86
|
+
// conflict raised by a savepoint-nested lease statement.
|
|
87
|
+
const write = <A>(
|
|
88
|
+
body: Effect.Effect<A, unknown>
|
|
89
|
+
): Effect.Effect<A, ConsensusError> =>
|
|
90
|
+
writer.write(body).pipe(Effect.mapError((cause) => cause instanceof ConsensusError ? cause : strategyError(cause)))
|
|
91
|
+
|
|
92
|
+
const leaseOf = (runId: string) =>
|
|
93
|
+
sql<LeaseRow>`
|
|
94
|
+
SELECT owner_host_id, owner_pid, owner_nonce, claim_host_id, claim_pid, claim_nonce
|
|
95
|
+
FROM flows_consensus_leases
|
|
96
|
+
WHERE run_id = ${runId}
|
|
97
|
+
`
|
|
98
|
+
|
|
99
|
+
const prune = (runId: string) =>
|
|
100
|
+
sql`
|
|
101
|
+
DELETE FROM flows_consensus_leases
|
|
102
|
+
WHERE run_id = ${runId} AND owner_host_id IS NULL AND claim_host_id IS NULL
|
|
103
|
+
`
|
|
104
|
+
|
|
105
|
+
const claim: Service["claim"] = Effect.fn("Consensus.claim")((
|
|
106
|
+
runId: string,
|
|
107
|
+
claimant: OwnerId,
|
|
108
|
+
nowMs: number
|
|
109
|
+
) =>
|
|
110
|
+
write(Effect.gen(function*() {
|
|
111
|
+
const rows = yield* sql<{ readonly run_id: string }>`
|
|
112
|
+
INSERT INTO flows_consensus_leases (run_id, claim_host_id, claim_pid, claim_nonce, claimed_at_ms)
|
|
113
|
+
VALUES (${runId}, ${claimant.hostId}, ${claimant.pid}, ${claimant.nonce}, ${nowMs})
|
|
114
|
+
ON CONFLICT (run_id) DO UPDATE SET
|
|
115
|
+
claim_host_id = excluded.claim_host_id,
|
|
116
|
+
claim_pid = excluded.claim_pid,
|
|
117
|
+
claim_nonce = excluded.claim_nonce,
|
|
118
|
+
claimed_at_ms = excluded.claimed_at_ms
|
|
119
|
+
WHERE flows_consensus_leases.claim_host_id IS NULL
|
|
120
|
+
AND flows_consensus_leases.owner_host_id IS NULL
|
|
121
|
+
RETURNING run_id
|
|
122
|
+
`
|
|
123
|
+
if (rows.length > 0) return claimed(nowMs)
|
|
124
|
+
// The upsert only misses on a conflicting row, so the lease exists and
|
|
125
|
+
// holds a claim, an owner, or both.
|
|
126
|
+
const lease = (yield* leaseOf(runId))[0]!
|
|
127
|
+
return lease.claim_host_id !== null ? rejected("already_claimed") : rejected("owner_held")
|
|
128
|
+
}))
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const activate: Service["activate"] = Effect.fn("Consensus.activate")((
|
|
132
|
+
runId: string,
|
|
133
|
+
owner: OwnerId,
|
|
134
|
+
grantedAtMs: number,
|
|
135
|
+
nowMs: number
|
|
136
|
+
) =>
|
|
137
|
+
write(Effect.gen(function*() {
|
|
138
|
+
const rows = yield* sql<{ readonly run_id: string }>`
|
|
139
|
+
UPDATE flows_consensus_leases
|
|
140
|
+
SET
|
|
141
|
+
owner_host_id = ${owner.hostId},
|
|
142
|
+
owner_pid = ${owner.pid},
|
|
143
|
+
owner_nonce = ${owner.nonce},
|
|
144
|
+
granted_at_ms = ${grantedAtMs},
|
|
145
|
+
heartbeat_at_ms = ${nowMs},
|
|
146
|
+
claim_host_id = NULL,
|
|
147
|
+
claim_pid = NULL,
|
|
148
|
+
claim_nonce = NULL,
|
|
149
|
+
claimed_at_ms = NULL
|
|
150
|
+
WHERE run_id = ${runId}
|
|
151
|
+
AND claim_host_id = ${owner.hostId}
|
|
152
|
+
AND claim_pid = ${owner.pid}
|
|
153
|
+
AND claim_nonce = ${owner.nonce}
|
|
154
|
+
AND claimed_at_ms = ${grantedAtMs}
|
|
155
|
+
RETURNING run_id
|
|
156
|
+
`
|
|
157
|
+
return (rows.length > 0 ? activated : lost) satisfies ActivateOutcome
|
|
158
|
+
}))
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const heartbeat: Service["heartbeat"] = Effect.fn("Consensus.heartbeat")((
|
|
162
|
+
runId: string,
|
|
163
|
+
owner: OwnerId,
|
|
164
|
+
nowMs: number
|
|
165
|
+
) =>
|
|
166
|
+
write(Effect.gen(function*() {
|
|
167
|
+
// Monotonic: a pulse delayed past a newer one from the same owner never
|
|
168
|
+
// moves the lease backwards and never makes a live run look stale.
|
|
169
|
+
const rows = yield* sql<{ readonly heartbeat_at_ms: number }>`
|
|
170
|
+
UPDATE flows_consensus_leases
|
|
171
|
+
SET heartbeat_at_ms = ${Dialect.greatest(sql)}(heartbeat_at_ms, ${nowMs})
|
|
172
|
+
WHERE run_id = ${runId}
|
|
173
|
+
AND owner_host_id = ${owner.hostId}
|
|
174
|
+
AND owner_pid = ${owner.pid}
|
|
175
|
+
AND owner_nonce = ${owner.nonce}
|
|
176
|
+
RETURNING heartbeat_at_ms
|
|
177
|
+
`
|
|
178
|
+
const row = rows[0]
|
|
179
|
+
return (row === undefined
|
|
180
|
+
? lost
|
|
181
|
+
: { _tag: "Renewed", heartbeatAtMs: Number(row.heartbeat_at_ms) }) satisfies HeartbeatOutcome
|
|
182
|
+
}))
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const reconfirm: Service["reconfirm"] = Effect.fn("Consensus.reconfirm")((
|
|
186
|
+
runId: string,
|
|
187
|
+
owner: OwnerId,
|
|
188
|
+
nowMs: number
|
|
189
|
+
) =>
|
|
190
|
+
write(Effect.gen(function*() {
|
|
191
|
+
// A pending steal has already reserved the lease, even before activation.
|
|
192
|
+
// Compare both owner and claim in the same update as the renewal.
|
|
193
|
+
const rows = yield* sql<{ readonly heartbeat_at_ms: number }>`
|
|
194
|
+
UPDATE flows_consensus_leases
|
|
195
|
+
SET heartbeat_at_ms = ${Dialect.greatest(sql)}(heartbeat_at_ms, ${nowMs})
|
|
196
|
+
WHERE run_id = ${runId}
|
|
197
|
+
AND owner_host_id = ${owner.hostId}
|
|
198
|
+
AND owner_pid = ${owner.pid}
|
|
199
|
+
AND owner_nonce = ${owner.nonce}
|
|
200
|
+
AND claim_host_id IS NULL
|
|
201
|
+
RETURNING heartbeat_at_ms
|
|
202
|
+
`
|
|
203
|
+
const row = rows[0]
|
|
204
|
+
return (row === undefined
|
|
205
|
+
? lost
|
|
206
|
+
: { _tag: "Renewed", heartbeatAtMs: Number(row.heartbeat_at_ms) }) satisfies HeartbeatOutcome
|
|
207
|
+
}))
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
const release: Service["release"] = Effect.fn("Consensus.release")((runId: string, owner: OwnerId) =>
|
|
211
|
+
write(Effect.gen(function*() {
|
|
212
|
+
yield* sql`
|
|
213
|
+
UPDATE flows_consensus_leases
|
|
214
|
+
SET claim_host_id = NULL, claim_pid = NULL, claim_nonce = NULL, claimed_at_ms = NULL
|
|
215
|
+
WHERE run_id = ${runId}
|
|
216
|
+
AND claim_host_id = ${owner.hostId}
|
|
217
|
+
AND claim_pid = ${owner.pid}
|
|
218
|
+
AND claim_nonce = ${owner.nonce}
|
|
219
|
+
`
|
|
220
|
+
yield* sql`
|
|
221
|
+
UPDATE flows_consensus_leases
|
|
222
|
+
SET owner_host_id = NULL, owner_pid = NULL, owner_nonce = NULL,
|
|
223
|
+
granted_at_ms = NULL, heartbeat_at_ms = NULL
|
|
224
|
+
WHERE run_id = ${runId}
|
|
225
|
+
AND owner_host_id = ${owner.hostId}
|
|
226
|
+
AND owner_pid = ${owner.pid}
|
|
227
|
+
AND owner_nonce = ${owner.nonce}
|
|
228
|
+
`
|
|
229
|
+
yield* prune(runId)
|
|
230
|
+
}))
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
const steal: Service["steal"] = Effect.fn("Consensus.steal")((
|
|
234
|
+
runId: string,
|
|
235
|
+
claimant: OwnerId,
|
|
236
|
+
nowMs: number,
|
|
237
|
+
evidence: LivenessEvidence
|
|
238
|
+
) =>
|
|
239
|
+
Effect.suspend((): Effect.Effect<ClaimOutcome, ConsensusError> => {
|
|
240
|
+
if (!matchesEvidence(evidence.expectedOwner, claimant, nowMs, evidence)) {
|
|
241
|
+
return Effect.succeed(rejected("evidence_invalid"))
|
|
242
|
+
}
|
|
243
|
+
return write(Effect.gen(function*() {
|
|
244
|
+
const rows = yield* sql<{ readonly run_id: string }>`
|
|
245
|
+
UPDATE flows_consensus_leases
|
|
246
|
+
SET
|
|
247
|
+
claim_host_id = ${claimant.hostId},
|
|
248
|
+
claim_pid = ${claimant.pid},
|
|
249
|
+
claim_nonce = ${claimant.nonce},
|
|
250
|
+
claimed_at_ms = ${nowMs}
|
|
251
|
+
WHERE run_id = ${runId}
|
|
252
|
+
AND owner_host_id = ${evidence.expectedOwner.hostId}
|
|
253
|
+
AND owner_pid = ${evidence.expectedOwner.pid}
|
|
254
|
+
AND owner_nonce = ${evidence.expectedOwner.nonce}
|
|
255
|
+
AND heartbeat_at_ms < ${nowMs - staleAfterMs}
|
|
256
|
+
AND claim_host_id IS NULL
|
|
257
|
+
RETURNING run_id
|
|
258
|
+
`
|
|
259
|
+
if (rows.length > 0) return claimed(nowMs)
|
|
260
|
+
const lease = (yield* leaseOf(runId))[0]
|
|
261
|
+
if (lease !== undefined && lease.claim_host_id !== null) return rejected("already_claimed")
|
|
262
|
+
if (
|
|
263
|
+
lease === undefined ||
|
|
264
|
+
lease.owner_host_id !== evidence.expectedOwner.hostId ||
|
|
265
|
+
Number(lease.owner_pid) !== evidence.expectedOwner.pid ||
|
|
266
|
+
lease.owner_nonce !== evidence.expectedOwner.nonce
|
|
267
|
+
) {
|
|
268
|
+
// A run nobody owns, or one owned by somebody else, has no owner
|
|
269
|
+
// this evidence could disprove.
|
|
270
|
+
return rejected("evidence_invalid")
|
|
271
|
+
}
|
|
272
|
+
return rejected("owner_live")
|
|
273
|
+
}))
|
|
274
|
+
})
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
const recover: Service["recover"] = Effect.fn("Consensus.recover")((
|
|
278
|
+
runId: string,
|
|
279
|
+
staleClaimant: OwnerId,
|
|
280
|
+
grantedAtMs: number,
|
|
281
|
+
observer: OwnerId,
|
|
282
|
+
nowMs: number,
|
|
283
|
+
evidence: LivenessEvidence
|
|
284
|
+
) =>
|
|
285
|
+
Effect.suspend((): Effect.Effect<RecoverOutcome, ConsensusError> => {
|
|
286
|
+
if (!matchesEvidence(staleClaimant, observer, nowMs, evidence)) {
|
|
287
|
+
return Effect.succeed(rejected("evidence_invalid"))
|
|
288
|
+
}
|
|
289
|
+
return write(Effect.gen(function*() {
|
|
290
|
+
const rows = yield* sql<{ readonly run_id: string }>`
|
|
291
|
+
UPDATE flows_consensus_leases
|
|
292
|
+
SET claim_host_id = NULL, claim_pid = NULL, claim_nonce = NULL, claimed_at_ms = NULL
|
|
293
|
+
WHERE run_id = ${runId}
|
|
294
|
+
AND claim_host_id = ${staleClaimant.hostId}
|
|
295
|
+
AND claim_pid = ${staleClaimant.pid}
|
|
296
|
+
AND claim_nonce = ${staleClaimant.nonce}
|
|
297
|
+
AND claimed_at_ms = ${grantedAtMs}
|
|
298
|
+
AND claimed_at_ms < ${nowMs - staleAfterMs}
|
|
299
|
+
RETURNING run_id
|
|
300
|
+
`
|
|
301
|
+
if (rows.length > 0) {
|
|
302
|
+
yield* prune(runId)
|
|
303
|
+
return recovered
|
|
304
|
+
}
|
|
305
|
+
const matches = yield* sql<{ readonly run_id: string }>`
|
|
306
|
+
SELECT run_id FROM flows_consensus_leases
|
|
307
|
+
WHERE run_id = ${runId}
|
|
308
|
+
AND claim_host_id = ${staleClaimant.hostId}
|
|
309
|
+
AND claim_pid = ${staleClaimant.pid}
|
|
310
|
+
AND claim_nonce = ${staleClaimant.nonce}
|
|
311
|
+
AND claimed_at_ms = ${grantedAtMs}
|
|
312
|
+
`
|
|
313
|
+
// The exact claim survived the compare-and-swap only because it is
|
|
314
|
+
// not yet stale; anything else means the claim moved on.
|
|
315
|
+
return matches.length > 0 ? rejected("claim_fresh") : rejected("claim_changed")
|
|
316
|
+
}))
|
|
317
|
+
})
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
const guard: Service["guard"] = Effect.fn("Consensus.guard")((runId: string, owner: OwnerId) =>
|
|
321
|
+
write(Effect.gen(function*() {
|
|
322
|
+
const held = yield* sql<{ readonly ok: number }>`
|
|
323
|
+
SELECT 1 AS ok FROM flows_consensus_leases
|
|
324
|
+
WHERE run_id = ${runId}
|
|
325
|
+
AND owner_host_id = ${owner.hostId}
|
|
326
|
+
AND owner_pid = ${owner.pid}
|
|
327
|
+
AND owner_nonce = ${owner.nonce}
|
|
328
|
+
`
|
|
329
|
+
if (held.length === 0) {
|
|
330
|
+
return yield* Effect.fail(fenceLost(runId, owner))
|
|
331
|
+
}
|
|
332
|
+
}))
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return Consensus.of({ claim, activate, heartbeat, reconfirm, release, steal, recover, guard })
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Provides the database-backed strategy.
|
|
340
|
+
*
|
|
341
|
+
* @since 1.0.0
|
|
342
|
+
* @category layers
|
|
343
|
+
*/
|
|
344
|
+
export const layer: Layer.Layer<Consensus, never, DurableWriter | SqlClient.SqlClient> = Layer.effect(Consensus, make)
|