expire old presences

This commit is contained in:
Devin Zuczek
2026-07-13 18:13:18 -04:00
parent 4860875cc7
commit c555f896a9
4 changed files with 97 additions and 4 deletions
+31 -2
View File
@@ -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<App>()
// 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<void> {
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<Env>
+38 -2
View File
@@ -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<number> => {
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 })
+7
View File
@@ -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"
+21
View File
@@ -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<number[]> {
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