mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
expire old presences
This commit is contained in:
@@ -3,6 +3,8 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createRoomInstance,
|
createRoomInstance,
|
||||||
|
deleteExpiredPresence,
|
||||||
|
getExpiredPresenceInstanceIds,
|
||||||
getJoinableInstance,
|
getJoinableInstance,
|
||||||
getOrCreateDormRoom,
|
getOrCreateDormRoom,
|
||||||
getPresence,
|
getPresence,
|
||||||
@@ -20,7 +22,7 @@ import { validateAndGetAccountId } from '@repo/jwt'
|
|||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { Room, StoredPresence } from '@repo/domain'
|
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
|
* 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.
|
// Rooms flagged as requiring an RR+ subscription. No such queue yet → empty list.
|
||||||
.get('/rooms/requiring/rrplus', (c) => c.json([]))
|
.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 { exports } from 'cloudflare:workers'
|
||||||
import { beforeAll, describe, expect, test } from 'vitest'
|
import { beforeAll, describe, expect, test } from 'vitest'
|
||||||
|
|
||||||
@@ -9,7 +15,7 @@ import {
|
|||||||
ROOM_INSTANCE_SCHEMA_DDL,
|
ROOM_INSTANCE_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
import '../../match.app'
|
import worker from '../../match.app'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
@@ -580,6 +586,14 @@ describe('auth-gated endpoints', () => {
|
|||||||
|
|
||||||
const nowSeconds = () => Math.floor(Date.now() / 1000)
|
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 () => {
|
test('heartbeat refreshes presence when its TTL is close to lapsing', async () => {
|
||||||
// TTL about to lapse (well inside the refresh window).
|
// TTL about to lapse (well inside the refresh window).
|
||||||
const nearExpiry = nowSeconds() + 10
|
const nearExpiry = nowSeconds() + 10
|
||||||
@@ -664,6 +678,28 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
|
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 () => {
|
test('player/login, exclusivelogin and logout all preserve presence', async () => {
|
||||||
const headers = await bearer('9')
|
const headers = await bearer('9')
|
||||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
||||||
|
|||||||
@@ -15,6 +15,13 @@
|
|||||||
"database_id": "local"
|
"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,
|
"logpush": false,
|
||||||
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
// 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"
|
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
||||||
|
|||||||
@@ -137,6 +137,27 @@ export async function countPlayersInInstance(
|
|||||||
return row?.n ?? 0
|
return row?.n ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The room instances that expired presence rows still point at — the instances a
|
||||||
|
* player was in when they stopped heartbeating (a crash or a hard quit, where no
|
||||||
|
* matchmake ever moved them out). Their head-count has really dropped, so callers
|
||||||
|
* purging presence use this to recompute those instances' fullness. Distinct ids,
|
||||||
|
* lobby (null-instance) presence excluded.
|
||||||
|
*/
|
||||||
|
export async function getExpiredPresenceInstanceIds(
|
||||||
|
db: D1Database,
|
||||||
|
now = nowSeconds()
|
||||||
|
): Promise<number[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT room_instance_id AS id FROM presence
|
||||||
|
WHERE expires_at <= ?1 AND room_instance_id IS NOT NULL`
|
||||||
|
)
|
||||||
|
.bind(now)
|
||||||
|
.all<{ id: number }>()
|
||||||
|
return results.map((r) => r.id)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Purge expired presence rows — housekeeping only, since reads already ignore them
|
* Purge expired presence rows — housekeeping only, since reads already ignore them
|
||||||
* (and `INSERT OR REPLACE` keeps a single row per account, so the table is bounded
|
* (and `INSERT OR REPLACE` keeps a single row per account, so the table is bounded
|
||||||
|
|||||||
Reference in New Issue
Block a user