Sleeping rooms stay asleep across a restart

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-05 22:05:43 -04:00
co-authored by Claude Fable 5.1
parent c7edfa74ad
commit 6293c36add
2 changed files with 70 additions and 22 deletions
+29 -5
View File
@@ -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<GameSummary, "name" | "yourTurn" | "attention">;
}
const sleepingStubs = new Map<string, RoomStub>();
/** A stub as it rests on disk — the token map spelled as an object. */
type StoredStub = Omit<RoomStub, "tokens"> & { tokens: Record<PlayerId, string> };
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<StoredStub>(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`);
}
+39 -15
View File
@@ -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<string, RoomLine[]> {
/** Every room with a ledger on disk. */
export function listRoomIds(): string[] {
ensureDataDir();
const rooms = new Map<string, RoomLine[]>();
for (const file of readdirSync(DATA_DIR)) {
if (!file.endsWith(".jsonl")) continue;
const id = file.slice(0, -".jsonl".length);
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<T>(roomId: string): { bytes: number; stub: T } | null {
if (!safeRoomId(roomId)) return null;
const file = stubFor(roomId);
if (!existsSync(file)) return null;
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);
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(`skipping unreadable room file ${file}:`, 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