Lanes, Storage, And Transport
Storage owns truth; delivery mode chooses polling or transport wakeups.
A lane is the infrastructure boundary Runlane uses at runtime. It combines one storage adapter with an explicit delivery mode:
- storage owns durable run truth
- storage leases let polling workers scan and lease due runs directly
lane.delivery.modeselects storage polling or transport-backed wakeups- transport delivery publishes provider messages that call back into storage-verified execution
- core owns task behavior, reducer semantics, retries, releases, schedules, cancellation, workers, and operator APIs
That split is intentional. A Postgres-backed runtime and an in-memory local runtime should run the same core behavior; only the persistence and acquisition mechanisms should change.
The Lane Boundary
Lane packages use createLane() from @runlane/contracts to compose compatible adapters:
import { createLane, LaneDeliveryMode } from '@runlane/contracts'
export function createProductionLane() {
const storage = postgresStorage({ connectionString, schema: 'runlane' })
const transport = sqsTransport({ client, queues })
return createLane({
delivery: { mode: LaneDeliveryMode.Transport, transport },
name: 'postgres-sqs',
productionDurable: true,
storage,
})
}createLane() validates the adapter shapes and delivery mode, keeps the original adapter instances, exposes storage and lane capability flags through lane.capabilities, and standardizes lifecycle order:
lane.start()starts storage first.lane.start()starts the transport second whenlane.delivery.modeistransport.- If transport startup fails, storage is closed.
lane.close()closes the transport first whenlane.delivery.modeistransport.lane.close()closes storage second.
It is not a capability shim. It does not make storage durable, add operator reads, provide missing queue semantics, or emulate unsupported transport behavior.
createLane() requires an explicit delivery mode. It still defaults operatorReads to true, productionDurable to false, and name to lane. Lane packages should override those defaults only when the composed adapters actually provide the advertised behavior. Malformed lane options and incomplete adapters fail fast with RunlaneError and ErrorCode.ConfigurationInvalid.
Storage
Storage persists append-only run events and the current materialized run state. Core supplies the projected run state when it appends events; storage verifies the command, checks optimistic sequence ownership, and commits the events, projection, storage-owned ownership rows, and any requested derived outbox rows atomically.
Storage also owns the durable concurrency decisions that cannot live in memory in a multi-process system:
| Storage responsibility | Why it belongs in storage |
|---|---|
| Event sequence checks | Competing writers need one authoritative compare-and-append boundary. |
| Idempotency ownership | Concurrent duplicate triggers must converge on one owner. |
| Singleton ownership | Competing runs for the same singleton key must serialize. |
| Bounded queue capacity | Multiple dispatchers and workers can race for the same queue partition. |
| Run leases and heartbeats | Workers need durable execution ownership. |
| Durable step completion | A stale worker must not checkpoint output after losing the active attempt lease. |
| Schedule occurrence claims | Multiple maintenance processes may observe the same due fire time. |
| Outbox persistence | Transport wakeups must be recoverable after process or provider failure. |
| Operator reads and pruning | Run history, summaries, and retention need backend-owned pagination and indexes. |
Storage capabilities describe which of those guarantees an adapter implements. Unsupported feature methods must fail with ErrorCode.CapabilityUnsupported; they must not silently return an empty result or a fake success.
Storage-polling workers use storage directly. executeNext() and worker({ mode: 'poll' | 'drain' }) ask storage for due run candidates, then try to claim a lease before executing.
See Adapter Authoring for the full storage contract and Postgres Storage for the first-party durable implementation.
Transport
Transport publishes minimal wakeups. A wakeup identifies the environment, run id, logical queue, request time, and optional trace context. It must not carry task payloads, run projections, retry policy, schedule state, or operator data.
Duplicate, delayed, or stale wakeups are safe because a worker does not trust the message as truth. It passes the wakeup to runlane.executeDelivery(message), and core re-reads storage before execution. Delivery proceeds only when storage still says that exact run is due for that queue and can be leased. Terminal, not-due, wrong-queue, already-leased, claim-lost, or otherwise stale wakeups are ack-safe ignored results.
Transport-driven execution is different from storage polling:
| Path | Acquisition source | Use when |
|---|---|---|
executeDelivery(message) | A provider-delivered wakeup such as SQS | A transport consumer already received a message for one run. |
executeNext() or worker() | Storage scans | A process intentionally polls storage for due work. |
A custom deployment may deliberately run storage-polling workers alongside a transport consumer during migration or as a polling backstop. The lane contract only models the optional external transport; polling is possible when storage supports run leases and workers are configured to poll. Deployment still needs one acquisition owner per logical queue. Polling workers can execute durable runs that also had provider wakeups published, but they cannot acknowledge provider messages. Without a consumer for that provider queue, messages can redrive or land in a provider DLQ.
Do not publish provider messages for a queue and then only run a storage-polling worker for that same queue. The worker may still execute the durable runs, but it will not receive or delete provider messages, so those messages can redrive or land in a provider DLQ.
See SQS Transport for the first-party wakeup transport and its Lambda and long-running consumer helpers.
Lane Matrix
| Lane | Storage | Delivery topology | Acquisition path | Use when |
|---|---|---|---|---|
@runlane/lane-local | In-memory process-local state | Storage polling | Storage scan in the owning process | Development, demos, and application tests. |
@runlane/lane-postgres-polling | Durable Postgres state | Storage polling | worker({ mode: WorkerMode.Poll }) scans Postgres | Production jobs with one operational dependency and long-running worker processes. |
@runlane/lane-postgres-sqs | Durable Postgres state | SQS wakeups, durableDelivery: true | SQS consumers call executeDelivery(message) | AWS event-driven wakeups, Lambda consumers, or workloads where polling pressure should not drive wake latency. |
Choosing Between Postgres Polling And Postgres/SQS
Postgres polling keeps the production surface to Postgres plus worker processes. Postgres/SQS keeps Postgres as durable truth but adds provider wakeups and provider message ownership.
| Choose | When |
|---|---|
| Postgres polling | You want one operational dependency, can keep long-running workers online, and accept database scan-and-claim latency. |
| Postgres/SQS | You want provider-delivered wakeups, Lambda or SQS consumer scale-out, or workloads where polling pressure should not drive wake latency. |
The migration path keeps task definitions, Postgres storage semantics, and operator APIs stable:
- Add SQS queues and bind each Runlane queue with
sqsQueue(). - Switch the lane factory from
postgresPollingLane({ connectionString, schema })topostgresSqsLane({ postgres: { connectionString, schema }, sqs }). - Replace polling workers for those queues with SQS consumers that call
runlane.executeDelivery(message). - Keep
tick()running as separate maintenance infrastructure.
Do not run SQS consumers and polling workers for the same logical queue unless the migration intentionally accepts duplicate acquisition attempts. Runlane storage leases protect durable execution, but provider messages still need one clear owner.
Local Lane
@runlane/lane-local provides createLocalLane() for development, examples, and tests. It composes createLocalStorage() from @runlane/local-adapters without an external transport.
The local lane is real Runlane behavior over process-local memory:
- run state, event history, durable step completions, observation records, exporter checkpoints, leases, schedule occurrences, idempotency owners, singleton owners, and queue capacity state live in memory
- local storage reports
processLocalState: trueanddurableState: false lane.delivery.modeisstorage_polling- the composed lane reports
productionDurable: false
Use it when you want a complete local backend without Postgres, SQS, or cloud credentials. Do not use it to prove production crash recovery, database isolation, provider delivery, or cross-process state sharing. A second process that constructs createLocalLane() gets a different in-memory store unless it talks to the process that owns the local runtime.
Postgres Storage
@runlane/postgres-storage is the first-party durable storage adapter. It stores run history, materialized run state, durable step completions, observation records, exporter checkpoints, leases, schedule occurrences, idempotency and singleton ownership, bounded queue reservations, outbox rows, operator read state, and pruning state in Postgres.
It reports:
durableState: truedurableSteps: trueexportsObservations: trueprocessLocalState: false- idempotency, singleton, queue concurrency, leasing, schedule claims, outbox persistence, run-history reads, and pruning support as enabled
postgresStorage({ connectionString, schema }) resolves the schema from the explicit schema option, then from a Prisma-style ?schema= query parameter, then from public. It removes that query parameter before creating the pg pool. Runlane does not auto-migrate at adapter startup; apply getPostgresStorageMigrationSql() through your normal migration system before starting workers. postgresStorage().start() probes the migrated runs table so missing schema or migration problems fail during lane.start().
Postgres Polling Lane
@runlane/lane-postgres-polling is the first-party production Postgres-only composition:
- Validate the lane-owned options: required
connectionString, optionalschema, optionalname. - Create Postgres storage from those options.
- Return
createLane({ name: 'postgres-polling', productionDurable: true, storage, delivery: { mode: LaneDeliveryMode.StoragePolling } }).
There is no fake polling transport. Core records delivery intent in Postgres run state and workers acquire due work by polling storage. Because lane.delivery.mode is storage_polling, core does not create or publish delivery outbox rows for this lane.
This is the default production path when teams want durable jobs, schedules, retries, releases, idempotency, singleton enforcement, operator reads, and worker processes backed by Postgres only. Run maintenance with tick() and run workers with worker({ mode: WorkerMode.Poll }). If all workers are down, no external system wakes work; due runs remain durable in Postgres until a polling worker starts.
See Postgres Polling Lane for setup, worker, and maintenance guidance.
SQS Transport
@runlane/transport-sqs is the first-party production wakeup transport. It maps logical Runlane queues to SQS queues with sqsQueue(queueDefinition, options), then publishes one SQS message per durable outbox row.
The SQS message body is a versioned runlane.wakeup envelope containing only the delivery message. The adapter may batch provider calls with SendMessageBatch, but it does not bundle multiple runs into one JSON body. publishWakeups() returns one indexed outcome for each attempted wakeup so core can mark each claimed outbox row published, failed, or dead-lettered.
Standard SQS queues are the default recommendation because Runlane correctness comes from storage leases and durable run state, not provider ordering. FIFO queues are supported through queue-level fifo options; the adapter reports messageGrouping: true and orderedDelivery: true only when every configured queue is FIFO. SQS native delay is not used for schedules, retries, releases, or outbox recovery; storage decides when work is due.
An SQS-consuming deployment should use one acquisition path per logical queue:
- Lambda with
createSqsLambdaHandler() - a long-running process with
createSqsDeliveryConsumer() - another deliberate SQS consumer that parses wakeups and calls
executeDelivery()
Postgres/SQS Lane
@runlane/lane-postgres-sqs is the reference production composition:
- Validate the lane-owned options: optional
name, requiredpostgres, requiredsqs. - Create SQS transport from the nested
sqsoptions. - Create Postgres storage from the nested
postgresoptions. - Return
createLane({ name: 'postgres-sqs', productionDurable: true, storage, delivery: { mode: LaneDeliveryMode.Transport, transport } }).
The package does not import core and does not add runtime semantics. It does not implement worker loops, Lambda behavior, retries, releases, schedules, storage polling, or outbox recovery. Those remain core and transport-helper responsibilities.
In the normal production flow:
trigger()creates or finds a durable run in Postgres.- Core appends
run.delivery_requestedwhen delivery should be attempted. - Postgres writes the event, projected run, and outbox row atomically.
- Eager trigger dispatch may claim and publish the just-created outbox row.
- SQS receives one wakeup message for that outbox row.
- A Lambda or long-running SQS consumer parses the wakeup and calls
runlane.executeDelivery(message). - Core re-reads Postgres, claims a run lease if the run is still executable for the delivered queue, and records the attempt outcome.
tick()handles recovery and maintenance: deferred outbox rows, failed eager publishes, due schedules, due releases and retries, expired leases, cancellation finalization, and stranded outbox rows.
Run tick() from separate maintenance infrastructure such as EventBridge, cron, a sidecar, or an operator command. Do not add a global tick() pass to every SQS delivery batch handler.
See Postgres SQS Lane for configuration and deployment details.
Lanes Versus Queues
Use queues for workload routing inside one runtime boundary. Examples: default, emails, billing, imports, media, or high-priority. Queues decide which workers may claim work and where transport wakeups are published; they do not create a separate durable truth boundary.
Use separate lanes only when the infrastructure boundary is actually different:
| Choose | When |
|---|---|
| Another queue | Same storage, same transport family, same environment, same durability policy, but different routing, concurrency, or worker fleet. |
| Another lane | Different database, schema, AWS account, region, tenant boundary, compliance boundary, durability policy, or transport/storage pair. |
Splitting lanes when a queue would do makes operator views, maintenance, migrations, and recovery harder. Overloading queues when the storage boundary is truly different makes isolation and failure handling unclear.