import { __rest } from "tslib"; import { EventEmitter } from 'events'; import { finishedErrors } from '../classes/finished-errors'; import { loadCommandSql } from './sql-loader'; /** Parses a nullable `bigint`-as-string column into a number, or `undefined`. */ function bigintOrUndefined(value) { return value === null || value === undefined ? undefined : Number(value); } /** Max events fetched per readEvents round-trip. */ const EVENT_READ_BATCH = 100; /** Strips `undefined` properties so the JSON shape matches the Redis backend. */ function removeUndefined(obj) { for (const key of Object.keys(obj)) { if (obj[key] === undefined) { delete obj[key]; } } return obj; } /** * Normalizes a `removeOnComplete`/`removeOnFail` option into the retention * params the move_to_completed/failed functions take: * - `true` → remove the job immediately * - `false`/`undefined` → keep forever * - number → keep at most that many (most recent) * - `{ age, count }` → keep within age (seconds) and/or count */ function normalizeKeep(opt) { var _a, _b; if (opt === true) { return { removeAll: true, keepAge: null, keepCount: null }; } if (opt === false || opt === undefined || opt === null) { return { removeAll: false, keepAge: null, keepCount: null }; } if (typeof opt === 'number') { return { removeAll: false, keepAge: null, keepCount: opt }; } const keep = opt; return { removeAll: false, keepAge: (_a = keep.age) !== null && _a !== void 0 ? _a : null, keepCount: (_b = keep.count) !== null && _b !== void 0 ? _b : null, }; } /** * Maps a `job` row to the public {@link JobJson} shape consumed by * `Job.fromJSON`. JSON-string fields (`data`, `returnvalue`, `stacktrace`) are * re-stringified; `opts` is returned as the stored object. */ function rowToJobJson(row) { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r; return removeUndefined({ id: row.id, name: row.name, data: JSON.stringify((_a = row.data) !== null && _a !== void 0 ? _a : {}), opts: (_b = row.opts) !== null && _b !== void 0 ? _b : {}, progress: (_c = row.progress) !== null && _c !== void 0 ? _c : 0, attemptsMade: (_d = row.attempts_made) !== null && _d !== void 0 ? _d : 0, attemptsStarted: (_e = row.attempts_started) !== null && _e !== void 0 ? _e : 0, finishedOn: bigintOrUndefined(row.finished_at_ms), processedOn: bigintOrUndefined(row.processed_at_ms), timestamp: Number(row.added_at_ms), delay: bigintOrUndefined(row.delay_ms), priority: (_f = row.priority) !== null && _f !== void 0 ? _f : undefined, failedReason: (_g = row.failed_reason) !== null && _g !== void 0 ? _g : undefined, stacktrace: JSON.stringify((_h = row.stacktrace) !== null && _h !== void 0 ? _h : []), returnvalue: JSON.stringify((_j = row.return_value) !== null && _j !== void 0 ? _j : null), parent: row.parent_id != null ? { id: row.parent_id, queueKey: (_k = row.parent_queue) !== null && _k !== void 0 ? _k : '' } : undefined, parentKey: (_l = row.parent_key) !== null && _l !== void 0 ? _l : undefined, repeatJobKey: (_m = row.scheduler_id) !== null && _m !== void 0 ? _m : undefined, deduplicationId: (_o = row.dedup_id) !== null && _o !== void 0 ? _o : undefined, deferredFailure: (_p = row.deferred_failure) !== null && _p !== void 0 ? _p : undefined, processedBy: (_q = row.processed_by) !== null && _q !== void 0 ? _q : undefined, stalledCounter: (_r = row.stalled_count) !== null && _r !== void 0 ? _r : 0, }); } /** * Marks an {@link IQueueBackend} operation that the PostgreSQL backend does not * implement yet. The full operation set is being filled in incrementally * (vertical slice by vertical slice), so calling an unfinished operation fails * loudly rather than silently misbehaving. */ function notImplemented(op) { throw new Error(`PostgresQueueBackend: operation '${op}' is not implemented yet.`); } /** * Maps a scheduler row to the Redis-compatible metadata hash (string values, * absent fields omitted) plus the next-run score, matching the shape the * shared `JobScheduler` consumer expects. */ function mapSchedulerRow(row) { const hash = {}; if (row.name != null) { hash.name = String(row.name); } if (row.iteration_count != null) { hash.ic = String(row.iteration_count); } if (row.limit_count != null) { hash.limit = String(row.limit_count); } if (row.start_date_ms != null) { hash.startDate = String(row.start_date_ms); } if (row.end_date_ms != null) { hash.endDate = String(row.end_date_ms); } if (row.tz != null) { hash.tz = String(row.tz); } if (row.pattern != null) { hash.pattern = String(row.pattern); } if (row.every_ms != null) { hash.every = String(row.every_ms); } if (row.offset_ms != null) { hash.offset = String(row.offset_ms); } if (row.template_data != null) { const data = JSON.stringify(row.template_data); if (data !== '{}') { hash.data = data; } } if (row.template_opts != null) { const opts = JSON.stringify(row.template_opts); if (opts !== '{}') { hash.opts = opts; } } return { hash, next: row.next_run_ms == null ? null : String(row.next_run_ms), }; } /** * PostgreSQL implementation of {@link IQueueBackend}. * * Fulfils the same database-agnostic contract as {@link RedisQueueBackend}, but * backed by a PostgreSQL database: queue operations are expressed as SQL / * PL/pgSQL functions (created by the migrations), job state lives in a single * `job` table keyed by `(queue, id)` with a `state` column and partial * indexes, claiming uses `FOR UPDATE SKIP LOCKED`, and the blocking * "wait for job" primitive uses `LISTEN`/`NOTIFY`. * * The class owns its {@link PostgresConnection}; the high-level classes (Queue, * Worker, FlowProducer) depend only on {@link IQueueBackend} and never touch a * `pg` client directly. */ export class PostgresQueueBackend extends EventEmitter { constructor(connection, queueName, opts, ownsConnection = true, /** * When set, the name applied to this backend's dedicated connection (its * `application_name`) so getWorkers can discover it — the PostgreSQL * analogue of the Redis worker's named blocking connection. Only workers * pass it; QueueEvents name themselves via {@link setName}. */ listenClientName) { super(); this.connection = connection; this.queueName = queueName; this.opts = opts; this.ownsConnection = ownsConnection; this.listenClientName = listenClientName; /** Whether the dedicated LISTEN client is subscribed to the jobs channel. */ this.listening = false; /** Whether the dedicated LISTEN client is subscribed to the events channel. */ this.listeningEvents = false; /** * Set by {@link disconnectBlocking} to interrupt the blocking wait. Unlike * {@link cancelWait} (which only fires the *current* wait), this flag also * short-circuits a {@link waitForJob} that starts during/after the disconnect * — closing the race where the worker re-enters `waitForJob` (still awaiting * `ensureListening`) just as `close()` interrupts it, leaving it blocked on a * timer that, under faked timers, never fires. Cleared by * {@link reconnectBlocking}. (The Redis backend gets this for free: tearing * down the blocking socket interrupts even a freshly-issued `BZPOPMIN`.) */ this.blockingDisconnected = false; this.schema = connection.schema; if (this.ownsConnection) { this.connection.on('error', err => this.emit('error', err)); this.connection.on('ready', () => this.emit('ready')); this.connection.on('close', () => this.emit('close')); } } // ============================================================ // Connection lifecycle // ============================================================ async waitUntilReady() { // Memoized so every caller (the Worker's constructor 'ready' hook AND an // explicit waitUntilReady()) awaits the SAME readiness — including the // connection naming below. Without this a second caller could observe the // naming as "already started" and return before the first caller's setName // had actually completed, racing discovery (getWorkers/getQueueEvents). if (!this.readyPromise) { this.readyPromise = (async () => { await this.connection.waitUntilReady(); // Name this backend's dedicated connection so it is discoverable via // getClientList (and thus getWorkers) — even an `autorun: false` // worker that never fetches is listed, matching the Redis backend // which names its blocking connection on creation. Best-effort: a // naming failure must never block readiness. if (this.listenClientName) { try { await this.setName(this.listenClientName); } catch (_a) { // Discovery is best-effort; leave the connection unnamed. } } })(); } return this.readyPromise; } async close(force = false) { void force; if (!this.ownsConnection) { return; } if (!this.closing) { this.closing = this.connection.close(); } return this.closing; } async disconnect() { var _a, _b; // Interrupt any in-flight blocking wait so a blocked readEvents/waitForJob // returns and the caller (e.g. QueueEvents.close) can proceed. (_a = this.cancelWait) === null || _a === void 0 ? void 0 : _a.call(this); (_b = this.cancelEventWait) === null || _b === void 0 ? void 0 : _b.call(this); if (!this.ownsConnection) { return; } await this.connection.disconnect(); } async setName(name) { // Name the dedicated LISTEN connection via `application_name` — the // PostgreSQL analogue of Redis `CLIENT SETNAME`. This is the long-lived // connection a worker / QueueEvents holds, so it appears (under this name) // in pg_stat_activity and is therefore discoverable by getWorkers / // getQueueEvents via getClientList. await this.connection.waitUntilReady(); const client = await this.connection.getListenClient(); await client.query(`SELECT set_config('application_name', $1, false)`, [ name, ]); } /** * PostgreSQL `LISTEN`/`NOTIFY` has no minimum block granularity, so any * positive timeout is fine; we mirror the Redis backend's smallest unit. */ get minimumBlockTimeout() { return 0.001; } forQueue(queueName, _prefix) { // The namespace is the connection's schema, shared by all queues, so a // sibling backend only needs a different queue name. BullMQ's per-queue // `prefix` (a Redis keyspace concern) is ignored here. return new PostgresQueueBackend(this.connection, queueName, this.opts, false); } /** * The queue's qualified name. With a schema-based namespace there is no * prefix, so the qualified name is simply the queue name. */ get qualifiedName() { return this.queueName; } /** * Backends that don't address jobs by key return an empty map; PostgreSQL * addresses rows by `(queue, id)` columns instead. */ get keys() { return {}; } /** * Builds a namespaced identifier of the given `type` (`":"`), * used e.g. for flow dependency identifiers. No prefix is involved. */ toKey(type) { return `${this.queueName}:${type}`; } /** * Parses a PostgreSQL flow child key (`":"`) into its components. * There is no keyspace prefix, so `prefix` is always empty. Inverse of * {@link toKey}. */ parseNodeKey(key) { const idx = key.lastIndexOf(':'); return { prefix: '', queueName: key.slice(0, idx), id: key.slice(idx + 1), }; } /** * Returns a backend identifier used by the generic API; PostgreSQL discovery * relies on {@link setName} setting `application_name` on the dedicated * LISTEN client. */ clientName(suffix = '') { return `${this.queueName}${suffix}`; } // ============================================================ // SQL helpers // ============================================================ /** * Runs a query on the connection's pool, first awaiting the connection's * (memoized) readiness so the schema/functions exist. This mirrors how the * ioredis client buffers commands until connected, letting callers (e.g. a * Worker's autorun loop) issue operations before `waitUntilReady` resolves. */ async query(text, params) { await this.connection.waitUntilReady(); // The owning connection may be shutting down (or already have ended its // pool). Issuing the query then throws a raw "Cannot use a pool after // calling end on the pool", which — for a fire-and-forget operation raced // in during teardown (e.g. an event handler still scheduling work after // close) — surfaces as an unhandled rejection. Mirror ioredis, whose // offline queue simply never settles a command issued against a closing // connection: return a promise that never resolves so such stragglers // neither crash nor pollute the run. This only triggers once close() has // begun, so no legitimate in-flight operation is affected. if (this.connection.isClosing) { return new Promise(() => undefined); } try { return await this.connection.pool.query(text, params); } catch (err) { // Close the race where the pool is ended between the check above and the // query dispatch. if (this.connection.isClosing && err instanceof Error && err.message.includes('after calling end on the pool')) { return new Promise(() => undefined); } throw err; } } /** * Loads a named `.sql` command file and runs it. The files contain no * schema/namespace references — the connection's `search_path` selects the * namespace — so they are portable verbatim to the other language ports. */ run(command, params) { return this.query(loadCommandSql(command), params); } /** * The processing worker's name (when this backend belongs to a Worker), used * to stamp `processedBy` on the next job fetched during a finish op. */ get workerName() { return this.opts.name; } /** * Re-throws a finish-op error (SQLSTATE `BM001`, whose DETAIL carries the * numeric `ErrorCode`) as the shared canonical error; passes anything else * through unchanged. */ mapFinishError(err, jobId, command) { if (err && err.code === 'BM001') { throw finishedErrors({ code: Number(err.detail), jobId, command, state: 'active', }); } throw err; } // ============================================================ // Adding jobs // ============================================================ async addJob(job, jobId, parentKeyOpts = {}) { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s; const opts = ((_a = job.opts) !== null && _a !== void 0 ? _a : {}); const parentKey = (_c = (_b = parentKeyOpts.parentKey) !== null && _b !== void 0 ? _b : job.parentKey) !== null && _c !== void 0 ? _c : null; let rows; try { ({ rows } = await this.run('add_job', [ this.queueName, jobId || job.id || '', job.name, (_d = job.data) !== null && _d !== void 0 ? _d : '{}', JSON.stringify(opts !== null && opts !== void 0 ? opts : {}), (_f = (_e = job.priority) !== null && _e !== void 0 ? _e : opts.priority) !== null && _f !== void 0 ? _f : 0, (_h = (_g = job.delay) !== null && _g !== void 0 ? _g : opts.delay) !== null && _h !== void 0 ? _h : 0, (_j = job.timestamp) !== null && _j !== void 0 ? _j : Date.now(), (_k = opts.attempts) !== null && _k !== void 0 ? _k : 1, (_m = (_l = job.parent) === null || _l === void 0 ? void 0 : _l.queueKey) !== null && _m !== void 0 ? _m : null, (_p = (_o = job.parent) === null || _o === void 0 ? void 0 : _o.id) !== null && _p !== void 0 ? _p : null, parentKey, (_q = job.deduplicationId) !== null && _q !== void 0 ? _q : null, (_r = job.repeatJobKey) !== null && _r !== void 0 ? _r : null, (_s = opts.lifo) !== null && _s !== void 0 ? _s : false, ])); } catch (err) { if (err && err.code === 'BM001') { throw finishedErrors({ code: Number(err.detail), jobId, parentKey: parentKey !== null && parentKey !== void 0 ? parentKey : undefined, command: 'addJob', }); } throw err; } return rows[0].id; } async addJobs(entries) { // Insert the whole batch in a single atomic statement so they all become // visible together (FIFO/priority ordering otherwise breaks: a worker could // claim an earlier-inserted lower-priority job before the rest land). const payload = entries.map(entry => this.toBatchEntry(this.queueName, entry.job, entry.jobId, entry.parentKeyOpts)); // Fast path: when every job is independent (no parent, no deduplication), // use the set-based `add_jobs_bulk` (one INSERT + one event INSERT) instead // of the row-by-row flow engine — several times faster for plain addBulk. const independent = payload.every(e => e.parentId == null && e.parentQueue == null && e.dedupId == null); if (independent) { const { rows } = await this.run('add_jobs_bulk', [ this.queueName, JSON.stringify(payload), ]); return rows.map(r => r.id); } const { rows } = await this.run('add_flow', [ JSON.stringify(payload), ]); return rows.map(r => r.id); } /** Builds one entry of the JSONB batch consumed by `add_flow`. */ toBatchEntry(queueName, data, jobId, parentKeyOpts) { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t; const opts = ((_a = data.opts) !== null && _a !== void 0 ? _a : {}); return { queue: queueName, id: jobId || data.id || '', name: data.name, data: (_b = data.data) !== null && _b !== void 0 ? _b : '{}', opts, priority: (_d = (_c = data.priority) !== null && _c !== void 0 ? _c : opts.priority) !== null && _d !== void 0 ? _d : 0, delay: (_f = (_e = data.delay) !== null && _e !== void 0 ? _e : opts.delay) !== null && _f !== void 0 ? _f : 0, timestamp: (_g = data.timestamp) !== null && _g !== void 0 ? _g : Date.now(), attempts: (_h = opts.attempts) !== null && _h !== void 0 ? _h : 1, parentQueue: (_k = (_j = data.parent) === null || _j === void 0 ? void 0 : _j.queueKey) !== null && _k !== void 0 ? _k : null, parentId: (_m = (_l = data.parent) === null || _l === void 0 ? void 0 : _l.id) !== null && _m !== void 0 ? _m : null, parentKey: (_p = (_o = parentKeyOpts === null || parentKeyOpts === void 0 ? void 0 : parentKeyOpts.parentKey) !== null && _o !== void 0 ? _o : data.parentKey) !== null && _p !== void 0 ? _p : null, dedupId: (_q = data.deduplicationId) !== null && _q !== void 0 ? _q : null, schedulerId: (_r = data.repeatJobKey) !== null && _r !== void 0 ? _r : null, lifo: (_s = opts.lifo) !== null && _s !== void 0 ? _s : false, addToWaitingChildren: (_t = parentKeyOpts === null || parentKeyOpts === void 0 ? void 0 : parentKeyOpts.addToWaitingChildren) !== null && _t !== void 0 ? _t : false, }; } async addFlow(entries) { // Build an ordered (roots-first) JSON array; the SQL function inserts the // whole tree in a single atomic statement. Each entry is self-describing // (carries its own queue), so the flow can span multiple queues. const payload = entries.map(entry => this.toBatchEntry(entry.queueName, entry.jobData, entry.jobId, entry.parentKeyOpts)); try { const { rows } = await this.run('add_flow', [ JSON.stringify(payload), ]); // A negative-integer id is an error/skip code (e.g. -5 = missing parent), // mirroring the Redis addFlow `[err, idOrCode]` convention; a real job id // is a positive counter or a custom string. return rows.map(r => { const code = Number(r.id); return Number.isInteger(code) && code < 0 ? [null, code] : [null, r.id]; }); } catch (err) { // The single-statement function is atomic: on failure nothing was // inserted, so report the same error for every entry. return entries.map(() => [err, 0]); } } async addJobScheduler(jobSchedulerId, nextMillis, templateData, templateOpts, opts, delayedJobOpts, producerId) { let rows; try { ({ rows } = await this.run('add_job_scheduler', [ this.queueName, jobSchedulerId, nextMillis !== null && nextMillis !== void 0 ? nextMillis : null, templateData || '{}', JSON.stringify(templateOpts !== null && templateOpts !== void 0 ? templateOpts : {}), JSON.stringify(opts !== null && opts !== void 0 ? opts : {}), JSON.stringify(delayedJobOpts !== null && delayedJobOpts !== void 0 ? delayedJobOpts : {}), Date.now(), producerId !== null && producerId !== void 0 ? producerId : null, ])); } catch (err) { if (err && err.code === 'BM001') { throw finishedErrors({ code: Number(err.detail), command: 'addJobScheduler', }); } throw err; } const row = rows[0]; return [row.job_id, Number(row.delay)]; } // ============================================================ // Job state transitions // ============================================================ async moveToActive(token, name) { var _a, _b, _c, _d, _e; const opts = this.opts; const lockDuration = (_a = opts.lockDuration) !== null && _a !== void 0 ? _a : 30000; const limiterMax = (_c = (_b = opts.limiter) === null || _b === void 0 ? void 0 : _b.max) !== null && _c !== void 0 ? _c : null; const limiterDuration = (_e = (_d = opts.limiter) === null || _d === void 0 ? void 0 : _d.duration) !== null && _e !== void 0 ? _e : null; const now = Date.now(); const { rows } = await this.run('move_to_active', [ this.queueName, token, lockDuration, now, name !== null && name !== void 0 ? name : null, limiterMax, limiterDuration, ]); return this.buildNextJobResult(rows, limiterMax, now); } /** * Shapes a job-claim result (from `move_to_active` or the fused finish+fetch) * into the worker's `[jobData, id, rateLimitDelay, delayUntil]` tuple. When no * job was claimed, a follow-up `next_signal` reports the rate-limit ttl or the * next delayed wake-up so the worker can block until then. */ async buildNextJobResult(rows, limiterMax, now) { var _a, _b, _c, _d, _e; if (rows.length > 0) { const row = rows[0]; // [jobData, id, rateLimitDelay, delayUntil] return [rowToJobJson(row), row.id, 0, 0]; } // No job claimed: report the rate-limit ttl (if rate limited) or the next // delayed wake-up so the worker can block until then. const { rows: sigRows } = await this.run('next_signal', [this.queueName, limiterMax, now]); const rateLimitTtl = Number((_b = (_a = sigRows[0]) === null || _a === void 0 ? void 0 : _a.rate_limit_ttl) !== null && _b !== void 0 ? _b : 0); if (rateLimitTtl > 0) { return [null, '', rateLimitTtl, 0]; } const delayUntil = (_e = bigintOrUndefined((_d = (_c = sigRows[0]) === null || _c === void 0 ? void 0 : _c.next_delay) !== null && _d !== void 0 ? _d : null)) !== null && _e !== void 0 ? _e : 0; return [null, '', 0, delayUntil]; } async moveToCompleted(job, returnValue, removeOnComplete, token, fetchNext) { var _a, _b, _c, _d, _e, _f; const finishedOn = Date.now(); const keep = normalizeKeep(removeOnComplete !== null && removeOnComplete !== void 0 ? removeOnComplete : this.opts.removeOnComplete); const opts = this.opts; // Fast path: fuse the completion and the next-job claim into a single // transaction (one commit), the Redis moveToFinished shape. Processing is // commit/fsync-bound, so collapsing two commits per job into one is the // dominant throughput win. if (fetchNext && !this.closing) { const lockDuration = (_a = opts.lockDuration) !== null && _a !== void 0 ? _a : 30000; const limiterMax = (_c = (_b = opts.limiter) === null || _b === void 0 ? void 0 : _b.max) !== null && _c !== void 0 ? _c : null; const limiterDuration = (_e = (_d = opts.limiter) === null || _d === void 0 ? void 0 : _d.duration) !== null && _e !== void 0 ? _e : null; const now = Date.now(); let rows = []; try { ({ rows } = await this.run('move_to_completed_fetch', [ this.queueName, job.id, token, JSON.stringify(returnValue !== null && returnValue !== void 0 ? returnValue : null), finishedOn, keep.removeAll, keep.keepAge, keep.keepCount, lockDuration, now, (_f = this.workerName) !== null && _f !== void 0 ? _f : null, limiterMax, limiterDuration, ])); } catch (err) { this.mapFinishError(err, job.id, 'moveToFinished'); } await this.collectMetrics('completed', finishedOn); const result = await this.buildNextJobResult(rows, limiterMax, now); return { result, finishedOn }; } try { await this.run('move_to_completed', [ this.queueName, job.id, token, JSON.stringify(returnValue !== null && returnValue !== void 0 ? returnValue : null), finishedOn, keep.removeAll, keep.keepAge, keep.keepCount, ]); } catch (err) { this.mapFinishError(err, job.id, 'moveToFinished'); } await this.collectMetrics('completed', finishedOn); return { result: undefined, finishedOn }; } async moveToFailed(job, failedReason, removeOnFail, token, fetchNext, fieldsToUpdate) { var _a, _b, _c, _d, _e, _f, _g, _h; const finishedOn = Date.now(); const keep = normalizeKeep(removeOnFail !== null && removeOnFail !== void 0 ? removeOnFail : this.opts.removeOnFail); const opts = this.opts; // Fast path: fuse the failure (or retry re-queue) and the next-job claim // into a single transaction (one commit), the Redis moveToFinished shape. if (fetchNext && !this.closing) { const lockDuration = (_a = opts.lockDuration) !== null && _a !== void 0 ? _a : 30000; const limiterMax = (_c = (_b = opts.limiter) === null || _b === void 0 ? void 0 : _b.max) !== null && _c !== void 0 ? _c : null; const limiterDuration = (_e = (_d = opts.limiter) === null || _d === void 0 ? void 0 : _d.duration) !== null && _e !== void 0 ? _e : null; const now = Date.now(); let rows = []; try { ({ rows } = await this.run('move_to_failed_fetch', [ this.queueName, job.id, token, failedReason, (_f = fieldsToUpdate === null || fieldsToUpdate === void 0 ? void 0 : fieldsToUpdate.stacktrace) !== null && _f !== void 0 ? _f : null, finishedOn, keep.removeAll, keep.keepAge, keep.keepCount, lockDuration, now, (_g = this.workerName) !== null && _g !== void 0 ? _g : null, limiterMax, limiterDuration, ])); } catch (err) { this.mapFinishError(err, job.id, 'moveToFinished'); } await this.collectMetrics('failed', finishedOn); const result = await this.buildNextJobResult(rows, limiterMax, now); return { result, finishedOn }; } try { await this.run('move_to_failed', [ this.queueName, job.id, token, failedReason, (_h = fieldsToUpdate === null || fieldsToUpdate === void 0 ? void 0 : fieldsToUpdate.stacktrace) !== null && _h !== void 0 ? _h : null, finishedOn, keep.removeAll, keep.keepAge, keep.keepCount, ]); } catch (err) { this.mapFinishError(err, job.id, 'moveToFinished'); } await this.collectMetrics('failed', finishedOn); return { result: undefined, finishedOn }; } async moveToDelayed(jobId, timestamp, delay, token, opts) { var _a, _b, _c, _d; const fields = (_a = opts === null || opts === void 0 ? void 0 : opts.fieldsToUpdate) !== null && _a !== void 0 ? _a : {}; try { await this.run('move_to_delayed', [ this.queueName, jobId, token !== null && token !== void 0 ? token : '', timestamp + delay, delay, (_b = opts === null || opts === void 0 ? void 0 : opts.skipAttempt) !== null && _b !== void 0 ? _b : false, (_c = fields.failedReason) !== null && _c !== void 0 ? _c : null, (_d = fields.stacktrace) !== null && _d !== void 0 ? _d : null, ]); } catch (err) { this.mapFinishError(err, jobId, 'moveToDelayed'); } if ((opts === null || opts === void 0 ? void 0 : opts.fetchNext) && !this.closing && token) { const next = await this.moveToActive(token, this.workerName); // Return the next job tuple only when a job was actually claimed; // otherwise an empty array (a delay hint is not "next job data"). return next && next[0] ? next : []; } return []; } async moveToWaitingChildren(jobId, token, _opts) { let rows; try { ({ rows } = await this.run('move_to_waiting_children', [ this.queueName, jobId, token, ])); } catch (err) { this.mapFinishError(err, jobId, 'moveToWaitingChildren'); } const code = rows[0].code; if (code < 0) { throw finishedErrors({ code, jobId, command: 'moveToWaitingChildren', state: 'active', }); } // 1 = moved to waiting-children (should wait); 0 = no pending, proceed. return code === 1; } async moveJobFromActiveToWait(jobId, token = '0') { const { rows } = await this.run('move_active_to_wait', [ this.queueName, jobId, token, Date.now(), ]); const n = Number(rows[0].n); if (n < 0) { throw finishedErrors({ code: n, jobId, command: 'moveJobFromActiveToWait', }); } return n; } async retryJob(jobId, lifo, token, opts) { var _a, _b, _c; const fields = (_a = opts === null || opts === void 0 ? void 0 : opts.fieldsToUpdate) !== null && _a !== void 0 ? _a : {}; try { await this.run('retry_job', [ this.queueName, jobId, token !== null && token !== void 0 ? token : '', lifo, (_b = fields.failedReason) !== null && _b !== void 0 ? _b : null, (_c = fields.stacktrace) !== null && _c !== void 0 ? _c : null, ]); } catch (err) { this.mapFinishError(err, jobId, 'retryJob'); } } async retryFinishedJob(job, state, opts = {}) { var _a, _b, _c, _d; const { rows } = await this.run('reprocess_job', [ this.queueName, job.id, state, (_b = (_a = job.opts) === null || _a === void 0 ? void 0 : _a.lifo) !== null && _b !== void 0 ? _b : false, (_c = opts.resetAttemptsMade) !== null && _c !== void 0 ? _c : false, (_d = opts.resetAttemptsStarted) !== null && _d !== void 0 ? _d : false, ]); const code = rows[0].code; if (code !== 1) { throw finishedErrors({ code, jobId: job.id, command: 'reprocessJob', state, }); } } async promote(jobId) { const { rows } = await this.run('promote', [ this.queueName, jobId, ]); const code = rows[0].code; if (code < 0) { throw finishedErrors({ code, jobId, command: 'promote', state: 'delayed', }); } } async moveStalledJobsToWait() { var _a, _b; // Recover active jobs whose lock expired: push them back to waiting so a // worker can re-claim them. Returns the recovered job ids. Uses a two-phase // mark/sweep so a freshly-claimed job is never reclaimed mid-processing. const opts = this.opts; const { rows } = await this.run('move_stalled_jobs_to_wait', [ this.queueName, (_a = opts.maxStalledCount) !== null && _a !== void 0 ? _a : 1, Date.now(), (_b = opts.stalledInterval) !== null && _b !== void 0 ? _b : 30000, ]); return rows.map(r => r.id); } // ============================================================ // Bulk admin transitions // ============================================================ async retryFinishedJobs(state, count, timestamp) { const { rows } = await this.run('retry_jobs', [ this.queueName, state !== null && state !== void 0 ? state : 'failed', count !== null && count !== void 0 ? count : null, timestamp !== null && timestamp !== void 0 ? timestamp : null, ]); return Number(rows[0].n); } async promoteJobs(count) { const { rows } = await this.run('promote_jobs', [ this.queueName, count !== null && count !== void 0 ? count : null, ]); return Number(rows[0].n); } async pause(pause) { await this.run('pause', [this.queueName, pause]); } async drain(delayed) { await this.run('drain', [this.queueName, delayed]); } async cleanJobsByState(state, timestamp, limit = 0) { const { rows } = await this.run('clean', [ this.queueName, state, timestamp, limit, ]); return rows.map(r => r.id); } async obliterate(opts) { const { rows } = await this.run('obliterate', [ this.queueName, opts.count, opts.force, ]); const cursor = Number(rows[0].cursor); if (cursor < 0) { switch (cursor) { case -1: throw new Error('Cannot obliterate non-paused queue'); case -2: throw new Error('Cannot obliterate queue with active jobs'); } } return cursor; } /** * Removes orphaned job hashes (job data present but not referenced by any * state set). This is a Redis keyspace-maintenance concern: on PostgreSQL a * job is a single relational row inserted transactionally with its state, so * orphans cannot exist and there is nothing to remove. Always returns 0. */ removeOrphanedJobs(_count, _limit) { return Promise.resolve(0); } // ============================================================ // Locks // ============================================================ async extendLock(jobId, token, duration) { const { rows } = await this.run('extend_lock', [ this.queueName, jobId, token, duration, Date.now(), ]); return rows[0].n; } async extendLocks(jobIds, tokens, duration) { const { rows } = await this.run('extend_locks', [ this.queueName, jobIds, tokens, duration, Date.now(), ]); // The SQL command returns the ids whose locks could not be renewed. return rows.map(({ id }) => id); } // ============================================================ // Job mutations // ============================================================ async updateData(job, data) { const { rows } = await this.run('update_data', [ this.queueName, job.id, JSON.stringify(data !== null && data !== void 0 ? data : {}), ]); if (rows.length === 0) { throw finishedErrors({ code: -1, jobId: job.id, command: 'updateData', }); } } async updateProgress(jobId, progress) { const { rows } = await this.run('update_progress', [ this.queueName, jobId, JSON.stringify(progress !== null && progress !== void 0 ? progress : null), ]); if (!rows[0].updated) { throw finishedErrors({ code: -1, jobId, command: 'updateProgress', }); } } async addLog(jobId, logRow, keepLogs) { let rows; try { ({ rows } = await this.run('add_log', [ this.queueName, jobId, logRow, ])); } catch (err) { // 23503 = foreign_key_violation: the job no longer exists. if (err && err.code === '23503') { throw finishedErrors({ code: -1, jobId, command: 'addLog' }); } throw err; } const count = Number(rows[0].idx) + 1; if (keepLogs && count > keepLogs) { await this.run('trim_logs', [this.queueName, jobId, count - keepLogs]); return keepLogs; } return count; } async clearLogs(jobId, keepLogs) { await this.run('clear_logs', [this.queueName, jobId, keepLogs !== null && keepLogs !== void 0 ? keepLogs : null]); } async changeDelay(jobId, delay) { const { rows } = await this.run('change_delay', [ this.queueName, jobId, delay, Date.now(), ]); const code = rows[0].code; if (code < 0) { throw finishedErrors({ code, jobId, command: 'changeDelay', state: 'delayed', }); } } async changePriority(jobId, priority = 0, lifo = false) { const { rows } = await this.run('change_priority', [ this.queueName, jobId, priority, lifo, ]); const code = rows[0].code; if (code < 0) { throw finishedErrors({ code, jobId, command: 'changePriority' }); } } async remove(jobId, removeChildren) { let rows; try { ({ rows } = await this.run('remove', [ this.queueName, jobId, removeChildren, ])); } catch (err) { if (err && err.code === 'BM001') { throw finishedErrors({ code: Number(err.detail), jobId, command: 'remove', }); } throw err; } return rows[0].n; } async removeUnprocessedChildren(jobId) { await this.run('remove_unprocessed_children', [this.queueName, jobId]); } async removeChildDependency(jobId, parentKey) { try { const { rows } = await this.run('remove_child_dependency', [this.queueName, jobId, parentKey, Date.now()]); return rows[0].n === 0; } catch (err) { if (err && err.code === 'BM001') { throw finishedErrors({ code: Number(err.detail), jobId, parentKey, command: 'removeChildDependency', }); } throw err; } } async removeDeduplicationKey(deduplicationId, jobId) { const { rows } = await this.run('remove_deduplication_key', [this.queueName, deduplicationId, jobId, Date.now()]); return rows.length; } async deleteDeduplicationKey(deduplicationId) { const { rows } = await this.run('delete_deduplication_key', [this.queueName, deduplicationId]); return rows.length; } // ============================================================ // Job schedulers // ============================================================ async updateJobSchedulerNextMillis(jobSchedulerId, nextMillis, templateData, delayedJobOpts, producerId) { var _a, _b; const { rows } = await this.run('update_job_scheduler', [ this.queueName, jobSchedulerId, nextMillis !== null && nextMillis !== void 0 ? nextMillis : null, templateData || '{}', JSON.stringify(delayedJobOpts !== null && delayedJobOpts !== void 0 ? delayedJobOpts : {}), Date.now(), producerId !== null && producerId !== void 0 ? producerId : null, ]); return (_b = (_a = rows[0]) === null || _a === void 0 ? void 0 : _a.job_id) !== null && _b !== void 0 ? _b : null; } async removeJobScheduler(jobSchedulerId) { var _a, _b; const { rows } = await this.run('remove_job_scheduler', [this.queueName, jobSchedulerId]); return (_b = (_a = rows[0]) === null || _a === void 0 ? void 0 : _a.removed) !== null && _b !== void 0 ? _b : 0; } async getJobScheduler(id) { const { rows } = await this.run('get_job_scheduler', [ this.queueName, id, ]); if (rows.length === 0) { return [null, null]; } const { hash, next } = mapSchedulerRow(rows[0]); const flat = []; for (const [k, v] of Object.entries(hash)) { flat.push(k, v); } return [flat, next]; } async isJobScheduler(id) { var _a, _b; const { rows } = await this.run('is_job_scheduler', [ this.queueName, id, ]); return (_b = (_a = rows[0]) === null || _a === void 0 ? void 0 : _a.exists) !== null && _b !== void 0 ? _b : false; } async getJobSchedulerData(key) { const { rows } = await this.run('get_job_scheduler', [ this.queueName, key, ]); if (rows.length === 0) { return {}; } return mapSchedulerRow(rows[0]).hash; } async getJobSchedulersRange(start, end, asc) { const count = end < 0 ? null : end - start + 1; const { rows } = await this.run('get_job_schedulers_range', [this.queueName, asc, start, count]); const flat = []; for (const r of rows) { flat.push(r.scheduler_id, String(r.next_run_ms)); } return flat; } async getJobSchedulersCount() { var _a, _b; const { rows } = await this.run('get_job_schedulers_count', [this.queueName]); return (_b = (_a = rows[0]) === null || _a === void 0 ? void 0 : _a.count) !== null && _b !== void 0 ? _b : 0; } // ============================================================ // Queue / job queries // ============================================================ async getState(jobId) { const { rows } = await this.run('get_state', [this.queueName, jobId]); if (!rows[0]) { return 'unknown'; } // A prioritized job is a waiting job with priority > 0. if (rows[0].state === 'waiting' && rows[0].priority > 0) { return 'prioritized'; } return rows[0].state; } async isFinished(jobId, returnValue) { var _a, _b; const { rows } = await this.run('is_finished', [this.queueName, jobId]); const row = rows[0]; // status: 0 = not finished, 1 = completed, 2 = failed, -1 = missing job. let status = 0; let value = ''; if (!row) { status = -1; value = `Missing key for job ${this.toKey(jobId)}. isFinished`; } else if (row.state === 'completed') { status = 1; value = JSON.stringify((_a = row.return_value) !== null && _a !== void 0 ? _a : null); } else if (row.state === 'failed') { status = 2; value = (_b = row.failed_reason) !== null && _b !== void 0 ? _b : ''; } return returnValue ? [status, value] : status; } async isMaxed() { const { rows } = await this.run('is_maxed', [ this.queueName, ]); return rows[0].maxed; } async isJobInState(state, jobId) { if (state === 'active') { const { rows } = await this.run('is_job_in_state', [ this.queueName, jobId, 'active', ]); return rows[0].present; } else if (state === 'wait' || state === 'paused') { // 'wait' or 'paused' — distinguished by the queue's paused flag. const { rows } = await this.run('is_job_in_wait', [ this.queueName, jobId, state === 'paused', ]); return rows[0].present; } else if (state === 'waiting') { return ((await this.isJobInState('wait', jobId)) || (await this.isJobInState('paused', jobId))); } else if (state === 'prioritized') { const { rows } = await this.run('is_job_prioritized', [this.queueName, jobId]); return rows[0].present; } else if (state === 'completed' || state === 'failed' || state === 'delayed' || state === 'waiting-children') { const { rows } = await this.run('is_job_in_state', [ this.queueName, jobId, state, ]); return rows[0].present; } throw new Error(`Unknown job state: ${state}`); } async getJobData(jobId) { const { rows } = await this.run('get_job_data', [ this.queueName, jobId, ]); return rows[0] ? rowToJobJson(rows[0]) : undefined; } async getDeduplicationJobId(deduplicationId) { var _a, _b; const { rows } = await this.run('get_deduplication_job_id', [this.queueName, deduplicationId, Date.now()]); return (_b = (_a = rows[0]) === null || _a === void 0 ? void 0 : _a.job_id) !== null && _b !== void 0 ? _b : null; } async getJobLogs(jobId, start, end, asc) { const { rows: countRows } = await this.run('get_job_logs_count', [this.queueName, jobId]); const count = Number(countRows[0].count); // start/end are inclusive zero-based indexes (Redis LRANGE semantics). const from = start < 0 ? Math.max(count + start, 0) : start; const to = end < 0 ? count + end : end; const limit = to - from + 1; if (limit <= 0) { return { logs: [], count }; } const { rows } = await this.run(asc ? 'get_job_logs_asc' : 'get_job_logs_desc', [this.queueName, jobId, from, limit]); return { logs: rows.map(r => r.row), count }; } async getRateLimitTtl(maxJobs) { // Mirrors getRateLimitTtl-2.lua: explicit maxJobs → check against it; else // the global meta `max`; else the raw window ttl (-2 when none). const { rows } = await this.run('get_rate_limit_ttl', [ this.queueName, maxJobs !== null && maxJobs !== void 0 ? maxJobs : 0, Date.now(), ]); return Number(rows[0].ttl); } async getCounts(types) { const { rows } = await this.run('get_counts', [ this.queueName, ]); const counts = rows[0]; const waiting = Number(counts.waiting); const prioritized = Number(counts.prioritized); const isPaused = counts.paused === '1'; const lookup = { active: Number(counts.active), completed: Number(counts.completed), failed: Number(counts.failed), delayed: Number(counts.delayed), // When paused, waiting jobs are reported as paused (the queue isn't // physically moving them — see the O(1) pause flag). wait: isPaused ? 0 : waiting, waiting: isPaused ? 0 : waiting, prioritized, 'waiting-children': Number(counts['waiting-children']), paused: isPaused ? waiting : 0, }; return types.map(type => { var _a; return (_a = lookup[type]) !== null && _a !== void 0 ? _a : 0; }); } async getCountsPerPriority(priorities) { const { rows } = await this.run('get_counts_per_priority', [this.queueName, priorities]); return rows.map(r => Number(r.cnt)); } async getRanges(types, start = 0, end = -1, asc = false) { const result = []; for (const type of types) { const { rows } = await this.run('get_range', [ this.queueName, type, start, end, asc, ]); result.push(rows.map(r => r.id)); } return result; } async getDependencyCounts(jobId, types) { const { rows } = await this.run('get_dependency_counts', [this.queueName, jobId]); const c = rows[0]; const map = { processed: Number(c.processed), unprocessed: Number(c.unprocessed), ignored: Number(c.ignored), failed: Number(c.failed), }; return types.map(t => { var _a; return (_a = map[t]) !== null && _a !== void 0 ? _a : 0; }); } async getDependencies(jobId, opts) { // No category requested: return all four in full (mirrors the Redis // hgetall/smembers path). `value` for processed/ignored comes back as a // parsed JSON value from jsonb. if (!opts.processed && !opts.unprocessed && !opts.ignored && !opts.failed) { const { rows } = await this.run('get_dependencies', [this.queueName, jobId]); const processed = {}; const unprocessed = []; const ignored = {}; const failed = []; for (const r of rows) { switch (r.status) { case 'processed': processed[r.child_key] = r.value; break; case 'pending': unprocessed.push(r.child_key); break; case 'ignored': ignored[r.child_key] = r.value; break; case 'failed': failed.push(r.child_key); break; } } return { processed, unprocessed, ignored, failed }; } // Paginated per requested category (cursor is a plain offset here). const result = {}; const page = async (status, cursor = 0, count = 20) => { const { rows } = await this.run('get_dependencies_page', [this.queueName, jobId, status, cursor, count]); return { rows, next: rows.length < count ? 0 : cursor + count }; }; if (opts.processed) { const { rows, next } = await page('processed', opts.processed.cursor, opts.processed.count); const processed = {}; for (const r of rows) { processed[r.child_key] = r.value; } result.processed = processed; result.nextProcessedCursor = next; } if (opts.unprocessed) { const { rows, next } = await page('pending', opts.unprocessed.cursor, opts.unprocessed.count); result.unprocessed = rows.map(r => r.child_key); result.nextUnprocessedCursor = next; } if (opts.ignored) { const { rows, next } = await page('ignored', opts.ignored.cursor, opts.ignored.count); const ignored = {}; for (const r of rows) { ignored[r.child_key] = r.value; } result.ignored = ignored; result.nextIgnoredCursor = next; } if (opts.failed) { const { rows, next } = await page('failed', opts.failed.cursor, opts.failed.count); result.failed = rows.map(r => r.child_key); result.nextFailedCursor = next; } return result; } async getProcessedChildrenValues(jobId) { const { rows } = await this.run('get_processed_children_values', [this.queueName, jobId]); const result = {}; for (const r of rows) { result[r.child_key] = r.value; } return result; } async getIgnoredChildrenFailures(jobId) { const { rows } = await this.run('get_ignored_children_failures', [this.queueName, jobId]); const result = {}; for (const r of rows) { result[r.child_key] = r.reason; } return result; } /** * Records one finished job into the per-minute metrics for the given `kind`, * when the worker was created with a `metrics.maxDataPoints`. Mirrors the * `collectMetrics` step of Redis's moveToFinished; kept as a separate query * (metrics are best-effort, so strict atomicity with the finish is not * required). */ async collectMetrics(kind, finishedOn) { var _a; const maxDataPoints = (_a = this.opts.metrics) === null || _a === void 0 ? void 0 : _a.maxDataPoints; if (maxDataPoints) { await this.run('collect_metrics', [ this.queueName, kind, maxDataPoints, finishedOn, ]); } } async getMetrics(type, start = 0, end = -1) { var _a, _b, _c, _d; const { rows } = await this.run('get_metrics', [this.queueName, type, start, end]); const total = (_b = (_a = rows[0]) === null || _a === void 0 ? void 0 : _a.total) !== null && _b !== void 0 ? _b : '0'; const data = ((_d = (_c = rows[0]) === null || _c === void 0 ? void 0 : _c.data) !== null && _d !== void 0 ? _d : []).map(String); // [meta, data, count] mirrors getMetrics-1.lua: meta = [count, prevTS, // prevCount] (only the cumulative count is tracked here), data = the sliced // per-minute points, count = number of points returned. return [[total, '0', '0'], data, data.length]; } async getClientList() { // Mirror Redis CLIENT LIST using pg_stat_activity: each named session // (workers / QueueEvents set their `application_name`) becomes a // `name=` line, which the shared client-list parser then // matches against the queue's client name. Returned as a single-element // array since PostgreSQL has no cluster-node fan-out. const { rows } = await this.run('get_client_list'); return [rows.map(r => `name=${r.application_name}`).join('\n')]; } async paginate(key, opts) { var _a; // The dependency getters page over a parent's children: the key is // `::dependencies` (pending children) or // `::processed` (resolved children, carrying their value). const prefix = `${this.queueName}:`; const inner = key.startsWith(prefix) ? key.slice(prefix.length) : key; let status; let parentId; let withValue = false; if (inner.endsWith(':processed')) { status = 'processed'; withValue = true; parentId = inner.slice(0, -':processed'.length); } else if (inner.endsWith(':dependencies')) { status = 'pending'; parentId = inner.slice(0, -':dependencies'.length); } else { // Only the dependency / processed pagination keys are supported. return notImplemented('paginate'); } const offset = Math.max((_a = opts.start) !== null && _a !== void 0 ? _a : 0, 0); const limit = opts.end != null && opts.end >= 0 ? opts.end - offset + 1 : null; const { rows } = await this.run('paginate_dependencies', [ this.queueName, parentId, status, offset, limit, ]); const total = rows.length ? Number(rows[0].total) : 0; const items = rows.map(r => withValue ? { id: r.child_key, v: r.dep_value } : { id: r.child_key }); const jobs = opts.fetchJobs ? rows.filter(r => r.id != null).map(r => rowToJobJson(r)) : undefined; return { cursor: '0', items, total, jobs }; } // ============================================================ // Queue metadata & maintenance keys // ============================================================ async setQueueMeta(values) { const fields = Object.keys(values); if (fields.length === 0) { return 0; } const vals = fields.map(f => String(values[f])); const { rowCount } = await this.run('set_queue_meta', [ this.queueName, fields, vals, ]); return rowCount !== null && rowCount !== void 0 ? rowCount : fields.length; } async getQueueMetaField(field) { var _a, _b; const { rows } = await this.run('get_queue_meta_field', [this.queueName, field]); return (_b = (_a = rows[0]) === null || _a === void 0 ? void 0 : _a.value) !== null && _b !== void 0 ? _b : null; } async getQueueMetaFields(fields) { if (fields.length === 0) { return []; } const { rows } = await this.run('get_queue_meta_fields', [this.queueName, fields]); const map = new Map(rows.map(r => [r.field, r.value])); return fields.map(f => { var _a; return (_a = map.get(f)) !== null && _a !== void 0 ? _a : null; }); } async getQueueMeta() { const { rows } = await this.run('get_queue_meta', [this.queueName]); const meta = {}; for (const r of rows) { meta[r.field] = r.value; } return meta; } async removeQueueMetaFields(fields) { if (fields.length === 0) { return 0; } const { rowCount } = await this.run('remove_queue_meta_fields', [ this.queueName, fields, ]); return rowCount !== null && rowCount !== void 0 ? rowCount : 0; } async hasQueueMetaField(field) { const { rows } = await this.run('has_queue_meta_field', [this.queueName, field]); return rows[0].exists; } async setRateLimit(expireTimeMs) { // Force the limiter window (mirrors Redis SET limiter=MAX PX expireTimeMs). await this.run('set_rate_limit', [ this.queueName, expireTimeMs, Date.now(), ]); } async removeRateLimitKey() { const { rows } = await this.run('remove_rate_limit', [ this.queueName, ]); return rows[0].n; } removeDeprecatedPriorityKey() { return notImplemented('removeDeprecatedPriorityKey'); } trimEvents(_maxLength) { return notImplemented('trimEvents'); } // ============================================================ // Event stream // ============================================================ async publishEvent(fields, _maxEvents) { const { event } = fields, rest = __rest(fields, ["event"]); const { rows } = await this.run('publish_event', [ this.queueName, String(event), JSON.stringify(rest), ]); return String(rows[0].id); } async readEvents(id, blockTimeout) { if (this.closing || this.connection.isClosing) { return null; } // Resolve the cursor: '$' means "only events from now on". let cursor; if (id === '$') { const { rows } = await this.run('read_events_max', [ this.queueName, ]); cursor = rows[0].max; } else { cursor = id; } let events = await this.fetchEvents(cursor); if (events.length === 0) { await this.waitForEvent(blockTimeout); if (this.closing || this.connection.isClosing) { return null; } events = await this.fetchEvents(cursor); } if (events.length === 0) { return null; } // Redis XREAD shape: [[streamKey, [[id, [k1,v1,...]], ...]]]. return [ ['events', events.map(e => [e.id, e.fields])], ]; } async fetchEvents(cursor) { const { rows } = await this.run('read_events', [this.queueName, cursor, EVENT_READ_BATCH]); return rows.map(r => { var _a; const fields = ['event', r.event]; for (const [k, v] of Object.entries((_a = r.data) !== null && _a !== void 0 ? _a : {})) { fields.push(k, typeof v === 'string' ? v : String(v)); } return { id: String(r.id), fields }; }); } /** Subscribes the dedicated client to the shared jobs channel (once). */ async ensureListening() { const client = await this.connection.getListenClient(); if (!this.listening) { await client.query(loadCommandSql('listen_jobs')); this.listening = true; } return client; } /** Subscribes the dedicated client to the shared events channel (once). */ async ensureListeningEvents() { const client = await this.connection.getListenClient(); if (!this.listeningEvents) { await client.query(loadCommandSql('listen_events')); this.listeningEvents = true; } return client; } /** * Blocks (up to `blockTimeout` ms) until a new event is published for this * queue (via `LISTEN`/`NOTIFY` on the events channel), or the timeout * elapses. Used by {@link readEvents} between polls. */ async waitForEvent(blockTimeout) { if (this.closing || this.connection.isClosing) { return; } const client = await this.ensureListeningEvents(); return new Promise(resolve => { let settled = false; const finish = () => { if (settled) { return; } settled = true; clearTimeout(timer); client.removeListener('notification', onNotify); this.cancelEventWait = undefined; resolve(); }; const onNotify = (msg) => { if (msg.channel === PostgresQueueBackend.EVENTS_CHANNEL && msg.payload === this.queueName) { finish(); } }; const timer = setTimeout(finish, Math.max(blockTimeout || 5000, 1)); this.cancelEventWait = finish; client.on('notification', onNotify); }); } /** * Blocks (up to `blockTimeout` seconds) until a job for this queue may be * available, via `LISTEN`/`NOTIFY`. Producers notify the shared `bullmq_jobs` * channel with the queue name as payload (in `add_job`), so a producer * in any process wakes a blocked worker immediately. Returns a marker * (`score` 0 = "check now") or `null` on timeout. The Redis backend * implements this with `BZPOPMIN`. */ async waitForJob(blockTimeout) { if (this.closing || this.blockingDisconnected) { return null; } const client = await this.ensureListening(); // The blocking connection may have been torn down while we were awaiting // `ensureListening`; bail rather than registering a wait that only a // (possibly faked) timer could end. if (this.closing || this.blockingDisconnected) { return null; } return new Promise(resolve => { let settled = false; const finish = (value) => { if (settled) { return; } settled = true; clearTimeout(timer); client.removeListener('notification', onNotify); this.cancelWait = undefined; resolve(value); }; const onNotify = (msg) => { var _a; // Filter for this queue (the shared channel carries every queue). if (msg.channel === PostgresQueueBackend.NOTIFY_CHANNEL && msg.payload === this.queueName) { finish({ member: (_a = msg.payload) !== null && _a !== void 0 ? _a : '', score: 0 }); } }; let timer = setTimeout(() => finish(null), Math.max(blockTimeout, 0) * 1000); this.cancelWait = () => finish(null); client.on('notification', onNotify); // Final race guard: if `disconnectBlocking` ran between the check above // and installing `cancelWait`, end the wait now instead of blocking on a // timer (which never fires under faked timers). if (this.blockingDisconnected) { finish(null); return; } // Close the race (and survive a NOTIFY that fired before we subscribed): // with the listener already registered above, check whether a claimable // job is already waiting and, if so, wake immediately. This mirrors the // check-and-block atomicity of Redis's blocking pop and avoids relying on // a (possibly faked) timeout to recover a missed notification. this.run('has_waiting_job', [this.queueName]) .then(({ rows }) => { var _a; if ((_a = rows[0]) === null || _a === void 0 ? void 0 : _a.present) { finish({ member: this.queueName, score: 0 }); } }) .catch(() => { // Ignore: a failed probe just falls back to the notify/timeout wait. }); // Shorten the wait to the next due delayed job: a delayed job's promotion // is not announced by a NOTIFY at its due time, so without this the worker // would sleep the full `blockTimeout` (drainDelay) before re-checking. this.run('next_delay', [this.queueName]) .then(({ rows }) => { var _a, _b; const next = bigintOrUndefined((_b = (_a = rows[0]) === null || _a === void 0 ? void 0 : _a.next_delay) !== null && _b !== void 0 ? _b : null); if (next === undefined || settled) { return; } const dueIn = next - Date.now(); if (dueIn <= 0) { // The delayed job is already due (its due time passed while this // probe was in flight — e.g. the clock advanced meanwhile). Wake // now instead of arming a 0ms timer: under faked timers a 0ms // timeout never fires unless the clock is advanced again, which // would only happen once a job is processed — a deadlock. finish(null); } else if (dueIn < Math.max(blockTimeout, 0) * 1000) { clearTimeout(timer); timer = setTimeout(() => finish(null), dueIn); } }) .catch(() => { // Ignore: fall back to the notify/timeout wait. }); }); } async disconnectBlocking(_wait = true) { var _a; this.blockingDisconnected = true; (_a = this.cancelWait) === null || _a === void 0 ? void 0 : _a.call(this); } async reconnectBlocking() { // Allow the blocking wait to run again and force a fresh LISTEN on the next // waitForJob (e.g. after a reconnect). this.blockingDisconnected = false; this.listening = false; } } // ============================================================ // Worker blocking primitive // ============================================================ /** The shared notify channel all producers post to (see `add_job`). */ PostgresQueueBackend.NOTIFY_CHANNEL = 'bullmq_jobs'; /** The shared event-stream channel (see `publish_event`). */ PostgresQueueBackend.EVENTS_CHANNEL = 'bullmq_events';