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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/queue/queue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { JobQueue } from "./queue.js";

function tempQueueFile(): string {
const dir = mkdtempSync(join(tmpdir(), "job-queue-test-"));
return join(dir, "queue.json");
}

const noSleep = () => Promise.resolve();
const noJitter = () => 0;

test("a job handler runs exactly once even if run() is called twice", async () => {
const filePath = tempQueueFile();
const queue = new JobQueue({ filePath, sleep: noSleep, random: noJitter });
let calls = 0;
queue.registerHandler("noop", async () => {
calls++;
});
const job = queue.enqueue("noop", {});

await queue.run(job.id);
await queue.run(job.id);

assert.equal(calls, 1);
assert.equal(queue.get(job.id)?.status, "completed");
});

test("idempotency survives a simulated crash-and-restart against the same durable file", async () => {
const filePath = tempQueueFile();
let calls = 0;

const queue1 = new JobQueue({ filePath, sleep: noSleep, random: noJitter });
queue1.registerHandler("submit-tx", async () => {
calls++;
});
const job = queue1.enqueue("submit-tx", { xdr: "abc" });
await queue1.run(job.id);
assert.equal(calls, 1);

// Simulate a process restart: a brand new queue instance backed by the
// same durable file, as would happen after a crash and redeploy.
const queue2 = new JobQueue({ filePath, sleep: noSleep, random: noJitter });
queue2.registerHandler("submit-tx", async () => {
calls++;
});
const rerun = await queue2.run(job.id);

assert.equal(calls, 1, "handler must not run twice across a restart");
assert.equal(rerun.status, "completed");
});

test("a job that keeps failing is retried up to maxAttempts then dead-lettered", async () => {
const filePath = tempQueueFile();
const queue = new JobQueue({
filePath,
sleep: noSleep,
random: noJitter,
defaultMaxAttempts: 3,
});
let attempts = 0;
queue.registerHandler("flaky", async () => {
attempts++;
throw new Error("always fails");
});
const job = queue.enqueue("flaky", {});

const result = await queue.run(job.id);

assert.equal(attempts, 3);
assert.equal(result.status, "dead-letter");
assert.equal(queue.deadLetterQueue().length, 1);
assert.equal(queue.deadLetterQueue()[0]?.lastError, "always fails");
});

test("metrics report queue depth and failure rate", async () => {
const filePath = tempQueueFile();
const queue = new JobQueue({
filePath,
sleep: noSleep,
random: noJitter,
defaultMaxAttempts: 1,
});
queue.registerHandler("ok", async () => {});
queue.registerHandler("bad", async () => {
throw new Error("nope");
});

const okJob = queue.enqueue("ok", {});
const badJob = queue.enqueue("bad", {});
const pendingJob = queue.enqueue("ok", {});

await queue.run(okJob.id);
await queue.run(badJob.id);

const metrics = queue.metrics();
assert.equal(metrics.deadLetterCount, 1);
assert.equal(metrics.depth, 1); // pendingJob never run
assert.equal(metrics.failureRate, 0.5); // 1 dead-letter of 2 terminal jobs
assert.ok(metrics.oldestPendingAgeMs >= 0);
void pendingJob;
});
150 changes: 150 additions & 0 deletions src/queue/queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { randomUUID } from "node:crypto";
import { JobStore } from "./store.js";
import type { Job, JobHandler, JobStatus, QueueMetrics } from "./types.js";

export interface JobQueueOptions {
filePath: string;
defaultMaxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
random?: () => number;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
}

const defaultSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

/**
* A small durable job queue for chain submission, export generation,
* indexer catch-up, and webhook fan-out — anything that takes long enough
* that running it inline in a request handler would tie up the connection.
*
* Idempotency: every job carries a stable id. `run()` checks the durable
* "processed" set *before* invoking the handler, so a job that already
* completed — including one whose completion write happened but whose
* process then crashed before returning to the caller — is not re-run
* after a restart. Handlers are expected to be idempotent themselves for
* the case where they crash mid-execution (having done real work) without
* ever reaching the processed marker; the queue does its part by never
* *knowingly* re-dispatching a job it already saw finish.
*/
export class JobQueue {
private readonly store: JobStore;
private readonly handlers = new Map<string, JobHandler>();
private readonly defaultMaxAttempts: number;
private readonly baseDelayMs: number;
private readonly maxDelayMs: number;
private readonly random: () => number;
private readonly sleep: (ms: number) => Promise<void>;
private readonly now: () => number;

constructor(opts: JobQueueOptions) {
this.store = new JobStore(opts.filePath);
this.defaultMaxAttempts = opts.defaultMaxAttempts ?? 5;
this.baseDelayMs = opts.baseDelayMs ?? 200;
this.maxDelayMs = opts.maxDelayMs ?? 10_000;
this.random = opts.random ?? Math.random;
this.sleep = opts.sleep ?? defaultSleep;
this.now = opts.now ?? Date.now;
}

registerHandler<P>(type: string, handler: JobHandler<P>) {
this.handlers.set(type, handler as JobHandler);
}

enqueue<P>(type: string, payload: P, opts?: { id?: string; maxAttempts?: number }): Job<P> {
const id = opts?.id ?? randomUUID();
const existing = this.store.get(id);
if (existing) return existing as Job<P>;

const job: Job<P> = {
id,
type,
payload,
status: "pending",
attempts: 0,
maxAttempts: opts?.maxAttempts ?? this.defaultMaxAttempts,
createdAt: this.now(),
updatedAt: this.now(),
};
this.store.put(job as Job);
return job;
}

/**
* Runs a single pending/failed job to completion (or into backoff /
* dead-letter). Safe to call twice for the same job id, including
* concurrently after a simulated crash-and-restart — see the idempotency
* test.
*/
async run(id: string): Promise<Job> {
const job = this.store.get(id);
if (!job) throw new Error(`unknown job ${id}`);

if (this.store.hasProcessed(id) || job.status === "completed") {
return job;
}

const handler = this.handlers.get(job.type);
if (!handler) throw new Error(`no handler registered for job type ${job.type}`);

job.status = "active";
job.attempts += 1;
job.updatedAt = this.now();
this.store.put(job);

try {
await handler(job.payload, job);
job.status = "completed";
job.updatedAt = this.now();
this.store.put(job);
this.store.markProcessed(id);
return job;
} catch (err) {
job.lastError = err instanceof Error ? err.message : String(err);
job.updatedAt = this.now();

if (job.attempts >= job.maxAttempts) {
job.status = "dead-letter";
this.store.put(job);
return job;
}

job.status = "pending";
this.store.put(job);

const cap = Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** (job.attempts - 1));
await this.sleep(this.random() * cap);
return this.run(id);
}
}

get(id: string): Job | undefined {
return this.store.get(id);
}

listByStatus(status: JobStatus): Job[] {
return this.store.all().filter((j) => j.status === status);
}

deadLetterQueue(): Job[] {
return this.listByStatus("dead-letter");
}

metrics(): QueueMetrics {
const all = this.store.all();
const pending = all.filter((j) => j.status === "pending" || j.status === "active");
const oldestPendingAgeMs = pending.length
? this.now() - Math.min(...pending.map((j) => j.createdAt))
: 0;
const completed = all.filter((j) => j.status === "completed").length;
const deadLetterCount = all.filter((j) => j.status === "dead-letter").length;
const terminal = completed + deadLetterCount;
return {
depth: pending.length,
oldestPendingAgeMs,
failureRate: terminal === 0 ? 0 : deadLetterCount / terminal,
deadLetterCount,
};
}
}
65 changes: 65 additions & 0 deletions src/queue/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { mkdirSync, existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
import { dirname } from "node:path";
import type { Job } from "./types.js";

/**
* Durable persistence for the queue: every mutation is written to disk with
* a write-to-temp-then-rename so a crash mid-write never leaves a
* half-written, unparseable file behind. This is deliberately a plain JSON
* snapshot rather than BullMQ/Redis — the acceptance criteria only require
* that jobs survive a restart, and a file gives us that without adding an
* external service dependency to run this repo's tests or CI.
*/
export class JobStore {
private jobs = new Map<string, Job>();
/** Job ids whose handler has been observed to run to completion at least
* once — consulted by the queue so a job replayed after a crash is not
* re-executed if it already finished. */
private processed = new Set<string>();

constructor(private readonly filePath: string) {
this.load();
}

private load() {
if (!existsSync(this.filePath)) return;
const raw = readFileSync(this.filePath, "utf-8").trim();
if (!raw) return;
const data = JSON.parse(raw) as { jobs: Job[]; processed: string[] };
for (const job of data.jobs) this.jobs.set(job.id, job);
for (const id of data.processed) this.processed.add(id);
}

private flush() {
mkdirSync(dirname(this.filePath), { recursive: true });
const tmpPath = `${this.filePath}.tmp`;
const data = {
jobs: [...this.jobs.values()],
processed: [...this.processed],
};
writeFileSync(tmpPath, JSON.stringify(data));
renameSync(tmpPath, this.filePath);
}

put(job: Job) {
this.jobs.set(job.id, job);
this.flush();
}

get(id: string): Job | undefined {
return this.jobs.get(id);
}

all(): Job[] {
return [...this.jobs.values()];
}

markProcessed(id: string) {
this.processed.add(id);
this.flush();
}

hasProcessed(id: string): boolean {
return this.processed.has(id);
}
}
30 changes: 30 additions & 0 deletions src/queue/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export type JobStatus =
| "pending"
| "active"
| "completed"
| "failed"
| "dead-letter";

export interface Job<P = unknown> {
id: string;
type: string;
payload: P;
status: JobStatus;
attempts: number;
maxAttempts: number;
createdAt: number;
updatedAt: number;
lastError?: string;
}

export type JobHandler<P = unknown> = (payload: P, job: Job<P>) => Promise<void>;

export interface QueueMetrics {
/** Jobs waiting to run. */
depth: number;
/** Age in ms of the oldest pending job, or 0 if the queue is empty. */
oldestPendingAgeMs: number;
/** completed / (completed + failed-terminal) over all jobs seen. */
failureRate: number;
deadLetterCount: number;
}
Loading