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>
28 lines
1005 B
JavaScript
28 lines
1005 B
JavaScript
function enabledAccountsFor(hosters, hoster, hasCreds) {
|
|
const list = hosters && hosters[hoster];
|
|
if (!Array.isArray(list)) return [];
|
|
return list.filter(a => a && a.enabled !== false && hasCreds(hoster, a));
|
|
}
|
|
|
|
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 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 };
|