@backgroundjs/core
Version:
An extendible background job queue for js/ts applications
814 lines (706 loc) • 24.8 kB
text/typescript
import { Job, JobStatus } from "../types";
import { JobStorage } from "./base-storage";
import type { Redis } from "ioredis";
/**
* Redis storage adapter for JobQueue
*
* This storage adapter uses Redis to store jobs, making it suitable
* for distributed environments with multiple instances/processes.
*
* Note: You must install the 'ioredis' package to use this adapter:
* npm install ioredis
*/
export interface RedisStorage extends JobStorage {
// Atomic job acquisition
acquireNextJob(ttl?: number): Promise<Job | null>;
// Get jobs by priority
getJobsByPriority(priority: number): Promise<Job[]>;
// Get scheduled jobs within a time range
getScheduledJobs(startTime: Date, endTime?: Date): Promise<Job[]>;
// Clear all jobs
clear(): Promise<void>;
}
export class RedisJobStorage implements RedisStorage {
private readonly redis: Redis; // ioredis instance
private readonly keyPrefix: string;
private readonly jobListKey: string;
// Priority queues (separate lists for different priorities)
private readonly priorityQueueKeys: { [priority: number]: string };
// Sorted set for scheduled jobs
private readonly scheduledJobsKey: string;
private readonly logging: boolean = false;
// Lua scripts
private readonly saveJobScript: string;
private readonly updateJobScript: string;
private readonly moveScheduledJobsScript: string;
private readonly completeJobScript: string;
private readonly failJobScript: string;
private readonly staleJobTimeout: number = 1000 * 60 * 60 * 24; // 24 hours
/**
* Create a new RedisJobStorage
*
* @param redis - An ioredis client instance
* @param options - Configuration options
*/
constructor(
redis: Redis,
options: { keyPrefix?: string; logging?: boolean; staleJobTimeout?: number } = {},
) {
this.redis = redis;
this.keyPrefix = options.keyPrefix || "jobqueue:";
this.jobListKey = `${this.keyPrefix}jobs`;
this.scheduledJobsKey = `${this.keyPrefix}scheduled`;
this.priorityQueueKeys = {
1: `${this.keyPrefix}priority:1`,
2: `${this.keyPrefix}priority:2`,
3: `${this.keyPrefix}priority:3`,
4: `${this.keyPrefix}priority:4`,
5: `${this.keyPrefix}priority:5`,
6: `${this.keyPrefix}priority:6`,
7: `${this.keyPrefix}priority:7`,
8: `${this.keyPrefix}priority:8`,
9: `${this.keyPrefix}priority:9`,
10: `${this.keyPrefix}priority:10`,
};
this.logging = options.logging || false;
this.staleJobTimeout = options.staleJobTimeout || 1000 * 60 * 60 * 24; // 24 hours
// Define Lua script for saveJob
this.saveJobScript = `
-- Arguments:
-- KEYS[1]: Job key (hash)
-- KEYS[2]: Status set key
-- KEYS[3]: Scheduled set key
-- KEYS[4]: Priority queue key prefix
-- ARGV[1]: Job ID
-- ARGV[2]: Job status
-- ARGV[3]: Priority (default 3)
-- ARGV[4]: Is scheduled (1 or 0)
-- ARGV[5]: Scheduled time (timestamp)
-- ARGV[6+]: Key-value pairs for job data (flattened)
local jobKey = KEYS[1]
local statusKey = KEYS[2]
local scheduledKey = KEYS[3]
local priorityKeyPrefix = KEYS[4]
local jobId = ARGV[1]
local status = ARGV[2]
local priority = tonumber(ARGV[3]) or 3
local isScheduled = ARGV[4] == "1"
local scheduledTime = tonumber(ARGV[5]) or 0
-- Store job hash fields
for i = 6, #ARGV, 2 do
redis.call('HSET', jobKey, ARGV[i], ARGV[i+1])
end
-- Add to status set
redis.call('SADD', statusKey, jobId)
-- Add to appropriate queue based on scheduling
if isScheduled then
redis.call('ZADD', scheduledKey, scheduledTime, jobId)
else
local priorityQueueKey = priorityKeyPrefix .. priority
redis.call('LPUSH', priorityQueueKey, jobId)
end
return 1
`;
// Define Lua script for updateJob
this.updateJobScript = `
-- Arguments:
-- KEYS[1]: Job key (hash)
-- KEYS[2]: New status set key
-- KEYS[3]: Scheduled set key
-- KEYS[4]: Priority queue key prefix
-- KEYS[5]: Status set key prefix
-- ARGV[1]: Job ID
-- ARGV[2]: Current status (for old status set)
-- ARGV[3]: New status
-- ARGV[4]: Priority (default 3)
-- ARGV[5]: Is scheduled (1 or 0)
-- ARGV[6]: Scheduled time (timestamp)
-- ARGV[7+]: Key-value pairs for job data (flattened)
local jobKey = KEYS[1]
local newStatusKey = KEYS[2]
local scheduledKey = KEYS[3]
local priorityKeyPrefix = KEYS[4]
local statusKeyPrefix = KEYS[5]
local jobId = ARGV[1]
local oldStatus = ARGV[2]
local newStatus = ARGV[3]
local priority = tonumber(ARGV[4]) or 3
local isScheduled = ARGV[5] == "1"
local scheduledTime = tonumber(ARGV[6]) or 0
-- Check if job exists
if redis.call('EXISTS', jobKey) == 0 then
return {err = "Job not found: " .. jobId}
end
-- Update job data
for i = 7, #ARGV, 2 do
redis.call('HSET', jobKey, ARGV[i], ARGV[i+1])
end
-- Update status sets if status changed
if oldStatus ~= newStatus then
local oldStatusKey = statusKeyPrefix .. oldStatus
redis.call('SREM', oldStatusKey, jobId)
redis.call('SADD', newStatusKey, jobId)
end
-- Handle queue placement based on status
if newStatus == 'pending' then
if isScheduled then
-- Remove from all priority queues
for p = 1, 10 do
redis.call('LREM', priorityKeyPrefix .. p, 0, jobId)
end
-- Add to scheduled set
redis.call('ZADD', scheduledKey, scheduledTime, jobId)
else
-- Remove from scheduled set
redis.call('ZREM', scheduledKey, jobId)
-- Remove from all other priority queues
for p = 1, 10 do
if p ~= priority then
redis.call('LREM', priorityKeyPrefix .. p, 0, jobId)
end
end
-- Add to correct priority queue
redis.call('LPUSH', priorityKeyPrefix .. priority, jobId)
end
else
-- If not pending, remove from all queues
redis.call('ZREM', scheduledKey, jobId)
for p = 1, 10 do
redis.call('LREM', priorityKeyPrefix .. p, 0, jobId)
end
end
return 1
`;
// Define Lua script for moveScheduledJobs
this.moveScheduledJobsScript = `
-- Arguments:
-- KEYS[1]: Scheduled jobs sorted set
-- KEYS[2]: Job key prefix
-- KEYS[3]: Priority queue key prefix
-- ARGV[1]: Current timestamp
local scheduledKey = KEYS[1]
local jobKeyPrefix = KEYS[2]
local priorityKeyPrefix = KEYS[3]
local now = tonumber(ARGV[1])
-- Get all jobs scheduled before now
local dueJobs = redis.call('ZRANGEBYSCORE', scheduledKey, 0, now)
if #dueJobs == 0 then
return 0 -- No jobs due yet
end
local movedCount = 0
-- Process each due job
for i, jobId in ipairs(dueJobs) do
-- Remove job from scheduled set
redis.call('ZREM', scheduledKey, jobId)
-- Get job details to find priority
local jobKey = jobKeyPrefix .. jobId
-- Check if job still exists
if redis.call('EXISTS', jobKey) == 1 then
-- Get current status and priority
local status = redis.call('HGET', jobKey, 'status')
local priority = tonumber(redis.call('HGET', jobKey, 'priority')) or 3
-- Only move to queue if job is still pending
if status == 'pending' then
-- Make sure priority is within bounds
priority = math.max(1, math.min(10, priority))
-- Add to appropriate priority queue
redis.call('LPUSH', priorityKeyPrefix .. priority, jobId)
movedCount = movedCount + 1
end
end
end
return movedCount
`;
// Define Lua script for completeJob
this.completeJobScript = `
-- Arguments:
-- KEYS[1]: Job key
-- KEYS[2]: Status set key prefix
-- ARGV[1]: Job ID
-- ARGV[2]: Result (JSON string)
-- ARGV[3]: Completion timestamp
local jobKey = KEYS[1]
local statusKeyPrefix = KEYS[2]
local jobId = ARGV[1]
local result = ARGV[2]
local completedAt = ARGV[3]
-- Check if job exists
if redis.call('EXISTS', jobKey) == 0 then
return {err = "Job not found: " .. jobId}
end
-- Get current status
local currentStatus = redis.call('HGET', jobKey, 'status')
-- Update job data
redis.call('HSET', jobKey,
'status', 'completed',
'result', result,
'completedAt', completedAt
)
-- Update status sets
if currentStatus then
redis.call('SREM', statusKeyPrefix .. currentStatus, jobId)
end
redis.call('SADD', statusKeyPrefix .. 'completed', jobId)
return 1
`;
// Define Lua script for failJob
this.failJobScript = `
-- Arguments:
-- KEYS[1]: Job key
-- KEYS[2]: Status set key prefix
-- ARGV[1]: Job ID
-- ARGV[2]: Error message
-- ARGV[3]: Completion timestamp
local jobKey = KEYS[1]
local statusKeyPrefix = KEYS[2]
local jobId = ARGV[1]
local errorMsg = ARGV[2]
local completedAt = ARGV[3]
-- Check if job exists
if redis.call('EXISTS', jobKey) == 0 then
return {err = "Job not found: " .. jobId}
end
-- Get current status
local currentStatus = redis.call('HGET', jobKey, 'status')
-- Update job data
redis.call('HSET', jobKey,
'status', 'failed',
'error', errorMsg,
'completedAt', completedAt
)
-- Update status sets
if currentStatus then
redis.call('SREM', statusKeyPrefix .. currentStatus, jobId)
end
redis.call('SADD', statusKeyPrefix .. 'failed', jobId)
return 1
`;
}
/**
* Save a job to Redis using a Lua script
*/
async saveJob(job: Job): Promise<void> {
try {
const jobKey = this.getJobKey(job.id);
const statusKey = this.getStatusKey(job.status);
// Serialize job data properly for Redis
const serializedJob = this.serializeJob(job);
// Convert serialized job to flattened array for Lua
const jobDataArray: string[] = [];
Object.entries(serializedJob).forEach(([key, value]) => {
jobDataArray.push(key, value);
});
// Check if job is scheduled
const isScheduled =
job.scheduledAt && job.scheduledAt > new Date() ? "1" : "0";
const scheduledTime = job.scheduledAt
? job.scheduledAt.getTime().toString()
: "0";
const priority = String(job.priority || 3);
// Run the Lua script
await this.redis.eval(
this.saveJobScript,
4, // Number of keys
jobKey,
statusKey,
this.scheduledJobsKey,
this.keyPrefix + "priority:",
job.id,
job.status,
priority,
isScheduled,
scheduledTime,
...jobDataArray,
);
} catch (error) {
if (this.logging) {
console.error(`[RedisJobStorage] Error saving job:`, error);
}
}
}
/**
* Get a job by ID
*/
async getJob(id: string): Promise<Job | null> {
try {
const jobKey = this.getJobKey(id);
const jobData = await this.redis.hgetall(jobKey);
if (!jobData || Object.keys(jobData).length === 0) return null;
return this.deserializeJob(jobData);
} catch (error) {
if (this.logging) {
console.error(`[RedisJobStorage] Error getting job:`, error);
}
return null;
}
}
/**
* Get jobs by status
*/
async getJobsByStatus(status: JobStatus): Promise<Job[]> {
try {
const statusKey = this.getStatusKey(status);
const jobIds = await this.redis.smembers(statusKey);
if (!jobIds.length) return [];
const jobs = await Promise.all(jobIds.map((id) => this.getJob(id)));
return jobs.filter((job) => job !== null) as Job[];
} catch (error) {
if (this.logging) {
console.error(`[RedisJobStorage] Error getting jobs by status:`, error);
}
return [];
}
}
/**
* Update a job using a Lua script
*/
async updateJob(job: Job): Promise<void> {
try {
const jobKey = this.getJobKey(job.id);
const newStatusKey = this.getStatusKey(job.status);
// Get current job to find its status
const existingJob = await this.getJob(job.id);
if (!existingJob) {
throw new Error(`Job ${job.id} not found`);
}
// Serialize job data properly for Redis
const serializedJob = this.serializeJob(job);
// Convert serialized job to flattened array for Lua
const jobDataArray: string[] = [];
Object.entries(serializedJob).forEach(([key, value]) => {
jobDataArray.push(key, value);
});
// Check if job is scheduled
const isScheduled =
job.scheduledAt && job.scheduledAt > new Date() ? "1" : "0";
const scheduledTime = job.scheduledAt
? job.scheduledAt.getTime().toString()
: "0";
const priority = String(job.priority || 3);
// Run the Lua script
const result = await this.redis.eval(
this.updateJobScript,
5, // Number of keys
jobKey,
newStatusKey,
this.scheduledJobsKey,
this.keyPrefix + "priority:",
this.keyPrefix + "status:", // Status set key prefix
job.id,
existingJob.status,
job.status,
priority,
isScheduled,
scheduledTime,
...jobDataArray,
);
// Handle error result from Lua script
if (
result &&
typeof result === "object" &&
result !== null &&
"err" in result
) {
throw new Error((result as { err: string }).err);
}
} catch (error) {
if (this.logging) {
console.error(`[RedisJobStorage] Error updating job:`, error);
}
}
}
/**
* Acquire the next job from the queue, respecting priorities and scheduled times
* Also checks for stale jobs that have been processing for too long
* @returns The next job or null if no job is available
*/
async acquireNextJob(ttl: number = 30): Promise<Job | null> {
try {
// First move scheduled jobs
await this.moveScheduledJobs();
// First try to get a pending job
let jobId: string | null = null;
// Try each priority queue in order
for (let priority = 1; priority <= 10; priority++) {
const queueKey = this.priorityQueueKeys[priority];
jobId = await this.redis.rpop(queueKey);
if (jobId) break;
}
// If no pending job found, check for stale jobs
if (!jobId) {
const processingJobs = await this.getJobsByStatus('processing');
const now = Date.now();
const staleTime = now - this.staleJobTimeout;
// Find the first stale job
const staleJob = processingJobs.find(job =>
job.startedAt && job.startedAt.getTime() < staleTime
);
if (staleJob) {
jobId = staleJob.id;
}
}
if (!jobId) {
return null;
}
// Update the job status to processing
const jobKey = this.getJobKey(jobId);
const statusKey = this.getStatusKey('processing');
const now = new Date();
// Use a transaction to update the job atomically
const multi = this.redis.multi();
// Get current status first
const currentStatus = await this.redis.hget(jobKey, 'status');
// Update job status and startedAt
multi.hset(jobKey, 'status', 'processing', 'startedAt', now.toISOString());
// Update status sets
if (currentStatus) {
multi.srem(this.getStatusKey(currentStatus as JobStatus), jobId);
}
multi.sadd(statusKey, jobId);
// Execute the transaction
await multi.exec();
// Get the full job data
const job = await this.getJob(jobId);
// Ensure we got a valid job with an ID
if (!job) {
return null;
}
return job;
} catch (error) {
if (this.logging) {
console.error(`[RedisJobStorage] Error acquiring next job:`, error);
}
return null;
}
}
/**
* Move scheduled jobs to priority queues using a Lua script
* @private
*/
private async moveScheduledJobs(): Promise<number> {
try {
const now = Date.now();
// Run the Lua script
const movedCount = await this.redis.eval(
this.moveScheduledJobsScript,
3, // Number of keys
this.scheduledJobsKey,
this.keyPrefix + "job:",
this.keyPrefix + "priority:",
String(now),
);
return movedCount as number;
} catch (error) {
if (this.logging) {
console.error(`[RedisJobStorage] Error moving scheduled jobs:`, error);
}
return 0;
}
}
/**
* Serialize a job object for Redis storage
* @private
*/
private serializeJob(job: Job): Record<string, string> {
const serialized: Record<string, string> = {};
serialized.id = job.id;
serialized.name = job.name;
serialized.status = job.status;
serialized.priority = String(job.priority || 3);
if (job.createdAt) serialized.createdAt = job.createdAt.toISOString();
if (job.scheduledAt) serialized.scheduledAt = job.scheduledAt.toISOString();
if (job.startedAt) serialized.startedAt = job.startedAt.toISOString();
if (job.completedAt) serialized.completedAt = job.completedAt.toISOString();
if (job.data) serialized.data = JSON.stringify(job.data);
if (job.result) serialized.result = JSON.stringify(job.result);
if (job.error) serialized.error = job.error;
if (job.retryCount !== undefined)
serialized.retryCount = String(job.retryCount);
if (job.repeat) serialized.repeat = JSON.stringify(job.repeat);
if (job.timeout) serialized.timeout = String(job.timeout);
return serialized;
}
/**
* Deserialize a job object from Redis storage
* @private
*/
private deserializeJob(data: Record<string, string>): Job {
const job: Job = {
id: data.id || "",
name: data.name,
status: data.status as JobStatus,
createdAt: data.createdAt ? new Date(data.createdAt) : new Date(),
data: {},
};
if (data.priority) job.priority = parseInt(data.priority, 10);
if (data.scheduledAt) job.scheduledAt = new Date(data.scheduledAt);
if (data.startedAt) job.startedAt = new Date(data.startedAt);
if (data.completedAt) job.completedAt = new Date(data.completedAt);
if (data.repeat) job.repeat = JSON.parse(data.repeat);
if (data.data) {
try {
job.data = JSON.parse(data.data);
} catch (e) {
job.data = data.data;
}
}
if (data.result) {
try {
job.result = JSON.parse(data.result);
} catch (e) {
job.result = data.result;
}
}
if (data.error) job.error = data.error;
if (data.retryCount) job.retryCount = parseInt(data.retryCount, 10);
if (data.timeout) job.timeout = parseInt(data.timeout, 10);
return job;
}
private getJobKey(id: string): string {
return `${this.keyPrefix}job:${id}`;
}
private getStatusKey(status: JobStatus): string {
return `${this.keyPrefix}status:${status}`;
}
/**
* Get jobs by priority
* @param priority - The priority level (1-5)
* @returns Array of jobs with the specified priority
*/
async getJobsByPriority(priority: number): Promise<Job[]> {
try {
if (!this.priorityQueueKeys[priority]) {
throw new Error(
`Invalid priority: ${priority}. Must be between 1 and 10.`,
);
}
const jobIds = await this.redis.lrange(
this.priorityQueueKeys[priority],
0,
-1,
);
if (!jobIds.length) return [];
const jobs = await Promise.all(jobIds.map((id) => this.getJob(id)));
return jobs.filter((job) => job !== null) as Job[];
} catch (error) {
if (this.logging) {
console.error(
`[RedisJobStorage] Error getting jobs by priority:`,
error,
);
}
return [];
}
}
/**
* Get scheduled jobs within a time range
* @param startTime - Start of time range (inclusive)
* @param endTime - End of time range (inclusive)
* @returns Array of scheduled jobs within the time range
*/
async getScheduledJobs(
startTime: Date = new Date(),
endTime?: Date,
): Promise<Job[]> {
try {
const start = startTime.getTime();
const end = endTime ? endTime.getTime() : "+inf";
const jobIds = await this.redis.zrangebyscore(
this.scheduledJobsKey,
start,
end,
);
if (!jobIds.length) return [];
const jobs = await Promise.all(jobIds.map((id) => this.getJob(id)));
return jobs.filter((job) => job !== null) as Job[];
} catch (error) {
if (this.logging) {
console.error(`[RedisJobStorage] Error getting scheduled jobs:`, error);
}
return [];
}
}
/**
* Complete a job using a Lua script
*
* @param jobId - ID of the job to complete
* @param result - Result of the job
*/
async completeJob(jobId: string, result: any): Promise<void> {
try {
const jobKey = this.getJobKey(jobId);
const serializedResult = JSON.stringify(result);
const now = new Date().toISOString();
// Run the Lua script to complete the job
const scriptResult = await this.redis.eval(
this.completeJobScript,
2, // Number of keys
jobKey,
this.keyPrefix + "status:",
jobId,
serializedResult,
now,
);
// Handle error result from Lua script
if (
scriptResult &&
typeof scriptResult === "object" &&
scriptResult !== null &&
"err" in scriptResult
) {
throw new Error((scriptResult as { err: string }).err);
}
} catch (error) {
if (this.logging) {
console.error(`[RedisJobStorage] Error completing job:`, error);
}
}
}
/**
* Fail a job using a Lua script
*
* @param jobId - ID of the job to fail
* @param error - Error message
*/
async failJob(jobId: string, error: string): Promise<void> {
try {
const jobKey = this.getJobKey(jobId);
const now = new Date().toISOString();
// Run the Lua script
const scriptResult = await this.redis.eval(
this.failJobScript,
2, // Number of keys
jobKey,
this.keyPrefix + "status:",
jobId,
error,
now,
);
// Handle error result from Lua script
if (
scriptResult &&
typeof scriptResult === "object" &&
scriptResult !== null &&
"err" in scriptResult
) {
throw new Error((scriptResult as { err: string }).err);
}
} catch (error) {
if (this.logging) {
console.error(`[RedisJobStorage] Error failing job:`, error);
}
}
}
/**
* Clear all jobs
*/
async clear(): Promise<void> {
try {
await this.redis.flushall();
} catch (error) {
if (this.logging) {
console.error(`[RedisJobStorage] Error clearing all jobs:`, error);
}
}
}
}