@ultimat3/jobs 2.0.0 → 4.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.
package/src/outbox-pg.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // the transaction's connection — so the wiring is one line there and no tier crossing here.
11
11
 
12
12
  import type { Clock } from '@ultimat3/core';
13
+ import { uuid } from '@ultimat3/core';
13
14
  import type { Tx } from '@ultimat3/entity';
14
15
  import { nowMs } from './clock';
15
16
  import type { PgExecutor } from './driver-pg';
@@ -17,9 +18,11 @@ import {
17
18
  SQL_OUTBOX_CLAIM,
18
19
  SQL_OUTBOX_MARK_PUBLISHED,
19
20
  SQL_OUTBOX_PENDING_COUNT,
21
+ SQL_OUTBOX_RELEASE,
20
22
  SQL_OUTBOX_STAGE,
21
23
  } from './driver-pg-sql';
22
24
  import type { OutboxRecord, OutboxStore } from './outbox';
25
+ import { resolveClaimLeaseMs } from './outbox-lease';
23
26
 
24
27
  interface OutboxRow {
25
28
  readonly id: string;
@@ -33,6 +36,7 @@ interface OutboxRow {
33
36
  readonly tenant_id: string | null;
34
37
  readonly traceparent: string | null;
35
38
  readonly enqueued_by: string | null;
39
+ readonly claimed_by?: string | null;
36
40
  }
37
41
 
38
42
  export interface PgOutboxOptions {
@@ -47,6 +51,22 @@ export interface PgOutboxOptions {
47
51
  */
48
52
  readonly txExecutor: (tx: Tx) => PgExecutor;
49
53
  readonly clock?: Clock;
54
+ /**
55
+ * How long a claimed row stays this relay's before any relay may take it again. It bounds one
56
+ * thing only: how long the rows of a relay that DIED mid-batch sit unpublished. A pass that is
57
+ * merely slow keeps its rows because it published them; a pass that failed hands them back
58
+ * through `release`.
59
+ */
60
+ readonly claimLeaseMs?: number;
61
+ /**
62
+ * Written to `claimed_by`, and read back as the FENCE on `release` and `markPublished` — so it
63
+ * must be UNIQUE PER PROCESS. Two replicas passing one literal are one claimant to Postgres, and
64
+ * each can then release or retire the other's live batch. Omit it: the default is
65
+ * `relay-<uuid>`, minted once per store, which is unique by construction. Diagnostics second —
66
+ * it is what an operator reads to see which relay is sitting on a batch, and a value that
67
+ * changed every tick would answer nobody.
68
+ */
69
+ readonly relayId?: string;
50
70
  }
51
71
 
52
72
  function toRecord(row: OutboxRow): OutboxRecord {
@@ -62,6 +82,7 @@ function toRecord(row: OutboxRow): OutboxRecord {
62
82
  ...(row.tenant_id === null ? {} : { tenantId: row.tenant_id }),
63
83
  ...(row.traceparent === null ? {} : { traceparent: row.traceparent }),
64
84
  ...(row.enqueued_by === null ? {} : { enqueuedBy: row.enqueued_by }),
85
+ ...(typeof row.claimed_by === 'string' ? { claimedBy: row.claimed_by } : {}),
65
86
  };
66
87
  }
67
88
 
@@ -71,6 +92,13 @@ export function createPgOutboxStore(options: PgOutboxOptions): OutboxStore {
71
92
  // is neither committed nor rolled back (a process killed mid-request) leaves nothing behind.
72
93
  const staged = new WeakMap<object, OutboxRecord[]>();
73
94
  const key = (tx: Tx): object => tx as unknown as object;
95
+ // One id per store, minted here rather than per claim: `claimed_by` is read by an operator
96
+ // asking which relay is sitting on a batch, and a value that changed every tick answers nobody.
97
+ // Per-store is also the granularity the fence needs — two relays are two processes, two stores.
98
+ const relayId = options.relayId ?? `relay-${uuid()}`;
99
+ // Resolved once, at construction, so a lease this store could never honour fails where it was
100
+ // written instead of inside a relay tick whose only trace is a log line nobody reads.
101
+ const claimLeaseMs = resolveClaimLeaseMs(options.claimLeaseMs);
74
102
 
75
103
  return {
76
104
  async stage(tx, record) {
@@ -112,18 +140,41 @@ export function createPgOutboxStore(options: PgOutboxOptions): OutboxStore {
112
140
  },
113
141
 
114
142
  /**
115
- * `for update skip locked` is in the statement, and under autocommit its row locks last only
116
- * for that statement — so two relays can hand the same row to `enqueue`. That is the
117
- * at-least-once the whole design already assumes and the idempotency key already collapses;
118
- * what the clause buys is that two relays running side by side do not serialise on each other.
143
+ * A CLAIM, not a read. `for update skip locked` in a bare select held its locks only for that
144
+ * statement — which under autocommit is over before this method resolves so two relays
145
+ * polling 200ms apart got the identical batch and both published it. The idempotency key
146
+ * collapses that only while the first job is still live, so the repeat that lands after it
147
+ * finished runs the handler a second time. `SQL_OUTBOX_CLAIM` stamps `claimed_at` in the same
148
+ * statement that locks the row; `skip locked` still keeps two relays from serialising.
119
149
  */
120
150
  async claim(limit) {
121
- const rows = await options.executor.query<OutboxRow>(SQL_OUTBOX_CLAIM, [limit]);
151
+ const rows = await options.executor.query<OutboxRow>(SQL_OUTBOX_CLAIM, [
152
+ limit,
153
+ claimLeaseMs,
154
+ relayId,
155
+ ]);
122
156
  return rows.map(toRecord);
123
157
  },
124
158
 
125
- async markPublished(id, at) {
126
- await options.executor.query(SQL_OUTBOX_MARK_PUBLISHED, [id, at || nowMs(options.clock)]);
159
+ /**
160
+ * Fenced on the CLAIMANT, not only on the ids. A relay that stalled past its lease wakes into
161
+ * a world where its batch belongs to another relay, and an unfenced release frees rows that
162
+ * relay is mid-publish on — a third relay claims them and publishes them again. `relayId` is
163
+ * the fallback because it is what this store stamped: a caller with no token is this store's
164
+ * own relay, and one holding somebody else's token could not have got it from here.
165
+ */
166
+ async release(ids, claimant) {
167
+ if (ids.length === 0) return;
168
+ await options.executor.query(SQL_OUTBOX_RELEASE, [ids, claimant ?? relayId]);
169
+ },
170
+
171
+ /** Same fence, and worse to miss: marking a row published is losing the job behind it. */
172
+ async markPublished(id, at, claimant) {
173
+ await options.executor.query(SQL_OUTBOX_MARK_PUBLISHED, [
174
+ id,
175
+ at || nowMs(options.clock),
176
+ claimant ?? relayId,
177
+ ]);
127
178
  },
128
179
 
129
180
  async pendingCount() {
package/src/outbox.ts CHANGED
@@ -31,6 +31,7 @@ import type { EnqueueResult, JobDriver } from './driver';
31
31
  import { DEFAULT_QUEUE, jobDriver } from './driver';
32
32
  import { DriverUnavailableError, OutboxNoTxError } from './errors';
33
33
  import type { JobHandle } from './job';
34
+ import { resolveClaimLeaseMs } from './outbox-lease';
34
35
 
35
36
  export interface OutboxRecord {
36
37
  readonly id: string;
@@ -46,6 +47,11 @@ export interface OutboxRecord {
46
47
  readonly traceparent?: string;
47
48
  readonly enqueuedBy?: string;
48
49
  readonly publishedAt?: number;
50
+ /**
51
+ * Stamped by `claim()`, absent on a staged row. Hand it back to `release`/`markPublished`: it is
52
+ * the FENCE, so a claimant whose lease lapsed cannot touch the rows a newer one is publishing.
53
+ */
54
+ readonly claimedBy?: string;
49
55
  }
50
56
 
51
57
  export interface OutboxStore {
@@ -55,12 +61,44 @@ export interface OutboxStore {
55
61
  commit(tx: Tx): Promise<readonly OutboxRecord[]>;
56
62
  /** Called by the tx runner after ROLLBACK. Staged rows vanish with the transaction. */
57
63
  rollback(tx: Tx): Promise<void>;
58
- /** Unpublished, committed rows — the relay's work queue. */
64
+ /**
65
+ * CLAIM unpublished, committed rows — the relay's work queue, and a lease rather than a read.
66
+ * A store that hands the same rows to two relays hands the same job to two workers, and the
67
+ * idempotency key only collapses that while the first job is still live.
68
+ */
59
69
  claim(limit: number): Promise<readonly OutboxRecord[]>;
60
- markPublished(id: string, at: number): Promise<void>;
70
+ /**
71
+ * Hand a claim back before its lease runs out, for the batch a failed publish stopped. OPTIONAL
72
+ * so a store written before the claim became a lease still compiles: without it those rows wait
73
+ * out the whole lease, which is slower, never wrong.
74
+ *
75
+ * `claimant` is the `claimedBy` the claim stamped. Passing it is what makes a lapsed relay's
76
+ * late release a no-op instead of an unclaim of somebody else's live batch.
77
+ */
78
+ release?(ids: readonly string[], claimant?: string): Promise<void>;
79
+ /** `claimant` fences the same way, and here it is worse to miss: this retires the row. */
80
+ markPublished(id: string, at: number, claimant?: string): Promise<void>;
61
81
  pendingCount(): Promise<number>;
62
82
  }
63
83
 
84
+ /**
85
+ * The claim's sort key, and it is TOTAL: `id` after `stagedAt`, exactly what `SQL_OUTBOX_CLAIM`
86
+ * orders by. Every row staged in one transaction shares a `stagedAt`, so the key ties for the
87
+ * batch that most depends on order — and a tie leaves both which rows a limit takes and the order
88
+ * they publish in to whatever the store iterated first. Code units, never `localeCompare`, for
89
+ * the reason `registeredJobs()` sorts that way.
90
+ */
91
+ function byClaimOrder(a: OutboxRecord, b: OutboxRecord): number {
92
+ if (a.stagedAt !== b.stagedAt) return a.stagedAt - b.stagedAt;
93
+ if (a.id === b.id) return 0;
94
+ return a.id < b.id ? -1 : 1;
95
+ }
96
+
97
+ export interface MemoryOutboxOptions {
98
+ readonly clock?: Clock;
99
+ readonly claimLeaseMs?: number;
100
+ }
101
+
64
102
  export interface MemoryOutboxStore extends OutboxStore {
65
103
  /**
66
104
  * Committed rows this process is still holding. The relay's backlog and nothing else — a
@@ -75,11 +113,29 @@ export interface MemoryOutboxStore extends OutboxStore {
75
113
  * transaction" guarantee needs no cooperation from the DB layer and rollback is a delete.
76
114
  * The pg store swaps this for a real `x_outbox` table written by the same connection.
77
115
  */
78
- export function createMemoryOutboxStore(): MemoryOutboxStore {
116
+ export function createMemoryOutboxStore(options: MemoryOutboxOptions = {}): MemoryOutboxStore {
79
117
  const staged = new WeakMap<object, OutboxRecord[]>();
80
118
  const committed = new Map<string, OutboxRecord>();
119
+ /** Each claimed row's lease: when it was taken and by whom. Absent is `claimed_at is null`. */
120
+ const claims = new Map<string, { at: number; by: string }>();
121
+ const leaseMs = resolveClaimLeaseMs(options.claimLeaseMs);
122
+ // A token per CLAIM, where the pg store stamps one per RELAY. Two relays there are two stores
123
+ // with two ids; here they are two `claim()` calls on one store, so the claim is the only
124
+ // granularity at which this store can answer "is this mutation from the current holder".
125
+ let claimSeq = 0;
81
126
 
82
127
  const key = (tx: Tx): object => tx as unknown as object;
128
+ const free = (id: string, at: number): boolean => {
129
+ const claim = claims.get(id);
130
+ return claim === undefined || at - claim.at >= leaseMs;
131
+ };
132
+ /**
133
+ * A mutation from a claimant that no longer holds the row is a NO-OP. `undefined` is the caller
134
+ * that holds no token at all — a store-level caller, or one written before the fence — and is
135
+ * left unfenced rather than silently dropped, the way `release` itself is optional.
136
+ */
137
+ const owns = (id: string, claimant: string | undefined): boolean =>
138
+ claimant === undefined || claims.get(id)?.by === claimant;
83
139
 
84
140
  return {
85
141
  stage(tx, record) {
@@ -98,19 +154,36 @@ export function createMemoryOutboxStore(): MemoryOutboxStore {
98
154
  staged.delete(key(tx));
99
155
  return Promise.resolve();
100
156
  },
157
+ /**
158
+ * The same question `SQL_OUTBOX_CLAIM` answers, and it has to stay the same one: a row this
159
+ * store hands back is CLAIMED for `leaseMs`, so a second relay polling the same store gets
160
+ * nothing, and a claim whose holder died is reclaimable once the window passes.
161
+ */
101
162
  claim(limit) {
163
+ const at = nowMs(options.clock);
164
+ claimSeq += 1;
165
+ const by = `claim-${claimSeq}`;
102
166
  const ready = [...committed.values()]
103
- .filter((record) => record.publishedAt === undefined)
104
- .sort((a, b) => a.stagedAt - b.stagedAt)
167
+ .filter((record) => record.publishedAt === undefined && free(record.id, at))
168
+ .sort(byClaimOrder)
105
169
  .slice(0, limit);
106
- return Promise.resolve(ready);
170
+ for (const record of ready) claims.set(record.id, { at, by });
171
+ return Promise.resolve(ready.map((record) => ({ ...record, claimedBy: by })));
172
+ },
173
+ release(ids, claimant) {
174
+ for (const id of ids) {
175
+ if (owns(id, claimant)) claims.delete(id);
176
+ }
177
+ return Promise.resolve();
107
178
  },
108
- markPublished(id, _at) {
179
+ markPublished(id, _at, claimant) {
180
+ if (!owns(id, claimant)) return Promise.resolve();
109
181
  // Deleted, not stamped. A published row is out of the relay's reach either way, and the
110
182
  // pg store's `published_at` column is a retained audit trail this map is not: rewriting
111
183
  // it in place held every payload ever enqueued — arbitrary job input — for the life of
112
184
  // the process, and made `claim()` and `pendingCount()` walk all of them every 200ms.
113
185
  committed.delete(id);
186
+ claims.delete(id);
114
187
  return Promise.resolve();
115
188
  },
116
189
  pendingCount() {
@@ -331,7 +404,10 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
331
404
  ...(record.traceparent === undefined ? {} : { traceparent: record.traceparent }),
332
405
  ...(record.enqueuedBy === undefined ? {} : { enqueuedBy: record.enqueuedBy }),
333
406
  });
334
- await options.store.markPublished(record.id, nowMs(options.clock));
407
+ // The claim's own token goes back with the mark. Without it a relay whose lease lapsed
408
+ // mid-stall retires a row the relay that reclaimed it has not published yet — the row is
409
+ // gone and nothing publishes it.
410
+ await options.store.markPublished(record.id, nowMs(options.clock), record.claimedBy);
335
411
  published += 1;
336
412
  } catch (error) {
337
413
  // STOP the batch. `claim()` returns rows in `staged_at` order and the loop used to log
@@ -347,6 +423,13 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
347
423
  remaining: batch.length - published,
348
424
  error: error instanceof Error ? error.message : String(error),
349
425
  });
426
+ // Hand the rest of the batch back rather than sit on a claim nobody is publishing. The
427
+ // claim is a lease now, so without this a single pool timeout parks every committed row
428
+ // behind it for the whole lease window instead of for one poll interval.
429
+ await options.store.release?.(
430
+ batch.slice(published).map((row) => row.id),
431
+ record.claimedBy,
432
+ );
350
433
  break;
351
434
  }
352
435
  }
package/src/register.ts CHANGED
@@ -5,9 +5,24 @@
5
5
  */
6
6
 
7
7
  import { type RegisteredPrimitive, registerPrimitiveRegistrar } from '@ultimat3/core';
8
+ import { ActionJobUnbridgedError } from './errors';
8
9
  import { isJobHandle, registerJob } from './job';
9
10
  import { isTaskHandle, registerTask } from './task';
10
11
 
12
+ /**
13
+ * `@ultimat3/action`'s job PROJECTION, recognised structurally because that package is this tier
14
+ * and may never be imported here. `kind: 'action-job'` is a literal chosen to be distinguishable
15
+ * from `'job'` rather than a near-miss (`packages/action/src/job-handle.ts` says so), which is
16
+ * precisely what makes this check possible without an import.
17
+ */
18
+ const isActionProjection = (
19
+ value: unknown,
20
+ ): value is { readonly kind: 'action-job'; readonly name: string } =>
21
+ typeof value === 'object' &&
22
+ value !== null &&
23
+ 'kind' in value &&
24
+ (value as { readonly kind: unknown }).kind === 'action-job';
25
+
11
26
  /** `registerJobs(await import('./jobs'))` — export names become job names. */
12
27
  export function registerJobs(
13
28
  module: Readonly<Record<string, unknown>>,
@@ -15,7 +30,16 @@ export function registerJobs(
15
30
  const registered: RegisteredPrimitive[] = [];
16
31
  for (const name of Object.keys(module).sort()) {
17
32
  const value = module[name];
18
- if (isJobHandle(value)) registered.push(registerJob(name, value));
33
+ if (isJobHandle(value)) {
34
+ registered.push(registerJob(name, value));
35
+ continue;
36
+ }
37
+ // Everything else is skipped in silence — a module namespace is full of constants, types and
38
+ // helpers exported beside the jobs. Everything else EXCEPT this one, which is unambiguously
39
+ // someone trying to queue an action and getting nothing at all.
40
+ if (isActionProjection(value)) {
41
+ throw new ActionJobUnbridgedError({ export: name, job: value.name });
42
+ }
19
43
  }
20
44
  return registered;
21
45
  }
@@ -0,0 +1,35 @@
1
+ // A renewal loop that is TERMINAL once stopped, and the one shape two files renew against.
2
+ // `heartbeat.ts` renews a job's lease and `worker-fleet-slots.ts` a fleet slot, and both decided a
3
+ // LOSS from an answer that arrived after the run had already finished cleanly: `stop()` cleared
4
+ // the interval, which does nothing to the request already on the wire. So a flag is what every
5
+ // branch after an `await` re-reads — the shape `settleWithin`'s `decided` uses in core.
6
+
7
+ export interface RenewalTimer {
8
+ /**
9
+ * True once `stop()` has been called. Read AFTER every await in the renewal body: a clean
10
+ * completion settles the row this renewal is fenced on, so the driver answering "not yours"
11
+ * past that point is a finished job, not a lost lease — and reporting it is an error-level page
12
+ * for a non-event, on exactly the signals that mean the queue re-delivered live work.
13
+ */
14
+ stopped(): boolean;
15
+ /** Stop renewing, for the pass in flight as well as the next one. Idempotent. */
16
+ stop(): void;
17
+ }
18
+
19
+ export function startRenewalTimer(
20
+ intervalMs: number,
21
+ renew: () => void | Promise<void>,
22
+ ): RenewalTimer {
23
+ let stopped = false;
24
+ const timer = setInterval(() => {
25
+ void renew();
26
+ }, intervalMs);
27
+ return {
28
+ stopped: () => stopped,
29
+ stop(): void {
30
+ if (stopped) return;
31
+ stopped = true;
32
+ clearInterval(timer);
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,112 @@
1
+ // One retry decision from two questions the executor used to ask only half of: "are there
2
+ // attempts left?" (./retry) and "is this error worth trying again at all?" (core's classification).
3
+ // The backoff arithmetic stays in ./retry — nothing here recomputes a delay `nextRetry` owns.
4
+
5
+ import type { ErrorRetry } from '@ultimat3/core';
6
+ import {
7
+ DEFAULT_ERROR_RETRY,
8
+ declaredErrorRetry,
9
+ isErrorRetry,
10
+ isUltimateError,
11
+ } from '@ultimat3/core';
12
+ import { toMs } from './clock';
13
+ import type { Random, RetryDecision, RetryPolicy } from './retry';
14
+ import { DEFAULT_RETRY, nextRetry } from './retry';
15
+
16
+ /** Why this attempt was the last one. Absent while the job is still being retried. */
17
+ export type JobStopReason = 'terminal' | 'attempts-exhausted';
18
+
19
+ export interface JobRetryDecision extends RetryDecision {
20
+ readonly stoppedBy: JobStopReason | undefined;
21
+ /** The classification consulted, or `undefined` when nobody classified the thrown code. */
22
+ readonly classification: ErrorRetry | undefined;
23
+ }
24
+
25
+ /**
26
+ * The classification that was DECLARED for this throw, or `undefined` when there is none.
27
+ *
28
+ * Deliberately not `error.retry` alone. That field is `init.retry ?? retryFor(code)` and
29
+ * `retryFor` fails closed, so every unclassified `UltimateError` already carries `terminal` —
30
+ * reading it would dead-letter the first attempt of every job in every app whose codes nobody has
31
+ * classified yet. So `terminal` counts only when it can have come from somewhere: an explicit
32
+ * per-instance override is indistinguishable from the default here, which is why an UNCLASSIFIED
33
+ * code carrying an instance `retry: 'terminal'` is read as unclassified. Register the code
34
+ * (`registerErrorRetry({ X_YOUR_CODE: 'terminal' })`) to have it honoured — one way, and the same
35
+ * way every other package declares it.
36
+ */
37
+ export function classifyThrown(error: unknown): ErrorRetry | undefined {
38
+ if (!isUltimateError(error)) return undefined;
39
+ const retry: unknown = error.retry;
40
+ if (!isErrorRetry(retry)) return undefined;
41
+ // Anything other than the fail-closed default can only have come from the code table or from an
42
+ // explicit override, so it is somebody's answer either way.
43
+ if (retry !== DEFAULT_ERROR_RETRY) return retry;
44
+ return declaredErrorRetry(error.code) === undefined ? undefined : retry;
45
+ }
46
+
47
+ /**
48
+ * The delay a `retry-after` error NAMED, in ms. `retryAfterSeconds` on the error's `meta` is the
49
+ * framework's one spelling for it — `@ultimat3/http`'s `rateLimited` writes it and the 429's
50
+ * `Retry-After` header renders it — so a job and an HTTP client read the same number.
51
+ */
52
+ export function statedDelayMs(error: unknown): number | undefined {
53
+ if (!isUltimateError(error)) return undefined;
54
+ const seconds: unknown = error.meta?.['retryAfterSeconds'];
55
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
56
+ return Math.round(seconds * 1_000);
57
+ }
58
+
59
+ /**
60
+ * Retry, dead-letter, and when. `terminal` stops here on the attempt that failed — the same code
61
+ * run again is the same answer, and the attempts left are a queue slot, a provider bill, and (the
62
+ * case that forced this) three more wrong passwords at a site that locks the account after three.
63
+ *
64
+ * Everything else keeps the attempt count in charge: `retry-after` only replaces the delay, never
65
+ * the ceiling, and an unclassified code takes exactly the path it took before this existed.
66
+ */
67
+ export function nextRetryForError(
68
+ policy: RetryPolicy,
69
+ attempt: number,
70
+ error: unknown,
71
+ random?: Random,
72
+ ): JobRetryDecision {
73
+ const classification = classifyThrown(error);
74
+ if (classification === 'terminal') {
75
+ return {
76
+ retry: false,
77
+ delayMs: 0,
78
+ // The policy still decides park-or-drop: `deadLetter: false` means this app does not keep
79
+ // failed jobs, and that is not a preference a classification gets to overturn.
80
+ deadLetter: policy.deadLetter ?? true,
81
+ nextAttempt: attempt,
82
+ stoppedBy: 'terminal',
83
+ classification,
84
+ };
85
+ }
86
+
87
+ const decision = nextRetry(policy, attempt, random);
88
+ if (!decision.retry) {
89
+ return { ...decision, stoppedBy: 'attempts-exhausted', classification };
90
+ }
91
+ if (classification !== 'retry-after')
92
+ return { ...decision, stoppedBy: undefined, classification };
93
+
94
+ const stated = statedDelayMs(error);
95
+ if (stated === undefined) return { ...decision, stoppedBy: undefined, classification };
96
+ // Clamped by the policy's own ceiling, which is what `maxDelay` is for: a responder naming a
97
+ // day is still a responder this deployment has not agreed to wait a day for.
98
+ const cap = toMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay);
99
+ return { ...decision, delayMs: Math.min(stated, cap), stoppedBy: undefined, classification };
100
+ }
101
+
102
+ /**
103
+ * What the job ROW records. `lastError` is the one failure field a row carries, so a dead letter
104
+ * that stopped at attempt 1 of 5 has to explain itself there or `x jobs show` reads as a silent
105
+ * early stop. Only the terminal verdict is appended: exhaustion is already legible from
106
+ * `attempt === maxAttempts`.
107
+ */
108
+ export function recordedFailure(message: string, decision: JobRetryDecision): string {
109
+ return decision.stoppedBy === 'terminal'
110
+ ? `${message} — not retried: this code is classified terminal, so every remaining attempt fails the same way`
111
+ : message;
112
+ }
package/src/retry.ts CHANGED
@@ -1,6 +1,7 @@
1
- // Retry arithmetic, kept pure so the schedule is testable and printable. `x jobs schedule`
2
- // renders `retrySchedule()` verbatim — an agent should be able to see when attempt 5 lands
3
- // without running the queue.
1
+ // Retry arithmetic, kept pure so the schedule is testable and printable. `x jobs show <id>`
2
+ // renders `retrySchedule()` verbatim as `JobTrace.retryDelaysMs` — an agent should be able to see
3
+ // when attempt 5 lands without running the queue. There is no `x jobs schedule`: the subcommands
4
+ // are `ls`, `show`, `retry`, `cancel`, `drain`.
4
5
 
5
6
  import type { DurationInput } from './clock';
6
7
  import { toMs } from './clock';
package/src/steps.ts CHANGED
@@ -12,7 +12,20 @@ import type { DurationInput } from './clock';
12
12
  import { nowMs, toMs } from './clock';
13
13
  import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
14
14
 
15
- export type StepStatus = 'completed' | 'sleeping' | 'waiting' | 'failed';
15
+ /**
16
+ * The runtime list is the declaration and `StepStatus` is derived from it, the shape
17
+ * `BACKFILL_STATUSES` and `PRIMITIVE_KINDS` already have. A bare union cannot narrow a `text`
18
+ * column, so `driver-pg-rows.ts` cast one instead — and a cast that lands an unknown status on a
19
+ * record makes `stepRun`'s `existing?.status === 'completed'` false, which RE-EXECUTES a step
20
+ * this file promises runs once.
21
+ */
22
+ export const STEP_STATUSES = ['completed', 'sleeping', 'waiting', 'failed'] as const;
23
+
24
+ export type StepStatus = (typeof STEP_STATUSES)[number];
25
+
26
+ /** Narrows a status read back out of a store. Never a cast — the list decides. */
27
+ export const isStepStatus = (value: string): value is StepStatus =>
28
+ (STEP_STATUSES as readonly string[]).includes(value);
16
29
 
17
30
  export interface StepRecord {
18
31
  readonly runId: string;
package/src/task.ts CHANGED
@@ -54,6 +54,7 @@ export interface TaskDefinition {
54
54
  */
55
55
  enqueue: (occurrenceMs: number) => readonly TaskEnqueueEntry[];
56
56
  readonly catchUp?: CatchUpPolicy;
57
+ /** Whole occurrences per round, one or more. Zero fires nothing at all, and `task()` refuses it. */
57
58
  readonly maxCatchUp?: number;
58
59
  }
59
60
 
@@ -98,6 +99,9 @@ export interface TaskHandle {
98
99
  const registry = new Map<string, TaskHandle>();
99
100
  let anonymous = 0;
100
101
 
102
+ /** Occurrences one round may fire when neither the declaration nor a catch-up says otherwise. */
103
+ const DEFAULT_MAX_CATCH_UP = 10;
104
+
101
105
  /** Job's store, for tasks: proof `task()` built the handle, plus whether it named itself. */
102
106
  interface TaskOrigin {
103
107
  readonly declaredName: boolean;
@@ -127,13 +131,26 @@ export function task(definition: TaskDefinition): TaskHandle {
127
131
  `use the full zone id on task("${name}"), e.g. tz: 'America/Bogota' — list the valid ones with: bun -e "console.log(Intl.supportedValuesOf('timeZone').join('\\n'))"`,
128
132
  );
129
133
 
134
+ // `maxCatchUp: 0` is not "no ceiling" — `occurrencesSince` walks
135
+ // `for (let i = 0; i < handle.maxCatchUp; i += 1)`, so zero (and any negative, and any fraction
136
+ // below one) returns an empty list on every round and the task NEVER fires: no error, no log
137
+ // line, no queue row, forever. Refused where it is written, exactly as `job()` refuses
138
+ // `concurrency: 0` and `createPacer` refuses `rate: 0`. `Number.isInteger` covers `NaN` and
139
+ // `Infinity` in the same predicate — an unbounded catch-up is a burst nobody declared.
140
+ assert(
141
+ definition.maxCatchUp === undefined ||
142
+ (Number.isInteger(definition.maxCatchUp) && definition.maxCatchUp >= 1),
143
+ `task "${name}" declares maxCatchUp ${String(definition.maxCatchUp)}, so no occurrence can ever fire`,
144
+ `set a whole maxCatchUp of 1 or more on task("${name}"), or omit the field for the default of ${DEFAULT_MAX_CATCH_UP}`,
145
+ );
146
+
130
147
  const handle: TaskHandle = {
131
148
  kind: 'task',
132
149
  name,
133
150
  cron: definition.cron,
134
151
  tz: definition.tz,
135
152
  catchUp: definition.catchUp ?? 'skip',
136
- maxCatchUp: definition.maxCatchUp ?? 10,
153
+ maxCatchUp: definition.maxCatchUp ?? DEFAULT_MAX_CATCH_UP,
137
154
  // `nowMs()` and not `Date.now()`: every reading of time in this package goes through a
138
155
  // Clock so a frozen one cannot be bypassed.
139
156
  entries: (occurrenceMs: number = nowMs()) => definition.enqueue(occurrenceMs),
@@ -215,8 +232,18 @@ export function nameTasks(record: Readonly<Record<string, TaskHandle>>): void {
215
232
  for (const [exportName, handle] of Object.entries(record)) registerTask(exportName, handle);
216
233
  }
217
234
 
235
+ /**
236
+ * Code-unit compare, never `localeCompare`. This list is projected into `x.manifest.json`, which
237
+ * both tracked apps COMMIT and `x verify`'s drift step diffs byte for byte — and `localeCompare`
238
+ * with no locale argument answers from the runtime's ICU default and collation version, so the
239
+ * same source could sort two ways on two machines. `@ultimat3/http`'s `describeRoutes` states the
240
+ * same rule; the comparator is restated rather than imported because `http` is not below this
241
+ * package on the tier table.
242
+ */
243
+ const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
244
+
218
245
  export function registeredTasks(): readonly TaskHandle[] {
219
- return [...registry.values()].sort((a, b) => a.name.localeCompare(b.name));
246
+ return [...registry.values()].sort((a, b) => byName(a.name, b.name));
220
247
  }
221
248
 
222
249
  export function getTask(name: string): TaskHandle | undefined {
@@ -8,6 +8,7 @@ import type { ClaimedJob } from './driver';
8
8
  import { getJob } from './job';
9
9
  import type { HeldLease, LeaseStore } from './leases';
10
10
  import { jobLeaseKey } from './leases';
11
+ import { startRenewalTimer } from './renewal-timer';
11
12
 
12
13
  /**
13
14
  * A renewal that REJECTED is not a lost slot: there is a TTL behind it and the interval gets
@@ -79,21 +80,25 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
79
80
  startRenewal(jobId, onLost) {
80
81
  const slot = held.get(jobId);
81
82
  if (slot === undefined) return noop;
82
- const stop = (): void => {
83
- clearInterval(timer);
84
- };
85
83
  // Renewed on the lease heartbeat's own interval and released in the same `finally`: one
86
84
  // clock for "this worker still owns the job" and "this worker still owns the slot" is one
87
- // fewer way for them to disagree.
88
- const timer = setInterval(() => {
89
- void options.leases
85
+ // fewer way for them to disagree — and `timer.stopped()` is the same latch `heartbeat.ts`
86
+ // reads, for the same reason.
87
+ const timer = startRenewalTimer(options.renewIntervalMs, () =>
88
+ options.leases
90
89
  ?.renew(slot, options.ttlMs)
91
90
  .then((renewed) => {
92
91
  // `=== false`, never `!renewed`, for the reason `heartbeat.ts` reads `held` that way:
93
92
  // a store written before this return value existed resolves `undefined`, and treating
94
93
  // that as a loss would cancel every job on every renewal. Only an explicit no is one.
95
- if (renewed !== false) return;
96
- stop();
94
+ //
95
+ // `stopped()` re-read AFTER the await for the other half: the run settles, this timer
96
+ // is stopped and `worker.ts` releases the slot — so the renewal already on the wire
97
+ // finds the row gone and answers `false` for a job that FINISHED. Reported, that is
98
+ // `jobs.worker.slot-lost` at error and an abort on a controller `runSignal.dispose()`
99
+ // has already torn down: noise about a run nobody lost.
100
+ if (renewed !== false || timer.stopped()) return;
101
+ timer.stop();
97
102
  logger.error('jobs.worker.slot-lost', {
98
103
  workerId: options.workerId,
99
104
  jobId,
@@ -102,9 +107,9 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
102
107
  });
103
108
  onLost?.(slot);
104
109
  })
105
- .catch(noop);
106
- }, options.renewIntervalMs);
107
- return stop;
110
+ .catch(noop),
111
+ );
112
+ return () => timer.stop();
108
113
  },
109
114
 
110
115
  async release(jobId) {
package/src/worker-run.ts CHANGED
@@ -48,10 +48,13 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
48
48
  const handle = getJob(claimed.name);
49
49
  if (handle === undefined) {
50
50
  // Park it, do not burn attempts: the job may well be registered by the pod next to this one.
51
+ // A genuine park — no worker in this deploy can run it — so it leaves the ready bucket, unlike
52
+ // a limiter shed, which is a job this fleet will pick up on its next pass.
51
53
  await driver.nack(claimed.id, {
52
54
  delayMs: 30_000,
53
55
  error: `no job registered as "${claimed.name}"`,
54
56
  countsAsAttempt: false,
57
+ park: true,
55
58
  });
56
59
  return unknownJob(claimed);
57
60
  }