mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(infra): cap session-maintenance-warning dedupe cache with LRU eviction (#101643)
* fix(infra): cap session-maintenance-warning dedupe cache with LRU eviction The warnedContexts Map accumulated every warned session key forever with no eviction, TTL, or size cap. A long-running gateway would grow this unboundedly. Add a 4 096-entry LRU cache with touch-on-read so frequently re-warned sessions survive and old entries are evicted on overflow. * fix(test): restore original unicode escapes and add LRU eviction tests * fix(test): seed eviction entries with real warning context keys ClawSweeper P3: the eviction test seeded but the production buildWarningContext computes . The mismatch meant params1 could redeliver because the context changed, not because LRU eviction actually worked. Seed the exact context pattern so the test fails when eviction breaks. * fix(infra): bound maintenance warning cache Co-authored-by: sunlit-deng <sunlit-deng@users.noreply.github.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: sunlit-deng <sunlit-deng@users.noreply.github.com>
This commit is contained in:
@@ -105,7 +105,13 @@ describe("deliverSessionMaintenanceWarning", () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
resetSessionMaintenanceWarningForTests();
|
||||
mocks.resolveSessionAgentId.mockClear();
|
||||
mocks.deliveryContextFromSession.mockClear();
|
||||
mocks.deliveryContextFromSession.mockReset();
|
||||
mocks.deliveryContextFromSession.mockReturnValue({
|
||||
channel: "mobilechat",
|
||||
to: "+15550001",
|
||||
accountId: "acct-1",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
mocks.normalizeMessageChannel.mockClear();
|
||||
mocks.isDeliverableMessageChannel.mockClear();
|
||||
mocks.deliverOutboundPayloads.mockClear();
|
||||
@@ -235,4 +241,57 @@ describe("deliverSessionMaintenanceWarning", () => {
|
||||
|
||||
expect(firstSystemEventCall()?.[0]).toContain(`older than ${expected}`);
|
||||
});
|
||||
|
||||
it("keeps a recently used context while evicting the least-recently-used entry", async () => {
|
||||
const maxEntries = 4096;
|
||||
const createSessionParams = (sessionKey: string) =>
|
||||
createParams({
|
||||
sessionKey,
|
||||
warning: {
|
||||
activeSessionKey: sessionKey,
|
||||
pruneAfterMs: 1_000,
|
||||
maxEntries: 100,
|
||||
wouldPrune: true,
|
||||
wouldCap: false,
|
||||
} as never,
|
||||
});
|
||||
mocks.deliveryContextFromSession.mockReturnValue(undefined as never);
|
||||
|
||||
for (let i = 0; i < maxEntries; i++) {
|
||||
await deliverSessionMaintenanceWarning(createSessionParams(`session:${i}`));
|
||||
}
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(4096);
|
||||
|
||||
// A duplicate read promotes session:0 from the LRU head without re-delivering.
|
||||
await deliverSessionMaintenanceWarning(createSessionParams("session:0"));
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(4096);
|
||||
|
||||
// Overflow evicts session:1 instead of the recently used session:0.
|
||||
await deliverSessionMaintenanceWarning(createSessionParams("session:extra"));
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(4097);
|
||||
await deliverSessionMaintenanceWarning(createSessionParams("session:0"));
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(4097);
|
||||
await deliverSessionMaintenanceWarning(createSessionParams("session:1"));
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(4098);
|
||||
});
|
||||
|
||||
it("re-delivers when the warning context changes for the same session", async () => {
|
||||
const sessionKey = `agent:${randomUUID()}:main`;
|
||||
const params = createParams({ sessionKey });
|
||||
await deliverSessionMaintenanceWarning(params);
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1);
|
||||
|
||||
const changedParams = createParams({
|
||||
sessionKey,
|
||||
warning: {
|
||||
activeSessionKey: sessionKey,
|
||||
pruneAfterMs: 86_400_000,
|
||||
maxEntries: 500,
|
||||
wouldPrune: true,
|
||||
wouldCap: true,
|
||||
} as never,
|
||||
});
|
||||
await deliverSessionMaintenanceWarning(changedParams);
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { createLazyPromiseLoader } from "../shared/lazy-runtime.js";
|
||||
import { deliveryContextFromSession } from "../utils/delivery-context.shared.js";
|
||||
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import { pruneMapToMaxSize } from "./map-size.js";
|
||||
import { buildOutboundSessionContext } from "./outbound/session-context.js";
|
||||
import { enqueueSystemEvent } from "./system-events.js";
|
||||
|
||||
@@ -18,7 +19,21 @@ type WarningParams = {
|
||||
warning: SessionMaintenanceWarning;
|
||||
};
|
||||
|
||||
// Bound process-lifetime dedupe while keeping several agents' default 500-session
|
||||
// windows resident. Eviction can re-emit one warning for an old session.
|
||||
const MAX_WARNED_CONTEXTS = 4096;
|
||||
const warnedContexts = new Map<string, string>();
|
||||
|
||||
function shouldSuppressWarning(sessionKey: string, contextKey: string): boolean {
|
||||
const duplicate = warnedContexts.get(sessionKey) === contextKey;
|
||||
// Refresh insertion order even for suppressed duplicates; otherwise active sessions
|
||||
// become eviction candidates and can receive repeated warnings under key churn.
|
||||
warnedContexts.delete(sessionKey);
|
||||
warnedContexts.set(sessionKey, contextKey);
|
||||
pruneMapToMaxSize(warnedContexts, MAX_WARNED_CONTEXTS);
|
||||
return duplicate;
|
||||
}
|
||||
|
||||
const log = createSubsystemLogger("session-maintenance-warning");
|
||||
const messageRuntimeLoader = createLazyPromiseLoader(
|
||||
() => import("../channels/message/runtime.js"),
|
||||
@@ -111,12 +126,11 @@ export async function deliverSessionMaintenanceWarning(params: WarningParams): P
|
||||
}
|
||||
|
||||
const contextKey = buildWarningContext(params);
|
||||
if (warnedContexts.get(params.sessionKey) === contextKey) {
|
||||
return;
|
||||
}
|
||||
// Dedupe by effective warning context so repeated maintenance scans do not
|
||||
// spam the same session, but changed limits still produce a fresh warning.
|
||||
warnedContexts.set(params.sessionKey, contextKey);
|
||||
if (shouldSuppressWarning(params.sessionKey, contextKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = buildWarningText(params.warning);
|
||||
const target = resolveWarningDeliveryTarget(params.entry);
|
||||
|
||||
Reference in New Issue
Block a user