// ── The lifecycle hooks ─────────────────────────────────────────────────── // // `register()` may not touch the database (MODULE_API.md §2.2). This file is // where everything it could not do goes. // // core schema → this module's schema fragment → onBoot(ctx) → the listener binds // // So by the time `onBoot` runs the tables exist, core's settings are seeded, and // nothing is serving traffic yet. // // **`onBoot` has no timeout.** Shutdown races the process being killed; boot does // not. A slow `onBoot` delays the listener, which is the promise above rather // than a problem to be timed out. // // **If `onBoot` throws, the module is `startup_failed` and the site still comes // up.** Its routes stay mounted but answer 503, because a module that failed to // warm up serving half-initialised data is worse than one that says it is down. // There is then NO `onShutdown` — being handed a half-built world to tear down is // worse than not closing cleanly. Which is why the poll below catches everything: // a sidecar that is not there yet is the ordinary state of a fresh install, and // letting that fail the boot would make installing the module before installing // the bridge impossible. // // ── Three timers, and they answer three different questions ─────────────── // // refresh (30s) what is each server, and who is on it — the BOARDS // ingest (5s) what has happened since we last looked — the CURSOR // prune (1h) forgetting the detail we promised not to keep for ever // // The boards poll and the ingest are deliberately separate rather than one loop // reading both. They fail differently and they matter differently: a board that // is 30 seconds stale shows a player count slightly behind, and an ingest that // is 30 seconds behind shows a killfeed that feels broken. Splitting them lets // the cheap one run often and the expensive one run rarely, and it means a // sidecar that answers one and not the other degrades in exactly one place. // // The poll was never a placeholder for a socket: a sidecar's store-backed reads // are what answer while a game server is off, which is most of what this module // renders. See `ingest.js` for why the live feed is a cursor and not a // WebSocket. const core = require('./core') const db = require('./model/servers/servers.db') const eventsDb = require('./model/events/events.db') const ingest = require('./ingest') const servers = require('./model/servers/servers.model') const sidecar = require('./sidecarClient') const log = core.logger('boot') let refreshTimer = null let ingestTimer = null let pruneTimer = null const REFRESH_MS = 30 * 1000 const INGEST_MS = 5 * 1000 const PRUNE_MS = 60 * 60 * 1000 /** * How long this module keeps raw events. * * Longer than the sidecar's 14 days, because this is the richer store and the * one a page reads — and because the sidecar lives on somebody's game host while * this lives on the website's own database. What is NOT bounded by it is the * record: `rust_player_wipe_stats` and `rust_gather_totals` are permanent, which * is the whole of R12's "a wipe does not erase a player's history". */ const EVENT_RETENTION_DAYS = 30 /** * Ask every configured sidecar how its server is doing, and store what it said. * * **Every server is polled independently and one failure never stops the * others.** `Promise.allSettled`, not `Promise.all`: six servers behind one * unreachable host would otherwise mean the whole fleet stops updating because * one of them does, and the site would report five healthy servers offline. */ async function refresh() { let rows try { rows = await servers.listForPolling() } catch (err) { log.warn('could not read the server list', { error: err.message }) return } await Promise.allSettled(rows.map(refreshOne)) } async function refreshOne(server) { try { // One call for both boards. `/server` would answer the same question about // the server itself, but presence would then be a second round trip to the // same process for a fact it already had in hand. const board = await sidecar.boards(server) // Three outcomes, and collapsing any two of them loses something an operator // needs: // // • the sidecar answered with a frame → the server has connected at least once // • the sidecar answered 204 (`empty`) → the sidecar is up and the game never connected // • the sidecar did not answer → the bridge is unreachable // // The middle case is the one that is easy to lose. It is a fresh install // whose plugin is not loaded yet, and reporting it as unreachable sends the // operator to look at the network instead of at the game server. if (!board.ok) { await db.putState({ serverId: server.id, reachable: false, online: false }) return } const boards = (board.data && board.data.boards) || {} const frame = boards['server.hello'] if (!frame) { // The sidecar is up and has never heard from the game. Presence is emptied // rather than left alone: a stale list of players on a server nobody can // reach is worse than an empty one, because it looks current. await db.putState({ serverId: server.id, reachable: true, online: false }) await ingest.applyBoards(server.id, {}) return } await ingest.applyBoards(server.id, boards) await db.putState({ serverId: server.id, reachable: true, // A stored `server.hello` means the game connected; whether it is connected // NOW is a different question, and `/health` is what answers it. The board // alone cannot say, which is why `online` is not simply `true` here — it is // decided by freshness in the model, from `updated_at`. online: true, players: Number(frame.players) || 0, maxPlayers: Number(frame.maxPlayers) || 0, hostname: frame.hostname || null, level: frame.level || null, seed: frame.seed === undefined ? null : Number(frame.seed), worldSize: frame.worldSize === undefined ? null : Number(frame.worldSize), bootId: frame.bootId || null, saveCreatedAt: frame.saveCreatedAt || null, wipeId: frame.wipeId || null, protocol: frame.protocol === undefined ? null : Number(frame.protocol), raw: frame, }) } catch (err) { // A failure here is one server's, and it must not reach `Promise.allSettled` // as a rejection that hides which one. Log with the id and carry on. log.warn('could not refresh a server', { server: server.id, error: err.message }) } } /** * Runs once, after the schema and before the listener binds. * * Receives the same frozen `ctx` `register()` was given — not a second object * built to look like it — so a module that only needs core at boot time can skip * `core.init` entirely and use this argument. */ /** Runs the cursor for every configured server, independently. */ async function ingestAll() { let rows try { rows = await servers.listForPolling() } catch (err) { log.warn('could not read the server list', { error: err.message }) return } // `allSettled`, for the same reason the board poll uses it: six servers behind // one unreachable host must not stop the other five being ingested. await Promise.allSettled(rows.map((server) => ingest.ingestServer(server))) } async function prune() { try { const gone = await eventsDb.pruneEvents(EVENT_RETENTION_DAYS) if (gone > 0) log.info('pruned old events', { events: gone, days: EVENT_RETENTION_DAYS }) } catch (err) { log.warn('could not prune events', { error: err.message }) } } async function onBoot() { await refresh() refreshTimer = setInterval(refresh, REFRESH_MS) ingestTimer = setInterval(ingestAll, INGEST_MS) pruneTimer = setInterval(prune, PRUNE_MS) // Node keeps the process alive for a pending timer. Core's own intervals are // unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a // thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL. for (const timer of [refreshTimer, ingestTimer, pruneTimer]) { if (timer && typeof timer.unref === 'function') timer.unref() } log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS }) } /** * Runs on SIGINT/SIGTERM, before core closes anything of its own. * * The database pool, the push dispatcher and the SSE fan-out are all still open, * because flushing through them is the only thing this hook is for. There is a * five-second budget per module, after which the hook is abandoned — abandoned * rather than cancelled, since nothing can stop a promise that is still running. */ async function onShutdown() { for (const timer of [refreshTimer, ingestTimer, pruneTimer]) { if (timer) clearInterval(timer) } refreshTimer = null ingestTimer = null pruneTimer = null log.info('shut down') } module.exports = { onBoot, onShutdown, refresh, refreshOne, ingestAll, prune, REFRESH_MS, INGEST_MS, EVENT_RETENTION_DAYS, }