Current-Process Execution
Use runNow when the caller wants one durable attempt to start immediately.
Current-process execution creates a durable run, claims its lease, and executes one attempt in the caller's process. Use it when application code intentionally wants the result of the first attempt now, but still wants Runlane's payload validation, run history, leases, retry/release outcomes, singleton enforcement, and operator visibility.
This is not a sync lane. runNow() is a core runtime method over the same storage contract as trigger(), workers, and delivered wakeups. Task handlers can still await I/O, observe context.signal, return context.release(...), throw retryable errors, or be cancelled by operators.
const run = await runlane.runNow(syncQuickBooksInvoices, { userId: 'user_123' })With explicit creation and lease controls:
import { ActorType, asId } from '@runlane/core'
const run = await runlane.runNow(
syncQuickBooksInvoices,
{ userId: 'user_123' },
{
actor: { type: ActorType.Operator, id: 'user_123' },
idempotencyKey: asId<'idempotency_key'>('quickbooks_sync_button_user_123'),
singletonKey: asId<'singleton_key'>('quickbooks_invoices_user_123'),
traceCarrier,
},
)When To Use It
Use runNow() for request-path or operator-path work where the caller wants immediate durable execution and can afford to wait for one attempt. Common examples are admin buttons, explicit user sync buttons, local development flows, and small workflows where the first attempt should complete before the HTTP response is chosen.
Use trigger() when the caller only needs to persist work and return. Use workers, executeNext(), executeDelivery(), or SQS consumers when another process or provider message owns execution.
runNow() executes exactly one attempt. If that attempt returns context.release(...), the run becomes released. If it fails retryably with retry budget left, the run becomes retrying. Later tick() writes a fresh delivery request and outbox row through the normal maintenance path. runNow() does not loop through retries or releases inline.
Do not use runNow() as a way to bypass background execution policy. It still resolves the task through the runtime's authoritative task registry, validates payload before durable creation, resolves queues and creation keys like trigger(), requires the selected lane capabilities for idempotency, singleton, and concurrency keys, and validates the persisted payload again before user code runs.
API
runlane.runNow(task, payload?, options?) accepts the same payload rules as trigger(): the payload is validated before durable creation and again before user code runs from the persisted payload.
Creation options:
| Option | Default | What it controls |
|---|---|---|
actor | { type: ActorType.System } | Actor recorded on the initial run.created event. |
concurrencyKey | Task-level concurrencyKey | Per-run bounded-queue partition override. |
idempotencyKey | Task-level idempotencyKey | Per-run idempotency owner override. Duplicate active or retained owners return the existing run without executing another inline attempt. |
idempotencyKeyTTL | Task-level idempotencyKeyTTL, then 30d | Retention for the selected idempotency owner. Requires a task or explicit 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. Recovered, retried, or released inline runs keep this queue for future workers. |
runId | Generated run_${uuid} | Caller-supplied run id for external correlation. It should be rare. |
singletonKey | Task-level singletonKey | Per-run active-resource lock override. |
traceCarrier | None | Small string map for trace headers or request correlation. |
Execution options:
| Option | Default | What it controls |
|---|---|---|
leaseDuration | contractDefaults.lease.duration (5m) | Durable ownership window written with the initial inline lease and each heartbeat. |
heartbeatInterval | Half of leaseDuration | How often core writes run.lease_heartbeat while the inline attempt is still running. Must be shorter than leaseDuration. |
signal | None | Local abort signal linked into TaskContext.signal. It does not create a durable cancellation request by itself, and an already-aborted signal still allows creation before the handler observes the abort. |
workerId | Runtime worker id | Diagnostic owner recorded on the inline lease. It does not route work. |
runNow() returns a RunRecord. For a new inline run, the record is returned after the first attempt outcome is durably persisted. If storage idempotency returns an existing owner, no inline attempt runs and the existing active or retained terminal run is returned as-is.
For a new inline attempt, the returned status can be:
| Status | Meaning |
|---|---|
succeeded | The handler completed without returning a release. |
failed | The handler failed and no retry is scheduled. Unknown thrown errors are persisted as ErrorCode.TaskFailed with a stable public message. |
retrying | The handler failed retryably and retry budget remains. A later tick() requests delivery. |
released | The handler returned context.release(...). A later tick() requests delivery when the release is due. |
cancelled | A durable operator cancellation request was observed and the attempt completed cancellation. |
Validation and configuration errors reject before durable creation. Storage capability errors for selected keys reject before durable creation. If storage, projection, heartbeat, or outcome persistence fails after creation, runNow() rejects with a structured RunlaneError; it does not convert framework failures into task failures.
Unknown handler errors are caught inside the task-attempt boundary and persisted as ErrorCode.TaskFailed with the stable public message Task failed. Structured RunlaneError failures preserve their code, metadata, and retryability, but user-facing failure messages are still normalized.
If an inline heartbeat loses lease ownership with StorageConflict, core aborts TaskContext.signal, abandons the task result, and runNow() rejects with StorageConflict because it cannot prove the current process still owns the run. Other heartbeat or persistence failures reject for the same reason: ownership or durable outcome is not trustworthy.
Event Model
The initial inline append writes the created event and the lease claim atomically:
run.created
run.lease_claimed
run.started
run.lease_heartbeat...
run.succeeded | run.failed | run.retry_scheduled | run.released | run.cancelledThere is no initial run.delivery_requested event and no initial outbox row. runNow() does not call transport for the first attempt, even when the runtime's trigger dispatch policy is eager.
That atomic lease matters. A storage-polling worker scanning immediately after creation sees an already leased running run, not a queued run it can claim before the caller starts the inline attempt.
If the process dies after run.created + run.lease_claimed but before an outcome is persisted, the run remains running until the lease expires. A later tick() appends run.delivery_requested, creates the outbox row, and normal workers or transport consumers recover the run.
Local caller abort and durable operator cancellation are different. Passing signal only affects the local task context. If the handler reacts by throwing, that is persisted as a task failure according to the task's retry policy. Durable cancellation comes from the operator API; when the inline attempt observes cancellation_requested, core aborts the task context and persists run.cancelled if the run is still owned.
Choosing An Execution Path
| API | Creates a run | Executes user code | How it chooses work | Use when |
|---|---|---|---|---|
runNow(task, payload, options) | Yes | Yes, one current-process attempt | The task and payload supplied by the caller | The caller wants one durable attempt to start immediately. |
trigger(task, payload, options) | Yes | No | The task and payload supplied by the caller | The caller wants durable background work and can return after persistence. |
executeNext(options) | No | Yes, at most one attempt | Scans storage for one due run in the allowed queues | A process intentionally owns storage polling for one unit of work. |
worker(options) | No | Yes, repeated attempts | Loops over executeNext() in poll or drain mode | A process should continuously or boundedly drain storage-acquired work. |
executeDelivery(message, options) | No | Yes, at most one delivered run | Reads the specific run named by a transport message | A transport already acquired a provider message, such as SQS Lambda. |
| SQS consumers | No | Through executeDelivery() | Receives provider messages, parses Runlane delivery envelopes, and acknowledges handled messages | AWS SQS owns wakeup delivery and redrive behavior. |
Do not implement current-process execution by calling trigger() followed by executeNext(). That path may create a delivery request and outbox row, and under concurrency executeNext() can execute a different due run. runNow() creates the run and its lease in one storage append, then executes that exact run.
Singleton Example
Use the same singleton key for the protected resource everywhere the work can be created. A UI button and a cron path that both sync QuickBooks invoices for one user should resolve the same singleton key.
import { asId, queue, task } from '@runlane/core'
const quickBooksQueue = queue({
name: asId<'queue'>('quickbooks'),
concurrencyLimit: 2,
})
const syncQuickBooksInvoices = task({
id: asId<'task'>('quickbooks.invoices.sync'),
queue: quickBooksQueue,
schema: quickBooksSyncSchema,
schedule: [
{
id: asId<'schedule'>('quickbooks.invoices.hourly.user_123'),
cron: '0 * * * *',
timeZone: 'UTC',
payload: { userId: 'user_123' },
},
],
singletonKey: (payload) => asId<'singleton_key'>(`quickbooks_invoices_${payload.userId}`),
async run(payload, context) {
await syncInvoicesForUser(payload.userId, { signal: context.signal })
},
})
await runlane.runNow(syncQuickBooksInvoices, { userId: 'user_123' })If a schedule has already created an active invoice sync for user_123, the inline UI path fails with the same storage-enforced singleton conflict instead of overlapping the sync. The local lane enforces singleton keys in memory so app tests can exercise this definition, but that does not prove production locking. Production singleton correctness requires a lane whose storage reports and implements enforcesSingleton.