UNPKG

pg-boss

Version:

Queueing jobs in Postgres from Node.js like a boss

805 lines (804 loc) 32.8 kB
import assert, { notStrictEqual } from 'node:assert'; import { randomUUID } from 'node:crypto'; import EventEmitter from 'node:events'; import { serializeError as stringify } from 'serialize-error'; import * as Attorney from "./attorney.js"; import * as plans from "./plans.js"; import * as timekeeper from "./timekeeper.js"; import { resolveWithinSeconds } from "./tools.js"; import * as types from "./types.js"; import Worker from "./worker.js"; import { JobSpy } from "./spy.js"; const INTERNAL_QUEUES = Object.values(timekeeper.QUEUES).reduce((acc, i) => ({ ...acc, [i]: i }), {}); const events = { error: 'error', wip: 'wip' }; class Manager extends EventEmitter { events = events; db; config; wipTs; workers; stopped; queueCacheInterval; timekeeper; queues; pendingOffWorkCleanups; #spies; #localGroupActive; #localGroupConfig; #localGroupMaxLimit; constructor(db, config) { super(); this.config = config; this.db = db; this.wipTs = Date.now(); this.workers = new Map(); this.queues = {}; this.pendingOffWorkCleanups = new Set(); this.#spies = new Map(); this.#localGroupActive = new Map(); this.#localGroupConfig = new Map(); this.#localGroupMaxLimit = new Map(); } getSpy(name) { if (!this.config.__test__enableSpies) { throw new Error('Spy is not enabled. Set __test__enableSpies: true in constructor options to use spies.'); } let spy = this.#spies.get(name); if (!spy) { spy = new JobSpy(); this.#spies.set(name, spy); } return spy; } clearSpies() { for (const spy of this.#spies.values()) { spy.clear(); } this.#spies.clear(); } #getLocalGroupLimit(queueName, groupTier) { const config = this.#localGroupConfig.get(queueName); if (!config) return Infinity; if (groupTier && config.tiers && groupTier in config.tiers) { return config.tiers[groupTier]; } return config.default; } #getGroupsAtLocalCapacity(queueName) { const config = this.#localGroupConfig.get(queueName); if (!config) return []; const queueGroups = this.#localGroupActive.get(queueName); if (!queueGroups) return []; // Only exclude a group from fetching when it has no remaining capacity for // any tier. Using config.default alone would exclude groups that still have // room for higher tier jobs. Those jobs never reach the per tier check in // #trackLocalGroupStart because ignoreGroups filters them out of the fetch // query before that point. maxLimit is precomputed once at setup time so // Object.values is not called on every fetch cycle. const maxLimit = this.#localGroupMaxLimit.get(queueName) ?? config.default; const atCapacity = []; for (const [groupId, activeCount] of queueGroups.entries()) { if (activeCount >= maxLimit) { atCapacity.push(groupId); } } return atCapacity; } #incrementLocalGroupCount(queueName, groupId) { let queueGroups = this.#localGroupActive.get(queueName); if (!queueGroups) { queueGroups = new Map(); this.#localGroupActive.set(queueName, queueGroups); } const current = queueGroups.get(groupId) || 0; queueGroups.set(groupId, current + 1); } #decrementLocalGroupCount(queueName, groupId) { const queueGroups = this.#localGroupActive.get(queueName); if (!queueGroups) return; const current = queueGroups.get(groupId) || 0; if (current <= 1) { queueGroups.delete(groupId); } else { queueGroups.set(groupId, current - 1); } } #trackJobsActive(name, jobs) { const spy = this.config.__test__enableSpies ? this.#spies.get(name) : undefined; if (spy) { for (const job of jobs) { spy.addJob(job.id, name, job.data, 'active'); } } } #trackJobsCompleted(name, jobs, result) { const spy = this.config.__test__enableSpies ? this.#spies.get(name) : undefined; if (spy) { const output = jobs.length === 1 ? result : undefined; for (const job of jobs) { spy.addJob(job.id, name, job.data, 'completed', output); } } } #trackJobsFailed(name, jobs, err) { const spy = this.config.__test__enableSpies ? this.#spies.get(name) : undefined; if (spy) { for (const job of jobs) { spy.addJob(job.id, name, job.data, 'failed', { message: err?.message, stack: err?.stack }); } } } #storeLocalGroupConfig(name, localGroupConcurrency) { const config = typeof localGroupConcurrency === 'number' ? { default: localGroupConcurrency } : localGroupConcurrency; this.#localGroupConfig.set(name, config); this.#localGroupMaxLimit.set(name, config.tiers ? Math.max(config.default, ...Object.values(config.tiers)) : config.default); } #cleanupLocalGroupTracking(name) { // Only cleanup if no more workers exist for this queue const hasWorkersForQueue = this.getWorkers().some(w => w.name === name && !w.stopping && !w.stopped); if (!hasWorkersForQueue) { this.#localGroupConfig.delete(name); this.#localGroupActive.delete(name); this.#localGroupMaxLimit.delete(name); } } #trackLocalGroupStart(name, jobs) { const allowed = []; const excess = []; const groupedJobs = []; for (const job of jobs) { if (!job.groupId) { // Jobs without group bypass local group limits allowed.push(job); continue; } const currentCount = this.#localGroupActive.get(name)?.get(job.groupId) || 0; const limit = this.#getLocalGroupLimit(name, job.groupTier); if (currentCount < limit) { this.#incrementLocalGroupCount(name, job.groupId); allowed.push(job); groupedJobs.push(job); } else { excess.push(job); } } return { allowed, excess, groupedJobs }; } #trackLocalGroupEnd(name, groupedJobs) { for (const job of groupedJobs) { if (job.groupId) { this.#decrementLocalGroupCount(name, job.groupId); } } } async #processJobs(name, jobs, callback, worker, heartbeatRefreshSeconds) { const jobIds = jobs.map(job => job.id); const maxExpiration = jobs.reduce((acc, i) => Math.max(acc, i.expireInSeconds), 0); const heartbeatSeconds = jobs.reduce((acc, j) => Math.max(acc, j.heartbeatSeconds || 0), 0); const ac = new AbortController(); jobs.forEach(job => { job.signal = ac.signal; }); // Store AbortController on worker so it can be aborted after graceful shutdown if (worker) { worker.abortController = ac; } let heartbeatTimer = null; if (heartbeatSeconds > 0) { const refreshSeconds = heartbeatRefreshSeconds ?? (heartbeatSeconds / 2); const intervalMs = refreshSeconds * 1000; heartbeatTimer = setInterval(async () => { try { await this.touch(name, jobIds); } catch (err) { this.emit(events.error, err); } }, intervalMs); } try { const result = await resolveWithinSeconds(callback(jobs), maxExpiration, `handler execution exceeded ${maxExpiration}s`, ac); await this.complete(name, jobIds, jobIds.length === 1 ? result : undefined); this.#trackJobsCompleted(name, jobs, result); } catch (err) { await this.fail(name, jobIds, err); this.#trackJobsFailed(name, jobs, err); } finally { if (heartbeatTimer) clearInterval(heartbeatTimer); if (worker) { // Clear between jobs worker.abortController = null; } } } async start() { this.stopped = false; this.queueCacheInterval = setInterval(() => this.onCacheQueues({ emit: true }), this.config.queueCacheIntervalSeconds * 1000); await this.onCacheQueues(); } async onCacheQueues({ emit = false } = {}) { try { assert(!this.config.__test__throw_queueCache, 'test error'); const queues = await this.getQueues(); this.queues = queues.reduce((acc, i) => { acc[i.name] = i; return acc; }, {}); } catch (error) { emit && this.emit(events.error, { ...error, message: error.message, stack: error.stack }); } } async getQueueCache(name) { assert(this.queues, 'Queue cache is not initialized'); let queue = this.queues[name]; if (queue) { return queue; } queue = await this.getQueue(name); if (!queue) { throw new Error(`Queue ${name} does not exist`); } this.queues[name] = queue; return queue; } async stop() { this.stopped = true; clearInterval(this.queueCacheInterval); await Promise.allSettled([...this.workers.values()] .filter(worker => !INTERNAL_QUEUES[worker.name]) .map(async (worker) => await this.offWork(worker.name, { wait: false }))); // Clean up all local group tracking on full stop this.#localGroupConfig.clear(); this.#localGroupActive.clear(); this.#localGroupMaxLimit.clear(); } async failWip() { for (const worker of this.workers.values()) { const jobIds = worker.jobs.map(j => j.id); if (jobIds.length) { await this.fail(worker.name, jobIds, 'pg-boss shut down while active'); } worker.abort(); } } async work(name, ...args) { const { options, callback } = Attorney.checkWorkArgs(name, args); if (this.stopped) { throw new Error('Workers are disabled. pg-boss is stopped'); } const { pollingInterval: interval, batchSize = 1, includeMetadata = false, priority = true, localConcurrency = 1, localGroupConcurrency, groupConcurrency, orderByCreatedOn = true, heartbeatRefreshSeconds, minPriority, maxPriority, } = options; if (localGroupConcurrency != null) { this.#storeLocalGroupConfig(name, localGroupConcurrency); } const firstWorkerId = randomUUID({ disableEntropyCache: true }); const createWorker = (workerId, workId) => { const fetch = () => { const ignoreGroups = localGroupConcurrency != null ? this.#getGroupsAtLocalCapacity(name) : undefined; return this.fetch(name, { batchSize, includeMetadata, priority, orderByCreatedOn, groupConcurrency, ignoreGroups, minPriority, maxPriority }); }; const onFetch = async (jobs) => { if (!jobs.length) return; if (this.config.__test__throw_worker) throw new Error('__test__throw_worker'); this.emitWip(name); this.#trackJobsActive(name, jobs); // Get the worker instance for abort controller tracking const worker = this.workers.get(workerId); // Skip all in-memory group tracking when localGroupConcurrency is not enabled if (localGroupConcurrency == null) { await this.#processJobs(name, jobs, callback, worker, heartbeatRefreshSeconds); } else { const { allowed, excess, groupedJobs } = this.#trackLocalGroupStart(name, jobs); if (excess.length > 0) { const excessIds = excess.map(job => job.id); await this.restore(name, excessIds); } if (allowed.length > 0) { try { await this.#processJobs(name, allowed, callback, worker, heartbeatRefreshSeconds); } finally { this.#trackLocalGroupEnd(name, groupedJobs); } } } this.emitWip(name); }; const onError = (error) => { this.emit(events.error, { ...error, message: error.message, stack: error.stack, queue: name, worker: workerId }); }; return new Worker({ id: workerId, workId, name, options, interval, fetch, onFetch, onError }); }; // Spawn workers based on localConcurrency setting for (let i = 0; i < localConcurrency; i++) { const workerId = i === 0 ? firstWorkerId : randomUUID({ disableEntropyCache: true }); const worker = createWorker(workerId, firstWorkerId); this.addWorker(worker); worker.start(); } return firstWorkerId; } addWorker(worker) { this.workers.set(worker.id, worker); } removeWorker(worker) { this.workers.delete(worker.id); } getWorkers() { return Array.from(this.workers.values()); } emitWip(name) { if (!INTERNAL_QUEUES[name]) { const now = Date.now(); if (now - this.wipTs > 2000) { this.emit(events.wip, this.getWipData()); this.wipTs = now; } } } getWipData(options = {}) { const { includeInternal = false } = options; const data = this.getWorkers() .map(i => i.toWipData()) .filter(i => i.state !== 'stopped' && (!INTERNAL_QUEUES[i.name] || includeInternal)); return data; } hasPendingCleanups() { return this.pendingOffWorkCleanups.size > 0; } async offWork(name, options = { wait: true }) { assert(name, 'queue name is required'); assert(typeof name === 'string', 'queue name must be a string'); const query = (i) => options?.id ? i.id === options.id : i.name === name; const workers = this.getWorkers().filter(i => query(i) && !i.stopping && !i.stopped); if (workers.length === 0) { return; } const cleanupPromise = Promise.allSettled(workers.map(async (worker) => { await worker.stop(); this.removeWorker(worker); })); if (options.wait) { await cleanupPromise; this.#cleanupLocalGroupTracking(name); } else { this.pendingOffWorkCleanups.add(cleanupPromise); cleanupPromise.finally(() => { this.pendingOffWorkCleanups.delete(cleanupPromise); this.#cleanupLocalGroupTracking(name); }); } } notifyWorker(workerId) { this.workers.get(workerId)?.notify(); } async subscribe(event, name) { assert(event, 'Missing required argument'); assert(name, 'Missing required argument'); const sql = plans.subscribe(this.config.schema); await this.db.executeSql(sql, [event, name]); } async unsubscribe(event, name) { assert(event, 'Missing required argument'); assert(name, 'Missing required argument'); const sql = plans.unsubscribe(this.config.schema); await this.db.executeSql(sql, [event, name]); } async publish(event, data, options) { assert(event, 'Missing required argument'); const sql = plans.getQueuesForEvent(this.config.schema); const { rows } = await this.db.executeSql(sql, [event]); await Promise.allSettled(rows.map(({ name }) => this.send(name, data, options))); } async send(...args) { const result = Attorney.checkSendArgs(args); return await this.createJob(result); } async sendAfter(name, data, options, after) { options = options ? { ...options } : {}; options.startAfter = after; const result = Attorney.checkSendArgs([name, data, options]); return await this.createJob(result); } async sendThrottled(name, data, options, seconds, key) { options = options ? { ...options } : {}; options.singletonSeconds = seconds; options.singletonNextSlot = false; options.singletonKey = key; const result = Attorney.checkSendArgs([name, data, options]); return await this.createJob(result); } async sendDebounced(name, data, options, seconds, key) { options = options ? { ...options } : {}; options.singletonSeconds = seconds; options.singletonNextSlot = true; options.singletonKey = key; const result = Attorney.checkSendArgs([name, data, options]); return await this.createJob(result); } async createJob(request) { const { name, data = null, options = {} } = request; const { id = null, db: wrapper, priority, startAfter, singletonKey = null, singletonSeconds, singletonNextSlot, expireInSeconds, deleteAfterSeconds, retentionSeconds, keepUntil, retryLimit, retryDelay, retryBackoff, retryDelayMax, heartbeatSeconds, group, deadLetter = null } = options; const job = { id, name, data, priority, startAfter, singletonKey, singletonSeconds, singletonOffset: 0, groupId: group?.id ?? null, groupTier: group?.tier ?? null, expireInSeconds, deleteAfterSeconds, retentionSeconds, keepUntil, retryLimit, retryDelay, retryBackoff, retryDelayMax, heartbeatSeconds, deadLetter }; const db = wrapper || this.db; const { table, policy } = await this.getQueueCache(name); if (policy === plans.QUEUE_POLICIES.key_strict_fifo && !singletonKey) { throw new Error(`${plans.QUEUE_POLICIES.key_strict_fifo} queues require a singletonKey`); } const sql = plans.insertJobs(this.config.schema, { table, name, returnId: true }); const { rows: try1 } = await db.executeSql(sql, [JSON.stringify([job])]); if (try1.length === 1) { const jobId = try1[0].id; if (this.config.__test__enableSpies) { const spy = this.#spies.get(name); if (spy) { spy.addJob(jobId, name, data || {}, 'created'); } } return jobId; } if (singletonNextSlot) { // delay starting by the offset to honor throttling config job.startAfter = this.getDebounceStartAfter(singletonSeconds, this.timekeeper.clockSkew); job.singletonOffset = singletonSeconds; const { rows: try2 } = await db.executeSql(sql, [JSON.stringify([job])]); if (try2.length === 1) { const jobId = try2[0].id; if (this.config.__test__enableSpies) { const spy = this.#spies.get(name); if (spy) { spy.addJob(jobId, name, data || {}, 'created'); } } return jobId; } } return null; } async insert(name, jobs, options = {}) { assert(Array.isArray(jobs), 'jobs argument should be an array'); const { table, policy } = await this.getQueueCache(name); if (policy === plans.QUEUE_POLICIES.key_strict_fifo) { for (const job of jobs) { if (!job.singletonKey) { throw new Error(`${plans.QUEUE_POLICIES.key_strict_fifo} queues require a singletonKey`); } } } const db = this.assertDb(options); const spy = this.config.__test__enableSpies ? this.#spies.get(name) : undefined; // Return IDs if spy is active for this queue (needed for job tracking) const returnId = !!spy || !!options.returnId; const sql = plans.insertJobs(this.config.schema, { table, name, returnId }); const { rows } = await db.executeSql(sql, [JSON.stringify(jobs)]); if (rows.length) { if (spy) { for (let i = 0; i < rows.length; i++) { spy.addJob(rows[i].id, name, jobs[i].data || {}, 'created'); } } return rows.map((i) => i.id); } return null; } getDebounceStartAfter(singletonSeconds, clockOffset) { const debounceInterval = singletonSeconds * 1000; const now = Date.now() + clockOffset; const slot = Math.floor(now / debounceInterval) * debounceInterval; // prevent startAfter=0 during debouncing let startAfter = (singletonSeconds - Math.floor((now - slot) / 1000)) || 1; if (singletonSeconds > 1) { startAfter++; } return startAfter; } async fetch(name, options = {}) { Attorney.checkFetchArgs(name, options); const db = this.assertDb(options); const { table, policy, singletonsActive } = await this.getQueueCache(name); const fetchOptions = { ...options, schema: this.config.schema, table, name, policy, limit: options.batchSize || 1, ignoreSingletons: singletonsActive }; const query = plans.fetchNextJob(fetchOptions); let result; try { result = await db.executeSql(query.text, query.values); } catch (err) { // errors from fetchquery should only be unique constraint violations } return result?.rows || []; } mapCompletionIdArg(id, funcName) { const errorMessage = `${funcName}() requires an id`; assert(id, errorMessage); const ids = Array.isArray(id) ? id : [id]; assert(ids.length, errorMessage); return ids; } mapCompletionDataArg(data) { if (data === null || typeof data === 'undefined' || typeof data === 'function') { return null; } const result = (typeof data === 'object' && !Array.isArray(data)) ? data : { value: data }; return stringify(result); } mapCommandResponse(ids, result) { return { jobs: ids, requested: ids.length, affected: result && result.rows ? parseInt(result.rows[0].count) : 0 }; } async complete(name, id, data, options = {}) { Attorney.assertQueueName(name); const db = this.assertDb(options); const ids = this.mapCompletionIdArg(id, 'complete'); const { table } = await this.getQueueCache(name); const sql = plans.completeJobs(this.config.schema, table, options.includeQueued); const result = await db.executeSql(sql, [name, ids, this.mapCompletionDataArg(data)]); return this.mapCommandResponse(ids, result); } async fail(name, id, data, options = {}) { Attorney.assertQueueName(name); const db = this.assertDb(options); const ids = this.mapCompletionIdArg(id, 'fail'); const { table } = await this.getQueueCache(name); const sql = plans.failJobsById(this.config.schema, table); const result = await db.executeSql(sql, [name, ids, this.mapCompletionDataArg(data)]); return this.mapCommandResponse(ids, result); } async deleteJob(name, id, options = {}) { Attorney.assertQueueName(name); const db = this.assertDb(options); const ids = this.mapCompletionIdArg(id, 'deleteJob'); const { table } = await this.getQueueCache(name); const sql = plans.deleteJobsById(this.config.schema, table); const result = await db.executeSql(sql, [name, ids]); return this.mapCommandResponse(ids, result); } async cancel(name, id, options = {}) { Attorney.assertQueueName(name); const db = this.assertDb(options); const ids = this.mapCompletionIdArg(id, 'cancel'); const { table } = await this.getQueueCache(name); const sql = plans.cancelJobs(this.config.schema, table); const result = await db.executeSql(sql, [name, ids]); return this.mapCommandResponse(ids, result); } async resume(name, id, options = {}) { Attorney.assertQueueName(name); const db = this.assertDb(options); const ids = this.mapCompletionIdArg(id, 'resume'); const { table } = await this.getQueueCache(name); const sql = plans.resumeJobs(this.config.schema, table); const result = await db.executeSql(sql, [name, ids]); return this.mapCommandResponse(ids, result); } async restore(name, id, options = {}) { Attorney.assertQueueName(name); const db = this.assertDb(options); const ids = this.mapCompletionIdArg(id, 'restore'); const { table } = await this.getQueueCache(name); const sql = plans.restoreJobs(this.config.schema, table); await db.executeSql(sql, [name, ids]); } async retry(name, id, options = {}) { Attorney.assertQueueName(name); const db = options.db || this.db; const ids = this.mapCompletionIdArg(id, 'retry'); const { table } = await this.getQueueCache(name); const sql = plans.retryJobs(this.config.schema, table); const result = await db.executeSql(sql, [name, ids]); return this.mapCommandResponse(ids, result); } async touch(name, id, options = {}) { Attorney.assertQueueName(name); const db = this.assertDb(options); const ids = this.mapCompletionIdArg(id, 'touch'); const { table } = await this.getQueueCache(name); const sql = plans.touchJobs(this.config.schema, table); const result = await db.executeSql(sql, [name, ids]); return this.mapCommandResponse(ids, result); } async createQueue(name, options = {}) { name = name || options.name; Attorney.assertQueueName(name); const policy = options.policy || plans.QUEUE_POLICIES.standard; assert(policy in plans.QUEUE_POLICIES, `${policy} is not a valid queue policy`); Attorney.validateQueueArgs(options); if (options.deadLetter) { Attorney.assertQueueName(options.deadLetter); notStrictEqual(name, options.deadLetter, 'deadLetter cannot be itself'); await this.getQueueCache(options.deadLetter); } const sql = plans.createQueue(this.config.schema, name, { ...options, policy }); await this.db.executeSql(sql); } async getBlockedKeys(name) { Attorney.assertQueueName(name); const { table, policy } = await this.getQueueCache(name); if (policy !== plans.QUEUE_POLICIES.key_strict_fifo) { throw new Error(`getBlockedKeys is only available for ${plans.QUEUE_POLICIES.key_strict_fifo} queues`); } const sql = plans.getBlockedKeys(this.config.schema, table); const { rows } = await this.db.executeSql(sql, [name]); return rows.map(row => row.singletonKey); } async getQueues(names) { names = Array.isArray(names) ? names : typeof names === 'string' ? [names] : undefined; if (names) { for (const name of names) { Attorney.assertQueueName(name); } } const query = plans.getQueues(this.config.schema, names); const { rows } = await this.db.executeSql(query.text, query.values); return rows; } async updateQueue(name, options = {}) { Attorney.assertQueueName(name); assert(Object.keys(options).length > 0, 'no properties found to update'); if ('policy' in options) { throw new Error('queue policy cannot be changed after creation'); } if ('partition' in options) { throw new Error('queue partitioning cannot be changed after creation'); } Attorney.validateQueueArgs(options); const { deadLetter } = options; if (deadLetter) { Attorney.assertQueueName(deadLetter); notStrictEqual(name, deadLetter, 'deadLetter cannot be itself'); } const sql = plans.updateQueue(this.config.schema, { deadLetter }); await this.db.executeSql(sql, [name, options]); } async getQueue(name) { Attorney.assertQueueName(name); const query = plans.getQueues(this.config.schema, [name]); const { rows } = await this.db.executeSql(query.text, query.values); return rows[0] || null; } async deleteQueue(name) { Attorney.assertQueueName(name); try { await this.getQueueCache(name); const sql = plans.deleteQueue(this.config.schema, name); await this.db.executeSql(sql); } catch { } } async deleteQueuedJobs(name) { Attorney.assertQueueName(name); const { table } = await this.getQueueCache(name); const sql = plans.deleteQueuedJobs(this.config.schema, table); await this.db.executeSql(sql, [name]); } async deleteStoredJobs(name) { Attorney.assertQueueName(name); const { table } = await this.getQueueCache(name); const sql = plans.deleteStoredJobs(this.config.schema, table); await this.db.executeSql(sql, [name]); } async deleteAllJobs(name) { if (!name) { const sql = plans.truncateTable(this.config.schema, 'job'); await this.db.executeSql(sql); return; } Attorney.assertQueueName(name); const { table, partition } = await this.getQueueCache(name); if (partition) { const sql = plans.truncateTable(this.config.schema, table); await this.db.executeSql(sql); } else { const sql = plans.deleteAllJobs(this.config.schema, table); await this.db.executeSql(sql, [name]); } } async getQueueStats(name) { Attorney.assertQueueName(name); const queue = await this.getQueueCache(name); const query = plans.getQueueStats(this.config.schema, queue.table, [name]); const { rows } = await this.db.executeSql(query.text, query.values); return Object.assign(queue, rows.at(0) || { deferredCount: 0, queuedCount: 0, activeCount: 0, totalCount: 0 }); } async getJobById(name, id, options = {}) { Attorney.assertQueueName(name); const db = this.assertDb(options); const { table } = await this.getQueueCache(name); const sql = plans.getJobById(this.config.schema, table); const result1 = await db.executeSql(sql, [name, id]); if (result1?.rows?.length === 1) { return result1.rows[0]; } else { return null; } } async findJobs(name, options = {}) { Attorney.assertQueueName(name); const db = this.assertDb(options); const { table } = await this.getQueueCache(name); const { id, key, data, queued = false } = options; const sql = plans.findJobs(this.config.schema, table, { byId: id !== undefined, byKey: key !== undefined, byData: data !== undefined, queued }); const values = [name]; if (id !== undefined) values.push(id); if (key !== undefined) values.push(key); if (data !== undefined) values.push(JSON.stringify(data)); const result = await db.executeSql(sql, values); return result?.rows || []; } assertDb(options) { if (options.db) { return options.db; } if (this.db._pgbdb) { assert(this.db.opened, 'Database connection is not opened'); } return this.db; } } export default Manager;