feat: add reusable jobqueue library
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
36
.gitea/workflows/publish.yml
Normal file
36
.gitea/workflows/publish.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
test-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: https://git.sal.giize.com/api/packages/mozempk/npm/
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Publish
|
||||
run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITEA_NPM_TOKEN }}
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
coverage/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Giancarmine Salucci
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
253
README.md
Normal file
253
README.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# jobqueue
|
||||
|
||||
Framework-agnostic async job queue for Node.js with:
|
||||
|
||||
- **SQLite persistence** via `better-sqlite3` + WAL mode
|
||||
- **Multi-phase pipelines** with per-phase progress and phase result passing
|
||||
- **Concurrency control** with a worker pool
|
||||
- **Automatic retry** with fixed, linear, or exponential backoff
|
||||
- **SSE helpers** for progress/completion streaming
|
||||
- **Outbound webhooks** with optional HMAC-SHA256 signing
|
||||
- **Retention cleanup** for stale and expired jobs
|
||||
- **ESM-only**, **Node 20+**
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install jobqueue --registry=https://git.sal.giize.com/api/packages/mozempk/npm/
|
||||
```
|
||||
|
||||
To make installs/publishes use Gitea by default:
|
||||
|
||||
```ini
|
||||
registry=https://git.sal.giize.com/api/packages/mozempk/npm/
|
||||
//git.sal.giize.com/api/packages/mozempk/npm/:_authToken=${NODE_AUTH_TOKEN}
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { JobQueue } from 'jobqueue';
|
||||
|
||||
const queue = new JobQueue<{ url: string }>({
|
||||
dbPath: './data/jobs.db',
|
||||
phases: ['download', 'process', 'upload'],
|
||||
concurrency: 2,
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
strategy: 'exponential',
|
||||
baseDelayMs: 1_000,
|
||||
maxDelayMs: 30_000,
|
||||
classifyError: async (error) => {
|
||||
if (error instanceof Error && error.message.includes('ECONNREFUSED')) {
|
||||
return 'recoverable';
|
||||
}
|
||||
|
||||
return 'fatal';
|
||||
},
|
||||
},
|
||||
webhook: {
|
||||
url: 'https://example.com/hooks/jobs',
|
||||
events: ['job:completed', 'job:failed'],
|
||||
secret: process.env.JOBQUEUE_WEBHOOK_SECRET,
|
||||
},
|
||||
retention: {
|
||||
staleAfterMs: 7 * 24 * 60 * 60 * 1000,
|
||||
deleteAfterMs: 30 * 24 * 60 * 60 * 1000,
|
||||
onDelete: async (job) => {
|
||||
console.log(`Deleting ${job.id}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
queue.handle('download', async (job, ctx) => {
|
||||
await ctx.progress(10, 'Downloading source');
|
||||
return { filePath: `/tmp/${job.id}.mp4` };
|
||||
});
|
||||
|
||||
queue.handle('process', async (_job, ctx) => {
|
||||
const download = ctx.phaseResult<{ filePath: string }>('download');
|
||||
await ctx.progress(50, 'Processing file');
|
||||
return { outputPath: `${download?.filePath}.json` };
|
||||
});
|
||||
|
||||
queue.handle('upload', async (_job, ctx) => {
|
||||
const processed = ctx.phaseResult<{ outputPath: string }>('process');
|
||||
await ctx.progress(100, 'Uploading result');
|
||||
return { uploaded: Boolean(processed?.outputPath) };
|
||||
});
|
||||
|
||||
queue.on('job:completed', (job) => {
|
||||
console.log('completed', job.id, job.phaseResults);
|
||||
});
|
||||
|
||||
const jobId = await queue.enqueue({
|
||||
url: 'https://example.com/video',
|
||||
});
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `new JobQueue(config)`
|
||||
|
||||
```ts
|
||||
interface QueueConfig<TData> {
|
||||
dbPath: string;
|
||||
phases: readonly string[];
|
||||
concurrency?: number;
|
||||
retry?: RetryConfig<TData>;
|
||||
retention?: RetentionConfig<TData>;
|
||||
webhook?: WebhookConfig;
|
||||
shutdownTimeoutMs?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### `queue.handle(phaseName, handler)`
|
||||
|
||||
Register one handler per phase.
|
||||
|
||||
```ts
|
||||
type PhaseHandler<TData> = (
|
||||
job: JobRecord<TData>,
|
||||
context: PhaseContext<TData>,
|
||||
) => Promise<JsonValue | undefined> | JsonValue | undefined;
|
||||
```
|
||||
|
||||
### `queue.enqueue(data, options?)`
|
||||
|
||||
Creates a new job and returns its ID.
|
||||
|
||||
```ts
|
||||
await queue.enqueue(
|
||||
{ url: 'https://example.com/video' },
|
||||
{
|
||||
scheduledAt: new Date(Date.now() + 5_000),
|
||||
maxAttempts: 5,
|
||||
webhookUrl: 'https://example.com/job-specific-hook',
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### `queue.getJob(id)` / `queue.listJobs(options)`
|
||||
|
||||
Inspect persisted jobs.
|
||||
|
||||
### `queue.retry(id, options?)`
|
||||
|
||||
Manual retry. By default it restarts from the first phase.
|
||||
|
||||
```ts
|
||||
await queue.retry(jobId, { fromStart: true });
|
||||
```
|
||||
|
||||
### `queue.cancel(id)`
|
||||
|
||||
Cooperative cancellation. Active handlers receive an `AbortSignal` at `ctx.signal`.
|
||||
|
||||
### `queue.createEventStream(options?)`
|
||||
|
||||
Returns a web `ReadableStream<Uint8Array>` ready to plug into Express, Hono, SvelteKit, Fastify, or plain Node responses.
|
||||
|
||||
```ts
|
||||
const stream = queue.createEventStream({
|
||||
jobId,
|
||||
includeSnapshot: true,
|
||||
});
|
||||
```
|
||||
|
||||
Use headers from `SseSerializer.headers()`.
|
||||
|
||||
### `queue.shutdown()`
|
||||
|
||||
Stops retention timers, waits for active jobs, then closes SQLite cleanly.
|
||||
|
||||
## Retry behavior
|
||||
|
||||
`maxAttempts` includes the first attempt.
|
||||
|
||||
- `maxAttempts: 1` → never auto-retry
|
||||
- `maxAttempts: 3` → first run + 2 retries
|
||||
|
||||
Error classification decides whether a failure is retryable:
|
||||
|
||||
```ts
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
classifyError: async (error) => {
|
||||
if (error instanceof Error && /timeout|econnrefused/i.test(error.message)) {
|
||||
return 'recoverable';
|
||||
}
|
||||
|
||||
return 'fatal';
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Webhooks
|
||||
|
||||
Supported outbound events:
|
||||
|
||||
- `job:completed`
|
||||
- `job:failed`
|
||||
- `job:retrying`
|
||||
- `job:cancelled`
|
||||
- `job:stale`
|
||||
|
||||
Payload shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "job:completed",
|
||||
"emittedAt": "2026-05-15T22:00:00.000Z",
|
||||
"job": {
|
||||
"id": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `secret` is configured, jobqueue sends:
|
||||
|
||||
```text
|
||||
X-JobQueue-Event: job:completed
|
||||
X-JobQueue-Signature: <hex hmac sha256>
|
||||
```
|
||||
|
||||
## SSE events
|
||||
|
||||
Stream payloads are emitted with these event names:
|
||||
|
||||
- `snapshot`
|
||||
- `job:enqueued`
|
||||
- `job:started`
|
||||
- `job:progress`
|
||||
- `job:phase:completed`
|
||||
- `job:completed`
|
||||
- `job:failed`
|
||||
- `job:retrying`
|
||||
- `job:cancelled`
|
||||
- `job:stale`
|
||||
- `job:deleted`
|
||||
- `job:webhook:delivered`
|
||||
- `job:webhook:failed`
|
||||
- `ping`
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run lint
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Publish from Gitea Actions
|
||||
|
||||
Create repo secret:
|
||||
|
||||
- `GITEA_NPM_TOKEN` → token with package publish permissions
|
||||
|
||||
Publishing flow:
|
||||
|
||||
1. Push code to `mozempk/jobqueue`
|
||||
2. Create tag: `git tag v0.1.0 && git push origin v0.1.0`
|
||||
3. Gitea Actions runs tests, build, and `npm publish`
|
||||
3394
package-lock.json
generated
Normal file
3394
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
package.json
Normal file
62
package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "jobqueue",
|
||||
"version": "0.1.0",
|
||||
"description": "Framework-agnostic async job queue with multi-phase pipelines, SSE helpers, webhooks, retry strategies, and SQLite persistence.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "ssh://git@git.sal.giize.com:2222/mozempk/jobqueue.git"
|
||||
},
|
||||
"homepage": "https://git.sal.giize.com/mozempk/jobqueue",
|
||||
"publishConfig": {
|
||||
"registry": "https://git.sal.giize.com/api/packages/mozempk/npm/"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run lint && npm run test && npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"job-queue",
|
||||
"async",
|
||||
"pipeline",
|
||||
"worker",
|
||||
"sse",
|
||||
"webhook",
|
||||
"sqlite",
|
||||
"retry",
|
||||
"concurrency"
|
||||
],
|
||||
"author": "Giancarmine Salucci <mozempk@gmail.com>",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"p-limit": "^6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^3.1.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.0",
|
||||
"tsup": "^8.4.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
721
src/JobQueue.ts
Normal file
721
src/JobQueue.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
import { CancellationError } from './util/errors.js';
|
||||
import { TypedEventBus } from './events/EventBus.js';
|
||||
import { SseSerializer } from './events/SseSerializer.js';
|
||||
import { WorkerPool } from './processor/WorkerPool.js';
|
||||
import { PhaseRunner } from './processor/PhaseRunner.js';
|
||||
import { RetryStrategy } from './retry/RetryStrategy.js';
|
||||
import { SqliteStorage } from './storage/SqliteStorage.js';
|
||||
import { RetentionScheduler } from './retention/RetentionScheduler.js';
|
||||
import { WebhookDispatcher } from './webhook/WebhookDispatcher.js';
|
||||
import { createJobId } from './util/id.js';
|
||||
|
||||
import type {
|
||||
EnqueueOptions,
|
||||
JobData,
|
||||
JobFailure,
|
||||
JobPhaseState,
|
||||
JobProgressEvent,
|
||||
JobQueueEvents,
|
||||
JobRecord,
|
||||
ListJobsOptions,
|
||||
PhaseHandler,
|
||||
QueueConfig,
|
||||
QueueStreamEvent,
|
||||
RetryOptions,
|
||||
StreamOptions,
|
||||
WebhookDispatchError,
|
||||
WebhookEventName,
|
||||
} from './types.js';
|
||||
|
||||
const DEFAULT_CONCURRENCY = 1;
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createInitialPhases(phaseNames: readonly string[]): JobPhaseState[] {
|
||||
return phaseNames.map((name) => ({
|
||||
name,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
message: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function clonePhases(phases: JobPhaseState[]): JobPhaseState[] {
|
||||
return phases.map((phase) => ({ ...phase }));
|
||||
}
|
||||
|
||||
function normalizeQueueConfig<TData extends JobData>(config: QueueConfig<TData>): QueueConfig<TData> {
|
||||
const phases = [...new Set(config.phases)];
|
||||
|
||||
if (phases.length === 0) {
|
||||
throw new Error('QueueConfig.phases must contain at least one phase');
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
phases,
|
||||
concurrency: config.concurrency ?? DEFAULT_CONCURRENCY,
|
||||
shutdownTimeoutMs: config.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export class JobQueue<TData extends JobData = JobData> {
|
||||
private readonly config: QueueConfig<TData>;
|
||||
private readonly storage: SqliteStorage<TData>;
|
||||
private readonly events = new TypedEventBus<JobQueueEvents<TData>>();
|
||||
private readonly retryStrategy: RetryStrategy<TData>;
|
||||
private readonly workerPool: WorkerPool;
|
||||
private readonly handlers = new Map<string, PhaseHandler<TData>>();
|
||||
private readonly serializer = new SseSerializer();
|
||||
private readonly controllers = new Map<string, AbortController>();
|
||||
private readonly webhookDispatcher?: WebhookDispatcher<TData>;
|
||||
private readonly retentionScheduler?: RetentionScheduler<TData>;
|
||||
private wakeupTimer: NodeJS.Timeout | null = null;
|
||||
private closed = false;
|
||||
private pumping = false;
|
||||
private repumpRequested = false;
|
||||
|
||||
public constructor(queueConfig: QueueConfig<TData>) {
|
||||
this.config = normalizeQueueConfig(queueConfig);
|
||||
this.storage = new SqliteStorage<TData>(this.config.dbPath);
|
||||
this.retryStrategy = new RetryStrategy(this.config.retry);
|
||||
this.workerPool = new WorkerPool(this.config.concurrency ?? DEFAULT_CONCURRENCY);
|
||||
this.webhookDispatcher = this.config.webhook
|
||||
? new WebhookDispatcher<TData>(this.config.webhook)
|
||||
: undefined;
|
||||
|
||||
this.storage.resetActiveJobs('Interrupted by process restart');
|
||||
|
||||
if (this.config.retention) {
|
||||
this.retentionScheduler = new RetentionScheduler(this.config.retention, {
|
||||
markStale: async (cutoffIso) => this.markStaleJobs(cutoffIso),
|
||||
deleteStale: async (cutoffIso) => this.deleteStaleJobs(cutoffIso),
|
||||
});
|
||||
this.retentionScheduler.start();
|
||||
}
|
||||
|
||||
this.requestPump();
|
||||
}
|
||||
|
||||
public handle(phaseName: string, handler: PhaseHandler<TData>): this {
|
||||
if (!this.config.phases.includes(phaseName)) {
|
||||
throw new Error(`Phase "${phaseName}" is not defined in queue config`);
|
||||
}
|
||||
|
||||
this.handlers.set(phaseName, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public on<TName extends keyof JobQueueEvents<TData> & string>(
|
||||
event: TName,
|
||||
listener: (...args: JobQueueEvents<TData>[TName]) => void,
|
||||
): () => void {
|
||||
return this.events.on(event, listener);
|
||||
}
|
||||
|
||||
public once<TName extends keyof JobQueueEvents<TData> & string>(
|
||||
event: TName,
|
||||
listener: (...args: JobQueueEvents<TData>[TName]) => void,
|
||||
): () => void {
|
||||
return this.events.once(event, listener);
|
||||
}
|
||||
|
||||
public async enqueue(data: TData, options: EnqueueOptions = {}): Promise<string> {
|
||||
this.ensureOpen();
|
||||
const id = options.id ?? createJobId();
|
||||
const job = this.storage.createJob(
|
||||
id,
|
||||
data,
|
||||
createInitialPhases(this.config.phases),
|
||||
options,
|
||||
options.maxAttempts ?? this.retryStrategy.defaultMaxAttempts(),
|
||||
);
|
||||
|
||||
this.events.emit('job:enqueued', job);
|
||||
this.requestPump();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public getJob(id: string): JobRecord<TData> | null {
|
||||
return this.storage.getJob(id);
|
||||
}
|
||||
|
||||
public listJobs(options: ListJobsOptions = {}): JobRecord<TData>[] {
|
||||
return this.storage.listJobs(options);
|
||||
}
|
||||
|
||||
public async retry(id: string, options: RetryOptions = {}): Promise<JobRecord<TData>> {
|
||||
this.ensureOpen();
|
||||
const existing = this.requireJob(id);
|
||||
|
||||
if (!['failed', 'cancelled', 'stale'].includes(existing.status)) {
|
||||
throw new Error(`Job ${id} cannot be retried from status ${existing.status}`);
|
||||
}
|
||||
|
||||
const phases =
|
||||
options.fromStart === false
|
||||
? clonePhases(existing.phases).map<JobPhaseState>((phase) =>
|
||||
phase.status === 'completed'
|
||||
? phase
|
||||
: {
|
||||
...phase,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
message: null,
|
||||
completedAt: null,
|
||||
error: null,
|
||||
},
|
||||
)
|
||||
: createInitialPhases(this.config.phases);
|
||||
const retried = this.storage.resetForRetry(
|
||||
id,
|
||||
phases,
|
||||
existing.maxAttempts,
|
||||
options.scheduledAt instanceof Date ? options.scheduledAt.toISOString() : options.scheduledAt ?? null,
|
||||
);
|
||||
|
||||
this.events.emit('job:enqueued', retried);
|
||||
this.requestPump();
|
||||
|
||||
return retried;
|
||||
}
|
||||
|
||||
public async cancel(id: string): Promise<JobRecord<TData>> {
|
||||
const job = this.requireJob(id);
|
||||
|
||||
if (!['pending', 'active'].includes(job.status)) {
|
||||
return job;
|
||||
}
|
||||
|
||||
const phases = clonePhases(job.phases);
|
||||
const activePhaseIndex = phases.findIndex((phase) => phase.status === 'active');
|
||||
if (activePhaseIndex >= 0) {
|
||||
phases[activePhaseIndex] = {
|
||||
...phases[activePhaseIndex],
|
||||
status: 'cancelled',
|
||||
completedAt: now(),
|
||||
};
|
||||
}
|
||||
|
||||
const controller = this.controllers.get(id);
|
||||
controller?.abort(new CancellationError());
|
||||
|
||||
const cancelled = this.storage.cancelJob(id, phases);
|
||||
this.events.emit('job:cancelled', cancelled);
|
||||
void this.dispatchWebhook('job:cancelled', cancelled);
|
||||
this.requestPump();
|
||||
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
public createEventStream(options: StreamOptions = {}): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const keepAliveMs = options.keepAliveMs ?? 30_000;
|
||||
let cleanup = () => {};
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start: (controller) => {
|
||||
if (options.includeSnapshot !== false) {
|
||||
if (options.jobId) {
|
||||
const job = this.getJob(options.jobId);
|
||||
if (job) {
|
||||
this.enqueueStreamEvent(controller, encoder, 'snapshot', {
|
||||
type: 'snapshot',
|
||||
jobId: job.id,
|
||||
job,
|
||||
timestamp: now(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const job of this.listJobs({ limit: 100 })) {
|
||||
this.enqueueStreamEvent(controller, encoder, 'snapshot', {
|
||||
type: 'snapshot',
|
||||
jobId: job.id,
|
||||
job,
|
||||
timestamp: now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribers = [
|
||||
this.on('job:enqueued', (job) => this.pushJobEvent(controller, encoder, 'job:enqueued', job, options.jobId)),
|
||||
this.on('job:started', (job) => this.pushJobEvent(controller, encoder, 'job:started', job, options.jobId)),
|
||||
this.on('job:progress', (job, progress) => {
|
||||
if (!this.matchesStreamJob(options.jobId, job.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enqueueStreamEvent(controller, encoder, 'job:progress', {
|
||||
type: 'job:progress',
|
||||
jobId: job.id,
|
||||
job,
|
||||
progress,
|
||||
timestamp: now(),
|
||||
});
|
||||
}),
|
||||
this.on('job:phase:completed', (job, phase) => {
|
||||
if (!this.matchesStreamJob(options.jobId, job.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enqueueStreamEvent(controller, encoder, 'job:phase:completed', {
|
||||
type: 'job:phase:completed',
|
||||
jobId: job.id,
|
||||
job,
|
||||
phase,
|
||||
timestamp: now(),
|
||||
});
|
||||
}),
|
||||
this.on('job:completed', (job) => this.pushJobEvent(controller, encoder, 'job:completed', job, options.jobId)),
|
||||
this.on('job:failed', (job, failure) => {
|
||||
if (!this.matchesStreamJob(options.jobId, job.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enqueueStreamEvent(controller, encoder, 'job:failed', {
|
||||
type: 'job:failed',
|
||||
jobId: job.id,
|
||||
job,
|
||||
failure,
|
||||
timestamp: now(),
|
||||
});
|
||||
}),
|
||||
this.on('job:retrying', (job, retry) => {
|
||||
if (!this.matchesStreamJob(options.jobId, job.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enqueueStreamEvent(controller, encoder, 'job:retrying', {
|
||||
type: 'job:retrying',
|
||||
jobId: job.id,
|
||||
job,
|
||||
retry,
|
||||
timestamp: now(),
|
||||
});
|
||||
}),
|
||||
this.on('job:cancelled', (job) => this.pushJobEvent(controller, encoder, 'job:cancelled', job, options.jobId)),
|
||||
this.on('job:stale', (job) => this.pushJobEvent(controller, encoder, 'job:stale', job, options.jobId)),
|
||||
this.on('job:deleted', (jobId) => {
|
||||
if (!this.matchesStreamJob(options.jobId, jobId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enqueueStreamEvent(controller, encoder, 'job:deleted', {
|
||||
type: 'job:deleted',
|
||||
deletedJobId: jobId,
|
||||
jobId,
|
||||
timestamp: now(),
|
||||
});
|
||||
}),
|
||||
this.on('job:webhook:delivered', (job, webhook) => {
|
||||
if (!this.matchesStreamJob(options.jobId, job.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enqueueStreamEvent(controller, encoder, 'job:webhook:delivered', {
|
||||
type: 'job:webhook:delivered',
|
||||
jobId: job.id,
|
||||
job,
|
||||
webhook,
|
||||
timestamp: now(),
|
||||
});
|
||||
}),
|
||||
this.on('job:webhook:failed', (job, webhook) => {
|
||||
if (!this.matchesStreamJob(options.jobId, job.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enqueueStreamEvent(controller, encoder, 'job:webhook:failed', {
|
||||
type: 'job:webhook:failed',
|
||||
jobId: job.id,
|
||||
job,
|
||||
webhook,
|
||||
timestamp: now(),
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
const keepAlive = setInterval(() => {
|
||||
this.enqueueStreamEvent(controller, encoder, 'ping', {
|
||||
type: 'ping',
|
||||
timestamp: now(),
|
||||
});
|
||||
}, keepAliveMs);
|
||||
keepAlive.unref?.();
|
||||
|
||||
cleanup = () => {
|
||||
clearInterval(keepAlive);
|
||||
for (const unsubscribe of unsubscribers) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
},
|
||||
cancel: () => {
|
||||
cleanup();
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async shutdown(timeoutMs = this.config.shutdownTimeoutMs): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
this.retentionScheduler?.stop();
|
||||
|
||||
if (this.wakeupTimer) {
|
||||
clearTimeout(this.wakeupTimer);
|
||||
this.wakeupTimer = null;
|
||||
}
|
||||
|
||||
await this.workerPool.drain(timeoutMs);
|
||||
this.events.removeAllListeners();
|
||||
this.storage.close();
|
||||
}
|
||||
|
||||
private async pump(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pumping) {
|
||||
this.repumpRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.pumping = true;
|
||||
|
||||
try {
|
||||
do {
|
||||
this.repumpRequested = false;
|
||||
|
||||
while (this.workerPool.hasCapacity()) {
|
||||
const candidates = this.storage.listRunnableJobs(this.workerPool.availableSlots);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
let dispatched = 0;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!this.workerPool.hasCapacity()) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!this.storage.claimPendingJob(candidate.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const claimed = this.requireJob(candidate.id);
|
||||
this.events.emit('job:started', claimed);
|
||||
this.workerPool
|
||||
.run(async () => this.processJob(claimed.id))
|
||||
.finally(() => {
|
||||
this.requestPump();
|
||||
});
|
||||
dispatched += 1;
|
||||
}
|
||||
|
||||
if (dispatched === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduleWakeup();
|
||||
} while (this.repumpRequested);
|
||||
} finally {
|
||||
this.pumping = false;
|
||||
}
|
||||
}
|
||||
|
||||
private requestPump(): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.pump();
|
||||
}
|
||||
|
||||
private scheduleWakeup(): void {
|
||||
if (this.wakeupTimer) {
|
||||
clearTimeout(this.wakeupTimer);
|
||||
this.wakeupTimer = null;
|
||||
}
|
||||
|
||||
const nextScheduledAt = this.storage.getNextScheduledAt();
|
||||
if (!nextScheduledAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = Math.max(new Date(nextScheduledAt).getTime() - Date.now(), 0);
|
||||
this.wakeupTimer = setTimeout(() => {
|
||||
this.wakeupTimer = null;
|
||||
this.requestPump();
|
||||
}, delay);
|
||||
this.wakeupTimer.unref?.();
|
||||
}
|
||||
|
||||
private async processJob(jobId: string): Promise<void> {
|
||||
const job = this.requireJob(jobId);
|
||||
const controller = new AbortController();
|
||||
this.controllers.set(jobId, controller);
|
||||
|
||||
try {
|
||||
const runner = new PhaseRunner<TData>({
|
||||
handlers: this.handlers,
|
||||
phases: this.config.phases,
|
||||
onPhaseStarted: async (phaseName, phases) =>
|
||||
this.storage.saveProgress(jobId, phaseName, phases, this.requireJob(jobId).progress, null),
|
||||
onProgress: async (phaseName, phases, phaseProgress, overallProgress, message, details) => {
|
||||
const updated = this.storage.saveProgress(jobId, phaseName, phases, overallProgress, message ?? null);
|
||||
const progressEvent: JobProgressEvent = {
|
||||
jobId,
|
||||
phase: phaseName,
|
||||
phaseProgress,
|
||||
overallProgress,
|
||||
message: message ?? null,
|
||||
timestamp: now(),
|
||||
...(details !== undefined ? { details } : {}),
|
||||
};
|
||||
|
||||
this.events.emit('job:progress', updated, progressEvent);
|
||||
},
|
||||
onPhaseCompleted: async (phaseName, phases, phaseResults, overallProgress) => {
|
||||
const updated = this.storage.savePhaseCompletion(
|
||||
jobId,
|
||||
phases,
|
||||
phaseResults,
|
||||
null,
|
||||
overallProgress,
|
||||
null,
|
||||
);
|
||||
const completedPhase = updated.phases.find((phase) => phase.name === phaseName);
|
||||
if (!completedPhase) {
|
||||
throw new Error(`Completed phase ${phaseName} not found`);
|
||||
}
|
||||
|
||||
this.events.emit('job:phase:completed', updated, completedPhase);
|
||||
return updated;
|
||||
},
|
||||
onCancelled: async (phaseName, phases) => {
|
||||
const activePhases = clonePhases(phases);
|
||||
if (phaseName) {
|
||||
const active = activePhases.find((phase) => phase.name === phaseName);
|
||||
if (active) {
|
||||
active.status = 'cancelled';
|
||||
active.completedAt = now();
|
||||
}
|
||||
}
|
||||
|
||||
const cancelled = this.storage.cancelJob(jobId, activePhases);
|
||||
this.events.emit('job:cancelled', cancelled);
|
||||
void this.dispatchWebhook('job:cancelled', cancelled);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runner.run(job, controller.signal);
|
||||
const latest = this.requireJob(jobId);
|
||||
|
||||
if (latest.status === 'cancelled') {
|
||||
return;
|
||||
}
|
||||
|
||||
const completed = this.storage.completeJob(jobId, result.phases, result.phaseResults);
|
||||
this.events.emit('job:completed', completed);
|
||||
void this.dispatchWebhook('job:completed', completed);
|
||||
} catch (error) {
|
||||
if (error instanceof CancellationError) {
|
||||
const current = this.requireJob(jobId);
|
||||
if (current.status !== 'cancelled') {
|
||||
const cancelled = this.storage.cancelJob(jobId, clonePhases(current.phases));
|
||||
this.events.emit('job:cancelled', cancelled);
|
||||
void this.dispatchWebhook('job:cancelled', cancelled);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.handleFailure(jobId, error);
|
||||
} finally {
|
||||
this.controllers.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFailure(jobId: string, error: unknown): Promise<void> {
|
||||
const current = this.requireJob(jobId);
|
||||
const recoverability = await this.retryStrategy.shouldRetry(error, current);
|
||||
const phaseName = current.currentPhase;
|
||||
const phases = clonePhases(current.phases);
|
||||
const activePhase = phaseName ? phases.find((phase) => phase.name === phaseName) : undefined;
|
||||
|
||||
if (activePhase) {
|
||||
activePhase.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
const failure: JobFailure = {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
phase: phaseName,
|
||||
recoverable: recoverability.disposition === 'recoverable',
|
||||
timestamp: now(),
|
||||
attempt: current.retryCount + 1,
|
||||
};
|
||||
|
||||
if (recoverability.retry && activePhase) {
|
||||
activePhase.status = 'pending';
|
||||
activePhase.progress = 0;
|
||||
activePhase.message = null;
|
||||
activePhase.completedAt = null;
|
||||
|
||||
const nextRunAt = new Date(Date.now() + recoverability.delayMs).toISOString();
|
||||
const pending = this.storage.scheduleRetry(
|
||||
jobId,
|
||||
phases,
|
||||
current.progress,
|
||||
failure,
|
||||
current.retryCount + 1,
|
||||
nextRunAt,
|
||||
);
|
||||
const retry = {
|
||||
jobId,
|
||||
phase: phaseName,
|
||||
attempt: pending.retryCount,
|
||||
delayMs: recoverability.delayMs,
|
||||
nextRunAt,
|
||||
timestamp: now(),
|
||||
};
|
||||
|
||||
this.events.emit('job:retrying', pending, retry);
|
||||
void this.dispatchWebhook('job:retrying', pending);
|
||||
this.requestPump();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activePhase) {
|
||||
activePhase.status = 'failed';
|
||||
activePhase.completedAt = now();
|
||||
}
|
||||
|
||||
const failed = this.storage.failJob(jobId, phases, failure);
|
||||
this.events.emit('job:failed', failed, failure);
|
||||
void this.dispatchWebhook('job:failed', failed);
|
||||
}
|
||||
|
||||
private async markStaleJobs(cutoffIso: string): Promise<JobRecord<TData>[]> {
|
||||
const staleJobs = this.storage.markStaleJobs(cutoffIso);
|
||||
|
||||
for (const job of staleJobs) {
|
||||
if (this.config.retention?.onStale) {
|
||||
await this.config.retention.onStale(job);
|
||||
}
|
||||
|
||||
this.events.emit('job:stale', job);
|
||||
void this.dispatchWebhook('job:stale', job);
|
||||
}
|
||||
|
||||
return staleJobs;
|
||||
}
|
||||
|
||||
private async deleteStaleJobs(cutoffIso: string): Promise<JobRecord<TData>[]> {
|
||||
const deletedJobs = this.storage.deleteStaleJobs(cutoffIso);
|
||||
|
||||
for (const job of deletedJobs) {
|
||||
if (this.config.retention?.onDelete) {
|
||||
await this.config.retention.onDelete(job);
|
||||
}
|
||||
|
||||
this.events.emit('job:deleted', job.id);
|
||||
}
|
||||
|
||||
return deletedJobs;
|
||||
}
|
||||
|
||||
private async dispatchWebhook(event: WebhookEventName, job: JobRecord<TData>): Promise<void> {
|
||||
if (!this.webhookDispatcher?.supports(event, job)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.webhookDispatcher.dispatch(event, job);
|
||||
this.storage.markWebhookSent(job.id);
|
||||
const refreshed = this.requireJob(job.id);
|
||||
this.events.emit('job:webhook:delivered', refreshed, result);
|
||||
} catch (error) {
|
||||
const dispatchError =
|
||||
error instanceof Error && 'dispatchError' in error
|
||||
? ((error as { dispatchError?: WebhookDispatchError }).dispatchError ?? {
|
||||
event,
|
||||
jobId: job.id,
|
||||
message: error.message,
|
||||
finalAttempt: 1,
|
||||
})
|
||||
: {
|
||||
event,
|
||||
jobId: job.id,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
finalAttempt: 1,
|
||||
};
|
||||
|
||||
const refreshed = this.requireJob(job.id);
|
||||
this.events.emit('job:webhook:failed', refreshed, dispatchError);
|
||||
}
|
||||
}
|
||||
|
||||
private pushJobEvent(
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
encoder: { encode: (input?: string) => Uint8Array },
|
||||
type: Exclude<QueueStreamEvent['type'], 'snapshot' | 'job:progress' | 'job:phase:completed' | 'job:failed' | 'job:retrying' | 'job:deleted' | 'job:webhook:delivered' | 'job:webhook:failed' | 'ping'>,
|
||||
job: JobRecord<TData>,
|
||||
jobIdFilter?: string,
|
||||
): void {
|
||||
if (!this.matchesStreamJob(jobIdFilter, job.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enqueueStreamEvent(controller, encoder, type, {
|
||||
type,
|
||||
jobId: job.id,
|
||||
job,
|
||||
timestamp: now(),
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueStreamEvent(
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
encoder: { encode: (input?: string) => Uint8Array },
|
||||
eventName: string,
|
||||
payload: QueueStreamEvent<TData>,
|
||||
): void {
|
||||
controller.enqueue(encoder.encode(this.serializer.event(eventName, payload)));
|
||||
}
|
||||
|
||||
private matchesStreamJob(jobIdFilter: string | undefined, jobId: string): boolean {
|
||||
return !jobIdFilter || jobIdFilter === jobId;
|
||||
}
|
||||
|
||||
private requireJob(id: string): JobRecord<TData> {
|
||||
const job = this.storage.getJob(id);
|
||||
|
||||
if (!job) {
|
||||
throw new Error(`Job ${id} not found`);
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
private ensureOpen(): void {
|
||||
if (this.closed) {
|
||||
throw new Error('JobQueue is shut down');
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/events/EventBus.ts
Normal file
47
src/events/EventBus.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
type ExtractArgs<TValue> = TValue extends unknown[] ? TValue : never;
|
||||
|
||||
export class TypedEventBus<TEvents extends object> {
|
||||
private readonly emitter = new EventEmitter();
|
||||
|
||||
public on<TName extends keyof TEvents & string>(
|
||||
event: TName,
|
||||
listener: (...args: ExtractArgs<TEvents[TName]>) => void,
|
||||
): () => void {
|
||||
this.emitter.on(event, listener as (...args: unknown[]) => void);
|
||||
return () => {
|
||||
this.off(event, listener);
|
||||
};
|
||||
}
|
||||
|
||||
public once<TName extends keyof TEvents & string>(
|
||||
event: TName,
|
||||
listener: (...args: ExtractArgs<TEvents[TName]>) => void,
|
||||
): () => void {
|
||||
const wrapped = (...args: ExtractArgs<TEvents[TName]>) => {
|
||||
this.off(event, wrapped);
|
||||
listener(...args);
|
||||
};
|
||||
|
||||
return this.on(event, wrapped);
|
||||
}
|
||||
|
||||
public off<TName extends keyof TEvents & string>(
|
||||
event: TName,
|
||||
listener: (...args: ExtractArgs<TEvents[TName]>) => void,
|
||||
): void {
|
||||
this.emitter.off(event, listener as (...args: unknown[]) => void);
|
||||
}
|
||||
|
||||
public emit<TName extends keyof TEvents & string>(
|
||||
event: TName,
|
||||
...args: ExtractArgs<TEvents[TName]>
|
||||
): void {
|
||||
this.emitter.emit(event, ...(args as unknown[]));
|
||||
}
|
||||
|
||||
public removeAllListeners(): void {
|
||||
this.emitter.removeAllListeners();
|
||||
}
|
||||
}
|
||||
29
src/events/SseSerializer.ts
Normal file
29
src/events/SseSerializer.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { QueueStreamEvent } from '../types.js';
|
||||
|
||||
export class SseSerializer {
|
||||
public static headers(): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
};
|
||||
}
|
||||
|
||||
public event<TEvent extends QueueStreamEvent>(event: string, payload: TEvent, id?: string): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (id) {
|
||||
lines.push(`id: ${id}`);
|
||||
}
|
||||
|
||||
lines.push(`event: ${event}`);
|
||||
lines.push(`data: ${JSON.stringify(payload)}`);
|
||||
|
||||
return `${lines.join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
public comment(comment: string): string {
|
||||
return `: ${comment}\n\n`;
|
||||
}
|
||||
}
|
||||
38
src/index.ts
Normal file
38
src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export { JobQueue } from './JobQueue.js';
|
||||
export { TypedEventBus } from './events/EventBus.js';
|
||||
export { SseSerializer } from './events/SseSerializer.js';
|
||||
export { PhaseRunner } from './processor/PhaseRunner.js';
|
||||
export { WorkerPool } from './processor/WorkerPool.js';
|
||||
export { RetentionScheduler } from './retention/RetentionScheduler.js';
|
||||
export { RetryStrategy } from './retry/RetryStrategy.js';
|
||||
export { SqliteStorage } from './storage/SqliteStorage.js';
|
||||
export { WebhookDispatcher } from './webhook/WebhookDispatcher.js';
|
||||
export { CancellationError } from './util/errors.js';
|
||||
export { createJobId } from './util/id.js';
|
||||
|
||||
export type {
|
||||
EnqueueOptions,
|
||||
ErrorDisposition,
|
||||
JobData,
|
||||
JobFailure,
|
||||
JobPhaseState,
|
||||
JobProgressEvent,
|
||||
JobQueueEvents,
|
||||
JobRecord,
|
||||
JobStatus,
|
||||
JsonValue,
|
||||
ListJobsOptions,
|
||||
PhaseContext,
|
||||
PhaseHandler,
|
||||
QueueConfig,
|
||||
QueueStreamEvent,
|
||||
RetentionConfig,
|
||||
RetryBackoffStrategy,
|
||||
RetryConfig,
|
||||
RetryOptions,
|
||||
StreamOptions,
|
||||
WebhookConfig,
|
||||
WebhookDispatchError,
|
||||
WebhookDispatchResult,
|
||||
WebhookEventName,
|
||||
} from './types.js';
|
||||
143
src/processor/PhaseRunner.ts
Normal file
143
src/processor/PhaseRunner.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
JobData,
|
||||
JobPhaseState,
|
||||
JobRecord,
|
||||
JsonValue,
|
||||
PhaseContext,
|
||||
PhaseHandler,
|
||||
} from '../types.js';
|
||||
|
||||
interface PhaseRunnerOptions<TData extends JobData> {
|
||||
handlers: Map<string, PhaseHandler<TData>>;
|
||||
phases: readonly string[];
|
||||
onProgress: (
|
||||
phaseName: string,
|
||||
phases: JobPhaseState[],
|
||||
phaseProgress: number,
|
||||
overallProgress: number,
|
||||
message: string | undefined,
|
||||
details: JsonValue | undefined,
|
||||
) => Promise<void>;
|
||||
onPhaseStarted: (phaseName: string, phases: JobPhaseState[]) => Promise<JobRecord<TData>>;
|
||||
onPhaseCompleted: (
|
||||
phaseName: string,
|
||||
phases: JobPhaseState[],
|
||||
phaseResults: Record<string, JsonValue | undefined>,
|
||||
overallProgress: number,
|
||||
) => Promise<JobRecord<TData>>;
|
||||
onCancelled: (phaseName: string | null, phases: JobPhaseState[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export class PhaseRunner<TData extends JobData = JobData> {
|
||||
public constructor(private readonly options: PhaseRunnerOptions<TData>) {}
|
||||
|
||||
public async run(
|
||||
job: JobRecord<TData>,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ phases: JobPhaseState[]; phaseResults: Record<string, JsonValue | undefined> }> {
|
||||
const phases = job.phases.map((phase) => ({ ...phase }));
|
||||
const phaseResults = { ...job.phaseResults };
|
||||
let currentJob = job;
|
||||
|
||||
for (const [phaseIndex, phaseName] of this.options.phases.entries()) {
|
||||
const phaseState = phases[phaseIndex];
|
||||
|
||||
if (!phaseState) {
|
||||
throw new Error(`Missing phase state for ${phaseName}`);
|
||||
}
|
||||
|
||||
if (phaseState.status === 'completed') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
phaseState.status = 'cancelled';
|
||||
phaseState.completedAt = new Date().toISOString();
|
||||
await this.options.onCancelled(phaseName, phases);
|
||||
return { phases, phaseResults };
|
||||
}
|
||||
|
||||
phaseState.status = 'active';
|
||||
phaseState.startedAt ??= new Date().toISOString();
|
||||
phaseState.error = null;
|
||||
currentJob = await this.options.onPhaseStarted(phaseName, phases);
|
||||
|
||||
const handler = this.options.handlers.get(phaseName);
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for phase "${phaseName}"`);
|
||||
}
|
||||
|
||||
const context: PhaseContext<TData> = {
|
||||
job: currentJob,
|
||||
phase: phaseName,
|
||||
signal,
|
||||
progress: async (percent, message, details) => {
|
||||
this.ensureNotCancelled(signal);
|
||||
phaseState.progress = normalizeProgress(percent);
|
||||
phaseState.message = message ?? null;
|
||||
|
||||
const overallProgress = calculateOverallProgress(
|
||||
phaseIndex,
|
||||
phases.length,
|
||||
phaseState.progress,
|
||||
);
|
||||
|
||||
await this.options.onProgress(
|
||||
phaseName,
|
||||
phases,
|
||||
phaseState.progress,
|
||||
overallProgress,
|
||||
message,
|
||||
details,
|
||||
);
|
||||
|
||||
return currentJob;
|
||||
},
|
||||
phaseResult: <TResult extends JsonValue = JsonValue>(lookupPhase: string) =>
|
||||
phaseResults[lookupPhase] as TResult | undefined,
|
||||
phaseResults: () => ({ ...phaseResults }),
|
||||
isCancelled: () => signal.aborted,
|
||||
throwIfCancelled: async () => {
|
||||
this.ensureNotCancelled(signal);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handler(currentJob, context);
|
||||
this.ensureNotCancelled(signal);
|
||||
|
||||
phaseState.status = 'completed';
|
||||
phaseState.progress = 100;
|
||||
phaseState.message = null;
|
||||
phaseState.completedAt = new Date().toISOString();
|
||||
phaseResults[phaseName] = result;
|
||||
|
||||
currentJob = await this.options.onPhaseCompleted(
|
||||
phaseName,
|
||||
phases,
|
||||
phaseResults,
|
||||
calculateOverallProgress(phaseIndex, phases.length, 100),
|
||||
);
|
||||
}
|
||||
|
||||
return { phases, phaseResults };
|
||||
}
|
||||
|
||||
private ensureNotCancelled(signal: AbortSignal): void {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason instanceof Error ? signal.reason : new Error('Job cancelled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calculateOverallProgress(
|
||||
phaseIndex: number,
|
||||
totalPhases: number,
|
||||
phaseProgress: number,
|
||||
): number {
|
||||
const clamped = normalizeProgress(phaseProgress);
|
||||
return Math.round(((phaseIndex + clamped / 100) / totalPhases) * 100);
|
||||
}
|
||||
|
||||
function normalizeProgress(value: number): number {
|
||||
return Math.min(100, Math.max(0, Math.round(value)));
|
||||
}
|
||||
58
src/processor/WorkerPool.ts
Normal file
58
src/processor/WorkerPool.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import pLimit from 'p-limit';
|
||||
|
||||
export class WorkerPool {
|
||||
private readonly limit: ReturnType<typeof pLimit>;
|
||||
private readonly running = new Set<Promise<unknown>>();
|
||||
|
||||
public constructor(private readonly concurrency: number) {
|
||||
if (!Number.isInteger(concurrency) || concurrency <= 0) {
|
||||
throw new Error('concurrency must be a positive integer');
|
||||
}
|
||||
|
||||
this.limit = pLimit(concurrency);
|
||||
}
|
||||
|
||||
public get activeCount(): number {
|
||||
return this.limit.activeCount;
|
||||
}
|
||||
|
||||
public get pendingCount(): number {
|
||||
return this.limit.pendingCount;
|
||||
}
|
||||
|
||||
public get availableSlots(): number {
|
||||
return Math.max(this.concurrency - this.activeCount - this.pendingCount, 0);
|
||||
}
|
||||
|
||||
public hasCapacity(): boolean {
|
||||
return this.availableSlots > 0;
|
||||
}
|
||||
|
||||
public run<T>(task: () => Promise<T>): Promise<T> {
|
||||
const scheduled = this.limit(async () => task());
|
||||
const tracked = scheduled.finally(() => {
|
||||
this.running.delete(tracked);
|
||||
});
|
||||
|
||||
this.running.add(tracked);
|
||||
return scheduled;
|
||||
}
|
||||
|
||||
public async drain(timeoutMs?: number): Promise<void> {
|
||||
const active = Promise.allSettled([...this.running]);
|
||||
|
||||
if (timeoutMs === undefined) {
|
||||
await active;
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
active.then(() => undefined),
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`Timed out waiting for workers to drain after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
43
src/retention/RetentionScheduler.ts
Normal file
43
src/retention/RetentionScheduler.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { JobData, JobRecord, RetentionConfig } from '../types.js';
|
||||
|
||||
export class RetentionScheduler<TData extends JobData = JobData> {
|
||||
private interval: NodeJS.Timeout | null = null;
|
||||
|
||||
public constructor(
|
||||
private readonly config: RetentionConfig<TData>,
|
||||
private readonly handlers: {
|
||||
markStale: (cutoffIso: string) => Promise<JobRecord<TData>[]>;
|
||||
deleteStale: (cutoffIso: string) => Promise<JobRecord<TData>[]>;
|
||||
},
|
||||
) {}
|
||||
|
||||
public start(): void {
|
||||
if (this.interval) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = this.config.intervalMs ?? 60_000;
|
||||
this.interval = setInterval(() => {
|
||||
void this.runCycle();
|
||||
}, intervalMs);
|
||||
this.interval.unref?.();
|
||||
}
|
||||
|
||||
public async runCycle(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const staleCutoff = new Date(now - this.config.staleAfterMs).toISOString();
|
||||
const deleteCutoff = new Date(now - this.config.deleteAfterMs).toISOString();
|
||||
|
||||
await this.handlers.markStale(staleCutoff);
|
||||
await this.handlers.deleteStale(deleteCutoff);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (!this.interval) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
82
src/retry/RetryStrategy.ts
Normal file
82
src/retry/RetryStrategy.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
ErrorDisposition,
|
||||
JobData,
|
||||
JobRecord,
|
||||
RetryBackoffStrategy,
|
||||
RetryConfig,
|
||||
} from '../types.js';
|
||||
|
||||
const DEFAULT_MAX_ATTEMPTS = 1;
|
||||
const DEFAULT_BASE_DELAY_MS = 1_000;
|
||||
const DEFAULT_MAX_DELAY_MS = 30_000;
|
||||
|
||||
export class RetryStrategy<TData extends JobData = JobData> {
|
||||
private readonly config: Required<
|
||||
Pick<RetryConfig<TData>, 'maxAttempts' | 'strategy' | 'baseDelayMs' | 'maxDelayMs'>
|
||||
> &
|
||||
Pick<RetryConfig<TData>, 'classifyError'>;
|
||||
|
||||
public constructor(config: RetryConfig<TData> = {}) {
|
||||
this.config = {
|
||||
maxAttempts: config.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
||||
strategy: config.strategy ?? 'exponential',
|
||||
baseDelayMs: config.baseDelayMs ?? DEFAULT_BASE_DELAY_MS,
|
||||
maxDelayMs: config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS,
|
||||
classifyError: config.classifyError,
|
||||
};
|
||||
}
|
||||
|
||||
public defaultMaxAttempts(): number {
|
||||
return this.config.maxAttempts;
|
||||
}
|
||||
|
||||
public async classify(error: unknown, job: JobRecord<TData>): Promise<ErrorDisposition> {
|
||||
if (this.config.classifyError) {
|
||||
return this.config.classifyError(error, job);
|
||||
}
|
||||
|
||||
return 'fatal';
|
||||
}
|
||||
|
||||
public getDelayMs(retryCount: number): number {
|
||||
const multiplier = this.getMultiplier(retryCount);
|
||||
return Math.min(this.config.baseDelayMs * multiplier, this.config.maxDelayMs);
|
||||
}
|
||||
|
||||
public async shouldRetry(
|
||||
error: unknown,
|
||||
job: JobRecord<TData>,
|
||||
): Promise<{ retry: boolean; delayMs: number; disposition: ErrorDisposition }> {
|
||||
const disposition = await this.classify(error, job);
|
||||
const nextRetryCount = job.retryCount + 1;
|
||||
|
||||
if (disposition !== 'recoverable' || nextRetryCount >= job.maxAttempts) {
|
||||
return {
|
||||
retry: false,
|
||||
delayMs: 0,
|
||||
disposition,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
retry: true,
|
||||
delayMs: this.getDelayMs(nextRetryCount),
|
||||
disposition,
|
||||
};
|
||||
}
|
||||
|
||||
private getMultiplier(retryCount: number): number {
|
||||
const normalizedRetryCount = Math.max(retryCount, 1);
|
||||
const strategy: RetryBackoffStrategy = this.config.strategy;
|
||||
|
||||
if (strategy === 'fixed') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (strategy === 'linear') {
|
||||
return normalizedRetryCount;
|
||||
}
|
||||
|
||||
return 2 ** (normalizedRetryCount - 1);
|
||||
}
|
||||
}
|
||||
580
src/storage/SqliteStorage.ts
Normal file
580
src/storage/SqliteStorage.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import type {
|
||||
EnqueueOptions,
|
||||
JobData,
|
||||
JobFailure,
|
||||
JobPhaseState,
|
||||
JobRecord,
|
||||
JobStatus,
|
||||
ListJobsOptions,
|
||||
} from '../types.js';
|
||||
|
||||
interface JobRow {
|
||||
id: string;
|
||||
status: JobStatus;
|
||||
data: string;
|
||||
current_phase: string | null;
|
||||
phases_json: string;
|
||||
phase_results: string;
|
||||
progress: number;
|
||||
progress_message: string | null;
|
||||
error_json: string | null;
|
||||
retry_count: number;
|
||||
max_attempts: number;
|
||||
webhook_url: string | null;
|
||||
webhook_sent: number;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
updated_at: string;
|
||||
scheduled_at: string | null;
|
||||
cancelled_at: string | null;
|
||||
}
|
||||
|
||||
function toIsoString(value: Date | string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
|
||||
function parseJson<T>(value: string | null, fallback: T): T {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
|
||||
export class SqliteStorage<TData extends JobData = JobData> {
|
||||
private readonly db: Database.Database;
|
||||
|
||||
public constructor(dbPath: string) {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
this.db = new Database(dbPath);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
this.db.pragma('busy_timeout = 5000');
|
||||
this.init();
|
||||
}
|
||||
|
||||
public createJob(
|
||||
id: string,
|
||||
data: TData,
|
||||
phases: JobPhaseState[],
|
||||
options: EnqueueOptions,
|
||||
maxAttempts: number,
|
||||
): JobRecord<TData> {
|
||||
const now = new Date().toISOString();
|
||||
const row: JobRow = {
|
||||
id,
|
||||
status: 'pending',
|
||||
data: JSON.stringify(data),
|
||||
current_phase: null,
|
||||
phases_json: JSON.stringify(phases),
|
||||
phase_results: JSON.stringify({}),
|
||||
progress: 0,
|
||||
progress_message: null,
|
||||
error_json: null,
|
||||
retry_count: 0,
|
||||
max_attempts: maxAttempts,
|
||||
webhook_url: options.webhookUrl ?? null,
|
||||
webhook_sent: 0,
|
||||
created_at: now,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
updated_at: now,
|
||||
scheduled_at: toIsoString(options.scheduledAt),
|
||||
cancelled_at: null,
|
||||
};
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO jobs (
|
||||
id, status, data, current_phase, phases_json, phase_results, progress,
|
||||
progress_message, error_json, retry_count, max_attempts, webhook_url,
|
||||
webhook_sent, created_at, started_at, completed_at, updated_at,
|
||||
scheduled_at, cancelled_at
|
||||
)
|
||||
VALUES (
|
||||
@id, @status, @data, @current_phase, @phases_json, @phase_results, @progress,
|
||||
@progress_message, @error_json, @retry_count, @max_attempts, @webhook_url,
|
||||
@webhook_sent, @created_at, @started_at, @completed_at, @updated_at,
|
||||
@scheduled_at, @cancelled_at
|
||||
)
|
||||
`,
|
||||
)
|
||||
.run(row);
|
||||
|
||||
return this.parseRow(row);
|
||||
}
|
||||
|
||||
public getJob(id: string): JobRecord<TData> | null {
|
||||
const row = this.db.prepare('SELECT * FROM jobs WHERE id = ?').get(id) as JobRow | undefined;
|
||||
return row ? this.parseRow(row) : null;
|
||||
}
|
||||
|
||||
public listJobs(options: ListJobsOptions = {}): JobRecord<TData>[] {
|
||||
const where: string[] = [];
|
||||
const params: Array<string | number> = [];
|
||||
|
||||
if (options.statuses && options.statuses.length > 0) {
|
||||
where.push(`status IN (${options.statuses.map(() => '?').join(', ')})`);
|
||||
params.push(...options.statuses);
|
||||
}
|
||||
|
||||
const limit = options.limit ?? 100;
|
||||
const offset = options.offset ?? 0;
|
||||
params.push(limit, offset);
|
||||
|
||||
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM jobs
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`,
|
||||
)
|
||||
.all(...params) as JobRow[];
|
||||
|
||||
return rows.map((row) => this.parseRow(row));
|
||||
}
|
||||
|
||||
public listRunnableJobs(limit: number): JobRecord<TData>[] {
|
||||
const now = new Date().toISOString();
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM jobs
|
||||
WHERE status = 'pending'
|
||||
AND (scheduled_at IS NULL OR scheduled_at <= ?)
|
||||
ORDER BY COALESCE(scheduled_at, created_at) ASC, created_at ASC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(now, limit) as JobRow[];
|
||||
|
||||
return rows.map((row) => this.parseRow(row));
|
||||
}
|
||||
|
||||
public claimPendingJob(id: string): boolean {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET status = 'active',
|
||||
started_at = COALESCE(started_at, ?),
|
||||
updated_at = ?,
|
||||
cancelled_at = NULL
|
||||
WHERE id = ?
|
||||
AND status = 'pending'
|
||||
AND (scheduled_at IS NULL OR scheduled_at <= ?)
|
||||
`,
|
||||
)
|
||||
.run(now, now, id, now);
|
||||
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
public saveProgress(
|
||||
id: string,
|
||||
currentPhase: string,
|
||||
phases: JobPhaseState[],
|
||||
progress: number,
|
||||
message: string | null,
|
||||
): JobRecord<TData> {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET current_phase = ?,
|
||||
phases_json = ?,
|
||||
progress = ?,
|
||||
progress_message = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.run(currentPhase, JSON.stringify(phases), progress, message, now, id);
|
||||
|
||||
return this.mustGetJob(id);
|
||||
}
|
||||
|
||||
public savePhaseCompletion(
|
||||
id: string,
|
||||
phases: JobPhaseState[],
|
||||
phaseResults: Record<string, unknown>,
|
||||
currentPhase: string | null,
|
||||
progress: number,
|
||||
message: string | null,
|
||||
): JobRecord<TData> {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET phases_json = ?,
|
||||
phase_results = ?,
|
||||
current_phase = ?,
|
||||
progress = ?,
|
||||
progress_message = ?,
|
||||
error_json = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
JSON.stringify(phases),
|
||||
JSON.stringify(phaseResults),
|
||||
currentPhase,
|
||||
progress,
|
||||
message,
|
||||
now,
|
||||
id,
|
||||
);
|
||||
|
||||
return this.mustGetJob(id);
|
||||
}
|
||||
|
||||
public completeJob(
|
||||
id: string,
|
||||
phases: JobPhaseState[],
|
||||
phaseResults: Record<string, unknown>,
|
||||
): JobRecord<TData> {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET status = 'completed',
|
||||
phases_json = ?,
|
||||
phase_results = ?,
|
||||
current_phase = NULL,
|
||||
progress = 100,
|
||||
progress_message = NULL,
|
||||
error_json = NULL,
|
||||
completed_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.run(JSON.stringify(phases), JSON.stringify(phaseResults), now, now, id);
|
||||
|
||||
return this.mustGetJob(id);
|
||||
}
|
||||
|
||||
public failJob(id: string, phases: JobPhaseState[], failure: JobFailure): JobRecord<TData> {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET status = 'failed',
|
||||
phases_json = ?,
|
||||
current_phase = ?,
|
||||
error_json = ?,
|
||||
completed_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.run(JSON.stringify(phases), failure.phase, JSON.stringify(failure), now, now, id);
|
||||
|
||||
return this.mustGetJob(id);
|
||||
}
|
||||
|
||||
public scheduleRetry(
|
||||
id: string,
|
||||
phases: JobPhaseState[],
|
||||
progress: number,
|
||||
failure: JobFailure,
|
||||
retryCount: number,
|
||||
scheduledAt: string,
|
||||
): JobRecord<TData> {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET status = 'pending',
|
||||
phases_json = ?,
|
||||
current_phase = ?,
|
||||
progress = ?,
|
||||
progress_message = ?,
|
||||
error_json = ?,
|
||||
retry_count = ?,
|
||||
scheduled_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
JSON.stringify(phases),
|
||||
failure.phase,
|
||||
progress,
|
||||
`Retry scheduled in ${Math.max(new Date(scheduledAt).getTime() - Date.now(), 0)}ms`,
|
||||
JSON.stringify(failure),
|
||||
retryCount,
|
||||
scheduledAt,
|
||||
now,
|
||||
id,
|
||||
);
|
||||
|
||||
return this.mustGetJob(id);
|
||||
}
|
||||
|
||||
public resetForRetry(
|
||||
id: string,
|
||||
phases: JobPhaseState[],
|
||||
maxAttempts: number,
|
||||
scheduledAt: string | null,
|
||||
): JobRecord<TData> {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET status = 'pending',
|
||||
current_phase = NULL,
|
||||
phases_json = ?,
|
||||
phase_results = '{}',
|
||||
progress = 0,
|
||||
progress_message = NULL,
|
||||
error_json = NULL,
|
||||
retry_count = 0,
|
||||
max_attempts = ?,
|
||||
webhook_sent = 0,
|
||||
completed_at = NULL,
|
||||
updated_at = ?,
|
||||
scheduled_at = ?,
|
||||
cancelled_at = NULL
|
||||
WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.run(JSON.stringify(phases), maxAttempts, now, scheduledAt, id);
|
||||
|
||||
return this.mustGetJob(id);
|
||||
}
|
||||
|
||||
public cancelJob(id: string, phases: JobPhaseState[]): JobRecord<TData> {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET status = 'cancelled',
|
||||
phases_json = ?,
|
||||
current_phase = NULL,
|
||||
completed_at = COALESCE(completed_at, ?),
|
||||
cancelled_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status IN ('pending', 'active')
|
||||
`,
|
||||
)
|
||||
.run(JSON.stringify(phases), now, now, now, id);
|
||||
|
||||
return this.mustGetJob(id);
|
||||
}
|
||||
|
||||
public markWebhookSent(id: string): void {
|
||||
this.db
|
||||
.prepare('UPDATE jobs SET webhook_sent = 1, updated_at = ? WHERE id = ?')
|
||||
.run(new Date().toISOString(), id);
|
||||
}
|
||||
|
||||
public resetActiveJobs(message: string): JobRecord<TData>[] {
|
||||
const activeRows = this.db
|
||||
.prepare(`SELECT * FROM jobs WHERE status = 'active'`)
|
||||
.all() as JobRow[];
|
||||
|
||||
if (activeRows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const failure: JobFailure = {
|
||||
message,
|
||||
phase: null,
|
||||
recoverable: false,
|
||||
timestamp: now,
|
||||
attempt: 0,
|
||||
};
|
||||
|
||||
const statement = this.db.prepare(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET status = 'failed',
|
||||
error_json = ?,
|
||||
completed_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
);
|
||||
|
||||
const transaction = this.db.transaction((rows: JobRow[]) => {
|
||||
for (const row of rows) {
|
||||
statement.run(JSON.stringify(failure), now, now, row.id);
|
||||
}
|
||||
});
|
||||
|
||||
transaction(activeRows);
|
||||
return activeRows.map((row) => this.mustGetJob(row.id));
|
||||
}
|
||||
|
||||
public markStaleJobs(cutoffIso: string): JobRecord<TData>[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM jobs
|
||||
WHERE status IN ('completed', 'failed', 'cancelled')
|
||||
AND COALESCE(completed_at, updated_at) <= ?
|
||||
`,
|
||||
)
|
||||
.all(cutoffIso) as JobRow[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const update = this.db.prepare(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET status = 'stale',
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
);
|
||||
|
||||
const transaction = this.db.transaction((staleRows: JobRow[]) => {
|
||||
for (const row of staleRows) {
|
||||
update.run(now, row.id);
|
||||
}
|
||||
});
|
||||
|
||||
transaction(rows);
|
||||
return rows.map((row) => this.mustGetJob(row.id));
|
||||
}
|
||||
|
||||
public deleteStaleJobs(cutoffIso: string): JobRecord<TData>[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM jobs
|
||||
WHERE status = 'stale'
|
||||
AND COALESCE(completed_at, updated_at) <= ?
|
||||
`,
|
||||
)
|
||||
.all(cutoffIso) as JobRow[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const remove = this.db.prepare('DELETE FROM jobs WHERE id = ?');
|
||||
const transaction = this.db.transaction((deleteRows: JobRow[]) => {
|
||||
for (const row of deleteRows) {
|
||||
remove.run(row.id);
|
||||
}
|
||||
});
|
||||
|
||||
transaction(rows);
|
||||
return rows.map((row) => this.parseRow(row));
|
||||
}
|
||||
|
||||
public getNextScheduledAt(): string | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT MIN(scheduled_at) AS scheduled_at
|
||||
FROM jobs
|
||||
WHERE status = 'pending'
|
||||
AND scheduled_at IS NOT NULL
|
||||
`,
|
||||
)
|
||||
.get() as { scheduled_at: string | null };
|
||||
|
||||
return row.scheduled_at;
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
current_phase TEXT,
|
||||
phases_json TEXT NOT NULL,
|
||||
phase_results TEXT NOT NULL DEFAULT '{}',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
progress_message TEXT,
|
||||
error_json TEXT,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 1,
|
||||
webhook_url TEXT,
|
||||
webhook_sent INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
scheduled_at TEXT,
|
||||
cancelled_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_scheduled_at ON jobs(scheduled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at);
|
||||
`);
|
||||
}
|
||||
|
||||
private mustGetJob(id: string): JobRecord<TData> {
|
||||
const job = this.getJob(id);
|
||||
|
||||
if (!job) {
|
||||
throw new Error(`Job ${id} not found`);
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
private parseRow(row: JobRow): JobRecord<TData> {
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
data: parseJson<TData>(row.data, {} as TData),
|
||||
currentPhase: row.current_phase,
|
||||
phases: parseJson<JobPhaseState[]>(row.phases_json, []),
|
||||
phaseResults: parseJson<Record<string, unknown>>(row.phase_results, {}) as Record<
|
||||
string,
|
||||
TData[keyof TData] | undefined
|
||||
>,
|
||||
progress: row.progress,
|
||||
progressMessage: row.progress_message,
|
||||
error: parseJson<JobFailure | null>(row.error_json, null),
|
||||
retryCount: row.retry_count,
|
||||
maxAttempts: row.max_attempts,
|
||||
webhookUrl: row.webhook_url,
|
||||
webhookSent: Boolean(row.webhook_sent),
|
||||
createdAt: row.created_at,
|
||||
startedAt: row.started_at,
|
||||
completedAt: row.completed_at,
|
||||
updatedAt: row.updated_at,
|
||||
scheduledAt: row.scheduled_at,
|
||||
cancelledAt: row.cancelled_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
216
src/types.ts
Normal file
216
src/types.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type Awaitable<T> = T | Promise<T>;
|
||||
export type JobData = Record<string, JsonValue>;
|
||||
export type JobStatus =
|
||||
| 'pending'
|
||||
| 'active'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'stale';
|
||||
export type JobPhaseStatus = 'pending' | 'active' | 'completed' | 'failed' | 'cancelled';
|
||||
export type RetryBackoffStrategy = 'fixed' | 'linear' | 'exponential';
|
||||
export type ErrorDisposition = 'recoverable' | 'fatal';
|
||||
|
||||
export interface JobPhaseState {
|
||||
name: string;
|
||||
status: JobPhaseStatus;
|
||||
progress: number;
|
||||
message: string | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface JobFailure {
|
||||
message: string;
|
||||
phase: string | null;
|
||||
recoverable: boolean;
|
||||
timestamp: string;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
export interface JobRetryEvent {
|
||||
jobId: string;
|
||||
phase: string | null;
|
||||
attempt: number;
|
||||
delayMs: number;
|
||||
nextRunAt: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface JobProgressEvent {
|
||||
jobId: string;
|
||||
phase: string;
|
||||
phaseProgress: number;
|
||||
overallProgress: number;
|
||||
message: string | null;
|
||||
timestamp: string;
|
||||
details?: JsonValue;
|
||||
}
|
||||
|
||||
export interface JobRecord<TData extends JobData = JobData> {
|
||||
id: string;
|
||||
status: JobStatus;
|
||||
data: TData;
|
||||
currentPhase: string | null;
|
||||
phases: JobPhaseState[];
|
||||
phaseResults: Record<string, JsonValue | undefined>;
|
||||
progress: number;
|
||||
progressMessage: string | null;
|
||||
error: JobFailure | null;
|
||||
retryCount: number;
|
||||
maxAttempts: number;
|
||||
webhookUrl: string | null;
|
||||
webhookSent: boolean;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
updatedAt: string;
|
||||
scheduledAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
}
|
||||
|
||||
export interface EnqueueOptions {
|
||||
id?: string;
|
||||
scheduledAt?: string | Date;
|
||||
maxAttempts?: number;
|
||||
webhookUrl?: string;
|
||||
}
|
||||
|
||||
export interface RetryOptions {
|
||||
fromStart?: boolean;
|
||||
scheduledAt?: string | Date;
|
||||
}
|
||||
|
||||
export interface ListJobsOptions {
|
||||
statuses?: JobStatus[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
jobId?: string;
|
||||
includeSnapshot?: boolean;
|
||||
keepAliveMs?: number;
|
||||
}
|
||||
|
||||
export interface RetryConfig<TData extends JobData = JobData> {
|
||||
maxAttempts?: number;
|
||||
strategy?: RetryBackoffStrategy;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
classifyError?: (
|
||||
error: unknown,
|
||||
job: JobRecord<TData>,
|
||||
) => Awaitable<ErrorDisposition>;
|
||||
}
|
||||
|
||||
export interface RetentionConfig<TData extends JobData = JobData> {
|
||||
staleAfterMs: number;
|
||||
deleteAfterMs: number;
|
||||
intervalMs?: number;
|
||||
onStale?: (job: JobRecord<TData>) => Awaitable<void>;
|
||||
onDelete?: (job: JobRecord<TData>) => Awaitable<void>;
|
||||
}
|
||||
|
||||
export type WebhookEventName =
|
||||
| 'job:completed'
|
||||
| 'job:failed'
|
||||
| 'job:retrying'
|
||||
| 'job:cancelled'
|
||||
| 'job:stale';
|
||||
|
||||
export interface WebhookConfig {
|
||||
url?: string;
|
||||
events?: WebhookEventName[];
|
||||
secret?: string;
|
||||
timeoutMs?: number;
|
||||
headers?: Record<string, string>;
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
}
|
||||
|
||||
export interface QueueConfig<TData extends JobData = JobData> {
|
||||
dbPath: string;
|
||||
phases: readonly string[];
|
||||
concurrency?: number;
|
||||
retry?: RetryConfig<TData>;
|
||||
retention?: RetentionConfig<TData>;
|
||||
webhook?: WebhookConfig;
|
||||
shutdownTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PhaseContext<TData extends JobData = JobData> {
|
||||
readonly job: JobRecord<TData>;
|
||||
readonly phase: string;
|
||||
readonly signal: AbortSignal;
|
||||
progress: (percent: number, message?: string, details?: JsonValue) => Promise<JobRecord<TData>>;
|
||||
phaseResult: <TResult extends JsonValue = JsonValue>(phaseName: string) => TResult | undefined;
|
||||
phaseResults: () => Record<string, JsonValue | undefined>;
|
||||
isCancelled: () => boolean;
|
||||
throwIfCancelled: () => Promise<void>;
|
||||
}
|
||||
|
||||
export type PhaseHandler<TData extends JobData = JobData> = (
|
||||
job: JobRecord<TData>,
|
||||
context: PhaseContext<TData>,
|
||||
) => Awaitable<JsonValue | undefined>;
|
||||
|
||||
export interface WebhookDispatchResult {
|
||||
event: WebhookEventName;
|
||||
jobId: string;
|
||||
status: number;
|
||||
deliveredAt: string;
|
||||
}
|
||||
|
||||
export interface WebhookDispatchError {
|
||||
event: WebhookEventName;
|
||||
jobId: string;
|
||||
message: string;
|
||||
finalAttempt: number;
|
||||
}
|
||||
|
||||
export interface QueueStreamEvent<TData extends JobData = JobData> {
|
||||
type:
|
||||
| 'snapshot'
|
||||
| 'job:enqueued'
|
||||
| 'job:started'
|
||||
| 'job:progress'
|
||||
| 'job:phase:completed'
|
||||
| 'job:completed'
|
||||
| 'job:failed'
|
||||
| 'job:retrying'
|
||||
| 'job:cancelled'
|
||||
| 'job:stale'
|
||||
| 'job:deleted'
|
||||
| 'job:webhook:delivered'
|
||||
| 'job:webhook:failed'
|
||||
| 'ping';
|
||||
jobId?: string;
|
||||
job?: JobRecord<TData>;
|
||||
progress?: JobProgressEvent;
|
||||
phase?: JobPhaseState;
|
||||
failure?: JobFailure;
|
||||
retry?: JobRetryEvent;
|
||||
webhook?: WebhookDispatchResult | WebhookDispatchError;
|
||||
deletedJobId?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface JobQueueEvents<TData extends JobData = JobData> {
|
||||
'job:enqueued': [job: JobRecord<TData>];
|
||||
'job:started': [job: JobRecord<TData>];
|
||||
'job:progress': [job: JobRecord<TData>, progress: JobProgressEvent];
|
||||
'job:phase:completed': [job: JobRecord<TData>, phase: JobPhaseState];
|
||||
'job:completed': [job: JobRecord<TData>];
|
||||
'job:failed': [job: JobRecord<TData>, failure: JobFailure];
|
||||
'job:retrying': [job: JobRecord<TData>, retry: JobRetryEvent];
|
||||
'job:cancelled': [job: JobRecord<TData>];
|
||||
'job:stale': [job: JobRecord<TData>];
|
||||
'job:deleted': [jobId: string];
|
||||
'job:webhook:delivered': [job: JobRecord<TData>, result: WebhookDispatchResult];
|
||||
'job:webhook:failed': [job: JobRecord<TData>, error: WebhookDispatchError];
|
||||
}
|
||||
6
src/util/errors.ts
Normal file
6
src/util/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class CancellationError extends Error {
|
||||
public constructor(message = 'Job cancelled') {
|
||||
super(message);
|
||||
this.name = 'CancellationError';
|
||||
}
|
||||
}
|
||||
5
src/util/id.ts
Normal file
5
src/util/id.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export function createJobId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
143
src/webhook/WebhookDispatcher.ts
Normal file
143
src/webhook/WebhookDispatcher.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
JobData,
|
||||
JobRecord,
|
||||
WebhookConfig,
|
||||
WebhookDispatchError,
|
||||
WebhookDispatchResult,
|
||||
WebhookEventName,
|
||||
} from '../types.js';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
const DEFAULT_BASE_DELAY_MS = 500;
|
||||
const DEFAULT_MAX_DELAY_MS = 5_000;
|
||||
|
||||
function sleep(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
});
|
||||
}
|
||||
|
||||
export class WebhookDispatcher<TData extends JobData = JobData> {
|
||||
private readonly config: Required<
|
||||
Pick<WebhookConfig, 'events' | 'timeoutMs' | 'maxAttempts' | 'baseDelayMs' | 'maxDelayMs'>
|
||||
> &
|
||||
Pick<WebhookConfig, 'headers' | 'secret' | 'url'>;
|
||||
|
||||
public constructor(config: WebhookConfig = {}) {
|
||||
this.config = {
|
||||
url: config.url,
|
||||
events: config.events ?? ['job:completed', 'job:failed'],
|
||||
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
headers: config.headers,
|
||||
secret: config.secret,
|
||||
maxAttempts: config.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
||||
baseDelayMs: config.baseDelayMs ?? DEFAULT_BASE_DELAY_MS,
|
||||
maxDelayMs: config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS,
|
||||
};
|
||||
}
|
||||
|
||||
public supports(event: WebhookEventName, job: JobRecord<TData>): boolean {
|
||||
return Boolean(job.webhookUrl ?? this.config.url) && this.config.events.includes(event);
|
||||
}
|
||||
|
||||
public async dispatch(
|
||||
event: WebhookEventName,
|
||||
job: JobRecord<TData>,
|
||||
): Promise<WebhookDispatchResult> {
|
||||
const url = job.webhookUrl ?? this.config.url;
|
||||
|
||||
if (!url) {
|
||||
throw new Error(`No webhook URL configured for ${event}`);
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
event,
|
||||
emittedAt: new Date().toISOString(),
|
||||
job,
|
||||
});
|
||||
const signature = this.sign(payload);
|
||||
let attempt = 0;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
while (attempt < this.config.maxAttempts) {
|
||||
attempt += 1;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, this.config.timeoutMs);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-JobQueue-Event': event,
|
||||
...(signature ? { 'X-JobQueue-Signature': signature } : {}),
|
||||
...(this.config.headers ?? {}),
|
||||
},
|
||||
body: payload,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
event,
|
||||
jobId: job.id,
|
||||
status: response.status,
|
||||
deliveredAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status < 500) {
|
||||
throw Object.assign(new Error(`Webhook returned non-retryable status ${response.status}`), {
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Webhook returned retryable status ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
const retryable =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'retryable' in error &&
|
||||
(error as { retryable?: boolean }).retryable === false
|
||||
? false
|
||||
: true;
|
||||
|
||||
if (!retryable || attempt >= this.config.maxAttempts) {
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(this.nextDelay(attempt));
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchError: WebhookDispatchError = {
|
||||
event,
|
||||
jobId: job.id,
|
||||
message: lastError?.message ?? 'Unknown webhook delivery error',
|
||||
finalAttempt: attempt,
|
||||
};
|
||||
|
||||
throw Object.assign(new Error(dispatchError.message), { dispatchError });
|
||||
}
|
||||
|
||||
private sign(payload: string): string | undefined {
|
||||
if (!this.config.secret) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return createHmac('sha256', this.config.secret).update(payload).digest('hex');
|
||||
}
|
||||
|
||||
private nextDelay(attempt: number): number {
|
||||
return Math.min(this.config.baseDelayMs * 2 ** (attempt - 1), this.config.maxDelayMs);
|
||||
}
|
||||
}
|
||||
135
tests/JobQueue.test.ts
Normal file
135
tests/JobQueue.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { JobQueue } from '../src/index.js';
|
||||
import { cleanupDir, createDbPath, createTempDir, waitFor } from './helpers.js';
|
||||
|
||||
describe('JobQueue', () => {
|
||||
it('runs multi-phase jobs to completion', async () => {
|
||||
const dir = createTempDir();
|
||||
const queue = new JobQueue<{ url: string }>({
|
||||
dbPath: createDbPath(dir),
|
||||
phases: ['download', 'process'],
|
||||
concurrency: 1,
|
||||
});
|
||||
const events: string[] = [];
|
||||
|
||||
queue.on('job:started', () => events.push('started'));
|
||||
queue.on('job:phase:completed', (_, phase) => events.push(`phase:${phase.name}`));
|
||||
queue.on('job:completed', () => events.push('completed'));
|
||||
|
||||
queue.handle('download', async (_job, ctx) => {
|
||||
await ctx.progress(50, 'downloading');
|
||||
return { filePath: '/tmp/video.mp4' };
|
||||
});
|
||||
|
||||
queue.handle('process', async (_job, ctx) => {
|
||||
expect(ctx.phaseResult<{ filePath: string }>('download')?.filePath).toBe('/tmp/video.mp4');
|
||||
await ctx.progress(25, 'processing');
|
||||
return { outputPath: '/tmp/video.txt' };
|
||||
});
|
||||
|
||||
try {
|
||||
const jobId = await queue.enqueue({ url: 'https://example.com/video' });
|
||||
await waitFor(() => queue.getJob(jobId)?.status === 'completed');
|
||||
|
||||
const job = queue.getJob(jobId);
|
||||
expect(job?.status).toBe('completed');
|
||||
expect(job?.phaseResults.download).toEqual({ filePath: '/tmp/video.mp4' });
|
||||
expect(job?.phaseResults.process).toEqual({ outputPath: '/tmp/video.txt' });
|
||||
expect(events).toEqual(['started', 'phase:download', 'phase:process', 'completed']);
|
||||
} finally {
|
||||
await queue.shutdown();
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
|
||||
it('retries recoverable failures and eventually completes', async () => {
|
||||
const dir = createTempDir();
|
||||
const queue = new JobQueue<{ url: string }>({
|
||||
dbPath: createDbPath(dir),
|
||||
phases: ['run'],
|
||||
concurrency: 1,
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
baseDelayMs: 10,
|
||||
classifyError: async (error) =>
|
||||
error instanceof Error && error.message === 'recoverable' ? 'recoverable' : 'fatal',
|
||||
},
|
||||
});
|
||||
let attempts = 0;
|
||||
let retries = 0;
|
||||
|
||||
queue.on('job:retrying', () => {
|
||||
retries += 1;
|
||||
});
|
||||
|
||||
queue.handle('run', async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error('recoverable');
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
try {
|
||||
const jobId = await queue.enqueue({ url: 'https://example.com/video' });
|
||||
await waitFor(() => queue.getJob(jobId)?.status === 'completed', { timeoutMs: 4_000 });
|
||||
|
||||
const job = queue.getJob(jobId);
|
||||
expect(job?.status).toBe('completed');
|
||||
expect(job?.retryCount).toBe(1);
|
||||
expect(retries).toBe(1);
|
||||
} finally {
|
||||
await queue.shutdown();
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
|
||||
it('streams queue events as SSE', async () => {
|
||||
const dir = createTempDir();
|
||||
const queue = new JobQueue<{ url: string }>({
|
||||
dbPath: createDbPath(dir),
|
||||
phases: ['run'],
|
||||
concurrency: 1,
|
||||
});
|
||||
const stream = queue.createEventStream({ includeSnapshot: false });
|
||||
const reader = stream.getReader();
|
||||
const chunks: string[] = [];
|
||||
const readPromise = (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
chunks.push(new TextDecoder().decode(value));
|
||||
if (chunks.some((chunk) => chunk.includes('event: job:completed'))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
queue.handle('run', async (_job, ctx) => {
|
||||
await ctx.progress(100, 'done');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
try {
|
||||
await queue.enqueue({ url: 'https://example.com/video' });
|
||||
await Promise.race([
|
||||
readPromise,
|
||||
waitFor(() => chunks.some((chunk) => chunk.includes('event: job:completed')), {
|
||||
timeoutMs: 4_000,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
await queue.shutdown();
|
||||
cleanupDir(dir);
|
||||
}
|
||||
|
||||
expect(chunks.join('\n')).toContain('event: job:completed');
|
||||
expect(chunks.join('\n')).toContain('event: job:progress');
|
||||
});
|
||||
});
|
||||
28
tests/RetentionScheduler.test.ts
Normal file
28
tests/RetentionScheduler.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { RetentionScheduler } from '../src/index.js';
|
||||
|
||||
describe('RetentionScheduler', () => {
|
||||
it('runs stale and delete handlers', async () => {
|
||||
const calls: string[] = [];
|
||||
const scheduler = new RetentionScheduler(
|
||||
{
|
||||
staleAfterMs: 1_000,
|
||||
deleteAfterMs: 2_000,
|
||||
},
|
||||
{
|
||||
markStale: async () => {
|
||||
calls.push('stale');
|
||||
return [];
|
||||
},
|
||||
deleteStale: async () => {
|
||||
calls.push('delete');
|
||||
return [];
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await scheduler.runCycle();
|
||||
scheduler.stop();
|
||||
|
||||
expect(calls).toEqual(['stale', 'delete']);
|
||||
});
|
||||
});
|
||||
72
tests/RetryStrategy.test.ts
Normal file
72
tests/RetryStrategy.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { RetryStrategy } from '../src/index.js';
|
||||
|
||||
describe('RetryStrategy', () => {
|
||||
it('uses exponential backoff for recoverable errors', async () => {
|
||||
const strategy = new RetryStrategy({
|
||||
maxAttempts: 4,
|
||||
baseDelayMs: 100,
|
||||
classifyError: async () => 'recoverable',
|
||||
});
|
||||
|
||||
const decision = await strategy.shouldRetry(new Error('boom'), {
|
||||
id: 'job-1',
|
||||
status: 'active',
|
||||
data: {},
|
||||
currentPhase: 'run',
|
||||
phases: [],
|
||||
phaseResults: {},
|
||||
progress: 0,
|
||||
progressMessage: null,
|
||||
error: null,
|
||||
retryCount: 1,
|
||||
maxAttempts: 4,
|
||||
webhookUrl: null,
|
||||
webhookSent: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
scheduledAt: null,
|
||||
cancelledAt: null,
|
||||
});
|
||||
|
||||
expect(decision.retry).toBe(true);
|
||||
expect(decision.delayMs).toBe(200);
|
||||
expect(decision.disposition).toBe('recoverable');
|
||||
});
|
||||
|
||||
it('does not retry fatal errors', async () => {
|
||||
const strategy = new RetryStrategy({
|
||||
maxAttempts: 4,
|
||||
classifyError: async () => 'fatal',
|
||||
});
|
||||
|
||||
const decision = await strategy.shouldRetry(new Error('fatal'), {
|
||||
id: 'job-1',
|
||||
status: 'active',
|
||||
data: {},
|
||||
currentPhase: 'run',
|
||||
phases: [],
|
||||
phaseResults: {},
|
||||
progress: 0,
|
||||
progressMessage: null,
|
||||
error: null,
|
||||
retryCount: 0,
|
||||
maxAttempts: 4,
|
||||
webhookUrl: null,
|
||||
webhookSent: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
scheduledAt: null,
|
||||
cancelledAt: null,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
retry: false,
|
||||
delayMs: 0,
|
||||
disposition: 'fatal',
|
||||
});
|
||||
});
|
||||
});
|
||||
76
tests/SqliteStorage.test.ts
Normal file
76
tests/SqliteStorage.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { SqliteStorage } from '../src/index.js';
|
||||
import { cleanupDir, createDbPath, createTempDir } from './helpers.js';
|
||||
|
||||
describe('SqliteStorage', () => {
|
||||
it('creates, updates, and completes jobs', () => {
|
||||
const dir = createTempDir();
|
||||
const storage = new SqliteStorage<{ url: string }>(createDbPath(dir));
|
||||
|
||||
try {
|
||||
const job = storage.createJob(
|
||||
'job-1',
|
||||
{ url: 'https://example.com' },
|
||||
[
|
||||
{
|
||||
name: 'download',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
message: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
{},
|
||||
3,
|
||||
);
|
||||
|
||||
expect(job.status).toBe('pending');
|
||||
expect(storage.claimPendingJob(job.id)).toBe(true);
|
||||
|
||||
const inProgress = storage.saveProgress(
|
||||
job.id,
|
||||
'download',
|
||||
[
|
||||
{
|
||||
name: 'download',
|
||||
status: 'active',
|
||||
progress: 50,
|
||||
message: 'halfway',
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
50,
|
||||
'halfway',
|
||||
);
|
||||
|
||||
expect(inProgress.status).toBe('active');
|
||||
expect(inProgress.progress).toBe(50);
|
||||
|
||||
const completed = storage.completeJob(
|
||||
job.id,
|
||||
[
|
||||
{
|
||||
name: 'download',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
message: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
{ download: { filePath: '/tmp/file' } },
|
||||
);
|
||||
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completed.progress).toBe(100);
|
||||
expect(completed.phaseResults.download).toEqual({ filePath: '/tmp/file' });
|
||||
} finally {
|
||||
storage.close();
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
25
tests/SseSerializer.test.ts
Normal file
25
tests/SseSerializer.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { SseSerializer } from '../src/index.js';
|
||||
|
||||
describe('SseSerializer', () => {
|
||||
it('formats SSE events', () => {
|
||||
const serializer = new SseSerializer();
|
||||
const event = serializer.event('job:completed', {
|
||||
type: 'job:completed',
|
||||
jobId: 'job-1',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(event).toContain('event: job:completed');
|
||||
expect(event).toContain('"jobId":"job-1"');
|
||||
expect(event.endsWith('\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns SSE headers', () => {
|
||||
expect(SseSerializer.headers()).toEqual({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
});
|
||||
});
|
||||
75
tests/WebhookDispatcher.test.ts
Normal file
75
tests/WebhookDispatcher.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
import { WebhookDispatcher } from '../src/index.js';
|
||||
|
||||
describe('WebhookDispatcher', () => {
|
||||
it('delivers signed webhook payloads', async () => {
|
||||
const payloads: string[] = [];
|
||||
const headers: string[] = [];
|
||||
const server = createServer((request, response) => {
|
||||
let body = '';
|
||||
request.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
request.on('end', () => {
|
||||
payloads.push(body);
|
||||
headers.push(String(request.headers['x-jobqueue-signature']));
|
||||
response.writeHead(202).end();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Server address unavailable');
|
||||
}
|
||||
|
||||
const dispatcher = new WebhookDispatcher({
|
||||
url: `http://127.0.0.1:${address.port}/hook`,
|
||||
secret: 'top-secret',
|
||||
events: ['job:completed'],
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await dispatcher.dispatch('job:completed', {
|
||||
id: 'job-1',
|
||||
status: 'completed',
|
||||
data: { url: 'https://example.com' },
|
||||
currentPhase: null,
|
||||
phases: [],
|
||||
phaseResults: {},
|
||||
progress: 100,
|
||||
progressMessage: null,
|
||||
error: null,
|
||||
retryCount: 0,
|
||||
maxAttempts: 1,
|
||||
webhookUrl: null,
|
||||
webhookSent: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
startedAt: null,
|
||||
completedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scheduledAt: null,
|
||||
cancelledAt: null,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(202);
|
||||
expect(payloads).toHaveLength(1);
|
||||
expect(payloads[0]).toContain('"event":"job:completed"');
|
||||
expect(headers[0]).toMatch(/^[a-f0-9]{64}$/);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
26
tests/WorkerPool.test.ts
Normal file
26
tests/WorkerPool.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { WorkerPool } from '../src/index.js';
|
||||
import { waitFor } from './helpers.js';
|
||||
|
||||
describe('WorkerPool', () => {
|
||||
it('respects concurrency limits', async () => {
|
||||
const pool = new WorkerPool(2);
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
|
||||
const tasks = Array.from({ length: 5 }, (_, index) =>
|
||||
pool.run(async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20 + index);
|
||||
});
|
||||
active -= 1;
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(tasks);
|
||||
await waitFor(() => pool.activeCount === 0 && pool.pendingCount === 0);
|
||||
|
||||
expect(maxActive).toBe(2);
|
||||
});
|
||||
});
|
||||
34
tests/helpers.ts
Normal file
34
tests/helpers.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export function createTempDir(prefix = 'jobqueue-'): string {
|
||||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
export function createDbPath(dir: string): string {
|
||||
return join(dir, 'jobs.db');
|
||||
}
|
||||
|
||||
export function cleanupDir(dir: string): void {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function waitFor(
|
||||
predicate: () => boolean,
|
||||
options: { timeoutMs?: number; intervalMs?: number } = {},
|
||||
): Promise<void> {
|
||||
const timeoutMs = options.timeoutMs ?? 2_000;
|
||||
const intervalMs = options.intervalMs ?? 10;
|
||||
const start = Date.now();
|
||||
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, intervalMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
11
tsup.config.ts
Normal file
11
tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
target: 'node20',
|
||||
splitting: false,
|
||||
});
|
||||
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/index.ts'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user