Durable job queues
@di-framework/queues is a durable job queue: producers enqueue work, DI-managed handlers process it, and a backend retains jobs across restarts. Enqueue resolves when the backend accepts the job. The worker acknowledges completion only after awaiting the handler.
This is not Events. @Publisher/@Subscriber and @di-framework/events move messages on an in-process bus or to a broker. Queues store jobs with leases, retries, timeouts, and a dead-letter state.
Delivery is at-least-once. Use stable job ids or idempotencyKey for application-level idempotency. Timeouts mark an attempt failed; they do not cancel JavaScript already running in the handler.
Installation
@QueueHandler is defined in @di-framework/core and re-exported here.
Receipt worker walkthrough
The receipt-worker example is the runnable end-to-end guide.
Producer
queue.get(name) returns a QueueProducer<T>. enqueue returns a Job<T> after the backend inserts it, not after the handler runs.
Handler
The dispatcher calls instance[method](job.payload, meta). Throw to fail the attempt.
Local worker
The example di-framework.config.json sets "applicationType": "worker".
Backends
Class |
| Use |
|---|---|---|
|
| Tests; virtual clock; |
|
| Local durability via |
|
| WASI SQLite; portable / wasmCloud entry |
The native package root exports SqliteQueueBackend. The wasmcloud export condition and @di-framework/queues/portable export the Wasm backend instead and do not import bun:sqlite. Portable imports alone do not provide durable Wasm storage; the composed di-framework:sqlite capability does.
queue is a process-wide QueueManager. Default backend is in-memory until queue.setBackend(...).
Options and defaults
Handler decorator defaults: maxRetries: 3, backoffMs: 1000, timeoutMs: 30000, concurrency: 1.
Enqueue uses the first registered handler for that queue as defaults, then falls back to those same numbers. Explicit enqueue options win. Import handler modules before producing jobs if you rely on decorator defaults.
Other enqueue fields: jobId, idempotencyKey, delayMs (default 0), priority (default 0). Dequeue order is priority DESC, availableAt ASC, enqueuedAt ASC.
Worker defaults: pollIntervalMs: 50, leaseTimeoutMs: 30000, recoveryIntervalMs: 10000, shutdownTimeoutMs: 5000.
Delivery, retries, and dead letters
Accept vs complete.
enqueueconfirms durable insert.completeruns only after the awaited handler returns.Job ids.
options.jobIdorjob_<timestamp>_<seq>_<rand>. Colliding SQLite primary keys throw.Idempotency. A matching
(queueName, idempotencyKey)returns the existing non-dead-letter job. Dead-lettered keys can be reused. The index is not unique.Retries.
failre-queues aspendingwhileattempts < maxRetries, with backoffmin(backoffMs * 2^(attempts-1), 60000). Otherwise the job becomesdead-letter.Timeouts. The dispatcher races
timeoutMs. The handler is not interrupted and may finish after a retry has begun. Make side effects idempotent.Leases. Dequeue sets
leaseExpiresAt.recoverUnacknowledgedreturns expiredprocessingrows topendingor dead-letter.Dead-letter retry.
retryJob(queueName, jobId?)sets matching dead-letter rows back topending. It does not resetattempts.
Completed and dead-letter jobs are retained; nothing purges them.
Testing with the in-memory backend
advanceTime(ms)moves the virtual clock (no real sleeps for delay/backoff)step(queueName?)dequeues one eligible jobdrain(queueName?, maxSteps?)loopsstepuntil emptysetExecutor(fn)supplies a default handler forstep/drain
step does not apply dispatcher timeouts; call ContainerQueueDispatcher.dispatch yourself when you need that path.
CLI
--status is pending | processing | completed | dead-letter. Inspect --limit defaults to 50. Database path: --db → DI_QUEUE_DB → existing .di-framework/queue.db → existing queue.db → else .di-framework/queue.db.
The receipt-worker local default is .di-framework/queues.db (plural). Pass --db or DI_QUEUE_DB to inspect that file.
JSON data through the public CLI envelope: { queues } for list, { jobs } for inspect, { retried } for retry. Missing @di-framework/queues exits 3 (QUEUES_PACKAGE_UNAVAILABLE).
wasmCloud workers
Build discovers @QueueHandler('name', { numeric options }). A project is a queue worker when handlers exist and either applicationType is "worker" or the sources have no HTTP controller decorators.
Implemented deploy path:
Guest WIT exports
wasi:http/[email protected]and importsdi-framework:sqliteGenerated module constructs
WasmSqliteQueueBackendandpump()s on control HTTP (request-scoped Wasm tasks do not startsetTimeoutpoll loops)Control prefix
/_di/queues/for list, enqueue, inspect, and retryWorkload
replicas: 1,deployPolicy: Recreate,hostgroup: storage, hostPath volume,QUEUE_DB_PATH=/data/queue.db,DI_SQLITE_BACKEND=wasmPublic ingress is omitted for workers; a ClusterIP Service still exists for control HTTP
List / enqueue / inspect require
invoke; retry requiresadmin
SQLite-backed workloads cannot use more than one replica (WASI VFS has no file locking).
Deployed HTTP workloads always receive DI_CONTROL_TOKEN. Unconfigured local/dev may invoke without a token (enqueue, list, inspect) but cannot retry. pump() runs after those control requests. Control paths reject X-Forwarded-* and are not reachable through public ingress. See Control HTTP.
You can still schedule work that enqueues jobs; neither feature requires the other.
Events versus queues
Queues | Events | |
|---|---|---|
Role | Durable jobs with persist, lease, retry, DLQ | In-process bus and broker bridge |
Produce |
|
|
Consume |
|
|
Durability | SQLite or in-memory job table | Broker-specific |
Inspect |
| Not a job CLI |
Limitations
At-least-once only; overlapping retries are possible after a timeout
SQLite/Wasm payloads are JSON; in-memory keeps object references
No Redis/Kafka/NATS queue backends
retryJobdoes not resetattemptsWasm workers process jobs when control HTTP runs
pump(), not via a hostqueueConsumersfield
Next steps
Events - Broker bridges, not durable jobs
Scheduling - Optional
@Cronthat can enqueue workCLI -
queue list/inspect/retryTesting - Isolated containers and in-memory backends
wasmCloud - Worker deploy without public ingress