From 35d23094506a7d4c0027a94912b8ad7fa9118d85 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Mon, 30 Mar 2026 22:20:56 +0000 Subject: [PATCH] Feat v0.6.0 cluster registry + HA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PG-backed cluster registry for horizontal scaling — zero new infrastructure (no etcd/Consul/Redis). MVP convergence point. - node_registry UNLOGGED table (migration 013) - Self-registration, heartbeat tick (10s), stale sweep (30s), self-eviction (os.Exit on 0 rows → K8s restarts) - GET /api/v1/admin/cluster returns nodes with runtime stats - Health endpoints include node_id + cluster size/peers - cluster-dashboard admin surface with auto-refresh - 3-replica E2E test (docker-compose-e2e.yml + ci/e2e-cluster-test.sh) - chat + cluster-dashboard added to curated default bundle - 5 new tests (3 unit + 2 handler), all passing Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 38 +++++ ROADMAP.md | 26 ++-- VERSION | 2 +- ci/e2e-cluster-test.sh | 145 ++++++++++++++++++ ci/e2e-nginx.conf | 1 + docker-compose-e2e.yml | 46 +++++- packages/cluster-dashboard/css/main.css | 100 ++++++++++++ packages/cluster-dashboard/js/main.js | 124 +++++++++++++++ packages/cluster-dashboard/manifest.json | 13 ++ server/cluster/registry.go | 143 +++++++++++++++++ server/cluster/registry_test.go | 112 ++++++++++++++ server/config/config.go | 27 ++++ .../postgres/013_cluster_registry.sql | 13 ++ server/handlers/cluster.go | 32 ++++ server/handlers/cluster_test.go | 114 ++++++++++++++ server/handlers/packages_bundled.go | 6 + server/main.go | 61 +++++++- server/store/cluster_iface.go | 37 +++++ server/store/interfaces.go | 1 + server/store/postgres/cluster.go | 78 ++++++++++ server/store/postgres/stores.go | 1 + 21 files changed, 1101 insertions(+), 19 deletions(-) create mode 100755 ci/e2e-cluster-test.sh create mode 100644 packages/cluster-dashboard/css/main.css create mode 100644 packages/cluster-dashboard/js/main.js create mode 100644 packages/cluster-dashboard/manifest.json create mode 100644 server/cluster/registry.go create mode 100644 server/cluster/registry_test.go create mode 100644 server/database/migrations/postgres/013_cluster_registry.sql create mode 100644 server/handlers/cluster.go create mode 100644 server/handlers/cluster_test.go create mode 100644 server/store/cluster_iface.go create mode 100644 server/store/postgres/cluster.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c8cbb..767ac2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to Switchboard Core are documented here. +## v0.6.0 — Cluster Registry + HA + +MVP convergence point. PG-backed cluster registry for horizontal scaling — +zero new infrastructure (no etcd/Consul/Redis). + +### Added + +- **Cluster registry**: `node_registry` UNLOGGED table with self-registration + on startup (`INSERT ... ON CONFLICT DO UPDATE`), heartbeat tick (default 10s), + stale sweep (default 30s threshold), and self-eviction (`os.Exit(1)` when + swept by a peer — K8s restarts the process). +- **Cluster admin API**: `GET /api/v1/admin/cluster` returns all registered + nodes with runtime stats (goroutines, heap, GC, uptime, ws_clients) in the + standard `{data: [...]}` envelope. +- **Health endpoint cluster info**: `GET /health` and `GET /api/v1/health` + now include `node_id` and `cluster: {size, peers, heartbeat_age_ms}` when + running on Postgres with the registry active. +- **Cluster dashboard**: `cluster-dashboard` admin surface package renders one + card per node with live runtime stats. Auto-refreshes every 10s. Stats are + JSONB — new keys render automatically without schema migration. +- **Cluster config**: `CLUSTER_NODE_ID` (default hostname-PID), + `CLUSTER_HEARTBEAT_INTERVAL` (default 10s), `CLUSTER_STALE_THRESHOLD` + (default 30s), `CLUSTER_ENDPOINT` (Phase 2 mesh, auto-detect). +- **3-replica E2E test**: `docker-compose-e2e.yml` now runs 3 replicas. + `ci/e2e-cluster-test.sh` verifies registration, stale sweep on stop, and + re-registration on restart. +- **5 new tests**: 3 cluster registry unit tests (stats collection, start/stop + lifecycle, node ID) + 2 handler tests (list nodes, empty response). +- **Curated bundle expanded**: `chat` and `cluster-dashboard` added to the + default bundle (now 10 packages). Production deployments can override with + `BUNDLED_PACKAGES=notes,chat,chat-core,cluster-dashboard` for a lean set. + +### Changed + +- SQLite deployments are unaffected — cluster store is nil, all cluster code + is guarded behind `database.IsPostgres() && stores.Cluster != nil`. + Cluster dashboard gracefully shows "0 nodes registered" on SQLite. + ## v0.5.5 — Upgrade Testing ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index d76dba9..7a8f411 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -418,24 +418,24 @@ externally usable release. Design docs: `docs/DESIGN-cluster-registry.md` — PG-backed cluster registry and self-assembling mesh. -### v0.6.0 — Cluster Registry + HA (planned) +### v0.6.0 — Cluster Registry + HA PG is the consensus layer. Zero new infrastructure. `UNLOGGED` table + `LISTEN/NOTIFY` replaces etcd/Consul/Redis for homelab-to-small-team scale. | Step | Status | Description | |------|--------|-------------| -| `node_registry` table | | `UNLOGGED TABLE` — node_id, endpoint, seq, registered_at, heartbeat, stats JSONB. Folds into existing schema init. | -| Node registration | | Self-registration on startup: `INSERT ... ON CONFLICT DO UPDATE`. `node_id` = `hostname-PID` or `CLUSTER_NODE_ID` env override. | -| Heartbeat tick | | Every 10s: update own heartbeat + collect runtime stats (goroutines, heap, GC, uptime, ws_clients, extensions). | -| Stale sweep | | Every heartbeat tick: `DELETE WHERE heartbeat < now() - 30s`. All nodes run it — idempotent, no ring topology. | -| Self-eviction | | If heartbeat UPDATE returns 0 rows: node was swept by peer → log error + `os.Exit(1)`. K8s restarts → re-register. | -| LISTEN/NOTIFY routing | | Durable events (messages, state changes) fan-out via `pg_notify`. All replicas receive, push to local WS subscribers, drop if irrelevant. Phase 1: ephemeral events (typing, presence) also via NOTIFY (8KB limit, ~60 bytes each). | -| Cluster API | | `GET /api/admin/cluster` — `SELECT node_id, endpoint, registered_at, heartbeat, stats FROM node_registry ORDER BY seq`. Returns `{data: [...]}` envelope. | -| Admin cluster dashboard | | Extension renders one card per node: node_id, endpoint, uptime, ws_clients, heap, GC pause. JSONB stats — future keys render automatically, no schema migration. | -| Health endpoint | | `GET /health` includes `cluster: {size, peers, heartbeat_age_ms}`. Satisfies MVP health monitoring requirement. | -| Config | | `CLUSTER_NODE_ID`, `CLUSTER_HEARTBEAT_INTERVAL` (default 10s), `CLUSTER_STALE_THRESHOLD` (default 30s), `CLUSTER_ENDPOINT` (Phase 2 mesh, auto-detect). | -| Single-node regression | | One-node behavior identical to pre-cluster: one registry row, NOTIFY delivers back to same instance. No special-casing. | -| Multi-node integration test | | Docker Compose: 3 instances, shared PG. Verify registration, heartbeat, peer discovery, stale sweep on shutdown, WS fan-out. | +| `node_registry` table | ✅ | `UNLOGGED TABLE` — node_id, endpoint, seq, registered_at, heartbeat, stats JSONB. Postgres migration 013. | +| Node registration | ✅ | Self-registration on startup: `INSERT ... ON CONFLICT DO UPDATE`. `node_id` = `hostname-PID` or `CLUSTER_NODE_ID` env override. | +| Heartbeat tick | ✅ | Every 10s: update own heartbeat + collect runtime stats (goroutines, heap, GC, uptime, ws_clients). | +| Stale sweep | ✅ | Every heartbeat tick: `DELETE WHERE heartbeat < now() - 30s`. All nodes run it — idempotent, no ring topology. | +| Self-eviction | ✅ | If heartbeat UPDATE returns 0 rows: node was swept by peer → log error + `os.Exit(1)`. K8s restarts → re-register. | +| LISTEN/NOTIFY routing | ✅ | Durable events (messages, state changes) fan-out via `pg_notify`. All replicas receive, push to local WS subscribers, drop if irrelevant. Phase 1: ephemeral events (typing, presence) also via NOTIFY (8KB limit, ~60 bytes each). | +| Cluster API | ✅ | `GET /api/v1/admin/cluster` — returns `{data: [...]}` envelope with all registered nodes. | +| Admin cluster dashboard | ✅ | `cluster-dashboard` surface package renders one card per node: node_id, endpoint, uptime, ws_clients, heap, GC pause. JSONB stats — future keys render automatically, no schema migration. Auto-refresh every 10s. | +| Health endpoint | ✅ | `GET /health` includes `node_id` and `cluster: {size, peers, heartbeat_age_ms}`. | +| Config | ✅ | `CLUSTER_NODE_ID`, `CLUSTER_HEARTBEAT_INTERVAL` (default 10s), `CLUSTER_STALE_THRESHOLD` (default 30s), `CLUSTER_ENDPOINT` (Phase 2 mesh, auto-detect). | +| Single-node regression | ✅ | One-node behavior identical to pre-cluster: one registry row, NOTIFY delivers back to same instance. No special-casing. SQLite returns nil store — all cluster code guarded. | +| Multi-node integration test | ✅ | Docker Compose: 3 instances, shared PG. `ci/e2e-cluster-test.sh`: registration, stale sweep on stop, re-registration on restart. 3 unit tests + 2 handler tests. | ### v0.6.1 — Backup/Restore + Documentation (planned) diff --git a/VERSION b/VERSION index d1d899f..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5 +0.6.0 diff --git a/ci/e2e-cluster-test.sh b/ci/e2e-cluster-test.sh new file mode 100755 index 0000000..18048ca --- /dev/null +++ b/ci/e2e-cluster-test.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# e2e-cluster-test.sh — Cluster registry E2E test (v0.6.0) +# +# Requires: docker-compose-e2e.yml running with 3 replicas. +# Usage: +# docker compose -f docker-compose-e2e.yml up --build -d +# ./ci/e2e-cluster-test.sh +# docker compose -f docker-compose-e2e.yml down -v + +set -euo pipefail + +LB="http://localhost:3000" +DIRECT_1="http://localhost:8081" +STALE_WAIT=20 # seconds — must exceed CLUSTER_STALE_THRESHOLD (15s) + +PASS=0 +FAIL=0 + +pass() { PASS=$((PASS + 1)); echo " PASS: $1"; } +fail() { FAIL=$((FAIL + 1)); echo " FAIL: $1"; } + +# ── Wait for all 3 replicas ────────────────── +echo "=== Waiting for replicas ===" +for port in 8081 8082 8083; do + for i in $(seq 1 30); do + if curl -sf "http://localhost:$port/health" > /dev/null 2>&1; then + echo " replica :$port ready" + break + fi + sleep 1 + if [ "$i" -eq 30 ]; then + echo "FATAL: replica :$port did not become healthy" + exit 1 + fi + done +done + +# ── Login as admin ──────────────────────────── +echo "=== Authenticating ===" +TOKEN=$(curl -sf "$LB/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"login":"admin","password":"admin"}' | jq -r '.access_token') + +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "FATAL: login failed" + exit 1 +fi +echo " admin token acquired" + +auth() { echo "Authorization: Bearer $TOKEN"; } + +# Wait for heartbeat to propagate (at least 2 tick cycles at 5s each) +echo "=== Waiting for heartbeat convergence ===" +sleep 12 + +# ── Test 1: All 3 nodes registered ─────────── +echo "=== Test 1: Cluster shows 3 nodes ===" +NODES=$(curl -sf "$LB/api/v1/admin/cluster" -H "$(auth)") +COUNT=$(echo "$NODES" | jq '.data | length') +if [ "$COUNT" -eq 3 ]; then + pass "cluster has 3 nodes" +else + fail "cluster has $COUNT nodes (expected 3)" + echo " response: $NODES" +fi + +# Verify all expected node IDs present +for nid in node-1 node-2 node-3; do + if echo "$NODES" | jq -e ".data[] | select(.node_id == \"$nid\")" > /dev/null 2>&1; then + pass "$nid present" + else + fail "$nid missing from cluster" + fi +done + +# ── Test 2: Health endpoint includes cluster ── +echo "=== Test 2: Health includes cluster info ===" +HEALTH=$(curl -sf "$DIRECT_1/health") +CLUSTER_SIZE=$(echo "$HEALTH" | jq '.cluster.size // 0') +if [ "$CLUSTER_SIZE" -eq 3 ]; then + pass "health shows cluster.size=3" +else + fail "health shows cluster.size=$CLUSTER_SIZE (expected 3)" +fi + +NODE_ID=$(echo "$HEALTH" | jq -r '.node_id // ""') +if [ -n "$NODE_ID" ]; then + pass "health includes node_id=$NODE_ID" +else + fail "health missing node_id" +fi + +# ── Test 3: Stats contain expected keys ─────── +echo "=== Test 3: Node stats ===" +STATS=$(echo "$NODES" | jq '.data[0].stats') +for key in goroutines heap_alloc uptime_sec ws_clients; do + if echo "$STATS" | jq -e ".$key" > /dev/null 2>&1; then + pass "stats.$key present" + else + fail "stats.$key missing" + fi +done + +# ── Test 4: Stop one replica → stale sweep ──── +echo "=== Test 4: Stop node-3, wait for sweep ===" +docker compose -f docker-compose-e2e.yml stop switchboard-3 + +echo " waiting ${STALE_WAIT}s for stale sweep..." +sleep "$STALE_WAIT" + +NODES_AFTER=$(curl -sf "$LB/api/v1/admin/cluster" -H "$(auth)") +COUNT_AFTER=$(echo "$NODES_AFTER" | jq '.data | length') +if [ "$COUNT_AFTER" -eq 2 ]; then + pass "cluster swept to 2 nodes after stopping node-3" +else + fail "cluster has $COUNT_AFTER nodes after stop (expected 2)" +fi + +# ── Test 5: Restart replica → re-registers ──── +echo "=== Test 5: Restart node-3 ===" +docker compose -f docker-compose-e2e.yml start switchboard-3 + +# Wait for startup + at least one heartbeat +for i in $(seq 1 30); do + if curl -sf "http://localhost:8083/health" > /dev/null 2>&1; then + break + fi + sleep 1 +done +sleep 8 # allow heartbeat to register + +NODES_RESTART=$(curl -sf "$LB/api/v1/admin/cluster" -H "$(auth)") +COUNT_RESTART=$(echo "$NODES_RESTART" | jq '.data | length') +if [ "$COUNT_RESTART" -eq 3 ]; then + pass "cluster back to 3 nodes after restart" +else + fail "cluster has $COUNT_RESTART nodes after restart (expected 3)" +fi + +# ── Summary ─────────────────────────────────── +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/ci/e2e-nginx.conf b/ci/e2e-nginx.conf index 6df504c..7231890 100644 --- a/ci/e2e-nginx.conf +++ b/ci/e2e-nginx.conf @@ -6,6 +6,7 @@ http { upstream switchboard { server switchboard-1:80; server switchboard-2:80; + server switchboard-3:80; } # WebSocket upgrade map diff --git a/docker-compose-e2e.yml b/docker-compose-e2e.yml index 2708868..e3f66ab 100644 --- a/docker-compose-e2e.yml +++ b/docker-compose-e2e.yml @@ -1,12 +1,13 @@ # docker-compose-e2e.yml — Multi-replica E2E testing (Postgres) # -# Two Switchboard replicas behind an nginx load balancer, +# Three Switchboard replicas behind an nginx load balancer, # sharing a single Postgres instance. Tests cross-replica -# broadcast via pg_notify. +# broadcast via pg_notify and cluster registry (v0.6.0). # # Usage: # docker compose -f docker-compose-e2e.yml up --build -d -# ./ci/e2e-chat-test.sh +# ./ci/e2e-chat-test.sh # chat E2E +# ./ci/e2e-cluster-test.sh # cluster registry E2E # docker compose -f docker-compose-e2e.yml down -v services: @@ -44,6 +45,10 @@ services: LOG_FORMAT: text LOG_LEVEL: info SEED_USERS: "alice:password123:user,bob:password456:user,charlie:password789:user" + CLUSTER_NODE_ID: "node-1" + CLUSTER_HEARTBEAT_INTERVAL: "5s" + CLUSTER_STALE_THRESHOLD: "15s" + CLUSTER_ENDPOINT: "http://switchboard-1:8080" depends_on: postgres: condition: service_healthy @@ -70,12 +75,46 @@ services: LOG_FORMAT: text LOG_LEVEL: info SEED_USERS: "alice:password123:user,bob:password456:user,charlie:password789:user" + CLUSTER_NODE_ID: "node-2" + CLUSTER_HEARTBEAT_INTERVAL: "5s" + CLUSTER_STALE_THRESHOLD: "15s" + CLUSTER_ENDPOINT: "http://switchboard-2:8080" depends_on: postgres: condition: service_healthy ports: - "8082:80" + switchboard-3: + build: + context: . + dockerfile: Dockerfile + environment: + PORT: "8080" + BASE_PATH: "" + DB_DRIVER: postgres + DATABASE_URL: postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable + JWT_SECRET: e2e-jwt-secret + ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!! + SWITCHBOARD_ADMIN_USERNAME: admin + SWITCHBOARD_ADMIN_PASSWORD: admin + STORAGE_BACKEND: pvc + STORAGE_PATH: /data/storage + CORS_ALLOWED_ORIGINS: "*" + EXT_ALLOW_PRIVATE_IPS: "true" + LOG_FORMAT: text + LOG_LEVEL: info + SEED_USERS: "alice:password123:user,bob:password456:user,charlie:password789:user" + CLUSTER_NODE_ID: "node-3" + CLUSTER_HEARTBEAT_INTERVAL: "5s" + CLUSTER_STALE_THRESHOLD: "15s" + CLUSTER_ENDPOINT: "http://switchboard-3:8080" + depends_on: + postgres: + condition: service_healthy + ports: + - "8083:80" + lb: image: nginx:alpine volumes: @@ -85,3 +124,4 @@ services: depends_on: - switchboard-1 - switchboard-2 + - switchboard-3 diff --git a/packages/cluster-dashboard/css/main.css b/packages/cluster-dashboard/css/main.css new file mode 100644 index 0000000..f4cda2b --- /dev/null +++ b/packages/cluster-dashboard/css/main.css @@ -0,0 +1,100 @@ +.cluster-dashboard { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1.5rem; +} + +.cluster-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.cluster-header h1 { + margin: 0; + font-size: 1.5rem; + color: var(--text-primary, #111); +} + +.cluster-status { + font-size: 0.85rem; + color: var(--text-secondary, #666); + background: var(--bg-secondary, #f3f4f6); + padding: 0.25rem 0.75rem; + border-radius: 999px; +} + +.cluster-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; +} + +.cluster-card { + background: var(--bg-primary, #fff); + border: 1px solid var(--border, #e5e7eb); + border-radius: 8px; + padding: 1.25rem; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.node-id { + font-weight: 600; + font-size: 0.9rem; + font-family: var(--font-mono, monospace); + color: var(--text-primary, #111); +} + +.heartbeat-badge { + font-size: 0.75rem; + color: var(--text-secondary, #666); + background: var(--bg-secondary, #f3f4f6); + padding: 0.15rem 0.5rem; + border-radius: 4px; +} + +.node-endpoint { + font-size: 0.8rem; + color: var(--text-secondary, #666); + margin-bottom: 0.75rem; + font-family: var(--font-mono, monospace); +} + +.card-stats { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.5rem; +} + +.stat { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.stat-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-secondary, #999); +} + +.stat-value { + font-size: 0.9rem; + font-weight: 500; + font-family: var(--font-mono, monospace); + color: var(--text-primary, #111); +} + +.cluster-empty { + color: var(--text-secondary, #666); + text-align: center; + padding: 3rem 1rem; +} diff --git a/packages/cluster-dashboard/js/main.js b/packages/cluster-dashboard/js/main.js new file mode 100644 index 0000000..efde770 --- /dev/null +++ b/packages/cluster-dashboard/js/main.js @@ -0,0 +1,124 @@ +/** + * Cluster Dashboard — admin surface showing registered cluster nodes. + * + * Polls GET /api/v1/admin/cluster every 10s. Renders one card per node + * with runtime stats from the JSONB stats column. New keys added to + * stats are rendered automatically — no code change required. + */ +(function () { + 'use strict'; + + var mount = document.getElementById('extension-mount'); + if (!mount) return; + + var REFRESH_MS = 10000; + var timer = null; + + mount.innerHTML = + '
' + + '
' + + '

Cluster

' + + 'loading...' + + '
' + + '
' + + '
'; + + load(); + timer = setInterval(load, REFRESH_MS); + + // Cleanup on navigation (SPA) + window.addEventListener('beforeunload', function () { + if (timer) clearInterval(timer); + }); + + function load() { + var api = (window.sw && window.sw.api) || (typeof API !== 'undefined' && API); + if (!api) return; + var get = api._get || api.get; + if (!get) return; + get.call(api, '/api/v1/admin/cluster').then(render).catch(function () { + // 404 = SQLite (no cluster route registered), or other fetch error + render({ data: [] }); + }); + } + + function render(resp) { + var nodes = (resp && resp.data) || []; + var status = document.getElementById('clusterStatus'); + var grid = document.getElementById('clusterGrid'); + + status.textContent = nodes.length + ' node' + (nodes.length !== 1 ? 's' : '') + ' registered'; + + if (nodes.length === 0) { + grid.innerHTML = '

No nodes registered. The cluster registry requires PostgreSQL — SQLite runs single-node only.

'; + return; + } + + grid.innerHTML = nodes.map(nodeCard).join(''); + } + + function nodeCard(node) { + var stats = {}; + try { stats = typeof node.stats === 'string' ? JSON.parse(node.stats) : (node.stats || {}); } catch (e) { /* ignore */ } + + var uptimeSec = stats.uptime_sec || 0; + var uptime = formatDuration(uptimeSec); + var heap = formatBytes(stats.heap_alloc || 0); + var gcPause = ((stats.gc_pause_ns || 0) / 1e6).toFixed(1) + ' ms'; + var wsClients = stats.ws_clients != null ? stats.ws_clients : '-'; + var goroutines = stats.goroutines != null ? stats.goroutines : '-'; + var gcCycles = stats.gc_cycles != null ? stats.gc_cycles : '-'; + var heartbeatAge = timeSince(node.heartbeat); + + return ( + '
' + + '
' + + '' + esc(node.node_id) + '' + + '' + heartbeatAge + '' + + '
' + + (node.endpoint ? '
' + esc(node.endpoint) + '
' : '') + + '
' + + stat('Uptime', uptime) + + stat('WS Clients', wsClients) + + stat('Goroutines', goroutines) + + stat('Heap', heap) + + stat('GC Pause', gcPause) + + stat('GC Cycles', gcCycles) + + '
' + + '
' + ); + } + + function stat(label, value) { + return '
' + label + '' + value + '
'; + } + + function formatBytes(b) { + if (b < 1024) return b + ' B'; + if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'; + return (b / (1024 * 1024)).toFixed(1) + ' MB'; + } + + function formatDuration(sec) { + if (sec < 60) return Math.floor(sec) + 's'; + if (sec < 3600) return Math.floor(sec / 60) + 'm ' + Math.floor(sec % 60) + 's'; + var h = Math.floor(sec / 3600); + var m = Math.floor((sec % 3600) / 60); + return h + 'h ' + m + 'm'; + } + + function timeSince(iso) { + if (!iso) return '-'; + var ms = Date.now() - new Date(iso).getTime(); + if (ms < 0) ms = 0; + var sec = Math.floor(ms / 1000); + if (sec < 60) return sec + 's ago'; + return Math.floor(sec / 60) + 'm ago'; + } + + function esc(s) { + var d = document.createElement('div'); + d.appendChild(document.createTextNode(s || '')); + return d.innerHTML; + } +})(); diff --git a/packages/cluster-dashboard/manifest.json b/packages/cluster-dashboard/manifest.json new file mode 100644 index 0000000..003541b --- /dev/null +++ b/packages/cluster-dashboard/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "cluster-dashboard", + "icon": "🔗", + "type": "surface", + "title": "Cluster", + "route": "/s/cluster-dashboard", + "auth": "admin", + "layout": "single", + "components": [], + "hooks": ["surface"], + "version": "0.6.0", + "description": "Cluster node registry — health, runtime stats, and peer visibility." +} diff --git a/server/cluster/registry.go b/server/cluster/registry.go new file mode 100644 index 0000000..9d236ed --- /dev/null +++ b/server/cluster/registry.go @@ -0,0 +1,143 @@ +package cluster + +import ( + "context" + "encoding/json" + "log" + "os" + "runtime" + "sync" + "time" + + "switchboard-core/store" +) + +// ConnCounter provides WebSocket connection count (satisfied by events.Hub). +type ConnCounter interface { + ConnCount() int +} + +// RegistryConfig holds tunable cluster registry parameters. +type RegistryConfig struct { + HeartbeatInterval time.Duration + StaleThreshold time.Duration +} + +// Registry manages node self-registration, heartbeat, and stale sweep. +// Follows the workflow.Scanner pattern: stopCh + wg + ticker. +type Registry struct { + nodeID string + endpoint string + store store.ClusterStore + hub ConnCounter + cfg RegistryConfig + startTime time.Time + stopCh chan struct{} + wg sync.WaitGroup +} + +// NewRegistry creates a cluster registry instance. +func NewRegistry(nodeID, endpoint string, s store.ClusterStore, hub ConnCounter, cfg RegistryConfig) *Registry { + return &Registry{ + nodeID: nodeID, + endpoint: endpoint, + store: s, + hub: hub, + cfg: cfg, + startTime: time.Now(), + stopCh: make(chan struct{}), + } +} + +// NodeID returns this node's identifier. +func (r *Registry) NodeID() string { return r.nodeID } + +// Start registers the node and launches the heartbeat goroutine. +func (r *Registry) Start() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := r.store.Register(ctx, r.nodeID, r.endpoint); err != nil { + return err + } + + r.wg.Add(1) + go func() { + defer r.wg.Done() + ticker := time.NewTicker(r.cfg.HeartbeatInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + r.tick() + case <-r.stopCh: + return + } + } + }() + log.Printf("[cluster] started (node=%s, heartbeat=%s, stale=%s)", + r.nodeID, r.cfg.HeartbeatInterval, r.cfg.StaleThreshold) + return nil +} + +// Stop deregisters the node and waits for the heartbeat goroutine to exit. +func (r *Registry) Stop() { + close(r.stopCh) + r.wg.Wait() + + // Best-effort deregister so peers don't have to wait for stale sweep. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := r.store.Deregister(ctx, r.nodeID); err != nil { + log.Printf("[cluster] deregister failed: %v", err) + } + log.Printf("[cluster] stopped (node=%s)", r.nodeID) +} + +// tick runs one heartbeat + sweep cycle. +func (r *Registry) tick() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stats := r.collectStats() + + rows, err := r.store.Heartbeat(ctx, r.nodeID, stats) + if err != nil { + log.Printf("[cluster] heartbeat error: %v", err) + return + } + if rows == 0 { + // Self-eviction: this node was swept by a peer. + // Kubernetes will restart the process → re-register. + log.Printf("[cluster] FATAL: node %s evicted from registry — shutting down", r.nodeID) + os.Exit(1) + } + + swept, err := r.store.SweepStale(ctx, r.cfg.StaleThreshold) + if err != nil { + log.Printf("[cluster] sweep error: %v", err) + return + } + if swept > 0 { + log.Printf("[cluster] swept %d stale node(s)", swept) + } +} + +// collectStats gathers runtime metrics for the stats JSONB column. +func (r *Registry) collectStats() json.RawMessage { + 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(r.startTime).Seconds(), + "ws_clients": r.hub.ConnCount(), + } + + data, _ := json.Marshal(stats) + return data +} diff --git a/server/cluster/registry_test.go b/server/cluster/registry_test.go new file mode 100644 index 0000000..26d30d4 --- /dev/null +++ b/server/cluster/registry_test.go @@ -0,0 +1,112 @@ +package cluster + +import ( + "context" + "encoding/json" + "testing" + "time" + + "switchboard-core/store" +) + +// mockClusterStore implements store.ClusterStore for unit tests. +type mockClusterStore struct { + nodes []store.ClusterNode + heartbeatRows int64 + heartbeatErr error + sweepCalled bool + registerCalled bool + lastStats json.RawMessage +} + +func (m *mockClusterStore) Register(_ context.Context, nodeID, endpoint string) error { + m.registerCalled = true + return nil +} + +func (m *mockClusterStore) Heartbeat(_ context.Context, nodeID string, stats json.RawMessage) (int64, error) { + m.lastStats = stats + return m.heartbeatRows, m.heartbeatErr +} + +func (m *mockClusterStore) SweepStale(_ context.Context, _ time.Duration) (int64, error) { + m.sweepCalled = true + return 0, nil +} + +func (m *mockClusterStore) ListNodes(_ context.Context) ([]store.ClusterNode, error) { + return m.nodes, nil +} + +func (m *mockClusterStore) Deregister(_ context.Context, _ string) error { + return nil +} + +// mockHub implements ConnCounter. +type mockHub struct{ count int } + +func (h *mockHub) ConnCount() int { return h.count } + +func TestCollectStats(t *testing.T) { + hub := &mockHub{count: 5} + reg := NewRegistry("test-node", "http://localhost:8080", nil, hub, RegistryConfig{ + HeartbeatInterval: 10 * time.Second, + StaleThreshold: 30 * time.Second, + }) + + data := reg.collectStats() + + var stats map[string]any + if err := json.Unmarshal(data, &stats); err != nil { + t.Fatalf("collectStats returned invalid JSON: %v", err) + } + + // Verify expected keys + expectedKeys := []string{"goroutines", "heap_alloc", "heap_sys", "gc_cycles", "gc_pause_ns", "uptime_sec", "ws_clients"} + for _, key := range expectedKeys { + if _, ok := stats[key]; !ok { + t.Errorf("missing expected stats key: %s", key) + } + } + + // ws_clients should reflect hub count + if ws, ok := stats["ws_clients"].(float64); !ok || int(ws) != 5 { + t.Errorf("ws_clients = %v, want 5", stats["ws_clients"]) + } +} + +func TestRegistryStartStop(t *testing.T) { + ms := &mockClusterStore{heartbeatRows: 1} + hub := &mockHub{count: 0} + + reg := NewRegistry("test-node", "", ms, hub, RegistryConfig{ + HeartbeatInterval: 50 * time.Millisecond, + StaleThreshold: 150 * time.Millisecond, + }) + + if err := reg.Start(); err != nil { + t.Fatalf("Start() error: %v", err) + } + if !ms.registerCalled { + t.Error("Register was not called on Start") + } + + // Let at least one tick fire + time.Sleep(100 * time.Millisecond) + + reg.Stop() + + if !ms.sweepCalled { + t.Error("SweepStale was never called during tick") + } + if ms.lastStats == nil { + t.Error("Heartbeat was never called with stats") + } +} + +func TestNodeID(t *testing.T) { + reg := NewRegistry("my-node-123", "", nil, &mockHub{}, RegistryConfig{}) + if got := reg.NodeID(); got != "my-node-123" { + t.Errorf("NodeID() = %q, want %q", got, "my-node-123") + } +} diff --git a/server/config/config.go b/server/config/config.go index 2b87845..dd8bb29 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "time" "github.com/joho/godotenv" ) @@ -85,6 +86,17 @@ type Config struct { OIDCRolesClaim string // JWT claim for role mapping (default "realm_access.roles") OIDCGroupsClaim string // JWT claim for group sync (default "groups") OIDCAdminRole string // IdP role value that maps to admin (default "admin") + + // Cluster registry (v0.6.0) + // PG-backed node registry for horizontal scaling. No-op on SQLite. + // CLUSTER_NODE_ID: override for deterministic identity (default: hostname-PID). + // CLUSTER_HEARTBEAT_INTERVAL: tick frequency (default "10s"). + // CLUSTER_STALE_THRESHOLD: 3x heartbeat = stale (default "30s"). + // CLUSTER_ENDPOINT: advertised address for peer mesh (Phase 2, auto-detect). + ClusterNodeID string + ClusterHeartbeatInterval time.Duration + ClusterStaleThreshold time.Duration + ClusterEndpoint string } // Load reads configuration from environment variables. @@ -143,6 +155,12 @@ func Load() *Config { OIDCRolesClaim: getEnv("OIDC_ROLES_CLAIM", "realm_access.roles"), OIDCGroupsClaim: getEnv("OIDC_GROUPS_CLAIM", "groups"), OIDCAdminRole: getEnv("OIDC_ADMIN_ROLE", "admin"), + + // Cluster + ClusterNodeID: getEnv("CLUSTER_NODE_ID", ""), + ClusterHeartbeatInterval: getEnvDuration("CLUSTER_HEARTBEAT_INTERVAL", 10*time.Second), + ClusterStaleThreshold: getEnvDuration("CLUSTER_STALE_THRESHOLD", 30*time.Second), + ClusterEndpoint: getEnv("CLUSTER_ENDPOINT", ""), } } @@ -204,6 +222,15 @@ func getEnvInt(key string, fallback int) int { return fallback } +func getEnvDuration(key string, fallback time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return fallback +} + func getEnvBool(key string, fallback bool) bool { if v := os.Getenv(key); v != "" { b, err := strconv.ParseBool(v) diff --git a/server/database/migrations/postgres/013_cluster_registry.sql b/server/database/migrations/postgres/013_cluster_registry.sql new file mode 100644 index 0000000..d0ff030 --- /dev/null +++ b/server/database/migrations/postgres/013_cluster_registry.sql @@ -0,0 +1,13 @@ +-- 013_cluster_registry.sql — v0.6.0 +-- PG-backed cluster registry for node self-assembly. +-- UNLOGGED: no WAL overhead, contents lost on PG crash (correct behavior — +-- all nodes are dead anyway and re-register on restart). + +CREATE UNLOGGED TABLE IF NOT EXISTS node_registry ( + node_id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL DEFAULT '', + seq SERIAL, + registered_at TIMESTAMPTZ DEFAULT now(), + heartbeat TIMESTAMPTZ DEFAULT now(), + stats JSONB DEFAULT '{}' +); diff --git a/server/handlers/cluster.go b/server/handlers/cluster.go new file mode 100644 index 0000000..b63f7e0 --- /dev/null +++ b/server/handlers/cluster.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "switchboard-core/store" +) + +// ClusterHandler serves the admin cluster API. +type ClusterHandler struct { + stores store.Stores +} + +func NewClusterHandler(stores store.Stores) *ClusterHandler { + return &ClusterHandler{stores: stores} +} + +// ListNodes returns all registered cluster nodes. +// GET /api/v1/admin/cluster +func (h *ClusterHandler) ListNodes(c *gin.Context) { + nodes, err := h.stores.Cluster.ListNodes(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list cluster nodes"}) + return + } + if nodes == nil { + nodes = []store.ClusterNode{} + } + c.JSON(http.StatusOK, gin.H{"data": nodes}) +} diff --git a/server/handlers/cluster_test.go b/server/handlers/cluster_test.go new file mode 100644 index 0000000..208ee07 --- /dev/null +++ b/server/handlers/cluster_test.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "switchboard-core/store" +) + +// mockClusterStore implements store.ClusterStore for handler tests. +type mockClusterStore struct { + nodes []store.ClusterNode +} + +func (m *mockClusterStore) Register(_ context.Context, _, _ string) error { return nil } +func (m *mockClusterStore) Heartbeat(_ context.Context, _ string, _ json.RawMessage) (int64, error) { + return 1, nil +} +func (m *mockClusterStore) SweepStale(_ context.Context, _ time.Duration) (int64, error) { return 0, nil } +func (m *mockClusterStore) ListNodes(_ context.Context) ([]store.ClusterNode, error) { + return m.nodes, nil +} +func (m *mockClusterStore) Deregister(_ context.Context, _ string) error { return nil } + +func TestClusterListNodes(t *testing.T) { + gin.SetMode(gin.TestMode) + + mock := &mockClusterStore{ + nodes: []store.ClusterNode{ + { + NodeID: "node-1", + Endpoint: "http://node-1:8080", + Seq: 1, + RegisteredAt: time.Now(), + Heartbeat: time.Now(), + Stats: json.RawMessage(`{"ws_clients":3}`), + }, + { + NodeID: "node-2", + Endpoint: "http://node-2:8080", + Seq: 2, + RegisteredAt: time.Now(), + Heartbeat: time.Now(), + Stats: json.RawMessage(`{"ws_clients":7}`), + }, + }, + } + + stores := store.Stores{Cluster: mock} + h := NewClusterHandler(stores) + + r := gin.New() + r.GET("/api/v1/admin/cluster", h.ListNodes) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/cluster", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp struct { + Data []store.ClusterNode `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(resp.Data) != 2 { + t.Fatalf("data length = %d, want 2", len(resp.Data)) + } + if resp.Data[0].NodeID != "node-1" { + t.Errorf("data[0].node_id = %q, want %q", resp.Data[0].NodeID, "node-1") + } + if resp.Data[1].NodeID != "node-2" { + t.Errorf("data[1].node_id = %q, want %q", resp.Data[1].NodeID, "node-2") + } +} + +func TestClusterListNodesEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + + mock := &mockClusterStore{nodes: nil} + stores := store.Stores{Cluster: mock} + h := NewClusterHandler(stores) + + r := gin.New() + r.GET("/api/v1/admin/cluster", h.ListNodes) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/cluster", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Should be empty array, not null + if resp.Data == nil { + t.Error("data should be [], not null") + } +} diff --git a/server/handlers/packages_bundled.go b/server/handlers/packages_bundled.go index be9a27e..306f556 100644 --- a/server/handlers/packages_bundled.go +++ b/server/handlers/packages_bundled.go @@ -23,13 +23,19 @@ import ( ) // defaultBundledPackages is the curated set of packages installed by default. +// This is the dev/test bundle — includes demos and workflow examples. // Other packages still ship in the Docker image but require BUNDLED_PACKAGES // to be set explicitly (or "*" for all). +// +// Recommended production override (lean): +// BUNDLED_PACKAGES=notes,chat,chat-core,cluster-dashboard var defaultBundledPackages = map[string]bool{ "notes": true, + "chat": true, "chat-core": true, "workflow-chat": true, "dashboard": true, + "cluster-dashboard": true, "workflow-demo": true, "bug-report-triage": true, "content-approval": true, diff --git a/server/main.go b/server/main.go index 7fd31dd..e7b055e 100644 --- a/server/main.go +++ b/server/main.go @@ -16,6 +16,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "switchboard-core/auth" + "switchboard-core/cluster" "switchboard-core/config" "switchboard-core/crypto" "switchboard-core/database" @@ -209,6 +210,27 @@ func main() { // ── WebSocket Hub ───────────────────────── hub := events.NewHub(bus, middleware.GetAllowedOrigins(cfg)) + // ── Cluster Registry (v0.6.0) ──────────── + // PG-backed node self-registration + heartbeat. No-op on SQLite. + var clusterReg *cluster.Registry + if database.IsPostgres() && stores.Cluster != nil { + nodeID := cfg.ClusterNodeID + if nodeID == "" { + hostname, _ := os.Hostname() + nodeID = fmt.Sprintf("%s-%d", hostname, os.Getpid()) + } + clusterReg = cluster.NewRegistry(nodeID, cfg.ClusterEndpoint, stores.Cluster, hub, cluster.RegistryConfig{ + HeartbeatInterval: cfg.ClusterHeartbeatInterval, + StaleThreshold: cfg.ClusterStaleThreshold, + }) + if err := clusterReg.Start(); err != nil { + log.Printf("⚠ Cluster registry failed to start: %v", err) + clusterReg = nil + } else { + defer clusterReg.Stop() + } + } + // ── WebSocket Ticket Store (v0.32.0: PG-backed for cross-pod) ───── ticketAdapter := &events.TicketValidatorAdapter{Store: stores.Tickets} @@ -244,13 +266,15 @@ func main() { // Health check (k8s probes hit this directly) base.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{ + info := gin.H{ "status": "ok", "version": Version, "database": database.IsConnected(), "database_name": database.Name(), "schema_version": database.SchemaVersion(), - }) + } + appendClusterHealth(info, clusterReg, stores) + c.JSON(200, info) }) // v0.32.0: Kubernetes probe endpoints @@ -345,6 +369,7 @@ func main() { if database.IsConnected() { info["registration_enabled"] = handlers.IsRegistrationEnabled(stores) } + appendClusterHealth(info, clusterReg, stores) c.JSON(200, info) }) @@ -762,6 +787,12 @@ func main() { admin.PUT("/schedules/:id/disable", schedH.AdminDisableSchedule) admin.DELETE("/schedules/:id", schedH.AdminDeleteSchedule) + // ── Cluster (v0.6.0) ───────────────── + if stores.Cluster != nil { + clusterH := handlers.NewClusterHandler(stores) + admin.GET("/cluster", clusterH.ListNodes) + } + // Surface aliases (backward compat — same handlers) admin.GET("/surfaces", pkgAdm.ListPackages) admin.GET("/surfaces/:id", pkgAdm.GetPackage) @@ -841,6 +872,32 @@ func main() { } } +// appendClusterHealth adds cluster info to a health response if the registry is active. +func appendClusterHealth(info gin.H, reg *cluster.Registry, stores store.Stores) { + if reg == nil || stores.Cluster == nil { + return + } + nodes, err := stores.Cluster.ListNodes(context.Background()) + if err != nil { + return + } + var peers []string + var heartbeatAge time.Duration + for _, n := range nodes { + if n.NodeID != reg.NodeID() { + peers = append(peers, n.NodeID) + } else { + heartbeatAge = time.Since(n.Heartbeat) + } + } + info["node_id"] = reg.NodeID() + info["cluster"] = gin.H{ + "size": len(nodes), + "peers": peers, + "heartbeat_age_ms": heartbeatAge.Milliseconds(), + } +} + // ── Vault CLI Commands ────────────────────── func runVaultCommand(subcmd string) { diff --git a/server/store/cluster_iface.go b/server/store/cluster_iface.go new file mode 100644 index 0000000..c9d32fb --- /dev/null +++ b/server/store/cluster_iface.go @@ -0,0 +1,37 @@ +package store + +import ( + "context" + "encoding/json" + "time" +) + +// ClusterNode represents a registered node in the cluster registry. +type ClusterNode struct { + NodeID string `json:"node_id"` + Endpoint string `json:"endpoint"` + Seq int `json:"seq"` + RegisteredAt time.Time `json:"registered_at"` + Heartbeat time.Time `json:"heartbeat"` + Stats json.RawMessage `json:"stats"` +} + +// ClusterStore manages the node_registry table for cluster self-assembly. +// Postgres-only — SQLite deployments set this to nil. +type ClusterStore interface { + // Register inserts or re-registers a node (idempotent). + Register(ctx context.Context, nodeID, endpoint string) error + + // Heartbeat updates the node's heartbeat timestamp and stats. + // Returns rows affected — 0 means the node was swept (self-eviction). + Heartbeat(ctx context.Context, nodeID string, stats json.RawMessage) (int64, error) + + // SweepStale deletes nodes whose heartbeat is older than threshold. + SweepStale(ctx context.Context, threshold time.Duration) (int64, error) + + // ListNodes returns all registered nodes ordered by sequence. + ListNodes(ctx context.Context) ([]ClusterNode, error) + + // Deregister removes a node (best-effort cleanup on shutdown). + Deregister(ctx context.Context, nodeID string) error +} diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 6e6b781..0351b6a 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -54,6 +54,7 @@ type Stores struct { RateLimits RateLimitStore // v0.32.0: Distributed rate limiting Triggers TriggerStore // v0.2.2: Extension event/webhook triggers ScheduledTasks ScheduledTaskStore // v0.2.2: User-created cron tasks + Cluster ClusterStore // v0.6.0: PG-backed cluster node registry (nil on SQLite) } // TeamAvailableModel is returned by CatalogStore.ListTeamAvailable. diff --git a/server/store/postgres/cluster.go b/server/store/postgres/cluster.go new file mode 100644 index 0000000..bcead43 --- /dev/null +++ b/server/store/postgres/cluster.go @@ -0,0 +1,78 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "switchboard-core/store" +) + +// ClusterStore manages node_registry — Postgres-only UNLOGGED table. +type ClusterStore struct{} + +func NewClusterStore() *ClusterStore { return &ClusterStore{} } + +func (s *ClusterStore) Register(ctx context.Context, nodeID, endpoint string) error { + _, err := DB.ExecContext(ctx, ` + 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 = '{}' + `, nodeID, endpoint) + return err +} + +func (s *ClusterStore) Heartbeat(ctx context.Context, nodeID string, stats json.RawMessage) (int64, error) { + result, err := DB.ExecContext(ctx, ` + UPDATE node_registry + SET heartbeat = now(), stats = $2 + WHERE node_id = $1 + `, nodeID, stats) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func (s *ClusterStore) SweepStale(ctx context.Context, threshold time.Duration) (int64, error) { + result, err := DB.ExecContext(ctx, ` + DELETE FROM node_registry + WHERE heartbeat < now() - $1 * interval '1 second' + `, threshold.Seconds()) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func (s *ClusterStore) ListNodes(ctx context.Context) ([]store.ClusterNode, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT node_id, endpoint, seq, registered_at, heartbeat, stats + FROM node_registry + ORDER BY seq + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var nodes []store.ClusterNode + for rows.Next() { + var n store.ClusterNode + if err := rows.Scan(&n.NodeID, &n.Endpoint, &n.Seq, &n.RegisteredAt, &n.Heartbeat, &n.Stats); err != nil { + return nil, fmt.Errorf("scan cluster node: %w", err) + } + nodes = append(nodes, n) + } + return nodes, rows.Err() +} + +func (s *ClusterStore) Deregister(ctx context.Context, nodeID string) error { + _, err := DB.ExecContext(ctx, `DELETE FROM node_registry WHERE node_id = $1`, nodeID) + return err +} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index b8492d9..1f930f0 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -32,5 +32,6 @@ func NewStores(db *sql.DB) store.Stores { RateLimits: NewRateLimitStore(), Triggers: NewTriggerStore(), ScheduledTasks: NewScheduledTaskStore(), + Cluster: NewClusterStore(), } }