Files
2026-07-13 13:39:12 +08:00

99 lines
3.5 KiB
TypeScript

/**
* Round-robin sticky state for combo routing.
*
* Holds the two mutable module-level Maps that back round-robin distribution
* (`rrCounters`) and sticky round-robin target affinity (`rrStickyTargets`),
* plus the helpers that read/write them. Extracted byte-identically from
* combo.ts (QG v2 Fase 9 T5 D7a).
*
* State cohesion: these two Maps MUST remain single instances. combo.ts imports
* the same references back and mutates them directly (orderTargetsByResetAwareQuota,
* orderTargetsByResetWindow, handleRoundRobinCombo) — never duplicate a Map.
*
* Pure leaf: this module never imports from the combo barrel.
*/
import type { ResolvedComboUnit } from "./types.ts";
// In-memory atomic counter per combo for round-robin distribution
// Resets on server restart (by design — no stale state)
// Eviction limits to prevent unbounded memory growth
export const MAX_RR_COUNTERS = 500;
export const rrCounters = new Map<string, number>();
export const rrStickyTargets = new Map<string, { executionKey: string; successCount: number }>();
export const weightedStickyTargets = new Map<
string,
{ executionKey: string; successCount: number }
>();
export function clampStickyRoundRobinTargetLimit(value: unknown): number {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) return 1;
return Math.min(Math.max(Math.floor(numericValue), 1), 1000);
}
export const clampStickyWeightedTargetLimit = clampStickyRoundRobinTargetLimit;
export function getStickyRoundRobinStartIndex(
comboName: string,
targets: ResolvedComboUnit[],
stickyLimit: number
): { startIndex: number; counter: number } {
const sticky = rrStickyTargets.get(comboName);
const stickyIndex = sticky
? targets.findIndex((target) => target.executionKey === sticky.executionKey)
: -1;
if (stickyLimit > 1 && sticky && stickyIndex >= 0 && sticky.successCount < stickyLimit) {
return { startIndex: stickyIndex, counter: rrCounters.get(comboName) || 0 };
}
const counter = rrCounters.get(comboName) || 0;
return { startIndex: counter % targets.length, counter };
}
export function recordStickyRoundRobinSuccess(
comboName: string,
target: ResolvedComboUnit,
stickyLimit: number,
targets: ResolvedComboUnit[]
): void {
const sticky = rrStickyTargets.get(comboName);
const successCount = sticky?.executionKey === target.executionKey ? sticky.successCount + 1 : 1;
if (successCount >= stickyLimit) {
const servedIndex = targets.findIndex((entry) => entry.executionKey === target.executionKey);
rrCounters.set(
comboName,
servedIndex >= 0 ? servedIndex + 1 : (rrCounters.get(comboName) || 0) + 1
);
rrStickyTargets.delete(comboName);
return;
}
rrStickyTargets.set(comboName, { executionKey: target.executionKey, successCount });
}
export function getStickyWeightedExecutionKey(
comboName: string,
stickyLimit: number
): string | null {
const sticky = weightedStickyTargets.get(comboName);
if (!sticky || stickyLimit <= 1 || sticky.successCount >= stickyLimit) return null;
return sticky.executionKey;
}
export function recordStickyWeightedSuccess(
comboName: string,
executionKey: string,
stickyLimit: number
): void {
const sticky = weightedStickyTargets.get(comboName);
const successCount = sticky?.executionKey === executionKey ? sticky.successCount + 1 : 1;
if (successCount >= stickyLimit) {
weightedStickyTargets.delete(comboName);
return;
}
weightedStickyTargets.set(comboName, { executionKey, successCount });
}