diff --git a/apps/match/migrations/0002_stat.sql b/apps/match/migrations/0002_stat.sql new file mode 100644 index 0000000..77f3d8a --- /dev/null +++ b/apps/match/migrations/0002_stat.sql @@ -0,0 +1,16 @@ +-- Server statistics sampled over time — one row per sample, as the presence cron +-- writes them. Generated from packages/domain/src/stats-db.ts (STAT_SCHEMA_DDL) — keep +-- in sync. +-- +-- `stat_type` names what was measured (currently just `online`: the number of live +-- `presence` rows, i.e. players online, taken right after the expired ones are swept). +-- `value` is the measurement and `datetime` is when it was taken, as an ISO-8601 UTC +-- string so it reads directly and sorts lexically. +CREATE TABLE IF NOT EXISTS stat ( + stat_type TEXT NOT NULL, + value INTEGER NOT NULL, + datetime TEXT NOT NULL + ); + +-- For "the `online` series over a time range". +CREATE INDEX IF NOT EXISTS idx_stat_type_datetime ON stat (stat_type, datetime); diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index fecfa96..7d280da 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -6,6 +6,7 @@ import { Accessibility, areFriends, canManageRoom, + countOnlinePlayers, createRoomInstance, createRoomInvite, deleteEmptyRoomInstances, @@ -35,6 +36,7 @@ import { MessageType, MOST_ACTIVE_CLUBHOUSE_LIMIT, recordRoomVisit, + recordStat, refreshInstanceFullness, RoomInstanceType, setPresence, @@ -2903,10 +2905,14 @@ async function sweepExpiredPresence(env: Env): Promise { for (const instanceId of staleInstanceIds) { await refreshInstanceFullness(env.DB, instanceId) } + // Sample the player count into `stat` — taken after the purge, so it's the live + // rows and not the ones that just lapsed. One row per cron run: the `online` series. + const online = await countOnlinePlayers(env.DB) + await recordStat(env.DB, 'online', online) // The tagged logger is request-scoped (its middleware never runs for a cron), so // log plainly here — Workers observability picks it up either way. console.log( - `presence sweep: removed ${removed} expired rows, deleted ${emptyInstanceIds.length} empty instances, refreshed ${staleInstanceIds.length} instances` + `presence sweep: removed ${removed} expired rows, deleted ${emptyInstanceIds.length} empty instances, refreshed ${staleInstanceIds.length} instances, ${online} online` ) } diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 8e13171..ebc2cd3 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -20,6 +20,7 @@ import { ROOM_SCHEMA_DDL, seedRoomWithSubRooms, setPresence, + STAT_SCHEMA_DDL, SUBROOM_SCHEMA_DDL, } from '@repo/domain' @@ -124,6 +125,8 @@ beforeAll(async () => { for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Room invites (owned by this worker) — POST /invite mints a row per invite. for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Stats (owned by this worker) — the presence cron samples the online count into it. + for (const stmt of STAT_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Accounts table (owned by the auth worker) — dorm creation reads the username // to name the room. Seed the players the dorm tests authenticate as. @@ -1909,6 +1912,30 @@ describe('auth-gated endpoints', () => { expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false) }) + test('records an `online` stat sample of the live presence count on each run', async () => { + await env.DB.prepare('DELETE FROM stat').run() + const before = (await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE expires_at > ?1') + .bind(nowSeconds()) + .first<{ n: number }>())!.n + + const ctx = createExecutionContext() + await scheduled(createScheduledController(), env, ctx) + await waitOnExecutionContext(ctx) + + const rows = ( + await env.DB.prepare('SELECT stat_type, value, datetime FROM stat').all<{ + stat_type: string + value: number + datetime: string + }>() + ).results + expect(rows).toHaveLength(1) + expect(rows[0]!.stat_type).toBe('online') + expect(rows[0]!.value).toBe(before) + // Stamped with the current time, as ISO-8601. + expect(Math.abs(Date.parse(rows[0]!.datetime) - Date.now())).toBeLessThan(10_000) + }) + // Age an instance past EMPTY_INSTANCE_GRACE_SECONDS by backdating its `createdAt` // (the generated `created_at` column follows the blob), so the empty-instance sweep // can be exercised without waiting out the grace window. @@ -2444,9 +2471,7 @@ describe('auth-gated endpoints', () => { // The one frame an invite from a client on `version` pushes, with the RoomInviteId // the call answered — a v2 invite names it in its Data. - const inviteFrom = async ( - version?: string - ): Promise<{ frame: Sent; roomInviteId: number }> => { + const inviteFrom = async (version?: string): Promise<{ frame: Sent; roomInviteId: number }> => { await hub().fetch('http://do/all', { method: 'DELETE' }) const res = await exports.default.fetch(`${ORIGIN}/invite`, { method: 'POST', diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 7f91dee..5c5af58 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -15,6 +15,7 @@ export * from './room-instance-db' export * from './room-comments-db' export * from './room-invites-db' export * from './presence-db' +export * from './stats-db' export * from './gifts-db' export * from './inventory-invention-db' export * from './lists-db' diff --git a/packages/domain/src/stats-db.ts b/packages/domain/src/stats-db.ts new file mode 100644 index 0000000..e785fb7 --- /dev/null +++ b/packages/domain/src/stats-db.ts @@ -0,0 +1,45 @@ +/** + * Server statistics sampled over time (`stat` table). The `match` presence cron + * records one `online` sample per run — the count of live `presence` rows once the + * expired ones are swept. Migration: apps/match/migrations/0002_stat.sql. + */ + +/** Schema DDL (mirror of apps/match/migrations/0002_stat.sql). */ +export const STAT_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS stat ( + stat_type TEXT NOT NULL, + value INTEGER NOT NULL, + datetime TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_stat_type_datetime ON stat (stat_type, datetime)`, +] + +export interface StatRow { + statType: string + value: number + datetime: string +} + +/** Record one sample of `statType`, stamped with the current UTC time (ISO-8601). */ +export async function recordStat( + db: D1Database, + statType: string, + value: number, + now: Date = new Date() +): Promise { + await db + .prepare('INSERT INTO stat (stat_type, value, datetime) VALUES (?1, ?2, ?3)') + .bind(statType, value, now.toISOString()) + .run() +} + +/** Samples of `statType`, oldest first. */ +export async function getStats(db: D1Database, statType: string, limit = 1000): Promise { + const { results } = await db + .prepare( + 'SELECT stat_type, value, datetime FROM stat WHERE stat_type = ?1 ORDER BY datetime ASC LIMIT ?2' + ) + .bind(statType, limit) + .all<{ stat_type: string; value: number; datetime: string }>() + return results.map((r) => ({ statType: r.stat_type, value: r.value, datetime: r.datetime })) +}