@sleep2agi/commhub-server 0.8.8 → 0.9.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +92 -6
- package/src/config-apply-sec1.test.ts +561 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/cross-tenant-injection.test.ts +335 -0
- package/src/db-adapter.ts +2 -2
- package/src/db.ts +356 -7
- package/src/index.ts +61 -17
- package/src/push.test.ts +383 -2
- package/src/push.ts +291 -55
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/tools.ts +597 -64
package/src/retention.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// Round-2/4 review ② — retention sweep + incremental VACUUM.
|
|
2
|
+
//
|
|
3
|
+
// Background maintenance for the commhub database. Multi-tenant
|
|
4
|
+
// public-facing hubs (e.g. <hub-domain>) write to high-frequency tables
|
|
5
|
+
// on every heartbeat (agent_telemetry, task_events, completions) — left
|
|
6
|
+
// unswept these grow unbounded, slow down indexed scans, and bloat the
|
|
7
|
+
// on-disk footprint. SQLite never reclaims freed pages without VACUUM,
|
|
8
|
+
// so a row delete by itself doesn't shrink the file.
|
|
9
|
+
//
|
|
10
|
+
// This module exposes:
|
|
11
|
+
// - sweepRetention(cfg): one-shot sweep across high-growth tables
|
|
12
|
+
// - startRetentionSweeper(): wires a 1-hour setInterval, fires once
|
|
13
|
+
// at startup with an immediate (non-blocking) sweep
|
|
14
|
+
//
|
|
15
|
+
// Defaults are conservative — long-tail debugging traces survive a
|
|
16
|
+
// week. Operators can override per env (COMMHUB_RETENTION_*_DAYS) for
|
|
17
|
+
// hot hubs.
|
|
18
|
+
//
|
|
19
|
+
// Run isolated tests against retention.test.ts:
|
|
20
|
+
// COMMHUB_DB=/tmp/test-retention.db bun test src/retention.test.ts
|
|
21
|
+
|
|
22
|
+
import { db } from "./db.js";
|
|
23
|
+
|
|
24
|
+
export type RetentionConfig = {
|
|
25
|
+
// High-frequency telemetry; default = 7 days.
|
|
26
|
+
// Heartbeat is ~30s → 50 agents × 86400 s/day ÷ 30 s = 144000 rows/day.
|
|
27
|
+
// At 7d that's ~1M rows. Indexed scans stay fast; raw disk = a few MB.
|
|
28
|
+
telemetryDays: number;
|
|
29
|
+
// Task state-change audit; default = 30 days.
|
|
30
|
+
// One row per status transition. Useful for incident forensics.
|
|
31
|
+
taskEventsDays: number;
|
|
32
|
+
// Acked inbox; default = 7 days post-ack.
|
|
33
|
+
// Once acked the row is no longer hot — keep a week for debugging.
|
|
34
|
+
// Unacked rows are NEVER swept (could be in-flight delivery).
|
|
35
|
+
ackedInboxDays: number;
|
|
36
|
+
// Completed/cancelled/failed tasks; default = 30 days.
|
|
37
|
+
// Active tasks (created/delivered/acked/running/replied less than
|
|
38
|
+
// 30 days old) are NEVER swept.
|
|
39
|
+
terminalTasksDays: number;
|
|
40
|
+
// Audit log; default = 90 days.
|
|
41
|
+
// Longer retention because audit_log is a security artifact.
|
|
42
|
+
auditLogDays: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type SweepResult = {
|
|
46
|
+
startedAt: string;
|
|
47
|
+
durationMs: number;
|
|
48
|
+
deletes: {
|
|
49
|
+
agent_telemetry: number;
|
|
50
|
+
task_events: number;
|
|
51
|
+
inbox: number;
|
|
52
|
+
tasks: number;
|
|
53
|
+
audit_log: number;
|
|
54
|
+
};
|
|
55
|
+
vacuum: {
|
|
56
|
+
walCheckpointPagesMoved: number | null;
|
|
57
|
+
incrementalFreedPages: number | null;
|
|
58
|
+
errored: boolean;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const DEFAULTS: RetentionConfig = {
|
|
63
|
+
telemetryDays: 7,
|
|
64
|
+
taskEventsDays: 30,
|
|
65
|
+
ackedInboxDays: 7,
|
|
66
|
+
terminalTasksDays: 30,
|
|
67
|
+
auditLogDays: 90,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function readEnvDays(name: string, fallback: number): number {
|
|
71
|
+
const raw = process.env[name];
|
|
72
|
+
if (raw === undefined || raw === "") return fallback;
|
|
73
|
+
const n = Number(raw);
|
|
74
|
+
// Negative = disable that table's sweep entirely (operator opt-out).
|
|
75
|
+
// NaN / non-finite falls back to default — guard against fat-fingered env.
|
|
76
|
+
if (!Number.isFinite(n)) return fallback;
|
|
77
|
+
return n;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readRetentionConfig(): RetentionConfig {
|
|
81
|
+
return {
|
|
82
|
+
telemetryDays: readEnvDays("COMMHUB_RETENTION_TELEMETRY_DAYS", DEFAULTS.telemetryDays),
|
|
83
|
+
taskEventsDays: readEnvDays("COMMHUB_RETENTION_TASK_EVENTS_DAYS", DEFAULTS.taskEventsDays),
|
|
84
|
+
ackedInboxDays: readEnvDays("COMMHUB_RETENTION_INBOX_ACKED_DAYS", DEFAULTS.ackedInboxDays),
|
|
85
|
+
terminalTasksDays: readEnvDays("COMMHUB_RETENTION_TASKS_TERMINAL_DAYS", DEFAULTS.terminalTasksDays),
|
|
86
|
+
auditLogDays: readEnvDays("COMMHUB_RETENTION_AUDIT_DAYS", DEFAULTS.auditLogDays),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sweepOne(
|
|
91
|
+
sql: string,
|
|
92
|
+
params: (string | number)[],
|
|
93
|
+
days: number,
|
|
94
|
+
): number {
|
|
95
|
+
// days < 0 → operator opt-out for this table; skip the DELETE.
|
|
96
|
+
// days === 0 is a legitimate "sweep everything older than now"
|
|
97
|
+
// (effectively a TRUNCATE); we honour it.
|
|
98
|
+
if (days < 0) return 0;
|
|
99
|
+
try {
|
|
100
|
+
const res = db.run(sql, params);
|
|
101
|
+
return res.changes ?? 0;
|
|
102
|
+
} catch (e: any) {
|
|
103
|
+
console.log(`[commhub retention] sweep failed: ${e?.message ?? e}`);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): SweepResult {
|
|
109
|
+
const startedAt = new Date().toISOString();
|
|
110
|
+
const t0 = Date.now();
|
|
111
|
+
|
|
112
|
+
const telemetry = sweepOne(
|
|
113
|
+
`DELETE FROM agent_telemetry WHERE created_at < datetime('now', ?1)`,
|
|
114
|
+
[`-${cfg.telemetryDays} days`],
|
|
115
|
+
cfg.telemetryDays,
|
|
116
|
+
);
|
|
117
|
+
const taskEvents = sweepOne(
|
|
118
|
+
`DELETE FROM task_events WHERE created_at < datetime('now', ?1)`,
|
|
119
|
+
[`-${cfg.taskEventsDays} days`],
|
|
120
|
+
cfg.taskEventsDays,
|
|
121
|
+
);
|
|
122
|
+
// Only sweep ACKED inbox rows — unacked may be in-flight delivery
|
|
123
|
+
// for an agent that's offline but coming back. Acked rows have
|
|
124
|
+
// served their purpose and exist only for debugging.
|
|
125
|
+
const inbox = sweepOne(
|
|
126
|
+
`DELETE FROM inbox WHERE acked = 1 AND created_at < datetime('now', ?1)`,
|
|
127
|
+
[`-${cfg.ackedInboxDays} days`],
|
|
128
|
+
cfg.ackedInboxDays,
|
|
129
|
+
);
|
|
130
|
+
// Terminal-task sweep — split into two DELETEs:
|
|
131
|
+
//
|
|
132
|
+
// (a) "safe terminals": cancelled / failed / expired / (theoretical)
|
|
133
|
+
// completed have no chain-ancestor risk and are safe to reap
|
|
134
|
+
// unconditionally past the horizon. NOTE: as of this writing
|
|
135
|
+
// 'completed' is dead code — no code path sets it (every reply
|
|
136
|
+
// uses 'replied'/'failed'/'cancelled' via send_reply or
|
|
137
|
+
// chainReplyToParent). Kept in the IN-list as a forward guard:
|
|
138
|
+
// if someone introduces a 'completed' write later, the sweep
|
|
139
|
+
// picks it up without code changes.
|
|
140
|
+
//
|
|
141
|
+
// (b) "replied" — was excluded entirely in the original PR, which
|
|
142
|
+
// missed the goal: every fulfilled task lives at 'replied'
|
|
143
|
+
// status, so the tasks table's MAIN GROWTH SOURCE would never
|
|
144
|
+
// be reclaimed. Reviewer caught this. Fix: child-ref-aware
|
|
145
|
+
// delete — reap an old replied row ONLY IF no other task
|
|
146
|
+
// references it as parent_task_id. A row with no children is
|
|
147
|
+
// not a chain ancestor and is safe to drop.
|
|
148
|
+
//
|
|
149
|
+
// **Time field** (both DELETEs): use COALESCE(completed_at, created_at).
|
|
150
|
+
// Terminal status means the server set `completed_at` at the
|
|
151
|
+
// reply/fail/cancel moment. Sweeping on `created_at` alone would
|
|
152
|
+
// reap a task created 60d ago but completed today the instant it
|
|
153
|
+
// entered terminal state — zero post-terminal retention. The
|
|
154
|
+
// COALESCE falls back to created_at for legacy rows that pre-date
|
|
155
|
+
// the completed_at column (back-compat).
|
|
156
|
+
const tasksSafeTerminal = sweepOne(
|
|
157
|
+
`DELETE FROM tasks
|
|
158
|
+
WHERE status IN ('completed', 'cancelled', 'failed', 'expired')
|
|
159
|
+
AND COALESCE(completed_at, created_at) < datetime('now', ?1)`,
|
|
160
|
+
[`-${cfg.terminalTasksDays} days`],
|
|
161
|
+
cfg.terminalTasksDays,
|
|
162
|
+
);
|
|
163
|
+
const tasksRepliedChildSafe = sweepOne(
|
|
164
|
+
`DELETE FROM tasks
|
|
165
|
+
WHERE status = 'replied'
|
|
166
|
+
AND COALESCE(completed_at, created_at) < datetime('now', ?1)
|
|
167
|
+
AND NOT EXISTS (
|
|
168
|
+
SELECT 1 FROM tasks AS child
|
|
169
|
+
WHERE child.parent_task_id = tasks.task_id
|
|
170
|
+
)`,
|
|
171
|
+
[`-${cfg.terminalTasksDays} days`],
|
|
172
|
+
cfg.terminalTasksDays,
|
|
173
|
+
);
|
|
174
|
+
const tasks = tasksSafeTerminal + tasksRepliedChildSafe;
|
|
175
|
+
const auditLog = sweepOne(
|
|
176
|
+
`DELETE FROM audit_log WHERE created_at < datetime('now', ?1)`,
|
|
177
|
+
[`-${cfg.auditLogDays} days`],
|
|
178
|
+
cfg.auditLogDays,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// VACUUM:
|
|
182
|
+
// - wal_checkpoint(TRUNCATE): trims the WAL file. Works on every
|
|
183
|
+
// SQLite DB regardless of auto_vacuum setting. Useful after
|
|
184
|
+
// big DELETEs so the WAL doesn't stay bloated until the next
|
|
185
|
+
// natural checkpoint.
|
|
186
|
+
// - incremental_vacuum: ONLY reclaims main-DB pages on databases
|
|
187
|
+
// created with `PRAGMA auto_vacuum = INCREMENTAL`. On legacy
|
|
188
|
+
// DBs (the default mode 0) this is a no-op and the main file
|
|
189
|
+
// does NOT shrink — freed pages stay in the freelist for
|
|
190
|
+
// future inserts. Operators who want disk reclamation on old
|
|
191
|
+
// DBs must run a one-time blocking `VACUUM` themselves (out of
|
|
192
|
+
// scope for the sweeper, which deliberately stays non-
|
|
193
|
+
// blocking).
|
|
194
|
+
let walCheckpointPagesMoved: number | null = null;
|
|
195
|
+
let incrementalFreedPages: number | null = null;
|
|
196
|
+
let errored = false;
|
|
197
|
+
try {
|
|
198
|
+
// wal_checkpoint(TRUNCATE) returns a row { busy, log, checkpointed }.
|
|
199
|
+
// We expose `log` (page count moved into the main DB) for ops visibility.
|
|
200
|
+
const checkpoint = db.get<{ busy: number; log: number; checkpointed: number }>(
|
|
201
|
+
"PRAGMA wal_checkpoint(TRUNCATE)",
|
|
202
|
+
);
|
|
203
|
+
walCheckpointPagesMoved = checkpoint?.log ?? null;
|
|
204
|
+
} catch (e: any) {
|
|
205
|
+
console.log(`[commhub retention] wal_checkpoint failed: ${e?.message ?? e}`);
|
|
206
|
+
errored = true;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
// incremental_vacuum is a no-op on databases not created with
|
|
210
|
+
// PRAGMA auto_vacuum = INCREMENTAL. For existing prod DBs that
|
|
211
|
+
// weren't, the operator would have to run a full one-time VACUUM
|
|
212
|
+
// to migrate — out of scope for the sweeper.
|
|
213
|
+
const freelistBefore = db.get<{ freelist_count: number }>("PRAGMA freelist_count");
|
|
214
|
+
db.exec("PRAGMA incremental_vacuum");
|
|
215
|
+
const freelistAfter = db.get<{ freelist_count: number }>("PRAGMA freelist_count");
|
|
216
|
+
incrementalFreedPages =
|
|
217
|
+
(freelistBefore?.freelist_count ?? 0) - (freelistAfter?.freelist_count ?? 0);
|
|
218
|
+
} catch (e: any) {
|
|
219
|
+
console.log(`[commhub retention] incremental_vacuum failed: ${e?.message ?? e}`);
|
|
220
|
+
errored = true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
startedAt,
|
|
225
|
+
durationMs: Date.now() - t0,
|
|
226
|
+
deletes: {
|
|
227
|
+
agent_telemetry: telemetry,
|
|
228
|
+
task_events: taskEvents,
|
|
229
|
+
inbox,
|
|
230
|
+
tasks,
|
|
231
|
+
audit_log: auditLog,
|
|
232
|
+
},
|
|
233
|
+
vacuum: { walCheckpointPagesMoved, incrementalFreedPages, errored },
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Used by index.ts to wire the periodic sweeper. Returns the timer so
|
|
238
|
+
// callers can stop it in tests / shutdown.
|
|
239
|
+
export function startRetentionSweeper(
|
|
240
|
+
intervalMs = 60 * 60 * 1000, // 1 hour
|
|
241
|
+
cfg?: RetentionConfig,
|
|
242
|
+
): ReturnType<typeof setInterval> {
|
|
243
|
+
const sweep = () => {
|
|
244
|
+
try {
|
|
245
|
+
const r = sweepRetention(cfg);
|
|
246
|
+
const totalDeletes =
|
|
247
|
+
r.deletes.agent_telemetry +
|
|
248
|
+
r.deletes.task_events +
|
|
249
|
+
r.deletes.inbox +
|
|
250
|
+
r.deletes.tasks +
|
|
251
|
+
r.deletes.audit_log;
|
|
252
|
+
if (totalDeletes > 0 || r.vacuum.errored) {
|
|
253
|
+
console.log(
|
|
254
|
+
`[commhub retention] swept in ${r.durationMs}ms — ` +
|
|
255
|
+
`telemetry=${r.deletes.agent_telemetry} events=${r.deletes.task_events} ` +
|
|
256
|
+
`inbox=${r.deletes.inbox} tasks=${r.deletes.tasks} audit=${r.deletes.audit_log} ` +
|
|
257
|
+
`walPages=${r.vacuum.walCheckpointPagesMoved} freedPages=${r.vacuum.incrementalFreedPages}`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
} catch (e: any) {
|
|
261
|
+
console.log(`[commhub retention] sweep top-level failed: ${e?.message ?? e}`);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
// Fire once on startup so a long-running hub eventually catches up
|
|
265
|
+
// even if it's restarted just after the previous sweep. setImmediate
|
|
266
|
+
// (vs synchronous) keeps server startup from blocking on the sweep.
|
|
267
|
+
setImmediate(sweep);
|
|
268
|
+
return setInterval(sweep, intervalMs);
|
|
269
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Round-2/4 review ③ — stale session sweeper tests.
|
|
2
|
+
//
|
|
3
|
+
// Drives the real sweepStaleSessions against isolated in-process
|
|
4
|
+
// SQLite (COMMHUB_DB=/tmp/...). Pins behaviour the read-path
|
|
5
|
+
// regression (per-request UPDATE) would re-introduce if anyone
|
|
6
|
+
// undoes the fix:
|
|
7
|
+
//
|
|
8
|
+
// 1. fresh sessions stay non-offline regardless of how many
|
|
9
|
+
// times the sweeper runs;
|
|
10
|
+
// 2. stale sessions get marked offline exactly once (idempotent);
|
|
11
|
+
// 3. global sweep crosses tenant boundaries — a stale agent in
|
|
12
|
+
// network B is marked offline even when no one in net B is
|
|
13
|
+
// polling /api/status;
|
|
14
|
+
// 4. env-var cutoff is respected and bad values fall back silently.
|
|
15
|
+
//
|
|
16
|
+
// Run with:
|
|
17
|
+
// COMMHUB_DB=/tmp/stale-test.db bun test src/stale-sweeper.test.ts
|
|
18
|
+
|
|
19
|
+
import { describe, expect, test, beforeEach } from "bun:test";
|
|
20
|
+
import { db } from "./db.js";
|
|
21
|
+
import { sweepStaleSessions, staleCutoffMinutes } from "./stale-sweeper.js";
|
|
22
|
+
|
|
23
|
+
function insertSession(opts: {
|
|
24
|
+
alias: string;
|
|
25
|
+
status?: string;
|
|
26
|
+
minutesAgo: number;
|
|
27
|
+
network_id?: string;
|
|
28
|
+
}): void {
|
|
29
|
+
const id = `r_${opts.alias}`;
|
|
30
|
+
db.run(
|
|
31
|
+
`INSERT OR REPLACE INTO sessions
|
|
32
|
+
(resume_id, alias, status, network_id, updated_at, registered_at)
|
|
33
|
+
VALUES (?1, ?2, ?3, ?4, datetime('now', ?5), datetime('now', ?5))`,
|
|
34
|
+
[id, opts.alias, opts.status ?? "idle", opts.network_id ?? "default", `-${opts.minutesAgo} minutes`]
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getStatus(alias: string): string | null {
|
|
39
|
+
const row = db.get<{ status: string }>(
|
|
40
|
+
"SELECT status FROM sessions WHERE alias = ?1",
|
|
41
|
+
alias
|
|
42
|
+
);
|
|
43
|
+
return row?.status ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
db.run("DELETE FROM sessions");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("sweepStaleSessions — fresh agents not touched", () => {
|
|
51
|
+
test("agent updated < cutoff stays at its current status", () => {
|
|
52
|
+
insertSession({ alias: "live-1", status: "idle", minutesAgo: 1 });
|
|
53
|
+
insertSession({ alias: "live-2", status: "working", minutesAgo: 5 });
|
|
54
|
+
|
|
55
|
+
sweepStaleSessions(10);
|
|
56
|
+
|
|
57
|
+
expect(getStatus("live-1")).toBe("idle");
|
|
58
|
+
expect(getStatus("live-2")).toBe("working");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("multiple sweeps on quiet hub are pure no-ops (no write fired)", () => {
|
|
62
|
+
insertSession({ alias: "live", status: "idle", minutesAgo: 1 });
|
|
63
|
+
|
|
64
|
+
const r1 = sweepStaleSessions(10);
|
|
65
|
+
const r2 = sweepStaleSessions(10);
|
|
66
|
+
const r3 = sweepStaleSessions(10);
|
|
67
|
+
|
|
68
|
+
expect(r1.markedOffline).toBe(0);
|
|
69
|
+
expect(r2.markedOffline).toBe(0);
|
|
70
|
+
expect(r3.markedOffline).toBe(0);
|
|
71
|
+
expect(getStatus("live")).toBe("idle");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("sweepStaleSessions — stale agents marked offline", () => {
|
|
76
|
+
test("agent updated > cutoff gets marked offline exactly once", () => {
|
|
77
|
+
insertSession({ alias: "stale", status: "idle", minutesAgo: 30 });
|
|
78
|
+
|
|
79
|
+
const r1 = sweepStaleSessions(10);
|
|
80
|
+
expect(r1.markedOffline).toBe(1);
|
|
81
|
+
expect(getStatus("stale")).toBe("offline");
|
|
82
|
+
|
|
83
|
+
// Second sweep does nothing because the row is already offline
|
|
84
|
+
// (the predicate is `status != 'offline'`).
|
|
85
|
+
const r2 = sweepStaleSessions(10);
|
|
86
|
+
expect(r2.markedOffline).toBe(0);
|
|
87
|
+
expect(getStatus("stale")).toBe("offline");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("already-offline stale row not re-marked (write avoided)", () => {
|
|
91
|
+
insertSession({ alias: "old-offline", status: "offline", minutesAgo: 60 });
|
|
92
|
+
|
|
93
|
+
const r = sweepStaleSessions(10);
|
|
94
|
+
|
|
95
|
+
expect(r.markedOffline).toBe(0);
|
|
96
|
+
expect(getStatus("old-offline")).toBe("offline");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("sweepStaleSessions — global across tenants (the read-path fix)", () => {
|
|
101
|
+
test("stale row in network B is marked offline even with no net-B polling", () => {
|
|
102
|
+
// Pre-fix: GET /api/status from net-A would only UPDATE rows in
|
|
103
|
+
// net-A's scope. A stale agent in net-B would stay non-offline
|
|
104
|
+
// until someone polled net-B. After the fix, the global sweeper
|
|
105
|
+
// is tenant-blind on the maintenance write.
|
|
106
|
+
insertSession({ alias: "stale-a", status: "idle", minutesAgo: 30, network_id: "net-A" });
|
|
107
|
+
insertSession({ alias: "stale-b", status: "idle", minutesAgo: 30, network_id: "net-B" });
|
|
108
|
+
insertSession({ alias: "stale-c", status: "idle", minutesAgo: 30, network_id: null as any });
|
|
109
|
+
|
|
110
|
+
const r = sweepStaleSessions(10);
|
|
111
|
+
|
|
112
|
+
expect(r.markedOffline).toBe(3);
|
|
113
|
+
expect(getStatus("stale-a")).toBe("offline");
|
|
114
|
+
expect(getStatus("stale-b")).toBe("offline");
|
|
115
|
+
expect(getStatus("stale-c")).toBe("offline");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("sweepStaleSessions — cutoff boundary + env", () => {
|
|
120
|
+
test("exactly-at-cutoff agent treated as stale (< strict, not <=)", () => {
|
|
121
|
+
// Just past the cutoff window.
|
|
122
|
+
insertSession({ alias: "just-stale", status: "idle", minutesAgo: 11 });
|
|
123
|
+
sweepStaleSessions(10);
|
|
124
|
+
expect(getStatus("just-stale")).toBe("offline");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("just-under-cutoff agent treated as live", () => {
|
|
128
|
+
insertSession({ alias: "just-live", status: "working", minutesAgo: 9 });
|
|
129
|
+
sweepStaleSessions(10);
|
|
130
|
+
expect(getStatus("just-live")).toBe("working");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("env COMMHUB_STALE_CUTOFF_MINUTES overrides default", () => {
|
|
134
|
+
process.env.COMMHUB_STALE_CUTOFF_MINUTES = "3";
|
|
135
|
+
expect(staleCutoffMinutes()).toBe(3);
|
|
136
|
+
delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("env invalid value falls back to default 10 (no operator footgun)", () => {
|
|
140
|
+
process.env.COMMHUB_STALE_CUTOFF_MINUTES = "not-a-number";
|
|
141
|
+
expect(staleCutoffMinutes()).toBe(10);
|
|
142
|
+
delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
|
|
143
|
+
|
|
144
|
+
process.env.COMMHUB_STALE_CUTOFF_MINUTES = "0";
|
|
145
|
+
expect(staleCutoffMinutes()).toBe(10);
|
|
146
|
+
delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
|
|
147
|
+
|
|
148
|
+
process.env.COMMHUB_STALE_CUTOFF_MINUTES = "-5";
|
|
149
|
+
expect(staleCutoffMinutes()).toBe(10);
|
|
150
|
+
delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("sweepStaleSessions — read-path regression guard (pin the fix)", () => {
|
|
155
|
+
// This is the load-bearing pin: if anyone adds back a per-request
|
|
156
|
+
// UPDATE in /api/status / /api/servers / /api/server-detail-* /
|
|
157
|
+
// get_all_status, this test plus the absence of those UPDATEs in
|
|
158
|
+
// the source should make the regression visible. Since this is a
|
|
159
|
+
// unit-level test of the sweeper itself, the regression-prevention
|
|
160
|
+
// role is mostly social — the comments in this file + the comments
|
|
161
|
+
// in index.ts and tools.ts at the former UPDATE sites tell future
|
|
162
|
+
// editors "do not put the UPDATE back; use the sweeper."
|
|
163
|
+
test("after sweep, sessions row has the offline status persisted (single source of truth)", () => {
|
|
164
|
+
insertSession({ alias: "x", status: "working", minutesAgo: 30 });
|
|
165
|
+
const before = sweepStaleSessions(10);
|
|
166
|
+
expect(before.markedOffline).toBe(1);
|
|
167
|
+
|
|
168
|
+
// Re-read directly — no extra UPDATE needed by a caller.
|
|
169
|
+
const status = getStatus("x");
|
|
170
|
+
expect(status).toBe("offline");
|
|
171
|
+
|
|
172
|
+
// 100 simulated read-path lookups don't fire any maintenance writes.
|
|
173
|
+
for (let i = 0; i < 100; i++) {
|
|
174
|
+
const row = db.get<{ status: string }>(
|
|
175
|
+
"SELECT status FROM sessions WHERE alias = ?1",
|
|
176
|
+
"x"
|
|
177
|
+
);
|
|
178
|
+
expect(row?.status).toBe("offline");
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Round-2/4 review ③ — read-path write amplification fix.
|
|
2
|
+
//
|
|
3
|
+
// Before this module: GET /api/status, GET /api/servers, GET
|
|
4
|
+
// /api/server-health/:host, and the MCP `get_all_status` tool each
|
|
5
|
+
// fired:
|
|
6
|
+
//
|
|
7
|
+
// UPDATE sessions SET status = 'offline'
|
|
8
|
+
// WHERE updated_at < ?1 AND status != 'offline'
|
|
9
|
+
//
|
|
10
|
+
// on every call. With the dashboard polling /api/status every few
|
|
11
|
+
// seconds × N tabs × M users, that's a high-frequency WRITE on a
|
|
12
|
+
// heavily-read table just to maintain a derived field. Wait events
|
|
13
|
+
// stack up (WAL fsync, page locks) and the writes are 99% no-ops.
|
|
14
|
+
//
|
|
15
|
+
// Fix: move the stale-marking off the read path into a single
|
|
16
|
+
// background timer. Reads now SELECT only; the timer runs every 60s
|
|
17
|
+
// and applies the same UPDATE GLOBALLY (no per-network scope — stale
|
|
18
|
+
// is stale regardless of which tenant is asking). Worst-case delay
|
|
19
|
+
// between an agent crashing and going `offline` in the dashboard:
|
|
20
|
+
//
|
|
21
|
+
// 10min stale cutoff + 60s sweep = up to ~11min
|
|
22
|
+
//
|
|
23
|
+
// (unchanged from before the fix for any reader that wasn't actively
|
|
24
|
+
// polling).
|
|
25
|
+
//
|
|
26
|
+
// Same pattern as `src/retention.ts` for shutdown (clearInterval in
|
|
27
|
+
// the graceful shutdown handler).
|
|
28
|
+
|
|
29
|
+
import { db } from "./db.js";
|
|
30
|
+
|
|
31
|
+
export type StaleSweepResult = {
|
|
32
|
+
startedAt: string;
|
|
33
|
+
durationMs: number;
|
|
34
|
+
markedOffline: number;
|
|
35
|
+
staleCutoffMinutes: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Stale window: an agent that hasn't reported in this many minutes is
|
|
39
|
+
// considered offline. Matches the pre-fix per-request cutoff so the
|
|
40
|
+
// observable behaviour from the dashboard's perspective is unchanged.
|
|
41
|
+
const DEFAULT_STALE_MINUTES = 10;
|
|
42
|
+
const DEFAULT_SWEEP_MS = 60 * 1000;
|
|
43
|
+
|
|
44
|
+
export function staleCutoffMinutes(): number {
|
|
45
|
+
const raw = process.env.COMMHUB_STALE_CUTOFF_MINUTES;
|
|
46
|
+
if (raw === undefined || raw === "") return DEFAULT_STALE_MINUTES;
|
|
47
|
+
const n = Number(raw);
|
|
48
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_STALE_MINUTES;
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sqliteCutoff(minutes: number): string {
|
|
53
|
+
return new Date(Date.now() - minutes * 60 * 1000)
|
|
54
|
+
.toISOString()
|
|
55
|
+
.replace("T", " ")
|
|
56
|
+
.slice(0, 19);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Single GLOBAL UPDATE — no per-network filtering. The pre-fix per-
|
|
60
|
+
// request stale marker WAS scoped (each REST call only saw its own
|
|
61
|
+
// network's stale rows), but the semantics of "this row hasn't been
|
|
62
|
+
// heard from in 10min" is global. A network that no one's looking at
|
|
63
|
+
// still wants its agents marked offline so an admin scanning all
|
|
64
|
+
// networks gets consistent state.
|
|
65
|
+
export function sweepStaleSessions(cutoffMinutes = staleCutoffMinutes()): StaleSweepResult {
|
|
66
|
+
const startedAt = new Date().toISOString();
|
|
67
|
+
const t0 = Date.now();
|
|
68
|
+
let markedOffline = 0;
|
|
69
|
+
try {
|
|
70
|
+
const cutoff = sqliteCutoff(cutoffMinutes);
|
|
71
|
+
const res = db.run(
|
|
72
|
+
"UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'",
|
|
73
|
+
[cutoff],
|
|
74
|
+
);
|
|
75
|
+
markedOffline = res.changes ?? 0;
|
|
76
|
+
} catch (e: any) {
|
|
77
|
+
console.log(`[commhub stale-sweep] failed: ${e?.message ?? e}`);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
startedAt,
|
|
81
|
+
durationMs: Date.now() - t0,
|
|
82
|
+
markedOffline,
|
|
83
|
+
staleCutoffMinutes: cutoffMinutes,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function startStaleSessionSweeper(
|
|
88
|
+
intervalMs?: number,
|
|
89
|
+
): ReturnType<typeof setInterval> {
|
|
90
|
+
const env = process.env.COMMHUB_STALE_SWEEP_SECONDS;
|
|
91
|
+
const envMs = env && Number.isFinite(Number(env)) && Number(env) > 0
|
|
92
|
+
? Number(env) * 1000
|
|
93
|
+
: undefined;
|
|
94
|
+
const ms = intervalMs ?? envMs ?? DEFAULT_SWEEP_MS;
|
|
95
|
+
const sweep = () => {
|
|
96
|
+
try {
|
|
97
|
+
const r = sweepStaleSessions();
|
|
98
|
+
// Log only when something actually changed — quiet hubs stay quiet.
|
|
99
|
+
if (r.markedOffline > 0) {
|
|
100
|
+
console.log(
|
|
101
|
+
`[commhub stale-sweep] marked ${r.markedOffline} session(s) offline in ${r.durationMs}ms ` +
|
|
102
|
+
`(cutoff=${r.staleCutoffMinutes}min)`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
} catch (e: any) {
|
|
106
|
+
console.log(`[commhub stale-sweep] top-level failed: ${e?.message ?? e}`);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
// Fire once at boot via setImmediate so the very-first /api/status
|
|
110
|
+
// after server start gets fresh stale state without blocking
|
|
111
|
+
// startup.
|
|
112
|
+
setImmediate(sweep);
|
|
113
|
+
return setInterval(sweep, ms);
|
|
114
|
+
}
|