diff --git a/backend/services/networkMapScheduler.js b/backend/services/networkMapScheduler.js index 89e61d0..dda149a 100644 --- a/backend/services/networkMapScheduler.js +++ b/backend/services/networkMapScheduler.js @@ -11,9 +11,11 @@ const { readS3TextObject, writeS3JsonObject } = require('./s3Service'); const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json'; const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json'; +const NEXT_RUN_AT_KEY = 'networkMapSchedulerNextRunAt'; const DEFAULT_INTERVAL_MINUTES = 5; const DEFAULT_LOG_MAX_LINES = 500; +const RESTART_RECOVERY_DELAY_MS = 10 * 60 * 1000; /** Кольцевой буфер логов */ class LogBuffer { @@ -329,16 +331,34 @@ async function writeCache(pingMap, speedMap) { } let schedulerTimer = null; +let schedulerStartTimeout = null; /** Время последнего запуска по расписанию (для расчёта следующего) */ let lastScheduledRunAt = null; +/** Реально запланированное время следующего запуска */ +let nextScheduledRunAt = null; +/** Интервал активного таймера (мс) */ +let activeIntervalMs = null; + +async function persistNextRunAt(nextRunAt) { + try { + const ui = await loadUiSettings(); + ui[NEXT_RUN_AT_KEY] = typeof nextRunAt === 'number' ? Math.round(nextRunAt) : null; + await writeS3JsonObject(UI_SETTINGS_KEY, ui); + } catch (e) { + logBuffer.push(`Не удалось сохранить nextRunAt: ${e?.message || e}`); + } +} function getSettingsFromUiSettings(uiSettings) { + const persistedNextRunAtRaw = uiSettings?.[NEXT_RUN_AT_KEY]; + const persistedNextRunAt = Number(persistedNextRunAtRaw); return { enabled: Boolean(uiSettings.networkMapSchedulerEnabled), intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.networkMapSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)), runPing: uiSettings.networkMapSchedulerRunPing !== false, runSpeedTest: uiSettings.networkMapSchedulerRunSpeedTest !== false, logMaxLines: Math.max(100, Math.min(2000, parseInt(uiSettings.networkMapSchedulerLogMaxLines, 10) || DEFAULT_LOG_MAX_LINES)), + persistedNextRunAt: Number.isFinite(persistedNextRunAt) ? persistedNextRunAt : null, }; } @@ -352,7 +372,13 @@ function initNetworkMapScheduler(logger, port) { const baseUrl = `http://127.0.0.1:${port}`; async function tick(source = 'interval') { - lastScheduledRunAt = Date.now(); + if (source === 'interval') { + lastScheduledRunAt = Date.now(); + if (activeIntervalMs) { + nextScheduledRunAt = lastScheduledRunAt + activeIntervalMs; + persistNextRunAt(nextScheduledRunAt); + } + } let uiSettings; try { const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' })); @@ -390,27 +416,62 @@ function initNetworkMapScheduler(logger, port) { } const settings = getSettingsFromUiSettings(uiSettings); if (!settings.enabled) { + activeIntervalMs = null; + nextScheduledRunAt = null; + persistNextRunAt(null); log.info({ component: 'network-map-scheduler' }, 'Scheduler disabled in settings'); return; } logBuffer.setMaxLines(settings.logMaxLines); const intervalMs = settings.intervalMinutes * 60 * 1000; + activeIntervalMs = intervalMs; + const now = Date.now(); + const persisted = settings.persistedNextRunAt; + let firstDelayMs = intervalMs; + if (typeof persisted === 'number' && Number.isFinite(persisted)) { + if (persisted > now) { + // Плановая дата ещё не наступила — сохраняем исходный план. + firstDelayMs = Math.max(1000, persisted - now); + } else { + // Плановая дата уже прошла (контейнер был перезапущен/простаивал) — + // первый запуск через 10 минут от старта контейнера. + firstDelayMs = RESTART_RECOVERY_DELAY_MS; + } + } + nextScheduledRunAt = now + firstDelayMs; + persistNextRunAt(nextScheduledRunAt); log.info( - { component: 'network-map-scheduler', intervalMinutes: settings.intervalMinutes }, - 'Network map scheduler started (runs every %d min by interval only)', + { + component: 'network-map-scheduler', + intervalMinutes: settings.intervalMinutes, + firstDelayMs, + recoveryDelayMs: RESTART_RECOVERY_DELAY_MS, + }, + 'Network map scheduler started (first run in %d sec, then every %d min)', + Math.round(firstDelayMs / 1000), settings.intervalMinutes ); - schedulerTimer = setInterval(() => tick('interval'), intervalMs); + schedulerStartTimeout = setTimeout(() => { + tick('interval'); + schedulerTimer = setInterval(() => tick('interval'), intervalMs); + }, firstDelayMs); } schedule(); } function stopNetworkMapScheduler() { + if (schedulerStartTimeout) { + clearTimeout(schedulerStartTimeout); + schedulerStartTimeout = null; + } if (schedulerTimer) { clearInterval(schedulerTimer); schedulerTimer = null; } + activeIntervalMs = null; + nextScheduledRunAt = null; + persistNextRunAt(null); } function getLogs() { @@ -438,9 +499,19 @@ function pushLog(msg) { */ function getNextRunAt(settings) { if (!settings || !settings.enabled) return null; - const intervalMs = (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000; - const from = lastScheduledRunAt || Date.now(); - return from + intervalMs; + if (typeof nextScheduledRunAt === 'number' && Number.isFinite(nextScheduledRunAt)) { + return nextScheduledRunAt; + } + if (typeof lastScheduledRunAt === 'number' && Number.isFinite(lastScheduledRunAt)) { + const intervalMs = activeIntervalMs || (settings.intervalMinutes || DEFAULT_INTERVAL_MINUTES) * 60 * 1000; + return lastScheduledRunAt + intervalMs; + } + if (typeof settings?.persistedNextRunAt === 'number' && Number.isFinite(settings.persistedNextRunAt)) { + const now = Date.now(); + if (settings.persistedNextRunAt > now) return settings.persistedNextRunAt; + return now + RESTART_RECOVERY_DELAY_MS; + } + return null; } module.exports = {