Parallel Go reimplementation of the dereth-tracker read side, deployed loopback-only (:8770) and reading the dereth TimescaleDB read-only. The live Python stack is untouched (added via a compose override, not by editing the tracked docker-compose.yml). - Phase 0 scaffold: stdlib net/http server (Go 1.22+ method+path routing), /health + /api-version, multi-stage distroless Docker build, and go-services/docker-compose.go.yml override (loopback :8770). - Phase 1: pgx v5 pool forced into read-only transactions, a 5s /live + /trails cache loop using the exact main.py:837 SQL, and Python-isoformat timestamps so output matches FastAPI's jsonable_encoder. - compare/compare_live.py: parity harness vs the live Python service. Uses the server-stamped received_at to prove same-row full-field equality and to make the online-set diff boundary-aware. Verified on live traffic (73 players): identical online set + 23-key schema, identity/type parity for all, every same-row pair matches on every field, and diff-row pairs differ only by the ~6s two-cache refresh skew. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
173 lines
5 KiB
Go
173 lines
5 KiB
Go
// Command tracker-go is a Go reimplementation of the MosswartOverlord
|
|
// "dereth-tracker" backend, deployed in parallel with the live Python service
|
|
// for side-by-side comparison (strangler-fig migration).
|
|
//
|
|
// Phase 1: read-side parity. Connects READ-ONLY to the existing dereth
|
|
// TimescaleDB and reimplements the HTTP read API, starting with the /live and
|
|
// /trails caches (the 5s _refresh_cache_loop). It never touches anything the
|
|
// Python service writes.
|
|
//
|
|
// Routes are declared WITHOUT the nginx-stripped "/go/" prefix, mirroring the
|
|
// Python service's "no /api/ prefix" convention. nginx's `location /go/` strips
|
|
// the prefix before proxying to this service on 127.0.0.1:8770.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// buildVersion is injected at build time via -ldflags "-X main.buildVersion=...".
|
|
// Mirrors the Python service's APP_VERSION / "/api-version" stamp.
|
|
var buildVersion = "dev"
|
|
|
|
// Server holds the shared dependencies for HTTP handlers.
|
|
type Server struct {
|
|
pool *pgxpool.Pool
|
|
cache *liveCache
|
|
log *slog.Logger
|
|
}
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
slog.SetDefault(logger)
|
|
|
|
cfg := loadConfig()
|
|
logger.Info("starting tracker-go", "version", buildVersion, "addr", cfg.Addr)
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
srv := &Server{cache: newLiveCache(), log: logger}
|
|
|
|
// Connect to the dereth DB (read-only). If DATABASE_URL is unset we still
|
|
// serve health/version (Phase-0 mode) so the container is observable.
|
|
if cfg.DatabaseURL == "" {
|
|
logger.Warn("DATABASE_URL unset — running without DB; /live and /trails will be empty")
|
|
} else {
|
|
connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
pool, err := newPool(connectCtx, cfg.DatabaseURL)
|
|
cancel()
|
|
if err != nil {
|
|
logger.Error("db pool init failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
defer pool.Close()
|
|
srv.pool = pool
|
|
go srv.runCacheLoop(ctx)
|
|
logger.Info("db connected; live cache loop started", "interval", cacheInterval.String())
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
srv.registerRoutes(mux)
|
|
|
|
httpSrv := &http.Server{
|
|
Addr: cfg.Addr,
|
|
Handler: withRequestLogging(mux),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
logger.Error("http server failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}()
|
|
logger.Info("listening", "addr", cfg.Addr)
|
|
|
|
<-ctx.Done()
|
|
logger.Info("shutdown signal received, draining")
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
|
|
logger.Error("graceful shutdown failed", "err", err)
|
|
}
|
|
logger.Info("stopped")
|
|
}
|
|
|
|
// config holds runtime configuration sourced from environment variables,
|
|
// 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)
|
|
}
|
|
|
|
func loadConfig() config {
|
|
return config{
|
|
Addr: ":" + envOr("PORT", "8770"),
|
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
|
}
|
|
}
|
|
|
|
func envOr(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /health", s.handleHealth)
|
|
// Mirrors Python's GET /api-version (hyphenated so nginx never strips it).
|
|
mux.HandleFunc("GET /api-version", s.handleVersion)
|
|
// Phase 1 read-side: the 5s caches.
|
|
mux.HandleFunc("GET /live", s.handleLive)
|
|
mux.HandleFunc("GET /live/", s.handleLive)
|
|
mux.HandleFunc("GET /trails", s.handleTrails)
|
|
mux.HandleFunc("GET /trails/", s.handleTrails)
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "ok",
|
|
"service": "tracker-go",
|
|
"version": buildVersion,
|
|
"db": s.pool != nil,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"version": buildVersion})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
slog.Error("json encode failed", "err", err)
|
|
}
|
|
}
|
|
|
|
// withRequestLogging is a thin access-log middleware.
|
|
func withRequestLogging(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
sr := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(sr, r)
|
|
slog.Info("http",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", sr.status,
|
|
"dur_ms", time.Since(start).Milliseconds(),
|
|
)
|
|
})
|
|
}
|
|
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (s *statusRecorder) WriteHeader(code int) {
|
|
s.status = code
|
|
s.ResponseWriter.WriteHeader(code)
|
|
}
|