import { generateAllJson } from "../src/lib/json/generator"; import { cleanupStoredIpas } from "../src/lib/storage/retention"; import { cleanupOldLogs } from "../src/lib/cleanup/logs"; import { cleanupCaches } from "../src/lib/cleanup/cache"; import { getSettings } from "../src/lib/config"; import { backfillMissingIcons, describeIconBackfill, } from "../src/lib/pipeline/icon-backfill"; import { scanConfiguredGithubReleaseSources } from "../src/lib/github/source-scanner"; import { logger } from "../src/lib/logger"; import { syncStorageMetrics } from "../src/lib/metrics/sync-storage"; import { TelegramService } from "../src/lib/telegram/service"; /** * How long to stop polling the queue after the storage cap blocks a download. * Without this the loop would keep `didWork` false, sleep only a second, and * hammer the database with cap checks forever. */ const STORAGE_BLOCKED_BACKOFF_MS = 60_000; /** * How the icon backfill paces itself. * * Fast while there is a backlog, so a catalog full of placeholders is caught up * in minutes rather than over days of cleanup intervals; hourly once every * build has been looked inside, where the whole cost is one indexed count. The * pass stamps every build it examines, so "caught up" is a state it can reach * rather than the same archives being re-opened forever. */ const ICON_BACKFILL_BUSY_MS = 30_000; const ICON_BACKFILL_IDLE_MS = 60 * 60_000; let running = true; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function main() { const telegram = new TelegramService(); let nextScanAt = 0; let nextJsonAt = 0; let nextCleanupAt = 0; let nextQueueAt = 0; let nextIconAt = 0; await logger.info("system", "FTStore Admin worker starting..."); await telegram.start(); while (running) { try { await telegram.heartbeat(); const settings = await getSettings(); const now = Date.now(); let didWork = false; if (await telegram.processNextCommand()) { didWork = true; } if (settings.system_enabled) { if (now >= nextScanAt) { await scanConfiguredGithubReleaseSources(settings.github_release_scan_limit); try { await telegram.scanConfiguredChannels(settings.scan_message_limit); } catch (error) { await logger.warn("system", "Scheduled scan skipped because Telegram is unavailable", { error: error instanceof Error ? error.message : String(error), }); } nextScanAt = Date.now() + settings.scan_interval_minutes * 60 * 1000; didWork = true; } if (now >= nextJsonAt) { await syncStorageMetrics(); await generateAllJson(); nextJsonAt = Date.now() + settings.json_regen_interval_minutes * 60 * 1000; didWork = true; } if (now >= nextCleanupAt) { await cleanupStoredIpas(); await cleanupOldLogs(); await cleanupCaches(); nextCleanupAt = Date.now() + settings.cleanup_interval_hours * 60 * 60 * 1000; didWork = true; } // Artwork for the builds the App Store could not answer for, taken out // of the IPA already on disk. Deliberately not tied to the cleanup // interval: this is what a person is looking at, and hours is the wrong // answer to a store full of grey squares. if (now >= nextIconAt) { const icons = await backfillMissingIcons(); nextIconAt = Date.now() + (icons.scanned > 0 ? ICON_BACKFILL_BUSY_MS : ICON_BACKFILL_IDLE_MS); if (icons.scanned > 0) { didWork = true; await logger.info("process", describeIconBackfill(icons)); } } if (now >= nextQueueAt) { const tick = await telegram.processNextQueuedIpa(); if (tick === "processed") { didWork = true; } else if (tick === "storage-blocked") { // Entries stay pending; back off so the cap check doesn't run at // 1 Hz until an operator raises the limit or cleanup frees space. nextQueueAt = Date.now() + STORAGE_BLOCKED_BACKOFF_MS; } } } if (!didWork) { await sleep(1000); } } catch (error) { await logger.error("system", "Worker loop error", { error: error instanceof Error ? error.message : String(error), }); await sleep(5000); } } await telegram.stop(); } async function shutdown(signal: string) { await logger.info("system", `Received ${signal}, shutting down gracefully...`); running = false; } process.on("SIGTERM", () => { void shutdown("SIGTERM"); }); process.on("SIGINT", () => { void shutdown("SIGINT"); }); main().catch(async (error) => { console.error("Worker fatal error:", error); await logger.error("system", "Worker crashed", { error: error instanceof Error ? error.message : String(error), }); process.exit(1); });