fix(db): reduce query LIMITs and fix O(n²) complexity to prevent OOMKill
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
be9a070fbb
commit
7e9d1af69c
4 changed files with 52 additions and 17 deletions
|
|
@ -289,7 +289,7 @@ func fetchBots(ctx context.Context, db *sql.DB) ([]BotData, error) {
|
||||||
FROM bots
|
FROM bots
|
||||||
WHERE status != 'retired'
|
WHERE status != 'retired'
|
||||||
ORDER BY rating_mu DESC
|
ORDER BY rating_mu DESC
|
||||||
LIMIT 10000
|
LIMIT 2000
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, query)
|
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
|
SELECT bot_id, match_id, rating, recorded_at
|
||||||
FROM rating_history
|
FROM rating_history
|
||||||
ORDER BY recorded_at DESC
|
ORDER BY recorded_at DESC
|
||||||
LIMIT 10000
|
LIMIT 5000
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, query)
|
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
|
SELECT feedback_id, match_id, turn, type, body, author, upvotes, created_at
|
||||||
FROM replay_feedback
|
FROM replay_feedback
|
||||||
ORDER BY upvotes DESC, created_at DESC
|
ORDER BY upvotes DESC, created_at DESC
|
||||||
LIMIT 5000
|
LIMIT 1000
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, query)
|
rows, err := db.QueryContext(ctx, query)
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,12 @@ func generateBotProfiles(data *IndexData, outputDir string, cfg *Config) error {
|
||||||
historyMap[h.BotID] = append(historyMap[h.BotID], h)
|
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)
|
// 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
|
// We store up to 20 matches per bot, pre-computed to avoid per-bot match iteration
|
||||||
matchMap := make(map[string][]MatchSummary, len(data.Bots))
|
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 {
|
if len(matchMap[p.BotID]) >= 20 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
summary := matchToSummary(m, data, cfg)
|
summary := matchToSummary(m, data, cfg, botNameMap)
|
||||||
matchMap[p.BotID] = append(matchMap[p.BotID], summary)
|
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 {
|
func generateMatchIndex(data *IndexData, outputDir string, botNameMap map[string]string, cfg *Config) error {
|
||||||
summaries := make([]MatchSummary, 0, len(data.Matches))
|
summaries := make([]MatchSummary, 0, len(data.Matches))
|
||||||
for _, m := range 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
|
// 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)
|
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))
|
participants := make([]MatchParticipantSummary, 0, len(m.Participants))
|
||||||
for _, p := range m.Participants {
|
for _, p := range m.Participants {
|
||||||
name := "Unknown"
|
name := "Unknown"
|
||||||
for _, bot := range data.Bots {
|
// Use botNameMap if provided for O(1) lookup, otherwise fall back to O(n) scan
|
||||||
if bot.ID == p.BotID {
|
if len(botNameMap) > 0 {
|
||||||
name = bot.Name
|
name = botNameMap[0][p.BotID]
|
||||||
break
|
} else {
|
||||||
|
for _, bot := range data.Bots {
|
||||||
|
if bot.ID == p.BotID {
|
||||||
|
name = bot.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
participants = append(participants, MatchParticipantSummary{
|
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))
|
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))
|
participants := make([]MatchParticipantSummary, 0, len(m.Participants))
|
||||||
scoreParts := make([]string, 0, len(m.Participants))
|
scoreParts := make([]string, 0, len(m.Participants))
|
||||||
for _, p := range m.Participants {
|
for _, p := range m.Participants {
|
||||||
name := "Unknown"
|
name := "Unknown"
|
||||||
for _, bot := range data.Bots {
|
// Use botNameMap for O(1) lookup
|
||||||
if bot.ID == p.BotID {
|
if n, ok := botNameMap[p.BotID]; ok {
|
||||||
name = bot.Name
|
name = n
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
participants = append(participants, MatchParticipantSummary{
|
participants = append(participants, MatchParticipantSummary{
|
||||||
BotID: p.BotID,
|
BotID: p.BotID,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"runtime/debug"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -160,7 +161,16 @@ func uploadMetaJSONToR2(ctx context.Context, cfg *Config, outputDir string, data
|
||||||
}
|
}
|
||||||
|
|
||||||
// runBuildCycle executes one full index build cycle
|
// 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
|
// Create data directories
|
||||||
dirs := []string{
|
dirs := []string{
|
||||||
cfg.OutputDir + "/data",
|
cfg.OutputDir + "/data",
|
||||||
|
|
|
||||||
|
|
@ -185,3 +185,19 @@ func getS3ContentType(filename string) string {
|
||||||
return "application/octet-stream"
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue