feat(go-services): inventory-go Phase A — read-side scaffold + simple endpoints

First slice of the inventory-service port, running in parallel READ-ONLY against
the production inventory_db (never written):
- main.go/store.go: pgx pool (forced read-only), enum-DB loader extracting
  AttributeSetInfo for set-name resolution, /health, /sets/list, /characters/list.
- Dockerfile + compose service inventory-go (127.0.0.1:8772, enum JSON mounted).

Validated vs the Python service on the same DB: /characters/list 167 chars exact
counts; /sets/list 76 sets EXACT match (ids, names, counts).

Remaining (large): /search/items (40+ filters + enrich_db_item), inventory
fetch, item-processing ingestion (extract_item_properties), and the suitbuilder
solver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 11:33:55 +02:00
parent 5b2db439a3
commit 253250a01d
5 changed files with 330 additions and 0 deletions

View file

@ -124,5 +124,33 @@ services:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
# Go port of inventory-service. Phase A: read side, READ-ONLY against the
# production inventory_db, validated vs the Python service. Loopback :8772.
inventory-go:
build:
context: ./go-services/inventory-go
args:
BUILD_VERSION: ${BUILD_VERSION:-dev}
image: inventory-go:local
container_name: inventory-go
ports:
- "127.0.0.1:8772:8772"
environment:
PORT: "8772"
DATABASE_URL: "postgresql://inventory_user:${INVENTORY_DB_PASSWORD}@inventory-db:5432/inventory_db"
READ_ONLY: "true"
ENUM_DB_PATH: "/enums/comprehensive_enum_database_v2.json"
LOG_LEVEL: "INFO"
volumes:
- ./inventory-service/comprehensive_enum_database_v2.json:/enums/comprehensive_enum_database_v2.json:ro
depends_on:
- inventory-db
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes: volumes:
dereth-go-data: dereth-go-data:

View file

@ -0,0 +1,12 @@
FROM golang:1.25-bookworm AS build
WORKDIR /src
COPY . .
RUN go mod tidy
ARG BUILD_VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath -ldflags "-s -w -X main.buildVersion=${BUILD_VERSION}" -o /out/inventory-go .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/inventory-go /inventory-go
EXPOSE 8772
ENTRYPOINT ["/inventory-go"]

View file

@ -0,0 +1,5 @@
module git.snakedesert.se/SawatoMosswartsEnjoyersClub/MosswartOverlord/go-services/inventory-go
go 1.25
require github.com/jackc/pgx/v5 v5.10.0

View file

@ -0,0 +1,202 @@
// Command inventory-go is a Go reimplementation of the MosswartOverlord
// inventory-service (FastAPI), deployed in parallel for comparison.
//
// Phase A: read side. Connects READ-ONLY to the existing inventory_db and
// reimplements the read endpoints, validated against the Python service on the
// same data. The heavy item-processing ingestion and the suitbuilder solver
// follow in later phases.
package main
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
var buildVersion = "dev"
type Server struct {
pool *pgxpool.Pool
attributeSets map[string]string // AttributeSetInfo: set-id -> set name
log *slog.Logger
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
addr := ":" + envOr("PORT", "8772")
dsn := os.Getenv("DATABASE_URL")
enumPath := envOr("ENUM_DB_PATH", "comprehensive_enum_database_v2.json")
readOnly := envOr("READ_ONLY", "true") != "false"
logger.Info("starting inventory-go", "version", buildVersion, "addr", addr, "read_only", readOnly)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &Server{log: logger, attributeSets: map[string]string{}}
if sets, err := loadAttributeSets(enumPath); err != nil {
logger.Warn("could not load enum AttributeSetInfo (set names will be unknown)", "err", err, "path", enumPath)
} else {
srv.attributeSets = sets
logger.Info("loaded enum AttributeSetInfo", "sets", len(sets))
}
if dsn == "" {
logger.Error("DATABASE_URL is required")
os.Exit(1)
}
connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
pool, err := newPool(connectCtx, dsn, readOnly)
cancel()
if err != nil {
logger.Error("db pool init failed", "err", err)
os.Exit(1)
}
defer pool.Close()
srv.pool = pool
mux := http.NewServeMux()
mux.HandleFunc("GET /health", srv.handleHealth)
mux.HandleFunc("GET /sets/list", srv.handleSetsList)
mux.HandleFunc("GET /characters/list", srv.handleCharactersList)
httpSrv := &http.Server{Addr: addr, Handler: withLogging(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", addr)
<-ctx.Done()
shutdownCtx, c := context.WithTimeout(context.Background(), 10*time.Second)
defer c()
_ = httpSrv.Shutdown(shutdownCtx)
logger.Info("stopped")
}
// GET /health (main.py:2674)
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dbOK := s.pool.Ping(ctx) == nil
status := "healthy"
if !dbOK {
status = "degraded"
}
writeJSON(w, http.StatusOK, map[string]any{
"status": status,
"timestamp": pyISO(time.Now()),
"database_connected": dbOK,
"version": "1.0.0",
})
}
// GET /sets/list (main.py:2712)
func (s *Server) handleSetsList(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
rows, err := queryRowsAsMaps(ctx, s.pool, `
SELECT enh.item_set, COUNT(*) AS item_count
FROM item_enhancements enh
WHERE enh.item_set IS NOT NULL AND enh.item_set != ''
GROUP BY enh.item_set
ORDER BY item_count DESC, enh.item_set`)
if err != nil {
s.dbErr(w, "sets/list", err)
return
}
sets := make([]map[string]any, 0, len(rows))
for _, row := range rows {
setID := toStr(row["item_set"])
name, ok := s.attributeSets[setID]
if !ok {
name = "Unknown Set " + setID
}
sets = append(sets, map[string]any{"id": setID, "name": name, "item_count": row["item_count"]})
}
writeJSON(w, http.StatusOK, map[string]any{"sets": sets})
}
// GET /characters/list (main.py:4291)
func (s *Server) handleCharactersList(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
rows, err := queryRowsAsMaps(ctx, s.pool, `
SELECT character_name, COUNT(*) AS item_count, MAX(timestamp) AS last_updated
FROM items GROUP BY character_name ORDER BY character_name`)
if err != nil {
s.dbErr(w, "characters/list", err)
return
}
formatTimes(rows, "last_updated")
chars := make([]map[string]any, 0, len(rows))
for _, row := range rows {
chars = append(chars, map[string]any{
"character_name": row["character_name"],
"item_count": row["item_count"],
"last_updated": row["last_updated"],
})
}
writeJSON(w, http.StatusOK, map[string]any{"characters": chars, "total_characters": len(chars)})
}
func (s *Server) dbErr(w http.ResponseWriter, where string, err error) {
s.log.Error("db query failed", "where", where, "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Internal server error"})
}
// loadAttributeSets reads the comprehensive enum DB and extracts AttributeSetInfo
// (set-id -> name), mirroring load_comprehensive_enums (dictionaries first, then
// enums). Only the slice needed for /sets/list is decoded.
func loadAttributeSets(path string) (map[string]string, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var db struct {
Dictionaries map[string]struct {
Values map[string]string `json:"values"`
} `json:"dictionaries"`
Enums map[string]struct {
Values map[string]string `json:"values"`
} `json:"enums"`
}
if err := json.Unmarshal(b, &db); err != nil {
return nil, err
}
if d, ok := db.Dictionaries["AttributeSetInfo"]; ok && len(d.Values) > 0 {
return d.Values, nil
}
if e, ok := db.Enums["AttributeSetInfo"]; ok {
return e.Values, nil
}
return map[string]string{}, nil
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("http", "method", r.Method, "path", r.URL.Path, "dur_ms", time.Since(start).Milliseconds())
})
}

View file

@ -0,0 +1,83 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// newPool creates a pgx pool. When readOnly (the default in parallel mode), every
// connection is forced into read-only transactions so the Go service can never
// mutate the production inventory_db it shares with the Python service.
func newPool(ctx context.Context, dsn string, readOnly bool) (*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
if readOnly {
cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
_, err := conn.Exec(ctx, "SET default_transaction_read_only = on")
return err
}
}
return pgxpool.NewWithConfig(ctx, cfg)
}
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 mirrors Python datetime.isoformat() for a UTC value (matches FastAPI's
// jsonable_encoder). Note the inventory-service stores naive datetimes (no tz),
// so isoformat has no offset — we format without one.
func pyISO(t time.Time) string {
t = t.UTC()
if t.Nanosecond() == 0 {
return t.Format("2006-01-02T15:04:05")
}
return t.Format("2006-01-02T15:04:05") + fmt.Sprintf(".%06d", t.Nanosecond()/1000)
}
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)
}
}
}
}
func toStr(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
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)
}
}