Skip to content

Commit 9c2a6a8

Browse files
committed
perf(gateway): borrow session reads on turn hot paths
1 parent 455d5e8 commit 9c2a6a8

10 files changed

Lines changed: 108 additions & 12 deletions

File tree

src/auto-reply/reply/agent-runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2161,6 +2161,8 @@ export async function runReplyAgent(params: {
21612161
await updateSessionStoreEntry({
21622162
storePath,
21632163
sessionKey,
2164+
skipMaintenance: true,
2165+
takeCacheOwnership: true,
21642166
update: async () => ({
21652167
pendingFinalDelivery: true,
21662168
pendingFinalDeliveryText: resolvedPendingText,

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,8 @@ async function clearPendingFinalDeliveryAfterSuccess(params: {
680680
await updateSessionStoreEntry({
681681
storePath: params.storePath,
682682
sessionKey: params.sessionKey,
683+
skipMaintenance: true,
684+
takeCacheOwnership: true,
683685
update: async (entry) => {
684686
if (!entry.pendingFinalDelivery && !entry.pendingFinalDeliveryText) {
685687
return null;

src/auto-reply/reply/get-reply.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,8 @@ export async function getReplyFromConfig(
520520
await updateSessionStoreEntry({
521521
storePath,
522522
sessionKey,
523+
skipMaintenance: true,
524+
takeCacheOwnership: true,
523525
update: async () => ({
524526
pendingFinalDelivery: undefined,
525527
pendingFinalDeliveryText: undefined,
@@ -548,6 +550,8 @@ export async function getReplyFromConfig(
548550
await updateSessionStoreEntry({
549551
storePath,
550552
sessionKey,
553+
skipMaintenance: true,
554+
takeCacheOwnership: true,
551555
update: async () => ({
552556
pendingFinalDeliveryText: replayText,
553557
pendingFinalDeliveryLastAttemptAt: updatedAt,

src/auto-reply/reply/session-usage.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ export async function persistSessionUsageUpdate(params: {
113113
await updateSessionStoreEntry({
114114
storePath,
115115
sessionKey,
116+
skipMaintenance: true,
117+
takeCacheOwnership: true,
116118
update: async (entry) => {
117119
const preserveSessionModelState =
118120
params.isHeartbeat === true || params.preserveUserFacingSessionModelState === true;
@@ -197,6 +199,8 @@ export async function persistSessionUsageUpdate(params: {
197199
await updateSessionStoreEntry({
198200
storePath,
199201
sessionKey,
202+
skipMaintenance: true,
203+
takeCacheOwnership: true,
200204
update: async (entry) => {
201205
const preserveSessionModelState =
202206
params.isHeartbeat === true || params.preserveUserFacingSessionModelState === true;

src/config/sessions.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,36 @@ describe("sessions", () => {
952952
expect(store[mainSessionKey]?.thinkingLevel).toBe("high");
953953
});
954954

955+
it("updateSessionStoreEntry can skip maintenance for existing-entry metadata writes", async () => {
956+
const mainSessionKey = "agent:main:main";
957+
const staleSessionKey = "agent:main:stale";
958+
const { storePath } = await createSessionStoreFixture({
959+
prefix: "updateSessionStoreEntry-skip-maintenance",
960+
entries: {
961+
[mainSessionKey]: {
962+
sessionId: "sess-1",
963+
updatedAt: Date.now(),
964+
thinkingLevel: "low",
965+
},
966+
[staleSessionKey]: {
967+
sessionId: "sess-stale",
968+
updatedAt: 1,
969+
},
970+
},
971+
});
972+
973+
await updateSessionStoreEntry({
974+
storePath,
975+
sessionKey: mainSessionKey,
976+
skipMaintenance: true,
977+
update: async () => ({ thinkingLevel: "high" }),
978+
});
979+
980+
const store = loadSessionStore(storePath);
981+
expect(store[mainSessionKey]?.thinkingLevel).toBe("high");
982+
expect(store[staleSessionKey]?.sessionId).toBe("sess-stale");
983+
});
984+
955985
it("updateSessionStore uses the writer-owned mutable cache without disk read", async () => {
956986
const mainSessionKey = "agent:main:main";
957987
const { storePath } = await createSessionStoreFixture({

src/config/sessions/store.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,7 @@ async function persistResolvedSessionEntry(params: {
633633
store: Record<string, SessionEntry>;
634634
resolved: ReturnType<typeof resolveSessionStoreEntry>;
635635
next: SessionEntry;
636+
skipMaintenance?: boolean;
636637
takeCacheOwnership?: boolean;
637638
}): Promise<SessionEntry> {
638639
params.store[params.resolved.normalizedKey] = params.next;
@@ -641,6 +642,7 @@ async function persistResolvedSessionEntry(params: {
641642
}
642643
await saveSessionStoreUnlocked(params.storePath, params.store, {
643644
activeSessionKey: params.resolved.normalizedKey,
645+
skipMaintenance: params.skipMaintenance,
644646
takeCacheOwnership: params.takeCacheOwnership,
645647
});
646648
return params.next;
@@ -650,6 +652,7 @@ export async function updateSessionStoreEntry(params: {
650652
storePath: string;
651653
sessionKey: string;
652654
update: (entry: SessionEntry) => Promise<Partial<SessionEntry> | null>;
655+
skipMaintenance?: boolean;
653656
takeCacheOwnership?: boolean;
654657
}): Promise<SessionEntry | null> {
655658
const { storePath, sessionKey, update } = params;
@@ -670,6 +673,7 @@ export async function updateSessionStoreEntry(params: {
670673
store,
671674
resolved,
672675
next,
676+
skipMaintenance: params.skipMaintenance,
673677
takeCacheOwnership: params.takeCacheOwnership,
674678
});
675679
});

src/gateway/server-methods/agent.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,7 +1085,9 @@ export const agentHandlers: GatewayRequestHandlers = {
10851085
let baseProvider: string | undefined;
10861086
let baseModel: string | undefined;
10871087
if (requestedSessionKeyRaw) {
1088-
const { cfg: sessCfg, entry: sessEntry } = loadSessionEntry(requestedSessionKeyRaw);
1088+
const { cfg: sessCfg, entry: sessEntry } = loadSessionEntry(requestedSessionKeyRaw, {
1089+
clone: false,
1090+
});
10891091
const sessionAgentId = resolveAgentIdFromSessionKey(requestedSessionKeyRaw);
10901092
const modelRef = resolveSessionModelRef(sessCfg, sessEntry, sessionAgentId);
10911093
baseProvider = modelRef.provider;
@@ -1162,7 +1164,9 @@ export const agentHandlers: GatewayRequestHandlers = {
11621164
const explicitVoiceWakeSessionTarget =
11631165
!agentId && requestedSessionKeyRaw
11641166
? (() => {
1165-
const { cfg: sessionCfg, canonicalKey } = loadSessionEntry(requestedSessionKeyRaw);
1167+
const { cfg: sessionCfg, canonicalKey } = loadSessionEntry(requestedSessionKeyRaw, {
1168+
clone: false,
1169+
});
11661170
const routedAgentId = resolveAgentIdFromSessionKey(canonicalKey);
11671171
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(sessionCfg));
11681172
if (routedAgentId !== defaultAgentId) {
@@ -1202,7 +1206,9 @@ export const agentHandlers: GatewayRequestHandlers = {
12021206
}
12031207
} else if ("sessionKey" in route) {
12041208
if (classifySessionKeyShape(route.sessionKey) !== "malformed_agent") {
1205-
const canonicalRouteSession = loadSessionEntry(route.sessionKey).canonicalKey;
1209+
const canonicalRouteSession = loadSessionEntry(route.sessionKey, {
1210+
clone: false,
1211+
}).canonicalKey;
12061212
const routedAgentId = resolveAgentIdFromSessionKey(canonicalRouteSession);
12071213
if (knownAgents.includes(routedAgentId)) {
12081214
requestedSessionKey = canonicalRouteSession;
@@ -1258,7 +1264,7 @@ export const agentHandlers: GatewayRequestHandlers = {
12581264
if (postResetMessage) {
12591265
message = postResetMessage;
12601266
} else {
1261-
const resetLoadedSession = loadSessionEntry(requestedSessionKey);
1267+
const resetLoadedSession = loadSessionEntry(requestedSessionKey, { clone: false });
12621268
const resetCfg = resetLoadedSession?.cfg ?? cfg;
12631269
const resetSessionEntry = resetLoadedSession?.entry;
12641270
const resetSpawnedBy = canonicalizeSpawnedByForAgent(
@@ -1318,7 +1324,9 @@ export const agentHandlers: GatewayRequestHandlers = {
13181324
}
13191325

13201326
if (requestedSessionKey) {
1321-
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(requestedSessionKey);
1327+
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(requestedSessionKey, {
1328+
clone: false,
1329+
});
13221330
cfgForAgent = cfg;
13231331
const now = Date.now();
13241332
const resetPolicy = resolveSessionResetPolicy({

src/gateway/session-lifecycle-state.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,15 @@ export async function persistGatewaySessionLifecycleEvent(params: {
152152
return;
153153
}
154154

155-
const sessionEntry = loadSessionEntry(params.sessionKey);
155+
const sessionEntry = loadSessionEntry(params.sessionKey, { clone: false });
156156
if (!sessionEntry.entry) {
157157
return;
158158
}
159159

160160
await updateSessionStoreEntry({
161161
storePath: sessionEntry.storePath,
162162
sessionKey: sessionEntry.canonicalKey,
163+
skipMaintenance: true,
163164
takeCacheOwnership: true,
164165
update: async (entry) =>
165166
derivePersistedSessionLifecyclePatch({

src/gateway/session-utils.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "node:path";
44
import { afterEach, describe, expect, test, vi } from "vitest";
55
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js";
66
import type { OpenClawConfig } from "../config/config.js";
7-
import type { SessionEntry } from "../config/sessions.js";
7+
import { loadSessionStore, type SessionEntry } from "../config/sessions.js";
88
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
99
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
1010
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
@@ -925,6 +925,39 @@ describe("gateway session utils", () => {
925925
}
926926
});
927927

928+
test("loadSessionEntry can borrow the cached store for read-only hot paths", async () => {
929+
resetConfigRuntimeState();
930+
try {
931+
await withStateDirEnv("session-utils-load-entry-borrowed-", async ({ stateDir }) => {
932+
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
933+
fs.mkdirSync(sessionsDir, { recursive: true });
934+
const storePath = path.join(sessionsDir, "sessions.json");
935+
fs.writeFileSync(
936+
storePath,
937+
JSON.stringify({
938+
"agent:main:main": { sessionId: "sess-main", updatedAt: 7 },
939+
}),
940+
"utf8",
941+
);
942+
const cfg = {
943+
session: {
944+
mainKey: "main",
945+
store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"),
946+
},
947+
agents: { list: [{ id: "main", default: true }] },
948+
} as OpenClawConfig;
949+
setRuntimeConfigSnapshot(cfg, cfg);
950+
951+
const loaded = loadSessionEntry("agent:main:main", { clone: false });
952+
const borrowedStore = loadSessionStore(loaded.storePath, { clone: false });
953+
954+
expect(loaded.entry).toBe(borrowedStore["agent:main:main"]);
955+
});
956+
} finally {
957+
resetConfigRuntimeState();
958+
}
959+
});
960+
928961
test("loadSessionEntry preserves a listed deleted main session over the live default main", async () => {
929962
resetConfigRuntimeState();
930963
try {

src/gateway/session-utils.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -762,16 +762,17 @@ export function resolveDeletedAgentIdFromSessionKey(
762762
return agentId;
763763
}
764764

765-
export function loadSessionEntry(sessionKey: string, opts?: { agentId?: string }) {
765+
export function loadSessionEntry(sessionKey: string, opts?: { agentId?: string; clone?: boolean }) {
766766
const cfg = getRuntimeConfig();
767767
const key = normalizeOptionalString(sessionKey) ?? "";
768768
const target = resolveGatewaySessionStoreTarget({
769769
cfg,
770770
key,
771+
...(opts?.clone === false ? { clone: false } : {}),
771772
...(opts?.agentId ? { agentId: opts.agentId } : {}),
772773
});
773774
const storePath = target.storePath;
774-
const store = loadSessionStore(storePath);
775+
const store = loadSessionStore(storePath, opts?.clone === false ? { clone: false } : undefined);
775776
const freshestMatch = resolveFreshestSessionStoreMatchFromStoreKeys(store, target.storeKeys);
776777
const legacyKey = freshestMatch?.key !== target.canonicalKey ? freshestMatch?.key : undefined;
777778
return {
@@ -1148,6 +1149,7 @@ function resolveGatewaySessionStoreLookup(params: {
11481149
key: string;
11491150
canonicalKey: string;
11501151
agentId: string;
1152+
clone?: boolean;
11511153
initialStore?: Record<string, SessionEntry>;
11521154
}): {
11531155
storePath: string;
@@ -1160,8 +1162,9 @@ function resolveGatewaySessionStoreLookup(params: {
11601162
agentId: params.agentId,
11611163
storePath: resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }),
11621164
};
1165+
const loadOptions = params.clone === false ? { clone: false } : undefined;
11631166
let selectedStorePath = fallback.storePath;
1164-
let selectedStore = params.initialStore ?? loadSessionStore(fallback.storePath);
1167+
let selectedStore = params.initialStore ?? loadSessionStore(fallback.storePath, loadOptions);
11651168
let selectedMatch = findFreshestStoreMatch(selectedStore, ...scanTargets);
11661169
let selectedUpdatedAt = selectedMatch?.entry.updatedAt ?? Number.NEGATIVE_INFINITY;
11671170

@@ -1170,7 +1173,7 @@ function resolveGatewaySessionStoreLookup(params: {
11701173
if (!candidate) {
11711174
continue;
11721175
}
1173-
const store = loadSessionStore(candidate.storePath);
1176+
const store = loadSessionStore(candidate.storePath, loadOptions);
11741177
const match = findFreshestStoreMatch(store, ...scanTargets);
11751178
if (!match) {
11761179
continue;
@@ -1196,6 +1199,7 @@ function resolveGatewaySessionStoreLookup(params: {
11961199
function resolveExplicitDeletedLegacyMainStoreTarget(params: {
11971200
cfg: OpenClawConfig;
11981201
key: string;
1202+
clone?: boolean;
11991203
scanLegacyKeys?: boolean;
12001204
}): {
12011205
agentId: string;
@@ -1232,11 +1236,12 @@ function resolveExplicitDeletedLegacyMainStoreTarget(params: {
12321236
match: { entry: SessionEntry; key: string };
12331237
}
12341238
| undefined;
1239+
const loadOptions = params.clone === false ? { clone: false } : undefined;
12351240
for (const target of resolveAllAgentSessionStoreTargetsSync(params.cfg)) {
12361241
if (target.agentId !== legacyAgentId) {
12371242
continue;
12381243
}
1239-
const store = loadSessionStore(target.storePath);
1244+
const store = loadSessionStore(target.storePath, loadOptions);
12401245
const match = findFreshestStoreMatch(store, ...lookupSeeds);
12411246
if (!match) {
12421247
continue;
@@ -1274,6 +1279,7 @@ export function resolveGatewaySessionStoreTarget(params: {
12741279
cfg: OpenClawConfig;
12751280
key: string;
12761281
agentId?: string;
1282+
clone?: boolean;
12771283
scanLegacyKeys?: boolean;
12781284
store?: Record<string, SessionEntry>;
12791285
}): {
@@ -1286,6 +1292,7 @@ export function resolveGatewaySessionStoreTarget(params: {
12861292
const explicitDeletedMainTarget = resolveExplicitDeletedLegacyMainStoreTarget({
12871293
cfg: params.cfg,
12881294
key,
1295+
clone: params.clone,
12891296
scanLegacyKeys: params.scanLegacyKeys,
12901297
});
12911298
if (explicitDeletedMainTarget) {
@@ -1306,6 +1313,7 @@ export function resolveGatewaySessionStoreTarget(params: {
13061313
key,
13071314
canonicalKey,
13081315
agentId,
1316+
clone: params.clone,
13091317
initialStore: params.store,
13101318
});
13111319

0 commit comments

Comments
 (0)