Repository navigation
feat(durable): wait for, publish, send, and subscribe to durable messages - #391
subnetmarco wants to merge 18 commits into
Conversation
…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
left a comment
There was a problem hiding this comment.
Matches JS closely: same publish key, same stream algorithm, same retry rules. Comments:
- Subscribe and the client token.
subscribeacceptsclient_tokenbut its docstring says the realtime connection needs a signed-in session. The public docs and thevolcano-durableskill both show a Python subscribe that passes onlyclient_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), anddurable_authoringopens one session per context against the server's cap of 20, soctx.mapover 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>
|
Addressed in 58b4f2c.
|
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
AI review: 1 P0-P2 finding(s)Adds durable message authoring, REST operations, and realtime subscriptions. A startup race can return a subscription that has already stopped receiving messages. 🟡 P2
⚪ P3
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.
Push a fix, or have a collaborator with write access (including the PR author) add the Human review: needed
Decide the points above: approve to accept them, or request changes. Suggested reviewer: |
…ch-up Co-authored-by: Cursor <cursoragent@cursor.com>
…wait refusals Co-authored-by: Cursor <cursoragent@cursor.com>
…ivery and catch-ups Co-authored-by: Cursor <cursoragent@cursor.com>
… dropped publications End a subscription once the client signs out, signs in again, or sets another session, before any further read or delivery, as Postgres delivery does. Catch up when a full callback queue drops a publication, coalescing catch-ups requested while one waits its turn, and follow the execution's channel under its canonical UUID. Co-authored-by: Cursor <cursoragent@cursor.com>
…t later handler errors A handler that raises during the first read, or a first read that fails, now closes the stream before anything held is delivered, and a stream closed while the first read runs fails subscribe(). Later handler errors are reported and delivery goes on; only failed reads back off. Live messages held during a catch-up are capped at 128, a retry replaces the one pending, and a closed stream holds nothing. Unbounded test waits now time out. Co-authored-by: Cursor <cursoragent@cursor.com>
…hannel once it changes Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…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>
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
left a comment
There was a problem hiding this comment.
Second pass at 2cfdab0. The earlier points are resolved:
subscriberefuses up front without a signed-in session.- The stream catches up when
prev_seqshows 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_unsubscribedonly callstransport_lost(). That covers an unsubscribe push, and a resubscribe after a reconnect that's refused because the function's visibility changed. channel.endedonly runs fromreset(), 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 newsubscribefor 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. - When the server ends the subscription,
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_currentchecks 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>
|
Done in 30111f5. A subscription the server ends now closes, leaves its channel, and reports once, like JS's
Ruff, mypy, basedpyright, the policy check, and the full suite at 100% line and branch coverage pass. Mutation on |
| channel.resubscribed = stream.resubscribed | ||
| channel.dropped = stream.dropped | ||
| # Before this, subscribe raises the server's refusal instead. | ||
| channel.server_ended = _leave_once_ended(stream, subscription) |
There was a problem hiding this comment.
🟡 P2: Register server-end handling before awaiting subscription readiness
RealtimeState._wait_subscription_readiness() awaits a separate task. If the server unsubscribes after wait_subscription() checks channel.subscribed but before its caller resumes, ChannelEvents.on_unsubscribed() finds server_ended still unset and loses the termination. If the subsequent HTTP catch-up succeeds, subscribe() returns a permanently inactive subscription without reporting an error, and another subscription is refused until this one is removed. Install the termination handler before awaiting channel.subscribe(), or retain termination events until it is installed.
Found by gpt-6.1-sol
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.
ctx.wait_for_message(name, timeout=...)suspends until a message with that name arrives, and returnsNoneon timeout.ctx.publish(name, data)is a checkpointed step returning the message'sidandseq. The first publish in a context opens a messages session through a callback; each publish sendssha256(session token):positionas its idempotency key, where the position counts publishes in that context, so a rerun after a crash returns the original message.client.durable.messages.send(function, execution_id, name, data, client_token=..., idempotency_key=...),list(...)withdirection,name,afterandlimit, andawait subscribe(..., handler). Subscribe catches up from the list and follows the execution's realtime channel on the client's existing connection. Passingproject_iduses the project route.client_tokentheir creating start returned.ClientRealtimesubclass, and the durable code depends on a smallDurableChannelprotocol.docs/functions.mdand the README are updated. The OpenAPI client is regenerated.wait_for_messageandpublish.Verification
uv run --locked poe checks: 2,865 tests, 100% coverage, and 100% type completeness inpackage-typesscripts/mutation.sh) for every changed module, including_durable_messages,_realtime_durable,_durable_message_stream,_durable_message_response,_transport_durable_messages,_realtime_connection,realtime,clientanddurable_authoring: no surviving mutantsCross-language impact
Classify this change: public facade / wire contract / shared behavior
tests/sdk-contract/features/durable-messages.feature, with bindings for all three languages, in volcano-hosting#1827.docs/functions.mdand the README here; JavaScript and Ruby in their PRs;docs/public/functions/durable-messages.mdin Hosting.