mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
remove empty instances
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
|||||||
areFriends,
|
areFriends,
|
||||||
canManageRoom,
|
canManageRoom,
|
||||||
createRoomInstance,
|
createRoomInstance,
|
||||||
|
deleteEmptyRoomInstances,
|
||||||
deleteExpiredPresence,
|
deleteExpiredPresence,
|
||||||
deletePresence,
|
deletePresence,
|
||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
@@ -1475,24 +1476,33 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cron: sweep presence that has aged past its TTL. Reads already ignore expired rows,
|
* Cron: sweep presence that has aged past its TTL, then the instances left empty.
|
||||||
* 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
|
* The presence purge isn't about correctness of `/player` — reads already ignore
|
||||||
* instance's fullness and it can stay flagged full (and unjoinable) with nobody in it.
|
* expired rows. It's that a player who crashed or hard-quit never matchmakes out of
|
||||||
* Recompute the instances the expiring rows point at, *then* delete: the sweep is the
|
* their instance, so nothing recomputes that instance's fullness and it can stay
|
||||||
* only thing that notices those departures. Fullness is recomputed after the delete so
|
* flagged full (and unjoinable) with nobody in it. Note the instances the expiring
|
||||||
* the head-count no longer sees them.
|
* rows point at *before* deleting: the sweep is the only thing that notices those
|
||||||
|
* departures.
|
||||||
|
*
|
||||||
|
* Emptying an instance is what makes it garbage — nothing ever reuses it, and a
|
||||||
|
* joiner handed one would land alone in a Photon room everyone left — so the empty
|
||||||
|
* sweep runs next. It reads presence without consulting expiry, so it depends on
|
||||||
|
* running after the purge above: this order is what makes a lapsed row count as a
|
||||||
|
* departure. Fullness is recomputed last, so it works from the final head-count and
|
||||||
|
* skips (returns null for) the instances just deleted.
|
||||||
*/
|
*/
|
||||||
async function sweepExpiredPresence(env: Env): Promise<void> {
|
async function sweepExpiredPresence(env: Env): Promise<void> {
|
||||||
const staleInstanceIds = await getExpiredPresenceInstanceIds(env.DB)
|
const staleInstanceIds = await getExpiredPresenceInstanceIds(env.DB)
|
||||||
const removed = await deleteExpiredPresence(env.DB)
|
const removed = await deleteExpiredPresence(env.DB)
|
||||||
|
const emptyInstanceIds = await deleteEmptyRoomInstances(env.DB)
|
||||||
for (const instanceId of staleInstanceIds) {
|
for (const instanceId of staleInstanceIds) {
|
||||||
await refreshInstanceFullness(env.DB, instanceId)
|
await refreshInstanceFullness(env.DB, instanceId)
|
||||||
}
|
}
|
||||||
// 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(
|
console.log(
|
||||||
`presence sweep: removed ${removed} expired rows, refreshed ${staleInstanceIds.length} instances`
|
`presence sweep: removed ${removed} expired rows, deleted ${emptyInstanceIds.length} empty instances, refreshed ${staleInstanceIds.length} instances`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1513,7 +1523,8 @@ app.get(
|
|||||||
'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a',
|
'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a',
|
||||||
'`room_instance` per session); presence — the instance each player is currently in —',
|
'`room_instance` per session); presence — the instance each player is currently in —',
|
||||||
'lives in the shared `presence` table and expires on a TTL. A cron sweep clears',
|
'lives in the shared `presence` table and expires on a TTL. A cron sweep clears',
|
||||||
'expired presence and frees up instances a crashed player never left.',
|
'expired presence, frees up instances a crashed player never left, and deletes',
|
||||||
|
'instances nobody is standing in any more.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
},
|
},
|
||||||
servers: [{ url: 'https://match.recflare.net', description: 'Production' }],
|
servers: [{ url: 'https://match.recflare.net', description: 'Production' }],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
countPlayersInInstance,
|
countPlayersInInstance,
|
||||||
createRoomInstance,
|
createRoomInstance,
|
||||||
|
EMPTY_INSTANCE_GRACE_SECONDS,
|
||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
getRoomInstance,
|
getRoomInstance,
|
||||||
PRESENCE_SCHEMA_DDL,
|
PRESENCE_SCHEMA_DDL,
|
||||||
@@ -731,14 +732,15 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(await stale.text()).toBe('')
|
expect(await stale.text()).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Seed presence directly into D1 with a chosen `expiresAt` (epoch seconds) so the
|
// Seed presence directly into D1 with a chosen instance and `expiresAt` (epoch
|
||||||
// TTL-refresh branch can be exercised deterministically (independent of timing).
|
// seconds), so the TTL branches can be exercised deterministically (independent of
|
||||||
const seedPresence = (id: number, expiresAt: number) =>
|
// timing) and a player can be planted in an instance without matchmaking there.
|
||||||
|
const seedPresenceInInstance = (id: number, roomInstanceId: number, expiresAt: number) =>
|
||||||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||||
.bind(
|
.bind(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
accountId: id,
|
accountId: id,
|
||||||
roomInstance: { roomInstanceId: 1000042, roomId: 1 },
|
roomInstance: { roomInstanceId, roomId: 1 },
|
||||||
statusVisibility: 0,
|
statusVisibility: 0,
|
||||||
deviceClass: 0,
|
deviceClass: 0,
|
||||||
vrMovementMode: 1,
|
vrMovementMode: 1,
|
||||||
@@ -749,6 +751,9 @@ describe('auth-gated endpoints', () => {
|
|||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
|
|
||||||
|
const seedPresence = (id: number, expiresAt: number) =>
|
||||||
|
seedPresenceInInstance(id, 1000042, expiresAt)
|
||||||
|
|
||||||
const storedExpiresAt = async (id: number): Promise<number> => {
|
const storedExpiresAt = async (id: number): Promise<number> => {
|
||||||
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -792,24 +797,9 @@ describe('auth-gated endpoints', () => {
|
|||||||
|
|
||||||
test('countPlayersInInstance counts live players in a room instance (excludes expired)', async () => {
|
test('countPlayersInInstance counts live players in a room instance (excludes expired)', async () => {
|
||||||
// Three players in instance 1000099 — two live, one expired.
|
// Three players in instance 1000099 — two live, one expired.
|
||||||
const seedInInstance = (id: number, expiresAt: number) =>
|
await seedPresenceInInstance(710, 1000099, nowSeconds() + 800)
|
||||||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
await seedPresenceInInstance(711, 1000099, nowSeconds() + 800)
|
||||||
.bind(
|
await seedPresenceInInstance(712, 1000099, nowSeconds() - 10) // expired → not counted
|
||||||
JSON.stringify({
|
|
||||||
accountId: id,
|
|
||||||
roomInstance: { roomInstanceId: 1000099, roomId: 2 },
|
|
||||||
statusVisibility: 0,
|
|
||||||
deviceClass: 0,
|
|
||||||
vrMovementMode: 1,
|
|
||||||
platform: 0,
|
|
||||||
appVersion: GAME_VERSION,
|
|
||||||
expiresAt,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.run()
|
|
||||||
await seedInInstance(710, nowSeconds() + 800)
|
|
||||||
await seedInInstance(711, nowSeconds() + 800)
|
|
||||||
await seedInInstance(712, nowSeconds() - 10) // already expired → not counted
|
|
||||||
expect(await countPlayersInInstance(env.DB, 1000099)).toBe(2)
|
expect(await countPlayersInInstance(env.DB, 1000099)).toBe(2)
|
||||||
expect(await countPlayersInInstance(env.DB, 999999)).toBe(0)
|
expect(await countPlayersInInstance(env.DB, 999999)).toBe(0)
|
||||||
})
|
})
|
||||||
@@ -872,6 +862,89 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
|
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Age an instance past EMPTY_INSTANCE_GRACE_SECONDS by backdating its `createdAt`
|
||||||
|
// (the generated `created_at` column follows the blob), so the empty-instance sweep
|
||||||
|
// can be exercised without waiting out the grace window.
|
||||||
|
const backdateInstance = (id: number, secondsAgo = EMPTY_INSTANCE_GRACE_SECONDS + 60) =>
|
||||||
|
env.DB.prepare(
|
||||||
|
"UPDATE room_instance SET data = json_set(data, '$.createdAt', ?2) WHERE id = ?1"
|
||||||
|
)
|
||||||
|
.bind(id, new Date(Date.now() - secondsAgo * 1000).toISOString())
|
||||||
|
.run()
|
||||||
|
|
||||||
|
const expirePresence = (accountId: number) =>
|
||||||
|
env.DB.prepare(
|
||||||
|
"UPDATE presence SET data = json_set(data, '$.expiresAt', ?2) WHERE account_id = ?1"
|
||||||
|
)
|
||||||
|
.bind(accountId, nowSeconds() - 10)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
test('the cron sweep deletes instances nobody is left standing in', async () => {
|
||||||
|
// Two instances built directly rather than by matchmaking, so neither is one a
|
||||||
|
// previous test's player is still standing in (public matchmakes reuse instances).
|
||||||
|
// One holds a player who crashed out — an expired row the sweep purges first,
|
||||||
|
// leaving the instance empty — the other a live player.
|
||||||
|
const abandoned = await createRoomInstance(env.DB, {
|
||||||
|
ownerAccountId: 830,
|
||||||
|
roomId: 2,
|
||||||
|
photonRoomId: 'abandoned-instance',
|
||||||
|
maxCapacity: 12,
|
||||||
|
})
|
||||||
|
await seedPresenceInInstance(830, abandoned.roomInstanceId, nowSeconds() - 10)
|
||||||
|
const occupied = await createRoomInstance(env.DB, {
|
||||||
|
ownerAccountId: 831,
|
||||||
|
roomId: 2,
|
||||||
|
photonRoomId: 'occupied-instance',
|
||||||
|
maxCapacity: 12,
|
||||||
|
})
|
||||||
|
await seedPresenceInInstance(831, occupied.roomInstanceId, nowSeconds() + 800)
|
||||||
|
await backdateInstance(abandoned.roomInstanceId)
|
||||||
|
await backdateInstance(occupied.roomInstanceId)
|
||||||
|
|
||||||
|
const ctx = createExecutionContext()
|
||||||
|
await scheduled(createScheduledController(), env, ctx)
|
||||||
|
await waitOnExecutionContext(ctx)
|
||||||
|
|
||||||
|
expect(await getRoomInstance(env.DB, abandoned.roomInstanceId)).toBeNull()
|
||||||
|
expect(await getRoomInstance(env.DB, occupied.roomInstanceId)).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the cron sweep spares a freshly created instance nobody has joined yet', async () => {
|
||||||
|
// The instance and its creator's presence are written by the same request but not
|
||||||
|
// atomically — a sweep landing in between must not delete the instance the player
|
||||||
|
// is being handed. `createdAt` is left alone, so it's inside the grace window.
|
||||||
|
const fresh = await createRoomInstance(env.DB, {
|
||||||
|
ownerAccountId: 832,
|
||||||
|
roomId: 2,
|
||||||
|
photonRoomId: 'fresh-instance',
|
||||||
|
maxCapacity: 12,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx = createExecutionContext()
|
||||||
|
await scheduled(createScheduledController(), env, ctx)
|
||||||
|
await waitOnExecutionContext(ctx)
|
||||||
|
|
||||||
|
expect(await getRoomInstance(env.DB, fresh.roomInstanceId)).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the cron sweep spares an empty dorm instance', async () => {
|
||||||
|
// A dorm is backed by one persistent instance so its Photon room id survives
|
||||||
|
// re-entry — it sits empty whenever the owner is anywhere else.
|
||||||
|
const headers = await bearer('833')
|
||||||
|
const dorm = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
||||||
|
).json()) as { roomInstance: { roomInstanceId: number } }
|
||||||
|
const dormInstanceId = dorm.roomInstance.roomInstanceId
|
||||||
|
await expirePresence(833)
|
||||||
|
await backdateInstance(dormInstanceId)
|
||||||
|
|
||||||
|
const ctx = createExecutionContext()
|
||||||
|
await scheduled(createScheduledController(), env, ctx)
|
||||||
|
await waitOnExecutionContext(ctx)
|
||||||
|
|
||||||
|
expect(await getRoomInstance(env.DB, dormInstanceId)).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
test('player/login and exclusivelogin preserve presence', async () => {
|
test('player/login and exclusivelogin 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 })
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
// Presence sweep. Rows expire on their own TTL (15m) and reads already ignore
|
// 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
|
// expired ones, so this is housekeeping: it purges them, deletes the room
|
||||||
// fullness of the instances the departed players were in (a crashed player never
|
// instances left with nobody in them, and recomputes the fullness of the
|
||||||
// matchmakes out, so nothing else notices they left). Every 5 minutes.
|
// instances the departed players were in (a crashed player never matchmakes out,
|
||||||
|
// so nothing else notices they left). Every 5 minutes.
|
||||||
"triggers": {
|
"triggers": {
|
||||||
"crons": ["*/5 * * * *"]
|
"crons": ["*/5 * * * *"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* the client DTO (`toDto`).
|
* the client DTO (`toDto`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { RoomInstanceType } from './enums'
|
||||||
import { countPlayersInInstance, getPlayerIdsByRoomInstance } from './presence-db'
|
import { countPlayersInInstance, getPlayerIdsByRoomInstance } from './presence-db'
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
|
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
|
||||||
@@ -282,6 +283,56 @@ export async function refreshInstanceFullness(
|
|||||||
return isFull
|
return isFull
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long (s) a room instance is left alone after it's created, even with nobody in
|
||||||
|
* it. Every path that creates an instance writes the creator's presence in the same
|
||||||
|
* request, so an empty instance is normally already abandoned — but the two writes
|
||||||
|
* aren't atomic, and a cron firing in between would delete the instance the player is
|
||||||
|
* being handed. One cron interval of slack closes that window.
|
||||||
|
*/
|
||||||
|
export const EMPTY_INSTANCE_GRACE_SECONDS = 300
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete room instances with no presence rows pointing at them — the sessions left
|
||||||
|
* behind when every player quit or timed out. Nothing reuses them (matchmaking would
|
||||||
|
* happily hand a joiner an instance whose Photon room has long since emptied), so
|
||||||
|
* they're pure accumulation: one row per room visit, forever.
|
||||||
|
*
|
||||||
|
* Emptiness is a plain "are there any rows" test — expiry is not consulted, because
|
||||||
|
* {@link deleteExpiredPresence} is what retires a lapsed row and must have run first.
|
||||||
|
* Run out of that order and a crashed player's stale row keeps their instance alive
|
||||||
|
* until the following sweep.
|
||||||
|
*
|
||||||
|
* Instances younger than `graceSeconds` are skipped (see
|
||||||
|
* {@link EMPTY_INSTANCE_GRACE_SECONDS}), as are dorms: a dorm is backed by one
|
||||||
|
* persistent instance so its Photon room id survives re-entry, and it sits empty
|
||||||
|
* whenever the owner is elsewhere.
|
||||||
|
*
|
||||||
|
* Returns the ids deleted.
|
||||||
|
*/
|
||||||
|
export async function deleteEmptyRoomInstances(
|
||||||
|
db: D1Database,
|
||||||
|
graceSeconds = EMPTY_INSTANCE_GRACE_SECONDS,
|
||||||
|
now = Date.now()
|
||||||
|
): Promise<number[]> {
|
||||||
|
// `createdAt` is an ISO-8601 UTC timestamp, which sorts lexicographically in the
|
||||||
|
// same order it sorts chronologically — so a string compare is a time compare.
|
||||||
|
const createdBefore = new Date(now - graceSeconds * 1000).toISOString()
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM room_instance
|
||||||
|
WHERE created_at < ?1
|
||||||
|
AND room_instance_type != ?2
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM presence WHERE presence.room_instance_id = room_instance.id
|
||||||
|
)
|
||||||
|
RETURNING json_extract(data, '$.roomInstanceId') AS id`
|
||||||
|
)
|
||||||
|
.bind(createdBefore, RoomInstanceType.Dormroom)
|
||||||
|
.all<{ id: number }>()
|
||||||
|
return results.map((r) => r.id)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The oldest joinable public instance of a room (not private, not full, joins
|
* The oldest joinable public instance of a room (not private, not full, joins
|
||||||
* enabled, not already in progress), or null when there's none to join. Used by
|
* enabled, not already in progress), or null when there's none to join. Used by
|
||||||
|
|||||||
Reference in New Issue
Block a user