Skip to content

S2 as stream backend: cost analysis, architecture options, protocol mapping, CDN collapsing, implementation reference#43

Draft
scottmessinger with Copilot wants to merge 12 commits into
mainfrom
copilot/implement-s2-storage
Draft

S2 as stream backend: cost analysis, architecture options, protocol mapping, CDN collapsing, implementation reference#43
scottmessinger with Copilot wants to merge 12 commits into
mainfrom
copilot/implement-s2-storage

Conversation

Copilot AI commented Feb 15, 2026

Copy link
Copy Markdown
Contributor

Evaluation of S2 (managed or s2-lite) as the storage layer replacing Cloudflare Durable Objects + SQLite + R2. Analyzes four architecture options against actual S2 pricing, with a detailed Durable Streams ↔ S2 protocol mapping and a complete implementation reference for the recommended option.

docs/14-elixir-s2-implementation.md

S2 Auth Model

  • S2 has per-stream access tokens via AccessTokenScope.streams (exact match or prefix)
  • Separate read/write permissions at stream level via op_groups.stream
  • Token expiry, auto-prefixing, and revocation supported

S2 Native SSE

  • S2's read endpoint returns text/event-stream when Accept: text/event-stream is set
  • SSE events are typed: batch (records), error, ping (keepalive)

S2 Cost Analysis

  • Honest cost analysis using actual S2 pricing (ReadSession at $0.000001/min)
  • 10K concurrent SSE readers = $432/mo without fan-out vs Cloudflare's ~$18/mo with CDN
  • Writes are negligible ($0.26/mo at 1 write/sec)

Four Architecture Options

  • Option 1: Clients hit S2 directly — Zero custom code, works for server-to-server.
  • Option 2: Thin protocol adapter (~300 lines) — Auth, CORS, DS ↔ S2 protocol translation. Viable under 1K readers or with s2-lite.
  • Option 3: Adapter + read fan-out (~500-800 lines) — Collapses N readers into 1 S2 read session per stream.
  • Option 4: CDN collapsing + SSE fan-out (~500 lines, recommended) — CDN collapses long-poll reads, adapter fans out SSE. Handles both modes at scale, same dual-mode approach as the current CF implementation.

Durable Streams ↔ S2 Protocol Mapping

  • Full translation table: append, read, SSE, long-poll, HEAD, DELETE
  • Offset mapping, Stream-Cursor generation for CDN collapsing, producer fencing

docs/15-s2-adapter-implementation.md (NEW)

Complete implementation reference for Option 4 — no prose, just code and diagrams:

  • File tree — 13 Elixir source files + Dockerfile + K8s manifests
  • Supervision tree — Phoenix Endpoint, Registry, DynamicSupervisor, Finch pool
  • S2Client — Full HTTP client: append, read, read_session_stream, check_tail, create, delete
  • Protocol — DS ↔ S2 offset/header/cursor translation with cache control headers
  • StreamHub — GenServer per stream: 1 S2 ReadSession → N SSE clients, ring buffer for catch-up
  • StreamSession — Task consuming S2's native SSE stream, parsing events, forwarding to Hub
  • Auth — JWT verification with scope checking
  • Router — Phoenix routes matching DS URL pattern (/v1/stream/:basin/*stream_path)
  • StreamController — Create, append, catch-up read, long-poll (with Cache-Control + Stream-Cursor for CDN collapsing), HEAD, delete
  • SseController — Chunked SSE streaming with fan-out subscription + keepalive
  • Configuration — runtime.exs, mix.exs (6 deps)
  • Dockerfile — Multi-stage Alpine build
  • K8s manifests — Deployment (3 replicas) + Service + Ingress (SSE timeout config)
  • Request flow diagrams for all 6 operations (write, catch-up, long-poll, SSE, HEAD, delete)
  • Memory estimate — 20-50MB per pod at 33 active streams + 3,333 SSE clients

docs/00-index.md

Updated with entries for both Chapter 14 and Chapter 15.


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Feb 15, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
durable-streams-demo-draw 0f4f75f Commit Preview URL

Branch Preview URL
Feb 17 2026, 06:32 PM

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>
Copilot AI changed the title [WIP] Add S2 storage implementation in Elixir app Add Elixir + S2 implementation design document Feb 15, 2026
Copilot AI requested a review from scottmessinger February 15, 2026 16:40
@scottmessinger

Copy link
Copy Markdown
Member

@copilot You missed the memo here. It seems like you're trying to make a port instead of solving the goals more creatively. There are a couple goals:

  • scalable writes. I want to not use my database to handle the writes. It can be spikey and I want something I don't have to worry about or deploy (object storage, like DO are great in this respect)
  • avoiding the thundering herd: in a typical setup where the state is kept in memory, on a redeploy, all the client reconnect to tell the server their subscriptions and get new updates. That makes deployments risky. By having state which is not in memory and on services that aren't often redeployed and where the state can be long polled or won't hit a DB (like with s2), we can avoid this problem.
  • a client protocol which supports real time (via SSE) and long polling. I want clients who go offline or lose their SSE connection to be able to quickly come back to a real time feed. I like the durable stream protocol more, but I'm not tied to it if the s2 protocol solves the same problems.

I'm interested in s2 because it's not our DB and it's not DO. I'm interested in object storage. It's possible using slatedb (lower level) is fine, too.

Having one instance is a non-starter. This must scale to several instances. Please rethink this working from my goals and first principles.

Copilot AI and others added 2 commits February 17, 2026 13:58
…mory state

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>
…hutdown, docs clarity

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>

Copilot AI commented Feb 17, 2026

Copy link
Copy Markdown
Contributor Author

@copilot You missed the memo here. It seems like you're trying to make a port instead of solving the goals more creatively. There are a couple goals:

  • scalable writes. I want to not use my databas...

Rewrote the design from scratch in 053d8f8. The previous version was a port — GenServer-per-stream, in-memory state, single-instance s2-lite. The new version works from your goals:

Stateless Elixir app. No GenServers, no in-memory state. The app is a thin HTTP layer (~250-500 lines) that proxies to S2. Any instance can handle any request.

S2 does the heavy lifting. Writes go directly to S2 (object storage). SSE connections each open an S2 readSession that tails the stream natively — no broadcast infrastructure needed in our code. Long-poll uses S2's waitSecs parameter — the storage layer holds the connection until new data arrives.

No thundering herd. Redeploying Elixir nodes loses nothing because there's nothing in memory. Clients reconnect to any instance with their seqNum and S2 picks up where they left off.

Multi-instance from day one. Multiple Elixir replicas behind a load balancer. No distributed Erlang, no coordination. S2 (managed) handles horizontal scaling of the storage layer.

Two fan-out options. Option B (recommended): clients read source streams directly — S2 handles read fan-out internally. Option A: session stream copies for when you need per-subscriber filtering.

Managed S2 for production. s2-lite is for dev/test only. Managed S2 scales horizontally with no code changes.

@scottmessinger

Copy link
Copy Markdown
Member

@copilot if we're using s2 for so much, why not hit it directly? Also, I don't think the s2's economics are awesome for this use case. Please point why we need elixir and what it's doing. Also, find if there are ways to convert s2 to durable stream's protocol.
CleanShot 2026-02-17 at 09 43 42
image

Copilot AI and others added 2 commits February 17, 2026 14:50
…a read fan-out

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>
…eader count thresholds

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>

Copilot AI commented Feb 17, 2026

Copy link
Copy Markdown
Contributor Author

@copilot if we're using s2 for so much, why not hit it directly? Also, I don't think the s2's economics are awesome for this use case. Please point why we need elixir and what it's doing. Also, find i...

Rewrote in 8ad616c to directly address all three questions.

Why not hit S2 directly? You can — it's Option 1 in the doc. Works for server-to-server. Doesn't work for browser clients (no per-stream auth, no CORS, S2 tokens in client code). For those you need at minimum a thin adapter (Option 2, ~300 lines).

S2 economics. Did the math with your pricing screenshots. ReadSession at $0.000001/min means 10K SSE readers = $432/mo — worse than Cloudflare's $18/mo. This is the exact same problem as DO duration billing (Chapter 2). The fix is the same too: read fan-out. One S2 session per active stream → broadcast to N clients → cost becomes O(streams) not O(readers). That's Option 3, and it's the only reason a middleware layer is justified at scale.

s2-lite changes the equation entirely. Self-hosted = no per-call pricing. Option 2 (thin adapter) works at any reader count because read sessions are free. The only cost is the K8s pod (~$30/mo). This may be the pragmatic path for your K8s deployment.

Protocol mapping. Full Durable Streams ↔ S2 translation table is in the doc. The mapping is clean: seqNum ↔ offset, waitSecs ↔ long-poll, ReadSession ↔ SSE, matchSeqNum ↔ producer fencing. A thin adapter translates the HTTP layer; the semantics already align.

Copilot AI changed the title Add Elixir + S2 implementation design document S2 as stream backend: cost analysis, architecture options, protocol mapping Feb 17, 2026
@scottmessinger

Copy link
Copy Markdown
Member

@copilot I believe s2 has per stream auth (https://s2.dev/docs/api/protocol). Please update the docs after having read these.

Also, if there's a protocol mapping, can we then use the CDN collapsing from durable streams (and as done in this library with the read keys for protection)?

Copilot AI and others added 2 commits February 17, 2026 16:06
Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>
Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>

Copilot AI commented Feb 17, 2026

Copy link
Copy Markdown
Contributor Author

@copilot I believe s2 has per stream auth (https://s2.dev/docs/api/protocol). Please update the docs after having read these.

Also, if there's a protocol mapping, can we then use the CDN collapsin...

Updated in 3638654.

S2 per-stream auth: Corrected — S2 does have per-stream auth. The AccessTokenScope supports streams: { "exact": "stream-name" } or { "prefix": "user/" } with separate read/write permissions via op_groups.stream. You can issue a read-only token scoped to a single stream. Added full documentation with example in the new "S2 Auth Model" section.

S2 natively serves SSE: Also discovered that S2's read endpoint returns text/event-stream when the client sends Accept: text/event-stream. Events are typed (batch, error, ping). This means S2 is already an SSE server — no bridging needed.

CDN collapsing with DS protocol: Yes — added as Option 4. If the adapter translates S2 responses to DS format with Stream-Cursor + Cache-Control headers, you can put a CDN in front and get the same request collapsing from Chapters 5-6. The adapter stays stateless (~300 lines). CDN collapses long-poll reads from O(readers) to O(streams). This is the simplest path if clients use long-poll. SSE connections still go 1:1 to S2 (CDN can't cache streaming), so if you need SSE fan-out too, use Option 3 (in-process).

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • s2.dev
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot AI changed the title S2 as stream backend: cost analysis, architecture options, protocol mapping S2 as stream backend: cost analysis, architecture options, protocol mapping, CDN collapsing Feb 17, 2026
@scottmessinger

Copy link
Copy Markdown
Member

@copilot I want to support both a lot of SSE client and long polling. It's not either or -- it's both. What combines both (which the current CF DO solution handles)

Copilot AI and others added 2 commits February 17, 2026 18:13
Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>
…fix section link

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>

Copilot AI commented Feb 17, 2026

Copy link
Copy Markdown
Contributor Author

@copilot I want to support both a lot of SSE client and long polling. It's not either or -- it's both. What combines both (which the current CF DO solution handles)

Restructured in 78593b5. Option 4 now handles both SSE and long-poll at scale — same dual-mode approach as the current CF implementation:

  • Long-poll: CDN in front of adapter collapses reads (same Stream-Cursor + Cache-Control mechanism from Chapters 5-6). 10K LP readers = ~100 adapter hits per cycle.
  • SSE: Adapter opens 1 S2 read session per active stream, broadcasts to N connected SSE clients. 10K SSE readers on 100 streams = 100 S2 sessions.
  • Mixed: Both modes collapse to O(streams), not O(readers). CDN handles LP fan-out, adapter handles SSE fan-out.

The adapter is ~500 lines (auth + CORS + protocol translation + SSE fan-out + long-poll handler). The SSE fan-out is the only stateful part — it holds a per-stream S2 session + client list + ring buffer. On redeploy, SSE clients auto-reconnect with Last-Event-ID and the adapter reopens S2 sessions from their offset. No subscription database needed.

@scottmessinger

Copy link
Copy Markdown
Member

@copilot Great. now, create a new file that simply explores " Option 4 now handles both SSE and long-poll at scale". i want to see files that would be created, sample code, etc. Diagram it all out. Cut the fluff (the whys, the timeline, etc), and just focus on what it would look like.

…code

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>

Copilot AI commented Feb 17, 2026

Copy link
Copy Markdown
Contributor Author

@copilot Great. now, create a new file that simply explores " Option 4 now handles both SSE and long-poll at scale". i want to see files that would be created, sample code, etc. Diagram it all out. Cu...

Created docs/15-s2-adapter-implementation.md in 0f4f75f.

13 source files with complete sample code, no prose:

  • File tree — project structure (13 files + Dockerfile + K8s)
  • Supervision tree — Phoenix Endpoint, Registry, DynamicSupervisor, Finch pool
  • S2Client — HTTP wrapper: append, read, read_session_stream, check_tail, create, delete
  • Protocol — DS ↔ S2 translation: offset encoding, cursor generation, cache headers, record decoding
  • StreamHub — GenServer per stream: 1 S2 ReadSession → N SSE clients, ring buffer for catch-up
  • StreamSession — Task consuming S2's native SSE stream, parsing events, forwarding to Hub
  • Auth — JWT verification + scope checking
  • Router — Phoenix routes matching DS URL pattern (/v1/stream/:basin/*stream_path)
  • StreamController — create, append, catch-up read, long-poll (with Cache-Control + Stream-Cursor for CDN collapsing), HEAD, delete
  • SseController — chunked SSE with fan-out subscription + keepalive
  • Config — runtime.exs, mix.exs (6 deps)
  • Dockerfile — multi-stage Alpine build
  • K8s — deployment (3 replicas) + service + ingress (SSE timeout config)
  • Request flow diagrams for all 6 operations
  • Memory estimate — 20-50MB per pod at 33 active streams + 3,333 SSE clients

Copilot AI changed the title S2 as stream backend: cost analysis, architecture options, protocol mapping, CDN collapsing S2 as stream backend: cost analysis, architecture options, protocol mapping, CDN collapsing, implementation reference Feb 17, 2026
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