pg-boss
Version:
Queueing jobs in Postgres from Node.js like a boss
188 lines (187 loc) • 7 kB
JavaScript
import EventEmitter from 'node:events';
import * as plans from "./plans.js";
import { delay, unwrapSQLResult } from "./tools.js";
import * as types from "./types.js";
import { emitAndPersistWarning } from "./warning.js";
const events = {
error: 'error',
warning: 'warning'
};
const WARNINGS = {
SLOW_QUERY: { seconds: 30, message: 'Warning: slow query. Your queues and/or database server should be reviewed' },
LARGE_QUEUE: { size: 10_000, message: 'Warning: large queue backlog. Your queue should be reviewed' }
};
const WARNING_TYPES = {
SLOW_QUERY: 'slow_query',
QUEUE_BACKLOG: 'queue_backlog'
};
class Boss extends EventEmitter {
#stopped;
#stopping;
#maintaining;
#superviseInterval;
#db;
#config;
#manager;
events = events;
constructor(db, manager, config) {
super();
this.#db = db;
this.#config = config;
this.#manager = manager;
this.#stopped = true;
this.#stopping = false;
if (config.warningSlowQuerySeconds) {
WARNINGS.SLOW_QUERY.seconds = config.warningSlowQuerySeconds;
}
if (config.warningQueueSize) {
WARNINGS.LARGE_QUEUE.size = config.warningQueueSize;
}
}
get maintaining() {
return !!this.#maintaining;
}
async start() {
if (this.#stopped) {
this.#stopping = false;
this.#superviseInterval = setInterval(() => this.#onSupervise(), this.#config.superviseIntervalSeconds * 1000);
this.#stopped = false;
}
}
async stop() {
if (!this.#stopped) {
this.#stopping = true;
if (this.#superviseInterval)
clearInterval(this.#superviseInterval);
this.#stopped = true;
while (this.#maintaining) {
await delay(10);
}
}
}
get #warningContext() {
return {
emitter: this,
db: this.#db,
schema: this.#config.schema,
persistWarnings: this.#config.persistWarnings,
warningEvent: events.warning,
errorEvent: events.error
};
}
async #executeQuery(query) {
if (typeof (query) === 'string') {
query = { text: query, values: [] };
}
const started = Date.now();
const result = unwrapSQLResult(await this.#db.executeSql(query.text, query.values));
const elapsed = (Date.now() - started) / 1000;
if (elapsed > WARNINGS.SLOW_QUERY.seconds ||
this.#config.__test__warn_slow_query) {
await emitAndPersistWarning(this.#warningContext, WARNING_TYPES.SLOW_QUERY, WARNINGS.SLOW_QUERY.message, { elapsed, sql: query.text, values: query.values });
}
return result;
}
async #onSupervise() {
try {
if (this.#stopped)
return;
if (this.#maintaining)
return;
if (this.#config.__test__throw_maint) {
throw new Error(this.#config.__test__throw_maint);
}
this.#maintaining = true;
if (this.#config.__test__delay_maint_ms) {
await delay(this.#config.__test__delay_maint_ms);
}
const queues = await this.#manager.getQueues();
!this.#stopped && (await this.supervise(queues));
!this.#stopped && (await this.#maintainWarnings());
}
catch (err) {
this.emit(events.error, err);
}
finally {
this.#maintaining = false;
}
}
async #maintainWarnings() {
if (!this.#config.persistWarnings || !this.#config.warningRetentionDays) {
return;
}
const sql = plans.deleteOldWarnings(this.#config.schema, this.#config.warningRetentionDays);
await this.#executeQuery(sql);
}
async supervise(value) {
let queues;
if (Array.isArray(value)) {
queues = value;
}
else {
queues = await this.#manager.getQueues(value);
}
const queueGroups = queues.reduce((acc, q) => {
const { table } = q;
acc[table] = acc[table] || { table, queues: [] };
acc[table].queues.push(q);
return acc;
}, {});
const heartbeatQueueNames = new Set(queues.filter(q => q.heartbeatSeconds != null).map(q => q.name));
for (const queueGroup of Object.values(queueGroups)) {
if (this.#stopping)
return;
const { table, queues } = queueGroup;
const names = queues.map((i) => i.name);
while (names.length) {
if (this.#stopping)
return;
const chunk = names.splice(0, 100);
await this.#monitor(table, chunk, heartbeatQueueNames);
await this.#maintain(table, chunk);
}
}
}
async #monitor(table, names, heartbeatQueueNames) {
if (this.#stopping)
return;
const command = plans.trySetQueueMonitorTime(this.#config.schema, names, this.#config.monitorIntervalSeconds);
const { rows } = await this.#executeQuery(command);
if (this.#stopping)
return;
if (rows.length) {
const queues = rows.map((q) => q.name);
const cacheStatsSql = plans.cacheQueueStats(this.#config.schema, table, queues);
const { rows: rowsCacheStats } = await this.#executeQuery(cacheStatsSql);
if (this.#stopping)
return;
const warnings = rowsCacheStats.filter(i => i.queuedCount > (i.warningQueueSize || WARNINGS.LARGE_QUEUE.size));
for (const warning of warnings) {
await emitAndPersistWarning(this.#warningContext, WARNING_TYPES.QUEUE_BACKLOG, WARNINGS.LARGE_QUEUE.message, warning);
}
const sql = plans.failJobsByTimeout(this.#config.schema, table, queues);
await this.#executeQuery(sql);
if (this.#stopping)
return;
const heartbeatQueues = queues.filter(q => heartbeatQueueNames.has(q));
if (heartbeatQueues.length) {
const heartbeatSql = plans.failJobsByHeartbeat(this.#config.schema, table, heartbeatQueues);
await this.#executeQuery(heartbeatSql);
}
}
}
async #maintain(table, names) {
if (this.#stopping)
return;
const command = plans.trySetQueueDeletionTime(this.#config.schema, names, this.#config.maintenanceIntervalSeconds);
const { rows } = await this.#executeQuery(command);
if (this.#stopping)
return;
if (rows.length) {
const queues = rows.map((q) => q.name);
const sql = plans.deletion(this.#config.schema, table, queues);
await this.#executeQuery(sql);
}
}
}
export default Boss;