Runlane
Concepts

Lanes, Storage, And Transport

Storage owns truth; transport wakes workers.

A lane is the infrastructure boundary Runlane uses at runtime. It combines one storage adapter and one transport adapter:

  • storage owns durable run truth
  • transport publishes wakeups
  • 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 wakeup mechanisms should change.

The Lane Boundary

Lane packages use createLane() from @runlane/contracts to compose compatible adapters:

import { createLane } from '@runlane/contracts'

export function createProductionLane() {
  const storage = postgresStorage({ connectionString, schema: 'runlane' })
  const transport = sqsTransport({ client, queues })

  return createLane({
    name: 'postgres-sqs',
    productionDurable: true,
    storage,
    transport,
  })
}

createLane() validates the adapter shapes, keeps the original adapter instances, exposes the adapter capability flags through lane.capabilities, and standardizes lifecycle order:

  1. lane.start() starts storage first.
  2. lane.start() starts transport second.
  3. If transport startup fails, storage is closed.
  4. lane.close() closes transport first.
  5. 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() 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 derived outbox rows atomically.

Storage also owns the durable concurrency decisions that cannot live in memory in a multi-process system:

Storage responsibilityWhy it belongs in storage
Event sequence checksCompeting writers need one authoritative compare-and-append boundary.
Idempotency ownershipConcurrent duplicate triggers must converge on one owner.
Singleton ownershipCompeting runs for the same singleton key must serialize.
Bounded queue capacityMultiple dispatchers and workers can race for the same queue partition.
Run leases and heartbeatsWorkers need durable execution ownership.
Schedule occurrence claimsMultiple maintenance processes may observe the same due fire time.
Outbox persistenceWakeups must be recoverable after process or provider failure.
Operator reads and pruningRun 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:

PathAcquisition sourceUse when
executeDelivery(message)A provider-delivered wakeup such as SQSA transport consumer already received a message for one run.
executeNext() or worker()Storage scansA process intentionally polls storage for due work.

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.

Local Lane

@runlane/lane-local provides createLocalLane() for development, examples, and tests. It composes createLocalStorage() and createLocalTransport() from @runlane/local-adapters with createLane().

The local lane is real Runlane behavior over process-local memory:

  • run state, event history, leases, schedule occurrences, idempotency owners, singleton owners, queue capacity state, and outbox rows live in memory
  • local storage reports processLocalState: true and durableState: false
  • local transport reports durableDelivery: false
  • 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, leases, schedule occurrences, idempotency and singleton ownership, bounded queue reservations, outbox rows, operator read state, and pruning state in Postgres.

It reports:

  • durableState: true
  • processLocalState: 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().

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:

  1. Validate the lane-owned options: optional name, required postgres, required sqs.
  2. Create SQS transport from the nested sqs options.
  3. Create Postgres storage from the nested postgres options.
  4. Return createLane({ name: 'postgres-sqs', productionDurable: true, storage, 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:

  1. trigger() creates or finds a durable run in Postgres.
  2. Core appends run.delivery_requested when delivery should be attempted.
  3. Postgres writes the event, projected run, and outbox row atomically.
  4. Eager trigger dispatch may claim and publish the just-created outbox row.
  5. SQS receives one wakeup message for that outbox row.
  6. A Lambda or long-running SQS consumer parses the wakeup and calls runlane.executeDelivery(message).
  7. Core re-reads Postgres, claims a run lease if the run is still executable for the delivered queue, and records the attempt outcome.
  8. 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:

ChooseWhen
Another queueSame storage, same transport family, same environment, same durability policy, but different routing, concurrency, or worker fleet.
Another laneDifferent 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.

On this page