feat(go-services): Phase 2 ingest — shared Ingestor + shadow consumer

Implements the plugin event handlers (the /ws/position write logic) as a shared
Ingestor, validated against real traffic by replaying Python's /ws/live firehose
into an isolated dereth_go DB (no production write, no plugin stolen).

- ingest.go: faithful ports of telemetry (kill-delta -> char_stats, server
  received_at stamp), rare (rare_stats/rare_stats_sessions/rare_events), portal
  (coord upsert), character_stats (stats_data JSONB subset + upsert), spawn, and
  the memory-only handlers (vitals/quest/equipment_cantrip/nearby/dungeon). In
  -memory live state + read-side overlay accessors.
- shadow.go: coder/websocket consumer of /ws/live -> Ingestor.dispatch (telemetry
  matched by shape since its broadcast has no type field).
- main.go/store.go: ingest mode (READ_ONLY=false + SHADOW_INGEST_WS) wires the
  ingestor; read handlers (/character-stats, /equipment-cantrip, /quest-status)
  now consult the live overlay first, like Python.
- compose: shadow instance ingests ws://dereth-tracker:8765/ws/live.

Validated live: dereth_go has 73 distinct telemetry chars; shadow /live online
set == production (73=73); character_stats 5/5 exact byte-match (0 mismatch);
char_stats kill-deltas + portals accumulating. compare/compare_ingest.py.

Deferred to next pass: combat_stats (delta/merge), share_*, the /ws/position +
/ws/live servers (for cutover).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 10:31:15 +02:00
parent 6a839e69bc
commit a5d69ba88d
7 changed files with 621 additions and 3 deletions

View file

@ -39,6 +39,7 @@ type Server struct {
invProxy *httputil.ReverseProxy
staticDir string
secretKey string
ingestor *Ingestor // non-nil only in ingest/shadow mode
log *slog.Logger
}
@ -93,6 +94,17 @@ func main() {
cancel()
}
// Shadow ingest: replay the Python /ws/live firehose into our handlers.
if cfg.IngestWS != "" {
if cfg.ReadOnly {
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)
go srv.runShadowConsumer(ctx, cfg.IngestWS)
logger.Info("shadow ingest enabled", "source", cfg.IngestWS)
}
go srv.runCacheLoop(ctx)
go srv.runTotalsLoop(ctx)
logger.Info("db connected; cache loops started",