fix(state): preserve doctor import TTL

This commit is contained in:
Benjamin Badejo
2026-07-23 03:42:28 +03:00
committed by Josh Avant
parent d25e89d1ee
commit 505d7b9f52
5 changed files with 53 additions and 5 deletions

View File

@@ -1006,4 +1006,45 @@ describe("matrix doctor contract state migrations", () => {
].toSorted(),
);
});
it("preserves a legacy inbound dedupe marker's remaining TTL", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-doctor-"));
tempDirs.push(stateDir);
const env = { OPENCLAW_STATE_DIR: stateDir };
const io = { context: createContext(), env };
const now = 2_000_000_000_000;
const remainingTtlMs = 1_000;
const roomId = "!room:example.org";
const eventId = "$near-expiry";
const key = `ops\0${roomId}\0${eventId}`;
const markerTs = now - MATRIX_INBOUND_DEDUPE_TTL_MS + remainingTtlMs;
const storedEntry = createPersistentDedupeImportEntry({ key, seenAt: markerTs });
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
await expect(
importNewestInboundDedupeMarkers({
io,
now,
markers: [
{
accountId: "ops",
roomId,
eventId,
ts: markerTs,
},
],
}),
).resolves.toEqual({ imported: 1, total: 1 });
const store = createPluginStateKeyedStoreForTests<PersistentDedupeEntry>("matrix", {
namespace: resolveMatrixInboundDedupeStateNamespace(),
maxEntries: 20_000,
defaultTtlMs: MATRIX_INBOUND_DEDUPE_TTL_MS,
env,
});
nowSpy.mockReturnValue(now + remainingTtlMs - 1);
await expect(store.lookup(storedEntry.key)).resolves.toEqual(storedEntry.value);
nowSpy.mockReturnValue(now + remainingTtlMs + 1);
await expect(store.lookup(storedEntry.key)).resolves.toBeUndefined();
});
});

View File

@@ -449,6 +449,7 @@ export async function importNewestInboundDedupeMarkers(params: {
key: entry.key,
value: entry.value,
createdAt: marker.ts,
...(entry.ttlMs != null ? { ttlMs: entry.ttlMs } : {}),
})),
);
const importedKeys = new Set((await store.entries()).map((entry) => entry.key));

View File

@@ -285,7 +285,7 @@ function createPluginDoctorStateMigrationContext(
},
importPluginStateEntries(
options: OpenKeyedStoreOptions,
entries: readonly { key: string; value: unknown; createdAt: number }[],
entries: readonly { key: string; value: unknown; createdAt: number; ttlMs?: number }[],
) {
importPluginStateEntriesForDoctor(pluginId, { ...options, env: options.env ?? env }, entries);
},

View File

@@ -69,6 +69,7 @@ type PluginStateImportEntry = {
key: string;
value: unknown;
createdAt: number;
ttlMs?: number;
};
const namespaceOptionSignatures = new Map<string, StoreOptionSignature>();
@@ -555,7 +556,7 @@ export function registerPluginStateSyncSequencedJournalEntry(params: {
});
}
/** Doctor-only import that preserves source age for retention ordering. */
/** Doctor-only import that preserves source age and remaining retention. */
export function importPluginStateEntriesForDoctor(
pluginId: string,
options: OpenKeyedStoreOptions,
@@ -575,7 +576,12 @@ export function importPluginStateEntriesForDoctor(
if (!Number.isSafeInteger(entry.createdAt)) {
throw invalidInput("plugin state import createdAt must be a safe integer", "register");
}
const prepared = prepareRegisterParams(entry.key, entry.value, defaultTtlMs);
const prepared = prepareRegisterParams(
entry.key,
entry.value,
defaultTtlMs,
entry.ttlMs != null ? { ttlMs: entry.ttlMs } : undefined,
);
pluginStateRegister({
pluginId,
namespace,

View File

@@ -59,10 +59,10 @@ export type PluginDoctorStateMigrationDetection = {
export type PluginDoctorStateMigrationContext = {
openPluginStateKeyedStore: <T>(options: OpenKeyedStoreOptions) => PluginStateKeyedStore<T>;
/** Doctor-only batch import preserving source age for retention ordering. */
/** Doctor-only batch import preserving source age and remaining retention. */
importPluginStateEntries?: (
options: OpenKeyedStoreOptions,
entries: readonly { key: string; value: unknown; createdAt: number }[],
entries: readonly { key: string; value: unknown; createdAt: number; ttlMs?: number }[],
) => void;
/** Plugin-wide live-row capacity for import preflight. Older test hosts may omit it. */
getPluginStateCapacity?: () => { liveEntries: number; maxEntries: number };