





























1+// Storage-neutral session maintenance operations for the file-backed session store.
2+import path from "node:path";
3+import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js";
4+import { collectSessionMaintenancePreserveKeys } from "./store-maintenance-preserve.js";
5+import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js";
6+import {
7+capEntryCount,
8+getActiveSessionMaintenanceWarning,
9+pruneQuotaSuspensions,
10+pruneStaleEntries,
11+shouldRunSessionEntryMaintenance,
12+type QuotaSuspensionMaintenanceResult,
13+type ResolvedSessionMaintenanceConfig,
14+type SessionMaintenanceWarning,
15+} from "./store-maintenance.js";
16+import type { SessionEntry } from "./types.js";
17+18+export type SessionMaintenanceApplyReport = {
19+mode: ResolvedSessionMaintenanceConfig["mode"];
20+beforeCount: number;
21+afterCount: number;
22+pruned: number;
23+capped: number;
24+diskBudget: SessionDiskBudgetSweepResult | null;
25+};
26+27+type SessionMaintenanceLogger = {
28+warn: (message: string, context?: Record<string, unknown>) => void;
29+info: (message: string, context?: Record<string, unknown>) => void;
30+};
31+32+type RemovedSessionFiles = Map<string, string | undefined>;
33+34+type RemovedSessionArtifactCleanup = {
35+archiveRemovedSessionTranscripts: (params: {
36+removedSessionFiles: Iterable<[string, string | undefined]>;
37+referencedSessionIds: ReadonlySet<string>;
38+storePath: string;
39+reason: "deleted";
40+restrictToStoreDir: true;
41+}) => Promise<Set<string>>;
42+removeRemovedSessionTrajectoryArtifacts: (params: {
43+removedSessionFiles: RemovedSessionFiles;
44+referencedSessionIds: ReadonlySet<string>;
45+storePath: string;
46+restrictToStoreDir: true;
47+}) => Promise<void>;
48+cleanupArchivedSessionTranscripts: (params: {
49+directories: string[];
50+rules: Array<{ reason: "deleted" | "reset"; olderThanMs: number }>;
51+}) => Promise<void>;
52+};
53+54+export type FileBackedSessionStoreMaintenanceParams = {
55+storePath: string;
56+store: Record<string, SessionEntry>;
57+activeSessionKey?: string;
58+onWarn?: (warning: SessionMaintenanceWarning) => void | Promise<void>;
59+onMaintenanceApplied?: (report: SessionMaintenanceApplyReport) => void | Promise<void>;
60+maintenanceOverride?: Partial<ResolvedSessionMaintenanceConfig>;
61+maintenanceConfig?: ResolvedSessionMaintenanceConfig;
62+log: SessionMaintenanceLogger;
63+artifacts: RemovedSessionArtifactCleanup;
64+};
65+66+export type FileBackedSessionStoreMaintenanceResult = {
67+changedStore: boolean;
68+};
69+70+function resolveMaintenanceForOperation(
71+params: Pick<
72+FileBackedSessionStoreMaintenanceParams,
73+"maintenanceConfig" | "maintenanceOverride"
74+>,
75+): ResolvedSessionMaintenanceConfig {
76+return params.maintenanceConfig
77+ ? { ...params.maintenanceConfig, ...params.maintenanceOverride }
78+ : { ...resolveMaintenanceConfig(), ...params.maintenanceOverride };
79+}
80+81+function collectReferencedSessionIds(store: Record<string, SessionEntry>): Set<string> {
82+return new Set(
83+Object.values(store)
84+.map((entry) => entry?.sessionId)
85+.filter((id): id is string => Boolean(id)),
86+);
87+}
88+89+function rememberRemovedSessionFile(
90+removedSessionFiles: RemovedSessionFiles,
91+entry: SessionEntry,
92+): void {
93+if (!removedSessionFiles.has(entry.sessionId) || entry.sessionFile) {
94+removedSessionFiles.set(entry.sessionId, entry.sessionFile);
95+}
96+}
97+98+async function applyWarnOnlyMaintenance(params: {
99+operation: FileBackedSessionStoreMaintenanceParams;
100+maintenance: ResolvedSessionMaintenanceConfig;
101+beforeCount: number;
102+shouldRunEntryMaintenance: boolean;
103+}): Promise<void> {
104+const activeSessionKey = params.operation.activeSessionKey?.trim();
105+if (activeSessionKey && params.shouldRunEntryMaintenance) {
106+const warning = getActiveSessionMaintenanceWarning({
107+store: params.operation.store,
108+ activeSessionKey,
109+pruneAfterMs: params.maintenance.pruneAfterMs,
110+maxEntries: params.maintenance.maxEntries,
111+});
112+if (warning) {
113+params.operation.log.warn(
114+"session maintenance would evict active session; skipping enforcement",
115+{
116+activeSessionKey: warning.activeSessionKey,
117+wouldPrune: warning.wouldPrune,
118+wouldCap: warning.wouldCap,
119+pruneAfterMs: warning.pruneAfterMs,
120+maxEntries: warning.maxEntries,
121+},
122+);
123+await params.operation.onWarn?.(warning);
124+}
125+}
126+const diskBudget = await enforceSessionDiskBudget({
127+store: params.operation.store,
128+storePath: params.operation.storePath,
129+activeSessionKey: params.operation.activeSessionKey,
130+maintenance: params.maintenance,
131+warnOnly: true,
132+log: params.operation.log,
133+});
134+await params.operation.onMaintenanceApplied?.({
135+mode: params.maintenance.mode,
136+beforeCount: params.beforeCount,
137+afterCount: Object.keys(params.operation.store).length,
138+pruned: 0,
139+capped: 0,
140+ diskBudget,
141+});
142+}
143+144+async function cleanupRemovedSessionArtifacts(params: {
145+operation: FileBackedSessionStoreMaintenanceParams;
146+maintenance: ResolvedSessionMaintenanceConfig;
147+removedSessionFiles: RemovedSessionFiles;
148+referencedSessionIds: ReadonlySet<string>;
149+}): Promise<void> {
150+// SQLite should commit entry-retention rows before this named artifact cleanup.
151+// The cleanup needs the final referenced-session set so shared transcripts and
152+// trajectory sidecars survive until the last referring row is gone.
153+const archivedDirs = await params.operation.artifacts.archiveRemovedSessionTranscripts({
154+removedSessionFiles: params.removedSessionFiles,
155+referencedSessionIds: params.referencedSessionIds,
156+storePath: params.operation.storePath,
157+reason: "deleted",
158+restrictToStoreDir: true,
159+});
160+if (params.removedSessionFiles.size > 0) {
161+await params.operation.artifacts.removeRemovedSessionTrajectoryArtifacts({
162+removedSessionFiles: params.removedSessionFiles,
163+referencedSessionIds: params.referencedSessionIds,
164+storePath: params.operation.storePath,
165+restrictToStoreDir: true,
166+});
167+}
168+if (archivedDirs.size === 0 && params.maintenance.resetArchiveRetentionMs == null) {
169+return;
170+}
171+const targetDirs =
172+archivedDirs.size > 0
173+ ? [...archivedDirs]
174+ : [path.dirname(path.resolve(params.operation.storePath))];
175+// Both retention reasons ride one cleanup call so each save enumerates the
176+// sessions dir at most once; reset retention defaults on, so a listing per
177+// reason would scan twice per save.
178+await params.operation.artifacts.cleanupArchivedSessionTranscripts({
179+directories: targetDirs,
180+rules:
181+params.maintenance.resetArchiveRetentionMs != null
182+ ? [
183+{ reason: "deleted", olderThanMs: params.maintenance.pruneAfterMs },
184+{ reason: "reset", olderThanMs: params.maintenance.resetArchiveRetentionMs },
185+]
186+ : [{ reason: "deleted", olderThanMs: params.maintenance.pruneAfterMs }],
187+});
188+}
189+190+async function applyEnforcedMaintenance(params: {
191+operation: FileBackedSessionStoreMaintenanceParams;
192+maintenance: ResolvedSessionMaintenanceConfig;
193+beforeCount: number;
194+forceMaintenance: boolean;
195+}): Promise<FileBackedSessionStoreMaintenanceResult> {
196+const preserveSessionKeys = collectSessionMaintenancePreserveKeys([
197+params.operation.activeSessionKey,
198+]);
199+const removedSessionFiles = new Map<string, string | undefined>();
200+const pruned = pruneStaleEntries(params.operation.store, params.maintenance.pruneAfterMs, {
201+onPruned: ({ entry }) => {
202+rememberRemovedSessionFile(removedSessionFiles, entry);
203+},
204+preserveKeys: preserveSessionKeys,
205+});
206+const countAfterPrune = Object.keys(params.operation.store).length;
207+const shouldRunCapMaintenance =
208+params.forceMaintenance ||
209+shouldRunSessionEntryMaintenance({
210+entryCount: countAfterPrune,
211+maxEntries: params.maintenance.maxEntries,
212+});
213+const capped = shouldRunCapMaintenance
214+ ? capEntryCount(params.operation.store, params.maintenance.maxEntries, {
215+onCapped: ({ entry }) => {
216+rememberRemovedSessionFile(removedSessionFiles, entry);
217+},
218+preserveKeys: preserveSessionKeys,
219+})
220+ : 0;
221+const referencedSessionIds = collectReferencedSessionIds(params.operation.store);
222+await cleanupRemovedSessionArtifacts({
223+operation: params.operation,
224+maintenance: params.maintenance,
225+ removedSessionFiles,
226+ referencedSessionIds,
227+});
228+229+// Disk-budget eviction is its own transaction-sized boundary: it may delete
230+// additional rows plus owned artifacts after prune/cap has settled, while
231+// preserving the active session and protected runtime-provided keys.
232+const diskBudget = await enforceSessionDiskBudget({
233+store: params.operation.store,
234+storePath: params.operation.storePath,
235+activeSessionKey: params.operation.activeSessionKey,
236+preserveKeys: preserveSessionKeys,
237+maintenance: params.maintenance,
238+warnOnly: false,
239+log: params.operation.log,
240+});
241+await params.operation.onMaintenanceApplied?.({
242+mode: params.maintenance.mode,
243+beforeCount: params.beforeCount,
244+afterCount: Object.keys(params.operation.store).length,
245+ pruned,
246+ capped,
247+ diskBudget,
248+});
249+return {
250+changedStore: pruned > 0 || capped > 0 || (diskBudget?.removedEntries ?? 0) > 0,
251+};
252+}
253+254+/**
255+ * Applies automatic session-store maintenance to the in-memory file-store image.
256+ *
257+ * Future SQLite adapters should map this into named boundaries: entry retention,
258+ * removed-session artifact cleanup, disk-budget eviction, and archive retention cleanup.
259+ */
260+export async function applyFileBackedSessionStoreMaintenance(
261+params: FileBackedSessionStoreMaintenanceParams,
262+): Promise<FileBackedSessionStoreMaintenanceResult> {
263+const maintenance = resolveMaintenanceForOperation(params);
264+const beforeCount = Object.keys(params.store).length;
265+const forceMaintenance = params.maintenanceOverride !== undefined;
266+const shouldRunEntryMaintenance = shouldRunSessionEntryMaintenance({
267+entryCount: beforeCount,
268+maxEntries: maintenance.maxEntries,
269+force: forceMaintenance,
270+});
271+272+if (maintenance.mode === "warn") {
273+await applyWarnOnlyMaintenance({
274+operation: params,
275+ maintenance,
276+ beforeCount,
277+ shouldRunEntryMaintenance,
278+});
279+return { changedStore: false };
280+}
281+282+return await applyEnforcedMaintenance({
283+operation: params,
284+ maintenance,
285+ beforeCount,
286+ forceMaintenance,
287+});
288+}
289+290+/**
291+ * Applies quota-suspension TTL maintenance to a store image.
292+ *
293+ * SQLite should implement this as a row transaction that returns the resumed
294+ * lane records and clear count before callers resume in-process quota lanes.
295+ */
296+export function applyQuotaSuspensionTtlMaintenance(params: {
297+store: Record<string, SessionEntry>;
298+now: number;
299+ttlMs?: number;
300+log?: boolean;
301+}): QuotaSuspensionMaintenanceResult {
302+return pruneQuotaSuspensions(params);
303+}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。