Files
qinglong/test/back/localArtifactRetentionLifecycle.test.cjs

276 lines
6.9 KiB
JavaScript

require('ts-node/register/transpile-only');
const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
LocalArtifactRetentionLifecycle,
} = require('../../back/runtime/application/localArtifactRetentionLifecycle');
const CURSOR = {
finishedAtMs: 1_800_000_000_000,
attemptId: '019f7500-0000-7000-8000-000000000001',
};
function deferred() {
let resolve;
const promise = new Promise((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function fakeScheduler() {
let id = 0;
const pending = new Map();
const cleared = [];
return {
pending,
cleared,
scheduler: {
setTimeout(callback, delayMs) {
const timer = {
id: ++id,
delayMs,
unrefCalls: 0,
unref() {
this.unrefCalls += 1;
},
};
pending.set(timer.id, { timer, callback });
return timer;
},
clearTimeout(timer) {
cleared.push(timer.id);
pending.delete(timer.id);
},
},
fireNext() {
const next = pending.values().next().value;
assert.ok(next, 'expected a scheduled timer');
pending.delete(next.timer.id);
next.callback();
return next.timer;
},
};
}
function memoryCheckpoints({ fenced = false } = {}) {
let checkpoint = { version: 0 };
const writes = [];
return {
writes,
store: {
async load() {
return checkpoint;
},
async compareAndSet(value) {
writes.push(value);
if (fenced || value.expectedVersion !== checkpoint.version)
return false;
checkpoint = {
version: checkpoint.version + 1,
...(value.cursor ? { cursor: { ...value.cursor } } : {}),
};
return true;
},
},
};
}
function sweepResult(overrides = {}) {
return {
status: 'complete',
pressure: false,
observedAtMs: 1_800_000_001_000,
retentionMs: 7 * 24 * 60 * 60_000,
availableBytes: BigInt(1024),
totalBytes: BigInt(2048),
candidatesScanned: 0,
deletionsAttempted: 0,
recordsWritten: 0,
failedCandidates: 0,
bytesReclaimed: 0,
entries: [],
...overrides,
};
}
async function flush() {
await new Promise((resolve) => setImmediate(resolve));
}
test('persists only changed cursors and emits bounded aggregate metrics', async () => {
const timers = fakeScheduler();
const checkpoints = memoryCheckpoints();
const cursors = [];
const summaries = [];
let calls = 0;
const lifecycle = new LocalArtifactRetentionLifecycle(
{
async sweep(cursor) {
cursors.push(cursor);
calls += 1;
if (calls === 1) {
return sweepResult({
status: 'page_complete',
candidatesScanned: 8,
deletionsAttempted: 8,
recordsWritten: 8,
bytesReclaimed: 4096,
nextCursor: CURSOR,
entries: [
{
attemptId: CURSOR.attemptId,
logArtifactId: `local-${'a'.repeat(30)}`,
outcome: 'deleted',
bytesReclaimed: 4096,
},
],
});
}
return sweepResult();
},
},
checkpoints.store,
{
intervalMs: 5_000,
initialDelayMs: 100,
scheduler: timers.scheduler,
onCycle(summary) {
summaries.push(summary);
},
},
);
assert.equal(lifecycle.start(), true);
const initial = timers.fireNext();
assert.equal(initial.delayMs, 100);
assert.equal(initial.unrefCalls, 1);
await flush();
assert.equal(summaries[0].cursorAction, 'advanced');
assert.equal(summaries[0].recordsWritten, 8);
assert.equal('entries' in summaries[0], false);
assert.equal(JSON.stringify(summaries[0]).includes(CURSOR.attemptId), false);
assert.equal(summaries[0].availableBytes, '1024');
timers.fireNext();
await flush();
assert.equal(summaries[1].cursorAction, 'cleared');
timers.fireNext();
await flush();
assert.equal(summaries[2].cursorAction, 'unchanged');
assert.deepEqual(cursors, [undefined, CURSOR, undefined]);
assert.equal(checkpoints.writes.length, 2);
assert.equal(await lifecycle.stop(), 'drained');
});
test('reports cursor fencing and observer failures without stopping cadence', async () => {
const timers = fakeScheduler();
const checkpoints = memoryCheckpoints({ fenced: true });
const errors = [];
const lifecycle = new LocalArtifactRetentionLifecycle(
{
async sweep() {
return sweepResult({ status: 'page_complete', nextCursor: CURSOR });
},
},
checkpoints.store,
{
intervalMs: 5_000,
scheduler: timers.scheduler,
onCycle(summary) {
assert.equal(summary.cursorAction, 'fenced');
throw new Error('metrics sink failed');
},
onError(error) {
errors.push(error.message);
},
},
);
lifecycle.start();
timers.fireNext();
await flush();
assert.deepEqual(errors, ['metrics sink failed']);
assert.equal(timers.pending.size, 1);
assert.equal(await lifecycle.stop(), 'drained');
});
test('never overlaps a slow sweep and bounds shutdown', async () => {
const timers = fakeScheduler();
const running = deferred();
const checkpoints = memoryCheckpoints();
let calls = 0;
const lifecycle = new LocalArtifactRetentionLifecycle(
{
async sweep() {
calls += 1;
return running.promise;
},
},
checkpoints.store,
{
intervalMs: 5_000,
stopTimeoutMs: 5,
scheduler: timers.scheduler,
},
);
lifecycle.start();
timers.fireNext();
await flush();
assert.equal(calls, 1);
assert.equal(timers.pending.size, 0);
assert.equal(await lifecycle.stop(), 'timed_out');
assert.equal(lifecycle.start(), false);
running.resolve(sweepResult());
await flush();
assert.equal(lifecycle.start(), true);
assert.equal(await lifecycle.stop(), 'drained');
});
test('rejects hot loops, invalid incomplete results, and unbounded stops', async () => {
const timers = fakeScheduler();
const checkpoints = memoryCheckpoints();
const service = {
async sweep() {
return sweepResult();
},
};
assert.throws(
() =>
new LocalArtifactRetentionLifecycle(service, checkpoints.store, {
intervalMs: 999,
}),
RangeError,
);
assert.throws(
() =>
new LocalArtifactRetentionLifecycle(service, checkpoints.store, {
intervalMs: 1_000,
stopTimeoutMs: 60_001,
}),
RangeError,
);
const errors = [];
const invalid = new LocalArtifactRetentionLifecycle(
{
async sweep() {
return sweepResult({ status: 'page_complete' });
},
},
checkpoints.store,
{
intervalMs: 1_000,
scheduler: timers.scheduler,
onError(error) {
errors.push(error.message);
},
},
);
invalid.start();
timers.fireNext();
await flush();
assert.match(errors[0], /requires a resume cursor/);
await invalid.stop();
});