From 10b035661fd17cb241d483e17a7632f4175cf7df Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Mon, 17 Aug 2026 13:10:23 -0700 Subject: [PATCH] Restart daily to refresh nxapi config --- app/cron.mjs | 25 +++++++++++++++ app/cron.test.mjs | 78 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 app/cron.test.mjs diff --git a/app/cron.mjs b/app/cron.mjs index b430769..2a18eb2 100644 --- a/app/cron.mjs +++ b/app/cron.mjs @@ -1,4 +1,5 @@ import { CronJob } from 'cron'; +import { setTimeout as sleep } from 'node:timers/promises'; import { update } from './data/index.mjs'; import { warmCaches } from './splatnet/index.mjs'; import { sendStatuses } from './social/index.mjs'; @@ -6,6 +7,7 @@ import { archiveData } from './data/DataArchiver.mjs'; import { updateAvatars } from './social/updateAvatars.mjs'; let updating = false; +const restartGracePeriod = 5 * 60 * 1000; // 5 minutes async function updateIfNotUpdating(mode) { if (updating) { @@ -25,6 +27,28 @@ async function updateIfNotUpdating(mode) { } } +// nxapi caches remote config for the life of the process, so restart daily to refresh it. +async function restartToRefreshConfig() { + const restartDeadline = Date.now() + restartGracePeriod; + + if (updating) { + console.log('[Cron] Restart pending; waiting for in-progress update to finish...'); + + while (updating && Date.now() < restartDeadline) { + await sleep(5000); + } + } + + if (updating) { + console.warn('[Cron] Update did not finish within 5 minutes; restarting anyway'); + } + + updating = true; + + console.log('[Cron] Exiting to refresh nxapi remote config; container will restart'); + process.exit(0); +} + export default function() { new CronJob('5,20,35,50 * * * *', warmCaches, null, true); new CronJob('15 0,1,2,3,4 * * * *', () => { @@ -38,4 +62,5 @@ export default function() { }, null, true); new CronJob('30 * * * *', updateAvatars, null, true); + new CronJob('0 55 4 * * *', restartToRefreshConfig, null, true); } diff --git a/app/cron.test.mjs b/app/cron.test.mjs new file mode 100644 index 0000000..1a6a2d8 --- /dev/null +++ b/app/cron.test.mjs @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + archiveData: vi.fn(), + jobs: new Map(), + sendStatuses: vi.fn(), + update: vi.fn(), +})); + +vi.mock('cron', () => ({ + CronJob: class { + constructor(cronTime, onTick) { + mocks.jobs.set(cronTime, onTick); + } + }, +})); +vi.mock('node:timers/promises', () => ({ + setTimeout: ms => new Promise(resolve => setTimeout(resolve, ms)), +})); +vi.mock('./data/index.mjs', () => ({ update: mocks.update })); +vi.mock('./splatnet/index.mjs', () => ({ warmCaches: vi.fn() })); +vi.mock('./social/index.mjs', () => ({ sendStatuses: mocks.sendStatuses })); +vi.mock('./data/DataArchiver.mjs', () => ({ archiveData: mocks.archiveData })); +vi.mock('./social/updateAvatars.mjs', () => ({ updateAvatars: vi.fn() })); + +async function loadJobs() { + const { default: startCron } = await import('./cron.mjs'); + startCron(); +} + +describe('cron update coordination', () => { + beforeEach(() => { + vi.resetModules(); + mocks.jobs.clear(); + mocks.update.mockReset(); + mocks.sendStatuses.mockReset(); + mocks.archiveData.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('allows another update after an update rejects', async () => { + mocks.update.mockRejectedValueOnce(new Error('update failed')); + await loadJobs(); + + const update = mocks.jobs.get('15 0,1,2,3,4 * * * *'); + await expect(update()).rejects.toThrow('update failed'); + await update(); + + expect(mocks.update).toHaveBeenCalledTimes(2); + }); + + it('restarts after a bounded wait when an update never settles', async () => { + vi.useFakeTimers(); + mocks.update.mockReturnValue(new Promise(() => {})); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const exit = vi.spyOn(process, 'exit').mockImplementation(() => {}); + await loadJobs(); + + const update = mocks.jobs.get('15 0,1,2,3,4 * * * *'); + const restart = mocks.jobs.get('0 55 4 * * *'); + update(); + const restarting = restart(); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 - 1); + + expect(exit).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + + expect(warn).toHaveBeenCalledWith('[Cron] Update did not finish within 5 minutes; restarting anyway'); + expect(exit).toHaveBeenCalledWith(0); + await restarting; + }); +});