mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:25:29 +08:00
276 lines
6.9 KiB
JavaScript
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();
|
|
});
|