Agora
Transports

SQL (database)

A broker-less, DBOS-style transport driver — remote steps are rows in the Lucid database you already run. Workers claim tasks with an atomic, portable lease so a row is never run twice. The migration ships with @adonis-agora/durable.

The db driver needs no broker. A dispatched step is a row in the same database your durable store already uses: ctx.step inserts a task row, a worker claims it with an atomic lease, runs the handler, writes a result row, and the engine polls that row to resume the run. The database you already operate is the queue.

It ships inside @adonis-agora/durable@adonisjs/lucid is an optional peer, imported lazily only when you select the db transport. The transport tables come from a migration published when you configure the package:

node ace configure @adonis-agora/durable
node ace migration:run

configure publishes the durable-store migration and the transport-tables migration (which creates the four db-transport tables). If you don't use the db transport, delete the create_durable_transport_tables migration before running it.

Wiring it up

transports.db() rides your app's default Lucid connection — the driver resolves the db service for you when it starts:

config/durable.ts
import { defineConfig, transports } from '@adonis-agora/durable'

export default defineConfig({
  transport: 'db',
  transports: {
    db: transports.db(), // pass { connection: 'durable' } to target a non-default connection
  },
})

A worker process constructs the transport and registers handlers — served by name, one claim loop per handler name:

start/worker.ts (a separate worker process)
import db from '@adonisjs/lucid/services/db'
import { DbTransport } from '@adonis-agora/durable'

const transport = new DbTransport({ db }) // pass { partition } to isolate this pool
transport.handle('pipeline:extract', async (input) => extract(input))

Trade-off vs a real broker: throughput is bounded by polling + row contention — great for workflow/pipeline scale (modest rate, long steps), not for high-fanout firehoses. But it adds zero new infrastructure — no Redis, no SQS — which is often the right trade.

Options — transports.db(config)

OptionDefaultDescription
groupDeprecated. Steps route by handler name, so an instance serves whatever names it registers — no group to declare. Accepted for back-compat and otherwise ignored; use partition for isolation.
partitionOptional isolation partition suffixing every per-handler routing token (<name>@<partition>) this worker serves, matching a dispatch's partition.
namespacefrom the engineWorker-pool namespace stamped on every row this instance writes and required on every row it claims, so one set of tables can host several pools. Setting it here is explicit and wins over the engine's propagation of config.namespace.
connectionLucid defaultThe Lucid connection to use.
pollIntervalMs200Poll interval in ms.
leaseMs30000Claim lease duration for crash recovery — a row whose worker died is reclaimed after this.
batchSize20Max rows claimed per poll.
autoCreatetrueCreate the transport tables on first use (in addition to the published migration).
instanceIdrandom uuidStable process id stamped on claims and control messages.

transports.db(config) returns the lazy driver thunk used in config/durable.ts; the underlying class is DbTransport, also exported from @adonis-agora/durable for constructing a worker-side instance directly. @adonis-agora/durable also exports TRANSPORT_TABLES, createDurableTransportTables(db), and dropDurableTransportTables(db) for managing the schema yourself.

The tables

The published migration creates four tables (default names):

TableDirectionPurpose
durable_transport_tasksengine → workerstep dispatch (primary key step_id, idempotent on redelivery)
durable_transport_resultsworker → enginestep results
durable_transport_heartbeatsworker → engineliveness for timeoutMs steps
durable_transport_controlbidirectionallifecycle events + cancellation (best-effort, single-consumer)

durable_transport_tasks carries step_id (<runId>:<seq>, the primary key and step identity), run_id, seq, name (the handler key), grp (worker group), input, attempt, plus the claim columns claimed_by/claimed_at and created_at. The results table mirrors it with status, output, error, and started_at (when the worker picked the task up — what powers queue-wait timing in the dashboard).

The claim protocol

A worker claims a batch atomically, runs each task outside the lock, then records the result — portable across SQLite, Postgres, and MySQL without FOR UPDATE SKIP LOCKED:

  1. Claim — select candidate rows for the group that are unclaimed or whose lease expired (claimed_at IS NULL OR claimed_at < now − leaseMs), oldest first, bounded by batchSize; then a conditional UPDATE stamps a unique per-round token into claimed_by/claimed_at; then select back the rows that match this round's token. Two instances can never claim the same row because the token is unique per claim round.
  2. Run — execute the handler for name with the parsed input.
  3. Record — insert the result row (idempotent on step_id) and delete the task row.

The claimed_at < now − leaseMs clause is crash recovery in place of heartbeats: a row whose worker crashed mid-flight is reclaimed after leaseMs. Delivery is at-least-once (a crash between insert-result and delete-task can re-run a handler), so handlers should be idempotent, keyed on step_id.

On this page