@sturmfrei/litequu
Version:
A simple same-thread queuing system for Node.js using SQLite with retry mechanism and exponential backoff
615 lines (468 loc) • 16 kB
Markdown
# LiteQuu
A simple, persistent task queue for Node.js using SQLite as storage. Tasks are processed in the main thread with configurable concurrency, automatic retries with exponential backoff, and comprehensive event handling.
## Features
- ✅ **Persistent Storage**: Uses SQLite for reliable task persistence
- 🎯 **Multi-Job Support**: Create named jobs with dedicated handlers for different task types
- ⚡ **Same-Thread Processing**: Runs in the main Node.js thread (perfect for I/O-bound tasks)
- 🔄 **Automatic Retries**: Exponential backoff with configurable retry limits
- 🚦 **Concurrency Control**: Configurable maximum concurrent task processing
- 📊 **Event-Driven**: Comprehensive event system with job-level and queue-level events
- 🔍 **Task Management**: Query task status, statistics, and cleanup utilities
- 🕐 **Auto-Processing**: Optional automatic task processing with polling
- 📦 **Zero Config**: Works out of the box with sensible defaults
## Installation
```bash
npm i @sturmfrei/litequu
```
## Quick Start
### Multi-Job API (Recommended)
Create named jobs for different task types with dedicated handlers:
```javascript
import Queue from '@sturmfrei/litequu';
// Create a queue
const queue = new Queue({
dbPath: './my-queue.db',
maxConcurrent: 5,
maxRetries: 3,
baseRetryDelay: 1000,
});
// Create different jobs for different types of work
const emailJob = queue.createJob('email');
const smsJob = queue.createJob('sms');
const webhookJob = queue.createJob('webhook');
// Set up handlers for each job
await emailJob.process(async (taskData) => {
await sendEmail(taskData.to, taskData.subject, taskData.body);
return `Email sent to ${taskData.to}`;
});
await smsJob.process(async (taskData) => {
await sendSMS(taskData.phone, taskData.message);
return `SMS sent to ${taskData.phone}`;
});
await webhookJob.process(async (taskData) => {
await callWebhook(taskData.url, taskData.payload);
return `Webhook called: ${taskData.url}`;
});
// Add tasks to specific jobs
emailJob.add({
to: 'user@example.com',
subject: 'Welcome!',
body: 'Thanks for signing up',
});
smsJob.add({
phone: '+1234567890',
message: 'Your verification code is 123456',
});
// Listen to job-specific events
emailJob.on('completed', (info) => {
console.log(`Email task ${info.taskId} completed:`, info.result);
});
// Listen to all events from all jobs at the queue level
queue.on('completed', (info) => {
console.log(`[${info.jobName}] Task ${info.taskId} completed`);
});
queue.on('failed', (info) => {
console.log(`[${info.jobName}] Task ${info.taskId} failed:`, info.error);
});
```
### Single-Handler API (Legacy, still supported)
For simpler use cases or backward compatibility:
```javascript
import Queue from '@sturmfrei/litequu';
const queue = new Queue();
// Add tasks
queue.add({
type: 'send_email',
to: 'user@example.com',
subject: 'Welcome!',
});
// Process tasks with a single handler
queue.process(async (taskData) => {
if (taskData.type === 'send_email') {
await sendEmail(taskData.to, taskData.subject);
return `Email sent to ${taskData.to}`;
}
throw new Error(`Unknown task type: ${taskData.type}`);
});
queue.on('completed', (info) => {
console.log(`Task ${info.taskId} completed:`, info.result);
});
```
## Configuration Options
```javascript
const queue = new Queue({
// Database file path (default: './queue.db')
dbPath: './my-app-queue.db',
// Maximum concurrent tasks (default: 5)
maxConcurrent: 3,
// Maximum retry attempts (default: 15)
maxRetries: 5,
// Base retry delay in milliseconds (default: 15_000ms)
baseRetryDelay: 2000,
// Polling interval for auto-processing (default: 5000ms)
pollingInterval: 1000,
// Enable automatic processing (default: true)
autoProcess: true,
// Add jitter to retry delays (default: true)
jitter: true,
});
```
## API Reference
### Queue Methods
#### `createJob(name)`
Create a named job for a specific type of work. Jobs have their own handlers and emit their own events.
```javascript
const emailJob = queue.createJob('email');
const smsJob = queue.createJob('sms');
// Each job can have its own handler
await emailJob.process(async (taskData) => {
// Process email tasks
});
await smsJob.process(async (taskData) => {
// Process SMS tasks
});
```
#### `add(taskData)` (Legacy)
Add a task to the default job queue. For new projects, use `createJob()` and `job.add()` instead.
```javascript
const taskId = queue.add({
action: 'process_image',
imageUrl: 'https://example.com/image.jpg',
userId: 123,
});
```
#### `process(handler)` (Legacy)
Start processing tasks with auto-polling enabled using a single handler. For new projects, use `createJob()` and `job.process()` instead.
```javascript
queue.process(async (taskData) => {
// Your task processing logic
return result;
});
```
#### `processOnce(handler)` (Legacy)
Process available tasks once without auto-polling.
```javascript
await queue.processOnce(async (taskData) => {
// Process single batch of tasks
return result;
});
```
#### `getStats()`
Get queue statistics grouped by job name and status.
```javascript
const stats = queue.getStats();
// Returns: [
// { job_name: 'email', status: 'pending', count: 5 },
// { job_name: 'email', status: 'completed', count: 10 },
// { job_name: 'sms', status: 'pending', count: 3 }
// ]
```
#### `getTask(id)`
Get a specific task by ID.
```javascript
const task = queue.getTask(123);
console.log(task.job_name, task.status, task.retry_count);
```
#### `cleanup(olderThanHours)`
Remove completed tasks older than specified hours.
```javascript
await queue.cleanup(24); // Remove completed tasks older than 24 hours
```
#### `close()`
Close the queue and database connection.
```javascript
await queue.close();
```
### Job Methods
#### `job.add(taskData)`
Add a task to a specific job.
```javascript
const emailJob = queue.createJob('email');
const taskId = emailJob.add({
to: 'user@example.com',
subject: 'Welcome!',
});
```
#### `job.process(handler)`
Register a handler function for processing tasks in this job.
```javascript
await emailJob.process(async (taskData) => {
// Process email task
await sendEmail(taskData.to, taskData.subject);
return 'Email sent';
});
```
### Properties
#### `status`
Get current queue status, including information about registered jobs.
```javascript
const status = queue.status;
console.log(status.currentRunning); // Currently processing tasks
console.log(status.maxConcurrent); // Maximum concurrent tasks
console.log(status.isProcessing); // Whether queue is actively processing
console.log(status.jobs); // Object with job names and their handler status
```
### Events
Events can be listened to at two levels:
1. **Job-level events** - Specific to a single job (no `jobName` in payload)
2. **Queue-level events** - All events from all jobs (includes `jobName` in payload)
#### Job-Level Events
Listen to events from a specific job:
```javascript
const emailJob = queue.createJob('email');
emailJob.on('added', (info) => {
// No jobName in payload - this is job-specific
console.log(`Task ${info.taskId} added:`, info.taskData);
});
emailJob.on('completed', (info) => {
console.log(`Task ${info.taskId} completed:`, info.result);
});
emailJob.on('retried', (info) => {
console.log(
`Task ${info.taskId} retry ${info.retryCount} in ${info.delay}ms`
);
console.log(`Error: ${info.error}`);
});
emailJob.on('failed', (info) => {
console.log(`Task ${info.taskId} permanently failed:`, info.error);
console.log(`Total attempts: ${info.retryCount}`);
});
```
#### Queue-Level Events
Listen to events from all jobs at the queue level. Queue-level events include the `jobName` field:
```javascript
// Listen to all completed tasks across all jobs
queue.on('completed', (info) => {
console.log(`[${info.jobName}] Task ${info.taskId} completed:`, info.result);
});
// Listen to all failures across all jobs
queue.on('failed', (info) => {
console.log(`[${info.jobName}] Task ${info.taskId} failed:`, info.error);
// Handle different jobs differently
if (info.jobName === 'critical-job') {
sendAlert(info);
}
});
// Listen to all retries
queue.on('retried', (info) => {
console.log(`[${info.jobName}] Task ${info.taskId} retry ${info.retryCount}`);
});
// Error events (queue operations)
queue.on('error', (info) => {
console.error(`Queue error in ${info.operation}:`, info.error);
});
```
#### Event Payload Differences
**Job-level events:**
```javascript
{
taskId: 123,
taskData: { ... },
result: 'success'
// No jobName
}
```
**Queue-level events:**
```javascript
{
jobName: 'email', // <-- Added at queue level
taskId: 123,
taskData: { ... },
result: 'success'
}
```
## Retry Mechanism
Tasks that fail are automatically retried with exponential backoff. The delay is roughly calculated as follows:
| Attempt | Next backoff | Total wait |
| ------- | ---------------------------- | --------------------------------- |
| 1 | 0d 0h 0m 7.5s – 0d 0h 0m 15s | 0d 0h 0m 7.5s – 0d 0h 0m 15s |
| 2 | 0d 0h 0m 15s – 0d 0h 0m 30s | 0d 0h 0m 22.5s – 0d 0h 0m 45s |
| 3 | 0d 0h 0m 30s – 0d 0h 1m 0s | 0d 0h 0m 52.5s – 0d 0h 1m 45s |
| 4 | 0d 0h 1m 0s – 0d 0h 2m 0s | 0d 0h 1m 52.5s – 0d 0h 3m 45s |
| 5 | 0d 0h 2m 0s – 0d 0h 4m 0s | 0d 0h 3m 52.5s – 0d 0h 7m 45s |
| 6 | 0d 0h 4m 0s – 0d 0h 8m 0s | 0d 0h 7m 52.5s – 0d 0h 15m 45s |
| 7 | 0d 0h 8m 0s – 0d 0h 16m 0s | 0d 0h 15m 52.5s – 0d 0h 31m 45s |
| 8 | 0d 0h 16m 0s – 0d 0h 32m 0s | 0d 0h 31m 52.5s – 0d 1h 3m 45s |
| 9 | 0d 0h 32m 0s – 0d 1h 4m 0s | 0d 1h 3m 52.5s – 0d 2h 7m 45s |
| 10 | 0d 1h 4m 0s – 0d 2h 8m 0s | 0d 2h 7m 52.5s – 0d 4h 15m 45s |
| 11 | 0d 2h 8m 0s – 0d 4h 16m 0s | 0d 4h 15m 52.5s – 0d 8h 31m 45s |
| 12 | 0d 4h 16m 0s – 0d 8h 32m 0s | 0d 8h 31m 52.5s – 0d 17h 3m 45s |
| 13 | 0d 8h 32m 0s – 0d 17h 4m 0s | 0d 17h 3m 52.5s – 1d 10h 7m 45s |
| 14 | 0d 17h 4m 0s – 1d 10h 8m 0s | 1d 10h 7m 52.5s – 2d 20h 15m 45s |
| 15 | 1d 10h 8m 0s – 2d 20h 16m 0s | 2d 20h 15m 52.5s – 5d 16h 31m 45s |
The formula for the delay is:
`floor(baseRetryDelay * 2^(retryCount - 1) * (jitter ? (0.5 + Math.random() * 0.5) : 1))`.
With jitter enabled (default), actual delays will vary by ±50% to prevent thundering herd effects.
## Examples
### Multi-Service Background Jobs
```javascript
import Queue from '@sturmfrei/litequu';
const queue = new Queue({
dbPath: './jobs.db',
maxConcurrent: 5,
autoProcess: true,
});
// Create jobs for different services
const emailJob = queue.createJob('email');
const imageJob = queue.createJob('image-processing');
const backupJob = queue.createJob('backup');
// Set up handlers
await emailJob.process(async (task) => {
await sendEmail(task.to, task.subject, task.body);
return `Email sent to ${task.to}`;
});
await imageJob.process(async (task) => {
const resized = await resizeImage(task.imageUrl, task.dimensions);
await uploadToS3(resized, task.destination);
return `Image processed: ${task.imageUrl}`;
});
await backupJob.process(async (task) => {
await backupDatabase(task.database);
return `Backup completed for ${task.database}`;
});
// Add tasks - they'll be processed automatically
emailJob.add({
to: 'user@example.com',
subject: 'Welcome!',
body: 'Thanks for signing up',
});
imageJob.add({
imageUrl: 'https://example.com/photo.jpg',
dimensions: { width: 800, height: 600 },
destination: 's3://bucket/photos/thumb.jpg',
});
backupJob.add({
database: 'production',
});
// Monitor specific job types
imageJob.on('failed', (info) => {
console.error(`Image processing failed:`, info.error);
// Could re-queue with different parameters or alert admins
});
```
### Auto-Processing with Polling
```javascript
const queue = new Queue({
autoProcess: true,
pollingInterval: 2000, // Check every 2 seconds
});
// Start processing (runs continuously)
queue.process(async (task) => {
return await handleTask(task);
});
// Tasks will be processed automatically as they're added
queue.add({ work: 'to_do' });
```
### Error Handling and Retries
```javascript
const queue = new Queue({
maxRetries: 3,
baseRetryDelay: 1000,
});
queue.on('retried', (info) => {
console.log(`Retry ${info.retryCount} for task ${info.taskId}`);
});
queue.on('failed', (info) => {
console.log(`Task ${info.taskId} gave up after ${info.retryCount} attempts`);
// Handle permanent failures (e.g., dead letter queue, alerting)
});
queue.process(async (task) => {
// This might fail and trigger retries
if (Math.random() < 0.5) {
throw new Error('Simulated failure');
}
return 'success';
});
```
## Best Practices
### 1. Keep Tasks Lightweight
Since tasks run in the main thread, avoid CPU-intensive operations:
```javascript
// ✅ Good - I/O bound tasks
queue.process(async (task) => {
await sendEmail(task.email);
await uploadFile(task.filePath);
await callWebhook(task.url);
});
// ❌ Avoid - CPU intensive tasks
queue.process(async (task) => {
// This will block the event loop
return heavyComputation(task.data);
});
```
### 2. Handle Errors Gracefully
```javascript
queue.process(async (task) => {
try {
return await processTask(task);
} catch (error) {
// Add context to errors for better debugging
throw new Error(`Failed to process ${task.type}: ${error.message}`);
}
});
```
### 3. Use Jobs for Organization
Instead of a single handler with switches, use named jobs:
```javascript
// ✅ Good - separate jobs for different task types
const emailJob = queue.createJob('email');
const webhookJob = queue.createJob('webhook');
const uploadJob = queue.createJob('file-upload');
await emailJob.process(async (task) => sendEmail(task));
await webhookJob.process(async (task) => callWebhook(task));
await uploadJob.process(async (task) => uploadFile(task));
// ❌ Avoid - single handler with switches (though still supported)
queue.process(async (task) => {
switch (task.type) {
case 'email':
return await sendEmail(task);
case 'webhook':
return await callWebhook(task);
case 'file_upload':
return await uploadFile(task);
default:
throw new Error(`Unknown task type: ${task.type}`);
}
});
```
### 4. Monitor Queue Health
```javascript
// Set up monitoring with job-specific metrics
setInterval(() => {
const stats = queue.getStats();
// Group stats by job
const statsByJob = {};
stats.forEach((stat) => {
if (!statsByJob[stat.job_name]) {
statsByJob[stat.job_name] = { pending: 0, failed: 0, completed: 0 };
}
statsByJob[stat.job_name][stat.status] = stat.count;
});
// Check each job's health
Object.entries(statsByJob).forEach(([jobName, jobStats]) => {
if (jobStats.pending > 1000) {
console.warn(`[${jobName}] Backlog growing:`, jobStats.pending);
}
if (jobStats.failed > 100) {
console.error(`[${jobName}] High failure rate:`, jobStats.failed);
}
});
}, 60000); // Check every minute
```
### 5. Graceful Shutdown
```javascript
process.on('SIGTERM', async () => {
console.log('Shutting down gracefully...');
await queue.close(); // Wait for current tasks to finish
process.exit(0);
});
```
## Limitations
- **Single Process**: Designed for single-process applications
- **Main Thread**: Not suitable for CPU-intensive tasks
- **SQLite Concurrency**: Write operations are serialized by SQLite
- **Memory Usage**: Large task payloads are stored in the database
## Contributing
Contributions are welcome! Please read our contributing guidelines and submit pull requests for any improvements.
## License
MIT License - see LICENSE file for details.