@fungi.computer/watchdog 0.1.0 → 0.1.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.
- package/README.md +94 -41
- package/dist/d1.js +1 -0
- package/dist/effect.d.ts +120 -8
- package/dist/effect.js +34 -11
- package/dist/index.d.ts +7 -5
- package/dist/index.js +2 -2
- package/dist/internal/async-runtime.d.ts +3 -1
- package/dist/internal/async-runtime.js +12 -3
- package/dist/internal/async-sql.d.ts +2 -1
- package/dist/internal/async-sql.js +18 -9
- package/dist/internal/async-store.d.ts +40 -7
- package/dist/internal/async-store.js +177 -24
- package/dist/internal/promise-watchdog-host.js +11 -2
- package/dist/internal/recovery.d.ts +9 -0
- package/dist/internal/recovery.js +20 -0
- package/dist/internal/retry.d.ts +19 -0
- package/dist/internal/retry.js +32 -0
- package/dist/internal/runtime.js +184 -82
- package/dist/internal/sqlite-store.d.ts +50 -9
- package/dist/internal/sqlite-store.js +200 -59
- package/dist/internal/watchdog-error.d.ts +2 -2
- package/dist/postgres.js +1 -0
- package/docs/api/d1.md +4 -3
- package/docs/api/effect.md +267 -29
- package/docs/api/index.md +48 -11
- package/docs/api/postgres.md +3 -3
- package/package.json +3 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as Cause from "effect/Cause";
|
|
2
2
|
import * as Effect from "effect/Effect";
|
|
3
3
|
import * as Schema from "effect/Schema";
|
|
4
|
+
import { reserveRetry, retryDue } from "./retry.js";
|
|
5
|
+
import { disappearanceRecovery } from "./recovery.js";
|
|
4
6
|
import { WatchdogError } from "./watchdog-error.js";
|
|
5
7
|
import { laneSchema } from "./lane.js";
|
|
6
8
|
const Identifier = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
|
|
@@ -31,8 +33,12 @@ const JobRowSchema = Schema.Struct({
|
|
|
31
33
|
lane: laneSchema,
|
|
32
34
|
priority: Priority,
|
|
33
35
|
payload_json: Schema.String,
|
|
36
|
+
not_before: Count,
|
|
37
|
+
attempt_count: Count,
|
|
34
38
|
recovery_max: Schema.NullOr(Count),
|
|
35
|
-
|
|
39
|
+
recovery_window_ms: Schema.NullOr(Count),
|
|
40
|
+
recovery_window_started_at: Schema.NullOr(Count),
|
|
41
|
+
state: Schema.Literals(["queued", "running", "completing", "settled"]),
|
|
36
42
|
outcome: Schema.NullOr(Schema.Literals([
|
|
37
43
|
"completed",
|
|
38
44
|
"failed",
|
|
@@ -59,9 +65,37 @@ function laneQuery(partition) {
|
|
|
59
65
|
values: [partition.lane],
|
|
60
66
|
};
|
|
61
67
|
}
|
|
62
|
-
const columns = `sequence, job_id, queue_name, lane, priority, payload_json,
|
|
63
|
-
recovery_max, state, outcome, claim_token, recovery_count,
|
|
68
|
+
const columns = `sequence, job_id, queue_name, lane, priority, payload_json, not_before, attempt_count,
|
|
69
|
+
recovery_max, recovery_window_ms, recovery_window_started_at, state, outcome, claim_token, recovery_count,
|
|
64
70
|
cancellation_id, failure_reason`;
|
|
71
|
+
/** Queue order is decided before readiness; a sleeping head still owns its queue. */
|
|
72
|
+
export const queuedHeadPredicate = `(cancellation_id IS NOT NULL OR NOT EXISTS (
|
|
73
|
+
SELECT 1 FROM watchdog_jobs predecessor
|
|
74
|
+
WHERE predecessor.queue_name = watchdog_jobs.queue_name
|
|
75
|
+
AND predecessor.state IN ('queued', 'running', 'completing')
|
|
76
|
+
AND (predecessor.state IN ('running', 'completing') OR
|
|
77
|
+
(predecessor.state = 'queued' AND
|
|
78
|
+
(predecessor.priority > watchdog_jobs.priority OR
|
|
79
|
+
(predecessor.priority = watchdog_jobs.priority AND
|
|
80
|
+
predecessor.sequence < watchdog_jobs.sequence))))))`;
|
|
81
|
+
const WakeRow = Schema.Struct({
|
|
82
|
+
pending: Schema.Literals([0, 1]),
|
|
83
|
+
next_wake_at: Schema.NullOr(Count),
|
|
84
|
+
});
|
|
85
|
+
export const parseWakeTime = Schema.decodeUnknownSync(Count);
|
|
86
|
+
export function parseWakeState(raw) {
|
|
87
|
+
const row = Schema.decodeUnknownSync(WakeRow)(raw);
|
|
88
|
+
return { pending: row.pending === 1, nextWakeAt: row.next_wake_at };
|
|
89
|
+
}
|
|
90
|
+
function readWakeState(owner, now) {
|
|
91
|
+
parseWakeTime(now);
|
|
92
|
+
return parseWakeState(owner.sql
|
|
93
|
+
.exec(`SELECT
|
|
94
|
+
COALESCE(MAX(CASE WHEN state IN ('running','completing') OR cancellation_id IS NOT NULL OR not_before <= ? THEN 1 ELSE 0 END),0) AS pending,
|
|
95
|
+
MIN(CASE WHEN state='queued' AND cancellation_id IS NULL AND not_before > ? THEN not_before END) AS next_wake_at
|
|
96
|
+
FROM watchdog_jobs WHERE state IN ('running','completing') OR (state='queued' AND ${queuedHeadPredicate})`, now, now)
|
|
97
|
+
.toArray()[0]);
|
|
98
|
+
}
|
|
65
99
|
function unavailable(cause) {
|
|
66
100
|
return new WatchdogError("store_unavailable", cause);
|
|
67
101
|
}
|
|
@@ -87,22 +121,21 @@ export function parseJobRow(raw) {
|
|
|
87
121
|
const payload = decodePayload(row.payload_json);
|
|
88
122
|
if (payload._tag === "Failure")
|
|
89
123
|
throw unavailable(Cause.squash(payload.cause));
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
};
|
|
124
|
+
let common = {
|
|
125
|
+
jobId: row.job_id,
|
|
126
|
+
queue: row.queue_name,
|
|
127
|
+
lane: row.lane,
|
|
128
|
+
priority: row.priority,
|
|
129
|
+
payload: payload.value,
|
|
130
|
+
};
|
|
131
|
+
if (row.not_before !== 0)
|
|
132
|
+
common = { ...common, notBefore: row.not_before };
|
|
133
|
+
if (row.recovery_max !== null) {
|
|
134
|
+
const recovery = row.recovery_window_ms === null
|
|
135
|
+
? { maxRecoveries: row.recovery_max }
|
|
136
|
+
: { maxRecoveries: row.recovery_max, windowMs: row.recovery_window_ms };
|
|
137
|
+
common = { ...common, recovery };
|
|
138
|
+
}
|
|
106
139
|
if (row.state === "queued")
|
|
107
140
|
return { row, job: { ...common, state: "queued" } };
|
|
108
141
|
if (row.state === "running") {
|
|
@@ -114,7 +147,7 @@ export function parseJobRow(raw) {
|
|
|
114
147
|
throw unavailable();
|
|
115
148
|
const settled = {
|
|
116
149
|
...common,
|
|
117
|
-
state:
|
|
150
|
+
state: row.state,
|
|
118
151
|
outcome: row.outcome,
|
|
119
152
|
};
|
|
120
153
|
return {
|
|
@@ -133,13 +166,22 @@ function readJob(owner, jobId) {
|
|
|
133
166
|
.toArray()[0];
|
|
134
167
|
return raw === undefined ? undefined : parseJobRow(raw);
|
|
135
168
|
}
|
|
136
|
-
function readSettled(owner, limit, afterSequence, partition) {
|
|
137
|
-
const
|
|
169
|
+
function readSettled(owner, limit, afterSequence, partition, selectedLane) {
|
|
170
|
+
const partitionQuery = laneQuery(partition);
|
|
171
|
+
const lane = selectedLane === undefined
|
|
172
|
+
? partitionQuery
|
|
173
|
+
: {
|
|
174
|
+
predicate: `${partitionQuery.predicate} AND lane = ?`,
|
|
175
|
+
values: [
|
|
176
|
+
...partitionQuery.values,
|
|
177
|
+
Schema.decodeUnknownSync(laneSchema)(selectedLane),
|
|
178
|
+
],
|
|
179
|
+
};
|
|
138
180
|
const rows = owner.sql
|
|
139
181
|
.exec(`SELECT ${columns} FROM watchdog_jobs NOT INDEXED WHERE state = 'settled' AND ${lane.predicate} AND sequence > ?
|
|
140
182
|
AND sequence < COALESCE(
|
|
141
183
|
(SELECT MIN(sequence) FROM watchdog_jobs INDEXED BY idx_watchdog_ready
|
|
142
|
-
WHERE state IN ('queued', 'running') AND ${lane.predicate}),
|
|
184
|
+
WHERE state IN ('queued', 'running', 'completing') AND ${lane.predicate}),
|
|
143
185
|
9223372036854775807)
|
|
144
186
|
ORDER BY sequence ASC LIMIT ?`, ...lane.values, afterSequence ?? 0, ...lane.values, limit)
|
|
145
187
|
.toArray();
|
|
@@ -154,8 +196,8 @@ function readQueueHead(owner, queue) {
|
|
|
154
196
|
const raw = owner.sql
|
|
155
197
|
.exec(`SELECT ${columns}
|
|
156
198
|
FROM watchdog_jobs
|
|
157
|
-
WHERE queue_name = ? AND state IN ('running', 'queued')
|
|
158
|
-
ORDER BY CASE state WHEN '
|
|
199
|
+
WHERE queue_name = ? AND state IN ('running', 'completing', 'queued')
|
|
200
|
+
ORDER BY CASE state WHEN 'queued' THEN 1 ELSE 0 END,
|
|
159
201
|
priority DESC, sequence ASC
|
|
160
202
|
LIMIT 1`, queue)
|
|
161
203
|
.toArray()[0];
|
|
@@ -165,7 +207,7 @@ function readQueueHead(owner, queue) {
|
|
|
165
207
|
if (parsed.job.state === "settled")
|
|
166
208
|
throw unavailable();
|
|
167
209
|
return {
|
|
168
|
-
state: parsed.job.state,
|
|
210
|
+
state: parsed.job.state === "completing" ? "running" : parsed.job.state,
|
|
169
211
|
jobId: parsed.job.jobId,
|
|
170
212
|
cancellationRequested: parsed.row.cancellation_id !== null,
|
|
171
213
|
};
|
|
@@ -202,8 +244,12 @@ function prepare(owner) {
|
|
|
202
244
|
lane TEXT NOT NULL,
|
|
203
245
|
priority INTEGER NOT NULL,
|
|
204
246
|
payload_json TEXT NOT NULL,
|
|
247
|
+
not_before INTEGER NOT NULL DEFAULT 0 CHECK (not_before >= 0),
|
|
248
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
205
249
|
recovery_max INTEGER,
|
|
206
|
-
|
|
250
|
+
recovery_window_ms INTEGER CHECK (recovery_window_ms IS NULL OR recovery_window_ms > 0),
|
|
251
|
+
recovery_window_started_at INTEGER,
|
|
252
|
+
state TEXT NOT NULL CHECK (state IN ('queued', 'running', 'completing', 'settled')),
|
|
207
253
|
outcome TEXT CHECK (
|
|
208
254
|
outcome IS NULL OR outcome IN (
|
|
209
255
|
'completed', 'failed', 'cancelled', 'outcome_unknown', 'interrupted'
|
|
@@ -214,17 +260,29 @@ function prepare(owner) {
|
|
|
214
260
|
cancellation_id TEXT UNIQUE,
|
|
215
261
|
failure_reason TEXT,
|
|
216
262
|
CHECK (recovery_max IS NULL OR recovery_max >= 0),
|
|
217
|
-
CHECK (
|
|
263
|
+
CONSTRAINT watchdog_execution_state CHECK (
|
|
218
264
|
(state = 'queued' AND outcome IS NULL AND claim_token IS NULL) OR
|
|
219
265
|
(state = 'running' AND outcome IS NULL AND claim_token IS NOT NULL) OR
|
|
266
|
+
(state = 'completing' AND outcome IS NOT NULL AND claim_token IS NOT NULL) OR
|
|
220
267
|
(state = 'settled' AND outcome IS NOT NULL)
|
|
221
268
|
)
|
|
222
269
|
)
|
|
223
270
|
`);
|
|
271
|
+
// Refuse the old state grammar before an executor can perform effects.
|
|
272
|
+
if (owner.sql
|
|
273
|
+
.exec("SELECT 1 AS current FROM sqlite_schema WHERE name = 'watchdog_jobs' AND instr(sql, 'watchdog_execution_state') > 0")
|
|
274
|
+
.toArray().length !== 1)
|
|
275
|
+
throw new WatchdogError("store_invalid", new Error("Obsolete Watchdog execution schema"));
|
|
276
|
+
try {
|
|
277
|
+
owner.sql.exec(`SELECT ${columns} FROM watchdog_jobs LIMIT 0`);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
throw new WatchdogError("store_invalid", error);
|
|
281
|
+
}
|
|
224
282
|
owner.sql.exec(`CREATE INDEX IF NOT EXISTS idx_watchdog_lane_failure
|
|
225
283
|
ON watchdog_jobs(lane, state DESC, sequence ASC)`);
|
|
226
284
|
owner.sql.exec(`CREATE INDEX IF NOT EXISTS idx_watchdog_ready
|
|
227
|
-
ON watchdog_jobs(state, priority DESC, sequence ASC)`);
|
|
285
|
+
ON watchdog_jobs(state, not_before, priority DESC, sequence ASC)`);
|
|
228
286
|
owner.sql.exec(`CREATE INDEX IF NOT EXISTS idx_watchdog_queue_head
|
|
229
287
|
ON watchdog_jobs(queue_name, state, priority DESC, sequence ASC)`);
|
|
230
288
|
});
|
|
@@ -239,14 +297,15 @@ function enqueue(owner, project, job) {
|
|
|
239
297
|
prior.job.lane !== job.lane ||
|
|
240
298
|
prior.job.priority !== job.priority ||
|
|
241
299
|
prior.row.payload_json !== payloadJson ||
|
|
242
|
-
priorRecovery !== nextRecovery
|
|
300
|
+
priorRecovery !== nextRecovery ||
|
|
301
|
+
prior.job.recovery?.windowMs !== job.recovery?.windowMs)
|
|
243
302
|
throw unavailable();
|
|
244
303
|
return false;
|
|
245
304
|
}
|
|
246
305
|
owner.sql.exec(`INSERT INTO watchdog_jobs
|
|
247
|
-
(job_id, queue_name, lane, priority, payload_json, recovery_max,
|
|
306
|
+
(job_id, queue_name, lane, priority, payload_json, recovery_max, recovery_window_ms, not_before,
|
|
248
307
|
state, outcome, claim_token, recovery_count, cancellation_id)
|
|
249
|
-
VALUES (?, ?, ?, ?, ?, ?, 'queued', NULL, NULL, 0, NULL)`, job.jobId, job.queue, job.lane, job.priority, payloadJson, job.recovery?.maxRecoveries ?? null);
|
|
308
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', NULL, NULL, 0, NULL)`, job.jobId, job.queue, job.lane, job.priority, payloadJson, job.recovery?.maxRecoveries ?? null, job.recovery?.windowMs ?? null, job.notBefore ?? 0);
|
|
250
309
|
projectQueue(owner, project, job.queue);
|
|
251
310
|
return true;
|
|
252
311
|
}
|
|
@@ -254,6 +313,8 @@ function recordCancellationIntent(owner, project, intent) {
|
|
|
254
313
|
const prior = readJob(owner, intent.jobId);
|
|
255
314
|
if (prior === undefined)
|
|
256
315
|
return { status: "missing" };
|
|
316
|
+
if (prior.job.state === "completing" || prior.job.state === "settled")
|
|
317
|
+
return { status: "decided", outcome: prior.job.outcome };
|
|
257
318
|
if (prior.row.cancellation_id !== null)
|
|
258
319
|
return prior.row.cancellation_id === intent.cancellationId
|
|
259
320
|
? { status: "replayed", intent }
|
|
@@ -273,17 +334,10 @@ function selectAndClaim(owner, project, claimToken, partition) {
|
|
|
273
334
|
.exec(`SELECT ${columns}
|
|
274
335
|
FROM watchdog_jobs
|
|
275
336
|
WHERE state = 'queued' AND ${lane.predicate}
|
|
276
|
-
AND (cancellation_id IS NOT NULL OR
|
|
277
|
-
|
|
278
|
-
WHERE predecessor.queue_name = watchdog_jobs.queue_name
|
|
279
|
-
AND predecessor.state IN ('queued', 'running')
|
|
280
|
-
AND (predecessor.state = 'running' OR
|
|
281
|
-
(predecessor.state = 'queued' AND
|
|
282
|
-
(predecessor.priority > watchdog_jobs.priority OR
|
|
283
|
-
(predecessor.priority = watchdog_jobs.priority AND
|
|
284
|
-
predecessor.sequence < watchdog_jobs.sequence))))))
|
|
337
|
+
AND (cancellation_id IS NOT NULL OR not_before <= ?)
|
|
338
|
+
AND ${queuedHeadPredicate}
|
|
285
339
|
ORDER BY priority DESC, sequence ASC
|
|
286
|
-
LIMIT 1`, ...lane.values)
|
|
340
|
+
LIMIT 1`, ...lane.values, Date.now())
|
|
287
341
|
.toArray()[0];
|
|
288
342
|
if (raw === undefined)
|
|
289
343
|
return { status: "no_ready" };
|
|
@@ -310,13 +364,13 @@ function selectAndClaim(owner, project, claimToken, partition) {
|
|
|
310
364
|
return { status: "claimed", job: claimed, claimToken };
|
|
311
365
|
});
|
|
312
366
|
}
|
|
313
|
-
function
|
|
367
|
+
function recordExecutionDecision(owner, input) {
|
|
314
368
|
return owner.transactionSync(() => {
|
|
315
369
|
const prior = readJob(owner, input.jobId);
|
|
316
370
|
if (prior === undefined)
|
|
317
371
|
return { status: "missing" };
|
|
318
372
|
const outcome = prior.row.cancellation_id === null ? input.decision.kind : "cancelled";
|
|
319
|
-
if (prior.job.state === "settled")
|
|
373
|
+
if (prior.job.state === "completing" || prior.job.state === "settled")
|
|
320
374
|
return prior.row.claim_token === input.claimToken &&
|
|
321
375
|
prior.job.outcome === outcome
|
|
322
376
|
? { status: "replayed", job: prior.job }
|
|
@@ -324,11 +378,28 @@ function settleExactClaim(owner, project, input) {
|
|
|
324
378
|
if (prior.job.state !== "running" ||
|
|
325
379
|
prior.row.claim_token !== input.claimToken)
|
|
326
380
|
return { status: "conflict" };
|
|
327
|
-
owner.sql.exec(`UPDATE watchdog_jobs
|
|
328
|
-
|
|
329
|
-
WHERE job_id = ? AND state = 'running' AND claim_token = ?`, outcome, outcome === "failed" && input.decision.kind === "failed"
|
|
381
|
+
owner.sql.exec(`UPDATE watchdog_jobs SET state = 'completing', outcome = ?, failure_reason = ?
|
|
382
|
+
WHERE job_id = ? AND state = 'running' AND claim_token = ?`, outcome, outcome === "failed" && input.decision.kind === "failed"
|
|
330
383
|
? (input.decision.reason ?? null)
|
|
331
384
|
: null, input.jobId, input.claimToken);
|
|
385
|
+
const decided = readJob(owner, input.jobId)?.job;
|
|
386
|
+
if (decided?.state !== "completing")
|
|
387
|
+
return { status: "conflict" };
|
|
388
|
+
return { status: "recorded", job: decided };
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
function finishCompletingClaim(owner, project, input) {
|
|
392
|
+
return owner.transactionSync(() => {
|
|
393
|
+
const prior = readJob(owner, input.jobId);
|
|
394
|
+
if (prior === undefined)
|
|
395
|
+
return { status: "missing" };
|
|
396
|
+
if (prior.row.claim_token !== input.claimToken)
|
|
397
|
+
return { status: "conflict" };
|
|
398
|
+
if (prior.job.state === "settled")
|
|
399
|
+
return { status: "replayed", job: prior.job };
|
|
400
|
+
if (prior.job.state !== "completing")
|
|
401
|
+
return { status: "conflict" };
|
|
402
|
+
owner.sql.exec("UPDATE watchdog_jobs SET state = 'settled' WHERE job_id = ? AND state = 'completing' AND claim_token = ?", input.jobId, input.claimToken);
|
|
332
403
|
const settled = readJob(owner, input.jobId)?.job;
|
|
333
404
|
if (settled?.state !== "settled")
|
|
334
405
|
return { status: "conflict" };
|
|
@@ -340,6 +411,19 @@ function settleExactClaim(owner, project, input) {
|
|
|
340
411
|
export function openSqliteState(owner, project, partition) {
|
|
341
412
|
prepare(owner);
|
|
342
413
|
const transactional = Object.freeze({
|
|
414
|
+
wakeState: (now) => readWakeState(owner, now),
|
|
415
|
+
readJob: (jobId) => {
|
|
416
|
+
const parsed = readJob(owner, jobId);
|
|
417
|
+
return parsed === undefined
|
|
418
|
+
? undefined
|
|
419
|
+
: { state: parsed.row.state, notBefore: parsed.row.not_before };
|
|
420
|
+
},
|
|
421
|
+
expediteQueuedJob: (jobId, now) => {
|
|
422
|
+
parseWakeTime(now);
|
|
423
|
+
return (owner.sql
|
|
424
|
+
.exec("UPDATE watchdog_jobs SET not_before=? WHERE job_id=? AND state='queued' AND not_before>? RETURNING job_id", now, jobId, now)
|
|
425
|
+
.toArray().length === 1);
|
|
426
|
+
},
|
|
343
427
|
readQueueHead: (queue) => readQueueHead(owner, queue),
|
|
344
428
|
readLatestSettledJob: (queue) => readLatestSettledJob(owner, queue),
|
|
345
429
|
enqueueAcceptedJob: (job) => {
|
|
@@ -350,9 +434,7 @@ export function openSqliteState(owner, project, partition) {
|
|
|
350
434
|
const createStore = (partition) => {
|
|
351
435
|
const lane = laneQuery(partition);
|
|
352
436
|
return Object.freeze({
|
|
353
|
-
|
|
354
|
-
.exec("SELECT 1 FROM watchdog_jobs WHERE state IN ('queued', 'running') LIMIT 1")
|
|
355
|
-
.toArray().length !== 0),
|
|
437
|
+
wakeState: (now) => storeEffect(() => readWakeState(owner, now)),
|
|
356
438
|
ingest: (job) => storeEffect(() => owner.transactionSync(() => ({
|
|
357
439
|
status: enqueue(owner, project, job) ? "queued" : "duplicate",
|
|
358
440
|
}))),
|
|
@@ -377,12 +459,51 @@ export function openSqliteState(owner, project, partition) {
|
|
|
377
459
|
};
|
|
378
460
|
}),
|
|
379
461
|
readQueueHead: (queue) => storeEffect(() => readQueueHead(owner, queue)),
|
|
380
|
-
readSettled: (limit, afterSequence) => storeEffect(() => readSettled(owner, limit, afterSequence, partition)),
|
|
462
|
+
readSettled: (limit, afterSequence, selectedLane) => storeEffect(() => readSettled(owner, limit, afterSequence, partition, selectedLane)),
|
|
381
463
|
selectAndClaim: (claimToken) => storeEffect(() => selectAndClaim(owner, project, claimToken, partition)),
|
|
382
|
-
|
|
464
|
+
reserveAttempt: (input) => storeEffect(() => owner.transactionSync(() => {
|
|
465
|
+
const prior = readJob(owner, input.jobId);
|
|
466
|
+
if (prior?.job.state !== "running" ||
|
|
467
|
+
prior.row.claim_token !== input.claimToken ||
|
|
468
|
+
prior.row.cancellation_id !== null)
|
|
469
|
+
throw new WatchdogError("claim_unavailable");
|
|
470
|
+
const reserved = reserveRetry(prior.row.attempt_count, Date.now(), input.policy);
|
|
471
|
+
owner.sql.exec("UPDATE watchdog_jobs SET attempt_count=?, not_before=? WHERE job_id=? AND state='running' AND claim_token=?", reserved.attempt.count, reserved.notBefore, input.jobId, input.claimToken);
|
|
472
|
+
return reserved.attempt;
|
|
473
|
+
})),
|
|
474
|
+
retryExactClaim: (input) => storeEffect(() => owner.transactionSync(() => {
|
|
475
|
+
const prior = readJob(owner, input.jobId);
|
|
476
|
+
if (prior?.job.state !== "running" ||
|
|
477
|
+
prior.row.claim_token !== input.claimToken)
|
|
478
|
+
return { status: "conflict" };
|
|
479
|
+
if (prior.row.cancellation_id !== null)
|
|
480
|
+
return { status: "cancelled" };
|
|
481
|
+
const due = input.policy === undefined
|
|
482
|
+
? prior.row.not_before
|
|
483
|
+
: retryDue(prior.row.attempt_count, Date.now(), input.policy);
|
|
484
|
+
owner.sql.exec("UPDATE watchdog_jobs SET state='queued', claim_token=NULL, not_before=? WHERE job_id=? AND state='running' AND claim_token=?", due, input.jobId, input.claimToken);
|
|
485
|
+
projectQueue(owner, project, prior.job.queue);
|
|
486
|
+
return { status: "requeued" };
|
|
487
|
+
})),
|
|
488
|
+
recordExecutionDecision: (input) => storeEffect(() => recordExecutionDecision(owner, input)),
|
|
489
|
+
nextCompletingClaim: () => storeEffect(() => {
|
|
490
|
+
const raw = owner.sql
|
|
491
|
+
.exec(`SELECT ${columns} FROM watchdog_jobs WHERE state = 'completing' AND ${lane.predicate} ORDER BY sequence LIMIT 1`, ...lane.values)
|
|
492
|
+
.toArray()[0];
|
|
493
|
+
if (raw === undefined)
|
|
494
|
+
return undefined;
|
|
495
|
+
const parsed = parseJobRow(raw);
|
|
496
|
+
if (parsed.job.state !== "completing" ||
|
|
497
|
+
parsed.row.claim_token === null)
|
|
498
|
+
throw unavailable();
|
|
499
|
+
return { job: parsed.job, claimToken: parsed.row.claim_token };
|
|
500
|
+
}),
|
|
501
|
+
finishCompletingClaim: (input) => storeEffect(() => finishCompletingClaim(owner, project, input)),
|
|
383
502
|
recordCancellation: (intent) => storeEffect(() => owner.transactionSync(() => {
|
|
384
503
|
const result = recordCancellationIntent(owner, project, intent);
|
|
385
|
-
return
|
|
504
|
+
return result.status === "decided"
|
|
505
|
+
? result
|
|
506
|
+
: { status: result.status };
|
|
386
507
|
})),
|
|
387
508
|
failLane: (raw) => storeEffect(() => {
|
|
388
509
|
const input = parseLaneFailure(raw);
|
|
@@ -390,14 +511,14 @@ export function openSqliteState(owner, project, partition) {
|
|
|
390
511
|
// Under the declared state grammar, DESC puts running before queued.
|
|
391
512
|
const rows = owner.sql
|
|
392
513
|
.exec(`SELECT ${columns} FROM watchdog_jobs
|
|
393
|
-
WHERE lane = ? AND state IN ('queued', 'running')
|
|
514
|
+
WHERE lane = ? AND state IN ('queued', 'running', 'completing')
|
|
394
515
|
ORDER BY state DESC, sequence ASC LIMIT ?`, input.lane, laneFailureBatchSize)
|
|
395
516
|
.toArray();
|
|
396
517
|
const jobs = [];
|
|
397
518
|
for (const row of rows) {
|
|
398
519
|
const prior = parseJobRow(row);
|
|
399
520
|
owner.sql.exec(`UPDATE watchdog_jobs SET state = 'settled', outcome = 'failed', failure_reason = ?, claim_token = NULL
|
|
400
|
-
WHERE job_id = ? AND state IN ('queued', 'running')`, input.reason, prior.job.jobId);
|
|
521
|
+
WHERE job_id = ? AND state IN ('queued', 'running', 'completing')`, input.reason, prior.job.jobId);
|
|
401
522
|
const settled = readJob(owner, prior.job.jobId)?.job;
|
|
402
523
|
if (settled?.state !== "settled" || settled.outcome !== "failed")
|
|
403
524
|
throw unavailable();
|
|
@@ -405,7 +526,7 @@ export function openSqliteState(owner, project, partition) {
|
|
|
405
526
|
jobs.push(settled);
|
|
406
527
|
}
|
|
407
528
|
const more = owner.sql
|
|
408
|
-
.exec("SELECT 1 FROM watchdog_jobs WHERE lane = ? AND state IN ('queued', 'running') LIMIT 1", input.lane)
|
|
529
|
+
.exec("SELECT 1 FROM watchdog_jobs WHERE lane = ? AND state IN ('queued', 'running', 'completing') LIMIT 1", input.lane)
|
|
409
530
|
.toArray().length !== 0;
|
|
410
531
|
return { jobs, more };
|
|
411
532
|
});
|
|
@@ -417,6 +538,19 @@ export function openSqliteState(owner, project, partition) {
|
|
|
417
538
|
projectQueue(owner, project, queue);
|
|
418
539
|
});
|
|
419
540
|
}),
|
|
541
|
+
handoffExactClaim: (input) => storeEffect(() => owner.transactionSync(() => {
|
|
542
|
+
const prior = readJob(owner, input.jobId);
|
|
543
|
+
if (prior === undefined)
|
|
544
|
+
return { status: "missing" };
|
|
545
|
+
if (prior.job.state !== "running" ||
|
|
546
|
+
prior.row.claim_token !== input.claimToken)
|
|
547
|
+
return { status: "conflict" };
|
|
548
|
+
if (prior.row.cancellation_id !== null)
|
|
549
|
+
return { status: "cancelled" };
|
|
550
|
+
owner.sql.exec("UPDATE watchdog_jobs SET state = 'queued', claim_token = NULL WHERE job_id = ? AND state = 'running' AND claim_token = ?", input.jobId, input.claimToken);
|
|
551
|
+
projectQueue(owner, project, prior.job.queue);
|
|
552
|
+
return { status: "requeued" };
|
|
553
|
+
})),
|
|
420
554
|
nextRunningClaim: () => storeEffect(() => {
|
|
421
555
|
const raw = owner.sql
|
|
422
556
|
.exec(`SELECT ${columns}
|
|
@@ -444,12 +578,19 @@ export function openSqliteState(owner, project, partition) {
|
|
|
444
578
|
prior.row.claim_token !== input.claimToken ||
|
|
445
579
|
prior.row.recovery_count !== input.recoveryCount)
|
|
446
580
|
return { status: "conflict" };
|
|
447
|
-
|
|
581
|
+
const recovery = input.requeue
|
|
582
|
+
? {
|
|
583
|
+
kind: "requeue",
|
|
584
|
+
recoveryCount: prior.row.recovery_count,
|
|
585
|
+
windowStartedAt: prior.row.recovery_window_started_at,
|
|
586
|
+
}
|
|
587
|
+
: disappearanceRecovery(prior.job.recovery, prior.row.recovery_count, prior.row.recovery_window_started_at, Date.now());
|
|
588
|
+
if (recovery.kind === "requeue" &&
|
|
448
589
|
prior.row.cancellation_id === null) {
|
|
449
590
|
owner.sql.exec(`UPDATE watchdog_jobs
|
|
450
591
|
SET state = 'queued', claim_token = NULL,
|
|
451
|
-
recovery_count =
|
|
452
|
-
WHERE job_id = ? AND state = 'running' AND claim_token = ?`, input.job.jobId, input.claimToken);
|
|
592
|
+
recovery_count = ?, recovery_window_started_at = ?
|
|
593
|
+
WHERE job_id = ? AND state = 'running' AND claim_token = ?`, recovery.recoveryCount, recovery.windowStartedAt, input.job.jobId, input.claimToken);
|
|
453
594
|
projectQueue(owner, project, input.job.queue);
|
|
454
595
|
return { status: "requeued" };
|
|
455
596
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Typed failures owned by the generic Watchdog engine. */
|
|
2
2
|
export declare class WatchdogError extends Error {
|
|
3
3
|
readonly _tag: "WatchdogError";
|
|
4
|
-
readonly cause: "store_unavailable" | "store_invalid" | "claim_unavailable" | "execution_failed" | "settlement_rejected" | "wake_unavailable" | "liveness_unavailable";
|
|
4
|
+
readonly cause: "store_unavailable" | "store_invalid" | "claim_unavailable" | "drain_failed" | "execution_failed" | "settlement_rejected" | "wake_unavailable" | "liveness_unavailable";
|
|
5
5
|
readonly underlying?: unknown;
|
|
6
|
-
constructor(failureKind: "store_unavailable" | "store_invalid" | "claim_unavailable" | "execution_failed" | "settlement_rejected" | "wake_unavailable" | "liveness_unavailable",
|
|
6
|
+
constructor(failureKind: "store_unavailable" | "store_invalid" | "claim_unavailable" | "drain_failed" | "execution_failed" | "settlement_rejected" | "wake_unavailable" | "liveness_unavailable",
|
|
7
7
|
/**
|
|
8
8
|
* The original thrown value, kept for server-side diagnosis only. `cause`
|
|
9
9
|
* is already the closed category above, so this is a separate field.
|
package/dist/postgres.js
CHANGED
|
@@ -5,6 +5,7 @@ export const watchdogPostgresSchema = schemaStatements(true);
|
|
|
5
5
|
/** Open the shared Effect engine on a migrated PostgreSQL database. */
|
|
6
6
|
export function createPostgresWatchdog(options) {
|
|
7
7
|
const runtime = createAsyncRuntime({
|
|
8
|
+
dialect: "postgres",
|
|
8
9
|
clock: options.now === undefined
|
|
9
10
|
? "EXTRACT(EPOCH FROM clock_timestamp())*1000"
|
|
10
11
|
: undefined,
|
package/docs/api/d1.md
CHANGED
|
@@ -65,6 +65,7 @@ Parameterized native purge; caller commits it with its own erasure facts.
|
|
|
65
65
|
```ts
|
|
66
66
|
type AsyncWatchdogOptions = Readonly<{
|
|
67
67
|
execute: WatchdogExecutor;
|
|
68
|
+
retryPolicy?: WatchdogOptions["retryPolicy"];
|
|
68
69
|
wake: Readonly<{
|
|
69
70
|
recompute: () => Promise<void>;
|
|
70
71
|
}>;
|
|
@@ -107,9 +108,9 @@ writes.
|
|
|
107
108
|
|
|
108
109
|
#### Parameters
|
|
109
110
|
|
|
110
|
-
| Parameter | Type
|
|
111
|
-
| --------- |
|
|
112
|
-
| `options` | `Readonly`\<\{ `execute`: [`WatchdogExecutor`](index.md#watchdogexecutor); `wake`: `Readonly`\<\{ `recompute`: () => `Promise`\<`void`\>; \}\>; `now?`: () => `number`; \}\> & `Readonly`\<\{ `database`: `D1Database`; \}\> |
|
|
111
|
+
| Parameter | Type |
|
|
112
|
+
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
113
|
+
| `options` | `Readonly`\<\{ `execute`: [`WatchdogExecutor`](index.md#watchdogexecutor); `retryPolicy?`: (`job`) => `Promise`\< \| `Readonly`\<\{ `baseMs`: `number`; `maxMs`: `number`; `limit`: `Readonly`\<...\> \| `Readonly`\<...\>; \}\> \| `undefined`\>; `wake`: `Readonly`\<\{ `recompute`: () => `Promise`\<`void`\>; \}\>; `now?`: () => `number`; \}\> & `Readonly`\<\{ `database`: `D1Database`; \}\> |
|
|
113
114
|
|
|
114
115
|
#### Returns
|
|
115
116
|
|