diff --git a/lib/throttled-cache.js b/lib/throttled-cache.js new file mode 100644 index 0000000..c6376cf --- /dev/null +++ b/lib/throttled-cache.js @@ -0,0 +1,51 @@ +// Time-windowed memoization. Reuses a previously-computed value if the +// signature + input identity match AND the cached entry is younger than +// `refreshMs`. Used by the renderer's dynamic-key sort throttle (every +// progress tick re-sorts a 5000-row queue → reuse for 200 ms, the user +// can't perceive sub-200 ms reorder lag). +// +// Loaded both as a CommonJS module (Node tests) and as a browser global +// (renderer/app.js via index.html script tag) — same single implementation +// across runtime and tests. + +(function (root) { + 'use strict'; + + /** + * Build a throttled cache. The clock is injected so tests don't have to + * sleep — pass `() => fakeClock.value` from tests. + * + * @param {number} refreshMs cache TTL in milliseconds + * @param {() => number} [now] clock source, defaults to Date.now + */ + function makeThrottledCache(refreshMs, now) { + if (!Number.isFinite(refreshMs) || refreshMs < 0) { + throw new TypeError('refreshMs must be a non-negative finite number'); + } + const clock = typeof now === 'function' ? now : () => Date.now(); + let entry = null; + return { + get(sig, input) { + if (!entry) return undefined; + if (entry.sig !== sig) return undefined; + if (entry.input !== input) return undefined; + if (clock() - entry.ts >= refreshMs) return undefined; + return entry.value; + }, + set(sig, input, value) { + entry = { sig, input, value, ts: clock() }; + return value; + }, + clear() { entry = null; }, + // Introspection (mainly for tests/debug). Returns null when empty. + peek() { + if (!entry) return null; + return { sig: entry.sig, ts: entry.ts, age: clock() - entry.ts }; + } + }; + } + + const api = { makeThrottledCache }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else if (root) root.ThrottledCache = api; +})(typeof window !== 'undefined' ? window : this); diff --git a/renderer/app.js b/renderer/app.js index 7127eaa..d969f56 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1167,8 +1167,11 @@ const _STATIC_SORT_KEYS = new Set(['filename', 'host']); // one UI_UPDATE_INTERVAL window (200ms), reuse the previous sort even if it's // slightly out of order — the user can't perceive sub-200ms reorder lag, and // at 5000 queued jobs this is the difference between smooth and stuttering. -let _dynamicSortCache = { key: '', direction: '', jobsRef: null, result: null, ts: 0 }; +// Uses lib/throttled-cache.js (see tests/throttled-cache.test.js). const DYNAMIC_SORT_REFRESH_MS = 200; +const _dynamicSortCache = window.ThrottledCache + ? window.ThrottledCache.makeThrottledCache(DYNAMIC_SORT_REFRESH_MS) + : { get: () => undefined, set: (s, i, v) => v, clear: () => {} }; function sortQueueJobs(jobs) { const { key, direction } = queueSortState; @@ -1179,11 +1182,13 @@ function sortQueueJobs(jobs) { return _queueSortCache.result; } // Dynamic-key throttle: same key+direction+array, sorted within the last - // 200ms → reuse. Cleared on user-initiated sort changes (different key). - if (!canCache && _dynamicSortCache.jobsRef === jobs && - _dynamicSortCache.key === key && _dynamicSortCache.direction === direction && - _dynamicSortCache.result && (Date.now() - _dynamicSortCache.ts) < DYNAMIC_SORT_REFRESH_MS) { - return _dynamicSortCache.result; + // 200ms → reuse. The cache is keyed by `key|direction` and uses the jobs + // array identity as the input ref, so a fresh queueJobs (e.g. after + // backup import) misses correctly. + if (!canCache) { + const dynSig = `${key}|${direction}`; + const cached = _dynamicSortCache.get(dynSig, jobs); + if (cached) return cached; } const sorted = jobs.slice().sort((a, b) => { @@ -1197,7 +1202,7 @@ function sortQueueJobs(jobs) { return cmp * factor; }); if (sig) _queueSortCache = { sig, result: sorted, jobsRef: jobs }; - else _dynamicSortCache = { key, direction, jobsRef: jobs, result: sorted, ts: Date.now() }; + else _dynamicSortCache.set(`${key}|${direction}`, jobs, sorted); return sorted; } diff --git a/renderer/index.html b/renderer/index.html index a9a2ae9..be63aaf 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -331,6 +331,7 @@ + diff --git a/tasks/todo.md b/tasks/todo.md index 478b7ea..d17bcd4 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -10,11 +10,11 @@ - ✅ 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.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) +- ✅ 3.3.9 — Throttled-Cache nach `lib/throttled-cache.js` extrahiert (von sortQueueJobs dynamic-throttle genutzt) + 12 Unit-Tests (TTL-Boundary, identity-tracking, fake-clock, peek/clear, refreshMs=0, large-input) ## Open items (priorisiert) -### Code-Qualität (deferred — bräuchte jsdom für DOM/state) -- [ ] sortQueueJobs dynamic-throttle (3.3.0) — modul-state-abhängig im renderer +### Code-Qualität (deferred) - [ ] removeFromQueueOnDone microtask-coalesce (3.3.1) — Microtask-Timing schwer zu testen ohne fake-timer setup ### Loop-Status diff --git a/tests/throttled-cache.test.js b/tests/throttled-cache.test.js new file mode 100644 index 0000000..7037c52 --- /dev/null +++ b/tests/throttled-cache.test.js @@ -0,0 +1,113 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { makeThrottledCache } = require('../lib/throttled-cache'); + +function fakeClock(start = 0) { + let t = start; + const fn = () => t; + fn.advance = (ms) => { t += ms; }; + fn.set = (ms) => { t = ms; }; + return fn; +} + +test('returns undefined when empty', () => { + const c = makeThrottledCache(100); + assert.equal(c.get('any', {}), undefined); +}); + +test('returns the set value within the window', () => { + const clock = fakeClock(); + const c = makeThrottledCache(100, clock); + const input = [1, 2, 3]; + c.set('sig-a', input, 'value-1'); + assert.equal(c.get('sig-a', input), 'value-1'); + clock.advance(50); + assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 50/100 ms'); + clock.advance(49); + assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 99/100 ms'); +}); + +test('expires exactly at refreshMs boundary', () => { + const clock = fakeClock(); + const c = makeThrottledCache(100, clock); + c.set('s', {}, 'v'); + clock.advance(100); + assert.equal(c.get('s', {}), undefined, '>= refreshMs is a miss'); +}); + +test('miss on different signature', () => { + const c = makeThrottledCache(1000, fakeClock()); + const input = {}; + c.set('sig-a', input, 'v'); + assert.equal(c.get('sig-b', input), undefined); +}); + +test('miss on different input identity even with same signature', () => { + const c = makeThrottledCache(1000, fakeClock()); + c.set('sig-a', { a: 1 }, 'v'); + // Different object identity — the cache compares by ===, not by contents + assert.equal(c.get('sig-a', { a: 1 }), undefined); +}); + +test('overwrite by re-setting same signature', () => { + const clock = fakeClock(); + const c = makeThrottledCache(100, clock); + const input = []; + c.set('s', input, 'old'); + clock.advance(50); + c.set('s', input, 'new'); + // The new entry has a fresh timestamp → still valid for another 100 ms + clock.advance(99); + assert.equal(c.get('s', input), 'new'); +}); + +test('clear empties the cache', () => { + const c = makeThrottledCache(1000, fakeClock()); + c.set('s', {}, 'v'); + c.clear(); + assert.equal(c.get('s', {}), undefined); + assert.equal(c.peek(), null); +}); + +test('peek reports age and signature', () => { + const clock = fakeClock(); + const c = makeThrottledCache(1000, clock); + c.set('mysig', {}, 'v'); + clock.advance(42); + const p = c.peek(); + assert.equal(p.sig, 'mysig'); + assert.equal(p.age, 42); + assert.equal(p.ts, 0); +}); + +test('throws on invalid refreshMs', () => { + assert.throws(() => makeThrottledCache(-1)); + assert.throws(() => makeThrottledCache(NaN)); + assert.throws(() => makeThrottledCache('100')); +}); + +test('refreshMs=0 means every call misses', () => { + const clock = fakeClock(); + const c = makeThrottledCache(0, clock); + const input = {}; + c.set('s', input, 'v'); + // Same tick: 0 - 0 = 0 → not less than refreshMs (0) → miss + assert.equal(c.get('s', input), undefined); +}); + +test('default clock is Date.now when none provided', () => { + const c = makeThrottledCache(10000); + const input = {}; // single ref — get and set must use the SAME identity + c.set('x', input, 'v'); + assert.equal(c.get('x', input), 'v'); +}); + +test('large input arrays are tracked by identity, not value', () => { + const c = makeThrottledCache(1000, fakeClock()); + const arr1 = new Array(10000).fill(0); + const arr2 = new Array(10000).fill(0); + c.set('s', arr1, 'cached'); + assert.equal(c.get('s', arr1), 'cached'); + assert.equal(c.get('s', arr2), undefined, 'different array → miss'); +});