fix(db): eliminate O(n²) iteration in generateBotProfiles

The generateBotProfiles function had two nested loops that caused O(n²) memory usage:
- Iterating through all rating history entries (10,000) for each bot (10,000) = 100M iterations
- Iterating through all matches (1,000) for each bot (10,000) = 10M iterations

This caused acb-index-builder to run out of memory and get OOMKilled during the build cycle.

Fixed by pre-building lookup maps (O(n) build + O(1) lookup):
- historyMap[botID] -> []RatingHistoryEntry
- matchMap[botID] -> []MatchSummary

Reduces complexity from O(bots × matches) to O(matches + bots) for lookups.

Resolves acb-index-builder CrashLoopBackOff after 45 days of failure.
This commit is contained in:
jedarden 2026-06-25 01:28:29 -04:00
parent be7588434d
commit 7befe516bf

View file

@ -272,37 +272,44 @@ func generateBotDirectory(data *IndexData, outputDir string) error {
func generateBotProfiles(data *IndexData, outputDir string, cfg *Config) error {
botsDir := filepath.Join(outputDir, "data", "bots")
// Pre-build lookup maps to avoid O(n²) iteration
// botID -> []RatingHistoryEntry (O(n) build + O(1) lookup vs O(n²) linear scan)
historyMap := make(map[string][]RatingHistoryEntry, len(data.Bots))
for _, h := range data.RatingHistory {
historyMap[h.BotID] = append(historyMap[h.BotID], h)
}
// 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))
for _, m := range data.Matches {
// Track which bots participated in this match
for _, p := range m.Participants {
// Skip if this bot already has 20 recent matches
if len(matchMap[p.BotID]) >= 20 {
continue
}
summary := matchToSummary(m, data, cfg)
matchMap[p.BotID] = append(matchMap[p.BotID], summary)
}
}
for _, bot := range data.Bots {
winRate := 0.0
if bot.MatchesPlayed > 0 {
winRate = float64(bot.MatchesWon) / float64(bot.MatchesPlayed) * 100
}
// Get rating history for this bot
history := make([]RatingHistoryEntry, 0)
for _, h := range data.RatingHistory {
if h.BotID == bot.ID {
history = append(history, h)
}
// O(1) map lookup instead of O(n) linear scan
history := historyMap[bot.ID]
if len(history) == 0 {
history = []RatingHistoryEntry{} // Ensure non-nil slice for JSON
}
// Get recent matches for this bot (last 20)
recentMatches := make([]MatchSummary, 0)
for _, m := range data.Matches {
participated := false
for _, p := range m.Participants {
if p.BotID == bot.ID {
participated = true
break
}
}
if participated {
summary := matchToSummary(m, data, cfg)
recentMatches = append(recentMatches, summary)
if len(recentMatches) >= 20 {
break
}
}
// O(1) map lookup instead of O(n) linear scan through all matches
recentMatches := matchMap[bot.ID]
if len(recentMatches) == 0 {
recentMatches = []MatchSummary{} // Ensure non-nil slice for JSON
}
profile := BotProfile{