Retain exact recovery after batch start failure

Persist job-ID terminal outcomes before final queue acknowledgement and clear recovery evidence only after both the terminal marker and renderer queue are durable.
This commit is contained in:
Sucukdeluxe
2026-08-13 21:16:04 +02:00
parent a7fb3421f1
commit 746ae29a4f
3 changed files with 78 additions and 14 deletions
+34 -1
View File
@@ -31,6 +31,39 @@
return Array.from(snapshots.values()); return Array.from(snapshots.values());
} }
function buildFailedUploadSummary(tasks, message, now = Date.now()) {
const files = new Map();
for (const [index, task] of (Array.isArray(tasks) ? tasks : []).entries()) {
if (!task || typeof task !== 'object') continue;
const filePath = typeof task.file === 'string' ? task.file : '';
const fileName = filePath.split(/[\\/]/).pop() || `upload-${index + 1}`;
const key = filePath || `${fileName}\0${index}`;
if (!files.has(key)) files.set(key, { name: fileName, size: 0, results: [] });
files.get(key).results.push({
jobId: typeof task.jobId === 'string' ? task.jobId : '',
hoster: typeof task.hoster === 'string' ? task.hoster : '',
status: 'error',
error: message,
failureDetails: null,
download_url: null,
embed_url: null,
file_code: null
});
}
const grouped = Array.from(files.values());
const failed = grouped.reduce((count, file) => count + file.results.length, 0);
return {
id: `start-error-${now}`,
timestamp: new Date(now).toISOString(),
total: failed,
succeeded: 0,
failed,
skipped: 0,
files: grouped,
error: message
};
}
function getRecoveryOutcome(job, recovery) { function getRecoveryOutcome(job, recovery) {
const status = typeof job?.status === 'string' ? job.status : 'preview'; const status = typeof job?.status === 'string' ? job.status : 'preview';
const jobId = typeof job?.id === 'string' ? job.id : ''; const jobId = typeof job?.id === 'string' ? job.id : '';
@@ -50,5 +83,5 @@
return { status, interrupted: interruptedIds.has(jobId) && !terminalStatuses.has(status) }; return { status, interrupted: interruptedIds.has(jobId) && !terminalStatuses.has(status) };
} }
return { buildTerminalJobSnapshots, getRecoveryOutcome }; return { buildFailedUploadSummary, buildTerminalJobSnapshots, getRecoveryOutcome };
}); });
+24 -12
View File
@@ -45,7 +45,7 @@ const stats = require('./lib/stats');
const { createCollectors } = require('./lib/diagnostics-collectors'); const { createCollectors } = require('./lib/diagnostics-collectors');
const { createAgent } = require('./lib/diagnostics-agent'); const { createAgent } = require('./lib/diagnostics-agent');
const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report'); const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report');
const { buildTerminalJobSnapshots } = require('./lib/upload-recovery'); const { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('./lib/upload-recovery');
const { selectPublicUploadUrl } = require('./lib/upload-confirmation'); const { selectPublicUploadUrl } = require('./lib/upload-confirmation');
const { createBatchMutationGate } = require('./lib/batch-mutation-gate'); const { createBatchMutationGate } = require('./lib/batch-mutation-gate');
const { createUploadStartReservation } = require('./lib/upload-start-reservation'); const { createUploadStartReservation } = require('./lib/upload-start-reservation');
@@ -2348,19 +2348,31 @@ async function executeReservedUploadStart(payload, startLease) {
_thisManager.startBatch(tasks, { _thisManager.startBatch(tasks, {
primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()), primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()),
primeOverrides: Array.from(_sessionAccountOverrides.entries()) primeOverrides: Array.from(_sessionAccountOverrides.entries())
}).catch((err) => { }).catch(async (err) => {
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
const errorSummary = { await batchMutationGate.sealAndDrain();
id: 'error', const errorSummary = buildFailedUploadSummary(tasks, 'Upload konnte nicht gestartet werden');
timestamp: new Date().toISOString(), let historyPersisted = true;
total: tasks.length, try { await configStore.appendHistory(errorSummary); } catch (historyError) {
succeeded: 0, historyPersisted = false;
failed: tasks.length, debugLog(`appendHistory after start failure failed: ${historyError.message}`);
files: [], }
error: err ? err.message : 'Unbekannter Fehler' const terminalRecovery = {
...recovery,
settledAt: new Date().toISOString(),
terminalJobs: buildTerminalJobSnapshots(errorSummary)
}; };
safeSend('upload-batch-done', errorSummary); let terminalRecoveryPersisted = true;
configStore.saveUploadRecovery(null).catch(error => debugLog(`upload recovery state could not be cleared after start failure: ${error.message}`)); try { await configStore.saveUploadRecovery(terminalRecovery); } catch (recoveryError) {
terminalRecoveryPersisted = false;
debugLog(`upload recovery outcomes could not be saved after start failure: ${recoveryError.message}`);
}
const queuePersisted = await requestUploadFinalization(errorSummary, historyPersisted);
if (queuePersisted && terminalRecoveryPersisted) {
try { await configStore.saveUploadRecovery(null); } catch (clearError) {
debugLog(`upload recovery state could not be cleared after start failure: ${clearError.message}`);
}
}
_producerTracker.finish(); _producerTracker.finish();
if (!isAutoRetry) sendBatchWebhook(errorSummary, 0); if (!isAutoRetry) sendBatchWebhook(errorSummary, 0);
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
+20 -1
View File
@@ -3,6 +3,20 @@ const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
test('catastrophic batch starts retain exact terminal outcomes for every job', () => {
const { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('../lib/upload-recovery');
const summary = buildFailedUploadSummary([
{ jobId: 'job-a', file: 'C:\\one\\same.mkv', hoster: 'doodstream.com' },
{ jobId: 'job-b', file: 'D:\\two\\same.mkv', hoster: 'voe.sx' }
], 'Upload konnte nicht gestartet werden', Date.UTC(2026, 7, 13));
assert.equal(summary.total, 2);
assert.equal(summary.failed, 2);
assert.deepEqual(buildTerminalJobSnapshots(summary).map(entry => [entry.jobId, entry.status]), [
['job-a', 'error'],
['job-b', 'error']
]);
});
test('terminal recovery snapshots retain exact job outcomes and canonical links', () => { test('terminal recovery snapshots retain exact job outcomes and canonical links', () => {
const { buildTerminalJobSnapshots } = require('../lib/upload-recovery'); const { buildTerminalJobSnapshots } = require('../lib/upload-recovery');
const snapshots = buildTerminalJobSnapshots({ const snapshots = buildTerminalJobSnapshots({
@@ -67,8 +81,13 @@ test('main and renderer keep recovery evidence until final queue persistence suc
const batchDone = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf("ipcMain.handle('cancel-upload'")); const batchDone = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf("ipcMain.handle('cancel-upload'"));
assert.match(batchDone, /buildTerminalJobSnapshots\(summary\)/); assert.match(batchDone, /buildTerminalJobSnapshots\(summary\)/);
assert.match(batchDone, /if \(queuePersisted\)[\s\S]*saveUploadRecovery\(null\)/); assert.match(batchDone, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveUploadRecovery\(null\)/);
assert.ok(batchDone.indexOf('saveUploadRecovery(recoveryWithTerminalJobs)') < batchDone.indexOf('requestUploadFinalization(summary, historyPersisted)')); assert.ok(batchDone.indexOf('saveUploadRecovery(recoveryWithTerminalJobs)') < batchDone.indexOf('requestUploadFinalization(summary, historyPersisted)'));
const startFailure = batchDone.slice(batchDone.indexOf('startBatch(tasks'));
assert.match(startFailure, /buildFailedUploadSummary\(tasks/);
assert.match(startFailure, /saveUploadRecovery\(terminalRecovery\)/);
assert.match(startFailure, /requestUploadFinalization\(errorSummary, historyPersisted\)/);
assert.match(startFailure, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveUploadRecovery\(null\)/);
assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/); assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/);
assert.match(rendererSource, /data\.historyPersisted !== true/); assert.match(rendererSource, /data\.historyPersisted !== true/);
assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js')); assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js'));