Fix: Langsame Mega-Accounts bremsen nicht mehr die ganze Umwandlungs-Queue (Latenz-Demotion)

Folge-Fix zu v1.7.197 (Round-Robin): User meldete direkt nach dem Update
deutlich langsamere Link-Umwandlung. Ursache: MegaWebFallback.runExclusive
ist eine GLOBALE Single-Flight-Queue — alle Umwandlungen laufen seriell.
Vor v1.7.197 liefen praktisch alle Links ueber denselben warmen, schnellen
Account (~800ms); das Round-Robin mischte nun auch die zuvor nie genutzten
Accounts in die Reihe. Ist einer davon langsam (kalte Session, traegere
Server, abgelaufenes Premium), blockiert seine Umwandlung in der seriellen
Queue ALLE nachfolgenden Links — gefuehlt wird alles langsam.

Fix: Pro Account+Modus wird ein EMA (0.7/0.3) der erfolgreichen
Unrestrict-Dauer gefuehrt. Bei jeder Aufloesung wird die Round-Robin-
Reihenfolge partitioniert: Accounts, deren EMA ueber
max(6s, 3x bestes verfuegbares EMA) liegt, wandern ans Ende der Reihe —
sie bleiben Failover-Reserve (werden weiter genutzt, wenn die schnellen
am Limit/Cooldown sind), laufen aber nicht mehr in der gleichmaessigen
Verteilung mit. Ungemessene Accounts gelten als gesund (bekommen ihre
Chance und damit ein EMA). Erholt sich ein Account (relativer Threshold),
rotiert er automatisch wieder mit. Schon nach EINEM langsamen Erfolg ist
ein Bremser-Account aus der Verteilung draussen. Fehlschlaege werden wie
bisher ueber die bestehenden Cooldowns behandelt.

Diagnose-Sichtbarkeit: das TEST-Event im account-rotation.log traegt jetzt
emaMs und slow=true, damit ein gebremster Account sofort erkennbar ist.

2 neue Regressionstests (langsamer Account wird depriorisiert; bleibt
Failover-Reserve wenn die schnellen gesperrt sind). Suite 808 gruen,
tsc=6 Baseline, self-check + build ok.
This commit is contained in:
Sucukdeluxe 2026-06-11 14:33:20 +02:00
parent b60b2b00f3
commit d77dfbedec
2 changed files with 145 additions and 5 deletions

View File

@ -298,6 +298,22 @@ export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
let megaDebridRotationCursor = 0; let megaDebridRotationCursor = 0;
const MEGA_DEBRID_SLOW_ACCOUNT_FLOOR_MS = 6000;
const MEGA_DEBRID_SLOW_ACCOUNT_FACTOR = 3;
const megaDebridAccountLatencyEma = new Map<string, number>();
function recordMegaDebridUnrestrictLatency(cooldownKey: string, elapsedMs: number): void {
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
return;
}
const prev = megaDebridAccountLatencyEma.get(cooldownKey);
megaDebridAccountLatencyEma.set(cooldownKey, prev === undefined ? elapsedMs : prev * 0.7 + elapsedMs * 0.3);
}
export function primeMegaDebridLatencyForTests(cooldownKey: string, emaMs: number): void {
megaDebridAccountLatencyEma.set(cooldownKey, emaMs);
}
export function recordMegaDebridEmptyResponseStreak(accountId: string): number { export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1; const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
megaDebridEmptyResponseStreaks.set(accountId, streak); megaDebridEmptyResponseStreaks.set(accountId, streak);
@ -312,6 +328,7 @@ export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridAccountCooldowns.clear(); megaDebridAccountCooldowns.clear();
megaDebridEmptyResponseStreaks.clear(); megaDebridEmptyResponseStreaks.clear();
megaDebridRotationCursor = 0; megaDebridRotationCursor = 0;
megaDebridAccountLatencyEma.clear();
} }
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number { export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
@ -1915,9 +1932,48 @@ class MegaDebridClient {
// wurde nie auch nur angeschaut. Der Cursor laesst jede Aufloesung beim Account // wurde nie auch nur angeschaut. Der Cursor laesst jede Aufloesung beim Account
// NACH dem zuletzt getesteten starten, alle Skip-/Cooldown-Checks bleiben. // NACH dem zuletzt getesteten starten, alle Skip-/Cooldown-Checks bleiben.
const startOffset = ((megaDebridRotationCursor % accounts.length) + accounts.length) % accounts.length; const startOffset = ((megaDebridRotationCursor % accounts.length) + accounts.length) % accounts.length;
const rotationOrder: { account: MegaDebridAccountEntry; idx: number }[] = [];
for (let step = 0; step < accounts.length; step += 1) { for (let step = 0; step < accounts.length; step += 1) {
const idx = (startOffset + step) % accounts.length; const idx = (startOffset + step) % accounts.length;
const account = accounts[idx]; rotationOrder.push({ account: accounts[idx], idx });
}
// Latenz-Schutz fuer die Mega-Web-Single-Flight-Queue: dort laufen ALLE
// Umwandlungen seriell, ein nachweislich langsamer Account bremst also
// jeden nachfolgenden Link aus. Accounts mit deutlich erhoehtem Erfolgs-EMA
// wandern ans Ende der Reihenfolge (= Failover-Reserve) statt in der
// gleichmaessigen Rotation mitzulaufen; erholt sich ihr EMA, rotieren sie
// automatisch wieder mit. Ungemessene Accounts gelten als gesund.
let bestEma = Infinity;
let anyUnmeasuredUsable = false;
for (const entry of rotationOrder) {
if (isMegaDebridAccountDisabled(settings, entry.account.id)) continue;
if (isMegaDebridAccountDailyLimitReached(settings, entry.account.id)) continue;
if (getMegaDebridAccountCooldownState(`${entry.account.id}:${mode}`)) continue;
const ema = megaDebridAccountLatencyEma.get(`${entry.account.id}:${mode}`);
if (ema === undefined) {
anyUnmeasuredUsable = true;
continue;
}
if (ema < bestEma) bestEma = ema;
}
const slowThreshold = Math.max(
MEGA_DEBRID_SLOW_ACCOUNT_FLOOR_MS,
anyUnmeasuredUsable ? 0 : (Number.isFinite(bestEma) ? MEGA_DEBRID_SLOW_ACCOUNT_FACTOR * bestEma : Infinity)
);
const isSlowEntry = (entry: { account: MegaDebridAccountEntry }): boolean => {
const ema = megaDebridAccountLatencyEma.get(`${entry.account.id}:${mode}`);
return ema !== undefined && ema > slowThreshold;
};
const orderedEntries = [
...rotationOrder.filter((entry) => !isSlowEntry(entry)),
...rotationOrder.filter((entry) => isSlowEntry(entry))
];
for (let orderPos = 0; orderPos < orderedEntries.length; orderPos += 1) {
const entry = orderedEntries[orderPos];
const account = entry.account;
const idx = entry.idx;
const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`; const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`;
const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`; const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;
@ -1955,8 +2011,13 @@ class MegaDebridClient {
continue; continue;
} }
const emaForLog = megaDebridAccountLatencyEma.get(cooldownKey);
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`); logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort }); logAccountRotation("INFO", providerName, rotationLabel, "TEST", {
link: linkShort,
emaMs: emaForLog !== undefined ? Math.round(emaForLog) : undefined,
slow: isSlowEntry(entry) ? true : undefined
});
const testStartedAt = Date.now(); const testStartedAt = Date.now();
usableAccountSeen = true; usableAccountSeen = true;
@ -1967,6 +2028,7 @@ class MegaDebridClient {
clearMegaDebridAccountCooldownState(cooldownKey); clearMegaDebridAccountCooldownState(cooldownKey);
clearMegaDebridEmptyResponseStreak(cooldownKey); clearMegaDebridEmptyResponseStreak(cooldownKey);
const elapsedMs = Date.now() - testStartedAt; const elapsedMs = Date.now() - testStartedAt;
recordMegaDebridUnrestrictLatency(cooldownKey, elapsedMs);
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`); logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
logAccountRotation("INFO", providerName, rotationLabel, "OK", { logAccountRotation("INFO", providerName, rotationLabel, "OK", {
elapsedMs, elapsedMs,
@ -2039,8 +2101,8 @@ class MegaDebridClient {
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s` ? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
: ""; : "";
let nextLabel = "ENDE"; let nextLabel = "ENDE";
for (let nextStep = step + 1; nextStep < accounts.length; nextStep += 1) { for (let nextPos = orderPos + 1; nextPos < orderedEntries.length; nextPos += 1) {
const nextAcc = accounts[(startOffset + nextStep) % accounts.length]; const nextAcc = orderedEntries[nextPos].account;
if (!isMegaDebridAccountDisabled(settings, nextAcc.id) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) { if (!isMegaDebridAccountDisabled(settings, nextAcc.id) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) {
nextLabel = `${nextAcc.label}/${totalAccounts} (${nextAcc.maskedLogin})`; nextLabel = `${nextAcc.label}/${totalAccounts} (${nextAcc.maskedLogin})`;
break; break;

View File

@ -3,7 +3,7 @@ import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, normalizeResolvedFilename, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; import { clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, normalizeResolvedFilename, primeMegaDebridLatencyForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@ -1463,6 +1463,84 @@ describe("debrid service", () => {
]); ]);
}, 30000); }, 30000);
it("depriorisiert einen nachweislich langsamen Account in der Rotation (Single-Flight-Schutz)", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn(async () => ({
fileName: "ok.rar",
directUrl: "https://mega-web.example/ok.rar",
fileSize: null,
retriesUsed: 0
}));
primeMegaDebridLatencyForTests(`${getMegaDebridAccountId("user1")}:web`, 15000);
primeMegaDebridLatencyForTests(`${getMegaDebridAccountId("user2")}:web`, 800);
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const usedIds: (string | undefined)[] = [];
for (let i = 0; i < 2; i += 1) {
const result = await service.unrestrictLink(`https://rapidgator.net/file/slow-${i}`);
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
}
expect(usedIds).toEqual([
getMegaDebridAccountId("user2"),
getMegaDebridAccountId("user2")
]);
}, 30000);
it("nutzt den langsamen Account weiterhin, wenn die schnellen gesperrt sind (Failover-Reserve)", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn(async () => ({
fileName: "ok.rar",
directUrl: "https://mega-web.example/ok.rar",
fileSize: null,
retriesUsed: 0
}));
primeMegaDebridLatencyForTests(`${getMegaDebridAccountId("user1")}:web`, 15000);
primeMegaDebridLatencyForTests(`${getMegaDebridAccountId("user2")}:web`, 800);
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user2")}:web`);
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/slow-fallback");
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user1"));
}, 30000);
it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => { it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),