Skip to content

Job Processing

This document describes how Queuert processes jobs: transactional design, prepare/complete pattern, and timeout philosophy.

Queuert’s core design principle is that jobs are created inside the same database transaction as your application state changes. This follows the transactional outbox pattern:

await withTransactionHooks(async (transactionHooks) =>
db.transaction(async (tx) => {
// Application state change
const image = await tx.images.create({ ... });
// Job creation in the same transaction
// The transaction context property name matches your StateProvider
await client.startJobChain({
tx,
transactionHooks,
typeName: "process-image",
input: { imageId: image.id },
});
}),
);
  1. Atomicity: If the transaction rolls back, the job is never created. No orphaned jobs.
  2. Consistency: The job always references valid application state.
  3. No dual-write problem: You don’t need to coordinate between your database and a separate job queue.

The same transactional principle extends to job processing through the prepare/complete pattern:

  • Prepare phase: Read application state within a transaction
  • Processing phase: Perform side-effects (API calls, file operations) outside the transaction
  • Complete phase: Write results back within a transaction

This ensures that job outputs and continuations are also created atomically with any state changes they produce.

Observability events (metrics, span ends, logs) emitted during the prepare and complete phases are transactional — they are buffered and only flushed after the transaction commits. If the transaction rolls back, no observability events leak out.

Attempt handlers split processing into distinct phases to support both atomic (single-transaction) and staged (long-running) operations. See AttemptHandler TSDoc for the full handler signature and AttemptPrepareOptions for mode details.

Most jobs don’t need prepare. Call complete directly and auto-setup infers the mode:

  • Synchronous complete (called immediately, no prior await): atomic mode — single transaction wraps everything
  • Async work before complete: staged mode — lease renewal active between async work and complete
  • Accessing prepare after auto-setup throws: “Prepare cannot be accessed after auto-setup”

See Processing Modes for examples and a decision flowchart.

For more control, call prepare explicitly:

  • Atomic mode: Prepare and complete run in the same transaction. Rarely needed since calling complete directly achieves the same result with less ceremony.
  • Staged mode: Prepare runs in one transaction, long-running work happens outside, then complete runs in another transaction. The worker automatically renews the job lease between phases. Implement the processing phase idempotently as it may retry if the worker crashes.

Both the prepare and complete callbacks run inside database savepoints. This is the mechanism that keeps jobs safe when user code throws.

A naive approach would run user callbacks directly inside the job’s transaction. The problem: if user code throws after executing partial SQL, the transaction is poisoned — most databases reject further statements on a transaction that has seen an error. The engine couldn’t even reschedule the job because the reschedule SQL would fail on the same broken transaction.

Savepoints solve this. A savepoint is a checkpoint within a transaction. If code inside the savepoint throws, the database rolls back to that checkpoint — undoing the partial work — while the outer transaction remains healthy. The engine can then reschedule the job and commit normally.

┌─ Transaction (acquires job) ──────────────────────────────┐
│ │
│ ┌─ Savepoint (prepare callback) ──┐ │
│ │ User SQL... │ ← throws? rollback │
│ │ User SQL... │ to savepoint │
│ └─────────────────────────────────┘ │
│ │
│ ... async work (staged mode only) ... │
│ │
│ ┌─ Savepoint (complete callback) ─┐ │
│ │ User SQL... │ ← throws? rollback │
│ │ completeJob / continueWith │ to savepoint │
│ └─────────────────────────────────┘ │
│ │
│ On error: reschedule with backoff ← always succeeds │
│ On success: commit │
└───────────────────────────────────────────────────────────┘

On any unhandled error the job is rescheduled with exponential backoff (default: 10 s → 20 s → 40 s → … capped at 300 s). There is no maximum retry count — jobs retry indefinitely. Use discriminated unions or compensation patterns to handle permanently failing jobs.

See Job Processing Reliability for per-phase error scenarios with code examples.

Queuert does not provide built-in soft timeout functionality. This is intentional:

  1. Userland solution is trivial: Combine AbortSignal.timeout() with the existing signal parameter using AbortSignal.any()
  2. Lease mechanism is the hard timeout: If a job doesn’t complete within leaseMs, the reaper reclaims it and another worker retries

Users implement cooperative timeouts by combining AbortSignal.timeout() with the existing signal parameter using AbortSignal.any().

For hard timeouts (forceful termination), the lease mechanism already handles this:

  • Configure leaseMs appropriately for the job type
  • If the job doesn’t complete or renew its lease in time, the reaper reclaims it
  • Another worker can then retry the job