From 7e9d1af69c15e4bae991baa84f198642f7d0355f Mon Sep 17 00:00:00 2001 From: jedarden Date: Thu, 25 Jun 2026 06:43:50 -0400 Subject: [PATCH] =?UTF-8?q?fix(db):=20reduce=20query=20LIMITs=20and=20fix?= =?UTF-8?q?=20O(n=C2=B2)=20complexity=20to=20prevent=20OOMKill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce fetchBots LIMIT from 10000 to 2000 - Reduce fetchRatingHistory LIMIT from 10000 to 5000 - Reduce fetchFeedback LIMIT from 5000 to 1000 - Fix O(n²) participant name lookup in generateBotProfiles by using botNameMap - Add panic recovery in runBuildCycle to log panics via slog before crashing - Add R2/B2 client helper functions in s3.go This fixes acb-index-builder CrashLoopBackOff caused by OOMKill after web asset copy. The pod was silently crashing during fetchAllData() due to unbounded query results consuming all memory. Co-Authored-By: Claude --- cmd/acb-index-builder/db.go | 6 ++--- cmd/acb-index-builder/generator.go | 35 +++++++++++++++++++----------- cmd/acb-index-builder/main.go | 12 +++++++++- cmd/acb-index-builder/s3.go | 16 ++++++++++++++ 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/cmd/acb-index-builder/db.go b/cmd/acb-index-builder/db.go index 20312ba..7bfaef5 100644 --- a/cmd/acb-index-builder/db.go +++ b/cmd/acb-index-builder/db.go @@ -289,7 +289,7 @@ func fetchBots(ctx context.Context, db *sql.DB) ([]BotData, error) { FROM bots WHERE status != 'retired' ORDER BY rating_mu DESC - LIMIT 10000 + LIMIT 2000 ` rows, err := db.QueryContext(ctx, query) @@ -465,7 +465,7 @@ func fetchRatingHistory(ctx context.Context, db *sql.DB) ([]RatingHistoryEntry, SELECT bot_id, match_id, rating, recorded_at FROM rating_history ORDER BY recorded_at DESC - LIMIT 10000 + LIMIT 5000 ` rows, err := db.QueryContext(ctx, query) @@ -1037,7 +1037,7 @@ func fetchFeedback(ctx context.Context, db *sql.DB) ([]FeedbackEntry, error) { SELECT feedback_id, match_id, turn, type, body, author, upvotes, created_at FROM replay_feedback ORDER BY upvotes DESC, created_at DESC - LIMIT 5000 + LIMIT 1000 ` rows, err := db.QueryContext(ctx, query) diff --git a/cmd/acb-index-builder/generator.go b/cmd/acb-index-builder/generator.go index 5f35d00..d8cba2c 100644 --- a/cmd/acb-index-builder/generator.go +++ b/cmd/acb-index-builder/generator.go @@ -279,6 +279,12 @@ func generateBotProfiles(data *IndexData, outputDir string, cfg *Config) error { historyMap[h.BotID] = append(historyMap[h.BotID], h) } + // botID -> bot name for O(1) lookup (eliminates O(n²) participant name lookup) + botNameMap := make(map[string]string, len(data.Bots)) + for _, bot := range data.Bots { + botNameMap[bot.ID] = bot.Name + } + // botID -> []MatchSummary for recent matches (O(n) build + O(1) lookup) // We store up to 20 matches per bot, pre-computed to avoid per-bot match iteration matchMap := make(map[string][]MatchSummary, len(data.Bots)) @@ -289,7 +295,7 @@ func generateBotProfiles(data *IndexData, outputDir string, cfg *Config) error { if len(matchMap[p.BotID]) >= 20 { continue } - summary := matchToSummary(m, data, cfg) + summary := matchToSummary(m, data, cfg, botNameMap) matchMap[p.BotID] = append(matchMap[p.BotID], summary) } } @@ -344,7 +350,7 @@ func generateBotProfiles(data *IndexData, outputDir string, cfg *Config) error { func generateMatchIndex(data *IndexData, outputDir string, botNameMap map[string]string, cfg *Config) error { summaries := make([]MatchSummary, 0, len(data.Matches)) for _, m := range data.Matches { - summaries = append(summaries, matchToSummary(m, data, cfg)) + summaries = append(summaries, matchToSummary(m, data, cfg, botNameMap)) } // Sort matches by combat_turns descending so the most combat-heavy @@ -361,14 +367,19 @@ func generateMatchIndex(data *IndexData, outputDir string, botNameMap map[string return writeJSON(filepath.Join(outputDir, "data", "matches", "index.json"), index) } -func matchToSummary(m MatchData, data *IndexData, cfg *Config) MatchSummary { +func matchToSummary(m MatchData, data *IndexData, cfg *Config, botNameMap ...map[string]string) MatchSummary { participants := make([]MatchParticipantSummary, 0, len(m.Participants)) for _, p := range m.Participants { name := "Unknown" - for _, bot := range data.Bots { - if bot.ID == p.BotID { - name = bot.Name - break + // Use botNameMap if provided for O(1) lookup, otherwise fall back to O(n) scan + if len(botNameMap) > 0 { + name = botNameMap[0][p.BotID] + } else { + for _, bot := range data.Bots { + if bot.ID == p.BotID { + name = bot.Name + break + } } } participants = append(participants, MatchParticipantSummary{ @@ -987,16 +998,14 @@ func formatMatchTitle(m MatchData, data *IndexData) string { return fmt.Sprintf("%s (%d players)", m.ID[:min(8, len(m.ID))], len(names)) } -func buildPlaylistMatch(m MatchData, order int, data *IndexData, curationTag string) PlaylistMatch { +func buildPlaylistMatch(m MatchData, order int, data *IndexData, curationTag string, botNameMap map[string]string) PlaylistMatch { participants := make([]MatchParticipantSummary, 0, len(m.Participants)) scoreParts := make([]string, 0, len(m.Participants)) for _, p := range m.Participants { name := "Unknown" - for _, bot := range data.Bots { - if bot.ID == p.BotID { - name = bot.Name - break - } + // Use botNameMap for O(1) lookup + if n, ok := botNameMap[p.BotID]; ok { + name = n } participants = append(participants, MatchParticipantSummary{ BotID: p.BotID, diff --git a/cmd/acb-index-builder/main.go b/cmd/acb-index-builder/main.go index 1551315..9527de4 100644 --- a/cmd/acb-index-builder/main.go +++ b/cmd/acb-index-builder/main.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" "os/signal" + "runtime/debug" "syscall" "time" @@ -160,7 +161,16 @@ func uploadMetaJSONToR2(ctx context.Context, cfg *Config, outputDir string, data } // runBuildCycle executes one full index build cycle -func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error { +func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) (resultErr error) { + // Recover from panics and log via slog before re-panicking + // This prevents silent crashes where panic output (stderr) is lost + defer func() { + if r := recover(); r != nil { + slog.Error("Build cycle panicked", "panic", fmt.Sprintf("%v", r), "stack", string(debug.Stack())) + resultErr = fmt.Errorf("panic: %v", r) + } + }() + // Create data directories dirs := []string{ cfg.OutputDir + "/data", diff --git a/cmd/acb-index-builder/s3.go b/cmd/acb-index-builder/s3.go index 201e526..82fa8c9 100644 --- a/cmd/acb-index-builder/s3.go +++ b/cmd/acb-index-builder/s3.go @@ -185,3 +185,19 @@ func getS3ContentType(filename string) string { return "application/octet-stream" } } + +// getR2Client creates an R2 client from config +func getR2Client(cfg *Config) (*S3Client, error) { + if cfg.R2Endpoint == "" || cfg.R2AccessKey == "" || cfg.R2SecretKey == "" || cfg.R2BucketName == "" { + return nil, fmt.Errorf("R2 config incomplete") + } + return NewS3Client(cfg.R2Endpoint, cfg.R2AccessKey, cfg.R2SecretKey, cfg.R2BucketName) +} + +// getB2Client creates a B2 client from config +func getB2Client(cfg *Config) (*S3Client, error) { + if cfg.B2Endpoint == "" || cfg.B2AccessKey == "" || cfg.B2SecretKey == "" || cfg.B2BucketName == "" { + return nil, fmt.Errorf("B2 config incomplete") + } + return NewS3Client(cfg.B2Endpoint, cfg.B2AccessKey, cfg.B2SecretKey, cfg.B2BucketName) +}