test(plugin-state): seed limit fixtures in one transaction

This commit is contained in:
Peter Steinberger
2026-05-02 12:02:05 +01:00
parent c136bb0eaf
commit 29dc18d33d
3 changed files with 113 additions and 24 deletions

View File

@@ -12,6 +12,7 @@ import {
} from "./plugin-state-store.js";
import { resolvePluginStateDir, resolvePluginStateSqlitePath } from "./plugin-state-store.paths.js";
import { MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN } from "./plugin-state-store.sqlite.js";
import { seedPluginStateEntriesForTests } from "./plugin-state-store.test-helpers.js";
afterEach(() => {
vi.useRealTimers();
@@ -198,21 +199,25 @@ describe("limits", () => {
// namespace eviction never fires (each namespace has generous room).
const nsCount = 10;
const perNs = MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN / nsCount; // 100
const stores = Array.from({ length: nsCount }, (_, i) =>
createPluginStateKeyedStore("fixture-plugin", {
namespace: `ns-${i}`,
maxEntries: perNs + 1,
seedPluginStateEntriesForTests(
Array.from({ length: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN }, (_, index) => {
const ns = Math.floor(index / perNs);
const k = index % perNs;
return {
pluginId: "fixture-plugin",
namespace: `ns-${ns}`,
key: `k-${k}`,
value: { ns, k },
};
}),
);
for (let ns = 0; ns < nsCount; ns += 1) {
for (let k = 0; k < perNs; k += 1) {
await stores[ns].register(`k-${k}`, { ns, k });
}
}
const store = createPluginStateKeyedStore("fixture-plugin", {
namespace: "ns-0",
maxEntries: perNs + 1,
});
// One more row tips over the plugin-wide limit.
await expect(stores[0].register("overflow", { boom: true })).rejects.toMatchObject({
await expect(store.register("overflow", { boom: true })).rejects.toMatchObject({
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
});
});

View File

@@ -0,0 +1,67 @@
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { resolvePluginStateSqlitePath } from "./plugin-state-store.paths.js";
import { closePluginStateSqliteStore, probePluginStateStore } from "./plugin-state-store.sqlite.js";
export type PluginStateSeedEntry = {
pluginId: string;
namespace: string;
key: string;
value: unknown;
createdAt?: number;
expiresAt?: number | null;
};
export function seedPluginStateEntriesForTests(entries: PluginStateSeedEntry[]): void {
if (entries.length === 0) {
return;
}
probePluginStateStore();
closePluginStateSqliteStore();
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(resolvePluginStateSqlitePath());
const insertEntry = db.prepare(`
INSERT INTO plugin_state_entries (
plugin_id,
namespace,
entry_key,
value_json,
created_at,
expires_at
) VALUES (
@plugin_id,
@namespace,
@entry_key,
@value_json,
@created_at,
@expires_at
)
`);
const now = Date.now();
db.exec("BEGIN IMMEDIATE");
try {
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
const valueJson = JSON.stringify(entry.value);
if (valueJson == null) {
throw new Error("plugin state seed value must be JSON serializable");
}
insertEntry.run({
plugin_id: entry.pluginId,
namespace: entry.namespace,
entry_key: entry.key,
value_json: valueJson,
created_at: entry.createdAt ?? now + index,
expires_at: entry.expiresAt ?? null,
});
}
db.exec("COMMIT");
} catch (error) {
db.exec("ROLLBACK");
throw error;
} finally {
db.close();
}
}

View File

@@ -12,6 +12,7 @@ import {
sweepExpiredPluginStateEntries,
} from "./plugin-state-store.js";
import { resolvePluginStateDir, resolvePluginStateSqlitePath } from "./plugin-state-store.paths.js";
import { seedPluginStateEntriesForTests } from "./plugin-state-store.test-helpers.js";
afterEach(() => {
vi.useRealTimers();
@@ -126,22 +127,38 @@ describe("plugin state keyed store", () => {
it("rejects when the per-plugin live row ceiling would be exceeded without evicting siblings", async () => {
await withOpenClawTestState({ label: "plugin-state-plugin-limit" }, async () => {
const stores = Array.from({ length: 10 }, (_, index) =>
createPluginStateKeyedStore("discord", {
namespace: `ns-${index}`,
maxEntries: 101,
}),
);
for (let namespaceIndex = 0; namespaceIndex < stores.length; namespaceIndex += 1) {
for (let entryIndex = 0; entryIndex < 100; entryIndex += 1) {
await stores[namespaceIndex].register(`k-${entryIndex}`, { namespaceIndex, entryIndex });
}
}
seedPluginStateEntriesForTests([
...Array.from({ length: 999 }, (_, entryIndex) => ({
pluginId: "discord",
namespace: "limit",
key: `k-${entryIndex}`,
value: { namespaceIndex: 0, entryIndex },
})),
{
pluginId: "discord",
namespace: "sibling",
key: "k-0",
value: { namespaceIndex: 1, entryIndex: 0 },
},
]);
await expect(stores[0].register("overflow", { overflow: true })).rejects.toMatchObject({
const limitStore = createPluginStateKeyedStore("discord", {
namespace: "limit",
maxEntries: 1_001,
});
const siblingStore = createPluginStateKeyedStore("discord", {
namespace: "sibling",
maxEntries: 10,
});
await expect(limitStore.register("overflow", { overflow: true })).rejects.toMatchObject({
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
});
await expect(stores[1].lookup("k-0")).resolves.toEqual({ namespaceIndex: 1, entryIndex: 0 });
await expect(siblingStore.lookup("k-0")).resolves.toEqual({
namespaceIndex: 1,
entryIndex: 0,
});
await expect(limitStore.lookup("overflow")).resolves.toBeUndefined();
});
});