[rooms] clean up old invites/votes

This commit is contained in:
Devin Zuczek
2026-09-22 19:14:36 -04:00
parent 30038afd30
commit 8ef8f9af50
8 changed files with 99 additions and 20 deletions
+11 -3
View File
@@ -39,9 +39,17 @@ same number, because the client's paged DTO and the reference disagree on the na
## Cron
A `*/5 * * * *` cron (`scheduled`) deletes vote-to-kick ballots in `room_vote` older than
5 minutes. The table is the `api` worker's and append-only; a vote stays open for 60
seconds and the caller cooldown looks back 5 minutes, so nothing reads an older ballot.
A `*/5 * * * *` cron (`scheduled`) sweeps two short-lived tables on the shared database,
neither of them this worker's own:
- `room_vote` (the `api` worker's) — vote-to-kick ballots older than 5 minutes. The table
is append-only; a vote stays open for 60 seconds and the caller cooldown looks back 5
minutes, so nothing reads an older ballot.
- `room_invite` (the `match` worker's) — game invites older than 5 minutes
(`ROOM_INVITE_TTL_SECONDS`). Expiry IS the deletion: a redeem that misses the row answers
`RoomInviteExpired`, so the invite prompt goes stale rather than becoming a permanent
key into wherever the inviter is later.
The `mono` facade fires this sweep from its own trigger alongside `match`'s presence sweep.
## Development
+13 -6
View File
@@ -15,6 +15,7 @@ import {
countRoomsByCreator,
createNotification,
createSubRoom,
deleteExpiredRoomInvites,
deleteRoom,
deleteRoomLeaderboard,
deleteSubRoom,
@@ -4218,21 +4219,27 @@ async function deleteStaleRoomVotes(db: D1Database, now = Date.now()): Promise<n
return res.meta.changes ?? 0
}
/** Cron: sweep `room_vote` ballots older than {@link ROOM_VOTE_RETENTION_MS}. */
async function sweepStaleRoomVotes(env: Env): Promise<void> {
const removed = await deleteStaleRoomVotes(env.DB)
/**
* Cron: sweep the short-lived rows on the shared database — `room_vote` ballots older than
* {@link ROOM_VOTE_RETENTION_MS} and `room_invite` rows older than
* `ROOM_INVITE_TTL_SECONDS` (both 5 minutes). Neither table is this worker's — `api` and
* `match` own them — but both are room housekeeping, so it runs here.
*/
async function sweepStaleRows(env: Env): Promise<void> {
const votes = await deleteStaleRoomVotes(env.DB)
const invites = await deleteExpiredRoomInvites(env.DB)
// 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(`room_vote sweep: removed ${removed} stale ballots`)
console.log(`room sweep: removed ${votes} stale ballots, ${invites} expired invites`)
}
// The HTTP surface is a standard Hono app, exported by name so it can be mounted
// uniformly like every other worker (e.g. by the `mono` facade). The cron that sweeps
// stale vote-to-kick ballots is exported alongside it.
// stale ballots and expired invites is exported alongside it.
export { app }
export const scheduled: ExportedHandlerScheduledHandler<Env> = (_controller, env, ctx) => {
ctx.waitUntil(sweepStaleRoomVotes(env))
ctx.waitUntil(sweepStaleRows(env))
}
// Standalone entry: a Worker only runs `scheduled` when it's on the default export,
+38 -3
View File
@@ -16,6 +16,7 @@ import {
NOTIFICATION_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_INVITE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
@@ -115,8 +116,10 @@ beforeAll(async () => {
// The audit log the staff takedown writes to. Owned by `api`'s migrations; built here
// directly, the way `notify`'s tests do.
for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Vote-to-kick ballots (owned by `api`) — the cron here sweeps the stale ones.
// Vote-to-kick ballots (owned by `api`) and game invites (owned by `match`) — the cron
// here sweeps the stale ones.
for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
@@ -5333,7 +5336,7 @@ describe('room name validation', () => {
})
})
describe('room_vote sweep cron', () => {
describe('sweep cron', () => {
async function castBallot(voterId: number, agoMs: number, init = 0): Promise<void> {
await env.DB.prepare(
`INSERT INTO room_vote (game_session_id, player_id, response, voter_id, voted_at, init)
@@ -5369,11 +5372,43 @@ describe('room_vote sweep cron', () => {
expect(await voterIds()).toEqual([3, 4, 5])
})
it('is a no-op on an empty table', async () => {
async function sendInvite(fromPlayerId: number, agoSeconds: number): Promise<void> {
await env.DB.prepare(
`INSERT INTO room_invite (from_player_id, to_player_id, room_id, created_at)
VALUES (?1, 790, 823, ?2)`
)
.bind(fromPlayerId, Math.floor(Date.now() / 1000) - agoSeconds)
.run()
}
async function inviterIds(): Promise<number[]> {
const rows = await env.DB.prepare(
'SELECT from_player_id FROM room_invite ORDER BY from_player_id'
).all<{ from_player_id: number }>()
return rows.results.map((r) => r.from_player_id)
}
it('deletes invites older than 5 minutes and keeps the rest', async () => {
await env.DB.prepare('DELETE FROM room_invite').run()
await sendInvite(1, 6 * 60)
await sendInvite(2, 5 * 60 + 1)
await sendInvite(3, 4 * 60 + 30)
await sendInvite(4, 0)
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
expect(await inviterIds()).toEqual([3, 4])
})
it('is a no-op on empty tables', async () => {
await env.DB.prepare('DELETE FROM room_vote').run()
await env.DB.prepare('DELETE FROM room_invite').run()
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
expect(await voterIds()).toEqual([])
expect(await inviterIds()).toEqual([])
})
})
+5 -3
View File
@@ -49,9 +49,11 @@
"secret_name": "JWT_SECRET"
}
],
// Vote-to-kick sweep. `room_vote` (owned by `api`) is append-only and a ballot
// matters for at most 5 minutes — a vote is open for 60s and the caller cooldown
// looks back 5 minutes — so the cron drops everything older. Every 5 minutes.
// Housekeeping sweep, every 5 minutes. Drops `room_vote` ballots (owned by `api`,
// append-only; a vote is open for 60s and the caller cooldown looks back 5 minutes,
// so nothing reads an older ballot) and `room_invite` rows (owned by `match`; an
// invite is redeemable for 5 minutes, after which the missing row IS the expiry)
// older than 5 minutes.
"triggers": {
"crons": ["*/5 * * * *"]
},