UNPKG

pg-boss

Version:

Queueing jobs in Postgres from Node.js like a boss

186 lines (185 loc) 6.7 kB
import { CronExpressionParser } from 'cron-parser'; import EventEmitter from 'node:events'; import * as Attorney from "./attorney.js"; import * as plans from "./plans.js"; import { delay } from "./tools.js"; import * as types from "./types.js"; import { emitAndPersistWarning } from "./warning.js"; export const QUEUES = { SEND_IT: '__pgboss__send-it' }; const EVENTS = { error: 'error', schedule: 'schedule', warning: 'warning' }; const WARNINGS = { CLOCK_SKEW: { message: 'Warning: Clock skew between this instance and the database server. This will not break scheduling, but is emitted any time the skew exceeds 60 seconds.' } }; const WARNING_TYPES = { CLOCK_SKEW: 'clock_skew' }; class Timekeeper extends EventEmitter { db; config; manager; stopped = true; cronMonitorInterval; skewMonitorInterval; timekeeping; _checkingSkew = false; clockSkew = 0; events = EVENTS; constructor(db, manager, config) { super(); this.db = db; this.config = config; this.manager = manager; } get checkingSkew() { return this._checkingSkew; } get warningContext() { return { emitter: this, db: this.db, schema: this.config.schema, persistWarnings: this.config.persistWarnings, warningEvent: this.events.warning, errorEvent: this.events.error }; } async start() { this.stopped = false; await this.cacheClockSkew(); await this.manager.createQueue(QUEUES.SEND_IT); const options = { pollingIntervalSeconds: this.config.cronWorkerIntervalSeconds, batchSize: 50 }; await this.manager.work(QUEUES.SEND_IT, options, (jobs) => this.onSendIt(jobs)); setImmediate(() => this.onCron()); this.cronMonitorInterval = setInterval(async () => await this.onCron(), this.config.cronMonitorIntervalSeconds * 1000); this.skewMonitorInterval = setInterval(async () => await this.cacheClockSkew(), this.config.clockMonitorIntervalSeconds * 1000); } async stop() { if (this.stopped) { return; } this.stopped = true; await this.manager.offWork(QUEUES.SEND_IT, { wait: true }); if (this.skewMonitorInterval) { clearInterval(this.skewMonitorInterval); this.skewMonitorInterval = null; } if (this.cronMonitorInterval) { clearInterval(this.cronMonitorInterval); this.cronMonitorInterval = null; } while (this.timekeeping || this._checkingSkew) { await delay(10); } } async cacheClockSkew() { let skew = 0; this._checkingSkew = true; try { if (this.config.__test__force_clock_monitoring_error) { throw new Error(this.config.__test__force_clock_monitoring_error); } if (this.config.__test__delay_clock_skew_ms) { await delay(this.config.__test__delay_clock_skew_ms); } const { rows } = await this.db.executeSql(plans.getTime()); const local = Date.now(); const dbTime = parseFloat(rows[0].time); skew = dbTime - local; const skewSeconds = Math.abs(skew) / 1000; if (skewSeconds >= 60 || this.config.__test__force_clock_skew_warning) { await emitAndPersistWarning(this.warningContext, WARNING_TYPES.CLOCK_SKEW, WARNINGS.CLOCK_SKEW.message, { seconds: skewSeconds, direction: skew > 0 ? 'slower' : 'faster' }); } } catch (err) { this.emit(this.events.error, err); } finally { this.clockSkew = skew; this._checkingSkew = false; } } async onCron() { try { if (this.stopped || this.timekeeping) return; if (this.config.__test__force_cron_monitoring_error) { throw new Error(this.config.__test__force_cron_monitoring_error); } this.timekeeping = true; const sql = plans.trySetCronTime(this.config.schema, this.config.cronMonitorIntervalSeconds); if (!this.stopped) { const { rows } = await this.db.executeSql(sql); if (!this.stopped && rows.length === 1) { await this.cron(); } } } catch (err) { this.emit(this.events.error, err); } finally { this.timekeeping = false; } } async cron() { const schedules = await this.getSchedules(); const scheduled = schedules .filter(i => this.shouldSendIt(i.cron, i.timezone)) .map(({ name, key, data, options }) => ({ data: { name, data, options }, singletonKey: `${name}__${key}`, singletonSeconds: 60 })); if (scheduled.length > 0 && !this.stopped) { await this.manager.insert(QUEUES.SEND_IT, scheduled); } } shouldSendIt(cron, tz) { const interval = CronExpressionParser.parse(cron, { tz, strict: false }); const prevTime = interval.prev(); const databaseTime = Date.now() + this.clockSkew; const prevDiff = (databaseTime - prevTime.getTime()) / 1000; return prevDiff < 60; } async onSendIt(jobs) { await Promise.allSettled(jobs.map(({ data }) => this.manager.send(data))); } async getSchedules(name, key = '') { let sql = plans.getSchedules(this.config.schema); let params = []; if (name) { sql = plans.getSchedulesByQueue(this.config.schema); params = [name, key]; } const { rows } = await this.db.executeSql(sql, params); return rows; } async schedule(name, cron, data, options = {}) { const { tz = 'UTC', key = '', ...rest } = options; CronExpressionParser.parse(cron, { tz, strict: false }); Attorney.checkSendArgs([name, data, { ...rest }]); Attorney.assertKey(key); try { const sql = plans.schedule(this.config.schema); await this.db.executeSql(sql, [name, key, cron, tz, data, options]); } catch (err) { if (err.message.includes('foreign key')) { err.message = `Queue ${name} not found`; } throw err; } } async unschedule(name, key = '') { const sql = plans.unschedule(this.config.schema); await this.db.executeSql(sql, [name, key]); } } export default Timekeeper;