From f77438d12472e1f3316f11d7dd1fe5372ac48c90 Mon Sep 17 00:00:00 2001 From: nesszerra <90105158+Finesssee@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:33:59 +0700 Subject: [PATCH] Save expired rematch locks so the room alarm stops looping The tick dropped expired rematch locks in memory without marking the state changed, so the stored lock stayed. scheduleAlarm then saw a past due time and re-armed the alarm for now, about 8 times a second, until another change was saved. Production logged 164k alarms in 7 days, most from this loop. --- docs/CONTRACTS.md | 2 ++ server/game.ts | 2 ++ tests/channel.test.ts | 12 ++++++++++++ 3 files changed, 16 insertions(+) diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 735274d..d9dfbfa 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -350,6 +350,8 @@ Bot replies and status (room table `bot_status`, one row): failed, stays due and is retried every 5 s in reveal order until it is 2 minutes past `revealAt`, then skipped; a bot command also posts any due result before its own reply. A line Twitch accepted and then dropped (AutoMod, chat settings) counts as done. Alarm errors are logged and never stop the next alarm. + The alarm's tick saves every expiry it applies (rematch locks included), so nothing past due stays stored and + re-arms the alarm for the current moment. An unrated duel's line ends with its "Just for fun" text (Game rules). The pair's last ranked duel this stream ends "Last ranked duel between them this stream." StreamElements channels get no result line; their `!elo` and `!ranks` show it after `revealAt`. diff --git a/server/game.ts b/server/game.ts index a3a423f..c77a157 100644 --- a/server/game.ts +++ b/server/game.ts @@ -391,7 +391,9 @@ function expireState(state: GameState, now: number): boolean { } } + const locks = state.rematchLocks.length; state.rematchLocks = state.rematchLocks.filter((lock) => lock.until > now); + if (state.rematchLocks.length !== locks) changed = true; // unsaved, a stale lock re-arms the alarm for now in a loop const inDuel = new Set(openDuels(state).flatMap((duel) => [duel.a, duel.b])); const before = state.players.length; state.players = state.players.filter( diff --git a/tests/channel.test.ts b/tests/channel.test.ts index bf9b6fe..b8cb4c8 100644 --- a/tests/channel.test.ts +++ b/tests/channel.test.ts @@ -504,6 +504,18 @@ test("a subscription stuck awaiting verification is re-checked within minutes an assert.equal((await r.call("/chat")).body.connected, true); }); +test("an expired rematch lock is saved away, so the alarm doesn't re-arm for now", async () => { + const r = room(); + await r.save("u1", "alice"); + const s = r.readState("nesszerra"); + s.rematchLocks = [{ pair: "u1:u2", until: Date.now() - 1 }]; + r.writeState(s); + await r.alarm(); + assert.deepEqual(r.readState("nesszerra").rematchLocks, [], "the expired lock is gone from storage"); + const next = r.ctx.storage.alarm; + assert.ok(next === null || next > Date.now() + 1000, "no alarm due again right away"); +}); + test("versioned config editor: history, optimistic version check, rollback", async () => { const r = room(); const a = await r.call("/admin", {