Skip to content

Commit f1ea5a5

Browse files
jzakirovclaude
andcommitted
perf(gateway): drop redundant per-access session-key case scan
migrateOrphanedSessionKeys already lowercases and merges every session store key at gateway startup (idempotent, freshest wins), so the runtime case-insensitive rescans were dead work: an O(N) scan per row on the session-list path made list building O(N^2) and froze the gateway at large stores, and it re-converged duplicates non-deterministically on every access. Make session-store resolution exact-only: remove findStoreKeysIgnoreCase (plus its two copies), the scanLegacyKeys opt-out plumbing, and the per-access scan loops. Runtime now assumes the canonical shape the startup migration guarantees; explicit alias convergence (main -> work) stays. Add a migration test asserting mixed-case keys converge freshest-wins, and move the gateway tests that asserted runtime case-variant resolution to the post-migration shape. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent c57fee8 commit f1ea5a5

15 files changed

Lines changed: 135 additions & 298 deletions

src/agents/main-session-restart-recovery.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,6 @@ export async function markRestartAbortedMainSessions(params: {
191191
const target = resolveGatewaySessionStoreTarget({
192192
cfg,
193193
key: sessionKey,
194-
scanLegacyKeys: true,
195194
});
196195
storePaths.add(path.resolve(target.storePath));
197196
for (const storeKey of target.storeKeys) {
@@ -710,7 +709,6 @@ function isRoutableRecoveryStore(params: {
710709
const target = resolveGatewaySessionStoreTarget({
711710
cfg: params.cfg,
712711
key: params.sessionKey,
713-
scanLegacyKeys: true,
714712
});
715713
return path.resolve(target.storePath) === path.resolve(params.storePath);
716714
} catch (err) {

src/config/sessions/session-accessor.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ describe("session accessor file-backed seam", () => {
369369
sessionId: "canonical-session",
370370
updatedAt: 10,
371371
},
372-
"AGENT:MAIN:MAIN": {
372+
main: {
373373
sessionId: "legacy-session",
374374
updatedAt: 20,
375375
},
@@ -381,7 +381,7 @@ describe("session accessor file-backed seam", () => {
381381
storePath,
382382
resolveTarget: () => ({
383383
primaryKey: "agent:main:main",
384-
candidateKeys: ["agent:main:main"],
384+
candidateKeys: ["agent:main:main", "main"],
385385
}),
386386
project: ({ entries, existingEntry, primaryKey }) => {
387387
expect(primaryKey).toBe("agent:main:main");
@@ -420,7 +420,7 @@ describe("session accessor file-backed seam", () => {
420420
sessionId: "canonical-session",
421421
updatedAt: 10,
422422
},
423-
"AGENT:MAIN:MAIN": {
423+
main: {
424424
sessionId: "legacy-session",
425425
updatedAt: 20,
426426
},
@@ -432,7 +432,7 @@ describe("session accessor file-backed seam", () => {
432432
storePath,
433433
resolveTarget: () => ({
434434
primaryKey: "agent:main:main",
435-
candidateKeys: ["agent:main:main"],
435+
candidateKeys: ["agent:main:main", "main"],
436436
}),
437437
project: () => ({
438438
ok: false as const,

src/config/sessions/store.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
// Session store facade coordinates reads, writes, maintenance, delivery metadata, and exports.
22
import fs from "node:fs";
33
import path from "node:path";
4-
import {
5-
normalizeLowercaseStringOrEmpty,
6-
normalizeOptionalString,
7-
} from "@openclaw/normalization-core/string-coerce";
4+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
85
import type { MsgContext } from "../../auto-reply/templating.js";
96
import { resolveStoredSessionOwnerAgentId } from "../../gateway/session-store-key.js";
107
import { writeTextAtomic } from "../../infra/json-files.js";
@@ -1038,9 +1035,6 @@ function resolveFreshestProjectedEntry(params: {
10381035
continue;
10391036
}
10401037
keys.add(trimmed);
1041-
for (const match of findSessionStoreKeysIgnoreCase(params.store, trimmed)) {
1042-
keys.add(match);
1043-
}
10441038
}
10451039
for (const key of keys) {
10461040
const entry = params.store[key];
@@ -1054,14 +1048,6 @@ function resolveFreshestProjectedEntry(params: {
10541048
return freshest;
10551049
}
10561050

1057-
function findSessionStoreKeysIgnoreCase(
1058-
store: Record<string, SessionEntry>,
1059-
targetKey: string,
1060-
): string[] {
1061-
const lowered = normalizeLowercaseStringOrEmpty(targetKey);
1062-
return Object.keys(store).filter((key) => normalizeLowercaseStringOrEmpty(key) === lowered);
1063-
}
1064-
10651051
function migrateSessionEntryProjectionTarget(params: {
10661052
store: Record<string, SessionEntry>;
10671053
target: SessionEntryPatchProjectionTarget;
@@ -1088,11 +1074,6 @@ function migrateSessionEntryProjectionTarget(params: {
10881074
if (trimmed !== params.target.primaryKey) {
10891075
keysToDelete.add(trimmed);
10901076
}
1091-
for (const match of findSessionStoreKeysIgnoreCase(params.store, trimmed)) {
1092-
if (match !== params.target.primaryKey) {
1093-
keysToDelete.add(match);
1094-
}
1095-
}
10961077
}
10971078
for (const key of keysToDelete) {
10981079
delete params.store[key];

src/gateway/server-methods/agent.test.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -648,7 +648,16 @@ describe("gateway agent handler", () => {
648648
});
649649

650650
it("disables single-entry persistence when admission prunes legacy store keys", async () => {
651-
mockMainSessionEntry({});
651+
mocks.loadConfigReturn = {
652+
session: { mainKey: "work" },
653+
agents: { list: [{ id: "main", default: true }] },
654+
};
655+
mocks.loadSessionEntry.mockReturnValue({
656+
cfg: mocks.loadConfigReturn,
657+
storePath: "/tmp/sessions.json",
658+
entry: { sessionId: "existing-session-id", updatedAt: Date.now() },
659+
canonicalKey: "agent:main:work",
660+
});
652661
let capturedOptions:
653662
| {
654663
resolveSingleEntryPersistence?: (result: unknown) => unknown;
@@ -657,8 +666,8 @@ describe("gateway agent handler", () => {
657666
let persistedResult: unknown;
658667
mocks.updateSessionStore.mockImplementation(async (_path, updater, opts) => {
659668
const store: Record<string, Record<string, unknown>> = {
660-
"agent:main:main": buildExistingMainStoreEntry({ updatedAt: 100 }),
661-
"Agent:main:main": buildExistingMainStoreEntry({ updatedAt: 50 }),
669+
"agent:main:work": buildExistingMainStoreEntry({ updatedAt: 100 }),
670+
"agent:main:main": buildExistingMainStoreEntry({ updatedAt: 50 }),
662671
};
663672
persistedResult = await updater(store);
664673
capturedOptions = opts;
@@ -5071,7 +5080,7 @@ describe("gateway agent handler", () => {
50715080
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
50725081
const store: Record<string, unknown> = {
50735082
"agent:main:work": { sessionId: "existing-session-id", updatedAt: 10 },
5074-
"agent:main:MAIN": { sessionId: "legacy-session-id", updatedAt: 5 },
5083+
"agent:main:main": { sessionId: "legacy-session-id", updatedAt: 5 },
50755084
};
50765085
await updater(store);
50775086
capturedStore = store;
@@ -5095,7 +5104,7 @@ describe("gateway agent handler", () => {
50955104
expect(mocks.updateSessionStore).toHaveBeenCalled();
50965105
const sessionStore = requireValue(capturedStore, "updated session store missing");
50975106
expect(sessionStore).toHaveProperty("agent:main:work");
5098-
expect(sessionStore["agent:main:MAIN"]).toBeUndefined();
5107+
expect(sessionStore["agent:main:main"]).toBeUndefined();
50995108
});
51005109

51015110
it("handles bare /new by resetting the same session without running the model", async () => {

src/gateway/server-methods/sessions.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1158,7 +1158,6 @@ export const sessionsHandlers: GatewayRequestHandlers = {
11581158
const cachedStoreTarget = resolveGatewaySessionStoreTargetWithStore({
11591159
cfg,
11601160
key,
1161-
scanLegacyKeys: false,
11621161
});
11631162
const store = storeCache.get(cachedStoreTarget.storePath) ?? cachedStoreTarget.store;
11641163
storeCache.set(cachedStoreTarget.storePath, store);

src/gateway/server.sessions.preview-resolve.test.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,15 @@ test("sessions.preview returns transcript previews", async () => {
8080
expect(entry?.items[1]?.text).toContain("call weather");
8181
});
8282

83-
test("sessions.preview resolves legacy mixed-case main alias with custom mainKey", async () => {
83+
test("sessions.preview resolves legacy main alias with custom mainKey", async () => {
8484
const sessionId = "sess-legacy-main";
8585

8686
const entry = await previewMainAliasFromStore({
8787
transcripts: {
8888
[sessionId]: "Legacy alias transcript",
8989
},
9090
store: {
91-
"agent:ops:MAIN": {
91+
"agent:ops:main": {
9292
sessionId,
9393
updatedAt: Date.now(),
9494
},
@@ -97,7 +97,7 @@ test("sessions.preview resolves legacy mixed-case main alias with custom mainKey
9797
expect(entry?.items[0]?.text).toContain("Legacy alias transcript");
9898
});
9999

100-
test("sessions.preview prefers the freshest duplicate row for a legacy mixed-case main alias", async () => {
100+
test("sessions.preview prefers the freshest duplicate row for a legacy main alias", async () => {
101101
const entry = await previewMainAliasFromStore({
102102
transcripts: {
103103
"sess-stale-main": "stale preview",
@@ -108,7 +108,7 @@ test("sessions.preview prefers the freshest duplicate row for a legacy mixed-cas
108108
sessionId: "sess-stale-main",
109109
updatedAt: 1,
110110
},
111-
"agent:ops:WORK": {
111+
"agent:ops:main": {
112112
sessionId: "sess-fresh-main",
113113
updatedAt: 2,
114114
},
@@ -139,8 +139,7 @@ test("sessions.resolve and mutators clean legacy main-alias ghost keys", async (
139139
JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<string, Record<string, unknown>>;
140140

141141
await writeRawStore({
142-
"agent:ops:MAIN": { sessionId, updatedAt: Date.now() - 2_000 },
143-
"agent:ops:Main": { sessionId, updatedAt: Date.now() - 1_000 },
142+
"agent:ops:main": { sessionId, updatedAt: Date.now() - 1_000 },
144143
});
145144

146145
const { ws } = await openClient();
@@ -155,7 +154,7 @@ test("sessions.resolve and mutators clean legacy main-alias ghost keys", async (
155154

156155
await writeRawStore({
157156
...store,
158-
"agent:ops:MAIN": { ...store["agent:ops:work"] },
157+
"agent:ops:main": { ...store["agent:ops:work"] },
159158
});
160159
const patched = await rpcReq<{ ok: true; key: string }>(ws, "sessions.patch", {
161160
key: "main",
@@ -169,7 +168,7 @@ test("sessions.resolve and mutators clean legacy main-alias ghost keys", async (
169168

170169
await writeRawStore({
171170
...store,
172-
"agent:ops:MAIN": { ...store["agent:ops:work"] },
171+
"agent:ops:main": { ...store["agent:ops:work"] },
173172
});
174173
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(ws, "sessions.compact", {
175174
key: "main",
@@ -182,7 +181,7 @@ test("sessions.resolve and mutators clean legacy main-alias ghost keys", async (
182181

183182
await writeRawStore({
184183
...store,
185-
"agent:ops:MAIN": { ...store["agent:ops:work"] },
184+
"agent:ops:main": { ...store["agent:ops:work"] },
186185
});
187186
const reset = await rpcReq<{ ok: true; key: string }>(ws, "sessions.reset", { key: "main" });
188187
expect(reset.ok).toBe(true);

src/gateway/session-transcript-key.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ function sessionKeyMatchesTranscriptPath(params: {
2828
const target = resolveGatewaySessionStoreTarget({
2929
cfg: params.cfg,
3030
key: params.key,
31-
scanLegacyKeys: false,
3231
store: params.store,
3332
});
3433
const sessionAgentId = normalizeAgentId(target.agentId);

0 commit comments

Comments
 (0)