feat: Go backend production cutover — website layer, ingest forwarding, alerts, live fixes
Completes the Go backend so it can fully replace Python in production: tracker-go website layer (serves the unchanged frontend): - static file serving + SPA fallback + /icons (website.go) - login/logout with itsdangerous cookie ISSUING (bcrypt, Python-interop) and the /me handler (auth.go issueSessionCookie + website.go) - admin user CRUD (website_admin.go) and the issue-board write side (website_issues.go) - request-scoped user context + requireAdmin (auth.go) cutover ingest (gated off during the parallel run, required for a clean cutover): - inventory forwarding: full_inventory -> /process-inventory, inventory_delta -> item POST/DELETE, per-character serialized, fire-and-forget (inventory_forward.go) - death/idle Discord alerts via DISCORD_ACLOG_WEBHOOK (aclog.go) - SKIP_SCHEMA_INIT so write mode against the prod DBs runs no DDL (tracker-go + inventory-go) two bugs found live and fixed: - coerceNum: the plugin sends kills_per_hour/deaths/total_deaths/prismatic_taper_count as STRINGS; pydantic coerced them, Go's number helpers wrote null/0 (reads.go/ingest.go) - telemetry is broadcast TYPELESS so the browser ignores it and uses the /live poll; broadcasting it typed flapped the per-player counters 0<->value (ingest.go stripType) docker-compose.cutover.yml: reversible override flipping the Go services to write mode against the production DBs and repointing the Discord bot at the Go /ws/live. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
776076b981
commit
5ade47dc64
13 changed files with 1074 additions and 66 deletions
144
go-services/tracker-go/inventory_forward.go
Normal file
144
go-services/tracker-go/inventory_forward.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// invForwarder forwards plugin inventory events to the inventory service,
|
||||
// porting main.py's _forward_to_inventory_service / _handle_inventory_delta.
|
||||
// Only active in cutover (write) mode; nil in shadow/read-only mode, where the
|
||||
// plugin firehose never carries inventory anyway.
|
||||
//
|
||||
// full_inventory -> POST {url}/process-inventory (full replace)
|
||||
// inventory_delta add/update -> POST {url}/inventory/{char}/item
|
||||
// inventory_delta remove -> DELETE {url}/inventory/{char}/item/{item_id}
|
||||
//
|
||||
// Deltas are fire-and-forget (never block the /ws/position read loop), serialized
|
||||
// per-character (so a char's rapid deltas don't race the inventory DELETE+INSERT),
|
||||
// and globally capped at 8 concurrent forwards.
|
||||
type invForwarder struct {
|
||||
url string
|
||||
client *http.Client
|
||||
sem chan struct{}
|
||||
mu sync.Mutex
|
||||
locks map[string]*sync.Mutex
|
||||
log *slog.Logger
|
||||
broadcast func(map[string]any)
|
||||
}
|
||||
|
||||
func newInvForwarder(rawURL string, log *slog.Logger, broadcast func(map[string]any)) *invForwarder {
|
||||
return &invForwarder{
|
||||
url: strings.TrimRight(rawURL, "/"),
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
sem: make(chan struct{}, 8),
|
||||
locks: map[string]*sync.Mutex{},
|
||||
log: log,
|
||||
broadcast: broadcast,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *invForwarder) charLock(name string) *sync.Mutex {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
l := f.locks[name]
|
||||
if l == nil {
|
||||
l = &sync.Mutex{}
|
||||
f.locks[name] = l
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// forwardFullInventory POSTs a full inventory snapshot (full replace). Runs
|
||||
// inline on the /ws/position handler — main.py awaits _store_inventory too.
|
||||
func (f *invForwarder) forwardFullInventory(data map[string]any) {
|
||||
char := toStr(data["character_name"])
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"character_name": char,
|
||||
"timestamp": data["timestamp"],
|
||||
"items": data["items"],
|
||||
})
|
||||
resp, err := f.client.Post(f.url+"/process-inventory", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
f.log.Error("full_inventory forward failed", "err", err, "char", char)
|
||||
return
|
||||
}
|
||||
defer drain(resp)
|
||||
if resp.StatusCode >= 400 {
|
||||
f.log.Warn("inventory service error (full_inventory)", "status", resp.StatusCode, "char", char)
|
||||
}
|
||||
}
|
||||
|
||||
// handleInventoryDelta forwards a single add/update/remove. Fire-and-forget.
|
||||
func (f *invForwarder) handleInventoryDelta(data map[string]any) {
|
||||
go func() {
|
||||
char := toStr(data["character_name"])
|
||||
lock := f.charLock(char)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
f.sem <- struct{}{}
|
||||
defer func() { <-f.sem }()
|
||||
|
||||
out := data
|
||||
switch toStr(data["action"]) {
|
||||
case "remove":
|
||||
if itemID := data["item_id"]; itemID != nil {
|
||||
req, _ := http.NewRequest(http.MethodDelete,
|
||||
fmt.Sprintf("%s/inventory/%s/item/%v", f.url, url.PathEscape(char), itemID), nil)
|
||||
if resp, err := f.client.Do(req); err != nil {
|
||||
f.log.Warn("inventory delta remove failed", "err", err, "char", char)
|
||||
} else {
|
||||
if resp.StatusCode >= 400 {
|
||||
f.log.Warn("inventory service error (delta remove)", "status", resp.StatusCode, "char", char)
|
||||
}
|
||||
drain(resp)
|
||||
}
|
||||
}
|
||||
case "add", "update":
|
||||
if item := data["item"]; item != nil {
|
||||
b, _ := json.Marshal(item)
|
||||
resp, err := f.client.Post(fmt.Sprintf("%s/inventory/%s/item", f.url, url.PathEscape(char)),
|
||||
"application/json", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
f.log.Warn("inventory delta add/update failed", "err", err, "char", char)
|
||||
} else {
|
||||
if resp.StatusCode < 400 {
|
||||
// Re-broadcast the enriched item the service returns.
|
||||
var r map[string]any
|
||||
if json.NewDecoder(resp.Body).Decode(&r) == nil {
|
||||
if enriched, ok := r["item"]; ok && enriched != nil {
|
||||
out = map[string]any{
|
||||
"type": "inventory_delta",
|
||||
"action": toStr(data["action"]),
|
||||
"character_name": char,
|
||||
"item": enriched,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
f.log.Warn("inventory service error (delta add/update)", "status", resp.StatusCode, "char", char)
|
||||
}
|
||||
drain(resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.broadcast != nil {
|
||||
f.broadcast(out)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func drain(resp *http.Response) {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue