Both caught a failed write and took the event, losing it silently; the SDK's rule is to throw when the work was not done. A failed write now throws so the bus offers the event again, and is spooled on disk at once; on its last delivery the spooled event is taken, and a background pass replays the spool once writing works. The runtime does not pass the delivery count, so the spool counts failed deliveries itself, across restarts. Over its bound (1000 events or 30 minutes) the last delivery is no longer taken, so the bus gives it up and the controller raises max-deliveries - the one existing condition that names a consumer which cannot keep up - while the spool still holds it. Writes are idempotent by event: the trail skips an id it already wrote; the usage upsert keeps the reading observed latest (migration 2), so a late replay never overwrites a newer one. Each module has a status tool for the spool, declared as valuable data (ADR 0233). model-usage moves to the bundle shape (ADR 0198) with its schema in a prepare step and numbered migrations; its old container shape had no image. Both on mesh-sdk 0.1.13. The log-only handlers of redis, mssql, mosquitto, mongodb, mesh-vault, showcase and the catalogue no longer throw a TypeError on an event without a body.
118 lines
4.2 KiB
TypeScript
118 lines
4.2 KiB
TypeScript
// The audit handler — appends one line per event to an append-only audit log. It is the whole of
|
|
// the module's code: an audit logger is not a privileged component, only a module that listens to
|
|
// everything and writes it down (novox/hq ADR 0041).
|
|
//
|
|
// **Written once per event.** Delivery is at-least-once, and since novox/hq issue 276 a failed write
|
|
// is asked for again and an event that keeps failing is replayed from the spool — so the same event can
|
|
// reach the trail more than once. An append-only file has no ON CONFLICT; its equivalent is the SDK's
|
|
// other answer ("Taking an event"): skip an event id already written, remembering it only once the line
|
|
// is on disk. The ids remembered are the most recent ones, seeded from the end of the trail on start,
|
|
// which covers every redelivery (seconds apart) and a restart between a write and its answer.
|
|
|
|
import { mkdir, open, stat } from "node:fs/promises";
|
|
import { dirname } from "node:path";
|
|
import type { Event } from "@novox/mesh-sdk/events";
|
|
import { keyOf } from "./spool.js";
|
|
|
|
/** Where the trail is written. A directory the host applies; one file per node. */
|
|
export function auditLogPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
return env.AUDIT_LOG ?? "/var/lib/audit-logger/audit.log";
|
|
}
|
|
|
|
/** Where an event that could not be written waits (see spool.ts). Beside the trail unless said. */
|
|
export function auditSpoolPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
return env.AUDIT_SPOOL ?? `${dirname(auditLogPath(env))}/spool`;
|
|
}
|
|
|
|
/** One event as the trail's line, keeping the metadata an audit needs first. The id is the event's own
|
|
* x-event-id (ADR 0042) — the handle a reader dedups the at-least-once trail on. */
|
|
export function lineOf(event: Event): string {
|
|
return (
|
|
JSON.stringify({
|
|
id: event.id,
|
|
type: event.type,
|
|
source: event.source,
|
|
node: event.node,
|
|
at: event.at,
|
|
...(event.causationId ? { causationId: event.causationId } : {}),
|
|
...(event.schema ? { schema: event.schema } : {}),
|
|
body: event.body ?? null,
|
|
}) + "\n"
|
|
);
|
|
}
|
|
|
|
/** How many recent event ids a trail remembers, and how much of its end it reads to seed them. */
|
|
const REMEMBERED = 50_000;
|
|
const SEED_BYTES = 8 * 1024 * 1024;
|
|
|
|
export class Trail {
|
|
private readonly seen = new Map<string, true>();
|
|
|
|
private constructor(readonly path: string) {}
|
|
|
|
/** Open the trail at `path`, remembering the ids at its end. */
|
|
static async open(path: string): Promise<Trail> {
|
|
const t = new Trail(path);
|
|
await t.seed();
|
|
return t;
|
|
}
|
|
|
|
has(event: Event): boolean {
|
|
return this.seen.has(keyOf(event));
|
|
}
|
|
|
|
/** Append the event as one line and sync it, unless this trail already holds it. Throws when the
|
|
* line could not be written — the handler's cue to ask for the event again. */
|
|
async record(event: Event): Promise<void> {
|
|
const key = keyOf(event);
|
|
if (this.seen.has(key)) return;
|
|
await mkdir(dirname(this.path), { recursive: true }).catch(() => {});
|
|
const fh = await open(this.path, "a", 0o600);
|
|
try {
|
|
await fh.appendFile(lineOf(event));
|
|
await fh.datasync();
|
|
} finally {
|
|
await fh.close();
|
|
}
|
|
this.remember(key);
|
|
}
|
|
|
|
private remember(key: string): void {
|
|
this.seen.set(key, true);
|
|
if (this.seen.size > REMEMBERED) {
|
|
const first = this.seen.keys().next().value;
|
|
if (first !== undefined) this.seen.delete(first);
|
|
}
|
|
}
|
|
|
|
private async seed(): Promise<void> {
|
|
let size: number;
|
|
try {
|
|
size = (await stat(this.path)).size;
|
|
} catch {
|
|
return; // no trail yet
|
|
}
|
|
const from = Math.max(0, size - SEED_BYTES);
|
|
const fh = await open(this.path, "r");
|
|
let text: string;
|
|
try {
|
|
const buf = Buffer.alloc(size - from);
|
|
await fh.read(buf, 0, buf.length, from);
|
|
text = buf.toString("utf8");
|
|
} finally {
|
|
await fh.close();
|
|
}
|
|
const lines = text.split("\n");
|
|
if (from > 0) lines.shift(); // the first is cut
|
|
for (const line of lines) {
|
|
if (!line) continue;
|
|
try {
|
|
const l = JSON.parse(line);
|
|
this.remember(keyOf({ id: l.id ?? "", type: l.type, source: l.source, node: l.node, at: l.at, body: l.body }));
|
|
} catch {
|
|
// a line cut short by a failed write: its event was asked for again
|
|
}
|
|
}
|
|
}
|
|
}
|