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) <noreply@anthropic.com>
This commit is contained in:
parent
950f103f99
commit
644ed712e0
@ -4,19 +4,24 @@ function enabledAccountsFor(hosters, hoster, hasCreds) {
|
|||||||
return list.filter(a => a && a.enabled !== false && hasCreds(hoster, a));
|
return list.filter(a => a && a.enabled !== false && hasCreds(hoster, a));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAccountPicker({ hosters, hosterSettings, hasCreds }) {
|
function createAccountPicker({ hosters, hosterSettings, hasCreds, indices }) {
|
||||||
const rotIdx = Object.create(null);
|
const rotIdx = Object.assign(Object.create(null), indices || {});
|
||||||
return function pick(hoster) {
|
let dirty = false;
|
||||||
|
function pick(hoster) {
|
||||||
const enabled = enabledAccountsFor(hosters, hoster, hasCreds);
|
const enabled = enabledAccountsFor(hosters, hoster, hasCreds);
|
||||||
if (enabled.length === 0) return null;
|
if (enabled.length === 0) return null;
|
||||||
const hs = (hosterSettings && hosterSettings[hoster]) || {};
|
const hs = (hosterSettings && hosterSettings[hoster]) || {};
|
||||||
if (hs.rotateAccounts === true && enabled.length > 1) {
|
if (hs.rotateAccounts === true && enabled.length > 1) {
|
||||||
const i = (rotIdx[hoster] || 0) % enabled.length;
|
const cursor = Number.isFinite(rotIdx[hoster]) ? rotIdx[hoster] : 0;
|
||||||
rotIdx[hoster] = (rotIdx[hoster] || 0) + 1;
|
rotIdx[hoster] = cursor + 1;
|
||||||
return enabled[i];
|
dirty = true;
|
||||||
|
return enabled[cursor % enabled.length];
|
||||||
}
|
}
|
||||||
return enabled[0];
|
return enabled[0];
|
||||||
};
|
}
|
||||||
|
pick.indices = () => ({ ...rotIdx });
|
||||||
|
pick.dirty = () => dirty;
|
||||||
|
return pick;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createAccountPicker, enabledAccountsFor };
|
module.exports = { createAccountPicker, enabledAccountsFor };
|
||||||
|
|||||||
@ -101,7 +101,8 @@ const DEFAULTS = {
|
|||||||
allowInput: true
|
allowInput: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
history: []
|
history: [],
|
||||||
|
rotationCursors: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const HISTORY_RETENTION_OPTIONS = [
|
const HISTORY_RETENTION_OPTIONS = [
|
||||||
@ -289,7 +290,10 @@ class ConfigStore {
|
|||||||
// Downstream readers consume logMode only and must NOT derive from
|
// Downstream readers consume logMode only and must NOT derive from
|
||||||
// sessionLog at call sites.
|
// sessionLog at call sites.
|
||||||
globalSettings.logMode = normalizeLogMode(globalSettings);
|
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
|
// Decrypt credentials stored with safeStorage so the rest of the app
|
||||||
// keeps working with plaintext in memory.
|
// keeps working with plaintext in memory.
|
||||||
secretStore.decryptCredentials(result);
|
secretStore.decryptCredentials(result);
|
||||||
@ -397,6 +401,14 @@ class ConfigStore {
|
|||||||
return this._atomicWrite(this._serializeForDisk(config));
|
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;
|
module.exports = ConfigStore;
|
||||||
|
|||||||
40
main.js
40
main.js
@ -742,9 +742,32 @@ function buildTaskFromAccount(hoster, account, extra) {
|
|||||||
return task;
|
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 tasks = [];
|
||||||
const pick = createAccountPicker({ hosters: config.hosters, hosterSettings: config.hosterSettings, hasCreds: hosterAccountHasCreds });
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
for (const hoster of hosters) {
|
for (const hoster of hosters) {
|
||||||
const account = pick(hoster);
|
const account = pick(hoster);
|
||||||
@ -755,9 +778,8 @@ function buildUploadTasks(config, files, hosters) {
|
|||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUploadTasksFromJobs(config, jobs) {
|
function buildUploadTasksFromJobs(config, jobs, pick) {
|
||||||
if (!Array.isArray(jobs)) return [];
|
if (!Array.isArray(jobs)) return [];
|
||||||
const pick = createAccountPicker({ hosters: config.hosters, hosterSettings: config.hosterSettings, hasCreds: hosterAccountHasCreds });
|
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
if (!job || !job.file || !job.hoster) continue;
|
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 });
|
logMarker('BATCH START', { files: files.length, hosters: hosters.length, jobs: jobs.length });
|
||||||
debugLog(`start-upload: 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
|
const tasks = jobs.length > 0
|
||||||
? buildUploadTasksFromJobs(config, jobs)
|
? buildUploadTasksFromJobs(config, jobs, pick)
|
||||||
: buildUploadTasks(config, files, hosters);
|
: buildUploadTasks(config, files, hosters, pick);
|
||||||
|
persistRotation(pick);
|
||||||
|
|
||||||
// Identify jobs that were skipped (no account/credentials)
|
// Identify jobs that were skipped (no account/credentials)
|
||||||
const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
|
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 config = configStore.load();
|
||||||
const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : [];
|
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 taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
|
||||||
const skippedJobs = jobs
|
const skippedJobs = jobs
|
||||||
.filter(j => j && j.id && !taskJobIds.has(j.id))
|
.filter(j => j && j.id && !taskJobIds.has(j.id))
|
||||||
|
|||||||
@ -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, 'byse.sx', hasCreds).map(a => a.id), ['a1', 'a4']);
|
||||||
assert.deepStrictEqual(enabledAccountsFor(hosters, 'missing', hasCreds), []);
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
@ -147,6 +147,34 @@ describe('ConfigStore', () => {
|
|||||||
assert.equal(store.loadHistory().length, 0);
|
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', () => {
|
it('corrupted JSON falls back to defaults', () => {
|
||||||
fs.writeFileSync(store.filePath, '{invalid json!!!', 'utf-8');
|
fs.writeFileSync(store.filePath, '{invalid json!!!', 'utf-8');
|
||||||
const config = store.load();
|
const config = store.load();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user