mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 00:52:18 +03:00
* perf(db): cap modelLockouts eviction at 1000 entries - Add MODEL_LOCKOUT_EVICTION_CAP constant set to 1000 - Evict oldest entries in insertion order when cap exceeded - modelFailureState eviction skips entries still in modelLockouts - Prevents unbounded memory growth under sustained load * test(db): add lockout eviction test, export helpers - Extract evictModelLockoutOverflow() from ensureCleanupTimer for testability - Add getModelLockoutSize() and export MODEL_LOCKOUT_EVICTION_CAP - 3 tests: overflow eviction, under-cap idempotent, keeps recent entries * fix(resilience): never evict a still-active model lockout in evictModelLockoutOverflow() evictModelLockoutOverflow() walked modelLockouts in raw insertion order and deleted the oldest N regardless of entry.until. If the map exceeded 1000 entries while some of the oldest were still well within their active cooldown window, eviction silently deleted them — isModelLocked() would then report the model as unlocked even though it was still rate-limited/quota-exhausted, undermining the Model Lockout resilience layer. Reproduced live: lock a "victim" model first, lock 1000 more distinct models, call evictModelLockoutOverflow(), and isModelLocked() on the victim flips from true to false despite ~60s of cooldown left. Fix: only entries whose `until` has already elapsed are eviction candidates. ensureCleanupTimer()'s tick already runs cleanupModelLockKey() on every key immediately before calling this function, which removes genuinely-expired entries — so anything active left over the cap is, by construction, a real in-progress cooldown and must never be silently dropped. If the map is still over cap purely from active entries, the cap becomes a (rare-case) soft bound rather than trading away correctness. The 3 existing tests only asserted Map.size shrank to the cap, which is exactly the buggy behavior being fixed (they created only active/never-expiring locks and expected mass eviction regardless). Rewrote them to use lockModel()'s cooldownMs sign to construct deterministic active vs. already-expired entries (no real sleeps needed), and added a direct regression test asserting a specific still-active key survives eviction via isModelLocked() while an overflow of expired fillers is correctly evicted down to the cap. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(resilience): extract lockout eviction to module (file-size cap) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: oyi77 <oyi77@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
130 lines
4.4 KiB
TypeScript
130 lines
4.4 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-lockout-eviction-"));
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
process.env.API_KEY_SECRET = "test-lockout-eviction-secret";
|
|
|
|
const {
|
|
lockModel,
|
|
isModelLocked,
|
|
clearAllModelLockouts,
|
|
evictModelLockoutOverflow,
|
|
getModelLockoutSize,
|
|
MODEL_LOCKOUT_EVICTION_CAP,
|
|
} = await import("../../open-sse/services/accountFallback.ts");
|
|
|
|
const REASON = "rate_limited";
|
|
// Far from expiring — entry.until is well in the future.
|
|
const ACTIVE_COOLDOWN_MS = 60_000;
|
|
// Negative cooldown backdates `until` into the past (Date.now() + cooldownMs),
|
|
// deterministically producing an already-expired entry without a real sleep.
|
|
const EXPIRED_COOLDOWN_MS = -60_000;
|
|
|
|
test("eviction removes expired entries beyond the cap", () => {
|
|
clearAllModelLockouts();
|
|
|
|
const cap = MODEL_LOCKOUT_EVICTION_CAP ?? 1000;
|
|
const extra = 50;
|
|
const total = cap + extra;
|
|
|
|
for (let i = 0; i < total; i++) {
|
|
lockModel(`evict-test-p-${i}`, `conn-${i}`, `m-${i}`, REASON, EXPIRED_COOLDOWN_MS);
|
|
}
|
|
|
|
assert.equal(getModelLockoutSize(), total);
|
|
|
|
evictModelLockoutOverflow();
|
|
|
|
// Every entry here is already expired, so nothing is protected — eviction
|
|
// should shrink exactly back down to the cap.
|
|
assert.equal(
|
|
getModelLockoutSize(),
|
|
cap,
|
|
"all-expired overflow should be evicted down to exactly the cap"
|
|
);
|
|
});
|
|
|
|
test("eviction is idempotent when under cap", () => {
|
|
clearAllModelLockouts();
|
|
|
|
const cap = MODEL_LOCKOUT_EVICTION_CAP ?? 1000;
|
|
const under = cap - 10;
|
|
for (let i = 0; i < under; i++) {
|
|
lockModel(`idemp-p-${i}`, `conn-${i}`, `m-${i}`, REASON, ACTIVE_COOLDOWN_MS);
|
|
}
|
|
|
|
assert.equal(getModelLockoutSize(), under);
|
|
|
|
evictModelLockoutOverflow();
|
|
|
|
assert.equal(getModelLockoutSize(), under);
|
|
});
|
|
|
|
test("eviction never removes a still-active lockout, even when the map exceeds the cap purely with active entries", () => {
|
|
clearAllModelLockouts();
|
|
|
|
const cap = MODEL_LOCKOUT_EVICTION_CAP ?? 1000;
|
|
|
|
// Lock a "victim" model FIRST — the oldest insertion-order entry, i.e.
|
|
// exactly what the old (buggy) insertion-order eviction deleted first.
|
|
lockModel("victim-provider", "victim-conn", "victim-model", REASON, ACTIVE_COOLDOWN_MS);
|
|
assert.ok(
|
|
isModelLocked("victim-provider", "victim-conn", "victim-model"),
|
|
"precondition: victim is locked"
|
|
);
|
|
|
|
// Push the map over the cap using only MORE active (not expired) entries.
|
|
for (let i = 0; i < cap + 10; i++) {
|
|
lockModel(`overflow-p-${i}`, `conn-${i}`, `m-${i}`, REASON, ACTIVE_COOLDOWN_MS);
|
|
}
|
|
assert.ok(getModelLockoutSize() > cap, "precondition: map exceeds the cap purely with active locks");
|
|
|
|
evictModelLockoutOverflow();
|
|
|
|
// The core regression (#6923): an active lock must survive eviction
|
|
// regardless of insertion order, even while the map stays over the
|
|
// nominal cap — a real, currently-cooling-down model must never be
|
|
// silently reported as unlocked just because the Map got large.
|
|
assert.ok(
|
|
isModelLocked("victim-provider", "victim-conn", "victim-model"),
|
|
"a still-active lockout must survive eviction even when the map is over cap"
|
|
);
|
|
});
|
|
|
|
test("eviction removes expired entries while an active lockout in the same overflow survives", () => {
|
|
clearAllModelLockouts();
|
|
|
|
const cap = MODEL_LOCKOUT_EVICTION_CAP ?? 1000;
|
|
const extra = 50;
|
|
|
|
// Victim locked FIRST (oldest insertion order) with an active cooldown.
|
|
lockModel("victim2-provider", "victim2-conn", "victim2-model", REASON, ACTIVE_COOLDOWN_MS);
|
|
|
|
// Fill past the cap with EXPIRED entries — these should be evicted.
|
|
for (let i = 0; i < cap + extra; i++) {
|
|
lockModel(`stale-p-${i}`, `conn-${i}`, `m-${i}`, REASON, EXPIRED_COOLDOWN_MS);
|
|
}
|
|
|
|
const beforeSize = getModelLockoutSize();
|
|
assert.equal(beforeSize, cap + extra + 1);
|
|
|
|
evictModelLockoutOverflow();
|
|
|
|
assert.ok(
|
|
isModelLocked("victim2-provider", "victim2-conn", "victim2-model"),
|
|
"active victim lockout must survive eviction"
|
|
);
|
|
// The oldest entry (the victim) is protected because it's active, so
|
|
// eviction skips it and instead removes (extra + 1) of the expired
|
|
// fillers to close the gap — landing exactly back at the cap.
|
|
assert.equal(
|
|
getModelLockoutSize(),
|
|
cap,
|
|
"expired fillers should be evicted down to exactly the cap, victim included"
|
|
);
|
|
});
|