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