insigz docs
Engineering

Realtime (Postgres LISTEN/NOTIFY)

Multiplayer/live updates without a message broker — one source of truth (Postgres) with minimal moving parts.

Multiplayer/live updates without a message broker — the architecture the product blog post argues for. One source of truth (Postgres), minimal moving parts.

Channel grammar (canonical — one spelling everywhere)

All channels are colon-delimited and tenant-prefixed. This is the single grammar; docs 10 and 20 defer to it (the old NOTIFY session_<id> underscore form is retired):

t:<tid>:entity:<entityId>     # an entity changed (live-ops default)
t:<tid>:aoi:<tileId>          # an area-of-interest tile changed (map viewport)
t:<tid>:case:<caseId>         # a case's bound items changed
t:<tid>:session:<sid>         # WARGAME ONLY — a session's world-state changed

Live-ops (the M0 maritime use case) has no sessions — a new AIS observation NOTIFYs the affected entity and aoi channels, never a session channel. session: is wargame-specific.

-- inside the committing transaction (ingest or mutation)
SELECT pg_notify('t:'||:tid||':entity:'||:entityId,
  json_build_object('kind','event','entity',:entityId)::text);
SELECT pg_notify('t:'||:tid||':aoi:'||:tileId,
  json_build_object('kind','observation','aoi',:tileId)::text);

The key rule: a notification is a hint, not data

The payload is a cache-invalidation nudge ("re-query this slice"), never the data itself. On receipt, the client runs an ordinary authorized read, which re-applies RLS — so a misrouted notification leaks nothing, and realtime updates pass the same authz as a cold load. Payloads stay under Postgres's 8KB NOTIFY cap by design.

Coarse channels for live-ops (not one LISTEN per fine-grained channel)

One LISTEN per session is fine for wargame rooms of dozens. A live map with thousands of entities/AOIs is different — a LISTEN connection per fine-grained channel would compete for the very Cloud SQL connection budget 60 worries about. So:

  • The fan-out process holds a small, fixed set of coarse channels — e.g. one per tenant (t:<tid>:fanout) or one per AOI tile — and the relayed payload carries the fine-grained entity/aoi id.
  • The client filters to the entities/AOIs it currently cares about (its map viewport / open case). Fine-grained routing happens client-side, not via thousands of server LISTENs.
  • Wargame sessions can still use a dedicated session: channel (bounded count).

Reliability

  • Fire-and-forget: clients also poll a cheap session_version / tenant_version counter as a backstop and reconcile on reconnect (a dropped notify during a fan-out restart self-heals).
  • WebSocket re-auth: a long-lived socket will outlive the short JWT (40). On token expiry the client performs a silent re-auth handshake (refresh token → new JWT → re-subscribe) rather than being dropped; the server closes sockets whose token has expired and no valid refresh arrives.

Where it runs

A dedicated realtime Cloud Run service with min-instances ≥ 1 so the LISTEN connection persists. This means realtime never scales to zero — note the cost consequence: combined with per-tenant Cloud SQL + Atlas, a tenant carries a 24/7 floor cost, so the "scales to zero / cheap idle trials" framing in 50 does not apply to any tenant that needs live updates (see the tiering decision in 00). Transport: WebSocket for fan-out; Claude streaming uses SSE separately (32, D6).

Gaps to close

  • Decide the coarse-channel granularity (per-tenant vs. per-AOI-tile) and the client-filter contract.
  • Define the tenant_version/session_version backstop table + the WS re-auth handshake message.
  • Confirm reconnection/resume semantics (what the client replays on reconnect).