Files
OmniRoute/tests/unit/account-fallback-lockout-eviction.test.ts
Paijo afd696e6b2 perf(db): cap modelLockouts eviction at 1000 entries (#6923)
* 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>
2026-07-18 11:33:53 -03:00

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"
);
});