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());
|
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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user