From 644ed712e0b8dc429f8a392457b7a1cb7c2a897d Mon Sep 17 00:00:00 2001 From: Administrator Date: Wed, 17 Jun 2026 03:33:20 +0200 Subject: [PATCH] fix(rotation): persist the round-robin cursor so drip-fed uploads keep rotating v3.3.74 created a fresh account picker inside every buildUploadTasks / buildUploadTasksFromJobs call, so its in-memory rotation index reset to 0 on each call. That is correct for a one-shot batch (drag-drop N files at once distributes fine), but it silently no-ops in exactly the pattern the byse 80-uploads/30-days quota cares about: the folder monitor feeds new files into a *running* batch one detection at a time via add-jobs-to-batch (renderer -> add-jobs-to-batch IPC), and per-detection autoStart likewise fires start-upload per file. Each of those calls rebuilt the picker from index 0, so account 1 won every single time and the secondary accounts never received anything. The rotation cursor now lives outside the picker and survives across calls and across app restarts (the quota spans a rolling 30-day window, so a session-only counter would still starve secondaries for users who restart between small uploads): - account-rotation.js: createAccountPicker() accepts a seed { indices } map, resumes the per-hoster cursor from it, and exposes indices() (the advanced cursors) + dirty() (whether any rotation actually happened this call). The cursor is a monotonic counter taken mod the current enabled-account count, so it keeps wrapping correctly even if an account is later enabled/disabled. - config-store.js: new top-level rotationCursors map (added to DEFAULTS, read back in load() which otherwise reconstructs the result and would drop unknown keys) plus a saveRotationCursors() method. The existing read-modify-write save() preserves it across unrelated settings/credential saves; secret-store never touches it. - main.js: a module-level _rotationCursors is the authoritative source of truth (seeded once from disk on first use, updated synchronously per batch), with config as restart-survival backing. makeAccountPicker() seeds the picker from it; persistRotation() folds the advance back and flushes to config only when dirty. Authoritative in-memory state also closes the disk-read race two rapid batches would otherwise hit. Both task builders now receive the picker instead of constructing their own. Composition with failover is unchanged and verified: the picker only chooses each file's *initial* account and is never called on the failover path. The pre-job-swap reroutes a task only when that task's own account is in _failedAccounts, so a dead account's jobs reroute while healthy accounts keep their rotation share. No double-advance of the cursor. Tests: account-rotation gains seeded-resume, drip-feed-across-pickers (the regression), dirty()-semantics, carry-forward, and count-shrink-wrap cases; config-store gains rotationCursors default, save round-trip, no-clobber, and credentials-undisturbed cases. Full suite 303/303. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/account-rotation.js | 19 ++++++++----- lib/config-store.js | 16 +++++++++-- main.js | 40 ++++++++++++++++++++++----- tests/account-rotation.test.js | 49 ++++++++++++++++++++++++++++++++++ tests/config-store.test.js | 28 +++++++++++++++++++ 5 files changed, 136 insertions(+), 16 deletions(-) diff --git a/lib/account-rotation.js b/lib/account-rotation.js index 0bf0d5a..de6ee7c 100644 --- a/lib/account-rotation.js +++ b/lib/account-rotation.js @@ -4,19 +4,24 @@ function enabledAccountsFor(hosters, hoster, hasCreds) { return list.filter(a => a && a.enabled !== false && hasCreds(hoster, a)); } -function createAccountPicker({ hosters, hosterSettings, hasCreds }) { - const rotIdx = Object.create(null); - return function pick(hoster) { +function createAccountPicker({ hosters, hosterSettings, hasCreds, indices }) { + const rotIdx = Object.assign(Object.create(null), indices || {}); + let dirty = false; + function pick(hoster) { const enabled = enabledAccountsFor(hosters, hoster, hasCreds); if (enabled.length === 0) return null; const hs = (hosterSettings && hosterSettings[hoster]) || {}; if (hs.rotateAccounts === true && enabled.length > 1) { - const i = (rotIdx[hoster] || 0) % enabled.length; - rotIdx[hoster] = (rotIdx[hoster] || 0) + 1; - return enabled[i]; + const cursor = Number.isFinite(rotIdx[hoster]) ? rotIdx[hoster] : 0; + rotIdx[hoster] = cursor + 1; + dirty = true; + return enabled[cursor % enabled.length]; } return enabled[0]; - }; + } + pick.indices = () => ({ ...rotIdx }); + pick.dirty = () => dirty; + return pick; } module.exports = { createAccountPicker, enabledAccountsFor }; diff --git a/lib/config-store.js b/lib/config-store.js index 066d440..1a467a5 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -101,7 +101,8 @@ const DEFAULTS = { allowInput: true } }, - history: [] + history: [], + rotationCursors: {} }; const HISTORY_RETENTION_OPTIONS = [ @@ -289,7 +290,10 @@ class ConfigStore { // Downstream readers consume logMode only and must NOT derive from // sessionLog at call sites. globalSettings.logMode = normalizeLogMode(globalSettings); - const result = { hosters, hosterSettings, globalSettings, history: data.history || [] }; + const rotationCursors = (data.rotationCursors && typeof data.rotationCursors === 'object' && !Array.isArray(data.rotationCursors)) + ? data.rotationCursors + : {}; + const result = { hosters, hosterSettings, globalSettings, history: data.history || [], rotationCursors }; // Decrypt credentials stored with safeStorage so the rest of the app // keeps working with plaintext in memory. secretStore.decryptCredentials(result); @@ -397,6 +401,14 @@ class ConfigStore { return this._atomicWrite(this._serializeForDisk(config)); }); } + + saveRotationCursors(cursors) { + return this._enqueueWrite(() => { + const config = this.load(); + config.rotationCursors = (cursors && typeof cursors === 'object' && !Array.isArray(cursors)) ? cursors : {}; + return this._atomicWrite(this._serializeForDisk(config)); + }); + } } module.exports = ConfigStore; diff --git a/main.js b/main.js index 3027328..e3d8bcc 100644 --- a/main.js +++ b/main.js @@ -742,9 +742,32 @@ function buildTaskFromAccount(hoster, account, extra) { return task; } -function buildUploadTasks(config, files, hosters) { +let _rotationCursors = null; +function rotationCursors() { + if (_rotationCursors === null) { + const persisted = configStore.load().rotationCursors; + _rotationCursors = (persisted && typeof persisted === 'object') ? { ...persisted } : {}; + } + return _rotationCursors; +} + +function makeAccountPicker(config) { + return createAccountPicker({ + hosters: config.hosters, + hosterSettings: config.hosterSettings, + hasCreds: hosterAccountHasCreds, + indices: rotationCursors() + }); +} + +function persistRotation(pick) { + if (!pick.dirty()) return; + _rotationCursors = { ...rotationCursors(), ...pick.indices() }; + configStore.saveRotationCursors(_rotationCursors); +} + +function buildUploadTasks(config, files, hosters, pick) { const tasks = []; - const pick = createAccountPicker({ hosters: config.hosters, hosterSettings: config.hosterSettings, hasCreds: hosterAccountHasCreds }); for (const file of files) { for (const hoster of hosters) { const account = pick(hoster); @@ -755,9 +778,8 @@ function buildUploadTasks(config, files, hosters) { return tasks; } -function buildUploadTasksFromJobs(config, jobs) { +function buildUploadTasksFromJobs(config, jobs, pick) { if (!Array.isArray(jobs)) return []; - const pick = createAccountPicker({ hosters: config.hosters, hosterSettings: config.hosterSettings, hasCreds: hosterAccountHasCreds }); const tasks = []; for (const job of jobs) { if (!job || !job.file || !job.hoster) continue; @@ -1534,9 +1556,11 @@ ipcMain.handle('start-upload', (_event, payload) => { logMarker('BATCH START', { files: files.length, hosters: hosters.length, jobs: jobs.length }); debugLog(`start-upload: files=${files.length}, hosters=${hosters.length}, jobs=${jobs.length}`); + const pick = makeAccountPicker(config); const tasks = jobs.length > 0 - ? buildUploadTasksFromJobs(config, jobs) - : buildUploadTasks(config, files, hosters); + ? buildUploadTasksFromJobs(config, jobs, pick) + : buildUploadTasks(config, files, hosters, pick); + persistRotation(pick); // Identify jobs that were skipped (no account/credentials) const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); @@ -1791,7 +1815,9 @@ ipcMain.handle('add-jobs-to-batch', (_event, payload) => { } const config = configStore.load(); const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; - const tasks = buildUploadTasksFromJobs(config, jobs); + const pick = makeAccountPicker(config); + const tasks = buildUploadTasksFromJobs(config, jobs, pick); + persistRotation(pick); const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); const skippedJobs = jobs .filter(j => j && j.id && !taskJobIds.has(j.id)) diff --git a/tests/account-rotation.test.js b/tests/account-rotation.test.js index 1b6eb01..fd47b55 100644 --- a/tests/account-rotation.test.js +++ b/tests/account-rotation.test.js @@ -75,3 +75,52 @@ test('enabledAccountsFor filters disabled + no-creds and preserves order', () => assert.deepStrictEqual(enabledAccountsFor(hosters, 'byse.sx', hasCreds).map(a => a.id), ['a1', 'a4']); assert.deepStrictEqual(enabledAccountsFor(hosters, 'missing', hasCreds), []); }); + +test('seeded index resumes mid-cycle (restart / persisted cursor)', () => { + const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] }; + const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'byse.sx': 1 } }); + assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a2', 'a3', 'a1', 'a2']); +}); + +test('drip-feed: fresh picker per call seeded from prior indices keeps rotating (no per-batch reset)', () => { + const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] }; + const settings = { 'byse.sx': { rotateAccounts: true } }; + let cursors = {}; + const landed = []; + for (let i = 0; i < 6; i++) { + const pick = createAccountPicker({ hosters, hosterSettings: settings, hasCreds, indices: cursors }); + landed.push(pick('byse.sx').id); + cursors = pick.indices(); + } + assert.deepStrictEqual(landed, ['a1', 'a2', 'a3', 'a1', 'a2', 'a3']); +}); + +test('dirty() is true only after an actual rotation advance', () => { + const hosters = { 'byse.sx': [acc('a1'), acc('a2')], 'voe.sx': [acc('v1'), acc('v2')] }; + const offPick = createAccountPicker({ hosters, hosterSettings: {}, hasCreds }); + offPick('byse.sx'); offPick('voe.sx'); + assert.strictEqual(offPick.dirty(), false); + + const singlePick = createAccountPicker({ hosters: { 'byse.sx': [acc('a1')] }, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds }); + singlePick('byse.sx'); + assert.strictEqual(singlePick.dirty(), false); + + const onPick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds }); + onPick('voe.sx'); + assert.strictEqual(onPick.dirty(), false); + onPick('byse.sx'); + assert.strictEqual(onPick.dirty(), true); +}); + +test('indices() carries forward unrotated seeded hosters alongside advanced ones', () => { + const hosters = { 'byse.sx': [acc('a1'), acc('a2')], 'voe.sx': [acc('v1'), acc('v2')] }; + const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'voe.sx': 5 } }); + pick('byse.sx'); + assert.deepStrictEqual(pick.indices(), { 'voe.sx': 5, 'byse.sx': 1 }); +}); + +test('persisted cursor wraps correctly after the enabled-account count shrinks', () => { + const hosters = { 'byse.sx': [acc('a1'), acc('a2')] }; + const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'byse.sx': 7 } }); + assert.deepStrictEqual(picks(pick, 'byse.sx', 3), ['a2', 'a1', 'a2']); +}); diff --git a/tests/config-store.test.js b/tests/config-store.test.js index 17c4198..bf2f804 100644 --- a/tests/config-store.test.js +++ b/tests/config-store.test.js @@ -147,6 +147,34 @@ describe('ConfigStore', () => { assert.equal(store.loadHistory().length, 0); }); + it('rotationCursors default to an empty object', () => { + const config = store.load(); + assert.deepEqual(config.rotationCursors, {}); + }); + + it('saveRotationCursors round-trips and survives reload', async () => { + await store.saveRotationCursors({ 'byse.sx': 7, 'voe.sx': 2 }); + const config = store.load(); + assert.equal(config.rotationCursors['byse.sx'], 7); + assert.equal(config.rotationCursors['voe.sx'], 2); + }); + + it('an unrelated save() does not clobber persisted rotationCursors', async () => { + await store.saveRotationCursors({ 'byse.sx': 3 }); + await store.save({ globalSettings: { alwaysOnTop: true } }); + const config = store.load(); + assert.equal(config.rotationCursors['byse.sx'], 3, 'cursor preserved across a settings save'); + assert.equal(config.globalSettings.alwaysOnTop, true); + }); + + it('saveRotationCursors does not disturb credentials', async () => { + await store.save({ hosters: { 'byse.sx': [{ id: 'k1', enabled: true, authType: 'api', apiKey: 'secret-key' }] } }); + await store.saveRotationCursors({ 'byse.sx': 1 }); + const config = store.load(); + assert.equal(config.hosters['byse.sx'][0].apiKey, 'secret-key'); + assert.equal(config.rotationCursors['byse.sx'], 1); + }); + it('corrupted JSON falls back to defaults', () => { fs.writeFileSync(store.filePath, '{invalid json!!!', 'utf-8'); const config = store.load();