Bug
When the same game is indexed more than once, game_features is safely upserted (H2 uses MERGE KEY, PostgreSQL uses ON CONFLICT DO UPDATE), but motif_occurrences rows are inserted with a plain INSERT — no conflict clause and no pre-delete. Each re-index of the same game appends a duplicate set of occurrence rows.
When this happens
- Current-month requests:
IndexedPeriodStore.upsertPeriod marks a period as complete only if the current time is past the first day of the next month (isComplete = !fetchedAt.isBefore(firstDayNextMonth)). Any request for the current month will be marked incomplete and therefore will be re-fetched on the next index request, doubling the occurrence rows each time.
- Concurrent requests: Two simultaneous index requests for the same player+month both pass the period-cache check and both call
insertOccurrences.
Root cause
GameFeatureDao.insertOccurrences (called from IndexWorker.indexGame) at line 157 of IndexWorker.java:
gameFeatureStore.insert(row); // safe: upserts
gameFeatureStore.insertOccurrences(game.url(), features.occurrences()); // not safe: plain INSERT
deleteOccurrencesByGameUrl exists but is only called from the re-analysis path (AdminController), not from normal game ingestion.
Fix
Before calling insertOccurrences, call deleteOccurrencesByGameUrl(game.url()) in IndexWorker.indexGame, or add an ON CONFLICT DO NOTHING clause to the occurrence INSERT and add a unique constraint on (game_url, motif, ply).
Bug
When the same game is indexed more than once,
game_featuresis safely upserted (H2 usesMERGE KEY, PostgreSQL usesON CONFLICT DO UPDATE), butmotif_occurrencesrows are inserted with a plainINSERT— no conflict clause and no pre-delete. Each re-index of the same game appends a duplicate set of occurrence rows.When this happens
IndexedPeriodStore.upsertPeriodmarks a period as complete only if the current time is past the first day of the next month (isComplete = !fetchedAt.isBefore(firstDayNextMonth)). Any request for the current month will be marked incomplete and therefore will be re-fetched on the next index request, doubling the occurrence rows each time.insertOccurrences.Root cause
GameFeatureDao.insertOccurrences(called fromIndexWorker.indexGame) at line 157 ofIndexWorker.java:deleteOccurrencesByGameUrlexists but is only called from the re-analysis path (AdminController), not from normal game ingestion.Fix
Before calling
insertOccurrences, calldeleteOccurrencesByGameUrl(game.url())inIndexWorker.indexGame, or add anON CONFLICT DO NOTHINGclause to the occurrence INSERT and add a unique constraint on(game_url, motif, ply).