fix(gateway): defer session store read maintenance

This commit is contained in:
Vincent Koc
2026-05-01 04:47:48 -07:00
parent ad1e14af53
commit bf8bdcb064
5 changed files with 61 additions and 30 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
- 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.
- WhatsApp: stage `qrcode` through root mirrored runtime dependencies so packaged QR pairing can render from staged plugin-runtime-deps installs. Fixes #75394. Thanks @FelipeX2001.
- 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.

View File

@@ -125,7 +125,7 @@ to `"enforce"` for automatic cleanup:
}
```
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.
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.
Preview with `openclaw sessions cleanup --dry-run`.

View File

@@ -79,7 +79,7 @@ Session persistence has automatic maintenance controls (`session.maintenance`) f
- `maxDiskBytes`: optional sessions-directory budget
- `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`)
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.
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.
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.

View File

@@ -22,6 +22,7 @@ import { normalizeSessionRuntimeModelFields, type SessionEntry } from "./types.j
export type LoadSessionStoreOptions = {
skipCache?: boolean;
maintenanceConfig?: ResolvedSessionMaintenanceConfig;
runMaintenance?: boolean;
clone?: boolean;
};
@@ -131,28 +132,30 @@ export function loadSessionStore(
if (migrated || normalized) {
serializedFromDisk = undefined;
}
const maintenance = opts.maintenanceConfig ?? resolveMaintenanceConfig();
const beforeCount = Object.keys(store).length;
if (maintenance.mode === "enforce" && beforeCount > maintenance.maxEntries) {
const pruned = pruneStaleEntries(store, maintenance.pruneAfterMs, { log: false });
const countAfterPrune = Object.keys(store).length;
const capped = shouldRunSessionEntryMaintenance({
entryCount: countAfterPrune,
maxEntries: maintenance.maxEntries,
})
? capEntryCount(store, maintenance.maxEntries, { log: false })
: 0;
const afterCount = Object.keys(store).length;
if (pruned > 0 || capped > 0) {
serializedFromDisk = undefined;
log.info("applied load-time maintenance to oversized session store", {
storePath,
before: beforeCount,
after: afterCount,
pruned,
capped,
if (opts.runMaintenance) {
const maintenance = opts.maintenanceConfig ?? resolveMaintenanceConfig();
const beforeCount = Object.keys(store).length;
if (maintenance.mode === "enforce" && beforeCount > maintenance.maxEntries) {
const pruned = pruneStaleEntries(store, maintenance.pruneAfterMs, { log: false });
const countAfterPrune = Object.keys(store).length;
const capped = shouldRunSessionEntryMaintenance({
entryCount: countAfterPrune,
maxEntries: maintenance.maxEntries,
});
})
? capEntryCount(store, maintenance.maxEntries, { log: false })
: 0;
const afterCount = Object.keys(store).length;
if (pruned > 0 || capped > 0) {
serializedFromDisk = undefined;
log.info("applied load-time maintenance to oversized session store", {
storePath,
before: beforeCount,
after: afterCount,
pruned,
capped,
maxEntries: maintenance.maxEntries,
});
}
}
}

View File

@@ -301,7 +301,7 @@ describe("Integration: saveSessionStore with pruning", () => {
expect(Object.keys(loaded)).toHaveLength(2);
});
it("loadSessionStore prunes stale entries from oversized stores by default", async () => {
it("loadSessionStore leaves oversized stores untouched during normal reads", async () => {
const now = Date.now();
const store: Record<string, SessionEntry> = {
stale: makeEntry(now - 31 * DAY_MS),
@@ -319,12 +319,37 @@ describe("Integration: saveSessionStore with pruning", () => {
},
});
expect(loaded.stale).toBeUndefined();
expect(Object.keys(loaded)).toHaveLength(3);
expect(loaded.stale).toBeDefined();
expect(loaded.recent).toBeDefined();
expect(loaded.newest).toBeDefined();
});
it("loadSessionStore caps oversized stores by default", async () => {
it("loadSessionStore applies maintenance only when explicitly requested", async () => {
const now = Date.now();
const store: Record<string, SessionEntry> = {
stale: makeEntry(now - 31 * DAY_MS),
recent: makeEntry(now - DAY_MS),
newest: makeEntry(now),
};
await fs.writeFile(storePath, JSON.stringify(store), "utf-8");
const loaded = loadSessionStore(storePath, {
skipCache: true,
runMaintenance: true,
maintenanceConfig: {
...ENFORCED_MAINTENANCE_OVERRIDE,
maxEntries: 1,
pruneAfterMs: 7 * DAY_MS,
},
});
expect(loaded.stale).toBeUndefined();
expect(loaded.recent).toBeUndefined();
expect(loaded.newest).toBeDefined();
});
it("loadSessionStore does not cap oversized stores during normal reads", async () => {
const now = Date.now();
const store: Record<string, SessionEntry> = {
oldest: makeEntry(now - 3 * DAY_MS),
@@ -342,13 +367,13 @@ describe("Integration: saveSessionStore with pruning", () => {
},
});
expect(Object.keys(loaded)).toHaveLength(2);
expect(loaded.oldest).toBeUndefined();
expect(Object.keys(loaded)).toHaveLength(3);
expect(loaded.oldest).toBeDefined();
expect(loaded.recent).toBeDefined();
expect(loaded.newest).toBeDefined();
});
it("loadSessionStore batches entry-count cleanup until the high-water mark", async () => {
it("explicit loadSessionStore maintenance batches entry-count cleanup until the high-water mark", async () => {
const now = Date.now();
const store = Object.fromEntries(
Array.from({ length: 51 }, (_, index) => [`session-${index}`, makeEntry(now - index)]),
@@ -357,6 +382,7 @@ describe("Integration: saveSessionStore with pruning", () => {
const loaded = loadSessionStore(storePath, {
skipCache: true,
runMaintenance: true,
maintenanceConfig: {
...ENFORCED_MAINTENANCE_OVERRIDE,
maxEntries: 50,
@@ -367,7 +393,7 @@ describe("Integration: saveSessionStore with pruning", () => {
expect(Object.keys(loaded)).toHaveLength(51);
});
it("loadSessionStore caps production-sized stores once they reach the high-water mark", async () => {
it("explicit loadSessionStore maintenance caps production-sized stores once they reach the high-water mark", async () => {
const now = Date.now();
const store = Object.fromEntries(
Array.from({ length: 75 }, (_, index) => [`session-${index}`, makeEntry(now - index)]),
@@ -376,6 +402,7 @@ describe("Integration: saveSessionStore with pruning", () => {
const loaded = loadSessionStore(storePath, {
skipCache: true,
runMaintenance: true,
maintenanceConfig: {
...ENFORCED_MAINTENANCE_OVERRIDE,
maxEntries: 50,