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 the executions it started, and messages them, with the credential it already holds. Listing executions, following any of them by project, and deciding the approvals they ask for are owner-scoped, so they belong on your backend, the CLI, or the dashboard.
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
npm install @volcano.dev/sdkThat 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.
// ❌ 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.
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).
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:
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 }); // explicitA 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.
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 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.
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. |
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:
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.
await ctx.wait('cool-off', '15m');
await ctx.wait('1d'); // one argument is always the durationctx.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.
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.
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.
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 instead; when it can
only call back with a token you hand it,
ctx.waitForCallback; and to wait
on a person's sign-off, ctx.waitForApproval.
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.
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:
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:
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 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.
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:
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:
// "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.
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:
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 what it published or subscribe to receive it live, which is how a long-running execution reports progress to the browser that started it.
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.
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.
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:
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.
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 — 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.
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.
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
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:
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 executionThe 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:
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
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.
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.
// 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. 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.
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 for the full API
and the CLI reference for volcano cloud durable.
Sending and reading messages
volcano.durable.messages sends the messages
ctx.waitForMessage waits for, and lists or
subscribes to what ctx.publish 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:
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 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:
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 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:
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 — standard functions, invocation, and user context
- Durable functions on the platform — the API, limits, and billing
- CLI — deploy, start, and inspect executions