@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.
@@ -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
- state: Schema.Literals(["queued", "running", "settled"]),
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
- const common = row.recovery_max === null
91
- ? {
92
- jobId: row.job_id,
93
- queue: row.queue_name,
94
- lane: row.lane,
95
- priority: row.priority,
96
- payload: payload.value,
97
- }
98
- : {
99
- jobId: row.job_id,
100
- queue: row.queue_name,
101
- lane: row.lane,
102
- priority: row.priority,
103
- payload: payload.value,
104
- recovery: { maxRecoveries: row.recovery_max },
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: "settled",
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 lane = laneQuery(partition);
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 'running' THEN 0 ELSE 1 END,
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
- state TEXT NOT NULL CHECK (state IN ('queued', 'running', 'settled')),
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 NOT EXISTS (
277
- SELECT 1 FROM watchdog_jobs predecessor INDEXED BY idx_watchdog_queue_head
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 settleExactClaim(owner, project, input) {
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
- SET state = 'settled', outcome = ?, failure_reason = ?
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
- hasPendingWork: storeEffect(() => owner.sql
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
- settleExactClaim: (input) => storeEffect(() => settleExactClaim(owner, project, input)),
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 { status: result.status };
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
- if (input.disposition === "requeue" &&
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 = recovery_count + 1
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