Repository navigation
feat(durable): send, wait for, publish, and subscribe to durable messages - #319
subnetmarco wants to merge 25 commits into
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
…ages Executions wait for messages with ctx.waitForMessage and publish progress with ctx.publish. Clients send and list messages through durable.messages, on the application route with the client credential and an execution's client token, or on the project route with the session. durable.messages.subscribe delivers what an execution publishes in seq order, once each, reading back what the realtime channel missed after each resubscription. Approval and message callbacks now share one platform request path. Co-authored-by: Cursor <cursoragent@cursor.com>
subnetmarco
left a comment
There was a problem hiding this comment.
Read the authoring side (durable-message.ts, the durable.ts context wiring, durable-platform-request.ts), the stream and the facade. The hold/catch-up/release logic in DurableMessageStream is careful work. Comments:
- The mutation gate is red, and it's a real finding. Shard 8 has 8 timeouts:
durable-message-stream.ts:73-87(thereadPagesloop) anddurable-platform-request.ts:118. If a mutant flips thehas_more/empty-page exit or the closed-code check, the loop never ends or the request retries for 30s, so the tests hang instead of failing. Bounding the loop helps the code too: if a page doesn't advancelastSeq, stop rather than re-request it forever. And give the closed-code test a fake clock (or a responder that fails after a couple of calls) so a retrying mutant gets killed instead of timing out. - Gap handling depends on #1826.
deliverdropsseq <= lastSeq, so a broadcast the server loses, or two that arrive out of order, are skipped permanently on an open subscription (details on #1826). Whatever the server ends up sending to make gaps detectable, the stream needs to catch up when it sees one. - One session per context.
durableContext()callsmessagePublisherfor every child, map item and parallel branch, and the server caps sessions at 20, soctx.mapover 21+ items that publish fails. Each session also costs 3 operations and a resume. If children can reuse the parent's session, key publishes by context path + position. If they can't, this needs a higher cap on the server. - The synced OpenAPI brings unrelated changes. Sandbox session/execution
memory_mband the other new models come from hostingmain, not from this feature. That's fine if intended, but the changelog entry from Release Please will credit them to durable messages. A separatechore: sync openapicommit would keep the history honest.
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Addressed in a45a877.
|
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # openapi/openapi.yaml # src/generated/openapi.d.ts
AI review: no P0-P2 findingsAdds durable messages to the SDK. 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.
Human review: needed
The AI review found no P0-P2 problems at 5f9a30f, so the code needs no second review for bugs. Decide the points above: approve to accept them, or request changes. Suggested reviewer: |
…ch-up A live message whose prev_seq the stream has not seen is held until a catch-up read covers the gap. A failed read, or a handler that throws, is retried with backoff from 0.5s up to 30s; a new catch-up cancels a pending retry and close stops it. A refusal whose body is not JSON still reports its status. Co-authored-by: Cursor <cursoragent@cursor.com>
…the first catch-up Co-authored-by: Cursor <cursoragent@cursor.com>
…t subscribes Co-authored-by: Cursor <cursoragent@cursor.com>
…after a failed first read Co-authored-by: Cursor <cursoragent@cursor.com>
…at ends while catching up
Read the message of the realtime server's { code, message } refusal and
keep it as the error's cause. subscribe resolves with an error when the
subscription ends during its first catch-up. A handler that throws during
a later read-back is reported and the read goes on; only failed reads
back off. A closed stream ignores broadcasts and holds nothing.
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…cally send() and list() now hold a message name to the 237 printable ASCII characters ctx.publish and ctx.waitForMessage accept, a client token to 128 characters, direction to in or out, after to a non-negative integer, and limit to 1 to 500, as the Python and Ruby SDKs do. 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>
clientToken and idempotencyKey travel as headers, so a non-ASCII or control character now fails with an error naming the field before the request is built. Co-authored-by: Cursor <cursoragent@cursor.com>
An unsubscribed channel the SDK did not pause was ended by the server, for example by refusing a resubscription. The subscription now logs it with console.error and leaves the channel, so the execution can be subscribed to again. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
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 17c8532. The earlier points are resolved:
- The mutation gate is at 100% with no timeouts.
readPagesstops at a page that delivers nothing new.- The stream holds a message and catches up when
prev_seqshows a gap. - Hosting raised the session cap to 1,000.
I re-read the stream, the facade, durable-message.ts, durable-platform-request.ts and the context wiring, and checked them against hosting#1826. Nothing blocking:
- I traced the gap handling against the server's numbering, including a racing rerun that numbers a seq it never inserts. In that case the next broadcast's
prev_seqpoints to a seq that doesn't exist. The catch-up lists past it and delivers the real message, then drops the held copy as already seen. Good. - On the AI review's open questions:
- hosting#1826 never ends a
durable:subscription itself: nothing ininternal/realtimeunsubscribes when an execution finishes. SoleaveIfServerEndedonly logs on a real refusal. - The session token isn't exposed through the API either. The trace returns operation kind and timing, not callback results.
- hosting#1826 never ends a
- The description says each publish sends its step's operation id as the idempotency key. The code uses
sha256(session token):position, which is the right choice, but the description should say so. The Python PR has the same wording. - When
readsAnsweris false,postToPlatformreturns without touching the response body. In Node, an unreadfetchbody holds its connection until GC;await response.body?.cancel()beforesettled()frees it. It's only a few requests per execution, so this is minor. - Parity nit: Python's
ctx.publishrefuses data containing a NUL before anything runs. Here the server answers 400 instead, after the session has opened, and the publish step fails. The outcome is the same, it just fails later.
…shed data Co-authored-by: Cursor <cursoragent@cursor.com>
|
Addressed in fc42644.
The mutation gate is at 100% on both changed files (329 killed). Lint, formatting, types and the full suite (3300 tests) pass. On the AI review's CLI question: |
…nnot outlive its test Co-authored-by: Cursor <cursoragent@cursor.com>
|
The mutation job failed in 77cca25's parent because Stryker counted a RuntimeError as an error, not a test failure. A subscribe retry left running by one mutant fired real timers during a later mutant's test in the same reused runner. The |
Co-authored-by: Cursor <cursoragent@cursor.com>
…s place Co-authored-by: Cursor <cursoragent@cursor.com>
A publication the stream cannot read is still one the execution published. It now starts a catch-up, unless one following the subscribe or still waiting its turn will list it, instead of being skipped until the next gap. Co-authored-by: Cursor <cursoragent@cursor.com>
Delivery ignored what a handler returned, so an async handler that rejected left an unhandled rejection. Its promise is still not waited for, but a rejection is now logged like a throw. The handler type returns unknown, so an async handler type-checks and passes no-misused-promises. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Tracking
Tracks: volcano-hosting#1826 (server support for durable messages)
Summary
ctx.waitForMessage(name, { timeout })suspends until a message with that name arrives, and resolvesnullon timeout.ctx.publish(name, data)is a checkpointed step that resolves{ id, seq }. The first publish in a context opens a messages session through a callback (3 operations, once). 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. Publish data with a NUL in any string or key is refused before the step runs, as in Python.volcano.durable.messages.send(target, name, data, { idempotencyKey }),list(target, { direction, name, after, limit })andsubscribe(target, handler, { realtime }).targetis{ functionName, executionId, clientToken?, projectId? }; withprojectIdthe calls use the project route and the session, without it the application route and the client credential.durable:realtime channel, delivers inseqorder once each, and lists from the lastseqafter each resubscription to fill what the channel missed. The channel sends the client token in its subscribe request; this isn't exposed on the public realtime API.startreturns now includesclient_tokenwhen that start created it.durable-platform-request.ts), with the same 30-second retry on brief refusals.docs/durable-functions.mdanddocs/realtime.mdare updated.waitForMessageandpublish.Verification
Run under Node 22.23.3 (
.node-version):pnpm quality:policy && pnpm quality:checks: audit, lint, unused code, OpenAPI drift, types, tooling, 3,245 Jest tests, typed runtime, package quickstart and examplesdurable-message-stream,durable-message-response,durable-messages-facade,durable-platform-request); after the fixes those four score 100.node-completeness.test.mjs) times out; it passes on 22, which CI pins. This PR doesn't touch.quality-tools.Cross-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/durable-functions.md,docs/realtime.mdand the README here; the Python and Ruby docs in their PRs;docs/public/functions/durable-messages.mdin Hosting.