MosswartOverlord/go-services/tracker-go/totals.go
Erik c4e8190656 feat(go-services): tracker-go — complete the Phase 1 read API
Adds the rest of the read-side endpoints to the Go tracker, all parity-checked
against the live Python service:

- DB reads: /stats/{c}, /portals, /spawns/heatmap, /server-health,
  /character-stats/{c} (stats_data JSONB merged to top level),
  /combat-stats[/{c}], /inventories, /inventory/{c}/search.
- 5-minute totals cache + /total-rares, /total-kills.
- Ingest-only state returned as Python's empty/default shapes (/quest-status,
  /vital-sharing/peers, /equipment-cantrip-state/{c}); /issues (flat file),
  /me (401 until cookie verification lands).
- Streaming reverse proxy to inventory-service (/inventory/{c},
  /inventory-characters, /search/*, /sets/list, /inv/{path...} incl. the SSE
  suitbuilder stream).
- compare/compare_endpoints.py: structural parity for all read endpoints +
  exact-match check for /character-stats and /combat-stats on OFFLINE chars
  (online chars legitimately differ — Python serves a richer live overlay that
  Phase-1 Go lacks until ingest).

Verified live: 14/14 endpoints structural-match, 8/8 rich offline chars
exact-match on /character-stats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:38:10 +02:00

80 lines
2.3 KiB
Go

package main
import (
"context"
"encoding/json"
"net/http"
"sync"
"time"
)
const totalsInterval = 300 * time.Second // _refresh_total_rares_cache cadence
// totalsCache holds the pre-marshaled bodies for /total-rares and /total-kills,
// refreshed every totalsInterval, mirroring main.py:924.
type totalsCache struct {
mu sync.RWMutex
raresJSON []byte
killsJSON []byte
}
func newTotalsCache() *totalsCache {
return &totalsCache{
raresJSON: []byte(`{"all_time":0,"today":0,"last_updated":null}`),
killsJSON: []byte(`{"total":0,"last_updated":null}`),
}
}
func (c *totalsCache) getRares() []byte { c.mu.RLock(); defer c.mu.RUnlock(); return c.raresJSON }
func (c *totalsCache) getKills() []byte { c.mu.RLock(); defer c.mu.RUnlock(); return c.killsJSON }
func (c *totalsCache) set(rares, kills []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.raresJSON = rares
c.killsJSON = kills
}
func (s *Server) refreshTotals(ctx context.Context) error {
qctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
var allTime, today, totalKills int64
// Each query degrades to 0 on error, mirroring the Python try/except blocks.
_ = s.pool.QueryRow(qctx, "SELECT COALESCE(SUM(total_rares), 0) FROM rare_stats").Scan(&allTime)
_ = s.pool.QueryRow(qctx, "SELECT COUNT(*) FROM rare_events WHERE timestamp >= CURRENT_DATE").Scan(&today)
_ = s.pool.QueryRow(qctx, "SELECT COALESCE(SUM(total_kills), 0) FROM char_stats").Scan(&totalKills)
lastUpdated := pyISO(time.Now().UTC())
raresJSON, err := json.Marshal(map[string]any{"all_time": allTime, "today": today, "last_updated": lastUpdated})
if err != nil {
return err
}
killsJSON, err := json.Marshal(map[string]any{"total": totalKills, "last_updated": lastUpdated})
if err != nil {
return err
}
s.totals.set(raresJSON, killsJSON)
return nil
}
func (s *Server) runTotalsLoop(ctx context.Context) {
for {
if err := s.refreshTotals(ctx); err != nil {
s.log.Error("totals cache refresh failed", "err", err)
}
select {
case <-ctx.Done():
return
case <-time.After(totalsInterval):
}
}
}
func (s *Server) handleTotalRares(w http.ResponseWriter, r *http.Request) {
writeRawJSON(w, s.totals.getRares())
}
func (s *Server) handleTotalKills(w http.ResponseWriter, r *http.Request) {
writeRawJSON(w, s.totals.getKills())
}