Skip to content

feat(durable): wait for, publish, send, and subscribe to durable messages - #391

Open
subnetmarco wants to merge 24 commits into
mainfrom
feat/durable-messages
Open

subnetmarco wants to merge 24 commits into
mainfrom
feat/durable-messages

Conversation

@subnetmarco

@subnetmarco subnetmarco commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Change

Durable functions can wait for messages and publish their own, and applications can send, list and follow them. Server support is in volcano-hosting#1826.

  • Authoring. ctx.wait_for_message(name, timeout=...) suspends until a message with that name arrives, and returns None on timeout. ctx.publish(name, data) is a checkpointed step returning the message's id and seq. The first publish in a context opens a messages session through a callback; each publish sends sha256(session token):position as its idempotency key, where the position counts publishes in that context, so a rerun after a crash returns the original message.
  • Client. client.durable.messages.send(function, execution_id, name, data, client_token=..., idempotency_key=...), list(...) with direction, name, after and limit, and await subscribe(..., handler). Subscribe catches up from the list and follows the execution's realtime channel on the client's existing connection. Passing project_id uses the project route.
  • Executions expose the client_token their creating start returned.
  • Internal realtime types stay out of public signatures: the client builds a ClientRealtime subclass, and the durable code depends on a small DurableChannel protocol.
  • docs/functions.md and the README are updated. The OpenAPI client is regenerated.
  • Purely additive. Release Please should cut 0.15.0, the minimum the Hosting docs name for wait_for_message and publish.

Verification

  • uv run --locked poe checks: 2,865 tests, 100% coverage, and 100% type completeness in package-types
  • Mutation testing (scripts/mutation.sh) for every changed module, including _durable_messages, _realtime_durable, _durable_message_stream, _durable_message_response, _transport_durable_messages, _realtime_connection, realtime, client and durable_authoring: no surviving mutants

Cross-language impact

Classify this change: public facade / wire contract / shared behavior

  • Requirement IDs and affected behavior: SDK-DURABLE-011 to 020 (message waits, timeout, publish order, gap-free subscribe, user isolation, client-token anon sends, idempotent sends, ended executions, read-only tokens).
  • JavaScript / Python / Ruby companion PRs, or why each is unaffected: volcano-sdk-js#319 and volcano-sdk-ruby#343 (client only, since Ruby has no durable authoring API).
  • Canonical scenarios, bindings and active/staged copies: tests/sdk-contract/features/durable-messages.feature, with bindings for all three languages, in volcano-hosting#1827.
  • Public examples updated in each affected language: docs/functions.md and the README here; JavaScript and Ruby in their PRs; docs/public/functions/durable-messages.md in Hosting.
  • Native checks and shared acceptance evidence (with tested revisions): native checks above on 990f833. Shared acceptance hasn't run yet; it runs from volcano-hosting#1827 once the three SDK PRs merge and volcano-hosting#1826 is on staging.

…ages

Add ctx.wait_for_message and ctx.publish to durable authoring, and
client.durable.messages with send, list, and an awaitable subscribe that
catches up from the list and follows the execution's realtime channel.
Subscriptions share the client's realtime connection. Executions expose
the client_token their creating start returned.

Co-authored-by: Cursor <cursoragent@cursor.com>

@subnetmarco subnetmarco left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matches JS closely: same publish key, same stream algorithm, same retry rules. Comments:

  • Subscribe and the client token. subscribe accepts client_token but its docstring says the realtime connection needs a signed-in session. The public docs and the volcano-durable skill both show a Python subscribe that passes only client_token, which reads as though an anon client can follow an execution the way it can in JS. Either support it, or have the docs and skill say that Python subscribe needs a signed-in user (and whether a service key works), and raise a clear error instead of failing at the realtime layer.
  • Same two issues as JS: the stream drops seq <= last_seq, so a lost or reordered broadcast is skipped for good (see #1826), and durable_authoring opens one session per context against the server's cap of 20, so ctx.map over more than 20 publishing items fails.
  • The OpenAPI sync pulls in unrelated models (variable environments, sandbox fields). Same suggestion as JS: a separate chore: commit so the release notes don't credit them to this feature.

Co-authored-by: Cursor <cursoragent@cursor.com>
@subnetmarco

Copy link
Copy Markdown
Member Author

Addressed in 58b4f2c.

  • Subscribe and the client token. subscribe now checks for a signed-in session before touching the channel. Without one it raises RuntimeError: subscribe() needs a signed-in session to open the realtime connection; a client token or service key alone cannot, with no subscription made and no list read. A service key doesn't work either; the test covers both cases. The README, docs/functions.md, the hosting page (hosting#1826) and the volcano-durable skill (volcano-skills#59) now say that a client holding only the client token or a service key can poll list with after.
  • Gaps. Each broadcast now carries prev_seq. When it's ahead of the last delivered seq, the stream holds the message, reads the gap from the list in the background, then releases it in order. A failed read is reported to the loop's exception handler and the held message is still delivered. A resubscription catches up the same way. Reads also stop at a page that delivers nothing new, so a server repeating a page can't loop it. New tests cover a lost broadcast, a late arrival, a non-integer prev_seq, and a first broadcast without one.
  • Sessions. Hosting raised the cap to 1,000 per execution over its life, the same as the publish cap. The docs and the skill give the per-item cost and say to publish from the parent across a ctx.map. We kept one session per context: reusing the parent's would need publish keys built from the context path and more replay handling in each SDK.
  • Mutation. _durable_message_stream, _durable_message_response and _durable_messages score 100% with no timeouts. Two test-harness fixes made that possible. dispatched() waits with asyncio.wait, so a handler running on the dispatcher can't deadlock it by cancelling it. The fake transport's canned list answers are each given once, so a read that keeps going falls through to the published pages instead of spinning.
  • OpenAPI sync. It's part of 990f833 here. The models it adds only touch volcano_sdk/_generated, which isn't public API. Splitting it into a chore: commit means rewriting that commit and force-pushing, and it only shows up in the release notes if the PR is rebase-merged rather than squashed. Say if you want that and I'll do it.

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	openapi/openapi.yaml
#	src/volcano_sdk/_generated/api/system/list_capabilities.py
#	src/volcano_sdk/_generated/models/__init__.py
#	src/volcano_sdk/_generated/models/capability.py
#	src/volcano_sdk/_generated/models/frontend_custom_domain_response.py
#	src/volcano_sdk/_generated/models/project_frontend_custom_domain.py
@subnetmarco
subnetmarco marked this pull request as ready for review October 14, 2026 23:37
@subnetmarco
subnetmarco requested a review from a team as a code owner October 14, 2026 23:37
Comment thread src/volcano_sdk/_tests/fixtures/durable_messages.py Outdated
Comment thread src/volcano_sdk/_tests/test_durable_message.py Outdated
@github-actions
github-actions Bot requested a review from swkeever October 14, 2026 23:45
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

AI review: no P0-P2 findings

Reviewed e5c55a7. Run log

This PR adds durable messages. Authoring gains ctx.wait_for_message and ctx.publish. The client gains durable.messages.send, list, and a realtime-backed subscribe that delivers messages in order, once each, and is bound to the signed-in session. It also moves approval registration into a shared helper for platform requests. I read all the hand-written production code closely and only skimmed the generated client and the tests. I found no defects. The feature is additive; the main risks are its wire contract with an unmerged Hosting PR and the concurrency in the subscription stream.

Not checked: The AI review read the diff, the code, and the PR's text; it ran no tests or builds and read nothing outside this repository.

  • Whether Hosting (volcano-hosting#1826) broadcasts on durable:<execution id> with full DurableMessage fields and prev_seq as the stream assumes, and accepts client_token as subscribe data.
  • Whether a real Centrifugo server raises on_unsubscribed for an unpaused channel only when it ends or refuses a subscription, which the SDK now reports as server-ended.
  • The shared acceptance scenarios in volcano-hosting#1827 have not run against these changes.
  • Whether the volcano durable messages send CLI command the docs reference exists.
  • Whether a messages session really costs three durable operations, as the docs claim.

Human review: needed

  • Is it OK that the messages-session token, which authorizes publishing as the execution, is persisted in the execution's checkpoint history (durable_authoring.py _session_token)?
  • Should anon/service keys holding an execution's client_token reach it, with the token sent as a header and as realtime subscribe data (_durable_messages.py, _realtime_durable.py)?
  • Should execution client tokens grant anonymous callers and other users access to execution messages through HTTP and realtime (_durable_messages.py, _realtime_durable.py)?

The AI review found no P0-P2 problems at e5c55a7, so the code needs no second review for bugs. Decide the points above: approve to accept them, or request changes.

Suggested reviewer: swkeever (wrote most of realtime.py and _transport.py, which the subscription stream builds on). Any collaborator with write access other than the author can approve.

Comment thread src/volcano_sdk/_durable_message_stream.py Outdated
Comment thread src/volcano_sdk/_realtime_durable.py
Comment thread src/volcano_sdk/_durable_message_stream.py Outdated
Comment thread src/volcano_sdk/_durable_message_stream.py Outdated
Comment thread src/volcano_sdk/_transport_durable_messages.py Outdated
Comment thread src/volcano_sdk/_durable_messages.py
Comment thread src/volcano_sdk/_realtime_durable.py
Comment thread docs/functions.md Outdated
subnetmarco and others added 2 commits October 15, 2026 00:02
…ch-up

Co-authored-by: Cursor <cursoragent@cursor.com>
…wait refusals

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/volcano_sdk/_durable_message_stream.py
Comment thread src/volcano_sdk/_realtime_durable.py
Comment thread src/volcano_sdk/_realtime_durable.py
Comment thread src/volcano_sdk/_durable_message_stream.py Outdated
…ivery and catch-ups

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/volcano_sdk/_durable_message_stream.py Outdated
Comment thread src/volcano_sdk/_durable_message_stream.py
…hannel once it changes

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/volcano_sdk/_durable_messages.py Outdated
Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/volcano_sdk/models.py Outdated
Comment thread src/volcano_sdk/_durable_message_stream.py
Comment thread openapi/openapi.yaml Outdated
…ally

send() and list() now hold a message name to the 237 printable ASCII
characters ctx.publish and ctx.wait_for_message accept, a client token to
128 characters, after to a non-negative integer, and limit to 1 to 500, so
a request the server would refuse fails before it is sent.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/volcano_sdk/_durable_messages.py
Comment thread src/volcano_sdk/_durable_message_stream.py Outdated
subnetmarco and others added 6 commits October 15, 2026 06:30
Brings in sender-scoped idempotency, the delivering status, and every refusal the message routes return.

Co-authored-by: Cursor <cursoragent@cursor.com>
DurableExecution.client_token is None outside the start that created the execution, so the documented client_token=execution.client_token now typechecks.

Co-authored-by: Cursor <cursoragent@cursor.com>
idempotency_key and client_token travel as headers, so a non-ASCII or control character now raises a ValueError naming the field instead of failing inside httpx.

Co-authored-by: Cursor <cursoragent@cursor.com>
An unsubscribe or a session change queued before the handler's task skips the handler. A handler raising on a message the first read held also closes the stream, as a failed read does.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

@subnetmarco subnetmarco left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass at 2cfdab0. The earlier points are resolved:

  • subscribe refuses up front without a signed-in session.
  • The stream catches up when prev_seq shows a gap.
  • Reads stop at a page that delivers nothing new.
  • Mutation is at 100% with no timeouts.

I re-read _durable_message.py, _durable_message_stream.py, _durable_messages.py, _realtime_durable.py and the DurableContext changes against hosting#1826 and the JS SDK. Publish keys and positions, session reuse after a failed open, the callback name and the wait result all match JS. One thing worth fixing:

  • A subscription the server ends goes silent. The AI review's P3 is real:

    • When the server ends the subscription, ChannelEvents.on_unsubscribed only calls transport_lost(). That covers an unsubscribe push, and a resubscribe after a reconnect that's refused because the function's visibility changed.
    • channel.ended only runs from reset(), which only removal and disconnect reach.
    • So the stream stays open and the handler never hears anything again.
    • The channel also stays in state.channels, so a new subscribe for that execution raises "already subscribes" until the caller unsubscribes.

    JS handles this in leaveIfServerEnded, which logs and removes the channel. Doing the same here would keep the SDKs aligned: close the stream, report through the loop's exception handler, and remove the channel. Add a test that ends the subscription from the server side.

Smaller:

  • The description still says each publish sends its step's operation id as the idempotency key. The code uses sha256(session token):position, the same as JS.
  • On the AI review's questions:
    • The checkpointed session token isn't readable through the API. The trace returns operation kind and timing, not callback results, and hosting stores only the token's hash.
    • Postgres delivery already ends a subscription on the next read or delivery after a session change, so binding durable messages the same way is consistent. _handle_if_current checks the session before it calls the handler, so a changed session never gets a message. That looks fine to me.

Co-authored-by: Cursor <cursoragent@cursor.com>
@subnetmarco

Copy link
Copy Markdown
Member Author

Done in 30111f5. A subscription the server ends now closes, leaves its channel, and reports once, like JS's leaveIfServerEnded.

  • ChannelEvents.on_unsubscribed uses the same test as JS. The SDK pauses or detaches a channel before it unsubscribes one, so an unsubscribe that reaches a current, unpaused channel came from the server. That covers both an unsubscribe push and a resubscribe refused after a reconnect. It calls the channel's new server_ended hook with the server's reason. Other channel types don't set the hook and behave as before.
  • The durable subscription sets the hook only after its first subscribe succeeds; before that, subscribe() still raises the refusal. The hook closes the stream at once. It then leaves the channel in a task of its own and reports RuntimeError(<reason>) to the loop's exception handler as "The server ended the durable message subscription; subscribe again to resume". The leave isn't awaited inside the event: centrifuge runs that event on the connection's reader, and leaving takes the connection lock, which a publish waiting on that reader may hold. The report comes after the leave, so a handler can subscribe again right away.
  • Tests cover an unsubscribe push (2000) and a refused resubscribe (103): nothing more is delivered, the channel is gone, there's one report, and a new subscribe gets the backlog. Another test checks that an unsubscribe the client makes itself isn't taken for the server's. Both fail against the previous code.
  • docs/functions.md and the README describe it.
  • The description now gives the publish key as sha256(session token):position.

Ruff, mypy, basedpyright, the policy check, and the full suite at 100% line and branch coverage pass. Mutation on _durable_messages and _realtime_channel leaves no survivors (144 and 250 killed).

Comment thread src/volcano_sdk/_durable_messages.py Outdated
Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/volcano_sdk/_durable_messages.py Outdated
…scribe

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/volcano_sdk/_durable_messages.py Outdated
Comment thread src/volcano_sdk/_realtime_channel.py
subnetmarco and others added 3 commits October 15, 2026 21:24
A native unsubscribe that failed left the channel's reset undone, so a durable stream's catch-up still in flight went on reading and delivering after remove_all_channels raised. Removal now ends the channel's followers first.

Co-authored-by: Cursor <cursoragent@cursor.com>
An end during subscribe's first catch-up made subscribe raise a generic error and also reported the reason to the loop. It is now kept until the read returns and raised from subscribe, as an end before readiness is, and nothing is reported.

Co-authored-by: Cursor <cursoragent@cursor.com>
A publication the stream cannot read is still one the execution published. Once the stream has started it now starts a catch-up, as a dropped one does; before that the first catch-up lists it.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread docs/functions.md

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant