Runlane
Concepts

Durable Steps

Checkpoint one-time external operations inside a run.

Durable steps let a task checkpoint the output of a named operation before the whole attempt finishes. Use them when retry, release, timeout, or process loss could otherwise repeat an external side effect such as submitting an AWS Batch job, creating a provider export, starting a media transcode, or opening a payment-session-like operation.

They are per run, not per task. The key submit-render-job names one checkpoint inside one run. A different run with the same task and payload gets its own step row.

import { task } from '@runlane/core'
import * as z from 'zod'

const batchSubmissionSchema = z.object({
  jobId: z.string().min(1),
})

const renderPdf = task({
  id: 'pdf.render',
  schema: renderPdfPayloadSchema,
  async run(payload, context) {
    const submission = await context.step.run(
      'submit-render-job',
      { output: batchSubmissionSchema },
      async ({ token }) => {
        const job = await batch.submitRenderJob({
          clientToken: token,
          documentId: payload.documentId,
          signal: context.signal,
        })

        return { jobId: job.id }
      },
    )

    const job = await batch.describeJob(submission.jobId, { signal: context.signal })

    if (job.status !== 'SUCCEEDED') {
      return context.release('30s', {
        meta: { jobId: submission.jobId },
        reason: 'batch_not_finished',
      })
    }

    await saveRenderedPdf(job)
  },
})

On the first attempt, context.step.run() executes the callback, validates its output with the supplied Standard Schema-compatible output schema, verifies the parsed value is JSON-compatible, and asks storage to complete the step. On later attempts, the same call reads the stored step output and skips the callback.

If the callback throws or returns invalid output, the step is not checkpointed. The attempt follows normal task failure and retry rules, and a later attempt can call the callback again.

The same pattern applies outside external compute. This task starts a provider export once, then polls that export on later attempts:

const exportSubmissionSchema = z.object({
  exportId: z.string().min(1),
})

const syncInvoices = task({
  id: 'invoices.sync',
  schema: syncInvoicesPayloadSchema,
  async run(payload, context) {
    const exportSubmission = await context.step.run(
      'create-invoice-export',
      { output: exportSubmissionSchema },
      ({ token }) =>
        billingProvider.createInvoiceExport({
          accountId: payload.accountId,
          idempotencyKey: token,
          signal: context.signal,
        }),
    )
    const providerExport = await billingProvider.getInvoiceExport(exportSubmission.exportId, {
      signal: context.signal,
    })

    if (providerExport.status !== 'complete') {
      return context.release('2m', { reason: 'invoice_export_pending' })
    }

    await importInvoiceExport(providerExport.downloadUrl)
  },
})

What To Store

Store the smallest stable value needed to continue without repeating the one-time operation:

Good step outputWhy
{ jobId }Lets later attempts poll the existing provider job instead of submitting another one.
{ exportId }Lets later attempts check or download the existing export.
{ providerOperationId }Lets later attempts reconcile the existing operation with application state.

Do not store domain state, provider response bodies, large payloads, secrets, or application records in step output. Domain state belongs in your application database. Release meta is operator context, not state. Successful task output is the final run result, not a scratchpad for intermediate provider identifiers.

When the output schema transforms, the callback returns the schema input shape and context.step.run() returns the parsed output shape.

Crash Windows

Durable steps narrow the crash window, but they do not make a non-idempotent provider call safe by themselves:

WindowResultRequired design
Process crashes before the callback side effectNo step exists, so the callback can run later.Safe.
Provider accepts the side effect but storage completion crashes before commitNo step exists, so the callback can run again.Pass the step token as the provider idempotency token when possible, or keep an application ledger keyed by the token.
Storage completes the step but the attempt crashes before release or successLater attempts read the step and skip the callback.Safe; continue from stored output.
The attempt releases after the step completestick() later requests delivery and the next attempt reads the same step.Safe; release is still the control-flow primitive.

The callback receives a deterministic token for environment + runId + stepKey. Use it as the provider idempotency key or as the key for your own submission ledger. Do not derive behavior by parsing the token.

Step Keys

Step keys use the same public id rules as other Runlane keys: non-empty string, no :, opaque to storage, and stable across deployments. Name the operation, not the resource:

GoodAvoid
submit-render-jobsubmit-render-job-user_123
create-provider-exportexport:${payload.exportId}
open-transcode-jobRaw provider ids or unbounded user input

The run already scopes the step by payload, resource, queue, environment, and attempt history. Put resource ids in task payload, application state, singleton keys, or step output as appropriate; do not hide them in the step key.

Storage And Observability

Durable steps require storage that reports durableSteps: true. The local lane supports them in memory for development and tests. Postgres storage persists them in runlane_run_steps. A lane without support fails the attempt with ErrorCode.CapabilityUnsupported when task code calls context.step.run().

Storage completes a step only for the currently owned run lease and attempt. Duplicate completion with the same output is idempotent. Duplicate completion with different output is an invariant violation because the same named operation cannot have two durable results in one run.

Runtime observers and durable observation exporters receive RunlaneObservationType.RunStep after storage confirms a new step completion. The observation includes environment, run id, step key, step token, attempt, and completion time. It intentionally does not include step output, task payload, release metadata, or provider detail.

Relationship To Release, Retry, And Output

Durable steps are checkpoints, not lifecycle outcomes. A task still returns a value to succeed, throws or returns invalid output to fail, or returns context.release(...) / context.waitForSignal(...) / context.waitForRun(...) to wait.

Use release for business waiting after the checkpointed operation starts. Use retry for failure pressure. Use task output for the final public result of the run. Use step output only for the intermediate pointer needed to avoid repeating the external operation.

On this page