@fungi.computer/watchdog 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,66 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { WatchdogQueuedJobSchema } from "../effect.js";
3
+ import { laneMaxLength } from "./lane.js";
4
+ export const columns = `sequence, job_id, queue_name, lane, priority, payload_json,
5
+ recovery_max, state, outcome, claim_token, recovery_count, cancellation_id, failure_reason`;
6
+ export function schemaStatements(postgres) {
7
+ return [
8
+ `CREATE TABLE IF NOT EXISTS watchdog_jobs (
9
+ sequence ${postgres ? "SERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT"},
10
+ job_id TEXT NOT NULL UNIQUE CHECK(length(job_id) BETWEEN 1 AND 256),
11
+ queue_name TEXT NOT NULL CHECK(length(queue_name) BETWEEN 1 AND 256),
12
+ lane TEXT NOT NULL CHECK(length(lane) BETWEEN 1 AND ${String(laneMaxLength)}),
13
+ priority ${postgres ? "DOUBLE PRECISION" : "INTEGER"} NOT NULL,
14
+ payload_json TEXT NOT NULL,
15
+ recovery_max ${postgres ? "DOUBLE PRECISION" : "INTEGER"} CHECK(recovery_max IS NULL OR recovery_max >= 0),
16
+ state TEXT NOT NULL CHECK(state IN ('queued','running','settled')),
17
+ outcome TEXT CHECK(outcome IN ('completed','failed','cancelled','outcome_unknown','interrupted')),
18
+ claim_token TEXT,
19
+ recovery_count ${postgres ? "DOUBLE PRECISION" : "INTEGER"} NOT NULL DEFAULT 0,
20
+ cancellation_id TEXT UNIQUE,
21
+ failure_reason TEXT,
22
+ lease_until ${postgres ? "DOUBLE PRECISION" : "INTEGER"},
23
+ CHECK ((state='queued' AND outcome IS NULL AND claim_token IS NULL) OR
24
+ (state='running' AND outcome IS NULL AND claim_token IS NOT NULL AND lease_until IS NOT NULL) OR
25
+ (state='settled' AND outcome IS NOT NULL))
26
+ )`,
27
+ "CREATE INDEX IF NOT EXISTS idx_watchdog_lane_failure ON watchdog_jobs(lane,state DESC,sequence ASC)",
28
+ "CREATE INDEX IF NOT EXISTS idx_watchdog_ready ON watchdog_jobs(state,priority DESC,sequence)",
29
+ "CREATE INDEX IF NOT EXISTS idx_watchdog_queue_head ON watchdog_jobs(queue_name,state,priority DESC,sequence)",
30
+ "CREATE INDEX IF NOT EXISTS idx_watchdog_lease ON watchdog_jobs(state,lease_until,sequence)",
31
+ ];
32
+ }
33
+ // A conflicting replay must fail the caller's entire native transaction. Setting
34
+ // the NOT NULL identity to NULL is a portable constraint failure, not a silent
35
+ // DO NOTHING that would commit the producer fact with an unrelated old job.
36
+ export function enqueueSelect(source) {
37
+ return {
38
+ text: `INSERT INTO watchdog_jobs
39
+ (job_id,queue_name,lane,priority,payload_json,recovery_max,state)
40
+ SELECT job_id,queue_name,lane,priority,payload_json,recovery_max,'queued'
41
+ FROM (${source.text}) AS admission WHERE true
42
+ ON CONFLICT(job_id) DO UPDATE SET job_id = NULL WHERE NOT (
43
+ watchdog_jobs.queue_name=excluded.queue_name AND watchdog_jobs.lane=excluded.lane AND
44
+ watchdog_jobs.priority=excluded.priority AND watchdog_jobs.payload_json=excluded.payload_json AND
45
+ (watchdog_jobs.recovery_max=excluded.recovery_max OR
46
+ (watchdog_jobs.recovery_max IS NULL AND excluded.recovery_max IS NULL))
47
+ )
48
+ RETURNING ${columns}`,
49
+ values: source.values,
50
+ };
51
+ }
52
+ export function enqueueStatement(input) {
53
+ const job = Schema.decodeUnknownSync(WatchdogQueuedJobSchema)(input);
54
+ return enqueueSelect({
55
+ text: `SELECT CAST($1 AS TEXT) AS job_id,CAST($2 AS TEXT) AS queue_name,CAST($3 AS TEXT) AS lane,CAST($4 AS DOUBLE PRECISION) AS priority,
56
+ CAST($5 AS TEXT) AS payload_json,CAST($6 AS DOUBLE PRECISION) AS recovery_max`,
57
+ values: [
58
+ job.jobId,
59
+ job.queue,
60
+ job.lane,
61
+ job.priority,
62
+ Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(job.payload),
63
+ job.recovery?.maxRecoveries ?? null,
64
+ ],
65
+ });
66
+ }
@@ -0,0 +1,58 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { WatchdogError } from "./watchdog-error.js";
3
+ import { type SqlDatabase } from "./async-sql.js";
4
+ export declare const heartbeatMs = 10000;
5
+ export declare const attempt: <A>(run: () => Promise<A>) => Effect.Effect<A, WatchdogError>;
6
+ export declare function openAsyncStore(database: SqlDatabase, now: () => number): {
7
+ store: Readonly<{
8
+ hasPendingWork: Effect.Effect<boolean, WatchdogError>;
9
+ ingest: (job: import("../effect.js").WatchdogQueuedJob) => Effect.Effect<import("../effect.js").WatchdogIngestResult, WatchdogError>;
10
+ read: (jobId: string) => Effect.Effect<(Extract<import("../effect.js").WatchdogReadResult, {
11
+ status: "found";
12
+ }> & Readonly<{
13
+ claimToken: string | null;
14
+ recoveryCount: number;
15
+ }>) | Extract<import("../effect.js").WatchdogReadResult, {
16
+ status: "missing";
17
+ }>, WatchdogError>;
18
+ readQueueHead: (queue: string) => Effect.Effect<import("../effect.js").WatchdogQueueHead, WatchdogError>;
19
+ readSettled: (limit: number, afterSequence?: number) => Effect.Effect<readonly import("../effect.js").WatchdogSettledRecord[], WatchdogError>;
20
+ selectAndClaim: (claimToken: string) => Effect.Effect<import("./sqlite-store.js").WatchdogSelection, WatchdogError>;
21
+ settleExactClaim: (input: Readonly<{
22
+ jobId: string;
23
+ claimToken: string;
24
+ decision: import("../effect.js").WatchdogExecutionDecision;
25
+ }>) => Effect.Effect<Readonly<{
26
+ status: "settled" | "replayed";
27
+ job: import("../effect.js").WatchdogSettledJob;
28
+ }> | Readonly<{
29
+ status: "conflict" | "missing";
30
+ }>, WatchdogError>;
31
+ recordCancellation: (input: import("../effect.js").WatchdogCancellationIntent) => Effect.Effect<import("../effect.js").WatchdogCancellationResult, WatchdogError>;
32
+ failLane: (input: import("../effect.js").WatchdogLaneFailure) => Effect.Effect<Readonly<{
33
+ jobs: readonly import("../effect.js").WatchdogSettledJob[];
34
+ more: boolean;
35
+ }>, WatchdogError>;
36
+ purgeQueued: (queue: string) => Effect.Effect<void, WatchdogError>;
37
+ nextRunningClaim: () => Effect.Effect<Readonly<{
38
+ job: import("../effect.js").WatchdogRunningJob;
39
+ claimToken: string;
40
+ recoveryCount: number;
41
+ }> | undefined, WatchdogError>;
42
+ reconcileDisappearedClaim: (input: Readonly<{
43
+ job: import("../effect.js").WatchdogRunningJob;
44
+ claimToken: string;
45
+ recoveryCount: number;
46
+ disposition: "requeue" | "interrupted";
47
+ }>) => Effect.Effect<Readonly<{
48
+ status: "requeued";
49
+ }> | Readonly<{
50
+ status: "settled";
51
+ job: import("../effect.js").WatchdogSettledJob;
52
+ }> | Readonly<{
53
+ status: "conflict" | "missing";
54
+ }>, WatchdogError>;
55
+ }>;
56
+ isAlive: (jobId: string) => Effect.Effect<boolean, WatchdogError, never>;
57
+ renew: (jobId: string) => Effect.Effect<void, WatchdogError, never>;
58
+ };
@@ -0,0 +1,235 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ import { parseJobRow, parseLaneFailure, laneFailureBatchSize, } from "./sqlite-store.js";
4
+ import { WatchdogError } from "./watchdog-error.js";
5
+ import { columns, enqueueStatement, } from "./async-sql.js";
6
+ const leaseMs = 120_000;
7
+ export const heartbeatMs = 10_000;
8
+ const Alive = Schema.Struct({ alive: Schema.Literals([0, 1]) });
9
+ export const attempt = (run) => Effect.tryPromise({
10
+ try: run,
11
+ catch: (error) => error instanceof WatchdogError
12
+ ? error
13
+ : new WatchdogError("store_unavailable", error),
14
+ });
15
+ export function openAsyncStore(database, now) {
16
+ // The database checks wall time when the mutation executes, so a delayed
17
+ // renewal cannot resurrect a lease that expired while the request was in flight.
18
+ const at = (parameter) => database.clock === undefined
19
+ ? `CAST($${String(parameter)} AS DOUBLE PRECISION)`
20
+ : `(CASE WHEN $${String(parameter)}>${database.clock} THEN $${String(parameter)} ELSE ${database.clock} END)`;
21
+ const query = (text, values = []) => database.query({ text, values });
22
+ const row = async (id) => {
23
+ const raw = (await query(`SELECT ${columns} FROM watchdog_jobs WHERE job_id=$1`, [id]))[0];
24
+ return raw === undefined ? undefined : parseJobRow(raw);
25
+ };
26
+ // Private to this runtime: claims never escape through a public job.
27
+ const claims = new Map();
28
+ const store = {
29
+ hasPendingWork: attempt(async () => (await query("SELECT job_id FROM watchdog_jobs WHERE state IN ('queued','running') LIMIT 1")).length > 0),
30
+ ingest: (job) => attempt(async () => ({
31
+ status: (await database.query(enqueueStatement(job))).length > 0
32
+ ? "queued"
33
+ : "duplicate",
34
+ })),
35
+ read: (id) => attempt(async () => {
36
+ const observedClaim = claims.get(id);
37
+ const found = await row(id);
38
+ // A delayed claim reply can arrive after failLane deleted its entry.
39
+ // Retire only this read's claim; a pre-claim snapshot cannot erase a newer one.
40
+ if ((found === undefined || found.job.state === "settled") &&
41
+ claims.get(id) === observedClaim)
42
+ claims.delete(id);
43
+ if (found === undefined)
44
+ return { status: "missing" };
45
+ return {
46
+ status: "found",
47
+ job: found.job,
48
+ claimToken: found.row.claim_token,
49
+ recoveryCount: found.row.recovery_count,
50
+ cancellation: found.row.cancellation_id === null
51
+ ? { status: "absent" }
52
+ : {
53
+ status: "present",
54
+ cancellation: {
55
+ jobId: id,
56
+ cancellationId: found.row.cancellation_id,
57
+ },
58
+ },
59
+ };
60
+ }),
61
+ readQueueHead: (queue) => attempt(async () => {
62
+ const raw = (await query(`SELECT ${columns} FROM watchdog_jobs WHERE queue_name=$1
63
+ AND state IN ('queued','running') ORDER BY CASE state WHEN 'running' THEN 0 ELSE 1 END,
64
+ priority DESC,sequence LIMIT 1`, [queue]))[0];
65
+ if (raw === undefined)
66
+ return { state: "idle" };
67
+ const found = parseJobRow(raw);
68
+ if (found.job.state === "settled")
69
+ throw new WatchdogError("store_unavailable");
70
+ return {
71
+ state: found.job.state,
72
+ jobId: found.job.jobId,
73
+ cancellationRequested: found.row.cancellation_id !== null,
74
+ };
75
+ }),
76
+ readSettled: (limit, afterSequence = 0) => attempt(async () => {
77
+ Schema.decodeUnknownSync(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(256)))(limit);
78
+ const rows = await query(`SELECT ${columns} FROM watchdog_jobs WHERE state='settled'
79
+ AND sequence>$1 ORDER BY sequence LIMIT $2`, [afterSequence, limit]);
80
+ return rows.map((raw) => {
81
+ const parsed = parseJobRow(raw);
82
+ if (parsed.job.state !== "settled")
83
+ throw new WatchdogError("store_unavailable");
84
+ return { sequence: parsed.row.sequence, job: parsed.job };
85
+ });
86
+ }),
87
+ selectAndClaim: (token) => attempt(async () => {
88
+ const raw = (await query(`UPDATE watchdog_jobs SET
89
+ state=CASE WHEN cancellation_id IS NULL THEN 'running' ELSE 'settled' END,
90
+ outcome=CASE WHEN cancellation_id IS NULL THEN NULL ELSE 'cancelled' END,
91
+ claim_token=CASE WHEN cancellation_id IS NULL THEN $1 ELSE NULL END,
92
+ lease_until=CASE WHEN cancellation_id IS NULL THEN ${at(2)}+${String(leaseMs)} ELSE NULL END
93
+ WHERE job_id=(SELECT job_id FROM watchdog_jobs WHERE state='queued'
94
+ ORDER BY priority DESC,sequence LIMIT 1) AND state='queued' RETURNING ${columns}`, [token, now()]))[0];
95
+ if (raw === undefined)
96
+ return { status: "no_ready" };
97
+ const parsed = parseJobRow(raw);
98
+ if (parsed.job.state === "settled")
99
+ return { status: "cancelled", job: parsed.job };
100
+ if (parsed.job.state !== "running")
101
+ throw new WatchdogError("claim_unavailable");
102
+ claims.set(parsed.job.jobId, token);
103
+ return { status: "claimed", job: parsed.job, claimToken: token };
104
+ }),
105
+ settleExactClaim: (input) => attempt(async () => {
106
+ try {
107
+ const raw = (await query(`UPDATE watchdog_jobs SET state='settled',
108
+ outcome=CASE WHEN cancellation_id IS NULL THEN $1 ELSE 'cancelled' END,
109
+ failure_reason=CASE WHEN cancellation_id IS NULL THEN $2 ELSE NULL END,lease_until=NULL
110
+ WHERE job_id=$3 AND state='running' AND claim_token=$4 AND lease_until>${at(5)} RETURNING ${columns}`, [
111
+ input.decision.kind,
112
+ input.decision.kind === "failed"
113
+ ? (input.decision.reason ?? null)
114
+ : null,
115
+ input.jobId,
116
+ input.claimToken,
117
+ now(),
118
+ ]))[0];
119
+ if (raw !== undefined) {
120
+ const parsed = parseJobRow(raw);
121
+ if (parsed.job.state === "settled")
122
+ return { status: "settled", job: parsed.job };
123
+ }
124
+ const prior = await row(input.jobId);
125
+ if (prior === undefined)
126
+ return { status: "missing" };
127
+ const outcome = prior.row.cancellation_id === null
128
+ ? input.decision.kind
129
+ : "cancelled";
130
+ return prior.job.state === "settled" &&
131
+ prior.row.claim_token === input.claimToken &&
132
+ prior.job.outcome === outcome
133
+ ? { status: "replayed", job: prior.job }
134
+ : { status: "conflict" };
135
+ }
136
+ finally {
137
+ claims.delete(input.jobId);
138
+ }
139
+ }),
140
+ recordCancellation: (intent) => attempt(async () => {
141
+ const updated = await query(`UPDATE watchdog_jobs SET cancellation_id=$1 WHERE job_id=$2
142
+ AND cancellation_id IS NULL ${intent.onlyIfQueued === true ? "AND state='queued'" : ""}
143
+ RETURNING job_id`, [intent.cancellationId, intent.jobId]);
144
+ if (updated.length > 0)
145
+ return { status: "recorded" };
146
+ const prior = await row(intent.jobId);
147
+ return {
148
+ status: prior === undefined
149
+ ? "missing"
150
+ : prior.row.cancellation_id === intent.cancellationId
151
+ ? "replayed"
152
+ : "conflict",
153
+ };
154
+ }),
155
+ failLane: (raw) => attempt(async () => {
156
+ const input = parseLaneFailure(raw);
157
+ // Under the declared state grammar, DESC puts running before queued.
158
+ const rows = await query(`UPDATE watchdog_jobs SET state='settled', outcome='failed', failure_reason=$1, lease_until=NULL, claim_token=NULL
159
+ WHERE job_id IN (SELECT job_id FROM watchdog_jobs WHERE lane=$2 AND state IN ('queued','running')
160
+ ORDER BY state DESC, sequence ASC LIMIT $3)
161
+ AND state IN ('queued','running') RETURNING ${columns}`, [input.reason, input.lane, laneFailureBatchSize]);
162
+ const jobs = rows.map((row) => {
163
+ const parsed = parseJobRow(row);
164
+ if (parsed.job.state !== "settled")
165
+ throw new WatchdogError("store_unavailable");
166
+ claims.delete(parsed.job.jobId);
167
+ return parsed.job;
168
+ });
169
+ const more = (await query("SELECT 1 AS pending FROM watchdog_jobs WHERE lane=$1 AND state IN ('queued','running') LIMIT 1", [input.lane])).length !== 0;
170
+ return { jobs, more };
171
+ }),
172
+ purgeQueued: (queue) => attempt(async () => {
173
+ await query("DELETE FROM watchdog_jobs WHERE queue_name=$1 AND state='queued'", [queue]);
174
+ }),
175
+ nextRunningClaim: () => attempt(async () => {
176
+ const raw = (await query(`SELECT ${columns} FROM watchdog_jobs WHERE state='running'
177
+ ORDER BY lease_until,sequence LIMIT 1`))[0];
178
+ if (raw === undefined)
179
+ return undefined;
180
+ const found = parseJobRow(raw);
181
+ if (found.job.state !== "running" || found.row.claim_token === null)
182
+ throw new WatchdogError("store_unavailable");
183
+ return {
184
+ job: found.job,
185
+ claimToken: found.row.claim_token,
186
+ recoveryCount: found.row.recovery_count,
187
+ };
188
+ }),
189
+ reconcileDisappearedClaim: (input) => attempt(async () => {
190
+ const requeue = input.disposition === "requeue";
191
+ const reason = input.job.recovery === undefined
192
+ ? "Execution disappeared without a recovery policy"
193
+ : `Restart recovery budget exhausted (${String(input.job.recovery.maxRecoveries)} recoveries)`;
194
+ const raw = (await query(`UPDATE watchdog_jobs SET
195
+ state=CASE WHEN $1=1 AND cancellation_id IS NULL THEN 'queued' ELSE 'settled' END,
196
+ outcome=CASE WHEN cancellation_id IS NOT NULL THEN 'cancelled' WHEN $1=1 THEN NULL ELSE 'interrupted' END,
197
+ claim_token=CASE WHEN $1=1 AND cancellation_id IS NULL THEN NULL ELSE claim_token END,
198
+ recovery_count=recovery_count+CASE WHEN $1=1 AND cancellation_id IS NULL THEN 1 ELSE 0 END,
199
+ failure_reason=CASE WHEN $1=0 AND cancellation_id IS NULL THEN $2 ELSE NULL END,lease_until=NULL
200
+ WHERE job_id=$3 AND state='running' AND claim_token=$4 AND recovery_count=$5 AND lease_until<=${at(6)}
201
+ RETURNING ${columns}`, [
202
+ requeue ? 1 : 0,
203
+ reason,
204
+ input.job.jobId,
205
+ input.claimToken,
206
+ input.recoveryCount,
207
+ now(),
208
+ ]))[0];
209
+ if (raw === undefined)
210
+ return { status: "conflict" };
211
+ const parsed = parseJobRow(raw);
212
+ if (parsed.job.state === "queued")
213
+ return { status: "requeued" };
214
+ if (parsed.job.state === "settled")
215
+ return { status: "settled", job: parsed.job };
216
+ throw new WatchdogError("store_unavailable");
217
+ }),
218
+ };
219
+ return {
220
+ store,
221
+ isAlive: (jobId) => attempt(async () => {
222
+ const raw = (await query(`SELECT CASE WHEN lease_until>${at(2)} THEN 1 ELSE 0 END AS alive FROM watchdog_jobs WHERE job_id=$1 AND state='running'`, [jobId, now()]))[0];
223
+ return (raw !== undefined && Schema.decodeUnknownSync(Alive)(raw).alive === 1);
224
+ }),
225
+ renew: (jobId) => attempt(async () => {
226
+ const token = claims.get(jobId);
227
+ if (token === undefined)
228
+ throw new WatchdogError("claim_unavailable");
229
+ const rows = await query(`UPDATE watchdog_jobs SET lease_until=${at(1)}+${String(leaseMs)} WHERE job_id=$2 AND state='running'
230
+ AND claim_token=$3 AND lease_until>${at(4)} AND cancellation_id IS NULL RETURNING job_id`, [now(), jobId, token, now()]);
231
+ if (rows.length === 0)
232
+ throw new WatchdogError("claim_unavailable");
233
+ }),
234
+ };
235
+ }
@@ -0,0 +1,3 @@
1
+ export declare const watchdogIds: Readonly<{
2
+ readonly claim: import("@fungi.computer/cairn").IdCodec<"watchdog", "claim", 1>;
3
+ }>;
@@ -0,0 +1,8 @@
1
+ import { defineIdFamily } from "@fungi.computer/cairn";
2
+ export const watchdogIds = defineIdFamily({
3
+ family: "watchdog",
4
+ version: 1,
5
+ kinds: {
6
+ claim: { prefix: "watchdog_claim" },
7
+ },
8
+ });
@@ -0,0 +1,3 @@
1
+ import * as Schema from "effect/Schema";
2
+ export declare const laneMaxLength = 512;
3
+ export declare const laneSchema: Schema.String;
@@ -0,0 +1,6 @@
1
+ import * as Schema from "effect/Schema";
2
+ // A 256-character identity plus "team-key:" is 265 characters ("member:"
3
+ // needs 263). Leave composition headroom without changing the identity or
4
+ // widening job/queue identifiers. Admission, storage and runtime share this bound.
5
+ export const laneMaxLength = 512;
6
+ export const laneSchema = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(laneMaxLength));
@@ -0,0 +1,4 @@
1
+ import type { EffectWatchdogOptions } from "../effect.js";
2
+ import type { WatchdogOptions } from "../index.js";
3
+ /** Lift Promise host ports into the Effect-owned runtime. */
4
+ export declare const adaptPromiseWatchdogHost: (options: WatchdogOptions) => EffectWatchdogOptions;
@@ -0,0 +1,25 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { WatchdogError } from "./watchdog-error.js";
3
+ /** Lift Promise host ports into the Effect-owned runtime. */
4
+ export const adaptPromiseWatchdogHost = (options) => {
5
+ return {
6
+ owner: options.owner,
7
+ project: options.project,
8
+ isAlive: (job) => Effect.tryPromise({
9
+ try: () => options.isAlive(job),
10
+ catch: (error) => new WatchdogError("liveness_unavailable", error),
11
+ }),
12
+ executor: {
13
+ execute: (job) => Effect.tryPromise({
14
+ try: (signal) => options.execute(job, signal),
15
+ catch: () => new WatchdogError("execution_failed"),
16
+ }),
17
+ },
18
+ wake: {
19
+ recompute: () => Effect.tryPromise({
20
+ try: () => options.wake.recompute(),
21
+ catch: () => new WatchdogError("wake_unavailable"),
22
+ }),
23
+ },
24
+ };
25
+ };
@@ -0,0 +1,3 @@
1
+ import type { EffectWatchdogOptions, WatchdogRuntimeEffect } from "../effect.js";
2
+ import type { WatchdogStore } from "./sqlite-store.js";
3
+ export declare const bindWatchdogEngine: (options: Pick<EffectWatchdogOptions, "isAlive" | "executor" | "wake">, store: WatchdogStore) => Omit<WatchdogRuntimeEffect, "transactional">;
@@ -0,0 +1,199 @@
1
+ import * as Cause from "effect/Cause";
2
+ import * as Deferred from "effect/Deferred";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Exit from "effect/Exit";
5
+ import * as Fiber from "effect/Fiber";
6
+ import { WatchdogError } from "./watchdog-error.js";
7
+ import { watchdogIds } from "./identities.js";
8
+ /**
9
+ * The engine's own reason for an executor exit that returned no decision: the
10
+ * executor's typed failure code, an unexpected defect, or an interruption.
11
+ */
12
+ function failureReasonOf(cause) {
13
+ if (Cause.hasInterruptsOnly(cause))
14
+ return "execution_interrupted";
15
+ const failure = Cause.findErrorOption(cause);
16
+ return failure._tag === "Some" ? failure.value.cause : "execution_defect";
17
+ }
18
+ export const bindWatchdogEngine = (options, store) => {
19
+ let tickState = "idle";
20
+ let activeExecution;
21
+ const ingest = (job) => store.ingest(job);
22
+ const read = (jobId) => store.read(jobId).pipe(Effect.map((result) => result.status === "missing"
23
+ ? result
24
+ : {
25
+ status: "found",
26
+ job: result.job,
27
+ cancellation: result.cancellation,
28
+ }));
29
+ const readQueueHead = (queue) => store.readQueueHead(queue);
30
+ const readSettled = (limit, afterSequence) => store.readSettled(limit, afterSequence);
31
+ const cancel = (input) => store.recordCancellation(input).pipe(Effect.tap((result) => {
32
+ if (result.status !== "recorded" && result.status !== "replayed")
33
+ return Effect.void;
34
+ const active = activeExecution;
35
+ return active?.jobId === input.jobId
36
+ ? Effect.sync(() => {
37
+ // Tick retains the fiber and awaits cleanup before settlement.
38
+ active.fiber.interruptUnsafe();
39
+ })
40
+ : options.wake.recompute();
41
+ }));
42
+ const failLane = (input) => store.failLane(input).pipe(Effect.tap((result) => {
43
+ const active = activeExecution;
44
+ const failed = result.jobs.find((job) => job.jobId === active?.jobId);
45
+ if (active !== undefined && failed !== undefined) {
46
+ active.failed = failed;
47
+ active.fiber.interruptUnsafe();
48
+ }
49
+ return result.jobs.length === 0
50
+ ? Effect.void
51
+ : options.wake.recompute();
52
+ }), Effect.map(({ jobs, more }) => ({ settled: jobs.length, more })));
53
+ const purge = (queue) => store.purgeQueued(queue);
54
+ const claimNext = () => Effect.try({
55
+ try: () => watchdogIds.claim.generate(),
56
+ catch: () => new WatchdogError("claim_unavailable"),
57
+ }).pipe(Effect.flatMap((claimToken) => store.selectAndClaim(claimToken)));
58
+ const inspectDisappearedClaim = () => {
59
+ return Effect.gen(function* () {
60
+ const claim = yield* store.nextRunningClaim();
61
+ if (claim === undefined)
62
+ return { status: "none" };
63
+ const probe = yield* Effect.result(options.isAlive(claim.job));
64
+ // A failed probe is not an answer: never alive, never dead.
65
+ if (probe._tag === "Failure")
66
+ return {
67
+ status: "liveness_unknown",
68
+ jobId: claim.job.jobId,
69
+ reason: probe.failure.cause,
70
+ };
71
+ if (probe.success)
72
+ return { status: "none" };
73
+ const disposition = claim.job.recovery !== undefined &&
74
+ claim.recoveryCount < claim.job.recovery.maxRecoveries
75
+ ? "requeue"
76
+ : "interrupted";
77
+ const result = yield* store.reconcileDisappearedClaim({
78
+ ...claim,
79
+ disposition,
80
+ });
81
+ switch (result.status) {
82
+ case "requeued":
83
+ yield* options.wake.recompute();
84
+ return { status: "none" };
85
+ case "settled":
86
+ yield* options.wake.recompute();
87
+ return { status: "recovered", job: result.job };
88
+ case "conflict":
89
+ case "missing": {
90
+ // This is an observer's recovery attempt, not its own execution claim.
91
+ // Another owner can advance the stored generation during the probe.
92
+ const current = yield* store.read(claim.job.jobId);
93
+ if (current.status === "found" &&
94
+ (current.job.state !== "running" ||
95
+ current.claimToken !== claim.claimToken ||
96
+ current.recoveryCount !== claim.recoveryCount))
97
+ return { status: "none" };
98
+ return yield* Effect.fail(new WatchdogError("settlement_rejected"));
99
+ }
100
+ }
101
+ });
102
+ };
103
+ const tick = () => Effect.suspend(() => {
104
+ if (tickState !== "idle")
105
+ return Effect.succeed({ status: "busy" });
106
+ tickState = "running";
107
+ return inspectDisappearedClaim().pipe(Effect.flatMap((inspection) => {
108
+ if (inspection.status === "recovered")
109
+ return Effect.succeed({
110
+ status: "recovered",
111
+ job: inspection.job,
112
+ });
113
+ // Other work still proceeds; only an otherwise idle tick has
114
+ // nothing to say but the unanswered probe.
115
+ return claimNext().pipe(Effect.map((selection) => selection.status === "no_ready" &&
116
+ inspection.status === "liveness_unknown"
117
+ ? inspection
118
+ : selection));
119
+ }), Effect.flatMap((selection) => {
120
+ if (selection.status === "no_ready")
121
+ return Effect.succeed({ status: "no_ready" });
122
+ if (selection.status === "liveness_unknown")
123
+ return Effect.succeed(selection);
124
+ if (selection.status === "claim_unavailable")
125
+ return Effect.fail(new WatchdogError("claim_unavailable"));
126
+ if (selection.status === "cancelled")
127
+ return options.wake.recompute().pipe(Effect.as({
128
+ status: "settled",
129
+ job: selection.job,
130
+ }));
131
+ if (selection.status === "recovered")
132
+ return Effect.succeed({
133
+ status: "settled",
134
+ job: selection.job,
135
+ });
136
+ return Effect.gen(function* () {
137
+ const start = yield* Deferred.make();
138
+ const fiber = yield* Effect.forkChild(Deferred.await(start).pipe(Effect.andThen(options.executor.execute(selection.job))));
139
+ const owned = {
140
+ jobId: selection.job.jobId,
141
+ fiber,
142
+ };
143
+ activeExecution = owned;
144
+ const current = yield* store.read(selection.job.jobId);
145
+ if (current.status !== "found" ||
146
+ current.job.state !== "running" ||
147
+ current.cancellation.status === "present") {
148
+ if (current.status === "found" &&
149
+ current.job.state === "settled" &&
150
+ current.job.outcome === "failed")
151
+ owned.failed = current.job;
152
+ yield* Fiber.interrupt(fiber);
153
+ }
154
+ else
155
+ yield* Deferred.succeed(start, undefined);
156
+ const execution = yield* Fiber.await(fiber).pipe(Effect.ensuring(Effect.sync(() => {
157
+ if (activeExecution?.fiber === fiber)
158
+ activeExecution = undefined;
159
+ })));
160
+ if (owned.failed !== undefined)
161
+ return yield* options.wake
162
+ .recompute()
163
+ .pipe(Effect.as({ status: "settled", job: owned.failed }));
164
+ const decision = Exit.isSuccess(execution)
165
+ ? execution.value
166
+ : { kind: "failed", reason: failureReasonOf(execution.cause) };
167
+ const settled = yield* store.settleExactClaim({
168
+ jobId: selection.job.jobId,
169
+ claimToken: selection.claimToken,
170
+ decision,
171
+ });
172
+ switch (settled.status) {
173
+ case "settled":
174
+ case "replayed":
175
+ return yield* options.wake.recompute().pipe(Effect.as({
176
+ status: "settled",
177
+ job: settled.job,
178
+ }));
179
+ case "conflict":
180
+ case "missing":
181
+ return yield* Effect.fail(new WatchdogError("settlement_rejected"));
182
+ }
183
+ });
184
+ }), Effect.ensuring(Effect.sync(() => {
185
+ tickState = "idle";
186
+ })));
187
+ });
188
+ return Object.freeze({
189
+ hasPendingWork: store.hasPendingWork,
190
+ ingest,
191
+ read,
192
+ readQueueHead,
193
+ readSettled,
194
+ tick,
195
+ cancel,
196
+ failLane,
197
+ purge,
198
+ });
199
+ };