diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 3bf76d6..a65e861 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -3,6 +3,8 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { createRoomInstance, + deleteExpiredPresence, + getExpiredPresenceInstanceIds, getJoinableInstance, getOrCreateDormRoom, getPresence, @@ -20,7 +22,7 @@ import { validateAndGetAccountId } from '@repo/jwt' import type { Context } from 'hono' import type { Room, StoredPresence } from '@repo/domain' -import type { App } from './context' +import type { App, Env } from './context' /** * The matchmaking surface. Rooms and room instances are D1-backed (matchmaking @@ -603,4 +605,31 @@ const app = new Hono() // Rooms flagged as requiring an RR+ subscription. No such queue yet → empty list. .get('/rooms/requiring/rrplus', (c) => c.json([])) -export default app +/** + * Cron: sweep presence that has aged past its TTL. Reads already ignore expired rows, + * so this isn't about correctness of `/player` — it's that a player who crashed or + * hard-quit never matchmakes out of their instance, so nothing recomputes that + * instance's fullness and it can stay flagged full (and unjoinable) with nobody in it. + * Recompute the instances the expiring rows point at, *then* delete: the sweep is the + * only thing that notices those departures. Fullness is recomputed after the delete so + * the head-count no longer sees them. + */ +async function sweepExpiredPresence(env: Env): Promise { + const staleInstanceIds = await getExpiredPresenceInstanceIds(env.DB) + const removed = await deleteExpiredPresence(env.DB) + for (const instanceId of staleInstanceIds) { + await refreshInstanceFullness(env.DB, instanceId) + } + // 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, refreshed ${staleInstanceIds.length} instances` + ) +} + +export default { + fetch: app.fetch, + scheduled: async (_controller, env, ctx) => { + ctx.waitUntil(sweepExpiredPresence(env)) + }, +} satisfies ExportedHandler diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 6feef7d..cabccd0 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1,4 +1,10 @@ -import { adminSecretsStore, env } from 'cloudflare:test' +import { + adminSecretsStore, + createExecutionContext, + createScheduledController, + env, + waitOnExecutionContext, +} from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' @@ -9,7 +15,7 @@ import { ROOM_INSTANCE_SCHEMA_DDL, } from '@repo/domain' -import '../../match.app' +import worker from '../../match.app' import type { Env } from '../../context' @@ -580,6 +586,14 @@ describe('auth-gated endpoints', () => { const nowSeconds = () => Math.floor(Date.now() / 1000) + /** Rows for an account, expired ones included — the sweep should leave none. */ + const countPresenceRows = async (id: number): Promise => { + const row = await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE account_id = ?1') + .bind(id) + .first<{ n: number }>() + return row?.n ?? 0 + } + test('heartbeat refreshes presence when its TTL is close to lapsing', async () => { // TTL about to lapse (well inside the refresh window). const nearExpiry = nowSeconds() + 10 @@ -664,6 +678,28 @@ describe('auth-gated endpoints', () => { expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false) }) + test('the cron sweep purges expired presence and frees the instance those players were in', async () => { + // A player fills SoloRoom, then vanishes without matchmaking out (a crash) — + // nothing recomputes fullness, so the instance sits full with nobody in it. + const solo = await matchmakeInto('5', '824') + expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(true) + await env.DB.prepare( + "UPDATE presence SET data = json_set(data, '$.expiresAt', ?2) WHERE account_id = ?1" + ) + .bind(824, nowSeconds() - 10) + .run() + + // Driven through the module's own export rather than the `exports` proxy — a + // ScheduledController can't cross the isolate boundary the proxy serializes over. + const ctx = createExecutionContext() + await worker.scheduled(createScheduledController(), env, ctx) + await waitOnExecutionContext(ctx) + + // Expired row gone, and the instance is joinable again. + expect(await countPresenceRows(824)).toBe(0) + expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false) + }) + test('player/login, exclusivelogin and logout all preserve presence', async () => { const headers = await bearer('9') await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers }) diff --git a/apps/match/wrangler.jsonc b/apps/match/wrangler.jsonc index 784ce8e..c7e865a 100644 --- a/apps/match/wrangler.jsonc +++ b/apps/match/wrangler.jsonc @@ -15,6 +15,13 @@ "database_id": "local" } ], + // Presence sweep. Rows expire on their own TTL (15m) and reads already ignore + // expired ones, so this is housekeeping: it purges them and recomputes the + // fullness of the instances the departed players were in (a crashed player never + // matchmakes out, so nothing else notices they left). Every 5 minutes. + "triggers": { + "crons": ["*/5 * * * *"] + }, "logpush": false, // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index b480790..b8cde48 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -137,6 +137,27 @@ export async function countPlayersInInstance( return row?.n ?? 0 } +/** + * The room instances that expired presence rows still point at — the instances a + * player was in when they stopped heartbeating (a crash or a hard quit, where no + * matchmake ever moved them out). Their head-count has really dropped, so callers + * purging presence use this to recompute those instances' fullness. Distinct ids, + * lobby (null-instance) presence excluded. + */ +export async function getExpiredPresenceInstanceIds( + db: D1Database, + now = nowSeconds() +): Promise { + const { results } = await db + .prepare( + `SELECT DISTINCT room_instance_id AS id FROM presence + WHERE expires_at <= ?1 AND room_instance_id IS NOT NULL` + ) + .bind(now) + .all<{ id: number }>() + return results.map((r) => r.id) +} + /** * Purge expired presence rows — housekeeping only, since reads already ignore them * (and `INSERT OR REPLACE` keeps a single row per account, so the table is bounded