Skip to main content

Transactional enqueue

The classic dual-write bug: you write a business row, then enqueue a job — but the process crashes between the two, or the write rolls back after the job was queued. Now a job references data that doesn't exist (or vice-versa).

PortQueue fixes this by letting the insert join your own transaction. The job exists iff your business write commits. The NOTIFY that wakes workers fires only after commit.

The core pattern​

The flow is always the same four steps: open a transaction → do your business write on it → addJob({ tx }) on the same transaction → commit. Both writes land together or not at all. If you roll back, the job insert rolls back with your business row.

import { fromKnex } from '@portqueue/core/tx';

// 1. Open your own transaction.
await knex.transaction(async (trx) => {
// 2. Do your business write on it.
const [order] = await trx('orders').insert({ customer: 'a@b.com', total: 4200 }).returning('*');

// 3. Enqueue on the SAME transaction — wrap it in a PortQueue adapter.
await pq.addJob({
queue: 'emails',
data: { to: order.customer },
tx: fromKnex(trx),
});

// 4. Committing the transaction (returning from the callback) makes BOTH
// the order row and the job durable atomically. Throwing here rolls
// back the order AND the queued job together.
});

Because the job insert lives inside your transaction, workers are only told about it after the transaction commits — never before. There's no window where a worker can claim a job whose business row hasn't been committed yet.

Wake-up after commit is engine-specific

On PostgreSQL / PGLite the driver issues a NOTIFY immediately after your transaction commits, so LISTEN-ing workers wake and claim the job almost instantly. On engines without LISTEN/NOTIFY (SQLite, MySQL) there is no cross-process wake-up — a worker picks the job up on its next poll (limiter.pollIntervalSeconds, default 5 seconds on all drivers). The job is durable either way the moment you commit; only the latency to first claim differs.

Adapters from @portqueue/core/tx​

An adapter exists for every supported client. Pick your tab:

import { fromKnex } from '@portqueue/core/tx';

await knex.transaction(async (trx) => {
await trx('order').insert(order);
await pq.addJob({ queue: 'fulfillment', data: { orderId }, tx: fromKnex(trx) });
}); // the developer's trx owns commit/rollback

Adapters ship for the query builders fromKnex, fromKysely, fromDrizzle; the ORMs fromPrisma, fromTypeorm, fromSequelize, fromMikroOrm; and the raw clients fromPg, fromOracle, fromBetterSqlite3, and fromNodeSqlite.

When no tx is passed, the method runs autonomously in its own short transaction. Every job-manipulating method that writes to the database takes an optional tx — addJob, addJobs, retryJob, cancelJob, removeJob, removeJobs, and removeSchedule — so any of them can join your transaction and commit atomically with your business write. addJobs is a single insert for the whole batch, so its entries must all carry the same tx (or none at all). (Queue/instance control and createQueue are not DB mutations and take no tx.)