Multi-Hoster-Upload/lib/queue-dedup.js
Administrator eeec1d150c fix(queue): completed files no longer reappear in the queue after restart
Closing the app (especially during an active upload or on a hard kill) and
reopening sometimes left already-uploaded files sitting in the queue as if
still pending. Root cause is three layers stacked:

1. Persist-starvation: persistQueueStateSoon() reset a 10s debounce on every
   progress event, so during an upload the on-disk queue snapshot was never
   rewritten and stayed frozen at the pre-upload state (all jobs "preview").
2. The beforeunload sync flush only covers a clean close; a hard kill / crash
   leaves that stale snapshot on disk.
3. Startup auto-dedup only dropped jobs with status "done". The completed
   files were stored as "preview" in the stale snapshot, so they survived and
   reappeared.

Fix (mechanism-independent — holds whether the stale snapshot came from
starvation, a mid-upload close race, or a hard kill):

FIX A (core, durable): the upload log is the source of truth. Each persisted
snapshot is now stamped with savedAt; on restart any restored job whose newest
matching log entry is timestamped at/after floor(savedAt) is dropped regardless
of status — it provably completed after the snapshot, so a "preview" row for it
is a ghost. lib/queue-dedup.js gains an additive 3rd savedAt param; without
savedAt or without log timestamps it behaves exactly as before (the 5 canary
tests stay green, so intentional re-uploads of older files still survive).

FIX B: new lib/throttle-timer.js with a max-wait. During uploads the snapshot
is now written at most ~20s into a continuous progress burst instead of never;
idle stays a pure debounce. The fallback shim honors max-wait too, so a missing
library can never silently reintroduce the starvation.

FIX C: the synchronous close-write retries renameSync on EBUSY/EPERM/EACCES and
uses a pid-unique tmp (de-conflicts it from config-store._atomicWrite's fixed
.tmp). A startup sweep reclaims orphaned <config>.<pid>.tmp files left by a hard
kill between write and rename.

lib/upload-log.js extracts formatUploadLogLine + parseUploadLogLine from main.js
so the real writer -> reader -> gate seam is unit-tested (a future log-format or
epoch-basis change can no longer pass green while breaking the fix).

Verified: 334/334 tests green (incl. throttle fake-clock starvation/maxWait,
ts-gate multi-hoster partial-completion, and the real-format seam tests), ESLint
clean, smoke-boot identical to baseline, and an adversarial multi-agent review
(15 findings, 14 refuted, 1 low — the tmp orphan, now swept) on the diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 04:35:27 +02:00

85 lines
3.4 KiB
JavaScript

// Startup queue auto-dedup logic. Extracted from renderer/app.js
// _autoDeduplicateFromLog so the decision can be unit-tested without a DOM or
// the renderer's module-level state.
//
// Loaded both as a CommonJS module (Node tests) and as a browser global
// (renderer/app.js via index.html script tag) so a single implementation backs
// runtime and tests — no drift.
//
// Behaviour: on launch the restored queue is compared against the lifetime
// upload log. Two rules drop a job:
// 1) a 'done' job whose fileName|hoster appears in the log (declutter of
// already-finished work), and
// 2) ANY job (incl. preview) whose newest matching log entry is timestamped
// at/after the snapshot's savedAt — it provably completed AFTER the queue
// was last persisted, so a restored 'preview' row for it is a stale ghost.
//
// Rule 2 only fires when a savedAt is passed AND the log carries timestamps;
// without them this falls back to rule 1 alone. That fallback is the invariant
// the canary tests pin: a pending job matching an OLDER log line (ts < savedAt,
// or no ts at all) is KEPT — it's an intentional re-upload of a file uploaded
// before, not a ghost. The old code filtered on log-presence alone, regardless
// of status, so the ENTIRE restored queue vanished on the next restart/update
// whenever the files had been uploaded previously. Manual log import
// (importUploadLog) stays separate and explicit for bulk dedup.
(function (root) {
'use strict';
function _key(fileName, hoster) {
return `${String(fileName).toLowerCase()}|${String(hoster).toLowerCase()}`;
}
/**
* Partition restored queue jobs into kept vs removed, given lifetime log
* entries. Removes only 'done' jobs whose fileName|hoster is in the log.
* @param {Array<{status:string,fileName:string,hoster:string}>} jobs
* @param {Array<{fileName:string,hoster:string}>} logEntries
* @returns {{ kept: Array, removed: Array }}
*/
function partitionRestoredJobsByLog(jobs, logEntries, savedAt) {
const kept = [];
const removed = [];
if (!Array.isArray(jobs) || jobs.length === 0) return { kept, removed };
const logKeys = new Set();
const logMaxTs = new Map();
for (const e of (Array.isArray(logEntries) ? logEntries : [])) {
if (e && e.fileName && e.hoster) {
const k = _key(e.fileName, e.hoster);
logKeys.add(k);
if (typeof e.ts === 'number' && isFinite(e.ts)) {
const prev = logMaxTs.get(k);
if (prev === undefined || e.ts > prev) logMaxTs.set(k, e.ts);
}
}
}
const savedAtFloor = (typeof savedAt === 'number' && isFinite(savedAt))
? Math.floor(savedAt / 1000) * 1000
: null;
for (const job of jobs) {
const hasIds = job && job.fileName && job.hoster;
const k = hasIds ? _key(job.fileName, job.hoster) : null;
const doneInLog = job && job.status === 'done' && hasIds && logKeys.has(k);
const uploadedAfterSnapshot = savedAtFloor !== null && k !== null
&& logMaxTs.has(k) && logMaxTs.get(k) >= savedAtFloor;
if (doneInLog || uploadedAfterSnapshot) {
removed.push(job);
} else {
kept.push(job);
}
}
return { kept, removed };
}
const api = { partitionRestoredJobsByLog };
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.QueueDedup = api;
}
})(typeof window !== 'undefined' ? window : this);