fix: preserve plugin state limits

This commit is contained in:
Peter Steinberger
2026-05-16 07:25:33 +01:00
parent 46aa7484e2
commit cbef25bae3
4 changed files with 55 additions and 7 deletions

View File

@@ -159,6 +159,19 @@ describe("DiffArtifactStore", () => {
});
});
it("keeps standalone artifact dirs when cleanup overlaps metadata registration", async () => {
const register = blobStore.register.bind(blobStore);
vi.spyOn(blobStore, "register").mockImplementationOnce(async (key, metadata, blob, opts) => {
await store.cleanupExpired();
await register(key, metadata, blob, opts);
});
const standalone = await store.createStandaloneFileArtifact();
const directory = await fs.stat(path.dirname(standalone.filePath));
expect(directory.isDirectory()).toBe(true);
});
it("expires standalone file artifacts using ttl metadata", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");

View File

@@ -168,8 +168,8 @@ export class DiffArtifactStore {
...(params.context ? { context: params.context } : {}),
};
await (await this.artifactRoot()).mkdir(id);
await this.writeStandaloneMeta(meta);
await (await this.artifactRoot()).mkdir(id);
this.scheduleCleanup();
return {
id,

View File

@@ -25,6 +25,7 @@ import {
} from "./plugin-state-store.types.js";
export const MAX_PLUGIN_STATE_VALUE_BYTES = 65_536;
export const MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN = 1_000;
type PluginStateEntriesTable = OpenClawStateKyselyDatabase["plugin_state_entries"];
type PluginStateStoreDatabase = Pick<OpenClawStateKyselyDatabase, "plugin_state_entries">;
@@ -247,6 +248,21 @@ function countLivePluginStateNamespaceEntries(
return countRow(row);
}
function countLivePluginStateEntries(
db: DatabaseSync,
params: { pluginId: string; now: number },
): number {
const row = executeSqliteQueryTakeFirstSync(
db,
getPluginStateKysely(db)
.selectFrom("plugin_state_entries")
.select((eb) => eb.fn.countAll<number | bigint>().as("count"))
.where("plugin_id", "=", params.pluginId)
.where((eb) => eb.or([eb("expires_at", "is", null), eb("expires_at", ">", params.now)])),
);
return countRow(row);
}
function deleteOldestPluginStateNamespaceEntries(
db: DatabaseSync,
params: { pluginId: string; namespace: string; protectedKey: string; now: number; limit: number },
@@ -358,6 +374,19 @@ function enforcePostRegisterLimits(params: {
limit: namespaceCount - params.maxEntries,
});
}
const pluginCount = countLivePluginStateEntries(params.store.db, {
pluginId: params.pluginId,
now: params.now,
});
if (pluginCount > MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN) {
throw createPluginStateError({
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
operation: "register",
message: `Plugin state for ${params.pluginId} exceeds the ${MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN} live row limit.`,
path: params.store.path,
});
}
}
export function pluginStateRegister(params: {

View File

@@ -228,7 +228,7 @@ describe("plugin state keyed store", () => {
});
});
it("registerIfAbsent preserves namespace eviction without capping sibling namespaces", async () => {
it("registerIfAbsent preserves sibling namespaces when plugin-wide limit rejects overflow", async () => {
await withPluginStateTestState(async () => {
vi.useFakeTimers();
const evicting = createPluginStateKeyedStore<number>("discord", {
@@ -265,8 +265,11 @@ describe("plugin state keyed store", () => {
namespace: "sibling",
maxEntries: 10,
});
await expect(limited.registerIfAbsent("overflow", { overflow: true })).resolves.toBe(true);
await expect(limited.lookup("overflow")).resolves.toEqual({ overflow: true });
await expectPluginStateStoreError(limited.registerIfAbsent("overflow", { overflow: true }), {
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
operation: "register",
});
await expect(limited.lookup("overflow")).resolves.toBeUndefined();
await expect(sibling.lookup("k-0")).resolves.toEqual({ sibling: true });
});
});
@@ -372,7 +375,7 @@ describe("plugin state keyed store", () => {
});
});
it("applies entry limits per namespace without evicting siblings", async () => {
it("rejects plugin-wide overflow without evicting sibling namespaces", async () => {
await withPluginStateTestState(async () => {
seedPluginStateEntriesForTests([
...Array.from({ length: 5_989 }, (_, entryIndex) => ({
@@ -398,12 +401,15 @@ describe("plugin state keyed store", () => {
maxEntries: 100,
});
await expect(limitStore.register("overflow", { overflow: true })).resolves.toBeUndefined();
await expectPluginStateStoreError(limitStore.register("overflow", { overflow: true }), {
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
operation: "register",
});
await expect(siblingStore.lookup("k-0")).resolves.toEqual({
namespaceIndex: 1,
entryIndex: 0,
});
await expect(limitStore.lookup("overflow")).resolves.toEqual({ overflow: true });
await expect(limitStore.lookup("overflow")).resolves.toBeUndefined();
});
});