UNPKG

@backgroundjs/core

Version:

An extendible background job queue for js/ts applications

346 lines (327 loc) 10 kB
import { Pool } from "pg"; import { JobStorage } from "./base-storage"; import { Job, JobStatus } from "../types"; export interface PostgreSQLStorage extends JobStorage { acquireNextJob(): Promise<Job | null>; completeJob(jobId: string, result: any): Promise<void>; failJob(jobId: string, error: string): Promise<void>; } /** * PostgreSQL storage adapter for JobQueue * * This storage adapter uses PostgreSQL to store jobs, making it suitable * for distributed environments with multiple instances/processes. */ export class PostgreSQLJobStorage implements PostgreSQLStorage { private readonly pool: Pool; private readonly tableName: string; private initialized: boolean = false; private readonly logging: boolean = false; private readonly staleJobTimeout: number = 1000 * 60 * 60 * 24; // 24 hours /** * Create a new PostgreSQL job storage * * @param pool - A pg Pool instance * @param options - Configuration options */ constructor( pool: Pool, options: { tableName?: string; logging?: boolean; staleJobTimeout?: number } = {}, ) { this.pool = pool; this.tableName = options.tableName || "jobs"; this.logging = options.logging || false; this.staleJobTimeout = options.staleJobTimeout || 1000 * 60 * 60 * 24; // 24 hours } /** * Initialize the database tables if they don't exist */ async initialize(): Promise<void> { const client = await this.pool.connect(); try { await client.query(` CREATE TABLE IF NOT EXISTS ${this.tableName} ( id TEXT PRIMARY KEY, name TEXT NOT NULL, data JSONB NOT NULL, status TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL, scheduled_at TIMESTAMP WITH TIME ZONE, started_at TIMESTAMP WITH TIME ZONE, completed_at TIMESTAMP WITH TIME ZONE, error TEXT, priority INTEGER DEFAULT 0, result JSONB, repeat JSONB, retry_count INTEGER DEFAULT 0, timeout INTEGER ) `); await client.query(` CREATE INDEX IF NOT EXISTS ${this.tableName}_status_idx ON ${this.tableName} (status) `); await client.query(` CREATE INDEX IF NOT EXISTS ${this.tableName}_scheduled_at_idx ON ${this.tableName} (scheduled_at) WHERE scheduled_at IS NOT NULL `); this.initialized = true; } finally { client.release(); } } /** * Save a job to PostgreSQL */ async saveJob(job: Job): Promise<void> { try { if (!this.initialized) { await this.initialize(); } await this.pool.query( `INSERT INTO ${this.tableName} ( id, name, data, status, created_at, scheduled_at, retry_count, priority, repeat, timeout ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ job.id, job.name, JSON.stringify(job.data), job.status, job.createdAt, job.scheduledAt || null, job.retryCount || 0, job.priority || 0, JSON.stringify(job.repeat), job.timeout || null, ], ); } catch (error) { if (this.logging) { console.error(`[PostgreSQLJobStorage] Error saving job:`, error); } } } /** * Get a job by ID */ async getJob(id: string): Promise<Job | null> { try { if (!this.initialized) { await this.initialize(); } const result = await this.pool.query( `SELECT * FROM ${this.tableName} WHERE id = $1`, [id], ); if (result.rows.length === 0) { return null; } return this.mapRowToJob(result.rows[0]); } catch (error) { if (this.logging) { console.error(`[PostgreSQLJobStorage] Error getting job:`, error); } return null; } } /** * Get jobs by status */ async getJobsByStatus(status: JobStatus): Promise<Job[]> { try { if (!this.initialized) { await this.initialize(); } const result = await this.pool.query( `SELECT * FROM ${this.tableName} WHERE status = $1`, [status], ); return result.rows.map((row) => this.mapRowToJob(row)); } catch (error) { if (this.logging) { console.error( `[PostgreSQLJobStorage] Error getting jobs by status:`, error, ); } return []; } } /** * Update a job */ async updateJob(job: Job): Promise<void> { try { if (!this.initialized) { await this.initialize(); } const result = await this.pool.query( `UPDATE ${this.tableName} SET name = $2, data = $3, status = $4, scheduled_at = $5, started_at = $6, completed_at = $7, error = $8, result = $9, repeat = $10, retry_count = $11, timeout = $12, priority = $13 WHERE id = $1`, [ job.id, job.name, JSON.stringify(job.data), job.status, job.scheduledAt || null, job.startedAt || null, job.completedAt || null, job.error || null, job.result ? JSON.stringify(job.result) : null, job.repeat ? JSON.stringify(job.repeat) : null, job.retryCount || 0, job.timeout || null, job.priority || 0, ], ); if (result.rowCount === 0) { throw new Error(`Job with ID ${job.id} not found`); } } catch (error) { if (this.logging) { console.error(`[PostgreSQLJobStorage] Error updating job:`, error); } } } /** * Acquire the next pending job * @returns The next pending job, or null if no pending jobs are available */ async acquireNextJob(): Promise<Job | null> { if (!this.initialized) { await this.initialize(); } const client = await this.pool.connect(); try { await client.query("BEGIN"); // Use the server's current time for the scheduled_at check const query = await client.query( `SELECT * FROM ${this.tableName} WHERE ( status = 'pending' AND (scheduled_at IS NULL OR scheduled_at <= $1) ) OR ( status = 'processing' AND started_at IS NOT NULL AND completed_at IS NULL AND started_at < $2 ) ORDER BY priority ASC, created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED`, [new Date(), new Date(Date.now() - this.staleJobTimeout)], ); if (query.rows.length === 0) { await client.query("ROLLBACK"); return null; } let job = query.rows[0]; const now = new Date(); await client.query( `UPDATE ${this.tableName} SET status = 'processing', started_at = $1 WHERE id = $2`, [now, job.id], ); job = this.mapRowToJob(job) as Job; // Add so that we do not update it again in the processJob method if (job) { job.status = "processing"; job.startedAt = now; } await client.query("COMMIT"); return job; } catch (error) { if (this.logging) { console.error( `[PostgreSQLJobStorage] Error acquiring next job:`, error, ); } await client.query("ROLLBACK"); return null; } finally { client.release(); } } /** * Complete a job * @param jobId - The ID of the job to complete * @param result - The result of the job */ async completeJob(jobId: string, result: any): Promise<void> { try { if (!this.initialized) { await this.initialize(); } await this.pool.query( `UPDATE ${this.tableName} SET status = 'completed', completed_at = NOW(), result = $2 WHERE id = $1`, [jobId, JSON.stringify(result)], ); } catch (error) { if (this.logging) { console.error(`[PostgreSQLJobStorage] Error completing job:`, error); } } } /** * Fail a job * @param jobId - The ID of the job to fail * @param error - The error message */ async failJob(jobId: string, error: string): Promise<void> { try { if (!this.initialized) { await this.initialize(); } await this.pool.query( `UPDATE ${this.tableName} SET status = 'failed', completed_at = NOW(), error = $2 WHERE id = $1`, [jobId, error], ); } catch (error) { if (this.logging) { console.error(`[PostgreSQLJobStorage] Error failing job:`, error); } } } /** * Map a database row to a Job object * @param row - The database row to map * @returns The mapped Job object */ private mapRowToJob(row: any): Job { return { id: row.id, name: row.name, data: typeof row.data === "string" ? JSON.parse(row.data) : row.data, status: row.status as JobStatus, createdAt: new Date(row.created_at), scheduledAt: row.scheduled_at ? new Date(row.scheduled_at) : undefined, startedAt: row.started_at ? new Date(row.started_at) : undefined, completedAt: row.completed_at ? new Date(row.completed_at) : undefined, error: row.error || undefined, result: row.result ? typeof row.result === "string" ? JSON.parse(row.result) : row.result : undefined, repeat: row.repeat ? typeof row.repeat === "string" ? JSON.parse(row.repeat) : row.repeat : undefined, retryCount: row.retry_count || 0, timeout: row.timeout || undefined, }; } }