feat(go-services): tracker-go Phase 0/1 — /live + /trails read parity
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>
This commit is contained in:
parent
b8fd449d62
commit
1af47520c0
7 changed files with 691 additions and 0 deletions
80
go-services/tracker-go/store.go
Normal file
80
go-services/tracker-go/store.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// newPool creates a pgx pool against the dereth TimescaleDB.
|
||||
//
|
||||
// Phase 1 is strictly read-only. As defense-in-depth we force every pooled
|
||||
// connection into read-only transaction mode, so even a buggy or future write
|
||||
// statement cannot mutate the live production data the Python service owns.
|
||||
func newPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 10
|
||||
cfg.MaxConnIdleTime = 5 * time.Minute
|
||||
cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
|
||||
if _, err := conn.Exec(ctx, "SET default_transaction_read_only = on"); err != nil {
|
||||
return fmt.Errorf("set read-only: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pool: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// queryRowsAsMaps runs a query and returns each row as a column-name->value map,
|
||||
// mirroring how the Python service builds response dicts directly from rows.
|
||||
// A nil result is coerced to an empty (non-nil) slice so JSON encodes "[]".
|
||||
func queryRowsAsMaps(ctx context.Context, pool *pgxpool.Pool, sql string, args ...any) ([]map[string]any, error) {
|
||||
rows, err := pool.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := pgx.CollectRows(rows, pgx.RowToMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []map[string]any{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pyISO formats a timestamp the way Python's datetime.isoformat() does for a
|
||||
// UTC tz-aware value, so output matches FastAPI's jsonable_encoder:
|
||||
// - no fractional part when microseconds are zero
|
||||
// - otherwise exactly 6 fractional digits
|
||||
// - "+00:00" offset (not "Z")
|
||||
// Postgres timestamptz has microsecond resolution, so ns is always a multiple
|
||||
// of 1000.
|
||||
func pyISO(t time.Time) string {
|
||||
t = t.UTC()
|
||||
if t.Nanosecond() == 0 {
|
||||
return t.Format("2006-01-02T15:04:05+00:00")
|
||||
}
|
||||
return t.Format("2006-01-02T15:04:05") + fmt.Sprintf(".%06d+00:00", t.Nanosecond()/1000)
|
||||
}
|
||||
|
||||
// formatTimes rewrites the named time.Time columns in-place to pyISO strings.
|
||||
// Missing or NULL (nil) values are left untouched, so they encode as JSON null.
|
||||
func formatTimes(rows []map[string]any, keys ...string) {
|
||||
for _, m := range rows {
|
||||
for _, k := range keys {
|
||||
if t, ok := m[k].(time.Time); ok {
|
||||
m[k] = pyISO(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue