fix(rotation): retry after batch-done reuses learned fallback state
Previously: clicking "Erneut versuchen" after a batch had already finished spawned a fresh UploadManager with empty _failedAccounts and _accountOverrides. The first retry then burned the full retry budget on the account we already knew was dead (e.g. disk-space-full byse account) before rotation kicked in again — same problem we fixed for within-batch flow but for across-batch flow. - main.js: two module-level maps (_sessionFailedAccounts, _sessionAccountOverrides) cache rotation state across batches in the same app session. Populated on account-failed and on both switchAccount paths (event-driven + save-config re-resolve). - lib/upload-manager.js: startBatch(tasks, opts) accepts primeFailedAccounts + primeOverrides. State is still cleared first (legacy behaviour for callers without opts), then re-primed from the passed session state. batch-start rot-log entry reports how many entries were primed for diagnostics. - Tests: prime priority is honored (pre-job-swap fires on first attempt, no fast-fail, no upload to acc1); back-compat for callers that don't pass opts. App restart remains the reset signal — matches the "neuer Tag, acc1 hat vielleicht wieder Platz" expectation.
This commit is contained in:
parent
1616ee8f14
commit
05e6d654c4
@ -227,7 +227,7 @@ class UploadManager extends EventEmitter {
|
|||||||
return this.semaphores[hoster];
|
return this.semaphores[hoster];
|
||||||
}
|
}
|
||||||
|
|
||||||
async startBatch(tasks) {
|
async startBatch(tasks, opts = {}) {
|
||||||
this.running = true;
|
this.running = true;
|
||||||
this.stopAfterActive = false;
|
this.stopAfterActive = false;
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
@ -240,13 +240,25 @@ class UploadManager extends EventEmitter {
|
|||||||
this.globalSemaphore = null;
|
this.globalSemaphore = null;
|
||||||
this.globalThrottle = null;
|
this.globalThrottle = null;
|
||||||
this.lastStartTime = {};
|
this.lastStartTime = {};
|
||||||
// Reset account-rotation state each batch. Otherwise a previously failed
|
// Reset account-rotation state each batch — but optionally re-prime from
|
||||||
// account (e.g. rate-limited during the last batch) stays permanently
|
// app-session memory so a "Retry failed" right after batch-done doesn't
|
||||||
// blacklisted until the app restarts — every upload would silently skip
|
// burn 5 retries on the account we already know is dead. Caller (main.js)
|
||||||
// straight to the fallback even after the original recovered.
|
// passes the session-scoped failed/override state.
|
||||||
this._failedAccounts.clear();
|
this._failedAccounts.clear();
|
||||||
this._accountOverrides.clear();
|
this._accountOverrides.clear();
|
||||||
this._rotLog('batch-start', { taskCount: tasks.length });
|
if (Array.isArray(opts.primeFailedAccounts)) {
|
||||||
|
for (const key of opts.primeFailedAccounts) this._failedAccounts.set(key, true);
|
||||||
|
}
|
||||||
|
if (Array.isArray(opts.primeOverrides)) {
|
||||||
|
for (const entry of opts.primeOverrides) {
|
||||||
|
if (Array.isArray(entry) && entry.length === 2) this._accountOverrides.set(entry[0], entry[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._rotLog('batch-start', {
|
||||||
|
taskCount: tasks.length,
|
||||||
|
primedFailed: this._failedAccounts.size,
|
||||||
|
primedOverrides: this._accountOverrides.size
|
||||||
|
});
|
||||||
|
|
||||||
const { signal } = this.abortController;
|
const { signal } = this.abortController;
|
||||||
const batchId = `batch-${Date.now()}`;
|
const batchId = `batch-${Date.now()}`;
|
||||||
|
|||||||
18
main.js
18
main.js
@ -20,6 +20,12 @@ let dropTargetWindow = null;
|
|||||||
let tray = null;
|
let tray = null;
|
||||||
const configStore = new ConfigStore(app);
|
const configStore = new ConfigStore(app);
|
||||||
let uploadManager = null;
|
let uploadManager = null;
|
||||||
|
// Rotation memory that survives batch-done → new UploadManager within the
|
||||||
|
// same app session. Without this, clicking "Retry failed" after a batch
|
||||||
|
// ended would burn the full retry budget on accounts we already know are
|
||||||
|
// dead. Cleared on app restart (which is the user's signal for "try fresh").
|
||||||
|
const _sessionFailedAccounts = new Map(); // "hoster:accountId" -> true
|
||||||
|
const _sessionAccountOverrides = new Map(); // hoster -> account object
|
||||||
const folderMonitor = new FolderMonitor();
|
const folderMonitor = new FolderMonitor();
|
||||||
let remoteServer = null;
|
let remoteServer = null;
|
||||||
let captureWindow = null;
|
let captureWindow = null;
|
||||||
@ -938,6 +944,7 @@ ipcMain.handle('save-config', async (_event, config) => {
|
|||||||
if (fallback) {
|
if (fallback) {
|
||||||
rotLog(`main: config-updated → late fallback ${fallback.id} for ${hoster} (was stuck on ${failedAccountId})`);
|
rotLog(`main: config-updated → late fallback ${fallback.id} for ${hoster} (was stuck on ${failedAccountId})`);
|
||||||
uploadManager.switchAccount(hoster, fallback);
|
uploadManager.switchAccount(hoster, fallback);
|
||||||
|
_sessionAccountOverrides.set(hoster, fallback);
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('account-switched', {
|
mainWindow.webContents.send('account-switched', {
|
||||||
hoster, fromAccountId: failedAccountId, toAccountId: fallback.id
|
hoster, fromAccountId: failedAccountId, toAccountId: fallback.id
|
||||||
@ -1149,11 +1156,15 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
uploadManager.on('account-failed', ({ hoster, accountId }) => {
|
uploadManager.on('account-failed', ({ hoster, accountId }) => {
|
||||||
|
// Persist to session cache so a subsequent batch (after batch-done)
|
||||||
|
// gets primed and won't burn retries on this account again.
|
||||||
|
_sessionFailedAccounts.set(hoster + ':' + accountId, true);
|
||||||
const cfg = configStore.load();
|
const cfg = configStore.load();
|
||||||
const fallback = getNextFallbackAccount(cfg, hoster, accountId);
|
const fallback = getNextFallbackAccount(cfg, hoster, accountId);
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
rotLog(`main: account-failed ${hoster} ${accountId} → resolved fallback ${fallback.id}`);
|
rotLog(`main: account-failed ${hoster} ${accountId} → resolved fallback ${fallback.id}`);
|
||||||
uploadManager.switchAccount(hoster, fallback);
|
uploadManager.switchAccount(hoster, fallback);
|
||||||
|
_sessionAccountOverrides.set(hoster, fallback);
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id });
|
mainWindow.webContents.send('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id });
|
||||||
}
|
}
|
||||||
@ -1193,8 +1204,11 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
// are not interleaved with the handle() response.
|
// are not interleaved with the handle() response.
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
if (!uploadManager) { debugLog('nextTick: uploadManager was nulled before startBatch'); return; }
|
if (!uploadManager) { debugLog('nextTick: uploadManager was nulled before startBatch'); return; }
|
||||||
debugLog('nextTick: calling startBatch now');
|
debugLog(`nextTick: calling startBatch now (priming ${_sessionFailedAccounts.size} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`);
|
||||||
uploadManager.startBatch(tasks).catch((err) => {
|
uploadManager.startBatch(tasks, {
|
||||||
|
primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()),
|
||||||
|
primeOverrides: Array.from(_sessionAccountOverrides.entries())
|
||||||
|
}).catch((err) => {
|
||||||
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
|
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
|
||||||
// Forward error to renderer as batch-done with failure
|
// Forward error to renderer as batch-done with failure
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
|||||||
@ -650,6 +650,66 @@ describe('UploadManager', () => {
|
|||||||
assert.equal(mgr.getOverride('voe.sx'), null, 'unrelated hoster still has no override');
|
assert.equal(mgr.getOverride('voe.sx'), null, 'unrelated hoster still has no override');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('startBatch primes failed-accounts + overrides — retry after batch-done skips dead account', async () => {
|
||||||
|
// acc1 fails with disk-space; acc2 succeeds.
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
if (apiKey === 'acc1-key') {
|
||||||
|
const err = new Error('Byse lehnte Datei ab: not enough disk space');
|
||||||
|
err.accountError = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager(
|
||||||
|
{ 'byse.sx': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
|
||||||
|
);
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e));
|
||||||
|
|
||||||
|
// Simulate a retry-after-batch-done: main.js would pass the
|
||||||
|
// session-cached failed-accounts + overrides from the previous batch.
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'byse.sx', apiKey: 'acc1-key', accountId: 'acc1', username: 'u1', password: 'p1' }
|
||||||
|
], {
|
||||||
|
primeFailedAccounts: ['byse.sx:acc1'],
|
||||||
|
primeOverrides: [['byse.sx', { id: 'acc2', username: 'u2', password: 'p2', apiKey: 'acc2-key' }]]
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = rotEvents.map(e => e.event);
|
||||||
|
// pre-job-swap should fire on the very first attempt — no fast-fail
|
||||||
|
// because acc1 was never touched.
|
||||||
|
assert.ok(events.includes('pre-job-swap'),
|
||||||
|
`expected pre-job-swap from primed state; got: ${events.join(',')}`);
|
||||||
|
assert.ok(!events.includes('fast-fail'),
|
||||||
|
`must NOT burn a fast-fail on primed-dead acc1; got: ${events.join(',')}`);
|
||||||
|
assert.ok(!events.includes('mark-failed'),
|
||||||
|
`acc1 was already marked failed (primed); must not emit mark-failed again; got: ${events.join(',')}`);
|
||||||
|
|
||||||
|
// acc1 must not be touched at all.
|
||||||
|
const acc1Calls = mockUploadFile.mock.calls.filter(c => c.arguments[2] === 'acc1-key').length;
|
||||||
|
assert.equal(acc1Calls, 0, 'primed-dead acc1 must not receive any upload attempts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startBatch without prime opts still clears state (back-compat)', async () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
mgr._failedAccounts.set('byse.sx:acc1', true);
|
||||||
|
mgr._accountOverrides.set('byse.sx', { id: 'leftover' });
|
||||||
|
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(mgr._failedAccounts.size, 0, 'legacy callers still get a clean slate');
|
||||||
|
assert.equal(mgr._accountOverrides.size, 0, 'legacy callers still get a clean slate for overrides');
|
||||||
|
});
|
||||||
|
|
||||||
it('transient network errors skip rotation (account stays fine)', () => {
|
it('transient network errors skip rotation (account stays fine)', () => {
|
||||||
const mgr = new UploadManager({});
|
const mgr = new UploadManager({});
|
||||||
const cases = [
|
const cases = [
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user