feat(go-services): tracker WS servers (/ws/position + /ws/live) + robust shadow
Completes the Go tracker as a cutover-ready drop-in: - wslive.go: browser broadcast hub with per-client subscribe filters (nil=all), request_dungeon_map replies, and command routing; auth = internal-trust or session cookie. The ingestor broadcasts every handled event to it. - wsposition.go: plugin ingest server with X-Plugin-Secret/SHARED_SECRET auth (constant-time, fails closed, legacy fallback), register -> plugin_conns, and dispatch into the shared Ingestor. plugin registry for backend->plugin commands. - main.go: statusRecorder.Unwrap() so coder/websocket can hijack through the logging middleware (WS handshakes failed without it); /ws/ bypasses HTTP auth. Shadow consumer robustness (the harness was being evicted under the full firehose): decouple socket read from processing — the read loop only copies raw frames to a queue; a worker unmarshals + dispatches. JSON parsing in the read loop was slowing it enough that Python's broadcast send errored and evicted us (Read then blocked forever). Added a 25s read-deadline watchdog to self-heal. Validated live: shadow /live online = 73 = production; telemetry sustained ~12/s, 0 drops, no eviction; and the shadow's /ws/live re-broadcast stream is IDENTICAL to production's (TOTAL 2150=2150, every event type exact). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7350b00341
commit
27757636e4
7 changed files with 418 additions and 34 deletions
|
|
@ -36,11 +36,15 @@ type Server struct {
|
|||
pool *pgxpool.Pool
|
||||
cache *liveCache
|
||||
totals *totalsCache
|
||||
invProxy *httputil.ReverseProxy
|
||||
staticDir string
|
||||
secretKey string
|
||||
ingestor *Ingestor // non-nil only in ingest/shadow mode
|
||||
log *slog.Logger
|
||||
invProxy *httputil.ReverseProxy
|
||||
staticDir string
|
||||
secretKey string
|
||||
sharedSecret string
|
||||
sharedSecretLegacy string
|
||||
ingestor *Ingestor // non-nil only in ingest/shadow mode
|
||||
hub *Hub // browser /ws/live fan-out
|
||||
plugins *pluginRegistry
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
|
@ -62,11 +66,15 @@ func main() {
|
|||
defer stop()
|
||||
|
||||
srv := &Server{
|
||||
cache: newLiveCache(),
|
||||
totals: newTotalsCache(),
|
||||
staticDir: cfg.StaticDir,
|
||||
secretKey: cfg.SecretKey,
|
||||
log: logger,
|
||||
cache: newLiveCache(),
|
||||
totals: newTotalsCache(),
|
||||
staticDir: cfg.StaticDir,
|
||||
secretKey: cfg.SecretKey,
|
||||
sharedSecret: cfg.SharedSecret,
|
||||
sharedSecretLegacy: cfg.SharedSecretLegacy,
|
||||
hub: newHub(),
|
||||
plugins: newPluginRegistry(logger),
|
||||
log: logger,
|
||||
}
|
||||
if cfg.SecretKey == "" {
|
||||
// Fail closed like the Python service: with no key, no external cookie
|
||||
|
|
@ -108,7 +116,7 @@ func main() {
|
|||
logger.Error("SHADOW_INGEST_WS set but READ_ONLY=true; refusing to ingest into the production DB")
|
||||
os.Exit(1)
|
||||
}
|
||||
srv.ingestor = newIngestor(pool, logger, nil)
|
||||
srv.ingestor = newIngestor(pool, logger, srv.hub.broadcast)
|
||||
go srv.runShadowConsumer(ctx, cfg.IngestWS)
|
||||
logger.Info("shadow ingest enabled", "source", cfg.IngestWS)
|
||||
}
|
||||
|
|
@ -154,19 +162,23 @@ type config struct {
|
|||
ReadOnly bool // true = read-side parity (force read-only txns); false = ingest/shadow (owns its DB)
|
||||
InventoryURL string // inventory-service base URL
|
||||
StaticDir string // directory for static assets / openissues.json
|
||||
SecretKey string // session-cookie signing key (must match the Python service)
|
||||
IngestWS string // optional: a /ws/live URL to shadow-ingest from (Python tracker)
|
||||
SecretKey string // session-cookie signing key (must match the Python service)
|
||||
SharedSecret string // plugin /ws/position auth
|
||||
SharedSecretLegacy string // plugin auth rotation fallback
|
||||
IngestWS string // optional: a /ws/live URL to shadow-ingest from (Python tracker)
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
return config{
|
||||
Addr: ":" + envOr("PORT", "8770"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
ReadOnly: envOr("READ_ONLY", "true") != "false",
|
||||
InventoryURL: envOr("INVENTORY_SERVICE_URL", "http://inventory-service:8000"),
|
||||
StaticDir: envOr("STATIC_DIR", "static"),
|
||||
SecretKey: os.Getenv("SECRET_KEY"),
|
||||
IngestWS: os.Getenv("SHADOW_INGEST_WS"),
|
||||
Addr: ":" + envOr("PORT", "8770"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
ReadOnly: envOr("READ_ONLY", "true") != "false",
|
||||
InventoryURL: envOr("INVENTORY_SERVICE_URL", "http://inventory-service:8000"),
|
||||
StaticDir: envOr("STATIC_DIR", "static"),
|
||||
SecretKey: os.Getenv("SECRET_KEY"),
|
||||
SharedSecret: os.Getenv("SHARED_SECRET"),
|
||||
SharedSecretLegacy: os.Getenv("SHARED_SECRET_LEGACY"),
|
||||
IngestWS: os.Getenv("SHADOW_INGEST_WS"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,6 +224,10 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("GET /issues", s.handleIssues)
|
||||
mux.HandleFunc("GET /me", s.handleMe)
|
||||
|
||||
// WebSocket servers (cutover-ready): browser fan-out + plugin ingest.
|
||||
mux.HandleFunc("GET /ws/live", s.handleWSLive)
|
||||
mux.HandleFunc("GET /ws/position", s.handleWSPosition)
|
||||
|
||||
// Inventory-service reverse proxies.
|
||||
s.registerProxyRoutes(mux)
|
||||
}
|
||||
|
|
@ -261,3 +277,10 @@ func (s *statusRecorder) WriteHeader(code int) {
|
|||
s.status = code
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController (used by coder/websocket to hijack the
|
||||
// connection for /ws upgrades) reach the underlying ResponseWriter through this
|
||||
// logging wrapper. Without it, WebSocket handshakes fail.
|
||||
func (s *statusRecorder) Unwrap() http.ResponseWriter {
|
||||
return s.ResponseWriter
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue