feat(go-services): Phase 2 foundation — isolated shadow DB + schema

Stands up the shadow-ingest substrate without touching production:
- schema.go: faithful replica of db_async.init_db_async (idempotent DDL),
  run only when an instance OWNS its DB (READ_ONLY=false). Fixes for a fresh DB:
  spawn_events has no sole-id PK (so it can be a hypertable), telemetry_events
  compression is enabled before its policy, and the portal unique index uses
  ROUND(..,1) to match main.py's ON CONFLICT. 35/35 statements OK.
- store.go: read-only transaction enforcement is now conditional (on for
  production read parity, off for ingest).
- main.go: READ_ONLY + SHADOW_INGEST_WS config; schema init on boot when owning
  the DB.
- compose override: a SEPARATE TimescaleDB `dereth-go-db` (isolated volume,
  127.0.0.1:5434) and a `dereth-tracker-go-shadow` instance (image reused via
  dereth-tracker-go:local) that owns it. Production DB never written.

Verified: dereth_go has all 13 tables; telemetry_events + spawn_events are
hypertables; the read-side instance still serves production read-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 10:18:30 +02:00
parent b6d2871cf0
commit 6a839e69bc
4 changed files with 277 additions and 12 deletions

View file

@ -77,7 +77,7 @@ func main() {
logger.Warn("DATABASE_URL unset — running without DB; DB-backed endpoints will be empty")
} else {
connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
pool, err := newPool(connectCtx, cfg.DatabaseURL)
pool, err := newPool(connectCtx, cfg.DatabaseURL, cfg.ReadOnly)
cancel()
if err != nil {
logger.Error("db pool init failed", "err", err)
@ -85,9 +85,18 @@ func main() {
}
defer pool.Close()
srv.pool = pool
// Ingest/shadow mode owns its own DB: create the schema on first run.
if !cfg.ReadOnly {
schemaCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
initSchema(schemaCtx, pool, logger)
cancel()
}
go srv.runCacheLoop(ctx)
go srv.runTotalsLoop(ctx)
logger.Info("db connected; cache loops started", "live_interval", cacheInterval.String(), "totals_interval", totalsInterval.String())
logger.Info("db connected; cache loops started",
"read_only", cfg.ReadOnly, "live_interval", cacheInterval.String(), "totals_interval", totalsInterval.String())
}
mux := http.NewServeMux()
@ -121,19 +130,23 @@ func main() {
// matching the Python service's env var names where they overlap.
type config struct {
Addr string // listen address, e.g. ":8770"
DatabaseURL string // dereth TimescaleDB DSN (read-only use)
DatabaseURL string // dereth TimescaleDB DSN
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)
}
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"),
}
}