Merge 4fd803fb7d48372cffe2adb9f4916327ed090bbe into 6b4c24c2e55b5b4013277bd799525086f6a0c40f

This commit is contained in:
qualiobra 2026-03-21 04:45:11 +00:00 committed by GitHub
commit 332c1a9c2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 139 additions and 2 deletions

View File

@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// The module reads process.env and Date.now() at call time, so we
// dynamically re-import after setting up fakes / env vars.
type Mod = typeof import("./session-manager-cache.js");
describe("session-manager-cache", () => {
let mod: Mod;
beforeEach(async () => {
vi.useFakeTimers();
// Ensure caching is enabled (default TTL > 0).
delete process.env.OPENCLAW_SESSION_MANAGER_CACHE_TTL_MS;
vi.resetModules();
mod = await import("./session-manager-cache.js");
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
delete process.env.OPENCLAW_SESSION_MANAGER_CACHE_TTL_MS;
});
// ── Max-size cap ──────────────────────────────────────────────
it("enforces max-size cap of 500 entries", () => {
for (let i = 0; i < 510; i++) {
mod.trackSessionManagerAccess(`/sessions/s-${i}.json`);
}
expect(mod.sessionManagerCacheSize()).toBeLessThanOrEqual(500);
});
// ── LRU ordering via delete+set ───────────────────────────────
it("re-access refreshes LRU position — recently used entry survives eviction", () => {
// Fill cache to 499 entries (s-0 … s-498).
for (let i = 0; i < 499; i++) {
mod.trackSessionManagerAccess(`/sessions/s-${i}.json`);
}
expect(mod.sessionManagerCacheSize()).toBe(499);
// Re-access s-0 → moves it to the end of insertion order.
mod.trackSessionManagerAccess("/sessions/s-0.json");
expect(mod.sessionManagerCacheSize()).toBe(499); // no growth, same key
// Add two new entries to push past 500.
mod.trackSessionManagerAccess("/sessions/s-500.json"); // size → 500
mod.trackSessionManagerAccess("/sessions/s-501.json"); // evicts oldest (s-1)
// s-0 was refreshed so it should still be present (500 entries, s-0 near end).
// The evicted entry should be s-1 (oldest after s-0 was refreshed).
expect(mod.sessionManagerCacheSize()).toBe(500);
// After eviction, the refreshed entry (s-0) must survive, oldest untouched (s-1) must be evicted
expect(mod.isSessionManagerCached("/sessions/s-0.json")).toBe(true);
expect(mod.isSessionManagerCached("/sessions/s-1.json")).toBe(false);
});
// ── Lazy TTL eviction ─────────────────────────────────────────
it("lazy TTL eviction removes expired entry on cache check", async () => {
// Insert an entry directly.
mod.trackSessionManagerAccess("/sessions/a.json");
expect(mod.sessionManagerCacheSize()).toBe(1);
// Advance time past the 45 s default TTL.
vi.advanceTimersByTime(46_000);
// prewarmSessionFile calls isSessionManagerCached internally, which
// should now delete the stale entry and return false. The subsequent
// fs.open will fail (file doesn't exist) so the entry is NOT re-added.
await mod.prewarmSessionFile("/sessions/a.json");
expect(mod.sessionManagerCacheSize()).toBe(0); // expired entry was evicted
});
// ── clearSessionManagerCache ──────────────────────────────────
it("clearSessionManagerCache empties the cache", () => {
mod.trackSessionManagerAccess("/sessions/a.json");
mod.trackSessionManagerAccess("/sessions/b.json");
expect(mod.sessionManagerCacheSize()).toBe(2);
mod.clearSessionManagerCache();
expect(mod.sessionManagerCacheSize()).toBe(0);
});
// ── Disabled cache (TTL=0) ────────────────────────────────────
it("skips tracking when cache is disabled via TTL=0", async () => {
process.env.OPENCLAW_SESSION_MANAGER_CACHE_TTL_MS = "0";
vi.resetModules();
const disabled = await import("./session-manager-cache.js");
disabled.trackSessionManagerAccess("/sessions/a.json");
expect(disabled.sessionManagerCacheSize()).toBe(0);
});
});

View File

@ -9,6 +9,9 @@ type SessionManagerCacheEntry = {
const SESSION_MANAGER_CACHE = new Map<string, SessionManagerCacheEntry>();
const DEFAULT_SESSION_MANAGER_TTL_MS = 45_000; // 45 seconds
// Safety-net cap. Working set is far smaller (entries live ≤45 s);
// env override is intentionally not exposed — 500 >> realistic working set.
const SESSION_MANAGER_CACHE_MAX_SIZE = 500;
function getSessionManagerTtl(): number {
return resolveCacheTtlMs({
@ -25,6 +28,20 @@ export function trackSessionManagerAccess(sessionFile: string): void {
if (!isSessionManagerCacheEnabled()) {
return;
}
// Refresh insertion order for existing keys so FIFO eviction is LRU-like.
// Map.set() on an existing key does NOT update insertion order (MDN), so
// we delete first to move it to the end.
if (SESSION_MANAGER_CACHE.has(sessionFile)) {
SESSION_MANAGER_CACHE.delete(sessionFile);
}
// Evict oldest entry when the cache is at its size limit.
// Map iteration order is insertion-order, so the first key is the oldest.
if (SESSION_MANAGER_CACHE.size >= SESSION_MANAGER_CACHE_MAX_SIZE) {
const oldest = SESSION_MANAGER_CACHE.keys().next();
if (!oldest.done) {
SESSION_MANAGER_CACHE.delete(oldest.value);
}
}
const now = Date.now();
SESSION_MANAGER_CACHE.set(sessionFile, {
sessionFile,
@ -32,7 +49,11 @@ export function trackSessionManagerAccess(sessionFile: string): void {
});
}
function isSessionManagerCached(sessionFile: string): boolean {
/**
* Check whether a session file is present and non-expired in the cache.
* @internal Visible for testing only.
*/
export function isSessionManagerCached(sessionFile: string): boolean {
if (!isSessionManagerCacheEnabled()) {
return false;
}
@ -42,7 +63,11 @@ function isSessionManagerCached(sessionFile: string): boolean {
}
const now = Date.now();
const ttl = getSessionManagerTtl();
return now - entry.loadedAt <= ttl;
if (now - entry.loadedAt > ttl) {
SESSION_MANAGER_CACHE.delete(sessionFile);
return false;
}
return true;
}
export async function prewarmSessionFile(sessionFile: string): Promise<void> {
@ -67,3 +92,18 @@ export async function prewarmSessionFile(sessionFile: string): Promise<void> {
// File doesn't exist yet, SessionManager will create it
}
}
/**
* Clear all cached entries. Exported for tests and graceful-shutdown hooks.
*/
export function clearSessionManagerCache(): void {
SESSION_MANAGER_CACHE.clear();
}
/**
* Return the current number of entries in the cache.
* @internal Visible for testing only.
*/
export function sessionManagerCacheSize(): number {
return SESSION_MANAGER_CACHE.size;
}