Build and operate queued work

Follow the complete imported Queue example set: define durable data, register and process a job, compose chains and batches, configure retry policy, defer backpressure, and replay terminal failures.

On this pageSource-backed Markdown

In this guide

  1. 1
    Define

    Create a validated, JSON-safe QueueableJob.

  2. 2
    Compose

    Dispatch one job, a chain, or an independent batch.

  3. 3
    Recover

    Apply backoff, defer backpressure, and replay failures.

Run the installed SQL example

Complete Installation’s tarball/development setup and dedicated SQL test account first. Copy the shipped Queue examples below. These are application helper functions, not self-starting commands; the runner constructs the App, installs a disposable schema, registers the job and cleans up.

The job logs a report identity. For real file-backed report generation, start with the Queue guide. The deterministic output panels farther down illustrate older contract tests; the SQL runner gives the actual independent-consumer result.

Copy the shipped example
bash
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/queue/examples/. examples/

Compare chain and batch behavior

Run from your independent app root. Expect empty-chain rejection with zero writes, one initial chain job, one remaining successor after its predecessor, two immediate batch jobs, four successful outcomes and zero jobs left.

Run the lab
bash
npx tsx examples/runQueueWorkflows.ts

Inspect the complete application runner

The bounded calls deliberately process this lab’s known jobs. A production queue needs registered workers and an application idempotency policy; this demonstration does not guarantee atomic chain handoff after a crash.

examples/runQueueWorkflows.ts
ts
import { pathToFileURL } from 'node:url';
import { createGeneratedTestDatabase } from '@db3.ai/app/db/test/db';
import { FailedJob, QueuedJob } from '@db3.ai/app/queue';
import { App } from '@db3.ai/app/server';
import { registerGenerateReportJob } from './createAndProcessReportJob';
import { dispatchReportBatch, dispatchReportPipeline } from './dispatchReportWorkflows';

/**
 * Compares sequential chains with immediately queued batches using real SQL.
 *
 * The example job only logs a report identity. This lab proves orchestration,
 * not a reporting provider. It creates and destroys its own test database.
 *
 * @returns Stable queue counts, validation and processing observations.
 */
export async function runQueueWorkflows() {
	const database = await createGeneratedTestDatabase('queue_workflows');
	const application = new App({ db: database.db, queue: { queue: 'reports', queueMonitor: false } });
	try {
		application.log.level = 'silent';
		await application.db.install(QueuedJob, FailedJob);
		registerGenerateReportJob();
		let invalidRejected = false;
		try { await dispatchReportPipeline([]); } catch (error) { invalidRejected = error instanceof Error && error.message.includes('at least one report'); }
		const afterInvalid = await QueuedJob.query().count();
		await dispatchReportPipeline(['first', 'second']);
		const chainInitial = await QueuedJob.query().count();
		const first = await application.queue.workNextJob('reports');
		const chainRemaining = await QueuedJob.query().count();
		const second = await application.queue.workNextJob('reports');
		const batch = await dispatchReportBatch(['third', 'fourth']);
		const batchInitial = await QueuedJob.query().count();
		const third = await application.queue.workNextJob('reports');
		const fourth = await application.queue.workNextJob('reports');
		return { invalidRejected, afterInvalid, chainInitial, chainRemaining, batchInitial, batchIds: batch.length, statuses: [first?.status, second?.status, third?.status, fourth?.status], remaining: await QueuedJob.query().count() };
	} finally {
		try { await application.close(); } finally { await database.destroy(); }
	}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
	console.log(JSON.stringify(await runQueueWorkflows(), null, 2));
}

Testing

Create a tests/queue directory and save the test below as runQueueWorkflows.test.ts. It imports the example you copied into examples/. Keep the same folder layout so the relative import resolves.

Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout. This database lab needs the same test-only SQL credentials when run through Vitest.

tests/queue/runQueueWorkflows.test.ts
ts
import { expect, it } from 'vitest';
import { runQueueWorkflows } from '../../examples/runQueueWorkflows';

it('rejects an empty chain, then processes sequential and independent jobs on real SQL', async () => {
	expect(await runQueueWorkflows()).toEqual({
		invalidRejected: true, afterInvalid: 0, chainInitial: 1, chainRemaining: 1,
		batchInitial: 2, batchIds: 2, statuses: ['succeeded', 'succeeded', 'succeeded', 'succeeded'], remaining: 0,
	});
});

Run and extend the consumer test

Save the exact test in the documented folder, then change the report identities or add one batch job. Keep the invalid-input and final-queue assertions.

Run your copied test and check types
bash
npx vitest run tests/queue/runQueueWorkflows.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts

Coverage and related recovery

This consumer scenario proves real SQL chaining, batch dispatch, queue counts and empty-input recovery. Advanced lease loss, retry state, failure retention and provider backpressure have their own tests and guides. It does not prove Redis conformance or deployment crash safety.

1. Define the job

The constructor validates the same durable data supplied by application code and restored by workers. handle() resolves ordinary services from the active application.

GenerateReportJob.ts
ts
import { app } from '@db3.ai/app/server';
import { QueueableJob } from '@db3.ai/app/queue';

/**
 * Durable data required to generate one application report.
 */
export interface GenerateReportJobData extends Record<string, unknown> {
	/** Stable application-owned report identifier. */
	reportId: string;
}

/**
 * Example application job restored from its JSON-safe queue payload.
 */
export class GenerateReportJob extends QueueableJob<GenerateReportJobData> {
	/**
	 * Creates a report job after validating its durable payload.
	 *
	 * @param data - Report identity persisted with the queued job.
	 */
	constructor(data: GenerateReportJobData) {
		if (typeof data.reportId !== 'string' || data.reportId.trim() === '') {
			throw new Error('GenerateReportJob requires a report id.');
		}

		super(data);
	}

	/**
	 * Generates the report through services resolved from the active application.
	 */
	async handle(): Promise<void> {
		app().log.info({
			reportId: this.data.reportId,
		}, 'Generating report');
	}
}

How it works

  1. 1

    The constructor validates the same durable data used after rehydration.

  2. 2

    Only JSON-safe application identity is persisted in the queue payload.

  3. 3

    handle() resolves normal services from the active application context.

2. Register, dispatch, and process

The worker process registers the class before it claims persisted work. The dispatching process constructs the job, and workNextJob() exposes the detailed attempt outcome.

createAndProcessReportJob.ts
ts
import { app } from '@db3.ai/app/server';
import type { QueueJobId, QueueProcessResult } from '@db3.ai/app/queue';
import { GenerateReportJob } from './GenerateReportJob';

/**
 * Registers the example job in a worker process.
 */
export function registerGenerateReportJob(): void {
	app().queue.registerJob(GenerateReportJob);
}

/**
 * Dispatches one report job through the active application queue.
 *
 * @param reportId - Stable application-owned report identifier.
 * @returns Driver-owned queued job identifier.
 */
export async function dispatchGenerateReportJob(reportId: string): Promise<QueueJobId> {
	return app().queue.dispatch(new GenerateReportJob({ reportId }));
}

/**
 * Claims and processes the next job from the default queue.
 *
 * @returns Queue result, or null when no job is available.
 */
export async function processNextReportJob(): Promise<QueueProcessResult | null> {
	return app().queue.workNextJob();
}
Tested output
json
{
	"jobId": 1,
	"status": "succeeded",
	"completedJobs": 1,
	"remainingJobs": 0
}

How it works

  1. 1

    The worker registers the job class before it claims stored work.

  2. 2

    Dispatch persists the job data and returns the driver-owned id.

  3. 3

    workNextJob() restores a fresh instance and returns the attempt status.

3. Chain dependent work or batch independent work

The pipeline persists the next report only after success. The batch queues every report immediately on the reports worker pool.

dispatchReportWorkflows.ts
ts
import { app } from '@db3.ai/app/server';
import type { QueueJobId } from '@db3.ai/app/queue';
import { GenerateReportJob } from './GenerateReportJob';

/**
 * Dispatches report jobs as an ordered pipeline.
 *
 * Each job is persisted only after its predecessor succeeds. A terminal failure
 * therefore prevents the remaining reports from being dispatched.
 *
 * @param reportIds - Report identifiers to process in order.
 * @returns Driver-owned identifier for the first queued job.
 */
export async function dispatchReportPipeline(reportIds: string[]): Promise<QueueJobId> {
	if (reportIds.length === 0) {
		throw new Error('A report pipeline requires at least one report.');
	}

	return app().queue.chain(
		reportIds.map(reportId => new GenerateReportJob({ reportId })),
		{
			queue: 'reports',
		},
	);
}

/**
 * Dispatches report jobs independently so workers can process them in parallel.
 *
 * @param reportIds - Report identifiers that do not depend on one another.
 * @returns Driver-owned identifiers for every queued job.
 */
export async function dispatchReportBatch(reportIds: string[]): Promise<QueueJobId[]> {
	return app().queue.batch(
		reportIds.map(reportId => new GenerateReportJob({ reportId })),
		{
			queue: 'reports',
		},
	);
}
Tested output
json
{
	"pipeline": {
		"initiallyQueued": 1,
		"completedJobs": 2
	},
	"batch": {
		"jobIds": [
			3,
			4
		],
		"queuedJobs": 2,
		"queue": "reports"
	}
}

How it works

  1. 1

    The chain initially persists one job and dispatches its successor only after success.

  2. 2

    The batch persists both independent jobs immediately on the reports queue.

  3. 3

    Visible aggregate progress still belongs on an application model or Flow.

4. Configure and recover retries

The retry example persists exponential backoff, distinguishes explicit provider backpressure, and replays a failed record without deleting its audit history.

manageReportRetries.ts
ts
import { app } from '@db3.ai/app/server';
import { QueueRetryLaterError, type QueueJobId } from '@db3.ai/app/queue';
import { GenerateReportJob } from './GenerateReportJob';

/**
 * Dispatches one report with an explicit ordinary-failure retry policy.
 *
 * @param reportId - Stable application-owned report identifier.
 * @returns Driver-owned queued job identifier.
 */
export async function dispatchReportWithRetryPolicy(reportId: string): Promise<QueueJobId> {
	return app().queue.dispatch(new GenerateReportJob({ reportId }), {
		queue: 'reports',
		maxTries: 8,
		backoff: {
			strategy: 'exponential',
			initialSeconds: 15,
			maxSeconds: 900,
			jitter: true,
		},
		retryUntilSeconds: 21_600,
	});
}

/**
 * Defers the current job when a provider supplies an explicit retry delay.
 *
 * Throw this from a QueueableJob handler after detecting provider backpressure.
 * Deferral restores the claimed attempt instead of consuming an ordinary retry.
 *
 * @param delaySeconds - Provider-requested delay before another attempt.
 */
export function retryReportAfterBackpressure(delaySeconds: number): never {
	throw new QueueRetryLaterError(delaySeconds, 'The report provider requested backpressure.');
}

/**
 * Replays one terminal report failure while preserving its audit record.
 *
 * @param failedJobId - Driver-owned identifier from failedJobs().
 * @returns Driver-owned identifier for the replacement queue job.
 */
export async function retryFailedReportJob(failedJobId: QueueJobId): Promise<QueueJobId> {
	return app().queue.retryFailed(failedJobId, {
		queue: 'reports',
	});
}
Tested output
json
{
	"policy": {
		"queue": "reports",
		"maxTries": 8,
		"backoff": {
			"strategy": "exponential",
			"initialSeconds": 15,
			"maxSeconds": 900,
			"jitter": true
		}
	},
	"backpressure": {
		"error": "QueueRetryLaterError",
		"delaySeconds": 45
	},
	"replay": {
		"replacementId": 2,
		"failedAuditRecords": 1,
		"retryOf": {
			"failedJobId": "failed_report_01",
			"jobUuid": "failed-report-uuid"
		}
	}
}

How it works

  1. 1

    Ordinary failures consume attempts and use the persisted exponential backoff policy.

  2. 2

    QueueRetryLaterError carries provider backpressure without consuming an ordinary attempt.

  3. 3

    Replay creates a replacement job while preserving the original failed audit record.

Behaviour tested Executed by the documentation maintenance gate
What this does

Rejects an empty chain, then processes sequential and independent jobs on real SQL.

Expected outputThe guide test passes against the real framework components.
Behaviour testpackages/app/src/queue/tests/examples/runQueueWorkflows.test.ts

This test command requires the framework repository. Use the walkthrough commands in an installed application.

Environment: Disposable SQL; no external report provider.