feat(logs): coalesce repeated identical lines into one row (×N badge)

A line identical to the last persisted row (level+source+message+path)
within a 5-min refreshing window updates that row — context._repeat counts
the fold, _firstAt keeps the first occurrence, createdAt tracks the latest
so the storm stays at the top of the newest-first viewer. A continuous
storm stays ONE row however long it rages, so it can't evict unrelated
history via the 50k row cap or grind the appliance disk. LogsViewer badges
coalesced rows ×N (tooltip: count + first occurrence, sq/en). In-memory
last-row cache only; a pruned-under-us row falls through to a fresh insert.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-10 08:29:43 +02:00
parent e2d5105da2
commit 51b160bfc9
5 changed files with 137 additions and 7 deletions
+63 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { appLogs, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { LogService, pinoDbStream } from "./log-service.js";
@@ -52,3 +52,65 @@ describe("pinoDbStream level encodings", () => {
expect(teed).toHaveLength(2);
});
});
// Storm coalescing: a line identical to the LAST persisted row (level+source+message+
// path), arriving within 5 min of its previous occurrence, UPDATES that row (bumping
// context._repeat) instead of inserting — one screaming device can't evict unrelated
// history. The row's createdAt tracks the LATEST occurrence; the first is preserved in
// context._firstAt.
describe("storm coalescing", () => {
afterEach(() => {
vi.useRealTimers();
});
it("folds a burst of identical error lines into ONE row with a repeat counter", () => {
for (let i = 0; i < 200; i++) {
stream.write(`{"level":"error","msg":"button-light setAux failed (ctl R3): send ENETUNREACH"}\n`);
}
const all = rows();
expect(all).toHaveLength(1);
expect(all[0].context).toMatchObject({ _repeat: 200 });
expect(teed).toHaveLength(200); // stdout still gets every line
});
it("keeps first-occurrence time in _firstAt while createdAt tracks the latest", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
stream.write(`{"level":"warn","msg":"same"}\n`);
vi.setSystemTime(new Date("2026-07-08T10:02:00.000Z"));
stream.write(`{"level":"warn","msg":"same"}\n`);
const [row] = rows();
expect(row.createdAt).toBe("2026-07-08T10:02:00.000Z");
expect(row.context).toMatchObject({ _repeat: 2, _firstAt: "2026-07-08T10:00:00.000Z" });
});
it("a different message (or level) breaks the run — separate rows", () => {
stream.write(`{"level":"error","msg":"boom A"}\n`);
stream.write(`{"level":"error","msg":"boom A"}\n`);
stream.write(`{"level":"error","msg":"boom B"}\n`);
stream.write(`{"level":"warn","msg":"boom B"}\n`);
expect(rows()).toHaveLength(3);
});
it("an occurrence past the 5-minute window starts a fresh row", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
stream.write(`{"level":"error","msg":"slow leak"}\n`);
vi.setSystemTime(new Date("2026-07-08T10:06:00.000Z"));
stream.write(`{"level":"error","msg":"slow leak"}\n`);
expect(rows()).toHaveLength(2);
});
it("a CONTINUOUS storm stays one row past the window (each hit refreshes it)", () => {
vi.useFakeTimers();
let t = new Date("2026-07-08T10:00:00.000Z").getTime();
for (let i = 0; i < 10; i++) {
vi.setSystemTime(new Date(t));
stream.write(`{"level":"error","msg":"storm"}\n`);
t += 240_000; // 4 min apart — each inside the window of the PREVIOUS hit
}
const all = rows();
expect(all).toHaveLength(1);
expect(all[0].context).toMatchObject({ _repeat: 10 });
});
});