From d719b04febee11ec88bd497472cc8136de88ce10 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Wed, 23 Sep 2026 18:41:12 -0400 Subject: [PATCH 1/3] fix(durable): align local runtime guidance --- README.md | 17 ++++++++-- docs/README.md | 2 +- docs/functions.md | 51 ++++++++++++++++++++++++++++ src/volcano_sdk/durable_authoring.py | 12 +++---- tests/unit/test_durable_authoring.py | 4 ++- 5 files changed, 76 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 38505ac4..1e24a498 100644 --- a/README.md +++ b/README.md @@ -239,8 +239,8 @@ A function's own response, HTTP 403, or a network failure never triggers this re Anonymous and service keys do not refresh. `durable.start()` begins an execution of a deployed durable function and returns -a handle rather than a result: an execution can run for hours, so its result is -read back with `durable.get()`. It takes the credential `functions.invoke()` +a handle rather than a result: an execution can run for up to 366 days, so its +result is read back with `durable.get()`. It takes the credential `functions.invoke()` takes, and is the only durable operation an application credential may perform. An `execution_name` makes the start idempotent — starting again under the same name returns the execution that already exists rather than beginning a second @@ -331,6 +331,19 @@ nothing while it waits. Running the handler anywhere durable execution does not exist raises `DurableRuntimeMissingError` rather than an import error from an unfamiliar package. +Run the same handler through the local durable engine: + +```bash +volcano start +volcano durable deploy --all +volcano durable start order-pipeline --input '{"order_id":"order-9"}' +``` + +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. Volcano does not expose externally +completed callbacks; use `ctx.wait_until` to poll application state instead. + `logs.search()` returns an immutable page of retained runtime or deployment log events. Pass `next_cursor` back as `cursor` to continue a search. `logs.activity()` returns immutable time buckets using the same resource selector and query syntax. diff --git a/docs/README.md b/docs/README.md index f1342922..89f179e2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -130,6 +130,6 @@ See [Realtime](./realtime.md) for broadcasts, presence, database changes, and sh See [Authentication](./authentication.md) for account, session, email, and OAuth workflows. -See [Functions](./functions.md) for invocation identity, response values, and error handling. +See [Functions](./functions.md) for standard invocation, durable execution, local durable development, and error handling. See [Versions and compatibility](./versions.md) for runtime support, upgrades, and restoring a tested dependency set. diff --git a/docs/functions.md b/docs/functions.md index 67b37652..0bf2ef8e 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -63,3 +63,54 @@ A function's own HTTP 404 does not trigger another invocation. Network failures do not establish whether a function ran; do not blindly retry operations with side effects. Invalid invocation arguments and malformed successful resolution responses can raise `ValueError` or `TypeError`. + +## Start and follow a durable execution + +A durable execution can run for up to 366 days. Starting one returns a handle instead of waiting for its result: + +```python +handle = client.durable.start( + "charge-order", + {"order_id": "order-9"}, + execution_name="order-9", +) + +execution = client.durable.get(project_id, "charge-order", handle.id) +page = client.durable.list(project_id, "charge-order", status="running") +client.durable.stop(project_id, "charge-order", handle.id) +``` + +`start()` accepts the same active session, service key, or anonymous key as `functions.invoke()`. It is the only durable operation available to application credentials. An execution name makes a start idempotent. + +`get()`, `list()`, and `stop()` are owner-scoped and require the project's platform token. `stop()` returns after the stop request is accepted, so poll `get()` until `is_terminal` is true. + +## Write a durable function + +Use `volcano_sdk.durable_authoring` in a durable function running on `python3.13` or `python3.14`: + +```python +from volcano_sdk.durable_authoring import durable + + +@durable +def handler(event, ctx): + charge = ctx.step("charge", lambda scope: charge_card(event["order_id"])) + ctx.wait("settle", "30s") + return {"charge_id": charge["id"]} +``` + +Volcano records each context operation. Resumed executions replay recorded results instead of repeating completed work. Keep changing decisions inside `ctx.step()`. Use `ctx.wait_until()` to poll application state. Volcano does not expose externally completed callbacks. + +## Run durable functions locally + +Deploy and start the same handler through the local durable engine: + +```bash +volcano start +volcano durable deploy --all +volcano durable start charge-order --input '{"order_id":"order-9"}' +``` + +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. + +Running a decorated handler directly in a Python process still needs the optional test runtime: `python -m pip install 'volcano-sdk-python[durable]'`. diff --git a/src/volcano_sdk/durable_authoring.py b/src/volcano_sdk/durable_authoring.py index 2e9dbf30..4a71502c 100644 --- a/src/volcano_sdk/durable_authoring.py +++ b/src/volcano_sdk/durable_authoring.py @@ -1,8 +1,8 @@ """Durable function authoring API. A durable function checkpoints its progress as it runs, so one execution can -span many invocations and run for hours. This module is what the function -itself is written against; starting an execution and reading its result are +span many invocations and run for up to 366 days. This module is what the +function itself is written against; starting an execution and reading its result are done through `client.durable`, the CLI, or the dashboard. from volcano_sdk.durable_authoring import durable @@ -123,10 +123,10 @@ def __init__(self, cause: BaseException | None = None) -> None: super().__init__( "Durable execution is not available here. Volcano provides the " "durable runtime when it builds a function deployed as durable, so " - "deploy this one that way (`volcano cloud durable deploy`, or " - "`kind: durable` in volcano-config.yaml). Durable execution is a " - f"cloud capability and does not run locally; to exercise a handler " - f"in your own tests, install `{_ENGINE_EXTRA}`." + "deploy this one that way (`volcano durable deploy` locally, " + "`volcano cloud durable deploy` in cloud, or `kind: durable` in " + "volcano-config.yaml). To exercise a handler directly in your own " + f"tests, install `{_ENGINE_EXTRA}`." ) self.__cause__ = cause diff --git a/tests/unit/test_durable_authoring.py b/tests/unit/test_durable_authoring.py index eb7a6dd9..e7cb8024 100644 --- a/tests/unit/test_durable_authoring.py +++ b/tests/unit/test_durable_authoring.py @@ -817,8 +817,10 @@ def handler(_event: Any, _ctx: DurableContext) -> Any: # fix is a deploy rather than an install. A function's requirements.txt # never names the runtime, and the error must not send a reader to add it. assert "deploy this one that way" in message + assert "`volcano durable deploy` locally" in message + assert "`volcano cloud durable deploy` in cloud" in message assert "kind: durable" in message - assert "does not run locally" in message + assert "does not run locally" not in message assert "requirements.txt" not in message assert "aws-durable-execution-sdk-python" not in message From 294b885724d123b6e8788f49fb791d01c35d3998 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Wed, 23 Sep 2026 20:03:40 -0400 Subject: [PATCH 2/3] docs(durable): align client and local contracts --- README.md | 24 +++++++++++++++--------- docs/functions.md | 15 ++++++++++----- src/volcano_sdk/durable.py | 14 ++++++-------- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 1e24a498..923b2ff5 100644 --- a/README.md +++ b/README.md @@ -69,17 +69,22 @@ execution = client.durable.start( ) print(execution.id, execution.status) -execution = client.durable.get( +owner_client = VolcanoClient( + api_url="https://api.volcano.dev", + anon_key="your-anon-key", + access_token="your-platform-user-token", +) +execution = owner_client.durable.get( "00000000-0000-4000-8000-000000000001", "order-pipeline", execution.id ) print(execution.is_terminal, execution.result) -executions = client.durable.list( +executions = owner_client.durable.list( "00000000-0000-4000-8000-000000000001", "order-pipeline", status="running" ) print(executions.total, executions.has_more) -client.durable.stop( +owner_client.durable.stop( "00000000-0000-4000-8000-000000000001", "order-pipeline", execution.id ) @@ -247,10 +252,10 @@ name returns the execution that already exists rather than beginning a second one, and is charged once. `durable.get()`, `durable.list()` and `durable.stop()` are owner-scoped and need -the project's own platform token, because an execution is addressed by its id -alone and an anonymous key is held by everyone who loads the page. Neither an -auth-user session from `sign_in()` nor a service key is accepted -- the routes -take a user token, and anything else is answered 401. Poll them from a backend. +the project owner's platform user token, because an execution is addressed by +its id alone and an anonymous key is held by everyone who loads the page. +Auth-user sessions from `sign_in()`, anonymous keys, service keys, and project +access tokens are not accepted. Poll them from a trusted backend. `get()` carries `result` once the execution has succeeded and `error` when it failed; `result_expired` separates a result the platform has discarded from a function that returned nothing. `is_terminal` reports whether the execution has @@ -341,8 +346,9 @@ volcano durable start order-pipeline --input '{"order_id":"order-9"}' 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. Volcano does not expose externally -completed callbacks; use `ctx.wait_until` to poll application state instead. +timing must match the deployed function. Local executions persist across +`volcano stop` and `volcano start`. Volcano does not expose externally completed +callbacks; use `ctx.wait_until` to poll application state instead. `logs.search()` returns an immutable page of retained runtime or deployment log events. Pass `next_cursor` back as `cursor` to continue a search. `logs.activity()` diff --git a/docs/functions.md b/docs/functions.md index 0bf2ef8e..42eb71ad 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -75,14 +75,19 @@ handle = client.durable.start( execution_name="order-9", ) -execution = client.durable.get(project_id, "charge-order", handle.id) -page = client.durable.list(project_id, "charge-order", status="running") -client.durable.stop(project_id, "charge-order", handle.id) +owner_client = VolcanoClient( + anon_key=os.environ["VOLCANO_ANON_KEY"], + api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), + access_token=os.environ["VOLCANO_PLATFORM_TOKEN"], +) +execution = owner_client.durable.get(project_id, "charge-order", handle.id) +page = owner_client.durable.list(project_id, "charge-order", status="running") +owner_client.durable.stop(project_id, "charge-order", handle.id) ``` `start()` accepts the same active session, service key, or anonymous key as `functions.invoke()`. It is the only durable operation available to application credentials. An execution name makes a start idempotent. -`get()`, `list()`, and `stop()` are owner-scoped and require the project's platform token. `stop()` returns after the stop request is accepted, so poll `get()` until `is_terminal` is true. +`get()`, `list()`, and `stop()` are owner-scoped. Call them from a trusted backend with the project owner's platform user token. Auth-user sessions, anonymous keys, service keys, and project access tokens are not accepted. `stop()` returns after the stop request is accepted, so poll `get()` until `is_terminal` is true. ## Write a durable function @@ -111,6 +116,6 @@ volcano durable deploy --all volcano durable start charge-order --input '{"order_id":"order-9"}' ``` -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. +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. Local executions persist across `volcano stop` and `volcano start`. Running a decorated handler directly in a Python process still needs the optional test runtime: `python -m pip install 'volcano-sdk-python[durable]'`. diff --git a/src/volcano_sdk/durable.py b/src/volcano_sdk/durable.py index 0c5dc320..e1173960 100644 --- a/src/volcano_sdk/durable.py +++ b/src/volcano_sdk/durable.py @@ -129,8 +129,8 @@ def start( ) -> DurableExecution: """Start a durable execution and return a handle to it. - A durable function is never invoked synchronously: it can run for - hours, so the platform accepts the start and answers with an execution + A durable function is never invoked synchronously: it can run for up to + 366 days, so the platform accepts the start and answers with an execution to follow. Starting is the only durable operation an application credential may perform -- reading a result or stopping an execution is owner-scoped. @@ -166,12 +166,10 @@ def get( ) -> DurableExecution: """Read an execution, including its result once it has succeeded. - Owner-scoped: it takes the project id and the project's own platform - token, because an execution is addressed by its id alone and an - anonymous key is held by everyone who loads the page. Poll it from a - backend, not a browser. Neither an auth-user session from sign-in nor a - service key is accepted here -- the route takes a user token, and - anything else is answered 401. + Owner-scoped: it takes the project id and the project owner's platform + user token, because an execution is addressed by its id alone. Poll it + from a backend, not a browser. Auth-user sessions, anonymous keys, + service keys, and project access tokens are not accepted. Returns ------- From 7ee2c590eac87473bc3201dc691ff8fabbe64de9 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Wed, 23 Sep 2026 20:28:53 -0400 Subject: [PATCH 3/3] docs(durable): keep parity update docs-only --- src/volcano_sdk/durable.py | 14 ++++++++------ src/volcano_sdk/durable_authoring.py | 12 ++++++------ tests/unit/test_durable_authoring.py | 4 +--- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/volcano_sdk/durable.py b/src/volcano_sdk/durable.py index e1173960..0c5dc320 100644 --- a/src/volcano_sdk/durable.py +++ b/src/volcano_sdk/durable.py @@ -129,8 +129,8 @@ def start( ) -> DurableExecution: """Start a durable execution and return a handle to it. - A durable function is never invoked synchronously: it can run for up to - 366 days, so the platform accepts the start and answers with an execution + A durable function is never invoked synchronously: it can run for + hours, so the platform accepts the start and answers with an execution to follow. Starting is the only durable operation an application credential may perform -- reading a result or stopping an execution is owner-scoped. @@ -166,10 +166,12 @@ def get( ) -> DurableExecution: """Read an execution, including its result once it has succeeded. - Owner-scoped: it takes the project id and the project owner's platform - user token, because an execution is addressed by its id alone. Poll it - from a backend, not a browser. Auth-user sessions, anonymous keys, - service keys, and project access tokens are not accepted. + Owner-scoped: it takes the project id and the project's own platform + token, because an execution is addressed by its id alone and an + anonymous key is held by everyone who loads the page. Poll it from a + backend, not a browser. Neither an auth-user session from sign-in nor a + service key is accepted here -- the route takes a user token, and + anything else is answered 401. Returns ------- diff --git a/src/volcano_sdk/durable_authoring.py b/src/volcano_sdk/durable_authoring.py index 4a71502c..2e9dbf30 100644 --- a/src/volcano_sdk/durable_authoring.py +++ b/src/volcano_sdk/durable_authoring.py @@ -1,8 +1,8 @@ """Durable function authoring API. A durable function checkpoints its progress as it runs, so one execution can -span many invocations and run for up to 366 days. This module is what the -function itself is written against; starting an execution and reading its result are +span many invocations and run for hours. This module is what the function +itself is written against; starting an execution and reading its result are done through `client.durable`, the CLI, or the dashboard. from volcano_sdk.durable_authoring import durable @@ -123,10 +123,10 @@ def __init__(self, cause: BaseException | None = None) -> None: super().__init__( "Durable execution is not available here. Volcano provides the " "durable runtime when it builds a function deployed as durable, so " - "deploy this one that way (`volcano durable deploy` locally, " - "`volcano cloud durable deploy` in cloud, or `kind: durable` in " - "volcano-config.yaml). To exercise a handler directly in your own " - f"tests, install `{_ENGINE_EXTRA}`." + "deploy this one that way (`volcano cloud durable deploy`, or " + "`kind: durable` in volcano-config.yaml). Durable execution is a " + f"cloud capability and does not run locally; to exercise a handler " + f"in your own tests, install `{_ENGINE_EXTRA}`." ) self.__cause__ = cause diff --git a/tests/unit/test_durable_authoring.py b/tests/unit/test_durable_authoring.py index e7cb8024..eb7a6dd9 100644 --- a/tests/unit/test_durable_authoring.py +++ b/tests/unit/test_durable_authoring.py @@ -817,10 +817,8 @@ def handler(_event: Any, _ctx: DurableContext) -> Any: # fix is a deploy rather than an install. A function's requirements.txt # never names the runtime, and the error must not send a reader to add it. assert "deploy this one that way" in message - assert "`volcano durable deploy` locally" in message - assert "`volcano cloud durable deploy` in cloud" in message assert "kind: durable" in message - assert "does not run locally" not in message + assert "does not run locally" in message assert "requirements.txt" not in message assert "aws-durable-execution-sdk-python" not in message