Postgres Polling Lane
Production Postgres-only lane for durable workers that poll storage directly.
@runlane/lane-postgres-polling is the prescribed production lane when you want Runlane backed by Postgres only. It composes postgresStorage() from @runlane/postgres-storage with createLane() from @runlane/contracts and no external transport.
Use it for ordinary worker-process deployments on EC2, ECS, Fargate, Kubernetes, systemd, or any platform where a long-running process can poll Postgres. Core still owns task execution, schedules, retries, releases, cancellation, workers, and operator APIs. Postgres owns durable truth.
pnpm add @runlane/core @runlane/lane-postgres-polling @runlane/postgres-storage zodWhen To Use This Lane
Use this lane when one operational dependency is the right trade-off:
| Use it for | Why |
|---|---|
| Postgres-only production jobs | Runs, history, leases, schedules, idempotency, singleton ownership, operator reads, and pruning all live in Postgres. |
| Long-running worker processes | Workers call runlane.worker({ mode: WorkerMode.Poll }) and claim due runs from storage. |
| Scheduled jobs and polling jobs | tick() materializes schedules, makes due retries and releases visible to polling workers, and recovers expired leases. |
Use @runlane/lane-local for in-memory development and tests. See Lanes, Storage, And Transport when you are choosing between first-party lanes.
Create The Lane
import { createRunlane, queue, task, WorkerMode } from '@runlane/core'
import { postgresPollingLane } from '@runlane/lane-postgres-polling'
import * as z from 'zod'
const quickbooksQueue = queue({ name: 'quickbooks', default: true })
const syncQuickBooksInvoices = task({
id: 'quickbooks.invoices.sync',
queue: quickbooksQueue,
schema: z.object({ userId: z.string().min(1) }),
singletonKey: (payload) => `quickbooks_invoices_${payload.userId}`,
async run(payload, context) {
await syncInvoicesForUser(payload.userId, { signal: context.signal })
},
})
const lane = postgresPollingLane({
connectionString: process.env.DATABASE_URL,
schema: 'runlane',
})
const runlane = createRunlane({
lane,
queues: [quickbooksQueue],
tasks: { syncQuickBooksInvoices },
})
const worker = runlane.worker({
mode: WorkerMode.Poll,
queues: [quickbooksQueue],
concurrency: 4,
})
process.once('SIGTERM', () => {
void worker.stop()
})
await worker.closedpostgresPollingLane() accepts the same Postgres storage options users learn for postgresStorage(), plus an optional lane diagnostic name:
| Option | Required | Default | What it controls |
|---|---|---|---|
connectionString | Yes | None | Forwarded to postgresStorage(). Must use postgres:// or postgresql://. |
schema | No | URL ?schema=, then public | Forwarded to postgresStorage(). Must be an unquoted Postgres identifier. |
name | No | postgres-polling | Lane diagnostic name. |
Run getPostgresStorageMigrationSql() from @runlane/postgres-storage in your normal migration system before starting the lane. The polling lane does not auto-migrate. lane.start() probes the migrated Postgres tables and fails fast when the database, schema, or migration is not ready.
Capabilities
postgresPollingLane() returns a standard Lane from createLane():
| Boundary | Reported behavior |
|---|---|
| Lane name | name ?? 'postgres-polling' |
lane.capabilities.operatorReads | true, backed by Postgres operator reads |
lane.capabilities.productionDurable | true |
lane.capabilities.storage | Exact Postgres storage capabilities: durable state, process-shared state, durable steps, observation export, leases, idempotency, singleton, queue concurrency, schedule occurrence claims, persisted outbox, history reads, and pruning |
lane.delivery.mode | storage_polling |
The polling lane has no external transport. Core does not create delivery outbox rows for this lane, and tick() does not publish provider wakeups. If no polling worker is running, no external provider will wake work; the run remains durable and due in Postgres until a worker starts.
Worker Processes
Run a polling worker anywhere you can keep a process alive:
import { WorkerMode } from '@runlane/core'
const worker = runlane.worker({
mode: WorkerMode.Poll,
queues: [quickbooksQueue],
concurrency: Number(process.env.RUNLANE_WORKER_CONCURRENCY ?? 4),
emptyPollDelay: '100ms',
leaseDuration: '5m',
})
process.once('SIGTERM', () => {
void worker.stop()
})
process.once('SIGINT', () => {
void worker.stop()
})
await worker.closedconcurrency is local process fan-out. Queue concurrencyLimit is durable Postgres policy across all polling workers. Multiple worker processes can run for the same queue; storage decides which run each process owns through leases.
Maintenance
Polling workers execute due runs, but they do not materialize schedules or recover every waiting state on their own. Run tick() from a separate maintenance loop:
import { setTimeout as sleep } from 'node:timers/promises'
while (!signal.aborted) {
await runlane.tick()
await sleep(30_000, undefined, { signal })
}One maintenance pass handles:
| Tick phase | Why it matters in Postgres polling |
|---|---|
| Schedules | Due task-colocated schedules become durable queued runs. |
| Retries and releases | Runs waiting for retry backoff or ctx.release() are re-queued when due. |
| Cancellation finalization | Abandoned cancellation requests become terminal when eligible. |
| Delivery recovery | Due waiting or expired leased runs get fresh delivery intent so polling workers can claim them. |
| Expired leases | Crashed workers become recoverable after their lease expires. |
| Bounded queue dispatch | Postgres reserves durable queue capacity before dispatching bounded-queue runs. |
A common starting cadence is 30 seconds. Use a shorter cadence when schedule or retry latency matters; use a longer cadence when database polling pressure matters more. Worker emptyPollDelay controls how often idle workers scan for due runs after maintenance has made them runnable.
Deployment Shapes
Use one shared runtime factory for producers, workers, maintenance, and operator commands:
| Role | What it does |
|---|---|
| Producer | Calls runlane.start() once per process, then runlane.trigger() from app code. |
| Worker | Runs runlane.worker({ mode: WorkerMode.Poll }) for one or more queues. |
| Maintenance | Calls runlane.tick() on a cadence for schedules, retries, releases, lease recovery, and cancellation finalization. |
| Operator/CLI | Lists, inspects, cancels, retries, reruns, prunes, or triggers runs against the same Postgres lane. |
For ECS, Fargate, Kubernetes, or systemd, run the worker and maintenance roles as separate process types when possible. A small deployment can run both loops in one process, but keep the code paths separate so worker failures and maintenance failures are visible.