From 8ef8f9af505a6164b94b1935ca40696c9de04d59 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 22 Sep 2026 19:14:36 -0400 Subject: [PATCH] [rooms] clean up old invites/votes --- apps/match/README.md | 5 ++- apps/mono/src/mono.app.ts | 2 +- apps/mono/wrangler.jsonc | 4 +- apps/rooms/README.md | 14 +++++-- apps/rooms/src/rooms.app.ts | 19 +++++++--- apps/rooms/src/test/integration/api.test.ts | 41 +++++++++++++++++++-- apps/rooms/wrangler.jsonc | 8 ++-- packages/domain/src/room-invites-db.ts | 26 +++++++++++++ 8 files changed, 99 insertions(+), 20 deletions(-) diff --git a/apps/match/README.md b/apps/match/README.md index 09425bc..cd100f0 100644 --- a/apps/match/README.md +++ b/apps/match/README.md @@ -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 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 -invite the `RoomInviteId` the response carries. Its `created_at` is epoch seconds, so old -invites can be swept later. +invite the `RoomInviteId` the response carries. Its `created_at` is epoch seconds; the +`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 key in it, the "avoid juniors" preference, and its write merges (as that worker's own PUT diff --git a/apps/mono/src/mono.app.ts b/apps/mono/src/mono.app.ts index 155e293..addc280 100644 --- a/apps/mono/src/mono.app.ts +++ b/apps/mono/src/mono.app.ts @@ -106,7 +106,7 @@ export default { 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. // Each schedules its work on `ctx.waitUntil`, so the runtime waits for both. scheduled( diff --git a/apps/mono/wrangler.jsonc b/apps/mono/wrangler.jsonc index 6124c6b..df27ce2 100644 --- a/apps/mono/wrangler.jsonc +++ b/apps/mono/wrangler.jsonc @@ -45,8 +45,8 @@ "bindings": [{ "name": "RECFLARE_NOTIFICATIONS_HUB", "class_name": "NotificationsHub" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["NotificationsHub"] }], - // Presence sweep (owned by match) and the stale vote-to-kick ballot sweep (owned by - // rooms) — both `*/5 * * * *`, so one trigger fires both. Every 5 minutes. + // Presence sweep (owned by match) and the stale ballot / expired invite sweep (owned + // by rooms) — both `*/5 * * * *`, so one trigger fires both. Every 5 minutes. "triggers": { "crons": ["*/5 * * * *"] }, diff --git a/apps/rooms/README.md b/apps/rooms/README.md index b6468ca..e887aaa 100644 --- a/apps/rooms/README.md +++ b/apps/rooms/README.md @@ -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 diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 983e002..b06ee89 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -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 { - 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 { + 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 = (_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, diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 2b4f418..b7d163c 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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) @@ -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 { 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 { + 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 { + 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([]) }) }) diff --git a/apps/rooms/wrangler.jsonc b/apps/rooms/wrangler.jsonc index 7cf043f..d997082 100644 --- a/apps/rooms/wrangler.jsonc +++ b/apps/rooms/wrangler.jsonc @@ -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 * * * *"] }, diff --git a/packages/domain/src/room-invites-db.ts b/packages/domain/src/room-invites-db.ts index 1fc11bc..bf2c3f3 100644 --- a/packages/domain/src/room-invites-db.ts +++ b/packages/domain/src/room-invites-db.ts @@ -13,6 +13,10 @@ * (`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). * `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). */ @@ -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) +/** + * 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. * @@ -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 { + 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. *