Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

All notable changes to this project will be documented in this file.

## [1.3.0] - 2026-01-15
### Added
- **Configurable retry strategies**: Choose between `exponential` (default), `linear`, or `fixed` backoff strategies
- **Retry configuration**: Set custom `delayMs` (base delay) and `maxDelayMs` (maximum delay cap) for retry attempts
- **Webhook notifications on retry**: Webhooks are now called on each retry attempt with detailed information (attemptNumber, retriesLeft, nextRetryAt)
- **Enhanced webhook payloads**: Improved webhook payloads for both retry and final failure events
### Improved
- Better retry error tracking with enhanced logging showing retry strategy in use
- More granular control over retry behavior per task
### Types
- Added `RetryStrategy` type: `'exponential' | 'linear' | 'fixed'`
- Added `RetryConfig` interface with `strategy`, `delayMs`, and `maxDelayMs` options
- Extended `ScheduleExtra` interface to include optional `retryConfig`

## [1.2.0] - 2026-01-11
### Performance
- Enabled SQLite WAL by default to improve concurrent reads/writes.
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,43 @@
**BlazerJob** is a lightweight, SQLite-backed task scheduler for Node.js and TypeScript applications.
Use it as a library in your code to schedule, execute, and manage asynchronous tasks.

## Why BlazerJob vs BullMQ?

BlazerJob and BullMQ are both task scheduling solutions, but with different philosophies:

### BlazerJob Advantages

| Feature | BlazerJob | BullMQ |
|---------|-----------|---------|
| **Infrastructure** | Zero external dependencies (SQLite) | Requires Redis server |
| **Setup complexity** | Single library, no setup | Redis installation & management required |
| **Deployment** | Single process, embedded DB | Separate Redis deployment |
| **Cost** | Free (no infrastructure) | Redis hosting costs |
| **Performance** | ~4.4k tasks/s (local NVMe) | Higher throughput with Redis |
| **Horizontal scaling** | Limited (single DB file) | Excellent (Redis cluster) |
| **Use case** | Small to medium workloads, embedded apps | Large-scale distributed systems |
| **Blockchain native** | Built-in Cosmos & HTTP connectors | Generic queue (requires custom code) |

### When to choose BlazerJob:
- ✅ You want **zero infrastructure** overhead
- ✅ Simple deployment (single Node.js process)
- ✅ Small to medium workloads (<5k tasks/second)
- ✅ Embedded applications or scripts
- ✅ Blockchain tasks (Cosmos, HTTP APIs)
- ✅ Development & testing environments
- ✅ Cost-conscious projects

### When to choose BullMQ:
- ✅ High-throughput requirements (>10k tasks/second)
- ✅ Distributed architecture across multiple servers
- ✅ Already using Redis in your stack
- ✅ Need Redis-specific features (streams, pub/sub)
- ✅ Enterprise-scale job processing

**In summary:** BlazerJob prioritizes **simplicity and zero dependencies**, while BullMQ prioritizes **scalability and throughput**. Choose based on your infrastructure constraints and workload requirements.

---

# Supported Connectors

BlazerJob currently supports only the following connectors for actual execution:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "blazerjob",
"version": "1.2.0",
"version": "1.3.0",
"description": "TypeScript library for scheduling, executing, and managing asynchronous tasks (custom, HTTP, Cosmos) with a SQLite backend.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
148 changes: 138 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,19 @@ import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing';
import { makeCosmosTaskFn } from './cosmos/queries';
import { makeHttpTaskFn } from './http/queries';

export type RetryStrategy = 'exponential' | 'linear' | 'fixed';

export interface RetryConfig {
strategy?: RetryStrategy;
delayMs?: number;
maxDelayMs?: number;
}

export interface ScheduleExtra {
maxRuns?: number;
maxDurationMs?: number;
onEnd?: (stats: { runCount: number, errorCount: number }) => void;
retryConfig?: RetryConfig;
}

export type OnTaskEnd = (taskId: number, stats: { runCount: number, errorCount: number }) => void;
Expand All @@ -30,8 +39,8 @@ export interface BlazeJobOptions {
export class BlazeJob {
private db: any;
private timer?: NodeJS.Timeout;
// Map: taskId -> { runCount, startedAt, maxRuns, maxDurationMs }
private taskRunStats = new Map<number, { runCount: number, startedAt: number, maxRuns?: number, maxDurationMs?: number, onEnd?: (stats: { runCount: number, errorCount: number }) => void, errorCount?: number }>();
// Map: taskId -> { runCount, startedAt, maxRuns, maxDurationMs, retryConfig }
private taskRunStats = new Map<number, { runCount: number, startedAt: number, maxRuns?: number, maxDurationMs?: number, onEnd?: (stats: { runCount: number, errorCount: number }) => void, errorCount?: number, retryConfig?: RetryConfig }>();
private taskErrorCount = new Map<number, number>();
private periodicTaskCount = 0;
private onAllTasksEndedCb?: OnAllTasksEnded;
Expand Down Expand Up @@ -145,7 +154,7 @@ export class BlazeJob {
try {
config = JSON.parse(config);
} catch (e) {
console.error('[BlazeJob][HTTP] Erreur de parsing config:', e, config);
console.error('[BlazeJob][HTTP] Config parsing error:', e, config);
break;
}
}
Expand Down Expand Up @@ -181,20 +190,137 @@ export class BlazeJob {
try {
this.stop();
} catch (e) {
console.error('[BlazeJob] Erreur lors de l\'arrêt du scheduler', e);
console.error('[BlazeJob] Error stopping scheduler', e);
}
try {
if (this.db && typeof this.db.close === 'function') this.db.close();
} catch (e) {
console.error('[BlazeJob] Erreur lors de la fermeture de la base', e);
console.error('[BlazeJob] Error closing database', e);
}
console.log('[BlazeJob] Toutes les tâches périodiques sont terminées. Arrêt automatique du process.');
console.log('[BlazeJob] All periodic tasks completed. Auto-exiting process.');
process.exit(0);
}, 200);
}
}
} catch (err) {
console.error('[BlazeJob] Erreur lors de l\'exécution de la tâche', task.id, err);
console.error('[BlazeJob] Error executing task', task.id, err);

// Mettre à jour le compteur d'erreurs
if (stat) {
stat.errorCount = (stat.errorCount || 0) + 1;
}

// Sauvegarder l'erreur dans la base
const errorMessage = err instanceof Error ? err.message : String(err);
this.db.prepare(`UPDATE tasks SET lastError = ? WHERE id = ?`).run(errorMessage, task.id);

// Décrémenter retriesLeft
const newRetriesLeft = Math.max(0, task.retriesLeft - 1);

if (newRetriesLeft > 0) {
// Récupérer la configuration de retry
Comment on lines +217 to +221

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry count is off by one

The retry decision is based on newRetriesLeft > 0, but newRetriesLeft is already decremented. That means a task configured with retriesLeft = 1 never retries (the first failure sets newRetriesLeft to 0 and falls into the permanent-failure path). More generally, a task configured for N retries will only get N-1 retries. Given the documented semantics (“number of retry attempts”), this causes fewer attempts than configured; consider checking task.retriesLeft > 0 before decrementing or adjusting the condition so a task with 1 retry actually gets one retry.

Useful? React with 👍 / 👎.

const retryConfig = stat?.retryConfig || {};
const strategy = retryConfig.strategy || 'exponential';
const baseDelayMs = retryConfig.delayMs || 1000;
const maxDelayMs = retryConfig.maxDelayMs || 60000;
const attemptNumber = (stat?.errorCount || 1);

// Calculer le délai selon la stratégie
let backoffMs: number;
switch (strategy) {
case 'fixed':
backoffMs = baseDelayMs;
break;
case 'linear':
backoffMs = baseDelayMs * attemptNumber;
break;
case 'exponential':
default:
backoffMs = baseDelayMs * Math.pow(2, attemptNumber - 1);
break;
}

// Limiter au délai maximum
backoffMs = Math.min(backoffMs, maxDelayMs);
const nextRunAt = new Date(Date.now() + backoffMs).toISOString();

console.log(`[BlazeJob] Task ${task.id} failed (attempt ${attemptNumber}). Retrying in ${backoffMs}ms (strategy: ${strategy}, retriesLeft: ${newRetriesLeft})`);

// Replanifier la tâche
this.db.prepare(`
UPDATE tasks
SET status = 'pending', runAt = ?, retriesLeft = ?
WHERE id = ?
`).run(nextRunAt, newRetriesLeft, task.id);

// Appeler le webhook si configuré (notification de retry)
if (task.webhookUrl) {
BlazeJob.sendWebhook(task.webhookUrl, {
taskId: task.id,
status: 'pending',
executedAt: new Date().toISOString(),
result: 'retry',
output: null,
error: errorMessage,
retriesLeft: newRetriesLeft,
nextRetryAt: nextRunAt,
attemptNumber
});
}
} else {
// Plus de retries disponibles, marquer comme échouée
console.log(`[BlazeJob] Task ${task.id} permanently failed after ${stat?.errorCount || 1} attempt(s)`);

this.db.prepare(`
UPDATE tasks
SET status = 'failed', executed_at = ?, retriesLeft = 0
WHERE id = ?
`).run(new Date().toISOString(), task.id);

// Appeler le webhook si configuré (notification d'échec définitif)
if (task.webhookUrl) {
BlazeJob.sendWebhook(task.webhookUrl, {
taskId: task.id,
status: 'failed',
executedAt: new Date().toISOString(),
result: 'error',
output: null,
error: errorMessage,
totalAttempts: stat?.errorCount || 1
});
}

// Appeler le callback onEnd si présent
if (stat && stat.onEnd) {
stat.onEnd({ runCount: stat.runCount, errorCount: stat.errorCount || 0 });
}

// Nettoyer les stats et décrémenter le compteur de tâches périodiques
this.taskRunStats.delete(task.id);
this.periodicTaskCount--;

// Vérifier si toutes les tâches sont terminées
if (this.periodicTaskCount === 0 && this.onAllTasksEndedCb) {
this.onAllTasksEndedCb();
}

if (this.periodicTaskCount === 0 && this.autoExit) {
setTimeout(() => {
try {
this.stop();
} catch (e) {
console.error('[BlazeJob] Error stopping scheduler', e);
}
try {
if (this.db && typeof this.db.close === 'function') this.db.close();
} catch (e) {
console.error('[BlazeJob] Error closing database', e);
}
console.log('[BlazeJob] All periodic tasks completed. Auto-exiting process.');
process.exit(0);
}, 200);
}
}
} finally {
this.activeTasksCount--;
}
Expand Down Expand Up @@ -291,7 +417,8 @@ export class BlazeJob {
webhookUrl,
maxRuns,
maxDurationMs,
onEnd
onEnd,
retryConfig
} = options;
const stmt = this.db.prepare(`
INSERT INTO tasks (runAt, interval, priority, retriesLeft, type, config, webhookUrl)
Expand All @@ -315,7 +442,8 @@ export class BlazeJob {
maxRuns,
maxDurationMs,
onEnd,
errorCount: 0
errorCount: 0,
retryConfig
});
return taskId;
}
Expand Down Expand Up @@ -409,7 +537,7 @@ export async function stopServer() {
await app.close();
jobs.stop();
jobs['db'].close();
console.log('Serveur et scheduler arrêtés proprement.');
console.log('Server and scheduler stopped gracefully.');
}

// Optionnel : gestion du signal SIGTERM/SIGINT
Expand Down
69 changes: 69 additions & 0 deletions src/tests/test_retry_backoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { BlazeJob } from '../index';
import * as fs from 'fs';

const dbPath = 'test_retry_backoff.db';

// Nettoyer la base avant le test
if (fs.existsSync(dbPath)) {
fs.unlinkSync(dbPath);
}

const jobs = new BlazeJob({
dbPath,
autoExit: true,
concurrency: 1
});

let attemptCount = 0;
const maxAttempts = 3;
const startTime = Date.now();

// Tâche qui échoue 2 fois, puis réussit
const taskId = jobs.schedule(async () => {
attemptCount++;
const elapsed = Date.now() - startTime;

console.log(`[Test] Tentative ${attemptCount} à ${elapsed}ms`);

if (attemptCount < maxAttempts) {
throw new Error(`Échec simulé - tentative ${attemptCount}/${maxAttempts}`);
}

console.log(`[Test] Succès après ${attemptCount} tentatives !`);
}, {
runAt: new Date(),
retriesLeft: 3,
priority: 1,
type: 'custom',
onEnd: (stats: { runCount: number, errorCount: number }) => {
console.log('[Test] Statistiques finales:', stats);
console.log(`[Test] Nombre total de tentatives: ${attemptCount}`);

// Vérifications
if (attemptCount === maxAttempts) {
console.log('✅ Test réussi : La tâche a été réessayée le bon nombre de fois');
} else {
console.error(`❌ Test échoué : Attendu ${maxAttempts} tentatives, obtenu ${attemptCount}`);
}

if (stats.errorCount === maxAttempts - 1) {
console.log('✅ Test réussi : Le compteur d\'erreurs est correct');
} else {
console.error(`❌ Test échoué : Attendu ${maxAttempts - 1} erreurs, obtenu ${stats.errorCount}`);
}

if (stats.runCount === 1) {
console.log('✅ Test réussi : La tâche a réussi une fois');
} else {
console.error(`❌ Test échoué : Attendu 1 run réussi, obtenu ${stats.runCount}`);
}
}
});

console.log(`[Test] Tâche créée avec ID: ${taskId}`);
console.log('[Test] Configuration:');
console.log(' - retriesLeft: 3');
console.log(' - Backoff exponentiel attendu: 1s, 2s');
console.log(' - Nombre de tentatives attendu: 3 (2 échecs + 1 succès)');

jobs.start();
Loading