mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[rooms] clean up old invites/votes
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 * * * *"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user