UNPKG

@backgroundjs/core

Version:

An extendible background job queue for js/ts applications

365 lines 12.2 kB
/** * 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 { pool; tableName; initialized = false; logging = false; staleJobTimeout = 1000 * 60 * 60 * 24; // 24 hours /** * Create a new PostgreSQL job storage * * @param pool - A pg Pool instance * @param options - Configuration options */ constructor(pool, options = {}) { 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() { 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) { 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) { 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) { 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) { 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); } throw error; } } /** * Acquire the next pending job * @returns The next pending job, or null if no pending jobs are available */ async acquireNextJob(handlerNames) { 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 name = ANY($3) AND ( ( 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), handlerNames]); 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); // 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(); } } async acquireNextJobs(batchSize, handlerNames) { if (!this.initialized) { await this.initialize(); } const client = await this.pool.connect(); let committed = false; try { await client.query("BEGIN"); // Acquire multiple jobs atomically const query = await client.query(`UPDATE ${this.tableName} SET status = 'processing', started_at = $1 WHERE name = ANY($4) AND id IN ( SELECT id 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 $3 FOR UPDATE SKIP LOCKED ) RETURNING *`, [new Date(), new Date(Date.now() - this.staleJobTimeout), batchSize, handlerNames]); await client.query("COMMIT"); committed = true; return query.rows.map(row => { const job = this.mapRowToJob(row); job.status = "processing"; job.startedAt = new Date(); return job; }); } catch (error) { if (this.logging) { console.error(`[PostgreSQLJobStorage] Error acquiring batch jobs:`, error); } if (!committed) { try { await client.query("ROLLBACK"); } catch (rollbackError) { if (this.logging) { console.error(`[PostgreSQLJobStorage] Error during rollback:`, rollbackError); } } } return []; } finally { try { client.release(); } catch (releaseError) { if (this.logging) { console.error(`[PostgreSQLJobStorage] Error releasing client:`, releaseError); } } } } /** * Complete a job * @param jobId - The ID of the job to complete * @param result - The result of the job */ async completeJob(jobId, result) { 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, error) { 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 */ mapRowToJob(row) { return { id: row.id, name: row.name, data: typeof row.data === "string" ? JSON.parse(row.data) : row.data, status: row.status, 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, }; } } //# sourceMappingURL=postgresql-storage.js.map