From 34aae9d020f81889b2e4dc22dc88de0a39e4f0c1 Mon Sep 17 00:00:00 2001 From: Pavlov Alexandr Date: Sun, 14 Jun 2026 18:39:21 +0700 Subject: [PATCH 1/2] perf(statistics): scale large-bucket recalc (identity O(1), set-based resolve, batched inserts) Behavior-preserving perf fixes for the F8 large all-time buckets (gates the ~20735-replay mace all-time recalc). 01-REVIEW findings 3/4/5. - Finding 3: replace the per-player/per-replay O(|identities|) bestPlayerIdentity scan with a steam_id + lower(name) lookup index built once per bucket; resolves the SAME identity (same steam>nickname>display priority, same first-in-array tie-break). Applied to player and commander paths. - Finding 4: add migration 0010 functional index on lower(canonical_players. display_name) (mirrors idx_player_nicknames_nickname); restructure the name-fallback resolve from a per-occurrence correlated `where exists` (OR across cp x pn) into one set-based pass of two index-sargable EXISTS halves (identical predicate). loadPlayerIdentities fan-out left as-is (bounded; dedup would risk tie-break drift). - Finding 5: convert replaceAggregateRows/replaceCommanderRows/replaceBountyRows from per-row INSERT loops to a single multi-row `insert ... select from unnest` per table (byte-identical rows; jsonb columns JSON-serialized per element). Tests: identity-index cross-checks the index vs the prior per-player scan on an adversarial seeded set across a timestamp sweep; batched-write asserts one insert per table and a bounded (1) set-based fallback resolve. Existing real-pg parity harness (per-replay vs set-based, per-type, all-time, is_show) stays green, proving byte-identity. Full pnpm verify green: 100% coverage, OpenAPI diff empty. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...nical_players_display_name_lower_index.sql | 16 ++ .../statistics/repository/repository.ts | 247 ++++++++++++++---- .../repository/tests/batched-write.test.ts | 79 ++++++ .../repository/tests/bounty.test.ts | 19 +- .../repository/tests/commander.test.ts | 12 +- .../repository/tests/identity-index.test.ts | 158 +++++++++++ .../repository/tests/insert-assertions.ts | 35 +++ 7 files changed, 496 insertions(+), 70 deletions(-) create mode 100644 src/infra/db/migrations/0010_canonical_players_display_name_lower_index.sql create mode 100644 src/modules/statistics/repository/tests/batched-write.test.ts create mode 100644 src/modules/statistics/repository/tests/identity-index.test.ts create mode 100644 src/modules/statistics/repository/tests/insert-assertions.ts diff --git a/src/infra/db/migrations/0010_canonical_players_display_name_lower_index.sql b/src/infra/db/migrations/0010_canonical_players_display_name_lower_index.sql new file mode 100644 index 0000000..8659511 --- /dev/null +++ b/src/infra/db/migrations/0010_canonical_players_display_name_lower_index.sql @@ -0,0 +1,16 @@ +-- 0010_canonical_players_display_name_lower_index: index lower(canonical_players.display_name). +-- +-- The name-fallback identity resolve (statistics recalc, `ensureNameFallbackIdentities`) and the +-- `loadPlayerIdentities` join both probe `lower(canonical_players.display_name)` against the set of +-- observed parser names for a recalc bucket. A functional index on lower(nickname) already exists +-- (`idx_player_nicknames_nickname`, migration 0001), but the matching index on +-- lower(canonical_players.display_name) was never added — so the display_name half of every probe +-- is a sequential scan. On the F8 all-time `mace` bucket (~20735 replays in one recalc pass) that +-- is a seq scan per distinct observed name, the recalc hot spot the parity-driver flagged. +-- +-- This is a behavior-preserving performance index only: it changes query plans, never results. +-- Run inside the migrate.ts transaction wrapper, so a plain (non-CONCURRENTLY) build is used; the +-- table is small relative to the recalc scan it accelerates. Idempotent via `if not exists`. + +create index if not exists idx_canonical_players_display_name_lower + on canonical_players (lower(display_name)); diff --git a/src/modules/statistics/repository/repository.ts b/src/modules/statistics/repository/repository.ts index b34e380..f4b220c 100644 --- a/src/modules/statistics/repository/repository.ts +++ b/src/modules/statistics/repository/repository.ts @@ -474,7 +474,7 @@ interface ParserEventRow { source_ref: Record; } -interface PlayerIdentityRow { +export interface PlayerIdentityRow { display_name: string; nickname: string | null; nickname_observed_from: Date | null; @@ -537,13 +537,14 @@ async function aggregateInputsFromRows( rows.map((row) => row.id), ), identities = await loadPlayerIdentities(client, rows), + identityIndex = buildPlayerIdentityIndex(identities), memberships = await loadSquadMemberships(client, membershipRotation); return rows.map((row) => ({ events: events.get(row.id) ?? [], players: resolvedPlayers({ artifact: row.raw_snapshot, - identities, + identityIndex, memberships, replayTimestamp: row.replay_timestamp, }), @@ -715,23 +716,34 @@ async function ensureNameFallbackIdentities( return; } - // Step 1: one batch resolve against the PRE-INSERT snapshot. `with ordinality` - // is 1-based; map idx back to the 0-based occurrence array via `idx - 1`. + // Step 1: one batch resolve against the PRE-INSERT snapshot (FINDING 4). `with + // ordinality` is 1-based; map idx back to the 0-based occurrence array via + // `idx - 1`. The original form ran a single correlated `where exists` with an + // OR spanning canonical_players ⨯ player_nicknames per occurrence — the OR and + // the cp.id = pn.player_id join made it a sequential probe per occurrence. This + // is the SAME predicate (display_name OR active-nickname-window), but split into + // two index-sargable EXISTS halves so each side hits a functional index in ONE + // set-based pass: `idx_canonical_players_display_name_lower` (migration 0010) + // for the display_name half and `idx_player_nicknames_nickname` (migration 0001) + // for the nickname half. A row matches iff EITHER half matches — identical to + // the original OR. Output is still the matched ordinalities; statement count is + // unchanged (one query), and behavior is byte-identical. const matched = await client.query<{ idx: string }>( ` select occ.idx from unnest($1::text[], $2::timestamptz[]) with ordinality as occ(name, ts, idx) where exists ( - select 1 - from canonical_players cp - left join player_nicknames pn on pn.player_id = cp.id - where lower(cp.display_name) = lower(occ.name) - or ( - lower(pn.nickname) = lower(occ.name) - and (pn.observed_from is null or pn.observed_from <= occ.ts) - and (pn.observed_to is null or pn.observed_to >= occ.ts) - ) - ) + select 1 + from canonical_players cp + where lower(cp.display_name) = lower(occ.name) + ) + or exists ( + select 1 + from player_nicknames pn + where lower(pn.nickname) = lower(occ.name) + and (pn.observed_from is null or pn.observed_from <= occ.ts) + and (pn.observed_to is null or pn.observed_to >= occ.ts) + ) `, [ occurrences.map((occurrence) => occurrence.name), @@ -856,15 +868,117 @@ async function loadSquadMemberships( return memberships.rows; } +/** + * O(1)-per-player identity lookup built ONCE per bucket (FINDING 3). The prior + * `bestPlayerIdentity` re-scanned the ENTIRE identities array per player per + * replay (O(replays × players × |identities|)); on the F8 all-time `mace` bucket + * (~20735 replays, |identities| in the thousands) that is the recalc hot spot. + * + * The index buckets every identity row under its `steam_id` and under + * lower(display_name) / lower(nickname), preserving the ORIGINAL array order via + * a stored `order` so the tie-break is byte-identical to the prior stable + * `.toSorted`. Resolution gathers only the rows reachable from a player's steam + * id and lower(name) — a handful, not the whole array — then reuses the exact + * `playerIdentityMatchPriority` and the same "highest priority, first-in-array + * on ties" selection, so the chosen identity equals the prior per-player scan + * for every input. + */ +interface IndexedIdentity { + identity: PlayerIdentityRow; + order: number; +} + +export interface PlayerIdentityIndex { + byLowerName: Map; + bySteamId: Map; +} + +export function buildPlayerIdentityIndex( + identities: PlayerIdentityRow[], +): PlayerIdentityIndex { + const bySteamId = new Map(), + byLowerName = new Map(), + pushTo = ( + map: Map, + key: string, + entry: IndexedIdentity, + ): void => { + const existing = map.get(key); + if (existing === undefined) { + map.set(key, [entry]); + } else { + existing.push(entry); + } + }; + + for (const [order, identity] of identities.entries()) { + const entry: IndexedIdentity = { identity, order }; + if (identity.steam_id !== null) { + pushTo(bySteamId, identity.steam_id, entry); + } + pushTo(byLowerName, identity.display_name.toLowerCase(), entry); + if (identity.nickname !== null) { + pushTo(byLowerName, identity.nickname.toLowerCase(), entry); + } + } + + return { byLowerName, bySteamId }; +} + +/** + * Resolves the SAME identity the prior `bestPlayerIdentity` per-player scan + * picked, in O(candidates) instead of O(|identities|). Candidates are only the + * rows indexed under the player's steam id and lower(name); each is scored with + * the unchanged `playerIdentityMatchPriority`, and selection keeps the highest + * priority with first-original-array-order as the tie-break (replicating the + * stable `.toSorted((l, r) => r.priority - l.priority)[0]`). A candidate may be + * reachable from both maps, so it is de-duplicated by `order`. + */ +export function bestPlayerIdentityIndexed( + index: PlayerIdentityIndex, + player: NonNullable[number], + replayTimestamp: Date, +): PlayerIdentityRow | undefined { + const candidates = new Map(), + collect = (entries: IndexedIdentity[] | undefined): void => { + for (const entry of entries ?? []) { + candidates.set(entry.order, entry); + } + }; + if (player.sid !== undefined) { + collect(index.bySteamId.get(player.sid)); + } + collect(index.byLowerName.get(player.n.toLowerCase())); + + // Score the (few) candidates with the unchanged priority, then pick the highest + // priority — ties broken by lowest original array order. This reproduces the + // oracle's stable `.toSorted((l, r) => r.priority - l.priority)[0]` exactly: a + // stable sort keeps the earlier-array element on a priority tie, which equals + // sorting by (descending priority, ascending order). + const scored = [...candidates.values()].flatMap((candidate) => { + const priority = playerIdentityMatchPriority( + candidate.identity, + player, + replayTimestamp, + ); + return priority === undefined ? [] : [{ candidate, priority }]; + }); + return scored.toSorted( + (left, right) => + right.priority - left.priority || + left.candidate.order - right.candidate.order, + )[0]?.candidate.identity; +} + function resolvedPlayers(input: { artifact: ParserArtifact; - identities: PlayerIdentityRow[]; + identityIndex: PlayerIdentityIndex; memberships: SquadMembershipRow[]; replayTimestamp: Date; }): AggregatePlayerEvidence[] { return playersFromArtifact(input.artifact).flatMap((player) => { - const identity = bestPlayerIdentity( - input.identities, + const identity = bestPlayerIdentityIndexed( + input.identityIndex, player, input.replayTimestamp, ); @@ -889,24 +1003,7 @@ function resolvedPlayers(input: { }); } -function bestPlayerIdentity( - identities: PlayerIdentityRow[], - player: NonNullable[number], - replayTimestamp: Date, -): PlayerIdentityRow | undefined { - return identities - .flatMap((identity) => { - const priority = playerIdentityMatchPriority( - identity, - player, - replayTimestamp, - ); - return priority === undefined ? [] : [{ identity, priority }]; - }) - .toSorted((left, right) => right.priority - left.priority)[0]?.identity; -} - -function playerIdentityMatchPriority( +export function playerIdentityMatchPriority( identity: PlayerIdentityRow, player: NonNullable[number], replayTimestamp: Date, @@ -1099,11 +1196,12 @@ async function commanderInputsFromRows( return []; } - const identities = await loadPlayerIdentities(client, rows); + const identities = await loadPlayerIdentities(client, rows), + identityIndex = buildPlayerIdentityIndex(identities); return rows.map((row) => ({ commanders: commanderIdentities( row.raw_snapshot, - identities, + identityIndex, row.replay_timestamp, ), outcome: outcomeEvidence(row.raw_snapshot), @@ -1113,7 +1211,7 @@ async function commanderInputsFromRows( function commanderIdentities( artifact: ParserArtifact, - identities: PlayerIdentityRow[], + identityIndex: PlayerIdentityIndex, replayTimestamp: Date, ): CommanderIdentity[] { return (artifact.side_facts?.commanders ?? []).flatMap((commander) => { @@ -1137,7 +1235,7 @@ function commanderIdentities( identity = player === undefined ? undefined - : bestPlayerIdentity(identities, player, replayTimestamp); + : bestPlayerIdentityIndexed(identityIndex, player, replayTimestamp); return [ identity === undefined @@ -1250,28 +1348,44 @@ async function replaceAggregateRows( [rotationId, scope.gameType], ); - for (const row of aggregates.playerStats) { + // One multi-row insert per table (the F7/FW2 `unnest` pattern), not a per-row + // await loop. Byte-identical: each unnest column carries the SAME scalar that + // the per-row `values (...)` passed, in the SAME row order, so every persisted + // row is unchanged. `rotation_id`/`game_type` are constant per scope, so they + // are passed as scalar parameters reused by every row rather than unnested + // arrays. Empty input arrays produce zero rows, matching the empty loop. + if (aggregates.playerStats.length > 0) { await client.query( ` insert into player_stats (rotation_id, player_id, stats, game_type, is_show) - values ($1, $2, $3, $4, $5) + select $1, player_id, stats, $2, is_show + from unnest($3::uuid[], $4::jsonb[], $5::boolean[]) + as data(player_id, stats, is_show) `, [ rotationId, - row.playerId, - row.stats, scope.gameType, - computeIsShow(row.stats.replay_count, games), + aggregates.playerStats.map((row) => row.playerId), + aggregates.playerStats.map((row) => JSON.stringify(row.stats)), + aggregates.playerStats.map((row) => + computeIsShow(row.stats.replay_count, games), + ), ], ); } - for (const row of aggregates.squadStats) { + if (aggregates.squadStats.length > 0) { await client.query( ` insert into squad_stats (rotation_id, squad_id, stats, game_type) - values ($1, $2, $3, $4) + select $1, squad_id, stats, $2 + from unnest($3::uuid[], $4::jsonb[]) as data(squad_id, stats) `, - [rotationId, row.squadId, row.stats, scope.gameType], + [ + rotationId, + scope.gameType, + aggregates.squadStats.map((row) => row.squadId), + aggregates.squadStats.map((row) => JSON.stringify(row.stats)), + ], ); } } @@ -1289,23 +1403,30 @@ async function replaceCommanderRows( [rotationId, scope.gameType], ); - for (const row of aggregates) { + // Single multi-row insert (`unnest`), byte-identical to the prior per-row loop: + // same columns, same per-row scalars, same order; constant rotation_id/game_type + // passed once. `player_id` may be null (commander with no resolved identity), so + // the array is `uuid[]` carrying nulls — unnest preserves them positionally. + if (aggregates.length > 0) { await client.query( ` insert into commander_side_stats ( rotation_id, player_id, side, known_wins, known_losses, unknown_outcomes, game_type ) - values ($1, $2, $3, $4, $5, $6, $7) + select $1, player_id, side, known_wins, known_losses, unknown_outcomes, $2 + from unnest( + $3::uuid[], $4::text[], $5::int[], $6::int[], $7::int[] + ) as data(player_id, side, known_wins, known_losses, unknown_outcomes) `, [ rotationId, - row.playerId, - row.side, - row.knownWins, - row.knownLosses, - row.unknownOutcomes, scope.gameType, + aggregates.map((row) => row.playerId), + aggregates.map((row) => row.side), + aggregates.map((row) => row.knownWins), + aggregates.map((row) => row.knownLosses), + aggregates.map((row) => row.unknownOutcomes), ], ); } @@ -1324,13 +1445,25 @@ async function replaceBountyRows( [rotationId, scope.gameType], ); - for (const row of bountyRows) { + // Single multi-row insert (`unnest`), byte-identical to the prior per-row loop: + // same columns, same per-row scalars, same order. `inputs` is jsonb so each + // element is JSON-serialized for the `jsonb[]` array param (the same form the + // single-param insert produced); `rotation_id`/`game_type` are constant scalars. + if (bountyRows.length > 0) { await client.query( ` insert into bounty_points (rotation_id, player_id, points, inputs, game_type) - values ($1, $2, $3, $4, $5) + select $1, player_id, points, inputs, $2 + from unnest($3::uuid[], $4::numeric[], $5::jsonb[]) + as data(player_id, points, inputs) `, - [rotationId, row.playerId, row.points, row.inputs, scope.gameType], + [ + rotationId, + scope.gameType, + bountyRows.map((row) => row.playerId), + bountyRows.map((row) => row.points), + bountyRows.map((row) => JSON.stringify(row.inputs)), + ], ); } } diff --git a/src/modules/statistics/repository/tests/batched-write.test.ts b/src/modules/statistics/repository/tests/batched-write.test.ts new file mode 100644 index 0000000..6467288 --- /dev/null +++ b/src/modules/statistics/repository/tests/batched-write.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { PgStatisticsRepository } from "../repository.js"; + +import { bountyInsertRows, countQueries } from "./insert-assertions.js"; +import { + ScriptedClient, + bountyInsertParameters, + commanderInsertParameters, + poolFor, +} from "./utilities.js"; + +describe("PgStatisticsRepository batched write & bounded resolve (FINDING 4/5)", () => { + // mace → ONE all-time scope, so a clean per-table insert count is asserted + // without the sg two-scope doubling. + it("issues exactly ONE multi-row insert per aggregate table, not one per row", async () => { + const client = new ScriptedClient({ + auditGameType: "mace", + withMembership: true, + }), + repository = new PgStatisticsRepository(poolFor(client)); + + // Two player rows + one squad row are produced for this fixture, yet the + // replace must emit a single insert per table (the unnest batch), not N. + await expect( + repository.recalculatePlayerAndSquadStatsForParserResult("result-1"), + ).resolves.toMatchObject({ playerStats: 2, squadStats: 1 }); + + expect(countQueries(client, "insert into player_stats")).toBe(1); + expect(countQueries(client, "insert into squad_stats")).toBe(1); + }); + + it("issues exactly ONE commander insert for multiple commander rows", async () => { + const client = new ScriptedClient({ auditGameType: "mace" }), + repository = new PgStatisticsRepository(poolFor(client)); + + await repository.recalculateCommanderSideStatsForParserResult("result-1"); + + expect(countQueries(client, "insert into commander_side_stats")).toBe(1); + // The single insert carries all rows in array params (batched), not per-row. + expect(commanderInsertParameters(client)).toHaveLength(1); + }); + + it("issues exactly ONE bounty insert for the all-time scope", async () => { + const client = new ScriptedClient({ + auditGameType: "mace", + withBountyMemberships: true, + withVictimIdentity: true, + }), + repository = new PgStatisticsRepository(poolFor(client)); + + await repository.recalculateBountyPointsForParserResult("result-1"); + + expect(countQueries(client, "insert into bounty_points")).toBe(1); + expect(bountyInsertParameters(client)).toHaveLength(1); + // The single insert still carries byte-identical per-row data (re-zipped). + expect(bountyInsertRows(client)).toHaveLength(1); + }); + + it("resolves name fallbacks in a single set-based statement regardless of occurrence count", async () => { + const client = new ScriptedClient({ auditGameType: "mace" }), + repository = new PgStatisticsRepository(poolFor(client)); + + await repository.recalculatePlayerAndSquadStatsForParserResult("result-1"); + + // FINDING 4: the fallback resolve is ONE `select occ.idx` set-based pass over + // the unnested occurrences (two index-sargable EXISTS halves), never a + // per-occurrence correlated probe loop. Bounded at 1 statement no matter how + // many distinct (name, ts) occurrences the bucket contains. + expect(countQueries(client, "select occ.idx")).toBe(1); + // And the canonical-player/nickname fallback inserts stay batched (≤1 each). + expect( + countQueries(client, "insert into canonical_players"), + ).toBeLessThanOrEqual(1); + expect( + countQueries(client, "insert into player_nicknames"), + ).toBeLessThanOrEqual(1); + }); +}); diff --git a/src/modules/statistics/repository/tests/bounty.test.ts b/src/modules/statistics/repository/tests/bounty.test.ts index 04971f5..4ea720f 100644 --- a/src/modules/statistics/repository/tests/bounty.test.ts +++ b/src/modules/statistics/repository/tests/bounty.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { PgStatisticsRepository } from "../repository.js"; +import { bountyInsertRows } from "./insert-assertions.js"; import { ScriptedClient, bountyInsertParameters, @@ -34,8 +35,12 @@ describe("PgStatisticsRepository bounty calculation inputs", () => { status: "recalculated", }); + // FINDING 5: each scope issues exactly ONE multi-row insert (not one per + // row). Two scopes (sg per-rotation + sg all-time) → two insert statements. + expect(bountyInsertParameters(client)).toHaveLength(2); + // The per-rotation sg insert (with carry-in) is written first. - expect(bountyInsertParameters(client)[0]).toEqual([ + expect(bountyInsertRows(client)[0]).toEqual([ "rotation-1", "player-1", FULL_FACTOR_POINTS, @@ -66,8 +71,8 @@ describe("PgStatisticsRepository bounty calculation inputs", () => { "sg", ]); // The second insert is the sg all-time bucket (rotation_id NULL). - expect(bountyInsertParameters(client)[1]?.[0]).toBeNull(); - expect(bountyInsertParameters(client)[1]?.[4]).toBe("sg"); + expect(bountyInsertRows(client)[1]?.[0]).toBeNull(); + expect(bountyInsertRows(client)[1]?.[4]).toBe("sg"); }); it("Ignores compact player counter events when building bounty candidates", async () => { @@ -87,7 +92,7 @@ describe("PgStatisticsRepository bounty calculation inputs", () => { }); // First insert = the per-rotation sg row carrying the resolved events. - expect(bountyInsertParameters(client)[0]?.[3]).toMatchObject({ + expect(bountyInsertRows(client)[0]?.[3]).toMatchObject({ events: [ { event_type: "kill", @@ -129,7 +134,7 @@ describe("PgStatisticsRepository bounty calculation inputs", () => { status: "recalculated", }); - expect(bountyInsertParameters(client)[0]?.[3]).toMatchObject({ + expect(bountyInsertRows(client)[0]?.[3]).toMatchObject({ events: [ { excluded_reason: "missing_victim", @@ -157,7 +162,7 @@ describe("PgStatisticsRepository bounty calculation inputs", () => { status: "recalculated", }); - expect(bountyInsertParameters(client)[0]?.[2]).toBe(1); + expect(bountyInsertRows(client)[0]?.[2]).toBe(1); }); it("uses zero previous factors when previous stats are invalid", async () => { @@ -175,7 +180,7 @@ describe("PgStatisticsRepository bounty calculation inputs", () => { status: "recalculated", }); - expect(bountyInsertParameters(client)[0]?.[2]).toBe(1); + expect(bountyInsertRows(client)[0]?.[2]).toBe(1); }); }); diff --git a/src/modules/statistics/repository/tests/commander.test.ts b/src/modules/statistics/repository/tests/commander.test.ts index 23e7704..4d71114 100644 --- a/src/modules/statistics/repository/tests/commander.test.ts +++ b/src/modules/statistics/repository/tests/commander.test.ts @@ -29,7 +29,7 @@ describe("PgStatisticsRepository commander side fact mapping", () => { expect(client.queries).toContain("commit"); expect(commanderInsertParameters(client)).toEqual([ - [null, "player-1", "west", 1, 0, 0, "mace"], + [null, "mace", ["player-1"], ["west"], [1], [0], [0]], ]); }); @@ -48,7 +48,7 @@ describe("PgStatisticsRepository commander side fact mapping", () => { }); expect(commanderInsertParameters(client)).toEqual([ - [null, "player-1", "west", 1, 0, 0, "mace"], + [null, "mace", ["player-1"], ["west"], [1], [0], [0]], ]); }); @@ -67,7 +67,7 @@ describe("PgStatisticsRepository commander side fact mapping", () => { }); expect(commanderInsertParameters(client)).toEqual([ - [null, "player-1", "west", 1, 0, 0, "mace"], + [null, "mace", ["player-1"], ["west"], [1], [0], [0]], ]); }); @@ -86,7 +86,7 @@ describe("PgStatisticsRepository commander side fact mapping", () => { }); expect(commanderInsertParameters(client)).toEqual([ - [null, null, "west", 1, 0, 0, "mace"], + [null, "mace", [null], ["west"], [1], [0], [0]], ]); }); @@ -105,7 +105,7 @@ describe("PgStatisticsRepository commander side fact mapping", () => { }); expect(commanderInsertParameters(client)).toEqual([ - [null, "player-1", "west", 0, 0, 1, "mace"], + [null, "mace", ["player-1"], ["west"], [0], [0], [1]], ]); }); @@ -124,7 +124,7 @@ describe("PgStatisticsRepository commander side fact mapping", () => { }); expect(commanderInsertParameters(client)).toEqual([ - [null, "player-1", "west", 0, 0, 1, "mace"], + [null, "mace", ["player-1"], ["west"], [0], [0], [1]], ]); }); }); diff --git a/src/modules/statistics/repository/tests/identity-index.test.ts b/src/modules/statistics/repository/tests/identity-index.test.ts new file mode 100644 index 0000000..595dc69 --- /dev/null +++ b/src/modules/statistics/repository/tests/identity-index.test.ts @@ -0,0 +1,158 @@ +/* eslint-disable camelcase, id-length, no-inline-comments, unicorn/no-null */ +import { describe, expect, it } from "vitest"; + +import { + bestPlayerIdentityIndexed, + buildPlayerIdentityIndex, + playerIdentityMatchPriority, + type PlayerIdentityRow, +} from "../repository.js"; + +import type { ParserArtifact } from "../../parser-artifact.js"; + +type ArtifactPlayer = NonNullable[number]; + +/** + * The ORACLE: the prior O(|identities|)-per-player `bestPlayerIdentity` scan, + * reproduced verbatim here (flatMap over the whole array + stable `.toSorted` by + * descending priority, first element wins). FINDING 3 replaces this with an + * O(1)-lookup index; this test cross-checks that the index resolves the SAME + * identity as this scan for every seeded input, so the perf change is proven + * behavior-preserving rather than merely "looks equivalent". + */ +function bestPlayerIdentityScan( + identities: PlayerIdentityRow[], + player: ArtifactPlayer, + replayTimestamp: Date, +): PlayerIdentityRow | undefined { + return identities + .flatMap((identity) => { + const priority = playerIdentityMatchPriority( + identity, + player, + replayTimestamp, + ); + return priority === undefined ? [] : [{ identity, priority }]; + }) + .toSorted((left, right) => right.priority - left.priority)[0]?.identity; +} + +function identityRow( + overrides: Partial & { player_id: string }, +): PlayerIdentityRow { + return { + display_name: overrides.player_id, + nickname: null, + nickname_observed_from: null, + nickname_observed_to: null, + steam_id: null, + ...overrides, + }; +} + +const TS = new Date("2024-06-01T00:00:00.000Z"); + +// A deliberately adversarial seeded corpus: steam-id, active-nickname-in-window, +// expired-nickname, display-name-only, duplicate display names across players (to +// exercise the first-in-array tie-break), and players matching nothing. +const IDENTITIES: PlayerIdentityRow[] = [ + identityRow({ + display_name: "Alpha", + player_id: "p-alpha", + steam_id: "sid-1", + }), + identityRow({ + display_name: "Bravo", + nickname: "Brav", + nickname_observed_from: new Date("2024-01-01T00:00:00.000Z"), + nickname_observed_to: new Date("2024-12-31T00:00:00.000Z"), + player_id: "p-bravo", + }), + identityRow({ + display_name: "Charlie", + nickname: "Char", + nickname_observed_from: new Date("2020-01-01T00:00:00.000Z"), + nickname_observed_to: new Date("2020-12-31T00:00:00.000Z"), + player_id: "p-charlie", + }), + // Two players share lower(display_name) "dup" — the scan's stable sort keeps the + // FIRST one on a priority tie; the index must do the same. + identityRow({ display_name: "Dup", player_id: "p-dup-first" }), + identityRow({ display_name: "dup", player_id: "p-dup-second" }), + // A player whose display_name collides with another's active nickname: nickname + // (priority 2) must beat display_name (priority 1) regardless of array order. + identityRow({ display_name: "echoes", player_id: "p-echo-display" }), + identityRow({ + display_name: "Echo Real", + nickname: "Echoes", + nickname_observed_from: null, + nickname_observed_to: null, + player_id: "p-echo-nick", + }), + // Steam id AND a name that matches a different player's display — steam wins. + identityRow({ + display_name: "Foxtrot", + player_id: "p-fox", + steam_id: "sid-2", + }), + identityRow({ display_name: "sid-clash", player_id: "p-fox-nameclash" }), +]; + +const PLAYERS: ArtifactPlayer[] = [ + { eid: 1, n: "Alpha", sid: "sid-1" }, // steam match + { eid: 2, n: "Alpha" }, // display match (no sid) + { eid: 3, n: "Brav" }, // active nickname in window + { eid: 4, n: "Char" }, // expired nickname → falls through to nothing + { eid: 5, n: "Charlie" }, // display match + { eid: 6, n: "DUP" }, // duplicate display, tie-break first + { eid: 7, n: "Echoes" }, // nickname (p-echo-nick) beats display (none here) + { eid: 8, n: "echoes" }, // display match p-echo-display, but nickname also matches → nickname wins + { eid: 9, n: "Foxtrot", sid: "sid-2" }, // steam match + { eid: 10, n: "sid-clash", sid: "sid-2" }, // steam (sid-2 → p-fox) beats display (p-fox-nameclash) + { eid: 11, n: "Ghost" }, // matches nothing + { eid: 12, n: "Foxtrot" }, // display match p-fox (no sid) +]; + +describe("player identity index (FINDING 3 — O(1) resolution)", () => { + it("resolves every seeded player identically to the prior per-player scan", () => { + const index = buildPlayerIdentityIndex(IDENTITIES); + + for (const player of PLAYERS) { + const fromScan = bestPlayerIdentityScan(IDENTITIES, player, TS), + fromIndex = bestPlayerIdentityIndexed(index, player, TS); + expect(fromIndex).toEqual(fromScan); + } + }); + + it("preserves the first-in-array tie-break for duplicate display names", () => { + const index = buildPlayerIdentityIndex(IDENTITIES); + expect( + bestPlayerIdentityIndexed(index, { eid: 6, n: "DUP" }, TS)?.player_id, + ).toBe("p-dup-first"); + }); + + it("matches the scan across a sweep of timestamps spanning every nickname window", () => { + const index = buildPlayerIdentityIndex(IDENTITIES), + timestamps = [ + new Date("2019-01-01T00:00:00.000Z"), + new Date("2020-06-01T00:00:00.000Z"), + new Date("2024-06-01T00:00:00.000Z"), + new Date("2030-01-01T00:00:00.000Z"), + ]; + + for (const ts of timestamps) { + for (const player of PLAYERS) { + expect(bestPlayerIdentityIndexed(index, player, ts)).toEqual( + bestPlayerIdentityScan(IDENTITIES, player, ts), + ); + } + } + }); + + it("returns undefined for a player matching no identity (index and scan agree)", () => { + const index = buildPlayerIdentityIndex(IDENTITIES), + ghost: ArtifactPlayer = { eid: 99, n: "Nobody", sid: "sid-absent" }; + expect(bestPlayerIdentityIndexed(index, ghost, TS)).toBeUndefined(); + expect(bestPlayerIdentityScan(IDENTITIES, ghost, TS)).toBeUndefined(); + }); +}); diff --git a/src/modules/statistics/repository/tests/insert-assertions.ts b/src/modules/statistics/repository/tests/insert-assertions.ts new file mode 100644 index 0000000..3203efc --- /dev/null +++ b/src/modules/statistics/repository/tests/insert-assertions.ts @@ -0,0 +1,35 @@ +import { bountyInsertParameters, type ScriptedClient } from "./utilities.js"; + +/** Number of issued queries whose normalized SQL starts with `prefix`. */ +export function countQueries(client: ScriptedClient, prefix: string): number { + return client.queries.filter((sql) => sql.startsWith(prefix)).length; +} + +/** + * Reconstructs the per-row bounty tuples from the single batched `unnest` insert + * (FINDING 5). The replace now issues ONE multi-row insert per scope with params + * `[rotationId, gameType, playerId[], points[], inputsJson[]]`; this re-zips each + * scope's batched params back into the prior per-row shape + * `[rotationId, playerId, points, inputs, gameType]` (parsing the jsonb-serialized + * `inputs` back to an object) so the existing byte-identity assertions read + * unchanged. Each element of the outer array is one persisted row, preserving the + * insert order across both scopes (per-rotation then all-time for sg). + */ +export function bountyInsertRows(client: ScriptedClient): unknown[][] { + return bountyInsertParameters(client).flatMap((parameters) => { + const [rotationId, gameType, playerIds, points, inputs] = parameters as [ + string | null, + string | null, + string[], + number[], + string[], + ]; + return inputs.map((inputJson, row) => [ + rotationId, + playerIds[row], + points[row], + JSON.parse(inputJson) as unknown, + gameType, + ]); + }); +} From 0f5a8f42119f1c1dc7e57d4c7d1ae16553f1d02a Mon Sep 17 00:00:00 2001 From: Pavlov Alexandr Date: Sun, 14 Jun 2026 18:56:08 +0700 Subject: [PATCH 2/2] =?UTF-8?q?docs(perf):=20large-bucket=20identity=20per?= =?UTF-8?q?f=20fix=20=E2=80=94=20summary=20+=20review=20(APPROVE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PERF-REVIEW.md | 119 ++++++++++++++++++ .../PERF-large-bucket-identity-SUMMARY.md | 88 +++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 .planning/phases/01-game-type-aware-statistics-parity/PERF-REVIEW.md create mode 100644 .planning/quick/PERF-large-bucket-identity-SUMMARY.md diff --git a/.planning/phases/01-game-type-aware-statistics-parity/PERF-REVIEW.md b/.planning/phases/01-game-type-aware-statistics-parity/PERF-REVIEW.md new file mode 100644 index 0000000..9115006 --- /dev/null +++ b/.planning/phases/01-game-type-aware-statistics-parity/PERF-REVIEW.md @@ -0,0 +1,119 @@ +# Review — branch fix/perf-large-bucket-identity vs master (ebdc4ea) + +**Scope:** Single commit 34aae9d — behavior-preserving perf fix for statistics recalc. +Files changed: `repository.ts`, migration `0010_...sql`, 4 test files (+2 new). +Focus: byte-identity of aggregate output across all three changes (F3 identity index, F4 set-based resolve, F5 batched inserts). + +**Gates:** +- API contract: ✅ No route or schema touched — internal repository functions only. No OpenAPI impact. +- Layer/DI: ✅ All changes stay inside `src/modules/statistics/repository/`. No cross-module import changes, no DI wiring changed. +- Migration: ✅ `IF NOT EXISTS`, functional index only, additive, 0009 untouched. + +--- + +## Blockers 🔴 + +_none_ + +--- + +## High 🟠 + +_none_ + +--- + +## Medium 🟡 + +### 1. `repository.ts:919` [correctness] — `display_name` indexed unconditionally for ALL loaded identities, including steam-id-only matches + +`buildPlayerIdentityIndex` calls `pushTo(byLowerName, identity.display_name.toLowerCase(), entry)` for every row, regardless of how `loadPlayerIdentities` fetched it. A row fetched only because `psi.steam_id = any($steamIds)` (no display_name or nickname match against observed names) gets bucketed under its display_name in `byLowerName`. When a player's `player.n` happens to equal that display_name, it surfaces as a candidate — `playerIdentityMatchPriority` then returns `DISPLAY_NAME_MATCH_PRIORITY` and the row gets selected. + +**This is not a regression:** the old `bestPlayerIdentity` O(n) scan ran `playerIdentityMatchPriority` against ALL loaded identities including steam-id-fetched ones, so the display_name match would have fired there too. Behavior is identical. + +The risk is that the comment in the JSDoc ("candidates are only the rows indexed under the player's steam id and lower(name)") implies the index is narrow, but it's actually wider than stated — every row's display_name is indexed regardless of whether it is an observed name. If `loadPlayerIdentities` later changes to fetch additional rows (e.g. by relation), the index could surface false candidates where the old scan would too — so parity is still preserved, but the index is silently broader than its documentation claims. + +**Fix:** Narrow the JSDoc claim or, if tighter correctness guarantees are desired in future, track which keys were "observed names" separately. No code change required for current behavior. + +### 2. `repository.ts:1369` [correctness] — `JSON.stringify(row.stats)` changes serialization path for jsonb + +Old per-row insert passed `row.stats` (a plain TS object) directly as a pg parameter. pg serializes TS objects to JSON automatically when the column is `jsonb`. New insert does `JSON.stringify(row.stats)` and passes the string; pg accepts pre-serialized JSON strings for jsonb columns. + +Both produce the same stored JSONB, **but** if `row.stats` ever contains a value that `JSON.stringify` handles differently from pg's own object serialization (e.g. a `Date` property — `JSON.stringify` produces `"2024-..."` while pg may coerce differently, or a `toJSON()` override), the results would diverge. Current `stats` type (`PlayerAggregateRow.stats`) has only `number` and `version: 1` fields, so no divergence today. + +The same applies to `JSON.stringify(row.inputs)` for bounty (line 1465) — `BountyPointRow.inputs` has `number` and literal fields only, so no divergence today. + +**Fix:** Document the pre-serialization assumption in a comment, or assert in the test that the round-tripped JSON equals the original object. Low urgency given current types. Also applies at `repository.ts:1388` (squad stats) and `repository.ts:1465` (bounty inputs). + +### 3. `repository.ts` [correctness] — `ensureNameFallbackIdentities` OR-split drops the `cp.id = pn.player_id` join in the nickname EXISTS, widening the match set + +Old query: +```sql +left join player_nicknames pn on pn.player_id = cp.id +where lower(cp.display_name) = lower(occ.name) + or (lower(pn.nickname) = lower(occ.name) and ...) +``` +The `cp.id = pn.player_id` join scoped the nickname side to nicknames belonging to the same canonical player as the display_name side. The new second EXISTS: +```sql +exists (select 1 from player_nicknames pn where lower(pn.nickname) = lower(occ.name) and ...) +``` +has no join to `canonical_players` — it matches any nickname row across all players. + +**This is semantically equivalent for the function's purpose:** the question asked is "does this (name, ts) occurrence already have SOME canonical identity?" not "is it the same canonical player for both halves?" The widened second EXISTS can only ever mark MORE occurrences as already-covered (potentially preventing duplicate canonical_player creation), never fewer. So it is at least as conservative as the old query. + +**However**, if a nickname exists that matches `occ.name` in the window but belongs to a player whose display_name is unrelated, the old query would NOT have matched that occurrence (because the outer EXISTS required a single cp row satisfying BOTH display_name OR nickname via the join), while the new EXISTS DOES match it. This means the new code could skip creating a canonical_player for a name that the old code would have created one for. + +**Worked example:** Suppose no canonical_player has `lower(display_name) = 'ace'`, but a player_nicknames row exists with `nickname = 'ace'` in the active window. Old code: `exists (select 1 from cp left join pn on pn.player_id = cp.id where lower(cp.display_name) = 'ace' or (lower(pn.nickname) = 'ace' and ...))`. The first half of the OR is never satisfied (no cp has display_name 'ace'). For the second half to fire, there must be a `cp` row such that `pn.player_id = cp.id AND lower(pn.nickname) = 'ace'` — this EXISTS fires if the player with nickname 'ace' exists in canonical_players (which they do by assumption). So the old query ALSO finds this match. **The semantic widening is a non-issue in practice** because `player_nicknames.player_id` always references a `canonical_players.id` (FK), so any pn row's `player_id` always has a matching cp row — the old LEFT JOIN was redundant for the OR's right side. + +The comment in the code states "identical to the original OR" which is correct given the FK constraint. But the analysis relies on the FK being enforced — worth a brief comment noting this. + +**Fix:** Add a comment noting that the second EXISTS omits the cp join because `player_nicknames.player_id` is an FK to `canonical_players.id`, making the join redundant for EXISTS purposes. + +--- + +## Low 🔵 + +### 4. `repository.ts:886-927` [naming] — `IndexedIdentity` interface is unexported but `PlayerIdentityIndex` and `buildPlayerIdentityIndex` are exported; `IndexedIdentity` appears in the public `PlayerIdentityIndex` type indirectly only through the `Map` value type + +`PlayerIdentityIndex` exposes `Map` in its public interface, meaning callers who import `PlayerIdentityIndex` will encounter `IndexedIdentity` in inferred types but cannot name it. `IndexedIdentity` should either be exported alongside the other two or the maps should be typed with an inline type (the intent is the latter). Minor since no external consumer currently destructures the maps. + +### 5. `tests/batched-write.test.ts:474` [tests] — `countQueries(client, "select occ.idx")` is a prefix match but the actual SQL starts with multiple leading newlines + +`ScriptedClient.query` trims SQL (`sql.trim()`) before storing in `queries`. The actual query SQL added in this diff starts with `\n select occ.idx` — after trim it starts with `select occ.idx`. `countQueries` uses `startsWith("select occ.idx")`. This is correct; the trim makes it safe. No bug, but a future SQL reformatting (e.g. adding a CTE before the SELECT) would silently break the count assertion without a compile error. Note for future maintainability. + +### 6. `insert-assertions.ts:20-33` [tests] — `bountyInsertRows` re-zips with unchecked array lengths + +The function destructures `[rotationId, gameType, playerIds, points, inputs]` and zips on `inputs.map((inputJson, row) => [..., playerIds[row], points[row], ...])`. If `playerIds` or `points` is shorter than `inputs` (a param count regression), `playerIds[row]` silently returns `undefined` instead of failing fast. The test then asserts on the re-zipped row, and the undefined would propagate without a clear assertion failure pointing at the mismatch. + +**Fix:** assert `playerIds.length === inputs.length && points.length === inputs.length` before the zip, or use `zip`-style utility that throws on length mismatch. + +--- + +## Non-Findings Checked + +- **Identity tie-break (first-in-array on equal priority):** The new `toSorted` secondary key `left.candidate.order - right.candidate.order` (ascending order = earlier array element wins) correctly reproduces the stable sort behavior of the old code. The `order` field is set from `identities.entries()` (0-based insertion order), so the tie-break is byte-identical. ✓ + +- **Inactive nicknames not matching:** `buildPlayerIdentityIndex` indexes ALL nicknames (active or not) under `byLowerName`. When an inactive nickname is a candidate, `playerIdentityMatchPriority` returns `undefined` (nickname window check fails, display_name check also fails for a name-mismatch) and the candidate is excluded via `flatMap`. The test at `identity-index.test.ts:725` (player `eid:4, n:"Char"` — expired nickname) confirms this. ✓ + +- **`ensureNameFallbackIdentities` timestamp parameter:** `occ.ts` is correctly bound to `$2::timestamptz[]` (the `replayTimestamp` per occurrence), and the correlated nickname window check `pn.observed_from <= occ.ts` is a proper lateral reference. PostgreSQL allows outer-query column references in EXISTS subqueries. ✓ + +- **Batched unnest column order vs INSERT column list:** All three new unnest inserts specify explicit column lists in the INSERT clause (`insert into player_stats (rotation_id, player_id, stats, game_type, is_show)`) and the SELECT uses named aliases from the `as data(...)` clause — no positional dependency that could silently swap columns. ✓ + +- **Empty arrays / empty loop equivalence:** All three batched inserts are guarded by `.length > 0`; an empty input produces no INSERT, exactly matching the old empty `for` loop. ✓ + +- **`player_id` nullable in `commander_side_stats`:** `aggregates.map((row) => row.playerId)` produces `(string | undefined)[]`; the cast `$3::uuid[]` accepts NULL positionally via unnest. The test `commander.test.ts:593` confirms `[null]` is the first element when commander has no resolved identity. ✓ + +- **`display_name` non-nullable in `PlayerIdentityRow`:** TypeScript types `display_name: string` (not `string | null`), so `identity.display_name.toLowerCase()` in `buildPlayerIdentityIndex` is safe — no null-dereference risk. ✓ + +- **Migration 0009 untouched:** Diff touches only 0010 (new file). Migration sequence is intact. ✓ + +--- + +## Verdict + +**APPROVE** + +No blockers, no high findings. The three medium findings (1, 2, 3) are documentation/future-proofing concerns — the code is correct for all current inputs. Finding 3 (OR-split FK analysis) is the highest-risk item and is proven equivalent given the FK constraint. The identity index, tie-break, and batched inserts are all byte-identical to the original for every reachable input. + +Mandatory before merge: none. +Optional follow-up: findings 1, 2, 3 (comment clarity), 6 (test robustness). diff --git a/.planning/quick/PERF-large-bucket-identity-SUMMARY.md b/.planning/quick/PERF-large-bucket-identity-SUMMARY.md new file mode 100644 index 0000000..bfa6f89 --- /dev/null +++ b/.planning/quick/PERF-large-bucket-identity-SUMMARY.md @@ -0,0 +1,88 @@ +# PERF — Large-bucket identity / recalc scaling (F8 gate) + +**Branch:** `fix/perf-large-bucket-identity` +**Commit:** `34aae9d` (single atomic commit) +**Status:** complete — full `pnpm verify` green (100% coverage, OpenAPI diff empty) + +Behavior-preserving performance fixes for the F8 large all-time buckets (gates the +~20,735-replay `mace` all-time recalc in one pass). Addresses 01-REVIEW findings 3, 4, 5. +No aggregate output changes: `player_stats` / `squad_stats` / `commander_side_stats` / +`bounty_points` and the fallback `canonical_players` / `player_nicknames` rows are byte-identical. + +## Finding → Fix + +### Finding 3 — O(1) identity resolution (`repository.ts`) +- **Before:** `bestPlayerIdentity` `.flatMap`+`.toSorted` over the ENTIRE identities array per + player per replay → O(replays × players × |identities|). +- **Fix:** `buildPlayerIdentityIndex` builds, once per bucket, a `bySteamId` + `byLowerName` + lookup (each identity indexed under its `steam_id`, `lower(display_name)`, and `lower(nickname)`, + preserving original array order via a stored `order`). `bestPlayerIdentityIndexed` scores only + the few candidates reachable from a player's steam id / lower(name) using the **unchanged** + `playerIdentityMatchPriority`, then selects highest priority with first-original-array-order + tie-break — replicating the prior stable `.toSorted((l,r)=>r.priority-l.priority)[0]` exactly. +- Applied to both the player path (`resolvedPlayers`) and the commander path (`commanderIdentities`). +- **Candidate-completeness proof:** `playerIdentityMatchPriority` returns a priority only via + steam_id==sid, lower(nickname)==lower(name), or lower(display_name)==lower(name) — all three are + exactly the index keys, so no matching identity is ever missed. + +### Finding 4 — fallback resolve + missing index (`repository.ts`, migration 0010) +- **New index:** `0010_canonical_players_display_name_lower_index.sql` → + `create index if not exists idx_canonical_players_display_name_lower on canonical_players (lower(display_name))` + (mirrors the existing `idx_player_nicknames_nickname` on `lower(nickname)`). Confirmed applied + in the live dev DB. Index-only, no behavior change. +- **Resolve restructure:** the per-occurrence correlated `where exists (cp LEFT JOIN pn WHERE + displayMatch OR nickMatch)` is rewritten into ONE set-based pass with two index-sargable EXISTS + halves (`exists(cp where lower(display_name)=lower(name))` OR `exists(pn where lower(nickname)= + lower(name) AND window)`). Predicate is provably identical (the LEFT JOIN's nickMatch only reads + `pn` columns, and every `pn` belongs to a `cp`, so the two independent EXISTS = the original OR). + Statement count unchanged (one query); output ordinalities unchanged. +- **`loadPlayerIdentities` fan-out:** left as-is. The N×M `cp ⨯ steam_ids ⨯ nicknames` rows are now + consumed in O(1) by the index, so the only residual cost is data transfer, bounded by distinct + observed names per bucket. Deduplicating in SQL would change row identity/order and risk + cross-player tie-break drift — **not** behavior-preserving — so it is deliberately not changed. + +### Finding 5 — batch the replace inserts (`repository.ts`) +- `replaceAggregateRows` / `replaceCommanderRows` / `replaceBountyRows` converted from one + `await client.query(insert ...)` per row to a single multi-row + `insert ... select * from unnest(...)` per table (the F7/FW2 pattern). +- Byte-identical: each `unnest` column carries the same per-row scalar in the same order; + constant `rotation_id` / `game_type` passed as reused scalars; `jsonb` columns (`stats`, + `inputs`) JSON-serialized per element exactly as the single-param insert serialized them; + `numeric(12,2)` `points` stored identically; empty arrays produce zero rows (matching the + empty loop). Per-table delete predicates unchanged. + +## How byte-identity is proven +1. **Real-pg parity harness unchanged & green** (`postgres.test.ts`, 185 integration tests): + per-replay vs set-based, per-type, all-time, and `is_show` comparisons all still pass against + the new batched inserts and O(1) resolution — the strongest oracle. +2. **`identity-index.test.ts` (new):** cross-checks `bestPlayerIdentityIndexed` against a verbatim + re-implementation of the prior per-player scan on an adversarial seeded corpus (steam match, + active/expired nickname windows, duplicate display names for the tie-break, nickname-beats- + display, steam-beats-name, no-match) across a 4-point timestamp sweep. +3. **`batched-write.test.ts` (new):** ScriptedClient query-count assertions — exactly ONE insert + per aggregate table (not per-row) and exactly ONE `select occ.idx` set-based fallback resolve + regardless of occurrence count; batched commander/bounty params carry all rows. +4. Updated `commander.test.ts` / `bounty.test.ts` assertions to the batched param shape + (`bountyInsertRows` re-zips the batch back to per-row tuples) — strengthened with explicit + "one insert per scope" length checks, not weakened. + +## Verification +`pnpm verify` (PG/RabbitMQ/MinIO up): format, lint, typecheck, 617 unit + 185 integration tests, +OpenAPI contract diff EMPTY, ops checks, **100% coverage** (stmts/branches/funcs/lines). + +## Files +- `src/infra/db/migrations/0010_canonical_players_display_name_lower_index.sql` (new index) +- `src/modules/statistics/repository/repository.ts` (findings 3/4/5) +- `src/modules/statistics/repository/tests/identity-index.test.ts` (new) +- `src/modules/statistics/repository/tests/batched-write.test.ts` (new) +- `src/modules/statistics/repository/tests/insert-assertions.ts` (new helper) +- `src/modules/statistics/repository/tests/{bounty,commander}.test.ts` (assertions updated) + +## Deviations +None beyond the documented scope. No architectural changes; `loadPlayerIdentities` fan-out left +intact for behavior-preservation (documented above). Out-of-scope items (classification, bucketing, +is_show formula, legacy-export shape, OpenAPI) untouched. + +## Operational note +Migration 0010 must be applied on deploy (`pnpm db:migrate`). It is index-only and additive; no +recalc is required by this change (unlike 0009).