Multi-Hoster-Upload/lib/queue-dedup.js
Administrator 6b0c515a9b fix(queue): close two lost-work / sticky-ghost edge cases found by intensive testing
Follow-up hardening on the v3.3.80 queue-persistence fix after a deeper adversarial
sweep + reviewer pass over the WHOLE subsystem (not just the diff). Two real defects:

1. Basename-collision lost work (regression introduced by v3.3.80 FIX A).
   restoreQueueStateFromConfig collapses jobs on the FULL path while the ts-gate keys
   on basename|hoster. Two genuinely different files with the same basename queued to
   the same hoster from different folders therefore share a gate key: if one was logged
   after savedAt, the ts-rule dropped BOTH — silently losing the still-pending one.
   Pre-FIX-A only 'done' jobs were dropped, so a pending file was never at risk.
   Fix: an ambiguity guard in partitionRestoredJobsByLog — the ts-rule is suppressed
   when a basename|hoster key maps to more than one distinct file path (the log records
   only basenames, so it can't say which physical file completed). The done-in-log rule
   is unchanged. Fails safe: worst case a visible ghost survives, never silent data loss.

2. selectedFiles re-materialization with removeFromQueueOnDone=ON (second mechanism,
   independent of the stale snapshot). When that setting is on, a completed job is
   stripped from queueJobs but its path stays in selectedFiles (syncSelectedFilesFromQueue
   only runs at batch-done, never on a mid-upload close). On restart the ts-gate operates
   on queueJobs and never sees it, then the startup updateUploadView -> buildQueuePreview
   re-creates it as a preview ghost AFTER the gate ran, and it re-persists with a fresh
   savedAt — sticky. Fix: completedSelectionKeys() seeds _completedUploadKeys (the set
   buildQueuePreview already consults) from the log at startup, keyed on full path, with
   the same ambiguity guard. Log-based so it survives a hard kill, consistent with FIX A.

Also extracts the orphan-tmp sweep decision into lib/orphan-tmp.js (was untested inline
code in main.js; behavior-preserving) and adds executable coverage for the paths that
were previously only argued from logic: orphan-tmp sweep, config-store pendingQueue+savedAt
round-trip, an end-to-end scenario in the exact user-reported shape (300 queued / ~200
finished mid-session), and a 3000+500-iteration property fuzz of the gate invariant
including the lost-work guarantee. 359/359 green, ESLint clean, smoke-boot unchanged.

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

114 lines
4.6 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;
const filesPerKey = new Map();
for (const job of jobs) {
if (job && job.fileName && job.hoster) {
const jk = _key(job.fileName, job.hoster);
let set = filesPerKey.get(jk);
if (!set) { set = new Set(); filesPerKey.set(jk, set); }
set.add(job.file || '');
}
}
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 keyUnambiguous = k !== null && filesPerKey.get(k).size <= 1;
const uploadedAfterSnapshot = savedAtFloor !== null && k !== null && keyUnambiguous
&& logMaxTs.has(k) && logMaxTs.get(k) >= savedAtFloor;
if (doneInLog || uploadedAfterSnapshot) {
removed.push(job);
} else {
kept.push(job);
}
}
return { kept, removed };
}
function completedSelectionKeys(selectedFiles, hosters, logEntries, savedAt) {
const out = [];
if (!Array.isArray(selectedFiles) || !Array.isArray(hosters)) return out;
if (!(typeof savedAt === 'number' && isFinite(savedAt))) return out;
const synthetic = [];
for (const f of selectedFiles) {
if (!f || !f.path) continue;
const name = f.name || String(f.path).split(/[\\/]/).pop();
for (const h of hosters) {
if (h) synthetic.push({ fileName: name, hoster: h, file: f.path, status: 'preview' });
}
}
if (synthetic.length === 0) return out;
const { removed } = partitionRestoredJobsByLog(synthetic, logEntries, savedAt);
for (const job of removed) out.push(`${job.file}|${job.hoster}`);
return out;
}
const api = { partitionRestoredJobsByLog, completedSelectionKeys };
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.QueueDedup = api;
}
})(typeof window !== 'undefined' ? window : this);