diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index 715959d0452..2543467c5c9 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -8,12 +8,18 @@ import { setSerializedSessionStore, writeSessionStoreCache, } from "./store-cache.js"; +import { + capEntryCount, + pruneStaleEntries, + resolveMaintenanceConfigFromInput, + type ResolvedSessionMaintenanceConfig, +} from "./store-maintenance.js"; import { applySessionStoreMigrations } from "./store-migrations.js"; -import { capEntryCount, pruneStaleEntries, resolveMaintenanceConfig } from "./store-maintenance.js"; import { normalizeSessionRuntimeModelFields, type SessionEntry } from "./types.js"; export type LoadSessionStoreOptions = { skipCache?: boolean; + maintenanceConfig?: ResolvedSessionMaintenanceConfig; }; const log = createSubsystemLogger("sessions/store"); @@ -122,7 +128,7 @@ export function loadSessionStore( applySessionStoreMigrations(store); normalizeSessionStore(store); - const maintenance = resolveMaintenanceConfig(); + const maintenance = opts.maintenanceConfig ?? resolveMaintenanceConfigFromInput(); if (maintenance.mode === "enforce" && Object.keys(store).length > maintenance.maxEntries) { const beforeCount = Object.keys(store).length; const pruned = pruneStaleEntries(store, maintenance.pruneAfterMs, { log: false }); diff --git a/src/config/sessions/store-maintenance-runtime.ts b/src/config/sessions/store-maintenance-runtime.ts new file mode 100644 index 00000000000..af3ed4a7213 --- /dev/null +++ b/src/config/sessions/store-maintenance-runtime.ts @@ -0,0 +1,16 @@ +import { loadConfig } from "../config.js"; +import type { SessionMaintenanceConfig } from "../types.base.js"; +import { + resolveMaintenanceConfigFromInput, + type ResolvedSessionMaintenanceConfig, +} from "./store-maintenance.js"; + +export function resolveMaintenanceConfig(): ResolvedSessionMaintenanceConfig { + let maintenance: SessionMaintenanceConfig | undefined; + try { + maintenance = loadConfig().session?.maintenance; + } catch { + // Config may not be available in narrow test/runtime helpers. + } + return resolveMaintenanceConfigFromInput(maintenance); +} diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index 4a272100134..83c793768a9 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -4,7 +4,6 @@ import { parseByteSize } from "../../cli/parse-bytes.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { normalizeStringifiedOptionalString } from "../../shared/string-coerce.js"; -import { loadConfig } from "../config.js"; import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types.base.js"; import type { SessionEntry } from "./types.js"; @@ -149,16 +148,6 @@ export function resolveMaintenanceConfigFromInput( }; } -export function resolveMaintenanceConfig(): ResolvedSessionMaintenanceConfig { - let maintenance: SessionMaintenanceConfig | undefined; - try { - maintenance = loadConfig().session?.maintenance; - } catch { - // Config may not be available (e.g. in tests). Use defaults. - } - return resolveMaintenanceConfigFromInput(maintenance); -} - /** * Remove entries whose `updatedAt` is older than the configured threshold. * Entries without `updatedAt` are kept (cannot determine staleness). @@ -169,7 +158,7 @@ export function pruneStaleEntries( overrideMaxAgeMs?: number, opts: { log?: boolean; onPruned?: (params: { key: string; entry: SessionEntry }) => void } = {}, ): number { - const maxAgeMs = overrideMaxAgeMs ?? resolveMaintenanceConfig().pruneAfterMs; + const maxAgeMs = overrideMaxAgeMs ?? resolveMaintenanceConfigFromInput().pruneAfterMs; const cutoffMs = Date.now() - maxAgeMs; let pruned = 0; for (const [key, entry] of Object.entries(store)) { @@ -278,7 +267,7 @@ export function capEntryCount( onCapped?: (params: { key: string; entry: SessionEntry }) => void; } = {}, ): number { - const maxEntries = overrideMax ?? resolveMaintenanceConfig().maxEntries; + const maxEntries = overrideMax ?? resolveMaintenanceConfigFromInput().maxEntries; const keys = Object.keys(store); if (keys.length <= maxEntries) { return 0; @@ -323,7 +312,7 @@ export async function rotateSessionFile( storePath: string, overrideBytes?: number, ): Promise { - const maxBytes = overrideBytes ?? resolveMaintenanceConfig().rotateBytes; + const maxBytes = overrideBytes ?? resolveMaintenanceConfigFromInput().rotateBytes; // Check current file size (file may not exist yet). const fileSize = await getSessionFileSize(storePath); diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index 21a12346c7f..b5c3065178c 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -247,15 +247,6 @@ describe("Integration: saveSessionStore with pruning", () => { }); it("loadSessionStore prunes stale entries from oversized stores by default", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { - maxEntries: 2, - pruneAfter: "7d", - rotateBytes: 10_485_760, - }, - }, - }); const now = Date.now(); const store: Record = { stale: makeEntry(now - 31 * DAY_MS), @@ -264,7 +255,14 @@ describe("Integration: saveSessionStore with pruning", () => { }; await fs.writeFile(storePath, JSON.stringify(store), "utf-8"); - const loaded = loadSessionStore(storePath, { skipCache: true }); + const loaded = loadSessionStore(storePath, { + skipCache: true, + maintenanceConfig: { + ...ENFORCED_MAINTENANCE_OVERRIDE, + maxEntries: 2, + pruneAfterMs: 7 * DAY_MS, + }, + }); expect(loaded.stale).toBeUndefined(); expect(loaded.recent).toBeDefined(); @@ -272,15 +270,6 @@ describe("Integration: saveSessionStore with pruning", () => { }); it("loadSessionStore caps oversized stores by default", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { - maxEntries: 2, - pruneAfter: "365d", - rotateBytes: 10_485_760, - }, - }, - }); const now = Date.now(); const store: Record = { oldest: makeEntry(now - 3 * DAY_MS), @@ -289,7 +278,14 @@ describe("Integration: saveSessionStore with pruning", () => { }; await fs.writeFile(storePath, JSON.stringify(store), "utf-8"); - const loaded = loadSessionStore(storePath, { skipCache: true }); + const loaded = loadSessionStore(storePath, { + skipCache: true, + maintenanceConfig: { + ...ENFORCED_MAINTENANCE_OVERRIDE, + maxEntries: 2, + pruneAfterMs: 365 * DAY_MS, + }, + }); expect(Object.keys(loaded)).toHaveLength(2); expect(loaded.oldest).toBeUndefined(); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index e28a6aa11a3..46feeda0695 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -34,11 +34,11 @@ import { type SessionStoreLockQueue, type SessionStoreLockTask, } from "./store-lock-state.js"; +import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js"; import { capEntryCount, getActiveSessionMaintenanceWarning, pruneStaleEntries, - resolveMaintenanceConfig, rotateSessionFile, type ResolvedSessionMaintenanceConfig, type SessionMaintenanceWarning, diff --git a/src/infra/heartbeat-runner.isolated-key-stability.test.ts b/src/infra/heartbeat-runner.isolated-key-stability.test.ts index 0376f263342..d6690acd61d 100644 --- a/src/infra/heartbeat-runner.isolated-key-stability.test.ts +++ b/src/infra/heartbeat-runner.isolated-key-stability.test.ts @@ -52,7 +52,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { sessionKey: params.sessionKey, deps: { getQueueSize: () => 0, - nowMs: () => 0, + nowMs: () => Date.now(), }, }); @@ -111,7 +111,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { JSON.stringify({ [alreadySuffixedKey]: { sessionId: "sid", - updatedAt: 1, + updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", @@ -128,7 +128,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { sessionKey: alreadySuffixedKey, deps: { getQueueSize: () => 0, - nowMs: () => 0, + nowMs: () => Date.now(), }, }); @@ -221,7 +221,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { reason: "interval", deps: { getQueueSize: () => 0, - nowMs: () => 0, + nowMs: () => Date.now(), }, }); @@ -233,7 +233,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { reason: "interval", deps: { getQueueSize: () => 0, - nowMs: () => 0, + nowMs: () => Date.now(), }, }); @@ -267,7 +267,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { JSON.stringify({ [alreadyIsolatedKey]: { sessionId: "sid", - updatedAt: 1, + updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", @@ -284,7 +284,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { sessionKey: alreadyIsolatedKey, deps: { getQueueSize: () => 0, - nowMs: () => 0, + nowMs: () => Date.now(), }, }); @@ -302,7 +302,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { JSON.stringify({ [isolatedSessionKey]: { sessionId: "sid", - updatedAt: 1, + updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", @@ -321,7 +321,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { reason: "hook:wake", deps: { getQueueSize: () => 0, - nowMs: () => 0, + nowMs: () => Date.now(), }, }); @@ -364,7 +364,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { JSON.stringify({ [isolatedSessionKey]: { sessionId: "sid", - updatedAt: 1, + updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", @@ -382,7 +382,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { sessionKey: isolatedSessionKey, deps: { getQueueSize: () => 0, - nowMs: () => 0, + nowMs: () => Date.now(), }, }); @@ -423,7 +423,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { JSON.stringify({ [baseSessionKey]: { sessionId: "sid", - updatedAt: 1, + updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", @@ -472,7 +472,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { JSON.stringify({ [legacyIsolatedKey]: { sessionId: "sid", - updatedAt: 1, + updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", @@ -488,7 +488,7 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { sessionKey: legacyIsolatedKey, deps: { getQueueSize: () => 0, - nowMs: () => 0, + nowMs: () => Date.now(), }, });