0 · Prerequisite vocabulary
Terms you need before the rest makes sense.
| Term | Meaning |
|---|---|
HTTP request/response | Client opens TCP (usually TLS) to server; one request → one response. Normally response ends → connection reusable or closed. |
| Long-lived response | Server deliberately does not finish the response body; keeps writing chunks for minutes/hours. That is the substrate for SSE. |
WSGI | Classic Python web interface. Sync: one worker thread/process typically busy for whole request lifetime. |
ASGI | Async-capable Python web interface. One event loop can juggle many open connections without one OS thread per stream. |
Gunicorn | Process manager: spawns N worker processes, binds port, restarts workers. Does not invent async by itself. |
uvicorn | ASGI server. Emailzap runs it as Gunicorn worker class: -k uvicorn.workers.UvicornWorker. |
| Worker process | Separate OS process with its own memory. Dicts in one worker are invisible to sibling workers. |
ALB | Load balancer. Each new HTTP request (including a push POST) can land on any healthy worker. |
| Pub/Sub | Publish once → all subscribers get a copy. Valkey/Redis PUBLISH is fire-and-forget (no durable queue for offline consumers). |
asyncio.Queue | In-process async mailbox. SSE view awaits queue.get(); another thread/coroutine puts events in. |
Django still serves normal sync API views under ASGI — Django 5.2 runs them in a threadpool. Streaming endpoints need the async-capable stack so open SSE connections do not pin sync WSGI workers.
1 · What Server-Sent Events are
Official standard: WHATWG HTML — Server-sent events · tutorial: MDN.
Idea
- Client opens an HTTP GET to a stream URL.
- Server responds with
Content-Type: text/event-streamand leaves the body open. - Server writes discrete events as text; client parses them as they arrive.
- One direction only: server → client. Client talks back with normal REST/other requests.
Wire format (minimal)
- Messages end with a blank line.
- Fields:
event:(name),data:(payload; multi-line allowed),id:,retry:. - Lines starting with
:are comments — browsers ignore them; some mobile libraries drop them before your code sees them (Emailzap heartbeats therefore use a realdata:frame for mobile). - UTF-8 required.
Browser EventSource vs custom clients
Browser EventSource
- Built-in reconnect +
Last-Event-ID. - Cannot set arbitrary headers (no
Authorization: Bearer …). - Fine for cookie/same-origin pages.
Emailzap clients
- Chromex:
fetch+ReadableStreamin offscreen doc (cookies + control). - Mobile:
react-native-sseshim that can attach Bearer headers. - Both implement their own reconnect / watchdog logic.
Proxies must not buffer
If nginx/ALB buffers the response, the client sees nothing until a big chunk fills. Emailzap puts all streams under /api/v1/stream/ with proxy_buffering off. Idle timeouts still kill quiet connections → heartbeats (~30s) keep the path alive under ALB idle limits.
2 · Why SSE (not polling, not WebSockets) here
| Polling | SSE | WebSocket | |
|---|---|---|---|
| Direction | Client asks repeatedly | Server pushes | Full duplex |
| Protocol | Many short HTTP calls | One long HTTP response | Upgrade to WS framing |
| Latency / waste | Worst-case = poll interval; wasted empties | Push when ready | Push; also client→server frames |
| Fit for Emailzap inbox | Was used (e.g. last_synced) — coarse | Server events → cache invalidate/patch | Overkill: clients already have REST for writes |
| Infra | Simple | Needs ASGI + non-buffering proxy | Extra sticky/upgrade config |
Emailzap still uses REST for mutations and some onboarding progress (poll). SSE is the push notification channel for “something changed — refresh / patch cache,” not a replacement for the whole API.
3 · ASGI, Gunicorn, and uvicorn (“unicorn”)
ADR: docs/adrs/sse-framework-asgi-migration.md. Driven by LLM streaming + long-lived event streams.
Problem with sync WSGI workers
- Sync worker: while an SSE response is open, that worker/thread is occupied.
- Hundreds of open Gmail/mobile streams ⇒ you run out of workers fast.
- Async event loop: one process can keep many half-open responses and wake when there is data to write.
What Emailzap actually runs
- Gunicorn owns process lifecycle and the listen socket.
- Each child runs uvicorn’s UvicornWorker → ASGI app + asyncio loop (often with uvloop from
uvicorn[standard]). - Django ASGI app serves both normal DRF views (threadpooled if sync) and streaming views that yield SSE frames.
- First-party helpers live in
backend/execfn/common/sse/(SSEEvent,SSEMixin,SSEStreamingResponse) — not a Django SSE SaaS SDK.
What happens on one open stream (simplified)
- Client GET
/api/v1/stream/mailbot/events/(or/api/v1/stream/inbox/events/) hits some worker W. UserEventStreamView— orInboxEventStreamViewfor the relational lane — registers anasyncio.Queuefor that user on W. Both share one registry: the key is an opaque string, a Mongo id on the legacy lane and the relational family-primary id on the other.- Generator loops:
event = await queue.get()→ format as SSE → write to response. - Heartbeats yield empty/keepalive data frames so proxies do not idle-timeout.
- Client disconnect → cancel → unregister that queue (multi-connection: other queues for same user may remain).
ASGI solves “many open sockets per process.” It does not share memory across Gunicorn workers. That gap is why Valkey exists.
4 · Why Valkey is needed
Valkey = Redis-compatible in-memory store (AWS ElastiCache Valkey in the multi-worker plan). Client library: redis-py speaking the same protocol. Channel name in code: sse:events.
The multi-worker trap (without a bus)
Open SSE connection lives only in the process that accepted the GET. A later notify that hits another process cannot see that dict. Documented in docs/plans/sse-multi-worker-fix.md.
Pub/Sub fix
Why not “just sticky sessions” or “one worker”?
- One worker: simple but single point of failure / capacity cliff.
- Sticky ALB to SSE GET: does not help producers (Celery/Lambda) that are not the browser — they still need a way to address “whoever holds the socket.”
- Managed SSE SaaS (Pusher/Ably/AppSync): rejected for this fix — would force chromex/mobile client rewrites + store review (
sse-multi-worker-fix.md).
Properties that match Emailzap’s model
- Best-effort: pub/sub does not store history for offline clients. Missed events → client refetch / focus / gap detection — same philosophy as fire-and-forget
notify_*. - Cross-runtime: Lambda and Celery can PUBLISH without holding HTTP streams.
- Local fallback: empty
VALKEY_ENDPOINT→ direct in-processpush_event(single-worker / local).
Pub/sub ≠ Redis Streams / SQS. No consumer group replay. If you need durable ordered logs, that is a different tool — Emailzap deliberately chose fire-and-forget fan-out.
5 · How the three ideas fit together
- SSE = the browser/mobile-facing contract (long HTTP response, named events).
- uvicorn/ASGI workers = how Django can keep many of those responses open efficiently in each process.
- Valkey = how an event created in Lambda/Celery (or another worker) reaches the one process that owns that user’s open socket(s).
Next tabs show what is wired vs dead in this repo. Theory above is the “why”; those tabs are the “where.”
Official fundamentals (not Emailzap-specific)
Per WHATWG HTML — Server-sent events and MDN:
- Unidirectional server → client over HTTP (unlike WebSockets).
- MIME type must be
text/event-stream; UTF-8; messages separated by a blank line. - Fields:
event:,data:,id:,retry:; lines starting with:are comments. - Browser
EventSourceauto-reconnects; it cannot set custom Authorization headers (important for mobile/chromex choices).
Ambiguity resolved as SSE: query said “SSC” once — this artifact documents Server-Sent Events. If you meant something else, say so and we’ll regenerate.
1 · Live push path (wired)
Same Valkey channel for backend Celery + Lambda. Multi-connection per user (chromex + mobile) via dict[str, set[Queue]] (EMA-945).
2 · Dead / superseded paths (do not design as if live)
Early plan docs still mention these. Code search in this worktree found no live implementations.
Status = presence of wiring in this worktree’s code + project memory. Runtime PostHog flag % and prod Valkey health are not asserted here.
| Surface | Path | Status | Notes |
|---|
Backend owns the HTTP SSE endpoint, the in-process queue registry, and Celery/DRF emit sites. Streaming requires ASGI (Gunicorn + uvicorn.workers.UvicornWorker) so one worker can hold many open streams without tying a sync thread each.
ASGI + nginx (from ADR)
- ADR
docs/adrs/sse-framework-asgi-migration.md: Approach A — full ASGI; shared framework underbackend/execfn/common/sse/. - All stream URLs under
/api/v1/stream/with nginxproxy_buffering off(seebackend/deployment/nginx/backend.conf). - Framework pieces:
SSEEvent,SSEMixin,SSEStreamingResponse,ServerSentEventRenderer— first-party, not a third-party SSE Django package. - Runtime deps for ASGI:
uvicorn[standard]inbackend/pyproject.toml(includes uvloop in that extra). - Heartbeats: view uses a nameless
data: {}frame (not: comment) because mobile’s library drops comments — seeviews_stream.py. - Two general stream views (EMA-1464).
mailbot/api/views_stream.py:UserEventStreamView(Mongo auth, unchanged) andinbox/api/views/event_stream.py:InboxEventStreamView(session + PG bearer + legacy bearer,IsAuthenticated— a born-migrated account has nomongo_user, soIsAuthenticatedMongoUserwould lock it out). The relational view raises 409 for an unmigrated family. - Relational channel identity lives in
inbox/services/realtime/identity.py(family primary, as a string).inbox/never importsmailbotidentity code. - Backend hint producers go through
inbox/services/realtime/notifications.py: channel resolution and publish both run inside a guardedtransaction.on_commit, so a rolled-back write emits nothing and a publish failure never fails the write.
Docstring on UserEventStreamView still says events are pushed “via the internal push endpoint”. That endpoint is not present in this worktree; live path is Valkey → subscriber → queue. Treat the docstring as stale.
Lambda never holds browser sockets. It configures the shared notifier at import time and PUBLISHes. No local_push_hook (no in-process streams).
Emit-active Lambdas (this worktree)
- profile — direct
notify_onboarding_progress(+v2). - scanner —
ScanEnginestarted/progress SSE. - email_classifier / sender_classifier — emit via shared
BusinessV1Pipeline/ batch processor (configure required at cold-start). - webhook, new_mail_post_processor, onboarding, auto_draft_generator — direct notify_* / draft.ready.
- post_onboarding —
configure_sse_notifieronly (no notify_* found in function tree). - push_notifications — no SSE configure in current handler (APNs).
Producer families (high level)
- v1 onboarding lifecycle —
notify_onboarding_*(onboarding + scanner + profile + pipeline sites). - Relational invalidation hints (EMA-1464) —
shared/utils/inbox_stream.py. Ingestion and Gmail-side label changes emit fromPgWebhookSync; classification emits fromPostProcessingPipeline._run_pg. No flag: the lane is the gate.ff-sse-v2-emitno longer exists in code. - New-mail terminal — legacy lane: classified batch →
email_categorized. Relational lane:email.createdat ingestion (only for a genuinely new thread from arrived inbound Inbox mail) thenemail.updatedat classification. - draft.ready — auto-draft generator, on both lanes (own
FF_AUTO_DRAFTS). - EMA-1146 streaming onboarding — coarse phase + cohorts ride REST poll; named-phase SSE progress producers drop for that streaming path (subtractive).
Chromex holds one long-lived stream in an offscreen document (MV3). Sidebar is the single useRealtimeEvents subscriber. Legacy handlers are live, and hints are consumed through inbox-core's coalescing invalidator. Lane selection is a one-time probe per open(): it tries the relational path first and on 409 or 404 flips a module global to the legacy path and falls through to the existing retry. 404 tolerance makes deploy ordering safe. Only a 401 re-probes — a 403 must not, because an account the legacy stream 403s can also be 409'd by the relational one, and the two rejections would alternate forever.
Mobile SSE is foreground-only (background → push path). Transport: react-native-sse (third-party EventSource shim) with named-event allowlist. Must register every dispatcher event name or frames are silently dropped. Lane selection reads the migration atom rather than probing: sseUrl(migrated) picks the endpoint, and the atom is a dependency of the auth effect so a mid-session flip tears the connection down and reconnects to the other lane.
packages/sse-contracts is the shared producer protocol (Python, zero non-stdlib deps). packages/inbox-core/src/shared/invalidate.ts is the shared TS consumer: it coalesces hints for 200 ms per QueryClient, dedupes by account:thread, OR-merges facts across the window, then invalidates. Both clients are wired.
Retained legacy events vs invalidation hints (wire)
| Retained legacy events | Hints (contract v1.1) | |
|---|---|---|
| Names | email_categorized, email_enriched, draft.ready, briefing.pin_changed, sender_category_updated, email.state_changed, email.soft_deleted, email.category_changed | email.created, email.updated |
| Lane | Legacy stream (Mongo producers) | Relational stream only — the URL, not the event name, is the version boundary, which is why the names could be reused |
| Payload | Rows / patches a consumer merges | {account_id, thread_ids[]} + optional facts; the client refetches |
| Seq | _seq_no attached in the shared notifier path (EMA-985) | None (skip_seq). A hint goes stale, never wrong, so there is nothing to sequence, gap-detect, or replay |
| Batching | 50-item truncation on the legacy mutation echoes | Chunked at HINT_MAX_BATCH_SIZE = 50 into consecutive events — nothing is dropped |
| Missing field | — | Fail-open: an absent fact means "refresh anyway", so a producer may omit a fact it cannot cheaply determine and new facts need no contract rev |
Hint facts
| Event | Fact | Effect when present |
|---|---|---|
email.created | briefing: false | Client skips its briefing refresh — the threads are too new to be there |
email.created | new_sender: true | Client also refreshes contacts |
email.updated | correlation_id | The client that caused the change can skip its own echo (lands on the wire; suppression deferred) |
The full-row v2 protocol is retired, not dormant: EmailRowV2, the *_batch hint names, gap detection, and replay are gone, and ff-sse-v2-emit no longer exists in code. Rows on the wire coupled every producer to a client serializer, and an out-of-order merge leaves a cache that is wrong rather than merely stale — which is precisely what forced the sequencing machinery. email.created fires once per thread lifetime; every later change, including a new message on that thread, is an email.updated.
Third-party vs first-party
| Piece | Library? | Evidence |
|---|---|---|
| SSE wire framework (Django) | first-party | backend/execfn/common/sse/ |
| ASGI server | uvicorn | pyproject.toml + ADR |
| Pub/sub broker | Valkey / redis-py | stream_registry.py, multi-worker plan |
| Producer schemas | first-party package | packages/sse-contracts (stdlib only) |
| Chromex client | first-party fetch + ReadableStream | offscreen.ts (not browser EventSource — needs cookies/headers control) |
| Mobile client | react-native-sse ^1.2.1 | mobile/package.json + sse-transport-rns.ts |
| Managed push SaaS (Pusher/Ably/AppSync) | rejected | docs/plans/sse-multi-worker-fix.md |
Web frontend/ app SSE | no wiring found | grep under frontend/src |
Why ASGI workers matter
- WSGI sync workers block one thread per open stream → hard concurrency ceiling.
- UvicornWorker runs an asyncio loop; Django 5.2 wraps remaining sync views in a threadpool.
- Registry + Redis subscriber thread hand off into that loop via
run_coroutine_threadsafe. - Multiple Gunicorn workers ⇒ process-local queues; Valkey is what makes publish reach the worker that holds the socket.