Tasks And Runs
User code is a task; durable execution is a run.
A task is user code plus the payload schema and execution policy around that code. A run is one durable execution record for a task and payload.
This separation is important. A task can be triggered many times. Each trigger creates or reuses a run according to idempotency and singleton rules. Operators inspect runs, not task definitions, because runs are where attempts, failures, releases, leases, and final outcomes live.
Use this page for the task/run mental model. For deeper surfaces, see Schedules, Retry Vs Release, Operator APIs, Rerun And Manual Retry, Current-Process Execution, and the Run Lifecycle vocabulary.
Task
A task definition describes:
- the stable task id
- the payload schema
- the default queue definition
- the retry budget and backoff policy
- optional idempotency and singleton keys
- optional colocated schedule entries with static payloads
- the handler that executes user code
The payload is validated before user code runs. Public task schemas use the Standard Schema contract so runtime packages can accept Zod and other compatible validators without coupling the public API to one validation library.
In @runlane/core, task() is the authoring helper for this definition. It accepts a task id, a Standard Schema-compatible schema, and a handler:
import { task } from '@runlane/core'
const sendEmail = task({
id: 'emails.send',
schema,
async run(payload, context) {
await sendEmailToUser(payload.userId)
},
})task() validates and normalizes the definition, then returns an immutable task handle. Treat that handle as deployment-time configuration:
- register it with a runtime
- trigger it through that runtime
- do not mutate it to change queues, retry policy, schedules, schema, or handlers
To change task behavior, create a new task definition and deploy a runtime with that definition.
task() options:
| Option | Required | Default | What it controls |
|---|---|---|---|
id | Yes | None | Stable task identity. Use names such as emails.welcome; do not include tenant, user, or resource ids. |
schema | Yes | None | Standard Schema-compatible payload validator. Runtime validates before trigger creation and again before handler execution. |
run | Yes | None | Async or sync task handler. It receives the validated payload and TaskContext. |
queue | No | Runtime default queue | Default queue definition for runs of this task. |
retry | No | No automatic retries | Retry budget and backoff for retryable task failures. |
schedule | No | No schedule | One schedule entry or an array of schedule entries colocated with the task. |
idempotencyKey | No | None | Static key or payload resolver for producer dedupe. Duplicate active or retained terminal owners return the original run. |
idempotencyKeyTTL | No | 30d when an idempotency key exists | Retention for successful or cancelled terminal idempotency owners. Use 'active' to release at terminal. Requires an idempotency key. |
singletonKey | No | None | Static key or payload resolver for one active protected resource. Requires a lane whose storage reports enforcesSingleton. |
concurrencyKey | No | None | Static key or payload resolver for bounded-queue capacity partitioning. Requires a queue with concurrencyLimit. |
Runlane ids, queue names, idempotency keys, singleton keys, and concurrency keys are opaque strings. Values must be non-empty and must not contain :. Queue definitions are created with queue() and registered on the runtime; core still brands and revalidates untrusted task definitions at runtime before writing durable run, schedule, lease, and outbox records.
Register tasks when creating the runtime:
const runlane = createRunlane({
lane,
queues,
tasks: { sendEmail },
})When tasks is supplied as a named catalog, that catalog is authoritative for triggering, executing, and maintaining work, and the same task handles are available through runlane.tasks. Triggering a task outside the catalog fails before a run is created, and workers can only execute task ids registered in that runtime.
Arrays are still accepted for registration-only runtimes, but application factories should prefer named catalogs so task registration and task lookup stay in one place.
trigger(task, payload, options) options:
| Option | Default | What it controls |
|---|---|---|
actor | { type: ActorType.System } | Actor recorded on the initial run.created event and on immediate unbounded-queue delivery requests. |
concurrencyKey | Task-level concurrencyKey | Per-run bounded-queue partition override. |
idempotencyKey | Task-level idempotencyKey | Per-run idempotency owner override. |
idempotencyKeyTTL | Task-level idempotencyKeyTTL, then 30d | Retention for the selected idempotency owner. Requires a task or trigger idempotency key. |
meta | None | JSON object stored on the initial run event and run projection for operator context. |
queue | Task queue or runtime default queue | Per-run queue definition override. It must match a registered runtime queue definition. |
runId | Generated run_${uuid} | Caller-supplied run id for external correlation. It participates in optimistic creation and should be rare. |
singletonKey | Task-level singletonKey | Per-run active-resource lock override. |
traceCarrier | None | Small string map for trace headers or request correlation. |
trigger() returns a TriggerRunResult:
| Outcome | Meaning |
|---|---|
TriggerOutcomeType.Created | This call persisted a new run. Unbounded queues also get an immediate delivery request; bounded queues wait for durable capacity reservation. |
TriggerOutcomeType.ReturnedExisting | Storage idempotency returned the active or retained run that already owns the selected idempotency key. |
Use the run field for the durable record:
const { run, outcome } = await runlane.trigger(runlane.tasks.sendWelcomeEmail, {
userId: 'user_123',
})runNow(task, payload, options) uses the same durable creation options, then adds lease execution controls such as leaseDuration, heartbeatInterval, signal, and workerId. It creates run.created + run.lease_claimed atomically and executes exactly one current-process attempt without creating an initial delivery request. Duplicate idempotency owners return the existing run without executing another inline attempt. See Current-Process Execution for the full API reference and failure modes.
The runtime validates payloads twice: once when trigger() creates new work, and again when executeNext() reads the persisted payload before calling user code. The second validation catches schema drift in old runs before the handler sees data it no longer accepts.
Tasks may omit payload when their schema accepts undefined. Callers may use runlane.trigger(task) and task-colocated schedules may omit payload only for those no-payload task schemas; that omission is not inheritance or a hidden callback, and no synthetic payload is written into run history.
Retry policies are part of the task definition. maxAttempts is the total attempt budget, including the first attempt. backoff may be a fixed duration string or a fixed/exponential backoff object; when omitted, core uses contractDefaults.retry.backoff.
Handlers can return context.release(delay, options) when the correct outcome is to continue later without recording a failure. Release is for business waiting, not exceptions. See Retry Vs Release for the retry budget, backoff, and release semantics.
Task Ids, Idempotency, Singleton, And Concurrency
Use the task id to name the kind of work. Use singletonKey to name the active resource that must not overlap.
import { queue, task } from '@runlane/core'
const quickBooksQueue = queue({
name: 'quickbooks',
concurrencyLimit: 2,
dispatchTimeout: '2m',
})
const syncQuickBooksInvoices = task({
id: 'quickbooks.invoices.sync',
queue: quickBooksQueue,
schema: quickBooksSyncSchema,
concurrencyKey: (payload) => `quickbooks_${payload.userId}`,
singletonKey: (payload) => `quickbooks_invoices_${payload.userId}`,
async run(payload, context) {
await syncInvoicesForUser(payload.userId, { signal: context.signal })
},
})In this example, quickbooks.invoices.sync identifies the task definition. Every user uses the same task id because every run executes the same handler.
quickbooks_invoices_user_123 identifies the protected resource. If a cron schedule and a UI button both trigger invoice sync for user_123, they should resolve the same singleton key so storage can reject overlapping active runs for that user.
Idempotency keys answer: "Is this the same logical trigger request as before?"
| State | Idempotency behavior |
|---|---|
| Active owner exists | Repeated triggers return the original run. |
| Successful or cancelled terminal owner exists | The owner is retained for idempotencyKeyTTL, which defaults to 30d. |
| Failed owner reaches terminal | The key is released automatically. |
idempotencyKeyTTL: 'active' | The key is released as soon as the run reaches any terminal state. |
The scope is environment + task id + idempotency key. Runlane does not compare payloads for idempotency keys. Reusing the same key with different payloads returns the original run. If an integration needs payload-mismatch detection or permanent API response replay, keep that in an application request ledger.
Operators can clear one retained terminal owner with runlane.idempotencyKeys.reset(task, { key }). Reset rejects active owners because it is an escape hatch for retained terminal keys, not a force-run option.
Singleton keys are active-run locks. They prevent overlap while a matching run is queued, running, retrying, released, or otherwise active.
Terminal runs release the active singleton key when storage persists the terminal projection. Core validates and resolves the key, then requires the selected lane to report storage.enforcesSingleton: true; storage owns the uniqueness guarantee.
The local lane enforces singleton keys in memory so application tests can use the same task definitions as production. It does not prove production locking or transaction behavior.
Keep singleton keys stable and resource-oriented. Do not put user ids, provider account ids, or tenant ids into the task id. If external ids can contain reserved characters or unbounded text, encode or hash them into a Runlane-safe singleton key before returning it.
Concurrency keys partition a bounded queue. They answer: "Which queue-capacity partition does this run belong to?" They do not deduplicate triggers. Use them when every request should become a run but execution should be limited per tenant, account, or other partition.
Without a concurrencyKey, the whole bounded queue is one capacity partition. With a key, each distinct key gets its own concurrencyLimit. See Queues for the difference between durable queue capacity and local worker concurrency.
Run
A run records:
- the task id and validated payload
- the environment namespace
- the queue
- current status
- attempt, failure, retry, and release counters
- append-only event history
- optional lease, source, idempotency, singleton, trace, and metadata fields
- optional concurrency key, dispatch reservation, and current failure summary
Run statuses are either active or terminal. Active runs can continue moving through the lifecycle. Terminal runs do not reactivate. Rerun and manual retry create new linked runs instead of mutating a terminal source run.
The stable status vocabulary lives in Run Lifecycle. The important split is:
| Class | Statuses | Meaning |
|---|---|---|
| Active | queued, running, retrying, released, scheduled, cancellation_requested | The run can still be claimed, resumed, retried, cancelled, or completed. |
| Terminal | succeeded, failed, cancelled | The run is historical fact. Operator rerun or manual retry creates a linked child run. |
Failed and retry-scheduled events include a RunFailure summary. Its code is always an ErrorCode: unstructured handler failures become ErrorCode.TaskFailed, while structured runtime failures preserve their RunlaneError.code.
run.failure exposes the current visible failure on failed and retrying runs. Full failure history remains in raw events and runlane.runs.attempts(runId).
Business or provider-specific values belong in meta, not in code, because Runlane owns the failure classification vocabulary used by retries, operators, and adapters.
Operator reads, cancellation, rerun, manual retry, idempotency-key reset, and pruning are covered in Operator APIs. Rerun and manual retry are intentionally linked-run creation operations; see Rerun And Manual Retry.
Events
Run events are the durable history of a run. Core owns the reducer semantics that turn events into materialized run state. Storage persists both the events and the core-projected state atomically.
Existing event shapes are replay contracts. New required event data should be introduced with a new event type instead of changing old history in place.
Unbounded-queue trigger() runs normally start with run.created and run.delivery_requested. Bounded-queue trigger creation starts with run.created; tick() later reserves queue capacity and appends run.delivery_requested when the run is due and capacity is available. That split is what keeps bounded queue capacity durable instead of being decided by transport delivery.
After delivery is requested, a worker or delivered wakeup records run.lease_claimed, run.started, optional run.lease_heartbeat events during long attempts, and finally run.succeeded, run.failed, run.retry_scheduled, run.released, or run.cancelled depending on the attempt outcome.
Current-process runs created by runNow() skip the initial delivery request and instead start with run.created, run.lease_claimed, and run.started. If the first inline attempt releases, retries, or loses process ownership before an outcome, later tick() recovery uses the normal delivery request path.