Split The code is public; the app answers only to the crew.

The first paint groups every match ever played: `standings` costs 42 ms at 50k rows and 171 ms at 200k #218

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/cs:scout/184-the-first-paint-groups-every-match-ever → main 1 commit fork

  • Merged. scout/184-the-first-paint-groups-every-match-ever was merged into main as 5249bc6.

Review

Sign in to review this change.
2 files changed +103 −17
src/db.test.ts +36
@@ -120,6 +120,32 @@ test("a row written before the map catalogue existed reads as de_forge, and no e
120120 expect(recentMatches(db).find((m) => m.id === "m1")?.map).toBe("de_forge");
121121 });
122122
123 test("a database written before the standing table existed builds it once, from what match already holds", () => {
124 const where = dir();
125 const old = new Database(join(where, "app.db"));
126 old.exec(`CREATE TABLE match (
127 id TEXT PRIMARY KEY, identity TEXT NOT NULL DEFAULT '', session TEXT NOT NULL, name TEXT NOT NULL,
128 map TEXT NOT NULL DEFAULT 'de_forge',
129 kills INTEGER NOT NULL, deaths INTEGER NOT NULL, headshots INTEGER NOT NULL,
130 damage INTEGER NOT NULL, rounds INTEGER NOT NULL, score_mine INTEGER NOT NULL,
131 score_theirs INTEGER NOT NULL, finished INTEGER NOT NULL, walked_out INTEGER NOT NULL DEFAULT 0,
132 seconds INTEGER NOT NULL,
133 created_at TEXT NOT NULL DEFAULT (datetime('now')),
134 updated_at TEXT NOT NULL DEFAULT (datetime('now')))`);
135 old.query(
136 `INSERT INTO match (id, identity, session, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, walked_out, seconds)
137 VALUES ('m1', 'user:u_1', 'deadbeefdeadbeefdeadbeefdeadbeef', 'Vesper', 9, 4, 3, 1200, 5, 3, 2, 1, 0, 420)`,
138 ).run();
139 old.close();
140
141 const db = openDatabase(where);
142 const table = standings(db);
143 expect(table).toHaveLength(1);
144 expect(table[0]?.identity).toBe("user:u_1");
145 expect(table[0]?.kills).toBe(9);
146 expect(table[0]?.matches).toBe(1);
147 });
148
123149 test("a saved match records the map it was played on", () => {
124150 const db = openDatabase(dir());
125151 expect(saveMatch(db, writer("user:u_1", "ada"), result({ map: "de_forge" }))).toBe(true);
@@ -207,6 +233,16 @@ test("a genuinely later post still updates the numbers it grows", () => {
207233 expect(row?.state).toBe("finished");
208234 });
209235
236 test("standings answers in a budget that does not grow with the table: 20,000 matches in under 10ms", () => {
237 const db = openDatabase(dir());
238 for (let i = 0; i < 20_000; i++) {
239 expect(saveMatch(db, writer(`user:u_${i}`, `player ${i}`), result({ kills: i % 40 }))).toBe(true);
240 }
241 const start = performance.now();
242 standings(db, 10);
243 expect(performance.now() - start).toBeLessThan(10);
244 });
245
210246 test("a row nobody ever closed reads as abandoned once it goes stale", () => {
211247 const db = openDatabase(dir());
212248 const stale = result({ finished: false, left: false });
src/db.ts +67 −17
@@ -117,9 +117,62 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
117117 // for every row this adds it to, and nothing already there is rewritten.
118118 if (!columns.includes("map")) db.exec("ALTER TABLE match ADD COLUMN map TEXT NOT NULL DEFAULT 'de_forge'");
119119 db.exec("CREATE INDEX IF NOT EXISTS match_by_identity ON match (identity)");
120 // The board, kept per identity instead of grouped from the whole table on
121 // every read: `saveMatch` and `deleteMatch` recompute one identity's row
122 // here — cheap, bounded by that identity's own matches — so `standings`
123 // below is just a sorted read of a table no bigger than the player count.
124 // A database written before this table existed builds it once, the same
125 // way `standings` used to compute it, and never again pays that cost.
126 const hasStanding = db.query<{ name: string }, []>(
127 "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'standing'",
128 ).get() !== null;
129 if (!hasStanding) {
130 db.exec(`CREATE TABLE standing (
131 identity TEXT PRIMARY KEY,
132 name TEXT NOT NULL,
133 last TEXT NOT NULL,
134 matches INTEGER NOT NULL,
135 kills INTEGER NOT NULL,
136 deaths INTEGER NOT NULL,
137 headshots INTEGER NOT NULL,
138 damage INTEGER NOT NULL,
139 wins INTEGER NOT NULL,
140 rounds INTEGER NOT NULL
141 )`);
142 db.exec(`INSERT INTO standing (identity, name, last, matches, kills, deaths, headshots, damage, wins, rounds)
143 SELECT identity, name, MAX(updated_at), COUNT(*), SUM(kills), SUM(deaths), SUM(headshots), SUM(damage),
144 SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END), SUM(rounds)
145 FROM match WHERE rounds > 0 GROUP BY identity`);
146 }
147 db.exec("CREATE INDEX IF NOT EXISTS standing_by_rank ON standing (kills DESC, damage DESC)");
120148 return db;
121149 }
122150
151 // What one identity's row in `standing` should read, recomputed from that
152 // identity's own matches only — never the whole table — so a save or a
153 // delete costs what that one player's history costs, not what everyone's
154 // does. This is `standings`' old GROUP BY, narrowed to `WHERE identity = ?1`.
155 const STANDING_FOR_IDENTITY_SQL = `SELECT name, MAX(updated_at) AS last, COUNT(*) AS matches,
156 SUM(kills) AS kills, SUM(deaths) AS deaths, SUM(headshots) AS headshots, SUM(damage) AS damage,
157 SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END) AS wins, SUM(rounds) AS rounds
158 FROM match WHERE identity = ?1 AND rounds > 0`;
159
160 function recomputeStanding(db: Database, identity: string): void {
161 const row = db.query<Omit<Standing, "identity">, [string]>(STANDING_FOR_IDENTITY_SQL).get(identity);
162 if (row === null || row.matches === 0) {
163 db.query("DELETE FROM standing WHERE identity = ?1").run(identity);
164 return;
165 }
166 db.query(
167 `INSERT INTO standing (identity, name, last, matches, kills, deaths, headshots, damage, wins, rounds)
168 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
169 ON CONFLICT(identity) DO UPDATE SET
170 name = excluded.name, last = excluded.last, matches = excluded.matches, kills = excluded.kills,
171 deaths = excluded.deaths, headshots = excluded.headshots, damage = excluded.damage,
172 wins = excluded.wins, rounds = excluded.rounds`,
173 ).run(identity, row.name, row.last, row.matches, row.kills, row.deaths, row.headshots, row.damage, row.wins, row.rounds);
174 }
175
123176 /**
124177 * Write a result, credited to the writer's identity and under the writer's
125178 * name — never a name the request asked for. A match belongs to the identity
@@ -158,6 +211,7 @@ export function saveMatch(db: Database, writer: Writer, result: Result): boolean
158211 result.headshots, result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0,
159212 result.left ? 1 : 0, result.seconds,
160213 );
214 recomputeStanding(db, writer.identity);
161215 return true;
162216 }
163217
@@ -170,24 +224,15 @@ export const recentMatches = (db: Database, limit = 10): MatchRow[] =>
170224
171225 /**
172226 * The table people actually look at: one row an identity, best first. The name
173 * shown is the one on that identity's most recent match — SQLite takes a bare
174 * column from the row `MAX(updated_at)` picked — so a forge rename follows the
175 * player instead of splitting their record in two.
227 * shown is the one on that identity's most recent match, kept current by
228 * `recomputeStanding` on every write, so a forge rename follows the player
229 * instead of splitting their record in two — and this read costs the size of
230 * the board, not the size of the match table behind it.
176231 */
177232 export const standings = (db: Database, limit = 10): Standing[] =>
178233 db.query<Standing, [number]>(
179 `SELECT identity,
180 name,
181 MAX(updated_at) AS last,
182 COUNT(*) AS matches,
183 SUM(kills) AS kills,
184 SUM(deaths) AS deaths,
185 SUM(headshots) AS headshots,
186 SUM(damage) AS damage,
187 SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END) AS wins,
188 SUM(rounds) AS rounds
189 FROM match WHERE rounds > 0
190 GROUP BY identity
234 `SELECT identity, name, last, matches, kills, deaths, headshots, damage, wins, rounds
235 FROM standing
191236 ORDER BY kills DESC, damage DESC
192237 LIMIT ?1`,
193238 ).all(limit);
@@ -195,8 +240,13 @@ export const standings = (db: Database, limit = 10): Standing[] =>
195240 export const matchCount = (db: Database): number =>
196241 db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM match").get()?.n ?? 0;
197242
198 export const deleteMatch = (db: Database, id: string): boolean =>
199 db.query("DELETE FROM match WHERE id = ?1").run(id).changes > 0;
243 export function deleteMatch(db: Database, id: string): boolean {
244 const row = db.query<{ identity: string }, [string]>("SELECT identity FROM match WHERE id = ?1").get(id);
245 if (row === null) return false;
246 const changed = db.query("DELETE FROM match WHERE id = ?1").run(id).changes > 0;
247 if (changed) recomputeStanding(db, row.identity);
248 return changed;
249 }
200250
201251 export const ratio = (kills: number, deaths: number): string =>
202252 (kills / Math.max(1, deaths)).toFixed(2);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs