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:
+34
-1
@@ -31,6 +31,39 @@
|
||||
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) {
|
||||
const status = typeof job?.status === 'string' ? job.status : 'preview';
|
||||
const jobId = typeof job?.id === 'string' ? job.id : '';
|
||||
@@ -50,5 +83,5 @@
|
||||
return { status, interrupted: interruptedIds.has(jobId) && !terminalStatuses.has(status) };
|
||||
}
|
||||
|
||||
return { buildTerminalJobSnapshots, getRecoveryOutcome };
|
||||
return { buildFailedUploadSummary, buildTerminalJobSnapshots, getRecoveryOutcome };
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ const stats = require('./lib/stats');
|
||||
const { createCollectors } = require('./lib/diagnostics-collectors');
|
||||
const { createAgent } = require('./lib/diagnostics-agent');
|
||||
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 { createBatchMutationGate } = require('./lib/batch-mutation-gate');
|
||||
const { createUploadStartReservation } = require('./lib/upload-start-reservation');
|
||||
@@ -2348,19 +2348,31 @@ async function executeReservedUploadStart(payload, startLease) {
|
||||
_thisManager.startBatch(tasks, {
|
||||
primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()),
|
||||
primeOverrides: Array.from(_sessionAccountOverrides.entries())
|
||||
}).catch((err) => {
|
||||
}).catch(async (err) => {
|
||||
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
|
||||
const errorSummary = {
|
||||
id: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
total: tasks.length,
|
||||
succeeded: 0,
|
||||
failed: tasks.length,
|
||||
files: [],
|
||||
error: err ? err.message : 'Unbekannter Fehler'
|
||||
await batchMutationGate.sealAndDrain();
|
||||
const errorSummary = buildFailedUploadSummary(tasks, 'Upload konnte nicht gestartet werden');
|
||||
let historyPersisted = true;
|
||||
try { await configStore.appendHistory(errorSummary); } catch (historyError) {
|
||||
historyPersisted = false;
|
||||
debugLog(`appendHistory after start failure failed: ${historyError.message}`);
|
||||
}
|
||||
const terminalRecovery = {
|
||||
...recovery,
|
||||
settledAt: new Date().toISOString(),
|
||||
terminalJobs: buildTerminalJobSnapshots(errorSummary)
|
||||
};
|
||||
safeSend('upload-batch-done', errorSummary);
|
||||
configStore.saveUploadRecovery(null).catch(error => debugLog(`upload recovery state could not be cleared after start failure: ${error.message}`));
|
||||
let terminalRecoveryPersisted = true;
|
||||
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();
|
||||
if (!isAutoRetry) sendBatchWebhook(errorSummary, 0);
|
||||
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
|
||||
|
||||
@@ -3,6 +3,20 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
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', () => {
|
||||
const { buildTerminalJobSnapshots } = require('../lib/upload-recovery');
|
||||
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'"));
|
||||
|
||||
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)'));
|
||||
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, /data\.historyPersisted !== true/);
|
||||
assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js'));
|
||||
|
||||
Reference in New Issue
Block a user