|
|
@@ -38,6 +38,8 @@ const mint = async (username: string, scope: string) => |
| 38 | 38 |
}) |
| 39 | 39 |
).json()) as { token: string } |
| 40 | 40 |
).token; |
| 41 |
|
| 42 |
const roomy = () => ({ freeBytes: 95, totalBytes: 100 }); |
| 41 | 43 |
const health = (token?: string) => |
| 42 | 44 |
fetch(servers.url + "/api/v1/health", { |
| 43 | 45 |
headers: token ? { authorization: `Bearer ${token}` } : {}, |
|
|
@@ -45,7 +47,7 @@ const health = (token?: string) => |
| 45 | 47 |
|
| 46 | 48 |
beforeAll(async () => { |
| 47 | 49 |
dataDir = await mkdtemp(join(tmpdir(), "forge-vitals-")); |
| 48 | |
app = createApp({ dataDir, port: 0 }); |
| 50 |
app = createApp({ dataDir, port: 0, disk: roomy }); |
| 49 | 51 |
servers = await serve(app); |
| 50 | 52 |
for (const username of ["alice", "bob"]) |
| 51 | 53 |
await post("/api/v1/users", { |
|
|
@@ -107,11 +109,11 @@ test("a full queue shows in queue, and an old one in needs", async () => { |
| 107 | 109 |
app.db.query("DELETE FROM run").run(); |
| 108 | 110 |
const old = Date.now() - 3_600_000; |
| 109 | 111 |
for (let i = 0; i < 3; i++) createRun(app.db, { repoId, kind: "agent" }); |
| 110 | |
const v = vitals({ db: app.db, signals: app.signals, dataDir }); |
| 112 |
const v = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }); |
| 111 | 113 |
expect(v.queue).toEqual({ agent: 3 }); |
| 112 | 114 |
|
| 113 | 115 |
expect(v.needs.join(" ")).not.toContain("queue"); |
| 114 | |
const later = vitals({ db: app.db, signals: app.signals, dataDir, now: old + 2 * 3_600_000 + 3_600_000 }); |
| 116 |
const later = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now: old + 2 * 3_600_000 + 3_600_000 }); |
| 115 | 117 |
expect(later.ok).toBe(false); |
| 116 | 118 |
expect(later.needs.join(" ")).toContain("agent queue"); |
| 117 | 119 |
}); |
|
|
@@ -132,7 +134,7 @@ test("a running run with a lapsed lease is stuck, and needs says so", async () = |
| 132 | 134 |
app.db |
| 133 | 135 |
.query("UPDATE run SET heartbeat_at=?2 WHERE id=?1") |
| 134 | 136 |
.run(run.id, new Date().toISOString()); |
| 135 | |
expect(vitals({ db: app.db, signals: app.signals, dataDir }).stuck).toEqual([]); |
| 137 |
expect(vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }).stuck).toEqual([]); |
| 136 | 138 |
}); |
| 137 | 139 |
|
| 138 | 140 |
test("the number of queries does not grow with runs", () => { |
|
|
@@ -145,7 +147,7 @@ test("the number of queries does not grow with runs", () => { |
| 145 | 147 |
return typeof v === "function" ? v.bind(t) : v; |
| 146 | 148 |
}, |
| 147 | 149 |
}); |
| 148 | |
vitals({ db: counting, signals: app.signals, dataDir }); |
| 150 |
vitals({ db: counting, signals: app.signals, dataDir, disk: roomy }); |
| 149 | 151 |
return n; |
| 150 | 152 |
}; |
| 151 | 153 |
const before = count(); |
|
|
@@ -182,8 +184,8 @@ test("errors are counted, bounded, redacted, and a burst is said", async () => { |
| 182 | 184 |
expect(seen.latest.length).toBe(ERROR_RING); |
| 183 | 185 |
expect(seen.lastHour).toBe(ERROR_RING + 51); |
| 184 | 186 |
expect(seen.lastHour).toBeGreaterThanOrEqual(ERROR_BURST); |
| 185 | |
expect(vitals({ db: app.db, signals: app.signals, dataDir }).needs.some((n) => n.includes("errors in the last hour"))).toBe(true); |
| 186 | |
expect(vitals({ db: app.db, signals: app.signals, dataDir, now: Date.now() + 2 * 3_600_000 }).errors.lastHour).toBe(0); |
| 187 |
expect(vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }).needs.some((n) => n.includes("errors in the last hour"))).toBe(true); |
| 188 |
expect(vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now: Date.now() + 2 * 3_600_000 }).errors.lastHour).toBe(0); |
| 187 | 189 |
} finally { |
| 188 | 190 |
console.error = orig; |
| 189 | 191 |
clearErrors(); |
|
|
@@ -196,10 +198,10 @@ test("the lane: depth, the running check and its age, the median of the last fin |
| 196 | 198 |
const now = Date.now(); |
| 197 | 199 |
const iso = (ms: number) => new Date(ms).toISOString(); |
| 198 | 200 |
|
| 199 | |
expect(vitals({ db: app.db, signals: app.signals, dataDir, now }).lane).toEqual({ |
| 201 |
expect(vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now }).lane).toEqual({ |
| 200 | 202 |
queued: 0, running: null, medianMinutes: null, clearsInMinutes: null, |
| 201 | 203 |
}); |
| 202 | |
expect(laneLine(vitals({ db: app.db, signals: app.signals, dataDir, now }).lane, now)).toBeNull(); |
| 204 |
expect(laneLine(vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now }).lane, now)).toBeNull(); |
| 203 | 205 |
|
| 204 | 206 |
for (const min of [10, 20, 30]) { |
| 205 | 207 |
const r = createRun(app.db, { repoId, kind: "check" }); |
|
|
@@ -212,7 +214,7 @@ test("the lane: depth, the running check and its age, the median of the last fin |
| 212 | 214 |
.query("UPDATE run SET state='running', started_at=?2, heartbeat_at=?3 WHERE id=?1") |
| 213 | 215 |
.run(live.id, iso(now - 6 * 60_000), iso(now)); |
| 214 | 216 |
for (let i = 0; i < 6; i++) createRun(app.db, { repoId, kind: "check" }); |
| 215 | |
const lane = vitals({ db: app.db, signals: app.signals, dataDir, now }).lane; |
| 217 |
const lane = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now }).lane; |
| 216 | 218 |
expect(lane.queued).toBe(6); |
| 217 | 219 |
expect(lane.running?.since).toBe(iso(now - 6 * 60_000)); |
| 218 | 220 |
expect(lane.medianMinutes).toBe(20); |
|
|
@@ -225,7 +227,7 @@ test("the lane: depth, the running check and its age, the median of the last fin |
| 225 | 227 |
expect(body.lane.queued).toBe(6); |
| 226 | 228 |
expect(body.lane.medianMinutes).toBe(20); |
| 227 | 229 |
|
| 228 | |
const later = vitals({ db: app.db, signals: app.signals, dataDir, now: now + 3_600_000 }); |
| 230 |
const later = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now: now + 3_600_000 }); |
| 229 | 231 |
const cause = later.needs.find((n) => n.includes("one lane")); |
| 230 | 232 |
expect(cause).toMatch(/^6 checks wait behind one lane; the oldest since \d\d:\d\d; the lane runs nothing\.$/); |
| 231 | 233 |
expect(cause).not.toContain("Lane:"); |
|
|
@@ -234,7 +236,7 @@ test("the lane: depth, the running check and its age, the median of the last fin |
| 234 | 236 |
test("an unauthenticated internal call is refused, logged and flagged; the probe is not", async () => { |
| 235 | 237 |
app.signals.refused.clear(); |
| 236 | 238 |
const flagged = () => |
| 237 | |
vitals({ db: app.db, signals: app.signals, dataDir: app.config.dataDir }).needs.filter((n) => |
| 239 |
vitals({ db: app.db, signals: app.signals, dataDir: app.config.dataDir, disk: roomy }).needs.filter((n) => |
| 238 | 240 |
n.includes("internal call"), |
| 239 | 241 |
); |
| 240 | 242 |
const call = (path: string) => |
|
|
@@ -258,10 +260,10 @@ test("two forges in one process keep their own error and refusal signals", async |
| 258 | 260 |
(await import("../src/kernel/log.ts")).clearErrors(); |
| 259 | 261 |
app.signals.refused.clear(); |
| 260 | 262 |
const dirB = await mkdtemp(join(tmpdir(), "forge-vitals-b-")); |
| 261 | |
const other = createApp({ dataDir: dirB, port: 0 }); |
| 263 |
const other = createApp({ dataDir: dirB, port: 0, disk: roomy }); |
| 262 | 264 |
try { |
| 263 | 265 |
const needs = (a: App) => |
| 264 | |
vitals({ db: a.db, dataDir: a.config.dataDir, signals: a.signals }).needs; |
| 266 |
vitals({ db: a.db, dataDir: a.config.dataDir, signals: a.signals, disk: roomy }).needs; |
| 265 | 267 |
expect(needs(app)).toEqual([]); |
| 266 | 268 |
expect(needs(other)).toEqual([]); |
| 267 | 269 |
for (let i = 0; i < ERROR_BURST; i++) other.log.error("boom"); |
|
|
@@ -275,7 +277,7 @@ test("two forges in one process keep their own error and refusal signals", async |
| 275 | 277 |
expect(needs(app).join(" ")).toContain("errors in the last hour"); |
| 276 | 278 |
|
| 277 | 279 |
const dirC = await mkdtemp(join(tmpdir(), "forge-vitals-c-")); |
| 278 | |
const later = createApp({ dataDir: dirC, port: 0 }); |
| 280 |
const later = createApp({ dataDir: dirC, port: 0, disk: roomy }); |
| 279 | 281 |
expect(needs(later)).toEqual([]); |
| 280 | 282 |
later.close(); |
| 281 | 283 |
await rm(dirC, { recursive: true, force: true }); |
|
|
@@ -294,7 +296,7 @@ test("degraded names each cause, stable, with when it began", async () => { |
| 294 | 296 |
try { |
| 295 | 297 |
clearErrors(); |
| 296 | 298 |
app.signals.refused.clear(); |
| 297 | |
const base = vitals({ db: app.db, signals: app.signals, dataDir }); |
| 299 |
const base = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }); |
| 298 | 300 |
expect(base.degraded.map((d) => d.cause)).not.toContain("errors"); |
| 299 | 301 |
|
| 300 | 302 |
|
|
|
@@ -304,7 +306,7 @@ test("degraded names each cause, stable, with when it began", async () => { |
| 304 | 306 |
app.db.query("SELECT MIN(created_at) AS m FROM run WHERE state='queued' AND kind='agent'").get() as { m: string } |
| 305 | 307 |
).m; |
| 306 | 308 |
const later = Date.now() + 2 * QUEUE_WAIT_MAX_MS; |
| 307 | |
const q = vitals({ db: app.db, signals: app.signals, dataDir, now: later }).degraded.find((d) => d.cause === "queue:agent"); |
| 309 |
const q = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now: later }).degraded.find((d) => d.cause === "queue:agent"); |
| 308 | 310 |
expect(q?.since).toBe(new Date(oldest).toISOString()); |
| 309 | 311 |
expect(q?.href).toBe("/settings/capacity"); |
| 310 | 312 |
|
|
|
@@ -314,7 +316,7 @@ test("degraded names each cause, stable, with when it began", async () => { |
| 314 | 316 |
app.db |
| 315 | 317 |
.query("UPDATE run SET state='running', started_at=?2, heartbeat_at=?2 WHERE id=?1") |
| 316 | 318 |
.run(run.id, moved); |
| 317 | |
const s = vitals({ db: app.db, signals: app.signals, dataDir }); |
| 319 |
const s = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }); |
| 318 | 320 |
const entry = s.degraded.find((d) => d.cause === `stuck:${run.id}`); |
| 319 | 321 |
expect(entry?.since).toBe(moved); |
| 320 | 322 |
expect(entry?.href).toBe(`/alice/r/runs/${run.id}`); |
|
|
@@ -324,37 +326,37 @@ test("degraded names each cause, stable, with when it began", async () => { |
| 324 | 326 |
|
| 325 | 327 |
const log = createLog("probe"); |
| 326 | 328 |
for (let i = 0; i < ERROR_BURST; i++) log.error(`boom ${i}`); |
| 327 | |
const a = vitals({ db: app.db, signals: app.signals, dataDir }); |
| 329 |
const a = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }); |
| 328 | 330 |
const e = a.degraded.find((d) => d.cause === "errors"); |
| 329 | |
const later2 = vitals({ db: app.db, signals: app.signals, dataDir, now: Date.now() + 5000 }); |
| 331 |
const later2 = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now: Date.now() + 5000 }); |
| 330 | 332 |
expect(e?.since).toBe(app.signals.errors.recent(100).oldest!); |
| 331 | 333 |
expect(later2.degraded.find((d) => d.cause === "errors")?.since).toBe(e!.since); |
| 332 | 334 |
expect(e?.href).toBe("/settings/capacity#errors"); |
| 333 | 335 |
|
| 334 | 336 |
|
| 335 | 337 |
await fetchInternal(app.hookAddress.current, "/internal/hook", { method: "POST" }); |
| 336 | |
const i = vitals({ db: app.db, signals: app.signals, dataDir }).degraded.find((d) => d.cause === "internal"); |
| 338 |
const i = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }).degraded.find((d) => d.cause === "internal"); |
| 337 | 339 |
expect(i?.href).toBe("/settings/capacity#internal"); |
| 338 | |
const i2 = vitals({ db: app.db, signals: app.signals, dataDir, now: Date.now() + 5000 }).degraded.find((d) => d.cause === "internal"); |
| 340 |
const i2 = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now: Date.now() + 5000 }).degraded.find((d) => d.cause === "internal"); |
| 339 | 341 |
expect(i2?.since).toBe(i!.since); |
| 340 | 342 |
expect(i!.since).toBe(app.signals.refused.recent().oldest!); |
| 341 | 343 |
|
| 342 | 344 |
|
| 343 | 345 |
const first = e!.since; |
| 344 | 346 |
clearErrors(); |
| 345 | |
expect(vitals({ db: app.db, signals: app.signals, dataDir }).degraded.some((d) => d.cause === "errors")).toBe(false); |
| 347 |
expect(vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }).degraded.some((d) => d.cause === "errors")).toBe(false); |
| 346 | 348 |
for (let k = 0; k < ERROR_BURST; k++) log.error(`again ${k}`); |
| 347 | |
const again = vitals({ db: app.db, signals: app.signals, dataDir }).degraded.find((d) => d.cause === "errors"); |
| 349 |
const again = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }).degraded.find((d) => d.cause === "errors"); |
| 348 | 350 |
expect(again?.since).toBe(app.signals.errors.recent(100).oldest!); |
| 349 | 351 |
expect(again!.since > first).toBe(true); |
| 350 | 352 |
|
| 351 | 353 |
|
| 352 | |
const early = vitals({ db: app.db, signals: app.signals, dataDir, diskFreeMin: 2, now: 1_000 }); |
| 354 |
const early = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, diskFreeMin: 2, now: 1_000 }); |
| 353 | 355 |
const d0 = early.degraded.find((d) => d.cause === "disk")!; |
| 354 | 356 |
expect(Date.parse(d0.since)).toBeGreaterThan(Date.parse("2020-01-01")); |
| 355 | 357 |
|
| 356 | 358 |
|
| 357 | |
const all = vitals({ db: app.db, signals: app.signals, dataDir }); |
| 359 |
const all = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }); |
| 358 | 360 |
expect(new Set(all.degraded.map((d) => d.sentence))).toEqual(new Set(all.needs)); |
| 359 | 361 |
|
| 360 | 362 |
|
|
|
@@ -368,17 +370,17 @@ test("degraded names each cause, stable, with when it began", async () => { |
| 368 | 370 |
}); |
| 369 | 371 |
|
| 370 | 372 |
test("disk: since rests on first sight, holds across calls, resets after clearing", async () => { |
| 371 | |
expect(vitals({ db: app.db, signals: app.signals, dataDir }).degraded.some((d) => d.cause === "disk")).toBe(false); |
| 372 | |
const a = vitals({ db: app.db, signals: app.signals, dataDir, diskFreeMin: 2 }).degraded.find((d) => d.cause === "disk"); |
| 373 |
expect(vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }).degraded.some((d) => d.cause === "disk")).toBe(false); |
| 374 |
const a = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, diskFreeMin: 2 }).degraded.find((d) => d.cause === "disk"); |
| 373 | 375 |
expect(a?.href).toBe("/settings/capacity"); |
| 374 | 376 |
await Bun.sleep(5); |
| 375 | |
const b = vitals({ db: app.db, signals: app.signals, dataDir, diskFreeMin: 2 }).degraded.find((d) => d.cause === "disk"); |
| 377 |
const b = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, diskFreeMin: 2 }).degraded.find((d) => d.cause === "disk"); |
| 376 | 378 |
expect(b?.since).toBe(a!.since); |
| 377 | |
expect(vitals({ db: app.db, signals: app.signals, dataDir }).degraded.some((d) => d.cause === "disk")).toBe(false); |
| 379 |
expect(vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }).degraded.some((d) => d.cause === "disk")).toBe(false); |
| 378 | 380 |
await Bun.sleep(5); |
| 379 | |
const c = vitals({ db: app.db, signals: app.signals, dataDir, diskFreeMin: 2 }).degraded.find((d) => d.cause === "disk"); |
| 381 |
const c = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, diskFreeMin: 2 }).degraded.find((d) => d.cause === "disk"); |
| 380 | 382 |
expect(c!.since > a!.since).toBe(true); |
| 381 | |
vitals({ db: app.db, signals: app.signals, dataDir }); |
| 383 |
vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }); |
| 382 | 384 |
}); |
| 383 | 385 |
|
| 384 | 386 |
test("since is said as a person would", async () => { |
|
|
@@ -395,10 +397,10 @@ test("since is said as a person would", async () => { |
| 395 | 397 |
|
| 396 | 398 |
test("since is kept per forge: one forge's cause neither lends nor loses its start to another", async () => { |
| 397 | 399 |
const dirB = await mkdtemp(join(tmpdir(), "forge-vitals-since-")); |
| 398 | |
const other = createApp({ dataDir: dirB, port: 0 }); |
| 400 |
const other = createApp({ dataDir: dirB, port: 0, disk: roomy }); |
| 399 | 401 |
try { |
| 400 | 402 |
const disk = (a: App, diskFreeMin: number) => |
| 401 | |
vitals({ db: a.db, dataDir: a.config.dataDir, signals: a.signals, diskFreeMin }) |
| 403 |
vitals({ db: a.db, dataDir: a.config.dataDir, signals: a.signals, diskFreeMin, disk: roomy }) |
| 402 | 404 |
.degraded.find((d) => d.cause === "disk"); |
| 403 | 405 |
const first = disk(app, 2)!; |
| 404 | 406 |
expect(first).toBeDefined(); |
|
|
@@ -428,7 +430,7 @@ test("a lane older than its budget is degraded with its cause; a silent run is s |
| 428 | 430 |
app.db.query("UPDATE run SET created_at=?2 WHERE id=?1").run(r.id, iso(now - (9 + i * 2) * 60_000)); |
| 429 | 431 |
} |
| 430 | 432 |
|
| 431 | |
const v = vitals({ db: app.db, signals: app.signals, dataDir, now }); |
| 433 |
const v = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now }); |
| 432 | 434 |
const lane = v.degraded.find((d) => d.cause === "queue:check"); |
| 433 | 435 |
expect(lane?.sentence).toMatch( |
| 434 | 436 |
/^3 checks wait behind one lane; the oldest since \d\d:\d\d; the lane runs a check for 31 min \(limit 60\)\.$/, |
|
|
@@ -436,10 +438,18 @@ test("a lane older than its budget is degraded with its cause; a silent run is s |
| 436 | 438 |
expect(v.ok).toBe(false); |
| 437 | 439 |
|
| 438 | 440 |
app.db.query("UPDATE run SET created_at=?1 WHERE state='queued'").run(iso(now - 5 * 60_000)); |
| 439 | |
expect(vitals({ db: app.db, signals: app.signals, dataDir, now }).degraded.map((d) => d.cause)).toEqual([]); |
| 441 |
expect(vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now }).degraded.map((d) => d.cause)).toEqual([]); |
| 440 | 442 |
|
| 441 | 443 |
app.db.query("UPDATE run SET heartbeat_at=?2 WHERE id=?1").run(live.id, iso(now - 4 * 60_000)); |
| 442 | |
const stuck = vitals({ db: app.db, signals: app.signals, dataDir, now }).degraded.find((d) => d.cause === `stuck:${live.id}`); |
| 444 |
const stuck = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now }).degraded.find((d) => d.cause === `stuck:${live.id}`); |
| 443 | 445 |
expect(stuck?.sentence).toBe(`Run ${live.id} is stuck: no heartbeat for 4 min (limit 3).`); |
| 444 | 446 |
app.db.query("DELETE FROM run").run(); |
| 445 | 447 |
}); |
| 448 |
|
| 449 |
test("only the disk need sees the disk: a 5% reading raises it, and nothing else does", () => { |
| 450 |
const low = () => ({ freeBytes: 5, totalBytes: 100 }); |
| 451 |
const v = vitals({ db: app.db, signals: app.signals, dataDir, disk: low }); |
| 452 |
expect(v.degraded.map((d) => d.cause)).toEqual(["disk"]); |
| 453 |
expect(v.needs).toEqual(["Disk is nearly full: 5% free."]); |
| 454 |
expect(vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy }).needs).toEqual([]); |
| 455 |
}); |