# Durable functions

Write functions that checkpoint their progress and resume where they left off for up to 366 days.

A durable function records its progress as it runs. When it suspends on a wait, or an attempt crashes, it resumes from the last completed operation instead of starting over — so one execution can run for up to a year, far longer than a single invocation is allowed.

`@volcano.dev/sdk/durable` is what you write that function against, and `volcano.durable.start` is how an app starts one. The app then [reads and stops](#reading-and-stopping-from-the-application) the executions it started, and [messages](#sending-and-reading-messages) them, with the credential it already holds. Listing executions, following any of them by project, and deciding the [approvals](#ctxwaitforapprovalname-options) they ask for are owner-scoped, so they belong on your backend, the [CLI](https://docs.volcano.dev/cli/durable-functions.md), or the dashboard.

```javascript
const { durable } = require('@volcano.dev/sdk/durable');

exports.handler = durable(async (input, ctx) => {
  const charge = await ctx.step('charge', () => chargeCard(input.order_id));

  await ctx.wait('settle', '30s');

  const shipment = await ctx.step('ship', () => ship(charge.id));

  return { charged: charge.id, shipment: shipment.tracking };
});
```

## Install

```bash
npm install @volcano.dev/sdk
```

That is the whole list. Checkpointing is done by a runtime the handler never
imports, and Volcano installs and versions it when it builds a function deployed
as durable, so it does not belong in your `package.json`.

Deploy the result as a durable function — `volcano cloud durable deploy`, or
`kind: durable` in `volcano-config.yaml`. A durable handler deployed as a
standard function fails on its first invocation with `DurableRuntimeMissingError`,
which is also what a handler throws in a browser or a local script: nothing is
there to checkpoint it. The error is exported from `@volcano.dev/sdk/durable`, so
a caller can tell it apart from a failure inside the handler.

## How a durable function runs

Your handler runs more than once. Each time it is invoked it starts from the
top, and every operation it already completed returns its recorded result
immediately instead of running again. When it reaches an operation that has not
run, that one executes for real.

That gives one rule to write by: **reach the same operations in the same order
every time.** The code between operations re-runs, so it has to be reproducible.

```javascript
// ❌ A random branch, so a resumed run can take the other path and the
//    recorded operations no longer line up.
if (Math.random() > 0.5) {
  await ctx.step('a', chargeCard);
}

// ✅ Decide inside a step, so the decision is recorded with everything else.
const branch = await ctx.step('pick', async () => (Math.random() > 0.5 ? 'a' : 'b'));
if (branch === 'a') {
  await ctx.step('a', chargeCard);
}
```

Anything non-deterministic belongs inside a step: `Date.now()`, `Math.random()`,
a UUID, a database read whose answer you branch on. Everything a step returns
must survive `JSON.stringify` — it is stored and handed back on replay.

A step is not retried once it has succeeded, but it is retried when it fails,
and an interrupted attempt can run it twice. Make the work idempotent, or read
[`atMostOnce`](#stepoptions).

## `durable(handler, options?)`

Wraps a handler so Volcano runs it as a durable execution. The handler is
called with the execution's input and a durable context, matching a standard
function's `(event, context)`.

```javascript
exports.handler = durable(async (input, ctx) => {
  /* ... */
});
```

| Option   | Type     | Description                                   |
| -------- | -------- | --------------------------------------------- |
| `logger` | `object` | Replaces the default logger behind `ctx.log`. |

Whatever the handler returns becomes the execution's `result`, so it has to be
JSON-serializable. Throwing fails the execution and records the error.

## Durations

Anywhere a duration is taken, these forms all work:

```javascript
await ctx.wait('30s'); // string, with s/m/h/d units
await ctx.wait('1m30s'); // compound
await ctx.wait(90); // a number is seconds
await ctx.wait({ minutes: 1, seconds: 30 }); // explicit
```

A bare number is **seconds**, not milliseconds: a durable wait is time the
platform holds, not a timer your process keeps.

Durations are whole seconds. There is no millisecond unit, and a fraction is
refused rather than rounded — the platform holds a wait between invocations, so
sub-second precision is not something it can honor.

Durations must be finite. The SDK rejects `NaN`, infinite numeric values, and
strings that overflow JavaScript's numeric range before passing them to the
durable runtime.

## `ctx.step(name?, fn, options?)`

Runs one atomic operation and records its result.

```javascript
const user = await ctx.step('load-user', async () => db.users.find(input.user_id));

const charge = await ctx.step(
  'charge',
  async (scope) => {
    scope.log.info('charging', { attempt: scope.attempt });
    return stripe.charges.create({ amount: user.total });
  },
  { retry: { attempts: 5, initialDelay: '2s' }, atMostOnce: true },
);
```

The function receives a scope, not a context: a step is a single operation and
cannot contain durable operations. Use [`ctx.child`](#ctxchildname-fn) to group
those.

| Scope     | Type     | Description                       |
| --------- | -------- | --------------------------------- |
| `log`     | logger   | Logs, suppressed while replaying. |
| `attempt` | `number` | 1 on the first attempt.           |

The name is what the operation is recorded under, and is worth giving; omit it
for a single obvious step. Names are per context and do not have to be unique
across replays of a loop — `ctx.map` names its items for you.

### StepOptions

| Option       | Type                          | Description                                                                                                                                                                    |
| ------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `retry`      | `false` \| object \| function | `false` fails on the first error. An object configures backoff. A function decides per attempt. Unset means the platform's default: 6 attempts, 5s apart doubling to a minute. |
| `atMostOnce` | `boolean`                     | Checkpoint before running instead of after, so an attempt interrupted mid-flight is not repeated on replay.                                                                    |

`atMostOnce` is per attempt, so pair it with `retry: false` for work that must
never run twice — a charge, an outbound payment, a one-shot email.

```javascript
await ctx.step('pay-out', () => bank.transfer(input), { atMostOnce: true, retry: false });
```

### Retry options

| Option         | Type                      | Default | Description                              |
| -------------- | ------------------------- | ------- | ---------------------------------------- |
| `attempts`     | `number`                  | `3`     | Total attempts, including the first.     |
| `initialDelay` | duration                  | `5s`    | Delay before the first retry.            |
| `maxDelay`     | duration                  | `5m`    | Ceiling for the backoff delay.           |
| `backoffRate`  | `number`                  | `2`     | Multiplier applied after each attempt.   |
| `retryOn`      | `Array<string \| RegExp>` | all     | Retry only errors whose message matches. |
| `retryOnTypes` | `Array<ErrorClass>`       | all     | Retry only errors of these types.        |

```javascript
await ctx.step('call-flaky-api', () => fetch(url), {
  retry: { attempts: 8, initialDelay: '5s', maxDelay: '2m', retryOn: [/timeout/i, '502'] },
});

// Or decide yourself.
await ctx.step('call-flaky-api', () => fetch(url), {
  retry: (error, attempt) =>
    attempt < 4 && error.name === 'TimeoutError'
      ? { shouldRetry: true, delay: { seconds: attempt * 10 } }
      : { shouldRetry: false },
});
```

A step that exhausts its retries fails the execution, unless you catch it:

```javascript
let receipt = null;
try {
  receipt = await ctx.step('receipt', () => sendReceipt(charge), { retry: { attempts: 2 } });
} catch (error) {
  ctx.log.warn('receipt failed, continuing', { message: error.message });
}
```

Catching is itself part of the replayed path, so keep the `catch` as
deterministic as the rest of the handler.

## `ctx.wait(name?, duration)`

Suspends the execution for a duration. The execution is not running while it
waits, and a wait can outlast any single invocation.

```javascript
await ctx.wait('cool-off', '15m');
await ctx.wait('1d'); // one argument is always the duration
```

## `ctx.child(name?, fn)`

Groups operations under one recorded context. The function is given a durable
context of its own, so it can run steps, waits, and further children.

```javascript
const total = await ctx.child('fulfil', async (childCtx) => {
  const pack = await childCtx.step('pack', () => packOrder(input));
  await childCtx.wait('label', '5s');
  return childCtx.step('label', () => printLabel(pack));
});
```

Reach for it to keep a long handler readable, to reuse a sub-workflow as a
function of its own, or to give `map` and `parallel` branches somewhere to run.

## `ctx.waitUntil(name?, check, options)`

Polls until a condition holds, suspending between checks rather than sleeping.
The check returns the state the next check receives, and `until` decides when to
stop. It is handed the same scope a step's function gets as a second argument,
so `(state, { log, attempt })` works when a check wants to log which round it is
on.

```javascript
const report = await ctx.waitUntil(
  'await-report',
  async (state) => {
    const job = await reports.status(input.report_id);
    return { ...state, status: job.status, checks: state.checks + 1 };
  },
  {
    initialState: { status: 'running', checks: 0 },
    until: (state) => state.status !== 'running',
    interval: '30s',
    maxInterval: '10m',
    maxAttempts: 200,
  },
);
```

| Option         | Type     | Default | Description                                        |
| -------------- | -------- | ------- | -------------------------------------------------- |
| `until`        | function | —       | Required. Stop once it returns true for the state. |
| `initialState` | any      | —       | Required. The state the first check receives.      |
| `interval`     | duration | `5s`    | Delay before the second check.                     |
| `maxInterval`  | duration | `5m`    | Ceiling for the backoff delay between checks.      |
| `backoffRate`  | `number` | `1.5`   | Multiplier applied after each check.               |
| `maxAttempts`  | `number` | `60`    | How many checks before the wait gives up.          |

A condition is bounded by how many times it is checked rather than by a
deadline: the platform holds the wait between checks, so there is no clock left
running to compare against. Running out of checks fails the execution instead of
returning the last state, so size `maxAttempts` against `interval` and
`maxInterval` for the longest you are willing to wait.

Every check is a metered operation, and the 200 above is 200 of them, so buy the
wait with a longer `interval` rather than with more checks where you can. See
[Limits](#limits-and-what-an-operation-costs).

This is how a durable function waits on state it has to go and read: a
third-party job, a file that has to land, a row another system writes. When
whatever finishes the work can tell the execution — a webhook, an endpoint of
yours, another function — use
[`ctx.waitForMessage`](#ctxwaitformessagename-options) instead; when it can
only call back with a token you hand it,
[`ctx.waitForCallback`](#ctxwaitforcallbackname-submitter-options); and to wait
on a person's sign-off, [`ctx.waitForApproval`](#ctxwaitforapprovalname-options).
None of them costs checks while it waits.

## `ctx.waitForApproval(name, options)`

Asks the project's owners to approve or deny, and suspends until one of them
decides. The execution is not running while it waits, and resumes with the
decision.

```javascript
const decision = await ctx.waitForApproval('ship-order', {
  title: `Ship order ${input.order_id}?`,
  description: 'The total is over the auto-ship limit.',
  details: { order_id: input.order_id, total: input.total },
  timeout: '3d',
});

if (!decision.approved) {
  // 'denied' or 'expired'
  return { shipped: false, reason: decision.status, comment: decision.comment };
}

await ctx.step('ship', () => ship(input.order_id));
```

The request shows up under **Approvals** in the dashboard, with the function and
execution that asked. Decide it there, from a terminal, or with
[`durable.approvals`](#deciding-approvals):

```bash
volcano cloud durable approvals list
volcano cloud durable approvals approve <approval-id> --comment "Address checked"
volcano cloud durable approvals deny <approval-id> --comment "Customer cancelled"
```

| Option        | Type     | Description                                                                                                   |
| ------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| `title`       | `string` | Required. What the reviewer sees first. Up to 200 characters.                                                 |
| `description` | `string` | Longer context for the reviewer. Up to 4,000 characters.                                                      |
| `details`     | any      | A JSON value shown with the request, such as the record under review. The whole request is limited to 64 KiB. |
| `timeout`     | duration | How long reviewers have to decide. Unset, the approval stays open as long as the execution.                   |

The name is what the approval is recorded under in the execution history, like
a step's name. Keep it short: it is up to 237 printable ASCII characters. No
text, `details` included, may contain a NUL character.

A denial and an expiry are decisions like an approval, so the call resolves
rather than throws: branch on `approved`.

| Field       | Type                                  | Description                                                                                                                       |
| ----------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `approved`  | `boolean`                             | `true` only when the status is `approved`.                                                                                        |
| `status`    | `'approved' \| 'denied' \| 'expired'` | `expired` when `timeout` ran out before anyone decided.                                                                           |
| `comment`   | `string`                              | The reviewer's comment, or `''` when they left none.                                                                              |
| `decidedBy` | `{ id, email }` \| `null`             | Who decided. `null` when the approval expired, or when the deciding account was deleted before the decision reached the workflow. |
| `decidedAt` | `string` \| `null`                    | When they decided, as an ISO 8601 timestamp. `null` on expiry.                                                                    |

Like every operation, the decision is recorded: a resumed execution gets the same
one back, and the request is registered once however many times the handler
replays. Approvals inside `ctx.parallel`, `ctx.map`, or `ctx.child` work the same
way, so a handler can ask several people at once.

The call throws, failing the execution unless you catch it, when the options are
invalid or Volcano refuses the request — for example when the execution already
holds too many pending approvals. Invalid options include `details` that cannot
be serialized as JSON, such as a `BigInt` or a circular object, and a request
over 64 KiB; both are refused before the approval is opened. The request goes to
`VOLCANO_PLATFORM_API_URL`, which Volcano sets on every durable function, so a
handler deployed as a standard function cannot request an approval.

Volcano can take a moment to see a new execution or approval, and the SDK
retries the request for up to 30 seconds while Volcano does not know the
execution yet, is not ready for the approval, throttles it, or is unavailable.
It then throws the last failure. A request that arrives after the approval's
`timeout` has already run out is not an error: the call resolves `expired`.

Invalid options throw a `TypeError`. A refusal, or a failure that outlasts the
retries, reaches the handler as the durable runtime's `CallbackSubmitterError`,
which keeps only the message. The message carries the status and error code,
or the network failure:

```javascript
try {
  await ctx.waitForApproval('ship-order', { title: 'Ship order 42?' });
} catch (error) {
  // "Volcano refused the approval request (409 too_many_pending_approvals): ..."
  if (error.message.includes('too_many_pending_approvals')) {
    return { shipped: false, reason: 'too many approvals waiting' };
  }
  throw error;
}
```

## `ctx.waitForMessage(name, options?)`

Suspends until a message with that name is sent to the execution, and resumes
holding it. Anyone the function's
[visibility](https://docs.volcano.dev/platform/functions/durable-messages.md#who-can-send-and-read) admits
can send one — a webhook receiver, a backend, a browser, another function — so
use it for any answer that does not need a person's decision.

```javascript
const payment = await ctx.waitForMessage('payment-confirmed', { timeout: '30m' });

if (payment === null) {
  return { status: 'abandoned' }; // the timeout passed first
}

await ctx.step('fulfil', () => fulfil(input.order_id, payment.data.charge_id));
```

| Option    | Type     | Description                                                                                  |
| --------- | -------- | -------------------------------------------------------------------------------------------- |
| `timeout` | duration | How long to wait, from 1 second to 366 days. Unset, the wait lasts as long as the execution. |

The name is what a sender addresses the message by, and what the wait is
recorded under in the execution history. It is up to 237 printable ASCII
characters.

It resolves with the message, or with `null` once the timeout passes. A timeout
is an outcome, not an error.

| Field           | Type               | Description                                         |
| --------------- | ------------------ | --------------------------------------------------- |
| `id`            | `string`           | The message's id.                                   |
| `name`          | `string`           | The name it was sent with.                          |
| `data`          | any                | The JSON value sent, or `null`.                     |
| `sender.type`   | `string`           | `service_key`, `user`, `anon`, or `project`.        |
| `sender.userId` | `string` \| `null` | The user who sent it, when `sender.type` is `user`. |
| `sentAt`        | `string`           | When it was sent, as an ISO 8601 timestamp.         |

Each wait takes one message. A message sent before anything waits for its name
is kept, and each wait takes the oldest one with its name, so a loop consumes a
burst of sends first in, first out:

```javascript
const items = [];
for (;;) {
  const item = await ctx.waitForMessage('item', { timeout: '10m' });
  if (item === null || item.data.last) break;
  items.push(item.data);
}
```

The message is recorded like any operation's result: a resumed execution gets
the same one back. Waits inside `ctx.parallel`, `ctx.map`, or `ctx.child` work
the same way, so one execution can wait for several names at once.

A message never decides an approval, whatever it carries. Where an answer from
code would defeat the point, keep `ctx.waitForApproval`.

The call throws, failing the execution unless you catch it, on an invalid name
or timeout, or when Volcano refuses the wait — for example when the execution
already has 100 waits open. Like an approval, the request goes to
`VOLCANO_PLATFORM_API_URL`, is retried for up to 30 seconds while Volcano is not
ready for it, and reaches the handler as the runtime's `CallbackSubmitterError`
with the status and code in its message:

```javascript
// "Volcano refused the message wait (409 too_many_message_waits): ..."
```

## `ctx.waitForCallback(name, submitter, options?)`

Hands a token to another system and suspends until that system answers with
it. Use it when the work happens somewhere you do not run code — a payment
provider, a job queue, a partner's API — and that system can call back once it
is done.

```javascript
const { CallbackFailedError, durable } = require('@volcano.dev/sdk/durable');

exports.handler = durable(async (input, ctx) => {
  let payment;
  try {
    payment = await ctx.waitForCallback(
      'payment',
      (token) => payments.start({ orderId: input.order_id, callbackToken: token }),
      { timeout: '1h', heartbeatTimeout: '10m' },
    );
  } catch (error) {
    if (error instanceof CallbackFailedError) {
      return { status: 'declined', reason: error.type, message: error.message };
    }
    throw error;
  }

  if (payment === null) {
    return { status: 'abandoned' }; // the timeout passed first
  }
  return ctx.step('fulfil', () => fulfil(input.order_id, payment.charge_id));
});
```

The submitter receives a `vcb_` token and passes it to whatever does the work.
That system answers with the token alone; it needs no Volcano credential:

```bash
curl -X POST "https://api.volcano.dev/durable-callbacks/succeed" \
  -H 'Content-Type: application/json' \
  -d '{"token":"vcb_...","result":{"charge_id":"ch_1"}}'

curl -X POST "https://api.volcano.dev/durable-callbacks/fail" \
  -H 'Content-Type: application/json' \
  -d '{"token":"vcb_...","error":{"type":"CardDeclined","message":"Insufficient funds"}}'

curl -X POST "https://api.volcano.dev/durable-callbacks/heartbeat" \
  -H 'Content-Type: application/json' \
  -d '{"token":"vcb_..."}'
```

| Option             | Type     | Description                                                                                         |
| ------------------ | -------- | --------------------------------------------------------------------------------------------------- |
| `timeout`          | duration | How long to wait for an answer. Unset, the wait lasts as long as the execution.                     |
| `heartbeatTimeout` | duration | How long the wait may go without a heartbeat. Each heartbeat restarts it. Unset, none are required. |

The name is what the wait is recorded under in the execution history. It is up
to 237 printable ASCII characters.

It resolves with the `result` the token was succeeded with, or with `null` once
`timeout` or `heartbeatTimeout` passes, as `waitForMessage` does. A result is
never `null`, so the two cannot be confused. A failure throws
`CallbackFailedError`, exported from `@volcano.dev/sdk/durable`, with the `type`
and `message` it was failed with; catch it to branch on `type`.

A token takes one answer. Once it has succeeded or failed, a second request
gets `409 callback_completed`, and after the wait times out or the execution
ends, `410`. It stops working when the wait does, and works only for this wait.
Treat it as a secret: anyone holding it can answer the wait.

The submitter runs inside a checkpointed step, so a replay does not run it
again. An attempt interrupted before the step is recorded can run it twice, and
it receives the same token both times, so make the hand-off idempotent. If the
token was answered, or the wait timed out, in between, it is not run again. A
promise it returns is awaited, and its value is ignored. It is not retried when
it throws: the call throws the runtime's
`CallbackSubmitterError`, failing the execution unless you catch it.

The token comes from `VOLCANO_PLATFORM_API_URL`, like an approval, and the
request is retried for up to 30 seconds while Volcano is not ready for it. An
invalid name, submitter, or option throws a `TypeError` before anything is
registered.

## `ctx.publish(name, data?)`

Records a message from the execution. Apps [list](#sending-and-reading-messages)
what it published or subscribe to receive it live, which is how a long-running
execution reports progress to the browser that started it.

```javascript
const published = await ctx.publish('progress', { stage: 'rendering', pct: 40 });
console.log(published.id, published.seq);
```

`data` is any JSON value up to 64 KiB; leave it out to publish `null`. The name
follows the same rules as a wait's. It resolves with the message's `id` and
`seq`, the position it is listed at.

Each `publish` is checkpointed as a `child` named after the message, so a replay
never publishes twice, and a `publish` that runs again after a crash returns the
message it already published. The first
`publish` in an execution — and in each `ctx.child`, `ctx.parallel` branch, or
`ctx.map` item that publishes — first opens a messages session, a credential
only this execution can publish with. It shows up in the history as a `child`
named `volcano:messages-session`. If the session cannot be opened, that
`publish` throws, and so does every later `publish` in the same context.

`publish` throws on an invalid name, data that cannot be serialized as JSON, is
over 64 KiB, or holds a NUL character in a string or key, and refusals such as an execution that has already published
1,000 messages: `Volcano refused the message (409 too_many_published_messages): ...`.

## `ctx.map(name?, items, fn, options?)`

Runs the same work over every item, each item in its own child context.

```javascript
const shipped = await ctx.map(
  'ship-items',
  input.items,
  async (item, itemCtx, index) => {
    const label = await itemCtx.step('label', () => printLabel(item));
    return { sku: item.sku, label, index };
  },
  { concurrency: 5 },
);

ctx.log.info('shipping done', { ok: shipped.succeeded, failed: shipped.failed });
shipped.throwIfFailed();
```

The item comes first and its context second, so the common case reads well and
the context is there when an item needs operations of its own.

## `ctx.parallel(name?, branches, options?)`

Runs different branches at the same time, each in its own child context. A
branch is a function, or `{ name, run }` to name it in the execution history.

```javascript
const checks = await ctx.parallel(
  'pre-flight',
  [
    (branch) => branch.step('fraud', () => scoreFraud(input)),
    { name: 'stock', run: (branch) => branch.step('stock', () => reserveStock(input)) },
  ],
  { minSucceeded: 2 },
);
```

### BatchOptions

| Option         | Type     | Description                                                   |
| -------------- | -------- | ------------------------------------------------------------- |
| `concurrency`  | `number` | How many items or branches run at once. Unlimited by default. |
| `minSucceeded` | `number` | Finish as soon as this many have succeeded.                   |

### BatchResult

`map` and `parallel` both resolve to the same plain object, so it logs and
returns cleanly:

| Field              | Type     | Description                                                                  |
| ------------------ | -------- | ---------------------------------------------------------------------------- |
| `items`            | array    | The items that finished, in input order: `{ index, status, result, error }`. |
| `results`          | array    | The results that succeeded, so not aligned with the input if any failed.     |
| `errors`           | array    | The failures, as `{ name, message, type, data }`.                            |
| `succeeded`        | `number` | How many succeeded.                                                          |
| `failed`           | `number` | How many failed.                                                             |
| `completed`        | `number` | How many finished, which is `succeeded + failed`.                            |
| `completionReason` | `string` | Why the batch ended.                                                         |
| `throwIfFailed()`  | function | Throws the first failure, if there was one.                                  |

An item's `status` is `succeeded` or `failed`.

`minSucceeded` ends the batch while other items are still running, and those
items are not in the result. Whether the platform can reproduce an in-flight
item when the execution resumes is not guaranteed, so a handler that branched on
one would take a different path on the replay and undo the point of durable
execution. `completionReason` is how to tell why the batch ended:

```javascript
const shipped = await ctx.map('ship', input.items, shipItem, { minSucceeded: 2 });

if (shipped.completionReason === 'min_successful_reached') {
  ctx.log.info('shipped enough to proceed', { completed: shipped.completed });
}
```

| `completionReason`           | Meaning                                               |
| ---------------------------- | ----------------------------------------------------- |
| `all_completed`              | Every item finished.                                  |
| `min_successful_reached`     | `minSucceeded` was reached and the rest were dropped. |
| `failure_tolerance_exceeded` | Too many items failed.                                |

Failures are plain objects rather than `Error` instances, so the result can be
logged or returned from the handler as it is — an `Error` loses its `message` to
`JSON.stringify`. To propagate the failure instead of reporting it, call
`throwIfFailed()`, which throws the real error.

## `ctx.log`

Logs through the durable logger, which suppresses output while an operation is
being replayed, so a resumed execution does not re-log what it already did.

```javascript
ctx.log.info('order received', { order_id: input.order_id });
ctx.log.warn('carrier slow', { order_id: input.order_id });
ctx.log.debug('quote', { total: order.total });
ctx.log.error('shipping failed', error, { order_id: input.order_id });
```

`error` takes the error as its second argument and the data as its third; the
other three take a message and data.

`console.log` still works, and still reaches [function logs](https://docs.volcano.dev/platform/functions/logs.md) — it just repeats on every replay.

## A complete function

An order pipeline: charge, fan out over the items, wait for a human, then
finish. Every operation is recorded, so an interrupted execution picks up where
it stopped rather than charging the card twice.

```javascript
const { durable } = require('@volcano.dev/sdk/durable');
const { Client } = require('pg');
const { databaseConnectionString } = require('@volcano.dev/sdk');

exports.handler = durable(async (input, ctx) => {
  const order = await ctx.step('load-order', () => withDb((db) => loadOrder(db, input.order_id)));

  const charge = await ctx.step('charge', () => chargeCard(order), {
    atMostOnce: true,
    retry: false,
  });

  const packed = await ctx.map(
    'pack',
    order.items,
    (item, itemCtx) => itemCtx.step('pack-item', () => packItem(item)),
    { concurrency: 5 },
  );
  packed.throwIfFailed();

  const review = await ctx.waitUntil('await-review', async () => withDb((db) => reviewStatus(db, order.id)), {
    initialState: 'pending',
    until: (status) => status !== 'pending',
    interval: '1m',
    maxAttempts: 500,
  });

  if (review === 'rejected') {
    await ctx.step('refund', () => refundCharge(charge.id), { atMostOnce: true, retry: false });
    return { order_id: order.id, outcome: 'refunded' };
  }

  await ctx.step('dispatch', () => dispatch(order, packed.results));
  return { order_id: order.id, outcome: 'shipped', charge_id: charge.id };
});

async function withDb(fn) {
  const client = new Client({
    connectionString: databaseConnectionString(process.env.DATABASE_URL),
  });
  await client.connect();
  try {
    return await fn(client);
  } finally {
    await client.end();
  }
}
```

A worked, deployable version of this — manifest, migration, and the endpoints
that start and read executions — is in
[`examples/durable-order-pipeline`](https://github.com/Kong/volcano-sdk-js/tree/main/examples/durable-order-pipeline).

## Starting and reading executions

Nothing in this module starts an execution. `volcano.durable.start` does, with
the same credential an invoke would send: a signed-in user's session when there
is one, otherwise the client's anon or service key. The durable function's
[visibility](https://docs.volcano.dev/platform/functions/durable-functions.md#who-can-start-executions)
decides who may start it, the same as for an invoke:

| Visibility          | Service keys and schedulers | Your project's signed-in users | Anon keys with `functions.invoke` |
| ------------------- | --------------------------- | ------------------------------ | --------------------------------- |
| `private` (default) | Yes                         | No                             | No                                |
| `authenticated`     | Yes                         | Yes                            | No                                |
| `public`            | Yes                         | Yes                            | Yes                               |

Signed-in users include anonymous sign-ins. Keep a durable function `private`
when only your own functions and backends start it. The call is the same
whatever the credential:

```javascript
const { data, error } = await volcano.durable.start(
  'order-pipeline',
  { order_id: orderId },
  { executionName: `order-${orderId}` },
);

if (error || !data) {
  throw error ?? new Error('Durable start returned no execution');
}
console.log(data.id, data.status); // 'running'
console.log(data.client_token); // lets a browser read, stop, and message this execution
```

The execution name is the idempotency key: starting again under the same name
returns the execution that already exists rather than beginning a second one,
and is charged once. It is trimmed, holds letters, digits, `-`, `_` and `.`, and
is at most 255 characters; a longer one is refused by `start` itself, with
`status` null because nothing was sent.

`start` resolves rather than throws when the platform refuses. `status` carries
why — `400` for input that is not JSON, or an execution name holding anything
but letters, digits, `-`, `_` and `.`,
`401` for a credential the endpoint does not accept, `403` for an anon key on an
`authenticated` function or without `functions.invoke`, `404` for a name that
is not a durable function in this project or is a `private` one and the caller
is not a service key, `409` while the function is still
provisioning or has no deployed region, `413` for an input over 256 KiB, `429`
for a project with too many executions in flight for its plan or out of either
durable allowance, and `503` where durable execution is unavailable. `409` is
the one to expect right after a deploy: retry once the function is `active`.

### Reading and stopping from the application

`getFromApplication` and `stopFromApplication` follow an execution with the
credential that started it. An anon key, a signed-in user who did not start the
execution, or a service key scoped without `*` also passes the `clientToken` its
start returned:

```javascript
const { data, error } = await volcano.durable.getFromApplication('order-pipeline', execution.id, {
  clientToken: execution.client_token,
});

if (!error && data?.status === 'succeeded') {
  console.log(data.result); // what the function returned
}

// Asks for the execution to stop; completed steps are not undone.
await volcano.durable.stopFromApplication('order-pipeline', execution.id, {
  clientToken: execution.client_token,
});
```

| Credential                               | Reaches                                                                     |
| ---------------------------------------- | --------------------------------------------------------------------------- |
| Service key holding `*` (the default)    | Any execution of the project                                                |
| Service key scoped to a list without `*` | Only executions whose client token it sends                                 |
| Signed-in user's session                 | Executions that user started, and any execution whose client token it sends |
| Anon key                                 | Only executions whose client token it sends                                 |

The function's
[visibility](https://docs.volcano.dev/platform/functions/durable-functions.md#who-can-start-executions)
has to admit the caller too, as it does for a start, and a scoped service key
needs `functions.invoke`. While an execution is still `pending`, only a service
key holding `*` reaches it, and stopping it answers `409`. An execution the
caller cannot reach answers `404`, like one that does not exist, and a read
never returns `client_token`. Reads and stops count against the function's rate
limit with starts, so poll every few seconds rather than in a tight loop.

Both answer `503` where durable execution is unavailable, and also when reads of
the execution's state are throttled. A throttled answer sets `error.retryAfter`,
the seconds to wait before reading again. A stop answered that way may already
have been sent, so read the execution before stopping it again.

### Following executions by project

`get`, `list`, and `stop` take the project id and are owner-scoped. Use a
separate backend client with the project owner's platform user token or a
project access token; a `read_only` token can read and list but not stop.
Auth-user sessions, anon keys, and service keys are not accepted.

```javascript
import { VolcanoClient } from '@volcano.dev/sdk';

const ownerClient = new VolcanoClient({
  apiUrl: process.env.VOLCANO_API_URL,
  anonKey: process.env.VOLCANO_ANON_KEY,
  accessToken: process.env.VOLCANO_PLATFORM_TOKEN,
});

const { data, error } = await ownerClient.durable.get(projectId, 'order-pipeline', executionId);

if (!error && data?.status === 'succeeded') {
  console.log(data.result); // what the function returned
}
```

An execution is `running` until it finishes, including while it is suspended in
a wait with nothing invoked, so polling it is how you follow one. It reads
`pending` only in the moment between being accepted and being started, and
finishes as one of `succeeded`, `failed`, `timed_out`, `stopped` or `unknown`
(the outcome the platform could not establish). Retry an `unknown` start under
the same name: the platform resumes that execution rather than starting a
second one.

`result` is what the handler returned. It is absent while the execution runs,
and absent on one that failed, timed out or was stopped — those carry
`data.error` with a `type` and a `message` instead. It is also absent once the
platform no longer holds the output, which is where a retained execution ends
up: `data.result_expired` is what tells that apart from a function that
returned nothing. A result too large to return is checkpointed instead and
reads the same as nothing returned, so keep what you read back small and write
anything bigger to a table or a bucket.

```javascript
// Most recent first, optionally filtered by status.
const { data } = await ownerClient.durable.list(projectId, 'order-pipeline', {
  status: 'running',
  limit: 20,
  page: 1,
});
console.log(data.data.length, data.total, data.has_more);

// Asks for the execution to stop; completed steps are not undone.
await ownerClient.durable.stop(projectId, 'order-pipeline', executionId);
```

Listing reports the status the platform last observed rather than polling each
execution, so read a single one for its live state. It pages one page at a
time: raise `page` while `has_more` is true.

`stop` and `stopFromApplication` are accepted rather than awaited.
Cancellation happens behind them, so the execution they resolve with is the one
read back after asking and often still says `running`; `get` or
`getFromApplication` is how you watch it reach `stopped`. Stopping is safe to
repeat, and one that has already finished reports the state it is in.

Each of these answers the same `{ data, status, error }` envelope as `start`,
with `404` for an execution or durable function this project does not have.

Project IDs, function names, execution IDs and approval IDs are URL path
segments. The SDK trims and percent-encodes each one. It refuses one that is
empty, is `.` or `..`, contains `/`, or has a lone UTF-16 surrogate, with
`status` null, because nothing was sent.

### Deciding approvals

`ownerClient.durable.approvals` reads and decides the approvals executions ask
for with [`ctx.waitForApproval`](#ctxwaitforapprovalname-options). Reading
accepts the owner's platform user token or a project access token. Deciding
takes a person, so approve and deny need the platform user token, and a project
access token gets `403`.

```javascript
const { data: pending } = await ownerClient.durable.approvals.list(projectId, {
  status: 'pending',
  function: 'order-pipeline',
});

for (const approval of pending?.data ?? []) {
  console.log(approval.id, approval.title, approval.details);
}

const { data, error } = await ownerClient.durable.approvals.approve(projectId, approvalId, {
  comment: 'Address checked',
});
if (error) {
  throw error;
}
console.log(data.status); // 'approved'; the execution resumes with the decision
```

| Method                                         | Description                                                                         |
| ---------------------------------------------- | ----------------------------------------------------------------------------------- |
| `list(projectId, options?)`                    | Approvals, most recent first. Pages like `durable.list`.                            |
| `get(projectId, approvalId)`                   | One approval, with its `decision` once it has one.                                  |
| `stats(projectId, options?)`                   | Counts by status, approval rate, and time to decision. The last 30 days by default. |
| `approve(projectId, approvalId, { comment? })` | Approve. The comment is up to 2,000 characters.                                     |
| `deny(projectId, approvalId, { comment? })`    | Deny, with the same comment.                                                        |

`list` takes `status` (`pending`, `approved`, `denied`, `expired`, or
`cancelled`), `function` (a durable function's name or id), `executionId`,
`from` and `to` (RFC 3339 timestamps), `page`, and `limit`. `stats` takes
`function`, `from`, and `to`, over a window of at most 366 days.

An approval is `cancelled` when its execution ends before anyone decides, so the
workflow never sees that status. Approvals and their decisions are kept for a
year, independently of the execution's own retention.

Each method answers the same `{ data, status, error }` envelope. Repeating a
decision returns the approval unchanged. A conflicting decision, or deciding an
approval that expired or was cancelled, is `409`, and `error.code` says which:
`approval_decided`, `approval_expired`, or `approval_cancelled`. An approval this
project does not have is `404`.

See [Durable functions](https://docs.volcano.dev/platform/functions/durable-functions.md) for the full API
and [the CLI reference](https://docs.volcano.dev/cli/durable-functions.md) for `volcano cloud durable`.

### Sending and reading messages

`volcano.durable.messages` sends the messages
[`ctx.waitForMessage`](#ctxwaitformessagename-options) waits for, and lists or
subscribes to what [`ctx.publish`](#ctxpublishname-data) records. It uses the
client's credential, so it works from a browser as well as a backend. Name the
execution by its durable function and id:

```javascript
const target = {
  functionName: 'checkout',
  executionId: execution.id,
  clientToken: execution.client_token,
};

const { data: message, error } = await volcano.durable.messages.send(
  target,
  'address-confirmed',
  { address_id: 'addr_19' },
  { idempotencyKey: 'confirm-addr_19' },
);
console.log(message?.status); // 'delivered', 'delivering' while the handover is confirmed, or 'queued'

const { data: page } = await volcano.durable.messages.list(target, { direction: 'out' });
for (const published of page?.data ?? []) {
  console.log(published.seq, published.name, published.data);
}
```

| Method                                | Description                                                                         |
| ------------------------------------- | ----------------------------------------------------------------------------------- |
| `send(target, name, data?, options?)` | Send a message. `options.idempotencyKey`, up to 255 characters, makes a retry safe. |
| `list(target, options?)`              | Messages in both directions, oldest first by `seq`.                                 |
| `subscribe(target, handler, options)` | Receive what the execution publishes, live. `options.realtime` is required.         |

`list` takes `direction` (`in` or `out`), `name`, `after` (only messages with a
greater `seq`), and `limit` (1 to 500). Pass the last `seq` you read as `after`
while `has_more` is true.

Who may send and read follows the function's visibility. A service key reaches
any execution of the project, and a signed-in user the executions they started.
Anyone else — an anon key, or a user who did not start it — needs the
execution's `clientToken`, which `start` returns once, when it creates the
execution. A backend that starts executions with the service key hands that
token to the browser that should talk to the execution. An execution the
caller may not reach answers `404`, like one that does not exist.

Add `projectId` to the target to use the owner-scoped route instead, with the
owner's platform user token or a project access token; a `read_only` access
token can list but not send.

`subscribe` delivers the execution's published messages on a connected
[`VolcanoRealtime`](https://docs.volcano.dev/sdk/js/realtime.md) client, in `seq` order and once each. It reads
the list before resolving, again after each reconnect, and whenever a live
message shows that an earlier one was lost. Until that read succeeds it holds
the live messages after the gap and retries, backing off to every 30 seconds and
logging each failed read with `console.error`, so no published message is
missed:

```javascript
import { VolcanoRealtime } from '@volcano.dev/sdk/realtime';

const realtime = new VolcanoRealtime({ apiUrl, anonKey, accessToken: volcano.accessToken });
await realtime.connect();

const { data: subscription, error } = await volcano.durable.messages.subscribe(
  target,
  (message) => {
    if (message.name === 'progress') showProgress(message.data);
  },
  { realtime },
);

// When the view is finished:
await subscription?.unsubscribe();
```

A realtime client holds one subscription per execution; subscribing again
before `unsubscribe` resolves with an error. `disconnect()`,
`removeAllChannels()`, and a change of signed-in user on the realtime client
end the subscription too, and a read still in flight delivers nothing; call
`unsubscribe` before subscribing to the execution again. Ended while it is
still catching up, `subscribe` resolves with an error. When the realtime server
refuses the subscription, `error.cause` holds its `code` and `message`. Once
`subscribe` has resolved, the realtime server can still end the subscription,
for example by refusing it on resubscribing after a reconnect. The SDK then
logs the end with `console.error` and delivers nothing more; subscribe to the
execution again to resume. A
handler that throws while `subscribe` is still catching up ends the
subscription, which delivers nothing more and resolves with that error; once it
has resolved, the subscription carries on past a handler that throws, including
during a later read-back, and logs the error with `console.error`. A handler
may return a promise, but delivery does not wait for it: the next message can
arrive before it settles, and a rejection is logged with `console.error`, even
while `subscribe` is catching up. Catch errors in the handler to handle them
yourself.

All three answer `{ data, status, error }`. `status` is `409` with
`error.code` `execution_not_running` for a send to an execution that has ended,
and `too_many_queued_messages` when it already holds 100 messages nothing has
taken. See [Durable messages](https://docs.volcano.dev/platform/functions/durable-messages.md) for the
limits and every error.

## Limits and what an operation costs

| Limit                             | HOBBY           | SUPERAGENT      |
| --------------------------------- | --------------- | --------------- |
| Execution allowance               | 5,000 / month   | 10,000 / month  |
| Operation allowance               | 100,000 / month | 200,000 / month |
| Compute allowance                 | 10,000 GB-s     | 100,000 GB-s    |
| Operations per execution          | 3,000           | 3,000           |
| Step timeout                      | 300 s           | 900 s           |
| Execution timeout                 | 366 days        | 366 days        |
| Concurrent executions per project | 10              | 100             |

Durable work is metered on those three allowances rather than on the request
allowance a standard invocation spends. Every context operation is one
operation: the execution itself, each `step` attempt — a retry is another
attempt — each `wait`, each `waitUntil` check, each `child` context, and each
`map` item or `parallel` branch. An approval is three: its own context, the
wait for its decision, and the step that registers it. A `waitForMessage` or a
`waitForCallback` is three the same way, a `publish` one, and the messages session each context
opens before its first `publish` three more. Time is not charged, so a
suspended execution costs nothing while it waits.

That makes the shape of a handler its cost. `ctx.map` over ten thousand items is
ten thousand operations and will fail on the per-execution ceiling; batch the
items, or start an execution per batch. A `waitUntil` polling every 5 seconds
for a day is not affordable either, and a longer `interval` with a higher
`maxInterval` costs less and is kinder to whatever it polls.

The step timeout bounds one attempt between checkpoints, not the execution. A
step that needs longer than that has to be split, or moved behind
`ctx.waitUntil` so the waiting happens between operations instead of inside one.

## Local development

Use the same handler and manifest with the local CLI:

```bash
volcano start
volcano durable deploy --all
volcano durable start order-pipeline --input '{"order_id":4417}' --name order-4417
volcano durable executions get order-pipeline <execution-id>
volcano durable approvals approve <approval-id>
volcano durable messages send order-pipeline <execution-id> payment-confirmed --data '{"charge_id":"ch_1"}'
```

Local waits resolve immediately by default while preserving checkpoint and replay
behavior. Set `LOCAL_DURABLE_REAL_TIME=true` before `volcano start` when wait
timing must match the deployed function. An approval's `timeout`, and a
callback's `timeout` and `heartbeatTimeout`, always run in real time. Answer a
callback with the same requests against the local API URL. Local executions
persist across `volcano stop` and `volcano start`.

## Not available yet

- **Durable invoke.** Call another function from inside a step —
  `ctx.step('sync', () => volcano.functions.invoke('sync', payload))` — rather
  than chaining durable executions.

## Next steps

- [Functions](https://docs.volcano.dev/sdk/js/functions.md) — standard functions, invocation, and user context
- [Durable functions on the platform](https://docs.volcano.dev/platform/functions/durable-functions.md) — the API, limits, and billing
- [CLI](https://docs.volcano.dev/cli/durable-functions.md) — deploy, start, and inspect executions
