mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
expire old presences
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user