Skip to content

Commit bf8bdcb

Browse files
committed
fix(gateway): defer session store read maintenance
1 parent ad1e14a commit bf8bdcb

5 files changed

Lines changed: 61 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
2929
- Discord/voice: rerun configured voice auto-join after Discord gateway RESUMED events and ignore already-destroyed stale voice connections during reconnect cleanup, so health-monitor account restarts can rejoin configured channels. Fixes #40665. Thanks @liz709.
3030
- Discord/voice: lengthen the default voice join Ready wait, add configurable `voice.connectTimeoutMs`/`voice.reconnectGraceMs`, and warn before destroying unrecovered disconnected sessions so slow Discord voice handshakes and reconnects no longer fail silently. Fixes #63098; refs #39825 and #65039. Thanks @darealgege, @kzicherman, and @ayochim.
3131
- Gateway/health: refresh cached health RPC snapshots when channel runtime state diverges, so Discord and other channel status reads no longer report stale running or connected values until the cache TTL expires. (#75423) Thanks @clawsweeper.
32+
- Gateway/sessions: keep session-store reads from running stale prune and entry-count cap maintenance during startup, so oversized stores no longer block chat history readiness after updates while writes and `sessions cleanup --enforce` still preserve the cleanup safeguards. Fixes #70050. Thanks @tangda18.
3233
- Discord/voice: merge configured media-understanding providers such as Deepgram into partial active provider registries, so follow-up voice turns keep transcribing after another media plugin is already active. Fixes #65687. Thanks @OneMintJulep.
3334
- WhatsApp: stage `qrcode` through root mirrored runtime dependencies so packaged QR pairing can render from staged plugin-runtime-deps installs. Fixes #75394. Thanks @FelipeX2001.
3435
- Discord/voice: apply per-channel Discord `systemPrompt` overrides to voice transcript turns by forwarding the trusted channel prompt through the voice agent run. Fixes #47095. Thanks @qearlyao.

docs/concepts/session.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ to `"enforce"` for automatic cleanup:
125125
}
126126
```
127127

128-
For production-sized `maxEntries` limits, Gateway runtime writes use a small high-water buffer and clean back down to the configured cap in batches. This avoids running full store cleanup on every isolated cron session. `openclaw sessions cleanup --enforce` applies the cap immediately.
128+
For production-sized `maxEntries` limits, Gateway runtime writes use a small high-water buffer and clean back down to the configured cap in batches. Session store reads do not prune or cap entries during Gateway startup. This avoids running full store cleanup on every startup or isolated cron session. `openclaw sessions cleanup --enforce` applies the cap immediately.
129129

130130
Preview with `openclaw sessions cleanup --dry-run`.
131131

docs/reference/session-management-compaction.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Session persistence has automatic maintenance controls (`session.maintenance`) f
7979
- `maxDiskBytes`: optional sessions-directory budget
8080
- `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`)
8181

82-
Normal Gateway writes batch `maxEntries` cleanup for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. `openclaw sessions cleanup --enforce` still applies the configured cap immediately.
82+
Normal Gateway writes batch `maxEntries` cleanup for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. Session store reads do not prune or cap entries during Gateway startup; use writes or `openclaw sessions cleanup --enforce` for cleanup. `openclaw sessions cleanup --enforce` still applies the configured cap immediately.
8383

8484
OpenClaw no longer creates automatic `sessions.json.bak.*` rotation backups during Gateway writes. The legacy `session.maintenance.rotateBytes` key is ignored and `openclaw doctor --fix` removes it from older configs.
8585

src/config/sessions/store-load.ts

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { normalizeSessionRuntimeModelFields, type SessionEntry } from "./types.j
2222
export type LoadSessionStoreOptions = {
2323
skipCache?: boolean;
2424
maintenanceConfig?: ResolvedSessionMaintenanceConfig;
25+
runMaintenance?: boolean;
2526
clone?: boolean;
2627
};
2728

@@ -131,28 +132,30 @@ export function loadSessionStore(
131132
if (migrated || normalized) {
132133
serializedFromDisk = undefined;
133134
}
134-
const maintenance = opts.maintenanceConfig ?? resolveMaintenanceConfig();
135-
const beforeCount = Object.keys(store).length;
136-
if (maintenance.mode === "enforce" && beforeCount > maintenance.maxEntries) {
137-
const pruned = pruneStaleEntries(store, maintenance.pruneAfterMs, { log: false });
138-
const countAfterPrune = Object.keys(store).length;
139-
const capped = shouldRunSessionEntryMaintenance({
140-
entryCount: countAfterPrune,
141-
maxEntries: maintenance.maxEntries,
142-
})
143-
? capEntryCount(store, maintenance.maxEntries, { log: false })
144-
: 0;
145-
const afterCount = Object.keys(store).length;
146-
if (pruned > 0 || capped > 0) {
147-
serializedFromDisk = undefined;
148-
log.info("applied load-time maintenance to oversized session store", {
149-
storePath,
150-
before: beforeCount,
151-
after: afterCount,
152-
pruned,
153-
capped,
135+
if (opts.runMaintenance) {
136+
const maintenance = opts.maintenanceConfig ?? resolveMaintenanceConfig();
137+
const beforeCount = Object.keys(store).length;
138+
if (maintenance.mode === "enforce" && beforeCount > maintenance.maxEntries) {
139+
const pruned = pruneStaleEntries(store, maintenance.pruneAfterMs, { log: false });
140+
const countAfterPrune = Object.keys(store).length;
141+
const capped = shouldRunSessionEntryMaintenance({
142+
entryCount: countAfterPrune,
154143
maxEntries: maintenance.maxEntries,
155-
});
144+
})
145+
? capEntryCount(store, maintenance.maxEntries, { log: false })
146+
: 0;
147+
const afterCount = Object.keys(store).length;
148+
if (pruned > 0 || capped > 0) {
149+
serializedFromDisk = undefined;
150+
log.info("applied load-time maintenance to oversized session store", {
151+
storePath,
152+
before: beforeCount,
153+
after: afterCount,
154+
pruned,
155+
capped,
156+
maxEntries: maintenance.maxEntries,
157+
});
158+
}
156159
}
157160
}
158161

src/config/sessions/store.pruning.integration.test.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ describe("Integration: saveSessionStore with pruning", () => {
301301
expect(Object.keys(loaded)).toHaveLength(2);
302302
});
303303

304-
it("loadSessionStore prunes stale entries from oversized stores by default", async () => {
304+
it("loadSessionStore leaves oversized stores untouched during normal reads", async () => {
305305
const now = Date.now();
306306
const store: Record<string, SessionEntry> = {
307307
stale: makeEntry(now - 31 * DAY_MS),
@@ -319,12 +319,37 @@ describe("Integration: saveSessionStore with pruning", () => {
319319
},
320320
});
321321

322-
expect(loaded.stale).toBeUndefined();
322+
expect(Object.keys(loaded)).toHaveLength(3);
323+
expect(loaded.stale).toBeDefined();
323324
expect(loaded.recent).toBeDefined();
324325
expect(loaded.newest).toBeDefined();
325326
});
326327

327-
it("loadSessionStore caps oversized stores by default", async () => {
328+
it("loadSessionStore applies maintenance only when explicitly requested", async () => {
329+
const now = Date.now();
330+
const store: Record<string, SessionEntry> = {
331+
stale: makeEntry(now - 31 * DAY_MS),
332+
recent: makeEntry(now - DAY_MS),
333+
newest: makeEntry(now),
334+
};
335+
await fs.writeFile(storePath, JSON.stringify(store), "utf-8");
336+
337+
const loaded = loadSessionStore(storePath, {
338+
skipCache: true,
339+
runMaintenance: true,
340+
maintenanceConfig: {
341+
...ENFORCED_MAINTENANCE_OVERRIDE,
342+
maxEntries: 1,
343+
pruneAfterMs: 7 * DAY_MS,
344+
},
345+
});
346+
347+
expect(loaded.stale).toBeUndefined();
348+
expect(loaded.recent).toBeUndefined();
349+
expect(loaded.newest).toBeDefined();
350+
});
351+
352+
it("loadSessionStore does not cap oversized stores during normal reads", async () => {
328353
const now = Date.now();
329354
const store: Record<string, SessionEntry> = {
330355
oldest: makeEntry(now - 3 * DAY_MS),
@@ -342,13 +367,13 @@ describe("Integration: saveSessionStore with pruning", () => {
342367
},
343368
});
344369

345-
expect(Object.keys(loaded)).toHaveLength(2);
346-
expect(loaded.oldest).toBeUndefined();
370+
expect(Object.keys(loaded)).toHaveLength(3);
371+
expect(loaded.oldest).toBeDefined();
347372
expect(loaded.recent).toBeDefined();
348373
expect(loaded.newest).toBeDefined();
349374
});
350375

351-
it("loadSessionStore batches entry-count cleanup until the high-water mark", async () => {
376+
it("explicit loadSessionStore maintenance batches entry-count cleanup until the high-water mark", async () => {
352377
const now = Date.now();
353378
const store = Object.fromEntries(
354379
Array.from({ length: 51 }, (_, index) => [`session-${index}`, makeEntry(now - index)]),
@@ -357,6 +382,7 @@ describe("Integration: saveSessionStore with pruning", () => {
357382

358383
const loaded = loadSessionStore(storePath, {
359384
skipCache: true,
385+
runMaintenance: true,
360386
maintenanceConfig: {
361387
...ENFORCED_MAINTENANCE_OVERRIDE,
362388
maxEntries: 50,
@@ -367,7 +393,7 @@ describe("Integration: saveSessionStore with pruning", () => {
367393
expect(Object.keys(loaded)).toHaveLength(51);
368394
});
369395

370-
it("loadSessionStore caps production-sized stores once they reach the high-water mark", async () => {
396+
it("explicit loadSessionStore maintenance caps production-sized stores once they reach the high-water mark", async () => {
371397
const now = Date.now();
372398
const store = Object.fromEntries(
373399
Array.from({ length: 75 }, (_, index) => [`session-${index}`, makeEntry(now - index)]),
@@ -376,6 +402,7 @@ describe("Integration: saveSessionStore with pruning", () => {
376402

377403
const loaded = loadSessionStore(storePath, {
378404
skipCache: true,
405+
runMaintenance: true,
379406
maintenanceConfig: {
380407
...ENFORCED_MAINTENANCE_OVERRIDE,
381408
maxEntries: 50,

0 commit comments

Comments
 (0)