diff --git a/README.md b/README.md index f2c09a0c..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. @@ -282,6 +283,40 @@ 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 client holding only an anonymous or service key gets +`RuntimeError`, even with the `client_token`; it can poll `list()` with `after` +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). + ## Write a durable function `volcano_sdk.durable_authoring` is what the durable function itself is written @@ -351,6 +386,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..6e170c44 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. @@ -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`. 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 `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`, `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()`. | + +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, 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): + 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`, 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 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 31feffa2..78f3bd4c 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -4496,6 +4496,203 @@ 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 + same sender has used on the execution 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, + `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: + $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 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: + $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, 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: + $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 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: + $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' + '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: @@ -5766,63 +5963,454 @@ paths: $ref: '#/components/schemas/Error' '503': description: | - Durable execution is not available in this environment. Either the - capability is paused or this deployment cannot serve it, so the - request is not one to retry in a loop. + Durable execution is not available in this environment. Either the + capability is paused or this deployment cannot serve it, so the + request is not one to retry in a loop. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-functions/{functionId}/executions/{executionId}/stop: + post: + tags: + - Durable Functions + summary: Stop a durable execution + description: | + Cancels a running execution. Its completed steps are not undone. + + The call is accepted rather than awaited: cancellation happens behind + it, so the response reports the execution as it was read back and may + still say `running`. Do not branch on that status — the execution + settles into `stopped` shortly after, and polling + `GET /projects/{id}/durable-functions/{functionId}/executions/{executionId}` + is how you see it get there. + + Stopping an execution that already finished is not an error: the + response carries the state it settled in. + operationId: stopDurableExecution + security: + - UserToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableFunctionId' + - $ref: '#/components/parameters/DurableExecutionId' + responses: + '200': + description: | + Stop accepted. The body is the execution as it was read back, which + may still report `running`. + content: + application/json: + schema: + $ref: '#/components/schemas/DurableExecution' + '404': + description: Durable function or execution not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Execution has not started yet + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: | + Durable execution is not available in this environment. Either the + capability is paused or this deployment cannot serve it, so the + request is not one to retry in a loop. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-functions/{functionId}/executions/{executionId}/messages: + post: + tags: + - Durable Functions + summary: Send a message to a durable execution + description: | + 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. + 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/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`, `delivering`, or `queued`, + as on the application route. + 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' + '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: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + 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 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 + 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. + + Project access tokens can list messages, including read-only ones. + operationId: listDurableMessages + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $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/DurableMessageList' + '400': + description: A filter is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '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: List a durable execution's open message waits + description: | + 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. + operationId: listDurableMessageWaits + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableFunctionId' + - $ref: '#/components/parameters/DurableExecutionId' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/DurableMessageWaitList' + '404': + description: Durable function or execution not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /durable-messages/waits: + post: + tags: + - Durable Functions + summary: Wait for a message from inside a workflow + description: | + 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`. + 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. + - `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. + 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`, `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: + $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' - /projects/{id}/durable-functions/{functionId}/executions/{executionId}/stop: + /durable-messages/publish: post: tags: - Durable Functions - summary: Stop a durable execution + summary: Publish a message from inside a workflow description: | - Cancels a running execution. Its completed steps are not undone. + Publishes a named message from a running workflow. The Volcano SDK + calls this from `ctx.publish`; applications do not call it directly. - The call is accepted rather than awaited: cancellation happens behind - it, so the response reports the execution as it was read back and may - still say `running`. Do not branch on that status — the execution - settles into `stopped` shortly after, and polling - `GET /projects/{id}/durable-functions/{functionId}/executions/{executionId}` - is how you see it get there. + 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. - Stopping an execution that already finished is not an error: the - response carries the state it settled in. - operationId: stopDurableExecution - security: - - UserToken: [] + 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: - - $ref: '#/components/parameters/ProjectId' - - $ref: '#/components/parameters/DurableFunctionId' - - $ref: '#/components/parameters/DurableExecutionId' + - 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: | - Stop accepted. The body is the execution as it was read back, which - may still report `running`. + description: A message with this `Idempotency-Key` was already published; it is returned content: application/json: schema: - $ref: '#/components/schemas/DurableExecution' - '404': - description: Durable function or execution not found + $ref: '#/components/schemas/DurableMessage' + '201': + description: Message published + content: + application/json: + schema: + $ref: '#/components/schemas/DurableMessage' + '400': + description: The session token header is missing, or the name, data or idempotency key is invalid content: application/json: schema: $ref: '#/components/schemas/Error' - '409': - description: Execution has not started yet + '401': + description: The session token is not valid content: application/json: schema: $ref: '#/components/schemas/Error' - '503': + '409': description: | - Durable execution is not available in this environment. Either the - capability is paused or this deployment cannot serve it, so the - request is not one to retry in a loop. + 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: @@ -5885,6 +6473,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. @@ -15735,6 +16325,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 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 + 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 @@ -20382,6 +21028,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 @@ -21723,6 +22375,213 @@ 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 + x-go-type-skip-optional-pointer: true + nullable: true + 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 + nullable: 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 + nullable: true + description: Any JSON value, up to 64 KiB. Omitted means null. + required: + - name + additionalProperties: false Issue: type: object description: Runtime errors from one function or frontend, grouped by error type and stack. @@ -24885,6 +25744,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. @@ -25015,6 +25910,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..869c4e90 --- /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, with a UUID in its canonical form. + + 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 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: + """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..e890d85a --- /dev/null +++ b/src/volcano_sdk/_durable_message_response.py @@ -0,0 +1,149 @@ +"""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 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. + + 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..c49dd199 --- /dev/null +++ b/src/volcano_sdk/_durable_message_stream.py @@ -0,0 +1,302 @@ +"""Ordered, once-each delivery of the messages a durable execution publishes.""" + +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 + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from .models import DurableMessage, DurableMessagePage + +_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) +class _Held: + message: DurableMessage + prev_seq: int + + +def _published(data: object) -> _Held | None: + message = durable_message(data) + if message.direction != "out": + return None + return _Held(message, broadcast_prev_seq(data)) + + +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} + ) + + +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, + 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, or + while its callback queue is full, and reports each resubscription and + 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__( + 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`. + + `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._leave: Callable[[], Awaitable[None]] | None = None + self._last_seq: int = 0 + self._started: bool = False + self._catch_ups: int = 0 + 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._handling: 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.""" + held = self._readable(data) + if held is None: + return + if not self._started or self._catch_ups > 0 or self._held: + self._hold(held) + return + if held.prev_seq > self._last_seq: + self._hold(held) + self._catch_up_in_background() + 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: + del self._held[0] + + 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._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. + + 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, 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(first=first)) + self._tasks.add(task) + 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. + + 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, *, 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(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 + if first and self._closed: + raise RuntimeError(_ENDED) + finally: + self._catch_ups -= 1 + + 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, report=report) + + async def _read_pages(self, *, report: bool) -> None: + while not self._closed: + 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. + if not page.has_more or self._last_seq == after: + return + + 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, report=report) + finally: + if read and not self._held: + self._failures = 0 + else: + self._retry_later() + + def _retry_later(self) -> None: + self._cancel_retry() + 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, *, 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: + if self._closed or message.seq <= self._last_seq: + return + await self._require_session() + self._last_seq = message.seq + if not report: + await self._handler(message) + return + (error,) = await asyncio.gather( + 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() + 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 new file mode 100644 index 00000000..db05b9cc --- /dev/null +++ b/src/volcano_sdk/_durable_messages.py @@ -0,0 +1,518 @@ +"""Send, list, and follow the messages of durable executions.""" + +from __future__ import annotations + +import asyncio +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_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 +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" +_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" +) +_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 +_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"} + + +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 + # 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 + + @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.""" + + 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.""" + ... + + 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`.""" + + 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 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, or that already holds 100 untaken messages, + raises `ConflictError`. + + Returns + ------- + DurableMessage + The stored message: `queued`, `delivered`, or `delivering` while + its handover to a wait is still being confirmed. + + """ + known_options(options, _SEND_OPTIONS) + target = _target(function_name, execution_id, options) + request = DurableMessageSendRequest( + name=operation_name(name), + data=data, + idempotency_key=_bounded_text( + options.get("idempotency_key"), + "idempotency_key", + _MAX_IDEMPOTENCY_KEY_LENGTH, + ), + ) + 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") + name = options.get("name") + request = DurableMessageListRequest( + direction=None if direction is None else _direction(direction), + 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, + 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; a client token or service key alone cannot open + it. Signing out, signing in again, or setting another session ends the + subscription and leaves the channel; a token refresh does not. + + Returns + ------- + DurableMessageSubscription + The subscription, once the messages published so far are delivered. + + Raises + ------ + RuntimeError + 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) + target = _target(function_name, execution_id, options) + deliver = _deliverer(handler) + 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 + stream = DurableMessageStream( + deliver, self._reader(target), realtime.session_check() + ) + 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) + + 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) + 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 _until_ended(channel.subscribe(), ended) + channel.resubscribed = stream.resubscribed + channel.dropped = stream.dropped + await _until_ended(stream.catch_up(), ended) + except BaseException: + 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 + except Exception as error: + # 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 + if ended: + raise RuntimeError(ended[0]) + + +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: + try: + await subscription.unsubscribe() + finally: + 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) + task.add_done_callback(_report_failed_leave) + + return ended + + +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=_bounded_text( + options.get("client_token"), "client_token", _MAX_CLIENT_TOKEN_LENGTH + ), + ) + + +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) + _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 + if type(value) is not int or value < 0: + message = "after must be a non-negative integer" + raise ValueError(message) + return value + + +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 value + + +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/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..2cf358eb --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/list_durable_messages_from_application.py @@ -0,0 +1,359 @@ +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 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[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..2a49a741 --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/send_durable_message.py @@ -0,0 +1,321 @@ +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 == 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( + 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..bed60a85 --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/send_durable_message_from_application.py @@ -0,0 +1,394 @@ +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 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[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 + same sender has used on the execution 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 + same sender has used on the execution 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 + same sender has used on the execution 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 + same sender has used on the execution 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/models/__init__.py b/src/volcano_sdk/_generated/models/__init__.py index 15421c77..a49fb1ac 100644 --- a/src/volcano_sdk/_generated/models/__init__.py +++ b/src/volcano_sdk/_generated/models/__init__.py @@ -236,6 +236,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 @@ -371,6 +382,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 @@ -524,6 +536,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 @@ -532,6 +545,7 @@ 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 @@ -570,6 +584,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 @@ -867,6 +882,17 @@ "DurableFunction", "DurableFunctionConfig", "DurableFunctionStatus", + "DurableMessage", + "DurableMessageDirection", + "DurableMessageList", + "DurableMessageSender", + "DurableMessageSenderType", + "DurableMessageSessionRegistration", + "DurableMessageStatus", + "DurableMessageWait", + "DurableMessageWaitList", + "DurableMessageWaitRegistration", + "DurableMessageWaitStatus", "EmailTemplate", "EmailTemplateTemplateType", "Error", @@ -1002,6 +1028,7 @@ "OAuthConfigProvider", "OAuthErrorResponse", "OpenAPISpecDocument", + "OpenDurableMessageSessionRequest", "PaginatedAuthUsers", "PaginatedDatabases", "PaginatedDurableApprovals", @@ -1155,6 +1182,7 @@ "ProjectTemplateInstallationPhase", "ProjectTemplateInstallationStatus", "ProjectUsageResponse", + "PublishDurableMessageRequest", "PublishSandboxPresetRequest", "PublishSandboxPresetRequestMemoryMb", "PublishSandboxPresetRequestPreset", @@ -1163,6 +1191,7 @@ "RealtimeStats", "RefreshOAuthProviderTokenProvider", "RefreshOAuthProviderTokenResponse200", + "RegisterDurableMessageWaitRequest", "RenameVariableEnvironmentRequest", "RenderDefaultManagedAuthPageAction", "ReplaceFrontendSharedVariablesBody", @@ -1201,6 +1230,7 @@ "SandboxTemplateStatus", "ScheduleRequest", "ScheduleRequestKind", + "SendDurableMessageRequest", "ServiceKey", "SetProjectGitProductionBranchRequest", "StartGitConnectProvider", 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/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/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/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/_realtime_channel.py b/src/volcano_sdk/_realtime_channel.py index b58bc7c7..87e487c4 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,12 +139,21 @@ 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: - 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: @@ -179,9 +188,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 +199,22 @@ 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 + # 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. + 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]] = {} self.presence_state: dict[str, RealtimePresenceInfo] = {} self.presence_events: list[tuple[str, RealtimePresenceInfo]] = [] @@ -258,6 +282,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}" @@ -536,6 +561,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: @@ -628,7 +655,12 @@ async def _run_callback( if inspect.isawaitable(result): await result + 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 9ed25e96..1dd6d17b 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,14 +383,17 @@ async def remove_channel( self, name: str, *, - channel_type: ChannelType = "broadcast", + channel_type: ChannelKind = "broadcast", + current: object | None = None, ) -> None: - """Unsubscribe and forget one broadcast or presence channel.""" - channel_type = validate_channel_type(channel_type) + """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) @@ -429,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() @@ -593,7 +599,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..a0c135d9 --- /dev/null +++ b/src/volcano_sdk/_realtime_durable.py @@ -0,0 +1,125 @@ +"""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 collections.abc import Callable + + 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 + # 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] = {} + + 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 + The new channel, or None when this connection already has it. + + """ + 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( + 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 + + 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. + + 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): + """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..75b7b939 --- /dev/null +++ b/src/volcano_sdk/_tests/fixtures/durable_messages.py @@ -0,0 +1,194 @@ +"""Volcano's durable message routes, faked for handlers on the local runner.""" + +from __future__ import annotations + +import hashlib +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" +# 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] + + +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..6d2c22e4 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 @@ -414,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) @@ -431,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 "") @@ -488,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" @@ -1696,7 +1717,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 +1769,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 +1986,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..fbcd6ff6 --- /dev/null +++ b/src/volcano_sdk/_tests/test_durable_message.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +import hashlib +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" +# 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" +) +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..13ef55d9 --- /dev/null +++ b/src/volcano_sdk/_tests/test_durable_messages.py @@ -0,0 +1,2727 @@ +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 centrifuge import CentrifugeError +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 ( + broadcast_prev_seq, + durable_message, + durable_message_page, +) +from volcano_sdk._durable_message_stream import DurableMessageStream, retry_delay +from volcano_sdk._realtime_durable import DurableChannels +from volcano_sdk._transport import ( + DurableMessageListRequest, + DurableMessageSendRequest, + DurableMessageTarget, + GeneratedTransport, +) +from volcano_sdk.durable import Durable + +from .client_inspection import InspectedClient +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 completed_operation, realtime_state +from .test_realtime import FakeCentrifugeClient, FakeCentrifugeFactory, FakeSubscription +from .transport_fixtures import RejectingTransport + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, 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, + # 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_answers: list[FakeResponse] = [] + 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_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: + _ = 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, native: FakeCentrifugeClient | None = None +) -> tuple[VolcanoClient, FakeCentrifugeClient]: + if native is None: + 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 + # 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({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", + ), + "long client token": ( + 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", + ), + "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", + ), + "blank name": ( + lambda m: m.send("checkout", EXECUTION_ID, " "), + "name must not be empty", + ), + "long name": ( + lambda m: m.send("checkout", EXECUTION_ID, "n" * 238), + "name must be at most 237 characters", + ), + "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=""), + "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", + ), + "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", + ), + "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", + ), + "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 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", + ), +} + + +@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_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() + name, key, token = "n" * 237, "k" * 255, "t" * 128 + + _ = messages_client(transport).durable.messages.send( + "checkout", EXECUTION_ID, name, idempotency_key=key, client_token=token + ) + + assert transport.calls[0][1]["request"] == DurableMessageSendRequest( + name=name, idempotency_key=key + ) + assert transport.calls[0][1]["target"] == DurableMessageTarget( + function_id="checkout", execution_id=EXECUTION_ID, client_token=token + ) + + +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() + + _ = messages_client(transport).durable.messages.list( + "checkout", EXECUTION_ID, after=after, limit=limit + ) + + assert transport.calls[0][1]["request"] == DurableMessageListRequest( + after=after, limit=limit + ) + + +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_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 + ) + + _ = 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) + + +@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] + 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(published(2)) + + assert await received.until(1) == [2] + assert transport.read_after() == [0] + finally: + await client.realtime.disconnect() + + +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] + 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_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] + 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_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 asyncio.wait_for(finish.wait(), timeout=2) + 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 asyncio.wait_for(handling.wait(), timeout=2) + 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 asyncio.wait_for(second.wait(), timeout=2) + 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] + 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_answers = [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_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() + + +class Retries: + """Stand in for the retry backoff, noting each call.""" + + 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.delays.pop(0) if self.delays else 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() + 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)) + + 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() + + +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_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() + 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] + assert reports.messages == ["Volcano durable message handler failed"] + assert reports.contexts[0]["exception"] is failure + assert retries.failures == [] + 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 dispatched(client) + gate.set() + _ = await subscribing + + assert await received.until(3) == [1, 2, 3] + assert transport.read_after() == [0, 1] + 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_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 == 3: + raise failure + + try: + _ = await client.durable.messages.subscribe("checkout", EXECUTION_ID, handle) + 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(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 == [] + 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() + + +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 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 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 + 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() + + +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)) + 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) + + 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() + + +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 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) + 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( + ("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_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, + } + 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() + + +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 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) + + +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( + self, + name: str, + *, + events: object, + join_leave: bool = False, + recoverable: bool = False, + data: Mapping[str, str] | None = None, + ) -> FakeSubscription: + first = self.first + if first is None: + return super().new_subscription( + name, + events=events, + join_leave=join_leave, + recoverable=recoverable, + data=data, + ) + self.first = None + subscription = first( + 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] + client, native = subscriber(transport, FirstSubscriptionClient(EndedOnceReady)) + 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_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_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)) + 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: + 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], +) -> None: + transport = FakeMessageTransport() + transport.published = listed + 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 +) -> 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_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) + 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 await 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: + 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_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) + 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_answers = [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 + _ = await client.durable.messages.subscribe( + "checkout", EXECUTION_ID, Received() + ) + finally: + 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] + 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_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_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"}, {})] + 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() + transport = FakeMessageTransport() + client = VolcanoClient( + anon_key="anon-key", + service_key=service_key, + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(native), + ) + try: + for _attempt in range(2): + with pytest.raises(RuntimeError, match=r"^subscribe\(\) needs a signed-in"): + _ = await client.durable.messages.subscribe( + "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() + + +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_answers = [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..dc80eabd 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 @@ -531,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)) @@ -608,6 +614,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 +625,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 +705,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 +3937,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 +3945,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/_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/_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..28930f24 --- /dev/null +++ b/src/volcano_sdk/_transport_durable_messages.py @@ -0,0 +1,157 @@ +"""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, plain_json + +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: + # 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: + 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..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, @@ -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,38 @@ 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. 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) +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..0a75c031 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() @@ -371,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 @@ -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..c463eea0 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,83 @@ 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. 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 + + +class DurableMessageSendOptions(DurableMessageTargetOptions, total=False): + """Route and idempotency for sending a durable message.""" + + # A repeat of the same key by the same sender 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..9a4c177b 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -269,12 +269,21 @@ 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.""" + """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() 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: ...