[match] improve presence delete on expiry/logout

This commit is contained in:
Devin Zuczek
2026-10-08 10:37:18 -04:00
parent e54224dbd0
commit 1415ebcc06
3 changed files with 89 additions and 12 deletions
+28 -12
View File
@@ -16,6 +16,7 @@ import {
GAME_VERSION,
getAccount,
getClubSummary,
getExpiredPresenceAccountIds,
getExpiredPresenceInstanceIds,
getFriendIds,
getJoinableInstance,
@@ -565,19 +566,22 @@ function presenceUpdateMessage(
/**
* Push a SubscriptionUpdatePresence to every online friend of `playerId` after their
* presence changes (they entered a room). Mirrors the reference's PlayerPresenceChanged:
* only currently-connected friends receive it (an offline friend gets nothing, not a
* queued stale frame), so it's an ephemeral batch send. The room instance the friends
* see is read from the player's stored presence — the authoritative record just written,
* the same one the heartbeat replays. Best-effort: a hub or lookup failure is logged and
* swallowed, so it never fails the matchmake that triggered it.
* presence changes (they entered a room, logged out, or lapsed). Mirrors the reference's
* PlayerPresenceChanged: only currently-connected friends receive it (an offline friend
* gets nothing, not a queued stale frame), so it's an ephemeral batch send. The room
* instance the friends see is read from the player's stored presence — the authoritative
* record just written, the same one the heartbeat replays; with the row gone (logout, or
* the cron sweep purging a lapsed row) the read is null and the frame is the offline
* snapshot (roomInstance null, isOnline false). Takes `Env` rather than a request context
* so the sweep, which has no request, sends the same frame. Best-effort: a hub or lookup
* failure is logged and swallowed, so it never fails the matchmake that triggered it.
*/
async function notifyFriendsPresence(c: Context<App>, playerId: number): Promise<void> {
async function notifyFriendsPresence(env: Env, playerId: number): Promise<void> {
try {
const friendIds = await getFriendIds(c.env.DB, playerId)
const friendIds = await getFriendIds(env.DB, playerId)
if (friendIds.length === 0) return
const presence = await getPresence<RoomInstance>(c.env.DB, playerId)
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
const presence = await getPresence<RoomInstance>(env.DB, playerId)
await env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
friendIds,
NotificationType.SubscriptionUpdatePresence,
presenceUpdateMessage(playerId, presence?.roomInstance ?? null, presence?.appVersion)
@@ -649,7 +653,7 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
// The player's presence changed — tell their online friends where they went, reading
// the instance back from the presence we just stored. Best-effort; never blocks or
// fails the matchmake.
await notifyFriendsPresence(c, id)
await notifyFriendsPresence(c.env, id)
}
/** Returned when a room isn't in the DB — and for every other opaque refusal. */
@@ -1859,7 +1863,7 @@ const app = new Hono<App>()
// Their presence changed — tell online friends they went offline. Presence
// is already cleared, so notifyFriendsPresence reads null and sends the
// offline snapshot (roomInstance null, isOnline false).
await notifyFriendsPresence(c, id)
await notifyFriendsPresence(c.env, id)
}
}
return c.body(null, 200)
@@ -3506,14 +3510,26 @@ const app = new Hono<App>()
* 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.
*
* A lapsed row is also the player going offline, exactly as a logout is — the client just
* never got to post one (it crashed, or lost its connection). So the sweep tells each
* lapsed player's online friends what `POST /player/logout` would have: the offline
* SubscriptionUpdatePresence. Without it a crashed player's friends kept seeing them
* online until their own client re-read the friends list. The account ids are read
* before the purge (the rows are what remembers who lapsed) and the frames sent after
* it, so `notifyFriendsPresence` reads no presence and sends the offline snapshot.
*/
async function sweepExpiredPresence(env: Env): Promise<void> {
const staleInstanceIds = await getExpiredPresenceInstanceIds(env.DB)
const lapsedPlayerIds = await getExpiredPresenceAccountIds(env.DB)
const removed = await deleteExpiredPresence(env.DB)
const emptyInstanceIds = await deleteEmptyRoomInstances(env.DB)
for (const instanceId of staleInstanceIds) {
await refreshInstanceFullness(env.DB, instanceId)
}
for (const playerId of lapsedPlayerIds) {
await notifyFriendsPresence(env, playerId)
}
// Sample the player count into `stat` — taken after the purge, so it's the live
// rows and not the ones that just lapsed. One row per cron run: the `online` series.
const online = await countOnlinePlayers(env.DB)
@@ -2161,6 +2161,48 @@ describe('auth-gated endpoints', () => {
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
})
test('the cron sweep tells a lapsed player’s friends they went offline, as a logout does', async () => {
type Batch = {
playerIds: number[]
notificationType: number | string
data: { playerId: number; isOnline: boolean; roomInstance: Record<string, unknown> | null }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Batch[]> =>
(await (await hub().fetch('http://do/all')).json()) as Batch[]
// 825 is friends with 826; 827 has no friends and lapses alongside.
await env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
.bind(825, 826, 3)
.run()
await seedPresence(825, nowSeconds() - 10)
await seedPresence(827, nowSeconds() - 10)
await hub().fetch('http://do/all', { method: 'DELETE' })
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
// Exactly one frame: the friend of the lapsed player, carrying the offline
// snapshot — the same frame `POST /player/logout` sends. The friendless player
// produces none.
const batch = await sent()
expect(batch).toHaveLength(1)
expect(batch[0]!.playerIds).toEqual([826])
expect(batch[0]!.notificationType).toBe('PresenceUpdate') // SubscriptionUpdatePresence
expect(batch[0]!.data).toMatchObject({ playerId: 825, isOnline: false, roomInstance: null })
expect(await countPresenceRows(825)).toBe(0)
// A second sweep has nothing lapsed → nothing sent.
await hub().fetch('http://do/all', { method: 'DELETE' })
const ctx2 = createExecutionContext()
await scheduled(createScheduledController(), env, ctx2)
await waitOnExecutionContext(ctx2)
expect(await sent()).toEqual([])
})
test('records an `online` stat sample of the live presence count on each run', async () => {
await env.DB.prepare('DELETE FROM stat').run()
const before = (await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE expires_at > ?1')