@ultimat3/jobs 23.0.0 → 24.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 (45) hide show
  1. package/CLAUDE.md +51 -52
  2. package/README.md +34 -5
  3. package/package.json +6 -6
  4. package/src/backfill-ledger.ts +24 -2
  5. package/src/backfill-pass.ts +22 -5
  6. package/src/claim-exhausted.ts +76 -0
  7. package/src/driver-memory-operator.ts +31 -9
  8. package/src/driver-memory.ts +68 -23
  9. package/src/driver-nats.ts +5 -2
  10. package/src/driver-pg-jobs-sql.ts +27 -19
  11. package/src/driver-pg-operator-sql.ts +32 -12
  12. package/src/driver-pg-operator.ts +19 -3
  13. package/src/driver-pg-rows.ts +3 -3
  14. package/src/driver-pg-sql.ts +98 -29
  15. package/src/driver-pg.ts +86 -81
  16. package/src/driver-redis.ts +5 -2
  17. package/src/driver.ts +37 -1
  18. package/src/errors-operator.ts +12 -4
  19. package/src/errors-requeue.ts +21 -3
  20. package/src/errors.ts +5 -13
  21. package/src/events-pg.ts +8 -18
  22. package/src/events.ts +20 -9
  23. package/src/execute.ts +1 -0
  24. package/src/index.ts +10 -4
  25. package/src/inspect-operator.ts +3 -3
  26. package/src/inspect.ts +4 -3
  27. package/src/introspection.ts +23 -11
  28. package/src/job.ts +5 -6
  29. package/src/limits.ts +33 -7
  30. package/src/outbox-pg.ts +1 -2
  31. package/src/outbox.ts +37 -9
  32. package/src/purge.ts +1 -1
  33. package/src/queue-wake.ts +1 -2
  34. package/src/scheduler-pg.ts +3 -2
  35. package/src/scheduler-state.ts +8 -1
  36. package/src/scheduler.ts +9 -10
  37. package/src/steps-suspension.ts +46 -0
  38. package/src/steps-timeout.ts +33 -0
  39. package/src/steps.ts +55 -81
  40. package/src/worker-admit.ts +7 -3
  41. package/src/worker-fleet-slots.ts +78 -44
  42. package/src/worker-key-busy.ts +38 -10
  43. package/src/worker-loop.ts +35 -11
  44. package/src/worker-registry.ts +16 -1
  45. package/src/worker.ts +52 -9
@@ -3,15 +3,15 @@
3
3
  // know whether the job is gone or was never theirs to remove. Apart from `inspect.ts`, which is
4
4
  // the read side.
5
5
 
6
+ import { NotImplementedError } from '@ultimat3/core';
6
7
  import type { JobDriver, JobRecord } from './driver';
7
- import { JobsNotImplementedError } from './errors';
8
8
  import { JobNotPromotableError } from './errors-operator';
9
9
  import type { JobIntrospection, PausedName } from './introspection';
10
10
 
11
11
  function operatorOf(driver: JobDriver): JobIntrospection {
12
12
  if (driver.introspect === undefined) {
13
- throw new JobsNotImplementedError({
14
- feature: `the operator surface of the "${driver.name}" jobs driver`,
13
+ throw new NotImplementedError({
14
+ cause: `the operator surface of the "${driver.name}" jobs driver is not implemented: the driver has no introspect`,
15
15
  fix: 'call setJobDriver(createPgDriver()) at boot — the pg driver implements introspect — then: x jobs ls --json',
16
16
  });
17
17
  }
package/src/inspect.ts CHANGED
@@ -2,11 +2,12 @@
2
2
  // object so `x jobs ... --json` and the MCP tool share one shape — an agent debugging a stuck
3
3
  // queue reads exactly what the dashboard renders.
4
4
 
5
+ import { NotImplementedError } from '@ultimat3/core';
5
6
  import type { BackfillProgress } from './backfill-inspect';
6
7
  import { backfillForRun } from './backfill-inspect';
7
8
  import type { WhenBusy } from './concurrency';
8
9
  import type { JobDriver, JobRecord, QueueStats } from './driver';
9
- import { CancelUnsupportedError, JobNotCancellableError, JobsNotImplementedError } from './errors';
10
+ import { CancelUnsupportedError, JobNotCancellableError } from './errors';
10
11
  import type { JobFilter, JobProgress } from './introspection';
11
12
  import type { AnyJobHandle } from './job';
12
13
  import { registeredJobs } from './job';
@@ -111,8 +112,8 @@ const iso = (ms: number | undefined): string | null =>
111
112
 
112
113
  function requireIntrospection(driver: JobDriver): NonNullable<JobDriver['introspect']> {
113
114
  if (driver.introspect === undefined) {
114
- throw new JobsNotImplementedError({
115
- feature: `introspection for the "${driver.name}" jobs driver`,
115
+ throw new NotImplementedError({
116
+ cause: `introspection for the "${driver.name}" jobs driver is not implemented: the driver has no introspect`,
116
117
  fix: 'call setJobDriver(createPgDriver()) at boot — only the pg driver implements introspect — then: x jobs ls --json',
117
118
  });
118
119
  }
@@ -68,7 +68,12 @@ export interface JobFilter {
68
68
  readonly tenantId?: string;
69
69
  }
70
70
 
71
- const CURSOR = /^(\d{1,16}):([0-9A-Za-z-]{1,64})$/;
71
+ /**
72
+ * `<createdAt ms>:<job id>`, and the id is a UUID in its one canonical shape. The group accepted
73
+ * any 1-64 letters, digits and hyphens, which the memory driver compared as text and the pg
74
+ * statement CAST: `123:not-a-uuid` was an empty page here and a raw `22P02` there.
75
+ */
76
+ const CURSOR = /^(\d{1,16}):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
72
77
 
73
78
  /** The cursor a page's LAST row yields. A page shorter than its limit is the last page. */
74
79
  export function jobCursor(record: Pick<JobRecord, 'createdAt' | 'id'>): string {
@@ -76,11 +81,12 @@ export function jobCursor(record: Pick<JobRecord, 'createdAt' | 'id'>): string {
76
81
  }
77
82
 
78
83
  export function parseJobCursor(
79
- after: string,
84
+ raw: string,
80
85
  subject = 'the job list',
86
+ cursor: 'after' | 'before' = 'after',
81
87
  ): { readonly createdAt: number; readonly id: string } {
82
- const match = CURSOR.exec(after);
83
- if (match === null) throw new JobPageInvalidError({ subject, maxPage: MAX_JOB_PAGE });
88
+ const match = CURSOR.exec(raw);
89
+ if (match === null) throw new JobPageInvalidError({ subject, maxPage: MAX_JOB_PAGE, cursor });
84
90
  return { createdAt: Number(match[1]), id: match[2] ?? '' };
85
91
  }
86
92
 
@@ -108,7 +114,11 @@ export const PROMOTABLE_STATES: ReadonlySet<JobState> = new Set<JobState>(['read
108
114
  export interface BulkResult {
109
115
  /** Rows this call changed — at most `MAX_BULK_ROWS`. */
110
116
  readonly affected: number;
111
- /** Rows still matching the filter afterwards. Call again until it is zero. */
117
+ /**
118
+ * Rows the SAME call would still act on afterwards — so "call again until it is zero" ends. A
119
+ * row the verb leaves where it is (a requeue whose key a live job holds, a promote of a row
120
+ * already due) is not counted: it will be skipped on every later call too.
121
+ */
112
122
  readonly remaining: number;
113
123
  }
114
124
 
@@ -183,16 +193,17 @@ export interface JobIntrospection {
183
193
  list(filter?: JobFilter): Promise<readonly JobRecord[]>;
184
194
  deadLetters(limit?: number): Promise<readonly JobRecord[]>;
185
195
  /**
186
- * Re-queue a finished job (`REQUEUEABLE_STATES`); a live one is `X_JOB_NOT_REQUEUEABLE`, and a
187
- * key a live job holds is `X_JOB_DUPLICATE`. `fromStep` drops that step and every step that
188
- * started after it.
196
+ * Re-queue a finished job (`REQUEUEABLE_STATES`); a live one is `X_JOB_NOT_REQUEUEABLE`, a key a
197
+ * live job holds is `X_JOB_DUPLICATE`, and an id nobody queued is `X_JOB_NOT_FOUND`. `fromStep`
198
+ * drops that step and every step that started after it, with the row's own move.
189
199
  */
190
200
  requeue(jobId: string, options?: { readonly fromStep?: string }): Promise<JobRecord>;
191
201
  /**
192
202
  * Stop a job from outside. Terminal for a queued row immediately; a RUNNING one stops at its
193
203
  * next heartbeat, which no longer matches its own row and cancels the attempt. Answers
194
- * `undefined` for a job id it does not hold or one that already finished. Optional, as it
195
- * shipped: a driver may have no way to address a single running row.
204
+ * `undefined` for a job id it does not hold or one that is not LIVE (`LIVE_STATES`) — a done,
205
+ * failed, dead or already cancelled row is left exactly as it ended. Optional, as it shipped: a
206
+ * driver may have no way to address a single running row.
196
207
  */
197
208
  cancel?(jobId: string, reason?: string): Promise<JobRecord | undefined>;
198
209
  /**
@@ -203,7 +214,8 @@ export interface JobIntrospection {
203
214
  remove(jobId: string): Promise<JobRecord | undefined>;
204
215
  /**
205
216
  * Re-queue up to `MAX_BULK_ROWS` finished jobs. `filter.state` must be a requeueable state. A
206
- * row whose key a live job holds is left where it is and counted in `remaining`.
217
+ * row whose key a live job holds is left where it is and is NOT counted in `remaining`; of
218
+ * several finished rows sharing one key, the oldest is the one requeued.
207
219
  */
208
220
  requeueMany(filter: BulkFilter): Promise<BulkResult>;
209
221
  /** Delete up to `MAX_BULK_ROWS` jobs. `filter.state` may be any state but `running`. */
package/src/job.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // deletes that class of bug: there is no way to define a job that cannot be deduped.
9
9
 
10
10
  import type { Ctx } from '@ultimat3/core';
11
- import { assert } from '@ultimat3/core';
11
+ import { assert, finiteCount } from '@ultimat3/core';
12
12
  import type { StandardSchemaV1 } from '@ultimat3/schema';
13
13
  import { parse } from '@ultimat3/schema';
14
14
  import type { DurationInput } from './clock';
@@ -266,11 +266,10 @@ export function job<I, R = unknown>(definition: JobDefinition<I, R>): JobHandle<
266
266
  throw new JobDeclarationInvalidError({ job: name, missing });
267
267
  }
268
268
  assertJobTenant(name, definition.tenant);
269
- assert(
270
- definition.retry.attempts >= 1,
271
- `job "${name}" needs retry.attempts >= 1, got ${String(definition.retry.attempts)}`,
272
- `set retry: { attempts: 1 } or higher on job("${name}") — 0 attempts means the job is never executed at all, not that it never retries`,
273
- );
269
+ // A COUNT, so whole and finite as well as at least 1: `>= 1` alone admitted `Infinity` and
270
+ // `1.5`, which the memory driver ran and Postgres refused at the first enqueue (`$7::int`).
271
+ // Zero is not "never retries" — that is `attempts: 1` — it is a job that never executes at all.
272
+ finiteCount(`job "${name}"`, 'retry.attempts', definition.retry.attempts, 1);
274
273
  // `concurrency: 0` is not "no cap" — it is a fleet slot table that grants nothing, and the job
275
274
  // is permanently unrunnable with no log line. Refused where it is written, plain or keyed.
276
275
  const concurrency = resolveConcurrency(name, definition.concurrency);
package/src/limits.ts CHANGED
@@ -44,11 +44,21 @@ export interface LimitKey {
44
44
  readonly tenantId?: string;
45
45
  }
46
46
 
47
- /** Returned on success; `release()` is idempotent so a double-release cannot leak slots. */
47
+ /**
48
+ * Returned on success. Handed back exactly once, by whichever of the two is called first — the
49
+ * other, and any repeat, is a no-op, so a double hand-back cannot leak a slot or pop two stamps.
50
+ */
48
51
  export interface Lease {
49
52
  readonly queue: string;
50
53
  readonly tenantId: string;
54
+ /** The run ENDED: the slot is free, and the start stays counted against `ratePerTenant`. */
51
55
  release(): void;
56
+ /**
57
+ * The run never STARTED — shed over a fleet cap, refused by its key: the slot is free and the
58
+ * rate stamp `tryAcquire` wrote goes with it. `ratePerTenant` is starts per window, and a job
59
+ * handed back unrun every poll interval otherwise spent its tenant's whole window on sheds.
60
+ */
61
+ abandon(): void;
52
62
  }
53
63
 
54
64
  export type LimitReason = 'per-tenant' | 'per-queue' | 'global' | 'rate';
@@ -261,16 +271,32 @@ export function createLimiter(
261
271
  maybeSweep(at);
262
272
 
263
273
  let released = false;
274
+ /** `false` when this lease was already handed back. */
275
+ const free = (): boolean => {
276
+ if (released) return false;
277
+ released = true;
278
+ global = Math.max(0, global - 1);
279
+ bump(byQueue, key.queue, -1);
280
+ bump(byTenant, tenant, -1);
281
+ bump(byQueueTenant, refusalKey, -1);
282
+ return true;
283
+ };
264
284
  return {
265
285
  queue: key.queue,
266
286
  tenantId: tenant,
267
287
  release() {
268
- if (released) return;
269
- released = true;
270
- global = Math.max(0, global - 1);
271
- bump(byQueue, key.queue, -1);
272
- bump(byTenant, tenant, -1);
273
- bump(byQueueTenant, refusalKey, -1);
288
+ free();
289
+ },
290
+ abandon() {
291
+ if (!free() || config.ratePerTenant === undefined) return;
292
+ // ONE stamp of this lease's own instant: two leases taken in the same millisecond wrote
293
+ // equal stamps, and either is this one. Absent when a sweep already dropped the window.
294
+ const stamps = starts.get(tenant);
295
+ const index = stamps?.lastIndexOf(at) ?? -1;
296
+ if (stamps === undefined || index === -1) return;
297
+ const rest = stamps.filter((_stamp, position) => position !== index);
298
+ if (rest.length === 0) starts.delete(tenant);
299
+ else starts.set(tenant, rest);
274
300
  },
275
301
  };
276
302
  },
package/src/outbox-pg.ts CHANGED
@@ -9,11 +9,10 @@
9
9
  // `Tx` on" is a question only boot can answer. Boot has `currentTx()` — a `DbTx` IS a client on
10
10
  // the transaction's connection — so the wiring is one line there and no tier crossing here.
11
11
 
12
- import type { Clock } from '@ultimat3/core';
12
+ import type { Clock, PgExecutor } from '@ultimat3/core';
13
13
  import { uuid } from '@ultimat3/core';
14
14
  import type { Tx } from '@ultimat3/entity';
15
15
  import { nowMs } from './clock';
16
- import type { PgExecutor } from './driver-pg';
17
16
  import {
18
17
  SQL_OUTBOX_CLAIM,
19
18
  SQL_OUTBOX_MARK_PUBLISHED,
package/src/outbox.ts CHANGED
@@ -24,7 +24,13 @@
24
24
  // test and `x dev` must enqueue with nothing wired — but it is a fallback, not the guarantee.
25
25
 
26
26
  import type { Clock } from '@ultimat3/core';
27
- import { currentSpanContext, traceparent, uuid } from '@ultimat3/core';
27
+ import {
28
+ currentSpanContext,
29
+ describeValue,
30
+ traceparent,
31
+ UltimateError,
32
+ uuid,
33
+ } from '@ultimat3/core';
28
34
  import type { Tx } from '@ultimat3/entity';
29
35
  import { nowMs } from './clock';
30
36
  import type { EnqueueResult, JobDriver } from './driver';
@@ -238,6 +244,27 @@ function ambientTraceparent(): string | undefined {
238
244
  return traceparent(context);
239
245
  }
240
246
 
247
+ /** What Postgres prints for a `uuid`, and so the only spelling both drivers store identically. */
248
+ const RUN_ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
249
+
250
+ /**
251
+ * `EnqueueOptions.runId`, refused HERE when it is not a uuid — the one screen both enqueue paths
252
+ * read. `run_id` is a `uuid` column: the memory driver took any string, Postgres raised a raw
253
+ * `22P02`, and on the staged path that was the caller's whole transaction failing at the outbox
254
+ * insert. Any version, lowercase: an uppercase one would come back from Postgres re-spelled.
255
+ */
256
+ function namedRunId(job: string, runId: unknown): string | undefined {
257
+ if (runId === undefined) return undefined;
258
+ if (typeof runId === 'string' && RUN_ID_SHAPE.test(runId)) return runId;
259
+ throw new UltimateError({
260
+ code: 'X_ID_INVALID',
261
+ // `describeValue`, never the value: what arrives here wrong is as often a key as a typo.
262
+ cause: `enqueue of job "${job}" was given runId ${describeValue(runId)}, and a run id is a lowercase uuid (8-4-4-4-12 hex) — the queue stores it in a uuid column`,
263
+ fix: "pass { runId: uuid() } to enqueue(), with import { uuid } from '@ultimat3/core'",
264
+ meta: { job, option: 'runId' },
265
+ });
266
+ }
267
+
241
268
  export interface OutboxDeps {
242
269
  readonly store: OutboxStore;
243
270
  readonly driver: JobDriver;
@@ -247,18 +274,19 @@ export interface OutboxDeps {
247
274
  }
248
275
 
249
276
  /** Stage a job inside `tx`. Returns the row that the relay will publish after commit. */
250
- export function enqueueInTx<I>(
277
+ export async function enqueueInTx<I>(
251
278
  deps: OutboxDeps,
252
279
  tx: Tx,
253
280
  handle: JobHandle<I>,
254
281
  input: I,
255
282
  options: EnqueueOptions = {},
256
283
  ): Promise<OutboxRecord> {
284
+ const runId = namedRunId(handle.name, options.runId);
257
285
  const at = nowMs(deps.clock);
258
286
  const trace = options.traceparent ?? ambientTraceparent();
259
287
  const record: OutboxRecord = {
260
288
  id: uuid(),
261
- runId: options.runId ?? uuid(),
289
+ runId: runId ?? uuid(),
262
290
  job: handle.name,
263
291
  queue: options.queue ?? handle.queue ?? DEFAULT_QUEUE,
264
292
  input,
@@ -272,11 +300,10 @@ export function enqueueInTx<I>(
272
300
  ...(trace === undefined ? {} : { traceparent: trace }),
273
301
  ...(options.enqueuedBy === undefined ? {} : { enqueuedBy: options.enqueuedBy }),
274
302
  };
275
- return deps.store.stage(tx, record).then(() => {
276
- // Wakes a relay in THIS process that has backed off; a relay elsewhere polls on its own.
277
- signalStaged();
278
- return record;
279
- });
303
+ await deps.store.stage(tx, record);
304
+ // Wakes a relay in THIS process that has backed off; a relay elsewhere polls on its own.
305
+ signalStaged();
306
+ return record;
280
307
  }
281
308
 
282
309
  export interface JobsFacade {
@@ -304,6 +331,7 @@ export function createJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
304
331
  if (deps.mode === 'required' && options.outbox !== false) {
305
332
  throw new OutboxNoTxError({ job: handle.name });
306
333
  }
334
+ const runId = namedRunId(handle.name, options.runId);
307
335
  const trace = options.traceparent ?? ambientTraceparent();
308
336
  const result = await deps.driver.enqueue({
309
337
  name: handle.name,
@@ -312,7 +340,7 @@ export function createJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
312
340
  idempotencyKey: handle.idempotencyKeyFor(input),
313
341
  maxAttempts: handle.retry.attempts,
314
342
  runAt: options.runAt ?? nowMs(deps.clock),
315
- ...(options.runId === undefined ? {} : { runId: options.runId }),
343
+ ...(runId === undefined ? {} : { runId }),
316
344
  ...(options.tenantId === undefined ? {} : { tenantId: options.tenantId }),
317
345
  ...(trace === undefined ? {} : { traceparent: trace }),
318
346
  ...(options.enqueuedBy === undefined ? {} : { enqueuedBy: options.enqueuedBy }),
package/src/purge.ts CHANGED
@@ -33,7 +33,7 @@ export interface PurgeTarget {
33
33
  /**
34
34
  * What this sweep is called in its durable step, its log line and its report. A table name is
35
35
  * the natural spelling (`x_rate_limit`); a target that clears a SET of tables names their common
36
- * prefix (`x_auth`, for `x_auth_failures` and `x_auth_lockouts`). Unique within one definition —
36
+ * prefix (`x_auth`, for `x_auth_lockouts`). Unique within one definition —
37
37
  * the name is the step key, and two steps under one name is `X_STEP_DUPLICATE` mid-run.
38
38
  */
39
39
  readonly name: string;
package/src/queue-wake.ts CHANGED
@@ -5,9 +5,8 @@
5
5
  // the poll keeps running underneath, and this file's other job is to say whether the wake is
6
6
  // PROVEN: the loops raise their idle ceiling only while it is.
7
7
 
8
- import type { Random } from '@ultimat3/core';
8
+ import type { PgExecutor, Random } from '@ultimat3/core';
9
9
  import { backoffDelay, finiteOption, logger, renderThrowable } from '@ultimat3/core';
10
- import type { PgExecutor } from './driver-pg';
11
10
  import { JOBS_WAKE_CHANNEL, OUTBOX_WAKE_CHANNEL, SQL_WAKE } from './driver-pg-wake-sql';
12
11
  import { setWakeLive, signalEnqueued, signalStaged } from './enqueue-signal';
13
12
 
@@ -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';
6
+ import type { Clock, PgExecutor } from '@ultimat3/core';
7
7
  import { finiteOption, uuid } 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,
@@ -62,6 +61,8 @@ export function pgSchedulerState(executor: PgExecutor): SchedulerState {
62
61
  max_attempts: request.maxAttempts,
63
62
  })),
64
63
  ),
64
+ // Where the watermark lands when that is past the occurrence (`run-once`): ONE statement.
65
+ fire.watermarkMs ?? null,
65
66
  ]);
66
67
  if (Number(rows[0]?.fired ?? 0) === 0) return undefined;
67
68
  const queued = new Map(
@@ -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
 
@@ -44,7 +51,7 @@ 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 });
package/src/scheduler.ts CHANGED
@@ -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
  }
@@ -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
+ }