feat(ms-ai-architect): add lib/log-rotate for bounded log disk use [skip-docs]
Foundation lib for v1.12.0 cron rewrite — closes brief deliverable
"log-rotate" that was missing from the original plan (Phase 9 scope
revision). Standard logrotate idiom, zero dependencies.
- rotateLog(logPath, opts) returns {rotated, dropped, kept}
- Defaults: maxSizeBytes 10 MB, maxGenerations 5 (1 active + 4 rotated)
- No-op when log missing or under threshold
- Over-size: drop oldest, shift .N..1 down by one, move active → .1
- maxGenerations=1 keeps only the active slot (no rotated copies)
- Pure stdlib fs.renameSync chain with silent try/catch on missing gens
8/8 tests pass: missing/under-size/over-size paths, chained 6 rotations
capped at maxGenerations, oldest dropped, two-step content shift.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2b3f544f86
commit
aefe9ef5b4
2 changed files with 221 additions and 0 deletions
94
plugins/ms-ai-architect/scripts/kb-update/lib/log-rotate.mjs
Normal file
94
plugins/ms-ai-architect/scripts/kb-update/lib/log-rotate.mjs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// log-rotate.mjs — Bounded-size log rotation for the cron-runner.
|
||||
// Zero dependencies. Standard Linux logrotate idiom: when logPath exceeds
|
||||
// maxSizeBytes, shift generations N..1 down by one, drop the oldest, and
|
||||
// move logPath → logPath.1. The next caller writes to a fresh logPath.
|
||||
|
||||
import { existsSync, statSync, renameSync, unlinkSync } from 'node:fs';
|
||||
|
||||
const DEFAULT_MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
const DEFAULT_MAX_GENERATIONS = 5;
|
||||
|
||||
function silentRename(from, to) {
|
||||
try {
|
||||
renameSync(from, to);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function silentUnlink(path) {
|
||||
try {
|
||||
unlinkSync(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate logPath if it exceeds maxSizeBytes. Keeps at most maxGenerations
|
||||
* total files (1 active + maxGenerations-1 rotated copies). No-op if logPath
|
||||
* is missing or under threshold.
|
||||
*
|
||||
* @param {string} logPath
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxSizeBytes] — default 10 MB
|
||||
* @param {number} [opts.maxGenerations] — default 5; total active+rotated
|
||||
* @returns {{rotated: boolean, dropped: string|null, kept: string[]}}
|
||||
*/
|
||||
export function rotateLog(logPath, opts = {}) {
|
||||
const result = { rotated: false, dropped: null, kept: [] };
|
||||
if (!logPath || typeof logPath !== 'string') {
|
||||
throw new Error('rotateLog: logPath is required');
|
||||
}
|
||||
const maxSizeBytes = opts.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES;
|
||||
const maxGenerations = Math.max(1, opts.maxGenerations ?? DEFAULT_MAX_GENERATIONS);
|
||||
|
||||
if (!existsSync(logPath)) return result;
|
||||
|
||||
let size;
|
||||
try {
|
||||
size = statSync(logPath).size;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
if (size <= maxSizeBytes) return result;
|
||||
|
||||
// The highest rotated generation we keep is maxGenerations - 1.
|
||||
// (Active log = generation 0; rotated copies = generations 1..N-1.)
|
||||
const lastGen = maxGenerations - 1;
|
||||
|
||||
if (lastGen === 0) {
|
||||
// Only the active log is kept — rotation = drop the previous active.
|
||||
silentUnlink(logPath);
|
||||
result.rotated = true;
|
||||
result.dropped = logPath;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Drop the oldest generation if it exists.
|
||||
const oldest = `${logPath}.${lastGen}`;
|
||||
if (existsSync(oldest)) {
|
||||
silentUnlink(oldest);
|
||||
result.dropped = oldest;
|
||||
}
|
||||
|
||||
// Shift down: .N-1 → .N, .N-2 → .N-1, ..., .1 → .2.
|
||||
for (let i = lastGen - 1; i >= 1; i--) {
|
||||
const from = `${logPath}.${i}`;
|
||||
const to = `${logPath}.${i + 1}`;
|
||||
if (existsSync(from)) {
|
||||
silentRename(from, to);
|
||||
result.kept.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
// Active log → .1.
|
||||
if (silentRename(logPath, `${logPath}.1`)) {
|
||||
result.kept.unshift(`${logPath}.1`);
|
||||
}
|
||||
|
||||
result.rotated = true;
|
||||
return result;
|
||||
}
|
||||
127
plugins/ms-ai-architect/tests/kb-update/test-log-rotate.test.mjs
Normal file
127
plugins/ms-ai-architect/tests/kb-update/test-log-rotate.test.mjs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// tests/kb-update/test-log-rotate.test.mjs
|
||||
// Unit tests for scripts/kb-update/lib/log-rotate.mjs
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { rotateLog } from '../../scripts/kb-update/lib/log-rotate.mjs';
|
||||
|
||||
function withTmp(fn) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'lr-test-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('rotateLog — missing log is a no-op', () => {
|
||||
withTmp((dir) => {
|
||||
const log = join(dir, 'kb.log');
|
||||
const result = rotateLog(log, { maxSizeBytes: 100 });
|
||||
assert.equal(result.rotated, false);
|
||||
assert.equal(existsSync(log), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('rotateLog — under-size log is not rotated', () => {
|
||||
withTmp((dir) => {
|
||||
const log = join(dir, 'kb.log');
|
||||
writeFileSync(log, 'tiny', 'utf8');
|
||||
const before = statSync(log).size;
|
||||
const result = rotateLog(log, { maxSizeBytes: 1024 });
|
||||
assert.equal(result.rotated, false);
|
||||
assert.equal(statSync(log).size, before);
|
||||
assert.equal(existsSync(`${log}.1`), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('rotateLog — over-size log moves to .1 with original content', () => {
|
||||
withTmp((dir) => {
|
||||
const log = join(dir, 'kb.log');
|
||||
const content = 'x'.repeat(2048);
|
||||
writeFileSync(log, content, 'utf8');
|
||||
const result = rotateLog(log, { maxSizeBytes: 1024 });
|
||||
assert.equal(result.rotated, true);
|
||||
assert.equal(existsSync(log), false);
|
||||
assert.equal(existsSync(`${log}.1`), true);
|
||||
assert.equal(readFileSync(`${log}.1`, 'utf8'), content);
|
||||
});
|
||||
});
|
||||
|
||||
test('rotateLog — chained rotation across 6 calls keeps at most maxGenerations files', () => {
|
||||
withTmp((dir) => {
|
||||
const log = join(dir, 'kb.log');
|
||||
const max = 5; // 1 active + 4 rotated; lastGen = 4
|
||||
for (let i = 0; i < 6; i++) {
|
||||
writeFileSync(log, `gen-${i}-${'x'.repeat(2048)}`, 'utf8');
|
||||
rotateLog(log, { maxSizeBytes: 1024, maxGenerations: max });
|
||||
}
|
||||
// After 6 over-size rotations, keep .1..(max-1) = .1..4 from the latest
|
||||
// chain. Active log was just rotated → no active log on disk.
|
||||
assert.equal(existsSync(log), false);
|
||||
for (let i = 1; i <= max - 1; i++) {
|
||||
assert.equal(existsSync(`${log}.${i}`), true, `expected ${log}.${i}`);
|
||||
}
|
||||
assert.equal(existsSync(`${log}.${max}`), false);
|
||||
assert.equal(existsSync(`${log}.${max + 1}`), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('rotateLog — existing oldest generation is dropped on next rotation', () => {
|
||||
withTmp((dir) => {
|
||||
const log = join(dir, 'kb.log');
|
||||
// Pre-seed an oldest generation so we can prove it gets dropped.
|
||||
writeFileSync(`${log}.4`, 'oldest-content', 'utf8');
|
||||
writeFileSync(log, 'x'.repeat(2048), 'utf8');
|
||||
const result = rotateLog(log, { maxSizeBytes: 1024, maxGenerations: 5 });
|
||||
assert.equal(result.rotated, true);
|
||||
// .4 dropped; new .1 created from prior active; nothing at .4 since
|
||||
// there were no .3/.2 to shift up.
|
||||
assert.equal(result.dropped, `${log}.4`);
|
||||
assert.equal(existsSync(`${log}.4`), false);
|
||||
assert.equal(existsSync(`${log}.1`), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('rotateLog — maxGenerations=1 keeps only the active slot (no .1)', () => {
|
||||
withTmp((dir) => {
|
||||
const log = join(dir, 'kb.log');
|
||||
writeFileSync(log, 'x'.repeat(2048), 'utf8');
|
||||
const result = rotateLog(log, { maxSizeBytes: 1024, maxGenerations: 1 });
|
||||
assert.equal(result.rotated, true);
|
||||
assert.equal(existsSync(log), false);
|
||||
assert.equal(existsSync(`${log}.1`), false);
|
||||
assert.equal(result.dropped, log);
|
||||
});
|
||||
});
|
||||
|
||||
test('rotateLog — preserves rotated content across two-step shift', () => {
|
||||
withTmp((dir) => {
|
||||
const log = join(dir, 'kb.log');
|
||||
// First rotation
|
||||
writeFileSync(log, `first-${'x'.repeat(2048)}`, 'utf8');
|
||||
rotateLog(log, { maxSizeBytes: 1024, maxGenerations: 3 });
|
||||
assert.equal(readFileSync(`${log}.1`, 'utf8').startsWith('first-'), true);
|
||||
|
||||
// Second rotation: prior .1 should shift to .2; new .1 from second active.
|
||||
writeFileSync(log, `second-${'x'.repeat(2048)}`, 'utf8');
|
||||
rotateLog(log, { maxSizeBytes: 1024, maxGenerations: 3 });
|
||||
assert.equal(readFileSync(`${log}.1`, 'utf8').startsWith('second-'), true);
|
||||
assert.equal(readFileSync(`${log}.2`, 'utf8').startsWith('first-'), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('rotateLog — empty logPath rejected', () => {
|
||||
assert.throws(() => rotateLog(''), /logPath is required/);
|
||||
assert.throws(() => rotateLog(undefined), /logPath is required/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue