e07993c536
- 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).
176 lines
7.2 KiB
JavaScript
176 lines
7.2 KiB
JavaScript
/**
|
|
* @file Tests server/lib/db-retention.js and its Settings routes — a new
|
|
* database is INCREMENTAL auto_vacuum, retention is off by default and purges
|
|
* nothing then, when on it removes only finished sessions older than the
|
|
* cutoff (with their events, agents and token rows) in bounded batches, the
|
|
* free-page reclaim shrinks the freelist, GET/PUT /api/settings/retention
|
|
* validate and persist the setting, and POST /api/settings/compact needs
|
|
* confirm and runs a VACUUM in a worker. The database and settings file are
|
|
* temp paths.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const os = require("node:os");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-db-retention-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
|
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
delete process.env.AGENTHUB_RETENTION_DAYS;
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const { db } = require("../db");
|
|
const retention = require("../lib/db-retention");
|
|
|
|
let server;
|
|
let BASE;
|
|
before(async () => {
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
after(() => {
|
|
server?.close();
|
|
delete process.env.AGENTHUB_RETENTION_DAYS;
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
async function call(method, urlPath, body) {
|
|
const res = await fetch(BASE + urlPath, {
|
|
method,
|
|
headers: body ? { "content-type": "application/json" } : {},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
return { status: res.status, body: await res.json() };
|
|
}
|
|
|
|
const daysAgo = (n) => new Date(Date.now() - n * 86400 * 1000).toISOString();
|
|
|
|
function seed(id, status, startedAt, events = 3) {
|
|
db.prepare(
|
|
"INSERT INTO sessions (id, name, status, cwd, started_at, updated_at) VALUES (?, ?, ?, '/tmp', ?, ?)"
|
|
).run(id, id, status, startedAt, startedAt);
|
|
const ins = db.prepare(
|
|
"INSERT INTO events (session_id, event_type, summary, data, created_at) VALUES (?, 'PostToolUse', 's', ?, ?)"
|
|
);
|
|
for (let i = 0; i < events; i++)
|
|
ins.run(id, JSON.stringify({ pad: "x".repeat(2000) }), startedAt);
|
|
}
|
|
const exists = (id) => !!db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(id);
|
|
const eventCount = (id) =>
|
|
db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?").get(id).n;
|
|
|
|
describe("auto_vacuum", () => {
|
|
it("a new database is INCREMENTAL", () => {
|
|
assert.equal(db.pragma("auto_vacuum", { simple: true }), 2);
|
|
assert.equal(retention.spaceInfo(db).auto_vacuum, "incremental");
|
|
});
|
|
});
|
|
|
|
describe("retentionConfig", () => {
|
|
it("is off by default and for anything out of range", () => {
|
|
assert.equal(retention.retentionConfig({}).days, 0);
|
|
for (const v of ["", "0", "-3", "1.5", "abc", "99999"]) {
|
|
assert.equal(retention.retentionConfig({ AGENTHUB_RETENTION_DAYS: v }).days, 0, v);
|
|
}
|
|
assert.equal(retention.retentionConfig({ AGENTHUB_RETENTION_DAYS: "30" }).days, 30);
|
|
});
|
|
});
|
|
|
|
describe("scheduled maintenance", () => {
|
|
it("purges nothing while retention is off", async () => {
|
|
seed("ret-off-old", "completed", daysAgo(400));
|
|
const out = await retention.runScheduledMaintenance({ db });
|
|
assert.equal(out.retention, undefined);
|
|
assert.ok(exists("ret-off-old"));
|
|
db.prepare("DELETE FROM sessions WHERE id = 'ret-off-old'").run();
|
|
});
|
|
|
|
it("purges only finished sessions older than the cutoff, with their rows", async () => {
|
|
seed("ret-old-done", "completed", daysAgo(100));
|
|
seed("ret-old-err", "error", daysAgo(100));
|
|
seed("ret-old-active", "active", daysAgo(100));
|
|
seed("ret-new-done", "completed", daysAgo(5));
|
|
db.prepare(
|
|
"INSERT INTO token_usage (session_id, model, input_tokens) VALUES ('ret-old-done', 'm', 1)"
|
|
).run();
|
|
process.env.AGENTHUB_RETENTION_DAYS = "30";
|
|
try {
|
|
const out = await retention.runScheduledMaintenance({ db });
|
|
assert.equal(out.retention.purged_sessions, 2);
|
|
assert.equal(out.retention.purged_events, 6);
|
|
} finally {
|
|
delete process.env.AGENTHUB_RETENTION_DAYS;
|
|
}
|
|
assert.ok(!exists("ret-old-done"));
|
|
assert.ok(!exists("ret-old-err"));
|
|
assert.equal(eventCount("ret-old-done"), 0);
|
|
assert.equal(
|
|
db.prepare("SELECT COUNT(*) AS n FROM token_usage WHERE session_id = 'ret-old-done'").get().n,
|
|
0
|
|
);
|
|
assert.ok(exists("ret-old-active"), "an active session is never purged");
|
|
assert.ok(exists("ret-new-done"), "a recent session is kept");
|
|
assert.equal(eventCount("ret-new-done"), 3);
|
|
});
|
|
|
|
it("works in bounded batches and stops at the per-run cap", async () => {
|
|
for (let i = 0; i < 7; i++) seed(`ret-batch-${i}`, "abandoned", daysAgo(50), 1);
|
|
const first = await retention.applyRetention({ db, days: 10, batch: 2, maxSessions: 5 });
|
|
assert.equal(first.purged_sessions, 5);
|
|
const second = await retention.applyRetention({ db, days: 10, batch: 2, maxSessions: 5 });
|
|
assert.equal(second.purged_sessions, 2);
|
|
const third = await retention.applyRetention({ db, days: 10 });
|
|
assert.equal(third.purged_sessions, 0);
|
|
});
|
|
|
|
it("returns free pages to the OS", async () => {
|
|
for (let i = 0; i < 40; i++) seed(`ret-big-${i}`, "completed", daysAgo(60), 20);
|
|
await retention.applyRetention({ db, days: 30 });
|
|
const before = db.pragma("freelist_count", { simple: true });
|
|
assert.ok(before > 50, `expected free pages, got ${before}`);
|
|
const out = await retention.reclaimFreePages({ db, minPages: 10, slice: 16 });
|
|
assert.ok(out.reclaimed_pages > 0);
|
|
assert.ok(db.pragma("freelist_count", { simple: true }) < before);
|
|
});
|
|
});
|
|
|
|
describe("routes", () => {
|
|
it("GET reports retention off by default", async () => {
|
|
const r = await call("GET", "/api/settings/retention");
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.days, 0);
|
|
assert.equal(r.body.compacting, false);
|
|
assert.equal(r.body.space.auto_vacuum, "incremental");
|
|
});
|
|
|
|
it("PUT validates and persists, 0 turns it off", async () => {
|
|
for (const bad of [-1, 1.5, "7", 99999, null]) {
|
|
const r = await call("PUT", "/api/settings/retention", { days: bad });
|
|
assert.equal(r.status, 400, String(bad));
|
|
}
|
|
let r = await call("PUT", "/api/settings/retention", { days: 90 });
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.days, 90);
|
|
assert.match(fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"), /RETENTION_DAYS=90/);
|
|
r = await call("PUT", "/api/settings/retention", { days: 0 });
|
|
assert.equal(r.body.days, 0);
|
|
});
|
|
|
|
it("POST /compact needs confirm and then vacuums", async () => {
|
|
let r = await call("POST", "/api/settings/compact", {});
|
|
assert.equal(r.status, 400);
|
|
r = await call("POST", "/api/settings/compact", { confirm: true });
|
|
assert.equal(r.status, 202);
|
|
for (let i = 0; i < 200 && retention.isCompacting(); i++) {
|
|
await new Promise((res) => setTimeout(res, 50));
|
|
}
|
|
const last = retention.lastCompactResult();
|
|
assert.equal(last?.ok, true, last?.error);
|
|
assert.equal(db.pragma("auto_vacuum", { simple: true }), 2);
|
|
});
|
|
});
|