@zmdb/jobs-postgres 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,564 @@
1
+ import type {
2
+ ClaimedJob,
3
+ DeadJob,
4
+ DeadReason,
5
+ JobCandidate,
6
+ JobEnqueue,
7
+ JobEnqueuer,
8
+ JobEnqueueResult,
9
+ JobSettlement,
10
+ JobStore,
11
+ JobStoreMigration,
12
+ JobStoreResource,
13
+ LeaseStore,
14
+ } from '@zmdb/jobs';
15
+ import { postgresDriver, type PgQueryable } from '@zmdb/postgres';
16
+ import type { Client, Pool, PoolClient } from 'pg';
17
+
18
+ export type PgJobClient = Pool | PoolClient | Client;
19
+ export type PgJobTransactionClient = PoolClient | Client;
20
+ export interface PgJobStore extends JobStore, LeaseStore, JobStoreResource {}
21
+ export interface PgJobStoreOptions {
22
+ readonly prepared?: boolean;
23
+ readonly maxCacheSize?: number;
24
+ readonly cancelVia?: PgJobClient;
25
+ readonly signal?: AbortSignal;
26
+ readonly operationTimeoutMs?: number;
27
+ }
28
+
29
+ export const jobsPostgresMigrations: readonly JobStoreMigration[] = Object.freeze([
30
+ Object.freeze({
31
+ version: 20260906000100,
32
+ name: 'jobs_queue',
33
+ up: `CREATE TABLE zmdb_job (
34
+ id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, payload TEXT NOT NULL,
35
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'done', 'dead')),
36
+ attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts BETWEEN 0 AND 2147483647),
37
+ enqueued_at TIMESTAMPTZ NOT NULL, dedupe_key TEXT UNIQUE,
38
+ lease_owner TEXT NOT NULL DEFAULT '', lease_until TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01T00:00:00.000Z',
39
+ last_error TEXT, dead_reason TEXT CHECK (dead_reason IN ('invalid-payload', 'unknown-name', 'attempts-exhausted')),
40
+ dead_detail TEXT, dead_at TIMESTAMPTZ
41
+ );
42
+ CREATE TABLE zmdb_job_done (key TEXT PRIMARY KEY NOT NULL, completed_at TIMESTAMPTZ NOT NULL);
43
+ CREATE INDEX zmdb_job_pending ON zmdb_job(status, lease_until, enqueued_at) WHERE status = 'pending';
44
+ CREATE INDEX zmdb_job_lease_expiry ON zmdb_job(lease_until) WHERE status = 'pending';
45
+ CREATE INDEX zmdb_job_dead ON zmdb_job(dead_at) WHERE status = 'dead';`,
46
+ down: 'DROP INDEX zmdb_job_dead; DROP INDEX zmdb_job_lease_expiry; DROP INDEX zmdb_job_pending; DROP TABLE zmdb_job_done; DROP TABLE zmdb_job;',
47
+ }),
48
+ Object.freeze({
49
+ version: 20260906000200,
50
+ name: 'jobs_schedule_lease',
51
+ up: `CREATE TABLE zmdb_job_lease (key TEXT PRIMARY KEY NOT NULL, holder TEXT NOT NULL CHECK (holder <> ''), expires_at TIMESTAMPTZ NOT NULL);
52
+ CREATE INDEX zmdb_job_schedule_expiry ON zmdb_job_lease(expires_at);`,
53
+ down: 'DROP INDEX zmdb_job_schedule_expiry; DROP TABLE zmdb_job_lease;',
54
+ }),
55
+ ]);
56
+
57
+ const MAX_INTEGER = 2_147_483_647;
58
+ function integer(name: string, value: number, minimum: number, maximum = MAX_INTEGER): void {
59
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum)
60
+ throw new RangeError(`${name} must be an integer in [${minimum}, ${maximum}]`);
61
+ }
62
+ function string(name: string, value: string, empty = false): void {
63
+ if (typeof value !== 'string' || (!empty && value.length === 0) || value.includes('\0') || !value.isWellFormed()) {
64
+ throw new TypeError(`${name} must be ${empty ? '' : 'nonempty '}Unicode text without NUL`);
65
+ }
66
+ }
67
+ function timestamp(value: Date): string {
68
+ if (
69
+ !(value instanceof Date) ||
70
+ !Number.isFinite(value.getTime()) ||
71
+ value.getTime() < -62_135_596_800_000 ||
72
+ value.getTime() > 253_402_300_799_999
73
+ )
74
+ throw new RangeError('timestamp must be a finite Date between years 0001 and 9999');
75
+ return value.toISOString();
76
+ }
77
+ function reason(value: unknown): DeadReason {
78
+ if (value === 'invalid-payload' || value === 'unknown-name' || value === 'attempts-exhausted') return value;
79
+ throw new TypeError('unknown dead-letter reason');
80
+ }
81
+ function text(row: Record<string, unknown>, key: string): string {
82
+ const value = row[key];
83
+ if (typeof value !== 'string') throw new TypeError(`zmdb_job.${key} must be text`);
84
+ return value;
85
+ }
86
+ function count(row: Record<string, unknown>): number {
87
+ const value = row['attempts'];
88
+ if (typeof value !== 'number') throw new TypeError('zmdb_job.attempts must be a number');
89
+ integer('attempts', value, 0);
90
+ return value;
91
+ }
92
+ function candidate(row: Record<string, unknown>): JobCandidate {
93
+ return { id: text(row, 'id'), name: text(row, 'name'), enqueuedAt: date(row, 'enqueued_at') };
94
+ }
95
+ function claimed(row: Record<string, unknown>): ClaimedJob {
96
+ return {
97
+ ...candidate(row),
98
+ payload: text(row, 'payload'),
99
+ attempts: count(row),
100
+ holder: text(row, 'lease_owner'),
101
+ ...(row['dedupe_key'] === null ? {} : { dedupeKey: text(row, 'dedupe_key') }),
102
+ };
103
+ }
104
+ function date(row: Record<string, unknown>, key: string): Date {
105
+ const value = row[key];
106
+ return value instanceof Date ? value : new Date(text(row, key));
107
+ }
108
+ type Rows = (sql: string, parameters?: readonly unknown[]) => Promise<readonly Record<string, unknown>[]>;
109
+ async function enqueue(rows: Rows, job: JobEnqueue): Promise<JobEnqueueResult> {
110
+ string('id', job.id);
111
+ string('name', job.name);
112
+ string('payload', job.payload, true);
113
+ if (job.dedupeKey !== undefined) string('dedupeKey', job.dedupeKey);
114
+ const enqueuedAt = timestamp(job.enqueuedAt),
115
+ availableAt = timestamp(job.availableAt);
116
+ const inserted = await rows(
117
+ `INSERT INTO zmdb_job (id, name, payload, enqueued_at, dedupe_key, lease_until)
118
+ VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT(dedupe_key) DO NOTHING RETURNING id`,
119
+ [job.id, job.name, job.payload, enqueuedAt, job.dedupeKey ?? null, availableAt],
120
+ );
121
+ if (inserted.length > 0) return { kind: 'inserted', jobId: job.id };
122
+ const existing = (await rows('SELECT id FROM zmdb_job WHERE dedupe_key = $1', [job.dedupeKey]))[0];
123
+ if (existing === undefined) throw new Error('@zmdb/jobs-postgres: dedupe row is missing');
124
+ return { kind: 'duplicate', jobId: text(existing, 'id') };
125
+ }
126
+
127
+ function isPool(client: PgJobClient): client is Pool {
128
+ return 'totalCount' in client && 'idleCount' in client;
129
+ }
130
+ const connectionTails = new WeakMap<PgJobTransactionClient, Promise<void>>();
131
+ interface PreparedState {
132
+ readonly names: Map<string, string>;
133
+ sequence: number;
134
+ readonly namespace: string;
135
+ }
136
+ const preparedStates = new WeakMap<PgJobTransactionClient, PreparedState>();
137
+
138
+ function preparedConnection(
139
+ connection: PgJobTransactionClient,
140
+ options: PgJobStoreOptions,
141
+ signal: AbortSignal,
142
+ ): PgQueryable {
143
+ return {
144
+ async query(
145
+ query:
146
+ | string
147
+ | {
148
+ readonly name?: string;
149
+ readonly queryMode?: 'extended';
150
+ readonly text: string;
151
+ readonly values?: readonly unknown[];
152
+ },
153
+ parameters?: readonly unknown[],
154
+ ) {
155
+ signal.throwIfAborted();
156
+ if (typeof query !== 'string') return connection.query({ ...query, values: [...(query.values ?? [])] });
157
+ if (!options.prepared || query === 'SELECT pg_backend_pid() AS pid')
158
+ return connection.query(query, [...(parameters ?? [])]);
159
+ let state = preparedStates.get(connection);
160
+ if (state === undefined) {
161
+ state = {
162
+ names: new Map(),
163
+ sequence: 0,
164
+ namespace: `zmdb_jobs_${globalThis.crypto.randomUUID().replaceAll('-', '')}`,
165
+ };
166
+ preparedStates.set(connection, state);
167
+ }
168
+ const maximum = options.maxCacheSize ?? 1000;
169
+ let name = state.names.get(query);
170
+ const desired = maximum - (maximum > 0 && name === undefined ? 1 : 0);
171
+ while (state.names.size > desired) {
172
+ const oldest = [...state.names.entries()].find(([sql]) => maximum === 0 || sql !== query);
173
+ if (oldest === undefined) break;
174
+ const present = await connection.query('SELECT name FROM pg_prepared_statements WHERE name = $1', [oldest[1]]);
175
+ signal.throwIfAborted();
176
+ if (present.rows.length > 0) await connection.query(`DEALLOCATE ${oldest[1]}`);
177
+ state.names.delete(oldest[0]);
178
+ signal.throwIfAborted();
179
+ }
180
+ if (maximum === 0) {
181
+ signal.throwIfAborted();
182
+ const unnamed = { queryMode: 'extended', text: query, values: [...(parameters ?? [])] };
183
+ return connection.query(unnamed);
184
+ }
185
+ name ??= `${state.namespace}_${(state.sequence++).toString(36)}`;
186
+ state.names.delete(query);
187
+ state.names.set(query, name);
188
+ return connection.query({ name, text: query, values: [...(parameters ?? [])] });
189
+ },
190
+ };
191
+ }
192
+
193
+ class Operations {
194
+ readonly #client: PgJobClient;
195
+ readonly #options: PgJobStoreOptions;
196
+ readonly #timeoutMs: number;
197
+ readonly #shutdown = new AbortController();
198
+ readonly #active = new Set<Promise<unknown>>();
199
+ readonly #cleanupErrors: unknown[] = [];
200
+ #closed = false;
201
+ #closing: Promise<void> | undefined;
202
+ constructor(client: PgJobClient, options?: PgJobStoreOptions) {
203
+ this.#client = client;
204
+ this.#options = options ?? {};
205
+ this.#timeoutMs = options?.operationTimeoutMs ?? 30_000;
206
+ integer('operationTimeoutMs', this.#timeoutMs, 1);
207
+ if (options?.maxCacheSize !== undefined) integer('maxCacheSize', options.maxCacheSize, 0, Number.MAX_SAFE_INTEGER);
208
+ }
209
+ run<T>(transaction: boolean, body: (rows: Rows) => Promise<T>): Promise<T> {
210
+ if (this.#closed) return Promise.reject(new Error('@zmdb/jobs-postgres: store is closed'));
211
+ if (this.#options.signal?.aborted) return Promise.reject(this.#options.signal.reason);
212
+ const deadline = new AbortController();
213
+ const signal = AbortSignal.any([
214
+ deadline.signal,
215
+ this.#shutdown.signal,
216
+ ...(this.#options.signal === undefined ? [] : [this.#options.signal]),
217
+ ]);
218
+ const timer = setTimeout(
219
+ () =>
220
+ deadline.abort(
221
+ new DOMException(
222
+ '@zmdb/jobs-postgres: operation deadline exceeded; database outcome may be incomplete',
223
+ 'TimeoutError',
224
+ ),
225
+ ),
226
+ this.#timeoutMs,
227
+ );
228
+ const client = this.#client;
229
+ const previous = isPool(client) ? undefined : connectionTails.get(client);
230
+ const actual = Promise.resolve().then(async () => {
231
+ if (previous !== undefined) await previous;
232
+ signal.throwIfAborted();
233
+ let connection: PgJobTransactionClient | undefined;
234
+ let acquired = false,
235
+ began = false,
236
+ failed = false;
237
+ let rollbackError: Error | undefined;
238
+ let failure: unknown;
239
+ let result: { readonly value: T } | undefined;
240
+ try {
241
+ if (isPool(client)) {
242
+ connection = await client.connect();
243
+ acquired = true;
244
+ } else connection = client;
245
+ signal.throwIfAborted();
246
+ const selected = postgresDriver(
247
+ preparedConnection(connection, this.#options, signal),
248
+ this.#options.cancelVia === undefined ? {} : { cancelVia: this.#options.cancelVia },
249
+ );
250
+ const rows: Rows = (sql, parameters = []) => selected.execute({ text: sql, parameters }, { signal });
251
+ if (transaction) {
252
+ await connection.query('BEGIN');
253
+ began = true;
254
+ }
255
+ signal.throwIfAborted();
256
+ result = { value: await body(rows) };
257
+ signal.throwIfAborted();
258
+ if (began) {
259
+ await connection.query('COMMIT');
260
+ began = false;
261
+ }
262
+ signal.throwIfAborted();
263
+ } catch (error) {
264
+ failed = true;
265
+ failure = error;
266
+ if (began && connection !== undefined) {
267
+ try {
268
+ await connection.query('ROLLBACK');
269
+ } catch (rollback) {
270
+ failure = new AggregateError([error, rollback], '@zmdb/jobs-postgres: rollback failed');
271
+ rollbackError =
272
+ rollback instanceof Error
273
+ ? rollback
274
+ : new Error('@zmdb/jobs-postgres: rollback failed', { cause: rollback });
275
+ if (signal.aborted) this.#cleanupErrors.push(rollback);
276
+ }
277
+ }
278
+ } finally {
279
+ if (acquired && connection !== undefined && 'release' in connection) {
280
+ try {
281
+ connection.release(rollbackError);
282
+ } catch (release) {
283
+ failure = failed ? new AggregateError([failure, release], '@zmdb/jobs-postgres: release failed') : release;
284
+ failed = true;
285
+ this.#cleanupErrors.push(release);
286
+ }
287
+ }
288
+ }
289
+ if (failed) throw failure;
290
+ if (result === undefined) throw new Error('@zmdb/jobs-postgres: operation did not produce a result');
291
+ return result.value;
292
+ });
293
+ // The serial slot belongs to actual SQL and cleanup, even after the caller's deadline expires.
294
+ if (!isPool(client))
295
+ connectionTails.set(
296
+ client,
297
+ actual.then(
298
+ () => undefined,
299
+ () => undefined,
300
+ ),
301
+ );
302
+ this.#active.add(actual);
303
+ let onAbort: () => void = () => undefined;
304
+ const aborted = new Promise<never>((_resolve, reject) => {
305
+ onAbort = () => reject(signal.reason);
306
+ signal.addEventListener('abort', onAbort, { once: true });
307
+ if (signal.aborted) onAbort();
308
+ });
309
+ void actual.then(
310
+ () => this.#finished(actual, timer, signal, onAbort),
311
+ () => this.#finished(actual, timer, signal, onAbort),
312
+ );
313
+ return Promise.race([actual, aborted]);
314
+ }
315
+ #finished(
316
+ actual: Promise<unknown>,
317
+ timer: ReturnType<typeof setTimeout>,
318
+ signal: AbortSignal,
319
+ onAbort: () => void,
320
+ ): void {
321
+ clearTimeout(timer);
322
+ signal.removeEventListener('abort', onAbort);
323
+ this.#active.delete(actual);
324
+ }
325
+ close(options?: { readonly graceMs: number }): Promise<void> {
326
+ if (options !== undefined) integer('graceMs', options.graceMs, 0);
327
+ if (this.#closing !== undefined) return this.#closing;
328
+ this.#closed = true;
329
+ this.#shutdown.abort(new DOMException('@zmdb/jobs-postgres: store is closed', 'AbortError'));
330
+ const graceMs = Math.min(options?.graceMs ?? this.#timeoutMs, this.#timeoutMs);
331
+ this.#closing = this.#drain(graceMs);
332
+ return this.#closing;
333
+ }
334
+ async #drain(graceMs: number): Promise<void> {
335
+ let timer: ReturnType<typeof setTimeout> | undefined;
336
+ try {
337
+ await Promise.race([
338
+ Promise.allSettled(this.#active),
339
+ new Promise<never>((_resolve, reject) => {
340
+ timer = setTimeout(
341
+ () =>
342
+ reject(
343
+ new DOMException('@zmdb/jobs-postgres: shutdown deadline exceeded; cleanup incomplete', 'TimeoutError'),
344
+ ),
345
+ graceMs,
346
+ );
347
+ }),
348
+ ]);
349
+ if (this.#cleanupErrors.length === 1) throw this.#cleanupErrors[0];
350
+ if (this.#cleanupErrors.length > 1)
351
+ throw new AggregateError(this.#cleanupErrors, '@zmdb/jobs-postgres: cleanup failed');
352
+ } finally {
353
+ clearTimeout(timer);
354
+ }
355
+ }
356
+ }
357
+
358
+ function validateEnqueue(job: JobEnqueue): void {
359
+ string('id', job.id);
360
+ string('name', job.name);
361
+ string('payload', job.payload, true);
362
+ if (job.dedupeKey !== undefined) string('dedupeKey', job.dedupeKey);
363
+ timestamp(job.enqueuedAt);
364
+ timestamp(job.availableAt);
365
+ }
366
+ class PgStore implements PgJobStore {
367
+ readonly #operations: Operations;
368
+ constructor(client: PgJobClient, options?: PgJobStoreOptions) {
369
+ this.#operations = new Operations(client, options);
370
+ }
371
+ async enqueue(job: JobEnqueue): Promise<JobEnqueueResult> {
372
+ validateEnqueue(job);
373
+ return this.#operations.run(true, rows => enqueue(rows, job));
374
+ }
375
+ async candidates(options: { readonly now: Date; readonly limit: number }): Promise<readonly JobCandidate[]> {
376
+ integer('limit', options.limit, 1);
377
+ const now = timestamp(options.now);
378
+ return this.#operations.run(true, async rows =>
379
+ (
380
+ await rows(
381
+ 'SELECT id, name, enqueued_at FROM zmdb_job WHERE status = \'pending\' AND lease_until <= $1 ORDER BY enqueued_at, id COLLATE "C" LIMIT $2',
382
+ [now, options.limit],
383
+ )
384
+ ).map(candidate),
385
+ );
386
+ }
387
+ async claim(options: {
388
+ readonly ids: readonly string[];
389
+ readonly holder: string;
390
+ readonly now: Date;
391
+ readonly leaseUntil: Date;
392
+ }): Promise<readonly ClaimedJob[]> {
393
+ string('holder', options.holder);
394
+ const now = timestamp(options.now),
395
+ until = timestamp(options.leaseUntil);
396
+ if (options.leaseUntil.getTime() <= options.now.getTime())
397
+ throw new RangeError('leaseUntil must be later than now');
398
+ for (const id of options.ids) string('id', id);
399
+ const ids = [...new Set(options.ids)];
400
+ return this.#operations.run(true, async rows => {
401
+ if (ids.length === 0) return [];
402
+ return (
403
+ await rows(
404
+ `WITH claimed AS (
405
+ UPDATE zmdb_job SET lease_owner = $1, lease_until = $2
406
+ WHERE id = ANY($3::text[]) AND status = 'pending' AND lease_until <= $4 RETURNING *
407
+ ) SELECT * FROM claimed ORDER BY enqueued_at, id COLLATE "C"`,
408
+ [options.holder, until, ids, now],
409
+ )
410
+ ).map(claimed);
411
+ });
412
+ }
413
+ async completed(key: string): Promise<boolean> {
414
+ string('key', key);
415
+ return this.#operations.run(
416
+ true,
417
+ async rows => (await rows('SELECT key FROM zmdb_job_done WHERE key = $1', [key])).length > 0,
418
+ );
419
+ }
420
+ async settle(settlement: JobSettlement): Promise<boolean> {
421
+ string('jobId', settlement.jobId);
422
+ string('holder', settlement.holder);
423
+ let sql: string, parameters: readonly unknown[];
424
+ switch (settlement.kind) {
425
+ case 'done':
426
+ string('idempotencyKey', settlement.idempotencyKey);
427
+ sql =
428
+ "UPDATE zmdb_job SET status = 'done', attempts = attempts + 1, lease_owner = '', lease_until = $1, last_error = NULL, dead_reason = NULL, dead_detail = NULL, dead_at = NULL";
429
+ parameters = [timestamp(settlement.completedAt)];
430
+ break;
431
+ case 'retry':
432
+ integer('attempts', settlement.attempts, 0);
433
+ string('detail', settlement.detail, true);
434
+ sql =
435
+ "UPDATE zmdb_job SET attempts = $1, lease_owner = '', lease_until = $2, last_error = $3, dead_reason = NULL, dead_detail = NULL, dead_at = NULL";
436
+ parameters = [settlement.attempts, timestamp(settlement.availableAt), settlement.detail];
437
+ break;
438
+ case 'dead':
439
+ integer('attempts', settlement.attempts, 0);
440
+ string('detail', settlement.detail, true);
441
+ reason(settlement.reason);
442
+ sql =
443
+ "UPDATE zmdb_job SET status = 'dead', attempts = $1, lease_owner = '', lease_until = $2, last_error = $3, dead_reason = $4, dead_detail = $5, dead_at = $6";
444
+ parameters = [
445
+ settlement.attempts,
446
+ timestamp(settlement.deadAt),
447
+ settlement.detail,
448
+ settlement.reason,
449
+ settlement.detail,
450
+ timestamp(settlement.deadAt),
451
+ ];
452
+ break;
453
+ case 'release':
454
+ sql = "UPDATE zmdb_job SET lease_owner = '', lease_until = $1";
455
+ parameters = [timestamp(settlement.availableAt)];
456
+ break;
457
+ default:
458
+ throw new TypeError('unknown job settlement');
459
+ }
460
+ const fence = ` WHERE id = $${parameters.length + 1} AND lease_owner = $${parameters.length + 2} AND lease_owner <> '' AND status = 'pending' RETURNING id`;
461
+ return this.#operations.run(true, async rows => {
462
+ const changed = (await rows(sql + fence, [...parameters, settlement.jobId, settlement.holder])).length > 0;
463
+ if (changed && settlement.kind === 'done')
464
+ await rows(
465
+ 'INSERT INTO zmdb_job_done (key, completed_at) VALUES ($1, $2) ON CONFLICT(key) DO NOTHING RETURNING key',
466
+ [settlement.idempotencyKey, timestamp(settlement.completedAt)],
467
+ );
468
+ return changed;
469
+ });
470
+ }
471
+ async listDead(options: { readonly limit: number; readonly reason?: DeadReason }): Promise<readonly DeadJob[]> {
472
+ integer('limit', options.limit, 1);
473
+ if (options.reason !== undefined) reason(options.reason);
474
+ return this.#operations.run(true, async rows =>
475
+ (
476
+ await rows(
477
+ `SELECT * FROM zmdb_job WHERE status = 'dead'${options.reason === undefined ? '' : ' AND dead_reason = $2'} ORDER BY dead_at DESC, id COLLATE "C" LIMIT $1`,
478
+ options.reason === undefined ? [options.limit] : [options.limit, options.reason],
479
+ )
480
+ ).map(row => ({
481
+ jobId: text(row, 'id'),
482
+ name: text(row, 'name'),
483
+ payload: text(row, 'payload'),
484
+ attempts: count(row),
485
+ reason: reason(row['dead_reason']),
486
+ detail: text(row, 'dead_detail'),
487
+ enqueuedAt: date(row, 'enqueued_at'),
488
+ deadAt: date(row, 'dead_at'),
489
+ })),
490
+ );
491
+ }
492
+ async replay(jobId: string, availableAt: Date): Promise<boolean> {
493
+ string('jobId', jobId);
494
+ const available = timestamp(availableAt);
495
+ return this.#operations.run(
496
+ true,
497
+ async rows =>
498
+ (
499
+ await rows(
500
+ "UPDATE zmdb_job SET status = 'pending', attempts = 0, lease_owner = '', lease_until = $1, last_error = NULL, dead_reason = NULL, dead_detail = NULL, dead_at = NULL WHERE id = $2 AND status = 'dead' RETURNING id",
501
+ [available, jobId],
502
+ )
503
+ ).length > 0,
504
+ );
505
+ }
506
+ async acquire(key: string, holder: string, ttlMs: number): Promise<boolean> {
507
+ string('key', key);
508
+ string('holder', holder);
509
+ integer('ttlMs', ttlMs, 1);
510
+ const now = Date.now();
511
+ return this.#operations.run(
512
+ true,
513
+ async rows =>
514
+ (
515
+ await rows(
516
+ `INSERT INTO zmdb_job_lease (key, holder, expires_at) VALUES ($1, $2, $3)
517
+ ON CONFLICT(key) DO UPDATE SET holder = excluded.holder, expires_at = excluded.expires_at
518
+ WHERE zmdb_job_lease.holder = excluded.holder OR zmdb_job_lease.expires_at <= $4 RETURNING key`,
519
+ [key, holder, timestamp(new Date(now + ttlMs)), timestamp(new Date(now))],
520
+ )
521
+ ).length > 0,
522
+ );
523
+ }
524
+ async renew(key: string, holder: string, ttlMs: number): Promise<boolean> {
525
+ string('key', key);
526
+ string('holder', holder);
527
+ integer('ttlMs', ttlMs, 1);
528
+ const now = Date.now();
529
+ return this.#operations.run(
530
+ true,
531
+ async rows =>
532
+ (
533
+ await rows(
534
+ 'UPDATE zmdb_job_lease SET expires_at = $1 WHERE key = $2 AND holder = $3 AND expires_at > $4 RETURNING key',
535
+ [timestamp(new Date(now + ttlMs)), key, holder, timestamp(new Date(now))],
536
+ )
537
+ ).length > 0,
538
+ );
539
+ }
540
+ async release(key: string, holder: string): Promise<void> {
541
+ string('key', key);
542
+ string('holder', holder);
543
+ await this.#operations.run(true, rows =>
544
+ rows('DELETE FROM zmdb_job_lease WHERE key = $1 AND holder = $2 RETURNING key', [key, holder]),
545
+ );
546
+ }
547
+ close(options?: { readonly graceMs: number }): Promise<void> {
548
+ return this.#operations.close(options);
549
+ }
550
+ }
551
+
552
+ export function createPgJobStore(client: PgJobClient, options?: PgJobStoreOptions): PgJobStore {
553
+ return new PgStore(client, options);
554
+ }
555
+ export function pgJobEnqueuer(client: PgJobTransactionClient, options?: PgJobStoreOptions): JobEnqueuer {
556
+ if (isPool(client)) throw new TypeError('@zmdb/jobs-postgres: transaction enqueue requires a pinned client');
557
+ const operations = new Operations(client, options);
558
+ return {
559
+ async enqueue(job) {
560
+ validateEnqueue(job);
561
+ return operations.run(false, rows => enqueue(rows, job));
562
+ },
563
+ };
564
+ }