perf(rotation): rotate after 1 fail on generic errors, not after 5
Before: a non-transient / non-file-rejected / non-account-specific error (e.g. "VOE Upload: <any generic message>") would burn the full retries-per-account budget on the primary before the rotation logic even kicked in. On retries=5 that's "Retry 2/5 Primär", "Retry 3/5 Primär", … all on the same broken account before the fallback gets a shot. Now: - main.js pre-resolves the next fallback for every hoster at batch- start (stored in _accountOverrides via the existing session cache + primeOverrides path). Pre-job-swap still ignores it until the primary is actually marked failed, so jobs still begin on primary. - upload-manager.js: in the retry loop's generic error branch, _hasPendingOverride() checks whether a usable fallback is ready. If yes and the error is NOT transient (transient = network glitch = retry same acc), break out to rotation. Marks primary failed, rotates to acc2, retries there. - Result: for a 2-account hoster, worst case is 1 attempt on primary + retries-per-account on fallback, instead of N × 2. Transient network errors (ENOTFOUND / ECONNRESET / socket hang up) keep the old "retry same account" semantics because the network is the issue, not the account. - Single-account hosters: unchanged. No pending override = classic retry-on-same-account until exhausted. 3 new tests pin: generic + override → rotate on attempt 1; transient + override → stay on same acc; no override → classic retry. 87/87 green.
This commit is contained in:
parent
c70f105685
commit
3553666d9d
@ -64,6 +64,19 @@ class UploadManager extends EventEmitter {
|
|||||||
return this._accountOverrides.get(hoster) || null;
|
return this._accountOverrides.get(hoster) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True if the hoster has a usable override stored that differs from the
|
||||||
|
// account currently in the task and isn't itself already marked failed.
|
||||||
|
// Used by the retry loop to decide "retry on same account vs break to
|
||||||
|
// rotation" — skipping wasted attempts on a likely-bad primary when a
|
||||||
|
// pre-resolved fallback is ready to try.
|
||||||
|
_hasPendingOverride(hoster, currentAccountId) {
|
||||||
|
const override = this._accountOverrides.get(hoster);
|
||||||
|
if (!override) return false;
|
||||||
|
if (override.id === currentAccountId) return false;
|
||||||
|
if (this._failedAccounts.has(hoster + ':' + override.id)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
_rotLog(event, data) {
|
_rotLog(event, data) {
|
||||||
this.emit('rot-log', { ts: Date.now(), event, ...data });
|
this.emit('rot-log', { ts: Date.now(), event, ...data });
|
||||||
}
|
}
|
||||||
@ -595,6 +608,19 @@ class UploadManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// Generic non-transient error AND a fallback is already resolved for
|
||||||
|
// this hoster: bail to rotation instead of burning more retries on a
|
||||||
|
// possibly-dead primary. The fallback (pre-resolved at batch-start)
|
||||||
|
// deserves a real shot. Transient network errors stay on the same
|
||||||
|
// account — the network is the issue, not the account.
|
||||||
|
if (!this._isTransientNetworkError(err) &&
|
||||||
|
this._hasPendingOverride(task.hoster, task.accountId)) {
|
||||||
|
this._rotLog('try-alternate-after-fail', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||||
|
attempt, error: err && err.message ? err.message : String(err)
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (attempt >= maxAttempts) break;
|
if (attempt >= maxAttempts) break;
|
||||||
// Wait 3 seconds before retry
|
// Wait 3 seconds before retry
|
||||||
await this._sleep(3000, signal);
|
await this._sleep(3000, signal);
|
||||||
|
|||||||
20
main.js
20
main.js
@ -1133,6 +1133,26 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
|
|
||||||
if (tasks.length === 0) return { error: 'Keine gültigen Zugangsdaten für die gewählten Hoster.', skippedJobs };
|
if (tasks.length === 0) return { error: 'Keine gültigen Zugangsdaten für die gewählten Hoster.', skippedJobs };
|
||||||
|
|
||||||
|
// Pre-resolve a fallback for every hoster that has one. Lets the upload
|
||||||
|
// manager break out of the retry loop after a single generic failure and
|
||||||
|
// try the alternate account immediately, instead of hammering a probably-
|
||||||
|
// dead primary 5× before the account-failed event even fires. Doesn't
|
||||||
|
// trigger pre-job-swap (which only fires when the current account is in
|
||||||
|
// _failedAccounts), so jobs still start on the primary as expected.
|
||||||
|
const hostersInBatch = new Set(tasks.map(t => t.hoster).filter(Boolean));
|
||||||
|
for (const hoster of hostersInBatch) {
|
||||||
|
if (_sessionAccountOverrides.has(hoster)) continue; // already learned from past batch
|
||||||
|
const accounts = config.hosters && config.hosters[hoster];
|
||||||
|
if (!Array.isArray(accounts) || accounts.length < 2) continue;
|
||||||
|
const primary = accounts.find(a => a && a.enabled !== false && hosterAccountHasCreds(hoster, a));
|
||||||
|
if (!primary) continue;
|
||||||
|
const next = getNextFallbackAccount(config, hoster, primary.id);
|
||||||
|
if (next) {
|
||||||
|
_sessionAccountOverrides.set(hoster, next);
|
||||||
|
rotLog(`main: pre-resolved fallback for ${hoster} → ${next.id} (primary ${primary.id} will try acc2 on first failure)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fresh collector for this new batch — old entries from the previous
|
// Fresh collector for this new batch — old entries from the previous
|
||||||
// batch's jobs are dropped (user's signal for "fresh log" is starting a
|
// batch's jobs are dropped (user's signal for "fresh log" is starting a
|
||||||
// new upload; addJobs during a running batch keeps them).
|
// new upload; addJobs during a running batch keeps them).
|
||||||
|
|||||||
@ -692,6 +692,94 @@ describe('UploadManager', () => {
|
|||||||
assert.equal(acc1Calls, 0, 'primed-dead acc1 must not receive any upload attempts');
|
assert.equal(acc1Calls, 0, 'primed-dead acc1 must not receive any upload attempts');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('generic error + pre-resolved override: rotates after 1 attempt (no more 5x on primary)', async () => {
|
||||||
|
// acc1 throws a generic non-transient, non-account-specific error.
|
||||||
|
// acc2 succeeds. With a pre-resolved override (from main.js at batch
|
||||||
|
// start), the retry loop must break after 1 attempt on acc1 and rotate.
|
||||||
|
let acc1Calls = 0;
|
||||||
|
let acc2Calls = 0;
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
if (apiKey === 'acc1-key') {
|
||||||
|
acc1Calls++;
|
||||||
|
throw new Error('VOE Upload: irgendein generischer Fehler');
|
||||||
|
}
|
||||||
|
acc2Calls++;
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager(
|
||||||
|
{ 'voe.sx': { retries: 5, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
|
||||||
|
);
|
||||||
|
const events = [];
|
||||||
|
mgr.on('rot-log', (e) => events.push(e.event));
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' }
|
||||||
|
], {
|
||||||
|
// Main.js pre-resolves the fallback at batch start.
|
||||||
|
primeOverrides: [['voe.sx', { id: 'acc2', apiKey: 'acc2-key' }]]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(acc1Calls, 1, 'acc1 must get exactly 1 attempt before rotation kicks in');
|
||||||
|
assert.ok(acc2Calls >= 1, 'acc2 must take over');
|
||||||
|
assert.ok(events.includes('try-alternate-after-fail'),
|
||||||
|
`expected try-alternate-after-fail; got: ${events.join(',')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transient error + pre-resolved override: retries SAME acc (network, not acc, is the issue)', async () => {
|
||||||
|
let acc1Calls = 0;
|
||||||
|
let acc2Calls = 0;
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
if (apiKey === 'acc1-key') {
|
||||||
|
acc1Calls++;
|
||||||
|
if (acc1Calls <= 2) throw new Error('connect ECONNRESET 1.2.3.4:443');
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok-on-acc1-retry', embed_url: null, file_code: 'ok' };
|
||||||
|
}
|
||||||
|
acc2Calls++;
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager(
|
||||||
|
{ 'voe.sx': { retries: 5, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
|
||||||
|
);
|
||||||
|
const events = [];
|
||||||
|
mgr.on('rot-log', (e) => events.push(e.event));
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' }
|
||||||
|
], {
|
||||||
|
primeOverrides: [['voe.sx', { id: 'acc2', apiKey: 'acc2-key' }]]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(acc1Calls, 3, 'transient must retry same acc until success');
|
||||||
|
assert.equal(acc2Calls, 0, 'must NOT rotate away on transient network errors');
|
||||||
|
assert.ok(!events.includes('try-alternate-after-fail'),
|
||||||
|
`transient should NOT trigger try-alternate-after-fail; got: ${events.join(',')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generic error + NO override: falls back to classic retry on same account', async () => {
|
||||||
|
let acc1Calls = 0;
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
acc1Calls++;
|
||||||
|
throw new Error('something generic');
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager(
|
||||||
|
{ 'voe.sx': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// retries=3 → maxAttempts=4 (retries + 1). Without an override to rotate
|
||||||
|
// to, must exhaust all 4 attempts on acc1.
|
||||||
|
assert.equal(acc1Calls, 4, 'single-account hoster must retry N+1 times on same account');
|
||||||
|
});
|
||||||
|
|
||||||
it('startBatch without prime opts still clears state (back-compat)', async () => {
|
it('startBatch without prime opts still clears state (back-compat)', async () => {
|
||||||
const mgr = new UploadManager({});
|
const mgr = new UploadManager({});
|
||||||
mgr._failedAccounts.set('byse.sx:acc1', true);
|
mgr._failedAccounts.set('byse.sx:acc1', true);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user