Skip to content

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

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

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

Conversation

@subnetmarco

@subnetmarco subnetmarco commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Tracking

Tracks: volcano-hosting#1826 (server support for durable messages)

Summary

  • Authoring. ctx.waitForMessage(name, { timeout }) suspends until a message with that name arrives, and resolves null on 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 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. Publish data with a NUL in any string or key is refused before the step runs, as in Python.
  • Client. volcano.durable.messages.send(target, name, data, { idempotencyKey }), list(target, { direction, name, after, limit }) and subscribe(target, handler, { realtime }). target is { functionName, executionId, clientToken?, projectId? }; with projectId the calls use the project route and the session, without it the application route and the client credential.
  • Subscribe follows the execution's durable: realtime channel, delivers in seq order once each, and lists from the last seq after 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.
  • The execution start returns now includes client_token when that start created it.
  • Approvals and message callbacks now share one platform request path (durable-platform-request.ts), with the same 30-second retry on brief refusals.
  • OpenAPI synced from Hosting, with the new operations added to the orval allowlist. The README, docs/durable-functions.md and docs/realtime.md are updated.
  • Purely additive. Release Please should cut 2.1.0, which the Hosting docs name as the minimum for waitForMessage and publish.

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 examples
  • Stryker over the 14 changed durable and realtime modules. The first run left survivors in four of them (durable-message-stream, durable-message-response, durable-messages-facade, durable-platform-request); after the fixes those four score 100.
  • Under Node 24, the tooling test "native reporter preserves cancelled case" (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

  • Requirement IDs and affected behavior: SDK-DURABLE-011 to 020, covering message waits (queued and live), timeout, published-message order, gap-free subscribe, user isolation, client-token anon sends, idempotent sends, ended executions, and read-only tokens.
  • JavaScript / Python / Ruby companion PRs, or why each is unaffected: volcano-sdk-python#391 (authoring and client) 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/durable-functions.md, docs/realtime.md and the README here; the Python and Ruby docs in their PRs; docs/public/functions/durable-messages.md in Hosting.
  • Native checks and shared acceptance evidence (with tested revisions): native checks above on 1495a2d. 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.

subnetmarco and others added 2 commits October 13, 2026 03:17
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 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.

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 (the readPages loop) and durable-platform-request.ts:118. If a mutant flips the has_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 advance lastSeq, 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. deliver drops seq <= 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() calls messagePublisher for every child, map item and parallel branch, and the server caps sessions at 20, so ctx.map over 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_mb and the other new models come from hosting main, not from this feature. That's fine if intended, but the changelog entry from Release Please will credit them to durable messages. A separate chore: sync openapi commit would keep the history honest.

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

subnetmarco commented Oct 14, 2026 •

Copy link
Copy Markdown
Member Author

Addressed in a45a877.

  • Mutation gate. It's now at 100% on durable-message-stream.ts, durable-message-response.ts and durable-platform-request.ts, with no timeouts. The fixes are in the code as well as the tests:
    • readPages stops when a page delivers nothing new, not only when has_more is false. A page that doesn't advance lastSeq is never requested again.
    • postToPlatform returns a success before it decides whether to retry. A mutant can no longer turn a success or a final refusal into a 30s retry loop.
    • The list mocks answer once, so a read that keeps going fails instead of spinning. The runtime test's responder returns a final 400 for any URL it doesn't know.
  • Gaps. hosting#1826 now sends each broadcast with prev_seq, the seq published before it. When prev_seq > lastSeq, the stream holds the live message, catches up from the list, then releases it. A failed catch-up still releases the held message. Resubscribing after a reconnect also catches up. Tests cover a lost broadcast, out-of-order arrival, and a non-integer prev_seq (read as no gap).
  • Sessions per context. We kept one session per context and raised the cap on hosting to 1,000 sessions over the execution's life, the same as the publish cap, so the 3,000-operation limit is the real bound. Reusing the parent's session would have needed publish keys built from the context path and more replay handling in every SDK. The hosting docs and the agent skills give the per-item cost and tell users to publish from the parent across a ctx.map.
  • OpenAPI sync. It's already a separate commit, 9c157af chore(api): sync OpenAPI contract. This repo only allows squash merges, though, so the separate commit won't survive into the changelog. The sandbox memory_mb changes it brings in only touch src/generated, which is internal; no public facade changes with them. If you'd rather the history credit them separately, I can open a sync-only PR from hosting main to land first and rebase this branch onto it. That needs a force-push here, so say if you want it.

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

# Conflicts:
#	openapi/openapi.yaml
#	src/generated/openapi.d.ts
@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
Copilot AI balanced review requested due to automatic review settings October 14, 2026 23:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions
github-actions Bot requested a review from swkeever October 14, 2026 23:46
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

AI review: no P0-P2 findings

Reviewed 5f9a30f. Run log

Adds durable messages to the SDK. ctx.waitForMessage and a checkpointed ctx.publish (with a per-context messages session and replay-stable idempotency keys) are added to the authoring API. On the client, volcano.durable.messages.send/list/subscribe is added, with a gap-filling realtime stream. Approval registration is folded into a shared platform-request helper. I read the hand-written source closely: the publish ordering, session reuse, NUL and size checks, the retry and close paths of the stream's catch-up, and the realtime channel wiring. I found no defects. The main risk is the new credential handling (client tokens and session tokens) and the reliance on server behavior in volcano-hosting#1826.

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.

  • That Hosting completes session and wait callbacks with the payload shapes the SDK parses, and broadcasts prev_seq on durable: channels.
  • That the realtime server accepts client_token in subscribe data for durable: channels and enforces the visibility rules the docs describe.
  • The shared acceptance scenarios in volcano-hosting#1827 have not run against this revision.
  • That the docs' links to /platform/functions/durable-messages and the volcano durable messages send CLI command exist where they point.
  • Verify integration at this head against Kong/volcano-hosting#1826 and #1827, particularly token isolation, replay idempotency, and realtime gap recovery.

Human review: needed

  • Should an execution's client_token travel in the realtime subscribe payload (realtime-client.ts) and as X-Volcano-Client-Token, given it lets an anon key reach the execution?
  • Should app and anon credentials be able to message executions and read their output (durable-messages-facade.ts), when starting was their only durable operation until now?
  • Should execution client tokens authorize message sends, reads, and realtime subscriptions, and callback-issued session tokens authorize publishing (src/durable-messages-facade.ts, src/durable-message…

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: swkeever (primary maintainer; touched the realtime docs, OpenAPI sync and build config this PR changes). Any collaborator with write access other than the author can approve.

Comment thread src/durable-message-stream.ts Outdated
Comment thread src/durable-message.ts Outdated
Comment thread src/durable-message-stream.ts
Comment thread src/durable-messages-facade.ts Outdated
Comment thread src/durable-platform-request.ts Outdated
…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>
Comment thread src/durable-message-stream.ts Outdated
Comment thread src/durable-message-stream.ts Outdated
…the first catch-up

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/durable-messages-facade.ts
…t subscribes

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/durable-message-stream.ts
…after a failed first read

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/durable-messages-facade.ts
Comment thread src/durable-message-stream.ts Outdated
subnetmarco and others added 2 commits October 15, 2026 04:15
…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>
Comment thread src/durable-messages-facade.ts
Comment thread openapi/openapi.yaml Outdated
subnetmarco and others added 8 commits October 15, 2026 06:16
…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 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 17c8532. The earlier points are resolved:

  • The mutation gate is at 100% with no timeouts.
  • readPages stops at a page that delivers nothing new.
  • The stream holds a message and catches up when prev_seq shows 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_seq points 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 in internal/realtime unsubscribes when an execution finishes. So leaveIfServerEnded only 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.
  • 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 readsAnswer is false, postToPlatform returns without touching the response body. In Node, an unread fetch body holds its connection until GC; await response.body?.cancel() before settled() frees it. It's only a few requests per execution, so this is minor.
  • Parity nit: Python's ctx.publish refuses 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>
@subnetmarco

Copy link
Copy Markdown
Member Author

Addressed in fc42644.

  • Unread answers. When the answer isn't read, postToPlatform now cancels the body before it settles. A failed cancel is ignored: Volcano already answered, so it's no reason to send the request again. Tests cover a cancelled body, a cancel that throws, and a 201 with no body.
  • NUL parity. ctx.publish now refuses data with a NUL in any string or key before the session opens, with ctx.publish() data must not contain NUL characters. The check walks the data rather than searching the JSON text, so a string that spells out \u0000 still goes through. docs/durable-functions.md lists it with the other refusals.
  • Description. It now says the publish key is sha256(session token):position. I'm fixing the Python PR's wording too.

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: volcano durable messages send ships with Kong/volcano-cli#302, which merges with this feature.

…nnot outlive its test

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

Copy link
Copy Markdown
Member Author

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 durable.messages.subscribe tests now run on fake timers, which end with each test. durable-message-stream.ts now scores 100% with no errors, and pnpm lint and pnpm test pass.

Comment thread src/durable-message.ts Outdated
Comment thread src/durable-message.ts Outdated
subnetmarco and others added 2 commits October 15, 2026 19:39
…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>
Comment thread src/durable-messages-facade.ts Outdated
subnetmarco and others added 2 commits October 15, 2026 22:36
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>

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.

2 participants