# Design: PG-Backed Cluster Registry & Self-Assembling Mesh **Status:** Proposed — Pre-MVP / MVP wrap-up **Author:** Jeff **Date:** 2026-03-30 **Scope:** Kernel primitive — zero domain awareness --- ## Problem Switchboard-core is tightly coupled to PostgreSQL. Scaling horizontally requires instance coordination: peer discovery, health monitoring, ephemeral event routing, and (optionally) leader election. Traditional HA solutions (Raft, etcd, Consul) introduce a second consensus layer on top of PG — doubling operational complexity for a system that already has serializable transactions and LISTEN/NOTIFY. ## Principle PG is already the consensus system. Use it as the sole coordinator for service discovery, health, pub/sub, and cluster state. Zero new infrastructure dependencies. --- ## Design ### 1. Node Registry (Unlogged Table) ```sql CREATE UNLOGGED TABLE IF NOT EXISTS node_registry ( node_id TEXT PRIMARY KEY, endpoint TEXT NOT NULL, seq SERIAL, registered_at TIMESTAMPTZ DEFAULT now(), heartbeat TIMESTAMPTZ DEFAULT now(), stats JSONB DEFAULT '{}' ); ``` **Why UNLOGGED:** No WAL overhead, visible to all connections, survives session disconnect. Contents do NOT survive a PG crash — but a PG crash means all nodes are dead anyway. Clean slate on recovery is the correct behavior; all nodes re-register on restart. **Why not TEMPORARY:** PG temporary tables are session-scoped and invisible to other connections. Useless for cross-instance coordination. ### 2. Node Lifecycle #### Startup ``` boot → connect PG → register self → read peer list → subscribe LISTEN/NOTIFY channels → open WS to clients → ready ``` ```sql -- Self-registration (atomic, idempotent) INSERT INTO node_registry (node_id, endpoint) VALUES ($1, $2) ON CONFLICT (node_id) DO UPDATE SET endpoint = EXCLUDED.endpoint, registered_at = now(), heartbeat = now(), stats = '{}'; ``` `node_id` is generated at startup: `hostname + PID` or a UUID. Deterministic IDs enable restart-in-place without orphaned rows. #### Heartbeat Tick (every N seconds, e.g., 10s) Each node performs two operations per tick: ```go // 1. Update own heartbeat + stats stats := collectStats() // see §5 db.Exec(`UPDATE node_registry SET heartbeat = now(), stats = $2 WHERE node_id = $1`, nodeID, stats) // 2. Sweep stale entries (idempotent — safe for concurrent execution) db.Exec(`DELETE FROM node_registry WHERE heartbeat < now() - interval '30 seconds'`) ``` The sweep is the distributed health check. Every node runs it. Multiple nodes deleting the same stale row is a no-op. No ring topology required — the sweep is the safety net that guarantees eventual cleanup regardless of which nodes are alive. #### Self-Eviction If a node's own heartbeat UPDATE returns 0 rows affected, it has been swept by a peer: ```go result := db.Exec(`UPDATE node_registry SET heartbeat = now() WHERE node_id = $1`, nodeID) if result.RowsAffected() == 0 { log.Error("evicted from registry, shutting down") os.Exit(1) } ``` Kubernetes restarts the process. On boot it re-registers as a fresh node. The restart loop IS the recovery mechanism. ### 3. Leaderless by Default No `role` column. No primary/replica distinction. All nodes are equal peers. PG is the sole coordinator. **If a leader is ever needed** (future use case), it's a small lift: ```sql ALTER TABLE node_registry ADD COLUMN role TEXT DEFAULT 'peer'; -- Atomic leader claim: first writer wins UPDATE node_registry SET role = 'primary' WHERE node_id = $1 AND NOT EXISTS (SELECT 1 FROM node_registry WHERE role = 'primary'); ``` No quorum, no voting, no term numbers. PG serialization handles it. But the current design intentionally avoids this — consistent with the existing pattern where scheduled tasks use first-to-mark-wins execution, not leader assignment. ### 4. Realtime Event Routing (Two Tiers) #### Tier 1: Durable Events (messages, state changes) These are PG writes. Fan-out via `LISTEN/NOTIFY`: ``` User A sends message → Instance 1 writes to PG → pg_notify('channel:xyz', '{"type":"message","id":"..."}') → All instances receive → push to local WS clients subscribed to channel:xyz ``` This maps directly to the v0.5.0 `realtime.publish()` Starlark primitive. Kernel implementation is `pg_notify(channel, payload)`. #### Tier 2: Ephemeral Events (typing indicators, presence, cursor position) These NEVER hit durable storage. **Phase 1 (MVP):** Use PG LISTEN/NOTIFY for ephemeral events too. ``` User A types → Instance 1 WS handler → pg_notify('ephemeral:xyz', '{"type":"typing","user":"A"}') → Instance 2 receives → pushes to User B's WS Instance 3 receives → pushes to User C's WS ``` PG NOTIFY payload limit is 8KB. A typing indicator is ~60 bytes. At homelab scale (3 nodes, dozens of users) this is a non-issue. One codepath, one transport, zero new infrastructure. **Phase 2 (Post-MVP, if needed):** Direct instance-to-instance mesh for ephemeral events. - Peer endpoints are already in the registry - Each node dials peers discovered from the registry on startup and on peer list changes - Ephemeral events route over the mesh; durable events stay on PG NOTIFY - The `realtime.publish()` Starlark API does not change — kernel swaps the underlying transport ``` ┌─────────────────────────────────┐ │ PG (source of truth) │ │ ┌───────────┐ ┌──────────────┐ │ │ │ node_ │ │ LISTEN/ │ │ │ │ registry │ │ NOTIFY │ │ │ └───────────┘ └──────────────┘ │ └──────┬──────────────┬────────────┘ │ durable │ ┌────────────┼──────────────┼────────────┐ │ │ │ │ ┌────┴────┐ ┌───┴─────┐ ┌────┴─────┐ │ Node 1 │◄─┤ Node 2 ├─►│ Node 3 │ │ User A │ │ User B │ │ User C │ └─────────┘ └─────────┘ └──────────┘ ◄──── ephemeral mesh ────► (Phase 2, if needed) ``` **Channel subscription model (Phase 1):** Broadcast and discard. Every node receives every event, checks local WebSocket subscriptions, drops if irrelevant. At 3 nodes this is the correct answer — trivial, no subscription state to sync, fully leaderless. ### 5. Stats Collection & Admin Dashboard Each heartbeat tick publishes runtime stats in the `stats` JSONB column: ```go var m runtime.MemStats runtime.ReadMemStats(&m) stats := map[string]any{ "goroutines": runtime.NumGoroutine(), "heap_alloc": m.HeapAlloc, "heap_sys": m.HeapSys, "gc_cycles": m.NumGC, "gc_pause_ns": m.PauseNs[(m.NumGC+255)%256], "uptime_sec": time.Since(startTime).Seconds(), "ws_clients": wsHub.Count(), "extensions": sandbox.LoadedCount(), } ``` Admin dashboard API endpoint: ```sql SELECT node_id, endpoint, registered_at, heartbeat, stats FROM node_registry ORDER BY seq; ``` **ICD envelope:** `{"data": [...]}` — consistent with list endpoint convention. The admin dashboard extension renders one card per node. One node = one card. Three nodes = three cards. The extension has zero awareness of clustering — it reads a list and renders it. This is the extensibility proof working as designed. Since `stats` is JSONB, future additions (mesh peer latency, extension execution counts, queue depths) require no schema migration. The dashboard extension renders whatever keys are present. ### 6. Self-Assembling Mesh Properties The mesh builds itself from the registry with zero configuration: | Event | Behavior | |---|---| | Node starts | Inserts into registry → peers discover it on next heartbeat tick | | Node dies | Heartbeat goes stale → swept by peers → mesh shrinks | | Node restarts | Re-registers → peers discover it → mesh restores | | Scale up (K8s) | New replicas register → mesh grows automatically | | Scale down (K8s) | Terminated nodes go stale → swept → mesh shrinks | | PG crash | All nodes lose connection → all exit → K8s restarts all → clean re-registration | No config files listing peers. No seed nodes. No service discovery sidecar. No bootstrap ceremony. The registry table IS the service mesh control plane. --- ## What This Enables (Future) These are NOT in scope for this work. Listed to validate the design supports them without rearchitecting. - **Work distribution:** Primary (if elected) assigns extension execution shards via registry metadata. Replicas poll for assignments. No message queue. - **Realtime routing (v0.5.0):** When a node owns a pub/sub channel, it registers ownership in PG. Other nodes route publishes to the owner. Node dies → sweep clears routes → subscribers reconnect. - **Rolling deploys:** New version registers, old version's heartbeat goes stale, clean handoff with zero coordination protocol. - **Session affinity:** Registry tracks which node holds a user's WS connection. Targeted event delivery instead of broadcast (optimization for scale). --- ## Implementation Notes ### Migration Pre-MVP: fold `CREATE UNLOGGED TABLE` into existing schema initialization. No new migration file needed. DB rebuild required (acceptable — no install base). ### Configuration | Setting | Default | Notes | |---|---|---| | `CLUSTER_NODE_ID` | `hostname-PID` | Override for deterministic identity across restarts | | `CLUSTER_HEARTBEAT_INTERVAL` | `10s` | Tick frequency | | `CLUSTER_STALE_THRESHOLD` | `30s` | 3x heartbeat = stale | | `CLUSTER_ENDPOINT` | auto-detect | Advertised address for peer mesh (Phase 2) | ### Single-Node Behavior When only one node is registered, the system behaves identically to pre-cluster switchboard-core. The registry has one row. LISTEN/NOTIFY delivers events back to the same instance. No special-casing required. ### Health Endpoint Integration `GET /health` includes cluster status: ```json { "status": "healthy", "node_id": "node-abc-1234", "cluster": { "size": 3, "peers": ["node-def-5678", "node-ghi-9012"], "heartbeat_age_ms": 2340 } } ``` Satisfies the v0.6.0 MVP health monitoring requirement. --- ## Rejected Alternatives | Alternative | Why rejected | |---|---| | etcd / Consul | Second consensus layer on top of PG. Doubles operational complexity for a system already tightly coupled to PG. Violates KISS. | | Redis pub/sub for ephemeral events | Second stateful service. Two failure domains, two connection pools, two things to monitor. PG NOTIFY handles the same job at homelab scale. | | Raft implementation in Go | Massive complexity for a problem PG serializable transactions already solve. | | Ring topology monitoring | Each node watches the one above it. Adjacent failures create blind spots. Sweep-all is simpler and has no edge cases. | | Leader election at startup | Unnecessary. Leaderless design with first-to-mark-wins task execution already works. Leader election is a future option, not a requirement. | --- ## Checklist - [ ] `node_registry` unlogged table in schema init - [ ] Node registration on startup - [ ] Heartbeat tick with stats collection and stale sweep - [ ] Self-eviction on 0-rows-affected heartbeat - [ ] PG LISTEN/NOTIFY integration for realtime events - [ ] Admin API endpoint: `GET /api/admin/cluster` - [ ] Admin dashboard extension: cluster node cards - [ ] Health endpoint cluster status - [ ] Configuration via environment variables - [ ] Single-node regression test (no behavioral change) - [ ] Multi-node integration test (Docker Compose, 3 instances)