@ultimat3/jobs 23.0.0 → 25.0.0

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.
Files changed (70) hide show
  1. package/CLAUDE.md +72 -75
  2. package/README.md +83 -38
  3. package/package.json +7 -7
  4. package/src/backfill-errors.ts +3 -2
  5. package/src/backfill-ledger.ts +25 -3
  6. package/src/backfill-pass.ts +22 -5
  7. package/src/backfill-rate.ts +2 -2
  8. package/src/backfill.ts +2 -2
  9. package/src/claim-exhausted.ts +76 -0
  10. package/src/counters.ts +1 -1
  11. package/src/driver-memory-operator.ts +34 -12
  12. package/src/driver-memory.ts +80 -38
  13. package/src/driver-pg-jobs-sql.ts +27 -19
  14. package/src/driver-pg-operator-sql.ts +32 -12
  15. package/src/driver-pg-operator.ts +19 -3
  16. package/src/driver-pg-rows.ts +3 -3
  17. package/src/driver-pg-settle-sql.ts +2 -3
  18. package/src/driver-pg-sql.ts +99 -30
  19. package/src/driver-pg.ts +103 -98
  20. package/src/driver.ts +46 -16
  21. package/src/errors-concurrency.ts +1 -1
  22. package/src/errors-operator.ts +12 -4
  23. package/src/errors-requeue.ts +21 -3
  24. package/src/errors.ts +9 -17
  25. package/src/events-pg.ts +11 -21
  26. package/src/events.ts +26 -15
  27. package/src/execute.ts +47 -5
  28. package/src/export.ts +2 -2
  29. package/src/heartbeat.ts +7 -2
  30. package/src/index.ts +45 -99
  31. package/src/inspect-operator.ts +4 -4
  32. package/src/inspect.ts +5 -4
  33. package/src/introspection.ts +23 -11
  34. package/src/job.ts +6 -7
  35. package/src/leases.ts +1 -1
  36. package/src/limits.ts +46 -15
  37. package/src/outbox-lease.ts +1 -1
  38. package/src/outbox-pg.ts +6 -7
  39. package/src/outbox-relay.ts +6 -6
  40. package/src/outbox.ts +47 -19
  41. package/src/purge.ts +1 -1
  42. package/src/queue-wake.ts +1 -2
  43. package/src/renewal-timer.ts +1 -1
  44. package/src/retry-classification.ts +52 -15
  45. package/src/scheduler-leader.ts +2 -2
  46. package/src/scheduler-pg.ts +19 -16
  47. package/src/scheduler-state.ts +10 -3
  48. package/src/scheduler.ts +14 -15
  49. package/src/steps-memory.ts +2 -2
  50. package/src/steps-suspension.ts +46 -0
  51. package/src/steps-timeout.ts +33 -0
  52. package/src/steps.ts +90 -88
  53. package/src/task.ts +1 -1
  54. package/src/webhook-attempt.ts +206 -0
  55. package/src/webhook-ledger.ts +15 -4
  56. package/src/webhook.ts +64 -118
  57. package/src/worker-admit.ts +14 -6
  58. package/src/worker-drain-cutoff.ts +110 -0
  59. package/src/worker-fleet-slots.ts +82 -45
  60. package/src/worker-held.ts +100 -0
  61. package/src/worker-key-busy.ts +38 -10
  62. package/src/worker-loop.ts +35 -11
  63. package/src/worker-options.ts +19 -15
  64. package/src/worker-registry.ts +16 -1
  65. package/src/worker-run.ts +38 -8
  66. package/src/worker-tally.ts +53 -0
  67. package/src/worker-types.ts +7 -4
  68. package/src/worker.ts +124 -73
  69. package/src/driver-nats.ts +0 -86
  70. package/src/driver-redis.ts +0 -89
@@ -3,11 +3,10 @@
3
3
  // both failures are silent — a redeployed pod arms to tomorrow and never reports the 03:00 run the
4
4
  // pod it replaced dropped, and two pods in a rolling update both dispatch every task.
5
5
 
6
- import type { Clock } from '@ultimat3/core';
7
- import { finiteOption, uuid } from '@ultimat3/core';
6
+ import type { Clock, PgExecutor } from '@ultimat3/core';
7
+ import { finiteOption, uuidV7 } from '@ultimat3/core';
8
8
  import { nowMs } from './clock';
9
9
  import { DEFAULT_QUEUE } from './driver';
10
- import type { PgExecutor } from './driver-pg';
11
10
  import { SQL_SCHEDULER_FIRE } from './driver-pg-operator-sql';
12
11
  import {
13
12
  SQL_LEADER_ACQUIRE,
@@ -25,7 +24,7 @@ import { fireThroughDriver } from './scheduler-state';
25
24
  * whose watermark dies with its process has none of them — it takes the arming branch on every
26
25
  * boot and drops every occurrence between the two processes with nothing logged.
27
26
  */
28
- export function pgSchedulerState(executor: PgExecutor): SchedulerState {
27
+ export function postgresSchedulerState(executor: PgExecutor): SchedulerState {
29
28
  const state: SchedulerState = {
30
29
  async lastFiredAt(taskName) {
31
30
  const rows = await executor.query<{ last_fired_at: number | string | null }>(
@@ -42,12 +41,11 @@ export function pgSchedulerState(executor: PgExecutor): SchedulerState {
42
41
  // One statement only when the queue IS this database. A host that swapped the driver keeps
43
42
  // the watermark here and its jobs elsewhere, and no statement spans the two.
44
43
  if (driver.name !== 'pg') return fireThroughDriver(state, driver, fire);
45
- const ids = fire.jobs.map(() => uuid());
44
+ const ids = fire.jobs.map(() => uuidV7());
46
45
  const rows = await executor.query<{
47
46
  fired: number | string;
48
47
  id: string | null;
49
48
  run_id: string | null;
50
- idempotency_key: string | null;
51
49
  }>(SQL_SCHEDULER_FIRE, [
52
50
  fire.task,
53
51
  fire.occurrenceMs,
@@ -58,23 +56,28 @@ export function pgSchedulerState(executor: PgExecutor): SchedulerState {
58
56
  queue: request.queue || DEFAULT_QUEUE,
59
57
  input: request.input ?? null,
60
58
  idempotency_key: request.idempotencyKey,
61
- run_id: request.runId ?? uuid(),
59
+ run_id: request.runId ?? uuidV7(),
62
60
  max_attempts: request.maxAttempts,
63
61
  })),
64
62
  ),
63
+ // Where the watermark lands when that is past the occurrence (`run-once`): ONE statement.
64
+ fire.watermarkMs ?? null,
65
65
  ]);
66
66
  if (Number(rows[0]?.fired ?? 0) === 0) return undefined;
67
+ // Keyed on the id THIS statement minted (`ids[index]`), never on the idempotency key: the
68
+ // key is unique per (name, tenant, key), so two jobs of one occurrence may share it, and a
69
+ // map by key told both callers the second one's id — where the memory store tells each its own.
67
70
  const queued = new Map(
68
71
  rows.flatMap((row) =>
69
- row.id === null || row.run_id === null || row.idempotency_key === null
72
+ row.id === null || row.run_id === null
70
73
  ? []
71
- : [[row.idempotency_key, { id: row.id, runId: row.run_id }] as const],
74
+ : [[row.id.toLowerCase(), { id: row.id, runId: row.run_id }] as const],
72
75
  ),
73
76
  );
74
77
  // A job absent from the answer met a LIVE row of its key — a fire from before this
75
78
  // statement existed, still in flight. It is that row's work, so it is reported deduped.
76
- return fire.jobs.map((request) => {
77
- const inserted = queued.get(request.idempotencyKey);
79
+ return fire.jobs.map((_, index) => {
80
+ const inserted = queued.get(String(ids[index]).toLowerCase());
78
81
  return inserted === undefined
79
82
  ? { id: '', runId: '', deduped: true }
80
83
  : { ...inserted, deduped: false };
@@ -84,7 +87,7 @@ export function pgSchedulerState(executor: PgExecutor): SchedulerState {
84
87
  return state;
85
88
  }
86
89
 
87
- export interface PgLeaseLeaderOptions {
90
+ export interface PostgresLeaseLeaderOptions {
88
91
  readonly executor: PgExecutor;
89
92
  /** One key per elected role. Default `'scheduler'`. */
90
93
  readonly lockKey?: string;
@@ -106,9 +109,9 @@ export const LEASE_RENEWALS_PER_TTL = 3;
106
109
 
107
110
  /**
108
111
  * Leader election as an expiring row, which is what makes it correct on the executor this package
109
- * is actually handed: a POOL. `createPgLeader`'s `pg_try_advisory_lock` is session-scoped, and a
112
+ * is actually handed: a POOL. `postgresLeader`'s `pg_try_advisory_lock` is session-scoped, and a
110
113
  * session on a pool ends the moment the connection goes back — so every node reads itself as
111
- * leader and a rolling update double-fires every task. `@ultimat3/realtime`'s `PgAdvisoryLock`
114
+ * leader and a rolling update double-fires every task. `@ultimat3/realtime`'s `postgresAdvisoryLock()`
112
115
  * solves the same problem by owning its connection; this package holds no wire protocol, so it
113
116
  * solves it with a row instead.
114
117
  *
@@ -117,9 +120,9 @@ export const LEASE_RENEWALS_PER_TTL = 3;
117
120
  * with nothing to clean up, which is the one property the advisory lock had and a plain
118
121
  * `insert ... on conflict do nothing` would not.
119
122
  */
120
- export function createPgLeaseLeader(options: PgLeaseLeaderOptions): LeaderElection {
123
+ export function postgresLeaseLeader(options: PostgresLeaseLeaderOptions): LeaderElection {
121
124
  const lockKey = options.lockKey ?? 'scheduler';
122
- const holder = options.holder ?? `scheduler-${uuid()}`;
125
+ const holder = options.holder ?? `scheduler-${uuidV7()}`;
123
126
  const ttlMs = finiteOption(
124
127
  'the pg scheduler lease',
125
128
  'ttlMs',
@@ -9,6 +9,13 @@ import type { EnqueueRequest, EnqueueResult, JobDriver } from './driver';
9
9
  export interface ScheduledFire {
10
10
  readonly task: string;
11
11
  readonly occurrenceMs: number;
12
+ /**
13
+ * Where the watermark LANDS, when that is past the occurrence: `run-once` runs the earliest
14
+ * missed occurrence and drops the rest, and dropping is moving the watermark past them — in the
15
+ * same write, or a crash between the two fires a second "one catch-up". The fence is still the
16
+ * occurrence. Omitted, the watermark lands on the occurrence.
17
+ */
18
+ readonly watermarkMs?: number;
12
19
  readonly jobs: readonly EnqueueRequest[];
13
20
  }
14
21
 
@@ -32,7 +39,7 @@ export interface SchedulerState {
32
39
  /**
33
40
  * The fire for a store that cannot share a statement with the queue: the watermark is checked,
34
41
  * the jobs go through `driver.enqueue`, and the watermark moves last. Not atomic across a crash —
35
- * which is why `pgSchedulerState` does not use it against the pg driver — but exact for the
42
+ * which is why `postgresSchedulerState` does not use it against the pg driver — but exact for the
36
43
  * memory pair, where nothing can fail between the steps and no second process exists.
37
44
  */
38
45
  export async function fireThroughDriver(
@@ -44,14 +51,14 @@ export async function fireThroughDriver(
44
51
  if (last !== undefined && last >= fire.occurrenceMs) return undefined;
45
52
  const results: EnqueueResult[] = [];
46
53
  for (const request of fire.jobs) results.push(await driver.enqueue(request));
47
- await state.markFired(fire.task, fire.occurrenceMs);
54
+ await state.markFired(fire.task, fire.watermarkMs ?? fire.occurrenceMs);
48
55
  // What an operator reads as "last fired". After the watermark, so a record never names an
49
56
  // occurrence a crash would fire again.
50
57
  await driver.introspect?.recordTaskFire({ task: fire.task, occurrenceMs: fire.occurrenceMs });
51
58
  return results;
52
59
  }
53
60
 
54
- export function createMemorySchedulerState(): SchedulerState {
61
+ export function memorySchedulerState(): SchedulerState {
55
62
  const fired = new Map<string, number>();
56
63
  const state: SchedulerState = {
57
64
  lastFiredAt: (taskName) => Promise.resolve(fired.get(taskName)),
package/src/scheduler.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // it owes and enqueues their jobs. The `task` primitive it reads lives in `task.ts`.
3
3
  //
4
4
  // Exactly one node dispatches per tick, enforced by leader election. Multi-node that is an
5
- // EXPIRING LEASE ROW in `x_scheduler_leader` (`createPgLeaseLeader`), never an advisory lock: an
5
+ // EXPIRING LEASE ROW in `x_scheduler_leader` (`postgresLeaseLeader`), never an advisory lock: an
6
6
  // advisory lock is held by the SESSION, not by this process — it outlives every transaction and is
7
7
  // released only by an explicit unlock, the pool's reset on release, or the connection dying, and
8
8
  // the next round may run on a different connection. So a node can neither renew it nor prove it
@@ -31,7 +31,7 @@ import type { LeaderElection } from './scheduler-leader';
31
31
  import { soleLeader } from './scheduler-leader';
32
32
  import { defaultCronResolver, latestOccurrence, occurrencesIn } from './scheduler-occurrences';
33
33
  import type { SchedulerState } from './scheduler-state';
34
- import { createMemorySchedulerState } from './scheduler-state';
34
+ import { memorySchedulerState } from './scheduler-state';
35
35
  import type { TaskHandle, TaskJobResult } from './task';
36
36
  import { registeredTasks } from './task';
37
37
 
@@ -89,11 +89,11 @@ export interface Scheduler {
89
89
  nextRunFor(handle: TaskHandle, from?: Date): Date;
90
90
  }
91
91
 
92
- export function createScheduler(options: SchedulerOptions): Scheduler {
93
- const schedulerState = options.state ?? createMemorySchedulerState();
92
+ export function jobScheduler(options: SchedulerOptions): Scheduler {
93
+ const schedulerState = options.state ?? memorySchedulerState();
94
94
  const resolveCron = options.cron ?? defaultCronResolver;
95
95
  const tickIntervalMs = finiteOption(
96
- 'createScheduler',
96
+ 'jobScheduler',
97
97
  'tickIntervalMs',
98
98
  options.tickIntervalMs ?? 1_000,
99
99
  );
@@ -144,6 +144,7 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
144
144
  handle: TaskHandle,
145
145
  occurrenceMs: number,
146
146
  catchUp: boolean,
147
+ watermarkMs?: number,
147
148
  ): Promise<DispatchedOccurrence | undefined> => {
148
149
  // The occurrence, not `at`: a catch-up dispatch runs long after the instant it fires for,
149
150
  // and the payload has to describe the occurrence the email/report claims to be about.
@@ -166,6 +167,7 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
166
167
  const results = await schedulerState.fire(options.driver, {
167
168
  task: handle.name,
168
169
  occurrenceMs,
170
+ ...(watermarkMs === undefined ? {} : { watermarkMs }),
169
171
  jobs: requests,
170
172
  });
171
173
  if (results === undefined) {
@@ -323,16 +325,13 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
323
325
  if (handle.catchUp === 'run-once') {
324
326
  const first = due[0];
325
327
  if (first !== undefined) {
326
- const ran = fired(await dispatch(handle, first, due.length > 1));
327
- // `dispatch` leaves the watermark on the occurrence it RAN — the earliest missed one
328
- // here — so the next round found occurrences 2..n still due and fired the second, then
329
- // the third, one per tick until the backlog drained: 24 nightly digests a second apart
330
- // after a day down. "One catch-up" means the rest are DROPPED, and dropping an
331
- // occurrence is moving the watermark past it. `at` rather than the last element of
332
- // `due`, which `maxCatchUp` truncates: every occurrence at or before `at` is missed by
333
- // definition, and this policy fires none of them.
334
- if (first !== at) await schedulerState.markFired(handle.name, at);
335
- if (ran) remember(handle, Math.max(first, at));
328
+ // "One catch-up" means the rest are DROPPED, and dropping an occurrence is moving the
329
+ // watermark past it: left on the occurrence that RAN, the next round found 2..n still
330
+ // due and fired one per tick — 24 nightly digests a second apart after a day down.
331
+ // `at` rather than the last element of `due`, which `maxCatchUp` truncates. It rides
332
+ // the FIRE (`watermarkMs`): a `markFired` behind it was a second write, and a scheduler
333
+ // that died between the two handed its successor another catch-up.
334
+ if (fired(await dispatch(handle, first, due.length > 1, at))) remember(handle, at);
336
335
  }
337
336
  continue;
338
337
  }
@@ -1,11 +1,11 @@
1
- // The in-memory `StepStore`: what `createMemoryDriver` and every runner test persist steps into.
1
+ // The in-memory `StepStore`: what `memoryJobDriver` and every runner test persist steps into.
2
2
  // Split from `steps.ts` at the file-size ceiling, along the seam the pg driver already draws —
3
3
  // `driver-pg.ts` holds the Postgres store, this file the map-backed one, `steps.ts` the runner
4
4
  // both are handed to. Same contract, and `steps.test.ts` is where the contract is pinned.
5
5
 
6
6
  import type { StepRecord, StepStore } from './steps';
7
7
 
8
- export function createMemoryStepStore(): StepStore {
8
+ export function memoryStepStore(): StepStore {
9
9
  const byRun = new Map<string, Map<string, StepRecord>>();
10
10
  const runOf = (runId: string): Map<string, StepRecord> => {
11
11
  let run = byRun.get(runId);
@@ -0,0 +1,46 @@
1
+ // A run that unwinds on purpose: the `StepSuspension` a `sleep` or `waitForEvent` throws, and the
2
+ // marker a timed-out wait persists. Split from `steps.ts` at the file-size ceiling — that file is
3
+ // the runner; this is the control flow the worker reads off what the runner threw.
4
+
5
+ /**
6
+ * Control flow, not a failure: it must never be logged as an error or counted as an attempt.
7
+ * Branded by string rather than `instanceof` so two copies of this module still agree.
8
+ */
9
+ export class StepSuspension extends Error {
10
+ static readonly brand = 'ultimate.jobs.suspension';
11
+ readonly brand: string = StepSuspension.brand;
12
+ readonly step: string;
13
+ readonly resumeAt: number;
14
+ readonly reason: 'sleep' | 'event';
15
+
16
+ constructor(input: { step: string; resumeAt: number; reason: 'sleep' | 'event' }) {
17
+ super(`step "${input.step}" suspended until ${new Date(input.resumeAt).toISOString()}`);
18
+ this.name = 'StepSuspension';
19
+ this.step = input.step;
20
+ this.resumeAt = input.resumeAt;
21
+ this.reason = input.reason;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * What a timed-out `waitForEvent` persists, and maps back to `undefined` on replay. `undefined`
27
+ * itself cannot survive a store — the pg driver writes `JSON.stringify(output ?? null)` — so the
28
+ * timeout replayed as `null` and `evt === undefined` read as "an event arrived". A namespaced key
29
+ * rather than a bare `{ timedOut: true }`, so no event payload can be mistaken for the marker.
30
+ */
31
+ export const WAIT_TIMED_OUT = Object.freeze<Record<string, true>>({
32
+ '~ultimate.waitTimedOut': true,
33
+ });
34
+
35
+ export function isWaitTimedOut(output: unknown): boolean {
36
+ return (
37
+ typeof output === 'object' &&
38
+ output !== null &&
39
+ Object.keys(output).length === 1 &&
40
+ (output as Record<string, unknown>)['~ultimate.waitTimedOut'] === true
41
+ );
42
+ }
43
+
44
+ export function isStepSuspension(error: unknown): error is StepSuspension {
45
+ return error instanceof Error && (error as { brand?: unknown }).brand === StepSuspension.brand;
46
+ }
@@ -0,0 +1,33 @@
1
+ // A step's own ceiling: the race between a step body and `stepTimeout`. Split from `steps.ts` at
2
+ // the file-size ceiling — that file is the runner, and this is the one timer it arms.
3
+
4
+ /**
5
+ * A step's own ceiling. It ABORTS before it rejects, in that order: `run()` fails the step on this
6
+ * rejection and the job retries, so a body still holding a socket open past the deadline would be
7
+ * racing the attempt that replaced it. Cancelling first is the only thing that can stop it.
8
+ */
9
+ export function withStepTimeout<T>(
10
+ work: Promise<T> | T,
11
+ timeoutMs: number | undefined,
12
+ deadline: AbortController,
13
+ error: () => Error,
14
+ ): Promise<T> {
15
+ if (timeoutMs === undefined || timeoutMs <= 0) return Promise.resolve(work);
16
+ return new Promise<T>((resolve, reject) => {
17
+ const timer = setTimeout(() => {
18
+ const failure = error();
19
+ deadline.abort(failure);
20
+ reject(failure);
21
+ }, timeoutMs);
22
+ Promise.resolve(work).then(
23
+ (value) => {
24
+ clearTimeout(timer);
25
+ resolve(value);
26
+ },
27
+ (cause) => {
28
+ clearTimeout(timer);
29
+ reject(cause);
30
+ },
31
+ );
32
+ });
33
+ }
package/src/steps.ts CHANGED
@@ -7,12 +7,21 @@
7
7
  // catches it and re-queues the job for `resumeAt` instead of holding a process for three days.
8
8
 
9
9
  import type { Clock } from '@ultimat3/core';
10
- import { finiteOption, logger, renderThrowable } from '@ultimat3/core';
10
+ import { finiteOption, isUltimateError, logger, renderThrowable } from '@ultimat3/core';
11
11
  import { expectedQueryLoop } from '@ultimat3/db';
12
12
  import type { DurationInput } from './clock';
13
13
  import { finiteDurationMs, nowMs } from './clock';
14
14
  import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
15
15
  import { createRunSignal } from './run-signal';
16
+ import {
17
+ isStepSuspension,
18
+ isWaitTimedOut,
19
+ StepSuspension,
20
+ WAIT_TIMED_OUT,
21
+ } from './steps-suspension';
22
+ import { withStepTimeout } from './steps-timeout';
23
+
24
+ export { isStepSuspension, StepSuspension } from './steps-suspension';
16
25
 
17
26
  /**
18
27
  * The runtime list is the declaration and `StepStatus` is derived from it, the shape
@@ -45,9 +54,23 @@ export interface StepRecord {
45
54
  readonly error?: string;
46
55
  }
47
56
 
57
+ /** The claim a step write is made under: `claimOf(claimed)`, plus the row it names. */
58
+ export interface StepFence {
59
+ readonly job: string;
60
+ readonly jobId: string;
61
+ readonly workerId: string;
62
+ readonly claim: number;
63
+ }
64
+
48
65
  export interface StepStore {
49
66
  get(runId: string, name: string): Promise<StepRecord | undefined>;
50
- put(record: StepRecord): Promise<void>;
67
+ /**
68
+ * With `by`, the write lands only while that claim still holds the row (`running`, same worker,
69
+ * same claim ordinal — `SQL_ACK`'s fence) and is `X_JOB_LEASE_LOST` otherwise: the in-process
70
+ * signal cannot stop a body in ANOTHER process whose lease lapsed. Without it the write is
71
+ * unfenced — a transfer (`x jobs drain`) or a runner driven by hand, with no row to hold.
72
+ */
73
+ put(record: StepRecord, by?: StepFence): Promise<void>;
51
74
  list(runId: string): Promise<readonly StepRecord[]>;
52
75
  del(runId: string, name: string): Promise<void>;
53
76
  clear(runId: string): Promise<void>;
@@ -55,6 +78,13 @@ export interface StepStore {
55
78
 
56
79
  /** The slice of the event bus a waiting step needs. Implemented by `events.ts`. */
57
80
  export interface EventLookup {
81
+ /**
82
+ * The bus's OWN clock, in epoch ms: what a publish issued now would be stamped with. A consumer
83
+ * that wants "published after I asked" takes its "asked at" from here and never from its own
84
+ * process — `publishedAt` is this clock's, and two clocks compared is a skew between pods
85
+ * deciding which answers count. `waitForEvent` stamps a NEW wait with it.
86
+ */
87
+ now(): Promise<number>;
58
88
  find(
59
89
  event: string,
60
90
  correlationKey: string | undefined,
@@ -90,53 +120,12 @@ export interface StepApi {
90
120
  ): Promise<T | undefined>;
91
121
  }
92
122
 
93
- /**
94
- * Control flow, not a failure: it must never be logged as an error or counted as an attempt.
95
- * Branded by string rather than `instanceof` so two copies of this module still agree.
96
- */
97
- export class StepSuspension extends Error {
98
- static readonly brand = 'ultimate.jobs.suspension';
99
- readonly brand: string = StepSuspension.brand;
100
- readonly step: string;
101
- readonly resumeAt: number;
102
- readonly reason: 'sleep' | 'event';
103
-
104
- constructor(input: { step: string; resumeAt: number; reason: 'sleep' | 'event' }) {
105
- super(`step "${input.step}" suspended until ${new Date(input.resumeAt).toISOString()}`);
106
- this.name = 'StepSuspension';
107
- this.step = input.step;
108
- this.resumeAt = input.resumeAt;
109
- this.reason = input.reason;
110
- }
111
- }
112
-
113
- /**
114
- * What a timed-out `waitForEvent` persists, and maps back to `undefined` on replay. `undefined`
115
- * itself cannot survive a store — the pg driver writes `JSON.stringify(output ?? null)` — so the
116
- * timeout replayed as `null` and `evt === undefined` read as "an event arrived". A namespaced key
117
- * rather than a bare `{ timedOut: true }`, so no event payload can be mistaken for the marker.
118
- */
119
- const WAIT_TIMED_OUT = Object.freeze<Record<string, true>>({
120
- '~ultimate.waitTimedOut': true,
121
- });
122
-
123
- function isWaitTimedOut(output: unknown): boolean {
124
- return (
125
- typeof output === 'object' &&
126
- output !== null &&
127
- Object.keys(output).length === 1 &&
128
- (output as Record<string, unknown>)['~ultimate.waitTimedOut'] === true
129
- );
130
- }
131
-
132
- export function isStepSuspension(error: unknown): error is StepSuspension {
133
- return error instanceof Error && (error as { brand?: unknown }).brand === StepSuspension.brand;
134
- }
135
-
136
123
  export interface StepRunnerOptions {
137
124
  readonly runId: string;
138
125
  readonly jobName: string;
139
126
  readonly store: StepStore;
127
+ /** The claim this attempt runs under — every write is fenced on it. `executeJob` passes it. */
128
+ readonly fence?: StepFence;
140
129
  readonly clock?: Clock;
141
130
  readonly events?: EventLookup;
142
131
  /** How long a waiting step stays parked between event polls. Default 30s. */
@@ -149,6 +138,13 @@ export interface StepRunnerOptions {
149
138
  * store belongs to whichever attempt has it now.
150
139
  */
151
140
  readonly signal?: AbortSignal;
141
+ /**
142
+ * Aborted the moment the ATTEMPT is over — settled, or past its job deadline. `signal` alone
143
+ * cannot say so once the drain has aborted it first (a controller keeps its first reason), and
144
+ * the drain's reason writes through `put` (`writesThrough`): this is what still refuses a step
145
+ * left in flight by a body that returned without awaiting it. `executeJob` passes it.
146
+ */
147
+ readonly ended?: AbortSignal;
152
148
  }
153
149
 
154
150
  export interface StepRunner {
@@ -179,7 +175,7 @@ function trace(into: string[], name: string): void {
179
175
  if (into.length > MAX_TRACE_NAMES) into.shift();
180
176
  }
181
177
 
182
- export function createStepRunner(options: StepRunnerOptions): StepRunner {
178
+ export function stepRunner(options: StepRunnerOptions): StepRunner {
183
179
  const { runId, jobName, store } = options;
184
180
  /** Every name this attempt has claimed. Membership only — the trace is `used`. */
185
181
  const claimed = new Set<string>();
@@ -187,6 +183,13 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
187
183
  const replayed: string[] = [];
188
184
  const clock = options.clock;
189
185
  const pollMs = finiteOption('step.waitForEvent', 'eventPollMs', options.eventPollMs ?? 30_000);
186
+ // `job()` refuses a non-finite `stepTimeout` at declaration (`job.ts`); screened again here, where
187
+ // it is read, because this runner is also built directly and `withStepTimeout` reads a `NaN`
188
+ // ceiling as none at all — the backstop `pollMs` above already is.
189
+ const stepTimeoutMs =
190
+ options.stepTimeoutMs === undefined
191
+ ? undefined
192
+ : finiteOption('step.run', 'stepTimeoutMs', options.stepTimeoutMs);
190
193
  const runSignal = options.signal ?? NEVER_ABORTED;
191
194
 
192
195
  /**
@@ -244,6 +247,19 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
244
247
 
245
248
  const cancelled = (): boolean => runSignal.aborted;
246
249
 
250
+ /**
251
+ * A cancelled run whose step result is still this attempt's to record: the worker's drain cut
252
+ * it short (`X_DRAINING`, `worker-drain-cutoff.ts`), the write is fenced on the claim, and the
253
+ * attempt has not ended. The side effect behind the record HAPPENED — refused, the next worker
254
+ * runs it again. The fence is what makes this safe and not the in-process signal: a row the
255
+ * drain has already handed back answers the write `X_JOB_LEASE_LOST` in the store.
256
+ */
257
+ const writesThrough = (): boolean =>
258
+ options.fence !== undefined &&
259
+ options.ended?.aborted !== true &&
260
+ isUltimateError(runSignal.reason) &&
261
+ runSignal.reason.code === 'X_DRAINING';
262
+
247
263
  /**
248
264
  * The statement itself, declared deliberate to the N+1 detector. One write per step IS the
249
265
  * design this file's header states — each step completes at its own instant and its output has
@@ -256,7 +272,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
256
272
  const persist = (record: StepRecord): Promise<void> =>
257
273
  expectedQueryLoop(
258
274
  'a durable step is written the instant it completes, one statement per step by design',
259
- () => store.put(record),
275
+ () => store.put(record, options.fence),
260
276
  );
261
277
 
262
278
  /**
@@ -266,7 +282,9 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
266
282
  * never ran — or overwrite one it did. The write is refused, and refusing it unwinds the body.
267
283
  */
268
284
  const put = async (record: StepRecord): Promise<void> => {
269
- if (cancelled()) throw new JobAbortedError({ job: jobName, step: record.name });
285
+ if (cancelled() && !writesThrough()) {
286
+ throw new JobAbortedError({ job: jobName, step: record.name });
287
+ }
270
288
  await persist(record);
271
289
  remember(record);
272
290
  };
@@ -294,15 +312,14 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
294
312
  // to compose, and DISPOSED in the `finally` below, which is the whole reason `run-signal.ts`
295
313
  // exists — `worker-run.ts` disposes the run's own the same way.
296
314
  const composed =
297
- options.stepTimeoutMs === undefined ? null : createRunSignal([runSignal, deadline.signal]);
315
+ stepTimeoutMs === undefined ? null : createRunSignal([runSignal, deadline.signal]);
298
316
  const signal = composed?.signal ?? runSignal;
299
317
  try {
300
318
  const output = await withStepTimeout(
301
319
  fn(signal),
302
- options.stepTimeoutMs,
320
+ stepTimeoutMs,
303
321
  deadline,
304
- () =>
305
- new JobTimeoutError({ job: jobName, step: name, timeoutMs: options.stepTimeoutMs ?? 0 }),
322
+ () => new JobTimeoutError({ job: jobName, step: name, timeoutMs: stepTimeoutMs ?? 0 }),
306
323
  );
307
324
  // Persist BEFORE returning: a crash one line later must not re-run this step.
308
325
  await put({
@@ -331,8 +348,16 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
331
348
  attempts,
332
349
  error: renderThrowable(error),
333
350
  };
334
- await persist(failure);
335
- remember(failure);
351
+ try {
352
+ await persist(failure);
353
+ remember(failure);
354
+ } catch (unwritten) {
355
+ logger.warn('jobs.step.failure-unrecorded', {
356
+ job: jobName,
357
+ step: name,
358
+ error: renderThrowable(unwritten),
359
+ });
360
+ }
336
361
  }
337
362
  throw error;
338
363
  } finally {
@@ -389,7 +414,9 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
389
414
  }
390
415
 
391
416
  const at = now();
392
- const startedAt = existing?.startedAt ?? at;
417
+ // A NEW wait is stamped by the bus, whose clock stamps every `publishedAt` it is compared
418
+ // with; the runner's own only when there is no bus to ask. A re-poll keeps what it persisted.
419
+ const startedAt = existing?.startedAt ?? (await options.events?.now()) ?? at;
393
420
  const deadline =
394
421
  startedAt +
395
422
  finiteDurationMs(waitOptions.timeout ?? 86_400_000, 'step.waitForEvent', 'timeout');
@@ -411,7 +438,13 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
411
438
  return hit.payload as T;
412
439
  }
413
440
 
414
- if (at >= deadline) {
441
+ // `deadline` is on the BUS's clock, so the bus calls the timeout: asked only once this runner
442
+ // believes the time is up, which costs a wait one statement at its end. A runner AHEAD of the
443
+ // bus used to give up early by the skew — on an answer still inside its window. (A runner
444
+ // BEHIND waits the skew longer; knowing that sooner would cost a round trip on every poll.)
445
+ let remaining = deadline - at;
446
+ if (remaining <= 0) remaining = deadline - ((await options.events?.now()) ?? at);
447
+ if (remaining <= 0) {
415
448
  if (waitOptions.required === true) {
416
449
  throw new JobTimeoutError({
417
450
  job: jobName,
@@ -437,7 +470,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
437
470
  return undefined;
438
471
  }
439
472
 
440
- const resumeAt = Math.min(deadline, at + pollMs);
473
+ const resumeAt = at + Math.min(remaining, pollMs);
441
474
  await put({
442
475
  runId,
443
476
  name,
@@ -463,34 +496,3 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
463
496
  replayedNames: () => [...replayed],
464
497
  };
465
498
  }
466
-
467
- /**
468
- * A step's own ceiling. It ABORTS before it rejects, in that order: `run()` fails the step on this
469
- * rejection and the job retries, so a body still holding a socket open past the deadline would be
470
- * racing the attempt that replaced it. Cancelling first is the only thing that can stop it.
471
- */
472
- function withStepTimeout<T>(
473
- work: Promise<T> | T,
474
- timeoutMs: number | undefined,
475
- deadline: AbortController,
476
- error: () => Error,
477
- ): Promise<T> {
478
- if (timeoutMs === undefined || timeoutMs <= 0) return Promise.resolve(work);
479
- return new Promise<T>((resolve, reject) => {
480
- const timer = setTimeout(() => {
481
- const failure = error();
482
- deadline.abort(failure);
483
- reject(failure);
484
- }, timeoutMs);
485
- Promise.resolve(work).then(
486
- (value) => {
487
- clearTimeout(timer);
488
- resolve(value);
489
- },
490
- (cause) => {
491
- clearTimeout(timer);
492
- reject(cause);
493
- },
494
- );
495
- });
496
- }
package/src/task.ts CHANGED
@@ -141,7 +141,7 @@ export function task(definition: TaskDefinition): TaskHandle {
141
141
  // `for (let i = 0; i < handle.maxCatchUp; i += 1)`, so zero (and any negative, and any fraction
142
142
  // below one) returns an empty list on every round and the task NEVER fires: no error, no log
143
143
  // line, no queue row, forever. Refused where it is written, exactly as `job()` refuses
144
- // `concurrency: 0` and `createPacer` refuses `rate: 0`. `Number.isInteger` covers `NaN` and
144
+ // `concurrency: 0` and `backfillPacer` refuses `rate: 0`. `Number.isInteger` covers `NaN` and
145
145
  // `Infinity` in the same predicate — an unbounded catch-up is a burst nobody declared.
146
146
  assert(
147
147
  definition.maxCatchUp === undefined ||