test(queue): extract terminal-job prune into testable module + 10 tests
handleBatchDone's terminal-job auto-cap (introduced in 3.3.0) lived
inline as a manual two-pass loop over queueJobs. Pull the algorithm
into lib/queue-prune.js as pure pruneOldestTerminalJobs(jobs, limit)
that returns { kept, dropped } so the caller can clean up its index/
selection in one go. Same single implementation backs runtime and
tests via dual-environment loader (CommonJS module.exports for Node
tests, window.QueuePrune global for the renderer via index.html
script tag).
Coverage:
- Empty / null / non-array input → no-op
- All-non-terminal → no-op (regardless of limit)
- Terminal count ≤ limit → no-op
- Terminal count > limit → drops oldest by insertion order
- Mixed queue: non-terminals always kept, only terminals dropped
- limit=0 → drops every terminal
- Negative / NaN / Infinity limits → safe no-op
- Malformed entries (null, missing status) handled without throwing
- Large-queue stress (5000 done jobs) keeps newest 500
- TERMINAL_STATUSES set covers exactly done/skipped/error/aborted
Renderer uses window.QueuePrune?. so a failed script load just
disables the prune rather than crashing every batch-done. 107/107
tests green.
This commit is contained in:
parent
7269504d3d
commit
f83fdabea3
59
lib/queue-prune.js
Normal file
59
lib/queue-prune.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// Queue auto-prune logic. Extracted from renderer/app.js handleBatchDone so
|
||||||
|
// the algorithm can be unit-tested without needing a DOM or the renderer's
|
||||||
|
// module-level state (queueJobs, _jobIndexById).
|
||||||
|
//
|
||||||
|
// Loaded both as a CommonJS module (Node tests) and as a browser global
|
||||||
|
// (renderer/app.js via index.html script tag) so the same single
|
||||||
|
// implementation backs both runtime and tests — no drift between them.
|
||||||
|
//
|
||||||
|
// Behaviour: when the number of terminal-status jobs (done / skipped /
|
||||||
|
// error / aborted) in the queue exceeds `limit`, drop the oldest terminal
|
||||||
|
// jobs (insertion order) until we're back at the limit. Non-terminal jobs
|
||||||
|
// (queued / preview / uploading / retrying / getting-server) are always
|
||||||
|
// kept — those are work the user can still act on. Without this cap a
|
||||||
|
// long session accumulates thousands of done rows and every render becomes
|
||||||
|
// O(N) on a perpetually-growing N.
|
||||||
|
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const TERMINAL_STATUSES = new Set(['done', 'skipped', 'error', 'aborted']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute which jobs to keep vs drop, given a queue and a terminal-jobs cap.
|
||||||
|
* @param {Array<{id: string, status: string}>} jobs the current queue
|
||||||
|
* @param {number} limit max terminal jobs to keep
|
||||||
|
* @returns {null | { kept: Array, dropped: Array }} null when nothing changed
|
||||||
|
*/
|
||||||
|
function pruneOldestTerminalJobs(jobs, limit) {
|
||||||
|
if (!Array.isArray(jobs) || jobs.length === 0) return null;
|
||||||
|
if (!Number.isFinite(limit) || limit < 0) return null;
|
||||||
|
|
||||||
|
// Walk once, record indices of terminal jobs in insertion order.
|
||||||
|
const terminalIdxs = [];
|
||||||
|
for (let i = 0; i < jobs.length; i++) {
|
||||||
|
const j = jobs[i];
|
||||||
|
if (j && TERMINAL_STATUSES.has(j.status)) terminalIdxs.push(i);
|
||||||
|
}
|
||||||
|
if (terminalIdxs.length <= limit) return null;
|
||||||
|
|
||||||
|
const dropCount = terminalIdxs.length - limit;
|
||||||
|
const dropSet = new Set(terminalIdxs.slice(0, dropCount));
|
||||||
|
|
||||||
|
const kept = [];
|
||||||
|
const dropped = [];
|
||||||
|
for (let i = 0; i < jobs.length; i++) {
|
||||||
|
if (dropSet.has(i)) dropped.push(jobs[i]);
|
||||||
|
else kept.push(jobs[i]);
|
||||||
|
}
|
||||||
|
return { kept, dropped };
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { pruneOldestTerminalJobs, TERMINAL_STATUSES };
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = api;
|
||||||
|
} else if (root) {
|
||||||
|
root.QueuePrune = api;
|
||||||
|
}
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
@ -2023,34 +2023,23 @@ function handleBatchDone(summary) {
|
|||||||
queueJobs = nextJobs;
|
queueJobs = nextJobs;
|
||||||
renderQueueTable();
|
renderQueueTable();
|
||||||
} else {
|
} else {
|
||||||
// Auto-prune for the default (removeOnDone=false) too: cap done/skipped
|
// Auto-prune for the default (removeOnDone=false) too: cap terminal
|
||||||
// jobs at the most recent N so the queue can't grow unbounded across
|
// jobs (done/skipped/error/aborted) at the most recent N so the queue
|
||||||
// long sessions. Without this, sortQueueJobs / _computeQueueStats /
|
// can't grow unbounded across long sessions. The algorithm lives in
|
||||||
// renderQueueTable all become O(N) on a forever-growing N and the UI
|
// lib/queue-prune.js (same impl Node-tested, see tests/queue-prune.test.js)
|
||||||
// starts visibly lagging once the queue passes a few thousand entries.
|
// and the result tells us which jobs to drop so we can clean up the
|
||||||
// 500 most-recent terminal jobs is enough for "see what just happened"
|
// index + selection in one pass.
|
||||||
// while stopping the runaway growth.
|
|
||||||
const TERMINAL_KEEP_LIMIT = 500;
|
const TERMINAL_KEEP_LIMIT = 500;
|
||||||
const terminalIdxs = [];
|
// Optional-chain so the renderer still works if the prune script fails
|
||||||
for (let i = 0; i < queueJobs.length; i++) {
|
// to load (e.g. file:// path issues during dev) — falls back to no-prune
|
||||||
const s = queueJobs[i].status;
|
// rather than crashing on every batch-done.
|
||||||
if (s === 'done' || s === 'skipped' || s === 'error' || s === 'aborted') terminalIdxs.push(i);
|
const result = window.QueuePrune?.pruneOldestTerminalJobs(queueJobs, TERMINAL_KEEP_LIMIT);
|
||||||
}
|
if (result) {
|
||||||
if (terminalIdxs.length > TERMINAL_KEEP_LIMIT) {
|
for (const j of result.dropped) {
|
||||||
const dropCount = terminalIdxs.length - TERMINAL_KEEP_LIMIT;
|
removeJobFromIndex(j);
|
||||||
// terminalIdxs is in insertion order → first `dropCount` are the
|
selectedJobIds.delete(j.id);
|
||||||
// oldest. Remove those, keep everything else.
|
|
||||||
const dropSet = new Set(terminalIdxs.slice(0, dropCount));
|
|
||||||
const nextJobs = [];
|
|
||||||
for (let i = 0; i < queueJobs.length; i++) {
|
|
||||||
if (dropSet.has(i)) {
|
|
||||||
removeJobFromIndex(queueJobs[i]);
|
|
||||||
selectedJobIds.delete(queueJobs[i].id);
|
|
||||||
} else {
|
|
||||||
nextJobs.push(queueJobs[i]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
queueJobs = nextJobs;
|
queueJobs = result.kept;
|
||||||
renderQueueTable();
|
renderQueueTable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -330,6 +330,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="../lib/queue-prune.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -9,12 +9,12 @@
|
|||||||
- ✅ 3.3.5 — Log-Rotation extrahiert nach `lib/log-rotation.js` + 10 neue Unit-Tests (cap, shift, eviction, idempotency, maxBackups=1, invalid input, no-extension)
|
- ✅ 3.3.5 — Log-Rotation extrahiert nach `lib/log-rotation.js` + 10 neue Unit-Tests (cap, shift, eviction, idempotency, maxBackups=1, invalid input, no-extension)
|
||||||
- ✅ 3.3.6 — CSS `.queue-row` transition nur noch auf `:hover` (kein 150ms compositor-tween bei status-flips)
|
- ✅ 3.3.6 — CSS `.queue-row` transition nur noch auf `:hover` (kein 150ms compositor-tween bei status-flips)
|
||||||
- ✅ 3.3.7 — `_sessionTrackedJobs`/`_sessionDoneJobs` werden bei handleBatchDone gegen current queueJobs geprunt (no more unbounded session memory growth across batches)
|
- ✅ 3.3.7 — `_sessionTrackedJobs`/`_sessionDoneJobs` werden bei handleBatchDone gegen current queueJobs geprunt (no more unbounded session memory growth across batches)
|
||||||
|
- ✅ 3.3.8 — queue-cap-prune-Logik nach `lib/queue-prune.js` extrahiert (dual-environment: Node + Browser-global) + 10 Unit-Tests (insertion-order, limit=0, malformed entries, large-queue 5000-job sweep)
|
||||||
|
|
||||||
## Open items (priorisiert)
|
## Open items (priorisiert)
|
||||||
|
|
||||||
### Code-Qualität (deferred — bräuchte jsdom für DOM/state)
|
### Code-Qualität (deferred — bräuchte jsdom für DOM/state)
|
||||||
- [ ] queue-cap-prune-Logik (3.3.0 handleBatchDone)
|
- [ ] sortQueueJobs dynamic-throttle (3.3.0) — modul-state-abhängig im renderer
|
||||||
- [ ] sortQueueJobs dynamic-throttle (3.3.0)
|
|
||||||
- [ ] removeFromQueueOnDone microtask-coalesce (3.3.1) — Microtask-Timing schwer zu testen ohne fake-timer setup
|
- [ ] removeFromQueueOnDone microtask-coalesce (3.3.1) — Microtask-Timing schwer zu testen ohne fake-timer setup
|
||||||
|
|
||||||
### Loop-Status
|
### Loop-Status
|
||||||
|
|||||||
115
tests/queue-prune.test.js
Normal file
115
tests/queue-prune.test.js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { pruneOldestTerminalJobs, TERMINAL_STATUSES } = require('../lib/queue-prune');
|
||||||
|
|
||||||
|
const j = (id, status) => ({ id, status });
|
||||||
|
|
||||||
|
test('returns null on empty / non-array input', () => {
|
||||||
|
assert.equal(pruneOldestTerminalJobs([], 5), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(null, 5), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(undefined, 5), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when all jobs are non-terminal regardless of limit', () => {
|
||||||
|
const jobs = [j('a', 'queued'), j('b', 'uploading'), j('c', 'preview')];
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, 0), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, 100), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when terminal count is at or under the limit', () => {
|
||||||
|
const jobs = [j('a', 'done'), j('b', 'done'), j('c', 'queued')];
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, 2), null, 'terminal=2, limit=2 → no-op');
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, 3), null, 'terminal=2, limit=3 → no-op');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops oldest terminal jobs when over the limit, keeps non-terminal', () => {
|
||||||
|
const jobs = [
|
||||||
|
j('t1', 'done'), // oldest terminal — should be dropped
|
||||||
|
j('t2', 'done'), // should be dropped
|
||||||
|
j('queued1', 'queued'),
|
||||||
|
j('t3', 'error'), // newest of the dropped block
|
||||||
|
j('uploading1', 'uploading'),
|
||||||
|
j('t4', 'done'), // kept (within limit window)
|
||||||
|
j('t5', 'skipped'), // kept
|
||||||
|
j('t6', 'aborted'), // kept
|
||||||
|
];
|
||||||
|
// 6 terminal, limit 3 → drop 3 oldest (t1, t2, t3)
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 3);
|
||||||
|
assert.notEqual(result, null);
|
||||||
|
const droppedIds = result.dropped.map(x => x.id).sort();
|
||||||
|
assert.deepEqual(droppedIds, ['t1', 't2', 't3']);
|
||||||
|
// Non-terminal jobs always kept; surviving terminals are the newest 3
|
||||||
|
const keptIds = result.kept.map(x => x.id);
|
||||||
|
assert.deepEqual(keptIds, ['queued1', 'uploading1', 't4', 't5', 't6']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('respects insertion order (oldest by index, not by status)', () => {
|
||||||
|
const jobs = [
|
||||||
|
j('older-error', 'error'),
|
||||||
|
j('newer-done', 'done'),
|
||||||
|
j('newest-aborted', 'aborted'),
|
||||||
|
];
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 1);
|
||||||
|
assert.deepEqual(result.dropped.map(x => x.id), ['older-error', 'newer-done']);
|
||||||
|
assert.deepEqual(result.kept.map(x => x.id), ['newest-aborted']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops everything terminal when limit is 0', () => {
|
||||||
|
const jobs = [
|
||||||
|
j('q', 'queued'),
|
||||||
|
j('d1', 'done'),
|
||||||
|
j('d2', 'done'),
|
||||||
|
j('e1', 'error'),
|
||||||
|
];
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 0);
|
||||||
|
assert.deepEqual(result.dropped.map(x => x.id), ['d1', 'd2', 'e1']);
|
||||||
|
assert.deepEqual(result.kept.map(x => x.id), ['q']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects negative or non-finite limits', () => {
|
||||||
|
const jobs = [j('a', 'done'), j('b', 'done')];
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, -1), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, NaN), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, Infinity), null,
|
||||||
|
'Infinity is technically not finite; safer to treat as no-op');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TERMINAL_STATUSES set covers all 4 terminal kinds', () => {
|
||||||
|
assert.ok(TERMINAL_STATUSES.has('done'));
|
||||||
|
assert.ok(TERMINAL_STATUSES.has('skipped'));
|
||||||
|
assert.ok(TERMINAL_STATUSES.has('error'));
|
||||||
|
assert.ok(TERMINAL_STATUSES.has('aborted'));
|
||||||
|
assert.equal(TERMINAL_STATUSES.size, 4);
|
||||||
|
// Non-terminal must not be in the set
|
||||||
|
for (const s of ['queued', 'preview', 'uploading', 'retrying', 'getting-server']) {
|
||||||
|
assert.equal(TERMINAL_STATUSES.has(s), false, `${s} must not be terminal`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles malformed entries (null / missing status) without throwing', () => {
|
||||||
|
const jobs = [
|
||||||
|
null,
|
||||||
|
j('a', 'done'),
|
||||||
|
{ id: 'no-status' }, // no status
|
||||||
|
j('b', 'done'),
|
||||||
|
];
|
||||||
|
// 2 terminal, limit 1 → drop oldest (a). null and no-status entries stay
|
||||||
|
// because they aren't terminal. The function must not throw on them.
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 1);
|
||||||
|
assert.notEqual(result, null);
|
||||||
|
assert.deepEqual(result.dropped.map(x => x && x.id), ['a']);
|
||||||
|
assert.equal(result.kept.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large queue: keeps the newest `limit` terminals', () => {
|
||||||
|
const jobs = [];
|
||||||
|
for (let i = 0; i < 5000; i++) jobs.push(j(`done-${i}`, 'done'));
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 500);
|
||||||
|
assert.notEqual(result, null);
|
||||||
|
assert.equal(result.dropped.length, 4500);
|
||||||
|
assert.equal(result.kept.length, 500);
|
||||||
|
// First kept = done-4500 (the 4501st original entry)
|
||||||
|
assert.equal(result.kept[0].id, 'done-4500');
|
||||||
|
assert.equal(result.kept[result.kept.length - 1].id, 'done-4999');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user