Harden source cleanup durability barriers

Separate provisional upload success from persistently confirmed cleanup state, invalidate prior confirmations before retries, and promote completion only inside the final queue persistence handshake. Preserve source files when history or queue persistence fails and add regression coverage for restart, retry, rollback, and real Electron finalization paths.
This commit is contained in:
Sucukdeluxe
2026-08-13 19:45:31 +02:00
parent bfb3a39fed
commit 67978cb81f
7 changed files with 540 additions and 91 deletions
+43 -23
View File
@@ -83,30 +83,29 @@ function createSourceFileCleanup(options) {
function createManifest(input, canonicalFile) {
const requiredHosters = uniqueStrings(input.requiredHosters);
const completedHosters = new Set(uniqueStrings(input.completedHosters));
const confirmedHosters = new Set(
uniqueStrings(input.confirmedHosters).filter((hoster) => requiredHosters.includes(hoster))
);
const jobs = new Map();
for (const job of Array.isArray(input.jobs) ? input.jobs : []) {
if (!job || typeof job.jobId !== 'string' || typeof job.hoster !== 'string') continue;
const status = completedHosters.has(job.hoster) ? 'done' : normalizeStatus(job.status);
const currentRound = job.currentRound !== false;
const status = currentRound ? 'pending' : normalizeStatus(job.status);
jobs.set(job.jobId, Object.freeze({
jobId: job.jobId,
hoster: job.hoster,
status
status,
currentRound
}));
}
for (const hoster of completedHosters) {
if (requiredHosters.includes(hoster)) continue;
completedHosters.delete(hoster);
}
return {
token: input.token || input.sourceCleanupToken,
file: path.resolve(input.file),
canonicalFile,
requiredHosters: Object.freeze(requiredHosters),
completedHosters,
confirmedHosters,
jobs,
suppliedFingerprint: isFingerprint(input.fingerprint) ? cloneFingerprint(input.fingerprint) : null,
fingerprint: null,
@@ -152,16 +151,32 @@ function createSourceFileCleanup(options) {
...existing.requiredHosters,
...input.requiredHosters
]));
for (const hoster of uniqueStrings(input.completedHosters)) {
if (existing.requiredHosters.includes(hoster)) existing.completedHosters.add(hoster);
const incomingJobs = Array.isArray(input.jobs) ? input.jobs : [];
const currentRoundHosters = new Set(
incomingJobs
.filter((job) => job && typeof job.hoster === 'string' && job.currentRound !== false)
.map((job) => job.hoster)
);
for (const hoster of currentRoundHosters) existing.confirmedHosters.delete(hoster);
for (const hoster of uniqueStrings(input.confirmedHosters)) {
if (existing.requiredHosters.includes(hoster) && !currentRoundHosters.has(hoster)) {
existing.confirmedHosters.add(hoster);
}
}
for (const job of Array.isArray(input.jobs) ? input.jobs : []) {
for (const job of incomingJobs) {
if (!job || typeof job.jobId !== 'string' || typeof job.hoster !== 'string') continue;
const previous = existing.jobs.get(job.jobId);
const status = existing.completedHosters.has(job.hoster)
? 'done'
const incomingCurrentRound = job.currentRound !== false;
const currentRound = Boolean((previous && previous.currentRound) || incomingCurrentRound);
const status = incomingCurrentRound
? 'pending'
: normalizeStatus(previous ? previous.status : job.status);
existing.jobs.set(job.jobId, Object.freeze({ jobId: job.jobId, hoster: job.hoster, status }));
existing.jobs.set(job.jobId, Object.freeze({
jobId: job.jobId,
hoster: job.hoster,
status,
currentRound
}));
}
fingerprints[token] = cloneFingerprint(existing.fingerprint);
continue;
@@ -194,10 +209,9 @@ function createSourceFileCleanup(options) {
const manifest = groups.get(token);
if (!manifest || manifest.finalizationPromise) return false;
const job = manifest.jobs.get(event.jobId);
if (!job || job.hoster !== event.hoster) return false;
if (!job || !job.currentRound || job.hoster !== event.hoster) return false;
if (typeof event.file === 'string' && canonicalize(event.file) !== manifest.canonicalFile) return false;
manifest.jobs.set(event.jobId, Object.freeze({ ...job, status: event.status }));
if (event.status === 'done') manifest.completedHosters.add(event.hoster);
return true;
}
@@ -206,7 +220,7 @@ function createSourceFileCleanup(options) {
for (const manifest of groups.values()) {
if (manifest.finalizationPromise) continue;
const job = manifest.jobs.get(jobId);
if (!job) continue;
if (!job || !job.currentRound) continue;
manifest.jobs.set(jobId, Object.freeze({ ...job, status: 'skipped' }));
changed = true;
}
@@ -216,11 +230,17 @@ function createSourceFileCleanup(options) {
function blockingStatuses(manifest) {
const blocking = [];
for (const hoster of manifest.requiredHosters) {
if (manifest.completedHosters.has(hoster)) continue;
const statuses = [...manifest.jobs.values()]
.filter((job) => job.hoster === hoster)
.map((job) => job.status);
if (statuses.includes('done')) continue;
const jobs = [...manifest.jobs.values()].filter((job) => job.hoster === hoster);
const currentJobs = jobs.filter((job) => job.currentRound);
if (currentJobs.length > 0) {
const currentBlocker = currentJobs.find((job) => job.status !== 'done');
if (!currentBlocker) continue;
const status = currentJobs.map((job) => job.status).find((value) => value !== 'pending') || 'pending';
blocking.push({ hoster, status });
continue;
}
if (manifest.confirmedHosters.has(hoster)) continue;
const statuses = jobs.map((job) => job.status).filter((status) => status !== 'done');
const status = statuses.find((value) => value !== 'pending') || 'pending';
blocking.push({ hoster, status });
}