mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
* feat+test: D58 — server.mjs streaming singleflight wiring (ADR 0005 Amendment 8 §§7,11,12, issue #16) Second of three D-days for v1.x roadmap #1. D57 landed the cache-layer primitive (cacheStore.getOrComputeStreaming); D58 wires it into the streaming branch of server.mjs and adds the X-OLP-Streaming-Inflight header. D59 closes issue #16 + polishes README known-limitations. ## What server.mjs — replaced the streaming branch (formerly the peek+spawn pattern at lines 1138-1327) with cacheStore.getOrComputeStreaming(...): - tryAcquireSpawn moves INSIDE sourceFactory closure (§7). Only first caller acquires; attached joiners share the slot. releaseSpawn lives in the source generator try/finally so it fires once on source completion / error / abort. - CONCURRENCY_LIMIT thrown by the factory triggers fallthrough to the buffered path (preserving today's behaviour); any other pre-stream factory error surfaces a 502. - X-OLP-Streaming-Inflight: source | attached header per §11 (cache_hit role omits — X-OLP-Cache: hit already says it). The §11 'solo' value is deferred to a future amendment — observable only post-stream via the streaming_inflight_source_done log event's attached_count. - auditCtx.cache_status: 'miss' for source, 'streaming_attached' for joiners, 'hit' for the TTL-race cache_hit branch. - res.on('close', () => stream.return?.()) propagates client disconnect into the tee's attachedClients accounting (§9). Note: Node 25 emits 'close' on ServerResponse, NOT on IncomingMessage — empirically verified in test 28g. - Cache writes now happen inside the cache layer's tee task on source completion (§4). Server still issues cacheStore.delete on stop-less exhaustion to preserve D16 truncated-not-cached invariant — the cache layer is IR-agnostic and writes accumulatedChunks unconditionally; the IR-aware server deletes the entry if no stop chunk was observed. - The pre-cache-store-acquire and matching releaseSpawn-on-503 branches are gone — they were vestigial once the factory owns acquire+release. lib/audit.mjs — JSDoc cache_status enum extended with 'streaming_attached'. Free-form string at the wire (no schema validator on append); the JSDoc is the source of truth for the consumer enum. test-features.mjs Suite 28 — 7 HTTP integration tests: - 28a single SSE request (source role + cache populated + second request → X-OLP-Cache: hit) - 28b 2 concurrent identical SSE → one spawn, source + attached roles, identical chunk sequences delivered - 28c TOCTOU regression — pre-populated cache + 2 concurrent → both hit buffered replay path, no streaming branch entry, no X-OLP-Streaming-Inflight header - 28d mid-stream join — late joiner receives accumulated burst + live tail - 28f one-of-N disconnect — source NOT aborted; survivor completes - 28g ALL clients disconnect → source aborted; no cache write; subsequent request gets fresh source spawn - 28h CONCURRENCY_LIMIT fallthrough — factory throws at maxConcurrent=1; buffered path's chain-exhausted 502 surfaces (28e backpressure deferred to Suite 27g unit-level coverage.) ## Scope server.mjs + lib/audit.mjs + test-features.mjs. Untouched: cache/store.mjs (D57 frozen), provider plugins, fallback engine, IR. CHANGELOG and package.json bump fires at D59 close. ## Authority - docs/adr/0005-cache-cross-provider.md Amendment 8 §§7, 8, 9, 11, 12 - docs/v1x-roadmap.md #1 - GitHub issue #16 (TOCTOU window; closed in D59) - ADR 0002 Amendment 6 (D38 tryAcquireSpawn/releaseSpawn semantics that §7 now invokes inside the sourceFactory closure) ## Test count 615 → 622 (+7 D58 integration tests). Local: 622/622 pass. ## Iron Rule 10 follow-up notes for the reviewer - res.on('close') vs req.on('close'): switched to res after empirical verification (28g fails on req under Node 25). Comment in code. - Cache-layer write + server-layer delete for stop-less exhaustion is cosmetically inconsistent with streaming_inflight_source_done's cache_written: true log. Functionally correct; flagged for future amendment. - 'solo' header value deferred — would need trailer mechanics or post-stream emission. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: D58 reviewer follow-ups — P2-1 (audit-query gauge drift) + P2-2 (stop-less HTTP test) Fold-in for D58 PR #37 fresh-context opus reviewer findings (APPROVE WITH MINOR — 0 P0/P1, 4 P2). P2-3 (`isFirst` unused) and P2-4 (`solo` not emitted) are design-acceptable per ADR 0005 Amendment 8 §11 and left as-is. P2-1 — `lib/audit-query.mjs` gauge reconciliation. `aggregateRequests` and `cacheHitRateWindow` previously counted `streaming_attached` rows in `total` / `pe.total` without contributing to hit/miss/bypass numerators, breaking the invariant that the cache_status breakdown should sum to the total. Added an explicit `streaming_attached` field to both the global return shape and the `by_provider` shape; the counter is excluded from `hit_rate` numerator AND denominator (joiners did not hit a literal cache so they don't belong in either side of the ratio). Test count is unchanged for D49 suites — they only assert presence + non-negative + reconciliation invariants that the new field preserves; if a test asserted exact value equality on a fixture with NO streaming_attached rows, the new field defaults to 0 and the assertion still passes. P2-2 — Suite 28 stop-less HTTP coverage gap. Test 28i fires an SSE request to a fake provider whose source generator returns WITHOUT a {type:"stop"} chunk; asserts (a) the synthetic truncation marker appears in the body (D26 F19 in-band signal), (b) [DONE] terminator follows, (c) a subsequent identical request triggers a fresh spawn (cache was NOT populated by the truncated stream). Pins the D58 `cacheStore.delete` path at server.mjs:1344-1346 end-to-end. 622 → 623 (+1 D58 follow-up test). 623/623 pass locally. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
297 lines
12 KiB
JavaScript
297 lines
12 KiB
JavaScript
/**
|
|
* lib/audit.mjs — OLP audit ndjson append (Phase 2 / D45)
|
|
*
|
|
* Authority: ADR 0007 § 6.2 (audit append semantics) + § 8 (event schema).
|
|
*
|
|
* Behaviour:
|
|
* - One JSON event per line; newline-terminated; UTF-8.
|
|
* - Append to ~/.olp/logs/audit.ndjson (chmod 0600 file, 0700 dir).
|
|
* - On append failure: log a warn ('audit_append_failed_once') + retry
|
|
* once synchronously.
|
|
* - On second-failure: increment per-process drop counter + log warn
|
|
* ('audit_append_dropped'); NEVER throw to the caller (audit is
|
|
* observability, not authorization). Per § 6.2.
|
|
* - No memory buffer at Phase 2 (forward-path note in ADR § 13).
|
|
*
|
|
* Atomicity note: Node's `fs.appendFileSync` opens with O_APPEND which is
|
|
* POSIX-atomic for writes <= PIPE_BUF (typically 4096 bytes). Our event
|
|
* payloads (§ 8 schema with hash + shape fields, no PII / no message
|
|
* content) are well under that limit, so concurrent in-process appends
|
|
* are line-atomic without explicit locking.
|
|
*
|
|
* No PII: § 8 explicitly excludes request body, response body, and IR
|
|
* message content. Hash + shape only.
|
|
*/
|
|
|
|
import { appendFileSync, mkdirSync, chmodSync, renameSync, existsSync, statSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
|
|
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
|
const OLP_HOME_ENV = 'OLP_HOME';
|
|
const RETRY_COUNT = 1; // § 6.2: warn + 1 retry
|
|
const LIVE_AUDIT_FILE = 'audit.ndjson';
|
|
const ROTATED_FILE_PREFIX = 'audit-';
|
|
const ROTATED_FILE_SUFFIX = '.ndjson';
|
|
|
|
let _dropCounter = 0;
|
|
let _rotateCounter = 0; // observability + test assertion target
|
|
let _rotateFailCounter = 0;
|
|
// Module-cached "last UTC date we saw at append time" so we don't read
|
|
// disk metadata on every append just to check for rotation.
|
|
let _lastSeenUtcDate = _utcDateNow();
|
|
|
|
/**
|
|
* Resolve OLP home dir (matches lib/keys.mjs precedence): opts.olpHome →
|
|
* process.env.OLP_HOME → ~/.olp. Resolved per call so tests setting the
|
|
* env mid-run take effect.
|
|
*/
|
|
function _resolveOlpHome(opts) {
|
|
if (opts?.olpHome) return opts.olpHome;
|
|
if (process.env[OLP_HOME_ENV]) return process.env[OLP_HOME_ENV];
|
|
return DEFAULT_OLP_HOME;
|
|
}
|
|
|
|
/**
|
|
* Current UTC date as YYYY-MM-DD. Module-private; reused by rotation logic.
|
|
*/
|
|
function _utcDateNow() {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
/**
|
|
* Read the first event's `ts` from the live audit file to discover the
|
|
* date it was opened on (for stale-cache recovery when the process is
|
|
* restarted and the in-memory `_lastSeenUtcDate` doesn't match). Returns
|
|
* the YYYY-MM-DD string, or null if the file is missing / empty /
|
|
* malformed.
|
|
*/
|
|
function _firstEventDateInLiveFile(livePath) {
|
|
try {
|
|
if (!existsSync(livePath)) return null;
|
|
// Read the file + slice off the first ndjson line. For audit ndjson the
|
|
// first line is at most a few hundred bytes; this is fine for the rare
|
|
// "process restart with a stale live file" recovery path. (Family-scale
|
|
// single-file audit is bounded; multi-MB scans are not a real concern
|
|
// until Phase 4+ when Option 3 SQLite migration would kick in anyway.)
|
|
const raw = readFileSync(livePath, 'utf-8');
|
|
const nl = raw.indexOf('\n');
|
|
const firstLine = nl === -1 ? raw : raw.slice(0, nl);
|
|
if (!firstLine) return null;
|
|
const obj = JSON.parse(firstLine);
|
|
if (typeof obj?.ts !== 'string') return null;
|
|
return obj.ts.slice(0, 10);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rotate the live audit file (if any) to its UTC-date suffix. Idempotent:
|
|
* if a rotated file with that name already exists (e.g., cron beat us to
|
|
* it), this skips the rename.
|
|
*
|
|
* Per ADR 0008 § 5.1 + § 5.3. SYNCHRONOUS so callers (appendAuditEvent +
|
|
* external cron) can rely on it completing before the next IO operation.
|
|
* Sync rotation eliminates the race that an async wrapper would create
|
|
* between the date-change-detection and the append (where the append could
|
|
* land in the old un-rotated file).
|
|
*
|
|
* Concurrent in-process invocations: Node's single-threaded event loop
|
|
* serializes synchronous calls within a tick. Cross-tick concurrency
|
|
* uses the `_lastSeenUtcDate` cache as the gate — once set, subsequent
|
|
* appends short-circuit the rotation check.
|
|
*
|
|
* @param {object} args - { olpHome, logEvent, _nowFn (test injection) }
|
|
* @returns {{ rotated: boolean, fromPath?: string, toPath?: string, dateUsed?: string }}
|
|
*/
|
|
export function _maybeRotateAudit(args = {}) {
|
|
const olpHome = _resolveOlpHome(args);
|
|
const logEvent = args.logEvent ?? ((level, ev, data) => {
|
|
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
|
|
process.stderr.write(JSON.stringify(entry) + '\n');
|
|
});
|
|
const nowFn = args._nowFn ?? (() => new Date());
|
|
|
|
const logsDir = join(olpHome, 'logs');
|
|
const livePath = join(logsDir, LIVE_AUDIT_FILE);
|
|
if (!existsSync(livePath)) {
|
|
// No live file yet (first append ever); no rotation needed.
|
|
_lastSeenUtcDate = nowFn().toISOString().slice(0, 10);
|
|
return { rotated: false };
|
|
}
|
|
// Determine the "date the live file holds" — use the first event's ts.
|
|
// Fall back to file mtime if events absent (corrupt / empty file edge).
|
|
let fileDate = _firstEventDateInLiveFile(livePath);
|
|
if (fileDate === null) {
|
|
try {
|
|
fileDate = statSync(livePath).mtime.toISOString().slice(0, 10);
|
|
} catch {
|
|
return { rotated: false };
|
|
}
|
|
}
|
|
const today = nowFn().toISOString().slice(0, 10);
|
|
if (fileDate === today) {
|
|
_lastSeenUtcDate = today;
|
|
return { rotated: false };
|
|
}
|
|
|
|
// Rotate: rename live → audit-<fileDate>.ndjson.
|
|
const rotatedName = `${ROTATED_FILE_PREFIX}${fileDate}${ROTATED_FILE_SUFFIX}`;
|
|
const rotatedPath = join(logsDir, rotatedName);
|
|
if (existsSync(rotatedPath)) {
|
|
// Cron or another writer beat us; the live file holding fileDate's
|
|
// events must be merged manually. Per ADR 0008 § 5.3 we log + skip.
|
|
logEvent('warn', 'audit_rotate_target_exists', {
|
|
livePath, rotatedPath,
|
|
message: 'rotation target exists — concurrent rotator beat in-process check; manual merge required if events overlap',
|
|
});
|
|
_lastSeenUtcDate = today;
|
|
return { rotated: false };
|
|
}
|
|
try {
|
|
renameSync(livePath, rotatedPath);
|
|
_rotateCounter++;
|
|
_lastSeenUtcDate = today;
|
|
logEvent('info', 'audit_rotated', { fromPath: livePath, toPath: rotatedPath, dateUsed: fileDate });
|
|
return { rotated: true, fromPath: livePath, toPath: rotatedPath, dateUsed: fileDate };
|
|
} catch (err) {
|
|
_rotateFailCounter++;
|
|
logEvent('warn', 'audit_rotate_failed', {
|
|
livePath, rotatedPath,
|
|
error: err?.message ?? String(err),
|
|
});
|
|
// Don't update _lastSeenUtcDate so the next append retries.
|
|
return { rotated: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append a single audit event to ~/.olp/logs/audit.ndjson.
|
|
*
|
|
* @param {object} event - § 8 schema fields (ts, key_id, owner_tier,
|
|
* method, path, provider, model, status_code, latency_ms, cache_status,
|
|
* fallback_hops, tried_providers, error_code, ir_request_hash, chain_id).
|
|
* Caller is responsible for populating fields; missing fields are
|
|
* serialized as undefined → omitted by JSON.stringify.
|
|
*
|
|
* cache_status enum (free-form string; not schema-validated at append):
|
|
* 'hit' — served from cache (buffered-replay or streaming
|
|
* cache_hit role from ADR 0005 Amendment 8 §1).
|
|
* 'miss' — buffered or streaming source path; provider
|
|
* spawn fired for this request.
|
|
* 'bypass' — D2 cache_control bypass (no cache read/write).
|
|
* 'streaming_attached' — D58 / ADR 0005 Amendment 8 §11: client joined
|
|
* an in-flight streaming source spawn from
|
|
* another caller; this request did NOT spawn a
|
|
* provider but also did NOT hit the cache (the
|
|
* cache was empty at the inflight Map check).
|
|
* null — pre-chain error paths (the cache layer was
|
|
* never consulted; e.g. 401, 415, 400 IR).
|
|
* @param {object} [opts]
|
|
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
|
* @param {(level: string, event: string, data?: object) => void} [opts.logEvent]
|
|
* - injectable structured logger; defaults to console.warn with JSON line
|
|
*/
|
|
export function appendAuditEvent(event, opts = {}) {
|
|
const olpHome = _resolveOlpHome(opts);
|
|
const logsDir = join(olpHome, 'logs');
|
|
const path = join(logsDir, LIVE_AUDIT_FILE);
|
|
const line = JSON.stringify(event) + '\n';
|
|
const logEvent = opts.logEvent ?? ((level, ev, data) => {
|
|
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
|
|
process.stderr.write(JSON.stringify(entry) + '\n');
|
|
});
|
|
|
|
// D52: cheap fast-path date check. If the module-cached date matches the
|
|
// current UTC date, skip the rotation probe entirely (no disk I/O). If
|
|
// the date has changed, synchronously rotate BEFORE the append so the
|
|
// append lands in the (post-rotation) new live file rather than the
|
|
// about-to-rotate-away old one.
|
|
// Per ADR 0008 § 5.1: rotation fires "on the first append after a UTC
|
|
// date change." Synchronous rotation ensures no append straddles the
|
|
// boundary — old-date events land in the rotated file; new-date events
|
|
// land in the fresh live file. The cache flip happens INSIDE
|
|
// _maybeRotateAudit so concurrent in-process re-triggers short-circuit.
|
|
const today = _utcDateNow();
|
|
if (today !== _lastSeenUtcDate) {
|
|
_maybeRotateAudit({ olpHome, logEvent });
|
|
}
|
|
|
|
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
|
|
try {
|
|
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
|
// Tighten dir mode in case it already existed with broader permissions.
|
|
try { chmodSync(logsDir, 0o700); } catch { /* tolerate EPERM */ }
|
|
appendFileSync(path, line, { mode: 0o600 });
|
|
return;
|
|
} catch (err) {
|
|
if (attempt < RETRY_COUNT) {
|
|
logEvent('warn', 'audit_append_failed_once', {
|
|
path,
|
|
error: err?.message ?? String(err),
|
|
});
|
|
continue;
|
|
}
|
|
_dropCounter++;
|
|
logEvent('warn', 'audit_append_dropped', {
|
|
path,
|
|
error: err?.message ?? String(err),
|
|
drop_count: _dropCounter,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Per-process count of audit events dropped due to repeated append failure.
|
|
* Useful for /health observability surface (D46).
|
|
*/
|
|
export function getAuditDropCount() {
|
|
return _dropCounter;
|
|
}
|
|
|
|
/**
|
|
* Test-only: reset the drop counter to zero. Suite 20 uses this between
|
|
* cases to assert independent failure-handling counts.
|
|
*/
|
|
export function __resetAuditDropCount() {
|
|
_dropCounter = 0;
|
|
}
|
|
|
|
/**
|
|
* Per-process count of successful rotations performed. Test + future
|
|
* observability surface.
|
|
*/
|
|
export function getAuditRotateCount() {
|
|
return _rotateCounter;
|
|
}
|
|
|
|
/**
|
|
* Per-process count of failed rotation attempts (rename threw, target
|
|
* existed, etc.). Test + future observability surface.
|
|
*/
|
|
export function getAuditRotateFailCount() {
|
|
return _rotateFailCounter;
|
|
}
|
|
|
|
/**
|
|
* Test-only: reset the rotation counters + the in-process "last seen UTC
|
|
* date" cache so each test exercises a fresh code path.
|
|
*/
|
|
export function __resetAuditRotateState() {
|
|
_rotateCounter = 0;
|
|
_rotateFailCounter = 0;
|
|
_lastSeenUtcDate = _utcDateNow();
|
|
}
|
|
|
|
/**
|
|
* Test-only: force the cached "last seen UTC date" to a specific value
|
|
* so the next appendAuditEvent observes a date change and triggers
|
|
* rotation deterministically.
|
|
*/
|
|
export function __setLastSeenUtcDateForTesting(dateStr) {
|
|
_lastSeenUtcDate = dateStr;
|
|
}
|