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];
|
||||
}
|
||||
|
||||
async startBatch(tasks) {
|
||||
async startBatch(tasks, opts = {}) {
|
||||
this.running = true;
|
||||
this.stopAfterActive = false;
|
||||
this.abortController = new AbortController();
|
||||
@ -240,13 +240,25 @@ class UploadManager extends EventEmitter {
|
||||
this.globalSemaphore = null;
|
||||
this.globalThrottle = null;
|
||||
this.lastStartTime = {};
|
||||
// Reset account-rotation state each batch. Otherwise a previously failed
|
||||
// account (e.g. rate-limited during the last batch) stays permanently
|
||||
// blacklisted until the app restarts — every upload would silently skip
|
||||
// straight to the fallback even after the original recovered.
|
||||
// Reset account-rotation state each batch — but optionally re-prime from
|
||||
// app-session memory so a "Retry failed" right after batch-done doesn't
|
||||
// burn 5 retries on the account we already know is dead. Caller (main.js)
|
||||
// passes the session-scoped failed/override state.
|
||||
this._failedAccounts.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 batchId = `batch-${Date.now()}`;
|
||||
|
||||
18
main.js
18
main.js
@ -20,6 +20,12 @@ let dropTargetWindow = null;
|
||||
let tray = null;
|
||||
const configStore = new ConfigStore(app);
|
||||
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();
|
||||
let remoteServer = null;
|
||||
let captureWindow = null;
|
||||
@ -938,6 +944,7 @@ ipcMain.handle('save-config', async (_event, config) => {
|
||||
if (fallback) {
|
||||
rotLog(`main: config-updated → late fallback ${fallback.id} for ${hoster} (was stuck on ${failedAccountId})`);
|
||||
uploadManager.switchAccount(hoster, fallback);
|
||||
_sessionAccountOverrides.set(hoster, fallback);
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('account-switched', {
|
||||
hoster, fromAccountId: failedAccountId, toAccountId: fallback.id
|
||||
@ -1149,11 +1156,15 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
});
|
||||
|
||||
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 fallback = getNextFallbackAccount(cfg, hoster, accountId);
|
||||
if (fallback) {
|
||||
rotLog(`main: account-failed ${hoster} ${accountId} → resolved fallback ${fallback.id}`);
|
||||
uploadManager.switchAccount(hoster, fallback);
|
||||
_sessionAccountOverrides.set(hoster, fallback);
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
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.
|
||||
process.nextTick(() => {
|
||||
if (!uploadManager) { debugLog('nextTick: uploadManager was nulled before startBatch'); return; }
|
||||
debugLog('nextTick: calling startBatch now');
|
||||
uploadManager.startBatch(tasks).catch((err) => {
|
||||
debugLog(`nextTick: calling startBatch now (priming ${_sessionFailedAccounts.size} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`);
|
||||
uploadManager.startBatch(tasks, {
|
||||
primeFailedAccounts: Array.from(_sessionFailedAccounts.keys()),
|
||||
primeOverrides: Array.from(_sessionAccountOverrides.entries())
|
||||
}).catch((err) => {
|
||||
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
|
||||
// Forward error to renderer as batch-done with failure
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
|
||||
@ -650,6 +650,66 @@ describe('UploadManager', () => {
|
||||
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)', () => {
|
||||
const mgr = new UploadManager({});
|
||||
const cases = [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user