Skip to content

feat(vault-ws): live-query WebSocket binding with DO hibernation — Phase 1 (0.0.8-rc.27) - #80

Merged
unforced merged 3 commits into
mainfrom
ag-unforced-dev
Jul 4, 2026
Merged

feat(vault-ws): live-query WebSocket binding with DO hibernation — Phase 1 (0.0.8-rc.27)#80
unforced merged 3 commits into
mainfrom
ag-unforced-dev

Conversation

@unforced

@unforced unforced commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Phase 1 of the WS-hibernation migration (decision Decisions/2026-07-04-live-query-ws-hibernation, plan Work/ws-hibernation-migration). Adds a WebSocket transport for live-query subscriptions alongside the existing SSE binding, with Cloudflare Durable Object hibernation so an idle-but-open live tab evicts the per-vault DO ($0 idle) instead of pinning it awake ($4/mo per always-open SSE tab). NOT for merge — gated on the cost proof (see below); orchestrator reviews.

The design, as built

One contract, two transports. Same URL GET /vault/<name>/api/subscribe?<query>; an Upgrade: websocket header selects WS, else SSE. The SSE handler is untouched (byte-for-byte wire output) and remains the fallback. Full contract: workers/vault/docs/live-query-ws.md.

  • index.ts — a WS upgrade (101) is forwarded unmodified; withCors does new Response(res.body, res) which cannot reconstruct a 101 nor carry the webSocket. SSE + REST unchanged.
  • auth.ts — extracted authenticateVaultToken(token, env, vaultName) (the request path is now a thin wrapper with byte-identical 401/403 bodies); surfaced JWT exp/jti on AuthResult; added vaultVerbRank for the re-auth narrow check.
  • subscriptions.ts — sink seam write(frame)send(event, data); SseSink (bytes UNCHANGED) + WsSink ({type, ...data}); one SubscriptionManager, both transports.
  • live/ws-subscribe.ts (new) — pure helpers: close codes (4400/4401/4403/4408), the versioned per-socket attachment (stores the RAW query string), query validation (same rejects/codes as SSE), pending-attachment build + a ~15 KB serialize guard (under workerd's 16 KB), chunked snapshot with a done flag, first-message parsing, a best-effort revocation tracker.
  • vault-do.ts — WS upgrade → acceptWebSocket + pending attachment (cap via getWebSockets().length → 503); first-message auth ({type:auth,token}) → ready + snapshot; re-auth (narrow-or-equal scope, widen → 4403, no re-snapshot); ensureSubscriptionsRehydrated() at every wake entry point (fetch/webSocketMessage/webSocketClose/webSocketError/alarm), guarded once-per-warm-DO; sweep-before-dispatch (exp → 4401, confirmed-revoked → 4401, pending-timeout → 4408); setWebSocketAutoResponse(ping/pong) — no server keepalive timer, and the 15-min lifetime cap is gone for WS. Transcription alarm ownership untouched.

THE COST PROOF (the gate)

Deployed to staging (deploy-staging.sh, both workers) and ran scripts/cost-proof-ws-hibernation.ts — two fresh staging vaults, one holding an SSE stream, one holding an idle WebSocket, over the same 45s window, with wrangler tail capturing the invocation pattern.

Functional hibernation round-trip (measured on staging):

  • Idle WS answered 4 ping→pong round-trips WITHOUT a single DO invocation (auto-response; confirmed by zero tail events during the idle window).
  • Wake-on-write: an upsert arrived 298 ms after a REST write to the idle-WS vault — the DO woke, rehydrated the sub from its attachment, and pushed. No missed event, no re-snapshot.

wrangler tail invocation pattern (router-level):

GET /vault/<ws-vault>/api/subscribe    8–13ms wall  ok   ← upgrade completes, DO hibernates
(45s idle, 4 pings)                    — no events —      ← auto-response, DO never wakes
POST /vault/<ws-vault>/api/notes       49ms  wall  ok    ← the write that wakes it
GET /vault/<sse-vault>/api/subscribe   held open → canceled at teardown ← SSE pins the DO

Honest limit (needs Aaron): the definitive per-vault DO duration (GB-s) comes from Cloudflare GraphQL durableObjectsPeriodicGroups, which needs an analytics-scoped API token. The wrangler OAuth token on this box has workers/d1/workers_tail but no analytics scope, so the exact GB-s delta is not measured here. The architectural claim (SSE holds an open response = continuous DO wall-clock; idle WS hibernates = ~0 duration) is confirmed by the tail invocation pattern + the round-trip, but the hard number is a follow-up once an analytics token is available.

Transcription-alarm ↔ hibernation finding: a pending DO alarm does not pin the DO awake — Cloudflare wakes it at the scheduled time, so an idle-WS vault with a scheduled far-future transcription alarm still hibernates in between. When the alarm fires, alarm() runs ensureSubscriptionsRehydrated() first, so live sockets are re-registered and survive — pinned deterministically by a test (alarm-wake coexistence: evict → alarm() → WS sub count 0→1 → a later write still pushes). A fully-live far-future-alarm eviction measurement shares the analytics-token gate above.

Gates (literal)

  • vault (workers/vault): bun run typecheck clean; bun x vitest run246 passed | 1 todo (247) (baseline 211 + 1 todo; +35: 21 frame-parity/unit + 14 ws-subscribe incl. handshake, auth close codes, chunked-snapshot chunking, cap-503, scope intersection, WS⇄SSE byte-parity, exp-sweep, alarm-coexistence, and the simulated-eviction harness).
  • verify:bundle OK — core inlined, no residual bun: import (564 KiB).
  • identity (workers/identity): untouched (369 baseline).
  • smoke-staging.ts: PASSED, 0 failures (exit 0) — includes the SSE snapshot path, so the SSE binding still works end-to-end post-refactor.

Deviations from the plan (+ why)

  1. Byte-parity, precisely: a WS message is not literally identical to the SSE frame — it folds the SSE event: name into a type discriminator (a WS message has no event-name framing) and snapshots carry a done chunk flag. The inner payload (note/id/notes) serializes byte-for-byte identically across both transports; that is the load-bearing invariant, pinned by the shared frame-corpus fixture (test/fixtures/live-frame-corpus.ts) + the parity tests. The doc states this exactly.
  2. Revocation sweep: best-effort and fail-OPEN for an already-authed socket on a revocation-list outage (it passed full fail-closed validation at auth time; a transient hub blip shouldn't mass-close live sockets). The network hop is skipped under ENVIRONMENT=test for miniflare hermeticity; the tracker is unit-tested with an injected fetcher.
  3. Two independent caps during the additive-rollout window: SSE via the manager's per-vault count, WS via getWebSockets().length (both 100). Converges as SSE is retired (Phase 5).
  4. Query validation duplicated in ws-subscribe.ts rather than extracted from subscribe.ts, to keep the SSE handler's logic byte-untouched; a parity test guards the shared 400 bodies.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB

unforced and others added 3 commits July 4, 2026 13:26
…se 1) (0.0.8-rc.27)

Add a WebSocket transport for live-query subscriptions alongside the existing
SSE binding — same `GET /api/subscribe?<query>` URL, `Upgrade: websocket`
selects WS. On Cloudflare, the socket is a Hibernatable WebSocket: an
idle-but-open connection evicts the per-vault DO (~$0 idle) and a write
transparently wakes it to push. SSE is the untouched fallback.

Phase 0 (contract): workers/vault/docs/live-query-ws.md (message schemas, auth
handshake, close codes 4400/4401/4403/4408, ping/pong, chunked snapshot with a
`done` flag) + a shared frame-corpus fixture both the SSE and WS parity tests
import.

Phase 1 (build):
- index.ts: forward a WS upgrade (101) UNMODIFIED — `withCors` does
  `new Response(res.body, res)` which can't reconstruct a 101 nor carry the
  webSocket. SSE + REST paths unchanged.
- auth.ts: extract `authenticateVaultToken(token, env, vaultName)` (request path
  is now a thin wrapper, byte-identical responses); surface JWT `exp`/`jti` on
  AuthResult; add `vaultVerbRank` for the re-auth narrow check.
- subscriptions.ts: sink seam `write(frame)` → `send(event, data)`; `SseSink`
  (bytes UNCHANGED) + `WsSink` (`{type, ...data}`); one manager, both transports.
- live/ws-subscribe.ts: pure helpers — close codes, versioned per-socket
  attachment (RAW query string), query validation (same rejects as SSE),
  pending-attachment build + 15KB serialize guard, chunked snapshot, first-message
  parsing, a best-effort revocation tracker.
- vault-do.ts: WS upgrade → acceptWebSocket + pending attachment; first-message
  auth (`{type:auth,token}`) → ready + snapshot; re-auth (narrow-or-equal);
  `ensureSubscriptionsRehydrated()` at every wake entry point; sweep-before-
  dispatch (exp/revocation/pending-timeout); `setWebSocketAutoResponse(ping/pong)`
  (no server keepalive timer, no 15-min cap for WS). Transcription alarm ownership
  untouched.

Tests: ws-subscribe (13) — handshake, auth close codes, chunked snapshot,
cap-503, scope intersection, WS⇄SSE byte-parity, exp-sweep, and a
simulated-eviction harness (drop the in-memory manager, deliver a REST write,
assert the push survives with no missed event / no re-snapshot). Plus
live-frame-parity (21). Vault suite 211+1todo → 245+1todo; typecheck clean;
deploy bundle inlines core, no residual bun: import.

Contract: Decisions/2026-07-04-live-query-ws-hibernation. NOT merged — gated on
the staging DO-duration cost proof.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
- scripts/cost-proof-ws-hibernation.ts: provisions two staging vaults, holds an
  SSE stream on one and an idle WebSocket on the other, and exercises the
  hibernation round-trip (idle → ping/pong via auto-response without waking →
  wake-on-write push). Prints the two vault names for `wrangler tail`
  correlation. The definitive per-vault DO duration (GB-s) needs an
  analytics-scoped token (durableObjectsPeriodicGroups); the wrangler OAuth token
  lacks that scope, so the harness proves the round-trip + the invocation pattern.

- ws-subscribe test: an alarm-driven wake rehydrates WS subs from attachments —
  pins that the transcription alarm's wake path re-registers live sockets (they
  survive), and that a pending alarm does not break the WS binding.

Vault suite 245+1todo → 246+1todo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
…wer nits

Reviewer nits on PR #80, folded into the same rc.27:

NIT 1 (drift guard): cross-transport byte-equality tests for the FULL 400
(search/near) + 503 (cap) bodies — the duplicated strings in subscribe.ts (SSE)
and ws-subscribe.ts (WS) are now diffed byte-for-byte across both real
transports, not just by `.code`. Makes the PR's "parity test guards the shared
400 bodies" claim literally true.

NIT 2 (pending-socket cap self-heal) — surfaced a REAL gap: `getWebSockets()`
also returns CLOSING sockets (readyState 2), including ones the sweep just
closed on the same wake, so the raw-length cap check kept the cap "full" of
already-departing sockets and the self-heal did NOT fire. Fix: count only LIVE
(readyState CONNECTING/OPEN) sockets toward the cap (`liveSocketCount`), so a
`sweepWsSockets` that closes stale pending sockets frees their slots
immediately. Added a test (fill cap with pending-past-deadline → sweep → a new
upgrade passes) + a doc paragraph on the pre-auth cap behavior + self-heal.

Vault suite 246+1todo → 250+1todo; typecheck clean; verify:bundle OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
@unforced

unforced commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer nits folded (same rc.27, no version bump) — commit b11f293.

NIT 1 (drift guard): added cross-transport byte-equality tests for the FULL 400 (search/near) and 503 (cap) bodies — both real transports' bodies are now diffed byte-for-byte, not just .code. The "parity test guards the shared 400 bodies" claim is now literally true.

NIT 2 (pending-socket cap self-heal) — surfaced a REAL gap, small source fix required: the assumed self-heal did NOT fire. getWebSockets() also returns CLOSING sockets (readyState 2), including ones the sweep just closed on the same wake, so the raw-length cap check (getWebSockets().length) kept the cap "full" of already-departing sockets → the immediately-following upgrade still got 503 (verified by a readyState probe: after ws.close(), sockets stay in getWebSockets() at readyState 2). Fix: count only LIVE sockets (readyState CONNECTING/OPEN) toward the cap via liveSocketCount(), so sweepWsSockets genuinely frees slots on the same wake. Added the self-heal test + a doc paragraph.

Gates: vault bun run typecheck clean; bun x vitest run250 passed | 1 todo (251) (was 246+1todo, +4); verify:bundle OK. Not merged.

@unforced
unforced merged commit 79bd840 into main Jul 4, 2026
3 checks passed
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.

1 participant