mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(sessions): disk budget counts unremovable *.migrated sidecars and evicts live sessions instead (#107238)
* fix(sessions): exclude migration archives from disk budget Co-authored-by: SL4N <272086617+SL4N@users.noreply.github.com> * refactor(sessions): keep disk budget scan bounded --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: SL4N <272086617+SL4N@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatSessionArchiveTimestamp,
|
||||
isCompactionCheckpointTranscriptFileName,
|
||||
isMigrationArchiveArtifactName,
|
||||
isPrimarySessionTranscriptFileName,
|
||||
isSessionArchiveArtifactName,
|
||||
isSessionStoreTempArtifactName,
|
||||
@@ -22,6 +23,14 @@ describe("session artifact helpers", () => {
|
||||
expect(isSessionArchiveArtifactName("abc.jsonl")).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies migration archive file names", () => {
|
||||
expect(isMigrationArchiveArtifactName("abc.jsonl.migrated")).toBe(true);
|
||||
expect(isMigrationArchiveArtifactName("sessions.json.migrated.2")).toBe(true);
|
||||
expect(isMigrationArchiveArtifactName("abc.jsonl.migrated.tmp")).toBe(false);
|
||||
expect(isMigrationArchiveArtifactName("abc.migrated.jsonl")).toBe(false);
|
||||
expect(isMigrationArchiveArtifactName("abc.jsonl.MIGRATED")).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies orphaned session store atomic-write temp files", () => {
|
||||
const uuid = "0f9c1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b";
|
||||
const store = "sessions.json";
|
||||
|
||||
@@ -9,6 +9,7 @@ export type SessionArchiveReason = "bak" | "reset" | "deleted";
|
||||
|
||||
const ARCHIVE_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d{3})?Z$/;
|
||||
const LEGACY_STORE_BACKUP_RE = /^sessions\.json\.bak\.\d+$/;
|
||||
const MIGRATION_ARCHIVE_RE = /\.migrated(?:\.\d+)?$/u;
|
||||
const COMPACTION_CHECKPOINT_TRANSCRIPT_RE =
|
||||
/^(.+)\.checkpoint\.([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.jsonl$/i;
|
||||
|
||||
@@ -37,6 +38,11 @@ export function isSessionArchiveArtifactName(fileName: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns true for migration rollback archives retained beside their legacy source. */
|
||||
export function isMigrationArchiveArtifactName(fileName: string): boolean {
|
||||
return MIGRATION_ARCHIVE_RE.test(fileName);
|
||||
}
|
||||
|
||||
// Compiled-pattern cache keyed by store basename. A disk sweep calls the matcher
|
||||
// once per file, so compiling the per-store pattern once (basenames are few — one
|
||||
// per agent store) keeps the hot path allocation-free.
|
||||
|
||||
@@ -54,6 +54,43 @@ function refreshPathBeforeSecondStat(targetPath: string): ReturnType<typeof vi.s
|
||||
}
|
||||
|
||||
describe("enforceSessionDiskBudget", () => {
|
||||
it("excludes migration archives from the session disk budget (#106875)", async () => {
|
||||
await withTempDir({ prefix: "openclaw-disk-budget-" }, async (dir) => {
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
const sessionKey = "agent:main:main";
|
||||
const sessionId = "keep";
|
||||
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
|
||||
const migrationArchivePath = path.join(dir, "legacy.jsonl.migrated");
|
||||
const numberedMigrationArchivePath = path.join(dir, "legacy.jsonl.migrated.2");
|
||||
const store: Record<string, SessionEntry> = {
|
||||
[sessionKey]: { sessionId, updatedAt: Date.now() },
|
||||
};
|
||||
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
|
||||
await fs.writeFile(transcriptPath, "t".repeat(64), "utf-8");
|
||||
await fs.writeFile(migrationArchivePath, "m".repeat(400), "utf-8");
|
||||
await fs.writeFile(numberedMigrationArchivePath, "n".repeat(400), "utf-8");
|
||||
|
||||
const result = await enforceSessionDiskBudget({
|
||||
store,
|
||||
storePath,
|
||||
maintenance: {
|
||||
maxDiskBytes: 300,
|
||||
highWaterBytes: 200,
|
||||
},
|
||||
warnOnly: false,
|
||||
});
|
||||
|
||||
expectBudgetResult(result);
|
||||
expect(result.overBudget).toBe(false);
|
||||
expect(result.removedEntries).toBe(0);
|
||||
expect(result.removedFiles).toBe(0);
|
||||
expect(store).toHaveProperty(sessionKey);
|
||||
await expectPathExists(transcriptPath);
|
||||
await expectPathExists(migrationArchivePath);
|
||||
await expectPathExists(numberedMigrationArchivePath);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat referenced transcripts with marker-like session IDs as archived artifacts", async () => {
|
||||
await withTempDir({ prefix: "openclaw-disk-budget-" }, async (dir) => {
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { runTasksWithConcurrency } from "../../utils/run-with-concurrency.js";
|
||||
import {
|
||||
isCompactionCheckpointTranscriptFileName,
|
||||
isMigrationArchiveArtifactName,
|
||||
isPrimarySessionTranscriptFileName,
|
||||
isSessionArchiveArtifactName,
|
||||
isSessionStoreTempArtifactName,
|
||||
@@ -223,11 +224,9 @@ async function readSessionsDirFiles(sessionsDir: string): Promise<SessionsDirFil
|
||||
const dirEntries = await fs.promises
|
||||
.readdir(sessionsDir, { withFileTypes: true })
|
||||
.catch(() => []);
|
||||
// Stat concurrently: the budget sweep stats every session file, and serial
|
||||
// stats turn one sweep into per-file latency round trips on networked
|
||||
// filesystems.
|
||||
// Skip rollback archives before concurrent stats so retained bytes cannot evict live sessions.
|
||||
const tasks = dirEntries
|
||||
.filter((dirent) => dirent.isFile())
|
||||
.filter((dirent) => dirent.isFile() && !isMigrationArchiveArtifactName(dirent.name))
|
||||
.map((dirent) => async (): Promise<SessionsDirFileStat | null> => {
|
||||
const filePath = path.join(sessionsDir, dirent.name);
|
||||
const stat = await fs.promises.stat(filePath).catch(() => null);
|
||||
|
||||
Reference in New Issue
Block a user