mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[match] add basic stat table for fun
This commit is contained in:
@@ -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);
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Accessibility,
|
Accessibility,
|
||||||
areFriends,
|
areFriends,
|
||||||
canManageRoom,
|
canManageRoom,
|
||||||
|
countOnlinePlayers,
|
||||||
createRoomInstance,
|
createRoomInstance,
|
||||||
createRoomInvite,
|
createRoomInvite,
|
||||||
deleteEmptyRoomInstances,
|
deleteEmptyRoomInstances,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
MessageType,
|
MessageType,
|
||||||
MOST_ACTIVE_CLUBHOUSE_LIMIT,
|
MOST_ACTIVE_CLUBHOUSE_LIMIT,
|
||||||
recordRoomVisit,
|
recordRoomVisit,
|
||||||
|
recordStat,
|
||||||
refreshInstanceFullness,
|
refreshInstanceFullness,
|
||||||
RoomInstanceType,
|
RoomInstanceType,
|
||||||
setPresence,
|
setPresence,
|
||||||
@@ -2903,10 +2905,14 @@ async function sweepExpiredPresence(env: Env): Promise<void> {
|
|||||||
for (const instanceId of staleInstanceIds) {
|
for (const instanceId of staleInstanceIds) {
|
||||||
await refreshInstanceFullness(env.DB, instanceId)
|
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
|
// The tagged logger is request-scoped (its middleware never runs for a cron), so
|
||||||
// log plainly here — Workers observability picks it up either way.
|
// log plainly here — Workers observability picks it up either way.
|
||||||
console.log(
|
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`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ROOM_SCHEMA_DDL,
|
ROOM_SCHEMA_DDL,
|
||||||
seedRoomWithSubRooms,
|
seedRoomWithSubRooms,
|
||||||
setPresence,
|
setPresence,
|
||||||
|
STAT_SCHEMA_DDL,
|
||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
@@ -124,6 +125,8 @@ beforeAll(async () => {
|
|||||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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.
|
// 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()
|
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
|
// Accounts table (owned by the auth worker) — dorm creation reads the username
|
||||||
// to name the room. Seed the players the dorm tests authenticate as.
|
// 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)
|
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`
|
// 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
|
// (the generated `created_at` column follows the blob), so the empty-instance sweep
|
||||||
// can be exercised without waiting out the grace window.
|
// 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 one frame an invite from a client on `version` pushes, with the RoomInviteId
|
||||||
// the call answered — a v2 invite names it in its Data.
|
// the call answered — a v2 invite names it in its Data.
|
||||||
const inviteFrom = async (
|
const inviteFrom = async (version?: string): Promise<{ frame: Sent; roomInviteId: number }> => {
|
||||||
version?: string
|
|
||||||
): Promise<{ frame: Sent; roomInviteId: number }> => {
|
|
||||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/invite`, {
|
const res = await exports.default.fetch(`${ORIGIN}/invite`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export * from './room-instance-db'
|
|||||||
export * from './room-comments-db'
|
export * from './room-comments-db'
|
||||||
export * from './room-invites-db'
|
export * from './room-invites-db'
|
||||||
export * from './presence-db'
|
export * from './presence-db'
|
||||||
|
export * from './stats-db'
|
||||||
export * from './gifts-db'
|
export * from './gifts-db'
|
||||||
export * from './inventory-invention-db'
|
export * from './inventory-invention-db'
|
||||||
export * from './lists-db'
|
export * from './lists-db'
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<StatRow[]> {
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user