@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/dist/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import * as core from "./effect.js";
2
2
  export { WatchdogError } from "./internal/watchdog-error.js";
3
- export { WatchdogCancellationIntentSchema, WatchdogCancellationResultSchema, WatchdogExecutionDecisionSchema, WatchdogIngestResultSchema, WatchdogQueuedJobSchema, WatchdogQueueHeadSchema, WatchdogReadResultSchema, WatchdogRunningJobSchema, WatchdogSettledJobSchema, WatchdogStoredJobSchema, } from "./effect.js";
4
- export type { EffectWatchdogOptions, EffectWatchdogExecutor, WatchdogCancellationDecision, WatchdogCancellationIntent, WatchdogCancellationResult, WatchdogExecutionDecision, WatchdogIngestResult, WatchdogLivenessUnknown, WatchdogLaneFailure, WatchdogLaneFailureResult, WatchdogQueuedJob, WatchdogQueueHead, WatchdogQueueProjector, WatchdogQueueTransition, WatchdogReadResult, WatchdogRunningJob, WatchdogRuntimeEffect, ConcurrentWatchdogRuntimeEffect, WatchdogSettledJob, WatchdogSqliteCursor, WatchdogSqliteOwner, WatchdogSqliteValue, WatchdogSqliteRow, WatchdogStoredJob, WatchdogTickResult, WatchdogTransactionalProjection, } from "./effect.js";
3
+ export { WatchdogCompletingJobSchema, WatchdogCancellationIntentSchema, WatchdogCancellationResultSchema, WatchdogExecutionDecisionSchema, WatchdogIngestResultSchema, WatchdogQueuedJobSchema, WatchdogQueueHeadSchema, WatchdogReadResultSchema, WatchdogRunningJobSchema, WatchdogSettledJobSchema, WatchdogStoredJobSchema, } from "./effect.js";
4
+ export type { EffectWatchdogOptions, EffectWatchdogExecutor, WatchdogCompletingJob, WatchdogCancellationDecision, WatchdogCancellationIntent, WatchdogCancellationResult, WatchdogExecutionDecision, WatchdogIngestResult, WatchdogLivenessUnknown, WatchdogLaneFailure, WatchdogLaneFailureResult, WatchdogQueuedJob, WatchdogQueueHead, WatchdogQueueProjector, WatchdogQueueTransition, WatchdogReadResult, WatchdogRunningJob, WatchdogRuntimeEffect, ConcurrentWatchdogRuntimeEffect, WatchdogSettledJob, WatchdogSqliteCursor, WatchdogSqliteOwner, WatchdogSqliteValue, WatchdogSqliteRow, WatchdogStoredJob, WatchdogTickResult, WatchdogTransactionalProjection, WatchdogWakeState, WatchdogRetryPolicy, WatchdogAttempt, } from "./effect.js";
5
5
  /** Promise host executor for one generic running job. */
6
- export type WatchdogExecutor = (job: core.WatchdogRunningJob, signal: AbortSignal) => Promise<core.WatchdogExecutionDecision>;
6
+ export type WatchdogExecutor = (job: core.WatchdogRunningJob, signal: AbortSignal, attempt: core.WatchdogAttempt | undefined) => Promise<core.WatchdogExecutionDecision>;
7
7
  /** Options for constructing the Promise facade. */
8
8
  export type WatchdogOptions = Readonly<{
9
9
  /** Optional opaque lane with its own single-flight execution slot. */
@@ -14,6 +14,8 @@ export type WatchdogOptions = Readonly<{
14
14
  isAlive: (job: core.WatchdogRunningJob) => Promise<boolean>;
15
15
  /** Executes one generic claimed job. */
16
16
  execute: WatchdogExecutor;
17
+ /** Resolve the code-owned delivery policy before reserving a receiver attempt. */
18
+ retryPolicy?: (job: core.WatchdogRunningJob) => Promise<core.WatchdogRetryPolicy | undefined>;
17
19
  /** Recomputes the owner wake after durable state changes. */
18
20
  wake: Readonly<{
19
21
  /** Arms the next wake from current owner state. */
@@ -24,8 +26,8 @@ export type WatchdogOptions = Readonly<{
24
26
  }>;
25
27
  /** Promise facade over the canonical Effect runtime. */
26
28
  export type WatchdogRuntime = Readonly<{
27
- /** Whether any queued or running job still needs owner reconciliation. */
28
- hasPendingWork: () => Promise<boolean>;
29
+ /** Read readiness and the next deadline from the same durable work rows. */
30
+ wakeState: (now: number) => Promise<core.WatchdogWakeState>;
29
31
  /** Persists one queued job. */
30
32
  ingest: (job: core.WatchdogQueuedJob) => Promise<core.WatchdogIngestResult>;
31
33
  /** Reads one durable job. */
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import * as core from "./effect.js";
6
6
  import { adaptPromiseWatchdogHost } from "./internal/promise-watchdog-host.js";
7
7
  import { WatchdogError } from "./internal/watchdog-error.js";
8
8
  export { WatchdogError } from "./internal/watchdog-error.js";
9
- export { WatchdogCancellationIntentSchema, WatchdogCancellationResultSchema, WatchdogExecutionDecisionSchema, WatchdogIngestResultSchema, WatchdogQueuedJobSchema, WatchdogQueueHeadSchema, WatchdogReadResultSchema, WatchdogRunningJobSchema, WatchdogSettledJobSchema, WatchdogStoredJobSchema, } from "./effect.js";
9
+ export { WatchdogCompletingJobSchema, WatchdogCancellationIntentSchema, WatchdogCancellationResultSchema, WatchdogExecutionDecisionSchema, WatchdogIngestResultSchema, WatchdogQueuedJobSchema, WatchdogQueueHeadSchema, WatchdogReadResultSchema, WatchdogRunningJobSchema, WatchdogSettledJobSchema, WatchdogStoredJobSchema, } from "./effect.js";
10
10
  const runPromise = (effect) => Effect.runPromiseExit(effect).then((exit) => {
11
11
  if (Exit.isSuccess(exit))
12
12
  return exit.value;
@@ -25,7 +25,7 @@ function make(options) {
25
25
  }
26
26
  function promiseRuntime(runtime) {
27
27
  return Object.freeze({
28
- hasPendingWork: () => runPromise(runtime.hasPendingWork),
28
+ wakeState: (now) => runPromise(runtime.wakeState(now)),
29
29
  ingest: (job) => runPromise(runtime.ingest(job)),
30
30
  read: (jobId) => runPromise(runtime.read(jobId)),
31
31
  readQueueHead: (queue) => runPromise(runtime.readQueueHead(queue)),
@@ -1,9 +1,11 @@
1
- import type { WatchdogExecutor, WatchdogRuntime } from "../index.js";
1
+ import type { WatchdogExecutor, WatchdogRuntime, WatchdogOptions } from "../index.js";
2
2
  import type { SqlDatabase } from "./async-sql.js";
3
3
  /** Host execution and wake scheduling for a shared-database Watchdog. */
4
4
  export type AsyncWatchdogOptions = Readonly<{
5
5
  /** Execute one claimed job; honour abort when its lease is lost. */
6
6
  execute: WatchdogExecutor;
7
+ /** Resolve the delivery owner policy before receiver attempt admission. */
8
+ retryPolicy?: WatchdogOptions["retryPolicy"];
7
9
  /** Recompute a durable wake after a transition commits. */
8
10
  wake: Readonly<{
9
11
  /** Arm the next durable owner wake. */
@@ -19,15 +19,24 @@ export function createAsyncRuntime(database, options) {
19
19
  const runtime = bindWatchdogEngine({
20
20
  isAlive: (job) => state.isAlive(job.jobId),
21
21
  executor: {
22
- execute: (job) => renew(job.jobId).pipe(Effect.andThen(Effect.raceFirst(Effect.tryPromise({
23
- try: (signal) => options.execute(job, signal),
22
+ retryPolicy: (job) => {
23
+ const policy = options.retryPolicy;
24
+ return policy === undefined
25
+ ? Effect.succeed(undefined)
26
+ : Effect.tryPromise({
27
+ try: () => policy(job),
28
+ catch: (error) => new WatchdogError("execution_failed", error),
29
+ });
30
+ },
31
+ execute: (job, reserved) => renew(job.jobId).pipe(Effect.andThen(Effect.raceFirst(Effect.tryPromise({
32
+ try: (signal) => options.execute(job, signal, reserved),
24
33
  catch: () => new WatchdogError("execution_failed"),
25
34
  }), Effect.forever(Effect.sleep(heartbeatMs).pipe(Effect.andThen(renew(job.jobId))))))),
26
35
  },
27
36
  wake: { recompute: () => attempt(options.wake.recompute) },
28
37
  }, state.store);
29
38
  return {
30
- hasPendingWork: () => run(runtime.hasPendingWork),
39
+ wakeState: (now) => run(runtime.wakeState(now)),
31
40
  ingest: (job) => run(runtime.ingest(job)),
32
41
  read: (id) => run(runtime.read(id)),
33
42
  readQueueHead: (queue) => run(runtime.readQueueHead(queue)),
@@ -6,10 +6,11 @@ export type Statement = Readonly<{
6
6
  values: readonly SqlValue[];
7
7
  }>;
8
8
  export type SqlDatabase = Readonly<{
9
+ dialect: "sqlite" | "postgres";
9
10
  clock?: string | undefined;
10
11
  query: (statement: Statement) => Promise<readonly SqlRow[]>;
11
12
  }>;
12
- export declare const columns = "sequence, job_id, queue_name, lane, priority, payload_json,\n recovery_max, state, outcome, claim_token, recovery_count, cancellation_id, failure_reason";
13
+ export declare const columns = "sequence, job_id, queue_name, lane, priority, payload_json, not_before, attempt_count,\n recovery_max, recovery_window_ms, recovery_window_started_at, state, outcome, claim_token, recovery_count, cancellation_id, failure_reason";
13
14
  export declare function schemaStatements(postgres: boolean): readonly string[];
14
15
  export declare function enqueueSelect(source: Statement): Statement;
15
16
  export declare function enqueueStatement(input: WatchdogQueuedJob): Statement;
@@ -1,8 +1,8 @@
1
1
  import * as Schema from "effect/Schema";
2
2
  import { WatchdogQueuedJobSchema } from "../effect.js";
3
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`;
4
+ export const columns = `sequence, job_id, queue_name, lane, priority, payload_json, not_before, attempt_count,
5
+ recovery_max, recovery_window_ms, recovery_window_started_at, state, outcome, claim_token, recovery_count, cancellation_id, failure_reason`;
6
6
  export function schemaStatements(postgres) {
7
7
  return [
8
8
  `CREATE TABLE IF NOT EXISTS watchdog_jobs (
@@ -12,20 +12,25 @@ export function schemaStatements(postgres) {
12
12
  lane TEXT NOT NULL CHECK(length(lane) BETWEEN 1 AND ${String(laneMaxLength)}),
13
13
  priority ${postgres ? "DOUBLE PRECISION" : "INTEGER"} NOT NULL,
14
14
  payload_json TEXT NOT NULL,
15
+ not_before ${postgres ? "DOUBLE PRECISION" : "INTEGER"} NOT NULL DEFAULT 0 CHECK(not_before>=0),
16
+ attempt_count ${postgres ? "DOUBLE PRECISION" : "INTEGER"} NOT NULL DEFAULT 0 CHECK(attempt_count>=0),
15
17
  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')),
18
+ recovery_window_ms ${postgres ? "DOUBLE PRECISION" : "INTEGER"} CHECK(recovery_window_ms IS NULL OR recovery_window_ms > 0),
19
+ recovery_window_started_at ${postgres ? "DOUBLE PRECISION" : "INTEGER"},
20
+ state TEXT NOT NULL CHECK(state IN ('queued','running','completing','settled')),
17
21
  outcome TEXT CHECK(outcome IN ('completed','failed','cancelled','outcome_unknown','interrupted')),
18
22
  claim_token TEXT,
19
23
  recovery_count ${postgres ? "DOUBLE PRECISION" : "INTEGER"} NOT NULL DEFAULT 0,
20
24
  cancellation_id TEXT UNIQUE,
21
25
  failure_reason TEXT,
22
26
  lease_until ${postgres ? "DOUBLE PRECISION" : "INTEGER"},
23
- CHECK ((state='queued' AND outcome IS NULL AND claim_token IS NULL) OR
27
+ CONSTRAINT watchdog_execution_state CHECK ((state='queued' AND outcome IS NULL AND claim_token IS NULL) OR
24
28
  (state='running' AND outcome IS NULL AND claim_token IS NOT NULL AND lease_until IS NOT NULL) OR
29
+ (state='completing' AND outcome IS NOT NULL AND claim_token IS NOT NULL) OR
25
30
  (state='settled' AND outcome IS NOT NULL))
26
31
  )`,
27
32
  "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)",
33
+ "CREATE INDEX IF NOT EXISTS idx_watchdog_ready ON watchdog_jobs(state,not_before,priority DESC,sequence)",
29
34
  "CREATE INDEX IF NOT EXISTS idx_watchdog_queue_head ON watchdog_jobs(queue_name,state,priority DESC,sequence)",
30
35
  "CREATE INDEX IF NOT EXISTS idx_watchdog_lease ON watchdog_jobs(state,lease_until,sequence)",
31
36
  ];
@@ -36,14 +41,16 @@ export function schemaStatements(postgres) {
36
41
  export function enqueueSelect(source) {
37
42
  return {
38
43
  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'
44
+ (job_id,queue_name,lane,priority,payload_json,recovery_max,recovery_window_ms,not_before,state)
45
+ SELECT job_id,queue_name,lane,priority,payload_json,recovery_max,recovery_window_ms,not_before,'queued'
41
46
  FROM (${source.text}) AS admission WHERE true
42
47
  ON CONFLICT(job_id) DO UPDATE SET job_id = NULL WHERE NOT (
43
48
  watchdog_jobs.queue_name=excluded.queue_name AND watchdog_jobs.lane=excluded.lane AND
44
49
  watchdog_jobs.priority=excluded.priority AND watchdog_jobs.payload_json=excluded.payload_json AND
45
50
  (watchdog_jobs.recovery_max=excluded.recovery_max OR
46
- (watchdog_jobs.recovery_max IS NULL AND excluded.recovery_max IS NULL))
51
+ (watchdog_jobs.recovery_max IS NULL AND excluded.recovery_max IS NULL)) AND
52
+ (watchdog_jobs.recovery_window_ms=excluded.recovery_window_ms OR
53
+ (watchdog_jobs.recovery_window_ms IS NULL AND excluded.recovery_window_ms IS NULL))
47
54
  )
48
55
  RETURNING ${columns}`,
49
56
  values: source.values,
@@ -53,7 +60,7 @@ export function enqueueStatement(input) {
53
60
  const job = Schema.decodeUnknownSync(WatchdogQueuedJobSchema)(input);
54
61
  return enqueueSelect({
55
62
  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`,
63
+ CAST($5 AS TEXT) AS payload_json,CAST($6 AS DOUBLE PRECISION) AS recovery_max,CAST($7 AS DOUBLE PRECISION) AS recovery_window_ms,CAST($8 AS DOUBLE PRECISION) AS not_before`,
57
64
  values: [
58
65
  job.jobId,
59
66
  job.queue,
@@ -61,6 +68,8 @@ export function enqueueStatement(input) {
61
68
  job.priority,
62
69
  Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(job.payload),
63
70
  job.recovery?.maxRecoveries ?? null,
71
+ job.recovery?.windowMs ?? null,
72
+ job.notBefore ?? 0,
64
73
  ],
65
74
  });
66
75
  }
@@ -5,7 +5,7 @@ export declare const heartbeatMs = 10000;
5
5
  export declare const attempt: <A>(run: () => Promise<A>) => Effect.Effect<A, WatchdogError>;
6
6
  export declare function openAsyncStore(database: SqlDatabase, now: () => number): {
7
7
  store: Readonly<{
8
- hasPendingWork: Effect.Effect<boolean, WatchdogError>;
8
+ wakeState: (now: number) => Effect.Effect<import("../effect.js").WatchdogWakeState, WatchdogError>;
9
9
  ingest: (job: import("../effect.js").WatchdogQueuedJob) => Effect.Effect<import("../effect.js").WatchdogIngestResult, WatchdogError>;
10
10
  read: (jobId: string) => Effect.Effect<(Extract<import("../effect.js").WatchdogReadResult, {
11
11
  status: "found";
@@ -16,24 +16,57 @@ export declare function openAsyncStore(database: SqlDatabase, now: () => number)
16
16
  status: "missing";
17
17
  }>, WatchdogError>;
18
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>;
19
+ readSettled: (limit: number, afterSequence?: number, lane?: string) => Effect.Effect<readonly import("../effect.js").WatchdogSettledRecord[], WatchdogError>;
20
20
  selectAndClaim: (claimToken: string) => Effect.Effect<import("./sqlite-store.js").WatchdogSelection, WatchdogError>;
21
- settleExactClaim: (input: Readonly<{
21
+ reserveAttempt: (input: {
22
22
  jobId: string;
23
23
  claimToken: string;
24
- decision: import("../effect.js").WatchdogExecutionDecision;
24
+ policy: import("../effect.js").WatchdogRetryPolicy;
25
+ }) => Effect.Effect<import("../effect.js").WatchdogAttempt, WatchdogError>;
26
+ retryExactClaim: (input: {
27
+ jobId: string;
28
+ claimToken: string;
29
+ policy?: import("../effect.js").WatchdogRetryPolicy;
30
+ }) => Effect.Effect<{
31
+ status: "requeued" | "cancelled" | "conflict";
32
+ }, WatchdogError>;
33
+ recordExecutionDecision: (input: Readonly<{
34
+ jobId: string;
35
+ claimToken: string;
36
+ decision: Exclude<import("../effect.js").WatchdogExecutionDecision, {
37
+ kind: "retry";
38
+ }>;
25
39
  }>) => Effect.Effect<Readonly<{
26
- status: "settled" | "replayed";
27
- job: import("../effect.js").WatchdogSettledJob;
40
+ status: "recorded" | "replayed";
41
+ job: import("../effect.js").WatchdogCompletingJob | import("../effect.js").WatchdogSettledJob;
28
42
  }> | Readonly<{
29
43
  status: "conflict" | "missing";
30
44
  }>, WatchdogError>;
45
+ nextCompletingClaim: () => Effect.Effect<{
46
+ job: import("../effect.js").WatchdogCompletingJob;
47
+ claimToken: string;
48
+ } | undefined, WatchdogError>;
49
+ finishCompletingClaim: (input: {
50
+ jobId: string;
51
+ claimToken: string;
52
+ }) => Effect.Effect<{
53
+ status: "settled" | "replayed";
54
+ job: import("../effect.js").WatchdogSettledJob;
55
+ } | {
56
+ status: "conflict" | "missing";
57
+ }, WatchdogError>;
31
58
  recordCancellation: (input: import("../effect.js").WatchdogCancellationIntent) => Effect.Effect<import("../effect.js").WatchdogCancellationResult, WatchdogError>;
32
59
  failLane: (input: import("../effect.js").WatchdogLaneFailure) => Effect.Effect<Readonly<{
33
60
  jobs: readonly import("../effect.js").WatchdogSettledJob[];
34
61
  more: boolean;
35
62
  }>, WatchdogError>;
36
63
  purgeQueued: (queue: string) => Effect.Effect<void, WatchdogError>;
64
+ handoffExactClaim: (input: {
65
+ jobId: string;
66
+ claimToken: string;
67
+ }) => Effect.Effect<{
68
+ status: "requeued" | "cancelled" | "conflict" | "missing";
69
+ }, WatchdogError>;
37
70
  nextRunningClaim: () => Effect.Effect<Readonly<{
38
71
  job: import("../effect.js").WatchdogRunningJob;
39
72
  claimToken: string;
@@ -43,7 +76,7 @@ export declare function openAsyncStore(database: SqlDatabase, now: () => number)
43
76
  job: import("../effect.js").WatchdogRunningJob;
44
77
  claimToken: string;
45
78
  recoveryCount: number;
46
- disposition: "requeue" | "interrupted";
79
+ requeue?: boolean;
47
80
  }>) => Effect.Effect<Readonly<{
48
81
  status: "requeued";
49
82
  }> | Readonly<{
@@ -1,6 +1,9 @@
1
+ import { laneSchema } from "./lane.js";
2
+ import { reserveRetry, retryDue } from "./retry.js";
3
+ import { disappearanceRecovery } from "./recovery.js";
1
4
  import * as Effect from "effect/Effect";
2
5
  import * as Schema from "effect/Schema";
3
- import { parseJobRow, parseLaneFailure, laneFailureBatchSize, } from "./sqlite-store.js";
6
+ import { parseJobRow, queuedHeadPredicate, parseWakeState, parseWakeTime, parseLaneFailure, laneFailureBatchSize, } from "./sqlite-store.js";
4
7
  import { WatchdogError } from "./watchdog-error.js";
5
8
  import { columns, enqueueStatement, } from "./async-sql.js";
6
9
  const leaseMs = 120_000;
@@ -18,7 +21,30 @@ export function openAsyncStore(database, now) {
18
21
  const at = (parameter) => database.clock === undefined
19
22
  ? `CAST($${String(parameter)} AS DOUBLE PRECISION)`
20
23
  : `(CASE WHEN $${String(parameter)}>${database.clock} THEN $${String(parameter)} ELSE ${database.clock} END)`;
21
- const query = (text, values = []) => database.query({ text, values });
24
+ let schemaChecked = false;
25
+ const query = async (text, values = []) => {
26
+ if (!schemaChecked) {
27
+ const current = await database.query({
28
+ text: database.dialect === "postgres"
29
+ ? "SELECT 1 AS current FROM pg_constraint WHERE conrelid = 'watchdog_jobs'::regclass AND conname = 'watchdog_execution_state'"
30
+ : "SELECT 1 AS current FROM sqlite_schema WHERE name = 'watchdog_jobs' AND instr(sql, 'watchdog_execution_state') > 0",
31
+ values: [],
32
+ });
33
+ if (current.length !== 1)
34
+ throw new WatchdogError("store_invalid", new Error("Obsolete Watchdog execution schema"));
35
+ try {
36
+ await database.query({
37
+ text: `SELECT ${columns} FROM watchdog_jobs LIMIT 0`,
38
+ values: [],
39
+ });
40
+ }
41
+ catch (error) {
42
+ throw new WatchdogError("store_invalid", error);
43
+ }
44
+ schemaChecked = true;
45
+ }
46
+ return database.query({ text, values });
47
+ };
22
48
  const row = async (id) => {
23
49
  const raw = (await query(`SELECT ${columns} FROM watchdog_jobs WHERE job_id=$1`, [id]))[0];
24
50
  return raw === undefined ? undefined : parseJobRow(raw);
@@ -26,12 +52,25 @@ export function openAsyncStore(database, now) {
26
52
  // Private to this runtime: claims never escape through a public job.
27
53
  const claims = new Map();
28
54
  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
- })),
55
+ wakeState: (instant) => attempt(async () => {
56
+ parseWakeTime(instant);
57
+ return parseWakeState((await query(`SELECT
58
+ COALESCE(MAX(CASE WHEN state='completing' OR cancellation_id IS NOT NULL OR
59
+ (state='running' AND lease_until <= ${at(1)}) OR
60
+ (state='queued' AND not_before <= ${at(1)}) THEN 1 ELSE 0 END),0) AS pending,
61
+ MIN(CASE WHEN state='running' AND lease_until > ${at(1)} THEN lease_until
62
+ WHEN state='queued' AND cancellation_id IS NULL AND not_before > ${at(1)} THEN not_before END) AS next_wake_at
63
+ FROM watchdog_jobs WHERE state IN ('running','completing') OR
64
+ (state='queued' AND ${queuedHeadPredicate})`, [instant]))[0]);
65
+ }),
66
+ ingest: (job) => attempt(async () => {
67
+ const statement = enqueueStatement(job);
68
+ return {
69
+ status: (await query(statement.text, statement.values)).length > 0
70
+ ? "queued"
71
+ : "duplicate",
72
+ };
73
+ }),
35
74
  read: (id) => attempt(async () => {
36
75
  const observedClaim = claims.get(id);
37
76
  const found = await row(id);
@@ -60,7 +99,7 @@ export function openAsyncStore(database, now) {
60
99
  }),
61
100
  readQueueHead: (queue) => attempt(async () => {
62
101
  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,
102
+ AND state IN ('queued','running','completing') ORDER BY CASE state WHEN 'queued' THEN 1 ELSE 0 END,
64
103
  priority DESC,sequence LIMIT 1`, [queue]))[0];
65
104
  if (raw === undefined)
66
105
  return { state: "idle" };
@@ -68,15 +107,21 @@ export function openAsyncStore(database, now) {
68
107
  if (found.job.state === "settled")
69
108
  throw new WatchdogError("store_unavailable");
70
109
  return {
71
- state: found.job.state,
110
+ state: found.job.state === "completing" ? "running" : found.job.state,
72
111
  jobId: found.job.jobId,
73
112
  cancellationRequested: found.row.cancellation_id !== null,
74
113
  };
75
114
  }),
76
- readSettled: (limit, afterSequence = 0) => attempt(async () => {
115
+ readSettled: (limit, afterSequence = 0, lane) => attempt(async () => {
77
116
  Schema.decodeUnknownSync(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(256)))(limit);
78
117
  const rows = await query(`SELECT ${columns} FROM watchdog_jobs WHERE state='settled'
79
- AND sequence>$1 ORDER BY sequence LIMIT $2`, [afterSequence, limit]);
118
+ AND sequence>$1 ${lane === undefined ? "" : "AND lane=$3"} ORDER BY sequence LIMIT $2`, lane === undefined
119
+ ? [afterSequence, limit]
120
+ : [
121
+ afterSequence,
122
+ limit,
123
+ Schema.decodeUnknownSync(laneSchema)(lane),
124
+ ]);
80
125
  return rows.map((raw) => {
81
126
  const parsed = parseJobRow(raw);
82
127
  if (parsed.job.state !== "settled")
@@ -91,6 +136,8 @@ export function openAsyncStore(database, now) {
91
136
  claim_token=CASE WHEN cancellation_id IS NULL THEN $1 ELSE NULL END,
92
137
  lease_until=CASE WHEN cancellation_id IS NULL THEN ${at(2)}+${String(leaseMs)} ELSE NULL END
93
138
  WHERE job_id=(SELECT job_id FROM watchdog_jobs WHERE state='queued'
139
+ AND (cancellation_id IS NOT NULL OR not_before<=${at(2)})
140
+ AND ${queuedHeadPredicate}
94
141
  ORDER BY priority DESC,sequence LIMIT 1) AND state='queued' RETURNING ${columns}`, [token, now()]))[0];
95
142
  if (raw === undefined)
96
143
  return { status: "no_ready" };
@@ -102,9 +149,50 @@ export function openAsyncStore(database, now) {
102
149
  claims.set(parsed.job.jobId, token);
103
150
  return { status: "claimed", job: parsed.job, claimToken: token };
104
151
  }),
105
- settleExactClaim: (input) => attempt(async () => {
152
+ reserveAttempt: (input) => attempt(async () => {
153
+ const prior = await row(input.jobId);
154
+ if (prior?.job.state !== "running" ||
155
+ prior.row.claim_token !== input.claimToken ||
156
+ prior.row.cancellation_id !== null)
157
+ throw new WatchdogError("claim_unavailable");
158
+ const reserved = reserveRetry(prior.row.attempt_count, now(), input.policy);
159
+ const updated = await query(`UPDATE watchdog_jobs SET attempt_count=$1,not_before=$2 WHERE job_id=$3 AND state='running' AND claim_token=$4 AND attempt_count=$5 AND cancellation_id IS NULL AND lease_until>${at(6)} RETURNING job_id`, [
160
+ reserved.attempt.count,
161
+ reserved.notBefore,
162
+ input.jobId,
163
+ input.claimToken,
164
+ prior.row.attempt_count,
165
+ now(),
166
+ ]);
167
+ if (updated.length !== 1)
168
+ throw new WatchdogError("claim_unavailable");
169
+ return reserved.attempt;
170
+ }),
171
+ retryExactClaim: (input) => attempt(async () => {
172
+ const prior = await row(input.jobId);
173
+ if (prior?.job.state !== "running" ||
174
+ prior.row.claim_token !== input.claimToken)
175
+ return { status: "conflict" };
176
+ const due = input.policy === undefined
177
+ ? prior.row.not_before
178
+ : retryDue(prior.row.attempt_count, now(), input.policy);
179
+ const updated = await query(`UPDATE watchdog_jobs SET state='queued',claim_token=NULL,lease_until=NULL,not_before=$1 WHERE job_id=$2 AND state='running' AND claim_token=$3 AND cancellation_id IS NULL AND lease_until>${at(4)} RETURNING job_id`, [due, input.jobId, input.claimToken, now()]);
180
+ if (updated.length === 1) {
181
+ claims.delete(input.jobId);
182
+ return { status: "requeued" };
183
+ }
184
+ const current = await row(input.jobId);
185
+ return {
186
+ status: current?.job.state === "running" &&
187
+ current.row.claim_token === input.claimToken &&
188
+ current.row.cancellation_id !== null
189
+ ? "cancelled"
190
+ : "conflict",
191
+ };
192
+ }),
193
+ recordExecutionDecision: (input) => attempt(async () => {
106
194
  try {
107
- const raw = (await query(`UPDATE watchdog_jobs SET state='settled',
195
+ const raw = (await query(`UPDATE watchdog_jobs SET state='completing',
108
196
  outcome=CASE WHEN cancellation_id IS NULL THEN $1 ELSE 'cancelled' END,
109
197
  failure_reason=CASE WHEN cancellation_id IS NULL THEN $2 ELSE NULL END,lease_until=NULL
110
198
  WHERE job_id=$3 AND state='running' AND claim_token=$4 AND lease_until>${at(5)} RETURNING ${columns}`, [
@@ -118,8 +206,8 @@ export function openAsyncStore(database, now) {
118
206
  ]))[0];
119
207
  if (raw !== undefined) {
120
208
  const parsed = parseJobRow(raw);
121
- if (parsed.job.state === "settled")
122
- return { status: "settled", job: parsed.job };
209
+ if (parsed.job.state === "completing")
210
+ return { status: "recorded", job: parsed.job };
123
211
  }
124
212
  const prior = await row(input.jobId);
125
213
  if (prior === undefined)
@@ -127,7 +215,8 @@ export function openAsyncStore(database, now) {
127
215
  const outcome = prior.row.cancellation_id === null
128
216
  ? input.decision.kind
129
217
  : "cancelled";
130
- return prior.job.state === "settled" &&
218
+ return (prior.job.state === "completing" ||
219
+ prior.job.state === "settled") &&
131
220
  prior.row.claim_token === input.claimToken &&
132
221
  prior.job.outcome === outcome
133
222
  ? { status: "replayed", job: prior.job }
@@ -137,13 +226,40 @@ export function openAsyncStore(database, now) {
137
226
  claims.delete(input.jobId);
138
227
  }
139
228
  }),
229
+ nextCompletingClaim: () => attempt(async () => {
230
+ const raw = (await query(`SELECT ${columns} FROM watchdog_jobs WHERE state='completing' ORDER BY sequence LIMIT 1`))[0];
231
+ if (raw === undefined)
232
+ return undefined;
233
+ const found = parseJobRow(raw);
234
+ if (found.job.state !== "completing" || found.row.claim_token === null)
235
+ throw new WatchdogError("store_unavailable");
236
+ return { job: found.job, claimToken: found.row.claim_token };
237
+ }),
238
+ finishCompletingClaim: (input) => attempt(async () => {
239
+ const raw = (await query(`UPDATE watchdog_jobs SET state='settled' WHERE job_id=$1 AND state='completing' AND claim_token=$2 RETURNING ${columns}`, [input.jobId, input.claimToken]))[0];
240
+ if (raw !== undefined) {
241
+ const found = parseJobRow(raw);
242
+ if (found.job.state !== "settled")
243
+ throw new WatchdogError("store_unavailable");
244
+ return { status: "settled", job: found.job };
245
+ }
246
+ const prior = await row(input.jobId);
247
+ if (prior === undefined)
248
+ return { status: "missing" };
249
+ return prior.job.state === "settled" &&
250
+ prior.row.claim_token === input.claimToken
251
+ ? { status: "replayed", job: prior.job }
252
+ : { status: "conflict" };
253
+ }),
140
254
  recordCancellation: (intent) => attempt(async () => {
141
255
  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'" : ""}
256
+ AND state IN ('queued','running') AND cancellation_id IS NULL ${intent.onlyIfQueued === true ? "AND state='queued'" : ""}
143
257
  RETURNING job_id`, [intent.cancellationId, intent.jobId]);
144
258
  if (updated.length > 0)
145
259
  return { status: "recorded" };
146
260
  const prior = await row(intent.jobId);
261
+ if (prior?.job.state === "completing" || prior?.job.state === "settled")
262
+ return { status: "decided", outcome: prior.job.outcome };
147
263
  return {
148
264
  status: prior === undefined
149
265
  ? "missing"
@@ -156,9 +272,9 @@ export function openAsyncStore(database, now) {
156
272
  const input = parseLaneFailure(raw);
157
273
  // Under the declared state grammar, DESC puts running before queued.
158
274
  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')
275
+ WHERE job_id IN (SELECT job_id FROM watchdog_jobs WHERE lane=$2 AND state IN ('queued','running','completing')
160
276
  ORDER BY state DESC, sequence ASC LIMIT $3)
161
- AND state IN ('queued','running') RETURNING ${columns}`, [input.reason, input.lane, laneFailureBatchSize]);
277
+ AND state IN ('queued','running','completing') RETURNING ${columns}`, [input.reason, input.lane, laneFailureBatchSize]);
162
278
  const jobs = rows.map((row) => {
163
279
  const parsed = parseJobRow(row);
164
280
  if (parsed.job.state !== "settled")
@@ -166,12 +282,28 @@ export function openAsyncStore(database, now) {
166
282
  claims.delete(parsed.job.jobId);
167
283
  return parsed.job;
168
284
  });
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;
285
+ const more = (await query("SELECT 1 AS pending FROM watchdog_jobs WHERE lane=$1 AND state IN ('queued','running','completing') LIMIT 1", [input.lane])).length !== 0;
170
286
  return { jobs, more };
171
287
  }),
172
288
  purgeQueued: (queue) => attempt(async () => {
173
289
  await query("DELETE FROM watchdog_jobs WHERE queue_name=$1 AND state='queued'", [queue]);
174
290
  }),
291
+ handoffExactClaim: (input) => attempt(async () => {
292
+ const changed = await query(`UPDATE watchdog_jobs SET state='queued',claim_token=NULL,lease_until=NULL
293
+ WHERE job_id=$1 AND state='running' AND claim_token=$2 AND lease_until>${at(3)} AND cancellation_id IS NULL RETURNING job_id`, [input.jobId, input.claimToken, now()]);
294
+ if (changed.length > 0) {
295
+ claims.delete(input.jobId);
296
+ return { status: "requeued" };
297
+ }
298
+ const prior = await row(input.jobId);
299
+ if (prior === undefined)
300
+ return { status: "missing" };
301
+ return prior.job.state === "running" &&
302
+ prior.row.claim_token === input.claimToken &&
303
+ prior.row.cancellation_id !== null
304
+ ? { status: "cancelled" }
305
+ : { status: "conflict" };
306
+ }),
175
307
  nextRunningClaim: () => attempt(async () => {
176
308
  const raw = (await query(`SELECT ${columns} FROM watchdog_jobs WHERE state='running'
177
309
  ORDER BY lease_until,sequence LIMIT 1`))[0];
@@ -187,7 +319,21 @@ export function openAsyncStore(database, now) {
187
319
  };
188
320
  }),
189
321
  reconcileDisappearedClaim: (input) => attempt(async () => {
190
- const requeue = input.disposition === "requeue";
322
+ const prior = await row(input.job.jobId);
323
+ if (prior === undefined)
324
+ return { status: "missing" };
325
+ if (prior.job.state !== "running" ||
326
+ prior.row.claim_token !== input.claimToken ||
327
+ prior.row.recovery_count !== input.recoveryCount)
328
+ return { status: "conflict" };
329
+ const recovery = input.requeue
330
+ ? {
331
+ kind: "requeue",
332
+ recoveryCount: prior.row.recovery_count,
333
+ windowStartedAt: prior.row.recovery_window_started_at,
334
+ }
335
+ : disappearanceRecovery(prior.job.recovery, prior.row.recovery_count, prior.row.recovery_window_started_at, now());
336
+ const requeue = recovery.kind === "requeue";
191
337
  const reason = input.job.recovery === undefined
192
338
  ? "Execution disappeared without a recovery policy"
193
339
  : `Restart recovery budget exhausted (${String(input.job.recovery.maxRecoveries)} recoveries)`;
@@ -195,15 +341,22 @@ export function openAsyncStore(database, now) {
195
341
  state=CASE WHEN $1=1 AND cancellation_id IS NULL THEN 'queued' ELSE 'settled' END,
196
342
  outcome=CASE WHEN cancellation_id IS NOT NULL THEN 'cancelled' WHEN $1=1 THEN NULL ELSE 'interrupted' END,
197
343
  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,
344
+ recovery_count=CASE WHEN $1=1 AND cancellation_id IS NULL THEN $6 ELSE recovery_count END,
345
+ recovery_window_started_at=CASE WHEN $1=1 AND cancellation_id IS NULL THEN $7 ELSE recovery_window_started_at END,
199
346
  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)}
347
+ WHERE job_id=$3 AND state='running' AND claim_token=$4 AND recovery_count=$5 AND lease_until<=${at(8)}
201
348
  RETURNING ${columns}`, [
202
349
  requeue ? 1 : 0,
203
350
  reason,
204
351
  input.job.jobId,
205
352
  input.claimToken,
206
353
  input.recoveryCount,
354
+ recovery.kind === "requeue"
355
+ ? recovery.recoveryCount
356
+ : input.recoveryCount,
357
+ recovery.kind === "requeue"
358
+ ? recovery.windowStartedAt
359
+ : prior.row.recovery_window_started_at,
207
360
  now(),
208
361
  ]))[0];
209
362
  if (raw === undefined)
@@ -10,8 +10,17 @@ export const adaptPromiseWatchdogHost = (options) => {
10
10
  catch: (error) => new WatchdogError("liveness_unavailable", error),
11
11
  }),
12
12
  executor: {
13
- execute: (job) => Effect.tryPromise({
14
- try: (signal) => options.execute(job, signal),
13
+ retryPolicy: (job) => {
14
+ const policy = options.retryPolicy;
15
+ return policy === undefined
16
+ ? Effect.succeed(undefined)
17
+ : Effect.tryPromise({
18
+ try: () => policy(job),
19
+ catch: (error) => new WatchdogError("execution_failed", error),
20
+ });
21
+ },
22
+ execute: (job, attempt) => Effect.tryPromise({
23
+ try: (signal) => options.execute(job, signal, attempt),
15
24
  catch: () => new WatchdogError("execution_failed"),
16
25
  }),
17
26
  },
@@ -0,0 +1,9 @@
1
+ import type { WatchdogRunningJob } from "../effect.js";
2
+ /** Decide one unexpected disappearance from its durable count and window. */
3
+ export declare function disappearanceRecovery(policy: WatchdogRunningJob["recovery"], recoveryCount: number, windowStartedAt: number | null, now: number): Readonly<{
4
+ kind: "interrupted";
5
+ }> | Readonly<{
6
+ kind: "requeue";
7
+ recoveryCount: number;
8
+ windowStartedAt: number | null;
9
+ }>;