274 lines
9.1 KiB
TypeScript
274 lines
9.1 KiB
TypeScript
// src/engine/reflection/retention.test.ts
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import {
|
|
mkdtempSync,
|
|
rmSync,
|
|
mkdirSync,
|
|
writeFileSync,
|
|
existsSync,
|
|
} from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
pruneOldSnapshots,
|
|
enforceDiskCap,
|
|
runReflectionRetentionSweep,
|
|
type RetentionDeps,
|
|
} from './retention.js';
|
|
|
|
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
|
|
const USER_ID = 'u-retention-test';
|
|
|
|
/** Format a Date as the snapshot directory name prefix (YYYYMMDDTHHmmssZ). */
|
|
function fmtTs(d: Date): string {
|
|
const pad2 = (n: number) => String(n).padStart(2, '0');
|
|
return (
|
|
`${d.getUTCFullYear()}` +
|
|
`${pad2(d.getUTCMonth() + 1)}` +
|
|
`${pad2(d.getUTCDate())}` +
|
|
`T${pad2(d.getUTCHours())}` +
|
|
`${pad2(d.getUTCMinutes())}` +
|
|
`${pad2(d.getUTCSeconds())}` +
|
|
`Z`
|
|
);
|
|
}
|
|
|
|
/** Create a fake snapshot dir for the given age in days (relative to now). */
|
|
function makeSnapshot(
|
|
histDir: string,
|
|
ageDays: number,
|
|
jobId: string,
|
|
fileSizeBytes = 100,
|
|
): string {
|
|
const d = new Date(Date.now() - ageDays * 86_400_000);
|
|
const name = `${fmtTs(d)}-${jobId}`;
|
|
const dir = join(histDir, name);
|
|
mkdirSync(dir, { recursive: true });
|
|
// Write a dummy meta.json + a content file of the requested size
|
|
writeFileSync(
|
|
join(dir, 'meta.json'),
|
|
JSON.stringify({ snapshotId: name, ts: d.toISOString(), pieceName: 'chat' }),
|
|
'utf-8',
|
|
);
|
|
writeFileSync(join(dir, 'data.bin'), Buffer.alloc(fileSizeBytes), 'binary');
|
|
return name;
|
|
}
|
|
|
|
function makeDeps(dataDir: string): RetentionDeps {
|
|
return { dataDir };
|
|
}
|
|
|
|
function histDir(dataDir: string, userId: string): string {
|
|
return join(dataDir, userId, '.reflection-history');
|
|
}
|
|
|
|
// ── Test suite ────────────────────────────────────────────────────────────────
|
|
|
|
describe('pruneOldSnapshots', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'retention-age-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('does NOT prune a snapshot newer than retentionDays', async () => {
|
|
const hDir = histDir(tmpDir, USER_ID);
|
|
mkdirSync(hDir, { recursive: true });
|
|
const snapId = makeSnapshot(hDir, 5, 'j-new'); // 5 days old
|
|
|
|
const result = await pruneOldSnapshots(makeDeps(tmpDir), USER_ID, 90);
|
|
|
|
expect(result.pruned).toHaveLength(0);
|
|
expect(existsSync(join(hDir, snapId))).toBe(true);
|
|
});
|
|
|
|
it('prunes a snapshot older than retentionDays', async () => {
|
|
const hDir = histDir(tmpDir, USER_ID);
|
|
mkdirSync(hDir, { recursive: true });
|
|
const snapId = makeSnapshot(hDir, 100, 'j-old'); // 100 days old, retention=90
|
|
|
|
const result = await pruneOldSnapshots(makeDeps(tmpDir), USER_ID, 90);
|
|
|
|
expect(result.pruned).toContain(snapId);
|
|
expect(existsSync(join(hDir, snapId))).toBe(false);
|
|
});
|
|
|
|
it('prunes old but keeps new when both present', async () => {
|
|
const hDir = histDir(tmpDir, USER_ID);
|
|
mkdirSync(hDir, { recursive: true });
|
|
const oldId = makeSnapshot(hDir, 200, 'j-old'); // old
|
|
const newId = makeSnapshot(hDir, 10, 'j-new'); // new
|
|
|
|
const result = await pruneOldSnapshots(makeDeps(tmpDir), USER_ID, 90);
|
|
|
|
expect(result.pruned).toContain(oldId);
|
|
expect(result.pruned).not.toContain(newId);
|
|
expect(existsSync(join(hDir, oldId))).toBe(false);
|
|
expect(existsSync(join(hDir, newId))).toBe(true);
|
|
});
|
|
|
|
it('returns empty pruned when no history dir exists', async () => {
|
|
const result = await pruneOldSnapshots(makeDeps(tmpDir), 'u-ghost', 90);
|
|
expect(result.pruned).toHaveLength(0);
|
|
});
|
|
|
|
it('does not prune index.jsonl (not a parseable snapshot dir name)', async () => {
|
|
const hDir = histDir(tmpDir, USER_ID);
|
|
mkdirSync(hDir, { recursive: true });
|
|
// Write index.jsonl as a file (not a dir), should be ignored
|
|
writeFileSync(join(hDir, 'index.jsonl'), '', 'utf-8');
|
|
const snapId = makeSnapshot(hDir, 5, 'j-ok');
|
|
|
|
const result = await pruneOldSnapshots(makeDeps(tmpDir), USER_ID, 90);
|
|
|
|
expect(result.pruned).toHaveLength(0);
|
|
expect(existsSync(join(hDir, 'index.jsonl'))).toBe(true);
|
|
expect(existsSync(join(hDir, snapId))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('enforceDiskCap', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'retention-cap-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('does not prune when total bytes is under cap', async () => {
|
|
const hDir = histDir(tmpDir, USER_ID);
|
|
mkdirSync(hDir, { recursive: true });
|
|
// Each snapshot ≈ 200 bytes (100 data + small meta.json); cap = 10 MiB
|
|
const snapId = makeSnapshot(hDir, 5, 'j-small', 100);
|
|
|
|
const cap = 10 * 1024 * 1024; // 10 MiB
|
|
const result = await enforceDiskCap(makeDeps(tmpDir), USER_ID, cap);
|
|
|
|
expect(result.pruned).toHaveLength(0);
|
|
expect(existsSync(join(hDir, snapId))).toBe(true);
|
|
});
|
|
|
|
it('prunes oldest snapshots first when over cap', async () => {
|
|
const hDir = histDir(tmpDir, USER_ID);
|
|
mkdirSync(hDir, { recursive: true });
|
|
|
|
// Create 3 snapshots: oldest, middle, newest — each ~1024 bytes of data
|
|
const oldestId = makeSnapshot(hDir, 30, 'j-oldest', 1024);
|
|
const middleId = makeSnapshot(hDir, 20, 'j-middle', 1024);
|
|
const newestId = makeSnapshot(hDir, 10, 'j-newest', 1024);
|
|
|
|
// The total will be around 3 * (1024 + meta) bytes.
|
|
// Set cap so that only 2 snapshots fit (i.e., total > 2 * entry_size).
|
|
// Each entry is ~1024 + ~80 bytes meta ≈ 1104 bytes.
|
|
// Cap at 2200 bytes → oldest must be pruned.
|
|
const cap = 2200;
|
|
|
|
const result = await enforceDiskCap(makeDeps(tmpDir), USER_ID, cap);
|
|
|
|
// oldest should be pruned, newest should survive
|
|
expect(result.pruned).toContain(oldestId);
|
|
expect(existsSync(join(hDir, oldestId))).toBe(false);
|
|
expect(existsSync(join(hDir, newestId))).toBe(true);
|
|
// middle may or may not be pruned depending on actual sizes, but newest must survive
|
|
void middleId; // referenced to suppress unused variable warning
|
|
});
|
|
|
|
it('newest snapshot survives when only one exists and under cap', async () => {
|
|
const hDir = histDir(tmpDir, USER_ID);
|
|
mkdirSync(hDir, { recursive: true });
|
|
const snapId = makeSnapshot(hDir, 5, 'j-only', 100);
|
|
|
|
const cap = 10 * 1024 * 1024; // 10 MiB — easily above 100 bytes
|
|
const result = await enforceDiskCap(makeDeps(tmpDir), USER_ID, cap);
|
|
|
|
expect(result.pruned).toHaveLength(0);
|
|
expect(existsSync(join(hDir, snapId))).toBe(true);
|
|
});
|
|
|
|
it('returns empty pruned when no history dir exists', async () => {
|
|
const result = await enforceDiskCap(makeDeps(tmpDir), 'u-ghost', 1024);
|
|
expect(result.pruned).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('runReflectionRetentionSweep', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'retention-sweep-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('sweeps all users and prunes old snapshots', async () => {
|
|
const uid1 = 'u-sweep-1';
|
|
const uid2 = 'u-sweep-2';
|
|
|
|
const hDir1 = histDir(tmpDir, uid1);
|
|
const hDir2 = histDir(tmpDir, uid2);
|
|
mkdirSync(hDir1, { recursive: true });
|
|
mkdirSync(hDir2, { recursive: true });
|
|
|
|
const old1 = makeSnapshot(hDir1, 100, 'j-old1'); // old
|
|
const new1 = makeSnapshot(hDir1, 5, 'j-new1'); // new
|
|
const old2 = makeSnapshot(hDir2, 200, 'j-old2'); // old
|
|
|
|
await runReflectionRetentionSweep(makeDeps(tmpDir), {
|
|
snapshotRetentionDays: 90,
|
|
snapshotMaxBytesPerUser: 100 * 1024 * 1024, // 100 MiB — won't trigger
|
|
});
|
|
|
|
expect(existsSync(join(hDir1, old1))).toBe(false);
|
|
expect(existsSync(join(hDir1, new1))).toBe(true);
|
|
expect(existsSync(join(hDir2, old2))).toBe(false);
|
|
});
|
|
|
|
it('skips users with no .reflection-history dir', async () => {
|
|
// Create a user dir without a history dir
|
|
const uid = 'u-no-history';
|
|
mkdirSync(join(tmpDir, uid), { recursive: true });
|
|
|
|
// Should not throw
|
|
await expect(
|
|
runReflectionRetentionSweep(makeDeps(tmpDir), {
|
|
snapshotRetentionDays: 90,
|
|
snapshotMaxBytesPerUser: 100 * 1024 * 1024,
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('handles a non-existent dataDir gracefully', async () => {
|
|
await expect(
|
|
runReflectionRetentionSweep(
|
|
{ dataDir: join(tmpDir, 'does-not-exist') },
|
|
{ snapshotRetentionDays: 90, snapshotMaxBytesPerUser: 100 * 1024 * 1024 },
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('single snapshot under cap and within retention is not touched', async () => {
|
|
const uid = 'u-clean';
|
|
const hDir = histDir(tmpDir, uid);
|
|
mkdirSync(hDir, { recursive: true });
|
|
const snapId = makeSnapshot(hDir, 5, 'j-only', 100);
|
|
|
|
await runReflectionRetentionSweep(makeDeps(tmpDir), {
|
|
snapshotRetentionDays: 90,
|
|
snapshotMaxBytesPerUser: 100 * 1024 * 1024,
|
|
});
|
|
|
|
expect(existsSync(join(hDir, snapId))).toBe(true);
|
|
});
|
|
});
|
|
|