Skip to content

Add stateless subscriptions example with listen-based e2e - #9

Open
yhxlele wants to merge 4 commits into
feat/go-sdk-v1.7.0from
feat/stateless-listen-e2e
Open

Add stateless subscriptions example with listen-based e2e#9
yhxlele wants to merge 4 commits into
feat/go-sdk-v1.7.0from
feat/stateless-listen-e2e

Conversation

@yhxlele

@yhxlele yhxlele commented Aug 4, 2026

Copy link
Copy Markdown

Stacked on #8 (go-sdk v1.7.0 bump) — the follow-up it deferred.

Adds examples/subscriptions/cmd/subscriptions-stateless: the tasks subscription wiring served with StreamableHTTPOptions{Stateless: true, PropagateRequestCancellation: true} — the deployment shape for horizontally scaled servers behind a plain round-robin load balancer, and the only mode in which the Go SDK speaks protocol revision 2026-07-28.

What the e2e suite pins

  • 2026-07-28 negotiated over plain HTTP via server/discover (the connect helper fails if a session ever falls back below it), with normal tool round-trips.
  • Listen-based subscription delivery: resources/subscribe doesn't exist on this path — ClientSession.Subscribe opens a per-URI subscriptions/listen stream, the server's SubscribeHandler still fires per URI (same ACL gate as the legacy path), and ResourceUpdated pushes flow back over the stream. Two concurrent clients on the same URI prove per-connection fan-out.
  • Teardown: Unsubscribe ends the listen stream, UnsubscribeHandler fires, and further mutations deliver nothing.
  • PropagateRequestCancellation: aborting the HTTP request mid-call cancels the in-flight handler context (proven with an instrumented blocking tool).
  • Legacy coexistence: the same stateless endpoint still answers a classic initialize from pre-2026 clients (echoing their requested version), and GET is rejected with 405/Allow: POST per the stateless contract.

Subscription registration is asynchronous under subscriptions/listen (the client dispatches without awaiting), so delivery tests mutate in a poll loop until the notification arrives rather than mutating once — the suite passed three consecutive -race -count=3 runs with no flakes.

Docs: new "Serving stateless" section in examples/subscriptions/README.md (why no session affinity is needed: subscription state lives on the connection; a dropped stream is re-issued by the client to whichever replica answers next; feed every replica from a shared event source), plus main README examples-table and cmd-list entries.

Verification

  • go test -race -count=1 ./... green (new suite: 6 tests, also run at -count=3 for flake detection)
  • golangci-lint run 0 issues; go mod tidy no-op (no new dependencies)

@yhxlele
yhxlele requested a review from jiachengxu August 4, 2026 07:27
Comment thread examples/subscriptions/README.md Outdated
@yhxlele
yhxlele force-pushed the feat/stateless-listen-e2e branch from 1da5197 to 2b95c26 Compare August 5, 2026 06:20
Comment thread examples/subscriptions/README.md Outdated
still believes it is subscribed — recovery is `Unsubscribe` (which
clears that client-side entry) followed by a fresh `Subscribe`, and any
replica can answer the new stream. A client that must survive
connection drops therefore needs its own liveness signal — e.g. a

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think periodic re-read cannot be used as liveness signal here, since each ReadResource is a separate stateless POST and it can succeed even when the subscriptions/listen connection is dead. If resource stays unchanged, client has no way to notice the dead stream and will never run the Unsubscribe + Subscribe recovery, could we call this a polling/reconciliation fallback instead, and use a missed heartbeat timeout or periodic re-subscribe for liveness?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Right again. A re-read is its own stateless POST, so it succeeds whether or not the listen stream is alive; with an unchanged resource it proves nothing, and even when the resource did change it detects the missed update after the fact, not the stream's health. Reworded in 760c89e: liveness now means a signal that rides the stream itself (a missed-heartbeat timeout on a subscribed heartbeat resource, or an unconditional periodic re-subscribe), and periodic re-reads are called what they are, a reconciliation fallback that bounds staleness but cannot detect a dead stream.

clears that client-side entry) followed by a fresh `Subscribe`, and any
replica can answer the new stream. A client that must survive
connection drops therefore needs a liveness signal that rides the
stream itself: a subscribed heartbeat resource the server touches on an

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think the heartbeat has to be sent on each watched URI's own stream, since ClientSession.Subscribe opens a separate subscriptions/listen POST for each URI, and a separate heartbeat subscription can keep arriving while watched URI's stream is already dead. Could we recommend either emitting a periodic update for every watched URI, or unconditionally Unsubscribe + Subscribe each URI, and update the same guidance in main.go?

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