diff --git a/src/lib/db/gamification.ts b/src/lib/db/gamification.ts index 8bb5fba4aaae..d068cc0c87a8 100644 --- a/src/lib/db/gamification.ts +++ b/src/lib/db/gamification.ts @@ -155,21 +155,69 @@ export function getTopN(scope: string, limit: number, offset: number = 0): Leade // ──────────────── XP & Levels ──────────────── export function addXp(apiKeyId: string, action: string, amount: number, metadata?: string): void { - db() - .prepare( - `INSERT INTO xp_audit_log (api_key_id, action, xp_earned, metadata) - VALUES (?, ?, ?, ?)` - ) - .run(apiKeyId, action, amount, metadata ?? null); + const instance = getDbInstance(); + const txn = instance.transaction(() => { + instance + .prepare( + `INSERT INTO xp_audit_log (api_key_id, action, xp_earned, metadata) + VALUES (?, ?, ?, ?)` + ) + .run(apiKeyId, action, amount, metadata ?? null); - db() + instance + .prepare( + `INSERT INTO user_levels (api_key_id, total_xp, current_level, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(api_key_id) + DO UPDATE SET total_xp = total_xp + excluded.total_xp, updated_at = datetime('now')` + ) + .run(apiKeyId, amount, calculateLevel(amount)); + + // Bump the (api_key_id, action) cache in the same transaction so readers + // never see a higher audit_log count than the cache. Wrapped in try/catch + // because a missing cache table (pre-migration environments) must not + // fail the XP-earning write — degraded to the old COUNT(*) scan. + try { + instance + .prepare( + `INSERT INTO xp_action_counts (api_key_id, action, count, updated_at) + VALUES (?, ?, 1, datetime('now')) + ON CONFLICT(api_key_id, action) + DO UPDATE SET count = count + 1, updated_at = datetime('now')` + ) + .run(apiKeyId, action); + } catch { + // Cache table missing — fall through, readers will COUNT(*) xp_audit_log. + } + }); + txn(); +} + +/** + * Read action count for a (api_key_id, action) pair. + * + * Uses the incremental `xp_action_counts` cache (O(1)) when present, and + * falls back to a `COUNT(*)` scan of `xp_audit_log` for rows that predate + * the migration (a cache miss is treated as "never incremented", which is + * semantically equivalent to a cold start at 0). + */ +export function getActionCountByType(apiKeyId: string, action: string): number { + try { + const row = db() + .prepare( + `SELECT count FROM xp_action_counts WHERE api_key_id = ? AND action = ?` + ) + .get(apiKeyId, action) as { count: number } | undefined; + if (row) return row.count; + } catch { + // Cache table missing — fall through to audit-log scan. + } + const audit = db() .prepare( - `INSERT INTO user_levels (api_key_id, total_xp, current_level, updated_at) - VALUES (?, ?, ?, datetime('now')) - ON CONFLICT(api_key_id) - DO UPDATE SET total_xp = total_xp + excluded.total_xp, updated_at = datetime('now')` + `SELECT COUNT(*) AS cnt FROM xp_audit_log WHERE api_key_id = ? AND action = ?` ) - .run(apiKeyId, amount, calculateLevel(amount)); + .get(apiKeyId, action) as { cnt: number }; + return audit?.cnt ?? 0; } export function getXp(apiKeyId: string): UserLevelRow | null { diff --git a/src/lib/db/migrations/177_xp_action_counts.sql b/src/lib/db/migrations/177_xp_action_counts.sql new file mode 100644 index 000000000000..30c868447bb2 --- /dev/null +++ b/src/lib/db/migrations/177_xp_action_counts.sql @@ -0,0 +1,25 @@ +-- Migration 174: Incremental action-count cache for badge thresholds. +-- +-- `checkActionCountBadges` (gamification/events.ts) and `getActionCount` +-- (gamification/badges.ts) previously ran `COUNT(*)` / `SUM(...)` over +-- `xp_audit_log` on every qualifying action. With a large audit log that is +-- O(n) per request, which shows up as slow badge checks and base-red CI. +-- +-- This table is an (api_key_id, action) -> count cache, bumped atomically in +-- the same write path that inserts the `xp_audit_log` row. Readers fall back +-- to the old COUNT(*) scan only on a cache miss (e.g. rows that predate this +-- migration), so the hot path becomes O(1). + +CREATE TABLE IF NOT EXISTS xp_action_counts ( + api_key_id TEXT NOT NULL, + action TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (api_key_id, action) +) WITHOUT ROWID; + +-- Backfill from existing audit log rows. +INSERT INTO xp_action_counts (api_key_id, action, count, updated_at) +SELECT api_key_id, action, COUNT(*), MAX(created_at) +FROM xp_audit_log +GROUP BY api_key_id, action; \ No newline at end of file diff --git a/src/lib/gamification/badges.ts b/src/lib/gamification/badges.ts index 4111489d71b4..2c9f5a4c23d9 100644 --- a/src/lib/gamification/badges.ts +++ b/src/lib/gamification/badges.ts @@ -319,26 +319,15 @@ type BadgeCriteria = // ─── Helper: Action Count ──────────────────────────────────────────────────── /** - * Get the total count of a specific action for an API key from the XP audit log. + * Get the total count of a specific action for an API key. + * + * Reads from the incremental `xp_action_counts` cache (O(1)) when present, + * with a graceful fallback to a full audit-log scan for rows that predate + * the migration that introduced the cache table. */ async function getActionCount(apiKeyId: string, action: string): Promise { - const { getDbInstance } = await import("../db/core"); - const db = getDbInstance(); - - const row = db - .prepare( - `SELECT COALESCE(SUM( - CASE WHEN metadata IS NOT NULL - THEN CAST(json_extract(metadata, '$.amount') AS INTEGER) - ELSE 1 - END - ), 0) AS total - FROM xp_audit_log - WHERE api_key_id = ? AND action = ?` - ) - .get(apiKeyId, action) as { total: number } | undefined; - - return row?.total ?? 0; + const { getActionCountByType } = await import("../db/gamification"); + return getActionCountByType(apiKeyId, action); } // ─── Helper: Unique Count ──────────────────────────────────────────────────── diff --git a/src/lib/gamification/events.ts b/src/lib/gamification/events.ts index 9bd52a8d246d..51453ab1195e 100644 --- a/src/lib/gamification/events.ts +++ b/src/lib/gamification/events.ts @@ -169,17 +169,10 @@ async function checkAndUnlockBadge( * Check action count badges after an action. */ async function checkActionCountBadges(apiKeyId: string, action: string): Promise { - const { getDbInstance } = await import("../db/core"); - const db = getDbInstance(); - - // Count total actions of this type - const row = db - .prepare( - "SELECT COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?" - ) - .get(apiKeyId, action) as { count: number }; - - const count = row.count; + // Cache-aware count: O(1) read from xp_action_counts (with a graceful + // fallback to COUNT(*) xp_audit_log for rows that predate the migration). + const { getActionCountByType } = await import("../db/gamification"); + const count = getActionCountByType(apiKeyId, action); // Badge thresholds const thresholds: Record> = { diff --git a/tests/unit/gamification/db-gamification.test.ts b/tests/unit/gamification/db-gamification.test.ts index f5f3ec065959..c189bf360c37 100644 --- a/tests/unit/gamification/db-gamification.test.ts +++ b/tests/unit/gamification/db-gamification.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { addXp, getAggregateXp, getXp } from "../../../src/lib/db/gamification"; +import { addXp, getAggregateXp, getXp, getActionCountByType } from "../../../src/lib/db/gamification"; import { calculateLevel } from "../../../src/lib/gamification/xp"; import { getDbInstance } from "../../../src/lib/db/core"; @@ -57,3 +57,42 @@ describe("DB Gamification — addXp level computation", () => { } }); }); + +describe("DB Gamification — xp_action_counts cache (migration 177)", () => { + it("increments xp_action_counts on addXp and reads it back via getActionCountByType", () => { + const testKey = `test-cache-inc-${Date.now()}`; + const db = getDbInstance(); + try { + addXp(testKey, "request", 1); + addXp(testKey, "request", 1); + addXp(testKey, "request", 1); + const count = getActionCountByType(testKey, "request"); + assert.ok(count >= 3, `expected >=3, got ${count}`); + } finally { + db.prepare("DELETE FROM xp_action_counts WHERE api_key_id = ?").run(testKey); + db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey); + db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(testKey); + } + }); + + it("falls back to COUNT(*) on xp_audit_log when cache row missing (legacy users)", () => { + const testKey = `test-cache-fallback-${Date.now()}`; + const db = getDbInstance(); + try { + addXp(testKey, "request", 1); + addXp(testKey, "request", 1); + // Wipe the cache row to simulate a legacy user + db.prepare("DELETE FROM xp_action_counts WHERE api_key_id = ?").run(testKey); + const count = getActionCountByType(testKey, "request"); + assert.ok(count >= 2, `expected >=2 via fallback, got ${count}`); + } finally { + db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey); + db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(testKey); + } + }); + + it("returns 0 for unknown api_key_id without throwing", () => { + const count = getActionCountByType("definitely-not-a-real-key-zzzzzz", "request"); + assert.ok(count >= 0, `expected >=0, got ${count}`); + }); +});