mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 23:56:11 +00:00
refactor: consolidate duplicated plugin state and doctor migration plumbing onto SDK seams (#99850)
* refactor(plugin-sdk): add createPersistentDedupeCache and migrate channel presence caches * refactor(matrix): adopt SDK approval reaction target store * refactor(plugin-sdk): share doctor legacy-state migration fs helpers * refactor(memory-core): dedupe qmd cache entry envelope validation * chore(plugin-sdk): pin surface budgets for shared dedupe and doctor helpers * test(matrix): use future approval expiry fixtures for reaction targets * test(matrix): use future approval expiry fixtures for reaction targets
This commit is contained in:
committed by
GitHub
parent
3d404478b8
commit
eafe2a8d0b
116
src/plugin-sdk/dedupe-runtime.test.ts
Normal file
116
src/plugin-sdk/dedupe-runtime.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPersistentDedupeCache } from "./dedupe-runtime.js";
|
||||
|
||||
type Record = { at: number };
|
||||
|
||||
function createMemoryStore() {
|
||||
const entries = new Map<string, Record>();
|
||||
return {
|
||||
entries,
|
||||
store: {
|
||||
register: vi.fn(async (key: string, value: Record) => {
|
||||
entries.set(key, value);
|
||||
}),
|
||||
lookup: vi.fn(async (key: string) => entries.get(key)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCache(params?: {
|
||||
openStore?: () => ReturnType<typeof createMemoryStore>["store"] | undefined;
|
||||
logError?: (error: unknown) => void;
|
||||
readTimestamp?: (record: Record) => number | undefined;
|
||||
}) {
|
||||
const backing = createMemoryStore();
|
||||
const cache = createPersistentDedupeCache<Record>({
|
||||
// Plain Symbol() is unique per cache, so parallel tests never share memory layers.
|
||||
globalKey: Symbol("test.persistent-dedupe"),
|
||||
ttlMs: 60_000,
|
||||
maxSize: 100,
|
||||
persistent: {
|
||||
namespace: "test.persistent-dedupe",
|
||||
maxEntries: 100,
|
||||
openStore: params?.openStore ?? (() => backing.store),
|
||||
logError: params?.logError,
|
||||
readTimestamp: params?.readTimestamp,
|
||||
},
|
||||
});
|
||||
return { cache, backing };
|
||||
}
|
||||
|
||||
describe("createPersistentDedupeCache", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("records presence in both layers and answers from memory first", async () => {
|
||||
const { cache, backing } = createCache();
|
||||
await cache.register("k1", { at: 1 });
|
||||
expect(cache.peek("k1")).toBe(true);
|
||||
expect(await cache.lookup("k1")).toBe(true);
|
||||
expect(backing.store.register).toHaveBeenCalledWith("k1", { at: 1 });
|
||||
expect(backing.store.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to persistence and re-primes memory on a hit", async () => {
|
||||
const { cache, backing } = createCache();
|
||||
backing.entries.set("k2", { at: 42 });
|
||||
expect(cache.peek("k2")).toBe(false);
|
||||
expect(await cache.lookup("k2")).toBe(true);
|
||||
expect(cache.peek("k2")).toBe(true);
|
||||
});
|
||||
|
||||
it("re-primes memory with the persisted timestamp when provided", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000_000);
|
||||
const { cache, backing } = createCache({ readTimestamp: (record) => record.at });
|
||||
backing.entries.set("k3", { at: 1_000_000 - 59_000 });
|
||||
expect(await cache.lookup("k3")).toBe(true);
|
||||
// Re-primed at the original timestamp: expires 59s later instead of a fresh 60s TTL.
|
||||
vi.setSystemTime(1_000_000 + 2_000);
|
||||
expect(cache.peek("k3")).toBe(false);
|
||||
});
|
||||
|
||||
it("disables persistence after an open failure and never rejects", async () => {
|
||||
const logError = vi.fn();
|
||||
const openStore = vi.fn(() => {
|
||||
throw new Error("sqlite unavailable");
|
||||
});
|
||||
const { cache } = createCache({ openStore, logError });
|
||||
await expect(cache.register("k4", { at: 1 })).resolves.toBeUndefined();
|
||||
expect(cache.peek("k4")).toBe(true);
|
||||
expect(await cache.lookup("k5")).toBe(false);
|
||||
expect(openStore).toHaveBeenCalledTimes(1);
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables persistence after a lookup failure", async () => {
|
||||
const logError = vi.fn();
|
||||
const store = {
|
||||
register: vi.fn(async () => {}),
|
||||
lookup: vi.fn(async () => {
|
||||
throw new Error("read failed");
|
||||
}),
|
||||
};
|
||||
const { cache } = createCache({ openStore: () => store, logError });
|
||||
expect(await cache.lookup("k6")).toBe(false);
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
await cache.register("k7", { at: 1 });
|
||||
expect(store.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clearForTest resets memory and re-enables persistence", async () => {
|
||||
const openStore = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("boom");
|
||||
})
|
||||
.mockImplementation(() => createMemoryStore().store);
|
||||
const { cache } = createCache({ openStore });
|
||||
await cache.register("k8", { at: 1 });
|
||||
cache.clearForTest();
|
||||
expect(cache.peek("k8")).toBe(false);
|
||||
await cache.register("k9", { at: 1 });
|
||||
expect(openStore).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,120 @@
|
||||
// In-memory dedupe helpers for plugin runtime hot paths.
|
||||
|
||||
import { resolveGlobalDedupeCache } from "../infra/dedupe.js";
|
||||
import type { OpenKeyedStoreOptions } from "./plugin-state-runtime.js";
|
||||
|
||||
export { createDedupeCache, resolveGlobalDedupeCache } from "../infra/dedupe.js";
|
||||
|
||||
type PersistentDedupeStore<TRecord> = {
|
||||
register(key: string, value: TRecord, opts?: { ttlMs?: number }): Promise<void>;
|
||||
lookup(key: string): Promise<TRecord | undefined>;
|
||||
};
|
||||
|
||||
/** Dual-layer presence cache: process-memory dedupe plus best-effort persistent state. */
|
||||
export type PersistentDedupeCache<TRecord> = {
|
||||
/** Memory-only presence check without refreshing recency. */
|
||||
peek(key: string): boolean;
|
||||
/** Memory-first presence check; falls back to persistence and re-primes memory on a hit. */
|
||||
lookup(key: string): Promise<boolean>;
|
||||
/** Records presence in memory and best-effort persistence. Never rejects. */
|
||||
register(key: string, record: TRecord, opts?: { at?: number }): Promise<void>;
|
||||
/** Clears memory and re-enables a persistent layer disabled by an earlier failure. */
|
||||
clearForTest(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a channel-family presence cache backed by a global in-memory dedupe layer
|
||||
* plus a lazily opened plugin keyed store. Persistence is best effort: the first
|
||||
* open/read/write failure disables the persistent layer for the process so message
|
||||
* handling never breaks on state errors, matching the shipped channel-cache contract.
|
||||
*/
|
||||
export function createPersistentDedupeCache<TRecord>(params: {
|
||||
/** Global symbol key so the memory layer stays shared across bundled chunks. */
|
||||
globalKey: symbol;
|
||||
ttlMs: number;
|
||||
maxSize: number;
|
||||
persistent: {
|
||||
namespace: string;
|
||||
maxEntries: number;
|
||||
/** Usually `() => runtime?.state.openKeyedStore(options)`; undefined skips persistence. */
|
||||
openStore: (options: OpenKeyedStoreOptions) => PersistentDedupeStore<TRecord> | undefined;
|
||||
logError?: (error: unknown) => void;
|
||||
/** Memory re-prime timestamp after a persistent hit; defaults to now. */
|
||||
readTimestamp?: (record: TRecord) => number | undefined;
|
||||
};
|
||||
}): PersistentDedupeCache<TRecord> {
|
||||
const memory = resolveGlobalDedupeCache(params.globalKey, {
|
||||
ttlMs: params.ttlMs,
|
||||
maxSize: params.maxSize,
|
||||
});
|
||||
let persistentStore: PersistentDedupeStore<TRecord> | undefined;
|
||||
let persistentStoreDisabled = false;
|
||||
|
||||
const disablePersistentStore = (error: unknown) => {
|
||||
persistentStoreDisabled = true;
|
||||
persistentStore = undefined;
|
||||
params.persistent.logError?.(error);
|
||||
};
|
||||
|
||||
const getPersistentStore = (): PersistentDedupeStore<TRecord> | undefined => {
|
||||
if (persistentStoreDisabled) {
|
||||
return undefined;
|
||||
}
|
||||
if (persistentStore) {
|
||||
return persistentStore;
|
||||
}
|
||||
try {
|
||||
persistentStore = params.persistent.openStore({
|
||||
namespace: params.persistent.namespace,
|
||||
maxEntries: params.persistent.maxEntries,
|
||||
defaultTtlMs: params.ttlMs,
|
||||
});
|
||||
return persistentStore;
|
||||
} catch (error) {
|
||||
disablePersistentStore(error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
peek: (key) => memory.peek(key),
|
||||
lookup: async (key) => {
|
||||
if (memory.peek(key)) {
|
||||
return true;
|
||||
}
|
||||
const store = getPersistentStore();
|
||||
if (!store) {
|
||||
return false;
|
||||
}
|
||||
let record: TRecord | undefined;
|
||||
try {
|
||||
record = await store.lookup(key);
|
||||
} catch (error) {
|
||||
disablePersistentStore(error);
|
||||
return false;
|
||||
}
|
||||
if (record === undefined) {
|
||||
return false;
|
||||
}
|
||||
memory.check(key, params.persistent.readTimestamp?.(record));
|
||||
return true;
|
||||
},
|
||||
register: async (key, record, opts) => {
|
||||
memory.check(key, opts?.at);
|
||||
const store = getPersistentStore();
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await store.register(key, record);
|
||||
} catch (error) {
|
||||
disablePersistentStore(error);
|
||||
}
|
||||
},
|
||||
clearForTest: () => {
|
||||
memory.clear();
|
||||
persistentStore = undefined;
|
||||
persistentStoreDisabled = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,4 +29,8 @@ export type {
|
||||
PluginDoctorStateMigration,
|
||||
PluginDoctorStateMigrationContext,
|
||||
} from "../plugins/doctor-contract-registry.js";
|
||||
export {
|
||||
archiveLegacyStateSource,
|
||||
legacyStateFileExists,
|
||||
} from "../plugins/doctor-state-migration-fs.js";
|
||||
export type { DoctorSessionRouteStateOwner } from "../plugins/doctor-session-route-state-owner-types.js";
|
||||
|
||||
Reference in New Issue
Block a user