[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
+3 -2
View File
@@ -127,8 +127,9 @@ The `presence` and `room_instance` tables are owned/migrated by the `rooms` work
worker owns one table of its own, `room_invite` (`migrations/0001_room_invite.sql`, applied worker owns one table of its own, `room_invite` (`migrations/0001_room_invite.sql`, applied
under its own `d1_migrations_match` table so it doesn't clash with the other workers under its own `d1_migrations_match` table so it doesn't clash with the other workers
sharing the database): a row per game invite `POST /invite` sends, which is what gives the sharing the database): a row per game invite `POST /invite` sends, which is what gives the
invite the `RoomInviteId` the response carries. Its `created_at` is epoch seconds, so old invite the `RoomInviteId` the response carries. Its `created_at` is epoch seconds; the
invites can be swept later. `rooms` worker's cron deletes rows older than `ROOM_INVITE_TTL_SECONDS` (5 minutes), and a
redeem that misses the row answers `RoomInviteExpired`.
The settings KV is owned by the `playersettings` worker; this worker touches exactly one The settings KV is owned by the `playersettings` worker; this worker touches exactly one
key in it, the "avoid juniors" preference, and its write merges (as that worker's own PUT key in it, the "avoid juniors" preference, and its write merges (as that worker's own PUT
+1 -1
View File
@@ -106,7 +106,7 @@ export default {
return services[resolved.name].fetch(resolved.request, env, ctx) return services[resolved.name].fetch(resolved.request, env, ctx)
}, },
// `match` (presence sweep) and `rooms` (stale vote-to-kick ballots) each run a // `match` (presence sweep) and `rooms` (stale ballots, expired invites) each run a
// `*/5 * * * *` cron in the split deployment; this worker's one trigger fires both. // `*/5 * * * *` cron in the split deployment; this worker's one trigger fires both.
// Each schedules its work on `ctx.waitUntil`, so the runtime waits for both. // Each schedules its work on `ctx.waitUntil`, so the runtime waits for both.
scheduled( scheduled(
+2 -2
View File
@@ -45,8 +45,8 @@
"bindings": [{ "name": "RECFLARE_NOTIFICATIONS_HUB", "class_name": "NotificationsHub" }] "bindings": [{ "name": "RECFLARE_NOTIFICATIONS_HUB", "class_name": "NotificationsHub" }]
}, },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["NotificationsHub"] }], "migrations": [{ "tag": "v1", "new_sqlite_classes": ["NotificationsHub"] }],
// Presence sweep (owned by match) and the stale vote-to-kick ballot sweep (owned by // Presence sweep (owned by match) and the stale ballot / expired invite sweep (owned
// rooms) — both `*/5 * * * *`, so one trigger fires both. Every 5 minutes. // by rooms) — both `*/5 * * * *`, so one trigger fires both. Every 5 minutes.
"triggers": { "triggers": {
"crons": ["*/5 * * * *"] "crons": ["*/5 * * * *"]
}, },
+11 -3
View File
@@ -39,9 +39,17 @@ same number, because the client's paged DTO and the reference disagree on the na
## Cron ## Cron
A `*/5 * * * *` cron (`scheduled`) deletes vote-to-kick ballots in `room_vote` older than A `*/5 * * * *` cron (`scheduled`) sweeps two short-lived tables on the shared database,
5 minutes. The table is the `api` worker's and append-only; a vote stays open for 60 neither of them this worker's own:
seconds and the caller cooldown looks back 5 minutes, so nothing reads an older ballot.
- `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. The `mono` facade fires this sweep from its own trigger alongside `match`'s presence sweep.
## Development ## Development
+13 -6
View File
@@ -15,6 +15,7 @@ import {
countRoomsByCreator, countRoomsByCreator,
createNotification, createNotification,
createSubRoom, createSubRoom,
deleteExpiredRoomInvites,
deleteRoom, deleteRoom,
deleteRoomLeaderboard, deleteRoomLeaderboard,
deleteSubRoom, deleteSubRoom,
@@ -4218,21 +4219,27 @@ async function deleteStaleRoomVotes(db: D1Database, now = Date.now()): Promise<n
return res.meta.changes ?? 0 return res.meta.changes ?? 0
} }
/** Cron: sweep `room_vote` ballots older than {@link ROOM_VOTE_RETENTION_MS}. */ /**
async function sweepStaleRoomVotes(env: Env): Promise<void> { * Cron: sweep the short-lived rows on the shared database — `room_vote` ballots older than
const removed = await deleteStaleRoomVotes(env.DB) * {@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 // 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(`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 // 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 // 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 { app }
export const scheduled: ExportedHandlerScheduledHandler<Env> = (_controller, env, ctx) => { 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, // 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, NOTIFICATION_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL, PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL, ROOM_INSTANCE_SCHEMA_DDL,
ROOM_INVITE_SCHEMA_DDL,
ROOM_SCHEMA_DDL, ROOM_SCHEMA_DDL,
seedRoomWithSubRooms, seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL,
@@ -115,8 +116,10 @@ beforeAll(async () => {
// The audit log the staff takedown writes to. Owned by `api`'s migrations; built here // The audit log the staff takedown writes to. Owned by `api`'s migrations; built here
// directly, the way `notify`'s tests do. // directly, the way `notify`'s tests do.
for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run() 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_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). // 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>) 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> { async function castBallot(voterId: number, agoMs: number, init = 0): Promise<void> {
await env.DB.prepare( await env.DB.prepare(
`INSERT INTO room_vote (game_session_id, player_id, response, voter_id, voted_at, init) `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]) 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_vote').run()
await env.DB.prepare('DELETE FROM room_invite').run()
const ctx = createExecutionContext() const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx) await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx) await waitOnExecutionContext(ctx)
expect(await voterIds()).toEqual([]) expect(await voterIds()).toEqual([])
expect(await inviterIds()).toEqual([])
}) })
}) })
+5 -3
View File
@@ -49,9 +49,11 @@
"secret_name": "JWT_SECRET" "secret_name": "JWT_SECRET"
} }
], ],
// Vote-to-kick sweep. `room_vote` (owned by `api`) is append-only and a ballot // Housekeeping sweep, every 5 minutes. Drops `room_vote` ballots (owned by `api`,
// matters for at most 5 minutes — a vote is open for 60s and the caller cooldown // append-only; a vote is open for 60s and the caller cooldown looks back 5 minutes,
// looks back 5 minutes — so the cron drops everything older. Every 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": { "triggers": {
"crons": ["*/5 * * * *"] "crons": ["*/5 * * * *"]
}, },
+26
View File
@@ -13,6 +13,10 @@
* (`apps/match/migrations/0001_room_invite.sql`, applied under its own `migrations_table` * (`apps/match/migrations/0001_room_invite.sql`, applied under its own `migrations_table`
* so it doesn't clash with the other workers' migrations that share the database). * so it doesn't clash with the other workers' migrations that share the database).
* `ROOM_INVITE_SCHEMA_DDL` mirrors that migration so tests can build the table directly. * `ROOM_INVITE_SCHEMA_DDL` mirrors that migration so tests can build the table directly.
*
* The `rooms` worker's cron expires them: {@link deleteExpiredRoomInvites} drops every row
* older than {@link ROOM_INVITE_TTL_SECONDS}, after which redeeming the invite answers
* `RoomInviteExpired`.
*/ */
/** Schema DDL (mirror of apps/match/migrations/0001_room_invite.sql). */ /** Schema DDL (mirror of apps/match/migrations/0001_room_invite.sql). */
@@ -60,6 +64,12 @@ const SELECT_COLUMNS = `room_invite_id, from_player_id, to_player_id, room_id`
const nowSeconds = () => Math.floor(Date.now() / 1000) const nowSeconds = () => Math.floor(Date.now() / 1000)
/**
* How long an invite stays redeemable (s) after it is sent. The prompt the invitee sees is
* a live socket frame, and this is how long the row behind it survives the `rooms` cron.
*/
export const ROOM_INVITE_TTL_SECONDS = 300
/** /**
* One invite by its id, or null when there is no such row. * One invite by its id, or null when there is no such row.
* *
@@ -148,6 +158,22 @@ export async function createRoomInvite(
} }
} }
/**
* Delete every invite sent more than {@link ROOM_INVITE_TTL_SECONDS} ago. Returns rows
* removed. The `rooms` cron runs this; a redeem that then misses the row answers
* `RoomInviteExpired`, which is the whole of what expiry means here.
*/
export async function deleteExpiredRoomInvites(
db: D1Database,
now = nowSeconds()
): Promise<number> {
const res = await db
.prepare('DELETE FROM room_invite WHERE created_at < ?1')
.bind(now - ROOM_INVITE_TTL_SECONDS)
.run()
return res.meta.changes ?? 0
}
/** /**
* Delete one invite by its id, answering whether a row was there to delete. * Delete one invite by its id, answering whether a row was there to delete.
* *