From 6293c36addcb7ab6049322a41c571d36d0a9239c Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 5 Sep 2026 22:05:43 -0400 Subject: [PATCH] Sleeping rooms stay asleep across a restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idle rooms already went back to their ledgers, but a boot replayed every ledger before the first sweep could put them to bed again — most of a restart's fifteen seconds spent rebuilding games nobody was at. A room put to sleep now leaves a stub beside the ledgers, with the ledger's size at that moment; at boot a stub whose size still matches answers for the room and the ledger stays unreplayed. Replayed rooms take their eviction clock from the ledger's last line, so the first sweep after a boot writes the stubs for everything long idle. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/server/src/rooms.ts | 34 +++++++++++++++++---- packages/server/src/store.ts | 58 +++++++++++++++++++++++++----------- 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 09942e0..a4ba6d0 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -22,7 +22,7 @@ import { type PlayerId, CURRENT_RULES_REV, } from "@wizwar/engine"; -import { appendLine, archiveRoomFile, countRoomFiles, ensureDataDir, readAllRooms, readRoom, roomFileExists, type RoomLine } from "./store"; +import { appendLine, archiveRoomFile, countRoomFiles, ensureDataDir, ledgerStat, listRoomIds, readRoom, readStubFile, roomFileExists, writeStubFile, type RoomLine } from "./store"; import { recordRoom } from "./stats"; export interface LoggedCommand { @@ -159,9 +159,16 @@ export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number const idle = now - (room.touchedAt ?? now); const allowance = room.state?.phase === "finished" ? FINISHED_MS : IDLE_MS; if (idle < allowance) continue; - sleepingStubs.set(id, stubOf(room)); + const stub = stubOf(room); + sleepingStubs.set(id, stub); rooms.delete(id); evicted++; + try { + const stat = ledgerStat(id); + if (stat) writeStubFile(id, stat.bytes, { ...stub, tokens: Object.fromEntries(stub.tokens) }); + } catch (e) { + console.error(`could not write stub for ${id}:`, e); + } } return evicted; } @@ -176,6 +183,8 @@ interface RoomStub { base: Omit; } const sleepingStubs = new Map(); +/** A stub as it rests on disk — the token map spelled as an object. */ +type StoredStub = Omit & { tokens: Record }; function stubOf(room: Room): RoomStub { const { active, turnHolder, waitKind } = turnFacts(room.state); @@ -775,14 +784,29 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null { return room; } -/** Rebuild every persisted room by replaying its file. */ +/** Bring the rooms back after a restart. A room whose stub still matches + * its ledger sleeps on, answering the games ledger from the stub; the + * rest are replayed, with the eviction clock set to their last line so + * the first sweep puts the long-idle ones back to bed. */ export function loadPersistedRooms(): void { ensureDataDir(); let restored = 0; - for (const [id, lines] of readAllRooms()) { + let asleep = 0; + for (const id of listRoomIds()) { + const stat = ledgerStat(id); + if (!stat) continue; + const stored = readStubFile(id); + if (stored && stored.bytes === stat.bytes) { + sleepingStubs.set(id, { ...stored.stub, tokens: new Map(Object.entries(stored.stub.tokens)) }); + asleep++; + continue; + } + const lines = readRoom(id); + if (!lines) continue; try { const room = rebuildRoom(id, lines); if (!room) continue; + room.touchedAt = stat.mtimeMs; rooms.set(id, room); recordRoom(room); restored++; @@ -790,5 +814,5 @@ export function loadPersistedRooms(): void { console.error(`could not restore room ${id}:`, e); } } - if (restored > 0) console.log(`restored ${restored} room(s) from disk`); + if (restored + asleep > 0) console.log(`restored ${restored} room(s) from disk, ${asleep} left sleeping`); } diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 0411e34..879241a 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -3,7 +3,7 @@ // command. Because the engine is deterministic, replaying a file rebuilds // the exact game state — server restarts lose nothing. -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; export interface RoomMetaLine { @@ -128,24 +128,48 @@ export function countRoomFiles(): number { return readdirSync(DATA_DIR).filter((f) => f.endsWith(".jsonl")).length; } -/** Read every persisted room's lines, keyed by room id. */ -export function readAllRooms(): Map { +/** Every room with a ledger on disk. */ +export function listRoomIds(): string[] { ensureDataDir(); - const rooms = new Map(); - for (const file of readdirSync(DATA_DIR)) { - if (!file.endsWith(".jsonl")) continue; - const id = file.slice(0, -".jsonl".length); - try { - const lines = readFileSync(join(DATA_DIR, file), "utf8") - .split("\n") - .filter((l) => l.trim().length > 0) - .map((l) => JSON.parse(l) as RoomLine); - if (lines.length > 0 && lines[0]!.kind === "meta") rooms.set(id, lines); - } catch (e) { - console.error(`skipping unreadable room file ${file}:`, e); - } + return readdirSync(DATA_DIR).filter((f) => f.endsWith(".jsonl")).map((f) => f.slice(0, -".jsonl".length)); +} + +/** A ledger's size and the moment of its last line. */ +export function ledgerStat(roomId: string): { bytes: number; mtimeMs: number } | null { + if (!safeRoomId(roomId)) return null; + const file = fileFor(roomId); + if (!existsSync(file)) return null; + const st = statSync(file); + return { bytes: st.size, mtimeMs: st.mtimeMs }; +} + +// --- Stubs of sleeping rooms. --------------------------------------------- +// A room put to sleep leaves a stub beside the ledgers: what the games +// ledger needs to answer for it, and the ledger's size at that moment. A +// ledger only ever grows, so at boot a stub whose size still matches is +// the room's whole truth and the room stays asleep, unreplayed. + +const stubDir = () => join(DATA_DIR, "..", "stubs"); +const stubFor = (roomId: string) => join(stubDir(), `${roomId}.json`); + +export function readStubFile(roomId: string): { bytes: number; stub: T } | null { + if (!safeRoomId(roomId)) return null; + const file = stubFor(roomId); + if (!existsSync(file)) return null; + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as { bytes: number; stub: T }; + return typeof parsed.bytes === "number" && parsed.stub ? parsed : null; + } catch (e) { + console.error(`unreadable stub file ${roomId}:`, e); + return null; } - return rooms; +} + +export function writeStubFile(roomId: string, bytes: number, stub: unknown): void { + const file = stubFor(roomId); + mkdirSync(stubDir(), { recursive: true }); + writeFileSync(file + ".tmp", JSON.stringify({ bytes, stub }), "utf8"); + renameSync(file + ".tmp", file); } /** Player surprise reports and the wizards' replies share one JSONL file