mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[match] improve presence delete on expiry/logout
This commit is contained in:
+28
-12
@@ -16,6 +16,7 @@ import {
|
|||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
getAccount,
|
getAccount,
|
||||||
getClubSummary,
|
getClubSummary,
|
||||||
|
getExpiredPresenceAccountIds,
|
||||||
getExpiredPresenceInstanceIds,
|
getExpiredPresenceInstanceIds,
|
||||||
getFriendIds,
|
getFriendIds,
|
||||||
getJoinableInstance,
|
getJoinableInstance,
|
||||||
@@ -565,19 +566,22 @@ function presenceUpdateMessage(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Push a SubscriptionUpdatePresence to every online friend of `playerId` after their
|
* Push a SubscriptionUpdatePresence to every online friend of `playerId` after their
|
||||||
* presence changes (they entered a room). Mirrors the reference's PlayerPresenceChanged:
|
* presence changes (they entered a room, logged out, or lapsed). Mirrors the reference's
|
||||||
* only currently-connected friends receive it (an offline friend gets nothing, not a
|
* PlayerPresenceChanged: only currently-connected friends receive it (an offline friend
|
||||||
* queued stale frame), so it's an ephemeral batch send. The room instance the friends
|
* gets nothing, not a queued stale frame), so it's an ephemeral batch send. The room
|
||||||
* see is read from the player's stored presence — the authoritative record just written,
|
* instance the friends see is read from the player's stored presence — the authoritative
|
||||||
* the same one the heartbeat replays. Best-effort: a hub or lookup failure is logged and
|
* record just written, the same one the heartbeat replays; with the row gone (logout, or
|
||||||
* swallowed, so it never fails the matchmake that triggered it.
|
* 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 {
|
try {
|
||||||
const friendIds = await getFriendIds(c.env.DB, playerId)
|
const friendIds = await getFriendIds(env.DB, playerId)
|
||||||
if (friendIds.length === 0) return
|
if (friendIds.length === 0) return
|
||||||
const presence = await getPresence<RoomInstance>(c.env.DB, playerId)
|
const presence = await getPresence<RoomInstance>(env.DB, playerId)
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
|
await env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
|
||||||
friendIds,
|
friendIds,
|
||||||
NotificationType.SubscriptionUpdatePresence,
|
NotificationType.SubscriptionUpdatePresence,
|
||||||
presenceUpdateMessage(playerId, presence?.roomInstance ?? null, presence?.appVersion)
|
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 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
|
// the instance back from the presence we just stored. Best-effort; never blocks or
|
||||||
// fails the matchmake.
|
// 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. */
|
/** 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
|
// Their presence changed — tell online friends they went offline. Presence
|
||||||
// is already cleared, so notifyFriendsPresence reads null and sends the
|
// is already cleared, so notifyFriendsPresence reads null and sends the
|
||||||
// offline snapshot (roomInstance null, isOnline false).
|
// offline snapshot (roomInstance null, isOnline false).
|
||||||
await notifyFriendsPresence(c, id)
|
await notifyFriendsPresence(c.env, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return c.body(null, 200)
|
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
|
* 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
|
* departure. Fullness is recomputed last, so it works from the final head-count and
|
||||||
* skips (returns null for) the instances just deleted.
|
* 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> {
|
async function sweepExpiredPresence(env: Env): Promise<void> {
|
||||||
const staleInstanceIds = await getExpiredPresenceInstanceIds(env.DB)
|
const staleInstanceIds = await getExpiredPresenceInstanceIds(env.DB)
|
||||||
|
const lapsedPlayerIds = await getExpiredPresenceAccountIds(env.DB)
|
||||||
const removed = await deleteExpiredPresence(env.DB)
|
const removed = await deleteExpiredPresence(env.DB)
|
||||||
const emptyInstanceIds = await deleteEmptyRoomInstances(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)
|
||||||
}
|
}
|
||||||
|
for (const playerId of lapsedPlayerIds) {
|
||||||
|
await notifyFriendsPresence(env, playerId)
|
||||||
|
}
|
||||||
// Sample the player count into `stat` — taken after the purge, so it's the live
|
// 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.
|
// rows and not the ones that just lapsed. One row per cron run: the `online` series.
|
||||||
const online = await countOnlinePlayers(env.DB)
|
const online = await countOnlinePlayers(env.DB)
|
||||||
|
|||||||
@@ -2161,6 +2161,48 @@ 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 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 () => {
|
test('records an `online` stat sample of the live presence count on each run', async () => {
|
||||||
await env.DB.prepare('DELETE FROM stat').run()
|
await env.DB.prepare('DELETE FROM stat').run()
|
||||||
const before = (await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE expires_at > ?1')
|
const before = (await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE expires_at > ?1')
|
||||||
|
|||||||
@@ -373,6 +373,25 @@ export async function getExpiredPresenceInstanceIds(
|
|||||||
return results.map((r) => r.id)
|
return results.map((r) => r.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The players whose presence rows have expired — the players about to be purged by
|
||||||
|
* {@link deleteExpiredPresence}. Read BEFORE the purge: once the rows are gone nothing
|
||||||
|
* remembers who lapsed, and their friends are owed the same offline
|
||||||
|
* SubscriptionUpdatePresence a logout sends (the lapse IS their logout, just one the
|
||||||
|
* client never got to post). Includes lobby (null-instance) presence — a player idle
|
||||||
|
* in the lobby who stops heartbeating has gone offline too.
|
||||||
|
*/
|
||||||
|
export async function getExpiredPresenceAccountIds(
|
||||||
|
db: D1Database,
|
||||||
|
now = nowSeconds()
|
||||||
|
): Promise<number[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare('SELECT account_id AS id FROM presence WHERE expires_at <= ?1 ORDER BY account_id')
|
||||||
|
.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