fix(diffs): sweep expired sqlite artifacts

This commit is contained in:
Peter Steinberger
2026-05-15 16:42:19 +01:00
parent 792c2e175d
commit af7bb78ce8
5 changed files with 87 additions and 4 deletions

View File

@@ -2,21 +2,25 @@ import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import path from "node:path";
import { resetPluginBlobStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime";
import type { PluginBlobStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { createMockServerResponse } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDiffsHttpHandler } from "./http.js";
import { DiffArtifactStore } from "./store.js";
import type { DiffBlobMetadata } from "./store.js";
import { createDiffStoreHarness } from "./test-helpers.js";
describe("DiffArtifactStore", () => {
let rootDir: string;
let store: DiffArtifactStore;
let blobStore: PluginBlobStore<DiffBlobMetadata>;
let cleanupRootDir: () => Promise<void>;
beforeEach(async () => {
({
rootDir,
store,
blobStore,
cleanup: cleanupRootDir,
} = await createDiffStoreHarness("openclaw-diffs-store-"));
});
@@ -92,6 +96,26 @@ describe("DiffArtifactStore", () => {
expect(loaded).toBeNull();
});
it("sweeps expired SQLite-only viewer artifacts during cleanup", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
vi.setSystemTime(now);
const artifact = await store.createArtifact({
html: "<html>sqlite</html>",
title: "SQLite",
inputKind: "patch",
fileCount: 1,
ttlMs: 1_000,
});
vi.setSystemTime(new Date(now.getTime() + 2_000));
await store.cleanupExpired();
expect(await blobStore.deleteExpired()).toBe(0);
await expect(blobStore.lookup(`view:${artifact.id}`)).resolves.toBeUndefined();
});
it("updates the stored file path", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",

View File

@@ -188,6 +188,8 @@ export class DiffArtifactStore {
}
async cleanupExpired(): Promise<void> {
await this.blobStore.deleteExpired();
const root = await this.artifactRoot();
const entries = await root.list("", { withFileTypes: true }).catch(() => []);

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import {
createPluginBlobStore,
type PluginBlobStore,
resetPluginBlobStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import { resolvePreferredOpenClawTmpDir } from "../api.js";
@@ -28,22 +29,25 @@ export async function createDiffStoreHarness(
): Promise<{
rootDir: string;
store: DiffArtifactStore;
blobStore: PluginBlobStore<DiffBlobMetadata>;
cleanup: () => Promise<void>;
}> {
const { rootDir, cleanup } = await createTempDiffRoot(prefix);
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = await fs.mkdtemp(path.join(rootDir, "state-"));
resetPluginBlobStoreForTests();
const blobStore = createPluginBlobStore<DiffBlobMetadata>("diffs", {
namespace: "artifacts",
maxEntries: MAX_TEST_DIFF_ARTIFACT_BLOBS,
});
return {
rootDir,
store: new DiffArtifactStore({
rootDir,
cleanupIntervalMs: options.cleanupIntervalMs,
blobStore: createPluginBlobStore<DiffBlobMetadata>("diffs", {
namespace: "artifacts",
maxEntries: MAX_TEST_DIFF_ARTIFACT_BLOBS,
}),
blobStore,
}),
blobStore,
cleanup: async () => {
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;

View File

@@ -30,4 +30,35 @@ describe("plugin blob store", () => {
await expect(store.entries()).resolves.toEqual([]);
});
});
it("deletes expired entries for the current namespace", async () => {
await withOpenClawTestState({ label: "plugin-blob-store-expired" }, async () => {
const store = createPluginBlobStore<{ contentType: string }>("zalo", {
namespace: "media",
maxEntries: 10,
});
const otherNamespace = createPluginBlobStore<{ contentType: string }>("zalo", {
namespace: "other-media",
maxEntries: 10,
});
await store.register("live", { contentType: "image/jpeg" }, Buffer.from("live"));
await store.register("expired", { contentType: "image/png" }, Buffer.from("expired"), {
ttlMs: 1,
});
await otherNamespace.register("expired", { contentType: "image/gif" }, Buffer.from("other"), {
ttlMs: 1,
});
await new Promise((resolve) => setTimeout(resolve, 5));
await expect(store.deleteExpired()).resolves.toBe(1);
await expect(store.entries()).resolves.toMatchObject([
{
key: "live",
metadata: { contentType: "image/jpeg" },
},
]);
await expect(otherNamespace.deleteExpired()).resolves.toBe(1);
});
});
});

View File

@@ -30,6 +30,7 @@ export type PluginBlobStore<TMetadata = Record<string, unknown>> = {
lookup(key: string): Promise<PluginBlobEntry<TMetadata> | undefined>;
consume(key: string): Promise<PluginBlobEntry<TMetadata> | undefined>;
delete(key: string): Promise<boolean>;
deleteExpired(): Promise<number>;
entries(): Promise<PluginBlobEntry<TMetadata>[]>;
clear(): Promise<void>;
};
@@ -39,6 +40,7 @@ export type PluginBlobSyncStore<TMetadata = Record<string, unknown>> = {
lookup(key: string): PluginBlobEntry<TMetadata> | undefined;
consume(key: string): PluginBlobEntry<TMetadata> | undefined;
delete(key: string): boolean;
deleteExpired(): number;
entries(): PluginBlobEntry<TMetadata>[];
clear(): void;
};
@@ -173,6 +175,9 @@ export function createPluginBlobStore<TMetadata = Record<string, unknown>>(
async delete(key) {
return syncStore.delete(key);
},
async deleteExpired() {
return syncStore.deleteExpired();
},
async entries() {
return syncStore.entries();
},
@@ -341,6 +346,23 @@ export function createPluginBlobSyncStore<TMetadata = Record<string, unknown>>(
);
return Number(result.numAffectedRows ?? 0) > 0;
},
deleteExpired() {
const expiredAt = now();
const result = runOpenClawStateWriteTransaction(
(database) =>
executeSqliteQuerySync(
database.db,
getPluginBlobKysely(database.db)
.deleteFrom("plugin_blob_entries")
.where("plugin_id", "=", pluginId)
.where("namespace", "=", namespace)
.where("expires_at", "is not", null)
.where("expires_at", "<=", expiredAt),
),
databaseOptions,
);
return Number(result.numAffectedRows ?? 0);
},
entries() {
const database = openOpenClawStateDatabase(databaseOptions);
const rows = executeSqliteQuerySync(