perf: hourly update check only with a browser open; opt-in retention, incremental auto-vacuum and Compact

- update-scheduler: default interval 1 h (DASHBOARD_UPDATE_CHECK_INTERVAL_MS and its 60 s floor kept);
  a scheduled tick with no /ws client is skipped, and the next browser to connect gets one catch-up
  check when the last one is stale (websocket.js onConnection). Manual check unchanged.
- db-retention.js: shared session purge (used by POST /api/settings/cleanup too), opt-in
  AGENTHUB_RETENTION_DAYS applied by the hourly maintenance in 25-session transactions with yields
  (max 5000/run), bounded incremental_vacuum when the freelist is large, and a manual Compact
  (VACUUM in a worker, converting to auto_vacuum=INCREMENTAL).
- db.js: new databases are INCREMENTAL; an existing one up to 32 MB is converted at startup.
- GET/PUT /api/settings/retention, POST /api/settings/compact (confirm: true); Settings → Data →
  Session cleanup gets the Automatic cleanup control and Compact now (en + vi).
This commit is contained in:
2026-10-16 10:20:38 +07:00
parent ecbbb4dd8f
commit e07993c536
21 changed files with 1323 additions and 58 deletions
+78 -10
View File
@@ -1,5 +1,11 @@
/**
* @file Periodic git upstream check and WebSocket broadcast when update availability changes.
* @file Periodic release/upstream check and WebSocket broadcast when update
* availability changes. Hourly by default (`DASHBOARD_UPDATE_CHECK_INTERVAL_MS`,
* floor 60 s); a tick with no browser connected is skipped — a checkout install
* runs `git fetch` on every check, and nobody would see the answer — and the
* first browser to connect after a skipped or stale check gets one promptly.
* The manual "Check now" (`POST /api/updates/check`, `agenthub update-check`)
* is not routed through here and always runs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,11 +19,39 @@ function isUpdateCheckDisabled() {
function intervalMs() {
const n = Number.parseInt(process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS || "", 10);
if (Number.isFinite(n) && n >= 60_000) return n;
return 5 * 60 * 1000;
return 60 * 60 * 1000;
}
function startUpdateScheduler({ broadcast }) {
/** Delay before the catch-up check a reconnecting browser triggers. */
const CONNECT_CHECK_DELAY_MS = 3_000;
/**
* @param {object} opts
* @param {Function} opts.broadcast
* @param {() => number} [opts.getConnectionCount] browsers connected now
* (default: server/websocket.js's); 0 skips a scheduled tick.
* @param {(fn: Function) => Function} [opts.onConnection] subscribe to new
* browser connections; returns an unsubscribe.
* @param {Function} [opts.getStatus] the check itself (tests inject it).
* @param {number} [opts.initialDelayMs] first tick after start (default 8 s).
*/
function startUpdateScheduler({
broadcast,
getConnectionCount,
onConnection,
getStatus = getUpdatesStatus,
initialDelayMs = 8_000,
}) {
if (isUpdateCheckDisabled()) return { stop: () => {} };
if (!getConnectionCount || !onConnection) {
const ws = require("./websocket");
getConnectionCount = getConnectionCount || ws.getConnectionCount;
onConnection = onConnection || ws.onConnection;
}
const every = intervalMs();
let lastCheckAt = 0;
let inFlight = false;
let connectTimer = null;
let lastFingerprint = "";
let lastHadUpdate = false;
@@ -25,10 +59,24 @@ function startUpdateScheduler({ broadcast }) {
let initialTimer = null;
let intervalTimer = null;
async function tick() {
// A scheduled tick: skipped while no browser is connected.
function scheduledTick() {
if (stopped) return;
let clients = 1;
try {
const status = await getUpdatesStatus();
clients = getConnectionCount();
} catch {
/* unknown: check as before */
}
if (clients > 0) tick();
}
async function tick() {
if (stopped || inFlight) return;
inFlight = true;
lastCheckAt = Date.now();
try {
const status = await getStatus();
// Fingerprint must change whenever anything user-visible changes,
// including the *shape* of manual_command (e.g. user switches branch
// or adds an upstream remote — same remote_sha / commits_behind, but
@@ -72,15 +120,33 @@ function startUpdateScheduler({ broadcast }) {
}
} catch {
// Non-fatal — never block the server on update checks
} finally {
inFlight = false;
}
}
initialTimer = setTimeout(() => {
tick();
}, 8_000);
// A browser came back after the scheduled checks were skipped (or the last
// one is older than the interval): check once, shortly, so the banner is
// current without waiting up to an hour.
let unsubscribe = null;
try {
unsubscribe = onConnection(() => {
if (stopped || connectTimer || inFlight) return;
if (Date.now() - lastCheckAt < every) return;
connectTimer = setTimeout(() => {
connectTimer = null;
tick();
}, CONNECT_CHECK_DELAY_MS);
if (typeof connectTimer.unref === "function") connectTimer.unref();
});
} catch {
/* no WebSocket server: scheduled ticks only */
}
initialTimer = setTimeout(scheduledTick, initialDelayMs);
if (typeof initialTimer.unref === "function") initialTimer.unref();
intervalTimer = setInterval(tick, intervalMs());
intervalTimer = setInterval(scheduledTick, every);
if (typeof intervalTimer.unref === "function") intervalTimer.unref();
return {
@@ -88,8 +154,10 @@ function startUpdateScheduler({ broadcast }) {
stopped = true;
if (initialTimer) clearTimeout(initialTimer);
if (intervalTimer) clearInterval(intervalTimer);
if (connectTimer) clearTimeout(connectTimer);
if (typeof unsubscribe === "function") unsubscribe();
},
};
}
module.exports = { startUpdateScheduler };
module.exports = { startUpdateScheduler, intervalMs };