From 990f833e809c6780b8f0a4f8923bb2c68e8e787f Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Tue, 13 Oct 2026 06:39:37 -0700 Subject: [PATCH 01/28] feat(durable): wait for, publish, send, and subscribe to durable messages Add ctx.wait_for_message and ctx.publish to durable authoring, and client.durable.messages with send, list, and an awaitable subscribe that catches up from the list and follows the execution's realtime channel. Subscriptions share the client's realtime connection. Executions expose the client_token their creating start returned. Co-authored-by: Cursor --- README.md | 32 + docs/functions.md | 84 + openapi/openapi.yaml | 1894 ++++++++++++++--- scripts/generate_openapi.py | 4 + src/volcano_sdk/__init__.py | 18 + .../_durable_approval_registration.py | 300 +-- src/volcano_sdk/_durable_arguments.py | 69 + src/volcano_sdk/_durable_callback.py | 123 ++ src/volcano_sdk/_durable_message.py | 195 ++ src/volcano_sdk/_durable_message_response.py | 138 ++ src/volcano_sdk/_durable_message_stream.py | 135 ++ src/volcano_sdk/_durable_messages.py | 393 ++++ src/volcano_sdk/_durable_platform_request.py | 252 +++ src/volcano_sdk/_durable_response.py | 9 + src/volcano_sdk/_durable_results.py | 27 +- .../api/auth_admin/get_auth_insights.py | 96 +- .../auth_configuration/test_email_config.py | 12 +- .../list_durable_message_waits.py | 226 ++ .../list_durable_messages.py | 322 +++ .../list_durable_messages_from_application.py | 345 +++ .../open_durable_message_session.py | 272 +++ .../publish_durable_message.py | 290 +++ .../register_durable_message_wait.py | 272 +++ .../durable_functions/send_durable_message.py | 314 +++ .../send_durable_message_from_application.py | 387 ++++ .../api/system/list_capabilities.py | 222 ++ .../api/variable_environments/__init__.py | 1 + .../create_variable_environment.py | 247 +++ .../delete_variable_environment.py | 225 ++ .../get_variable_environment.py | 214 ++ .../list_variable_environments.py | 194 ++ .../rename_variable_environment.py | 256 +++ .../api/verified_domains/verify_domain.py | 40 +- src/volcano_sdk/_generated/models/__init__.py | 44 + .../_generated/models/auth_config.py | 60 + .../models/auth_insights_series_point.py | 31 +- .../_generated/models/capability.py | 76 + .../_generated/models/capability_list.py | 76 + .../_generated/models/capability_status.py | 10 + .../create_variable_environment_request.py | 59 + .../_generated/models/durable_execution.py | 11 + .../_generated/models/durable_message.py | 205 ++ .../models/durable_message_direction.py | 10 + .../_generated/models/durable_message_list.py | 103 + .../models/durable_message_sender.py | 100 + .../models/durable_message_sender_type.py | 10 + .../durable_message_session_registration.py | 82 + .../models/durable_message_status.py | 10 + .../_generated/models/durable_message_wait.py | 146 ++ .../models/durable_message_wait_list.py | 94 + .../durable_message_wait_registration.py | 127 ++ .../models/durable_message_wait_status.py | 10 + src/volcano_sdk/_generated/models/frontend.py | 21 + .../frontend_custom_domain_conflict_error.py | 4 +- .../models/frontend_custom_domain_response.py | 6 +- .../_generated/models/metric_usage_data.py | 19 +- .../open_durable_message_session_request.py | 66 + .../models/project_config_sandbox.py | 13 +- .../models/project_frontend_custom_domain.py | 6 +- .../models/publish_durable_message_request.py | 69 + .../register_durable_message_wait_request.py | 75 + .../rename_variable_environment_request.py | 59 + .../request_durable_approval_request.py | 7 +- .../models/sandbox_command_request.py | 7 +- .../_generated/models/sandbox_session.py | 51 +- .../models/send_durable_message_request.py | 69 + .../models/update_auth_config_request.py | 53 + .../_generated/models/variable_environment.py | 132 ++ .../models/variable_environment_list.py | 94 + src/volcano_sdk/_realtime_channel.py | 15 +- src/volcano_sdk/_realtime_connection.py | 10 +- src/volcano_sdk/_realtime_durable.py | 88 + src/volcano_sdk/_realtime_messages.py | 4 + src/volcano_sdk/_realtime_presence.py | 4 +- src/volcano_sdk/_realtime_transport.py | 3 + .../_tests/fixtures/durable_messages.py | 193 ++ .../_tests/fixtures/durable_platform.py | 113 + .../_tests/fixtures/invalid_arguments.py | 48 + src/volcano_sdk/_tests/realtime_probes.py | 5 +- src/volcano_sdk/_tests/test_durable.py | 28 + .../test_durable_approval_registration.py | 128 +- .../_tests/test_durable_authoring.py | 306 ++- .../_tests/test_durable_message.py | 413 ++++ .../_tests/test_durable_message_authoring.py | 257 +++ .../_tests/test_durable_messages.py | 1329 ++++++++++++ src/volcano_sdk/_tests/test_realtime.py | 13 +- src/volcano_sdk/_transport.py | 8 + .../_transport_durable_messages.py | 154 ++ src/volcano_sdk/_transport_types.py | 38 +- src/volcano_sdk/client.py | 26 +- src/volcano_sdk/durable.py | 113 +- src/volcano_sdk/durable_authoring.py | 143 +- src/volcano_sdk/models.py | 91 + src/volcano_sdk/realtime.py | 4 +- typings/centrifuge/__init__.pyi | 1 + 95 files changed, 12320 insertions(+), 838 deletions(-) create mode 100644 src/volcano_sdk/_durable_arguments.py create mode 100644 src/volcano_sdk/_durable_callback.py create mode 100644 src/volcano_sdk/_durable_message.py create mode 100644 src/volcano_sdk/_durable_message_response.py create mode 100644 src/volcano_sdk/_durable_message_stream.py create mode 100644 src/volcano_sdk/_durable_messages.py create mode 100644 src/volcano_sdk/_durable_platform_request.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/list_durable_message_waits.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/list_durable_messages.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/list_durable_messages_from_application.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/open_durable_message_session.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/publish_durable_message.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/register_durable_message_wait.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/send_durable_message.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/send_durable_message_from_application.py create mode 100644 src/volcano_sdk/_generated/api/system/list_capabilities.py create mode 100644 src/volcano_sdk/_generated/api/variable_environments/__init__.py create mode 100644 src/volcano_sdk/_generated/api/variable_environments/create_variable_environment.py create mode 100644 src/volcano_sdk/_generated/api/variable_environments/delete_variable_environment.py create mode 100644 src/volcano_sdk/_generated/api/variable_environments/get_variable_environment.py create mode 100644 src/volcano_sdk/_generated/api/variable_environments/list_variable_environments.py create mode 100644 src/volcano_sdk/_generated/api/variable_environments/rename_variable_environment.py create mode 100644 src/volcano_sdk/_generated/models/capability.py create mode 100644 src/volcano_sdk/_generated/models/capability_list.py create mode 100644 src/volcano_sdk/_generated/models/capability_status.py create mode 100644 src/volcano_sdk/_generated/models/create_variable_environment_request.py create mode 100644 src/volcano_sdk/_generated/models/durable_message.py create mode 100644 src/volcano_sdk/_generated/models/durable_message_direction.py create mode 100644 src/volcano_sdk/_generated/models/durable_message_list.py create mode 100644 src/volcano_sdk/_generated/models/durable_message_sender.py create mode 100644 src/volcano_sdk/_generated/models/durable_message_sender_type.py create mode 100644 src/volcano_sdk/_generated/models/durable_message_session_registration.py create mode 100644 src/volcano_sdk/_generated/models/durable_message_status.py create mode 100644 src/volcano_sdk/_generated/models/durable_message_wait.py create mode 100644 src/volcano_sdk/_generated/models/durable_message_wait_list.py create mode 100644 src/volcano_sdk/_generated/models/durable_message_wait_registration.py create mode 100644 src/volcano_sdk/_generated/models/durable_message_wait_status.py create mode 100644 src/volcano_sdk/_generated/models/open_durable_message_session_request.py create mode 100644 src/volcano_sdk/_generated/models/publish_durable_message_request.py create mode 100644 src/volcano_sdk/_generated/models/register_durable_message_wait_request.py create mode 100644 src/volcano_sdk/_generated/models/rename_variable_environment_request.py create mode 100644 src/volcano_sdk/_generated/models/send_durable_message_request.py create mode 100644 src/volcano_sdk/_generated/models/variable_environment.py create mode 100644 src/volcano_sdk/_generated/models/variable_environment_list.py create mode 100644 src/volcano_sdk/_realtime_durable.py create mode 100644 src/volcano_sdk/_tests/fixtures/durable_messages.py create mode 100644 src/volcano_sdk/_tests/fixtures/durable_platform.py create mode 100644 src/volcano_sdk/_tests/test_durable_message.py create mode 100644 src/volcano_sdk/_tests/test_durable_message_authoring.py create mode 100644 src/volcano_sdk/_tests/test_durable_messages.py create mode 100644 src/volcano_sdk/_transport_durable_messages.py diff --git a/README.md b/README.md index f2c09a0c..343c0e01 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,36 @@ deciding an approval that expired or whose execution ended, raises `approval_cancelled`. See the [functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#decide-approvals-from-a-backend). +`durable.messages` sends messages to a running execution, lists its messages, +and follows what it publishes. It takes the credential `durable.start()` takes; +pass the execution's `client_token` when an anonymous key, or a signed-in user +who did not start the execution, should reach it: + +```python +message = client.durable.messages.send( + "order-pipeline", + execution.id, + "address-confirmed", + {"address_id": "addr_19"}, + client_token=execution.client_token, + idempotency_key="confirm-addr_19", +) +page = client.durable.messages.list("order-pipeline", execution.id, direction="out") +subscription = await client.durable.messages.subscribe( + "order-pipeline", + execution.id, + lambda m: print(m.name, m.data), + client_token=execution.client_token, +) +``` + +`subscribe()` delivers what the execution publishes in `seq` order, once each, +including what it published before the call, and needs a signed-in session for +the realtime connection. A send to an execution that has ended raises +`ConflictError` with `code` set to `execution_not_running`. Pass `project_id` to +use the owner's project route instead. See the +[functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#send-and-follow-messages). + ## Write a durable function `volcano_sdk.durable_authoring` is what the durable function itself is written @@ -351,6 +381,8 @@ the clock or a random value. | `ctx.wait(name, duration)` | Suspends the execution. `"30s"`, `"2h"`, `"1m30s"`, a whole number of seconds, or `{"hours": 2}`. | | `ctx.wait_until(check, options, name=None)` | Polls your own state until `options.until` holds, suspending between checks. `options.initial_state` is required. | | `ctx.wait_for_approval(name, title=..., description=None, details=None, timeout=None)` | Suspends until a person approves or denies, and returns an `ApprovalDecision`. A timeout returns `status="expired"` rather than raising. | +| `ctx.wait_for_message(name, timeout=None)` | Suspends until a message with that name is sent to the execution, and returns a `ReceivedMessage`, or `None` once the timeout passes. | +| `ctx.publish(name, data=None)` | Records a message for applications to list or receive live, and returns a `PublishedMessage` with `id` and `seq`. | | `ctx.map(items, fn, name=None, options=None)` | Runs the same work over every item, each in its own child context. | | `ctx.parallel(branches, name=None, options=None)` | Runs independent branches at the same time. | | `ctx.child(name, fn)` | Groups operations under one recorded context. | diff --git a/docs/functions.md b/docs/functions.md index aeda7c4a..6aa05c25 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -182,6 +182,33 @@ A denial or a timeout is a value to branch on, not an exception. The execution c Volcano sets `VOLCANO_PLATFORM_API_URL` on deployed and local durable functions. The function sends the approval there without a credential. Volcano accepts it only from the execution that is waiting. When Volcano does not answer, has not seen the execution or its approval yet, throttles the request, or fails, the function retries for up to 30 seconds. An approval whose timeout passes before Volcano records it returns the `expired` decision. If Volcano refuses the approval, or keeps failing for 30 seconds, `wait_for_approval()` raises the durable runtime's `CallbackSubmitterError`, carrying the SDK error's message. Unless the handler catches it, the execution fails. Calling `wait_for_approval()` without `VOLCANO_PLATFORM_API_URL` raises `RuntimeError`. +## Wait for a message and publish progress + +`ctx.wait_for_message()` pauses the execution until something outside it sends a message with that name: a webhook receiver, a backend, a browser, or another function, through [`client.durable.messages`](#send-and-follow-messages). `ctx.publish()` records a message that applications list or receive live: + +```python +from volcano_sdk.durable_authoring import durable + + +@durable +def handler(event, ctx): + ctx.publish("progress", {"stage": "waiting-for-upload"}) + + upload = ctx.wait_for_message("uploaded", timeout="1d") + if upload is None: + return {"status": "abandoned"} + + report = ctx.step("render", lambda scope: render(upload.data["url"])) + ctx.publish("progress", {"stage": "done", "report_url": report["url"]}) + return {"status": "done", "report_url": report["url"]} +``` + +`wait_for_message(name, timeout=None)` returns a `ReceivedMessage` with `id`, `name`, `data`, `sender` (a `DurableMessageSender` with `type` and `user_id`), and `sent_at` as an RFC 3339 string, or `None` once the timeout passes. Messages sent before anything waits for their name are kept, and each wait takes the oldest one. A message never decides an approval, so use `wait_for_approval()` wherever only a person may answer. + +`publish(name, data=None)` returns a `PublishedMessage` with `id` and `seq`. `data` is any JSON value up to 64 KiB. Publishing is checkpointed, so a replay never publishes twice. The first publish in each context, and in each `ctx.child()`, `ctx.parallel()` branch, or `ctx.map()` item that publishes, opens a messages session, which appears in the execution's history as `volcano:messages-session` and costs three durable operations once. + +Names follow the approval rules: up to 237 printable ASCII characters, checked before anything is recorded, as is the timeout. Both need `VOLCANO_PLATFORM_API_URL`. Volcano refusing a wait, for example with 100 waits already open, raises `CallbackSubmitterError` after the same 30 seconds of retries an approval gets. A refused publish fails its step without a retry, which fails the execution unless the handler catches it. + ## Decide approvals from a backend `client.durable.approvals` lists, reads, and decides a project's approvals: @@ -224,6 +251,61 @@ print(stats.counts.pending, stats.approval_rate, stats.median_seconds_to_decisio Read approvals with the project owner's platform user token or a project access token. Only a person decides: `approve()` and `deny()` need a platform user token, and a project access token raises `PermissionDeniedError`. Repeating the same decision returns the approval unchanged. A conflicting decision, or deciding an expired or cancelled approval, raises `ConflictError`. An unknown approval raises `NotFoundError`. `PermissionDeniedError` subclasses `AuthenticationError`, so existing handlers still catch it. +## Send and follow messages + +`client.durable.messages` sends messages to an execution, lists its messages in both directions, and subscribes to what it publishes. It uses the application route with the client's credential, as `durable.start()` does. Pass the execution's `client_token` from the start response when an anon key, or a signed-in user who did not start the execution, should reach it: + +```python +import os + +from volcano_sdk import ConflictError, VolcanoClient + +client = VolcanoClient( + anon_key=os.environ["VOLCANO_ANON_KEY"], + service_key=os.environ["VOLCANO_SERVICE_KEY"], +) +execution = client.durable.start("report-builder", {"report_id": "7f3a"}) + +try: + message = client.durable.messages.send( + "report-builder", + execution.id, + "uploaded", + {"url": "https://files.example.com/7f3a.csv"}, + idempotency_key="upload-7f3a", + ) +except ConflictError as error: + print(error.code) # execution_not_running + +page = client.durable.messages.list("report-builder", execution.id, direction="out") +for published in page.messages: + print(published.seq, published.name, published.data) +``` + +| Method | Returns | +|---|---| +| `send(function_name, execution_id, name, data=None, idempotency_key=..., client_token=..., project_id=...)` | The stored `DurableMessage`, `queued` or already `delivered`. | +| `list(function_name, execution_id, direction=..., name=..., after=..., limit=..., client_token=..., project_id=...)` | `DurableMessagePage` with `messages`, oldest first, and `has_more`. | +| `await subscribe(function_name, execution_id, handler, client_token=..., project_id=...)` | A `DurableMessageSubscription` with `await unsubscribe()`. | + +A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the execution has seen returns the first message and sends nothing. The key is up to 255 characters. + +`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order and once each. The messages published before the call are delivered before it returns, and after a reconnect the ones published meanwhile are read back before live delivery resumes. It follows the execution on the client's realtime connection, which needs a signed-in session. One client subscribes to an execution once at a time: + +```python +async def follow(client, execution): + subscription = await client.durable.messages.subscribe( + "report-builder", + execution.id, + lambda message: print(message.name, message.data), + client_token=execution.client_token, + ) + ... + await subscription.unsubscribe() +``` + +Pass `project_id` to use the owner's project route with the session's platform token or a project access token instead; a `read_only` project access token can list but not send. A send to an execution that has ended raises `ConflictError` with `code` set to `execution_not_running`. An execution the caller may not reach raises `NotFoundError`, as an unknown one does. + ## Run durable functions locally Deploy and start the same handler through the local durable engine: @@ -238,4 +320,6 @@ Local waits resolve immediately by default while preserving checkpoint and repla Approvals work the same locally. Decide one with `volcano durable approvals approve` or `volcano durable approvals deny`, or point the owner client at the local server. Approval timeouts run in real time. +Messages work the same locally too. Send one with `volcano durable messages send`, or point the client at the local server. Message wait timeouts run in real time. + 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/openapi/openapi.yaml b/openapi/openapi.yaml index a0cc7369..837f800c 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -37,6 +37,8 @@ tags: description: Service keys for function invocation and database admin access - name: Variables description: Environment variable management + - name: Variable Environments + description: Project-owned variable Environment identity and lifecycle management - name: Databases description: PostgreSQL database provisioning and management - name: Database Branches @@ -78,7 +80,7 @@ tags: - name: Realtime description: WebSocket realtime configuration and monitoring - name: System - description: System health and monitoring endpoints + description: System health, capability discovery, and monitoring endpoints - name: CLI Authentication description: | Browser-based CLI login flow for automatic token provisioning. @@ -594,7 +596,7 @@ paths: schema: $ref: '#/components/schemas/SandboxSession' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. Code `sandbox_plan_limit_exceeded` (403) means the requested resources exceed the plan. Code `sandbox_allowance_exhausted` (429) means the remaining allowance or credits cannot admit the operation. content: application/json: schema: @@ -635,7 +637,7 @@ paths: schema: $ref: '#/components/schemas/SandboxExecutionResult' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. Code `sandbox_plan_limit_exceeded` (403) means the requested resources exceed the plan. Code `sandbox_allowance_exhausted` (429) means the remaining allowance or credits cannot admit the operation. content: application/json: schema: @@ -751,7 +753,7 @@ paths: schema: $ref: '#/components/schemas/SandboxSession' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. Code `sandbox_plan_limit_exceeded` (403) means the requested resources exceed the plan. Code `sandbox_allowance_exhausted` (429) means the remaining allowance or credits cannot admit the operation. content: application/json: schema: @@ -1839,13 +1841,15 @@ paths: Publish a TXT record named `_volcano.`, then send this request. Without the record, the response is `409` with `code: ownership_verification_required` and the `required_record` to - publish. Its value is the same on every request. The record must be - served at that name itself; a record reached through a CNAME does not - count. + publish. Its value stays the same until Volcano rotates the key it is + derived from. The record must be served at that name itself; a record + reached through a CNAME does not count. When another account verified the domain, publishing your record moves the domain to your account once that account's record is no longer - published. Keep your record published so the domain stays yours. + published. Keep your record published so the domain stays yours. After + Volcano rotates the key the value is derived from, only a record with + the new value counts. Verifying a domain the account already verified returns `200`. operationId: verifyDomain @@ -4468,6 +4472,178 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /durable-functions/{functionId}/executions/{executionId}/messages: + post: + tags: + - Durable Functions + summary: Send a message to a durable execution from an application + description: | + Sends a named message to a running execution. The workflow receives it + from `ctx.waitForMessage` with the same name. A message sent before + anything waits for its name is kept, and each wait takes the oldest + one, so messages arrive in the order they were sent. + + Messages never decide an approval: a workflow waiting in + `ctx.waitForApproval` is not resumed by one. + + **With a service key:** any execution in the project. + + **With an auth user token:** an execution the user started, or one + whose `X-Volcano-Client-Token` the request carries, of a durable + function whose `visibility` is `authenticated` or `public`. + + **With an anon key:** an execution whose `X-Volcano-Client-Token` the + request carries, of a function with `visibility: public`. + + Any other execution answers `404`, as a missing one does. + + Send `Idempotency-Key` to make a retry safe: a repeat with a key the + execution has seen returns the first message with `200` and sends + nothing. + operationId: sendDurableMessageFromApplication + security: + - AnonKey: [] + - ServiceRoleKey: [] + - AuthUserAccessToken: [] + parameters: + - $ref: '#/components/parameters/DurableFunctionId' + - $ref: '#/components/parameters/DurableExecutionId' + - $ref: '#/components/parameters/DurableMessageIdempotencyKey' + - $ref: '#/components/parameters/DurableClientToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SendDurableMessageRequest' + responses: + '200': + description: A message with this `Idempotency-Key` was already sent; it is returned + content: + application/json: + schema: + $ref: '#/components/schemas/DurableMessage' + '201': + description: | + Message sent. `status` is `delivered` when a wait took it at once, + and `queued` when it is kept for the next wait. + content: + application/json: + schema: + $ref: '#/components/schemas/DurableMessage' + '400': + description: The name, data or idempotency key is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid credential + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: | + The function is `authenticated` and the request carries an anon + key, or the request's origin is refused by the project's CORS + policy. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: | + Durable function or execution not found, or the credential cannot + reach the execution. A `private` function answers every credential + but a service key this way. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + The message cannot be sent. `code` says why: + + - `execution_not_running`: the execution has finished. + - `too_many_queued_messages`: the execution already holds the + maximum number of messages nothing has taken. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: The request body is larger than 72 KiB + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: Too many messages sent to this execution + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + get: + tags: + - Durable Functions + summary: List a durable execution's messages from an application + description: | + Lists the messages sent to and published by an execution, oldest + first. Pass the last `seq` you read as `after` to read only what came + next; that is how a client catches up after a live subscription + reconnects. + + Access follows the same rules as sending. + operationId: listDurableMessagesFromApplication + security: + - AnonKey: [] + - ServiceRoleKey: [] + - AuthUserAccessToken: [] + parameters: + - $ref: '#/components/parameters/DurableFunctionId' + - $ref: '#/components/parameters/DurableExecutionId' + - $ref: '#/components/parameters/DurableClientToken' + - $ref: '#/components/parameters/DurableMessageDirectionFilter' + - $ref: '#/components/parameters/DurableMessageNameFilter' + - $ref: '#/components/parameters/DurableMessageAfter' + - $ref: '#/components/parameters/DurableMessageLimit' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/DurableMessageList' + '400': + description: A filter is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid credential + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: | + The function is `authenticated` and the request carries an anon + key, or the request's origin is refused by the project's CORS + policy. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: | + Durable function or execution not found, or the credential cannot + reach the execution. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /functions/resolve: get: tags: @@ -5799,217 +5975,598 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /durable-approvals: + /projects/{id}/durable-functions/{functionId}/executions/{executionId}/messages: post: tags: - Durable Functions - summary: Request an approval from inside a workflow + summary: Send a message to a durable execution description: | - Registers an approval request for a running durable execution. The - Volcano SDK calls this from `ctx.waitForApproval`; applications do not - call it directly. - - `execution_ref` and `callback_id` are opaque values the SDK reads from - the running workflow. Together they are the request's credential, so - the operation takes no other authentication. The approval belongs to - the project that owns the execution, and expires when the workflow's - own approval timeout does. + Sends a named message to a running execution, as + `POST /durable-functions/{functionId}/executions/{executionId}/messages` + does for an application. The workflow receives it from + `ctx.waitForMessage` with the same name; until then it is kept, and + waits take messages oldest first. - Retrying is safe: a request for an approval that is already registered - returns it with `200`. - operationId: requestDurableApproval - security: [] + Project access tokens can send messages, but read-only ones cannot. + A message never decides an approval. + operationId: sendDurableMessage + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableFunctionId' + - $ref: '#/components/parameters/DurableExecutionId' + - $ref: '#/components/parameters/DurableMessageIdempotencyKey' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/RequestDurableApprovalRequest' + $ref: '#/components/schemas/SendDurableMessageRequest' responses: '200': - description: The approval was already registered + description: A message with this `Idempotency-Key` was already sent; it is returned content: application/json: schema: - $ref: '#/components/schemas/DurableApprovalRegistration' + $ref: '#/components/schemas/DurableMessage' '201': - description: Approval registered + description: Message sent content: application/json: schema: - $ref: '#/components/schemas/DurableApprovalRegistration' + $ref: '#/components/schemas/DurableMessage' '400': - description: The request body is invalid + description: The name, data or idempotency key is invalid content: application/json: schema: $ref: '#/components/schemas/Error' '404': - description: No running durable execution matches `execution_ref` + description: Durable function or execution not found content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: | - The approval cannot be registered now. `code` says why: - - - `approval_not_ready`: the workflow has not finished opening the - approval yet. Retry with a short backoff; the SDK does. - - `approval_closed`: the workflow is no longer waiting on it. - - `execution_ended`: the execution has finished. - - `too_many_pending_approvals`: the execution already has the - maximum number of pending approvals. + The message cannot be sent. `code` is `execution_not_running` or + `too_many_queued_messages`. content: application/json: schema: $ref: '#/components/schemas/Error' '413': - description: The request body is larger than 64 KiB + description: The request body is larger than 72 KiB content: application/json: schema: $ref: '#/components/schemas/Error' '429': - description: Too many approval requests from this execution or address - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '503': - description: | - The approval could not be confirmed with the workflow right now. - Retry with backoff. + description: Too many messages sent to this execution content: application/json: schema: $ref: '#/components/schemas/Error' - /projects/{id}/durable-approvals: get: tags: - Durable Functions - summary: List durable approvals + summary: List a durable execution's messages description: | - Lists the approvals durable workflows in the project have requested, - newest first. Pending approvals are the ones a workflow is waiting on; - decided, expired, and cancelled ones are kept for a year. + Lists the messages sent to and published by an execution, oldest + first. Pass the last `seq` you read as `after` to read only what came + next. - Project access tokens can list approvals, including read-only ones. - operationId: listDurableApprovals + Project access tokens can list messages, including read-only ones. + operationId: listDurableMessages security: - UserToken: [] - ProjectAccessToken: [] parameters: - $ref: '#/components/parameters/ProjectId' - - $ref: '#/components/parameters/Page' - - $ref: '#/components/parameters/Limit' - - name: status - in: query - required: false - description: Return only approvals in this status. - schema: - $ref: '#/components/schemas/DurableApprovalStatus' - - $ref: '#/components/parameters/DurableApprovalFunctionFilter' - - name: execution_id - in: query - required: false - description: Return only approvals requested by this durable execution. - schema: - type: string - format: uuid - - name: from - in: query - required: false - description: Return only approvals requested at or after this time. - schema: - type: string - format: date-time - - name: to - in: query - required: false - description: Return only approvals requested before this time. - schema: - type: string - format: date-time + - $ref: '#/components/parameters/DurableFunctionId' + - $ref: '#/components/parameters/DurableExecutionId' + - $ref: '#/components/parameters/DurableMessageDirectionFilter' + - $ref: '#/components/parameters/DurableMessageNameFilter' + - $ref: '#/components/parameters/DurableMessageAfter' + - $ref: '#/components/parameters/DurableMessageLimit' responses: '200': description: Successful response content: application/json: schema: - $ref: '#/components/schemas/PaginatedDurableApprovals' + $ref: '#/components/schemas/DurableMessageList' '400': - description: A filter is invalid, or `from` is after `to` + description: A filter is invalid content: application/json: schema: $ref: '#/components/schemas/Error' - /projects/{id}/durable-approvals/stats: + '404': + description: Durable function or execution not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-functions/{functionId}/executions/{executionId}/message-waits: get: tags: - Durable Functions - summary: Get durable approval statistics + summary: List a durable execution's open message waits description: | - Counts the project's approvals by outcome over a time window, overall, - per workflow, and per day. The window defaults to the last 30 days and - can cover up to a year. An approval is counted on the day it was - requested. + Lists the waits an execution has open in `ctx.waitForMessage`, oldest + first, so you can see which message names it is waiting for. - Project access tokens can read statistics, including read-only ones. - operationId: getDurableApprovalStats + Project access tokens can list waits, including read-only ones. + operationId: listDurableMessageWaits security: - UserToken: [] - ProjectAccessToken: [] parameters: - $ref: '#/components/parameters/ProjectId' - - $ref: '#/components/parameters/DurableApprovalFunctionFilter' - - name: from - in: query - required: false - description: Start of the window. Defaults to 30 days before `to`. - schema: - type: string - format: date-time - - name: to - in: query - required: false - description: End of the window. Defaults to now. - schema: - type: string - format: date-time + - $ref: '#/components/parameters/DurableFunctionId' + - $ref: '#/components/parameters/DurableExecutionId' responses: '200': description: Successful response content: application/json: schema: - $ref: '#/components/schemas/DurableApprovalStats' - '400': - description: The window is invalid or longer than 366 days + $ref: '#/components/schemas/DurableMessageWaitList' + '404': + description: Durable function or execution not found content: application/json: schema: $ref: '#/components/schemas/Error' - /projects/{id}/durable-approvals/{approvalId}: - get: + /durable-messages/waits: + post: tags: - Durable Functions - summary: Get a durable approval + summary: Wait for a message from inside a workflow description: | - Returns one approval: what the workflow asked, the durable function - and execution that asked it, its deadline, and the decision once one - is made. + Registers a wait for a named message. The Volcano SDK calls this from + `ctx.waitForMessage`; applications do not call it directly. - Project access tokens can read approvals, including read-only ones. - operationId: getDurableApproval - security: - - UserToken: [] - - ProjectAccessToken: [] - parameters: - - $ref: '#/components/parameters/ProjectId' - - $ref: '#/components/parameters/DurableApprovalId' - responses: - '200': + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. When a message for the + name is already queued, the wait takes the oldest one at once. + + Retrying is safe: a request for a wait that is already registered + returns it with `200`. + operationId: registerDurableMessageWait + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterDurableMessageWaitRequest' + responses: + '200': + description: The wait was already registered + content: + application/json: + schema: + $ref: '#/components/schemas/DurableMessageWaitRegistration' + '201': + description: Wait registered + content: + application/json: + schema: + $ref: '#/components/schemas/DurableMessageWaitRegistration' + '400': + description: The request body is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: No running durable execution matches `execution_ref` + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + The wait cannot be registered now. `code` says why: + + - `callback_not_ready`: the workflow has not finished opening the + wait yet. Retry with a short backoff; the SDK does. + - `callback_closed`: the workflow is no longer waiting on it, or + it is an approval's. + - `execution_ended`: the execution has finished. + - `too_many_message_waits`: the execution already has the maximum + number of open waits. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: The request body is larger than 8 KiB + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: Too many requests from this execution or address + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: | + The wait could not be confirmed with the workflow right now. Retry + with backoff. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /durable-messages/sessions: + post: + tags: + - Durable Functions + summary: Open a messages session from inside a workflow + description: | + Opens the session a workflow publishes messages with. The Volcano SDK + calls this once per workflow context, on its first `ctx.publish`; + applications do not call it directly. + + As for a wait, `execution_ref` and `callback_id` are the credential. + The session's token is delivered to the workflow itself and never + appears in a response. + + Retrying is safe: a request for a session that is already registered + returns it with `200`. + operationId: openDurableMessageSession + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OpenDurableMessageSessionRequest' + responses: + '200': + description: The session was already registered + content: + application/json: + schema: + $ref: '#/components/schemas/DurableMessageSessionRegistration' + '201': + description: Session opened + content: + application/json: + schema: + $ref: '#/components/schemas/DurableMessageSessionRegistration' + '400': + description: The request body is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: No running durable execution matches `execution_ref` + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + The session cannot be opened now. `code` is `callback_not_ready`, + `callback_closed`, `execution_ended`, or + `too_many_message_sessions`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: The request body is larger than 8 KiB + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: Too many requests from this execution or address + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: | + The session could not be confirmed with the workflow right now. + Retry with backoff. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /durable-messages/publish: + post: + tags: + - Durable Functions + summary: Publish a message from inside a workflow + description: | + Publishes a named message from a running workflow. The Volcano SDK + calls this from `ctx.publish`; applications do not call it directly. + + The message is stored, listed with the execution's messages, and + delivered live to clients subscribed to the execution. Live delivery + counts against the project's Realtime messages. + + The SDK sends an `Idempotency-Key` that a rerun of the publishing step + repeats, so a step that runs again returns the first message with + `200` and publishes nothing. + operationId: publishDurableMessage + security: [] + parameters: + - name: X-Volcano-Durable-Session + in: header + required: true + description: The session token the workflow received when its session opened. + schema: + type: string + minLength: 1 + maxLength: 128 + - $ref: '#/components/parameters/DurableMessageIdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PublishDurableMessageRequest' + responses: + '200': + description: A message with this `Idempotency-Key` was already published; it is returned + content: + application/json: + schema: + $ref: '#/components/schemas/DurableMessage' + '201': + description: Message published + content: + application/json: + schema: + $ref: '#/components/schemas/DurableMessage' + '400': + description: The name, data or idempotency key is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: The session token is missing or not valid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + The message cannot be published. `code` is + `execution_not_running` or `too_many_published_messages`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: The request body is larger than 72 KiB + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /durable-approvals: + post: + tags: + - Durable Functions + summary: Request an approval from inside a workflow + description: | + Registers an approval request for a running durable execution. The + Volcano SDK calls this from `ctx.waitForApproval`; applications do not + call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. The approval belongs to + the project that owns the execution, and expires when the workflow's + own approval timeout does. + + Retrying is safe: a request for an approval that is already registered + returns it with `200`. + operationId: requestDurableApproval + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RequestDurableApprovalRequest' + responses: + '200': + description: The approval was already registered + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalRegistration' + '201': + description: Approval registered + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalRegistration' + '400': + description: The request body is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: No running durable execution matches `execution_ref` + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + The approval cannot be registered now. `code` says why: + + - `approval_not_ready`: the workflow has not finished opening the + approval yet. Retry with a short backoff; the SDK does. + - `approval_closed`: the workflow is no longer waiting on it. + - `execution_ended`: the execution has finished. + - `too_many_pending_approvals`: the execution already has the + maximum number of pending approvals. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: The request body is larger than 64 KiB + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: Too many approval requests from this execution or address + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: | + The approval could not be confirmed with the workflow right now. + Retry with backoff. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals: + get: + tags: + - Durable Functions + summary: List durable approvals + description: | + Lists the approvals durable workflows in the project have requested, + newest first. Pending approvals are the ones a workflow is waiting on; + decided, expired, and cancelled ones are kept for a year. + + Project access tokens can list approvals, including read-only ones. + operationId: listDurableApprovals + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Limit' + - name: status + in: query + required: false + description: Return only approvals in this status. + schema: + $ref: '#/components/schemas/DurableApprovalStatus' + - $ref: '#/components/parameters/DurableApprovalFunctionFilter' + - name: execution_id + in: query + required: false + description: Return only approvals requested by this durable execution. + schema: + type: string + format: uuid + - name: from + in: query + required: false + description: Return only approvals requested at or after this time. + schema: + type: string + format: date-time + - name: to + in: query + required: false + description: Return only approvals requested before this time. + schema: + type: string + format: date-time + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedDurableApprovals' + '400': + description: A filter is invalid, or `from` is after `to` + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals/stats: + get: + tags: + - Durable Functions + summary: Get durable approval statistics + description: | + Counts the project's approvals by outcome over a time window, overall, + per workflow, and per day. The window defaults to the last 30 days and + can cover up to a year. An approval is counted on the day it was + requested. + + Project access tokens can read statistics, including read-only ones. + operationId: getDurableApprovalStats + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableApprovalFunctionFilter' + - name: from + in: query + required: false + description: Start of the window. Defaults to 30 days before `to`. + schema: + type: string + format: date-time + - name: to + in: query + required: false + description: End of the window. Defaults to now. + schema: + type: string + format: date-time + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalStats' + '400': + description: The window is invalid or longer than 366 days + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals/{approvalId}: + get: + tags: + - Durable Functions + summary: Get a durable approval + description: | + Returns one approval: what the workflow asked, the durable function + and execution that asked it, its deadline, and the decision once one + is made. + + Project access tokens can read approvals, including read-only ones. + operationId: getDurableApproval + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableApprovalId' + responses: + '200': description: Successful response content: application/json: @@ -6637,7 +7194,12 @@ paths: schema: $ref: '#/components/schemas/Error' '409': - description: Conflict - ownership not verified, custom domain owned or reserved by another account, already in use, still detaching, or frontend already has a custom domain + description: 'Conflict - ownership not verified, custom domain owned or reserved by another account, already in use, still detaching, or frontend already has a custom domain. While a removed domain detaches and Volcano releases it (about a minute for BYOC, a few minutes for managed TLS), adding its hostname to any frontend, or any domain to its frontend, returns `code: custom_domain_releasing` and a `Retry-After` header; send the same request again after that many seconds. A release that will not finish within a few minutes returns the conflict without `code`.' + headers: + Retry-After: + description: Seconds to wait before sending the same request again. Sent only with code `custom_domain_releasing`. + schema: + type: integer content: application/json: schema: @@ -6714,128 +7276,326 @@ paths: - $ref: '#/components/parameters/Limit' responses: '200': - description: Successful response + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedFrontendDeployments' + '400': + description: Bad request - invalid identifier + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized - invalid or missing token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden - project ownership required + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Frontend not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/frontends/{frontendId}/usage: + get: + tags: + - Frontends + summary: Per-day request and error counts for a single frontend + description: | + Returns a zero-filled daily series of request counts and 5xx + error counts for one frontend, oldest first. Each entry is one + UTC day; missing days (no traffic recorded) come back as + `requests: 0, errors: 0` so the response always has exactly + `days` entries. + + Backs the Monitoring section on the frontend detail page in + the Volcano dashboard. `days` defaults to 30 and is capped at 90 to keep + the (frontend_id, day) index scan bounded. + operationId: getFrontendUsageHistory + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/FrontendId' + - name: days + in: query + description: Number of trailing days to return (1–90, default 30). + required: false + schema: + type: integer + minimum: 1 + maximum: 90 + default: 30 + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/FrontendUsageHistoryResponse' + '400': + description: Bad request - invalid identifier + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized - invalid or missing token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden - project ownership required + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Frontend not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/frontends/{frontendId}/function-routes: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/FrontendId' + get: + tags: + - Frontends + summary: List a Frontend's Function routes + operationId: listFrontendFunctionRoutes + security: + - UserToken: [] + - ProjectAccessToken: [] + responses: + '200': + description: Function routes ordered from most to least specific + content: + application/json: + schema: + $ref: '#/components/schemas/FrontendFunctionRouteList' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Project not owned by the caller + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Failed to list Function routes + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + tags: + - Frontends + summary: Route a Frontend path to an HTTP Function + operationId: createFrontendFunctionRoute + description: | + The Frontend and Function must belong to this Project. The Function must be a standard + Function with `visibility: public` and HTTP invocation mode, because requests through the + route carry no credential: Volcano does not check a token sent to a route path or add + `__volcano_auth`, so the Function authenticates its callers itself. A Function cannot leave `public` + while a route targets it. The route applies to every hostname that resolves to the + Frontend, including generated, custom-domain, preview, and local hostnames. + security: + - UserToken: [] + - ProjectAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFrontendFunctionRouteRequest' + responses: + '201': + description: Function route created + content: + application/json: + schema: + $ref: '#/components/schemas/FrontendFunctionRoute' + '400': + description: Invalid path or non-HTTP Function + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Project not owned by the caller + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Frontend or Function not found in the Project + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Path is already routed, the Frontend has reached its 64-route limit, or the Function is not public + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Failed to create Function route + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/frontends/{frontendId}/function-routes/{routeId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/FrontendId' + - $ref: '#/components/parameters/FrontendFunctionRouteId' + put: + tags: + - Frontends + summary: Replace a Frontend Function route + description: 'Replaces the route''s target, path, and prefix handling. The new target must meet the same rules as on create, including `visibility: public`.' + operationId: updateFrontendFunctionRoute + security: + - UserToken: [] + - ProjectAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFrontendFunctionRouteRequest' + responses: + '200': + description: Function route replaced content: application/json: schema: - $ref: '#/components/schemas/PaginatedFrontendDeployments' + $ref: '#/components/schemas/FrontendFunctionRoute' '400': - description: Bad request - invalid identifier + description: Invalid path or non-HTTP Function content: application/json: schema: $ref: '#/components/schemas/Error' '401': - description: Unauthorized - invalid or missing token + description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': - description: Forbidden - project ownership required + description: Project not owned by the caller content: application/json: schema: $ref: '#/components/schemas/Error' '404': - description: Frontend not found + description: Function route, Frontend, or Function not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Path is already routed, the Frontend has reached its 64-route limit, or the Function is not public content: application/json: schema: $ref: '#/components/schemas/Error' '500': - description: Internal server error + description: Failed to replace Function route content: application/json: schema: $ref: '#/components/schemas/Error' - /projects/{id}/frontends/{frontendId}/usage: - get: + delete: tags: - Frontends - summary: Per-day request and error counts for a single frontend - description: | - Returns a zero-filled daily series of request counts and 5xx - error counts for one frontend, oldest first. Each entry is one - UTC day; missing days (no traffic recorded) come back as - `requests: 0, errors: 0` so the response always has exactly - `days` entries. - - Backs the Monitoring section on the frontend detail page in - the Volcano dashboard. `days` defaults to 30 and is capped at 90 to keep - the (frontend_id, day) index scan bounded. - operationId: getFrontendUsageHistory + summary: Delete a Frontend Function route + operationId: deleteFrontendFunctionRoute security: - UserToken: [] - ProjectAccessToken: [] - parameters: - - $ref: '#/components/parameters/ProjectId' - - $ref: '#/components/parameters/FrontendId' - - name: days - in: query - description: Number of trailing days to return (1–90, default 30). - required: false - schema: - type: integer - minimum: 1 - maximum: 90 - default: 30 responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/FrontendUsageHistoryResponse' - '400': - description: Bad request - invalid identifier - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + '204': + description: Function route deleted '401': - description: Unauthorized - invalid or missing token + description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': - description: Forbidden - project ownership required + description: Project not owned by the caller content: application/json: schema: $ref: '#/components/schemas/Error' '404': - description: Frontend not found + description: Function route not found on the Frontend content: application/json: schema: $ref: '#/components/schemas/Error' '500': - description: Internal server error + description: Failed to delete Function route content: application/json: schema: $ref: '#/components/schemas/Error' - /projects/{id}/frontends/{frontendId}/function-routes: - parameters: - - $ref: '#/components/parameters/ProjectId' - - $ref: '#/components/parameters/FrontendId' + /projects/{id}/variable-environments: get: tags: - - Frontends - summary: List a Frontend's Function routes - operationId: listFrontendFunctionRoutes + - Variable Environments + summary: List variable Environments + operationId: listVariableEnvironments security: - UserToken: [] - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' responses: '200': - description: Function routes ordered from most to least specific + description: Variable Environments, with Global first and custom Environments ordered by name content: application/json: schema: - $ref: '#/components/schemas/FrontendFunctionRouteList' + $ref: '#/components/schemas/VariableEnvironmentList' '401': description: Unauthorized content: @@ -6849,41 +7609,40 @@ paths: schema: $ref: '#/components/schemas/Error' '500': - description: Failed to list Function routes + description: Failed to list variable Environments content: application/json: schema: $ref: '#/components/schemas/Error' post: tags: - - Frontends - summary: Route a Frontend path to an HTTP Function - operationId: createFrontendFunctionRoute - description: | - The Frontend and Function must belong to this Project. The Function must be a standard - Function with `visibility: public` and HTTP invocation mode, because requests through the - route carry no credential: Volcano does not check a token sent to a route path or add - `__volcano_auth`, so the Function authenticates its callers itself. A Function cannot leave `public` - while a route targets it. The route applies to every hostname that resolves to the - Frontend, including generated, custom-domain, preview, and local hostnames. + - Variable Environments + summary: Create a variable Environment + operationId: createVariableEnvironment + description: Creates a custom variable Environment. Global is reserved and already exists. A Project may have at most 100 custom Environments. security: - UserToken: [] - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/CreateFrontendFunctionRouteRequest' + $ref: '#/components/schemas/CreateVariableEnvironmentRequest' responses: '201': - description: Function route created + description: Variable Environment created content: application/json: schema: - $ref: '#/components/schemas/FrontendFunctionRoute' + $ref: '#/components/schemas/VariableEnvironment' '400': - description: Invalid path or non-HTTP Function + description: | + Invalid name, or the reserved Global name. Schema-invalid names + return the generic invalid-request error; a reserved name returns + `invalid_variable_environment`. content: application/json: schema: @@ -6901,34 +7660,76 @@ paths: schema: $ref: '#/components/schemas/Error' '404': - description: Frontend or Function not found in the Project + description: Project no longer exists content: application/json: schema: $ref: '#/components/schemas/Error' '409': - description: Path is already routed, the Frontend has reached its 64-route limit, or the Function is not public + description: | + A case-insensitive name conflict or the 100-custom-Environment + limit has been reached, or the Project is being deleted. Tell them + apart with `code`, which is `variable_environment_conflict`, + `variable_environment_limit_reached`, or `project_deleting`. content: application/json: schema: $ref: '#/components/schemas/Error' '500': - description: Failed to create Function route + description: Failed to create the variable Environment content: application/json: schema: $ref: '#/components/schemas/Error' - /projects/{id}/frontends/{frontendId}/function-routes/{routeId}: + /projects/{id}/variable-environments/{environmentId}: parameters: - $ref: '#/components/parameters/ProjectId' - - $ref: '#/components/parameters/FrontendId' - - $ref: '#/components/parameters/FrontendFunctionRouteId' - put: + - $ref: '#/components/parameters/VariableEnvironmentId' + get: tags: - - Frontends - summary: Replace a Frontend Function route - description: 'Replaces the route''s target, path, and prefix handling. The new target must meet the same rules as on create, including `visibility: public`.' - operationId: updateFrontendFunctionRoute + - Variable Environments + summary: Get a variable Environment + operationId: getVariableEnvironment + security: + - UserToken: [] + - ProjectAccessToken: [] + responses: + '200': + description: Variable Environment details + content: + application/json: + schema: + $ref: '#/components/schemas/VariableEnvironment' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Project not owned by the caller + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Variable Environment not found in this Project + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Failed to get the variable Environment + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + patch: + tags: + - Variable Environments + summary: Rename a variable Environment + operationId: renameVariableEnvironment + description: Renames a custom Environment without changing its stable ID. Global cannot be renamed. security: - UserToken: [] - ProjectAccessToken: [] @@ -6937,16 +7738,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/CreateFrontendFunctionRouteRequest' + $ref: '#/components/schemas/RenameVariableEnvironmentRequest' responses: '200': - description: Function route replaced + description: Variable Environment renamed content: application/json: schema: - $ref: '#/components/schemas/FrontendFunctionRoute' + $ref: '#/components/schemas/VariableEnvironment' '400': - description: Invalid path or non-HTTP Function + description: | + Invalid name, or the reserved Global name. Schema-invalid names + return the generic invalid-request error; a reserved name returns + `invalid_variable_environment`. content: application/json: schema: @@ -6964,34 +7768,39 @@ paths: schema: $ref: '#/components/schemas/Error' '404': - description: Function route, Frontend, or Function not found + description: Variable Environment not found in this Project content: application/json: schema: $ref: '#/components/schemas/Error' '409': - description: Path is already routed, the Frontend has reached its 64-route limit, or the Function is not public + description: | + A case-insensitive name conflict, an attempt to rename Global, or + a Project being deleted. Tell them apart with `code`, which is + `variable_environment_conflict`, + `variable_environment_immutable`, or `project_deleting`. content: application/json: schema: $ref: '#/components/schemas/Error' '500': - description: Failed to replace Function route + description: Failed to rename the variable Environment content: application/json: schema: $ref: '#/components/schemas/Error' delete: tags: - - Frontends - summary: Delete a Frontend Function route - operationId: deleteFrontendFunctionRoute + - Variable Environments + summary: Delete a variable Environment + operationId: deleteVariableEnvironment + description: Deletes a custom Environment. Global cannot be deleted. security: - UserToken: [] - ProjectAccessToken: [] responses: '204': - description: Function route deleted + description: Variable Environment deleted '401': description: Unauthorized content: @@ -7005,13 +7814,22 @@ paths: schema: $ref: '#/components/schemas/Error' '404': - description: Function route not found on the Frontend + description: Variable Environment not found in this Project + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + Global is protected, or the Project is being deleted. Tell them + apart with `code`, which is `variable_environment_immutable` or + `project_deleting`. content: application/json: schema: $ref: '#/components/schemas/Error' '500': - description: Failed to delete Function route + description: Failed to delete the variable Environment content: application/json: schema: @@ -10317,14 +11135,22 @@ paths: summary: Get auth user insights description: | Returns current and deleted auth-user totals, rolling 24-hour and 30-day - active users, and zero-filled signup, deletion, net-growth, and successful - sign-in counts for an inclusive UTC date range. Weeks start on Monday. - Signups remain counted after deletion; net growth is signups minus - deletions. Deleted users contribute to active counts until their last - activity leaves the rolling window. Token refreshes affect active users - but not the sign-in series. Collection starts at deployment, with signup - history backfilled from accounts still present then. Earlier hard-deleted - accounts cannot be recovered. + active users, and zero-filled daily activity and account-retention counts + for an inclusive UTC date range. Weeks start on Monday. + The existing deletions series counts accounts by deletion date; net_growth + remains signups minus deletions and is not affected by bans. + The banned_users, deleted_users, and available_users fields describe the + current state of users registered in each bucket. Banned users exclude + deleted users and expired bans. Available users are signups minus + banned_users minus deleted_users. Unban and expiry restore availability in + the original signup bucket. These current-state cohorts can change after + the requested dates have passed, without rewriting activity counts. + Sign-in counts remain grouped by session creation date. Deleted users + contribute to active counts until their last activity leaves the rolling + window. Token refreshes affect active users but not the sign-in series. + Signup history is backfilled from accounts present when collection was + deployed. Accounts hard-deleted before deletion-history collection cannot + be recovered. Current bans require no separate event-history collection. operationId: getAuthInsights security: - UserToken: [] @@ -10372,6 +11198,9 @@ paths: signups: 12 signins: 97 deletions: 3 + banned_users: 2 + deleted_users: 3 + available_users: 7 net_growth: 9 is_partial: true '400': @@ -11074,7 +11903,8 @@ paths: `subject` field is ignored. Sending `subject` alone (no bodies) is rejected with 400 to avoid a silently-dropped subject or a blank message. Also rejects with 400 if - `email_enabled=false` or `smtp_host` is empty. + `email_enabled=false`, `smtp_host` is empty, or the saved + `smtp_password` can't be read and must be set again. operationId: testEmailConfig security: - UserToken: [] @@ -14632,6 +15462,45 @@ paths: schema: type: string example: OK + /capabilities: + get: + tags: + - System + summary: List capabilities + description: | + Lists which product capabilities accept new work in this environment. Check it + to decide what to offer, instead of calling a route to see whether it fails. + + | ID | Covers | + | --- | --- | + | `sandboxes` | The Sandbox API: presets, templates, sessions, and executions | + | `sandboxes.sessions` | Starting and resuming sessions, and one-shot executions | + | `sandboxes.custom_templates` | Deploying a custom template; its validation waits while `sandboxes.sessions` is unavailable | + + A capability missing from the list is unavailable, and a capability under + another, such as `sandboxes.sessions`, is unavailable whenever its parent is. + While a capability is unavailable, requests that start new work answer `404` + or `400` with code `feature_unavailable`; refresh this list when you receive + that code. Reading, deleting, and terminating what already exists keep + working unless the whole feature is absent from the environment. Every caller + in an environment gets the same answer. + operationId: listCapabilities + security: [] + responses: + '200': + description: Capabilities in this environment + content: + application/json: + schema: + $ref: '#/components/schemas/CapabilityList' + example: + data: + - id: sandboxes + status: available + - id: sandboxes.sessions + status: available + - id: sandboxes.custom_templates + status: unavailable /openapi.json: get: tags: @@ -15001,6 +15870,14 @@ components: type: string format: uuid description: Frontend deployment ID + VariableEnvironmentId: + name: environmentId + in: path + required: true + schema: + type: string + format: uuid + description: Variable Environment ID FrontendId: name: frontendId in: path @@ -15060,6 +15937,62 @@ components: Only approvals requested by this durable function, given by id or by name. A name also matches approvals from a deleted function of that name. + DurableMessageIdempotencyKey: + name: Idempotency-Key + in: header + required: false + description: | + Makes a send safe to retry. A repeat with a key the execution has + already seen returns the first message and sends nothing. Up to 255 + characters. + schema: + type: string + minLength: 1 + maxLength: 255 + DurableClientToken: + name: X-Volcano-Client-Token + in: header + required: false + description: | + The `client_token` the execution's start returned. Lets an anon key, + or a user who did not start the execution, reach it. + schema: + type: string + maxLength: 128 + DurableMessageDirectionFilter: + name: direction + in: query + required: false + description: Return only messages travelling this way. + schema: + $ref: '#/components/schemas/DurableMessageDirection' + DurableMessageNameFilter: + name: name + in: query + required: false + description: Return only messages with this name. + schema: + $ref: '#/components/schemas/DurableMessageName' + DurableMessageAfter: + name: after + in: query + required: false + description: Return only messages whose `seq` is greater than this. + schema: + type: integer + format: int64 + minimum: 0 + default: 0 + DurableMessageLimit: + name: limit + in: query + required: false + description: Most messages to return. + schema: + type: integer + minimum: 1 + maximum: 500 + default: 100 Limit: name: limit in: query @@ -15421,6 +16354,8 @@ components: sandbox_id: type: string format: uuid + nullable: true + description: Explicit template used to create the session. Null when created directly from a preset. state: type: string enum: @@ -15451,6 +16386,8 @@ components: expires_at: type: string format: date-time + nullable: true + description: Absolute VM expiry. Null means unlimited in local mode. required: - id - project_id @@ -15472,6 +16409,7 @@ components: type: string format: uuid memory_mb: + description: Baseline memory in MiB. Cloud HOBBY supports 1024; SUPERAGENT supports 1024 or 2048. Omission uses the preset or template default; a template requiring more than the plan permits is rejected. Local sessions are exempt from cloud plan limits. type: integer enum: - 1024 @@ -15481,15 +16419,10 @@ components: pattern: ^(aws-)?[a-z]{2}(-[a-z]+)+-[0-9]+$ description: Region such as `us-east-1`. Region IDs issued by earlier versions of the API are still accepted. max_duration_seconds: - type: integer - minimum: 30 - maximum: 28800 - description: Inherits the template TTL when omitted (3600 seconds for a new template). - idle_timeout_seconds: type: integer minimum: 0 - maximum: 28800 - description: Inherits the template idle timeout when omitted, capped at the session duration. Set zero to disable idle timeout. + maximum: 2147483647 + description: Inherits the template TTL when omitted (cloud default 3600 seconds; local default 0, unlimited). Cloud HOBBY accepts 30–3600 seconds; SUPERAGENT accepts 30–28800 seconds. Inherited template defaults are clamped to the plan limit; explicit values above it are rejected. Local accepts 0 for unlimited or a positive lifetime. required: - region oneOf: @@ -15513,9 +16446,9 @@ components: maxLength: 65536 timeout_seconds: type: integer - minimum: 1 - maximum: 3600 - default: 60 + minimum: 0 + maximum: 2147483647 + description: Command execution time from process start. Cloud defaults to 60 seconds and accepts 1–28800; local defaults to 0 (unlimited) and accepts nonnegative values. VM expiry always takes precedence. Cloud synchronous requests must return within the public connection idle limit (1000 seconds); use a session with a background process and short polling requests for longer work. environment: type: object additionalProperties: @@ -15534,6 +16467,7 @@ components: type: string format: uuid memory_mb: + description: Baseline memory in MiB. Cloud HOBBY supports 1024; SUPERAGENT supports 1024 or 2048. Omission uses the preset or template default; a template requiring more than the plan permits is rejected. Local sessions are exempt from cloud plan limits. type: integer enum: - 1024 @@ -15542,15 +16476,20 @@ components: type: string pattern: ^(aws-)?[a-z]{2}(-[a-z]+)+-[0-9]+$ description: Region such as `us-east-1`. Region IDs issued by earlier versions of the API are still accepted. + max_duration_seconds: + type: integer + minimum: 0 + maximum: 2147483647 + description: Absolute VM lifetime including startup, bounded by environment capacity policy. Inherits the template TTL when omitted (cloud default 3600 seconds; local default 0, unlimited). Cloud HOBBY accepts 30–3600 seconds; SUPERAGENT accepts 30–28800 seconds. Inherited template defaults are clamped to the plan limit; explicit values above it are rejected. Local accepts 0 for unlimited or a positive lifetime. The VM is reclaimed early when the command finishes. command: type: string minLength: 1 maxLength: 65536 timeout_seconds: type: integer - minimum: 1 - maximum: 60 - default: 60 + minimum: 0 + maximum: 2147483647 + description: Command execution time from process start. Cloud defaults to 60 seconds and accepts 1–28800; local defaults to 0 (unlimited) and accepts nonnegative values. VM expiry always takes precedence. Cloud synchronous requests must return within the public connection idle limit (1000 seconds); use a session with a background process and short polling requests for longer work. environment: type: object additionalProperties: @@ -15793,16 +16732,46 @@ components: pagination: $ref: '#/components/schemas/SandboxPagination' required: - - data - - pagination - SandboxPresetList: + - data + - pagination + SandboxPresetList: + type: object + additionalProperties: false + properties: + data: + type: array + items: + $ref: '#/components/schemas/SandboxPreset' + required: + - data + Capability: + type: object + additionalProperties: false + description: A product capability and whether it accepts new work in this environment. + properties: + id: + type: string + description: Stable capability ID, such as `sandboxes`, `sandboxes.sessions`, or `sandboxes.custom_templates`. New IDs may appear at any time. + example: sandboxes.sessions + status: + $ref: '#/components/schemas/CapabilityStatus' + required: + - id + - status + CapabilityStatus: + type: string + enum: + - available + - unavailable + description: '`available` means the capability accepts new work; a temporary outage still answers `503`. `unavailable` means requests that start new work answer `404` or `400` with code `feature_unavailable`. Treat any other value as `unavailable`.' + CapabilityList: type: object additionalProperties: false properties: data: type: array items: - $ref: '#/components/schemas/SandboxPreset' + $ref: '#/components/schemas/Capability' required: - data SandboxCapacity: @@ -15906,6 +16875,10 @@ components: type: integer description: Refresh token lifetime in seconds default: 2592000 + refresh_token_reuse_interval: + type: integer + description: A refresh within this many seconds of the refresh token being issued returns the same refresh token instead of rotating it, so concurrent refreshes from several tabs all succeed + default: 10 inactivity_timeout: type: integer description: Force re-login after inactivity (seconds, 0=never) @@ -15954,6 +16927,10 @@ components: type: integer description: Refreshes per hour per IP default: 1000 + rate_limit_password_reset: + type: integer + description: Password reset requests per hour per IP. Unlike the other limits, 0 applies the default of 10 instead of turning the limit off. + default: 10 cors_enabled: type: boolean default: false @@ -16090,6 +17067,12 @@ components: the built-in managed device page. Lets a CLI surface the project's own RFC 8628 approval page. Empty falls back to the managed page. example: https://app.acme.com/device + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time required: - password_policy AuthInsightsInterval: @@ -16139,6 +17122,21 @@ components: format: int64 minimum: 0 description: Successful session creations during the bucket. + banned_users: + type: integer + format: int64 + minimum: 0 + description: Users registered in this bucket who are currently banned. Excludes deleted users, lifted bans, expired temporary bans, and internal test identities. + deleted_users: + type: integer + format: int64 + minimum: 0 + description: Users registered in this bucket who are now deleted, counted once across soft and hard deletion. Grouped by signup date, not deletion date. + available_users: + type: integer + format: int64 + minimum: 0 + description: Currently available users registered in this bucket (signups minus banned_users minus deleted_users). Unban or expiry restores availability in the original signup bucket. This measures account availability, not return activity. deletions: type: integer format: int64 @@ -16147,7 +17145,7 @@ components: net_growth: type: integer format: int64 - description: Registrations minus deletions during this bucket. May be negative. + description: Registrations minus deletions during this bucket. May be negative. Bans do not affect this value. is_partial: type: boolean description: Whether the requested window or observation time clips this bucket. @@ -16157,6 +17155,9 @@ components: - signins - is_partial - deletions + - banned_users + - deleted_users + - available_users - net_growth AuthInsightsSummary: type: object @@ -17068,6 +18069,67 @@ components: required: - name - value + VariableEnvironment: + type: object + description: Stable identity for a Project-owned variable Environment. + properties: + id: + type: string + format: uuid + project_id: + type: string + format: uuid + name: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + minLength: 1 + maxLength: 64 + is_global: + type: boolean + description: Whether this is the reserved Global Environment. + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - project_id + - name + - is_global + - created_at + - updated_at + VariableEnvironmentList: + type: object + properties: + data: + type: array + maxItems: 101 + items: + $ref: '#/components/schemas/VariableEnvironment' + required: + - data + CreateVariableEnvironmentRequest: + type: object + additionalProperties: false + properties: + name: + type: string + description: Leading and trailing ASCII whitespace is trimmed before validating 1–64 ASCII letters, digits, underscores, or hyphens. Global is reserved. + pattern: ^[ \t\n\f\r]*[A-Za-z0-9_-]{1,64}[ \t\n\f\r]*$ + required: + - name + RenameVariableEnvironmentRequest: + type: object + additionalProperties: false + properties: + name: + type: string + description: Leading and trailing ASCII whitespace is trimmed before validating 1–64 ASCII letters, digits, underscores, or hyphens. Global is reserved. + pattern: ^[ \t\n\f\r]*[A-Za-z0-9_-]{1,64}[ \t\n\f\r]*$ + required: + - name Database: type: object description: | @@ -18714,6 +19776,10 @@ components: Paths on this frontend that forward to a public function, ordered from most to least specific. Anyone who can load the frontend can reach the target function at these paths without a credential. + last_deployed_at: + type: string + format: date-time + description: Recorded completion time of the most recent deploy or redeploy with status active. Omitted when no such deployment has a recorded completion time. Site traffic, pending deployments, degraded deployments, failed deployments, and deletion do not change this value. last_invoked_at: type: string format: date-time @@ -18886,6 +19952,7 @@ components: description: Failure category, present only when managed TLS setup has failed. Current values are provider, certificate, ownership, and internal; ownership means another account has already claimed the hostname through ownership verification. Treat unrecognized values as internal. verification_records: type: array + description: DNS records to publish now. For a managed domain whose hostname the account already owns, the create response names the `_acme-challenge` CNAME that authorizes certificate issuance and renewal. Otherwise it names the `_volcano` TXT record that proves ownership of the hostname's registrable domain, and reads return the CNAME once Volcano sees that record. Usually empty for BYOC. items: $ref: '#/components/schemas/FrontendDomainVerificationRecord' required_routing_record: @@ -19022,7 +20089,7 @@ components: - type - value FrontendCustomDomainConflictError: - description: 'Domain ownership conflict. With `code: ownership_verification_required`, the account has not proven it owns the domain: publish `required_record` in DNS and send the same request again. The retry succeeds once Volcano can see the record. Other conflicts omit both fields.' + description: 'Domain ownership conflict. With `code: ownership_verification_required`, the account has not proven it owns the domain: publish `required_record` in DNS and send the same request again. The retry succeeds once Volcano can see the record. With `code: custom_domain_releasing`, a removed domain still holds the hostname or the frontend while Volcano releases it: send the same request again after the response''s `Retry-After` seconds. A release that will not finish within a few minutes is a plain conflict. Other conflicts omit both fields.' allOf: - $ref: '#/components/schemas/Error' - type: object @@ -19567,6 +20634,12 @@ components: type: string format: date-time description: Present once the execution has reached a terminal status. + client_token: + type: string + description: | + Lets a browser read and send the execution's messages. Returned only + by the start that created the execution, never by a repeat under the + same name or by a read. Send it as `X-Volcano-Client-Token`. required: - id - function_id @@ -20374,12 +21447,19 @@ components: at times the time it spent running, in megabyte-seconds; the allowance for it is published in gigabyte-seconds, which is 1024 of these. "Sandbox Running (MiB-Seconds)", "Sandbox Suspended (Seconds)", and - "Sandbox Uncertain (MiB-Seconds)" are preview-only usage; they do not - debit credits. Running reports configured memory times observed runtime, - not measured CPU or elastic memory consumption. Uncertain includes - observation gaps and unconfirmed state transitions. Suspended reports - duration, not snapshot size. Local sessions do not contribute. - Totals are rounded down after aggregating millisecond measurements. + "Sandbox Uncertain (MiB-Seconds)" report cloud Sandbox usage. Running + reports configured baseline memory times confirmed runtime, not measured + CPU or elastic memory consumption. For sessions admitted after the pricing + release, confirmed running time consumes the account's runtime allowance; + SUPERAGENT overage debits credits according to the account's pricing terms. + Suspended time and uncertain usage do not consume runtime allowance or + debit credits. Uncertain includes observation gaps and unconfirmed state + transitions. Suspended reports duration, not snapshot size; snapshot costs + are included in runtime pricing. Local and platform image-validation + sessions do not contribute. Existing preview sessions remain unbilled, + and preview history is never charged retroactively. Displayed totals are + rounded down after aggregating millisecond measurements; billing uses + unrounded measurements in the monthly account billing cycle. "Database Storage (Bytes)" is a current observed gauge, not a cumulative counter. It is the sum of the latest samples exposed as `storage_bytes` by the project's database list, so it includes what each @@ -20862,9 +21942,14 @@ components: type: string maxLength: 4000 details: + x-go-type: json.RawMessage + x-go-type-skip-optional-pointer: true description: | - Any JSON value to show the person deciding. The whole request is - limited to 64 KiB. + Any JSON value to show the person deciding. A number's exponent must + be between -324 and 324, and a number too large or too precise to + store is refused with `400`. Numbers are read back written out in + full, so the exponents' absolute values may total at most 65,536. + The whole request is limited to 64 KiB. required: - execution_ref - callback_id @@ -20896,6 +21981,209 @@ components: - limit - total - has_more + DurableMessageName: + type: string + minLength: 1 + maxLength: 237 + pattern: ^[ -~]+$ + description: | + The name a workflow waits on or publishes under: up to 237 printable + ASCII characters. + DurableMessageDirection: + type: string + enum: + - in + - out + description: | + `in` is a message sent to the execution. `out` is one the execution + published. + DurableMessage: + type: object + description: A message sent to, or published by, a durable execution. + properties: + id: + type: string + format: uuid + execution_id: + type: string + format: uuid + direction: + $ref: '#/components/schemas/DurableMessageDirection' + seq: + type: integer + format: int64 + description: | + Orders the execution's messages in both directions. It increases + with every message but can skip numbers. Pass the last one you read + as `after` to read what came next. + name: + type: string + data: + x-go-type: json.RawMessage + description: The JSON value sent or published, or null. + sender: + $ref: '#/components/schemas/DurableMessageSender' + status: + $ref: '#/components/schemas/DurableMessageStatus' + delivered_at: + type: string + format: date-time + nullable: true + description: When the execution received the message. Null until then, and for `out` messages. + sent_at: + type: string + format: date-time + required: + - id + - execution_id + - direction + - seq + - name + - data + - sender + - status + - delivered_at + - sent_at + DurableMessageList: + type: object + properties: + data: + type: array + description: Messages after `after`, oldest first. + items: + $ref: '#/components/schemas/DurableMessage' + has_more: + type: boolean + description: | + Whether more messages follow. Read them with `after` set to the + last message's `seq`. + required: + - data + - has_more + DurableMessageWait: + type: object + description: A wait an execution has open for a message. + properties: + id: + type: string + format: uuid + name: + type: string + status: + $ref: '#/components/schemas/DurableMessageWaitStatus' + expires_at: + type: string + format: date-time + nullable: true + description: When the wait times out. Null when the workflow set no timeout. + created_at: + type: string + format: date-time + required: + - id + - name + - status + - expires_at + - created_at + DurableMessageWaitList: + type: object + properties: + data: + type: array + description: Open waits, oldest first. + items: + $ref: '#/components/schemas/DurableMessageWait' + required: + - data + SendDurableMessageRequest: + type: object + properties: + name: + $ref: '#/components/schemas/DurableMessageName' + data: + x-go-type: json.RawMessage + x-go-type-skip-optional-pointer: true + description: Any JSON value, up to 64 KiB. Omitted means null. + required: + - name + additionalProperties: false + RegisterDurableMessageWaitRequest: + type: object + properties: + execution_ref: + type: string + minLength: 1 + maxLength: 2048 + description: Opaque reference to the running execution, supplied by the SDK. + callback_id: + type: string + minLength: 1 + maxLength: 1024 + description: Opaque wait reference, supplied by the SDK. + name: + $ref: '#/components/schemas/DurableMessageName' + required: + - execution_ref + - callback_id + - name + additionalProperties: false + DurableMessageWaitRegistration: + type: object + description: The registered wait, as the waiting workflow sees it. + properties: + id: + type: string + format: uuid + status: + $ref: '#/components/schemas/DurableMessageWaitStatus' + expires_at: + type: string + format: date-time + nullable: true + required: + - id + - status + - expires_at + OpenDurableMessageSessionRequest: + type: object + properties: + execution_ref: + type: string + minLength: 1 + maxLength: 2048 + description: Opaque reference to the running execution, supplied by the SDK. + callback_id: + type: string + minLength: 1 + maxLength: 1024 + description: Opaque session reference, supplied by the SDK. + required: + - execution_ref + - callback_id + additionalProperties: false + DurableMessageSessionRegistration: + type: object + description: | + The registered session. Its token reaches the workflow through the + workflow itself, never in this response. + properties: + id: + type: string + format: uuid + required: + - id + PublishDurableMessageRequest: + type: object + properties: + name: + $ref: '#/components/schemas/DurableMessageName' + data: + x-go-type: json.RawMessage + x-go-type-skip-optional-pointer: true + description: Any JSON value, up to 64 KiB. Omitted means null. + required: + - name + additionalProperties: false PaginatedProjectCustomDomains: type: object properties: @@ -23007,10 +24295,17 @@ components: type: integer refresh_token_lifetime: type: integer + refresh_token_reuse_interval: + type: integer inactivity_timeout: type: integer max_session_duration: type: integer + platform_token_ttl: + type: integer + description: TTL in seconds for platform tokens minted via `/auth/platform/exchange`. From 3600 (1 hour) to 31536000 (365 days). + minimum: 3600 + maximum: 31536000 min_password_length: type: integer minimum: 15 @@ -23035,6 +24330,19 @@ components: type: integer rate_limit_token_refresh: type: integer + rate_limit_password_reset: + type: integer + description: Password reset requests per hour per IP. Unlike the other limits, 0 applies the default of 10 instead of turning the limit off. + cors_enabled: + type: boolean + cors_allowed_origins: + type: array + description: Replaces the allowed origins list. + items: + type: string + example: + - https://myapp.com + - http://localhost:3000 cors_allow_credentials: type: boolean cors_max_age: @@ -23673,16 +24981,11 @@ components: type: integer minimum: 1 maximum: 65532 - idle_timeout_seconds: - type: integer - minimum: 0 - maximum: 28800 - description: Default idle timeout for new sessions when the caller omits it. ttl_seconds: type: integer - minimum: 30 - maximum: 28800 - description: Default absolute lifetime for new sessions when the caller omits it. + minimum: 0 + maximum: 2147483647 + description: Default absolute lifetime for new sessions when the caller omits it. Cloud templates accept 30–28800 seconds; inherited lifetimes are clamped to the session plan limit (3600 for HOBBY, 28800 for SUPERAGENT). Local accepts 0 for unlimited or a positive lifetime. DatabaseQueryPerformanceDatabase: type: object properties: @@ -23812,6 +25115,42 @@ components: - elapsed_seconds - phases - updated_at + DurableMessageSender: + type: object + description: Who sent the message. + properties: + type: + type: string + enum: + - service_key + - user + - anon + - project + - execution + description: | + The credential the message was sent with. `project` is the + project's owner or one of its access tokens, and `execution` is the + execution itself publishing. + user_id: + type: string + nullable: true + description: The auth user who sent it. Null unless `type` is `user`. + required: + - type + - user_id + DurableMessageStatus: + type: string + enum: + - queued + - delivering + - delivered + - dropped + - published + description: | + An `in` message is `queued` until a wait for its name takes it, + `delivering` while it is handed over, and `delivered` once the + execution has it. It is `dropped` if the execution ended before any + wait took it. Every `out` message is `published`. LogDeploymentRequestSelector: type: object description: Deployment log selector for deployable resources. @@ -23942,6 +25281,19 @@ components: description: Deployment stage that produced the log event, when available. required: - id + DurableMessageWaitStatus: + type: string + enum: + - waiting + - matched + - delivered + - expired + - cancelled + description: | + `waiting` until a message for its name arrives, `matched` while that + message is handed over, then `delivered`. `expired` means the wait's + timeout passed first, and `cancelled` that the execution stopped + waiting. DurableApprovalFunction: type: object description: | diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index f6521597..13494207 100755 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -32,6 +32,8 @@ "invoke_function.py", "list_durable_approvals.py", "list_durable_executions.py", + "list_durable_messages.py", + "list_durable_messages_from_application.py", "list_storage_objects.py", "move_storage_object.py", "query_database_select.py", @@ -39,6 +41,8 @@ "renew_project_lock.py", "resolve_function_for_invocation.py", "search_project_logs.py", + "send_durable_message.py", + "send_durable_message_from_application.py", "start_durable_execution_from_application.py", "stop_durable_execution.py", "update_storage_object_visibility.py", diff --git a/src/volcano_sdk/__init__.py b/src/volcano_sdk/__init__.py index 79596c0e..48b33d3c 100644 --- a/src/volcano_sdk/__init__.py +++ b/src/volcano_sdk/__init__.py @@ -1,5 +1,6 @@ """Public Volcano SDK facade.""" +from ._durable_messages import DurableMessageSubscription from ._lock_guard import LockGuard from .client import VolcanoClient from .connection_string import database_connection_string @@ -37,6 +38,14 @@ DurableExecutionFailure, DurableExecutionPage, DurableExecutionStatus, + DurableMessage, + DurableMessageDirection, + DurableMessageListOptions, + DurableMessagePage, + DurableMessageSender, + DurableMessageSendOptions, + DurableMessageStatus, + DurableMessageTargetOptions, EmailChangeResult, FunctionResponse, LinkedOAuthProvider, @@ -89,6 +98,15 @@ "DurableExecutionFailure", "DurableExecutionPage", "DurableExecutionStatus", + "DurableMessage", + "DurableMessageDirection", + "DurableMessageListOptions", + "DurableMessagePage", + "DurableMessageSendOptions", + "DurableMessageSender", + "DurableMessageStatus", + "DurableMessageSubscription", + "DurableMessageTargetOptions", "EmailChangeResult", "FunctionResponse", "LinkedOAuthProvider", diff --git a/src/volcano_sdk/_durable_approval_registration.py b/src/volcano_sdk/_durable_approval_registration.py index fd939c5b..18fb401d 100644 --- a/src/volcano_sdk/_durable_approval_registration.py +++ b/src/volcano_sdk/_durable_approval_registration.py @@ -2,53 +2,31 @@ from __future__ import annotations -import asyncio -import json -import os import re -from collections.abc import Mapping -from dataclasses import dataclass from datetime import datetime -from http import HTTPMethod -from time import monotonic, sleep -from typing import TYPE_CHECKING, TypeGuard - -import httpx - +from typing import TYPE_CHECKING + +from ._durable_callback import ( + callback_fields, + has_nul, + is_mapping, + operation_name, + text, +) +from ._durable_platform_request import PlatformRequest, encode, post_to_platform from ._durable_response import json_value from ._durable_results import ApprovalDecision -from ._transport_response import ( - invoke_async, - plain_json, - response_payload, - unparsed_response, -) -from ._transport_types import HTTP_CREATED, HTTP_OK, decode_json -from .errors import ( - ConflictError, - NotFoundError, - RateLimitedError, - ServerError, - TransportError, - VolcanoError, -) +from ._transport_response import plain_json from .models import DurableApprovalDecider if TYPE_CHECKING: - from ._transport_types import TransportResponse + from collections.abc import Mapping + from .models import JSONValue -# 0.5 s doubling to a 5 s cap, within the deadline: long enough to outlast the -# moment the platform has not yet seen the execution start or the callback open. -_RETRY_DELAYS = (0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0) -_DEADLINE_SECONDS = 30.0 -_REQUEST_TIMEOUT_SECONDS = 10.0 _NOT_READY = "approval_not_ready" _CLOSED = "approval_closed" -_HEADERS = {"Content-Type": "application/json"} _MAX_BODY_BYTES = 65536 -# Volcano answers in a few hundred bytes; more is not an answer worth reading. -_MAX_ANSWER_BYTES = 65536 # The callback id is assigned once the callback opens, after the size check; # the spec allows it 1024 characters. _LONGEST_CALLBACK_ID = "x" * 1024 @@ -57,41 +35,15 @@ "JSON; send less in details" ) _INVALID_DECISION = "Expected a complete approval decision" -_API_URL_VARIABLE = "VOLCANO_PLATFORM_API_URL" -_MISSING_API_URL = ( - "wait_for_approval() needs VOLCANO_PLATFORM_API_URL. Volcano sets it on " - "deployed durable functions; set it yourself to run the handler elsewhere." -) _INVALID_DETAILS = ( "details must be JSON-serializable: mappings with string keys, lists, " "tuples, strings, finite numbers, booleans, and None" ) -# The limits on what the approval shows the person deciding. The name is also -# the runtime's operation name, which allows 256 printable ASCII characters, -# and the runtimes add suffixes of up to 19 characters to it; every SDK holds -# names to the same limit. -_TEXT_LIMITS = {"name": 237, "title": 200, "description": 4000} -_NOT_PRINTABLE_ASCII = "name must be printable ASCII" -_NUL = "\0" +# The limits on what the approval shows the person deciding. +_TEXT_LIMITS = {"title": 200, "description": 4000} EXPIRED = ApprovalDecision(approved=False, status="expired") -def platform_api_url() -> str: - """Read the Volcano API address the platform gives durable functions. - - Returns: - The API base URL. - - Raises: - RuntimeError: The variable is unset or empty. - - """ - url = os.environ.get(_API_URL_VARIABLE, "").strip() - if not url: - raise RuntimeError(_MISSING_API_URL) - return url - - def approval_body( name: object, title: object, description: object, details: object ) -> dict[str, JSONValue]: @@ -102,7 +54,7 @@ def approval_body( """ body: dict[str, JSONValue] = { - "name": _name(name), + "name": operation_name(name), "title": _text(title, "title", required=True), } if description is not None: @@ -112,57 +64,18 @@ def approval_body( return body -def _name(value: object) -> str: - name = _text(value, "name", required=True) - if not (name.isascii() and name.isprintable()): - raise ValueError(_NOT_PRINTABLE_ASCII) - return name - - def _details(value: object) -> JSONValue: # A copy, so a change the caller makes while the callback opens cannot # slip past the checks. details = plain_json(json_value(value, _INVALID_DETAILS)) - if _has_nul(details): + if has_nul(details): message = "details must not contain NUL characters" raise ValueError(message) return details -def _has_nul(value: JSONValue) -> bool: - if isinstance(value, str): - return _NUL in value - if isinstance(value, dict): - return any(_NUL in key or _has_nul(item) for key, item in value.items()) - if isinstance(value, list): - return any(_has_nul(item) for item in value) - return False - - def _text(value: object, field: str, *, required: bool) -> str: - if not isinstance(value, str): - message = f"{field} must be a string" - raise TypeError(message) - try: - _ = value.encode() - except UnicodeEncodeError as error: - message = f"{field} must be JSON-serializable: it contains a surrogate" - raise TypeError(message) from error - if required and not value.strip(): - message = f"{field} must not be empty" - raise ValueError(message) - return _recordable(value, field) - - -def _recordable(value: str, field: str) -> str: - limit = _TEXT_LIMITS[field] - if len(value) > limit: - message = f"{field} must be at most {limit} characters" - raise ValueError(message) - if _NUL in value: - message = f"{field} must not contain NUL characters" - raise ValueError(message) - return value + return text(value, field, limit=_TEXT_LIMITS[field], required=required) def check_size(body: Mapping[str, JSONValue]) -> None: @@ -173,154 +86,25 @@ def check_size(body: Mapping[str, JSONValue]) -> None: can assign, would encode to more than 64 KiB. """ - if len(_encode({**body, "callback_id": _LONGEST_CALLBACK_ID})) > _MAX_BODY_BYTES: + if len(encode({**body, "callback_id": _LONGEST_CALLBACK_ID})) > _MAX_BODY_BYTES: raise ValueError(_TOO_LARGE) -def _encode(body: Mapping[str, JSONValue]) -> bytes: - return json.dumps( - plain_json(body), ensure_ascii=False, separators=(",", ":"), allow_nan=False - ).encode() - - def register_approval(api_url: str, body: Mapping[str, JSONValue]) -> None: """Ask Volcano to record the approval and hold the callback for a person. - The route takes no credential: the execution reference and callback id in - the body are what Volcano checks against the running execution. - - What can clear on its own is retried until a 30 second deadline: no - answer, an execution or callback Volcano cannot see yet, throttling, and - server failures. An approval that already closed, normally because its - timeout passed first, needs no registration: the callback's own outcome - is what the execution resumes with. - - Each attempt, from connecting to the last byte of the answer, ends within - 10 seconds or what is left of the deadline, whichever is sooner. - - Raises: - VolcanoError: Volcano refused the approval, or kept failing until the - deadline passed or the retries ran out. - + The execution reference and callback id in the body are what Volcano + checks against the running execution. An approval that already closed, + normally because its timeout passed first, needs no registration: the + callback's own outcome is what the execution resumes with. A refusal, or a + failure that outlasts the retries, raises its `VolcanoError`. """ - url = f"{api_url.removesuffix('/')}/durable-approvals" - content = _encode(body) - deadline = monotonic() + _DEADLINE_SECONDS - timeout = _REQUEST_TIMEOUT_SECONDS - for delay in _RETRY_DELAYS: - try: - _attempt(url, content, timeout) - except VolcanoError as error: - next_timeout = _next_timeout(error, delay, deadline) - if next_timeout is None: - raise - timeout = next_timeout - else: - return - _attempt(url, content, timeout) - - -def _attempt(url: str, content: bytes, timeout: float) -> None: - response = _send(url, content, timeout) - if response.status_code == HTTP_CREATED: - return - try: - _ = response_payload(response, HTTP_OK) - except ConflictError as error: - if error.code != _CLOSED: - raise - - -def _send(url: str, content: bytes, timeout: float) -> TransportResponse: - # httpx times each network read on its own, restarting with every chunk, - # so only cancelling the request bounds the attempt as a whole; its own - # timeouts still keep any one phase within it. The runtime calls the - # submitter on a thread without an event loop. asyncio.run() would wait on - # its way out for a host lookup that cancelling leaves running in the - # loop's executor; closing the loop leaves it behind instead. - client = httpx.AsyncClient(timeout=timeout) - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete( - asyncio.wait_for(invoke_async(_exchange, client, url, content), timeout) - ) - except TimeoutError as error: - raise _no_answer(timeout) from error - except TransportError as error: - # The client's timeouts spend the same budget. When a stalled loop - # fires both at once, Python 3.11's wait_for reports the client's. - if isinstance(error.__cause__, httpx.TimeoutException): - raise _no_answer(timeout) from error.__cause__ - raise - finally: - loop.run_until_complete(loop.shutdown_asyncgens()) - loop.close() - - -def _no_answer(timeout: float) -> TransportError: - return TransportError(f"Volcano did not answer within {timeout:g} seconds") - - -async def _exchange( - client: httpx.AsyncClient, url: str, content: bytes -) -> TransportResponse: - async with ( - client, - client.stream( - HTTPMethod.POST, url, content=content, headers=_HEADERS - ) as response, - ): - answer = await _answer(response) - return unparsed_response(_Answer(response.status_code, answer, response.headers)) - - -async def _answer(response: httpx.Response) -> bytes: - """Read the answer, unless it runs past what Volcano would send. - - Returns: - The body, or nothing when it is too large to be one of Volcano's. - - """ - answer = bytearray() - async for chunk in response.aiter_bytes(): - answer += chunk - if len(answer) > _MAX_ANSWER_BYTES: - answer.clear() - break - return bytes(answer) - - -@dataclass(frozen=True, slots=True) -class _Answer: - status_code: int - content: bytes - headers: Mapping[str, str] - - -def _retryable(error: VolcanoError) -> bool: - # NotFoundError included: Volcano records an execution only once it has - # started, so an approval that is its first operation can arrive first. - if isinstance( - error, (TransportError, NotFoundError, RateLimitedError, ServerError) - ): - return True - return isinstance(error, ConflictError) and error.code == _NOT_READY - - -def _next_timeout(error: VolcanoError, delay: float, deadline: float) -> float | None: - """Wait to retry a failed attempt, if it is worth retrying in the time left. - - Returns: - The next attempt's timeout, or None when the failure is final. - - """ - if not _retryable(error) or monotonic() + delay >= deadline: - return None - sleep(delay) - remaining = deadline - monotonic() - if remaining <= 0: - return None - return min(_REQUEST_TIMEOUT_SECONDS, remaining) + _ = post_to_platform( + api_url, + PlatformRequest( + "/durable-approvals", body, not_ready=_NOT_READY, closed=_CLOSED + ), + ) def approval_decision(result: object) -> ApprovalDecision: @@ -337,7 +121,7 @@ def approval_decision(result: object) -> ApprovalDecision: TypeError: The callback result is not a decision. """ - values = _decision_fields(result) + values = callback_fields(result, _INVALID_DECISION) status = values.get("status") if status not in {"approved", "denied"}: raise TypeError(_INVALID_DECISION) @@ -352,28 +136,8 @@ def approval_decision(result: object) -> ApprovalDecision: ) -def _decision_fields(result: object) -> Mapping[str, object]: - if isinstance(result, (str, bytes)): - document = result.encode() if isinstance(result, str) else result - try: - result = decode_json(document) - except ValueError as error: - raise TypeError(_INVALID_DECISION) from error - if not _is_fields(result): - raise TypeError(_INVALID_DECISION) - return result - - -def _is_fields(value: object) -> TypeGuard[Mapping[str, object]]: - return _is_mapping(value) and all(isinstance(key, str) for key in value) - - -def _is_mapping(value: object) -> TypeGuard[Mapping[object, object]]: - return isinstance(value, Mapping) - - def _decider(value: object) -> DurableApprovalDecider | None: - if not _is_mapping(value): + if not is_mapping(value): return None decider_id = value.get("id") email = value.get("email") diff --git a/src/volcano_sdk/_durable_arguments.py b/src/volcano_sdk/_durable_arguments.py new file mode 100644 index 00000000..afb81195 --- /dev/null +++ b/src/volcano_sdk/_durable_arguments.py @@ -0,0 +1,69 @@ +"""Arguments the durable facades check before spending a request.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +if TYPE_CHECKING: + from collections.abc import Mapping + +_INVALID_IDENTIFIERS = { + "function_name": "function_name must be a non-empty string", + "execution_name": "execution_name must be a non-empty string", + "project_id": "project_id must be a non-empty string", + "execution_id": "execution_id must be a non-empty string", + "approval_id": "approval_id must be a non-empty string", +} +# The identifiers the transport converts to a UUID before sending. Checked +# here so a malformed one is a facade ValidationError rather than a +# `badly formed hexadecimal UUID string` raised from inside the transport, +# outside the error hierarchy this package documents. +_UUID_IDENTIFIERS = { + "project_id": "project_id must be a UUID", + "execution_id": "execution_id must be a UUID", + "approval_id": "approval_id must be a UUID", +} + + +def identifier(value: object, field: str) -> str: + """Require a non-empty path segment, and a UUID where one is sent as one. + + An empty segment would address the collection instead of the execution, + which is a different request rather than a failed one. + + Returns + ------- + str + The trimmed identifier, preserving its UUID spelling. + + Raises + ------ + ValueError + If the value is empty, is not a string, or requires a valid UUID. + + """ + if not isinstance(value, str) or not value.strip(): + raise ValueError(_INVALID_IDENTIFIERS[field]) + trimmed = value.strip() + if field in _UUID_IDENTIFIERS: + try: + _ = UUID(trimmed) + except ValueError as exc: + raise ValueError(_UUID_IDENTIFIERS[field]) from exc + return trimmed + + +def known_options(options: Mapping[str, object], known: frozenset[str]) -> None: + """Refuse keyword options a method does not take. + + Raises + ------ + TypeError + If any option is not in `known`. + + """ + unknown = sorted(options.keys() - known) + if unknown: + message = f"unexpected option: {', '.join(unknown)}" + raise TypeError(message) diff --git a/src/volcano_sdk/_durable_callback.py b/src/volcano_sdk/_durable_callback.py new file mode 100644 index 00000000..b61486cc --- /dev/null +++ b/src/volcano_sdk/_durable_callback.py @@ -0,0 +1,123 @@ +"""Rules shared by the callbacks Volcano completes for a workflow.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, TypeGuard + +from ._transport_types import decode_json + +if TYPE_CHECKING: + from .models import JSONValue + +# A name is also the runtime's operation name, which allows 256 printable ASCII +# characters, and the runtimes add suffixes of up to 19 characters to it; every +# SDK holds names to the same limit. +MAX_NAME_LENGTH = 237 +_NOT_PRINTABLE_ASCII = "name must be printable ASCII" +_NUL = "\0" + + +def operation_name(value: object) -> str: + """Require a name the runtime and Volcano both accept. + + Returns: + The name, unchanged. + + Raises: + ValueError: The name is empty, too long, or not printable ASCII. + + """ + name = text(value, "name", limit=MAX_NAME_LENGTH, required=True) + if not (name.isascii() and name.isprintable()): + raise ValueError(_NOT_PRINTABLE_ASCII) + return name + + +def text(value: object, field: str, *, limit: int, required: bool) -> str: + """Require a string Volcano can record. + + Returns: + The string, unchanged. + + Raises: + TypeError: The value is not a string, or holds a lone surrogate. + ValueError: The string is required but blank. + + """ + if not isinstance(value, str): + message = f"{field} must be a string" + raise TypeError(message) + try: + _ = value.encode() + except UnicodeEncodeError as error: + message = f"{field} must be JSON-serializable: it contains a surrogate" + raise TypeError(message) from error + if required and not value.strip(): + message = f"{field} must not be empty" + raise ValueError(message) + return _recordable(value, field, limit) + + +def _recordable(value: str, field: str, limit: int) -> str: + if len(value) > limit: + message = f"{field} must be at most {limit} characters" + raise ValueError(message) + if _NUL in value: + message = f"{field} must not contain NUL characters" + raise ValueError(message) + return value + + +def has_nul(value: JSONValue) -> bool: + """Whether any string in a JSON value, key or value, holds a NUL. + + Returns: + True when Volcano would refuse to store the value. + + """ + if isinstance(value, str): + return _NUL in value + if isinstance(value, dict): + return any(_NUL in key or has_nul(item) for key, item in value.items()) + if isinstance(value, list): + return any(has_nul(item) for item in value) + return False + + +def callback_fields(result: object, invalid: str) -> Mapping[str, object]: + """Read the object Volcano completed a callback with. + + The runtime hands the result back as Volcano sent it, a JSON document, or + already decoded. + + Returns: + The result's fields. + + Raises: + TypeError: The result is not a JSON object. + + """ + if isinstance(result, (str, bytes)): + document = result.encode() if isinstance(result, str) else result + try: + result = decode_json(document) + except ValueError as error: + raise TypeError(invalid) from error + if not _is_fields(result): + raise TypeError(invalid) + return result + + +def _is_fields(value: object) -> TypeGuard[Mapping[str, object]]: + return is_mapping(value) and all(isinstance(key, str) for key in value) + + +def is_mapping(value: object) -> TypeGuard[Mapping[object, object]]: + """Narrow a decoded value to a mapping. + + Returns: + True when the value is a mapping. + + """ + return isinstance(value, Mapping) diff --git a/src/volcano_sdk/_durable_message.py b/src/volcano_sdk/_durable_message.py new file mode 100644 index 00000000..ce5fee5d --- /dev/null +++ b/src/volcano_sdk/_durable_message.py @@ -0,0 +1,195 @@ +"""Wait for, and publish, the messages a durable execution exchanges.""" + +from __future__ import annotations + +from hashlib import sha256 +from typing import TYPE_CHECKING + +from ._durable_callback import callback_fields, has_nul, is_mapping +from ._durable_platform_request import PlatformRequest, encode, post_to_platform +from ._durable_response import json_value +from ._durable_results import PublishedMessage, ReceivedMessage +from ._transport_response import plain_json +from .models import DurableMessageSender + +if TYPE_CHECKING: + from collections.abc import Mapping + + from .models import JSONValue + +SESSION_NAME = "volcano:messages-session" +_NOT_READY = "callback_not_ready" +_CLOSED = "callback_closed" +_SESSION_HEADER = "X-Volcano-Durable-Session" +_IDEMPOTENCY_HEADER = "Idempotency-Key" +_MAX_DATA_BYTES = 65536 +_INVALID_DATA = ( + "data must be JSON-serializable: mappings with string keys, lists, " + "tuples, strings, finite numbers, booleans, and None" +) +_UNREADABLE_MESSAGE = ( + "Volcano resumed the message wait with a message this SDK cannot read" +) +_UNREADABLE_SESSION = ( + "Volcano opened the messages session with a token this SDK cannot read" +) +_UNREADABLE_PUBLISH = "Volcano answered the publish with a message this SDK cannot read" + + +def message_data(value: object) -> JSONValue: + """Validate what a publish sends, before its step runs. + + Returns: + A plain copy, so a change the caller makes later cannot slip past the + checks. + + Raises: + ValueError: The data holds a NUL, or is over 64 KiB as JSON. + + """ + data = plain_json(json_value(value, _INVALID_DATA)) + if has_nul(data): + message = "data must not contain NUL characters" + raise ValueError(message) + if len(encode(data)) > _MAX_DATA_BYTES: + message = "data must be at most 64 KiB once encoded as JSON" + raise ValueError(message) + return data + + +def register_wait( + api_url: str, execution_ref: str, callback_id: str, name: str +) -> None: + """Ask Volcano to complete the callback with the next message of a name. + + A wait that already closed, normally because its timeout passed first, + needs no registration: the callback's own outcome is what the execution + resumes with. + """ + body: dict[str, JSONValue] = { + "execution_ref": execution_ref, + "callback_id": callback_id, + "name": name, + } + _ = post_to_platform(api_url, _registration("/durable-messages/waits", body)) + + +def open_session(api_url: str, execution_ref: str, callback_id: str) -> None: + """Ask Volcano to complete the callback with a token to publish with.""" + body: dict[str, JSONValue] = { + "execution_ref": execution_ref, + "callback_id": callback_id, + } + _ = post_to_platform(api_url, _registration("/durable-messages/sessions", body)) + + +def _registration(path: str, body: Mapping[str, JSONValue]) -> PlatformRequest: + return PlatformRequest(path, body, not_ready=_NOT_READY, closed=_CLOSED) + + +def publish_key(token: str, position: int) -> str: + """Key a publish by its context's session and its place in that context. + + Unique within the execution, because every context has its own session, + and the same when a step runs again. Hashed, so Volcano never stores the + token itself. + + Returns: + The idempotency key. + + """ + return f"{sha256(token.encode()).hexdigest()}:{position}" + + +def publish_message( + api_url: str, token: str, key: str, message: Mapping[str, JSONValue] +) -> dict[str, JSONValue]: + """Publish the message, or return the one this key already published. + + Returns: + The message's id and seq, in the form the step checkpoints. + + """ + payload = post_to_platform( + api_url, + PlatformRequest( + "/durable-messages/publish", + message, + headers={_SESSION_HEADER: token, _IDEMPOTENCY_HEADER: key}, + ), + ) + published = published_message(payload) + return {"id": published.id, "seq": published.seq} + + +def published_message(value: object) -> PublishedMessage: + """Read a publish's answer, live or as its step recorded it. + + Returns: + The published message's id and seq. + + Raises: + TypeError: The value is not a published message. + + """ + if not is_mapping(value): + raise TypeError(_UNREADABLE_PUBLISH) + message_id = value.get("id") + seq = value.get("seq") + if not isinstance(message_id, str) or type(seq) is not int: + raise TypeError(_UNREADABLE_PUBLISH) + return PublishedMessage(id=message_id, seq=seq) + + +def received_message(result: object) -> ReceivedMessage: + """Read the message Volcano completed a wait's callback with. + + Returns: + The message. + + Raises: + TypeError: The result is not a message. + + """ + values = callback_fields(result, _UNREADABLE_MESSAGE) + message_id = values.get("id") + name = values.get("name") + sent_at = values.get("sent_at") + if not ( + isinstance(message_id, str) + and isinstance(name, str) + and isinstance(sent_at, str) + ): + raise TypeError(_UNREADABLE_MESSAGE) + return ReceivedMessage( + id=message_id, + name=name, + data=json_value(values.get("data"), _UNREADABLE_MESSAGE), + sender=_sender(values.get("sender")), + sent_at=sent_at, + ) + + +def _sender(value: object) -> DurableMessageSender: + if not is_mapping(value) or not isinstance(sender_type := value.get("type"), str): + raise TypeError(_UNREADABLE_MESSAGE) + user_id = value.get("user_id") + return DurableMessageSender( + type=sender_type, user_id=user_id if isinstance(user_id, str) else None + ) + + +def session_token(result: object) -> str: + """Read the token Volcano completed a messages session's callback with. + + Returns: + The session token. + + Raises: + TypeError: The result carries no token. + + """ + token = callback_fields(result, _UNREADABLE_SESSION).get("token") + if not isinstance(token, str) or not token: + raise TypeError(_UNREADABLE_SESSION) + return token diff --git a/src/volcano_sdk/_durable_message_response.py b/src/volcano_sdk/_durable_message_response.py new file mode 100644 index 00000000..063d6862 --- /dev/null +++ b/src/volcano_sdk/_durable_message_response.py @@ -0,0 +1,138 @@ +"""Validate durable message responses before exposing immutable models.""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime +from typing import TypeGuard + +from ._durable_response import json_value +from .models import ( + DurableMessage, + DurableMessageDirection, + DurableMessagePage, + DurableMessageSender, + DurableMessageStatus, +) + +_INVALID_MESSAGE = "Expected a complete durable message" +_INVALID_PAGE = "Expected a complete durable message page" +_DIRECTIONS: tuple[DurableMessageDirection, ...] = ("in", "out") +_STATUSES: tuple[DurableMessageStatus, ...] = ( + "queued", + "delivering", + "delivered", + "dropped", + "published", +) + + +def _is_fields(value: object) -> TypeGuard[Mapping[str, object]]: + return _is_mapping(value) and all(isinstance(key, str) for key in value) + + +def _is_mapping(value: object) -> TypeGuard[Mapping[object, object]]: + return isinstance(value, Mapping) + + +def _is_sequence(value: object) -> TypeGuard[list[object] | tuple[object, ...]]: + return isinstance(value, (list, tuple)) + + +def _fields(value: object, message: str) -> Mapping[str, object]: + if not _is_fields(value): + raise TypeError(message) + return value + + +def _text(values: Mapping[str, object], key: str) -> str: + value = values.get(key) + if not isinstance(value, str) or not value: + raise TypeError(_INVALID_MESSAGE) + return value + + +def _timestamp(value: object) -> datetime: + if not isinstance(value, str): + raise TypeError(_INVALID_MESSAGE) + try: + return datetime.fromisoformat(value) + except ValueError as error: + raise TypeError(_INVALID_MESSAGE) from error + + +def _direction(value: object) -> DurableMessageDirection: + for direction in _DIRECTIONS: + if value == direction: + return direction + raise TypeError(_INVALID_MESSAGE) + + +def _status(value: object) -> DurableMessageStatus: + for status in _STATUSES: + if value == status: + return status + raise TypeError(_INVALID_MESSAGE) + + +def _seq(value: object) -> int: + if type(value) is not int or value < 1: + raise TypeError(_INVALID_MESSAGE) + return value + + +def _sender(payload: object) -> DurableMessageSender: + values = _fields(payload, _INVALID_MESSAGE) + user_id = values.get("user_id") + if user_id is not None and not isinstance(user_id, str): + raise TypeError(_INVALID_MESSAGE) + return DurableMessageSender(type=_text(values, "type"), user_id=user_id) + + +def _delivered_at(value: object) -> datetime | None: + if value is None: + return None + return _timestamp(value) + + +def durable_message(payload: object) -> DurableMessage: + """Validate and snapshot one durable message. + + Returns: + The complete message model. + + """ + values = _fields(payload, _INVALID_MESSAGE) + return DurableMessage( + id=_text(values, "id"), + execution_id=_text(values, "execution_id"), + direction=_direction(values.get("direction")), + seq=_seq(values.get("seq")), + name=_text(values, "name"), + data=json_value(values.get("data"), _INVALID_MESSAGE), + sender=_sender(values.get("sender")), + status=_status(values.get("status")), + sent_at=_timestamp(values.get("sent_at")), + delivered_at=_delivered_at(values.get("delivered_at")), + ) + + +def durable_message_page(payload: object) -> DurableMessagePage: + """Validate and snapshot a page of messages. + + Returns: + The messages and whether more follow. + + Raises: + TypeError: The page container or `has_more` is invalid. + + """ + values = _fields(payload, _INVALID_PAGE) + data = values.get("data") + has_more = values.get("has_more") + if not _is_sequence(data) or not isinstance(has_more, bool): + raise TypeError(_INVALID_PAGE) + return DurableMessagePage( + messages=tuple(durable_message(entry) for entry in data), + has_more=has_more, + ) diff --git a/src/volcano_sdk/_durable_message_stream.py b/src/volcano_sdk/_durable_message_stream.py new file mode 100644 index 00000000..7c4bf614 --- /dev/null +++ b/src/volcano_sdk/_durable_message_stream.py @@ -0,0 +1,135 @@ +"""Ordered, once-each delivery of the messages a durable execution publishes.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from ._durable_message_response import durable_message + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from .models import DurableMessage, DurableMessagePage + + +def _published(data: object) -> DurableMessage | None: + try: + message = durable_message(data) + except TypeError: + return None + return message if message.direction == "out" else None + + +def _report_failed_catch_up(task: asyncio.Task[None]) -> None: + if task.cancelled() or (error := task.exception()) is None: + return + asyncio.get_running_loop().call_exception_handler( + {"message": "Volcano durable message catch-up failed", "exception": error} + ) + + +class DurableMessageStream: + """Deliver messages in `seq` order, once each, from the channel and the list. + + A live message can only be delivered once everything before it has been + listed, or the missed ones would fall below the last delivered `seq` and be + skipped. So live messages are held until the first catch-up starts, and + while any catch-up is reading, then released in order. + + The channel itself drops publications while it is not subscribed, and + reports each resubscription, which starts a catch-up of what it dropped. + """ + + def __init__( + self, + deliver: Callable[[DurableMessage], Awaitable[None]], + list_after: Callable[[int], Awaitable[DurableMessagePage]], + ) -> None: + """Deliver through `deliver`, reading missed messages with `list_after`.""" + self._handler: Callable[[DurableMessage], Awaitable[None]] = deliver + self._list_after: Callable[[int], Awaitable[DurableMessagePage]] = list_after + self._last_seq: int = 0 + self._started: bool = False + self._catch_ups: int = 0 + self._held: list[DurableMessage] = [] + self._closed: bool = False + self._reads: asyncio.Lock = asyncio.Lock() + self._tasks: set[asyncio.Task[None]] = set() + + async def live(self, data: object) -> None: + """Deliver a channel publication, or hold it until a catch-up releases it.""" + message = _published(data) + if message is None: + return + if not self._started or self._catch_ups > 0: + self._held.append(message) + return + await self._deliver(message) + + def resubscribed(self) -> None: + """Catch up on what the channel dropped while it was not subscribed.""" + # A failed catch-up leaves the gap until the next one; live delivery + # goes on. + self.catch_up().add_done_callback(_report_failed_catch_up) + + def catch_up(self) -> asyncio.Task[None]: + """Read what was missed, then release the held messages. + + Counted before the task starts, so nothing is delivered live while a + read waits its turn. + + Returns + ------- + asyncio.Task[None] + The read, which fails with the list's or the handler's error. + + """ + self._started = True + self._catch_ups += 1 + task = asyncio.create_task(self._read_missed()) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + return task + + def close(self) -> None: + """Deliver nothing more; a catch-up still reading stops after its page. + + Not cancelled: the handler a catch-up is running may be what closed it. + """ + self._closed = True + + async def _read_missed(self) -> None: + try: + async with self._reads: + try: + await self._read_pages() + finally: + # Released by the last catch-up only, and while still + # counted, so a live message that arrives meanwhile is + # held behind the ones being released. + if self._catch_ups == 1: + await self._release() + finally: + self._catch_ups -= 1 + + async def _read_pages(self) -> None: + while not self._closed: + page = await self._list_after(self._last_seq) + for message in page.messages: + await self._deliver(message) + if not page.has_more or not page.messages: + return + + async def _release(self) -> None: + while self._held: + held = sorted(self._held, key=lambda message: message.seq) + self._held = [] + for message in held: + await self._deliver(message) + + async def _deliver(self, message: DurableMessage) -> None: + if self._closed or message.seq <= self._last_seq: + return + self._last_seq = message.seq + await self._handler(message) diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py new file mode 100644 index 00000000..f4dbc7a1 --- /dev/null +++ b/src/volcano_sdk/_durable_messages.py @@ -0,0 +1,393 @@ +"""Send, list, and follow the messages of durable executions.""" + +from __future__ import annotations + +import inspect +from typing import TYPE_CHECKING, Protocol, Unpack, runtime_checkable + +from ._callbacks import require_callable +from ._durable_arguments import identifier, known_options +from ._durable_message_response import durable_message, durable_message_page +from ._durable_message_stream import DurableMessageStream +from ._transport import HTTP_CREATED, HTTP_OK, invoke, invoke_async, response_payload +from ._transport_types import ( + DurableMessageListRequest, + DurableMessageSendRequest, + DurableMessageTarget, +) + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from ._transport import Transport, TransportResponse + from .models import ( + DurableMessage, + DurableMessageDirection, + DurableMessageListOptions, + DurableMessagePage, + DurableMessageSendOptions, + DurableMessageTargetOptions, + JSONValue, + ) + +_INVALID_TRANSPORT = "Transport does not support durable messages" +_NO_REALTIME = "subscribe() needs a VolcanoClient, which owns the realtime connection" +_ALREADY_SUBSCRIBED = ( + "This client already subscribes to the execution's messages; unsubscribe first" +) +# The spec's maxLength on Idempotency-Key. +_MAX_IDEMPOTENCY_KEY_LENGTH = 255 +_TARGET_OPTIONS = frozenset({"client_token", "project_id"}) +_SEND_OPTIONS = _TARGET_OPTIONS | {"idempotency_key"} +_LIST_OPTIONS = _TARGET_OPTIONS | {"direction", "name", "after", "limit"} + + +class DurableMessagesContext(Protocol): + """Client capabilities required by durable message requests.""" + + def transport(self) -> Transport: + """Return the active typed transport.""" + ... + + def function_token(self) -> str: + """Return the current function-invocation credential.""" + ... + + def session_token(self) -> str: + """Return the active session credential.""" + ... + + +@runtime_checkable +class DurableMessagesTransport(Protocol): + """Transport operations required by the durable messages facade.""" + + def send_durable_message( + self, + *, + authorization: str, + target: DurableMessageTarget, + request: DurableMessageSendRequest, + ) -> TransportResponse: + """Send a message to a durable execution.""" + ... + + def list_durable_messages( + self, + *, + authorization: str, + target: DurableMessageTarget, + request: DurableMessageListRequest, + ) -> TransportResponse: + """List a durable execution's messages.""" + ... + + async def list_durable_messages_async( + self, + *, + authorization: str, + target: DurableMessageTarget, + request: DurableMessageListRequest, + ) -> TransportResponse: + """List a durable execution's messages without blocking the event loop.""" + ... + + +class DurableChannel(Protocol): + """An execution's realtime channel, as a subscription uses it.""" + + # Called each time the channel is subscribed again after a drop. + resubscribed: Callable[[], None] | None + + @property + def name(self) -> str: + """The channel's name on the realtime connection.""" + ... + + def on(self, event: str, callback: Callable[[object], object]) -> object: + """Register a callback for the channel's publications.""" + ... + + async def subscribe(self) -> None: + """Wait until the channel is subscribed.""" + ... + + +class DurableMessageChannels(Protocol): + """The realtime connection a subscription follows an execution on.""" + + def durable_channel( + self, execution_id: str, client_token: str | None + ) -> DurableChannel | None: + """Open the execution's channel, or None when it is already open.""" + ... + + async def remove_durable_channel(self, channel: DurableChannel) -> None: + """Unsubscribe and forget the execution's channel.""" + ... + + +class DurableMessageSubscription: + """Messages an execution publishes, delivered until `unsubscribe`.""" + + def __init__( + self, + stream: DurableMessageStream, + channel: DurableChannel, + realtime: DurableMessageChannels, + ) -> None: + """Own a subscription's stream and channel.""" + self._stream: DurableMessageStream = stream + self._channel: DurableChannel = channel + self._realtime: DurableMessageChannels = realtime + + async def unsubscribe(self) -> None: + """Stop delivering messages and leave the execution's channel. + + Safe to repeat, and to call from the handler. + """ + self._stream.close() + await self._realtime.remove_durable_channel(self._channel) + + +class DurableMessages: + """Send to, list, and follow the messages of durable executions. + + Each method addresses an execution by its function's name and its id. By + default they use the application route with the client's credential, as a + start does; pass `client_token` where that credential needs it. Pass + `project_id` to use the owner's project route with the session's platform + token instead. + """ + + def __init__( + self, + client: DurableMessagesContext, + realtime: DurableMessageChannels | None, + ) -> None: + """Bind message operations to a Volcano client.""" + self._client: DurableMessagesContext = client + self._realtime: DurableMessageChannels | None = realtime + + def _transport(self) -> DurableMessagesTransport: + transport = self._client.transport() + if not isinstance(transport, DurableMessagesTransport): + raise TypeError(_INVALID_TRANSPORT) + return transport + + def _authorization(self, target: DurableMessageTarget) -> str: + if target.project_id is None: + return self._client.function_token() + return self._client.session_token() + + def send( + self, + function_name: str, + execution_id: str, + name: str, + data: JSONValue = None, + **options: Unpack[DurableMessageSendOptions], + ) -> DurableMessage: + """Send a message to a running execution. + + It is queued until a `ctx.wait_for_message(name)` takes it. A send with + an `idempotency_key` the execution has seen returns the message the + first one stored, and sends nothing. A send to an execution that has + ended raises `ConflictError`. + + Returns + ------- + DurableMessage + The stored message, `queued` or already `delivered`. + + """ + known_options(options, _SEND_OPTIONS) + target = _target(function_name, execution_id, options) + request = DurableMessageSendRequest( + name=_message_name(name), + data=data, + idempotency_key=_idempotency_key(options.get("idempotency_key")), + ) + response = invoke( + self._transport().send_durable_message, + authorization=self._authorization(target), + target=target, + request=request, + ) + created = int(response.status_code) == HTTP_CREATED + response_body: object = response_payload( + response, HTTP_CREATED if created else HTTP_OK + ) + return durable_message(response_body) + + def list( + self, + function_name: str, + execution_id: str, + **options: Unpack[DurableMessageListOptions], + ) -> DurableMessagePage: + """List an execution's messages in both directions, oldest first. + + Read the next page with `after` set to the last message's `seq`. + + Returns + ------- + DurableMessagePage + The messages and whether more follow. + + """ + known_options(options, _LIST_OPTIONS) + target = _target(function_name, execution_id, options) + direction = options.get("direction") + request = DurableMessageListRequest( + direction=None if direction is None else _direction(direction), + name=options.get("name"), + after=options.get("after"), + limit=options.get("limit"), + ) + response = invoke( + self._transport().list_durable_messages, + authorization=self._authorization(target), + target=target, + request=request, + ) + response_body: object = response_payload(response, HTTP_OK) + return durable_message_page(response_body) + + async def subscribe( + self, + function_name: str, + execution_id: str, + handler: Callable[[DurableMessage], object], + **options: Unpack[DurableMessageTargetOptions], + ) -> DurableMessageSubscription: + """Deliver every message the execution publishes, in order, once each. + + Messages it published before the call are delivered first. After a + reconnect, the ones published meanwhile are read back before live + delivery resumes. The handler may be a coroutine function; one that is + not callable raises `TypeError`. + + Follows the execution on the client's realtime connection, which needs + a signed-in session. + + Returns + ------- + DurableMessageSubscription + The subscription, once the messages published so far are delivered. + + Raises + ------ + RuntimeError + If this client already subscribes to the execution. + + """ + known_options(options, _TARGET_OPTIONS) + target = _target(function_name, execution_id, options) + deliver = _deliverer(handler) + realtime = self._realtime + if realtime is None: + raise RuntimeError(_NO_REALTIME) + list_after = self._reader(target) + channel = realtime.durable_channel(target.execution_id, target.client_token) + if channel is None: + raise RuntimeError(_ALREADY_SUBSCRIBED) + return await _follow( + DurableMessageStream(deliver, list_after), channel, realtime + ) + + def _reader( + self, target: DurableMessageTarget + ) -> Callable[[int], Awaitable[DurableMessagePage]]: + transport = self._transport() + + async def list_after(after: int) -> DurableMessagePage: + response = await invoke_async( + transport.list_durable_messages_async, + authorization=self._authorization(target), + target=target, + request=DurableMessageListRequest(direction="out", after=after), + ) + response_body: object = response_payload(response, HTTP_OK) + return durable_message_page(response_body) + + return list_after + + +async def _follow( + stream: DurableMessageStream, + channel: DurableChannel, + realtime: DurableMessageChannels, +) -> DurableMessageSubscription: + subscription = DurableMessageSubscription(stream, channel, realtime) + _ = channel.on("message", stream.live) + try: + await channel.subscribe() + channel.resubscribed = stream.resubscribed + await stream.catch_up() + except BaseException: + await subscription.unsubscribe() + raise + return subscription + + +def _deliverer( + handler: Callable[[DurableMessage], object], +) -> Callable[[DurableMessage], Awaitable[None]]: + require_callable(handler, "subscribe() requires a handler function") + + async def deliver(message: DurableMessage) -> None: + result = handler(message) + if inspect.isawaitable(result): + await result + + return deliver + + +def _target( + function_name: object, + execution_id: object, + options: DurableMessageTargetOptions, +) -> DurableMessageTarget: + project_id = options.get("project_id") + return DurableMessageTarget( + function_id=identifier(function_name, "function_name"), + execution_id=identifier(execution_id, "execution_id"), + project_id=None if project_id is None else identifier(project_id, "project_id"), + client_token=_optional_text(options.get("client_token"), "client_token"), + ) + + +def _optional_text(value: object, field: str) -> str | None: + if value is None: + return None + if not isinstance(value, str) or not value: + message = f"{field} must be a non-empty string" + raise ValueError(message) + return value + + +def _message_name(value: object) -> str: + if not isinstance(value, str) or not value.strip(): + message = "name must be a non-empty string" + raise ValueError(message) + return value + + +def _idempotency_key(value: object) -> str | None: + key = _optional_text(value, "idempotency_key") + if key is not None and len(key) > _MAX_IDEMPOTENCY_KEY_LENGTH: + message = ( + f"idempotency_key must be at most {_MAX_IDEMPOTENCY_KEY_LENGTH} characters" + ) + raise ValueError(message) + return key + + +def _direction(value: object) -> DurableMessageDirection: + if value == "in": + return "in" + if value == "out": + return "out" + message = "direction must be 'in' or 'out'" + raise ValueError(message) diff --git a/src/volcano_sdk/_durable_platform_request.py b/src/volcano_sdk/_durable_platform_request.py new file mode 100644 index 00000000..2d1827e8 --- /dev/null +++ b/src/volcano_sdk/_durable_platform_request.py @@ -0,0 +1,252 @@ +"""Requests a durable function makes to Volcano's runtime routes.""" + +from __future__ import annotations + +import asyncio +import json +import os +from dataclasses import dataclass, field +from http import HTTPMethod +from time import monotonic, sleep +from typing import TYPE_CHECKING + +import httpx + +from ._transport_response import ( + invoke_async, + plain_json, + response_payload, + unparsed_response, +) +from ._transport_types import HTTP_CREATED, HTTP_OK +from .errors import ( + ConflictError, + NotFoundError, + RateLimitedError, + ServerError, + TransportError, + VolcanoError, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + + from ._transport_types import TransportResponse + from .models import JSONValue + +# 0.5 s doubling to a 5 s cap, within the deadline: long enough to outlast the +# moment the platform has not yet seen the execution start or the callback open. +_RETRY_DELAYS = (0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0) +_DEADLINE_SECONDS = 30.0 +_REQUEST_TIMEOUT_SECONDS = 10.0 +_JSON = {"Content-Type": "application/json"} +# Volcano answers in a few hundred bytes, or with one published message: up to +# 64 KiB of data, which escaping `<`, `>`, and `&` can grow sixfold. More is not +# an answer worth reading. +_MAX_ANSWER_BYTES = 524288 +_API_URL_VARIABLE = "VOLCANO_PLATFORM_API_URL" + + +@dataclass(frozen=True, slots=True) +class PlatformRequest: + """A POST to one of the routes Volcano serves running workflows.""" + + path: str + body: Mapping[str, JSONValue] + headers: Mapping[str, str] = field(default_factory=dict[str, str]) + # The conflict code for a callback Volcano cannot see waiting yet. + not_ready: str | None = None + # The conflict code for a callback that stopped waiting before the request + # landed, normally because its timeout passed. The runtime resumes the + # callback with that outcome, so there is nothing left to do. + closed: str | None = None + + +def platform_api_url(operation: str) -> str: + """Read the Volcano API address the platform gives durable functions. + + Returns: + The API base URL. + + Raises: + RuntimeError: The variable is unset or empty. + + """ + url = os.environ.get(_API_URL_VARIABLE, "").strip() + if not url: + message = ( + f"{operation} needs {_API_URL_VARIABLE}. Volcano sets it on deployed " + "durable functions; set it yourself to run the handler elsewhere." + ) + raise RuntimeError(message) + return url + + +def encode(value: JSONValue) -> bytes: + """Encode a value the way a request sends it, to size it. + + Returns: + The compact UTF-8 JSON document. + + """ + return json.dumps( + plain_json(value), ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode() + + +def post_to_platform(api_url: str, request: PlatformRequest) -> object: + """Send the request and return the payload Volcano answered with. + + The routes take no platform credential: what the body or headers carry is + what Volcano checks against the running execution. + + What can clear on its own is retried until a 30 second deadline: no + answer, an execution or callback Volcano cannot see yet, throttling, and + server failures. Each attempt, from connecting to the last byte of the + answer, ends within 10 seconds or what is left of the deadline, whichever + is sooner. + + Returns: + The decoded answer to a 200 or 201, or None when the request's closed + code says the callback no longer waits. + + Raises: + VolcanoError: Volcano refused the request, or kept failing until the + deadline passed or the retries ran out. + + """ + url = f"{api_url.removesuffix('/')}{request.path}" + content = encode(request.body) + headers = {**_JSON, **request.headers} + deadline = monotonic() + _DEADLINE_SECONDS + timeout = _REQUEST_TIMEOUT_SECONDS + for delay in _RETRY_DELAYS: + try: + return _attempt(url, content, headers, request, timeout) + except VolcanoError as error: + next_timeout = _next_timeout(error, request, delay, deadline) + if next_timeout is None: + raise + timeout = next_timeout + return _attempt(url, content, headers, request, timeout) + + +def _attempt( + url: str, + content: bytes, + headers: Mapping[str, str], + request: PlatformRequest, + timeout: float, +) -> object: + response = _send(url, content, headers, timeout) + expected = HTTP_CREATED if response.status_code == HTTP_CREATED else HTTP_OK + try: + return response_payload(response, expected) + except ConflictError as error: + # A request without the code expects none, so an uncoded conflict is + # not it. + if request.closed is None or error.code != request.closed: + raise + return None + + +def _send( + url: str, content: bytes, headers: Mapping[str, str], timeout: float +) -> TransportResponse: + # httpx times each network read on its own, restarting with every chunk, + # so only cancelling the request bounds the attempt as a whole; its own + # timeouts still keep any one phase within it. The runtime calls the + # submitter on a thread without an event loop. asyncio.run() would wait on + # its way out for a host lookup that cancelling leaves running in the + # loop's executor; closing the loop leaves it behind instead. + client = httpx.AsyncClient(timeout=timeout) + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete( + asyncio.wait_for( + invoke_async(_exchange, client, url, content, headers), timeout + ) + ) + except TimeoutError as error: + raise _no_answer(timeout) from error + except TransportError as error: + # The client's timeouts spend the same budget. When a stalled loop + # fires both at once, Python 3.11's wait_for reports the client's. + if isinstance(error.__cause__, httpx.TimeoutException): + raise _no_answer(timeout) from error.__cause__ + raise + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + + +def _no_answer(timeout: float) -> TransportError: + return TransportError(f"Volcano did not answer within {timeout:g} seconds") + + +async def _exchange( + client: httpx.AsyncClient, url: str, content: bytes, headers: Mapping[str, str] +) -> TransportResponse: + async with ( + client, + client.stream( + HTTPMethod.POST, url, content=content, headers=headers + ) as response, + ): + answer = await _answer(response) + return unparsed_response(_Answer(response.status_code, answer, response.headers)) + + +async def _answer(response: httpx.Response) -> bytes: + """Read the answer, unless it runs past what Volcano would send. + + Returns: + The body, or nothing when it is too large to be one of Volcano's. + + """ + answer = bytearray() + async for chunk in response.aiter_bytes(): + answer += chunk + if len(answer) > _MAX_ANSWER_BYTES: + answer.clear() + break + return bytes(answer) + + +@dataclass(frozen=True, slots=True) +class _Answer: + status_code: int + content: bytes + headers: Mapping[str, str] + + +def _retryable(error: VolcanoError, request: PlatformRequest) -> bool: + # NotFoundError included: Volcano records an execution only once it has + # started, so a request made first thing can arrive first. + if isinstance( + error, (TransportError, NotFoundError, RateLimitedError, ServerError) + ): + return True + return ( + isinstance(error, ConflictError) + and request.not_ready is not None + and error.code == request.not_ready + ) + + +def _next_timeout( + error: VolcanoError, request: PlatformRequest, delay: float, deadline: float +) -> float | None: + """Wait to retry a failed attempt, if it is worth retrying in the time left. + + Returns: + The next attempt's timeout, or None when the failure is final. + + """ + if not _retryable(error, request) or monotonic() + delay >= deadline: + return None + sleep(delay) + remaining = deadline - monotonic() + if remaining <= 0: + return None + return min(_REQUEST_TIMEOUT_SECONDS, remaining) diff --git a/src/volcano_sdk/_durable_response.py b/src/volcano_sdk/_durable_response.py index 50a42d1d..cc6a1850 100644 --- a/src/volcano_sdk/_durable_response.py +++ b/src/volcano_sdk/_durable_response.py @@ -170,9 +170,18 @@ def durable_execution(payload: object) -> DurableExecution: result_expired=result_expired, error=_durable_error(values.get("error")), completed_at=optional_datetime(values.get("completed_at")), + client_token=_client_token(values.get("client_token")), ) +def _client_token(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str) or not value: + raise TypeError(_INVALID_EXECUTION_PAYLOAD) + return value + + def _durable_error(payload: object) -> DurableExecutionFailure | None: if payload is None: return None diff --git a/src/volcano_sdk/_durable_results.py b/src/volcano_sdk/_durable_results.py index 74037b04..0da27574 100644 --- a/src/volcano_sdk/_durable_results.py +++ b/src/volcano_sdk/_durable_results.py @@ -2,14 +2,14 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Generic, Literal from typing_extensions import TypeVar if TYPE_CHECKING: from ._durable_protocols import RuntimeBatch, RuntimeBatchItem - from .models import DurableApprovalDecider + from .models import DurableApprovalDecider, DurableMessageSender, JSONValue T = TypeVar("T", default=object) @@ -35,6 +35,29 @@ class ApprovalDecision: decided_at: str | None = None +@dataclass(frozen=True, slots=True) +class ReceivedMessage: + """What `ctx.wait_for_message` resumes with.""" + + id: str + name: str + # The JSON value sent, or None. Plain dicts and lists, so the handler can + # return or checkpoint it as it is. + data: JSONValue = field(hash=False) + sender: DurableMessageSender + # RFC 3339, as Volcano recorded it. + sent_at: str + + +@dataclass(frozen=True, slots=True) +class PublishedMessage: + """What `ctx.publish` returns.""" + + id: str + # Orders the execution's messages; list the ones after it with `after`. + seq: int + + @dataclass(frozen=True, slots=True) class BatchFailure: """Why one item of a batch failed. diff --git a/src/volcano_sdk/_generated/api/auth_admin/get_auth_insights.py b/src/volcano_sdk/_generated/api/auth_admin/get_auth_insights.py index e5f6b001..655af8b1 100644 --- a/src/volcano_sdk/_generated/api/auth_admin/get_auth_insights.py +++ b/src/volcano_sdk/_generated/api/auth_admin/get_auth_insights.py @@ -134,14 +134,22 @@ def sync_detailed( """ Get auth user insights Returns current and deleted auth-user totals, rolling 24-hour and 30-day - active users, and zero-filled signup, deletion, net-growth, and successful - sign-in counts for an inclusive UTC date range. Weeks start on Monday. - Signups remain counted after deletion; net growth is signups minus - deletions. Deleted users contribute to active counts until their last - activity leaves the rolling window. Token refreshes affect active users - but not the sign-in series. Collection starts at deployment, with signup - history backfilled from accounts still present then. Earlier hard-deleted - accounts cannot be recovered. + active users, and zero-filled daily activity and account-retention counts + for an inclusive UTC date range. Weeks start on Monday. + The existing deletions series counts accounts by deletion date; net_growth + remains signups minus deletions and is not affected by bans. + The banned_users, deleted_users, and available_users fields describe the + current state of users registered in each bucket. Banned users exclude + deleted users and expired bans. Available users are signups minus + banned_users minus deleted_users. Unban and expiry restore availability in + the original signup bucket. These current-state cohorts can change after + the requested dates have passed, without rewriting activity counts. + Sign-in counts remain grouped by session creation date. Deleted users + contribute to active counts until their last activity leaves the rolling + window. Token refreshes affect active users but not the sign-in series. + Signup history is backfilled from accounts present when collection was + deployed. Accounts hard-deleted before deletion-history collection cannot + be recovered. Current bans require no separate event-history collection. Args: id (UUID): @@ -184,14 +192,22 @@ def sync( """ Get auth user insights Returns current and deleted auth-user totals, rolling 24-hour and 30-day - active users, and zero-filled signup, deletion, net-growth, and successful - sign-in counts for an inclusive UTC date range. Weeks start on Monday. - Signups remain counted after deletion; net growth is signups minus - deletions. Deleted users contribute to active counts until their last - activity leaves the rolling window. Token refreshes affect active users - but not the sign-in series. Collection starts at deployment, with signup - history backfilled from accounts still present then. Earlier hard-deleted - accounts cannot be recovered. + active users, and zero-filled daily activity and account-retention counts + for an inclusive UTC date range. Weeks start on Monday. + The existing deletions series counts accounts by deletion date; net_growth + remains signups minus deletions and is not affected by bans. + The banned_users, deleted_users, and available_users fields describe the + current state of users registered in each bucket. Banned users exclude + deleted users and expired bans. Available users are signups minus + banned_users minus deleted_users. Unban and expiry restore availability in + the original signup bucket. These current-state cohorts can change after + the requested dates have passed, without rewriting activity counts. + Sign-in counts remain grouped by session creation date. Deleted users + contribute to active counts until their last activity leaves the rolling + window. Token refreshes affect active users but not the sign-in series. + Signup history is backfilled from accounts present when collection was + deployed. Accounts hard-deleted before deletion-history collection cannot + be recovered. Current bans require no separate event-history collection. Args: id (UUID): @@ -229,14 +245,22 @@ async def asyncio_detailed( """ Get auth user insights Returns current and deleted auth-user totals, rolling 24-hour and 30-day - active users, and zero-filled signup, deletion, net-growth, and successful - sign-in counts for an inclusive UTC date range. Weeks start on Monday. - Signups remain counted after deletion; net growth is signups minus - deletions. Deleted users contribute to active counts until their last - activity leaves the rolling window. Token refreshes affect active users - but not the sign-in series. Collection starts at deployment, with signup - history backfilled from accounts still present then. Earlier hard-deleted - accounts cannot be recovered. + active users, and zero-filled daily activity and account-retention counts + for an inclusive UTC date range. Weeks start on Monday. + The existing deletions series counts accounts by deletion date; net_growth + remains signups minus deletions and is not affected by bans. + The banned_users, deleted_users, and available_users fields describe the + current state of users registered in each bucket. Banned users exclude + deleted users and expired bans. Available users are signups minus + banned_users minus deleted_users. Unban and expiry restore availability in + the original signup bucket. These current-state cohorts can change after + the requested dates have passed, without rewriting activity counts. + Sign-in counts remain grouped by session creation date. Deleted users + contribute to active counts until their last activity leaves the rolling + window. Token refreshes affect active users but not the sign-in series. + Signup history is backfilled from accounts present when collection was + deployed. Accounts hard-deleted before deletion-history collection cannot + be recovered. Current bans require no separate event-history collection. Args: id (UUID): @@ -279,14 +303,22 @@ async def asyncio( """ Get auth user insights Returns current and deleted auth-user totals, rolling 24-hour and 30-day - active users, and zero-filled signup, deletion, net-growth, and successful - sign-in counts for an inclusive UTC date range. Weeks start on Monday. - Signups remain counted after deletion; net growth is signups minus - deletions. Deleted users contribute to active counts until their last - activity leaves the rolling window. Token refreshes affect active users - but not the sign-in series. Collection starts at deployment, with signup - history backfilled from accounts still present then. Earlier hard-deleted - accounts cannot be recovered. + active users, and zero-filled daily activity and account-retention counts + for an inclusive UTC date range. Weeks start on Monday. + The existing deletions series counts accounts by deletion date; net_growth + remains signups minus deletions and is not affected by bans. + The banned_users, deleted_users, and available_users fields describe the + current state of users registered in each bucket. Banned users exclude + deleted users and expired bans. Available users are signups minus + banned_users minus deleted_users. Unban and expiry restore availability in + the original signup bucket. These current-state cohorts can change after + the requested dates have passed, without rewriting activity counts. + Sign-in counts remain grouped by session creation date. Deleted users + contribute to active counts until their last activity leaves the rolling + window. Token refreshes affect active users but not the sign-in series. + Signup history is backfilled from accounts present when collection was + deployed. Accounts hard-deleted before deletion-history collection cannot + be recovered. Current bans require no separate event-history collection. Args: id (UUID): diff --git a/src/volcano_sdk/_generated/api/auth_configuration/test_email_config.py b/src/volcano_sdk/_generated/api/auth_configuration/test_email_config.py index ba7fe59c..c65d5579 100644 --- a/src/volcano_sdk/_generated/api/auth_configuration/test_email_config.py +++ b/src/volcano_sdk/_generated/api/auth_configuration/test_email_config.py @@ -105,7 +105,8 @@ def sync_detailed( `subject` field is ignored. Sending `subject` alone (no bodies) is rejected with 400 to avoid a silently-dropped subject or a blank message. Also rejects with 400 if - `email_enabled=false` or `smtp_host` is empty. + `email_enabled=false`, `smtp_host` is empty, or the saved + `smtp_password` can't be read and must be set again. Args: id (UUID): @@ -159,7 +160,8 @@ def sync( `subject` field is ignored. Sending `subject` alone (no bodies) is rejected with 400 to avoid a silently-dropped subject or a blank message. Also rejects with 400 if - `email_enabled=false` or `smtp_host` is empty. + `email_enabled=false`, `smtp_host` is empty, or the saved + `smtp_password` can't be read and must be set again. Args: id (UUID): @@ -208,7 +210,8 @@ async def asyncio_detailed( `subject` field is ignored. Sending `subject` alone (no bodies) is rejected with 400 to avoid a silently-dropped subject or a blank message. Also rejects with 400 if - `email_enabled=false` or `smtp_host` is empty. + `email_enabled=false`, `smtp_host` is empty, or the saved + `smtp_password` can't be read and must be set again. Args: id (UUID): @@ -262,7 +265,8 @@ async def asyncio( `subject` field is ignored. Sending `subject` alone (no bodies) is rejected with 400 to avoid a silently-dropped subject or a blank message. Also rejects with 400 if - `email_enabled=false` or `smtp_host` is empty. + `email_enabled=false`, `smtp_host` is empty, or the saved + `smtp_password` can't be read and must be set again. Args: id (UUID): diff --git a/src/volcano_sdk/_generated/api/durable_functions/list_durable_message_waits.py b/src/volcano_sdk/_generated/api/durable_functions/list_durable_message_waits.py new file mode 100644 index 00000000..205aa25e --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/list_durable_message_waits.py @@ -0,0 +1,226 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_message_wait_list import DurableMessageWaitList +from ...models.error import Error +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + +) -> dict[str, Any]: + + + + + + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/projects/{id}/durable-functions/{function_id}/executions/{execution_id}/message-waits".format(id=quote(str(id), safe=""),function_id=quote(str(function_id), safe=""),execution_id=quote(str(execution_id), safe=""),), + } + + + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableMessageWaitList | Error | None: + if response.status_code == 200: + response_200 = DurableMessageWaitList.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableMessageWaitList | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Response[DurableMessageWaitList | Error]: + """ List a durable execution's open message waits + + Lists the waits an execution has open in `ctx.waitForMessage`, oldest + first, so you can see which message names it is waiting for. + + Project access tokens can list waits, including read-only ones. + + Args: + id (UUID): + function_id (str): + execution_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessageWaitList | Error] + """ + + + kwargs = request_kwargs( + id=id, +function_id=function_id, +execution_id=execution_id, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> DurableMessageWaitList | Error | None: + """ List a durable execution's open message waits + + Lists the waits an execution has open in `ctx.waitForMessage`, oldest + first, so you can see which message names it is waiting for. + + Project access tokens can list waits, including read-only ones. + + Args: + id (UUID): + function_id (str): + execution_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessageWaitList | Error + """ + + + return sync_detailed( + id=id, +function_id=function_id, +execution_id=execution_id, +client=client, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Response[DurableMessageWaitList | Error]: + """ List a durable execution's open message waits + + Lists the waits an execution has open in `ctx.waitForMessage`, oldest + first, so you can see which message names it is waiting for. + + Project access tokens can list waits, including read-only ones. + + Args: + id (UUID): + function_id (str): + execution_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessageWaitList | Error] + """ + + + kwargs = request_kwargs( + id=id, +function_id=function_id, +execution_id=execution_id, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> DurableMessageWaitList | Error | None: + """ List a durable execution's open message waits + + Lists the waits an execution has open in `ctx.waitForMessage`, oldest + first, so you can see which message names it is waiting for. + + Project access tokens can list waits, including read-only ones. + + Args: + id (UUID): + function_id (str): + execution_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessageWaitList | Error + """ + + + return (await asyncio_detailed( + id=id, +function_id=function_id, +execution_id=execution_id, +client=client, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/list_durable_messages.py b/src/volcano_sdk/_generated/api/durable_functions/list_durable_messages.py new file mode 100644 index 00000000..d0fa92d5 --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/list_durable_messages.py @@ -0,0 +1,322 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_message_direction import check_durable_message_direction +from ...models.durable_message_direction import DurableMessageDirection +from ...models.durable_message_list import DurableMessageList +from ...models.error import Error +from ...types import UNSET, Unset +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + direction: DurableMessageDirection | Unset = UNSET, + name: str | Unset = UNSET, + after: int | Unset = 0, + limit: int | Unset = 100, + +) -> dict[str, Any]: + + + + + params: dict[str, Any] = {} + + json_direction: str | Unset = UNSET + if not isinstance(direction, Unset): + json_direction = direction + + params["direction"] = json_direction + + params["name"] = name + + params["after"] = after + + params["limit"] = limit + + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/projects/{id}/durable-functions/{function_id}/executions/{execution_id}/messages".format(id=quote(str(id), safe=""),function_id=quote(str(function_id), safe=""),execution_id=quote(str(execution_id), safe=""),), + "params": params, + } + + + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableMessageList | Error | None: + if response.status_code == 200: + response_200 = DurableMessageList.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableMessageList | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + direction: DurableMessageDirection | Unset = UNSET, + name: str | Unset = UNSET, + after: int | Unset = 0, + limit: int | Unset = 100, + +) -> Response[DurableMessageList | Error]: + """ List a durable execution's messages + + Lists the messages sent to and published by an execution, oldest + first. Pass the last `seq` you read as `after` to read only what came + next. + + Project access tokens can list messages, including read-only ones. + + Args: + id (UUID): + function_id (str): + execution_id (UUID): + direction (DurableMessageDirection | Unset): `in` is a message sent to the execution. + `out` is one the execution + published. + name (str | Unset): The name a workflow waits on or publishes under: up to 237 printable + ASCII characters. + after (int | Unset): Default: 0. + limit (int | Unset): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessageList | Error] + """ + + + kwargs = request_kwargs( + id=id, +function_id=function_id, +execution_id=execution_id, +direction=direction, +name=name, +after=after, +limit=limit, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + direction: DurableMessageDirection | Unset = UNSET, + name: str | Unset = UNSET, + after: int | Unset = 0, + limit: int | Unset = 100, + +) -> DurableMessageList | Error | None: + """ List a durable execution's messages + + Lists the messages sent to and published by an execution, oldest + first. Pass the last `seq` you read as `after` to read only what came + next. + + Project access tokens can list messages, including read-only ones. + + Args: + id (UUID): + function_id (str): + execution_id (UUID): + direction (DurableMessageDirection | Unset): `in` is a message sent to the execution. + `out` is one the execution + published. + name (str | Unset): The name a workflow waits on or publishes under: up to 237 printable + ASCII characters. + after (int | Unset): Default: 0. + limit (int | Unset): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessageList | Error + """ + + + return sync_detailed( + id=id, +function_id=function_id, +execution_id=execution_id, +client=client, +direction=direction, +name=name, +after=after, +limit=limit, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + direction: DurableMessageDirection | Unset = UNSET, + name: str | Unset = UNSET, + after: int | Unset = 0, + limit: int | Unset = 100, + +) -> Response[DurableMessageList | Error]: + """ List a durable execution's messages + + Lists the messages sent to and published by an execution, oldest + first. Pass the last `seq` you read as `after` to read only what came + next. + + Project access tokens can list messages, including read-only ones. + + Args: + id (UUID): + function_id (str): + execution_id (UUID): + direction (DurableMessageDirection | Unset): `in` is a message sent to the execution. + `out` is one the execution + published. + name (str | Unset): The name a workflow waits on or publishes under: up to 237 printable + ASCII characters. + after (int | Unset): Default: 0. + limit (int | Unset): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessageList | Error] + """ + + + kwargs = request_kwargs( + id=id, +function_id=function_id, +execution_id=execution_id, +direction=direction, +name=name, +after=after, +limit=limit, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + direction: DurableMessageDirection | Unset = UNSET, + name: str | Unset = UNSET, + after: int | Unset = 0, + limit: int | Unset = 100, + +) -> DurableMessageList | Error | None: + """ List a durable execution's messages + + Lists the messages sent to and published by an execution, oldest + first. Pass the last `seq` you read as `after` to read only what came + next. + + Project access tokens can list messages, including read-only ones. + + Args: + id (UUID): + function_id (str): + execution_id (UUID): + direction (DurableMessageDirection | Unset): `in` is a message sent to the execution. + `out` is one the execution + published. + name (str | Unset): The name a workflow waits on or publishes under: up to 237 printable + ASCII characters. + after (int | Unset): Default: 0. + limit (int | Unset): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessageList | Error + """ + + + return (await asyncio_detailed( + id=id, +function_id=function_id, +execution_id=execution_id, +client=client, +direction=direction, +name=name, +after=after, +limit=limit, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/list_durable_messages_from_application.py b/src/volcano_sdk/_generated/api/durable_functions/list_durable_messages_from_application.py new file mode 100644 index 00000000..06cbd6ca --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/list_durable_messages_from_application.py @@ -0,0 +1,345 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_message_direction import check_durable_message_direction +from ...models.durable_message_direction import DurableMessageDirection +from ...models.durable_message_list import DurableMessageList +from ...models.error import Error +from ...types import UNSET, Unset +from typing import cast +from uuid import UUID + + + +def request_kwargs( + function_id: str, + execution_id: UUID | str, + *, + direction: DurableMessageDirection | Unset = UNSET, + name: str | Unset = UNSET, + after: int | Unset = 0, + limit: int | Unset = 100, + x_volcano_client_token: str | Unset = UNSET, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + if not isinstance(x_volcano_client_token, Unset): + headers["X-Volcano-Client-Token"] = x_volcano_client_token + + + + + + params: dict[str, Any] = {} + + json_direction: str | Unset = UNSET + if not isinstance(direction, Unset): + json_direction = direction + + params["direction"] = json_direction + + params["name"] = name + + params["after"] = after + + params["limit"] = limit + + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/durable-functions/{function_id}/executions/{execution_id}/messages".format(function_id=quote(str(function_id), safe=""),execution_id=quote(str(execution_id), safe=""),), + "params": params, + } + + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableMessageList | Error | None: + if response.status_code == 200: + response_200 = DurableMessageList.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableMessageList | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + direction: DurableMessageDirection | Unset = UNSET, + name: str | Unset = UNSET, + after: int | Unset = 0, + limit: int | Unset = 100, + x_volcano_client_token: str | Unset = UNSET, + +) -> Response[DurableMessageList | Error]: + """ List a durable execution's messages from an application + + Lists the messages sent to and published by an execution, oldest + first. Pass the last `seq` you read as `after` to read only what came + next; that is how a client catches up after a live subscription + reconnects. + + Access follows the same rules as sending. + + Args: + function_id (str): + execution_id (UUID): + direction (DurableMessageDirection | Unset): `in` is a message sent to the execution. + `out` is one the execution + published. + name (str | Unset): The name a workflow waits on or publishes under: up to 237 printable + ASCII characters. + after (int | Unset): Default: 0. + limit (int | Unset): Default: 100. + x_volcano_client_token (str | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessageList | Error] + """ + + + kwargs = request_kwargs( + function_id=function_id, +execution_id=execution_id, +direction=direction, +name=name, +after=after, +limit=limit, +x_volcano_client_token=x_volcano_client_token, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + direction: DurableMessageDirection | Unset = UNSET, + name: str | Unset = UNSET, + after: int | Unset = 0, + limit: int | Unset = 100, + x_volcano_client_token: str | Unset = UNSET, + +) -> DurableMessageList | Error | None: + """ List a durable execution's messages from an application + + Lists the messages sent to and published by an execution, oldest + first. Pass the last `seq` you read as `after` to read only what came + next; that is how a client catches up after a live subscription + reconnects. + + Access follows the same rules as sending. + + Args: + function_id (str): + execution_id (UUID): + direction (DurableMessageDirection | Unset): `in` is a message sent to the execution. + `out` is one the execution + published. + name (str | Unset): The name a workflow waits on or publishes under: up to 237 printable + ASCII characters. + after (int | Unset): Default: 0. + limit (int | Unset): Default: 100. + x_volcano_client_token (str | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessageList | Error + """ + + + return sync_detailed( + function_id=function_id, +execution_id=execution_id, +client=client, +direction=direction, +name=name, +after=after, +limit=limit, +x_volcano_client_token=x_volcano_client_token, + + ).parsed + +async def asyncio_detailed( + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + direction: DurableMessageDirection | Unset = UNSET, + name: str | Unset = UNSET, + after: int | Unset = 0, + limit: int | Unset = 100, + x_volcano_client_token: str | Unset = UNSET, + +) -> Response[DurableMessageList | Error]: + """ List a durable execution's messages from an application + + Lists the messages sent to and published by an execution, oldest + first. Pass the last `seq` you read as `after` to read only what came + next; that is how a client catches up after a live subscription + reconnects. + + Access follows the same rules as sending. + + Args: + function_id (str): + execution_id (UUID): + direction (DurableMessageDirection | Unset): `in` is a message sent to the execution. + `out` is one the execution + published. + name (str | Unset): The name a workflow waits on or publishes under: up to 237 printable + ASCII characters. + after (int | Unset): Default: 0. + limit (int | Unset): Default: 100. + x_volcano_client_token (str | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessageList | Error] + """ + + + kwargs = request_kwargs( + function_id=function_id, +execution_id=execution_id, +direction=direction, +name=name, +after=after, +limit=limit, +x_volcano_client_token=x_volcano_client_token, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + direction: DurableMessageDirection | Unset = UNSET, + name: str | Unset = UNSET, + after: int | Unset = 0, + limit: int | Unset = 100, + x_volcano_client_token: str | Unset = UNSET, + +) -> DurableMessageList | Error | None: + """ List a durable execution's messages from an application + + Lists the messages sent to and published by an execution, oldest + first. Pass the last `seq` you read as `after` to read only what came + next; that is how a client catches up after a live subscription + reconnects. + + Access follows the same rules as sending. + + Args: + function_id (str): + execution_id (UUID): + direction (DurableMessageDirection | Unset): `in` is a message sent to the execution. + `out` is one the execution + published. + name (str | Unset): The name a workflow waits on or publishes under: up to 237 printable + ASCII characters. + after (int | Unset): Default: 0. + limit (int | Unset): Default: 100. + x_volcano_client_token (str | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessageList | Error + """ + + + return (await asyncio_detailed( + function_id=function_id, +execution_id=execution_id, +client=client, +direction=direction, +name=name, +after=after, +limit=limit, +x_volcano_client_token=x_volcano_client_token, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/open_durable_message_session.py b/src/volcano_sdk/_generated/api/durable_functions/open_durable_message_session.py new file mode 100644 index 00000000..91c5c8ad --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/open_durable_message_session.py @@ -0,0 +1,272 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_message_session_registration import DurableMessageSessionRegistration +from ...models.error import Error +from ...models.open_durable_message_session_request import OpenDurableMessageSessionRequest +from typing import cast + + + +def request_kwargs( + *, + body: OpenDurableMessageSessionRequest, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/durable-messages/sessions", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableMessageSessionRegistration | Error | None: + if response.status_code == 200: + response_200 = DurableMessageSessionRegistration.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 201: + response_201 = DurableMessageSessionRegistration.from_dict(response.json()) + + + + return response_201 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if response.status_code == 413: + response_413 = Error.from_dict(response.json()) + + + + return response_413 + + if response.status_code == 429: + response_429 = Error.from_dict(response.json()) + + + + return response_429 + + if response.status_code == 503: + response_503 = Error.from_dict(response.json()) + + + + return response_503 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableMessageSessionRegistration | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: OpenDurableMessageSessionRequest, + +) -> Response[DurableMessageSessionRegistration | Error]: + """ Open a messages session from inside a workflow + + Opens the session a workflow publishes messages with. The Volcano SDK + calls this once per workflow context, on its first `ctx.publish`; + applications do not call it directly. + + As for a wait, `execution_ref` and `callback_id` are the credential. + The session's token is delivered to the workflow itself and never + appears in a response. + + Retrying is safe: a request for a session that is already registered + returns it with `200`. + + Args: + body (OpenDurableMessageSessionRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessageSessionRegistration | Error] + """ + + + kwargs = request_kwargs( + body=body, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + *, + client: AuthenticatedClient | Client, + body: OpenDurableMessageSessionRequest, + +) -> DurableMessageSessionRegistration | Error | None: + """ Open a messages session from inside a workflow + + Opens the session a workflow publishes messages with. The Volcano SDK + calls this once per workflow context, on its first `ctx.publish`; + applications do not call it directly. + + As for a wait, `execution_ref` and `callback_id` are the credential. + The session's token is delivered to the workflow itself and never + appears in a response. + + Retrying is safe: a request for a session that is already registered + returns it with `200`. + + Args: + body (OpenDurableMessageSessionRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessageSessionRegistration | Error + """ + + + return sync_detailed( + client=client, +body=body, + + ).parsed + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: OpenDurableMessageSessionRequest, + +) -> Response[DurableMessageSessionRegistration | Error]: + """ Open a messages session from inside a workflow + + Opens the session a workflow publishes messages with. The Volcano SDK + calls this once per workflow context, on its first `ctx.publish`; + applications do not call it directly. + + As for a wait, `execution_ref` and `callback_id` are the credential. + The session's token is delivered to the workflow itself and never + appears in a response. + + Retrying is safe: a request for a session that is already registered + returns it with `200`. + + Args: + body (OpenDurableMessageSessionRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessageSessionRegistration | Error] + """ + + + kwargs = request_kwargs( + body=body, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: OpenDurableMessageSessionRequest, + +) -> DurableMessageSessionRegistration | Error | None: + """ Open a messages session from inside a workflow + + Opens the session a workflow publishes messages with. The Volcano SDK + calls this once per workflow context, on its first `ctx.publish`; + applications do not call it directly. + + As for a wait, `execution_ref` and `callback_id` are the credential. + The session's token is delivered to the workflow itself and never + appears in a response. + + Retrying is safe: a request for a session that is already registered + returns it with `200`. + + Args: + body (OpenDurableMessageSessionRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessageSessionRegistration | Error + """ + + + return (await asyncio_detailed( + client=client, +body=body, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/publish_durable_message.py b/src/volcano_sdk/_generated/api/durable_functions/publish_durable_message.py new file mode 100644 index 00000000..de29d303 --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/publish_durable_message.py @@ -0,0 +1,290 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_message import DurableMessage +from ...models.error import Error +from ...models.publish_durable_message_request import PublishDurableMessageRequest +from ...types import UNSET, Unset +from typing import cast + + + +def request_kwargs( + *, + body: PublishDurableMessageRequest, + x_volcano_durable_session: str, + idempotency_key: str | Unset = UNSET, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + headers["X-Volcano-Durable-Session"] = x_volcano_durable_session + + if not isinstance(idempotency_key, Unset): + headers["Idempotency-Key"] = idempotency_key + + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/durable-messages/publish", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableMessage | Error | None: + if response.status_code == 200: + response_200 = DurableMessage.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 201: + response_201 = DurableMessage.from_dict(response.json()) + + + + return response_201 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + + + return response_401 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if response.status_code == 413: + response_413 = Error.from_dict(response.json()) + + + + return response_413 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableMessage | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: PublishDurableMessageRequest, + x_volcano_durable_session: str, + idempotency_key: str | Unset = UNSET, + +) -> Response[DurableMessage | Error]: + """ Publish a message from inside a workflow + + Publishes a named message from a running workflow. The Volcano SDK + calls this from `ctx.publish`; applications do not call it directly. + + The message is stored, listed with the execution's messages, and + delivered live to clients subscribed to the execution. Live delivery + counts against the project's Realtime messages. + + The SDK sends an `Idempotency-Key` that a rerun of the publishing step + repeats, so a step that runs again returns the first message with + `200` and publishes nothing. + + Args: + x_volcano_durable_session (str): + idempotency_key (str | Unset): + body (PublishDurableMessageRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessage | Error] + """ + + + kwargs = request_kwargs( + body=body, +x_volcano_durable_session=x_volcano_durable_session, +idempotency_key=idempotency_key, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + *, + client: AuthenticatedClient | Client, + body: PublishDurableMessageRequest, + x_volcano_durable_session: str, + idempotency_key: str | Unset = UNSET, + +) -> DurableMessage | Error | None: + """ Publish a message from inside a workflow + + Publishes a named message from a running workflow. The Volcano SDK + calls this from `ctx.publish`; applications do not call it directly. + + The message is stored, listed with the execution's messages, and + delivered live to clients subscribed to the execution. Live delivery + counts against the project's Realtime messages. + + The SDK sends an `Idempotency-Key` that a rerun of the publishing step + repeats, so a step that runs again returns the first message with + `200` and publishes nothing. + + Args: + x_volcano_durable_session (str): + idempotency_key (str | Unset): + body (PublishDurableMessageRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessage | Error + """ + + + return sync_detailed( + client=client, +body=body, +x_volcano_durable_session=x_volcano_durable_session, +idempotency_key=idempotency_key, + + ).parsed + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: PublishDurableMessageRequest, + x_volcano_durable_session: str, + idempotency_key: str | Unset = UNSET, + +) -> Response[DurableMessage | Error]: + """ Publish a message from inside a workflow + + Publishes a named message from a running workflow. The Volcano SDK + calls this from `ctx.publish`; applications do not call it directly. + + The message is stored, listed with the execution's messages, and + delivered live to clients subscribed to the execution. Live delivery + counts against the project's Realtime messages. + + The SDK sends an `Idempotency-Key` that a rerun of the publishing step + repeats, so a step that runs again returns the first message with + `200` and publishes nothing. + + Args: + x_volcano_durable_session (str): + idempotency_key (str | Unset): + body (PublishDurableMessageRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessage | Error] + """ + + + kwargs = request_kwargs( + body=body, +x_volcano_durable_session=x_volcano_durable_session, +idempotency_key=idempotency_key, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: PublishDurableMessageRequest, + x_volcano_durable_session: str, + idempotency_key: str | Unset = UNSET, + +) -> DurableMessage | Error | None: + """ Publish a message from inside a workflow + + Publishes a named message from a running workflow. The Volcano SDK + calls this from `ctx.publish`; applications do not call it directly. + + The message is stored, listed with the execution's messages, and + delivered live to clients subscribed to the execution. Live delivery + counts against the project's Realtime messages. + + The SDK sends an `Idempotency-Key` that a rerun of the publishing step + repeats, so a step that runs again returns the first message with + `200` and publishes nothing. + + Args: + x_volcano_durable_session (str): + idempotency_key (str | Unset): + body (PublishDurableMessageRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessage | Error + """ + + + return (await asyncio_detailed( + client=client, +body=body, +x_volcano_durable_session=x_volcano_durable_session, +idempotency_key=idempotency_key, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/register_durable_message_wait.py b/src/volcano_sdk/_generated/api/durable_functions/register_durable_message_wait.py new file mode 100644 index 00000000..e5d4fc2e --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/register_durable_message_wait.py @@ -0,0 +1,272 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_message_wait_registration import DurableMessageWaitRegistration +from ...models.error import Error +from ...models.register_durable_message_wait_request import RegisterDurableMessageWaitRequest +from typing import cast + + + +def request_kwargs( + *, + body: RegisterDurableMessageWaitRequest, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/durable-messages/waits", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableMessageWaitRegistration | Error | None: + if response.status_code == 200: + response_200 = DurableMessageWaitRegistration.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 201: + response_201 = DurableMessageWaitRegistration.from_dict(response.json()) + + + + return response_201 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if response.status_code == 413: + response_413 = Error.from_dict(response.json()) + + + + return response_413 + + if response.status_code == 429: + response_429 = Error.from_dict(response.json()) + + + + return response_429 + + if response.status_code == 503: + response_503 = Error.from_dict(response.json()) + + + + return response_503 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableMessageWaitRegistration | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: RegisterDurableMessageWaitRequest, + +) -> Response[DurableMessageWaitRegistration | Error]: + """ Wait for a message from inside a workflow + + Registers a wait for a named message. The Volcano SDK calls this from + `ctx.waitForMessage`; applications do not call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. When a message for the + name is already queued, the wait takes the oldest one at once. + + Retrying is safe: a request for a wait that is already registered + returns it with `200`. + + Args: + body (RegisterDurableMessageWaitRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessageWaitRegistration | Error] + """ + + + kwargs = request_kwargs( + body=body, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + *, + client: AuthenticatedClient | Client, + body: RegisterDurableMessageWaitRequest, + +) -> DurableMessageWaitRegistration | Error | None: + """ Wait for a message from inside a workflow + + Registers a wait for a named message. The Volcano SDK calls this from + `ctx.waitForMessage`; applications do not call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. When a message for the + name is already queued, the wait takes the oldest one at once. + + Retrying is safe: a request for a wait that is already registered + returns it with `200`. + + Args: + body (RegisterDurableMessageWaitRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessageWaitRegistration | Error + """ + + + return sync_detailed( + client=client, +body=body, + + ).parsed + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: RegisterDurableMessageWaitRequest, + +) -> Response[DurableMessageWaitRegistration | Error]: + """ Wait for a message from inside a workflow + + Registers a wait for a named message. The Volcano SDK calls this from + `ctx.waitForMessage`; applications do not call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. When a message for the + name is already queued, the wait takes the oldest one at once. + + Retrying is safe: a request for a wait that is already registered + returns it with `200`. + + Args: + body (RegisterDurableMessageWaitRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessageWaitRegistration | Error] + """ + + + kwargs = request_kwargs( + body=body, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: RegisterDurableMessageWaitRequest, + +) -> DurableMessageWaitRegistration | Error | None: + """ Wait for a message from inside a workflow + + Registers a wait for a named message. The Volcano SDK calls this from + `ctx.waitForMessage`; applications do not call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. When a message for the + name is already queued, the wait takes the oldest one at once. + + Retrying is safe: a request for a wait that is already registered + returns it with `200`. + + Args: + body (RegisterDurableMessageWaitRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessageWaitRegistration | Error + """ + + + return (await asyncio_detailed( + client=client, +body=body, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/send_durable_message.py b/src/volcano_sdk/_generated/api/durable_functions/send_durable_message.py new file mode 100644 index 00000000..3733c0ca --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/send_durable_message.py @@ -0,0 +1,314 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_message import DurableMessage +from ...models.error import Error +from ...models.send_durable_message_request import SendDurableMessageRequest +from ...types import UNSET, Unset +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + body: SendDurableMessageRequest, + idempotency_key: str | Unset = UNSET, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + if not isinstance(idempotency_key, Unset): + headers["Idempotency-Key"] = idempotency_key + + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/projects/{id}/durable-functions/{function_id}/executions/{execution_id}/messages".format(id=quote(str(id), safe=""),function_id=quote(str(function_id), safe=""),execution_id=quote(str(execution_id), safe=""),), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableMessage | Error | None: + if response.status_code == 200: + response_200 = DurableMessage.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 201: + response_201 = DurableMessage.from_dict(response.json()) + + + + return response_201 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if response.status_code == 413: + response_413 = Error.from_dict(response.json()) + + + + return response_413 + + if response.status_code == 429: + response_429 = Error.from_dict(response.json()) + + + + return response_429 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableMessage | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + body: SendDurableMessageRequest, + idempotency_key: str | Unset = UNSET, + +) -> Response[DurableMessage | Error]: + """ Send a message to a durable execution + + Sends a named message to a running execution, as + `POST /durable-functions/{functionId}/executions/{executionId}/messages` + does for an application. The workflow receives it from + `ctx.waitForMessage` with the same name; until then it is kept, and + waits take messages oldest first. + + Project access tokens can send messages, but read-only ones cannot. + A message never decides an approval. + + Args: + id (UUID): + function_id (str): + execution_id (UUID): + idempotency_key (str | Unset): + body (SendDurableMessageRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessage | Error] + """ + + + kwargs = request_kwargs( + id=id, +function_id=function_id, +execution_id=execution_id, +body=body, +idempotency_key=idempotency_key, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + body: SendDurableMessageRequest, + idempotency_key: str | Unset = UNSET, + +) -> DurableMessage | Error | None: + """ Send a message to a durable execution + + Sends a named message to a running execution, as + `POST /durable-functions/{functionId}/executions/{executionId}/messages` + does for an application. The workflow receives it from + `ctx.waitForMessage` with the same name; until then it is kept, and + waits take messages oldest first. + + Project access tokens can send messages, but read-only ones cannot. + A message never decides an approval. + + Args: + id (UUID): + function_id (str): + execution_id (UUID): + idempotency_key (str | Unset): + body (SendDurableMessageRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessage | Error + """ + + + return sync_detailed( + id=id, +function_id=function_id, +execution_id=execution_id, +client=client, +body=body, +idempotency_key=idempotency_key, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + body: SendDurableMessageRequest, + idempotency_key: str | Unset = UNSET, + +) -> Response[DurableMessage | Error]: + """ Send a message to a durable execution + + Sends a named message to a running execution, as + `POST /durable-functions/{functionId}/executions/{executionId}/messages` + does for an application. The workflow receives it from + `ctx.waitForMessage` with the same name; until then it is kept, and + waits take messages oldest first. + + Project access tokens can send messages, but read-only ones cannot. + A message never decides an approval. + + Args: + id (UUID): + function_id (str): + execution_id (UUID): + idempotency_key (str | Unset): + body (SendDurableMessageRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessage | Error] + """ + + + kwargs = request_kwargs( + id=id, +function_id=function_id, +execution_id=execution_id, +body=body, +idempotency_key=idempotency_key, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + body: SendDurableMessageRequest, + idempotency_key: str | Unset = UNSET, + +) -> DurableMessage | Error | None: + """ Send a message to a durable execution + + Sends a named message to a running execution, as + `POST /durable-functions/{functionId}/executions/{executionId}/messages` + does for an application. The workflow receives it from + `ctx.waitForMessage` with the same name; until then it is kept, and + waits take messages oldest first. + + Project access tokens can send messages, but read-only ones cannot. + A message never decides an approval. + + Args: + id (UUID): + function_id (str): + execution_id (UUID): + idempotency_key (str | Unset): + body (SendDurableMessageRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessage | Error + """ + + + return (await asyncio_detailed( + id=id, +function_id=function_id, +execution_id=execution_id, +client=client, +body=body, +idempotency_key=idempotency_key, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/send_durable_message_from_application.py b/src/volcano_sdk/_generated/api/durable_functions/send_durable_message_from_application.py new file mode 100644 index 00000000..e955a6e8 --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/send_durable_message_from_application.py @@ -0,0 +1,387 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_message import DurableMessage +from ...models.error import Error +from ...models.send_durable_message_request import SendDurableMessageRequest +from ...types import UNSET, Unset +from typing import cast +from uuid import UUID + + + +def request_kwargs( + function_id: str, + execution_id: UUID | str, + *, + body: SendDurableMessageRequest, + idempotency_key: str | Unset = UNSET, + x_volcano_client_token: str | Unset = UNSET, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + if not isinstance(idempotency_key, Unset): + headers["Idempotency-Key"] = idempotency_key + + if not isinstance(x_volcano_client_token, Unset): + headers["X-Volcano-Client-Token"] = x_volcano_client_token + + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/durable-functions/{function_id}/executions/{execution_id}/messages".format(function_id=quote(str(function_id), safe=""),execution_id=quote(str(execution_id), safe=""),), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableMessage | Error | None: + if response.status_code == 200: + response_200 = DurableMessage.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 201: + response_201 = DurableMessage.from_dict(response.json()) + + + + return response_201 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if response.status_code == 413: + response_413 = Error.from_dict(response.json()) + + + + return response_413 + + if response.status_code == 429: + response_429 = Error.from_dict(response.json()) + + + + return response_429 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableMessage | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + body: SendDurableMessageRequest, + idempotency_key: str | Unset = UNSET, + x_volcano_client_token: str | Unset = UNSET, + +) -> Response[DurableMessage | Error]: + """ Send a message to a durable execution from an application + + Sends a named message to a running execution. The workflow receives it + from `ctx.waitForMessage` with the same name. A message sent before + anything waits for its name is kept, and each wait takes the oldest + one, so messages arrive in the order they were sent. + + Messages never decide an approval: a workflow waiting in + `ctx.waitForApproval` is not resumed by one. + + **With a service key:** any execution in the project. + + **With an auth user token:** an execution the user started, or one + whose `X-Volcano-Client-Token` the request carries, of a durable + function whose `visibility` is `authenticated` or `public`. + + **With an anon key:** an execution whose `X-Volcano-Client-Token` the + request carries, of a function with `visibility: public`. + + Any other execution answers `404`, as a missing one does. + + Send `Idempotency-Key` to make a retry safe: a repeat with a key the + execution has seen returns the first message with `200` and sends + nothing. + + Args: + function_id (str): + execution_id (UUID): + idempotency_key (str | Unset): + x_volcano_client_token (str | Unset): + body (SendDurableMessageRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessage | Error] + """ + + + kwargs = request_kwargs( + function_id=function_id, +execution_id=execution_id, +body=body, +idempotency_key=idempotency_key, +x_volcano_client_token=x_volcano_client_token, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + body: SendDurableMessageRequest, + idempotency_key: str | Unset = UNSET, + x_volcano_client_token: str | Unset = UNSET, + +) -> DurableMessage | Error | None: + """ Send a message to a durable execution from an application + + Sends a named message to a running execution. The workflow receives it + from `ctx.waitForMessage` with the same name. A message sent before + anything waits for its name is kept, and each wait takes the oldest + one, so messages arrive in the order they were sent. + + Messages never decide an approval: a workflow waiting in + `ctx.waitForApproval` is not resumed by one. + + **With a service key:** any execution in the project. + + **With an auth user token:** an execution the user started, or one + whose `X-Volcano-Client-Token` the request carries, of a durable + function whose `visibility` is `authenticated` or `public`. + + **With an anon key:** an execution whose `X-Volcano-Client-Token` the + request carries, of a function with `visibility: public`. + + Any other execution answers `404`, as a missing one does. + + Send `Idempotency-Key` to make a retry safe: a repeat with a key the + execution has seen returns the first message with `200` and sends + nothing. + + Args: + function_id (str): + execution_id (UUID): + idempotency_key (str | Unset): + x_volcano_client_token (str | Unset): + body (SendDurableMessageRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessage | Error + """ + + + return sync_detailed( + function_id=function_id, +execution_id=execution_id, +client=client, +body=body, +idempotency_key=idempotency_key, +x_volcano_client_token=x_volcano_client_token, + + ).parsed + +async def asyncio_detailed( + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + body: SendDurableMessageRequest, + idempotency_key: str | Unset = UNSET, + x_volcano_client_token: str | Unset = UNSET, + +) -> Response[DurableMessage | Error]: + """ Send a message to a durable execution from an application + + Sends a named message to a running execution. The workflow receives it + from `ctx.waitForMessage` with the same name. A message sent before + anything waits for its name is kept, and each wait takes the oldest + one, so messages arrive in the order they were sent. + + Messages never decide an approval: a workflow waiting in + `ctx.waitForApproval` is not resumed by one. + + **With a service key:** any execution in the project. + + **With an auth user token:** an execution the user started, or one + whose `X-Volcano-Client-Token` the request carries, of a durable + function whose `visibility` is `authenticated` or `public`. + + **With an anon key:** an execution whose `X-Volcano-Client-Token` the + request carries, of a function with `visibility: public`. + + Any other execution answers `404`, as a missing one does. + + Send `Idempotency-Key` to make a retry safe: a repeat with a key the + execution has seen returns the first message with `200` and sends + nothing. + + Args: + function_id (str): + execution_id (UUID): + idempotency_key (str | Unset): + x_volcano_client_token (str | Unset): + body (SendDurableMessageRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableMessage | Error] + """ + + + kwargs = request_kwargs( + function_id=function_id, +execution_id=execution_id, +body=body, +idempotency_key=idempotency_key, +x_volcano_client_token=x_volcano_client_token, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + function_id: str, + execution_id: UUID | str, + *, + client: AuthenticatedClient, + body: SendDurableMessageRequest, + idempotency_key: str | Unset = UNSET, + x_volcano_client_token: str | Unset = UNSET, + +) -> DurableMessage | Error | None: + """ Send a message to a durable execution from an application + + Sends a named message to a running execution. The workflow receives it + from `ctx.waitForMessage` with the same name. A message sent before + anything waits for its name is kept, and each wait takes the oldest + one, so messages arrive in the order they were sent. + + Messages never decide an approval: a workflow waiting in + `ctx.waitForApproval` is not resumed by one. + + **With a service key:** any execution in the project. + + **With an auth user token:** an execution the user started, or one + whose `X-Volcano-Client-Token` the request carries, of a durable + function whose `visibility` is `authenticated` or `public`. + + **With an anon key:** an execution whose `X-Volcano-Client-Token` the + request carries, of a function with `visibility: public`. + + Any other execution answers `404`, as a missing one does. + + Send `Idempotency-Key` to make a retry safe: a repeat with a key the + execution has seen returns the first message with `200` and sends + nothing. + + Args: + function_id (str): + execution_id (UUID): + idempotency_key (str | Unset): + x_volcano_client_token (str | Unset): + body (SendDurableMessageRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableMessage | Error + """ + + + return (await asyncio_detailed( + function_id=function_id, +execution_id=execution_id, +client=client, +body=body, +idempotency_key=idempotency_key, +x_volcano_client_token=x_volcano_client_token, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/system/list_capabilities.py b/src/volcano_sdk/_generated/api/system/list_capabilities.py new file mode 100644 index 00000000..9acf3c20 --- /dev/null +++ b/src/volcano_sdk/_generated/api/system/list_capabilities.py @@ -0,0 +1,222 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.capability_list import CapabilityList +from typing import cast + + + +def request_kwargs( + +) -> dict[str, Any]: + + + + + + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/capabilities", + } + + + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> CapabilityList | None: + if response.status_code == 200: + response_200 = CapabilityList.from_dict(response.json()) + + + + return response_200 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[CapabilityList]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + +) -> Response[CapabilityList]: + """ List capabilities + + Lists which product capabilities accept new work in this environment. Check it + to decide what to offer, instead of calling a route to see whether it fails. + + | ID | Covers | + | --- | --- | + | `sandboxes` | The Sandbox API: presets, templates, sessions, and executions | + | `sandboxes.sessions` | Starting and resuming sessions, and one-shot executions | + | `sandboxes.custom_templates` | Deploying a custom template; its validation waits while + `sandboxes.sessions` is unavailable | + + A capability missing from the list is unavailable, and a capability under + another, such as `sandboxes.sessions`, is unavailable whenever its parent is. + While a capability is unavailable, requests that start new work answer `404` + or `400` with code `feature_unavailable`; refresh this list when you receive + that code. Reading, deleting, and terminating what already exists keep + working unless the whole feature is absent from the environment. Every caller + in an environment gets the same answer. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[CapabilityList] + """ + + + kwargs = request_kwargs( + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + *, + client: AuthenticatedClient | Client, + +) -> CapabilityList | None: + """ List capabilities + + Lists which product capabilities accept new work in this environment. Check it + to decide what to offer, instead of calling a route to see whether it fails. + + | ID | Covers | + | --- | --- | + | `sandboxes` | The Sandbox API: presets, templates, sessions, and executions | + | `sandboxes.sessions` | Starting and resuming sessions, and one-shot executions | + | `sandboxes.custom_templates` | Deploying a custom template; its validation waits while + `sandboxes.sessions` is unavailable | + + A capability missing from the list is unavailable, and a capability under + another, such as `sandboxes.sessions`, is unavailable whenever its parent is. + While a capability is unavailable, requests that start new work answer `404` + or `400` with code `feature_unavailable`; refresh this list when you receive + that code. Reading, deleting, and terminating what already exists keep + working unless the whole feature is absent from the environment. Every caller + in an environment gets the same answer. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + CapabilityList + """ + + + return sync_detailed( + client=client, + + ).parsed + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + +) -> Response[CapabilityList]: + """ List capabilities + + Lists which product capabilities accept new work in this environment. Check it + to decide what to offer, instead of calling a route to see whether it fails. + + | ID | Covers | + | --- | --- | + | `sandboxes` | The Sandbox API: presets, templates, sessions, and executions | + | `sandboxes.sessions` | Starting and resuming sessions, and one-shot executions | + | `sandboxes.custom_templates` | Deploying a custom template; its validation waits while + `sandboxes.sessions` is unavailable | + + A capability missing from the list is unavailable, and a capability under + another, such as `sandboxes.sessions`, is unavailable whenever its parent is. + While a capability is unavailable, requests that start new work answer `404` + or `400` with code `feature_unavailable`; refresh this list when you receive + that code. Reading, deleting, and terminating what already exists keep + working unless the whole feature is absent from the environment. Every caller + in an environment gets the same answer. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[CapabilityList] + """ + + + kwargs = request_kwargs( + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + *, + client: AuthenticatedClient | Client, + +) -> CapabilityList | None: + """ List capabilities + + Lists which product capabilities accept new work in this environment. Check it + to decide what to offer, instead of calling a route to see whether it fails. + + | ID | Covers | + | --- | --- | + | `sandboxes` | The Sandbox API: presets, templates, sessions, and executions | + | `sandboxes.sessions` | Starting and resuming sessions, and one-shot executions | + | `sandboxes.custom_templates` | Deploying a custom template; its validation waits while + `sandboxes.sessions` is unavailable | + + A capability missing from the list is unavailable, and a capability under + another, such as `sandboxes.sessions`, is unavailable whenever its parent is. + While a capability is unavailable, requests that start new work answer `404` + or `400` with code `feature_unavailable`; refresh this list when you receive + that code. Reading, deleting, and terminating what already exists keep + working unless the whole feature is absent from the environment. Every caller + in an environment gets the same answer. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + CapabilityList + """ + + + return (await asyncio_detailed( + client=client, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/variable_environments/__init__.py b/src/volcano_sdk/_generated/api/variable_environments/__init__.py new file mode 100644 index 00000000..c9921b5f --- /dev/null +++ b/src/volcano_sdk/_generated/api/variable_environments/__init__.py @@ -0,0 +1 @@ +""" Contains endpoint functions for accessing the API """ diff --git a/src/volcano_sdk/_generated/api/variable_environments/create_variable_environment.py b/src/volcano_sdk/_generated/api/variable_environments/create_variable_environment.py new file mode 100644 index 00000000..6197caae --- /dev/null +++ b/src/volcano_sdk/_generated/api/variable_environments/create_variable_environment.py @@ -0,0 +1,247 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.create_variable_environment_request import CreateVariableEnvironmentRequest +from ...models.error import Error +from ...models.variable_environment import VariableEnvironment +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + *, + body: CreateVariableEnvironmentRequest, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/projects/{id}/variable-environments".format(id=quote(str(id), safe=""),), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Error | VariableEnvironment | None: + if response.status_code == 201: + response_201 = VariableEnvironment.from_dict(response.json()) + + + + return response_201 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Error | VariableEnvironment]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + *, + client: AuthenticatedClient, + body: CreateVariableEnvironmentRequest, + +) -> Response[Error | VariableEnvironment]: + """ Create a variable Environment + + Creates a custom variable Environment. Global is reserved and already exists. A Project may have at + most 100 custom Environments. + + Args: + id (UUID): + body (CreateVariableEnvironmentRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | VariableEnvironment] + """ + + + kwargs = request_kwargs( + id=id, +body=body, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + *, + client: AuthenticatedClient, + body: CreateVariableEnvironmentRequest, + +) -> Error | VariableEnvironment | None: + """ Create a variable Environment + + Creates a custom variable Environment. Global is reserved and already exists. A Project may have at + most 100 custom Environments. + + Args: + id (UUID): + body (CreateVariableEnvironmentRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | VariableEnvironment + """ + + + return sync_detailed( + id=id, +client=client, +body=body, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + *, + client: AuthenticatedClient, + body: CreateVariableEnvironmentRequest, + +) -> Response[Error | VariableEnvironment]: + """ Create a variable Environment + + Creates a custom variable Environment. Global is reserved and already exists. A Project may have at + most 100 custom Environments. + + Args: + id (UUID): + body (CreateVariableEnvironmentRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | VariableEnvironment] + """ + + + kwargs = request_kwargs( + id=id, +body=body, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + *, + client: AuthenticatedClient, + body: CreateVariableEnvironmentRequest, + +) -> Error | VariableEnvironment | None: + """ Create a variable Environment + + Creates a custom variable Environment. Global is reserved and already exists. A Project may have at + most 100 custom Environments. + + Args: + id (UUID): + body (CreateVariableEnvironmentRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | VariableEnvironment + """ + + + return (await asyncio_detailed( + id=id, +client=client, +body=body, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/variable_environments/delete_variable_environment.py b/src/volcano_sdk/_generated/api/variable_environments/delete_variable_environment.py new file mode 100644 index 00000000..b033baa3 --- /dev/null +++ b/src/volcano_sdk/_generated/api/variable_environments/delete_variable_environment.py @@ -0,0 +1,225 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.error import Error +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + environment_id: UUID | str, + +) -> dict[str, Any]: + + + + + + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/projects/{id}/variable-environments/{environment_id}".format(id=quote(str(id), safe=""),environment_id=quote(str(environment_id), safe=""),), + } + + + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | Error | None: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + environment_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Response[Any | Error]: + """ Delete a variable Environment + + Deletes a custom Environment. Global cannot be deleted. + + Args: + id (UUID): + environment_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | Error] + """ + + + kwargs = request_kwargs( + id=id, +environment_id=environment_id, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + environment_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Any | Error | None: + """ Delete a variable Environment + + Deletes a custom Environment. Global cannot be deleted. + + Args: + id (UUID): + environment_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | Error + """ + + + return sync_detailed( + id=id, +environment_id=environment_id, +client=client, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + environment_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Response[Any | Error]: + """ Delete a variable Environment + + Deletes a custom Environment. Global cannot be deleted. + + Args: + id (UUID): + environment_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | Error] + """ + + + kwargs = request_kwargs( + id=id, +environment_id=environment_id, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + environment_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Any | Error | None: + """ Delete a variable Environment + + Deletes a custom Environment. Global cannot be deleted. + + Args: + id (UUID): + environment_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | Error + """ + + + return (await asyncio_detailed( + id=id, +environment_id=environment_id, +client=client, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/variable_environments/get_variable_environment.py b/src/volcano_sdk/_generated/api/variable_environments/get_variable_environment.py new file mode 100644 index 00000000..b2ee4cf4 --- /dev/null +++ b/src/volcano_sdk/_generated/api/variable_environments/get_variable_environment.py @@ -0,0 +1,214 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.error import Error +from ...models.variable_environment import VariableEnvironment +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + environment_id: UUID | str, + +) -> dict[str, Any]: + + + + + + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/projects/{id}/variable-environments/{environment_id}".format(id=quote(str(id), safe=""),environment_id=quote(str(environment_id), safe=""),), + } + + + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Error | VariableEnvironment | None: + if response.status_code == 200: + response_200 = VariableEnvironment.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Error | VariableEnvironment]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + environment_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Response[Error | VariableEnvironment]: + """ Get a variable Environment + + Args: + id (UUID): + environment_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | VariableEnvironment] + """ + + + kwargs = request_kwargs( + id=id, +environment_id=environment_id, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + environment_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Error | VariableEnvironment | None: + """ Get a variable Environment + + Args: + id (UUID): + environment_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | VariableEnvironment + """ + + + return sync_detailed( + id=id, +environment_id=environment_id, +client=client, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + environment_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Response[Error | VariableEnvironment]: + """ Get a variable Environment + + Args: + id (UUID): + environment_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | VariableEnvironment] + """ + + + kwargs = request_kwargs( + id=id, +environment_id=environment_id, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + environment_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Error | VariableEnvironment | None: + """ Get a variable Environment + + Args: + id (UUID): + environment_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | VariableEnvironment + """ + + + return (await asyncio_detailed( + id=id, +environment_id=environment_id, +client=client, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/variable_environments/list_variable_environments.py b/src/volcano_sdk/_generated/api/variable_environments/list_variable_environments.py new file mode 100644 index 00000000..1da597c9 --- /dev/null +++ b/src/volcano_sdk/_generated/api/variable_environments/list_variable_environments.py @@ -0,0 +1,194 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.error import Error +from ...models.variable_environment_list import VariableEnvironmentList +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + +) -> dict[str, Any]: + + + + + + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/projects/{id}/variable-environments".format(id=quote(str(id), safe=""),), + } + + + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Error | VariableEnvironmentList | None: + if response.status_code == 200: + response_200 = VariableEnvironmentList.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + + + return response_403 + + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Error | VariableEnvironmentList]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Response[Error | VariableEnvironmentList]: + """ List variable Environments + + Args: + id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | VariableEnvironmentList] + """ + + + kwargs = request_kwargs( + id=id, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Error | VariableEnvironmentList | None: + """ List variable Environments + + Args: + id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | VariableEnvironmentList + """ + + + return sync_detailed( + id=id, +client=client, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Response[Error | VariableEnvironmentList]: + """ List variable Environments + + Args: + id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | VariableEnvironmentList] + """ + + + kwargs = request_kwargs( + id=id, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Error | VariableEnvironmentList | None: + """ List variable Environments + + Args: + id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | VariableEnvironmentList + """ + + + return (await asyncio_detailed( + id=id, +client=client, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/variable_environments/rename_variable_environment.py b/src/volcano_sdk/_generated/api/variable_environments/rename_variable_environment.py new file mode 100644 index 00000000..a450eb02 --- /dev/null +++ b/src/volcano_sdk/_generated/api/variable_environments/rename_variable_environment.py @@ -0,0 +1,256 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.error import Error +from ...models.rename_variable_environment_request import RenameVariableEnvironmentRequest +from ...models.variable_environment import VariableEnvironment +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + environment_id: UUID | str, + *, + body: RenameVariableEnvironmentRequest, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + + + + + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": "/projects/{id}/variable-environments/{environment_id}".format(id=quote(str(id), safe=""),environment_id=quote(str(environment_id), safe=""),), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Error | VariableEnvironment | None: + if response.status_code == 200: + response_200 = VariableEnvironment.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Error | VariableEnvironment]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + environment_id: UUID | str, + *, + client: AuthenticatedClient, + body: RenameVariableEnvironmentRequest, + +) -> Response[Error | VariableEnvironment]: + """ Rename a variable Environment + + Renames a custom Environment without changing its stable ID. Global cannot be renamed. + + Args: + id (UUID): + environment_id (UUID): + body (RenameVariableEnvironmentRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | VariableEnvironment] + """ + + + kwargs = request_kwargs( + id=id, +environment_id=environment_id, +body=body, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + environment_id: UUID | str, + *, + client: AuthenticatedClient, + body: RenameVariableEnvironmentRequest, + +) -> Error | VariableEnvironment | None: + """ Rename a variable Environment + + Renames a custom Environment without changing its stable ID. Global cannot be renamed. + + Args: + id (UUID): + environment_id (UUID): + body (RenameVariableEnvironmentRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | VariableEnvironment + """ + + + return sync_detailed( + id=id, +environment_id=environment_id, +client=client, +body=body, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + environment_id: UUID | str, + *, + client: AuthenticatedClient, + body: RenameVariableEnvironmentRequest, + +) -> Response[Error | VariableEnvironment]: + """ Rename a variable Environment + + Renames a custom Environment without changing its stable ID. Global cannot be renamed. + + Args: + id (UUID): + environment_id (UUID): + body (RenameVariableEnvironmentRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | VariableEnvironment] + """ + + + kwargs = request_kwargs( + id=id, +environment_id=environment_id, +body=body, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + environment_id: UUID | str, + *, + client: AuthenticatedClient, + body: RenameVariableEnvironmentRequest, + +) -> Error | VariableEnvironment | None: + """ Rename a variable Environment + + Renames a custom Environment without changing its stable ID. Global cannot be renamed. + + Args: + id (UUID): + environment_id (UUID): + body (RenameVariableEnvironmentRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | VariableEnvironment + """ + + + return (await asyncio_detailed( + id=id, +environment_id=environment_id, +client=client, +body=body, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/verified_domains/verify_domain.py b/src/volcano_sdk/_generated/api/verified_domains/verify_domain.py index fedb5cbe..7388c39f 100644 --- a/src/volcano_sdk/_generated/api/verified_domains/verify_domain.py +++ b/src/volcano_sdk/_generated/api/verified_domains/verify_domain.py @@ -129,13 +129,15 @@ def sync_detailed( Publish a TXT record named `_volcano.`, then send this request. Without the record, the response is `409` with `code: ownership_verification_required` and the `required_record` to - publish. Its value is the same on every request. The record must be - served at that name itself; a record reached through a CNAME does not - count. + publish. Its value stays the same until Volcano rotates the key it is + derived from. The record must be served at that name itself; a record + reached through a CNAME does not count. When another account verified the domain, publishing your record moves the domain to your account once that account's record is no longer - published. Keep your record published so the domain stays yours. + published. Keep your record published so the domain stays yours. After + Volcano rotates the key the value is derived from, only a record with + the new value counts. Verifying a domain the account already verified returns `200`. @@ -177,13 +179,15 @@ def sync( Publish a TXT record named `_volcano.`, then send this request. Without the record, the response is `409` with `code: ownership_verification_required` and the `required_record` to - publish. Its value is the same on every request. The record must be - served at that name itself; a record reached through a CNAME does not - count. + publish. Its value stays the same until Volcano rotates the key it is + derived from. The record must be served at that name itself; a record + reached through a CNAME does not count. When another account verified the domain, publishing your record moves the domain to your account once that account's record is no longer - published. Keep your record published so the domain stays yours. + published. Keep your record published so the domain stays yours. After + Volcano rotates the key the value is derived from, only a record with + the new value counts. Verifying a domain the account already verified returns `200`. @@ -220,13 +224,15 @@ async def asyncio_detailed( Publish a TXT record named `_volcano.`, then send this request. Without the record, the response is `409` with `code: ownership_verification_required` and the `required_record` to - publish. Its value is the same on every request. The record must be - served at that name itself; a record reached through a CNAME does not - count. + publish. Its value stays the same until Volcano rotates the key it is + derived from. The record must be served at that name itself; a record + reached through a CNAME does not count. When another account verified the domain, publishing your record moves the domain to your account once that account's record is no longer - published. Keep your record published so the domain stays yours. + published. Keep your record published so the domain stays yours. After + Volcano rotates the key the value is derived from, only a record with + the new value counts. Verifying a domain the account already verified returns `200`. @@ -268,13 +274,15 @@ async def asyncio( Publish a TXT record named `_volcano.`, then send this request. Without the record, the response is `409` with `code: ownership_verification_required` and the `required_record` to - publish. Its value is the same on every request. The record must be - served at that name itself; a record reached through a CNAME does not - count. + publish. Its value stays the same until Volcano rotates the key it is + derived from. The record must be served at that name itself; a record + reached through a CNAME does not count. When another account verified the domain, publishing your record moves the domain to your account once that account's record is no longer - published. Keep your record published so the domain stays yours. + published. Keep your record published so the domain stays yours. After + Volcano rotates the key the value is derived from, only a record with + the new value counts. Verifying a domain the account already verified returns `200`. diff --git a/src/volcano_sdk/_generated/models/__init__.py b/src/volcano_sdk/_generated/models/__init__.py index cfd528b8..53cd5037 100644 --- a/src/volcano_sdk/_generated/models/__init__.py +++ b/src/volcano_sdk/_generated/models/__init__.py @@ -111,6 +111,9 @@ from .call_o_auth_provider_api_provider import CallOAuthProviderApiProvider from .call_o_auth_provider_api_response_200 import CallOAuthProviderApiResponse200 from .call_o_auth_provider_api_response_200_provider import CallOAuthProviderApiResponse200Provider +from .capability import Capability +from .capability_list import CapabilityList +from .capability_status import CapabilityStatus from .complete_upload_session_response import CompleteUploadSessionResponse from .configure_auth_methods_body import ConfigureAuthMethodsBody from .configure_auth_methods_body_oauth_providers_item import ConfigureAuthMethodsBodyOauthProvidersItem @@ -152,6 +155,7 @@ from .create_storage_policy_request_operation import CreateStoragePolicyRequestOperation from .create_upload_session_request import CreateUploadSessionRequest from .create_upload_session_response import CreateUploadSessionResponse +from .create_variable_environment_request import CreateVariableEnvironmentRequest from .create_variable_request import CreateVariableRequest from .created_project_access_token import CreatedProjectAccessToken from .database import Database @@ -228,6 +232,17 @@ from .durable_function import DurableFunction from .durable_function_config import DurableFunctionConfig from .durable_function_status import DurableFunctionStatus +from .durable_message import DurableMessage +from .durable_message_direction import DurableMessageDirection +from .durable_message_list import DurableMessageList +from .durable_message_sender import DurableMessageSender +from .durable_message_sender_type import DurableMessageSenderType +from .durable_message_session_registration import DurableMessageSessionRegistration +from .durable_message_status import DurableMessageStatus +from .durable_message_wait import DurableMessageWait +from .durable_message_wait_list import DurableMessageWaitList +from .durable_message_wait_registration import DurableMessageWaitRegistration +from .durable_message_wait_status import DurableMessageWaitStatus from .email_template import EmailTemplate from .email_template_template_type import EmailTemplateTemplateType from .error import Error @@ -360,6 +375,7 @@ from .o_auth_config_provider import OAuthConfigProvider from .o_auth_error_response import OAuthErrorResponse from .open_api_spec_document import OpenAPISpecDocument +from .open_durable_message_session_request import OpenDurableMessageSessionRequest from .paginated_auth_users import PaginatedAuthUsers from .paginated_databases import PaginatedDatabases from .paginated_durable_approvals import PaginatedDurableApprovals @@ -511,6 +527,7 @@ from .project_template_installation_phase import ProjectTemplateInstallationPhase from .project_template_installation_status import ProjectTemplateInstallationStatus from .project_usage_response import ProjectUsageResponse +from .publish_durable_message_request import PublishDurableMessageRequest from .publish_sandbox_preset_request import PublishSandboxPresetRequest from .publish_sandbox_preset_request_memory_mb import PublishSandboxPresetRequestMemoryMb from .publish_sandbox_preset_request_preset import PublishSandboxPresetRequestPreset @@ -519,6 +536,8 @@ from .realtime_stats import RealtimeStats from .refresh_o_auth_provider_token_provider import RefreshOAuthProviderTokenProvider from .refresh_o_auth_provider_token_response_200 import RefreshOAuthProviderTokenResponse200 +from .register_durable_message_wait_request import RegisterDurableMessageWaitRequest +from .rename_variable_environment_request import RenameVariableEnvironmentRequest from .render_default_managed_auth_page_action import RenderDefaultManagedAuthPageAction from .replace_frontend_shared_variables_body import ReplaceFrontendSharedVariablesBody from .replace_shared_variables_body import ReplaceSharedVariablesBody @@ -556,6 +575,7 @@ from .sandbox_template_status import SandboxTemplateStatus from .schedule_request import ScheduleRequest from .schedule_request_kind import ScheduleRequestKind +from .send_durable_message_request import SendDurableMessageRequest from .service_key import ServiceKey from .set_project_git_production_branch_request import SetProjectGitProductionBranchRequest from .start_git_connect_provider import StartGitConnectProvider @@ -607,6 +627,8 @@ from .usage_data_point import UsageDataPoint from .variable import Variable from .variable_deploy_source import VariableDeploySource +from .variable_environment import VariableEnvironment +from .variable_environment_list import VariableEnvironmentList from .variable_status import VariableStatus from .verified_domain import VerifiedDomain from .verified_domains_response import VerifiedDomainsResponse @@ -724,6 +746,9 @@ "CallOAuthProviderApiProvider", "CallOAuthProviderApiResponse200", "CallOAuthProviderApiResponse200Provider", + "Capability", + "CapabilityList", + "CapabilityStatus", "CompleteUploadSessionResponse", "ConfigureAuthMethodsBody", "ConfigureAuthMethodsBodyOauthProvidersItem", @@ -766,6 +791,7 @@ "CreateStoragePolicyRequestOperation", "CreateUploadSessionRequest", "CreateUploadSessionResponse", + "CreateVariableEnvironmentRequest", "CreateVariableRequest", "Database", "DatabaseBackup", @@ -841,6 +867,17 @@ "DurableFunction", "DurableFunctionConfig", "DurableFunctionStatus", + "DurableMessage", + "DurableMessageDirection", + "DurableMessageList", + "DurableMessageSender", + "DurableMessageSenderType", + "DurableMessageSessionRegistration", + "DurableMessageStatus", + "DurableMessageWait", + "DurableMessageWaitList", + "DurableMessageWaitRegistration", + "DurableMessageWaitStatus", "EmailTemplate", "EmailTemplateTemplateType", "Error", @@ -973,6 +1010,7 @@ "OAuthConfigProvider", "OAuthErrorResponse", "OpenAPISpecDocument", + "OpenDurableMessageSessionRequest", "PaginatedAuthUsers", "PaginatedDatabases", "PaginatedDurableApprovals", @@ -1124,6 +1162,7 @@ "ProjectTemplateInstallationPhase", "ProjectTemplateInstallationStatus", "ProjectUsageResponse", + "PublishDurableMessageRequest", "PublishSandboxPresetRequest", "PublishSandboxPresetRequestMemoryMb", "PublishSandboxPresetRequestPreset", @@ -1132,6 +1171,8 @@ "RealtimeStats", "RefreshOAuthProviderTokenProvider", "RefreshOAuthProviderTokenResponse200", + "RegisterDurableMessageWaitRequest", + "RenameVariableEnvironmentRequest", "RenderDefaultManagedAuthPageAction", "ReplaceFrontendSharedVariablesBody", "ReplaceSharedVariablesBody", @@ -1169,6 +1210,7 @@ "SandboxTemplateStatus", "ScheduleRequest", "ScheduleRequestKind", + "SendDurableMessageRequest", "ServiceKey", "SetProjectGitProductionBranchRequest", "StartGitConnectProvider", @@ -1220,6 +1262,8 @@ "UsageDataPoint", "Variable", "VariableDeploySource", + "VariableEnvironment", + "VariableEnvironmentList", "VariableStatus", "VerifiedDomain", "VerifiedDomainsResponse", diff --git a/src/volcano_sdk/_generated/models/auth_config.py b/src/volcano_sdk/_generated/models/auth_config.py index 00aa9339..f096393d 100644 --- a/src/volcano_sdk/_generated/models/auth_config.py +++ b/src/volcano_sdk/_generated/models/auth_config.py @@ -13,6 +13,7 @@ from ..types import UNSET, Unset from typing import cast from uuid import UUID +import datetime if TYPE_CHECKING: from ..models.auth_password_policy import AuthPasswordPolicy @@ -33,6 +34,9 @@ class AuthConfig: project_id (UUID | Unset): access_token_lifetime (int | Unset): Access token lifetime in seconds Default: 3600. refresh_token_lifetime (int | Unset): Refresh token lifetime in seconds Default: 2592000. + refresh_token_reuse_interval (int | Unset): A refresh within this many seconds of the refresh token being issued + returns the same refresh token instead of rotating it, so concurrent refreshes from several tabs all succeed + Default: 10. inactivity_timeout (int | Unset): Force re-login after inactivity (seconds, 0=never) Default: 0. max_session_duration (int | Unset): Force re-login after duration (seconds, 0=never) Default: 0. min_password_length (int | Unset): Configured minimum password length in Unicode characters. Default: 15. @@ -45,6 +49,8 @@ class AuthConfig: rate_limit_signup (int | Unset): Signups per hour per IP Default: 100. rate_limit_signin (int | Unset): Signins per hour per IP Default: 100. rate_limit_token_refresh (int | Unset): Refreshes per hour per IP Default: 1000. + rate_limit_password_reset (int | Unset): Password reset requests per hour per IP. Unlike the other limits, 0 + applies the default of 10 instead of turning the limit off. Default: 10. cors_enabled (bool | Unset): Default: False. cors_allowed_origins (list[str] | Unset): Example: ['https://myapp.com', 'http://localhost:3000']. enable_anonymous_signins (bool | Unset): Allow creating users without email/password Default: False. @@ -105,12 +111,15 @@ class AuthConfig: the built-in managed device page. Lets a CLI surface the project's own RFC 8628 approval page. Empty falls back to the managed page. Example: https://app.acme.com/device. + created_at (datetime.datetime | Unset): + updated_at (datetime.datetime | Unset): """ password_policy: AuthPasswordPolicy project_id: UUID | Unset = UNSET access_token_lifetime: int | Unset = 3600 refresh_token_lifetime: int | Unset = 2592000 + refresh_token_reuse_interval: int | Unset = 10 inactivity_timeout: int | Unset = 0 max_session_duration: int | Unset = 0 min_password_length: int | Unset = 15 @@ -123,6 +132,7 @@ class AuthConfig: rate_limit_signup: int | Unset = 100 rate_limit_signin: int | Unset = 100 rate_limit_token_refresh: int | Unset = 1000 + rate_limit_password_reset: int | Unset = 10 cors_enabled: bool | Unset = False cors_allowed_origins: list[str] | Unset = UNSET enable_anonymous_signins: bool | Unset = False @@ -153,6 +163,8 @@ class AuthConfig: allowed_redirect_urls: list[str] | Unset = UNSET post_logout_redirect_url: str | Unset = UNSET device_verification_url: str | Unset = UNSET + created_at: datetime.datetime | Unset = UNSET + updated_at: datetime.datetime | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -171,6 +183,8 @@ def to_dict(self) -> dict[str, Any]: refresh_token_lifetime = self.refresh_token_lifetime + refresh_token_reuse_interval = self.refresh_token_reuse_interval + inactivity_timeout = self.inactivity_timeout max_session_duration = self.max_session_duration @@ -195,6 +209,8 @@ def to_dict(self) -> dict[str, Any]: rate_limit_token_refresh = self.rate_limit_token_refresh + rate_limit_password_reset = self.rate_limit_password_reset + cors_enabled = self.cors_enabled cors_allowed_origins: list[str] | Unset = UNSET @@ -270,6 +286,14 @@ def to_dict(self) -> dict[str, Any]: device_verification_url = self.device_verification_url + created_at: str | Unset = UNSET + if not isinstance(self.created_at, Unset): + created_at = self.created_at.isoformat() + + updated_at: str | Unset = UNSET + if not isinstance(self.updated_at, Unset): + updated_at = self.updated_at.isoformat() + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) @@ -282,6 +306,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["access_token_lifetime"] = access_token_lifetime if refresh_token_lifetime is not UNSET: field_dict["refresh_token_lifetime"] = refresh_token_lifetime + if refresh_token_reuse_interval is not UNSET: + field_dict["refresh_token_reuse_interval"] = refresh_token_reuse_interval if inactivity_timeout is not UNSET: field_dict["inactivity_timeout"] = inactivity_timeout if max_session_duration is not UNSET: @@ -306,6 +332,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["rate_limit_signin"] = rate_limit_signin if rate_limit_token_refresh is not UNSET: field_dict["rate_limit_token_refresh"] = rate_limit_token_refresh + if rate_limit_password_reset is not UNSET: + field_dict["rate_limit_password_reset"] = rate_limit_password_reset if cors_enabled is not UNSET: field_dict["cors_enabled"] = cors_enabled if cors_allowed_origins is not UNSET: @@ -366,6 +394,10 @@ def to_dict(self) -> dict[str, Any]: field_dict["post_logout_redirect_url"] = post_logout_redirect_url if device_verification_url is not UNSET: field_dict["device_verification_url"] = device_verification_url + if created_at is not UNSET: + field_dict["created_at"] = created_at + if updated_at is not UNSET: + field_dict["updated_at"] = updated_at return field_dict @@ -394,6 +426,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: refresh_token_lifetime = d.pop("refresh_token_lifetime", UNSET) + refresh_token_reuse_interval = d.pop("refresh_token_reuse_interval", UNSET) + inactivity_timeout = d.pop("inactivity_timeout", UNSET) max_session_duration = d.pop("max_session_duration", UNSET) @@ -418,6 +452,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: rate_limit_token_refresh = d.pop("rate_limit_token_refresh", UNSET) + rate_limit_password_reset = d.pop("rate_limit_password_reset", UNSET) + cors_enabled = d.pop("cors_enabled", UNSET) cors_allowed_origins = cast(list[str], d.pop("cors_allowed_origins", UNSET)) @@ -489,11 +525,32 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: device_verification_url = d.pop("device_verification_url", UNSET) + _created_at = d.pop("created_at", UNSET) + created_at: datetime.datetime | Unset + if isinstance(_created_at, Unset): + created_at = UNSET + else: + created_at = datetime.datetime.fromisoformat(_created_at) + + + + + _updated_at = d.pop("updated_at", UNSET) + updated_at: datetime.datetime | Unset + if isinstance(_updated_at, Unset): + updated_at = UNSET + else: + updated_at = datetime.datetime.fromisoformat(_updated_at) + + + + auth_config = cls( password_policy=password_policy, project_id=project_id, access_token_lifetime=access_token_lifetime, refresh_token_lifetime=refresh_token_lifetime, + refresh_token_reuse_interval=refresh_token_reuse_interval, inactivity_timeout=inactivity_timeout, max_session_duration=max_session_duration, min_password_length=min_password_length, @@ -506,6 +563,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: rate_limit_signup=rate_limit_signup, rate_limit_signin=rate_limit_signin, rate_limit_token_refresh=rate_limit_token_refresh, + rate_limit_password_reset=rate_limit_password_reset, cors_enabled=cors_enabled, cors_allowed_origins=cors_allowed_origins, enable_anonymous_signins=enable_anonymous_signins, @@ -536,6 +594,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: allowed_redirect_urls=allowed_redirect_urls, post_logout_redirect_url=post_logout_redirect_url, device_verification_url=device_verification_url, + created_at=created_at, + updated_at=updated_at, ) diff --git a/src/volcano_sdk/_generated/models/auth_insights_series_point.py b/src/volcano_sdk/_generated/models/auth_insights_series_point.py index 2a4f47b6..a5f10a4a 100644 --- a/src/volcano_sdk/_generated/models/auth_insights_series_point.py +++ b/src/volcano_sdk/_generated/models/auth_insights_series_point.py @@ -28,14 +28,25 @@ class AuthInsightsSeriesPoint: signups (int): Registrations during the bucket. Subsequent deletion does not subtract from this count. Historical counts removed before deletion-history collection cannot be reconstructed. signins (int): Successful session creations during the bucket. + banned_users (int): Users registered in this bucket who are currently banned. Excludes deleted users, lifted + bans, expired temporary bans, and internal test identities. + deleted_users (int): Users registered in this bucket who are now deleted, counted once across soft and hard + deletion. Grouped by signup date, not deletion date. + available_users (int): Currently available users registered in this bucket (signups minus banned_users minus + deleted_users). Unban or expiry restores availability in the original signup bucket. This measures account + availability, not return activity. deletions (int): Accounts deleted during this bucket, counted once across soft and hard deletion. - net_growth (int): Registrations minus deletions during this bucket. May be negative. + net_growth (int): Registrations minus deletions during this bucket. May be negative. Bans do not affect this + value. is_partial (bool): Whether the requested window or observation time clips this bucket. """ bucket_start: datetime.date signups: int signins: int + banned_users: int + deleted_users: int + available_users: int deletions: int net_growth: int is_partial: bool @@ -51,6 +62,12 @@ def to_dict(self) -> dict[str, Any]: signins = self.signins + banned_users = self.banned_users + + deleted_users = self.deleted_users + + available_users = self.available_users + deletions = self.deletions net_growth = self.net_growth @@ -64,6 +81,9 @@ def to_dict(self) -> dict[str, Any]: "bucket_start": bucket_start, "signups": signups, "signins": signins, + "banned_users": banned_users, + "deleted_users": deleted_users, + "available_users": available_users, "deletions": deletions, "net_growth": net_growth, "is_partial": is_partial, @@ -85,6 +105,12 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: signins = d.pop("signins") + banned_users = d.pop("banned_users") + + deleted_users = d.pop("deleted_users") + + available_users = d.pop("available_users") + deletions = d.pop("deletions") net_growth = d.pop("net_growth") @@ -95,6 +121,9 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: bucket_start=bucket_start, signups=signups, signins=signins, + banned_users=banned_users, + deleted_users=deleted_users, + available_users=available_users, deletions=deletions, net_growth=net_growth, is_partial=is_partial, diff --git a/src/volcano_sdk/_generated/models/capability.py b/src/volcano_sdk/_generated/models/capability.py new file mode 100644 index 00000000..395d6353 --- /dev/null +++ b/src/volcano_sdk/_generated/models/capability.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..models.capability_status import CapabilityStatus +from ..models.capability_status import check_capability_status +from typing import cast + + + + + + +T = TypeVar("T", bound="Capability") + + + +@_attrs_define +class Capability: + """ A product capability and whether it accepts new work in this environment. + + Attributes: + id (str): Stable capability ID, such as `sandboxes`, `sandboxes.sessions`, or `sandboxes.custom_templates`. New + IDs may appear at any time. Example: sandboxes.sessions. + status (CapabilityStatus): `available` means the capability accepts new work; a temporary outage still answers + `503`. `unavailable` means requests that start new work answer `404` or `400` with code `feature_unavailable`. + Treat any other value as `unavailable`. + """ + + id: str + status: CapabilityStatus + + + + + + def to_dict(self) -> dict[str, Any]: + id = self.id + + status: str = self.status + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "id": id, + "status": status, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = d.pop("id") + + status = check_capability_status(d.pop("status")) + + + + + capability = cls( + id=id, + status=status, + ) + + return capability + diff --git a/src/volcano_sdk/_generated/models/capability_list.py b/src/volcano_sdk/_generated/models/capability_list.py new file mode 100644 index 00000000..203f8b24 --- /dev/null +++ b/src/volcano_sdk/_generated/models/capability_list.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast + +if TYPE_CHECKING: + from ..models.capability import Capability + + + + + +T = TypeVar("T", bound="CapabilityList") + + + +@_attrs_define +class CapabilityList: + """ + Attributes: + data (list[Capability]): + """ + + data: list[Capability] + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.capability import Capability # noqa: PLC0415 + data = [] + for data_item_data in self.data: + data_item = data_item_data.to_dict() + data.append(data_item) + + + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "data": data, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.capability import Capability # noqa: PLC0415 + d = dict(src_dict) + data = [] + _data = d.pop("data") + for data_item_data in (_data): + data_item = Capability.from_dict(data_item_data) + + + + data.append(data_item) + + + capability_list = cls( + data=data, + ) + + return capability_list + diff --git a/src/volcano_sdk/_generated/models/capability_status.py b/src/volcano_sdk/_generated/models/capability_status.py new file mode 100644 index 00000000..c0e99672 --- /dev/null +++ b/src/volcano_sdk/_generated/models/capability_status.py @@ -0,0 +1,10 @@ +from typing import Literal + +CapabilityStatus = Literal['available', 'unavailable'] + +CAPABILITY_STATUS_VALUES: set[CapabilityStatus] = { 'available', 'unavailable', } + +def check_capability_status(value: str) -> CapabilityStatus: + if value in CAPABILITY_STATUS_VALUES: + return value + raise TypeError(f"Unexpected value {value!r}. Expected one of {CAPABILITY_STATUS_VALUES!r}") diff --git a/src/volcano_sdk/_generated/models/create_variable_environment_request.py b/src/volcano_sdk/_generated/models/create_variable_environment_request.py new file mode 100644 index 00000000..fc1a732a --- /dev/null +++ b/src/volcano_sdk/_generated/models/create_variable_environment_request.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + + + + + + + +T = TypeVar("T", bound="CreateVariableEnvironmentRequest") + + + +@_attrs_define +class CreateVariableEnvironmentRequest: + """ + Attributes: + name (str): Leading and trailing ASCII whitespace is trimmed before validating 1–64 ASCII letters, digits, + underscores, or hyphens. Global is reserved. + """ + + name: str + + + + + + def to_dict(self) -> dict[str, Any]: + name = self.name + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "name": name, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + create_variable_environment_request = cls( + name=name, + ) + + return create_variable_environment_request + diff --git a/src/volcano_sdk/_generated/models/durable_execution.py b/src/volcano_sdk/_generated/models/durable_execution.py index 7c379ac8..a7bee0e8 100644 --- a/src/volcano_sdk/_generated/models/durable_execution.py +++ b/src/volcano_sdk/_generated/models/durable_execution.py @@ -68,6 +68,9 @@ class DurableExecution: unset, so it reads like a function that returned nothing. error (DurableExecutionError | Unset): Why a failed or timed-out execution ended. completed_at (datetime.datetime | Unset): Present once the execution has reached a terminal status. + client_token (str | Unset): Lets a browser read and send the execution's messages. Returned only + by the start that created the execution, never by a repeat under the + same name or by a read. Send it as `X-Volcano-Client-Token`. """ id: UUID @@ -80,6 +83,7 @@ class DurableExecution: result_expired: bool | Unset = UNSET error: DurableExecutionError | Unset = UNSET completed_at: datetime.datetime | Unset = UNSET + client_token: str | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -112,6 +116,8 @@ def to_dict(self) -> dict[str, Any]: if not isinstance(self.completed_at, Unset): completed_at = self.completed_at.isoformat() + client_token = self.client_token + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) @@ -131,6 +137,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["error"] = error if completed_at is not UNSET: field_dict["completed_at"] = completed_at + if client_token is not UNSET: + field_dict["client_token"] = client_token return field_dict @@ -188,6 +196,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + client_token = d.pop("client_token", UNSET) + durable_execution = cls( id=id, function_id=function_id, @@ -199,6 +209,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: result_expired=result_expired, error=error, completed_at=completed_at, + client_token=client_token, ) diff --git a/src/volcano_sdk/_generated/models/durable_message.py b/src/volcano_sdk/_generated/models/durable_message.py new file mode 100644 index 00000000..ade4b4a4 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_message.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..models.durable_message_direction import check_durable_message_direction +from ..models.durable_message_direction import DurableMessageDirection +from ..models.durable_message_status import check_durable_message_status +from ..models.durable_message_status import DurableMessageStatus +from typing import cast +from uuid import UUID +import datetime + +if TYPE_CHECKING: + from ..models.durable_message_sender import DurableMessageSender + + + + + +T = TypeVar("T", bound="DurableMessage") + + + +@_attrs_define +class DurableMessage: + """ A message sent to, or published by, a durable execution. + + Attributes: + id (UUID): + execution_id (UUID): + direction (DurableMessageDirection): `in` is a message sent to the execution. `out` is one the execution + published. + seq (int): Orders the execution's messages in both directions. It increases + with every message but can skip numbers. Pass the last one you read + as `after` to read what came next. + name (str): + data (Any): The JSON value sent or published, or null. + sender (DurableMessageSender): Who sent the message. + status (DurableMessageStatus): An `in` message is `queued` until a wait for its name takes it, + `delivering` while it is handed over, and `delivered` once the + execution has it. It is `dropped` if the execution ended before any + wait took it. Every `out` message is `published`. + delivered_at (datetime.datetime | None): When the execution received the message. Null until then, and for `out` + messages. + sent_at (datetime.datetime): + """ + + id: UUID + execution_id: UUID + direction: DurableMessageDirection + seq: int + name: str + data: Any + sender: DurableMessageSender + status: DurableMessageStatus + delivered_at: datetime.datetime | None + sent_at: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_message_sender import DurableMessageSender # noqa: PLC0415 + id = str(self.id) + + execution_id = str(self.execution_id) + + direction: str = self.direction + + seq = self.seq + + name = self.name + + data = self.data + + sender = self.sender.to_dict() + + status: str = self.status + + delivered_at: None | str + if isinstance(self.delivered_at, datetime.datetime): + delivered_at = self.delivered_at.isoformat() + else: + delivered_at = self.delivered_at + + sent_at = self.sent_at.isoformat() + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "execution_id": execution_id, + "direction": direction, + "seq": seq, + "name": name, + "data": data, + "sender": sender, + "status": status, + "delivered_at": delivered_at, + "sent_at": sent_at, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_message_sender import DurableMessageSender # noqa: PLC0415 + d = dict(src_dict) + id = UUID(d.pop("id")) + + + + + execution_id = UUID(d.pop("execution_id")) + + + + + direction = check_durable_message_direction(d.pop("direction")) + + + + + seq = d.pop("seq") + + name = d.pop("name") + + data = d.pop("data") + + sender = DurableMessageSender.from_dict(d.pop("sender")) + + + + + status = check_durable_message_status(d.pop("status")) + + + + + def _parse_delivered_at(data: object) -> datetime.datetime | None: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + delivered_at_type_0 = datetime.datetime.fromisoformat(data) + + + + return delivered_at_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None, data) + + delivered_at = _parse_delivered_at(d.pop("delivered_at")) + + + sent_at = datetime.datetime.fromisoformat(d.pop("sent_at")) + + + + + durable_message = cls( + id=id, + execution_id=execution_id, + direction=direction, + seq=seq, + name=name, + data=data, + sender=sender, + status=status, + delivered_at=delivered_at, + sent_at=sent_at, + ) + + + durable_message.additional_properties = d + return durable_message + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_message_direction.py b/src/volcano_sdk/_generated/models/durable_message_direction.py new file mode 100644 index 00000000..ed28a71e --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_message_direction.py @@ -0,0 +1,10 @@ +from typing import Literal + +DurableMessageDirection = Literal['in', 'out'] + +DURABLE_MESSAGE_DIRECTION_VALUES: set[DurableMessageDirection] = { 'in', 'out', } + +def check_durable_message_direction(value: str) -> DurableMessageDirection: + if value in DURABLE_MESSAGE_DIRECTION_VALUES: + return value + raise TypeError(f"Unexpected value {value!r}. Expected one of {DURABLE_MESSAGE_DIRECTION_VALUES!r}") diff --git a/src/volcano_sdk/_generated/models/durable_message_list.py b/src/volcano_sdk/_generated/models/durable_message_list.py new file mode 100644 index 00000000..be34a0ca --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_message_list.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast + +if TYPE_CHECKING: + from ..models.durable_message import DurableMessage + + + + + +T = TypeVar("T", bound="DurableMessageList") + + + +@_attrs_define +class DurableMessageList: + """ + Attributes: + data (list[DurableMessage]): Messages after `after`, oldest first. + has_more (bool): Whether more messages follow. Read them with `after` set to the + last message's `seq`. + """ + + data: list[DurableMessage] + has_more: bool + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_message import DurableMessage # noqa: PLC0415 + data = [] + for data_item_data in self.data: + data_item = data_item_data.to_dict() + data.append(data_item) + + + + has_more = self.has_more + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "data": data, + "has_more": has_more, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_message import DurableMessage # noqa: PLC0415 + d = dict(src_dict) + data = [] + _data = d.pop("data") + for data_item_data in (_data): + data_item = DurableMessage.from_dict(data_item_data) + + + + data.append(data_item) + + + has_more = d.pop("has_more") + + durable_message_list = cls( + data=data, + has_more=has_more, + ) + + + durable_message_list.additional_properties = d + return durable_message_list + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_message_sender.py b/src/volcano_sdk/_generated/models/durable_message_sender.py new file mode 100644 index 00000000..7c7c2175 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_message_sender.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..models.durable_message_sender_type import check_durable_message_sender_type +from ..models.durable_message_sender_type import DurableMessageSenderType +from typing import cast + + + + + + +T = TypeVar("T", bound="DurableMessageSender") + + + +@_attrs_define +class DurableMessageSender: + """ Who sent the message. + + Attributes: + type_ (DurableMessageSenderType): The credential the message was sent with. `project` is the + project's owner or one of its access tokens, and `execution` is the + execution itself publishing. + user_id (None | str): The auth user who sent it. Null unless `type` is `user`. + """ + + type_: DurableMessageSenderType + user_id: None | str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + type_: str = self.type_ + + user_id: None | str + user_id = self.user_id + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "type": type_, + "user_id": user_id, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = check_durable_message_sender_type(d.pop("type")) + + + + + def _parse_user_id(data: object) -> None | str: + if data is None: + return data + return cast(None | str, data) + + user_id = _parse_user_id(d.pop("user_id")) + + + durable_message_sender = cls( + type_=type_, + user_id=user_id, + ) + + + durable_message_sender.additional_properties = d + return durable_message_sender + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_message_sender_type.py b/src/volcano_sdk/_generated/models/durable_message_sender_type.py new file mode 100644 index 00000000..df1eedf3 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_message_sender_type.py @@ -0,0 +1,10 @@ +from typing import Literal + +DurableMessageSenderType = Literal['anon', 'execution', 'project', 'service_key', 'user'] + +DURABLE_MESSAGE_SENDER_TYPE_VALUES: set[DurableMessageSenderType] = { 'anon', 'execution', 'project', 'service_key', 'user', } + +def check_durable_message_sender_type(value: str) -> DurableMessageSenderType: + if value in DURABLE_MESSAGE_SENDER_TYPE_VALUES: + return value + raise TypeError(f"Unexpected value {value!r}. Expected one of {DURABLE_MESSAGE_SENDER_TYPE_VALUES!r}") diff --git a/src/volcano_sdk/_generated/models/durable_message_session_registration.py b/src/volcano_sdk/_generated/models/durable_message_session_registration.py new file mode 100644 index 00000000..b888927a --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_message_session_registration.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from uuid import UUID + + + + + + +T = TypeVar("T", bound="DurableMessageSessionRegistration") + + + +@_attrs_define +class DurableMessageSessionRegistration: + """ The registered session. Its token reaches the workflow through the + workflow itself, never in this response. + + Attributes: + id (UUID): + """ + + id: UUID + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + id = str(self.id) + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = UUID(d.pop("id")) + + + + + durable_message_session_registration = cls( + id=id, + ) + + + durable_message_session_registration.additional_properties = d + return durable_message_session_registration + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_message_status.py b/src/volcano_sdk/_generated/models/durable_message_status.py new file mode 100644 index 00000000..a6b2a36b --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_message_status.py @@ -0,0 +1,10 @@ +from typing import Literal + +DurableMessageStatus = Literal['delivered', 'delivering', 'dropped', 'published', 'queued'] + +DURABLE_MESSAGE_STATUS_VALUES: set[DurableMessageStatus] = { 'delivered', 'delivering', 'dropped', 'published', 'queued', } + +def check_durable_message_status(value: str) -> DurableMessageStatus: + if value in DURABLE_MESSAGE_STATUS_VALUES: + return value + raise TypeError(f"Unexpected value {value!r}. Expected one of {DURABLE_MESSAGE_STATUS_VALUES!r}") diff --git a/src/volcano_sdk/_generated/models/durable_message_wait.py b/src/volcano_sdk/_generated/models/durable_message_wait.py new file mode 100644 index 00000000..1ec42494 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_message_wait.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..models.durable_message_wait_status import check_durable_message_wait_status +from ..models.durable_message_wait_status import DurableMessageWaitStatus +from typing import cast +from uuid import UUID +import datetime + + + + + + +T = TypeVar("T", bound="DurableMessageWait") + + + +@_attrs_define +class DurableMessageWait: + """ A wait an execution has open for a message. + + Attributes: + id (UUID): + name (str): + status (DurableMessageWaitStatus): `waiting` until a message for its name arrives, `matched` while that + message is handed over, then `delivered`. `expired` means the wait's + timeout passed first, and `cancelled` that the execution stopped + waiting. + expires_at (datetime.datetime | None): When the wait times out. Null when the workflow set no timeout. + created_at (datetime.datetime): + """ + + id: UUID + name: str + status: DurableMessageWaitStatus + expires_at: datetime.datetime | None + created_at: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + id = str(self.id) + + name = self.name + + status: str = self.status + + expires_at: None | str + if isinstance(self.expires_at, datetime.datetime): + expires_at = self.expires_at.isoformat() + else: + expires_at = self.expires_at + + created_at = self.created_at.isoformat() + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "name": name, + "status": status, + "expires_at": expires_at, + "created_at": created_at, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = UUID(d.pop("id")) + + + + + name = d.pop("name") + + status = check_durable_message_wait_status(d.pop("status")) + + + + + def _parse_expires_at(data: object) -> datetime.datetime | None: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + expires_at_type_0 = datetime.datetime.fromisoformat(data) + + + + return expires_at_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None, data) + + expires_at = _parse_expires_at(d.pop("expires_at")) + + + created_at = datetime.datetime.fromisoformat(d.pop("created_at")) + + + + + durable_message_wait = cls( + id=id, + name=name, + status=status, + expires_at=expires_at, + created_at=created_at, + ) + + + durable_message_wait.additional_properties = d + return durable_message_wait + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_message_wait_list.py b/src/volcano_sdk/_generated/models/durable_message_wait_list.py new file mode 100644 index 00000000..cdc3f6ae --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_message_wait_list.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast + +if TYPE_CHECKING: + from ..models.durable_message_wait import DurableMessageWait + + + + + +T = TypeVar("T", bound="DurableMessageWaitList") + + + +@_attrs_define +class DurableMessageWaitList: + """ + Attributes: + data (list[DurableMessageWait]): Open waits, oldest first. + """ + + data: list[DurableMessageWait] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_message_wait import DurableMessageWait # noqa: PLC0415 + data = [] + for data_item_data in self.data: + data_item = data_item_data.to_dict() + data.append(data_item) + + + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "data": data, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_message_wait import DurableMessageWait # noqa: PLC0415 + d = dict(src_dict) + data = [] + _data = d.pop("data") + for data_item_data in (_data): + data_item = DurableMessageWait.from_dict(data_item_data) + + + + data.append(data_item) + + + durable_message_wait_list = cls( + data=data, + ) + + + durable_message_wait_list.additional_properties = d + return durable_message_wait_list + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_message_wait_registration.py b/src/volcano_sdk/_generated/models/durable_message_wait_registration.py new file mode 100644 index 00000000..70127a1a --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_message_wait_registration.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..models.durable_message_wait_status import check_durable_message_wait_status +from ..models.durable_message_wait_status import DurableMessageWaitStatus +from typing import cast +from uuid import UUID +import datetime + + + + + + +T = TypeVar("T", bound="DurableMessageWaitRegistration") + + + +@_attrs_define +class DurableMessageWaitRegistration: + """ The registered wait, as the waiting workflow sees it. + + Attributes: + id (UUID): + status (DurableMessageWaitStatus): `waiting` until a message for its name arrives, `matched` while that + message is handed over, then `delivered`. `expired` means the wait's + timeout passed first, and `cancelled` that the execution stopped + waiting. + expires_at (datetime.datetime | None): + """ + + id: UUID + status: DurableMessageWaitStatus + expires_at: datetime.datetime | None + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + id = str(self.id) + + status: str = self.status + + expires_at: None | str + if isinstance(self.expires_at, datetime.datetime): + expires_at = self.expires_at.isoformat() + else: + expires_at = self.expires_at + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "status": status, + "expires_at": expires_at, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = UUID(d.pop("id")) + + + + + status = check_durable_message_wait_status(d.pop("status")) + + + + + def _parse_expires_at(data: object) -> datetime.datetime | None: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + expires_at_type_0 = datetime.datetime.fromisoformat(data) + + + + return expires_at_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None, data) + + expires_at = _parse_expires_at(d.pop("expires_at")) + + + durable_message_wait_registration = cls( + id=id, + status=status, + expires_at=expires_at, + ) + + + durable_message_wait_registration.additional_properties = d + return durable_message_wait_registration + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_message_wait_status.py b/src/volcano_sdk/_generated/models/durable_message_wait_status.py new file mode 100644 index 00000000..39d00a85 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_message_wait_status.py @@ -0,0 +1,10 @@ +from typing import Literal + +DurableMessageWaitStatus = Literal['cancelled', 'delivered', 'expired', 'matched', 'waiting'] + +DURABLE_MESSAGE_WAIT_STATUS_VALUES: set[DurableMessageWaitStatus] = { 'cancelled', 'delivered', 'expired', 'matched', 'waiting', } + +def check_durable_message_wait_status(value: str) -> DurableMessageWaitStatus: + if value in DURABLE_MESSAGE_WAIT_STATUS_VALUES: + return value + raise TypeError(f"Unexpected value {value!r}. Expected one of {DURABLE_MESSAGE_WAIT_STATUS_VALUES!r}") diff --git a/src/volcano_sdk/_generated/models/frontend.py b/src/volcano_sdk/_generated/models/frontend.py index 40d2cdd3..dc40a193 100644 --- a/src/volcano_sdk/_generated/models/frontend.py +++ b/src/volcano_sdk/_generated/models/frontend.py @@ -66,6 +66,9 @@ class Frontend: site_url (str | Unset): custom_domain (str | Unset): Active custom domain hostname when configured custom_domain_status (FrontendCustomDomainStatus | Unset): Current custom domain lifecycle status + last_deployed_at (datetime.datetime | Unset): Recorded completion time of the most recent deploy or redeploy + with status active. Omitted when no such deployment has a recorded completion time. Site traffic, pending + deployments, degraded deployments, failed deployments, and deletion do not change this value. last_invoked_at (datetime.datetime | Unset): """ @@ -87,6 +90,7 @@ class Frontend: site_url: str | Unset = UNSET custom_domain: str | Unset = UNSET custom_domain_status: FrontendCustomDomainStatus | Unset = UNSET + last_deployed_at: datetime.datetime | Unset = UNSET last_invoked_at: datetime.datetime | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -155,6 +159,10 @@ def to_dict(self) -> dict[str, Any]: custom_domain_status = self.custom_domain_status + last_deployed_at: str | Unset = UNSET + if not isinstance(self.last_deployed_at, Unset): + last_deployed_at = self.last_deployed_at.isoformat() + last_invoked_at: str | Unset = UNSET if not isinstance(self.last_invoked_at, Unset): last_invoked_at = self.last_invoked_at.isoformat() @@ -191,6 +199,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["custom_domain"] = custom_domain if custom_domain_status is not UNSET: field_dict["custom_domain_status"] = custom_domain_status + if last_deployed_at is not UNSET: + field_dict["last_deployed_at"] = last_deployed_at if last_invoked_at is not UNSET: field_dict["last_invoked_at"] = last_invoked_at @@ -306,6 +316,16 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + _last_deployed_at = d.pop("last_deployed_at", UNSET) + last_deployed_at: datetime.datetime | Unset + if isinstance(_last_deployed_at, Unset): + last_deployed_at = UNSET + else: + last_deployed_at = datetime.datetime.fromisoformat(_last_deployed_at) + + + + _last_invoked_at = d.pop("last_invoked_at", UNSET) last_invoked_at: datetime.datetime | Unset if isinstance(_last_invoked_at, Unset): @@ -335,6 +355,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: site_url=site_url, custom_domain=custom_domain, custom_domain_status=custom_domain_status, + last_deployed_at=last_deployed_at, last_invoked_at=last_invoked_at, ) diff --git a/src/volcano_sdk/_generated/models/frontend_custom_domain_conflict_error.py b/src/volcano_sdk/_generated/models/frontend_custom_domain_conflict_error.py index ae8bd93a..48324ad0 100644 --- a/src/volcano_sdk/_generated/models/frontend_custom_domain_conflict_error.py +++ b/src/volcano_sdk/_generated/models/frontend_custom_domain_conflict_error.py @@ -26,7 +26,9 @@ class FrontendCustomDomainConflictError: """ Domain ownership conflict. With `code: ownership_verification_required`, the account has not proven it owns the domain: publish `required_record` in DNS and send the same request again. The retry succeeds once Volcano can see - the record. Other conflicts omit both fields. + the record. With `code: custom_domain_releasing`, a removed domain still holds the hostname or the frontend while + Volcano releases it: send the same request again after the response's `Retry-After` seconds. A release that will not + finish within a few minutes is a plain conflict. Other conflicts omit both fields. Attributes: error (str): diff --git a/src/volcano_sdk/_generated/models/frontend_custom_domain_response.py b/src/volcano_sdk/_generated/models/frontend_custom_domain_response.py index d04b3329..2f4659f0 100644 --- a/src/volcano_sdk/_generated/models/frontend_custom_domain_response.py +++ b/src/volcano_sdk/_generated/models/frontend_custom_domain_response.py @@ -47,7 +47,11 @@ class FrontendCustomDomainResponse: failure_reason (str | Unset): Failure category, present only when managed TLS setup has failed. Current values are provider, certificate, ownership, and internal; ownership means another account has already claimed the hostname through ownership verification. Treat unrecognized values as internal. - verification_records (list[FrontendDomainVerificationRecord] | Unset): + verification_records (list[FrontendDomainVerificationRecord] | Unset): DNS records to publish now. For a managed + domain whose hostname the account already owns, the create response names the `_acme-challenge` CNAME that + authorizes certificate issuance and renewal. Otherwise it names the `_volcano` TXT record that proves ownership + of the hostname's registrable domain, and reads return the CNAME once Volcano sees that record. Usually empty + for BYOC. required_routing_record (FrontendDomainRoutingRecord | Unset): routing_target_hostname (str | Unset): DNS routing target hostname for this frontend. The DNS record type depends on whether the custom domain is a zone apex. diff --git a/src/volcano_sdk/_generated/models/metric_usage_data.py b/src/volcano_sdk/_generated/models/metric_usage_data.py index 9f58ef12..e961121e 100644 --- a/src/volcano_sdk/_generated/models/metric_usage_data.py +++ b/src/volcano_sdk/_generated/models/metric_usage_data.py @@ -40,12 +40,19 @@ class MetricUsageData: at times the time it spent running, in megabyte-seconds; the allowance for it is published in gigabyte-seconds, which is 1024 of these. "Sandbox Running (MiB-Seconds)", "Sandbox Suspended (Seconds)", and - "Sandbox Uncertain (MiB-Seconds)" are preview-only usage; they do not - debit credits. Running reports configured memory times observed runtime, - not measured CPU or elastic memory consumption. Uncertain includes - observation gaps and unconfirmed state transitions. Suspended reports - duration, not snapshot size. Local sessions do not contribute. - Totals are rounded down after aggregating millisecond measurements. + "Sandbox Uncertain (MiB-Seconds)" report cloud Sandbox usage. Running + reports configured baseline memory times confirmed runtime, not measured + CPU or elastic memory consumption. For sessions admitted after the pricing + release, confirmed running time consumes the account's runtime allowance; + SUPERAGENT overage debits credits according to the account's pricing terms. + Suspended time and uncertain usage do not consume runtime allowance or + debit credits. Uncertain includes observation gaps and unconfirmed state + transitions. Suspended reports duration, not snapshot size; snapshot costs + are included in runtime pricing. Local and platform image-validation + sessions do not contribute. Existing preview sessions remain unbilled, + and preview history is never charged retroactively. Displayed totals are + rounded down after aggregating millisecond measurements; billing uses + unrounded measurements in the monthly account billing cycle. "Database Storage (Bytes)" is a current observed gauge, not a cumulative counter. It is the sum of the latest samples exposed as `storage_bytes` by the project's database list, so it includes what each diff --git a/src/volcano_sdk/_generated/models/open_durable_message_session_request.py b/src/volcano_sdk/_generated/models/open_durable_message_session_request.py new file mode 100644 index 00000000..68915df7 --- /dev/null +++ b/src/volcano_sdk/_generated/models/open_durable_message_session_request.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + + + + + + + +T = TypeVar("T", bound="OpenDurableMessageSessionRequest") + + + +@_attrs_define +class OpenDurableMessageSessionRequest: + """ + Attributes: + execution_ref (str): Opaque reference to the running execution, supplied by the SDK. + callback_id (str): Opaque session reference, supplied by the SDK. + """ + + execution_ref: str + callback_id: str + + + + + + def to_dict(self) -> dict[str, Any]: + execution_ref = self.execution_ref + + callback_id = self.callback_id + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "execution_ref": execution_ref, + "callback_id": callback_id, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + execution_ref = d.pop("execution_ref") + + callback_id = d.pop("callback_id") + + open_durable_message_session_request = cls( + execution_ref=execution_ref, + callback_id=callback_id, + ) + + return open_durable_message_session_request + diff --git a/src/volcano_sdk/_generated/models/project_config_sandbox.py b/src/volcano_sdk/_generated/models/project_config_sandbox.py index 23751cdd..6e96fd0f 100644 --- a/src/volcano_sdk/_generated/models/project_config_sandbox.py +++ b/src/volcano_sdk/_generated/models/project_config_sandbox.py @@ -30,14 +30,14 @@ class ProjectConfigSandbox: memory_mb (ProjectConfigSandboxMemoryMb | Unset): Immutable deployed memory profile; config apply asserts it and Git deploy builds it. ports (list[int] | Unset): Service readiness ports; config apply asserts them and Git deploy builds them. - idle_timeout_seconds (int | Unset): Default idle timeout for new sessions when the caller omits it. - ttl_seconds (int | Unset): Default absolute lifetime for new sessions when the caller omits it. + ttl_seconds (int | Unset): Default absolute lifetime for new sessions when the caller omits it. Cloud templates + accept 30–28800 seconds; inherited lifetimes are clamped to the session plan limit (3600 for HOBBY, 28800 for + SUPERAGENT). Local accepts 0 for unlimited or a positive lifetime. """ name: str memory_mb: ProjectConfigSandboxMemoryMb | Unset = UNSET ports: list[int] | Unset = UNSET - idle_timeout_seconds: int | Unset = UNSET ttl_seconds: int | Unset = UNSET @@ -58,8 +58,6 @@ def to_dict(self) -> dict[str, Any]: - idle_timeout_seconds = self.idle_timeout_seconds - ttl_seconds = self.ttl_seconds @@ -72,8 +70,6 @@ def to_dict(self) -> dict[str, Any]: field_dict["memory_mb"] = memory_mb if ports is not UNSET: field_dict["ports"] = ports - if idle_timeout_seconds is not UNSET: - field_dict["idle_timeout_seconds"] = idle_timeout_seconds if ttl_seconds is not UNSET: field_dict["ttl_seconds"] = ttl_seconds @@ -99,15 +95,12 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ports = cast(list[int], d.pop("ports", UNSET)) - idle_timeout_seconds = d.pop("idle_timeout_seconds", UNSET) - ttl_seconds = d.pop("ttl_seconds", UNSET) project_config_sandbox = cls( name=name, memory_mb=memory_mb, ports=ports, - idle_timeout_seconds=idle_timeout_seconds, ttl_seconds=ttl_seconds, ) diff --git a/src/volcano_sdk/_generated/models/project_frontend_custom_domain.py b/src/volcano_sdk/_generated/models/project_frontend_custom_domain.py index 3eb1e683..743b05dc 100644 --- a/src/volcano_sdk/_generated/models/project_frontend_custom_domain.py +++ b/src/volcano_sdk/_generated/models/project_frontend_custom_domain.py @@ -50,7 +50,11 @@ class ProjectFrontendCustomDomain: failure_reason (str | Unset): Failure category, present only when managed TLS setup has failed. Current values are provider, certificate, ownership, and internal; ownership means another account has already claimed the hostname through ownership verification. Treat unrecognized values as internal. - verification_records (list[FrontendDomainVerificationRecord] | Unset): + verification_records (list[FrontendDomainVerificationRecord] | Unset): DNS records to publish now. For a managed + domain whose hostname the account already owns, the create response names the `_acme-challenge` CNAME that + authorizes certificate issuance and renewal. Otherwise it names the `_volcano` TXT record that proves ownership + of the hostname's registrable domain, and reads return the CNAME once Volcano sees that record. Usually empty + for BYOC. required_routing_record (FrontendDomainRoutingRecord | Unset): routing_target_hostname (str | Unset): DNS routing target hostname for this frontend. The DNS record type depends on whether the custom domain is a zone apex. diff --git a/src/volcano_sdk/_generated/models/publish_durable_message_request.py b/src/volcano_sdk/_generated/models/publish_durable_message_request.py new file mode 100644 index 00000000..8e728393 --- /dev/null +++ b/src/volcano_sdk/_generated/models/publish_durable_message_request.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..types import UNSET, Unset + + + + + + +T = TypeVar("T", bound="PublishDurableMessageRequest") + + + +@_attrs_define +class PublishDurableMessageRequest: + """ + Attributes: + name (str): The name a workflow waits on or publishes under: up to 237 printable + ASCII characters. + data (Any | Unset): Any JSON value, up to 64 KiB. Omitted means null. + """ + + name: str + data: Any | Unset = UNSET + + + + + + def to_dict(self) -> dict[str, Any]: + name = self.name + + data = self.data + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "name": name, + }) + if data is not UNSET: + field_dict["data"] = data + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + data = d.pop("data", UNSET) + + publish_durable_message_request = cls( + name=name, + data=data, + ) + + return publish_durable_message_request + diff --git a/src/volcano_sdk/_generated/models/register_durable_message_wait_request.py b/src/volcano_sdk/_generated/models/register_durable_message_wait_request.py new file mode 100644 index 00000000..40d88bce --- /dev/null +++ b/src/volcano_sdk/_generated/models/register_durable_message_wait_request.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + + + + + + + +T = TypeVar("T", bound="RegisterDurableMessageWaitRequest") + + + +@_attrs_define +class RegisterDurableMessageWaitRequest: + """ + Attributes: + execution_ref (str): Opaque reference to the running execution, supplied by the SDK. + callback_id (str): Opaque wait reference, supplied by the SDK. + name (str): The name a workflow waits on or publishes under: up to 237 printable + ASCII characters. + """ + + execution_ref: str + callback_id: str + name: str + + + + + + def to_dict(self) -> dict[str, Any]: + execution_ref = self.execution_ref + + callback_id = self.callback_id + + name = self.name + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "execution_ref": execution_ref, + "callback_id": callback_id, + "name": name, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + execution_ref = d.pop("execution_ref") + + callback_id = d.pop("callback_id") + + name = d.pop("name") + + register_durable_message_wait_request = cls( + execution_ref=execution_ref, + callback_id=callback_id, + name=name, + ) + + return register_durable_message_wait_request + diff --git a/src/volcano_sdk/_generated/models/rename_variable_environment_request.py b/src/volcano_sdk/_generated/models/rename_variable_environment_request.py new file mode 100644 index 00000000..029d2c9a --- /dev/null +++ b/src/volcano_sdk/_generated/models/rename_variable_environment_request.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + + + + + + + +T = TypeVar("T", bound="RenameVariableEnvironmentRequest") + + + +@_attrs_define +class RenameVariableEnvironmentRequest: + """ + Attributes: + name (str): Leading and trailing ASCII whitespace is trimmed before validating 1–64 ASCII letters, digits, + underscores, or hyphens. Global is reserved. + """ + + name: str + + + + + + def to_dict(self) -> dict[str, Any]: + name = self.name + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "name": name, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + rename_variable_environment_request = cls( + name=name, + ) + + return rename_variable_environment_request + diff --git a/src/volcano_sdk/_generated/models/request_durable_approval_request.py b/src/volcano_sdk/_generated/models/request_durable_approval_request.py index 043cff5e..fb4aa5f1 100644 --- a/src/volcano_sdk/_generated/models/request_durable_approval_request.py +++ b/src/volcano_sdk/_generated/models/request_durable_approval_request.py @@ -28,8 +28,11 @@ class RequestDurableApprovalRequest: name (str): title (str): description (str | Unset): - details (Any | Unset): Any JSON value to show the person deciding. The whole request is - limited to 64 KiB. + details (Any | Unset): Any JSON value to show the person deciding. A number's exponent must + be between -324 and 324, and a number too large or too precise to + store is refused with `400`. Numbers are read back written out in + full, so the exponents' absolute values may total at most 65,536. + The whole request is limited to 64 KiB. """ execution_ref: str diff --git a/src/volcano_sdk/_generated/models/sandbox_command_request.py b/src/volcano_sdk/_generated/models/sandbox_command_request.py index d52926fd..7c8e31dc 100644 --- a/src/volcano_sdk/_generated/models/sandbox_command_request.py +++ b/src/volcano_sdk/_generated/models/sandbox_command_request.py @@ -27,12 +27,15 @@ class SandboxCommandRequest: """ Attributes: command (str): - timeout_seconds (int | Unset): Default: 60. + timeout_seconds (int | Unset): Command execution time from process start. Cloud defaults to 60 seconds and + accepts 1–28800; local defaults to 0 (unlimited) and accepts nonnegative values. VM expiry always takes + precedence. Cloud synchronous requests must return within the public connection idle limit (1000 seconds); use a + session with a background process and short polling requests for longer work. environment (SandboxCommandRequestEnvironment | Unset): """ command: str - timeout_seconds: int | Unset = 60 + timeout_seconds: int | Unset = UNSET environment: SandboxCommandRequestEnvironment | Unset = UNSET diff --git a/src/volcano_sdk/_generated/models/sandbox_session.py b/src/volcano_sdk/_generated/models/sandbox_session.py index 5eb7f7b3..9cc9b99e 100644 --- a/src/volcano_sdk/_generated/models/sandbox_session.py +++ b/src/volcano_sdk/_generated/models/sandbox_session.py @@ -32,25 +32,26 @@ class SandboxSession: Attributes: id (UUID): project_id (UUID): - sandbox_id (UUID): + sandbox_id (None | UUID): Explicit template used to create the session. Null when created directly from a + preset. state (SandboxSessionState): desired_state (SandboxSessionDesiredState): region (str): memory_mb (int): created_at (datetime.datetime): - expires_at (datetime.datetime): + expires_at (datetime.datetime | None): Absolute VM expiry. Null means unlimited in local mode. started_at (datetime.datetime | Unset): """ id: UUID project_id: UUID - sandbox_id: UUID + sandbox_id: None | UUID state: SandboxSessionState desired_state: SandboxSessionDesiredState region: str memory_mb: int created_at: datetime.datetime - expires_at: datetime.datetime + expires_at: datetime.datetime | None started_at: datetime.datetime | Unset = UNSET @@ -62,7 +63,11 @@ def to_dict(self) -> dict[str, Any]: project_id = str(self.project_id) - sandbox_id = str(self.sandbox_id) + sandbox_id: None | str + if isinstance(self.sandbox_id, UUID): + sandbox_id = str(self.sandbox_id) + else: + sandbox_id = self.sandbox_id state: str = self.state @@ -74,7 +79,11 @@ def to_dict(self) -> dict[str, Any]: created_at = self.created_at.isoformat() - expires_at = self.expires_at.isoformat() + expires_at: None | str + if isinstance(self.expires_at, datetime.datetime): + expires_at = self.expires_at.isoformat() + else: + expires_at = self.expires_at started_at: str | Unset = UNSET if not isinstance(self.started_at, Unset): @@ -114,9 +123,22 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - sandbox_id = UUID(d.pop("sandbox_id")) + def _parse_sandbox_id(data: object) -> None | UUID: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + sandbox_id_type_0 = UUID(data) + + return sandbox_id_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | UUID, data) + + sandbox_id = _parse_sandbox_id(d.pop("sandbox_id")) state = check_sandbox_session_state(d.pop("state")) @@ -138,9 +160,22 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - expires_at = datetime.datetime.fromisoformat(d.pop("expires_at")) + def _parse_expires_at(data: object) -> datetime.datetime | None: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + expires_at_type_0 = datetime.datetime.fromisoformat(data) + + + return expires_at_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None, data) + expires_at = _parse_expires_at(d.pop("expires_at")) _started_at = d.pop("started_at", UNSET) diff --git a/src/volcano_sdk/_generated/models/send_durable_message_request.py b/src/volcano_sdk/_generated/models/send_durable_message_request.py new file mode 100644 index 00000000..1430556e --- /dev/null +++ b/src/volcano_sdk/_generated/models/send_durable_message_request.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..types import UNSET, Unset + + + + + + +T = TypeVar("T", bound="SendDurableMessageRequest") + + + +@_attrs_define +class SendDurableMessageRequest: + """ + Attributes: + name (str): The name a workflow waits on or publishes under: up to 237 printable + ASCII characters. + data (Any | Unset): Any JSON value, up to 64 KiB. Omitted means null. + """ + + name: str + data: Any | Unset = UNSET + + + + + + def to_dict(self) -> dict[str, Any]: + name = self.name + + data = self.data + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "name": name, + }) + if data is not UNSET: + field_dict["data"] = data + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + data = d.pop("data", UNSET) + + send_durable_message_request = cls( + name=name, + data=data, + ) + + return send_durable_message_request + diff --git a/src/volcano_sdk/_generated/models/update_auth_config_request.py b/src/volcano_sdk/_generated/models/update_auth_config_request.py index b1e1fb3f..7da93df3 100644 --- a/src/volcano_sdk/_generated/models/update_auth_config_request.py +++ b/src/volcano_sdk/_generated/models/update_auth_config_request.py @@ -30,8 +30,11 @@ class UpdateAuthConfigRequest: Attributes: access_token_lifetime (int | Unset): refresh_token_lifetime (int | Unset): + refresh_token_reuse_interval (int | Unset): inactivity_timeout (int | Unset): max_session_duration (int | Unset): + platform_token_ttl (int | Unset): TTL in seconds for platform tokens minted via `/auth/platform/exchange`. From + 3600 (1 hour) to 31536000 (365 days). min_password_length (int | Unset): require_uppercase (bool | Unset): require_lowercase (bool | Unset): @@ -42,6 +45,11 @@ class UpdateAuthConfigRequest: rate_limit_signup (int | Unset): rate_limit_signin (int | Unset): rate_limit_token_refresh (int | Unset): + rate_limit_password_reset (int | Unset): Password reset requests per hour per IP. Unlike the other limits, 0 + applies the default of 10 instead of turning the limit off. + cors_enabled (bool | Unset): + cors_allowed_origins (list[str] | Unset): Replaces the allowed origins list. Example: ['https://myapp.com', + 'http://localhost:3000']. cors_allow_credentials (bool | Unset): cors_max_age (int | Unset): enable_anonymous_signins (bool | Unset): @@ -94,8 +102,10 @@ class UpdateAuthConfigRequest: access_token_lifetime: int | Unset = UNSET refresh_token_lifetime: int | Unset = UNSET + refresh_token_reuse_interval: int | Unset = UNSET inactivity_timeout: int | Unset = UNSET max_session_duration: int | Unset = UNSET + platform_token_ttl: int | Unset = UNSET min_password_length: int | Unset = UNSET require_uppercase: bool | Unset = UNSET require_lowercase: bool | Unset = UNSET @@ -106,6 +116,9 @@ class UpdateAuthConfigRequest: rate_limit_signup: int | Unset = UNSET rate_limit_signin: int | Unset = UNSET rate_limit_token_refresh: int | Unset = UNSET + rate_limit_password_reset: int | Unset = UNSET + cors_enabled: bool | Unset = UNSET + cors_allowed_origins: list[str] | Unset = UNSET cors_allow_credentials: bool | Unset = UNSET cors_max_age: int | Unset = UNSET enable_anonymous_signins: bool | Unset = UNSET @@ -144,10 +157,14 @@ def to_dict(self) -> dict[str, Any]: refresh_token_lifetime = self.refresh_token_lifetime + refresh_token_reuse_interval = self.refresh_token_reuse_interval + inactivity_timeout = self.inactivity_timeout max_session_duration = self.max_session_duration + platform_token_ttl = self.platform_token_ttl + min_password_length = self.min_password_length require_uppercase = self.require_uppercase @@ -168,6 +185,16 @@ def to_dict(self) -> dict[str, Any]: rate_limit_token_refresh = self.rate_limit_token_refresh + rate_limit_password_reset = self.rate_limit_password_reset + + cors_enabled = self.cors_enabled + + cors_allowed_origins: list[str] | Unset = UNSET + if not isinstance(self.cors_allowed_origins, Unset): + cors_allowed_origins = self.cors_allowed_origins + + + cors_allow_credentials = self.cors_allow_credentials cors_max_age = self.cors_max_age @@ -242,10 +269,14 @@ def to_dict(self) -> dict[str, Any]: field_dict["access_token_lifetime"] = access_token_lifetime if refresh_token_lifetime is not UNSET: field_dict["refresh_token_lifetime"] = refresh_token_lifetime + if refresh_token_reuse_interval is not UNSET: + field_dict["refresh_token_reuse_interval"] = refresh_token_reuse_interval if inactivity_timeout is not UNSET: field_dict["inactivity_timeout"] = inactivity_timeout if max_session_duration is not UNSET: field_dict["max_session_duration"] = max_session_duration + if platform_token_ttl is not UNSET: + field_dict["platform_token_ttl"] = platform_token_ttl if min_password_length is not UNSET: field_dict["min_password_length"] = min_password_length if require_uppercase is not UNSET: @@ -266,6 +297,12 @@ def to_dict(self) -> dict[str, Any]: field_dict["rate_limit_signin"] = rate_limit_signin if rate_limit_token_refresh is not UNSET: field_dict["rate_limit_token_refresh"] = rate_limit_token_refresh + if rate_limit_password_reset is not UNSET: + field_dict["rate_limit_password_reset"] = rate_limit_password_reset + if cors_enabled is not UNSET: + field_dict["cors_enabled"] = cors_enabled + if cors_allowed_origins is not UNSET: + field_dict["cors_allowed_origins"] = cors_allowed_origins if cors_allow_credentials is not UNSET: field_dict["cors_allow_credentials"] = cors_allow_credentials if cors_max_age is not UNSET: @@ -332,10 +369,14 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: refresh_token_lifetime = d.pop("refresh_token_lifetime", UNSET) + refresh_token_reuse_interval = d.pop("refresh_token_reuse_interval", UNSET) + inactivity_timeout = d.pop("inactivity_timeout", UNSET) max_session_duration = d.pop("max_session_duration", UNSET) + platform_token_ttl = d.pop("platform_token_ttl", UNSET) + min_password_length = d.pop("min_password_length", UNSET) require_uppercase = d.pop("require_uppercase", UNSET) @@ -356,6 +397,13 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: rate_limit_token_refresh = d.pop("rate_limit_token_refresh", UNSET) + rate_limit_password_reset = d.pop("rate_limit_password_reset", UNSET) + + cors_enabled = d.pop("cors_enabled", UNSET) + + cors_allowed_origins = cast(list[str], d.pop("cors_allowed_origins", UNSET)) + + cors_allow_credentials = d.pop("cors_allow_credentials", UNSET) cors_max_age = d.pop("cors_max_age", UNSET) @@ -423,8 +471,10 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: update_auth_config_request = cls( access_token_lifetime=access_token_lifetime, refresh_token_lifetime=refresh_token_lifetime, + refresh_token_reuse_interval=refresh_token_reuse_interval, inactivity_timeout=inactivity_timeout, max_session_duration=max_session_duration, + platform_token_ttl=platform_token_ttl, min_password_length=min_password_length, require_uppercase=require_uppercase, require_lowercase=require_lowercase, @@ -435,6 +485,9 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: rate_limit_signup=rate_limit_signup, rate_limit_signin=rate_limit_signin, rate_limit_token_refresh=rate_limit_token_refresh, + rate_limit_password_reset=rate_limit_password_reset, + cors_enabled=cors_enabled, + cors_allowed_origins=cors_allowed_origins, cors_allow_credentials=cors_allow_credentials, cors_max_age=cors_max_age, enable_anonymous_signins=enable_anonymous_signins, diff --git a/src/volcano_sdk/_generated/models/variable_environment.py b/src/volcano_sdk/_generated/models/variable_environment.py new file mode 100644 index 00000000..0ca687f5 --- /dev/null +++ b/src/volcano_sdk/_generated/models/variable_environment.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast +from uuid import UUID +import datetime + + + + + + +T = TypeVar("T", bound="VariableEnvironment") + + + +@_attrs_define +class VariableEnvironment: + """ Stable identity for a Project-owned variable Environment. + + Attributes: + id (UUID): + project_id (UUID): + name (str): + is_global (bool): Whether this is the reserved Global Environment. + created_at (datetime.datetime): + updated_at (datetime.datetime): + """ + + id: UUID + project_id: UUID + name: str + is_global: bool + created_at: datetime.datetime + updated_at: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + id = str(self.id) + + project_id = str(self.project_id) + + name = self.name + + is_global = self.is_global + + created_at = self.created_at.isoformat() + + updated_at = self.updated_at.isoformat() + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "project_id": project_id, + "name": name, + "is_global": is_global, + "created_at": created_at, + "updated_at": updated_at, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = UUID(d.pop("id")) + + + + + project_id = UUID(d.pop("project_id")) + + + + + name = d.pop("name") + + is_global = d.pop("is_global") + + created_at = datetime.datetime.fromisoformat(d.pop("created_at")) + + + + + updated_at = datetime.datetime.fromisoformat(d.pop("updated_at")) + + + + + variable_environment = cls( + id=id, + project_id=project_id, + name=name, + is_global=is_global, + created_at=created_at, + updated_at=updated_at, + ) + + + variable_environment.additional_properties = d + return variable_environment + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/variable_environment_list.py b/src/volcano_sdk/_generated/models/variable_environment_list.py new file mode 100644 index 00000000..1327b764 --- /dev/null +++ b/src/volcano_sdk/_generated/models/variable_environment_list.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast + +if TYPE_CHECKING: + from ..models.variable_environment import VariableEnvironment + + + + + +T = TypeVar("T", bound="VariableEnvironmentList") + + + +@_attrs_define +class VariableEnvironmentList: + """ + Attributes: + data (list[VariableEnvironment]): + """ + + data: list[VariableEnvironment] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.variable_environment import VariableEnvironment # noqa: PLC0415 + data = [] + for data_item_data in self.data: + data_item = data_item_data.to_dict() + data.append(data_item) + + + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "data": data, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.variable_environment import VariableEnvironment # noqa: PLC0415 + d = dict(src_dict) + data = [] + _data = d.pop("data") + for data_item_data in (_data): + data_item = VariableEnvironment.from_dict(data_item_data) + + + + data.append(data_item) + + + variable_environment_list = cls( + data=data, + ) + + + variable_environment_list.additional_properties = d + return variable_environment_list + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_realtime_channel.py b/src/volcano_sdk/_realtime_channel.py index b58bc7c7..20a964d7 100644 --- a/src/volcano_sdk/_realtime_channel.py +++ b/src/volcano_sdk/_realtime_channel.py @@ -33,7 +33,7 @@ POSTGRES_QUEUE_LIMIT, CallbackDelivery, CentrifugeSubscription, - ChannelType, + ChannelKind, PostgresChange, PostgresChangeCallback, PostgresDelivery, @@ -139,6 +139,8 @@ async def on_subscribed(self, ctx: object) -> None: await self.channel.begin_postgres_epoch() if self.channel.type == "presence": self.channel.presence.schedule_presence_sync() + if self.channel.resubscribed is not None: + self.channel.resubscribed() @override async def on_unsubscribed(self, ctx: object) -> None: @@ -179,9 +181,10 @@ def __init__( self, realtime: RealtimeOperations, name: str, - channel_type: ChannelType, + channel_type: ChannelKind, *, fetch_config: PostgresFetchConfig, + subscribe_data: Mapping[str, str] | None = None, ) -> None: """Create a channel managed by a realtime facade.""" self.realtime: RealtimeOperations = realtime @@ -189,8 +192,13 @@ def __init__( self, lambda: realtime.sync_presence(self) ) self.wire_name: str = name - self.type: ChannelType = channel_type + self.type: ChannelKind = channel_type self.fetch_config: PostgresFetchConfig = fetch_config + # Sent with every subscribe request, resubscribes included. + self.subscribe_data: Mapping[str, str] | None = subscribe_data + # Called each time the subscription is established once it is set, + # after the subscribed state and callbacks are in place. + self.resubscribed: Callable[[], None] | None = None self.callbacks: dict[str, list[DynamicCallback]] = {} self.presence_state: dict[str, RealtimePresenceInfo] = {} self.presence_events: list[tuple[str, RealtimePresenceInfo]] = [] @@ -258,6 +266,7 @@ def on(self, event: str, callback: Callable[[_MessageT], object]) -> ChannelStat "broadcast": {"message"}, "presence": {"message", "join", "leave", "presence_sync"}, "postgres": {"*"}, + "durable": {"message"}, }[self.type] if event not in allowed_events: message = f"unsupported realtime event: {event}" diff --git a/src/volcano_sdk/_realtime_connection.py b/src/volcano_sdk/_realtime_connection.py index 9ed25e96..dcca6456 100644 --- a/src/volcano_sdk/_realtime_connection.py +++ b/src/volcano_sdk/_realtime_connection.py @@ -48,6 +48,7 @@ POSTGRES_QUERY_UNAVAILABLE, CentrifugeFactory, CentrifugeSubscription, + ChannelKind, ChannelType, RealtimeConnectContext, RealtimeContext, @@ -382,10 +383,9 @@ async def remove_channel( self, name: str, *, - channel_type: ChannelType = "broadcast", + channel_type: ChannelKind = "broadcast", ) -> None: - """Unsubscribe and forget one broadcast or presence channel.""" - channel_type = validate_channel_type(channel_type) + """Unsubscribe and forget one channel.""" wire_name = f"{channel_type}:{name}" async with self.connection_lock: entry = self.channels.get(wire_name) @@ -593,7 +593,9 @@ async def _prepare_subscription( channel.name, events=channel.subscription_events, join_leave=channel.type == "presence", - recoverable=channel.type != "postgres", + # Postgres and durable deliveries are read back from the API. + recoverable=channel.type in {"broadcast", "presence"}, + data=channel.subscribe_data, ) return channel.subscription diff --git a/src/volcano_sdk/_realtime_durable.py b/src/volcano_sdk/_realtime_durable.py new file mode 100644 index 00000000..94d80fd5 --- /dev/null +++ b/src/volcano_sdk/_realtime_durable.py @@ -0,0 +1,88 @@ +"""The realtime channels durable message subscriptions follow executions on.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Generic, TypeVar + +from ._realtime_channel import ChannelState +from ._realtime_connection import ManagedChannel +from ._realtime_messages import ( + POSTGRES_BATCH_WINDOW_MS, + POSTGRES_MAX_BATCH_SIZE, + postgres_fetch_config, +) +from .realtime import Channel, Realtime + +if TYPE_CHECKING: + from ._durable_messages import DurableChannel + from ._realtime_connection import RealtimeState + +FacadeT = TypeVar("FacadeT") + +_CHANNEL_PREFIX = "durable:" + +# Durable channels fetch nothing; every channel carries a fetch configuration. +_NO_FETCH = postgres_fetch_config( + auto_fetch=False, + fetch_batch_window_ms=POSTGRES_BATCH_WINDOW_MS, + fetch_max_batch_size=POSTGRES_MAX_BATCH_SIZE, +) + + +class DurableChannels(Generic[FacadeT]): + """Open and close `durable:` channels on a shared connection.""" + + def __init__(self, state: RealtimeState[FacadeT]) -> None: + """Register durable channels with a client's realtime state.""" + self._state: RealtimeState[FacadeT] = state + + def durable_channel( + self, execution_id: str, client_token: str | None + ) -> ChannelState | None: + """Open the channel an execution publishes on. + + Returns + ------- + ChannelState | None + The new channel, or None when this connection already has it. + + """ + state = self._state + wire_name = _CHANNEL_PREFIX + execution_id + if wire_name in state.channels or wire_name in state.removing_channels: + return None + channel = ChannelState( + state, + wire_name, + "durable", + fetch_config=_NO_FETCH, + subscribe_data=( + None if client_token is None else {"client_token": client_token} + ), + ) + state.channels[wire_name] = ManagedChannel(channel, state.factory(channel)) + return channel + + async def remove_durable_channel(self, channel: DurableChannel) -> None: + """Unsubscribe and forget a channel, unless another has replaced it.""" + entry = self._state.channels.get(channel.name) + if entry is None or entry.state is not channel: + return + await self._state.remove_channel( + channel.name.removeprefix(_CHANNEL_PREFIX), channel_type="durable" + ) + + +class ClientRealtime(Realtime): + """A client's realtime facade, whose connection durable messages share.""" + + def durable_channels(self) -> DurableChannels[Channel]: + """Open durable message channels on this facade's connection. + + Returns + ------- + DurableChannels[Channel] + Channels that subscribe over the same connection as this facade. + + """ + return DurableChannels(self._state) diff --git a/src/volcano_sdk/_realtime_messages.py b/src/volcano_sdk/_realtime_messages.py index 6bb854e1..161aec03 100644 --- a/src/volcano_sdk/_realtime_messages.py +++ b/src/volcano_sdk/_realtime_messages.py @@ -57,6 +57,10 @@ ChannelType: TypeAlias = Literal["broadcast", "presence", "postgres"] +# Durable channels are opened only by `durable.messages.subscribe`. +ChannelKind: TypeAlias = ChannelType | Literal["durable"] + + PostgresEvent: TypeAlias = Literal["INSERT", "UPDATE", "DELETE"] diff --git a/src/volcano_sdk/_realtime_presence.py b/src/volcano_sdk/_realtime_presence.py index 9ce6d0dd..e05ed027 100644 --- a/src/volcano_sdk/_realtime_presence.py +++ b/src/volcano_sdk/_realtime_presence.py @@ -9,7 +9,7 @@ from ._realtime_messages import ( CHANNEL_NOT_SUBSCRIBED, PRESENCE_ONLY, - ChannelType, + ChannelKind, RealtimePresenceInfo, freeze_mapping, presence_info, @@ -22,7 +22,7 @@ class PresenceChannel(Protocol): - type: ChannelType + type: ChannelKind subscribed: bool presence_state: dict[str, RealtimePresenceInfo] presence_events: list[tuple[str, RealtimePresenceInfo]] diff --git a/src/volcano_sdk/_realtime_transport.py b/src/volcano_sdk/_realtime_transport.py index 3493ed94..bedfc82d 100644 --- a/src/volcano_sdk/_realtime_transport.py +++ b/src/volcano_sdk/_realtime_transport.py @@ -143,6 +143,7 @@ def new_subscription( events: object, join_leave: bool, recoverable: bool, + data: Mapping[str, str] | None, ) -> CentrifugeSubscription: """Create a subscription for a remote channel.""" ... @@ -268,12 +269,14 @@ def new_subscription( events: object, join_leave: bool, recoverable: bool, + data: Mapping[str, str] | None, ) -> CentrifugeSubscription: return self._connection.new_subscription( name, events=events, join_leave=join_leave, recoverable=recoverable, + data=data, ) def remove_subscription(self, subscription: CentrifugeSubscription) -> None: diff --git a/src/volcano_sdk/_tests/fixtures/durable_messages.py b/src/volcano_sdk/_tests/fixtures/durable_messages.py new file mode 100644 index 00000000..b7c5c960 --- /dev/null +++ b/src/volcano_sdk/_tests/fixtures/durable_messages.py @@ -0,0 +1,193 @@ +"""Volcano's durable message routes, faked for handlers on the local runner.""" + +from __future__ import annotations + +import json +import threading +from typing import TYPE_CHECKING + +import httpx + +from volcano_sdk._durable_response import json_value +from volcano_sdk._transport_types import decode_json + +from .durable_platform import REAL_CLIENT + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from volcano_sdk.durable_authoring import FunctionHandler + from volcano_sdk.models import JSONValue + +WAITS = "/durable-messages/waits" +SESSIONS = "/durable-messages/sessions" +PUBLISH = "/durable-messages/publish" +SESSION = "volcano:messages-session" +# sha256 of "token-1", the first token the fake hands out. +FIRST_TOKEN_DIGEST = "3f08aace122ee2368432c1ca23a049bc640bafbf00fdf33a52429f38ba12dbf9" +_INVOCATION_TIMEOUT_SECONDS = 30 + +Answer = tuple[str, bytes] + + +def callback_id(body: Mapping[str, JSONValue]) -> str: + value = body["callback_id"] + assert isinstance(value, str) + assert value + return value + + +def session_answer(index: int) -> bytes: + return json.dumps({"token": f"token-{index}"}).encode() + + +def message_payload(name: str, data: JSONValue = None) -> dict[str, JSONValue]: + return { + "id": f"message-for-{name}", + "name": name, + "data": data, + "sender": {"type": "user", "user_id": "user-7"}, + "sent_at": "2026-10-12T09:00:00Z", + } + + +class MessagePlatform: + """Stand in for Volcano's message routes, from the runtime's threads.""" + + def __init__(self) -> None: + self.waits: list[dict[str, JSONValue]] = [] + self.sessions: list[dict[str, JSONValue]] = [] + self.publishes: list[httpx.Request] = [] + # What a wait for each name is completed with; a name missing here is + # left for its timeout. + self.messages: dict[str, dict[str, JSONValue]] = {} + self.refusals: dict[str, httpx.Response] = {} + self._published: dict[str, dict[str, JSONValue]] = {} + self._answered: set[str] = set() + self._lock: threading.Lock = threading.Lock() + + def handle(self, request: httpx.Request) -> httpx.Response: + body = json_value(decode_json(request.content), "expected a JSON body") + assert isinstance(body, dict) + with self._lock: + path = request.url.path + if path == PUBLISH: + self.publishes.append(request) + else: + (self.waits if path == WAITS else self.sessions).append(body) + if path in self.refusals: + return self.refusals[path] + if path == PUBLISH: + return self._publish(request.headers["Idempotency-Key"], body) + registered = len(self.waits) + len(self.sessions) + return httpx.Response(201, json={"id": f"registration-{registered}"}) + + def _publish(self, key: str, body: Mapping[str, JSONValue]) -> httpx.Response: + if key in self._published: + return httpx.Response(200, json=self._published[key]) + message: dict[str, JSONValue] = { + "id": f"message-{len(self._published) + 1}", + "execution_id": "execution-1", + "direction": "out", + "seq": len(self._published) + 1, + "name": body["name"], + "data": body.get("data"), + "sender": {"type": "execution", "user_id": None}, + "status": "published", + "delivered_at": None, + "sent_at": "2026-10-12T09:00:00Z", + } + self._published[key] = message + return httpx.Response(201, json=message) + + def client(self, *, timeout: float) -> httpx.AsyncClient: + return REAL_CLIENT(transport=httpx.MockTransport(self.handle), timeout=timeout) + + def unanswered(self) -> list[Answer]: + """Claim the sessions and waits that can be completed now. + + Returns: + Each callback id with what to complete it with. + + """ + with self._lock: + answers = [ + (callback_id(body), session_answer(index)) + for index, body in enumerate(self.sessions, start=1) + ] + answers += [ + ( + callback_id(body), + json.dumps(self.messages[str(body["name"])]).encode(), + ) + for body in self.waits + if body["name"] in self.messages + ] + fresh = [answer for answer in answers if answer[0] not in self._answered] + self._answered.update(answer[0] for answer in fresh) + return fresh + + def idempotency_keys(self) -> list[str]: + return [request.headers["Idempotency-Key"] for request in self.publishes] + + def sessions_used(self) -> list[str]: + return [ + request.headers["X-Volcano-Durable-Session"] for request in self.publishes + ] + + +class Suspensions: + """Track a handler's invocations, to answer what each suspended on. + + The local runner invalidates an invocation's checkpoints when a callback + completes under it, so callbacks are completed only once no invocation + is running. + """ + + def __init__(self, handler: FunctionHandler) -> None: + self._handler: FunctionHandler = handler + self._running: int = 0 + self._finished: bool = False + self._changed: threading.Condition = threading.Condition() + + def __call__(self, event: object, function_context: object) -> object: + with self._changed: + self._running += 1 + output: object = None + try: + output = self._handler(event, function_context) + finally: + with self._changed: + self._running -= 1 + match output: + case {"Status": "PENDING"}: + self._finished = False + case _: + self._finished = True + self._changed.notify_all() + return output + + def next(self, unanswered: Callable[[], list[Answer]]) -> list[Answer]: + """Wait for the handler to stop, then return what it waits on. + + Returns: + The callbacks to complete, or none once the handler finished. + + Raises: + AssertionError: The handler neither suspends nor finishes. + + """ + answers: list[Answer] = [] + + def stopped() -> bool: + nonlocal answers + if self._running: + return False + answers = unanswered() + return bool(answers) or self._finished + + with self._changed: + if not self._changed.wait_for(stopped, timeout=_INVOCATION_TIMEOUT_SECONDS): + message = "expected the invocation to suspend or finish" + raise AssertionError(message) + return answers diff --git a/src/volcano_sdk/_tests/fixtures/durable_platform.py b/src/volcano_sdk/_tests/fixtures/durable_platform.py new file mode 100644 index 00000000..24ca8830 --- /dev/null +++ b/src/volcano_sdk/_tests/fixtures/durable_platform.py @@ -0,0 +1,113 @@ +"""A stand-in for the routes Volcano serves running workflows.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, NoReturn, TypeAlias + +import httpx + +if TYPE_CHECKING: + import pytest + +# Replaced for the module under test, which is the same attribute everywhere. +REAL_CLIENT = httpx.AsyncClient +PLATFORM_REQUEST = "volcano_sdk._durable_platform_request" +# Long past any attempt's deadline: a slow answer still running by then was +# never cut off, and ends rather than hang the suite. +GIVE_UP_SECONDS = 10.0 +_NEVER_CANCELLED = "the attempt outlived its deadline" + + +class Stall: + """Accept the request and never answer it.""" + + def __init__(self) -> None: + self.released: bool = False + + async def hold(self) -> NoReturn: + try: + await asyncio.sleep(GIVE_UP_SECONDS) + finally: + self.released = True + raise AssertionError(_NEVER_CANCELLED) + + +Answer: TypeAlias = "httpx.Response | Exception | Stall" + + +class Platform: + """Answer requests in order and record what was sent. + + Time is the platform's own: it passes while a request sleeps to retry and + while one runs into its timeout, and nowhere else. An attempt that is + cancelled at its deadline takes real time, not platform time. + """ + + def __init__(self, *answers: Answer) -> None: + self.answers: list[Answer] = list(answers) + self.requests: list[httpx.Request] = [] + self.timeouts: list[float] = [] + self.sleeps: list[float] = [] + self.now: float = 0.0 + # How much longer than asked each sleep takes. + self.oversleep: float = 0.0 + self.clock_runs: bool = True + + async def handle(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + match request.extensions: + case {"timeout": {"read": float(timeout)}}: + self.timeouts.append(timeout) + case _: + message = f"expected a read timeout, got {request.extensions}" + raise AssertionError(message) + answer = self.answers[0] if len(self.answers) == 1 else self.answers.pop(0) + if isinstance(answer, Stall): + await answer.hold() + if isinstance(answer, httpx.TimeoutException): + self.advance(timeout) + if isinstance(answer, Exception): + raise answer + return answer + + def client(self, *, timeout: float) -> httpx.AsyncClient: + return REAL_CLIENT(transport=httpx.MockTransport(self.handle), timeout=timeout) + + def monotonic(self) -> float: + return self.now + + def sleep(self, delay: float) -> None: + self.sleeps.append(delay) + self.advance(delay + self.oversleep) + + def advance(self, seconds: float) -> None: + if self.clock_runs: + self.now += seconds + + +def install_platform(monkeypatch: pytest.MonkeyPatch, *answers: Answer) -> Platform: + """Route the platform requests, and their clock, to a fake. + + Returns: + The fake, to inspect what was sent. + + """ + fake = Platform(*answers) + monkeypatch.setattr(f"{PLATFORM_REQUEST}.httpx.AsyncClient", fake.client) + monkeypatch.setattr(f"{PLATFORM_REQUEST}.monotonic", fake.monotonic) + monkeypatch.setattr(f"{PLATFORM_REQUEST}.sleep", fake.sleep) + return fake + + +def refused(status: int, code: str | None = None) -> httpx.Response: + """Answer the way Volcano refuses a request. + + Returns: + An error response with the status, and the code when given. + + """ + body = {"error": f"refused with {status}"} + if code is not None: + body["code"] = code + return httpx.Response(status, json=body) diff --git a/src/volcano_sdk/_tests/fixtures/invalid_arguments.py b/src/volcano_sdk/_tests/fixtures/invalid_arguments.py index fcbaba4b..91f8f362 100644 --- a/src/volcano_sdk/_tests/fixtures/invalid_arguments.py +++ b/src/volcano_sdk/_tests/fixtures/invalid_arguments.py @@ -6,6 +6,7 @@ from collections.abc import Mapping from typing import BinaryIO + from volcano_sdk._durable_messages import DurableMessages from volcano_sdk.auth import Auth from volcano_sdk.durable import DurableApprovals from volcano_sdk.durable_authoring import DurableContext @@ -146,6 +147,19 @@ async def remove_unsupported_realtime_channel_type(realtime: Realtime) -> None: ) +def durable_realtime_channel(realtime: Realtime, execution_id: str) -> None: + _ = realtime.channel(execution_id, channel_type="durable") # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + + +async def remove_durable_realtime_channel( + realtime: Realtime, execution_id: str +) -> None: + await realtime.remove_channel( + execution_id, + channel_type="durable", # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + ) + + def assign_session_page(page: SessionPage) -> None: page.page = 3 # type: ignore[misc] # pyright: ignore[reportAttributeAccessIssue] @@ -199,6 +213,40 @@ def non_string_approval_title(context: DurableContext) -> None: _ = context.wait_for_approval("ship", title=None) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] +def non_string_message_name(context: DurableContext) -> None: + _ = context.wait_for_message(1) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + + +def non_json_message_data(context: DurableContext) -> None: + _ = context.publish("progress", {"at": {1}}) # type: ignore[dict-item] # pyright: ignore[reportArgumentType] + + def unknown_approval_option(approvals: DurableApprovals) -> None: project_id = "00000000-0000-4000-8000-000000000001" approvals.stats(project_id, state="pending", sort="asc") # type: ignore[call-arg] # pyright: ignore[reportCallIssue] + + +def non_string_sent_message_name(messages: DurableMessages, execution_id: str) -> None: + _ = messages.send("checkout", execution_id, 5) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + + +def unknown_sent_message_option(messages: DurableMessages, execution_id: str) -> None: + messages.send("checkout", execution_id, "ping", retries=3) # type: ignore[call-arg] # pyright: ignore[reportCallIssue] + + +def unknown_listed_message_direction( + messages: DurableMessages, execution_id: str +) -> None: + _ = messages.list("checkout", execution_id, direction="both") # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + + +async def non_callable_message_handler( + messages: DurableMessages, execution_id: str +) -> None: + _ = await messages.subscribe("checkout", execution_id, "print") # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + + +async def unknown_subscription_option( + messages: DurableMessages, execution_id: str +) -> None: + await messages.subscribe("checkout", execution_id, print, after=3) # type: ignore[call-arg] # pyright: ignore[reportCallIssue] diff --git a/src/volcano_sdk/_tests/realtime_probes.py b/src/volcano_sdk/_tests/realtime_probes.py index c619c527..7655db17 100644 --- a/src/volcano_sdk/_tests/realtime_probes.py +++ b/src/volcano_sdk/_tests/realtime_probes.py @@ -12,6 +12,7 @@ from volcano_sdk import realtime as realtime_module from volcano_sdk._realtime_channel import ChannelState from volcano_sdk._realtime_connection import RealtimeState +from volcano_sdk._realtime_durable import ClientRealtime from volcano_sdk._realtime_fetch_worker import PostgresFetchWorker from volcano_sdk.realtime import Channel, Realtime @@ -46,7 +47,7 @@ def state(self) -> ChannelState: return state -class InspectableRealtime(Realtime): +class InspectableRealtime(ClientRealtime): @property def state(self) -> RealtimeState[Channel]: return self._state @@ -72,7 +73,7 @@ def realtime_state(realtime: Realtime) -> InspectedRealtimeState: @pytest.fixture(autouse=True) def realtime_state_probes(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(client_module, "Realtime", InspectableRealtime) + monkeypatch.setattr(client_module, "ClientRealtime", InspectableRealtime) monkeypatch.setattr(realtime_module, "Channel", InspectableChannel) monkeypatch.setattr(realtime_module, "RealtimeState", InspectedRealtimeState) monkeypatch.setattr(connection_module, "ChannelState", InspectedChannelState) diff --git a/src/volcano_sdk/_tests/test_durable.py b/src/volcano_sdk/_tests/test_durable.py index 2eccc72b..755a3c32 100644 --- a/src/volcano_sdk/_tests/test_durable.py +++ b/src/volcano_sdk/_tests/test_durable.py @@ -184,6 +184,23 @@ def test_start_falls_back_to_the_anon_key() -> None: assert transport.calls[0][1]["authorization"] == anon_key +def test_start_returns_the_client_token_the_message_routes_take() -> None: + transport = FakeDurableTransport() + transport.start_response = FakeResponse( + 202, running_execution(client_token="client-token"), {} + ) + + execution = durable_client(transport, session=False).durable.start("order-pipeline") + + assert execution.client_token == "client-token" + assert ( + durable_client(transport) + .durable.get(PROJECT_ID, "order-pipeline", EXECUTION_ID) + .client_token + is None + ) + + def test_get_returns_the_result_of_a_succeeded_execution() -> None: transport = FakeDurableTransport() @@ -614,3 +631,14 @@ def test_execution_rejects_non_boolean_expiration(value: object) -> None: _ = durable_client(transport).durable.get( PROJECT_ID, "order-pipeline", EXECUTION_ID ) + + +@pytest.mark.parametrize("value", ["", 7, ["client-token"]]) +def test_execution_rejects_an_invalid_client_token(value: object) -> None: + transport = FakeDurableTransport() + transport.start_response = FakeResponse( + 202, running_execution(client_token=value), {} + ) + + with pytest.raises(TypeError, match="complete durable execution"): + _ = durable_client(transport).durable.start("order-pipeline") diff --git a/src/volcano_sdk/_tests/test_durable_approval_registration.py b/src/volcano_sdk/_tests/test_durable_approval_registration.py index f94417aa..ec8d50c1 100644 --- a/src/volcano_sdk/_tests/test_durable_approval_registration.py +++ b/src/volcano_sdk/_tests/test_durable_approval_registration.py @@ -6,7 +6,7 @@ import threading import time from types import MappingProxyType -from typing import TYPE_CHECKING, NoReturn, Protocol, TypeAlias +from typing import TYPE_CHECKING, NoReturn, Protocol import httpx import pytest @@ -25,12 +25,22 @@ approval_body, approval_decision, check_size, - platform_api_url, register_approval, ) +from volcano_sdk._durable_platform_request import platform_api_url from volcano_sdk.durable_authoring import ApprovalDecision from volcano_sdk.models import DurableApprovalDecider +from .fixtures.durable_platform import ( + GIVE_UP_SECONDS, + PLATFORM_REQUEST, + Answer, + Platform, + Stall, + install_platform, + refused, +) + if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable @@ -44,33 +54,13 @@ "title": "Ship order 1234?", } RETRY_DELAYS = [0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0] -# The fixture replaces httpx.AsyncClient for the module under test, which is -# the same attribute everywhere. -REAL_CLIENT = httpx.AsyncClient -REGISTRATION = "volcano_sdk._durable_approval_registration" +# The largest answer read: a published message with 64 KiB of data, escaped. +MAX_ANSWER_BYTES = 524288 INVALID_DETAILS = ( "details must be JSON-serializable: mappings with string keys, lists, " "tuples, strings, finite numbers, booleans, and None" ) -# Long past any attempt's deadline: a slow answer still running by then was -# never cut off, and ends rather than hang the suite. -_GIVE_UP_SECONDS = 10.0 _TRICKLE_INTERVAL = 0.01 -_NEVER_CANCELLED = "the attempt outlived its deadline" - - -class Stall: - """Accept the request and never answer it.""" - - def __init__(self) -> None: - self.released: bool = False - - async def hold(self) -> NoReturn: - try: - await asyncio.sleep(_GIVE_UP_SECONDS) - finally: - self.released = True - raise AssertionError(_NEVER_CANCELLED) class Trickle(httpx.AsyncByteStream): @@ -82,7 +72,7 @@ def __init__(self) -> None: @override async def __aiter__(self) -> AsyncIterator[bytes]: - for _ in range(int(_GIVE_UP_SECONDS / _TRICKLE_INTERVAL)): + for _ in range(int(GIVE_UP_SECONDS / _TRICKLE_INTERVAL)): await asyncio.sleep(_TRICKLE_INTERVAL) self.sent += 1 yield b" " @@ -117,59 +107,6 @@ async def __aiter__(self) -> AsyncIterator[bytes]: yield piece -Answer: TypeAlias = "httpx.Response | Exception | Stall" - - -class Platform: - """Answer registrations in order and record what was sent. - - Time is the platform's own: it passes while the registration sleeps and - while a request runs into its timeout, and nowhere else. An attempt that - is cancelled at its deadline takes real time, not platform time. - """ - - def __init__(self, *answers: Answer) -> None: - self.answers: list[Answer] = list(answers) - self.requests: list[httpx.Request] = [] - self.timeouts: list[float] = [] - self.sleeps: list[float] = [] - self.now: float = 0.0 - # How much longer than asked each sleep takes. - self.oversleep: float = 0.0 - self.clock_runs: bool = True - - async def handle(self, request: httpx.Request) -> httpx.Response: - self.requests.append(request) - match request.extensions: - case {"timeout": {"read": float(timeout)}}: - self.timeouts.append(timeout) - case _: - message = f"expected a read timeout, got {request.extensions}" - raise AssertionError(message) - answer = self.answers[0] if len(self.answers) == 1 else self.answers.pop(0) - if isinstance(answer, Stall): - await answer.hold() - if isinstance(answer, httpx.TimeoutException): - self.advance(timeout) - if isinstance(answer, Exception): - raise answer - return answer - - def client(self, *, timeout: float) -> httpx.AsyncClient: - return REAL_CLIENT(transport=httpx.MockTransport(self.handle), timeout=timeout) - - def monotonic(self) -> float: - return self.now - - def sleep(self, delay: float) -> None: - self.sleeps.append(delay) - self.advance(delay + self.oversleep) - - def advance(self, seconds: float) -> None: - if self.clock_runs: - self.now += seconds - - class InstallPlatform(Protocol): def __call__(self, *answers: Answer) -> Platform: ... @@ -177,11 +114,7 @@ def __call__(self, *answers: Answer) -> Platform: ... @pytest.fixture def platform(monkeypatch: pytest.MonkeyPatch) -> InstallPlatform: def install(*answers: Answer) -> Platform: - fake = Platform(*answers) - monkeypatch.setattr(f"{REGISTRATION}.httpx.AsyncClient", fake.client) - monkeypatch.setattr(f"{REGISTRATION}.monotonic", fake.monotonic) - monkeypatch.setattr(f"{REGISTRATION}.sleep", fake.sleep) - return fake + return install_platform(monkeypatch, *answers) return install @@ -193,13 +126,6 @@ def registered(status: int = 201) -> httpx.Response: ) -def refused(status: int, code: str | None = None) -> httpx.Response: - body = {"error": f"refused with {status}"} - if code is not None: - body["code"] = code - return httpx.Response(status, json=body) - - def test_registration_posts_the_body_without_credentials( platform: InstallPlatform, ) -> None: @@ -439,15 +365,15 @@ def test_an_attempt_ends_by_its_deadline_while_the_host_lookup_stalls( # whose host lookup runs on a thread that cancelling cannot stop. fake = Platform() fake.oversleep = _WAKE_LATE - monkeypatch.setattr(f"{REGISTRATION}.monotonic", fake.monotonic) - monkeypatch.setattr(f"{REGISTRATION}.sleep", fake.sleep) + monkeypatch.setattr(f"{PLATFORM_REQUEST}.monotonic", fake.monotonic) + monkeypatch.setattr(f"{PLATFORM_REQUEST}.sleep", fake.sleep) released = threading.Event() lookups: list[object] = [] def lookup(host: object, *_args: object, **_kwargs: object) -> NoReturn: lookups.append(host) if len(lookups) > 1: - _ = released.wait(_GIVE_UP_SECONDS) + _ = released.wait(GIVE_UP_SECONDS) raise socket.gaierror(socket.EAI_NONAME, "unknown host") monkeypatch.setattr(socket, "getaddrinfo", lookup) @@ -485,8 +411,8 @@ def test_a_stalled_loop_still_reports_the_attempts_deadline( # connect timeout in one pass, while the host lookup is still running. fake = Platform() fake.oversleep = _WAKE_LATE - monkeypatch.setattr(f"{REGISTRATION}.monotonic", fake.monotonic) - monkeypatch.setattr(f"{REGISTRATION}.sleep", fake.sleep) + monkeypatch.setattr(f"{PLATFORM_REQUEST}.monotonic", fake.monotonic) + monkeypatch.setattr(f"{PLATFORM_REQUEST}.sleep", fake.sleep) released = threading.Event() lookups: list[object] = [] new_event_loop = asyncio.new_event_loop @@ -494,7 +420,7 @@ def test_a_stalled_loop_still_reports_the_attempts_deadline( def lookup(host: object, *_args: object, **_kwargs: object) -> NoReturn: lookups.append(host) if len(lookups) > 1: - _ = released.wait(_GIVE_UP_SECONDS) + _ = released.wait(GIVE_UP_SECONDS) raise socket.gaierror(socket.EAI_NONAME, "unknown host") def stalling_loop() -> asyncio.AbstractEventLoop: @@ -503,7 +429,7 @@ def stalling_loop() -> asyncio.AbstractEventLoop: return loop monkeypatch.setattr(socket, "getaddrinfo", lookup) - monkeypatch.setattr(f"{REGISTRATION}.asyncio.new_event_loop", stalling_loop) + monkeypatch.setattr(f"{PLATFORM_REQUEST}.asyncio.new_event_loop", stalling_loop) try: with pytest.raises(TransportError) as caught: @@ -525,13 +451,13 @@ def test_an_answer_too_large_to_be_volcanos_is_not_read_to_the_end( register_approval(API_URL, BODY) assert caught.value.code is None - assert flood.sent <= 65536 + 4096 + assert flood.sent <= MAX_ANSWER_BYTES + 4096 assert len(fake.requests) == 1 def test_an_answer_at_the_size_limit_is_read(platform: InstallPlatform) -> None: closed = refused(409, "approval_closed").content - fake = platform(httpx.Response(409, content=closed.ljust(65536))) + fake = platform(httpx.Response(409, content=closed.ljust(MAX_ANSWER_BYTES))) register_approval(API_URL, BODY) @@ -609,7 +535,7 @@ def test_the_platform_api_url_comes_from_the_environment( ) -> None: monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", f" {API_URL} ") - assert platform_api_url() == API_URL + assert platform_api_url("wait_for_approval()") == API_URL @pytest.mark.parametrize("value", [None, "", " "]) @@ -622,7 +548,7 @@ def test_a_missing_platform_api_url_is_explained( monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", value) with pytest.raises(RuntimeError) as caught: - _ = platform_api_url() + _ = platform_api_url("wait_for_approval()") assert str(caught.value) == ( "wait_for_approval() needs VOLCANO_PLATFORM_API_URL. Volcano sets it on " diff --git a/src/volcano_sdk/_tests/test_durable_authoring.py b/src/volcano_sdk/_tests/test_durable_authoring.py index 4572ded3..3d6b0c95 100644 --- a/src/volcano_sdk/_tests/test_durable_authoring.py +++ b/src/volcano_sdk/_tests/test_durable_authoring.py @@ -48,6 +48,7 @@ from volcano_sdk._durable_duration import to_seconds from volcano_sdk._durable_engine import Engine, load_engine from volcano_sdk._durable_results import completion_reason +from volcano_sdk._transport_types import decode_json from volcano_sdk.durable_authoring import ( ApprovalDecision, BatchFailure, @@ -58,12 +59,13 @@ DurableRuntimeMissingError, FunctionHandler, ParallelBranch, + ReceivedMessage, RetryOptions, StepScope, WaitUntilOptions, durable, ) -from volcano_sdk.models import DurableApprovalDecider +from volcano_sdk.models import DurableApprovalDecider, DurableMessageSender from .fixtures.durable_context import ( RecordedBatch, @@ -76,6 +78,16 @@ InspectedDurableContext, InspectedEngine, ) +from .fixtures.durable_messages import ( + FIRST_TOKEN_DIGEST, + PUBLISH, + SESSION, + WAITS, + MessagePlatform, + Suspensions, + message_payload, +) +from .fixtures.durable_platform import PLATFORM_REQUEST from .fixtures.invalid_arguments import non_string_approval_title from .fixtures.invalid_callbacks import ( decorate_non_callable, @@ -90,6 +102,10 @@ if TYPE_CHECKING: from collections.abc import Callable, Generator, Iterator + from aws_durable_execution_sdk_python_testing.runner import ( + DurableFunctionTestResult, + ) + from volcano_sdk._durable_protocols import RuntimeContext from volcano_sdk.models import JSONValue @@ -1696,7 +1712,7 @@ def client(*, timeout: float) -> httpx.AsyncClient: return real_client(transport=httpx.MockTransport(closed), timeout=timeout) monkeypatch.setattr( - "volcano_sdk._durable_approval_registration.httpx.AsyncClient", client + "volcano_sdk._durable_platform_request.httpx.AsyncClient", client ) monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) @@ -1748,7 +1764,9 @@ def test_a_missing_platform_api_url_fails_before_opening_a_callback( runtime = RecordingContext() context = DurableContext(runtime, Engine()) - with pytest.raises(RuntimeError, match="needs VOLCANO_PLATFORM_API_URL"): + with pytest.raises( + RuntimeError, match=r"^wait_for_approval\(\) needs VOLCANO_PLATFORM_API_URL" + ): _ = context.wait_for_approval("ship-order", title="Ship?") assert runtime.submitter is None @@ -1963,3 +1981,285 @@ def handler(_event: object, ctx: DurableContext) -> object: assert first != second assert [body["execution_ref"] for body in registrations.bodies] == [first] assert [body["execution_ref"] for body in later.bodies] == [second] + + +@pytest.fixture +def message_platform(monkeypatch: pytest.MonkeyPatch) -> MessagePlatform: + fake = MessagePlatform() + monkeypatch.setattr(f"{PLATFORM_REQUEST}.httpx.AsyncClient", fake.client) + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) + return fake + + +def exchange( + handler: FunctionHandler, platform: MessagePlatform +) -> tuple[str, DurableFunctionTestResult]: + """Run a handler, answering every session it opens and message it waits for. + + Returns: + The execution reference and the runner's result. + + """ + suspensions = Suspensions(handler) + with local_runner(suspensions) as runner: + execution_ref = runner.run_async(input=json.dumps({})) + while answers := suspensions.next(platform.unanswered): + for callback, answer in answers: + runner.send_callback_success(callback, answer) + return execution_ref, runner.wait_for_result(execution_ref, timeout=60) + + +def succeeded(result: DurableFunctionTestResult) -> object: + if result.error is not None: + raise AssertionError(result.error.message) + assert result.result is not None + return decode_json(result.result.encode()) + + +def received(value: ReceivedMessage | None) -> object: + if value is None: + return None + return { + "id": value.id, + "name": value.name, + "data": value.data, + "sender": [value.sender.type, value.sender.user_id], + "sent_at": value.sent_at, + } + + +def test_a_message_wait_registers_and_resumes_with_the_message( + message_platform: MessagePlatform, +) -> None: + message_platform.messages["approve"] = message_payload("approve", {"ok": True}) + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + return received(ctx.wait_for_message("approve", timeout="1h")) + + execution_ref, result = exchange(handler, message_platform) + + assert succeeded(result) == { + "id": "message-for-approve", + "name": "approve", + "data": {"ok": True}, + "sender": ["user", "user-7"], + "sent_at": "2026-10-12T09:00:00Z", + } + [body] = message_platform.waits + assert body == { + "execution_ref": execution_ref, + "callback_id": callback_id(body), + "name": "approve", + } + assert message_platform.sessions == [] + + +@pytest.mark.parametrize( + "refusal", + [ + None, + httpx.Response( + 409, json={"error": "callback is closed", "code": "callback_closed"} + ), + ], +) +def test_a_message_wait_that_times_out_or_closes_returns_none( + message_platform: MessagePlatform, refusal: httpx.Response | None +) -> None: + if refusal is not None: + message_platform.refusals[WAITS] = refusal + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + return {"message": received(ctx.wait_for_message("approve", timeout="1s"))} + + # A lost timeout would otherwise leave the runner waiting for 15 minutes. + with local_runner(handler) as runner: + result = runner.run(input=json.dumps({}), timeout=15) + + assert succeeded(result) == {"message": None} + assert len(message_platform.waits) == 1 + + +def test_a_refused_message_wait_fails_the_execution( + message_platform: MessagePlatform, +) -> None: + message_platform.refusals[WAITS] = httpx.Response( + 409, + json={"error": "too many open message waits", "code": "too_many_message_waits"}, + ) + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + return received(ctx.wait_for_message("approve")) + + _, result = exchange(handler, message_platform) + + assert result.error is not None + assert "too many open message waits" in str(result.error.message) + assert len(message_platform.waits) == 1 + + +def test_a_replay_does_not_register_the_message_wait_again( + message_platform: MessagePlatform, +) -> None: + message_platform.messages["approve"] = message_payload("approve", "yes") + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + approval = ctx.wait_for_message("approve") + ctx.wait("after", "1s") + return ctx.step("ship", lambda _scope: received(approval)) + + _, result = exchange(handler, message_platform) + + assert succeeded(result) == received( + ReceivedMessage( + id="message-for-approve", + name="approve", + data="yes", + sender=DurableMessageSender(type="user", user_id="user-7"), + sent_at="2026-10-12T09:00:00Z", + ) + ) + assert len(message_platform.waits) == 1 + + +def test_a_publish_opens_a_session_and_returns_the_message_id_and_seq( + message_platform: MessagePlatform, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + published = ctx.publish("progress", {"done": 1}) + return [published.id, published.seq] + + execution_ref, result = exchange(handler, message_platform) + + assert succeeded(result) == ["message-1", 1] + [session] = message_platform.sessions + assert session == { + "execution_ref": execution_ref, + "callback_id": callback_id(session), + } + [request] = message_platform.publishes + assert str(request.url) == f"{PLATFORM_API_URL}{PUBLISH}" + assert request.headers["X-Volcano-Durable-Session"] == "token-1" + assert request.headers["Idempotency-Key"] == f"{FIRST_TOKEN_DIGEST}:1" + assert json.loads(request.content) == {"name": "progress", "data": {"done": 1}} + assert [operation.name for operation in result.operations] == [ + SESSION, + "progress", + ] + + +def test_publishes_in_one_context_share_its_session( + message_platform: MessagePlatform, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + first = ctx.publish("progress", 1) + second = ctx.publish("progress") + return [first.seq, second.seq] + + _, result = exchange(handler, message_platform) + + assert succeeded(result) == [1, 2] + assert len(message_platform.sessions) == 1 + assert message_platform.idempotency_keys() == [ + f"{FIRST_TOKEN_DIGEST}:1", + f"{FIRST_TOKEN_DIGEST}:2", + ] + assert [json.loads(request.content) for request in message_platform.publishes] == [ + {"name": "progress", "data": 1}, + {"name": "progress", "data": None}, + ] + + +def test_a_replay_does_not_publish_again(message_platform: MessagePlatform) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + published = ctx.publish("progress", "started") + ctx.wait("after", "1s") + later = ctx.publish("progress", "finished") + return [published.seq, later.seq] + + _, result = exchange(handler, message_platform) + + assert succeeded(result) == [1, 2] + assert len(message_platform.sessions) == 1 + assert message_platform.idempotency_keys() == [ + f"{FIRST_TOKEN_DIGEST}:1", + f"{FIRST_TOKEN_DIGEST}:2", + ] + + +def test_every_child_context_publishes_with_its_own_session( + message_platform: MessagePlatform, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + root = ctx.publish("progress", "root") + child = ctx.child( + "review", lambda child: child.publish("progress", "child").seq + ) + batch = ctx.map( + ["a", "b"], + lambda item, child, _index: child.publish("progress", item).seq, + "items", + ) + return [root.seq, child, sorted(batch.results)] + + _, result = exchange(handler, message_platform) + + assert succeeded(result) == [1, 2, [3, 4]] + assert len(message_platform.sessions) == 4 + assert sorted(message_platform.sessions_used()) == [ + "token-1", + "token-2", + "token-3", + "token-4", + ] + keys = message_platform.idempotency_keys() + assert len(set(keys)) == 4 + assert all(key.endswith(":1") for key in keys) + + +def test_a_message_can_be_received_and_answered( + message_platform: MessagePlatform, +) -> None: + message_platform.messages["question"] = message_payload("question", "how far?") + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + question = ctx.wait_for_message("question") + assert question is not None + return ctx.publish("answer", {"to": question.id, "done": 3}).seq + + _, result = exchange(handler, message_platform) + + assert succeeded(result) == 1 + [request] = message_platform.publishes + assert json.loads(request.content) == { + "name": "answer", + "data": {"to": "message-for-question", "done": 3}, + } + + +def test_a_refused_publish_fails_the_execution_without_retrying( + message_platform: MessagePlatform, +) -> None: + message_platform.refusals[PUBLISH] = httpx.Response( + 409, + json={"error": "execution is not running", "code": "execution_not_running"}, + ) + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + return ctx.publish("progress").seq + + _, result = exchange(handler, message_platform) + + assert result.error is not None + assert "execution is not running" in str(result.error.message) + assert len(message_platform.publishes) == 1 diff --git a/src/volcano_sdk/_tests/test_durable_message.py b/src/volcano_sdk/_tests/test_durable_message.py new file mode 100644 index 00000000..692aaab5 --- /dev/null +++ b/src/volcano_sdk/_tests/test_durable_message.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Protocol + +import httpx +import pytest + +from volcano_sdk import AuthenticationError, ConflictError, VolcanoError +from volcano_sdk._durable_message import ( + message_data, + open_session, + publish_key, + publish_message, + published_message, + received_message, + register_wait, + session_token, +) +from volcano_sdk._durable_results import PublishedMessage, ReceivedMessage +from volcano_sdk._transport_types import decode_json +from volcano_sdk.models import DurableMessageSender + +from .fixtures.durable_platform import Answer, Platform, install_platform, refused + +if TYPE_CHECKING: + from collections.abc import Callable + + from volcano_sdk.models import JSONValue + +API_URL = "https://api.volcano.test" +TOKEN = "session-token" +# sha256("session-token"), as every SDK keys a publish. +TOKEN_DIGEST = "c101e911469c969171040b50d70543313cf968fdef5bacc780776f8fb399ab36" +UNREADABLE_MESSAGE = ( + "Volcano resumed the message wait with a message this SDK cannot read" +) +UNREADABLE_SESSION = ( + "Volcano opened the messages session with a token this SDK cannot read" +) +UNREADABLE_PUBLISH = "Volcano answered the publish with a message this SDK cannot read" +INVALID_DATA = ( + "data must be JSON-serializable: mappings with string keys, lists, " + "tuples, strings, finite numbers, booleans, and None" +) + + +class InstallPlatform(Protocol): + def __call__(self, *answers: Answer) -> Platform: ... + + +@pytest.fixture +def platform(monkeypatch: pytest.MonkeyPatch) -> InstallPlatform: + def install(*answers: Answer) -> Platform: + return install_platform(monkeypatch, *answers) + + return install + + +def registered() -> httpx.Response: + return httpx.Response(201, json={"id": "wait-1"}) + + +def published(status: int = 201, seq: int = 3) -> httpx.Response: + return httpx.Response( + status, + json={ + "id": "message-1", + "execution_id": "execution-1", + "direction": "out", + "seq": seq, + "name": "progress", + "data": {"done": 2}, + "sender": {"type": "execution", "user_id": None}, + "status": "published", + "delivered_at": None, + "sent_at": "2026-10-12T09:00:00Z", + }, + ) + + +def sent(request: httpx.Request) -> object: + return decode_json(request.content) + + +def test_a_wait_registers_its_callback_for_the_name( + platform: InstallPlatform, +) -> None: + fake = platform(registered()) + + register_wait(API_URL, "arn:execution/order-1", "callback-1", "approve") + + [request] = fake.requests + assert request.method == "POST" + assert str(request.url) == f"{API_URL}/durable-messages/waits" + assert request.headers["Content-Type"] == "application/json" + assert sent(request) == { + "execution_ref": "arn:execution/order-1", + "callback_id": "callback-1", + "name": "approve", + } + + +def test_a_session_opens_for_its_callback(platform: InstallPlatform) -> None: + fake = platform(registered()) + + open_session(API_URL, "arn:execution/order-1", "callback-2") + + [request] = fake.requests + assert str(request.url) == f"{API_URL}/durable-messages/sessions" + assert sent(request) == { + "execution_ref": "arn:execution/order-1", + "callback_id": "callback-2", + } + + +@pytest.mark.parametrize( + "register", + [ + lambda: register_wait(API_URL, "arn:execution", "callback-1", "approve"), + lambda: open_session(API_URL, "arn:execution", "callback-1"), + ], + ids=["wait", "session"], +) +def test_a_callback_the_runtime_has_not_recorded_yet_is_retried( + platform: InstallPlatform, register: Callable[[], None] +) -> None: + fake = platform(refused(409, "callback_not_ready"), registered()) + + register() + + assert len(fake.requests) == 2 + assert fake.sleeps == [0.5] + + +@pytest.mark.parametrize( + "register", + [ + lambda: register_wait(API_URL, "arn:execution", "callback-1", "approve"), + lambda: open_session(API_URL, "arn:execution", "callback-1"), + ], + ids=["wait", "session"], +) +def test_a_callback_that_already_closed_needs_no_registration( + platform: InstallPlatform, register: Callable[[], None] +) -> None: + fake = platform(refused(409, "callback_closed")) + + register() + + assert len(fake.requests) == 1 + assert fake.sleeps == [] + + +@pytest.mark.parametrize( + "code", ["too_many_message_waits", "execution_ended", "approval_closed", None] +) +def test_a_wait_refused_for_another_reason_raises_at_once( + platform: InstallPlatform, code: str | None +) -> None: + fake = platform(refused(409, code)) + + with pytest.raises(ConflictError, match="refused with 409") as caught: + register_wait(API_URL, "arn:execution", "callback-1", "approve") + + assert caught.value.code == code + assert len(fake.requests) == 1 + + +def test_a_session_refused_for_too_many_sessions_raises( + platform: InstallPlatform, +) -> None: + _ = platform(refused(409, "too_many_message_sessions")) + + with pytest.raises(ConflictError) as caught: + open_session(API_URL, "arn:execution", "callback-1") + + assert caught.value.code == "too_many_message_sessions" + + +def test_publish_sends_the_session_and_key_and_returns_id_and_seq( + platform: InstallPlatform, +) -> None: + fake = platform(published()) + key = publish_key(TOKEN, 1) + + result = publish_message( + API_URL, TOKEN, key, {"name": "progress", "data": {"done": 2}} + ) + + assert result == {"id": "message-1", "seq": 3} + [request] = fake.requests + assert str(request.url) == f"{API_URL}/durable-messages/publish" + assert request.headers["X-Volcano-Durable-Session"] == TOKEN + assert request.headers["Idempotency-Key"] == f"{TOKEN_DIGEST}:1" + assert request.headers["Content-Type"] == "application/json" + assert sent(request) == {"name": "progress", "data": {"done": 2}} + + +def test_a_repeated_publish_returns_the_message_already_published( + platform: InstallPlatform, +) -> None: + _ = platform(published(status=200, seq=7)) + + result = publish_message(API_URL, TOKEN, "key", {"name": "progress"}) + + assert result == {"id": "message-1", "seq": 7} + + +@pytest.mark.parametrize( + "code", + ["callback_not_ready", "callback_closed", "execution_not_running"], +) +def test_a_publish_conflict_is_never_retried_or_ignored( + platform: InstallPlatform, code: str +) -> None: + fake = platform(refused(409, code)) + + with pytest.raises(ConflictError) as caught: + _ = publish_message(API_URL, TOKEN, "key", {"name": "progress"}) + + assert caught.value.code == code + assert len(fake.requests) == 1 + + +def test_a_publish_with_a_rejected_session_raises(platform: InstallPlatform) -> None: + _ = platform(refused(401)) + + with pytest.raises(AuthenticationError, match="refused with 401"): + _ = publish_message(API_URL, TOKEN, "key", {"name": "progress"}) + + +def test_a_publish_too_large_for_volcano_raises(platform: InstallPlatform) -> None: + fake = platform(refused(413)) + + with pytest.raises(VolcanoError) as caught: + _ = publish_message(API_URL, TOKEN, "key", {"name": "progress"}) + + assert caught.value.status == 413 + assert len(fake.requests) == 1 + + +def test_a_publish_answered_without_a_message_raises( + platform: InstallPlatform, +) -> None: + _ = platform(httpx.Response(201, json={"id": "message-1"})) + + with pytest.raises(TypeError, match=UNREADABLE_PUBLISH): + _ = publish_message(API_URL, TOKEN, "key", {"name": "progress"}) + + +def test_a_publish_key_names_the_session_digest_and_position() -> None: + assert publish_key(TOKEN, 1) == f"{TOKEN_DIGEST}:1" + assert publish_key(TOKEN, 12) == f"{TOKEN_DIGEST}:12" + assert publish_key("other-token", 1) != publish_key(TOKEN, 1) + + +@pytest.mark.parametrize( + "value", + [ + None, + "message-1", + {"seq": 1}, + {"id": "message-1"}, + {"id": 1, "seq": 1}, + {"id": "message-1", "seq": "1"}, + {"id": "message-1", "seq": 1.0}, + {"id": "message-1", "seq": True}, + ], +) +def test_an_unreadable_published_message_is_refused(value: object) -> None: + with pytest.raises(TypeError, match=UNREADABLE_PUBLISH): + _ = published_message(value) + + +def test_a_published_message_is_read_from_its_checkpoint() -> None: + assert published_message({"id": "message-1", "seq": 0}) == PublishedMessage( + id="message-1", seq=0 + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + ({"done": (1, 2)}, {"done": [1, 2]}), + ("é" * 32767, "é" * 32767), + ("x" * 65534, "x" * 65534), + ], + ids=["none", "tuple", "multibyte-limit", "limit"], +) +def test_message_data_returns_a_plain_copy(value: object, expected: object) -> None: + assert message_data(value) == expected + + +def test_message_data_is_copied_before_it_is_sent() -> None: + original: dict[str, JSONValue] = {"items": [1]} + + data = message_data(original) + original["items"] = [2] + + assert data == {"items": [1]} + + +@pytest.mark.parametrize( + ("value", "message"), + [ + ("x" * 65535, "data must be at most 64 KiB once encoded as JSON"), + ("é" * 32768, "data must be at most 64 KiB once encoded as JSON"), + ({"key\0": 1}, "data must not contain NUL characters"), + (["\0"], "data must not contain NUL characters"), + ], +) +def test_message_data_volcano_would_refuse_is_refused( + value: object, message: str +) -> None: + with pytest.raises(ValueError, match=f"^{message}$"): + _ = message_data(value) + + +@pytest.mark.parametrize("value", [{1: "a"}, {"a": float("nan")}, {"a": {1, 2}}]) +def test_message_data_that_is_not_json_is_refused(value: object) -> None: + with pytest.raises(TypeError, match=f"^{INVALID_DATA}$"): + _ = message_data(value) + + +def message_result(**overrides: object) -> dict[str, object]: + message: dict[str, object] = { + "id": "message-1", + "name": "approve", + "data": {"approved": True}, + "sender": {"type": "user", "user_id": "user-7"}, + "sent_at": "2026-10-12T09:00:00Z", + } + message.update(overrides) + return message + + +@pytest.mark.parametrize( + "result", + [ + message_result(), + json.dumps(message_result()), + json.dumps(message_result()).encode(), + ], + ids=["decoded", "text", "bytes"], +) +def test_a_received_message_is_read_from_the_callback(result: object) -> None: + assert received_message(result) == ReceivedMessage( + id="message-1", + name="approve", + data={"approved": True}, + sender=DurableMessageSender(type="user", user_id="user-7"), + sent_at="2026-10-12T09:00:00Z", + ) + + +def test_a_received_message_may_carry_no_data_or_user() -> None: + message = received_message( + message_result(data=None, sender={"type": "service_key", "user_id": None}) + ) + + assert message.data is None + assert message.sender == DurableMessageSender(type="service_key") + + +def test_a_sender_without_a_readable_user_has_none() -> None: + message = received_message(message_result(sender={"type": "anon", "user_id": 7})) + + assert message.sender == DurableMessageSender(type="anon", user_id=None) + + +def test_received_data_stays_plain_for_the_handler_to_return() -> None: + message = received_message(message_result(data={"items": [1, 2]})) + + assert message.data == {"items": [1, 2]} + assert isinstance(message.data, dict) + + +@pytest.mark.parametrize( + "result", + [ + None, + "not json", + b"[1]", + {1: "id"}, + message_result(id=None), + message_result(name=1), + message_result(sent_at=None), + message_result(data={"a": {1, 2}}), + message_result(sender=None), + message_result(sender={"user_id": "user-7"}), + message_result(sender={"type": 1}), + ], +) +def test_an_unreadable_received_message_is_refused(result: object) -> None: + with pytest.raises(TypeError, match=f"^{UNREADABLE_MESSAGE}$"): + _ = received_message(result) + + +@pytest.mark.parametrize( + "result", [{"token": TOKEN}, json.dumps({"token": TOKEN})], ids=["decoded", "text"] +) +def test_a_session_token_is_read_from_the_callback(result: object) -> None: + assert session_token(result) == TOKEN + + +@pytest.mark.parametrize( + "result", [None, "[]", {}, {"token": ""}, {"token": 1}, {"token": None}] +) +def test_an_unreadable_session_token_is_refused(result: object) -> None: + with pytest.raises(TypeError, match=f"^{UNREADABLE_SESSION}$"): + _ = session_token(result) diff --git a/src/volcano_sdk/_tests/test_durable_message_authoring.py b/src/volcano_sdk/_tests/test_durable_message_authoring.py new file mode 100644 index 00000000..b8631dfc --- /dev/null +++ b/src/volcano_sdk/_tests/test_durable_message_authoring.py @@ -0,0 +1,257 @@ +"""Durable message authoring tests against a recording runtime context. + +The handler tests that run on the local runner are in test_durable_authoring. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest +from aws_durable_execution_sdk_python.config import ( + StepConfig, + StepSemantics, + WaitForCallbackConfig, +) +from aws_durable_execution_sdk_python.exceptions import ( + CallbackError, + CallbackTimeoutError, +) + +from volcano_sdk._durable_engine import Engine +from volcano_sdk.durable_authoring import DurableContext, ReceivedMessage +from volcano_sdk.models import DurableMessageSender + +from .fixtures.durable_context import RecordingContext +from .fixtures.durable_messages import SESSION, message_payload, session_answer +from .fixtures.invalid_arguments import non_json_message_data, non_string_message_name +from .test_durable_authoring import PLATFORM_API_URL + +if TYPE_CHECKING: + from volcano_sdk.durable_authoring import Duration + from volcano_sdk.models import JSONValue + + +class Waits: + """Record what wait_for_message registers, in place of the HTTP request.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, str, str, str]] = [] + + def register( + self, api_url: str, execution_ref: str, callback: str, name: str + ) -> None: + self.calls.append((api_url, execution_ref, callback, name)) + + +@pytest.fixture +def waits(monkeypatch: pytest.MonkeyPatch) -> Waits: + recorder = Waits() + monkeypatch.setattr( + "volcano_sdk.durable_authoring.register_wait", recorder.register + ) + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) + return recorder + + +def test_wait_for_message_forwards_its_name_timeout_and_submitter( + waits: Waits, +) -> None: + runtime = RecordingContext() + runtime.callback_result = json.dumps(message_payload("approve", [1])) + context = DurableContext(runtime, Engine()) + + approval = context.wait_for_message("approve", timeout="2h") + + assert approval == ReceivedMessage( + id="message-for-approve", + name="approve", + data=[1], + sender=DurableMessageSender(type="user", user_id="user-7"), + sent_at="2026-10-12T09:00:00Z", + ) + assert runtime.name == "approve" + assert isinstance(runtime.config, WaitForCallbackConfig) + assert runtime.config.timeout.to_seconds() == 7200 + retry = runtime.config.retry_strategy + assert retry is not None + assert retry(RuntimeError("failed"), 1).should_retry is False + assert runtime.submitter is not None + assert waits.calls == [] + runtime.submitter("callback-9", None) + assert waits.calls == [(PLATFORM_API_URL, "arn:execution", "callback-9", "approve")] + + +@pytest.mark.usefixtures("waits") +def test_a_message_wait_without_a_timeout_lasts_as_long_as_the_execution() -> None: + runtime = RecordingContext() + runtime.callback_result = message_payload("approve") + context = DurableContext(runtime, Engine()) + + approval = context.wait_for_message("approve") + + assert approval is not None + assert approval.data is None + assert isinstance(runtime.config, WaitForCallbackConfig) + assert runtime.config.timeout.to_seconds() == 0 + + +@pytest.mark.usefixtures("waits") +def test_only_a_callback_timeout_ends_a_message_wait_with_none() -> None: + runtime = RecordingContext() + runtime.callback_error = CallbackTimeoutError("timed out") + context = DurableContext(runtime, Engine()) + + assert context.wait_for_message("approve", timeout=60) is None + runtime.callback_error = CallbackError("callback failed") + with pytest.raises(CallbackError, match="callback failed"): + _ = context.wait_for_message("approve") + + +@pytest.mark.usefixtures("waits") +def test_an_unreadable_message_fails_the_wait() -> None: + runtime = RecordingContext() + runtime.callback_result = {"id": "message-1"} + context = DurableContext(runtime, Engine()) + + with pytest.raises(TypeError, match="cannot read"): + _ = context.wait_for_message("approve") + + +@pytest.mark.usefixtures("waits") +@pytest.mark.parametrize( + ("name", "timeout", "error", "match"), + [ + ("", None, ValueError, "^name must not be empty$"), + ("ap\tprove", None, ValueError, "^name must be printable ASCII$"), + ("n" * 238, None, ValueError, "^name must be at most 237 characters$"), + ("approve", "0s", TypeError, "^timeout must be at least 1 second$"), + ("approve", "soon", ValueError, "^timeout must be a duration"), + ], +) +def test_a_message_wait_is_checked_before_its_callback_opens( + name: str, timeout: Duration | None, error: type[Exception], match: str +) -> None: + runtime = RecordingContext() + context = DurableContext(runtime, Engine()) + + with pytest.raises(error, match=match): + _ = context.wait_for_message(name, timeout=timeout) + assert runtime.submitter is None + + +@pytest.mark.usefixtures("waits") +def test_a_message_name_must_be_a_string() -> None: + runtime = RecordingContext() + context = DurableContext(runtime, Engine()) + + with pytest.raises(TypeError, match=r"^name must be a string$"): + non_string_message_name(context) + assert runtime.submitter is None + + +def test_waiting_for_a_message_needs_the_platform_api_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("VOLCANO_PLATFORM_API_URL", raising=False) + runtime = RecordingContext() + context = DurableContext(runtime, Engine()) + + with pytest.raises(RuntimeError, match=r"^wait_for_message\(\) needs VOLCANO"): + _ = context.wait_for_message("approve") + assert runtime.submitter is None + + +class Sessions: + """Record the sessions publish opens, in place of the HTTP request.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, str, str]] = [] + + def open(self, api_url: str, execution_ref: str, callback: str) -> None: + self.calls.append((api_url, execution_ref, callback)) + + +@pytest.fixture +def sessions(monkeypatch: pytest.MonkeyPatch) -> Sessions: + recorder = Sessions() + monkeypatch.setattr("volcano_sdk.durable_authoring.open_session", recorder.open) + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) + return recorder + + +def test_publish_opens_its_session_then_runs_a_step_that_is_never_retried( + sessions: Sessions, +) -> None: + runtime = RecordingContext() + runtime.callback_result = session_answer(1).decode() + context = DurableContext(runtime, Engine()) + + with pytest.raises(AssertionError, match="unexpected runtime operation"): + _ = context.publish("progress", {"done": 1}) + + assert runtime.name == SESSION + assert isinstance(runtime.config, StepConfig) + assert runtime.config.step_semantics is StepSemantics.AT_LEAST_ONCE_PER_RETRY + retry = runtime.config.retry_strategy + assert retry is not None + assert retry(RuntimeError("failed"), 1).should_retry is False + assert runtime.submitter is not None + runtime.submitter("callback-3", None) + assert sessions.calls == [(PLATFORM_API_URL, "arn:execution", "callback-3")] + + +@pytest.mark.usefixtures("sessions") +def test_a_session_without_a_token_fails_the_publish() -> None: + runtime = RecordingContext() + runtime.callback_result = {"token": ""} + context = DurableContext(runtime, Engine()) + + with pytest.raises(TypeError, match="token this SDK cannot read"): + _ = context.publish("progress") + assert isinstance(runtime.config, WaitForCallbackConfig) + assert runtime.config.timeout.to_seconds() == 0 + + +@pytest.mark.usefixtures("sessions") +@pytest.mark.parametrize( + ("name", "data", "error", "match"), + [ + ("", None, ValueError, "^name must not be empty$"), + ("pro\ngress", None, ValueError, "^name must be printable ASCII$"), + ("progress", "\0", ValueError, "^data must not contain NUL characters$"), + ("progress", "x" * 65535, ValueError, "^data must be at most 64 KiB"), + ], +) +def test_a_publish_is_checked_before_its_session_opens( + name: str, data: JSONValue, error: type[Exception], match: str +) -> None: + runtime = RecordingContext() + context = DurableContext(runtime, Engine()) + + with pytest.raises(error, match=match): + _ = context.publish(name, data) + assert runtime.submitter is None + + +@pytest.mark.usefixtures("sessions") +def test_published_data_must_be_json() -> None: + runtime = RecordingContext() + context = DurableContext(runtime, Engine()) + + with pytest.raises(TypeError, match=r"^data must be JSON-serializable"): + non_json_message_data(context) + assert runtime.submitter is None + + +def test_publishing_needs_the_platform_api_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("VOLCANO_PLATFORM_API_URL", raising=False) + runtime = RecordingContext() + context = DurableContext(runtime, Engine()) + + with pytest.raises(RuntimeError, match=r"^publish\(\) needs VOLCANO"): + _ = context.publish("progress") + assert runtime.submitter is None diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py new file mode 100644 index 00000000..4faf90fb --- /dev/null +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -0,0 +1,1329 @@ +from __future__ import annotations + +import asyncio +import json +import re +from dataclasses import dataclass +from datetime import UTC, datetime +from types import MappingProxyType +from typing import TYPE_CHECKING + +import httpx +import pytest +from typing_extensions import override + +from volcano_sdk import ( + ConflictError, + DurableMessage, + DurableMessagePage, + DurableMessageSender, + DurableMessageSubscription, + NotFoundError, + ServerError, + Session, + VolcanoClient, +) +from volcano_sdk._durable_message_response import ( + durable_message, + durable_message_page, +) +from volcano_sdk._transport import ( + DurableMessageListRequest, + DurableMessageSendRequest, + DurableMessageTarget, + GeneratedTransport, +) +from volcano_sdk.durable import Durable + +from .fixtures.invalid_arguments import ( + assign_snapshot_value, + durable_realtime_channel, + non_callable_message_handler, + non_string_sent_message_name, + remove_durable_realtime_channel, + unknown_listed_message_direction, + unknown_sent_message_option, + unknown_subscription_option, +) +from .realtime_probes import realtime_state +from .test_realtime import FakeCentrifugeClient, FakeCentrifugeFactory, FakeSubscription +from .transport_fixtures import RejectingTransport + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from volcano_sdk._durable_messages import DurableMessages + +PROJECT_ID = "00000000-0000-4000-8000-000000000001" +EXECUTION_ID = "00000000-0000-4000-8000-0000000000e1" +USER_ID = "00000000-0000-4000-8000-0000000000a7" +API_URL = "https://api.volcano.test" +CHANNEL = f"durable:{EXECUTION_ID}" +APPLICATION_PATH = f"/durable-functions/checkout/executions/{EXECUTION_ID}/messages" +PROJECT_PATH = f"/projects/{PROJECT_ID}{APPLICATION_PATH}" + + +@dataclass(frozen=True) +class FakeResponse: + status_code: int + payload: object + headers: dict[str, str] + content: bytes = b"" + + +def published(seq: int, **overrides: object) -> dict[str, object]: + message: dict[str, object] = { + "id": f"00000000-0000-4000-8000-{seq:012d}", + "execution_id": EXECUTION_ID, + "direction": "out", + "seq": seq, + "name": "progress", + "data": {"step": seq}, + "sender": {"type": "execution", "user_id": None}, + "status": "published", + "sent_at": "2026-10-12T09:00:00Z", + "delivered_at": None, + } + message.update(overrides) + return message + + +def sent(**overrides: object) -> dict[str, object]: + return ( + published( + 4, + direction="in", + name="address-confirmed", + data={"street": "Main St", "lines": [1, 2]}, + sender={"type": "user", "user_id": USER_ID}, + status="delivered", + delivered_at="2026-10-12T09:00:05Z", + ) + | overrides + ) + + +class FakeMessageTransport(RejectingTransport): + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, object]]] = [] + self.send_response: FakeResponse = FakeResponse(201, sent(), {}) + self.list_response: FakeResponse = FakeResponse( + 200, {"data": [published(1), sent()], "has_more": False}, {} + ) + # Answers subscription reads from the execution's published messages. + self.published: list[int] = [] + self.page_size: int = 100 + self.read_answer: FakeResponse | None = None + self.next_read_gate: asyncio.Event | None = None + self.read_arrivals: Arrivals = Arrivals() + + def send_durable_message(self, **kwargs: object) -> FakeResponse: + self.calls.append(("sendDurableMessage", kwargs)) + return self.send_response + + def list_durable_messages(self, **kwargs: object) -> FakeResponse: + self.calls.append(("listDurableMessages", kwargs)) + return self.list_response + + async def list_durable_messages_async(self, **kwargs: object) -> FakeResponse: + self.calls.append(("listDurableMessagesAsync", kwargs)) + request = kwargs["request"] + assert isinstance(request, DurableMessageListRequest) + # A catch-up read without its cursor would page from the start forever. + assert request.after is not None + # Read before waiting, as a list racing a publish would. + answer = self.read_answer or self.page_after(request.after) + self.read_arrivals.arrive() + gate, self.next_read_gate = self.next_read_gate, None + if gate is not None: + _ = await asyncio.wait_for(gate.wait(), timeout=2) + return answer + + def page_after(self, after: int) -> FakeResponse: + newer = [seq for seq in self.published if seq > after] + page = newer[: self.page_size] + return FakeResponse( + 200, + {"data": [published(seq) for seq in page], "has_more": page != newer}, + {}, + ) + + def reads(self) -> list[DurableMessageListRequest]: + requests: list[DurableMessageListRequest] = [] + for name, arguments in self.calls: + request = arguments["request"] + if name == "listDurableMessagesAsync": + assert isinstance(request, DurableMessageListRequest) + requests.append(request) + return requests + + def read_after(self) -> list[int | None]: + return [request.after for request in self.reads()] + + +class Arrivals: + def __init__(self) -> None: + self.count: int = 0 + self._arrived: asyncio.Event = asyncio.Event() + + def arrive(self) -> None: + self.count += 1 + self._arrived.set() + + async def wait_for(self, count: int) -> None: + async def reach() -> None: + while self.count < count: + self._arrived.clear() + _ = await self._arrived.wait() + + await asyncio.wait_for(reach(), timeout=2) + + +class Received: + def __init__(self) -> None: + self.messages: list[DurableMessage] = [] + self.arrivals: Arrivals = Arrivals() + + def __call__(self, message: DurableMessage) -> None: + self.messages.append(message) + self.arrivals.arrive() + + @property + def seqs(self) -> list[int]: + return [message.seq for message in self.messages] + + async def until(self, count: int) -> list[int]: + await self.arrivals.wait_for(count) + return self.seqs + + +def messages_client( + transport: RejectingTransport | GeneratedTransport, *, session: bool = False +) -> VolcanoClient: + client = VolcanoClient(anon_key="anon-key", _transport=transport) + if session: + _ = client.auth.set_session( + Session( + access_token="access-token", + refresh_token="refresh-token", + user_id=USER_ID, + ) + ) + return client + + +def wire_client( + respond: Callable[[httpx.Request], httpx.Response], *, session: bool = False +) -> tuple[VolcanoClient, list[httpx.Request]]: + requests: list[httpx.Request] = [] + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + return respond(request) + + client = messages_client( + GeneratedTransport( + api_url=API_URL, httpx_transport=httpx.MockTransport(handle) + ), + session=session, + ) + return client, requests + + +def subscriber( + transport: FakeMessageTransport, +) -> tuple[VolcanoClient, FakeCentrifugeClient]: + native = FakeCentrifugeClient() + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(native), + ) + return client, native + + +def subscription_of(native: FakeCentrifugeClient) -> FakeSubscription: + return native.subscriptions[CHANNEL] + + +async def dispatched(client: VolcanoClient) -> None: + """Wait until the channel has handed its queued publications on.""" + task = realtime_state(client.realtime).channels[CHANNEL].state.callback_task + if task is not None: + await asyncio.wait_for(task, timeout=2) + + +SEND_AND_LIST: dict[str, Callable[[DurableMessages], object]] = { + "send": lambda m: m.send("checkout", EXECUTION_ID, "ping", project_id=PROJECT_ID), + "list": lambda m: m.list("checkout", EXECUTION_ID, project_id=PROJECT_ID), +} + + +@pytest.mark.parametrize( + "call", [pytest.param(call, id=name) for name, call in SEND_AND_LIST.items()] +) +def test_messages_require_a_transport_with_message_methods( + call: Callable[[DurableMessages], object], +) -> None: + messages = messages_client(RejectingTransport(), session=True).durable.messages + + with pytest.raises(TypeError, match="Transport does not support durable messages"): + _ = call(messages) + + +def test_send_uses_the_application_route_with_the_client_credential() -> None: + transport = FakeMessageTransport() + + message = messages_client(transport).durable.messages.send( + " checkout ", + f" {EXECUTION_ID} ", + "address-confirmed", + {"street": "Main St"}, + client_token="client-token", + idempotency_key="confirm-1", + ) + + assert message == DurableMessage( + id="00000000-0000-4000-8000-000000000004", + execution_id=EXECUTION_ID, + direction="in", + seq=4, + name="address-confirmed", + data={"street": "Main St", "lines": (1, 2)}, + sender=DurableMessageSender(type="user", user_id=USER_ID), + status="delivered", + sent_at=datetime(2026, 10, 12, 9, tzinfo=UTC), + delivered_at=datetime(2026, 10, 12, 9, 0, 5, tzinfo=UTC), + ) + assert transport.calls == [ + ( + "sendDurableMessage", + { + "authorization": "anon-key", + "target": DurableMessageTarget( + function_id="checkout", + execution_id=EXECUTION_ID, + client_token="client-token", + ), + "request": DurableMessageSendRequest( + name="address-confirmed", + data={"street": "Main St"}, + idempotency_key="confirm-1", + ), + }, + ) + ] + + +def test_a_repeated_send_returns_the_message_the_first_one_stored() -> None: + transport = FakeMessageTransport() + transport.send_response = FakeResponse(200, sent(status="queued"), {}) + + message = messages_client(transport).durable.messages.send( + "checkout", EXECUTION_ID, "address-confirmed", idempotency_key="confirm-1" + ) + + assert message.status == "queued" + assert message.delivered_at == datetime(2026, 10, 12, 9, 0, 5, tzinfo=UTC) + + +def test_send_on_the_project_route_uses_the_session() -> None: + transport = FakeMessageTransport() + + _ = messages_client(transport, session=True).durable.messages.send( + "checkout", EXECUTION_ID, "ping", project_id=f" {PROJECT_ID} " + ) + + assert transport.calls == [ + ( + "sendDurableMessage", + { + "authorization": "access-token", + "target": DurableMessageTarget( + function_id="checkout", + execution_id=EXECUTION_ID, + project_id=PROJECT_ID, + ), + "request": DurableMessageSendRequest(name="ping"), + }, + ) + ] + + +@pytest.mark.parametrize( + "call", [pytest.param(call, id=name) for name, call in SEND_AND_LIST.items()] +) +def test_the_project_route_needs_a_session( + call: Callable[[DurableMessages], object], +) -> None: + transport = FakeMessageTransport() + + with pytest.raises(RuntimeError, match="No active session"): + _ = call(messages_client(transport).durable.messages) + assert transport.calls == [] + + +@pytest.mark.parametrize( + ("status", "code", "expected"), + [ + (404, None, NotFoundError), + (409, "execution_not_running", ConflictError), + (500, None, ServerError), + ], +) +def test_send_failures_raise_typed_errors( + status: int, code: str | None, expected: type[Exception] +) -> None: + transport = FakeMessageTransport() + body: dict[str, object] = {"error": "cannot send"} + if code is not None: + body["code"] = code + transport.send_response = FakeResponse(status, body, {}) + + with pytest.raises(expected, match="cannot send") as caught: + _ = messages_client(transport).durable.messages.send( + "checkout", EXECUTION_ID, "ping" + ) + + assert type(caught.value) is expected + assert getattr(caught.value, "code", None) == code + + +def test_list_forwards_every_filter_and_returns_a_page() -> None: + transport = FakeMessageTransport() + + page = messages_client(transport).durable.messages.list( + "checkout", + EXECUTION_ID, + client_token="client-token", + direction="in", + name="address-confirmed", + after=3, + limit=50, + ) + + assert page == DurableMessagePage( + messages=(durable_message(published(1)), durable_message(sent())), + has_more=False, + ) + assert transport.calls == [ + ( + "listDurableMessages", + { + "authorization": "anon-key", + "target": DurableMessageTarget( + function_id="checkout", + execution_id=EXECUTION_ID, + client_token="client-token", + ), + "request": DurableMessageListRequest( + direction="in", name="address-confirmed", after=3, limit=50 + ), + }, + ) + ] + + +def test_list_omits_unset_filters() -> None: + transport = FakeMessageTransport() + + _ = messages_client(transport, session=True).durable.messages.list( + "checkout", EXECUTION_ID, project_id=PROJECT_ID, direction="out" + ) + + assert transport.calls == [ + ( + "listDurableMessages", + { + "authorization": "access-token", + "target": DurableMessageTarget( + function_id="checkout", + execution_id=EXECUTION_ID, + project_id=PROJECT_ID, + ), + "request": DurableMessageListRequest(direction="out"), + }, + ) + ] + + +def test_list_raises_not_found_for_an_execution_it_cannot_reach() -> None: + transport = FakeMessageTransport() + transport.list_response = FakeResponse(404, {"error": "execution not found"}, {}) + + with pytest.raises(NotFoundError, match="execution not found"): + _ = messages_client(transport).durable.messages.list("checkout", EXECUTION_ID) + + +INVALID_ARGUMENTS: dict[str, tuple[Callable[[DurableMessages], object], str]] = { + "empty function": ( + lambda m: m.send("", EXECUTION_ID, "ping"), + "function_name must be a non-empty string", + ), + "blank execution": ( + lambda m: m.send("checkout", " ", "ping"), + "execution_id must be a non-empty string", + ), + "send execution": ( + lambda m: m.send("checkout", "execution-1", "ping"), + "execution_id must be a UUID", + ), + "send project": ( + lambda m: m.send("checkout", EXECUTION_ID, "ping", project_id="project"), + "project_id must be a UUID", + ), + "empty client token": ( + lambda m: m.send("checkout", EXECUTION_ID, "ping", client_token=""), + "client_token must be a non-empty string", + ), + "empty name": ( + lambda m: m.send("checkout", EXECUTION_ID, ""), + "name must be a non-empty string", + ), + "blank name": ( + lambda m: m.send("checkout", EXECUTION_ID, " "), + "name must be a non-empty string", + ), + "non-string name": ( + lambda m: non_string_sent_message_name(m, EXECUTION_ID), + "name must be a non-empty string", + ), + "empty idempotency key": ( + lambda m: m.send("checkout", EXECUTION_ID, "ping", idempotency_key=""), + "idempotency_key must be a non-empty string", + ), + "long idempotency key": ( + lambda m: m.send("checkout", EXECUTION_ID, "ping", idempotency_key="k" * 256), + "idempotency_key must be at most 255 characters", + ), + "list function": ( + lambda m: m.list(" ", EXECUTION_ID), + "function_name must be a non-empty string", + ), + "list execution": ( + lambda m: m.list("checkout", "execution-1"), + "execution_id must be a UUID", + ), + "list project": ( + lambda m: m.list("checkout", EXECUTION_ID, project_id="project"), + "project_id must be a UUID", + ), + "list client token": ( + lambda m: m.list("checkout", EXECUTION_ID, client_token=""), + "client_token must be a non-empty string", + ), + "list direction": ( + lambda m: unknown_listed_message_direction(m, EXECUTION_ID), + "direction must be 'in' or 'out'", + ), +} + + +@pytest.mark.parametrize( + ("call", "message"), + [pytest.param(*case, id=name) for name, case in INVALID_ARGUMENTS.items()], +) +def test_invalid_arguments_are_refused_before_transport( + call: Callable[[DurableMessages], object], message: str +) -> None: + transport = FakeMessageTransport() + messages = messages_client(transport).durable.messages + + with pytest.raises(ValueError, match=f"^{re.escape(message)}$"): + _ = call(messages) + assert transport.calls == [] + + +def test_an_idempotency_key_may_use_the_whole_length() -> None: + transport = FakeMessageTransport() + key = "k" * 255 + + _ = messages_client(transport).durable.messages.send( + "checkout", EXECUTION_ID, "ping", idempotency_key=key + ) + + assert transport.calls[0][1]["request"] == DurableMessageSendRequest( + name="ping", idempotency_key=key + ) + + +def test_unknown_options_are_refused() -> None: + transport = FakeMessageTransport() + + with pytest.raises(TypeError, match="unexpected option: retries"): + unknown_sent_message_option( + messages_client(transport).durable.messages, EXECUTION_ID + ) + assert transport.calls == [] + + +def test_send_goes_over_the_application_route() -> None: + client, requests = wire_client(lambda _r: httpx.Response(201, json=sent())) + + message = client.durable.messages.send( + "checkout", + EXECUTION_ID, + "address-confirmed", + {"street": "Main St"}, + client_token="client-token", + idempotency_key="confirm-1", + ) + + assert message.seq == 4 + assert len(requests) == 1 + assert requests[0].method == "POST" + assert str(requests[0].url) == f"{API_URL}{APPLICATION_PATH}" + assert requests[0].headers["authorization"] == "Bearer anon-key" + assert requests[0].headers["x-volcano-client-token"] == "client-token" + assert requests[0].headers["idempotency-key"] == "confirm-1" + assert json.loads(requests[0].content) == { + "name": "address-confirmed", + "data": {"street": "Main St"}, + } + + +def test_send_goes_over_the_project_route() -> None: + client, requests = wire_client( + lambda _r: httpx.Response(200, json=sent()), session=True + ) + + _ = client.durable.messages.send( + "checkout", EXECUTION_ID, "ping", project_id=PROJECT_ID + ) + + assert str(requests[0].url) == f"{API_URL}{PROJECT_PATH}" + assert requests[0].headers["authorization"] == "Bearer access-token" + assert "x-volcano-client-token" not in requests[0].headers + assert "idempotency-key" not in requests[0].headers + assert json.loads(requests[0].content) == {"name": "ping", "data": None} + + +def test_send_on_the_project_route_forwards_the_idempotency_key() -> None: + client, requests = wire_client( + lambda _r: httpx.Response(201, json=sent()), session=True + ) + + _ = client.durable.messages.send( + "checkout", + EXECUTION_ID, + "ping", + project_id=PROJECT_ID, + idempotency_key="ping-1", + ) + + assert requests[0].url.path == PROJECT_PATH + assert requests[0].headers["idempotency-key"] == "ping-1" + + +def test_list_goes_over_the_application_route_with_every_filter() -> None: + client, requests = wire_client( + lambda _r: httpx.Response(200, json={"data": [sent()], "has_more": True}) + ) + + page = client.durable.messages.list( + "checkout", + EXECUTION_ID, + client_token="client-token", + direction="out", + name="progress", + after=2, + limit=50, + ) + + assert page.has_more is True + assert requests[0].method == "GET" + assert requests[0].url.path == APPLICATION_PATH + assert dict(requests[0].url.params) == { + "direction": "out", + "name": "progress", + "after": "2", + "limit": "50", + } + assert requests[0].headers["authorization"] == "Bearer anon-key" + assert requests[0].headers["x-volcano-client-token"] == "client-token" + + +def test_list_goes_over_the_project_route_without_filters() -> None: + client, requests = wire_client( + lambda _r: httpx.Response(200, json={"data": [], "has_more": False}), + session=True, + ) + + page = client.durable.messages.list("checkout", EXECUTION_ID, project_id=PROJECT_ID) + + assert page == DurableMessagePage(messages=(), has_more=False) + assert requests[0].url.path == PROJECT_PATH + assert dict(requests[0].url.params) == {} + assert requests[0].headers["authorization"] == "Bearer access-token" + assert "x-volcano-client-token" not in requests[0].headers + + +def test_list_goes_over_the_project_route_with_every_filter() -> None: + client, requests = wire_client( + lambda _r: httpx.Response(200, json={"data": [], "has_more": False}), + session=True, + ) + + _ = client.durable.messages.list( + "checkout", + EXECUTION_ID, + project_id=PROJECT_ID, + direction="in", + name="address-confirmed", + after=3, + limit=10, + ) + + assert requests[0].url.path == PROJECT_PATH + assert dict(requests[0].url.params) == { + "direction": "in", + "name": "address-confirmed", + "after": "3", + "limit": "10", + } + + +@pytest.mark.parametrize( + ("target", "path", "headers"), + [ + ( + DurableMessageTarget( + function_id="checkout", + execution_id=EXECUTION_ID, + client_token="client-token", + ), + APPLICATION_PATH, + {"x-volcano-client-token": "client-token"}, + ), + ( + DurableMessageTarget( + function_id="checkout", + execution_id=EXECUTION_ID, + project_id=PROJECT_ID, + client_token="client-token", + ), + PROJECT_PATH, + {}, + ), + ], +) +async def test_the_async_list_goes_over_either_route_with_every_filter( + target: DurableMessageTarget, path: str, headers: dict[str, str] +) -> None: + requests: list[httpx.Request] = [] + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"data": [published(1)], "has_more": False}) + + transport = GeneratedTransport( + api_url=API_URL, httpx_transport=httpx.MockTransport(handle) + ) + + response = await transport.list_durable_messages_async( + authorization="access-token", + target=target, + request=DurableMessageListRequest( + direction="out", name="progress", after=7, limit=20 + ), + ) + + assert response.status_code == 200 + assert requests[0].url.path == path + assert dict(requests[0].url.params) == { + "direction": "out", + "name": "progress", + "after": "7", + "limit": "20", + } + assert requests[0].headers["authorization"] == "Bearer access-token" + assert { + name: value + for name, value in requests[0].headers.items() + if name == "x-volcano-client-token" + } == headers + + +def test_a_message_snapshots_its_data() -> None: + message = durable_message(sent(data={"street": "Main St"})) + + assert isinstance(message.data, MappingProxyType) + with pytest.raises(TypeError): + assign_snapshot_value(message.data) + + +def test_an_undelivered_message_has_no_delivery_time() -> None: + message = durable_message( + sent(status="queued", delivered_at=None, sender={"type": "anon"}) + ) + + assert message.delivered_at is None + assert message.sender == DurableMessageSender(type="anon") + + +@pytest.mark.parametrize("payload", [None, [], "message", {1: "id"}]) +def test_a_message_must_be_an_object(payload: object) -> None: + with pytest.raises(TypeError, match="Expected a complete durable message"): + _ = durable_message(payload) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("id", ""), + ("id", None), + ("execution_id", 7), + ("direction", "both"), + ("seq", 0), + ("seq", True), + ("seq", "4"), + ("name", ""), + ("data", {"at": {1}}), + ("sender", None), + ("sender", {"type": ""}), + ("sender", {"type": "user", "user_id": 7}), + ("status", "sent"), + ("sent_at", None), + ("sent_at", "yesterday"), + ("delivered_at", "soon"), + ], +) +def test_a_message_must_be_complete(field: str, value: object) -> None: + with pytest.raises(TypeError, match=r"^Expected a complete durable message$"): + _ = durable_message(sent(**{field: value})) + + +def test_the_first_message_has_seq_one() -> None: + assert durable_message(published(1)).seq == 1 + + +def test_a_page_accepts_any_sequence_of_messages() -> None: + page = durable_message_page({"data": (published(1),), "has_more": True}) + + assert page.messages == (durable_message(published(1)),) + assert page.has_more is True + + +@pytest.mark.parametrize( + "payload", + [ + None, + {"data": None, "has_more": False}, + {"data": {}, "has_more": False}, + {"data": [], "has_more": None}, + {"data": []}, + ], +) +def test_a_page_must_be_complete(payload: object) -> None: + with pytest.raises(TypeError, match="Expected a complete durable message page"): + _ = durable_message_page(payload) + + +async def test_subscribe_delivers_what_was_published_then_live_messages() -> None: + transport = FakeMessageTransport() + transport.published = [1, 2] + client, native = subscriber(transport) + received = Received() + try: + subscription = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, received, client_token="client-token" + ) + + assert isinstance(subscription, DurableMessageSubscription) + assert received.seqs == [1, 2] + channel = subscription_of(native) + assert channel.data == {"client_token": "client-token"} + assert channel.recoverable is False + assert channel.join_leave is False + assert transport.calls == [ + ( + "listDurableMessagesAsync", + { + "authorization": "access-token", + "target": DurableMessageTarget( + function_id="checkout", + execution_id=EXECUTION_ID, + client_token="client-token", + ), + "request": DurableMessageListRequest(direction="out", after=0), + }, + ) + ] + + await channel.emit(published(2)) + await channel.emit(published(3)) + + assert await received.until(3) == [1, 2, 3] + assert received.messages[2] == durable_message(published(3)) + finally: + await client.realtime.disconnect() + + +async def test_subscribe_on_the_project_route_sends_no_client_token() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + try: + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received(), project_id=PROJECT_ID + ) + + assert subscription_of(native).data is None + assert transport.calls[0][1]["target"] == DurableMessageTarget( + function_id="checkout", execution_id=EXECUTION_ID, project_id=PROJECT_ID + ) + finally: + await client.realtime.disconnect() + + +async def test_subscribe_ignores_messages_sent_to_the_execution() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + + await channel.emit(published(1, direction="in")) + await channel.emit({"seq": 2}) + await channel.emit(published(3)) + + assert await received.until(1) == [3] + finally: + await client.realtime.disconnect() + + +async def test_live_messages_wait_for_the_first_read_and_follow_it_in_order() -> None: + transport = FakeMessageTransport() + transport.published = [1, 2] + gate = asyncio.Event() + transport.next_read_gate = gate + client, native = subscriber(transport) + received = Received() + try: + subscribing = asyncio.create_task( + client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + ) + await transport.read_arrivals.wait_for(1) + channel = subscription_of(native) + await channel.emit(published(4)) + await channel.emit(published(3)) + await channel.emit(published(2)) + await dispatched(client) + + assert received.seqs == [] + gate.set() + _ = await subscribing + + assert received.seqs == [1, 2, 3, 4] + finally: + await client.realtime.disconnect() + + +class PublishingBeforeReady(FakeSubscription): + """Deliver a publication while subscribe still waits to be ready.""" + + early: object = None + + @override + async def ready(self) -> None: + await super().ready() + await self.emit(self.early) + task = self._events().channel.callback_task + if task is not None: + await task + + +class PublishingBeforeReadyClient(FakeCentrifugeClient): + def __init__(self, early: object) -> None: + super().__init__() + self.early: object = early + + @override + def new_subscription( + self, + name: str, + *, + events: object, + join_leave: bool = False, + recoverable: bool = False, + data: Mapping[str, str] | None = None, + ) -> FakeSubscription: + subscription = PublishingBeforeReady( + name, events, join_leave=join_leave, recoverable=recoverable, data=data + ) + subscription.early = self.early + self.subscriptions[name] = subscription + return subscription + + +async def test_a_message_published_before_the_first_read_is_not_delivered_early() -> ( + None +): + transport = FakeMessageTransport() + transport.published = [1] + native = PublishingBeforeReadyClient(published(2)) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(native), + ) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + + assert received.seqs == [1, 2] + finally: + await client.realtime.disconnect() + + +async def test_a_resubscription_reads_back_what_the_channel_missed() -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + + await channel.emit_subscribing() + transport.published += [2, 3] + await channel.emit(published(2)) + await channel.emit_subscribed() + assert await received.until(3) == [1, 2, 3] + await channel.emit(published(4)) + + assert await received.until(4) == [1, 2, 3, 4] + assert transport.read_after() == [0, 1] + finally: + await client.realtime.disconnect() + + +async def test_an_earlier_read_does_not_release_messages_a_later_one_must_precede() -> ( + None +): + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + gate = asyncio.Event() + transport.next_read_gate = gate + await channel.emit_subscribing() + await channel.emit_subscribed() + await transport.read_arrivals.wait_for(2) + + await channel.emit_subscribing() + transport.published += [2, 3] + await channel.emit_subscribed() + await channel.emit(published(4)) + await dispatched(client) + gate.set() + + assert await received.until(4) == [1, 2, 3, 4] + assert transport.read_after() == [0, 1, 1] + finally: + await client.realtime.disconnect() + + +async def test_a_message_arriving_during_release_follows_the_held_ones() -> None: + transport = FakeMessageTransport() + transport.published = [1] + gate = asyncio.Event() + transport.next_read_gate = gate + client, native = subscriber(transport) + seqs: list[int] = [] + + async def handle(message: DurableMessage) -> None: + seqs.append(message.seq) + if message.seq == 3: + await subscription_of(native).emit(published(4)) + await dispatched(client) + + try: + subscribing = asyncio.create_task( + client.durable.messages.subscribe("checkout", EXECUTION_ID, handle) + ) + await transport.read_arrivals.wait_for(1) + await subscription_of(native).emit(published(3)) + await dispatched(client) + gate.set() + _ = await subscribing + + assert seqs == [1, 3, 4] + finally: + await client.realtime.disconnect() + + +async def test_subscribe_reads_every_page() -> None: + transport = FakeMessageTransport() + transport.published = [1, 2, 3] + transport.page_size = 2 + client, _native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + + assert received.seqs == [1, 2, 3] + assert transport.read_after() == [0, 2] + finally: + await client.realtime.disconnect() + + +async def test_an_empty_page_ends_the_read() -> None: + transport = FakeMessageTransport() + transport.read_answer = FakeResponse(200, {"data": [], "has_more": True}, {}) + client, _native = subscriber(transport) + try: + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + + assert transport.read_after() == [0] + finally: + await client.realtime.disconnect() + + +async def test_unsubscribing_from_the_handler_stops_the_read() -> None: + transport = FakeMessageTransport() + transport.published = [1] + transport.page_size = 1 + client, native = subscriber(transport) + seqs: list[int] = [] + subscriptions: list[DurableMessageSubscription] = [] + left = asyncio.Event() + + async def handle(message: DurableMessage) -> None: + seqs.append(message.seq) + if message.seq == 2: + await subscriptions[0].unsubscribe() + left.set() + + try: + subscriptions.append( + await client.durable.messages.subscribe("checkout", EXECUTION_ID, handle) + ) + channel = subscription_of(native) + await channel.emit_subscribing() + transport.published += [2, 3] + await channel.emit_subscribed() + _ = await asyncio.wait_for(left.wait(), timeout=2) + + assert seqs == [1, 2] + assert CHANNEL not in native.subscriptions + assert transport.read_after() == [0, 1] + assert ("unsubscribe", None) in channel.calls + finally: + await client.realtime.disconnect() + + +async def test_unsubscribe_stops_delivery_and_can_be_repeated() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + received = Received() + try: + subscription = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, received + ) + channel = subscription_of(native) + + await subscription.unsubscribe() + await subscription.unsubscribe() + await channel.emit(published(1)) + + assert received.seqs == [] + assert channel.calls.count(("unsubscribe", None)) == 1 + assert CHANNEL not in realtime_state(client.realtime).channels + finally: + await client.realtime.disconnect() + + +async def test_one_client_subscribes_to_an_execution_once() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + received = Received() + try: + subscription = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + + with pytest.raises(RuntimeError, match="already subscribes"): + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, received + ) + + await subscription.unsubscribe() + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + await subscription_of(native).emit(published(1)) + + assert await received.until(1) == [1] + finally: + await client.realtime.disconnect() + + +async def test_a_subscription_still_leaving_blocks_a_new_one() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + try: + subscription = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + channel = subscription_of(native) + channel.unsubscribe_entered = asyncio.Event() + channel.unsubscribe_release = asyncio.Event() + leaving = asyncio.create_task(subscription.unsubscribe()) + _ = await asyncio.wait_for(channel.unsubscribe_entered.wait(), timeout=2) + + with pytest.raises(RuntimeError, match="already subscribes"): + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + + channel.unsubscribe_release.set() + await leaving + finally: + await client.realtime.disconnect() + + +async def test_an_old_subscription_leaves_a_replacement_alone() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + received = Received() + try: + stale = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + await client.realtime.remove_all_channels() + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + + await stale.unsubscribe() + await subscription_of(native).emit(published(1)) + + assert await received.until(1) == [1] + assert CHANNEL in realtime_state(client.realtime).channels + finally: + await client.realtime.disconnect() + + +async def test_a_failed_first_read_fails_subscribe_and_leaves_the_channel() -> None: + transport = FakeMessageTransport() + transport.read_answer = FakeResponse(404, {"error": "execution not found"}, {}) + client, native = subscriber(transport) + try: + with pytest.raises(NotFoundError, match="execution not found"): + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + + assert CHANNEL not in native.subscriptions + transport.read_answer = None + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + finally: + await client.realtime.disconnect() + + +async def test_a_failing_handler_fails_subscribe_and_leaves_the_channel() -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + failure = RuntimeError("handler failed") + + def fail(_message: DurableMessage) -> None: + raise failure + + try: + with pytest.raises(RuntimeError) as caught: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, fail) + + assert caught.value is failure + assert CHANNEL not in native.subscriptions + finally: + await client.realtime.disconnect() + + +async def test_subscribe_needs_a_signed_in_session() -> None: + native = FakeCentrifugeClient() + client = VolcanoClient( + anon_key="anon-key", + _transport=FakeMessageTransport(), + _realtime_client_factory=FakeCentrifugeFactory(native), + ) + try: + for _attempt in range(2): + with pytest.raises(RuntimeError, match="No active session"): + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + + assert CHANNEL not in realtime_state(client.realtime).channels + finally: + await client.realtime.disconnect() + + +async def test_a_failed_read_after_a_resubscription_is_reported() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + received = Received() + errors: list[dict[str, object]] = [] + reported = asyncio.Event() + loop = asyncio.get_running_loop() + previous = loop.get_exception_handler() + + def report(_loop: asyncio.AbstractEventLoop, context: dict[str, object]) -> None: + errors.append(context) + reported.set() + + loop.set_exception_handler(report) + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + transport.read_answer = FakeResponse(500, {"error": "list failed"}, {}) + await channel.emit_subscribing() + await channel.emit_subscribed() + _ = await asyncio.wait_for(reported.wait(), timeout=2) + + assert [error["message"] for error in errors] == [ + "Volcano durable message catch-up failed" + ] + assert isinstance(errors[0]["exception"], ServerError) + await channel.emit(published(1)) + assert await received.until(1) == [1] + finally: + loop.set_exception_handler(previous) + await client.realtime.disconnect() + + +async def test_subscribe_needs_the_client_s_realtime_connection() -> None: + transport = FakeMessageTransport() + durable = Durable(client=messages_client(transport, session=True)) + + with pytest.raises(RuntimeError, match="needs a VolcanoClient"): + _ = await durable.messages.subscribe("checkout", EXECUTION_ID, Received()) + assert transport.calls == [] + + +async def test_subscribe_refuses_a_handler_it_cannot_call() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + + with pytest.raises(TypeError, match=r"^subscribe\(\) requires a handler function$"): + await non_callable_message_handler(client.durable.messages, EXECUTION_ID) + with pytest.raises(TypeError, match="unexpected option: after"): + await unknown_subscription_option(client.durable.messages, EXECUTION_ID) + + assert transport.calls == [] + assert native.subscriptions == {} + + +async def test_a_durable_channel_is_not_a_public_channel_type() -> None: + realtime = subscriber(FakeMessageTransport())[0].realtime + + with pytest.raises(ValueError, match="unsupported realtime channel type: durable"): + durable_realtime_channel(realtime, EXECUTION_ID) + with pytest.raises(ValueError, match="unsupported realtime channel type: durable"): + await remove_durable_realtime_channel(realtime, EXECUTION_ID) diff --git a/src/volcano_sdk/_tests/test_realtime.py b/src/volcano_sdk/_tests/test_realtime.py index 272b38ac..ec638503 100644 --- a/src/volcano_sdk/_tests/test_realtime.py +++ b/src/volcano_sdk/_tests/test_realtime.py @@ -446,6 +446,7 @@ def __init__( *, join_leave: bool, recoverable: bool, + data: Mapping[str, str] | None = None, ) -> None: if events is not None and not isinstance(events, ChannelEvents): message = "expected SDK channel events" @@ -454,6 +455,7 @@ def __init__( self.events: ChannelEvents | None = events self.join_leave: bool = join_leave self.recoverable: bool = recoverable + self.data: Mapping[str, str] | None = data self.calls: list[tuple[str, object]] = [] self.presence_clients: dict[str, object] = {} self.presence_error: Exception | None = None @@ -608,6 +610,7 @@ def new_subscription( events: object, join_leave: bool = False, recoverable: bool = False, + data: Mapping[str, str] | None = None, ) -> FakeSubscription: if name in self._subs: message = f"duplicate subscription: {name}" @@ -618,6 +621,7 @@ def new_subscription( events, join_leave=join_leave, recoverable=recoverable, + data=data, ) self.subscription.presence_clients = self.presence_clients.copy() self.subscription.presence_error = self.presence_error @@ -697,9 +701,14 @@ def new_subscription( *, join_leave: bool = False, recoverable: bool = False, + data: object | None = None, ) -> SDKNativeSubscription: subscription = super().new_subscription( - name, events=events, join_leave=join_leave, recoverable=recoverable + name, + events=events, + join_leave=join_leave, + recoverable=recoverable, + data=data, ) if not isinstance(subscription, SDKNativeSubscription): message = "expected instrumented native subscription" @@ -3924,6 +3933,7 @@ def new_subscription( events: object, join_leave: bool = False, recoverable: bool = False, + data: Mapping[str, str] | None = None, ) -> FakeSubscription: official.calls.append(f"channel:{name}") official.subscription = BlockingSubscription( @@ -3931,6 +3941,7 @@ def new_subscription( events, join_leave=join_leave, recoverable=recoverable, + data=data, ) official.subscriptions[name] = official.subscription return official.subscription diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index 26c41533..0cc4576e 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -4,6 +4,7 @@ from ._transport_auth_identity import AuthIdentityTransport from ._transport_database import DatabaseTransport from ._transport_durable_approvals import DurableApprovalTransport +from ._transport_durable_messages import DurableMessageTransport from ._transport_execution import ExecutionTransport from ._transport_locks import LocksTransport from ._transport_response import invoke, invoke_async, response_payload @@ -48,6 +49,9 @@ DurableApprovalListRequest, DurableApprovalStatsRequest, DurableExecutionListRequest, + DurableMessageListRequest, + DurableMessageSendRequest, + DurableMessageTarget, StorageUploadPartRequest, StorageUploadSessionReference, StorageUploadSessionRequest, @@ -94,6 +98,9 @@ "DurableApprovalListRequest", "DurableApprovalStatsRequest", "DurableExecutionListRequest", + "DurableMessageListRequest", + "DurableMessageSendRequest", + "DurableMessageTarget", "GeneratedTransport", "StorageUploadPartRequest", "StorageUploadSessionReference", @@ -113,6 +120,7 @@ class GeneratedTransport( StorageTransport, ExecutionTransport, DurableApprovalTransport, + DurableMessageTransport, LocksTransport, SandboxHTTPTransport, ): diff --git a/src/volcano_sdk/_transport_durable_messages.py b/src/volcano_sdk/_transport_durable_messages.py new file mode 100644 index 00000000..fe969326 --- /dev/null +++ b/src/volcano_sdk/_transport_durable_messages.py @@ -0,0 +1,154 @@ +"""Generated durable message operation adapters.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from ._generated.api.durable_functions import ( + list_durable_messages, + list_durable_messages_from_application, + send_durable_message, + send_durable_message_from_application, +) +from ._generated.models.send_durable_message_request import SendDurableMessageRequest +from ._generated.types import UNSET, Unset +from ._transport_base import TransportBase +from ._transport_response import parsed_response + +if TYPE_CHECKING: + from ._generated.client import AuthenticatedClient + from ._generated.models.durable_message_list import DurableMessageList + from ._generated.models.error import Error + from ._generated.types import Response + from ._transport_types import ( + DurableMessageListRequest, + DurableMessageSendRequest, + DurableMessageTarget, + TransportResponse, + ) + from .models import DurableMessageDirection + + +def _unset(value: str | None) -> str | Unset: + return UNSET if value is None else value + + +def _direction( + value: DurableMessageDirection | None, +) -> DurableMessageDirection | Unset: + return UNSET if value is None else value + + +def _count(value: int | None) -> int | Unset: + return UNSET if value is None else value + + +class DurableMessageTransport(TransportBase): + """Adapt generated durable message operations to the SDK transport. + + A target with a project id goes to the project route, with the owner's + credential; one without goes to the application route. + """ + + def send_durable_message( + self, + *, + authorization: str, + target: DurableMessageTarget, + request: DurableMessageSendRequest, + ) -> TransportResponse: + body = SendDurableMessageRequest(name=request.name, data=request.data) + key = _unset(request.idempotency_key) + execution_id = UUID(target.execution_id) + with self._client(authorization) as client: + if target.project_id is None: + response = send_durable_message_from_application.sync_detailed( + target.function_id, + execution_id, + client=client, + body=body, + idempotency_key=key, + x_volcano_client_token=_unset(target.client_token), + ) + else: + response = send_durable_message.sync_detailed( + UUID(target.project_id), + target.function_id, + execution_id, + client=client, + body=body, + idempotency_key=key, + ) + return parsed_response(response) + + def list_durable_messages( + self, + *, + authorization: str, + target: DurableMessageTarget, + request: DurableMessageListRequest, + ) -> TransportResponse: + with self._client(authorization) as client: + if target.project_id is None: + response = list_durable_messages_from_application.sync_detailed( + target.function_id, + UUID(target.execution_id), + client=client, + direction=_direction(request.direction), + name=_unset(request.name), + after=_count(request.after), + limit=_count(request.limit), + x_volcano_client_token=_unset(target.client_token), + ) + else: + response = list_durable_messages.sync_detailed( + UUID(target.project_id), + target.function_id, + UUID(target.execution_id), + client=client, + direction=_direction(request.direction), + name=_unset(request.name), + after=_count(request.after), + limit=_count(request.limit), + ) + return parsed_response(response) + + async def list_durable_messages_async( + self, + *, + authorization: str, + target: DurableMessageTarget, + request: DurableMessageListRequest, + ) -> TransportResponse: + async with self._client(authorization) as client: + response = await _list_async(client, target, request) + return parsed_response(response) + + +async def _list_async( + client: AuthenticatedClient, + target: DurableMessageTarget, + request: DurableMessageListRequest, +) -> Response[DurableMessageList | Error]: + if target.project_id is None: + return await list_durable_messages_from_application.asyncio_detailed( + target.function_id, + UUID(target.execution_id), + client=client, + direction=_direction(request.direction), + name=_unset(request.name), + after=_count(request.after), + limit=_count(request.limit), + x_volcano_client_token=_unset(target.client_token), + ) + return await list_durable_messages.asyncio_detailed( + UUID(target.project_id), + target.function_id, + UUID(target.execution_id), + client=client, + direction=_direction(request.direction), + name=_unset(request.name), + after=_count(request.after), + limit=_count(request.limit), + ) diff --git a/src/volcano_sdk/_transport_types.py b/src/volcano_sdk/_transport_types.py index 8f1685a1..d08405d3 100644 --- a/src/volcano_sdk/_transport_types.py +++ b/src/volcano_sdk/_transport_types.py @@ -44,7 +44,12 @@ from ._generated.models.refresh_o_auth_provider_token_provider import ( RefreshOAuthProviderTokenProvider, ) - from .models import DurableApprovalStatus, DurableExecutionStatus, JSONValue + from .models import ( + DurableApprovalStatus, + DurableExecutionStatus, + DurableMessageDirection, + JSONValue, + ) HTTP_CREATED = 201 @@ -183,6 +188,37 @@ class DurableApprovalStatsRequest: to: datetime | None = None +@dataclass(frozen=True, slots=True) +class DurableMessageTarget: + """The execution a message request addresses, and through which route.""" + + function_id: str + execution_id: str + # The project route when set; the application route otherwise. + project_id: str | None = None + # Only the application route reads it. + client_token: str | None = None + + +@dataclass(frozen=True, slots=True) +class DurableMessageSendRequest: + """A message to send to a durable execution.""" + + name: str + data: JSONValue = None + idempotency_key: str | None = None + + +@dataclass(frozen=True, slots=True) +class DurableMessageListRequest: + """Filters and paging for a durable execution's messages.""" + + direction: DurableMessageDirection | None = None + name: str | None = None + after: int | None = None + limit: int | None = None + + @dataclass(frozen=True, slots=True) class StorageUploadSessionRequest: """Values needed to create a resumable storage upload session.""" diff --git a/src/volcano_sdk/client.py b/src/volcano_sdk/client.py index 3d85ce7c..ad16ab90 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -12,6 +12,7 @@ from ._auth_requests import AuthRequests from ._client_context import ClientContext from ._client_session import BootstrapCredentials, CallbackOutcome, bootstrap_session +from ._realtime_durable import ClientRealtime from ._sandbox import SandboxRequests, SandboxTransport from ._session import validate_refresh_identity from ._session_operations import SessionOperations @@ -30,7 +31,6 @@ JSONValue, Session, ) -from .realtime import CentrifugeFactory, Realtime from .sandboxes import Sandboxes from .storage import Storage @@ -39,6 +39,7 @@ from collections.abc import Callable, Mapping from ._transport_sandbox import SandboxRequest + from .realtime import CentrifugeFactory, Realtime _NO_ACTIVE_SESSION = "No active session" _NO_SERVICE_KEY = "No service key configured" @@ -88,20 +89,25 @@ def __init__( self._auth_requests: AuthRequests = AuthRequests(auth_context) self.auth: Auth = Auth(auth_context, _requests=self._auth_requests) self._facades: ClientContext = self._facade_context() + # One connection serves both channels and durable message subscriptions. + realtime = ( + ClientRealtime(self._facades, api_url=self._api_url) + if _realtime_client_factory is None + else ClientRealtime( + self._facades, + api_url=self._api_url, + client_factory=_realtime_client_factory, + ) + ) self.functions: Functions = Functions(self._facades) - self.durable: Durable = Durable(self._facades) + self.durable: Durable = Durable( + self._facades, _realtime=realtime.durable_channels() + ) self.logs: Logs = Logs(self._facades) self.storage: Storage = Storage(self._facades) self.locks: Locks = Locks(self._facades) self.sandboxes: Sandboxes = Sandboxes(SandboxRequests(self._sandbox_request)) - if _realtime_client_factory is None: - self.realtime: Realtime = Realtime(self._facades, api_url=self._api_url) - else: - self.realtime = Realtime( - self._facades, - api_url=self._api_url, - client_factory=_realtime_client_factory, - ) + self.realtime: Realtime = realtime def _auth_context(self) -> AuthContext: def capture_auth_session_binding() -> tuple[ diff --git a/src/volcano_sdk/durable.py b/src/volcano_sdk/durable.py index d3973fb4..014ddd38 100644 --- a/src/volcano_sdk/durable.py +++ b/src/volcano_sdk/durable.py @@ -4,7 +4,6 @@ from datetime import datetime from typing import TYPE_CHECKING, Literal, Protocol, Unpack, runtime_checkable -from uuid import UUID from ._client_context import ClientContextSource, facade_context from ._durable_approval_response import ( @@ -13,6 +12,8 @@ durable_approval_page, durable_approval_stats, ) +from ._durable_arguments import identifier, known_options +from ._durable_messages import DurableMessageChannels, DurableMessages from ._durable_response import durable_execution, durable_execution_page from ._transport import ( DurableApprovalListRequest, @@ -25,8 +26,6 @@ ) if TYPE_CHECKING: - from collections.abc import Mapping - from .models import ( DurableApproval, DurableApprovalListOptions, @@ -53,23 +52,6 @@ # over-long name is refused before a request is spent on it, the way the # JavaScript SDK refuses it. _MAX_EXECUTION_NAME_LENGTH = 255 - -_INVALID_IDENTIFIERS = { - "function_name": "function_name must be a non-empty string", - "execution_name": "execution_name must be a non-empty string", - "project_id": "project_id must be a non-empty string", - "execution_id": "execution_id must be a non-empty string", - "approval_id": "approval_id must be a non-empty string", -} -# The two identifiers the transport converts to a UUID before sending. Checked -# here so a malformed one is a facade ValidationError rather than a -# `badly formed hexadecimal UUID string` raised from inside the transport, -# outside the error hierarchy this package documents. -_UUID_IDENTIFIERS = { - "project_id": "project_id must be a UUID", - "execution_id": "execution_id must be a UUID", - "approval_id": "approval_id must be a UUID", -} _HTTP_ACCEPTED = 202 _HTTP_OK = 200 @@ -228,7 +210,7 @@ def list( Approvals and pagination metadata. """ - project = _identifier(project_id, "project_id") + project = identifier(project_id, "project_id") request = _list_request(options) transport = self._transport() response = invoke( @@ -249,8 +231,8 @@ def get(self, project_id: str, approval_id: str) -> DurableApproval: The approval. """ - project = _identifier(project_id, "project_id") - approval = _identifier(approval_id, "approval_id") + project = identifier(project_id, "project_id") + approval = identifier(approval_id, "approval_id") transport = self._transport() response = invoke( transport.get_durable_approval, @@ -274,7 +256,7 @@ def stats( Counts, approval rate, and decision times over the window. """ - project = _identifier(project_id, "project_id") + project = identifier(project_id, "project_id") request = _stats_request(options) transport = self._transport() response = invoke( @@ -325,8 +307,8 @@ def _decide( approval_id: str, comment: object, ) -> DurableApproval: - project = _identifier(project_id, "project_id") - approval = _identifier(approval_id, "approval_id") + project = identifier(project_id, "project_id") + approval = identifier(approval_id, "approval_id") note = _comment(comment) transport = self._transport() operation = ( @@ -348,11 +330,19 @@ def _decide( class Durable: """Start and follow executions of deployed durable functions.""" - def __init__(self, client: DurableClientContext | ClientContextSource) -> None: + def __init__( + self, + client: DurableClientContext | ClientContextSource, + *, + _realtime: DurableMessageChannels | None = None, + ) -> None: """Bind durable operations to a Volcano client.""" self._client: DurableClientContext = facade_context(client) # Approvals that workflows wait on with `ctx.wait_for_approval`. self.approvals: DurableApprovals = DurableApprovals(self._client) + # Messages workflows wait on with `ctx.wait_for_message`, and the ones + # they publish with `ctx.publish`. + self.messages: DurableMessages = DurableMessages(self._client, _realtime) def _durable_transport(self) -> DurableTransport: transport = self._client.transport() @@ -385,13 +375,13 @@ def start( The accepted execution, or the existing execution for an idempotent start. """ - identifier = _identifier(function_name, "function_name") + function = identifier(function_name, "function_name") name = None if execution_name is None else _execution_name(execution_name) transport = self._durable_transport() response = invoke( transport.start_durable_execution_from_application, authorization=self._client.function_token(), - function_id=identifier, + function_id=function, payload={} if payload is None else payload, execution_name=name, ) @@ -419,15 +409,15 @@ def get( The execution snapshot, including any available result or failure. """ - project = _identifier(project_id, "project_id") - identifier = _identifier(function_name, "function_name") - execution = _identifier(execution_id, "execution_id") + project = identifier(project_id, "project_id") + function = identifier(function_name, "function_name") + execution = identifier(execution_id, "execution_id") transport = self._durable_transport() response = invoke( transport.get_durable_execution, authorization=self._client.session_token(), project_id=project, - function_id=identifier, + function_id=function, execution_id=execution, ) response_body: object = response_payload(response, _HTTP_OK) @@ -453,15 +443,15 @@ def list( Execution summaries and pagination metadata. """ - project = _identifier(project_id, "project_id") - identifier = _identifier(function_name, "function_name") + project = identifier(project_id, "project_id") + function = identifier(function_name, "function_name") transport = self._durable_transport() request = DurableExecutionListRequest(status=status, page=page, limit=limit) response = invoke( transport.list_durable_executions, authorization=self._client.session_token(), project_id=project, - function_id=identifier, + function_id=function, request=request, ) response_body: object = response_payload(response, _HTTP_OK) @@ -487,15 +477,15 @@ def stop( The execution snapshot after the stop request; it may still be running. """ - project = _identifier(project_id, "project_id") - identifier = _identifier(function_name, "function_name") - execution = _identifier(execution_id, "execution_id") + project = identifier(project_id, "project_id") + function = identifier(function_name, "function_name") + execution = identifier(execution_id, "execution_id") transport = self._durable_transport() response = invoke( transport.stop_durable_execution, authorization=self._client.session_token(), project_id=project, - function_id=identifier, + function_id=function, execution_id=execution, ) response_body: object = response_payload(response, _HTTP_OK) @@ -520,7 +510,7 @@ def _execution_name(value: object) -> str: If the name is empty, is not a string, or exceeds the length limit. """ - name = _identifier(value, "execution_name") + name = identifier(value, "execution_name") if len(name) > _MAX_EXECUTION_NAME_LENGTH: message = ( f"execution_name must be at most {_MAX_EXECUTION_NAME_LENGTH} characters" @@ -529,50 +519,15 @@ def _execution_name(value: object) -> str: return name -def _identifier(value: object, field: str) -> str: - """Require a non-empty path segment, and a UUID where one is sent as one. - - An empty segment would address the collection instead of the execution, - which is a different request rather than a failed one. - - Returns - ------- - str - The trimmed identifier, preserving its UUID spelling. - - Raises - ------ - ValueError - If the value is empty, is not a string, or requires a valid UUID. - - """ - if not isinstance(value, str) or not value.strip(): - raise ValueError(_INVALID_IDENTIFIERS[field]) - trimmed = value.strip() - if field in _UUID_IDENTIFIERS: - try: - _ = UUID(trimmed) - except ValueError as exc: - raise ValueError(_UUID_IDENTIFIERS[field]) from exc - return trimmed - - -def _known_options(options: Mapping[str, object], known: frozenset[str]) -> None: - unknown = sorted(options.keys() - known) - if unknown: - message = f"unexpected option: {', '.join(unknown)}" - raise TypeError(message) - - def _list_request(options: DurableApprovalListOptions) -> DurableApprovalListRequest: - _known_options(options, _LIST_OPTIONS) + known_options(options, _LIST_OPTIONS) status = options.get("status") execution_id = options.get("execution_id") return DurableApprovalListRequest( status=None if status is None else _approval_status(status), function=_function_filter(options.get("function")), execution_id=( - None if execution_id is None else _identifier(execution_id, "execution_id") + None if execution_id is None else identifier(execution_id, "execution_id") ), from_=_moment(options.get("from_"), "from_"), to=_moment(options.get("to"), "to"), @@ -584,7 +539,7 @@ def _list_request(options: DurableApprovalListOptions) -> DurableApprovalListReq def _stats_request( options: DurableApprovalStatsOptions, ) -> DurableApprovalStatsRequest: - _known_options(options, _STATS_OPTIONS) + known_options(options, _STATS_OPTIONS) return DurableApprovalStatsRequest( function=_function_filter(options.get("function")), from_=_moment(options.get("from_"), "from_"), diff --git a/src/volcano_sdk/durable_authoring.py b/src/volcano_sdk/durable_authoring.py index b2b2af05..3cabc717 100644 --- a/src/volcano_sdk/durable_authoring.py +++ b/src/volcano_sdk/durable_authoring.py @@ -44,11 +44,22 @@ def handler(event, ctx): approval_body, approval_decision, check_size, - platform_api_url, register_approval, ) +from ._durable_callback import operation_name from ._durable_duration import to_seconds from ._durable_engine import DurableRuntimeMissingError, load_engine +from ._durable_message import ( + SESSION_NAME, + message_data, + open_session, + publish_key, + publish_message, + published_message, + received_message, + register_wait, + session_token, +) from ._durable_options import ( BatchOptions, Duration, @@ -56,13 +67,21 @@ def handler(event, ctx): RetryOptions, WaitUntilOptions, ) +from ._durable_platform_request import platform_api_url from ._durable_protocols import ( DurableEngine, DurableLogger, OperationScope, RuntimeContext, ) -from ._durable_results import ApprovalDecision, BatchFailure, BatchItem, BatchResult +from ._durable_results import ( + ApprovalDecision, + BatchFailure, + BatchItem, + BatchResult, + PublishedMessage, + ReceivedMessage, +) if TYPE_CHECKING: from collections.abc import Sequence @@ -83,6 +102,8 @@ def handler(event, ctx): "Duration", "FunctionHandler", "ParallelBranch", + "PublishedMessage", + "ReceivedMessage", "Retry", "RetryOptions", "StepScope", @@ -156,16 +177,27 @@ class DurableContext: durations be written as `"30s"` rather than `Duration.from_seconds(30)`. Raw callbacks are the deliberate omission: completing one takes a call - nothing in a project can make. `wait_for_approval` is the callback Volcano - completes, when a person decides. + nothing in a project can make. `wait_for_approval` and `wait_for_message` + are the callbacks Volcano completes, when a person decides or a message + arrives. """ - __slots__: tuple[str, ...] = ("_context", "_engine", "log") + __slots__: tuple[str, ...] = ( + "_context", + "_engine", + "_published", + "_session", + "log", + ) def __init__(self, context: RuntimeContext, engine: DurableEngine) -> None: """Wrap an engine context.""" self._context: RuntimeContext = context self._engine: DurableEngine = engine + # Publishes so far in this context, as a replay reaches them again. + self._published: int = 0 + # The token this context publishes with, once its session is open. + self._session: str | None = None # Logs, suppressed while an operation is being replayed. self.log: DurableLogger = context.logger @@ -396,7 +428,7 @@ def wait_for_approval( """ body = approval_body(name, title, description, details) seconds = None if timeout is None else self._approval_timeout(timeout) - api_url = platform_api_url() + api_url = platform_api_url("wait_for_approval()") body["execution_ref"] = self._context.execution_context.durable_execution_arn check_size(body) @@ -413,6 +445,105 @@ def submit(callback_id: str, _context: object) -> None: return EXPIRED return approval_decision(result) + def wait_for_message( + self, name: str, *, timeout: Duration | None = None + ) -> ReceivedMessage | None: + """Suspend until a message of this name arrives, or the timeout passes. + + Whoever the function's visibility admits can send one: a webhook, + another function, or a user of your app, through + `client.durable.messages.send`. A message already queued under the + name is taken at once, oldest first. The execution is not running, and + not billed, while it waits. + + Without a timeout the wait lasts as long as the execution. A timeout + that passes first returns None rather than raising. + + The name is also the operation's name in the execution's history: up + to 237 printable ASCII characters. It, the timeout, and + `VOLCANO_PLATFORM_API_URL` are checked before the callback opens. + Registering the wait is retried for up to 30 seconds; a refusal, such + as too many open waits, raises the runtime's `CallbackSubmitterError` + with the SDK error's message. + + Returns: + The message, or None once the timeout passes, restored from its + checkpoint during replay. + + """ + message_name = operation_name(name) + seconds = None if timeout is None else self._approval_timeout(timeout) + api_url = platform_api_url("wait_for_message()") + execution_ref = self._context.execution_context.durable_execution_arn + + # Runs inside the runtime's checkpointed submitter step, so a replay + # does not register the wait again. + def submit(callback_id: str, _context: object) -> None: + register_wait(api_url, execution_ref, callback_id, message_name) + + try: + result = self._context.wait_for_callback( + submit, message_name, self._engine.callback_options(seconds) + ) + except self._engine.callback_timeout_error: + return None + return received_message(result) + + def publish(self, name: str, data: JSONValue = None) -> PublishedMessage: + """Record a message for applications to list, or receive live. + + Clients subscribed with `client.durable.messages.subscribe` get it at + once; anyone allowed to read the execution's messages can list it. + `data` is any JSON value up to 64 KiB. + + Publishing is a step that is never retried, keyed so a step that runs + again returns the message it already published. The first publish in + each context -- the root, a `child`, a `parallel` branch, or a `map` + item -- first opens a messages session, a callback named + `volcano:messages-session` that Volcano completes with the token + publishes carry. + + Returns: + The message's id and seq, restored from its checkpoint during + replay. + + """ + message_name = operation_name(name) + message: dict[str, JSONValue] = { + "name": message_name, + "data": message_data(data), + } + api_url = platform_api_url("publish()") + # Counted before the session opens, as the runtime numbers operations, + # so a replay gives every publish the position it had before. + self._published += 1 + key_position = self._published + token = self._session_token(api_url) + key = publish_key(token, key_position) + + def run(_scope: OperationScope) -> dict[str, JSONValue]: + return publish_message(api_url, token, key, message) + + recorded = self._context.step( + run, + message_name, + self._engine.step_options(retry=False, at_most_once=False), + ) + return published_message(recorded) + + def _session_token(self, api_url: str) -> str: + if self._session is None: + execution_ref = self._context.execution_context.durable_execution_arn + + def submit(callback_id: str, _context: object) -> None: + open_session(api_url, execution_ref, callback_id) + + result = self._context.wait_for_callback( + submit, SESSION_NAME, self._engine.callback_options(None) + ) + self._session = session_token(result) + return self._session + @staticmethod def _approval_timeout(value: object) -> int: return _bounded_seconds(value, "timeout") diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index 767acbe6..a5275226 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -48,6 +48,18 @@ "expired", "cancelled", ] +# "in" is a message sent to the execution; "out" is one it published. +DurableMessageDirection: TypeAlias = Literal["in", "out"] +# An "in" message is "queued" until a wait for its name takes it, "delivering" +# while it is handed over, and "delivered" once the execution has it; "dropped" +# if the execution ended first. Every "out" message is "published". +DurableMessageStatus: TypeAlias = Literal[ + "queued", + "delivering", + "delivered", + "dropped", + "published", +] def _freeze_metadata( @@ -369,6 +381,10 @@ class DurableExecution: result_expired: bool | None = None error: DurableExecutionFailure | None = None completed_at: datetime | None = None + # Lets whoever started the execution send it messages and read what it + # publishes. Returned only by the start that created the execution, so + # keep it with the execution id. + client_token: str | None = field(default=None, repr=False) def __post_init__(self) -> None: """Defensively freeze the function's own result.""" @@ -502,6 +518,81 @@ def __post_init__(self) -> None: object.__setattr__(self, "approvals", tuple(self.approvals)) +@dataclass(frozen=True, slots=True) +class DurableMessageSender: + """Who sent a durable message.""" + + # The credential it was sent with: "service_key", "user", "anon", or + # "project" (the owner or an access token), or "execution" for one the + # execution published. + type: str + # The auth user who sent it; None unless `type` is "user". + user_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class DurableMessage: + """A message sent to a durable execution, or one it published.""" + + id: str + execution_id: str + direction: DurableMessageDirection + # Orders the execution's messages in both directions. It increases with + # every message but can skip numbers; list the ones after it with `after`. + seq: int + name: str + data: JSONValue = field(repr=False, hash=False) + sender: DurableMessageSender + status: DurableMessageStatus + sent_at: datetime + # When the execution received it; None until then, and for "out" messages. + delivered_at: datetime | None = None + + def __post_init__(self) -> None: + """Defensively freeze the message's data.""" + object.__setattr__(self, "data", freeze_json(self.data)) + + +@dataclass(frozen=True, slots=True) +class DurableMessagePage: + """Messages after a `seq`, oldest first.""" + + messages: tuple[DurableMessage, ...] + # Whether more follow; read them with `after` set to the last `seq`. + has_more: bool + + def __post_init__(self) -> None: + """Defensively snapshot the messages in this page.""" + object.__setattr__(self, "messages", tuple(self.messages)) + + +class DurableMessageTargetOptions(TypedDict, total=False): + """Which route reaches an execution's messages, and with what.""" + + # The execution's `client_token`. The application route needs it for an + # anon key, or a signed-in user who did not start the execution. + client_token: str + # Use the owner's project route instead, with the session's platform token. + project_id: str + + +class DurableMessageSendOptions(DurableMessageTargetOptions, total=False): + """Route and idempotency for sending a durable message.""" + + # A repeat with the same key returns the message the first send stored. + idempotency_key: str + + +class DurableMessageListOptions(DurableMessageTargetOptions, total=False): + """Route, filters, and paging for listing durable messages.""" + + direction: DurableMessageDirection + name: str + # Only messages with a greater `seq`. + after: int + limit: int + + @dataclass(frozen=True, slots=True) class DurableApprovalCounts: """Approvals counted by outcome; `requested` is all of them.""" diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 2a17c835..b11a8d2b 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -269,7 +269,9 @@ async def remove_channel( channel_type: ChannelType = "broadcast", ) -> None: """Unsubscribe and forget one broadcast or presence channel.""" - await self._state.remove_channel(name, channel_type=channel_type) + await self._state.remove_channel( + name, channel_type=_messages.validate_channel_type(channel_type) + ) async def remove_all_channels(self) -> None: """Unsubscribe and forget every managed channel.""" diff --git a/typings/centrifuge/__init__.pyi b/typings/centrifuge/__init__.pyi index 22c28e48..7b43763d 100644 --- a/typings/centrifuge/__init__.pyi +++ b/typings/centrifuge/__init__.pyi @@ -55,6 +55,7 @@ class Client: /, events: object | None = None, *, + data: object | None = None, recoverable: bool = False, join_leave: bool = False, ) -> Subscription: ... From 58b4f2c3ec8a06d7bc13a39599e6579b82068b4e Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Wed, 14 Oct 2026 13:19:50 -0700 Subject: [PATCH 02/28] fix(durable): catch up when a message broadcast shows a missed publish Co-authored-by: Cursor --- README.md | 4 +- docs/functions.md | 2 +- src/volcano_sdk/_durable_message_response.py | 11 ++ src/volcano_sdk/_durable_message_stream.py | 21 ++- src/volcano_sdk/_durable_messages.py | 14 +- .../_tests/test_durable_messages.py | 120 ++++++++++++++++-- 6 files changed, 151 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 343c0e01..e32144fc 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,9 @@ subscription = await client.durable.messages.subscribe( `subscribe()` delivers what the execution publishes in `seq` order, once each, including what it published before the call, and needs a signed-in session for -the realtime connection. A send to an execution that has ended raises +the realtime connection. A client holding only an anonymous or service key gets +`RuntimeError`, even with the `client_token`; it can poll `list()` with `after` +instead. A send to an execution that has ended raises `ConflictError` with `code` set to `execution_not_running`. Pass `project_id` to use the owner's project route instead. See the [functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#send-and-follow-messages). diff --git a/docs/functions.md b/docs/functions.md index 6aa05c25..d2f32c5b 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -290,7 +290,7 @@ for published in page.messages: A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the execution has seen returns the first message and sends nothing. The key is up to 255 characters. -`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order and once each. The messages published before the call are delivered before it returns, and after a reconnect the ones published meanwhile are read back before live delivery resumes. It follows the execution on the client's realtime connection, which needs a signed-in session. One client subscribes to an execution once at a time: +`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order and once each. The messages published before the call are delivered before it returns. After a reconnect, or a live message that shows an earlier one was lost, the missing ones are read back before live delivery resumes. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: ```python async def follow(client, execution): diff --git a/src/volcano_sdk/_durable_message_response.py b/src/volcano_sdk/_durable_message_response.py index 063d6862..e890d85a 100644 --- a/src/volcano_sdk/_durable_message_response.py +++ b/src/volcano_sdk/_durable_message_response.py @@ -117,6 +117,17 @@ def durable_message(payload: object) -> DurableMessage: ) +def broadcast_prev_seq(payload: object) -> int: + """Read the `seq` a broadcast says the execution published before it. + + Returns: + That `seq`, or 0, naming no gap, when the broadcast has none. + + """ + prev_seq = payload.get("prev_seq") if _is_mapping(payload) else None + return prev_seq if type(prev_seq) is int else 0 + + def durable_message_page(payload: object) -> DurableMessagePage: """Validate and snapshot a page of messages. diff --git a/src/volcano_sdk/_durable_message_stream.py b/src/volcano_sdk/_durable_message_stream.py index 7c4bf614..1b430d11 100644 --- a/src/volcano_sdk/_durable_message_stream.py +++ b/src/volcano_sdk/_durable_message_stream.py @@ -5,7 +5,7 @@ import asyncio from typing import TYPE_CHECKING -from ._durable_message_response import durable_message +from ._durable_message_response import broadcast_prev_seq, durable_message if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -39,6 +39,8 @@ class DurableMessageStream: The channel itself drops publications while it is not subscribed, and reports each resubscription, which starts a catch-up of what it dropped. + A broadcast naming a publish not yet delivered starts one too, since the + broadcast before it was lost or has yet to arrive. """ def __init__( @@ -65,12 +67,19 @@ async def live(self, data: object) -> None: if not self._started or self._catch_ups > 0: self._held.append(message) return + if broadcast_prev_seq(data) > self._last_seq: + self._held.append(message) + self._catch_up_in_background() + return await self._deliver(message) def resubscribed(self) -> None: """Catch up on what the channel dropped while it was not subscribed.""" - # A failed catch-up leaves the gap until the next one; live delivery - # goes on. + self._catch_up_in_background() + + def _catch_up_in_background(self) -> None: + # A failed catch-up leaves the gap until the next one; the held + # messages are still released and live delivery goes on. self.catch_up().add_done_callback(_report_failed_catch_up) def catch_up(self) -> asyncio.Task[None]: @@ -115,10 +124,12 @@ async def _read_missed(self) -> None: async def _read_pages(self) -> None: while not self._closed: - page = await self._list_after(self._last_seq) + after = self._last_seq + page = await self._list_after(after) for message in page.messages: await self._deliver(message) - if not page.has_more or not page.messages: + # A page that delivered nothing would be asked for again unchanged. + if not page.has_more or self._last_seq == after: return async def _release(self) -> None: diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index f4dbc7a1..f00b4d53 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -32,6 +32,10 @@ _INVALID_TRANSPORT = "Transport does not support durable messages" _NO_REALTIME = "subscribe() needs a VolcanoClient, which owns the realtime connection" +_NO_SESSION = ( + "subscribe() needs a signed-in session to open the realtime connection; " + "a client token or service key alone cannot" +) _ALREADY_SUBSCRIBED = ( "This client already subscribes to the execution's messages; unsubscribe first" ) @@ -269,7 +273,8 @@ async def subscribe( not callable raises `TypeError`. Follows the execution on the client's realtime connection, which needs - a signed-in session. + a signed-in session; a client token or service key alone cannot open + it. Returns ------- @@ -279,7 +284,8 @@ async def subscribe( Raises ------ RuntimeError - If this client already subscribes to the execution. + If the client has no signed-in session, or already subscribes to + the execution. """ known_options(options, _TARGET_OPTIONS) @@ -288,6 +294,10 @@ async def subscribe( realtime = self._realtime if realtime is None: raise RuntimeError(_NO_REALTIME) + try: + _ = self._client.session_token() + except RuntimeError as error: + raise RuntimeError(_NO_SESSION) from error list_after = self._reader(target) channel = realtime.durable_channel(target.execution_id, target.client_token) if channel is None: diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index 4faf90fb..5be4fb7b 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -110,10 +110,12 @@ def __init__(self) -> None: self.list_response: FakeResponse = FakeResponse( 200, {"data": [published(1), sent()], "has_more": False}, {} ) - # Answers subscription reads from the execution's published messages. + # Answers subscription reads from the execution's published messages, + # after any canned answers. Each canned answer is given once, so a + # read that goes on gets the published pages instead of spinning. self.published: list[int] = [] self.page_size: int = 100 - self.read_answer: FakeResponse | None = None + self.read_answers: list[FakeResponse] = [] self.next_read_gate: asyncio.Event | None = None self.read_arrivals: Arrivals = Arrivals() @@ -132,7 +134,11 @@ async def list_durable_messages_async(self, **kwargs: object) -> FakeResponse: # A catch-up read without its cursor would page from the start forever. assert request.after is not None # Read before waiting, as a list racing a publish would. - answer = self.read_answer or self.page_after(request.after) + answer = ( + self.read_answers.pop(0) + if self.read_answers + else self.page_after(request.after) + ) self.read_arrivals.arrive() gate, self.next_read_gate = self.next_read_gate, None if gate is not None: @@ -250,8 +256,10 @@ def subscription_of(native: FakeCentrifugeClient) -> FakeSubscription: async def dispatched(client: VolcanoClient) -> None: """Wait until the channel has handed its queued publications on.""" task = realtime_state(client.realtime).channels[CHANNEL].state.callback_task + # Not wait_for: cancelling the dispatcher from a handler it is running + # would leave both cancelling forever instead of failing the test. if task is not None: - await asyncio.wait_for(task, timeout=2) + _ = await asyncio.wait({task}, timeout=2) SEND_AND_LIST: dict[str, Callable[[DurableMessages], object]] = { @@ -1075,7 +1083,7 @@ async def test_subscribe_reads_every_page() -> None: async def test_an_empty_page_ends_the_read() -> None: transport = FakeMessageTransport() - transport.read_answer = FakeResponse(200, {"data": [], "has_more": True}, {}) + transport.read_answers = [FakeResponse(200, {"data": [], "has_more": True}, {})] client, _native = subscriber(transport) try: _ = await client.durable.messages.subscribe( @@ -1087,6 +1095,90 @@ async def test_an_empty_page_ends_the_read() -> None: await client.realtime.disconnect() +async def test_a_page_that_delivers_nothing_new_ends_the_read() -> None: + transport = FakeMessageTransport() + unchanged = FakeResponse(200, {"data": [published(1)], "has_more": True}, {}) + transport.read_answers = [unchanged, unchanged] + client, _native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + + assert received.seqs == [1] + assert transport.read_after() == [0, 1] + finally: + await client.realtime.disconnect() + + +async def test_a_broadcast_naming_a_missed_publish_reads_it_back() -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + + transport.published += [2, 3] + await channel.emit(published(3, prev_seq=2)) + assert await received.until(3) == [1, 2, 3] + await channel.emit(published(2, prev_seq=1)) + await channel.emit(published(4, prev_seq=3)) + await channel.emit(published(5, prev_seq="9")) + await channel.emit(published(6, prev_seq=True)) + await channel.emit(published(7)) + + assert await received.until(7) == [1, 2, 3, 4, 5, 6, 7] + assert transport.read_after() == [0, 1] + finally: + await client.realtime.disconnect() + + +async def test_a_first_broadcast_without_prev_seq_is_delivered_without_a_read() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + await subscription_of(native).emit(published(1)) + + assert await received.until(1) == [1] + assert transport.read_after() == [0] + finally: + await client.realtime.disconnect() + + +async def test_a_failed_read_after_a_gap_still_delivers_the_broadcast() -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + errors: list[dict[str, object]] = [] + reported = asyncio.Event() + loop = asyncio.get_running_loop() + previous = loop.get_exception_handler() + + def report(_loop: asyncio.AbstractEventLoop, context: dict[str, object]) -> None: + errors.append(context) + reported.set() + + loop.set_exception_handler(report) + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + transport.read_answers = [FakeResponse(500, {"error": "list failed"}, {})] + await subscription_of(native).emit(published(3, prev_seq=2)) + _ = await asyncio.wait_for(reported.wait(), timeout=2) + + assert received.seqs == [1, 3] + assert transport.read_after() == [0, 1] + assert [error["message"] for error in errors] == [ + "Volcano durable message catch-up failed" + ] + finally: + loop.set_exception_handler(previous) + await client.realtime.disconnect() + + async def test_unsubscribing_from_the_handler_stops_the_read() -> None: transport = FakeMessageTransport() transport.published = [1] @@ -1210,7 +1302,7 @@ async def test_an_old_subscription_leaves_a_replacement_alone() -> None: async def test_a_failed_first_read_fails_subscribe_and_leaves_the_channel() -> None: transport = FakeMessageTransport() - transport.read_answer = FakeResponse(404, {"error": "execution not found"}, {}) + transport.read_answers = [FakeResponse(404, {"error": "execution not found"}, {})] client, native = subscriber(transport) try: with pytest.raises(NotFoundError, match="execution not found"): @@ -1219,7 +1311,6 @@ async def test_a_failed_first_read_fails_subscribe_and_leaves_the_channel() -> N ) assert CHANNEL not in native.subscriptions - transport.read_answer = None _ = await client.durable.messages.subscribe( "checkout", EXECUTION_ID, Received() ) @@ -1246,21 +1337,26 @@ def fail(_message: DurableMessage) -> None: await client.realtime.disconnect() -async def test_subscribe_needs_a_signed_in_session() -> None: +@pytest.mark.parametrize("service_key", [None, "service-key"]) +async def test_subscribe_needs_a_signed_in_session(service_key: str | None) -> None: native = FakeCentrifugeClient() + transport = FakeMessageTransport() client = VolcanoClient( anon_key="anon-key", - _transport=FakeMessageTransport(), + service_key=service_key, + _transport=transport, _realtime_client_factory=FakeCentrifugeFactory(native), ) try: for _attempt in range(2): - with pytest.raises(RuntimeError, match="No active session"): + with pytest.raises(RuntimeError, match=r"^subscribe\(\) needs a signed-in"): _ = await client.durable.messages.subscribe( - "checkout", EXECUTION_ID, Received() + "checkout", EXECUTION_ID, Received(), client_token="client-token" ) assert CHANNEL not in realtime_state(client.realtime).channels + assert native.subscriptions == {} + assert transport.reads() == [] finally: await client.realtime.disconnect() @@ -1282,7 +1378,7 @@ def report(_loop: asyncio.AbstractEventLoop, context: dict[str, object]) -> None try: _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) channel = subscription_of(native) - transport.read_answer = FakeResponse(500, {"error": "list failed"}, {}) + transport.read_answers = [FakeResponse(500, {"error": "list failed"}, {})] await channel.emit_subscribing() await channel.emit_subscribed() _ = await asyncio.wait_for(reported.wait(), timeout=2) From 58f3ae14f452d7c052c0daa20b2b4b14061833c4 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 00:02:58 -0700 Subject: [PATCH 03/28] fix(durable): hold live messages past an unread gap and retry the catch-up Co-authored-by: Cursor --- src/volcano_sdk/_durable_message_stream.py | 123 +++++-- src/volcano_sdk/_durable_messages.py | 3 + src/volcano_sdk/_realtime_channel.py | 5 + src/volcano_sdk/_realtime_connection.py | 8 +- src/volcano_sdk/_realtime_durable.py | 7 +- .../_tests/fixtures/durable_messages.py | 5 +- .../_tests/test_durable_message.py | 5 +- .../_tests/test_durable_messages.py | 330 +++++++++++++++++- .../_transport_durable_messages.py | 7 +- src/volcano_sdk/realtime.py | 11 +- 10 files changed, 443 insertions(+), 61 deletions(-) diff --git a/src/volcano_sdk/_durable_message_stream.py b/src/volcano_sdk/_durable_message_stream.py index 1b430d11..eec04a37 100644 --- a/src/volcano_sdk/_durable_message_stream.py +++ b/src/volcano_sdk/_durable_message_stream.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +from bisect import insort +from dataclasses import dataclass from typing import TYPE_CHECKING from ._durable_message_response import broadcast_prev_seq, durable_message @@ -12,13 +14,24 @@ from .models import DurableMessage, DurableMessagePage +_FIRST_RETRY_SECONDS = 0.5 +_MAX_RETRY_SECONDS = 30.0 -def _published(data: object) -> DurableMessage | None: + +@dataclass(frozen=True) +class _Held: + message: DurableMessage + prev_seq: int + + +def _published(data: object) -> _Held | None: try: message = durable_message(data) except TypeError: return None - return message if message.direction == "out" else None + if message.direction != "out": + return None + return _Held(message, broadcast_prev_seq(data)) def _report_failed_catch_up(task: asyncio.Task[None]) -> None: @@ -29,18 +42,34 @@ def _report_failed_catch_up(task: asyncio.Task[None]) -> None: ) +def retry_delay(failures: int) -> float: + """Wait half a second after the first failure, doubling to 30 seconds. + + Returns + ------- + float + Seconds to wait before the next catch-up. + + """ + delay = _FIRST_RETRY_SECONDS + for _ in range(failures): + delay = min(delay * 2, _MAX_RETRY_SECONDS) + return delay + + class DurableMessageStream: """Deliver messages in `seq` order, once each, from the channel and the list. - A live message can only be delivered once everything before it has been - listed, or the missed ones would fall below the last delivered `seq` and be - skipped. So live messages are held until the first catch-up starts, and - while any catch-up is reading, then released in order. + A live message can only be delivered once everything before it has been, + or the missed ones would fall below the last delivered `seq` and be + skipped. Each broadcast names the publish before it, so a live message is + held, with that name, until the first catch-up starts, while any catch-up + is reading, while it does not follow the last delivered `seq`, and behind + any message already held. A catch-up that fails, or that leaves a held + message still short of its predecessor, is tried again until one reads it. The channel itself drops publications while it is not subscribed, and reports each resubscription, which starts a catch-up of what it dropped. - A broadcast naming a publish not yet delivered starts one too, since the - broadcast before it was lost or has yet to arrive. """ def __init__( @@ -54,36 +83,40 @@ def __init__( self._last_seq: int = 0 self._started: bool = False self._catch_ups: int = 0 - self._held: list[DurableMessage] = [] + self._held: list[_Held] = [] + self._failures: int = 0 + self._retry: asyncio.TimerHandle | None = None self._closed: bool = False self._reads: asyncio.Lock = asyncio.Lock() self._tasks: set[asyncio.Task[None]] = set() async def live(self, data: object) -> None: """Deliver a channel publication, or hold it until a catch-up releases it.""" - message = _published(data) - if message is None: + held = _published(data) + if held is None: return - if not self._started or self._catch_ups > 0: - self._held.append(message) + if not self._started or self._catch_ups > 0 or self._held: + self._hold(held) return - if broadcast_prev_seq(data) > self._last_seq: - self._held.append(message) + if held.prev_seq > self._last_seq: + self._hold(held) self._catch_up_in_background() return - await self._deliver(message) + await self._deliver(held.message) + + def _hold(self, held: _Held) -> None: + insort(self._held, held, key=lambda each: each.message.seq) def resubscribed(self) -> None: """Catch up on what the channel dropped while it was not subscribed.""" self._catch_up_in_background() def _catch_up_in_background(self) -> None: - # A failed catch-up leaves the gap until the next one; the held - # messages are still released and live delivery goes on. + # The stream retries a failed catch-up itself; each failure is reported. self.catch_up().add_done_callback(_report_failed_catch_up) def catch_up(self) -> asyncio.Task[None]: - """Read what was missed, then release the held messages. + """Read what was missed, then release the held messages that follow on. Counted before the task starts, so nothing is delivered live while a read waits its turn. @@ -95,6 +128,7 @@ def catch_up(self) -> asyncio.Task[None]: """ self._started = True + self._cancel_retry() self._catch_ups += 1 task = asyncio.create_task(self._read_missed()) self._tasks.add(task) @@ -107,21 +141,27 @@ def close(self) -> None: Not cancelled: the handler a catch-up is running may be what closed it. """ self._closed = True + self._cancel_retry() async def _read_missed(self) -> None: try: async with self._reads: try: await self._read_pages() - finally: - # Released by the last catch-up only, and while still - # counted, so a live message that arrives meanwhile is - # held behind the ones being released. - if self._catch_ups == 1: - await self._release() + except BaseException: + await self._release_if_last(read=False) + raise + await self._release_if_last(read=True) finally: self._catch_ups -= 1 + async def _release_if_last(self, *, read: bool) -> None: + # Released by the last catch-up only, and while still counted, so a + # live message that arrives meanwhile is held behind the ones being + # released. + if self._catch_ups == 1: + await self._release(read=read) + async def _read_pages(self) -> None: while not self._closed: after = self._last_seq @@ -132,12 +172,33 @@ async def _read_pages(self) -> None: if not page.has_more or self._last_seq == after: return - async def _release(self) -> None: - while self._held: - held = sorted(self._held, key=lambda message: message.seq) - self._held = [] - for message in held: - await self._deliver(message) + async def _release(self, *, read: bool) -> None: + # One a handler raises on is already delivered, so a retry resumes + # after it. + try: + while self._held and self._held[0].prev_seq <= self._last_seq: + await self._deliver(self._held.pop(0).message) + finally: + if read and not self._held: + self._failures = 0 + else: + self._retry_later() + + def _retry_later(self) -> None: + if self._closed: + return + delay = retry_delay(self._failures) + self._failures += 1 + self._retry = asyncio.get_running_loop().call_later(delay, self._retry_now) + + def _retry_now(self) -> None: + self._retry = None + self._catch_up_in_background() + + def _cancel_retry(self) -> None: + if self._retry is not None: + self._retry.cancel() + self._retry = None async def _deliver(self, message: DurableMessage) -> None: if self._closed or message.seq <= self._last_seq: diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index f00b4d53..30f54ff7 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -102,6 +102,8 @@ class DurableChannel(Protocol): # Called each time the channel is subscribed again after a drop. resubscribed: Callable[[], None] | None + # Called when the client's realtime facade removes or disconnects it. + ended: Callable[[], None] | None @property def name(self) -> str: @@ -331,6 +333,7 @@ async def _follow( ) -> DurableMessageSubscription: subscription = DurableMessageSubscription(stream, channel, realtime) _ = channel.on("message", stream.live) + channel.ended = stream.close try: await channel.subscribe() channel.resubscribed = stream.resubscribed diff --git a/src/volcano_sdk/_realtime_channel.py b/src/volcano_sdk/_realtime_channel.py index 20a964d7..193ef025 100644 --- a/src/volcano_sdk/_realtime_channel.py +++ b/src/volcano_sdk/_realtime_channel.py @@ -199,6 +199,9 @@ def __init__( # Called each time the subscription is established once it is set, # after the subscribed state and callbacks are in place. self.resubscribed: Callable[[], None] | None = None + # Called when removal or disconnect resets the channel, which then + # subscribes again only when asked to. + self.ended: Callable[[], None] | None = None self.callbacks: dict[str, list[DynamicCallback]] = {} self.presence_state: dict[str, RealtimePresenceInfo] = {} self.presence_events: list[tuple[str, RealtimePresenceInfo]] = [] @@ -638,6 +641,8 @@ async def _run_callback( await result async def reset(self) -> None: + if self.ended is not None: + self.ended() self.invalidate() await self._end_postgres_epoch() await self.presence.cancel_presence_sync() diff --git a/src/volcano_sdk/_realtime_connection.py b/src/volcano_sdk/_realtime_connection.py index dcca6456..3d5d4bd5 100644 --- a/src/volcano_sdk/_realtime_connection.py +++ b/src/volcano_sdk/_realtime_connection.py @@ -384,12 +384,16 @@ async def remove_channel( name: str, *, channel_type: ChannelKind = "broadcast", + current: object | None = None, ) -> None: - """Unsubscribe and forget one channel.""" + """Unsubscribe and forget one channel. + + Given `current`, it is left alone when another channel has replaced it. + """ wire_name = f"{channel_type}:{name}" async with self.connection_lock: entry = self.channels.get(wire_name) - if entry is None: + if entry is None or (current is not None and current is not entry.state): return channel = entry.state self.removing_channels.add(wire_name) diff --git a/src/volcano_sdk/_realtime_durable.py b/src/volcano_sdk/_realtime_durable.py index 94d80fd5..72fdc019 100644 --- a/src/volcano_sdk/_realtime_durable.py +++ b/src/volcano_sdk/_realtime_durable.py @@ -65,11 +65,10 @@ def durable_channel( async def remove_durable_channel(self, channel: DurableChannel) -> None: """Unsubscribe and forget a channel, unless another has replaced it.""" - entry = self._state.channels.get(channel.name) - if entry is None or entry.state is not channel: - return await self._state.remove_channel( - channel.name.removeprefix(_CHANNEL_PREFIX), channel_type="durable" + channel.name.removeprefix(_CHANNEL_PREFIX), + channel_type="durable", + current=channel, ) diff --git a/src/volcano_sdk/_tests/fixtures/durable_messages.py b/src/volcano_sdk/_tests/fixtures/durable_messages.py index b7c5c960..75b7b939 100644 --- a/src/volcano_sdk/_tests/fixtures/durable_messages.py +++ b/src/volcano_sdk/_tests/fixtures/durable_messages.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import threading from typing import TYPE_CHECKING @@ -23,8 +24,8 @@ SESSIONS = "/durable-messages/sessions" PUBLISH = "/durable-messages/publish" SESSION = "volcano:messages-session" -# sha256 of "token-1", the first token the fake hands out. -FIRST_TOKEN_DIGEST = "3f08aace122ee2368432c1ca23a049bc640bafbf00fdf33a52429f38ba12dbf9" +# Keys hash "token-1", the first token the fake hands out. +FIRST_TOKEN_DIGEST = hashlib.sha256(b"token-1").hexdigest() _INVOCATION_TIMEOUT_SECONDS = 30 Answer = tuple[str, bytes] diff --git a/src/volcano_sdk/_tests/test_durable_message.py b/src/volcano_sdk/_tests/test_durable_message.py index 692aaab5..fbcd6ff6 100644 --- a/src/volcano_sdk/_tests/test_durable_message.py +++ b/src/volcano_sdk/_tests/test_durable_message.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json from typing import TYPE_CHECKING, Protocol @@ -30,8 +31,8 @@ API_URL = "https://api.volcano.test" TOKEN = "session-token" -# sha256("session-token"), as every SDK keys a publish. -TOKEN_DIGEST = "c101e911469c969171040b50d70543313cf968fdef5bacc780776f8fb399ab36" +# Every SDK keys a publish with the session token's SHA-256. +TOKEN_DIGEST = hashlib.sha256(TOKEN.encode()).hexdigest() UNREADABLE_MESSAGE = ( "Volcano resumed the message wait with a message this SDK cannot read" ) diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index 5be4fb7b..30946e27 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -27,6 +27,8 @@ durable_message, durable_message_page, ) +from volcano_sdk._durable_message_stream import retry_delay +from volcano_sdk._realtime_durable import DurableChannels from volcano_sdk._transport import ( DurableMessageListRequest, DurableMessageSendRequest, @@ -50,7 +52,7 @@ from .transport_fixtures import RejectingTransport if TYPE_CHECKING: - from collections.abc import Callable, Mapping + from collections.abc import AsyncIterator, Callable, Mapping from volcano_sdk._durable_messages import DurableMessages @@ -591,6 +593,20 @@ def test_send_goes_over_the_application_route() -> None: } +def test_send_accepts_the_data_of_a_message_it_read() -> None: + client, requests = wire_client(lambda _r: httpx.Response(201, json=sent())) + received = durable_message(sent()) + + _ = client.durable.messages.send( + "checkout", EXECUTION_ID, "address-confirmed", received.data + ) + + assert json.loads(requests[0].content) == { + "name": "address-confirmed", + "data": {"street": "Main St", "lines": [1, 2]}, + } + + def test_send_goes_over_the_project_route() -> None: client, requests = wire_client( lambda _r: httpx.Response(200, json=sent()), session=True @@ -1148,34 +1164,316 @@ async def test_a_first_broadcast_without_prev_seq_is_delivered_without_a_read() await client.realtime.disconnect() -async def test_a_failed_read_after_a_gap_still_delivers_the_broadcast() -> None: +class Retries: + """Stand in for the retry backoff, noting each call.""" + + def __init__(self) -> None: + self.failures: list[int] = [] + self.delay: float = 0.0 + + def __call__(self, failures: int) -> float: + self.failures.append(failures) + return self.delay + + +@pytest.fixture +def retries(monkeypatch: pytest.MonkeyPatch) -> Retries: + fake = Retries() + monkeypatch.setattr("volcano_sdk._durable_message_stream.retry_delay", fake) + return fake + + +class Reports: + """Collect what the event loop's exception handler is given.""" + + def __init__(self) -> None: + self.contexts: list[dict[str, object]] = [] + self.arrivals: Arrivals = Arrivals() + + def __call__( + self, _loop: asyncio.AbstractEventLoop, context: dict[str, object] + ) -> None: + self.contexts.append(context) + self.arrivals.arrive() + + @property + def messages(self) -> list[object]: + return [context["message"] for context in self.contexts] + + +@pytest.fixture +async def reports() -> AsyncIterator[Reports]: + loop = asyncio.get_running_loop() + previous = loop.get_exception_handler() + fake = Reports() + loop.set_exception_handler(fake) + try: + yield fake + finally: + # Deliver queued loop callbacks before restoring the test handler. + await asyncio.sleep(0) + loop.set_exception_handler(previous) + + +def test_retries_back_off_from_half_a_second_to_thirty() -> None: + assert [retry_delay(failures) for failures in range(9)] == [ + 0.5, + 1.0, + 2.0, + 4.0, + 8.0, + 16.0, + 30.0, + 30.0, + 30.0, + ] + + +async def test_a_failed_read_after_a_gap_holds_the_broadcast_until_a_retry( + retries: Retries, reports: Reports +) -> None: transport = FakeMessageTransport() transport.published = [1] client, native = subscriber(transport) received = Received() - errors: list[dict[str, object]] = [] - reported = asyncio.Event() - loop = asyncio.get_running_loop() - previous = loop.get_exception_handler() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + transport.read_answers = [FakeResponse(500, {"error": "list failed"}, {})] + transport.published += [2, 3] + await channel.emit(published(3, prev_seq=2)) + await channel.emit(published(4)) - def report(_loop: asyncio.AbstractEventLoop, context: dict[str, object]) -> None: - errors.append(context) - reported.set() + assert await received.until(4) == [1, 2, 3, 4] + assert transport.read_after() == [0, 1, 1] + assert retries.failures == [0] + assert reports.messages == ["Volcano durable message catch-up failed"] + assert isinstance(reports.contexts[0]["exception"], ServerError) + finally: + await client.realtime.disconnect() - loop.set_exception_handler(report) + +async def test_a_failed_read_still_releases_what_follows_on( + retries: Retries, reports: Reports +) -> None: + transport = FakeMessageTransport() + transport.published = [1] + gate = asyncio.Event() + client, native = subscriber(transport) + received = Received() + retries.delay = 60.0 try: _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) transport.read_answers = [FakeResponse(500, {"error": "list failed"}, {})] + transport.next_read_gate = gate + await channel.emit_subscribing() + await channel.emit_subscribed() + await transport.read_arrivals.wait_for(2) + await channel.emit(published(2, prev_seq=1)) + await channel.emit(published(4, prev_seq=3)) + await dispatched(client) + gate.set() + await reports.arrivals.wait_for(1) + + assert received.seqs == [1, 2] + assert retries.failures == [0] + assert reports.messages == ["Volcano durable message catch-up failed"] + finally: + await client.realtime.disconnect() + + +async def test_a_failed_read_with_nothing_held_is_retried( + retries: Retries, reports: Reports +) -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + transport.read_answers = [FakeResponse(500, {"error": "list failed"}, {})] + transport.published += [2] + await channel.emit_subscribing() + await channel.emit_subscribed() + + assert await received.until(2) == [1, 2] + assert transport.read_after() == [0, 1, 1] + assert retries.failures == [0] + assert reports.messages == ["Volcano durable message catch-up failed"] + finally: + await client.realtime.disconnect() + + +async def test_a_handler_raising_with_nothing_held_is_retried_after_it( + retries: Retries, reports: Reports +) -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + failure = RuntimeError("handler failed") + + def handle(message: DurableMessage) -> None: + received(message) + if message.seq == 2: + raise failure + + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, handle) + channel = subscription_of(native) + transport.published += [2, 3] + await channel.emit_subscribing() + await channel.emit_subscribed() + + assert await received.until(3) == [1, 2, 3] + assert transport.read_after() == [0, 1, 2] + assert reports.contexts[0]["exception"] is failure + assert retries.failures == [0] + finally: + await client.realtime.disconnect() + + +async def test_a_read_that_stops_short_of_a_held_broadcast_reads_again( + retries: Retries, +) -> None: + transport = FakeMessageTransport() + transport.published = [1] + gate = asyncio.Event() + transport.next_read_gate = gate + client, native = subscriber(transport) + received = Received() + try: + subscribing = asyncio.create_task( + client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + ) + await transport.read_arrivals.wait_for(1) + transport.published += [2, 3] await subscription_of(native).emit(published(3, prev_seq=2)) - _ = await asyncio.wait_for(reported.wait(), timeout=2) + await dispatched(client) + gate.set() + _ = await subscribing - assert received.seqs == [1, 3] + assert await received.until(3) == [1, 2, 3] assert transport.read_after() == [0, 1] - assert [error["message"] for error in errors] == [ - "Volcano durable message catch-up failed" - ] + assert retries.failures == [0] + finally: + await client.realtime.disconnect() + + +@pytest.mark.usefixtures("reports") +async def test_the_backoff_starts_over_once_a_read_catches_up( + retries: Retries, +) -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + failed = FakeResponse(500, {"error": "list failed"}, {}) + transport.read_answers = [failed, failed, failed] + transport.published += [2, 3, 4] + await channel.emit(published(4, prev_seq=3)) + assert await received.until(4) == [1, 2, 3, 4] + + transport.read_answers = [failed] + transport.published += [5, 6] + await channel.emit(published(6, prev_seq=5)) + assert await received.until(6) == [1, 2, 3, 4, 5, 6] + + assert retries.failures == [0, 1, 2, 0] + finally: + await client.realtime.disconnect() + + +async def test_a_later_read_resumes_after_the_message_a_handler_raised_on( + retries: Retries, reports: Reports +) -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + failure = RuntimeError("handler failed") + + def handle(message: DurableMessage) -> None: + received(message) + if message.seq == 2: + raise failure + + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, handle) + transport.published += [2, 3, 4] + await subscription_of(native).emit(published(5, prev_seq=4)) + + assert await received.until(5) == [1, 2, 3, 4, 5] + assert transport.read_after() == [0, 1, 2] + assert reports.contexts[0]["exception"] is failure + assert retries.failures == [0] + finally: + await client.realtime.disconnect() + + +@pytest.mark.parametrize("end", ["remove_all_channels", "disconnect"]) +async def test_ending_the_realtime_channels_ends_the_subscription( + end: str, retries: Retries +) -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + gate = asyncio.Event() + transport.next_read_gate = gate + transport.published += [2] + await channel.emit(published(3, prev_seq=2)) + await transport.read_arrivals.wait_for(2) + await getattr(client.realtime, end)() + transport.published += [3] + gate.set() + await asyncio.sleep(0.05) + + assert received.seqs == [1] + assert transport.read_after() == [0, 1] + assert retries.failures == [] + finally: + await client.realtime.disconnect() + + +async def test_leaving_after_a_removal_spares_the_channel_that_replaced_it() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + state = realtime_state(client.realtime) + try: + stale = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + channel = subscription_of(native) + channel.unsubscribe_entered = asyncio.Event() + channel.unsubscribe_release = asyncio.Event() + removing = asyncio.create_task(client.realtime.remove_all_channels()) + _ = await asyncio.wait_for(channel.unsubscribe_entered.wait(), timeout=2) + + async def replace() -> object: + async with state.connection_lock: + return DurableChannels(state).durable_channel(EXECUTION_ID, None) + + replacing = asyncio.create_task(replace()) + await asyncio.sleep(0) + leaving = asyncio.create_task(stale.unsubscribe()) + await asyncio.sleep(0) + channel.unsubscribe_release.set() + await removing + replacement = await replacing + await leaving + + assert replacement is not None + assert state.channels[CHANNEL].state is replacement finally: - loop.set_exception_handler(previous) await client.realtime.disconnect() diff --git a/src/volcano_sdk/_transport_durable_messages.py b/src/volcano_sdk/_transport_durable_messages.py index fe969326..28930f24 100644 --- a/src/volcano_sdk/_transport_durable_messages.py +++ b/src/volcano_sdk/_transport_durable_messages.py @@ -14,7 +14,7 @@ from ._generated.models.send_durable_message_request import SendDurableMessageRequest from ._generated.types import UNSET, Unset from ._transport_base import TransportBase -from ._transport_response import parsed_response +from ._transport_response import parsed_response, plain_json if TYPE_CHECKING: from ._generated.client import AuthenticatedClient @@ -58,7 +58,10 @@ def send_durable_message( target: DurableMessageTarget, request: DurableMessageSendRequest, ) -> TransportResponse: - body = SendDurableMessageRequest(name=request.name, data=request.data) + # Data read from a message is frozen, which the request cannot encode. + body = SendDurableMessageRequest( + name=request.name, data=plain_json(request.data) + ) key = _unset(request.idempotency_key) execution_id = UUID(target.execution_id) with self._client(authorization) as client: diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index b11a8d2b..9a4c177b 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -274,9 +274,16 @@ async def remove_channel( ) async def remove_all_channels(self) -> None: - """Unsubscribe and forget every managed channel.""" + """Unsubscribe and forget every managed channel. + + Ends the client's durable message subscriptions too. + """ await self._state.remove_all_channels() async def disconnect(self) -> None: - """Disconnect and reset every channel managed by this facade.""" + """Disconnect and reset every channel managed by this facade. + + Ends the client's durable message subscriptions too; unsubscribe one + before subscribing to its execution again. + """ await self._state.disconnect() From eb52269ece19516ef4c31d55a7674188361a5007 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 00:03:17 -0700 Subject: [PATCH 04/28] docs(durable): describe catch-up retries, subscription teardown, and wait refusals Co-authored-by: Cursor --- docs/functions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index d2f32c5b..cf9e79a8 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -207,7 +207,7 @@ def handler(event, ctx): `publish(name, data=None)` returns a `PublishedMessage` with `id` and `seq`. `data` is any JSON value up to 64 KiB. Publishing is checkpointed, so a replay never publishes twice. The first publish in each context, and in each `ctx.child()`, `ctx.parallel()` branch, or `ctx.map()` item that publishes, opens a messages session, which appears in the execution's history as `volcano:messages-session` and costs three durable operations once. -Names follow the approval rules: up to 237 printable ASCII characters, checked before anything is recorded, as is the timeout. Both need `VOLCANO_PLATFORM_API_URL`. Volcano refusing a wait, for example with 100 waits already open, raises `CallbackSubmitterError` after the same 30 seconds of retries an approval gets. A refused publish fails its step without a retry, which fails the execution unless the handler catches it. +Names follow the approval rules: up to 237 printable ASCII characters, checked before anything is recorded, as is the timeout. Both need `VOLCANO_PLATFORM_API_URL`. A wait Volcano does not answer, cannot see yet, throttles, or fails on is retried for the same 30 seconds an approval gets, then raises `CallbackSubmitterError`. A wait Volcano refuses, for example with `too_many_message_waits` when 100 are already open, raises it at once. A refused publish fails its step without a retry, which fails the execution unless the handler catches it. ## Decide approvals from a backend @@ -290,7 +290,7 @@ for published in page.messages: A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the execution has seen returns the first message and sends nothing. The key is up to 255 characters. -`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order and once each. The messages published before the call are delivered before it returns. After a reconnect, or a live message that shows an earlier one was lost, the missing ones are read back before live delivery resumes. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: +`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order and once each. The messages published before the call are delivered before it returns. After a reconnect, or a live message that shows an earlier one was lost, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: ```python async def follow(client, execution): From 239817be96e9d8cf708d0bdac14e026ba5447e26 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 01:05:10 -0700 Subject: [PATCH 05/28] fix(durable): call a subscribe handler once at a time across live delivery and catch-ups Co-authored-by: Cursor --- docs/functions.md | 2 +- src/volcano_sdk/_durable_message_stream.py | 12 ++++--- .../_tests/test_durable_messages.py | 36 +++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index cf9e79a8..8f45864f 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -290,7 +290,7 @@ for published in page.messages: A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the execution has seen returns the first message and sends nothing. The key is up to 255 characters. -`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order and once each. The messages published before the call are delivered before it returns. After a reconnect, or a live message that shows an earlier one was lost, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: +`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. After a reconnect, or a live message that shows an earlier one was lost, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: ```python async def follow(client, execution): diff --git a/src/volcano_sdk/_durable_message_stream.py b/src/volcano_sdk/_durable_message_stream.py index eec04a37..afb342f6 100644 --- a/src/volcano_sdk/_durable_message_stream.py +++ b/src/volcano_sdk/_durable_message_stream.py @@ -88,6 +88,7 @@ def __init__( self._retry: asyncio.TimerHandle | None = None self._closed: bool = False self._reads: asyncio.Lock = asyncio.Lock() + self._handling: asyncio.Lock = asyncio.Lock() self._tasks: set[asyncio.Task[None]] = set() async def live(self, data: object) -> None: @@ -201,7 +202,10 @@ def _cancel_retry(self) -> None: self._retry = None async def _deliver(self, message: DurableMessage) -> None: - if self._closed or message.seq <= self._last_seq: - return - self._last_seq = message.seq - await self._handler(message) + # Live messages and catch-ups are delivered from different tasks, so a + # catch-up waits for a handler still running on a live message. + async with self._handling: + if self._closed or message.seq <= self._last_seq: + return + self._last_seq = message.seq + await self._handler(message) diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index 30946e27..f50d87fe 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -1082,6 +1082,42 @@ async def handle(message: DurableMessage) -> None: await client.realtime.disconnect() +async def test_a_read_waits_for_the_handler_still_running_on_a_live_message() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + calls: list[tuple[str, int]] = [] + handling = asyncio.Event() + finish = asyncio.Event() + second = asyncio.Event() + + async def handle(message: DurableMessage) -> None: + calls.append(("start", message.seq)) + if message.seq == 1: + handling.set() + _ = await finish.wait() + calls.append(("end", message.seq)) + if message.seq == 2: + second.set() + + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, handle) + channel = subscription_of(native) + await channel.emit(published(1)) + _ = await handling.wait() + await channel.emit_subscribing() + transport.published += [1, 2] + await channel.emit_subscribed() + await transport.read_arrivals.wait_for(2) + await asyncio.sleep(0) + + assert calls == [("start", 1)] + finish.set() + _ = await second.wait() + assert calls == [("start", 1), ("end", 1), ("start", 2), ("end", 2)] + finally: + await client.realtime.disconnect() + + async def test_subscribe_reads_every_page() -> None: transport = FakeMessageTransport() transport.published = [1, 2, 3] From 935cbaa4b55b9dc0159da96ce8dee4428a22e04c Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 02:45:42 -0700 Subject: [PATCH 06/28] fix(durable): bind message subscriptions to their session and recover dropped publications End a subscription once the client signs out, signs in again, or sets another session, before any further read or delivery, as Postgres delivery does. Catch up when a full callback queue drops a publication, coalescing catch-ups requested while one waits its turn, and follow the execution's channel under its canonical UUID. Co-authored-by: Cursor --- docs/functions.md | 2 +- src/volcano_sdk/_durable_arguments.py | 14 +- src/volcano_sdk/_durable_message_stream.py | 35 ++- src/volcano_sdk/_durable_messages.py | 18 +- src/volcano_sdk/_realtime_channel.py | 4 + src/volcano_sdk/_realtime_durable.py | 22 ++ .../_tests/test_durable_messages.py | 241 ++++++++++++++++++ 7 files changed, 320 insertions(+), 16 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index 8f45864f..a50ac5ec 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -290,7 +290,7 @@ for published in page.messages: A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the execution has seen returns the first message and sends nothing. The key is up to 255 characters. -`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. After a reconnect, or a live message that shows an earlier one was lost, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: +`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: ```python async def follow(client, execution): diff --git a/src/volcano_sdk/_durable_arguments.py b/src/volcano_sdk/_durable_arguments.py index afb81195..869c4e90 100644 --- a/src/volcano_sdk/_durable_arguments.py +++ b/src/volcano_sdk/_durable_arguments.py @@ -35,7 +35,7 @@ def identifier(value: object, field: str) -> str: Returns ------- str - The trimmed identifier, preserving its UUID spelling. + The trimmed identifier, with a UUID in its canonical form. Raises ------ @@ -46,12 +46,12 @@ def identifier(value: object, field: str) -> str: if not isinstance(value, str) or not value.strip(): raise ValueError(_INVALID_IDENTIFIERS[field]) trimmed = value.strip() - if field in _UUID_IDENTIFIERS: - try: - _ = UUID(trimmed) - except ValueError as exc: - raise ValueError(_UUID_IDENTIFIERS[field]) from exc - return trimmed + if field not in _UUID_IDENTIFIERS: + return trimmed + try: + return str(UUID(trimmed)) + except ValueError as exc: + raise ValueError(_UUID_IDENTIFIERS[field]) from exc def known_options(options: Mapping[str, object], known: frozenset[str]) -> None: diff --git a/src/volcano_sdk/_durable_message_stream.py b/src/volcano_sdk/_durable_message_stream.py index afb342f6..304cc05a 100644 --- a/src/volcano_sdk/_durable_message_stream.py +++ b/src/volcano_sdk/_durable_message_stream.py @@ -16,6 +16,10 @@ _FIRST_RETRY_SECONDS = 0.5 _MAX_RETRY_SECONDS = 30.0 +_SESSION_CHANGED = ( + "The session that subscribed to the execution's messages has changed; " + "the subscription has ended" +) @dataclass(frozen=True) @@ -68,18 +72,26 @@ class DurableMessageStream: any message already held. A catch-up that fails, or that leaves a held message still short of its predecessor, is tried again until one reads it. - The channel itself drops publications while it is not subscribed, and - reports each resubscription, which starts a catch-up of what it dropped. + The channel itself drops publications while it is not subscribed, or + while its callback queue is full, and reports each resubscription and + each drop, which start a catch-up of what it dropped. """ def __init__( self, deliver: Callable[[DurableMessage], Awaitable[None]], list_after: Callable[[int], Awaitable[DurableMessagePage]], + in_session: Callable[[], bool], ) -> None: - """Deliver through `deliver`, reading missed messages with `list_after`.""" + """Deliver through `deliver`, reading missed messages with `list_after`. + + `in_session` says whether the session that subscribed is still the + client's. Once it is not, the stream closes rather than read or + deliver another user's messages. + """ self._handler: Callable[[DurableMessage], Awaitable[None]] = deliver self._list_after: Callable[[int], Awaitable[DurableMessagePage]] = list_after + self._in_session: Callable[[], bool] = in_session self._last_seq: int = 0 self._started: bool = False self._catch_ups: int = 0 @@ -112,7 +124,17 @@ def resubscribed(self) -> None: """Catch up on what the channel dropped while it was not subscribed.""" self._catch_up_in_background() + def dropped(self) -> None: + """Catch up on a publication the channel's full callback queue dropped.""" + self._catch_up_in_background() + def _catch_up_in_background(self) -> None: + # One catch-up waiting its turn reads whatever more are asked for + # meanwhile, so a burst of dropped publications reads once. Of those + # counted, only the one reading holds `_reads`. + reading = 1 if self._reads.locked() else 0 + if self._catch_ups > reading: + return # The stream retries a failed catch-up itself; each failure is reported. self.catch_up().add_done_callback(_report_failed_catch_up) @@ -165,6 +187,7 @@ async def _release_if_last(self, *, read: bool) -> None: async def _read_pages(self) -> None: while not self._closed: + self._require_session() after = self._last_seq page = await self._list_after(after) for message in page.messages: @@ -207,5 +230,11 @@ async def _deliver(self, message: DurableMessage) -> None: async with self._handling: if self._closed or message.seq <= self._last_seq: return + self._require_session() self._last_seq = message.seq await self._handler(message) + + def _require_session(self) -> None: + if not self._in_session(): + self.close() + raise RuntimeError(_SESSION_CHANGED) diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index 30f54ff7..8e585de9 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -104,6 +104,8 @@ class DurableChannel(Protocol): resubscribed: Callable[[], None] | None # Called when the client's realtime facade removes or disconnects it. ended: Callable[[], None] | None + # Called each time a full callback queue drops a publication. + dropped: Callable[[], None] | None @property def name(self) -> str: @@ -132,6 +134,10 @@ async def remove_durable_channel(self, channel: DurableChannel) -> None: """Unsubscribe and forget the execution's channel.""" ... + def session_check(self) -> Callable[[], bool]: + """Return whether the client still has the session signed in now.""" + ... + class DurableMessageSubscription: """Messages an execution publishes, delivered until `unsubscribe`.""" @@ -276,7 +282,8 @@ async def subscribe( Follows the execution on the client's realtime connection, which needs a signed-in session; a client token or service key alone cannot open - it. + it. Signing out, signing in again, or setting another session ends the + subscription; a token refresh does not. Returns ------- @@ -300,13 +307,13 @@ async def subscribe( _ = self._client.session_token() except RuntimeError as error: raise RuntimeError(_NO_SESSION) from error - list_after = self._reader(target) + stream = DurableMessageStream( + deliver, self._reader(target), realtime.session_check() + ) channel = realtime.durable_channel(target.execution_id, target.client_token) if channel is None: raise RuntimeError(_ALREADY_SUBSCRIBED) - return await _follow( - DurableMessageStream(deliver, list_after), channel, realtime - ) + return await _follow(stream, channel, realtime) def _reader( self, target: DurableMessageTarget @@ -337,6 +344,7 @@ async def _follow( try: await channel.subscribe() channel.resubscribed = stream.resubscribed + channel.dropped = stream.dropped await stream.catch_up() except BaseException: await subscription.unsubscribe() diff --git a/src/volcano_sdk/_realtime_channel.py b/src/volcano_sdk/_realtime_channel.py index 193ef025..a6eca7c6 100644 --- a/src/volcano_sdk/_realtime_channel.py +++ b/src/volcano_sdk/_realtime_channel.py @@ -202,6 +202,8 @@ def __init__( # Called when removal or disconnect resets the channel, which then # subscribes again only when asked to. self.ended: Callable[[], None] | None = None + # Called after a full callback queue drops a publication. + self.dropped: Callable[[], None] | None = None self.callbacks: dict[str, list[DynamicCallback]] = {} self.presence_state: dict[str, RealtimePresenceInfo] = {} self.presence_events: list[tuple[str, RealtimePresenceInfo]] = [] @@ -548,6 +550,8 @@ def _queue_callback(self, delivery: CallbackDelivery) -> bool: "channel": self.name, } ) + if self.dropped is not None: + self.dropped() return True def _start_callback_dispatcher(self) -> None: diff --git a/src/volcano_sdk/_realtime_durable.py b/src/volcano_sdk/_realtime_durable.py index 72fdc019..07b1b5e6 100644 --- a/src/volcano_sdk/_realtime_durable.py +++ b/src/volcano_sdk/_realtime_durable.py @@ -14,6 +14,8 @@ from .realtime import Channel, Realtime if TYPE_CHECKING: + from collections.abc import Callable + from ._durable_messages import DurableChannel from ._realtime_connection import RealtimeState @@ -63,6 +65,26 @@ def durable_channel( state.channels[wire_name] = ManagedChannel(channel, state.factory(channel)) return channel + def session_check(self) -> Callable[[], bool]: + """Bind to the session signed in now, as Postgres delivery does. + + Returns + ------- + Callable[[], bool] + Whether the client still has that session. Signing out, signing + in again, or setting another session ends it; refreshing its + token does not. + + """ + capture = self._state.client_context.capture_session_binding + _generation, lineage, _session = capture() + + def in_session() -> bool: + _generation, current, session = capture() + return session is not None and current is lineage + + return in_session + async def remove_durable_channel(self, channel: DurableChannel) -> None: """Unsubscribe and forget a channel, unless another has replaced it.""" await self._state.remove_channel( diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index f50d87fe..22dc7436 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -24,6 +24,7 @@ VolcanoClient, ) from volcano_sdk._durable_message_response import ( + broadcast_prev_seq, durable_message, durable_message_page, ) @@ -37,6 +38,7 @@ ) from volcano_sdk.durable import Durable +from .client_inspection import InspectedClient from .fixtures.invalid_arguments import ( assign_snapshot_value, durable_realtime_channel, @@ -844,6 +846,25 @@ def test_a_page_must_be_complete(payload: object) -> None: _ = durable_message_page(payload) +@pytest.mark.parametrize( + ("payload", "prev_seq"), + [ + ({"prev_seq": 4}, 4), + ({"prev_seq": 0}, 0), + ({}, 0), + ({"prev_seq": "4"}, 0), + ({"prev_seq": True}, 0), + ({"prev_seq": 4.0}, 0), + ([("prev_seq", 4)], 0), + (None, 0), + ], +) +def test_a_broadcast_names_the_publish_before_it_only_as_an_integer( + payload: object, prev_seq: int +) -> None: + assert broadcast_prev_seq(payload) == prev_seq + + async def test_subscribe_delivers_what_was_published_then_live_messages() -> None: transport = FakeMessageTransport() transport.published = [1, 2] @@ -1053,6 +1074,33 @@ async def test_an_earlier_read_does_not_release_messages_a_later_one_must_preced await client.realtime.disconnect() +async def test_resubscriptions_during_a_read_wait_for_one_more_read() -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + gate = asyncio.Event() + transport.next_read_gate = gate + await channel.emit_subscribing() + await channel.emit_subscribed() + await transport.read_arrivals.wait_for(2) + + for _resubscription in range(2): + await channel.emit_subscribing() + await channel.emit_subscribed() + transport.published += [2] + await channel.emit(published(3, prev_seq=2)) + gate.set() + + assert await received.until(3) == [1, 2, 3] + assert transport.read_after() == [0, 1, 1] + finally: + await client.realtime.disconnect() + + async def test_a_message_arriving_during_release_follows_the_held_ones() -> None: transport = FakeMessageTransport() transport.published = [1] @@ -1452,6 +1500,179 @@ def handle(message: DurableMessage) -> None: await client.realtime.disconnect() +async def test_publications_a_full_callback_queue_drops_are_read_back_once( + reports: Reports, +) -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + received = Received() + handling = asyncio.Event() + finish = asyncio.Event() + + async def handle(message: DurableMessage) -> None: + received(message) + if message.seq == 1: + handling.set() + _ = await finish.wait() + + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, handle) + channel = subscription_of(native) + await channel.emit(published(1)) + _ = await handling.wait() + state = realtime_state(client.realtime).channels[CHANNEL].state + # The queue is empty while the handler runs; the last two overflow it. + last = state.callback_queue.maxsize + 3 + transport.published = list(range(1, last + 1)) + transport.page_size = last + for seq in range(2, last + 1): + await channel.emit(published(seq)) + await transport.read_arrivals.wait_for(2) + finish.set() + + assert await received.until(last) == list(range(1, last + 1)) + await dispatched(client) + # Waits behind a second catch-up, if one were started. + await channel.emit(published(last + 1, prev_seq=last)) + assert await received.until(last + 1) == list(range(1, last + 2)) + assert transport.read_after() == [0, 1] + assert ( + reports.messages + == ["Volcano realtime callback queue is full; publication dropped"] * 2 + ) + finally: + await client.realtime.disconnect() + + +def test_a_subscription_s_session_outlives_only_a_token_refresh() -> None: + client = InspectedClient(anon_key="anon-key") + _ = client.auth.set_session(Session("access-token", "refresh-token", USER_ID)) + channels = DurableChannels(realtime_state(client.realtime)) + refreshed = channels.session_check() + generation, _session = client.capture_session() + assert client.set_session_if_current( + Session("access-2", "refresh-2", USER_ID), generation, event="TOKEN_REFRESHED" + ) + + assert refreshed() + + replaced = channels.session_check() + _ = client.auth.set_session(Session("access-3", "refresh-3", USER_ID)) + signed_out = channels.session_check() + generation, _session = client.capture_session() + assert client.clear_session_if_current(generation, event="SIGNED_OUT") + + assert not replaced() + assert not signed_out() + + +def other_user() -> Session: + return Session( + access_token="other-access-token", + refresh_token="other-refresh-token", + user_id="00000000-0000-4000-8000-0000000000b8", + ) + + +def session_changed(context: dict[str, object]) -> bool: + error = context["exception"] + return isinstance(error, RuntimeError) and "session that subscribed" in str(error) + + +async def test_a_read_the_session_changed_during_delivers_nothing_it_read( + retries: Retries, reports: Reports +) -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + gate = asyncio.Event() + transport.next_read_gate = gate + transport.published += [2] + await channel.emit_subscribing() + await channel.emit_subscribed() + await transport.read_arrivals.wait_for(2) + _ = client.auth.set_session(other_user()) + gate.set() + await reports.arrivals.wait_for(1) + await channel.emit(published(3, prev_seq=2)) + await dispatched(client) + + assert received.seqs == [1] + assert transport.read_after() == [0, 1] + assert reports.messages == ["Volcano durable message catch-up failed"] + assert session_changed(reports.contexts[0]) + assert retries.failures == [] + finally: + await client.realtime.disconnect() + + +async def test_a_session_change_stops_the_next_read( + retries: Retries, reports: Reports +) -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + try: + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + channel = subscription_of(native) + _ = client.auth.set_session(other_user()) + await channel.emit_subscribing() + await channel.emit_subscribed() + await reports.arrivals.wait_for(1) + + assert transport.read_after() == [0] + assert reports.messages == ["Volcano durable message catch-up failed"] + assert session_changed(reports.contexts[0]) + assert retries.failures == [] + finally: + await client.realtime.disconnect() + + +async def test_a_session_change_stops_live_delivery(reports: Reports) -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + _ = client.auth.set_session(other_user()) + await channel.emit(published(1)) + await channel.emit(published(2)) + await dispatched(client) + + assert received.seqs == [] + assert reports.messages == ["Volcano realtime callback failed"] + assert session_changed(reports.contexts[0]) + finally: + await client.realtime.disconnect() + + +async def test_a_session_change_during_the_first_read_fails_subscribe() -> None: + transport = FakeMessageTransport() + transport.published = [1] + gate = asyncio.Event() + transport.next_read_gate = gate + client, native = subscriber(transport) + try: + subscribing = asyncio.create_task( + client.durable.messages.subscribe("checkout", EXECUTION_ID, Received()) + ) + await transport.read_arrivals.wait_for(1) + _ = client.auth.set_session(other_user()) + gate.set() + + with pytest.raises(RuntimeError, match="session that subscribed"): + _ = await subscribing + assert CHANNEL not in native.subscriptions + finally: + await client.realtime.disconnect() + + @pytest.mark.parametrize("end", ["remove_all_channels", "disconnect"]) async def test_ending_the_realtime_channels_ends_the_subscription( end: str, retries: Retries @@ -1590,6 +1811,26 @@ async def test_one_client_subscribes_to_an_execution_once() -> None: await client.realtime.disconnect() +async def test_subscribe_follows_the_canonical_execution_id() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + try: + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID.upper(), Received() + ) + + assert list(native.subscriptions) == [CHANNEL] + assert transport.calls[0][1]["target"] == DurableMessageTarget( + function_id="checkout", execution_id=EXECUTION_ID + ) + with pytest.raises(RuntimeError, match="already subscribes"): + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID.replace("-", ""), Received() + ) + finally: + await client.realtime.disconnect() + + async def test_a_subscription_still_leaving_blocks_a_new_one() -> None: transport = FakeMessageTransport() client, native = subscriber(transport) From 6ac6cc8dd830db68aebdc9bdf9381f3d9d9320b1 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 03:55:20 -0700 Subject: [PATCH 07/28] fix(durable): end a subscription its first read cannot keep and report later handler errors A handler that raises during the first read, or a first read that fails, now closes the stream before anything held is delivered, and a stream closed while the first read runs fails subscribe(). Later handler errors are reported and delivery goes on; only failed reads back off. Live messages held during a catch-up are capped at 128, a retry replaces the one pending, and a closed stream holds nothing. Unbounded test waits now time out. Co-authored-by: Cursor --- docs/functions.md | 2 +- src/volcano_sdk/_durable_message_stream.py | 72 ++++-- .../_tests/test_durable_messages.py | 215 ++++++++++++++++-- 3 files changed, 253 insertions(+), 36 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index a50ac5ec..55f57d50 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -290,7 +290,7 @@ for published in page.messages: A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the execution has seen returns the first message and sends nothing. The key is up to 255 characters. -`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: +`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: ```python async def follow(client, execution): diff --git a/src/volcano_sdk/_durable_message_stream.py b/src/volcano_sdk/_durable_message_stream.py index 304cc05a..3f09f83d 100644 --- a/src/volcano_sdk/_durable_message_stream.py +++ b/src/volcano_sdk/_durable_message_stream.py @@ -16,10 +16,16 @@ _FIRST_RETRY_SECONDS = 0.5 _MAX_RETRY_SECONDS = 30.0 +# Live messages held past this are dropped, oldest first. Each one held after +# a dropped one names a predecessor at or past it, so it waits for a read that +# lists the dropped one. +_HELD_LIMIT = 128 _SESSION_CHANGED = ( "The session that subscribed to the execution's messages has changed; " "the subscription has ended" ) +_ENDED = "The durable message subscription has ended" +_HANDLER_FAILED = "Volcano durable message handler failed" @dataclass(frozen=True) @@ -106,7 +112,7 @@ def __init__( async def live(self, data: object) -> None: """Deliver a channel publication, or hold it until a catch-up releases it.""" held = _published(data) - if held is None: + if held is None or self._closed: return if not self._started or self._catch_ups > 0 or self._held: self._hold(held) @@ -115,10 +121,12 @@ async def live(self, data: object) -> None: self._hold(held) self._catch_up_in_background() return - await self._deliver(held.message) + await self._deliver(held.message, report=True) def _hold(self, held: _Held) -> None: insort(self._held, held, key=lambda each: each.message.seq) + if len(self._held) > _HELD_LIMIT: + del self._held[0] def resubscribed(self) -> None: """Catch up on what the channel dropped while it was not subscribed.""" @@ -136,24 +144,31 @@ def _catch_up_in_background(self) -> None: if self._catch_ups > reading: return # The stream retries a failed catch-up itself; each failure is reported. - self.catch_up().add_done_callback(_report_failed_catch_up) + self._start_catch_up(first=False).add_done_callback(_report_failed_catch_up) def catch_up(self) -> asyncio.Task[None]: """Read what was missed, then release the held messages that follow on. - Counted before the task starts, so nothing is delivered live while a - read waits its turn. + The first catch-up, which subscribing waits for. Should it fail, the + stream closes before anything held is delivered. Later catch-ups + report what a handler raises and go on. Returns ------- asyncio.Task[None] - The read, which fails with the list's or the handler's error. + The read, which fails with the list's or the handler's error, or + with `RuntimeError` when the stream closed while it read. """ + return self._start_catch_up(first=True) + + def _start_catch_up(self, *, first: bool) -> asyncio.Task[None]: + # Counted before the task starts, so nothing is delivered live while a + # read waits its turn. self._started = True self._cancel_retry() self._catch_ups += 1 - task = asyncio.create_task(self._read_missed()) + task = asyncio.create_task(self._read_missed(first=first)) self._tasks.add(task) task.add_done_callback(self._tasks.discard) return task @@ -164,44 +179,53 @@ def close(self) -> None: Not cancelled: the handler a catch-up is running may be what closed it. """ self._closed = True + self._held.clear() self._cancel_retry() - async def _read_missed(self) -> None: + async def _read_missed(self, *, first: bool) -> None: + # Past the first read, a handler that raises is reported and the read + # goes on, as live delivery does; backing off is for failed reads. + # The first read's handler errors end the subscription instead. + report = not first try: async with self._reads: try: - await self._read_pages() + await self._read_pages(report=report) except BaseException: - await self._release_if_last(read=False) + if first: + self.close() + await self._release_if_last(read=False, report=report) raise - await self._release_if_last(read=True) + await self._release_if_last(read=True, report=report) + if first and self._closed: + raise RuntimeError(_ENDED) finally: self._catch_ups -= 1 - async def _release_if_last(self, *, read: bool) -> None: + async def _release_if_last(self, *, read: bool, report: bool) -> None: # Released by the last catch-up only, and while still counted, so a # live message that arrives meanwhile is held behind the ones being # released. if self._catch_ups == 1: - await self._release(read=read) + await self._release(read=read, report=report) - async def _read_pages(self) -> None: + async def _read_pages(self, *, report: bool) -> None: while not self._closed: self._require_session() after = self._last_seq page = await self._list_after(after) for message in page.messages: - await self._deliver(message) + await self._deliver(message, report=report) # A page that delivered nothing would be asked for again unchanged. if not page.has_more or self._last_seq == after: return - async def _release(self, *, read: bool) -> None: + async def _release(self, *, read: bool, report: bool) -> None: # One a handler raises on is already delivered, so a retry resumes # after it. try: while self._held and self._held[0].prev_seq <= self._last_seq: - await self._deliver(self._held.pop(0).message) + await self._deliver(self._held.pop(0).message, report=report) finally: if read and not self._held: self._failures = 0 @@ -209,6 +233,7 @@ async def _release(self, *, read: bool) -> None: self._retry_later() def _retry_later(self) -> None: + self._cancel_retry() if self._closed: return delay = retry_delay(self._failures) @@ -224,7 +249,7 @@ def _cancel_retry(self) -> None: self._retry.cancel() self._retry = None - async def _deliver(self, message: DurableMessage) -> None: + async def _deliver(self, message: DurableMessage, *, report: bool) -> None: # Live messages and catch-ups are delivered from different tasks, so a # catch-up waits for a handler still running on a live message. async with self._handling: @@ -232,7 +257,16 @@ async def _deliver(self, message: DurableMessage) -> None: return self._require_session() self._last_seq = message.seq - await self._handler(message) + if not report: + await self._handler(message) + return + (error,) = await asyncio.gather( + self._handler(message), return_exceptions=True + ) + if isinstance(error, BaseException): + asyncio.get_running_loop().call_exception_handler( + {"message": _HANDLER_FAILED, "exception": error} + ) def _require_session(self) -> None: if not self._in_session(): diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index 22dc7436..f6cd1301 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -1142,7 +1142,7 @@ async def handle(message: DurableMessage) -> None: calls.append(("start", message.seq)) if message.seq == 1: handling.set() - _ = await finish.wait() + _ = await asyncio.wait_for(finish.wait(), timeout=2) calls.append(("end", message.seq)) if message.seq == 2: second.set() @@ -1151,7 +1151,7 @@ async def handle(message: DurableMessage) -> None: _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, handle) channel = subscription_of(native) await channel.emit(published(1)) - _ = await handling.wait() + _ = await asyncio.wait_for(handling.wait(), timeout=2) await channel.emit_subscribing() transport.published += [1, 2] await channel.emit_subscribed() @@ -1160,7 +1160,7 @@ async def handle(message: DurableMessage) -> None: assert calls == [("start", 1)] finish.set() - _ = await second.wait() + _ = await asyncio.wait_for(second.wait(), timeout=2) assert calls == [("start", 1), ("end", 1), ("start", 2), ("end", 2)] finally: await client.realtime.disconnect() @@ -1254,10 +1254,11 @@ class Retries: def __init__(self) -> None: self.failures: list[int] = [] self.delay: float = 0.0 + self.delays: list[float] = [] def __call__(self, failures: int) -> float: self.failures.append(failures) - return self.delay + return self.delays.pop(0) if self.delays else self.delay @pytest.fixture @@ -1390,7 +1391,33 @@ async def test_a_failed_read_with_nothing_held_is_retried( await client.realtime.disconnect() -async def test_a_handler_raising_with_nothing_held_is_retried_after_it( +async def test_a_handler_raising_on_a_live_message_is_reported_and_delivery_goes_on( + reports: Reports, +) -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + received = Received() + failure = RuntimeError("handler failed") + + def handle(message: DurableMessage) -> None: + received(message) + if message.seq == 1: + raise failure + + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, handle) + channel = subscription_of(native) + await channel.emit(published(1)) + await channel.emit(published(2)) + + assert await received.until(2) == [1, 2] + assert reports.messages == ["Volcano durable message handler failed"] + assert reports.contexts[0]["exception"] is failure + finally: + await client.realtime.disconnect() + + +async def test_a_handler_raising_in_a_read_back_is_reported_and_the_read_goes_on( retries: Retries, reports: Reports ) -> None: transport = FakeMessageTransport() @@ -1412,9 +1439,10 @@ def handle(message: DurableMessage) -> None: await channel.emit_subscribed() assert await received.until(3) == [1, 2, 3] - assert transport.read_after() == [0, 1, 2] + assert transport.read_after() == [0, 1] + assert reports.messages == ["Volcano durable message handler failed"] assert reports.contexts[0]["exception"] is failure - assert retries.failures == [0] + assert retries.failures == [] finally: await client.realtime.disconnect() @@ -1473,29 +1501,76 @@ async def test_the_backoff_starts_over_once_a_read_catches_up( await client.realtime.disconnect() -async def test_a_later_read_resumes_after_the_message_a_handler_raised_on( +async def test_a_handler_raising_on_a_released_message_is_reported_and_the_rest_follow( retries: Retries, reports: Reports ) -> None: transport = FakeMessageTransport() transport.published = [1] + gate = asyncio.Event() client, native = subscriber(transport) received = Received() failure = RuntimeError("handler failed") def handle(message: DurableMessage) -> None: received(message) - if message.seq == 2: + if message.seq == 3: raise failure try: _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, handle) - transport.published += [2, 3, 4] - await subscription_of(native).emit(published(5, prev_seq=4)) + channel = subscription_of(native) + transport.next_read_gate = gate + transport.published += [2] + await channel.emit(published(3, prev_seq=2)) + await transport.read_arrivals.wait_for(2) + await channel.emit(published(4, prev_seq=3)) + await dispatched(client) + gate.set() - assert await received.until(5) == [1, 2, 3, 4, 5] - assert transport.read_after() == [0, 1, 2] + assert await received.until(4) == [1, 2, 3, 4] + assert transport.read_after() == [0, 1] + assert reports.messages == ["Volcano durable message handler failed"] assert reports.contexts[0]["exception"] is failure - assert retries.failures == [0] + assert retries.failures == [] + finally: + await client.realtime.disconnect() + + +async def test_a_retry_replaces_the_one_pending(retries: Retries) -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + seqs: list[int] = [] + releasing = asyncio.Event() + finish = asyncio.Event() + + async def handle(message: DurableMessage) -> None: + seqs.append(message.seq) + if message.seq == 3: + releasing.set() + _ = await asyncio.wait_for(finish.wait(), timeout=2) + + # The first retry would fire at once; the one replacing it never does here. + retries.delays = [0.01, 60.0] + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, handle) + channel = subscription_of(native) + transport.published += [2] + await channel.emit(published(3, prev_seq=2)) + await channel.emit(published(5, prev_seq=4)) + _ = await asyncio.wait_for(releasing.wait(), timeout=2) + # Starts a read that waits behind the release, so neither retry + # exists yet for it to cancel. + await channel.emit_subscribing() + await channel.emit_subscribed() + await dispatched(client) + finish.set() + await transport.read_arrivals.wait_for(3) + await asyncio.sleep(0.05) + + assert seqs == [1, 2, 3] + assert transport.read_after() == [0, 1, 3] + assert retries.failures == [0, 1] finally: await client.realtime.disconnect() @@ -1513,13 +1588,13 @@ async def handle(message: DurableMessage) -> None: received(message) if message.seq == 1: handling.set() - _ = await finish.wait() + _ = await asyncio.wait_for(finish.wait(), timeout=2) try: _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, handle) channel = subscription_of(native) await channel.emit(published(1)) - _ = await handling.wait() + _ = await asyncio.wait_for(handling.wait(), timeout=2) state = realtime_state(client.realtime).channels[CHANNEL].state # The queue is empty while the handler runs; the last two overflow it. last = state.callback_queue.maxsize + 3 @@ -1544,6 +1619,37 @@ async def handle(message: DurableMessage) -> None: await client.realtime.disconnect() +async def test_holding_past_the_limit_drops_the_oldest_for_a_read_to_list( + retries: Retries, +) -> None: + transport = FakeMessageTransport() + transport.published = [1] + gate = asyncio.Event() + transport.next_read_gate = gate + client, native = subscriber(transport) + received = Received() + try: + subscribing = asyncio.create_task( + client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + ) + await transport.read_arrivals.wait_for(1) + channel = subscription_of(native) + # One past the limit, the newest first. + for seq in [130, *range(2, 130)]: + await channel.emit(published(seq, prev_seq=seq - 1)) + await dispatched(client) + # Lists only the dropped one, so the rest can only come from those held. + transport.published = [1, 2] + gate.set() + _ = await subscribing + + assert await received.until(130) == list(range(1, 131)) + assert transport.read_after() == [0, 1] + assert retries.failures == [0] + finally: + await client.realtime.disconnect() + + def test_a_subscription_s_session_outlives_only_a_token_refresh() -> None: client = InspectedClient(anon_key="anon-key") _ = client.auth.set_session(Session("access-token", "refresh-token", USER_ID)) @@ -1912,6 +2018,83 @@ def fail(_message: DurableMessage) -> None: await client.realtime.disconnect() +async def test_a_handler_raising_in_the_first_read_delivers_nothing_it_held() -> None: + transport = FakeMessageTransport() + transport.published = [1] + gate = asyncio.Event() + transport.next_read_gate = gate + client, native = subscriber(transport) + received = Received() + failure = RuntimeError("handler failed") + + def fail(message: DurableMessage) -> None: + received(message) + raise failure + + try: + subscribing = asyncio.create_task( + client.durable.messages.subscribe("checkout", EXECUTION_ID, fail) + ) + await transport.read_arrivals.wait_for(1) + await subscription_of(native).emit(published(2, prev_seq=1)) + await dispatched(client) + gate.set() + with pytest.raises(RuntimeError) as caught: + _ = await subscribing + + assert caught.value is failure + assert received.seqs == [1] + finally: + await client.realtime.disconnect() + + +async def test_a_failed_first_read_delivers_nothing_it_held() -> None: + transport = FakeMessageTransport() + transport.read_answers = [FakeResponse(404, {"error": "execution not found"}, {})] + gate = asyncio.Event() + transport.next_read_gate = gate + client, native = subscriber(transport) + received = Received() + try: + subscribing = asyncio.create_task( + client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + ) + await transport.read_arrivals.wait_for(1) + await subscription_of(native).emit(published(1)) + await dispatched(client) + gate.set() + with pytest.raises(NotFoundError): + _ = await subscribing + + assert received.seqs == [] + finally: + await client.realtime.disconnect() + + +async def test_channels_removed_during_the_first_read_fail_subscribe() -> None: + transport = FakeMessageTransport() + transport.published = [1] + gate = asyncio.Event() + transport.next_read_gate = gate + client, native = subscriber(transport) + received = Received() + try: + subscribing = asyncio.create_task( + client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + ) + await transport.read_arrivals.wait_for(1) + await client.realtime.remove_all_channels() + gate.set() + + with pytest.raises(RuntimeError, match="subscription has ended"): + _ = await subscribing + assert received.seqs == [] + assert CHANNEL not in native.subscriptions + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + finally: + await client.realtime.disconnect() + + @pytest.mark.parametrize("service_key", [None, "service-key"]) async def test_subscribe_needs_a_signed_in_session(service_key: str | None) -> None: native = FakeCentrifugeClient() From 52f9f84cf99942f08c8029e742d69a86964b1f57 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 05:17:01 -0700 Subject: [PATCH 08/28] fix(durable): recheck the session after an empty read and leave the channel once it changes Co-authored-by: Cursor --- docs/functions.md | 2 +- src/volcano_sdk/_durable_message_stream.py | 18 ++++++++++-- src/volcano_sdk/_durable_messages.py | 3 +- .../_tests/test_durable_messages.py | 28 ++++++++++++++++--- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index 55f57d50..7b51cf73 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -290,7 +290,7 @@ for published in page.messages: A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the execution has seen returns the first message and sends nothing. The key is up to 255 characters. -`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: +`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery leaves the execution's channel and raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: ```python async def follow(client, execution): diff --git a/src/volcano_sdk/_durable_message_stream.py b/src/volcano_sdk/_durable_message_stream.py index 3f09f83d..8bc22ae3 100644 --- a/src/volcano_sdk/_durable_message_stream.py +++ b/src/volcano_sdk/_durable_message_stream.py @@ -98,6 +98,7 @@ def __init__( self._handler: Callable[[DurableMessage], Awaitable[None]] = deliver self._list_after: Callable[[int], Awaitable[DurableMessagePage]] = list_after self._in_session: Callable[[], bool] = in_session + self._leave: Callable[[], Awaitable[None]] | None = None self._last_seq: int = 0 self._started: bool = False self._catch_ups: int = 0 @@ -173,6 +174,13 @@ def _start_catch_up(self, *, first: bool) -> asyncio.Task[None]: task.add_done_callback(self._tasks.discard) return task + def end_with(self, leave: Callable[[], Awaitable[None]]) -> None: + """Call `leave` too once the session changes. + + Until then, leaving the channel is up to whoever subscribes. + """ + self._leave = leave + def close(self) -> None: """Deliver nothing more; a catch-up still reading stops after its page. @@ -211,9 +219,11 @@ async def _release_if_last(self, *, read: bool, report: bool) -> None: async def _read_pages(self, *, report: bool) -> None: while not self._closed: - self._require_session() + await self._require_session() after = self._last_seq page = await self._list_after(after) + # An empty page delivers nothing that would check it. + await self._require_session() for message in page.messages: await self._deliver(message, report=report) # A page that delivered nothing would be asked for again unchanged. @@ -255,7 +265,7 @@ async def _deliver(self, message: DurableMessage, *, report: bool) -> None: async with self._handling: if self._closed or message.seq <= self._last_seq: return - self._require_session() + await self._require_session() self._last_seq = message.seq if not report: await self._handler(message) @@ -268,7 +278,9 @@ async def _deliver(self, message: DurableMessage, *, report: bool) -> None: {"message": _HANDLER_FAILED, "exception": error} ) - def _require_session(self) -> None: + async def _require_session(self) -> None: if not self._in_session(): self.close() + if self._leave is not None: + await self._leave() raise RuntimeError(_SESSION_CHANGED) diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index 8e585de9..cd660a96 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -283,7 +283,7 @@ async def subscribe( Follows the execution on the client's realtime connection, which needs a signed-in session; a client token or service key alone cannot open it. Signing out, signing in again, or setting another session ends the - subscription; a token refresh does not. + subscription and leaves the channel; a token refresh does not. Returns ------- @@ -349,6 +349,7 @@ async def _follow( except BaseException: await subscription.unsubscribe() raise + stream.end_with(subscription.unsubscribe) return subscription diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index f6cd1301..2f9e9231 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -1704,14 +1704,13 @@ async def test_a_read_the_session_changed_during_delivers_nothing_it_read( _ = client.auth.set_session(other_user()) gate.set() await reports.arrivals.wait_for(1) - await channel.emit(published(3, prev_seq=2)) - await dispatched(client) assert received.seqs == [1] assert transport.read_after() == [0, 1] assert reports.messages == ["Volcano durable message catch-up failed"] assert session_changed(reports.contexts[0]) assert retries.failures == [] + assert CHANNEL not in native.subscriptions finally: await client.realtime.disconnect() @@ -1758,9 +1757,30 @@ async def test_a_session_change_stops_live_delivery(reports: Reports) -> None: await client.realtime.disconnect() -async def test_a_session_change_during_the_first_read_fails_subscribe() -> None: +async def test_a_session_change_leaves_the_channel(reports: Reports) -> None: transport = FakeMessageTransport() - transport.published = [1] + client, native = subscriber(transport) + try: + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + channel = subscription_of(native) + _ = client.auth.set_session(other_user()) + await channel.emit(published(1)) + await reports.arrivals.wait_for(1) + + assert CHANNEL not in native.subscriptions + assert session_changed(reports.contexts[0]) + finally: + await client.realtime.disconnect() + + +@pytest.mark.parametrize("listed", [[], [1]]) +async def test_a_session_change_during_the_first_read_fails_subscribe( + listed: list[int], +) -> None: + transport = FakeMessageTransport() + transport.published = listed gate = asyncio.Event() transport.next_read_gate = gate client, native = subscriber(transport) From deb84ea20b19ec65b0367dcb85e1edb1f9bb5c52 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 05:29:31 -0700 Subject: [PATCH 09/28] docs(durable): say idempotency keys belong to their sender Co-authored-by: Cursor --- docs/functions.md | 2 +- src/volcano_sdk/_durable_messages.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index 7b51cf73..2fe3ce63 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -288,7 +288,7 @@ for published in page.messages: | `list(function_name, execution_id, direction=..., name=..., after=..., limit=..., client_token=..., project_id=...)` | `DurableMessagePage` with `messages`, oldest first, and `has_more`. | | `await subscribe(function_name, execution_id, handler, client_token=..., project_id=...)` | A `DurableMessageSubscription` with `await unsubscribe()`. | -A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the execution has seen returns the first message and sends nothing. The key is up to 255 characters. +A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the same sender has used on the execution returns the first message and sends nothing; another sender's keys never collide with it. The key is up to 255 characters. `subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery leaves the execution's channel and raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index cd660a96..a7e452b6 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -203,9 +203,9 @@ def send( """Send a message to a running execution. It is queued until a `ctx.wait_for_message(name)` takes it. A send with - an `idempotency_key` the execution has seen returns the message the - first one stored, and sends nothing. A send to an execution that has - ended raises `ConflictError`. + an `idempotency_key` the same sender has used on the execution returns + the message the first one stored, and sends nothing. A send to an + execution that has ended raises `ConflictError`. Returns ------- From 8927b4eedf098751789beb63ed18b5629f11a5c0 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 05:59:51 -0700 Subject: [PATCH 10/28] fix(durable): check message names, client tokens, and list paging locally send() and list() now hold a message name to the 237 printable ASCII characters ctx.publish and ctx.wait_for_message accept, a client token to 128 characters, after to a non-negative integer, and limit to 1 to 500, so a request the server would refuse fails before it is sent. Co-authored-by: Cursor --- src/volcano_sdk/_durable_messages.py | 50 +++++++----- .../_tests/test_durable_messages.py | 80 ++++++++++++++++--- 2 files changed, 103 insertions(+), 27 deletions(-) diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index a7e452b6..9ed29bb7 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -7,6 +7,7 @@ from ._callbacks import require_callable from ._durable_arguments import identifier, known_options +from ._durable_callback import operation_name from ._durable_message_response import durable_message, durable_message_page from ._durable_message_stream import DurableMessageStream from ._transport import HTTP_CREATED, HTTP_OK, invoke, invoke_async, response_payload @@ -39,8 +40,10 @@ _ALREADY_SUBSCRIBED = ( "This client already subscribes to the execution's messages; unsubscribe first" ) -# The spec's maxLength on Idempotency-Key. +# The spec's bounds on Idempotency-Key, X-Volcano-Client-Token, and a page. _MAX_IDEMPOTENCY_KEY_LENGTH = 255 +_MAX_CLIENT_TOKEN_LENGTH = 128 +_MAX_LIST_LIMIT = 500 _TARGET_OPTIONS = frozenset({"client_token", "project_id"}) _SEND_OPTIONS = _TARGET_OPTIONS | {"idempotency_key"} _LIST_OPTIONS = _TARGET_OPTIONS | {"direction", "name", "after", "limit"} @@ -216,9 +219,13 @@ def send( known_options(options, _SEND_OPTIONS) target = _target(function_name, execution_id, options) request = DurableMessageSendRequest( - name=_message_name(name), + name=operation_name(name), data=data, - idempotency_key=_idempotency_key(options.get("idempotency_key")), + idempotency_key=_bounded_text( + options.get("idempotency_key"), + "idempotency_key", + _MAX_IDEMPOTENCY_KEY_LENGTH, + ), ) response = invoke( self._transport().send_durable_message, @@ -251,11 +258,12 @@ def list( known_options(options, _LIST_OPTIONS) target = _target(function_name, execution_id, options) direction = options.get("direction") + name = options.get("name") request = DurableMessageListRequest( direction=None if direction is None else _direction(direction), - name=options.get("name"), - after=options.get("after"), - limit=options.get("limit"), + name=None if name is None else operation_name(name), + after=_after(options.get("after")), + limit=_limit(options.get("limit")), ) response = invoke( self._transport().list_durable_messages, @@ -376,34 +384,40 @@ def _target( function_id=identifier(function_name, "function_name"), execution_id=identifier(execution_id, "execution_id"), project_id=None if project_id is None else identifier(project_id, "project_id"), - client_token=_optional_text(options.get("client_token"), "client_token"), + client_token=_bounded_text( + options.get("client_token"), "client_token", _MAX_CLIENT_TOKEN_LENGTH + ), ) -def _optional_text(value: object, field: str) -> str | None: +def _bounded_text(value: object, field: str, limit: int) -> str | None: if value is None: return None if not isinstance(value, str) or not value: message = f"{field} must be a non-empty string" raise ValueError(message) + if len(value) > limit: + message = f"{field} must be at most {limit} characters" + raise ValueError(message) return value -def _message_name(value: object) -> str: - if not isinstance(value, str) or not value.strip(): - message = "name must be a non-empty string" +def _after(value: object) -> int | None: + if value is None: + return None + if type(value) is not int or value < 0: + message = "after must be a non-negative integer" raise ValueError(message) return value -def _idempotency_key(value: object) -> str | None: - key = _optional_text(value, "idempotency_key") - if key is not None and len(key) > _MAX_IDEMPOTENCY_KEY_LENGTH: - message = ( - f"idempotency_key must be at most {_MAX_IDEMPOTENCY_KEY_LENGTH} characters" - ) +def _limit(value: object) -> int | None: + if value is None: + return None + if type(value) is not int or not 1 <= value <= _MAX_LIST_LIMIT: + message = f"limit must be an integer between 1 and {_MAX_LIST_LIMIT}" raise ValueError(message) - return key + return value def _direction(value: object) -> DurableMessageDirection: diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index 2f9e9231..9a28319e 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -489,17 +489,25 @@ def test_list_raises_not_found_for_an_execution_it_cannot_reach() -> None: lambda m: m.send("checkout", EXECUTION_ID, "ping", client_token=""), "client_token must be a non-empty string", ), + "long client token": ( + lambda m: m.send("checkout", EXECUTION_ID, "ping", client_token="t" * 129), + "client_token must be at most 128 characters", + ), "empty name": ( lambda m: m.send("checkout", EXECUTION_ID, ""), - "name must be a non-empty string", + "name must not be empty", ), "blank name": ( lambda m: m.send("checkout", EXECUTION_ID, " "), - "name must be a non-empty string", + "name must not be empty", + ), + "long name": ( + lambda m: m.send("checkout", EXECUTION_ID, "n" * 238), + "name must be at most 237 characters", ), - "non-string name": ( - lambda m: non_string_sent_message_name(m, EXECUTION_ID), - "name must be a non-empty string", + "unprintable name": ( + lambda m: m.send("checkout", EXECUTION_ID, "pi\tng"), + "name must be printable ASCII", ), "empty idempotency key": ( lambda m: m.send("checkout", EXECUTION_ID, "ping", idempotency_key=""), @@ -525,10 +533,38 @@ def test_list_raises_not_found_for_an_execution_it_cannot_reach() -> None: lambda m: m.list("checkout", EXECUTION_ID, client_token=""), "client_token must be a non-empty string", ), + "list long client token": ( + lambda m: m.list("checkout", EXECUTION_ID, client_token="t" * 129), + "client_token must be at most 128 characters", + ), "list direction": ( lambda m: unknown_listed_message_direction(m, EXECUTION_ID), "direction must be 'in' or 'out'", ), + "list name": ( + lambda m: m.list("checkout", EXECUTION_ID, name="n" * 238), + "name must be at most 237 characters", + ), + "negative after": ( + lambda m: m.list("checkout", EXECUTION_ID, after=-1), + "after must be a non-negative integer", + ), + "boolean after": ( + lambda m: m.list("checkout", EXECUTION_ID, after=True), + "after must be a non-negative integer", + ), + "zero limit": ( + lambda m: m.list("checkout", EXECUTION_ID, limit=0), + "limit must be an integer between 1 and 500", + ), + "limit over a page": ( + lambda m: m.list("checkout", EXECUTION_ID, limit=501), + "limit must be an integer between 1 and 500", + ), + "boolean limit": ( + lambda m: m.list("checkout", EXECUTION_ID, limit=True), + "limit must be an integer between 1 and 500", + ), } @@ -547,16 +583,42 @@ def test_invalid_arguments_are_refused_before_transport( assert transport.calls == [] -def test_an_idempotency_key_may_use_the_whole_length() -> None: +def test_a_sent_message_name_must_be_a_string() -> None: + transport = FakeMessageTransport() + + with pytest.raises(TypeError, match=r"^name must be a string$"): + non_string_sent_message_name( + messages_client(transport).durable.messages, EXECUTION_ID + ) + assert transport.calls == [] + + +def test_a_send_may_use_the_whole_length_of_each_bound() -> None: transport = FakeMessageTransport() - key = "k" * 255 + name, key, token = "n" * 237, "k" * 255, "t" * 128 _ = messages_client(transport).durable.messages.send( - "checkout", EXECUTION_ID, "ping", idempotency_key=key + "checkout", EXECUTION_ID, name, idempotency_key=key, client_token=token ) assert transport.calls[0][1]["request"] == DurableMessageSendRequest( - name="ping", idempotency_key=key + name=name, idempotency_key=key + ) + assert transport.calls[0][1]["target"] == DurableMessageTarget( + function_id="checkout", execution_id=EXECUTION_ID, client_token=token + ) + + +@pytest.mark.parametrize(("after", "limit"), [(0, 1), (7, 500)]) +def test_a_list_may_page_from_the_start_and_up_to_500(after: int, limit: int) -> None: + transport = FakeMessageTransport() + + _ = messages_client(transport).durable.messages.list( + "checkout", EXECUTION_ID, after=after, limit=limit + ) + + assert transport.calls[0][1]["request"] == DurableMessageListRequest( + after=after, limit=limit ) From 660c4d7d990aec023d6a93a502fda7521c9d392b Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 06:30:57 -0700 Subject: [PATCH 11/28] chore(openapi): sync the durable messages contract Brings in sender-scoped idempotency, the delivering status, and every refusal the message routes return. Co-authored-by: Cursor --- openapi/openapi.yaml | 83 ++++++++++++++----- .../list_durable_messages_from_application.py | 14 ++++ .../publish_durable_message.py | 7 ++ .../durable_functions/send_durable_message.py | 7 ++ .../send_durable_message_from_application.py | 23 +++-- 5 files changed, 106 insertions(+), 28 deletions(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 1b1be15d..f20dfaf3 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -4522,8 +4522,8 @@ paths: Any other execution answers `404`, as a missing one does. Send `Idempotency-Key` to make a retry safe: a repeat with a key the - execution has seen returns the first message with `200` and sends - nothing. + same sender has used on the execution returns the first message with + `200` and sends nothing. operationId: sendDurableMessageFromApplication security: - AnonKey: [] @@ -4550,7 +4550,8 @@ paths: '201': description: | Message sent. `status` is `delivered` when a wait took it at once, - and `queued` when it is kept for the next wait. + `delivering` when a wait took it and the handover is still being + confirmed, and `queued` when it is kept for the next wait. content: application/json: schema: @@ -4569,9 +4570,10 @@ paths: $ref: '#/components/schemas/Error' '403': description: | - The function is `authenticated` and the request carries an anon - key, or the request's origin is refused by the project's CORS - policy. + The anon key lacks `functions.invoke`, the service key is scoped + without it, or the function is `authenticated` and the request + carries an anon key. Also returned when the request's origin is + refused by the project's CORS policy. content: application/json: schema: @@ -4603,7 +4605,15 @@ paths: schema: $ref: '#/components/schemas/Error' '429': - description: Too many messages sent to this execution + description: | + Too many messages sent to this execution, or the function's rate + limit, which sends share with its invocations and starts. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: The plan terms for the request could not be read. Retry with backoff. content: application/json: schema: @@ -4653,9 +4663,10 @@ paths: $ref: '#/components/schemas/Error' '403': description: | - The function is `authenticated` and the request carries an anon - key, or the request's origin is refused by the project's CORS - policy. + The anon key lacks `functions.invoke`, the service key is scoped + without it, or the function is `authenticated` and the request + carries an anon key. Also returned when the request's origin is + refused by the project's CORS policy. content: application/json: schema: @@ -4668,6 +4679,20 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + '429': + description: | + The function's rate limit, which lists share with its invocations + and starts. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: The plan terms for the request could not be read. Retry with backoff. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /functions/resolve: get: tags: @@ -6036,7 +6061,9 @@ paths: schema: $ref: '#/components/schemas/DurableMessage' '201': - description: Message sent + description: | + Message sent. `status` is `delivered`, `delivering`, or `queued`, + as on the application route. content: application/json: schema: @@ -6047,6 +6074,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + '403': + description: The request used a `read_only` project access token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '404': description: Durable function or execution not found content: @@ -6200,8 +6233,9 @@ paths: - `callback_not_ready`: the workflow has not finished opening the wait yet. Retry with a short backoff; the SDK does. - - `callback_closed`: the workflow is no longer waiting on it, or - it is an approval's. + - `callback_closed`: the workflow is no longer waiting on it. + - `callback_taken`: the callback is already registered as an + approval or a messages session. - `execution_ended`: the execution has finished. - `too_many_message_waits`: the execution already has the maximum number of open waits. @@ -6281,8 +6315,9 @@ paths: '409': description: | The session cannot be opened now. `code` is `callback_not_ready`, - `callback_closed`, `execution_ended`, or - `too_many_message_sessions`. + `callback_closed`, `callback_taken` (the callback is already + registered as an approval or a message wait), `execution_ended`, + or `too_many_message_sessions`. content: application/json: schema: @@ -6355,13 +6390,13 @@ paths: schema: $ref: '#/components/schemas/DurableMessage' '400': - description: The name, data or idempotency key is invalid + description: The session token header is missing, or the name, data or idempotency key is invalid content: application/json: schema: $ref: '#/components/schemas/Error' '401': - description: The session token is missing or not valid + description: The session token is not valid content: application/json: schema: @@ -6380,6 +6415,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + '429': + description: Too many requests from this address with session tokens that are not valid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /durable-approvals: post: tags: @@ -6438,6 +6479,8 @@ paths: - `approval_not_ready`: the workflow has not finished opening the approval yet. Retry with a short backoff; the SDK does. - `approval_closed`: the workflow is no longer waiting on it. + - `callback_taken`: the callback is already registered as a + message wait or a messages session. - `execution_ended`: the execution has finished. - `too_many_pending_approvals`: the execution already has the maximum number of pending approvals. @@ -16293,9 +16336,9 @@ components: in: header required: false description: | - Makes a send safe to retry. A repeat with a key the execution has - already seen returns the first message and sends nothing. Up to 255 - characters. + Makes a send safe to retry. A repeat with a key the same sender has + used on the execution returns the first message and sends nothing. + Keys of different senders never collide. Up to 255 characters. schema: type: string minLength: 1 diff --git a/src/volcano_sdk/_generated/api/durable_functions/list_durable_messages_from_application.py b/src/volcano_sdk/_generated/api/durable_functions/list_durable_messages_from_application.py index 06cbd6ca..2cf358eb 100644 --- a/src/volcano_sdk/_generated/api/durable_functions/list_durable_messages_from_application.py +++ b/src/volcano_sdk/_generated/api/durable_functions/list_durable_messages_from_application.py @@ -103,6 +103,20 @@ def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Res return response_404 + if response.status_code == 429: + response_429 = Error.from_dict(response.json()) + + + + return response_429 + + if response.status_code == 503: + response_503 = Error.from_dict(response.json()) + + + + return response_503 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: diff --git a/src/volcano_sdk/_generated/api/durable_functions/publish_durable_message.py b/src/volcano_sdk/_generated/api/durable_functions/publish_durable_message.py index de29d303..fbe3c3ab 100644 --- a/src/volcano_sdk/_generated/api/durable_functions/publish_durable_message.py +++ b/src/volcano_sdk/_generated/api/durable_functions/publish_durable_message.py @@ -92,6 +92,13 @@ def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Res return response_413 + if response.status_code == 429: + response_429 = Error.from_dict(response.json()) + + + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: diff --git a/src/volcano_sdk/_generated/api/durable_functions/send_durable_message.py b/src/volcano_sdk/_generated/api/durable_functions/send_durable_message.py index 3733c0ca..2a49a741 100644 --- a/src/volcano_sdk/_generated/api/durable_functions/send_durable_message.py +++ b/src/volcano_sdk/_generated/api/durable_functions/send_durable_message.py @@ -72,6 +72,13 @@ def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Res return response_400 + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + + + return response_403 + if response.status_code == 404: response_404 = Error.from_dict(response.json()) diff --git a/src/volcano_sdk/_generated/api/durable_functions/send_durable_message_from_application.py b/src/volcano_sdk/_generated/api/durable_functions/send_durable_message_from_application.py index e955a6e8..bed60a85 100644 --- a/src/volcano_sdk/_generated/api/durable_functions/send_durable_message_from_application.py +++ b/src/volcano_sdk/_generated/api/durable_functions/send_durable_message_from_application.py @@ -117,6 +117,13 @@ def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Res return response_429 + if response.status_code == 503: + response_503 = Error.from_dict(response.json()) + + + + return response_503 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -164,8 +171,8 @@ def sync_detailed( Any other execution answers `404`, as a missing one does. Send `Idempotency-Key` to make a retry safe: a repeat with a key the - execution has seen returns the first message with `200` and sends - nothing. + same sender has used on the execution returns the first message with + `200` and sends nothing. Args: function_id (str): @@ -230,8 +237,8 @@ def sync( Any other execution answers `404`, as a missing one does. Send `Idempotency-Key` to make a retry safe: a repeat with a key the - execution has seen returns the first message with `200` and sends - nothing. + same sender has used on the execution returns the first message with + `200` and sends nothing. Args: function_id (str): @@ -291,8 +298,8 @@ async def asyncio_detailed( Any other execution answers `404`, as a missing one does. Send `Idempotency-Key` to make a retry safe: a repeat with a key the - execution has seen returns the first message with `200` and sends - nothing. + same sender has used on the execution returns the first message with + `200` and sends nothing. Args: function_id (str): @@ -357,8 +364,8 @@ async def asyncio( Any other execution answers `404`, as a missing one does. Send `Idempotency-Key` to make a retry safe: a repeat with a key the - execution has seen returns the first message with `200` and sends - nothing. + same sender has used on the execution returns the first message with + `200` and sends nothing. Args: function_id (str): From 9d0d8b5f0ad2808b0193c331b4e74ca70df2e6e1 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 06:51:43 -0700 Subject: [PATCH 12/28] fix(durable): type the message client_token option as optional DurableExecution.client_token is None outside the start that created the execution, so the documented client_token=execution.client_token now typechecks. Co-authored-by: Cursor --- .../_tests/typing/durable_messages.py | 34 +++++++++++++++++++ src/volcano_sdk/models.py | 8 +++-- 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 src/volcano_sdk/_tests/typing/durable_messages.py diff --git a/src/volcano_sdk/_tests/typing/durable_messages.py b/src/volcano_sdk/_tests/typing/durable_messages.py new file mode 100644 index 00000000..eb0a0137 --- /dev/null +++ b/src/volcano_sdk/_tests/typing/durable_messages.py @@ -0,0 +1,34 @@ +"""An execution's own client token addresses its messages as documented.""" + +from typing import assert_type + +from volcano_sdk import ( + DurableExecution, + DurableMessage, + DurableMessageSubscription, + VolcanoClient, +) + + +async def execution_client_token( + client: VolcanoClient, execution: DurableExecution +) -> None: + _ = assert_type(execution.client_token, str | None) + _sent = assert_type( + client.durable.messages.send( + "order-pipeline", + execution.id, + "address-confirmed", + client_token=execution.client_token, + ), + DurableMessage, + ) + _subscription = assert_type( + await client.durable.messages.subscribe( + "order-pipeline", + execution.id, + print, + client_token=execution.client_token, + ), + DurableMessageSubscription, + ) diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index a5275226..c463eea0 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -570,8 +570,9 @@ class DurableMessageTargetOptions(TypedDict, total=False): """Which route reaches an execution's messages, and with what.""" # The execution's `client_token`. The application route needs it for an - # anon key, or a signed-in user who did not start the execution. - client_token: str + # anon key, or a signed-in user who did not start the execution. None, + # as `DurableExecution.client_token` is when not from the start, sends none. + client_token: str | None # Use the owner's project route instead, with the session's platform token. project_id: str @@ -579,7 +580,8 @@ class DurableMessageTargetOptions(TypedDict, total=False): class DurableMessageSendOptions(DurableMessageTargetOptions, total=False): """Route and idempotency for sending a durable message.""" - # A repeat with the same key returns the message the first send stored. + # A repeat of the same key by the same sender returns the message the + # first send stored. idempotency_key: str From 4531f4acb1a9a7271c7e4c89fba095584f662331 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 06:51:47 -0700 Subject: [PATCH 13/28] fix(durable): refuse a message header value that is not printable ASCII idempotency_key and client_token travel as headers, so a non-ASCII or control character now raises a ValueError naming the field instead of failing inside httpx. Co-authored-by: Cursor --- src/volcano_sdk/_durable_messages.py | 4 ++++ src/volcano_sdk/_tests/test_durable_messages.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index 9ed29bb7..967c502a 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -396,6 +396,10 @@ def _bounded_text(value: object, field: str, limit: int) -> str | None: if not isinstance(value, str) or not value: message = f"{field} must be a non-empty string" raise ValueError(message) + # Sent as a header, which carries printable ASCII only. + if not (value.isascii() and value.isprintable()): + message = f"{field} must be printable ASCII" + raise ValueError(message) if len(value) > limit: message = f"{field} must be at most {limit} characters" raise ValueError(message) diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index 9a28319e..e8ad31db 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -493,6 +493,10 @@ def test_list_raises_not_found_for_an_execution_it_cannot_reach() -> None: lambda m: m.send("checkout", EXECUTION_ID, "ping", client_token="t" * 129), "client_token must be at most 128 characters", ), + "non-ASCII client token": ( + lambda m: m.send("checkout", EXECUTION_ID, "ping", client_token="tök"), + "client_token must be printable ASCII", + ), "empty name": ( lambda m: m.send("checkout", EXECUTION_ID, ""), "name must not be empty", @@ -517,6 +521,16 @@ def test_list_raises_not_found_for_an_execution_it_cannot_reach() -> None: lambda m: m.send("checkout", EXECUTION_ID, "ping", idempotency_key="k" * 256), "idempotency_key must be at most 255 characters", ), + "non-ASCII idempotency key": ( + lambda m: m.send( + "checkout", EXECUTION_ID, "ping", idempotency_key="confirm-café" + ), + "idempotency_key must be printable ASCII", + ), + "unprintable idempotency key": ( + lambda m: m.send("checkout", EXECUTION_ID, "ping", idempotency_key="a\nb"), + "idempotency_key must be printable ASCII", + ), "list function": ( lambda m: m.list(" ", EXECUTION_ID), "function_name must be a non-empty string", From eb8dd34a9654d27996cb80454b0803dd1f89556d Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 06:51:47 -0700 Subject: [PATCH 14/28] fix(durable): recheck a live handler's subscription when its task starts An unsubscribe or a session change queued before the handler's task skips the handler. A handler raising on a message the first read held also closes the stream, as a failed read does. Co-authored-by: Cursor --- src/volcano_sdk/_durable_message_stream.py | 10 ++- .../_tests/test_durable_messages.py | 80 ++++++++++++++++++- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/volcano_sdk/_durable_message_stream.py b/src/volcano_sdk/_durable_message_stream.py index 8bc22ae3..3a6be576 100644 --- a/src/volcano_sdk/_durable_message_stream.py +++ b/src/volcano_sdk/_durable_message_stream.py @@ -199,12 +199,12 @@ async def _read_missed(self, *, first: bool) -> None: async with self._reads: try: await self._read_pages(report=report) + await self._release_if_last(read=True, report=report) except BaseException: if first: self.close() await self._release_if_last(read=False, report=report) raise - await self._release_if_last(read=True, report=report) if first and self._closed: raise RuntimeError(_ENDED) finally: @@ -271,13 +271,19 @@ async def _deliver(self, message: DurableMessage, *, report: bool) -> None: await self._handler(message) return (error,) = await asyncio.gather( - self._handler(message), return_exceptions=True + self._handle_if_current(message), return_exceptions=True ) if isinstance(error, BaseException): asyncio.get_running_loop().call_exception_handler( {"message": _HANDLER_FAILED, "exception": error} ) + async def _handle_if_current(self, message: DurableMessage) -> None: + # Runs as a task of its own, which an unsubscribe or a session change + # can get to first. The next delivery or read ends a changed session. + if not self._closed and self._in_session(): + await self._handler(message) + async def _require_session(self) -> None: if not self._in_session(): self.close() diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index e8ad31db..dbbbe3d9 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -28,7 +28,7 @@ durable_message, durable_message_page, ) -from volcano_sdk._durable_message_stream import retry_delay +from volcano_sdk._durable_message_stream import DurableMessageStream, retry_delay from volcano_sdk._realtime_durable import DurableChannels from volcano_sdk._transport import ( DurableMessageListRequest, @@ -49,7 +49,7 @@ unknown_sent_message_option, unknown_subscription_option, ) -from .realtime_probes import realtime_state +from .realtime_probes import completed_operation, realtime_state from .test_realtime import FakeCentrifugeClient, FakeCentrifugeFactory, FakeSubscription from .transport_fixtures import RejectingTransport @@ -1833,6 +1833,51 @@ async def test_a_session_change_stops_live_delivery(reports: Reports) -> None: await client.realtime.disconnect() +async def started_stream( + received: Received, in_session: Callable[[], bool] +) -> DurableMessageStream: + async def deliver(message: DurableMessage) -> None: + received(await completed_operation(message)) + + async def list_after(_after: int) -> DurableMessagePage: + return await completed_operation( + DurableMessagePage(messages=(), has_more=False) + ) + + stream = DurableMessageStream(deliver, list_after, in_session) + await stream.catch_up() + return stream + + +async def live_interrupted( + stream: DurableMessageStream, interrupt: Callable[[], None] +) -> None: + delivering = asyncio.create_task(stream.live(published(1))) + # Runs once live() has checked the stream, before the handler's task starts. + _ = asyncio.get_running_loop().call_soon(interrupt) + await delivering + + +async def test_an_unsubscribe_before_a_live_handler_starts_skips_it() -> None: + received = Received() + stream = await started_stream(received, lambda: True) + + await live_interrupted(stream, stream.close) + + assert received.seqs == [] + + +async def test_a_session_change_before_a_live_handler_starts_skips_it() -> None: + received = Received() + signed_in = asyncio.Event() + signed_in.set() + stream = await started_stream(received, signed_in.is_set) + + await live_interrupted(stream, signed_in.clear) + + assert received.seqs == [] + + async def test_a_session_change_leaves_the_channel(reports: Reports) -> None: transport = FakeMessageTransport() client, native = subscriber(transport) @@ -2144,6 +2189,37 @@ def fail(message: DurableMessage) -> None: await client.realtime.disconnect() +async def test_a_handler_raising_on_a_message_the_first_read_held_ends_delivery() -> ( + None +): + received = Received() + failure = RuntimeError("handler failed") + gate = asyncio.Event() + + async def deliver(message: DurableMessage) -> None: + received(await completed_operation(message)) + if message.seq == 2: + raise failure + + async def list_after(_after: int) -> DurableMessagePage: + _ = await gate.wait() + return DurableMessagePage( + messages=(durable_message(published(1)),), has_more=False + ) + + stream = DurableMessageStream(deliver, list_after, lambda: True) + reading = stream.catch_up() + await stream.live(published(2, prev_seq=1)) + gate.set() + with pytest.raises(RuntimeError) as caught: + await reading + # Arrives before subscribe has left the channel. + await stream.live(published(3, prev_seq=2)) + + assert caught.value is failure + assert received.seqs == [1, 2] + + async def test_a_failed_first_read_delivers_nothing_it_held() -> None: transport = FakeMessageTransport() transport.read_answers = [FakeResponse(404, {"error": "execution not found"}, {})] From 5dfe48bffec460b82d31535b5f75aaeb1d1a6782 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 09:07:36 -0700 Subject: [PATCH 15/28] chore(openapi): sync the durable message publish contract Co-authored-by: Cursor --- openapi/openapi.yaml | 6 ------ .../api/durable_functions/publish_durable_message.py | 7 ------- 2 files changed, 13 deletions(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index f20dfaf3..58be1c46 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -6415,12 +6415,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - '429': - description: Too many requests from this address with session tokens that are not valid - content: - application/json: - schema: - $ref: '#/components/schemas/Error' /durable-approvals: post: tags: diff --git a/src/volcano_sdk/_generated/api/durable_functions/publish_durable_message.py b/src/volcano_sdk/_generated/api/durable_functions/publish_durable_message.py index fbe3c3ab..de29d303 100644 --- a/src/volcano_sdk/_generated/api/durable_functions/publish_durable_message.py +++ b/src/volcano_sdk/_generated/api/durable_functions/publish_durable_message.py @@ -92,13 +92,6 @@ def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Res return response_413 - if response.status_code == 429: - response_429 = Error.from_dict(response.json()) - - - - return response_429 - if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: From 2cfdab033ac8af5f87433a6665e39eda7ce64f7f Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 09:07:36 -0700 Subject: [PATCH 16/28] docs(durable): document the delivering status of a sent message Co-authored-by: Cursor --- docs/functions.md | 4 ++-- src/volcano_sdk/_durable_messages.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index 2fe3ce63..924efe35 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -284,7 +284,7 @@ for published in page.messages: | Method | Returns | |---|---| -| `send(function_name, execution_id, name, data=None, idempotency_key=..., client_token=..., project_id=...)` | The stored `DurableMessage`, `queued` or already `delivered`. | +| `send(function_name, execution_id, name, data=None, idempotency_key=..., client_token=..., project_id=...)` | The stored `DurableMessage`: `queued`, `delivered`, or `delivering` while its handover to a wait is still being confirmed. | | `list(function_name, execution_id, direction=..., name=..., after=..., limit=..., client_token=..., project_id=...)` | `DurableMessagePage` with `messages`, oldest first, and `has_more`. | | `await subscribe(function_name, execution_id, handler, client_token=..., project_id=...)` | A `DurableMessageSubscription` with `await unsubscribe()`. | @@ -304,7 +304,7 @@ async def follow(client, execution): await subscription.unsubscribe() ``` -Pass `project_id` to use the owner's project route with the session's platform token or a project access token instead; a `read_only` project access token can list but not send. A send to an execution that has ended raises `ConflictError` with `code` set to `execution_not_running`. An execution the caller may not reach raises `NotFoundError`, as an unknown one does. +Pass `project_id` to use the owner's project route with the session's platform token or a project access token instead; a `read_only` project access token can list but not send. A send to an execution that has ended raises `ConflictError` with `code` set to `execution_not_running`, and one to an execution already holding 100 messages nothing has taken raises it with `too_many_queued_messages`. An execution the caller may not reach raises `NotFoundError`, as an unknown one does. ## Run durable functions locally diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index 967c502a..7e2434c0 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -208,12 +208,14 @@ def send( It is queued until a `ctx.wait_for_message(name)` takes it. A send with an `idempotency_key` the same sender has used on the execution returns the message the first one stored, and sends nothing. A send to an - execution that has ended raises `ConflictError`. + execution that has ended, or that already holds 100 untaken messages, + raises `ConflictError`. Returns ------- DurableMessage - The stored message, `queued` or already `delivered`. + The stored message: `queued`, `delivered`, or `delivering` while + its handover to a wait is still being confirmed. """ known_options(options, _SEND_OPTIONS) From 30111f587f7e9b2a068beff461a2dda2232ba9c0 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 17:19:08 -0700 Subject: [PATCH 17/28] fix(durable): leave and report a subscription the server ends Co-authored-by: Cursor --- README.md | 4 +- docs/functions.md | 2 +- src/volcano_sdk/_durable_messages.py | 32 +++++++++++ src/volcano_sdk/_realtime_channel.py | 16 ++++-- .../_tests/test_durable_messages.py | 53 +++++++++++++++++++ src/volcano_sdk/_tests/test_realtime.py | 4 ++ 6 files changed, 106 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e32144fc..56d961ed 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,9 @@ subscription = await client.durable.messages.subscribe( including what it published before the call, and needs a signed-in session for the realtime connection. A client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`; it can poll `list()` with `after` -instead. A send to an execution that has ended raises +instead. A subscription the server ends, as when the execution is no longer +visible to the session, leaves the channel and is reported once to the event +loop's exception handler. A send to an execution that has ended raises `ConflictError` with `code` set to `execution_not_running`. Pass `project_id` to use the owner's project route instead. See the [functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#send-and-follow-messages). diff --git a/docs/functions.md b/docs/functions.md index 924efe35..540ea1f4 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -290,7 +290,7 @@ for published in page.messages: A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the same sender has used on the execution returns the first message and sends nothing; another sender's keys never collide with it. The key is up to 255 characters. -`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery leaves the execution's channel and raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: +`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery leaves the execution's channel and raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. When the server ends the subscription, or refuses to restore it after a reconnect, as when the execution is no longer visible to the session, the subscription delivers nothing more and leaves the execution's channel, then reports `RuntimeError` with the server's reason to the event loop's exception handler once; the execution can be subscribed to again. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: ```python async def follow(client, execution): diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index 7e2434c0..ec4ddb07 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import inspect from typing import TYPE_CHECKING, Protocol, Unpack, runtime_checkable @@ -40,6 +41,9 @@ _ALREADY_SUBSCRIBED = ( "This client already subscribes to the execution's messages; unsubscribe first" ) +_SERVER_ENDED = ( + "The server ended the durable message subscription; subscribe again to resume" +) # The spec's bounds on Idempotency-Key, X-Volcano-Client-Token, and a page. _MAX_IDEMPOTENCY_KEY_LENGTH = 255 _MAX_CLIENT_TOKEN_LENGTH = 128 @@ -107,6 +111,8 @@ class DurableChannel(Protocol): resubscribed: Callable[[], None] | None # Called when the client's realtime facade removes or disconnects it. ended: Callable[[], None] | None + # Called with the server's reason when the server ends the subscription. + server_ended: Callable[[str], None] | None # Called each time a full callback queue drops a publication. dropped: Callable[[], None] | None @@ -355,6 +361,8 @@ async def _follow( await channel.subscribe() channel.resubscribed = stream.resubscribed channel.dropped = stream.dropped + # Before this, subscribe raises the server's refusal instead. + channel.server_ended = _leave_once_ended(stream, subscription) await stream.catch_up() except BaseException: await subscription.unsubscribe() @@ -363,6 +371,30 @@ async def _follow( return subscription +def _leave_once_ended( + stream: DurableMessageStream, subscription: DurableMessageSubscription +) -> Callable[[str], None]: + leaving: set[asyncio.Task[None]] = set() + + # Left before it is reported, so the report can follow the execution again. + async def leave(reason: str) -> None: + await subscription.unsubscribe() + asyncio.get_running_loop().call_exception_handler( + {"message": _SERVER_ENDED, "exception": RuntimeError(reason)} + ) + + def ended(reason: str) -> None: + stream.close() + # Not awaited by the channel's event: the connection's reader runs it, + # and leaving takes the connection lock, which a request waiting on + # that reader may hold. + task = asyncio.create_task(leave(reason)) + leaving.add(task) + task.add_done_callback(leaving.discard) + + return ended + + def _deliverer( handler: Callable[[DurableMessage], object], ) -> Callable[[DurableMessage], Awaitable[None]]: diff --git a/src/volcano_sdk/_realtime_channel.py b/src/volcano_sdk/_realtime_channel.py index a6eca7c6..3203f6fa 100644 --- a/src/volcano_sdk/_realtime_channel.py +++ b/src/volcano_sdk/_realtime_channel.py @@ -144,9 +144,16 @@ async def on_subscribed(self, ctx: object) -> None: @override async def on_unsubscribed(self, ctx: object) -> None: - del ctx - if self.is_current(): - await self.channel.transport_lost() + if not self.is_current(): + return + # The SDK pauses a channel before it unsubscribes one, so one still + # unpaused was ended by the server, by an unsubscribe or by refusing to + # subscribe it again. + by_server = not self.channel.paused + await self.channel.transport_lost() + ended = self.channel.server_ended + if by_server and ended is not None: + ended(str(_native.native_attribute(ctx, "reason", ""))) @override async def on_join(self, ctx: object) -> None: @@ -202,6 +209,9 @@ def __init__( # Called when removal or disconnect resets the channel, which then # subscribes again only when asked to. self.ended: Callable[[], None] | None = None + # Called with the server's reason when it ends the subscription, which + # is not restored. + self.server_ended: Callable[[str], None] | None = None # Called after a full callback queue drops a publication. self.dropped: Callable[[], None] | None = None self.callbacks: dict[str, list[DynamicCallback]] = {} diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index dbbbe3d9..575b83dc 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -1896,6 +1896,59 @@ async def test_a_session_change_leaves_the_channel(reports: Reports) -> None: await client.realtime.disconnect() +@pytest.mark.parametrize( + ("code", "reason"), [(2000, "server unsubscribe"), (103, "permission denied")] +) +async def test_a_subscription_the_server_ends_is_left_and_reported_once( + code: int, reason: str, reports: Reports +) -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + await channel.emit_unsubscribed(code, reason) + await reports.arrivals.wait_for(1) + await channel.emit(published(2)) + + assert CHANNEL not in native.subscriptions + error = reports.contexts[0]["exception"] + assert isinstance(error, RuntimeError) + assert str(error) == reason + transport.published += [2] + again = Received() + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, again) + assert again.seqs == [1, 2] + assert received.seqs == [1] + (ended,) = reports.messages + assert str(ended).startswith( + "The server ended the durable message subscription" + ) + finally: + await client.realtime.disconnect() + + +async def test_an_unsubscribe_the_client_makes_is_not_taken_for_the_server_s( + reports: Reports, +) -> None: + transport = FakeMessageTransport() + client, _native = subscriber(transport) + state = realtime_state(client.realtime) + try: + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + await state.unsubscribe(state.channels[CHANNEL].state) + await asyncio.sleep(0.01) + + assert reports.contexts == [] + assert CHANNEL in state.channels + finally: + await client.realtime.disconnect() + + @pytest.mark.parametrize("listed", [[], [1]]) async def test_a_session_change_during_the_first_read_fails_subscribe( listed: list[int], diff --git a/src/volcano_sdk/_tests/test_realtime.py b/src/volcano_sdk/_tests/test_realtime.py index ec638503..dc80eabd 100644 --- a/src/volcano_sdk/_tests/test_realtime.py +++ b/src/volcano_sdk/_tests/test_realtime.py @@ -533,6 +533,10 @@ async def emit_subscribing(self) -> None: SimpleNamespace(code=0, reason="reconnecting") ) + async def emit_unsubscribed(self, code: int, reason: str) -> None: + self.subscribed.clear() + await self._events().on_unsubscribed(SimpleNamespace(code=code, reason=reason)) + async def emit(self, data: object) -> None: await self._events().on_publication( FakePublicationContext(pub=FakePublication(data=data)) From 41589f25cb497a8d5bd6b13883feafbc3deb8bc1 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 19:03:51 -0700 Subject: [PATCH 18/28] fix(durable): fail subscribe when the server ends it after it is ready Co-authored-by: Cursor --- src/volcano_sdk/_durable_messages.py | 18 +++-- .../_tests/test_durable_messages.py | 71 +++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index ec4ddb07..7ba7a709 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -309,8 +309,8 @@ async def subscribe( Raises ------ RuntimeError - If the client has no signed-in session, or already subscribes to - the execution. + If the client has no signed-in session, already subscribes to the + execution, or the server ends the subscription before it returns. """ known_options(options, _TARGET_OPTIONS) @@ -358,10 +358,9 @@ async def _follow( _ = channel.on("message", stream.live) channel.ended = stream.close try: - await channel.subscribe() + await _subscribe(channel) channel.resubscribed = stream.resubscribed channel.dropped = stream.dropped - # Before this, subscribe raises the server's refusal instead. channel.server_ended = _leave_once_ended(stream, subscription) await stream.catch_up() except BaseException: @@ -371,6 +370,17 @@ async def _follow( return subscription +async def _subscribe(channel: DurableChannel) -> None: + # Until subscribe returns, an end is raised rather than reported. One that + # lands after the channel is ready does not make subscribe raise, so it is + # kept until then. + ended: list[str] = [] + channel.server_ended = ended.append + await channel.subscribe() + if ended: + raise RuntimeError(ended[0]) + + def _leave_once_ended( stream: DurableMessageStream, subscription: DurableMessageSubscription ) -> Callable[[str], None]: diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index 575b83dc..e65c01f2 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -1930,6 +1930,77 @@ async def test_a_subscription_the_server_ends_is_left_and_reported_once( await client.realtime.disconnect() +class EndedOnceReady(FakeSubscription): + """End the subscription after it is ready, before subscribe returns.""" + + ending: asyncio.Task[None] | None = None + + @override + async def ready(self) -> None: + await super().ready() + # Runs before the task waiting on readiness wakes its caller. + self.ending = asyncio.create_task( + self.emit_unsubscribed(2000, "server unsubscribe") + ) + + +class EndedOnceReadyClient(FakeCentrifugeClient): + """End the first subscription once it is ready.""" + + ended: bool = False + + @override + def new_subscription( + self, + name: str, + *, + events: object, + join_leave: bool = False, + recoverable: bool = False, + data: Mapping[str, str] | None = None, + ) -> FakeSubscription: + if self.ended: + return super().new_subscription( + name, + events=events, + join_leave=join_leave, + recoverable=recoverable, + data=data, + ) + self.ended = True + subscription = EndedOnceReady( + name, events, join_leave=join_leave, recoverable=recoverable, data=data + ) + self.subscriptions[name] = subscription + return subscription + + +async def test_an_end_after_the_channel_is_ready_fails_subscribe() -> None: + transport = FakeMessageTransport() + transport.published = [1] + native = EndedOnceReadyClient() + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(native), + ) + received = Received() + try: + with pytest.raises(RuntimeError, match=r"^server unsubscribe$"): + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, received + ) + + assert received.seqs == [] + assert CHANNEL not in native.subscriptions + again = Received() + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, again) + assert again.seqs == [1] + finally: + await client.realtime.disconnect() + + async def test_an_unsubscribe_the_client_makes_is_not_taken_for_the_server_s( reports: Reports, ) -> None: From 589c0b1de2f3fd976efca889a4ab4aa3b2fa27b1 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 20:06:19 -0700 Subject: [PATCH 19/28] fix(durable): raise the server's reason when it refuses the first subscribe Co-authored-by: Cursor --- src/volcano_sdk/_durable_messages.py | 9 +- .../_tests/test_durable_messages.py | 88 +++++++++++++++---- 2 files changed, 81 insertions(+), 16 deletions(-) diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index 7ba7a709..9931eadd 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -376,7 +376,14 @@ async def _subscribe(channel: DurableChannel) -> None: # kept until then. ended: list[str] = [] channel.server_ended = ended.append - await channel.subscribe() + try: + await channel.subscribe() + except Exception as error: + # A refusal ends the subscription before readiness fails, so its reason + # is already here. + if ended: + raise RuntimeError(ended[0]) from error + raise if ended: raise RuntimeError(ended[0]) diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index e65c01f2..bd356e56 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -10,6 +10,7 @@ import httpx import pytest +from centrifuge import CentrifugeError from typing_extensions import override from volcano_sdk import ( @@ -241,9 +242,10 @@ def handle(request: httpx.Request) -> httpx.Response: def subscriber( - transport: FakeMessageTransport, + transport: FakeMessageTransport, native: FakeCentrifugeClient | None = None ) -> tuple[VolcanoClient, FakeCentrifugeClient]: - native = FakeCentrifugeClient() + if native is None: + native = FakeCentrifugeClient() client = VolcanoClient( anon_key="anon-key", access_token="access-token", @@ -1944,10 +1946,36 @@ async def ready(self) -> None: ) -class EndedOnceReadyClient(FakeCentrifugeClient): - """End the first subscription once it is ready.""" +class UnreadySubscription(FakeSubscription): + """Never ready, and never ended by the server.""" + + @override + async def subscribe(self) -> None: + self.calls.append(("subscribe", None)) + await self.emit_subscribing() + + @override + async def ready(self) -> None: + message = "timeout waiting for subscription to be ready" + raise CentrifugeError(message) + - ended: bool = False +class RefusedSubscription(UnreadySubscription): + """Refused by the server, which ends it before readiness fails.""" + + @override + async def ready(self) -> None: + await self.emit_unsubscribed(103, "permission denied") + message = "subscription unsubscribed" + raise CentrifugeError(message) + + +class FirstSubscriptionClient(FakeCentrifugeClient): + """Make the first subscription a given kind, and later ones ordinary.""" + + def __init__(self, first: type[FakeSubscription]) -> None: + super().__init__() + self.first: type[FakeSubscription] | None = first @override def new_subscription( @@ -1959,7 +1987,8 @@ def new_subscription( recoverable: bool = False, data: Mapping[str, str] | None = None, ) -> FakeSubscription: - if self.ended: + first = self.first + if first is None: return super().new_subscription( name, events=events, @@ -1967,8 +1996,8 @@ def new_subscription( recoverable=recoverable, data=data, ) - self.ended = True - subscription = EndedOnceReady( + self.first = None + subscription = first( name, events, join_leave=join_leave, recoverable=recoverable, data=data ) self.subscriptions[name] = subscription @@ -1978,13 +2007,7 @@ def new_subscription( async def test_an_end_after_the_channel_is_ready_fails_subscribe() -> None: transport = FakeMessageTransport() transport.published = [1] - native = EndedOnceReadyClient() - client = VolcanoClient( - anon_key="anon-key", - access_token="access-token", - _transport=transport, - _realtime_client_factory=FakeCentrifugeFactory(native), - ) + client, native = subscriber(transport, FirstSubscriptionClient(EndedOnceReady)) received = Received() try: with pytest.raises(RuntimeError, match=r"^server unsubscribe$"): @@ -2001,6 +2024,41 @@ async def test_an_end_after_the_channel_is_ready_fails_subscribe() -> None: await client.realtime.disconnect() +async def test_a_refused_subscribe_fails_with_the_server_s_reason() -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport, FirstSubscriptionClient(RefusedSubscription)) + try: + with pytest.raises(RuntimeError, match=r"^permission denied$") as raised: + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + + assert isinstance(raised.value.__cause__, CentrifugeError) + assert CHANNEL not in native.subscriptions + again = Received() + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, again) + assert again.seqs == [1] + finally: + await client.realtime.disconnect() + + +async def test_a_subscribe_that_never_becomes_ready_fails_with_its_error() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport, FirstSubscriptionClient(UnreadySubscription)) + try: + with pytest.raises( + CentrifugeError, match=r"^timeout waiting for subscription to be ready$" + ): + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + + assert CHANNEL not in native.subscriptions + finally: + await client.realtime.disconnect() + + async def test_an_unsubscribe_the_client_makes_is_not_taken_for_the_server_s( reports: Reports, ) -> None: From 0e349267773a3bb6a0456bfeb89b73825499f1ab Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 21:24:23 -0700 Subject: [PATCH 20/28] fix(realtime): end a durable stream before a removal that can fail A native unsubscribe that failed left the channel's reset undone, so a durable stream's catch-up still in flight went on reading and delivering after remove_all_channels raised. Removal now ends the channel's followers first. Co-authored-by: Cursor --- src/volcano_sdk/_realtime_channel.py | 10 +++++-- src/volcano_sdk/_realtime_connection.py | 2 ++ .../_tests/test_durable_messages.py | 29 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/volcano_sdk/_realtime_channel.py b/src/volcano_sdk/_realtime_channel.py index 3203f6fa..87e487c4 100644 --- a/src/volcano_sdk/_realtime_channel.py +++ b/src/volcano_sdk/_realtime_channel.py @@ -206,8 +206,9 @@ def __init__( # Called each time the subscription is established once it is set, # after the subscribed state and callbacks are in place. self.resubscribed: Callable[[], None] | None = None - # Called when removal or disconnect resets the channel, which then - # subscribes again only when asked to. + # Called when removal starts or disconnect resets the channel, which + # then subscribes again only when asked to. A removal that fails and is + # retried calls it again. self.ended: Callable[[], None] | None = None # Called with the server's reason when it ends the subscription, which # is not restored. @@ -654,9 +655,12 @@ async def _run_callback( if inspect.isawaitable(result): await result - async def reset(self) -> None: + def end(self) -> None: if self.ended is not None: self.ended() + + async def reset(self) -> None: + self.end() self.invalidate() await self._end_postgres_epoch() await self.presence.cancel_presence_sync() diff --git a/src/volcano_sdk/_realtime_connection.py b/src/volcano_sdk/_realtime_connection.py index 3d5d4bd5..1dd6d17b 100644 --- a/src/volcano_sdk/_realtime_connection.py +++ b/src/volcano_sdk/_realtime_connection.py @@ -433,6 +433,8 @@ async def _remove_registered_channel( async def _remove_channel_state(self, channel: ChannelState) -> None: channel.supersede_subscribe_intent() + # Before the native unsubscribe, whose failure skips the reset. + channel.end() await self._discard_subscription(channel) await channel.reset() diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index bd356e56..5badb190 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -2130,6 +2130,35 @@ async def test_ending_the_realtime_channels_ends_the_subscription( await client.realtime.disconnect() +async def test_a_removal_whose_unsubscribe_fails_still_ends_the_subscription( + retries: Retries, +) -> None: + transport = FakeMessageTransport() + transport.published = [1] + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + channel = subscription_of(native) + gate = asyncio.Event() + transport.next_read_gate = gate + transport.published += [2] + await channel.emit(published(3, prev_seq=2)) + await transport.read_arrivals.wait_for(2) + channel.unsubscribe_error = CentrifugeError("unsubscribe failed") + with pytest.raises(CentrifugeError, match=r"^unsubscribe failed$"): + await client.realtime.remove_all_channels() + transport.published += [3] + gate.set() + await asyncio.sleep(0.05) + + assert received.seqs == [1] + assert transport.read_after() == [0, 1] + assert retries.failures == [] + finally: + await client.realtime.disconnect() + + async def test_leaving_after_a_removal_spares_the_channel_that_replaced_it() -> None: transport = FakeMessageTransport() client, native = subscriber(transport) From 8440387b41d5250a16d8790eb6599620cb20e4a5 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 21:24:29 -0700 Subject: [PATCH 21/28] fix(durable): raise the server's reason when it ends the first read An end during subscribe's first catch-up made subscribe raise a generic error and also reported the reason to the loop. It is now kept until the read returns and raised from subscribe, as an end before readiness is, and nothing is reported. Co-authored-by: Cursor --- docs/functions.md | 2 +- src/volcano_sdk/_durable_messages.py | 29 ++++++++++-------- .../_tests/test_durable_messages.py | 30 +++++++++++++++++++ 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index 540ea1f4..107da8ce 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -290,7 +290,7 @@ for published in page.messages: A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the same sender has used on the execution returns the first message and sends nothing; another sender's keys never collide with it. The key is up to 255 characters. -`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery leaves the execution's channel and raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. When the server ends the subscription, or refuses to restore it after a reconnect, as when the execution is no longer visible to the session, the subscription delivers nothing more and leaves the execution's channel, then reports `RuntimeError` with the server's reason to the event loop's exception handler once; the execution can be subscribed to again. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: +`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery leaves the execution's channel and raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. When the server ends the subscription, or refuses to restore it after a reconnect, as when the execution is no longer visible to the session, the subscription delivers nothing more and leaves the execution's channel, then reports `RuntimeError` with the server's reason to the event loop's exception handler once; the execution can be subscribed to again. An end or refusal before `subscribe()` returns makes it raise that `RuntimeError` instead, and nothing is reported. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: ```python async def follow(client, execution): diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index 9931eadd..4d03578f 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -357,30 +357,35 @@ async def _follow( subscription = DurableMessageSubscription(stream, channel, realtime) _ = channel.on("message", stream.live) channel.ended = stream.close + # Until the first catch-up has read, an end is raised from subscribe + # rather than reported. One that lands after the channel is ready, or + # after the read, does not make either step raise, so it is kept. + ended: list[str] = [] + + def end(reason: str) -> None: + ended.append(reason) + stream.close() + + channel.server_ended = end try: - await _subscribe(channel) + await _until_ended(channel.subscribe(), ended) channel.resubscribed = stream.resubscribed channel.dropped = stream.dropped - channel.server_ended = _leave_once_ended(stream, subscription) - await stream.catch_up() + await _until_ended(stream.catch_up(), ended) except BaseException: await subscription.unsubscribe() raise + channel.server_ended = _leave_once_ended(stream, subscription) stream.end_with(subscription.unsubscribe) return subscription -async def _subscribe(channel: DurableChannel) -> None: - # Until subscribe returns, an end is raised rather than reported. One that - # lands after the channel is ready does not make subscribe raise, so it is - # kept until then. - ended: list[str] = [] - channel.server_ended = ended.append +async def _until_ended(step: Awaitable[None], ended: list[str]) -> None: try: - await channel.subscribe() + await step except Exception as error: - # A refusal ends the subscription before readiness fails, so its reason - # is already here. + # A refusal ends the subscription before readiness fails, and an end + # closes the stream a read is on, so its reason is already here. if ended: raise RuntimeError(ended[0]) from error raise diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index 5badb190..1d5ca9eb 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -2043,6 +2043,36 @@ async def test_a_refused_subscribe_fails_with_the_server_s_reason() -> None: await client.realtime.disconnect() +async def test_an_end_during_the_first_read_fails_subscribe_with_the_server_s_reason( + reports: Reports, +) -> None: + transport = FakeMessageTransport() + transport.published = [1] + gate = asyncio.Event() + transport.next_read_gate = gate + client, native = subscriber(transport) + received = Received() + try: + subscribing = asyncio.create_task( + client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + ) + await transport.read_arrivals.wait_for(1) + await subscription_of(native).emit_unsubscribed(2000, "server unsubscribe") + gate.set() + with pytest.raises(RuntimeError, match=r"^server unsubscribe$"): + _ = await subscribing + await asyncio.sleep(0.05) + + assert received.seqs == [] + assert reports.messages == [] + assert CHANNEL not in native.subscriptions + again = Received() + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, again) + assert again.seqs == [1] + finally: + await client.realtime.disconnect() + + async def test_a_subscribe_that_never_becomes_ready_fails_with_its_error() -> None: transport = FakeMessageTransport() client, native = subscriber(transport, FirstSubscriptionClient(UnreadySubscription)) From 6a8fc6d9c6a03941dcf5c76e91cc0a692e4c3b6e Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 21:24:29 -0700 Subject: [PATCH 22/28] fix(durable): read back a durable broadcast that does not parse A publication the stream cannot read is still one the execution published. Once the stream has started it now starts a catch-up, as a dropped one does; before that the first catch-up lists it. Co-authored-by: Cursor --- src/volcano_sdk/_durable_message_stream.py | 24 +++++++--- .../_tests/test_durable_messages.py | 44 +++++++++++++++++-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/volcano_sdk/_durable_message_stream.py b/src/volcano_sdk/_durable_message_stream.py index 3a6be576..c49dd199 100644 --- a/src/volcano_sdk/_durable_message_stream.py +++ b/src/volcano_sdk/_durable_message_stream.py @@ -35,10 +35,7 @@ class _Held: def _published(data: object) -> _Held | None: - try: - message = durable_message(data) - except TypeError: - return None + message = durable_message(data) if message.direction != "out": return None return _Held(message, broadcast_prev_seq(data)) @@ -80,7 +77,8 @@ class DurableMessageStream: The channel itself drops publications while it is not subscribed, or while its callback queue is full, and reports each resubscription and - each drop, which start a catch-up of what it dropped. + each drop, which start a catch-up of what it dropped. A publication that + does not read as a message starts one too. """ def __init__( @@ -112,8 +110,8 @@ def __init__( async def live(self, data: object) -> None: """Deliver a channel publication, or hold it until a catch-up releases it.""" - held = _published(data) - if held is None or self._closed: + held = self._readable(data) + if held is None: return if not self._started or self._catch_ups > 0 or self._held: self._hold(held) @@ -124,6 +122,18 @@ async def live(self, data: object) -> None: return await self._deliver(held.message, report=True) + def _readable(self, data: object) -> _Held | None: + if self._closed: + return None + try: + return _published(data) + except TypeError: + # Still one the execution published, so the list is read for it, + # by the first catch-up when that has yet to start. + if self._started: + self._catch_up_in_background() + return None + def _hold(self, held: _Held) -> None: insort(self._held, held, key=lambda each: each.message.seq) if len(self._held) > _HELD_LIMIT: diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index 1d5ca9eb..66e86651 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -1008,14 +1008,52 @@ async def test_subscribe_ignores_messages_sent_to_the_execution() -> None: channel = subscription_of(native) await channel.emit(published(1, direction="in")) - await channel.emit({"seq": 2}) - await channel.emit(published(3)) + await channel.emit(published(2)) + + assert await received.until(1) == [2] + assert transport.read_after() == [0] + finally: + await client.realtime.disconnect() + - assert await received.until(1) == [3] +async def test_an_unreadable_publication_is_read_back() -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + received = Received() + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + transport.published = [1] + await subscription_of(native).emit({"seq": 1}) + + assert await received.until(1) == [1] + assert transport.read_after() == [0, 0] finally: await client.realtime.disconnect() +async def test_an_unreadable_publication_waits_for_the_first_read() -> None: + received = Received() + reads: list[int] = [] + + async def deliver(message: DurableMessage) -> None: + received(await completed_operation(message)) + + async def list_after(after: int) -> DurableMessagePage: + reads.append(after) + return await completed_operation( + DurableMessagePage( + messages=(durable_message(published(1)),), has_more=False + ) + ) + + stream = DurableMessageStream(deliver, list_after, lambda: True) + await stream.live({"seq": 1}) + await stream.catch_up() + + assert received.seqs == [1] + assert reads == [0] + + async def test_live_messages_wait_for_the_first_read_and_follow_it_in_order() -> None: transport = FakeMessageTransport() transport.published = [1, 2] From e5c55a7834ac1ca291b6db841b7a59b975809857 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 15 Oct 2026 22:36:54 -0700 Subject: [PATCH 23/28] docs(durable): count messages among what application credentials can do Co-authored-by: Cursor --- README.md | 3 ++- docs/functions.md | 2 +- src/volcano_sdk/durable.py | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 56d961ed..c7bab795 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,8 @@ 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 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. +takes, and is, with `durable.messages`, 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 one, and is charged once. diff --git a/docs/functions.md b/docs/functions.md index 107da8ce..79d9ea29 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -85,7 +85,7 @@ 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. +`start()` accepts the same active session, service key, or anonymous key as `functions.invoke()`. Besides [sending and following messages](#send-and-follow-messages), it is the only durable operation available to application credentials. An execution name makes a start idempotent. `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. diff --git a/src/volcano_sdk/durable.py b/src/volcano_sdk/durable.py index 014ddd38..0a75c031 100644 --- a/src/volcano_sdk/durable.py +++ b/src/volcano_sdk/durable.py @@ -361,9 +361,9 @@ def start( 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. + to follow. Besides messaging an execution through `durable.messages`, + starting is the only durable operation an application credential may + perform -- reading a result or stopping an execution is owner-scoped. Passing `execution_name` makes the start idempotent: starting again under the same name returns the execution that already exists rather From 14e071de1a9af0efffb26497b13e9de8bdc0d718 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Fri, 16 Oct 2026 06:06:40 -0700 Subject: [PATCH 24/28] fix(openapi): mark durable message data nullable Sync the contract with Hosting: send, publish and message data accept and return an explicit null. Co-authored-by: Cursor --- openapi/openapi.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 58be1c46..78f3bd4c 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -22414,6 +22414,8 @@ components: type: string data: x-go-type: json.RawMessage + x-go-type-skip-optional-pointer: true + nullable: true description: The JSON value sent or published, or null. sender: $ref: '#/components/schemas/DurableMessageSender' @@ -22497,6 +22499,7 @@ components: data: x-go-type: json.RawMessage x-go-type-skip-optional-pointer: true + nullable: true description: Any JSON value, up to 64 KiB. Omitted means null. required: - name @@ -22574,6 +22577,7 @@ components: data: x-go-type: json.RawMessage x-go-type-skip-optional-pointer: true + nullable: true description: Any JSON value, up to 64 KiB. Omitted means null. required: - name From 781496a6fa60c40441e46f9dfaf590731b6728c8 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Fri, 16 Oct 2026 08:03:30 -0700 Subject: [PATCH 25/28] fix(durable): refuse padded header values and report a server end the leave cannot finish Co-authored-by: Cursor --- docs/functions.md | 4 +- src/volcano_sdk/_durable_messages.py | 36 +++++++++++++----- .../_tests/test_durable_messages.py | 38 +++++++++++++++++++ 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index 79d9ea29..0f438567 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -288,9 +288,9 @@ for published in page.messages: | `list(function_name, execution_id, direction=..., name=..., after=..., limit=..., client_token=..., project_id=...)` | `DurableMessagePage` with `messages`, oldest first, and `has_more`. | | `await subscribe(function_name, execution_id, handler, client_token=..., project_id=...)` | A `DurableMessageSubscription` with `await unsubscribe()`. | -A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the same sender has used on the execution returns the first message and sends nothing; another sender's keys never collide with it. The key is up to 255 characters. +A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the same sender has used on the execution returns the first message and sends nothing; another sender's keys never collide with it. The key is up to 255 characters. The key and `client_token` travel as headers, so each must be printable ASCII without leading or trailing whitespace, or the call raises `ValueError`. -`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery leaves the execution's channel and raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. When the server ends the subscription, or refuses to restore it after a reconnect, as when the execution is no longer visible to the session, the subscription delivers nothing more and leaves the execution's channel, then reports `RuntimeError` with the server's reason to the event loop's exception handler once; the execution can be subscribed to again. An end or refusal before `subscribe()` returns makes it raise that `RuntimeError` instead, and nothing is reported. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: +`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery leaves the execution's channel and raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. When the server ends the subscription, or refuses to restore it after a reconnect, as when the execution is no longer visible to the session, the subscription delivers nothing more and leaves the execution's channel, then reports `RuntimeError` with the server's reason to the event loop's exception handler once; the execution can be subscribed to again. If leaving the channel fails, that error is reported too; call `unsubscribe()` before subscribing again. An end or refusal before `subscribe()` returns makes it raise that `RuntimeError` instead, and nothing is reported. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: ```python async def follow(client, execution): diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index 4d03578f..cec00d28 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -44,6 +44,7 @@ _SERVER_ENDED = ( "The server ended the durable message subscription; subscribe again to resume" ) +_LEAVE_FAILED = "Volcano could not leave the ended durable message channel" # The spec's bounds on Idempotency-Key, X-Volcano-Client-Token, and a page. _MAX_IDEMPOTENCY_KEY_LENGTH = 255 _MAX_CLIENT_TOKEN_LENGTH = 128 @@ -400,10 +401,19 @@ def _leave_once_ended( # Left before it is reported, so the report can follow the execution again. async def leave(reason: str) -> None: - await subscription.unsubscribe() - asyncio.get_running_loop().call_exception_handler( - {"message": _SERVER_ENDED, "exception": RuntimeError(reason)} - ) + try: + await subscription.unsubscribe() + finally: + asyncio.get_running_loop().call_exception_handler( + {"message": _SERVER_ENDED, "exception": RuntimeError(reason)} + ) + + def left(task: asyncio.Task[None]) -> None: + leaving.discard(task) + if not task.cancelled() and (error := task.exception()) is not None: + asyncio.get_running_loop().call_exception_handler( + {"message": _LEAVE_FAILED, "exception": error} + ) def ended(reason: str) -> None: stream.close() @@ -412,7 +422,7 @@ def ended(reason: str) -> None: # that reader may hold. task = asyncio.create_task(leave(reason)) leaving.add(task) - task.add_done_callback(leaving.discard) + task.add_done_callback(left) return ended @@ -452,16 +462,24 @@ def _bounded_text(value: object, field: str, limit: int) -> str | None: if not isinstance(value, str) or not value: message = f"{field} must be a non-empty string" raise ValueError(message) - # Sent as a header, which carries printable ASCII only. - if not (value.isascii() and value.isprintable()): - message = f"{field} must be printable ASCII" - raise ValueError(message) + _require_header_value(value, field) if len(value) > limit: message = f"{field} must be at most {limit} characters" raise ValueError(message) return value +def _require_header_value(value: str, field: str) -> None: + # Headers carry printable ASCII only, and the HTTP stack refuses a value + # with surrounding whitespace. + if not (value.isascii() and value.isprintable()): + message = f"{field} must be printable ASCII" + raise ValueError(message) + if value != value.strip(): + message = f"{field} must not start or end with whitespace" + raise ValueError(message) + + def _after(value: object) -> int | None: if value is None: return None diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index 66e86651..919b9691 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -499,6 +499,10 @@ def test_list_raises_not_found_for_an_execution_it_cannot_reach() -> None: lambda m: m.send("checkout", EXECUTION_ID, "ping", client_token="tök"), "client_token must be printable ASCII", ), + "padded client token": ( + lambda m: m.send("checkout", EXECUTION_ID, "ping", client_token=" token"), + "client_token must not start or end with whitespace", + ), "empty name": ( lambda m: m.send("checkout", EXECUTION_ID, ""), "name must not be empty", @@ -533,6 +537,12 @@ def test_list_raises_not_found_for_an_execution_it_cannot_reach() -> None: lambda m: m.send("checkout", EXECUTION_ID, "ping", idempotency_key="a\nb"), "idempotency_key must be printable ASCII", ), + "padded idempotency key": ( + lambda m: m.send( + "checkout", EXECUTION_ID, "ping", idempotency_key="confirm-addr " + ), + "idempotency_key must not start or end with whitespace", + ), "list function": ( lambda m: m.list(" ", EXECUTION_ID), "function_name must be a non-empty string", @@ -1970,6 +1980,34 @@ async def test_a_subscription_the_server_ends_is_left_and_reported_once( await client.realtime.disconnect() +async def test_an_ended_subscription_it_cannot_leave_still_reports_the_end( + reports: Reports, +) -> None: + transport = FakeMessageTransport() + client, native = subscriber(transport) + try: + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + channel = subscription_of(native) + failure = CentrifugeError("unsubscribe failed") + channel.unsubscribe_error = failure + await channel.emit_unsubscribed(2000, "server unsubscribe") + await reports.arrivals.wait_for(2) + + ended, left = reports.contexts + assert str(ended["message"]).startswith( + "The server ended the durable message subscription" + ) + assert str(ended["exception"]) == "server unsubscribe" + assert left == { + "message": "Volcano could not leave the ended durable message channel", + "exception": failure, + } + finally: + await client.realtime.disconnect() + + class EndedOnceReady(FakeSubscription): """End the subscription after it is ready, before subscribe returns.""" From 6a49cee3af724b16011051f953f612470995b1fc Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Fri, 16 Oct 2026 09:09:34 -0700 Subject: [PATCH 26/28] fix(durable): keep a failed subscription setup recoverable when leaving fails Co-authored-by: Cursor --- docs/functions.md | 2 +- src/volcano_sdk/_durable_messages.py | 33 +++++++++----- src/volcano_sdk/_realtime_durable.py | 30 ++++++++++--- .../_tests/test_durable_messages.py | 43 ++++++++++++++++++- 4 files changed, 88 insertions(+), 20 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index 0f438567..6e170c44 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -290,7 +290,7 @@ for published in page.messages: A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the same sender has used on the execution returns the first message and sends nothing; another sender's keys never collide with it. The key is up to 255 characters. The key and `client_token` travel as headers, so each must be printable ASCII without leading or trailing whitespace, or the call raises `ValueError`. -`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery leaves the execution's channel and raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. When the server ends the subscription, or refuses to restore it after a reconnect, as when the execution is no longer visible to the session, the subscription delivers nothing more and leaves the execution's channel, then reports `RuntimeError` with the server's reason to the event loop's exception handler once; the execution can be subscribed to again. If leaving the channel fails, that error is reported too; call `unsubscribe()` before subscribing again. An end or refusal before `subscribe()` returns makes it raise that `RuntimeError` instead, and nothing is reported. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: +`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error, even when leaving the channel then fails, which is reported to the event loop's exception handler; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery leaves the execution's channel and raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. When the server ends the subscription, or refuses to restore it after a reconnect, as when the execution is no longer visible to the session, the subscription delivers nothing more and leaves the execution's channel, then reports `RuntimeError` with the server's reason to the event loop's exception handler once; the execution can be subscribed to again. If leaving the channel fails, that error is reported too, and the next `subscribe()` to the execution leaves it first. An end or refusal before `subscribe()` returns makes it raise that `RuntimeError` instead, and nothing is reported. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: ```python async def follow(client, execution): diff --git a/src/volcano_sdk/_durable_messages.py b/src/volcano_sdk/_durable_messages.py index cec00d28..db05b9cc 100644 --- a/src/volcano_sdk/_durable_messages.py +++ b/src/volcano_sdk/_durable_messages.py @@ -134,7 +134,7 @@ async def subscribe(self) -> None: class DurableMessageChannels(Protocol): """The realtime connection a subscription follows an execution on.""" - def durable_channel( + async def durable_channel( self, execution_id: str, client_token: str | None ) -> DurableChannel | None: """Open the execution's channel, or None when it is already open.""" @@ -327,7 +327,9 @@ async def subscribe( stream = DurableMessageStream( deliver, self._reader(target), realtime.session_check() ) - channel = realtime.durable_channel(target.execution_id, target.client_token) + channel = await realtime.durable_channel( + target.execution_id, target.client_token + ) if channel is None: raise RuntimeError(_ALREADY_SUBSCRIBED) return await _follow(stream, channel, realtime) @@ -374,13 +376,28 @@ def end(reason: str) -> None: channel.dropped = stream.dropped await _until_ended(stream.catch_up(), ended) except BaseException: - await subscription.unsubscribe() + await _leave_after_failed_setup(subscription) raise channel.server_ended = _leave_once_ended(stream, subscription) stream.end_with(subscription.unsubscribe) return subscription +async def _leave_after_failed_setup(subscription: DurableMessageSubscription) -> None: + # subscribe raises what failed the setup, so a failure to leave is + # reported instead; the next subscription to the execution leaves it. + leaving = asyncio.ensure_future(subscription.unsubscribe()) + leaving.add_done_callback(_report_failed_leave) + _ = await asyncio.wait([leaving]) + + +def _report_failed_leave(leaving: asyncio.Future[None]) -> None: + if not leaving.cancelled() and (error := leaving.exception()) is not None: + asyncio.get_running_loop().call_exception_handler( + {"message": _LEAVE_FAILED, "exception": error} + ) + + async def _until_ended(step: Awaitable[None], ended: list[str]) -> None: try: await step @@ -408,13 +425,6 @@ async def leave(reason: str) -> None: {"message": _SERVER_ENDED, "exception": RuntimeError(reason)} ) - def left(task: asyncio.Task[None]) -> None: - leaving.discard(task) - if not task.cancelled() and (error := task.exception()) is not None: - asyncio.get_running_loop().call_exception_handler( - {"message": _LEAVE_FAILED, "exception": error} - ) - def ended(reason: str) -> None: stream.close() # Not awaited by the channel's event: the connection's reader runs it, @@ -422,7 +432,8 @@ def ended(reason: str) -> None: # that reader may hold. task = asyncio.create_task(leave(reason)) leaving.add(task) - task.add_done_callback(left) + task.add_done_callback(leaving.discard) + task.add_done_callback(_report_failed_leave) return ended diff --git a/src/volcano_sdk/_realtime_durable.py b/src/volcano_sdk/_realtime_durable.py index 07b1b5e6..a0c135d9 100644 --- a/src/volcano_sdk/_realtime_durable.py +++ b/src/volcano_sdk/_realtime_durable.py @@ -37,12 +37,18 @@ class DurableChannels(Generic[FacadeT]): def __init__(self, state: RealtimeState[FacadeT]) -> None: """Register durable channels with a client's realtime state.""" self._state: RealtimeState[FacadeT] = state + # Channels whose subscription closed but which could not be left, by + # name. Nothing else would leave them, and they would refuse every + # later subscription to their execution. + self._abandoned: dict[str, DurableChannel] = {} - def durable_channel( + async def durable_channel( self, execution_id: str, client_token: str | None ) -> ChannelState | None: """Open the channel an execution publishes on. + A channel an ended subscription could not leave is left first. + Returns ------- ChannelState | None @@ -51,6 +57,8 @@ def durable_channel( """ state = self._state wire_name = _CHANNEL_PREFIX + execution_id + if (abandoned := self._abandoned.pop(wire_name, None)) is not None: + await self.remove_durable_channel(abandoned) if wire_name in state.channels or wire_name in state.removing_channels: return None channel = ChannelState( @@ -86,12 +94,20 @@ def in_session() -> bool: return in_session async def remove_durable_channel(self, channel: DurableChannel) -> None: - """Unsubscribe and forget a channel, unless another has replaced it.""" - await self._state.remove_channel( - channel.name.removeprefix(_CHANNEL_PREFIX), - channel_type="durable", - current=channel, - ) + """Unsubscribe and forget a channel, unless another has replaced it. + + One that cannot be left is left again by the next subscription to its + execution. + """ + try: + await self._state.remove_channel( + channel.name.removeprefix(_CHANNEL_PREFIX), + channel_type="durable", + current=channel, + ) + except BaseException: + self._abandoned[channel.name] = channel + raise class ClientRealtime(Realtime): diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index 919b9691..94128fb9 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -2004,6 +2004,11 @@ async def test_an_ended_subscription_it_cannot_leave_still_reports_the_end( "message": "Volcano could not leave the ended durable message channel", "exception": failure, } + channel.unsubscribe_error = None + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + assert subscription_of(native) is not channel finally: await client.realtime.disconnect() @@ -2281,7 +2286,7 @@ async def test_leaving_after_a_removal_spares_the_channel_that_replaced_it() -> async def replace() -> object: async with state.connection_lock: - return DurableChannels(state).durable_channel(EXECUTION_ID, None) + return await DurableChannels(state).durable_channel(EXECUTION_ID, None) replacing = asyncio.create_task(replace()) await asyncio.sleep(0) @@ -2457,6 +2462,42 @@ async def test_a_failed_first_read_fails_subscribe_and_leaves_the_channel() -> N await client.realtime.disconnect() +async def test_a_failed_setup_it_cannot_leave_raises_its_own_error_and_is_left_next( + reports: Reports, +) -> None: + transport = FakeMessageTransport() + transport.read_answers = [FakeResponse(404, {"error": "execution not found"}, {})] + gate = asyncio.Event() + transport.next_read_gate = gate + client, native = subscriber(transport) + try: + subscribing = asyncio.create_task( + client.durable.messages.subscribe("checkout", EXECUTION_ID, Received()) + ) + await transport.read_arrivals.wait_for(1) + abandoned = subscription_of(native) + failure = CentrifugeError("unsubscribe failed") + abandoned.unsubscribe_error = failure + gate.set() + with pytest.raises(NotFoundError, match="execution not found"): + _ = await subscribing + + assert reports.contexts == [ + { + "message": "Volcano could not leave the ended durable message channel", + "exception": failure, + } + ] + abandoned.unsubscribe_error = None + received = Received() + transport.published = [1] + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, received) + assert received.seqs == [1] + assert subscription_of(native) is not abandoned + finally: + await client.realtime.disconnect() + + async def test_a_failing_handler_fails_subscribe_and_leaves_the_channel() -> None: transport = FakeMessageTransport() transport.published = [1] From 94d9cbb1e2bedeebe692d222fc4e4754ac904d98 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Fri, 16 Oct 2026 10:26:14 -0700 Subject: [PATCH 27/28] test(durable): cap local runner runs so a retried step fails fast Co-authored-by: Cursor --- src/volcano_sdk/_tests/test_durable_authoring.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/volcano_sdk/_tests/test_durable_authoring.py b/src/volcano_sdk/_tests/test_durable_authoring.py index 3d6b0c95..6d2c22e4 100644 --- a/src/volcano_sdk/_tests/test_durable_authoring.py +++ b/src/volcano_sdk/_tests/test_durable_authoring.py @@ -430,8 +430,11 @@ def run_handler(handler: FunctionHandler, event: object = None) -> object: Raises: AssertionError: If the handler fails. """ + # A step retried with backoff would otherwise run to the 15-minute default. with local_runner(handler) as runner: - result = runner.run(input=json.dumps({} if event is None else event)) + result = runner.run( + input=json.dumps({} if event is None else event), timeout=15 + ) if result.error is not None: raise AssertionError(result.error.message) return None if result.result is None else json.loads(result.result) @@ -447,7 +450,9 @@ def failing_handler(handler: FunctionHandler, event: object = None) -> str: AssertionError: If the handler succeeds. """ with local_runner(handler) as runner: - result = runner.run(input=json.dumps({} if event is None else event)) + result = runner.run( + input=json.dumps({} if event is None else event), timeout=15 + ) if result.error is None: raise AssertionError(_EXPECTED_FAILURE) return str(result.error.message or "") @@ -504,7 +509,7 @@ def handler(_event: object, ctx: DurableContext) -> object: return ctx.child("fulfil", lambda child: child.step("ship", lambda _s: "ok")) with local_runner(handler) as runner: - result = runner.run(input=json.dumps({})) + result = runner.run(input=json.dumps({}), timeout=15) assert result.error is None assert result.get_step("charge").name == "charge" From 4ec7095a4af727718bd5d529184cfba10520f656 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Fri, 16 Oct 2026 10:57:34 -0700 Subject: [PATCH 28/28] fix(durable): keep a message target's client token out of its repr Co-authored-by: Cursor --- src/volcano_sdk/_tests/test_durable_messages.py | 14 ++++++++++++++ src/volcano_sdk/_transport_types.py | 7 ++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/volcano_sdk/_tests/test_durable_messages.py b/src/volcano_sdk/_tests/test_durable_messages.py index 94128fb9..13ef55d9 100644 --- a/src/volcano_sdk/_tests/test_durable_messages.py +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -635,6 +635,20 @@ def test_a_send_may_use_the_whole_length_of_each_bound() -> None: ) +def test_a_message_target_keeps_its_client_token_out_of_its_repr() -> None: + transport = FakeMessageTransport() + + _ = messages_client(transport).durable.messages.send( + "checkout", EXECUTION_ID, "address-confirmed", client_token="client-token" + ) + + target = transport.calls[0][1]["target"] + assert isinstance(target, DurableMessageTarget) + assert target.client_token == "client-token" + assert "client-token" not in repr(target) + assert EXECUTION_ID in repr(target) + + @pytest.mark.parametrize(("after", "limit"), [(0, 1), (7, 500)]) def test_a_list_may_page_from_the_start_and_up_to_500(after: int, limit: int) -> None: transport = FakeMessageTransport() diff --git a/src/volcano_sdk/_transport_types.py b/src/volcano_sdk/_transport_types.py index d08405d3..0f673c08 100644 --- a/src/volcano_sdk/_transport_types.py +++ b/src/volcano_sdk/_transport_types.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import ( TYPE_CHECKING, ParamSpec, @@ -196,8 +196,9 @@ class DurableMessageTarget: execution_id: str # The project route when set; the application route otherwise. project_id: str | None = None - # Only the application route reads it. - client_token: str | None = None + # Only the application route reads it. A bearer credential, so repr omits + # it, as it does from DurableExecution. + client_token: str | None = field(default=None, repr=False) @dataclass(frozen=True, slots=True)