@ultimat3/jobs 22.14.0 → 23.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 (63) hide show
  1. package/CLAUDE.md +129 -87
  2. package/README.md +329 -19
  3. package/package.json +6 -6
  4. package/src/concurrency.ts +144 -0
  5. package/src/counters.ts +105 -0
  6. package/src/describe.ts +29 -0
  7. package/src/driver-memory-operator.ts +297 -0
  8. package/src/driver-memory.ts +79 -37
  9. package/src/driver-nats.ts +2 -2
  10. package/src/driver-pg-ddl.ts +71 -0
  11. package/src/driver-pg-jobs-sql.ts +71 -8
  12. package/src/driver-pg-operator-sql.ts +303 -0
  13. package/src/driver-pg-operator.ts +300 -0
  14. package/src/driver-pg-outbox-sql.ts +110 -0
  15. package/src/driver-pg-rows.ts +31 -0
  16. package/src/driver-pg-settle-sql.ts +71 -0
  17. package/src/driver-pg-sql.ts +80 -129
  18. package/src/driver-pg-wake-sql.ts +76 -0
  19. package/src/driver-pg.ts +74 -34
  20. package/src/driver-redis.ts +2 -2
  21. package/src/driver.ts +96 -34
  22. package/src/enqueue-signal.ts +50 -0
  23. package/src/errors-concurrency.ts +91 -0
  24. package/src/errors-operator.ts +94 -0
  25. package/src/errors.ts +13 -17
  26. package/src/events-pg.ts +38 -28
  27. package/src/events.ts +25 -0
  28. package/src/execute.ts +160 -81
  29. package/src/export.ts +23 -4
  30. package/src/heartbeat.ts +9 -3
  31. package/src/idle-backoff.ts +74 -0
  32. package/src/index.ts +105 -12
  33. package/src/inspect-operator.ts +49 -0
  34. package/src/inspect.ts +42 -2
  35. package/src/introspection.ts +262 -0
  36. package/src/job.ts +78 -15
  37. package/src/leases.ts +49 -9
  38. package/src/metrics.ts +6 -1
  39. package/src/outbox-pg.ts +3 -0
  40. package/src/outbox-relay.ts +114 -39
  41. package/src/outbox.ts +29 -6
  42. package/src/progress.ts +78 -0
  43. package/src/queue-wake.ts +220 -0
  44. package/src/redact-input.ts +26 -0
  45. package/src/renewal-timer.ts +28 -10
  46. package/src/retry-classification.ts +13 -1
  47. package/src/retry.ts +10 -2
  48. package/src/run-deadline.ts +57 -0
  49. package/src/scheduler-leader.ts +26 -0
  50. package/src/scheduler-occurrences.ts +76 -0
  51. package/src/scheduler-pg.ts +55 -2
  52. package/src/scheduler-state.ts +66 -0
  53. package/src/scheduler.ts +143 -103
  54. package/src/settled.ts +161 -0
  55. package/src/worker-admit.ts +163 -0
  56. package/src/worker-fleet-slots.ts +107 -38
  57. package/src/worker-hand-back.ts +6 -1
  58. package/src/worker-key-busy.ts +79 -0
  59. package/src/worker-loop.ts +145 -0
  60. package/src/worker-registry.ts +103 -0
  61. package/src/worker-run.ts +9 -0
  62. package/src/worker-types.ts +23 -0
  63. package/src/worker.ts +103 -134
@@ -0,0 +1,105 @@
1
+ // The per-job history counters: which bucket a settle lands in, what it adds, and how buckets
2
+ // age from one tier to the next. The arithmetic lives here ONCE — the memory store below runs it
3
+ // directly and the pg statements (`driver-pg-operator-sql.ts`) spell the same floor in SQL, so a
4
+ // count read under `x dev` and one read in production are the same number for the same settles.
5
+
6
+ import type { NackOptions } from './driver';
7
+ import { nackState } from './driver';
8
+ import type { CounterBucketMs, CounterOutcome, CounterTotals, JobCounter } from './introspection';
9
+ import { COUNTER_BUCKET_MS, COUNTER_TIERS } from './introspection';
10
+
11
+ /** The instant a bucket of `bucketMs` containing `atMs` opens. */
12
+ export const counterBucketStart = (atMs: number, bucketMs: number): number =>
13
+ Math.floor(atMs / bucketMs) * bucketMs;
14
+
15
+ /**
16
+ * What a nack adds, or nothing. A shed, a suspension and a drained attempt are handed back
17
+ * UNCOUNTED and are not history: counting them would make every `step.sleep` a failure.
18
+ */
19
+ export function nackOutcome(options: NackOptions): CounterOutcome | undefined {
20
+ const state = nackState(options);
21
+ if (state === 'dead') return 'dead';
22
+ if (state === 'failed') return 'failed';
23
+ return state === 'ready' && options.countsAsAttempt !== false ? 'retried' : undefined;
24
+ }
25
+
26
+ type Counts = Omit<JobCounter, 'job' | 'bucketStart' | 'bucketMs'>;
27
+
28
+ const ZERO: Counts = Object.freeze({ done: 0, retried: 0, failed: 0, dead: 0, durationMs: 0 });
29
+
30
+ const sum = (a: Counts, b: Counts): Counts => ({
31
+ done: a.done + b.done,
32
+ retried: a.retried + b.retried,
33
+ failed: a.failed + b.failed,
34
+ dead: a.dead + b.dead,
35
+ durationMs: a.durationMs + b.durationMs,
36
+ });
37
+
38
+ const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
39
+
40
+ export interface MemoryCounters {
41
+ add(job: string, outcome: CounterOutcome, durationMs: number, atMs: number): void;
42
+ list(query: { readonly job: string; readonly sinceMs: number }): readonly JobCounter[];
43
+ totals(sinceMs: number): readonly CounterTotals[];
44
+ rollup(atMs: number): number;
45
+ }
46
+
47
+ /** The memory driver's counters. Same buckets, same tiers, same fold as `x_job_counters`. */
48
+ export function createMemoryCounters(): MemoryCounters {
49
+ const buckets = new Map<string, JobCounter>();
50
+ const keyOf = (job: string, bucketMs: number, bucketStart: number): string =>
51
+ `${bucketMs}\u0000${bucketStart}\u0000${job}`;
52
+
53
+ const merge = (
54
+ job: string,
55
+ bucketMs: CounterBucketMs,
56
+ bucketStart: number,
57
+ add: Counts,
58
+ ): void => {
59
+ const key = keyOf(job, bucketMs, bucketStart);
60
+ const current = buckets.get(key);
61
+ buckets.set(key, { job, bucketMs, bucketStart, ...sum(current ?? ZERO, add) });
62
+ };
63
+
64
+ return {
65
+ add(job, outcome, durationMs, atMs) {
66
+ const start = counterBucketStart(atMs, COUNTER_BUCKET_MS);
67
+ merge(job, COUNTER_BUCKET_MS, start, { ...ZERO, [outcome]: 1, durationMs });
68
+ },
69
+ list({ job, sinceMs }) {
70
+ return [...buckets.values()]
71
+ .filter((bucket) => bucket.job === job && bucket.bucketStart >= sinceMs)
72
+ .sort((a, b) => a.bucketStart - b.bucketStart || a.bucketMs - b.bucketMs);
73
+ },
74
+ totals(sinceMs) {
75
+ const byJob = new Map<string, Counts>();
76
+ for (const bucket of buckets.values()) {
77
+ if (bucket.bucketStart < sinceMs) continue;
78
+ byJob.set(bucket.job, sum(byJob.get(bucket.job) ?? ZERO, bucket));
79
+ }
80
+ return [...byJob.entries()]
81
+ .sort(([a], [b]) => byName(a, b))
82
+ .map(([job, counts]) => ({ job, ...sum(ZERO, counts) }));
83
+ },
84
+ rollup(atMs) {
85
+ let moved = 0;
86
+ for (const [index, tier] of COUNTER_TIERS.entries()) {
87
+ const next = COUNTER_TIERS[index + 1];
88
+ for (const [key, bucket] of [...buckets]) {
89
+ if (bucket.bucketMs !== tier.bucketMs || bucket.bucketStart >= atMs - tier.keepMs)
90
+ continue;
91
+ buckets.delete(key);
92
+ moved += 1;
93
+ if (next === undefined) continue;
94
+ merge(
95
+ bucket.job,
96
+ next.bucketMs,
97
+ counterBucketStart(bucket.bucketStart, next.bucketMs),
98
+ bucket,
99
+ );
100
+ }
101
+ }
102
+ return moved;
103
+ },
104
+ };
105
+ }
package/src/describe.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // clock- or iteration-order-dependent in this shape is a spurious change on every build.
4
4
 
5
5
  import { toJsonSchema } from '@ultimat3/schema';
6
+ import type { WhenBusy } from './concurrency';
6
7
  import type { BackoffStrategy, RetryPolicy } from './retry';
7
8
  import { DEFAULT_RETRY } from './retry';
8
9
 
@@ -22,6 +23,21 @@ export interface JobDescriptor {
22
23
  * is app data, so a descriptor carrying it would put customer ids in `x.manifest.json`.
23
24
  */
24
25
  readonly idempotent: boolean;
26
+ /**
27
+ * The declared fleet-wide cap, or `null` for none. `keyed: true` says `limit` holds per
28
+ * `concurrency.key(input)` rather than for the whole job, and `whenBusy` is what a claim over
29
+ * it does — `null` for a plain number, which always waits. The KEY never crosses, for
30
+ * `idempotent`'s reason: it is computed from an input. `x jobs show <id>` reports one run's.
31
+ */
32
+ readonly concurrency: JobConcurrencyDescriptor | null;
33
+ /** Whether the job declares an `onSettled` hook — what is told how each run ended. */
34
+ readonly onSettled: boolean;
35
+ }
36
+
37
+ export interface JobConcurrencyDescriptor {
38
+ readonly limit: number;
39
+ readonly keyed: boolean;
40
+ readonly whenBusy: WhenBusy | null;
25
41
  }
26
42
 
27
43
  /**
@@ -40,6 +56,10 @@ export interface DescribableJob {
40
56
  * which is the exact wrong answer the `/_x` jobs panel used to give for every job.
41
57
  */
42
58
  readonly idempotencyKeyFor: unknown;
59
+ /** `JobHandle.concurrency` and `.whenBusy` — the cap as `job()` resolved it. */
60
+ readonly concurrency: number | undefined;
61
+ readonly whenBusy: WhenBusy | undefined;
62
+ readonly declaresOnSettled: boolean;
43
63
  }
44
64
 
45
65
  export function describeJob(handle: DescribableJob): JobDescriptor {
@@ -55,6 +75,15 @@ export function describeJob(handle: DescribableJob): JobDescriptor {
55
75
  // not statically knowable. `inspect(name)` reports the steps an actual run recorded.
56
76
  steps: [],
57
77
  idempotent: typeof handle.idempotencyKeyFor === 'function',
78
+ concurrency:
79
+ handle.concurrency === undefined
80
+ ? null
81
+ : {
82
+ limit: handle.concurrency,
83
+ keyed: handle.whenBusy !== undefined,
84
+ whenBusy: handle.whenBusy ?? null,
85
+ },
86
+ onSettled: handle.declaresOnSettled,
58
87
  };
59
88
  }
60
89
 
@@ -0,0 +1,297 @@
1
+ // The memory driver's operator surface: paging, bulk, pause, promote, the worker registry,
2
+ // progress and counters, over the SAME map the claim loop reads. Apart from `driver-memory.ts`
3
+ // because that file is the six-method queue; this is everything `JobIntrospection` added, written
4
+ // to answer exactly as the pg statements in `driver-pg-operator-sql.ts` do.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import { assert, finiteOption } from '@ultimat3/core';
8
+ import { nowMs } from './clock';
9
+ import type { MemoryCounters } from './counters';
10
+ import { createMemoryCounters } from './counters';
11
+ import type { JobRecord } from './driver';
12
+ import { REQUEUEABLE_STATES } from './driver';
13
+ import { signalEnqueued } from './enqueue-signal';
14
+ import { JobNotRemovableError } from './errors-operator';
15
+ import type {
16
+ BulkFilter,
17
+ BulkResult,
18
+ JobFilter,
19
+ JobIntrospection,
20
+ PausedName,
21
+ TaskFire,
22
+ WorkerRecord,
23
+ } from './introspection';
24
+ import {
25
+ MAX_BULK_ROWS,
26
+ MAX_TASK_FIRES,
27
+ MAX_WORKER_IN_FLIGHT,
28
+ PROMOTABLE_STATES,
29
+ pageLimit,
30
+ parseJobCursor,
31
+ } from './introspection';
32
+ import type { StepStore } from './steps';
33
+
34
+ export interface MemoryQueueState {
35
+ readonly jobs: Map<string, JobRecord>;
36
+ readonly steps: StepStore;
37
+ readonly clock: Clock;
38
+ /** The LIVE row holding this record's `(name, tenant, key)`, other than itself. */
39
+ liveHolder(record: JobRecord): JobRecord | undefined;
40
+ /** Rewrite a row and release its claim — the driver's own `settle`. */
41
+ settle(id: string, patch: Partial<JobRecord>): void;
42
+ }
43
+
44
+ export type MemoryOperatorMembers = Omit<
45
+ JobIntrospection,
46
+ 'job' | 'deadLetters' | 'requeue' | 'cancel'
47
+ >;
48
+
49
+ export interface MemoryOperator {
50
+ readonly members: MemoryOperatorMembers;
51
+ readonly counters: MemoryCounters;
52
+ /** The claim scan's question: is this queue held back? */
53
+ isQueuePaused(queue: string): boolean;
54
+ }
55
+
56
+ /** Newest first by `(createdAt, id)` — the keyset `SQL_JOB_LIST` orders and seeks by. */
57
+ const newestFirst = (a: JobRecord, b: JobRecord): number =>
58
+ b.createdAt - a.createdAt || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0);
59
+
60
+ const inBulk = (record: JobRecord, filter: BulkFilter): boolean =>
61
+ record.state === filter.state &&
62
+ (filter.queue === undefined || record.queue === filter.queue) &&
63
+ (filter.name === undefined || record.name === filter.name) &&
64
+ (filter.tenantId === undefined || record.tenantId === filter.tenantId);
65
+
66
+ type Keyset = Pick<JobRecord, 'createdAt' | 'id'>;
67
+
68
+ /** `a` strictly newer than `b` by `(createdAt, id)`, in the keyset's own order. */
69
+ const newerThan = (a: Keyset, b: Keyset): boolean =>
70
+ a.createdAt > b.createdAt || (a.createdAt === b.createdAt && a.id > b.id);
71
+
72
+ const byName = (a: PausedName, b: PausedName): number =>
73
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
74
+
75
+ function pauseSet(clock: Clock): {
76
+ pause(name: string): void;
77
+ resume(name: string): void;
78
+ has(name: string): boolean;
79
+ list(): readonly PausedName[];
80
+ } {
81
+ const paused = new Map<string, number>();
82
+ return {
83
+ // The FIRST pause is the one recorded, as `on conflict do nothing` keeps it in pg.
84
+ pause: (name) => {
85
+ if (!paused.has(name)) paused.set(name, nowMs(clock));
86
+ },
87
+ resume: (name) => {
88
+ paused.delete(name);
89
+ },
90
+ has: (name) => paused.has(name),
91
+ list: () => [...paused].map(([name, pausedAt]) => ({ name, pausedAt })).sort(byName),
92
+ };
93
+ }
94
+
95
+ export function createMemoryOperator(state: MemoryQueueState): MemoryOperator {
96
+ const { jobs, clock } = state;
97
+ const counters = createMemoryCounters();
98
+ const queues = pauseSet(clock);
99
+ const tasks = pauseSet(clock);
100
+ const workers = new Map<string, WorkerRecord & { readonly expiresAt: number }>();
101
+ const fires = new Map<string, TaskFire>();
102
+
103
+ // `eligible` is the verb's own predicate, applied BEFORE the bound as the pg statement's `where`
104
+ // is applied before its `limit`: rows the verb would skip never spend the bound.
105
+ const bulk = async (
106
+ filter: BulkFilter,
107
+ apply: (record: JobRecord) => Promise<boolean> | boolean,
108
+ eligible: (record: JobRecord) => boolean = () => true,
109
+ ): Promise<BulkResult> => {
110
+ let affected = 0;
111
+ const matching = [...jobs.values()].filter(
112
+ (record) => inBulk(record, filter) && eligible(record),
113
+ );
114
+ for (const record of matching.sort(newestFirst).reverse().slice(0, MAX_BULK_ROWS)) {
115
+ if (await apply(record)) affected += 1;
116
+ }
117
+ const remaining = [...jobs.values()].filter((record) => inBulk(record, filter)).length;
118
+ return { affected, remaining };
119
+ };
120
+
121
+ const members: MemoryOperatorMembers = {
122
+ // `async`, so a refused bound REJECTS here exactly as it does on the pg driver.
123
+ async list(filter: JobFilter = {}) {
124
+ const limit = pageLimit('the memory driver list', filter.limit);
125
+ assert(
126
+ filter.after === undefined || filter.before === undefined,
127
+ 'list was asked for a page both after and before a cursor',
128
+ 'pass one of after: jobCursor(lastRow) or before: jobCursor(firstRow)',
129
+ );
130
+ const after =
131
+ filter.after === undefined
132
+ ? undefined
133
+ : parseJobCursor(filter.after, 'the memory driver list');
134
+ const before =
135
+ filter.before === undefined
136
+ ? undefined
137
+ : parseJobCursor(filter.before, 'the memory driver list');
138
+ const { idPrefix, createdFrom, createdTo, tenantId } = filter;
139
+ const matching = [...jobs.values()]
140
+ .filter((record) => filter.queue === undefined || record.queue === filter.queue)
141
+ .filter((record) => filter.name === undefined || record.name === filter.name)
142
+ .filter((record) => filter.state === undefined || record.state === filter.state)
143
+ .filter((record) => tenantId === undefined || record.tenantId === tenantId)
144
+ .filter((record) => idPrefix === undefined || record.id.startsWith(idPrefix))
145
+ .filter((record) => createdFrom === undefined || record.createdAt >= createdFrom)
146
+ .filter((record) => createdTo === undefined || record.createdAt < createdTo)
147
+ .filter((record) => after === undefined || newerThan(after, record))
148
+ .filter((record) => before === undefined || newerThan(record, before))
149
+ .sort(newestFirst);
150
+ // Backwards: the rows NEAREST the cursor, which are the oldest of the newer ones.
151
+ return before === undefined
152
+ ? matching.slice(0, limit)
153
+ : matching.slice(Math.max(0, matching.length - limit));
154
+ },
155
+
156
+ async remove(jobId) {
157
+ const record = jobs.get(jobId);
158
+ if (record === undefined) return undefined;
159
+ if (record.state === 'running') throw new JobNotRemovableError({ jobId });
160
+ jobs.delete(jobId);
161
+ await state.steps.clear(record.runId);
162
+ return record;
163
+ },
164
+
165
+ async requeueMany(filter) {
166
+ assert(
167
+ REQUEUEABLE_STATES.has(filter.state),
168
+ `requeueMany was asked for ${filter.state} jobs, and only a finished job can be requeued`,
169
+ "pass state: 'dead', 'failed', 'cancelled' or 'done' to requeueMany",
170
+ );
171
+ const result = await bulk(filter, (record) => {
172
+ // A key a live job holds stays where it is — the single requeue's `X_JOB_DUPLICATE`,
173
+ // answered as "not this one" so one conflict does not fail a thousand rows.
174
+ if (state.liveHolder(record) !== undefined) return false;
175
+ state.settle(record.id, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
176
+ return true;
177
+ });
178
+ // The pg driver's `SQL_WAKE`, in-process: an operator's repair starts now.
179
+ if (result.affected > 0) signalEnqueued(filter.queue);
180
+ return result;
181
+ },
182
+
183
+ async promoteMany(filter) {
184
+ assert(
185
+ PROMOTABLE_STATES.has(filter.state),
186
+ `promoteMany was asked for ${filter.state} jobs, and only a job waiting on its run time can be promoted`,
187
+ "pass state: 'delayed' or 'ready' to promoteMany",
188
+ );
189
+ const at = nowMs(clock);
190
+ const result = await bulk(
191
+ filter,
192
+ (record) => {
193
+ jobs.set(record.id, { ...record, state: 'ready', runAt: at, updatedAt: at });
194
+ return true;
195
+ },
196
+ (record) => record.runAt > at,
197
+ );
198
+ if (result.affected > 0) signalEnqueued(filter.queue);
199
+ // Rows of the filter still waiting — a due `ready` row has nothing left to promote.
200
+ const waiting = [...jobs.values()].filter(
201
+ (record) => inBulk(record, filter) && record.runAt > at,
202
+ ).length;
203
+ return { affected: result.affected, remaining: waiting };
204
+ },
205
+
206
+ async removeMany(filter) {
207
+ if (filter.state === 'running') throw new JobNotRemovableError({});
208
+ return bulk(filter, async (record) => {
209
+ jobs.delete(record.id);
210
+ await state.steps.clear(record.runId);
211
+ return true;
212
+ });
213
+ },
214
+
215
+ promote(jobId) {
216
+ const record = jobs.get(jobId);
217
+ const at = nowMs(clock);
218
+ const waiting =
219
+ record !== undefined &&
220
+ (record.state === 'ready' || record.state === 'delayed') &&
221
+ record.runAt > at;
222
+ if (record === undefined || !waiting) return Promise.resolve(undefined);
223
+ const next: JobRecord = { ...record, state: 'ready', runAt: at, updatedAt: at };
224
+ jobs.set(jobId, next);
225
+ signalEnqueued(next.queue);
226
+ return Promise.resolve(next);
227
+ },
228
+
229
+ pauseQueue: (queue) => Promise.resolve(queues.pause(queue)),
230
+ resumeQueue(queue) {
231
+ queues.resume(queue);
232
+ signalEnqueued(queue);
233
+ return Promise.resolve();
234
+ },
235
+ pausedQueues: () => Promise.resolve(queues.list()),
236
+ pauseTask: (task) => Promise.resolve(tasks.pause(task)),
237
+ resumeTask: (task) => Promise.resolve(tasks.resume(task)),
238
+ pausedTasks: () => Promise.resolve(tasks.list()),
239
+
240
+ recordTaskFire({ task, occurrenceMs }) {
241
+ fires.set(task, { task, occurrenceMs, firedAt: nowMs(clock) });
242
+ return Promise.resolve();
243
+ },
244
+ taskFires: () =>
245
+ Promise.resolve(
246
+ [...fires.values()]
247
+ // Code units, as `collate "C"` orders them in `SQL_TASK_FIRES`.
248
+ .sort((a, b) => (a.task < b.task ? -1 : a.task > b.task ? 1 : 0))
249
+ .slice(0, MAX_TASK_FIRES),
250
+ ),
251
+
252
+ announceWorker(worker, ttlMs) {
253
+ const at = nowMs(clock);
254
+ workers.set(worker.id, {
255
+ ...worker,
256
+ queues: [...worker.queues],
257
+ inFlight: worker.inFlight.slice(0, MAX_WORKER_IN_FLIGHT),
258
+ heartbeatAt: at,
259
+ expiresAt: at + finiteOption('the memory worker registry', 'ttlMs', ttlMs),
260
+ });
261
+ return Promise.resolve();
262
+ },
263
+ forgetWorker(workerId) {
264
+ workers.delete(workerId);
265
+ return Promise.resolve();
266
+ },
267
+ workers() {
268
+ const at = nowMs(clock);
269
+ // Expired by reading, never by cleanup: a killed worker announces nothing and is gone.
270
+ for (const [id, worker] of workers) if (worker.expiresAt <= at) workers.delete(id);
271
+ return Promise.resolve(
272
+ [...workers.values()]
273
+ .map(({ expiresAt: _expiresAt, ...worker }) => worker)
274
+ .sort((a, b) => a.startedAt - b.startedAt || (a.id < b.id ? -1 : 1)),
275
+ );
276
+ },
277
+
278
+ recordProgress(jobId, by, progress) {
279
+ const record = jobs.get(jobId);
280
+ // The settle's fence: progress from a claim the row no longer carries is dropped.
281
+ if (
282
+ record?.state === 'running' &&
283
+ record.claimedBy === by.workerId &&
284
+ record.claim === by.claim
285
+ ) {
286
+ jobs.set(jobId, { ...record, progress });
287
+ }
288
+ return Promise.resolve();
289
+ },
290
+
291
+ counters: (query) => Promise.resolve(counters.list(query)),
292
+ counterTotals: (sinceMs) => Promise.resolve(counters.totals(sinceMs)),
293
+ rollupCounters: () => Promise.resolve(counters.rollup(nowMs(clock))),
294
+ };
295
+
296
+ return { members, counters, isQueuePaused: (queue) => queues.has(queue) };
297
+ }
@@ -7,22 +7,33 @@ import { assert, finiteCount, systemClock, uuid } from '@ultimat3/core';
7
7
  import type { BackfillLedger } from './backfill-ledger';
8
8
  import { createMemoryBackfillLedger } from './backfill-ledger';
9
9
  import { nowMs } from './clock';
10
+ import { nackOutcome } from './counters';
10
11
  import type {
12
+ AckOptions,
11
13
  ClaimedJob,
14
+ ClaimIdentity,
12
15
  ClaimOptions,
13
16
  EnqueueRequest,
14
17
  EnqueueResult,
15
18
  HeartbeatOptions,
16
19
  JobDriver,
17
- JobFilter,
18
- JobIntrospection,
19
20
  JobRecord,
20
21
  NackOptions,
21
22
  QueueStats,
22
23
  } from './driver';
23
- import { assertClaimBounds, assertClaimQueues, DEFAULT_QUEUE, REQUEUEABLE_STATES } from './driver';
24
+ import {
25
+ assertClaimBounds,
26
+ assertClaimQueues,
27
+ DEFAULT_QUEUE,
28
+ nackState,
29
+ REQUEUEABLE_STATES,
30
+ } from './driver';
31
+ import { createMemoryOperator } from './driver-memory-operator';
32
+ import { signalEnqueued } from './enqueue-signal';
24
33
  import { JobDuplicateError } from './errors';
25
34
  import { JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
35
+ import type { JobIntrospection } from './introspection';
36
+ import { MAX_ERROR_STACK_LENGTH } from './introspection';
26
37
  import type { LeaseStore } from './leases';
27
38
  import { createMemoryLeaseStore } from './leases';
28
39
  import type { StepStore } from './steps';
@@ -100,25 +111,32 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
100
111
  jobs.set(id, { ...released, ...patch, updatedAt: nowMs(clock) });
101
112
  };
102
113
 
114
+ const operator = createMemoryOperator({
115
+ jobs,
116
+ steps,
117
+ clock,
118
+ settle,
119
+ liveHolder: (record) => {
120
+ const holder = liveByKey(record.name, record.idempotencyKey, record.tenantId);
121
+ return holder === undefined || holder.id === record.id ? undefined : holder;
122
+ },
123
+ });
124
+
125
+ /** The row, when this worker still holds it `running` — the fence both settles share. */
126
+ const heldBy = (jobId: string, by: ClaimIdentity): JobRecord | undefined => {
127
+ const record = jobs.get(jobId);
128
+ return record?.state === 'running' &&
129
+ record.claimedBy === by.workerId &&
130
+ record.claim === by.claim
131
+ ? record
132
+ : undefined;
133
+ };
134
+
103
135
  const introspect: JobIntrospection = {
104
136
  job(jobId) {
105
137
  return Promise.resolve(jobs.get(jobId));
106
138
  },
107
- // `async` for the reason `claim` is: a refused bound must REJECT here exactly as it does on the
108
- // pg driver, and a synchronous throw out of a method typed `Promise<…>` is a second answer to
109
- // one question.
110
- async list(filter: JobFilter = {}) {
111
- const rows = [...jobs.values()]
112
- .filter((record) => filter.queue === undefined || record.queue === filter.queue)
113
- .filter((record) => filter.name === undefined || record.name === filter.name)
114
- .filter((record) => filter.state === undefined || record.state === filter.state)
115
- // NEWEST first, as `createPgDriver`'s `order by created_at desc` is. Ascending here meant
116
- // `x jobs ls` answered one thing against `x dev` and the opposite in production — and,
117
- // because the limit is applied after the sort, a default page of the hundred OLDEST rows.
118
- .sort((a, b) => b.createdAt - a.createdAt)
119
- .slice(0, finiteCount('the memory driver list', 'limit', filter.limit ?? 100));
120
- return rows;
121
- },
139
+ ...operator.members,
122
140
  async deadLetters(limit = 100) {
123
141
  const rows = [...jobs.values()]
124
142
  .filter((record) => record.state === 'dead')
@@ -158,6 +176,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
158
176
  }
159
177
  // `settle` releases the claim — `SQL_JOB_REQUEUE` writes `claimed_by = null` too.
160
178
  settle(jobId, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
179
+ signalEnqueued(record.queue);
161
180
  const next = jobs.get(jobId);
162
181
  return next ?? record;
163
182
  },
@@ -190,6 +209,12 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
190
209
  // `Promise<…>` is a second answer to one question — caught by different code, and an
191
210
  // unhandled exception rather than a settled promise wherever the caller holds the promise.
192
211
  async enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
212
+ // A caller-allocated id that already names a row is that row's publish, repeated: the same
213
+ // job, whatever state it has reached — `SQL_ENQUEUE`'s `not exists` is the pg half.
214
+ const published = request.id === undefined ? undefined : jobs.get(request.id);
215
+ if (published !== undefined) {
216
+ return { id: published.id, runId: published.runId, deduped: true };
217
+ }
193
218
  const existing = liveByKey(request.name, request.idempotencyKey, request.tenantId);
194
219
  if (existing !== undefined) {
195
220
  if (request.onConflict === 'error') {
@@ -205,10 +230,13 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
205
230
  const at = nowMs(clock);
206
231
  const runAt = request.runAt ?? at;
207
232
  const record: JobRecord = {
208
- id: uuid(),
233
+ id: request.id ?? uuid(),
209
234
  name: request.name,
210
235
  queue: request.queue || DEFAULT_QUEUE,
211
- input: request.input,
236
+ // Through JSON, exactly as the pg driver binds it (`JSON.stringify(request.input ?? null)`):
237
+ // a live reference kept a `Date`, an `undefined` member and a non-enumerable property the
238
+ // queue in production never stores, so a job that read one passed here and failed there.
239
+ input: JSON.parse(JSON.stringify(request.input ?? null)) as unknown,
212
240
  idempotencyKey: request.idempotencyKey,
213
241
  runId: request.runId ?? uuid(),
214
242
  attempt: 0,
@@ -235,6 +263,8 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
235
263
  const wanted = new Set(claimOptions.queues);
236
264
  const claimable = [...jobs.values()]
237
265
  .filter((record) => wanted.has(record.queue))
266
+ // A paused queue is never claimed — `SQL_CLAIM`'s `not exists` over `x_job_pauses`.
267
+ .filter((record) => !operator.isQueuePaused(record.queue))
238
268
  .filter((record) => {
239
269
  if (record.runAt > at) return false;
240
270
  if (record.state === 'ready' || record.state === 'delayed') return true;
@@ -252,6 +282,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
252
282
  state: 'running',
253
283
  attempt: record.attempt + 1,
254
284
  claimedBy: claimOptions.workerId,
285
+ claim: (record.claim ?? 0) + 1,
255
286
  claimedAt: at,
256
287
  visibleAt: at + claimOptions.visibilityTimeoutMs,
257
288
  updatedAt: at,
@@ -262,29 +293,29 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
262
293
  return Promise.resolve(out);
263
294
  },
264
295
 
265
- // Both settlements are FENCED on `running`, as `SQL_ACK`/`SQL_NACK` are: an ack from a worker
266
- // whose job was cancelled — or whose lease lapsed and whose job another worker re-claimed —
267
- // would otherwise overwrite a row it no longer owns.
268
- ack(jobId: string): Promise<void> {
269
- if (jobs.get(jobId)?.state !== 'running') return Promise.resolve();
296
+ // Both settlements are FENCED on `running` AND on the claimer, as `SQL_ACK`/`SQL_NACK` are: an
297
+ // ack from a worker whose job was cancelled — or whose lease lapsed and whose job another
298
+ // worker re-claimed — would otherwise overwrite a row it no longer owns. The counter moves in
299
+ // the same step as the row, so the two cannot disagree.
300
+ ack(jobId: string, by: AckOptions): Promise<boolean> {
301
+ const record = heldBy(jobId, by);
302
+ if (record === undefined) return Promise.resolve(false);
270
303
  settle(jobId, { state: 'done' });
271
- return Promise.resolve();
304
+ if (by.counted !== false) {
305
+ operator.counters.add(record.name, 'done', by.durationMs ?? 0, nowMs(clock));
306
+ }
307
+ return Promise.resolve(true);
272
308
  },
273
309
 
274
- nack(jobId: string, nackOptions: NackOptions): Promise<void> {
275
- const record = jobs.get(jobId);
276
- if (record === undefined || record.state !== 'running') return Promise.resolve();
310
+ nack(jobId: string, nackOptions: NackOptions): Promise<boolean> {
311
+ const record = heldBy(jobId, nackOptions);
312
+ if (record === undefined) return Promise.resolve(false);
277
313
  const at = nowMs(clock);
278
314
  const counts = nackOptions.countsAsAttempt !== false;
279
315
  const patch: Partial<JobRecord> = {
280
316
  // `park`, never `counts`: parking is what leaves the ready bucket, and burning an attempt
281
317
  // is a separate fact. A shed sets neither and stays `ready`, which is what it is.
282
- state:
283
- nackOptions.deadLetter === true
284
- ? 'dead'
285
- : nackOptions.park === true
286
- ? 'suspended'
287
- : 'ready',
318
+ state: nackState(nackOptions),
288
319
  runAt: at + nackOptions.delayMs,
289
320
  // A suspension must not burn an attempt, or a 3-day sleep dead-letters the run. Floored
290
321
  // where `SQL_NACK` floors it (`greatest(attempt - 1, 0)`): the fence above is what keeps
@@ -292,9 +323,16 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
292
323
  // being read as the only one.
293
324
  attempt: counts ? record.attempt : Math.max(0, record.attempt - 1),
294
325
  ...(nackOptions.error === undefined ? {} : { lastError: nackOptions.error }),
326
+ ...(nackOptions.stack === undefined
327
+ ? {}
328
+ : { lastErrorStack: nackOptions.stack.slice(0, MAX_ERROR_STACK_LENGTH) }),
295
329
  };
296
330
  settle(jobId, patch);
297
- return Promise.resolve();
331
+ const outcome = nackOutcome(nackOptions);
332
+ if (outcome !== undefined) {
333
+ operator.counters.add(record.name, outcome, nackOptions.durationMs ?? 0, at);
334
+ }
335
+ return Promise.resolve(true);
298
336
  },
299
337
 
300
338
  heartbeat(jobId: string, heartbeatOptions: HeartbeatOptions): Promise<boolean> {
@@ -304,7 +342,9 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
304
342
  if (
305
343
  record === undefined ||
306
344
  record.state !== 'running' ||
307
- (heartbeatOptions.workerId !== undefined && record.claimedBy !== heartbeatOptions.workerId)
345
+ (heartbeatOptions.workerId !== undefined &&
346
+ record.claimedBy !== heartbeatOptions.workerId) ||
347
+ (heartbeatOptions.claim !== undefined && record.claim !== heartbeatOptions.claim)
308
348
  ) {
309
349
  return Promise.resolve(false);
310
350
  }
@@ -322,6 +362,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
322
362
  delayed: 0,
323
363
  running: 0,
324
364
  suspended: 0,
365
+ failed: 0,
325
366
  dead: 0,
326
367
  oldestReadyMs: 0,
327
368
  };
@@ -337,6 +378,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
337
378
  } else if (waiting) next.delayed += 1;
338
379
  else if (record.state === 'running') next.running += 1;
339
380
  else if (record.state === 'suspended') next.suspended += 1;
381
+ else if (record.state === 'failed') next.failed += 1;
340
382
  else if (record.state === 'dead') next.dead += 1;
341
383
  byQueue.set(record.queue, next);
342
384
  }