mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-04 02:04:05 +00:00
fix(agents): bound sqlite cache expiry
This commit is contained in:
120
src/agents/cache/agent-cache-store.sqlite.test.ts
vendored
120
src/agents/cache/agent-cache-store.sqlite.test.ts
vendored
@@ -2,9 +2,11 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { MAX_DATE_TIMESTAMP_MS } from "../../shared/number-coercion.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
listOpenClawRegisteredAgentDatabases,
|
||||
openOpenClawAgentDatabase,
|
||||
} from "../../state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
|
||||
import {
|
||||
@@ -147,6 +149,124 @@ describe("SQLite agent cache store", () => {
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects cache expiries outside the valid Date range", () => {
|
||||
const env = { OPENCLAW_STATE_DIR: createTempStateDir() };
|
||||
|
||||
expect(() =>
|
||||
writeSqliteAgentCacheEntry({
|
||||
env,
|
||||
agentId: "main",
|
||||
scope: "runtime",
|
||||
key: "explicit-overflow",
|
||||
value: "bad",
|
||||
expiresAt: Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
).toThrow("SQLite agent cache expiresAt must be a valid Date timestamp.");
|
||||
expect(() =>
|
||||
writeSqliteAgentCacheEntry({
|
||||
env,
|
||||
agentId: "main",
|
||||
scope: "runtime",
|
||||
key: "ttl-overflow",
|
||||
value: "bad",
|
||||
ttlMs: 1000,
|
||||
now: () => MAX_DATE_TIMESTAMP_MS,
|
||||
}),
|
||||
).toThrow("SQLite agent cache ttlMs must resolve to a valid Date timestamp.");
|
||||
});
|
||||
|
||||
it("preserves explicit null cache expiry as non-expiring", () => {
|
||||
const env = { OPENCLAW_STATE_DIR: createTempStateDir() };
|
||||
|
||||
expect(
|
||||
writeSqliteAgentCacheEntry({
|
||||
env,
|
||||
agentId: "main",
|
||||
scope: "runtime",
|
||||
key: "no-expiry",
|
||||
value: "ok",
|
||||
expiresAt: null,
|
||||
now: () => 1000,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
key: "no-expiry",
|
||||
value: "ok",
|
||||
expiresAt: null,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
readSqliteAgentCacheEntry({
|
||||
env,
|
||||
agentId: "main",
|
||||
scope: "runtime",
|
||||
key: "no-expiry",
|
||||
now: () => MAX_DATE_TIMESTAMP_MS,
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ key: "no-expiry", expiresAt: null }));
|
||||
});
|
||||
|
||||
it("hides invalid persisted expiries and ignores invalid clear clocks", () => {
|
||||
const env = { OPENCLAW_STATE_DIR: createTempStateDir() };
|
||||
|
||||
writeSqliteAgentCacheEntry({
|
||||
env,
|
||||
agentId: "main",
|
||||
scope: "runtime",
|
||||
key: "valid",
|
||||
value: "ok",
|
||||
ttlMs: 1000,
|
||||
now: () => 1000,
|
||||
});
|
||||
writeSqliteAgentCacheEntry({
|
||||
env,
|
||||
agentId: "main",
|
||||
scope: "runtime",
|
||||
key: "invalid",
|
||||
value: "bad",
|
||||
now: () => 1000,
|
||||
});
|
||||
const database = openOpenClawAgentDatabase({ agentId: "main", env });
|
||||
database.db
|
||||
.prepare("update cache_entries set expires_at = ? where scope = ? and key = ?")
|
||||
.run(Number.MAX_SAFE_INTEGER, "runtime", "invalid");
|
||||
|
||||
expect(
|
||||
readSqliteAgentCacheEntry({
|
||||
env,
|
||||
agentId: "main",
|
||||
scope: "runtime",
|
||||
key: "invalid",
|
||||
now: () => 1500,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
listSqliteAgentCacheEntries({
|
||||
env,
|
||||
agentId: "main",
|
||||
scope: "runtime",
|
||||
now: () => 1500,
|
||||
}).map((entry) => entry.key),
|
||||
).toEqual(["valid"]);
|
||||
expect(
|
||||
clearExpiredSqliteAgentCacheEntries({
|
||||
env,
|
||||
agentId: "main",
|
||||
scope: "runtime",
|
||||
currentTime: Number.NaN,
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
clearExpiredSqliteAgentCacheEntries({
|
||||
env,
|
||||
agentId: "main",
|
||||
scope: "runtime",
|
||||
currentTime: 1500,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("exposes a scoped runtime cache adapter", () => {
|
||||
const env = { OPENCLAW_STATE_DIR: createTempStateDir() };
|
||||
const cache = createSqliteAgentCacheStore({
|
||||
|
||||
46
src/agents/cache/agent-cache-store.sqlite.ts
vendored
46
src/agents/cache/agent-cache-store.sqlite.ts
vendored
@@ -5,6 +5,13 @@ import {
|
||||
getNodeSqliteKysely,
|
||||
} from "../../infra/kysely-sync.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import {
|
||||
MAX_DATE_TIMESTAMP_MS,
|
||||
asDateTimestampMs,
|
||||
isFutureDateTimestampMs,
|
||||
resolveDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "../../shared/number-coercion.js";
|
||||
import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js";
|
||||
import {
|
||||
openOpenClawAgentDatabase,
|
||||
@@ -89,7 +96,7 @@ function parseValue(raw: string | null): unknown {
|
||||
|
||||
function isExpired(row: AgentCacheRow, now: number): boolean {
|
||||
const expiresAt = asNumber(row.expires_at);
|
||||
return expiresAt !== null && expiresAt <= now;
|
||||
return expiresAt !== null && !isFutureDateTimestampMs(expiresAt, { nowMs: now });
|
||||
}
|
||||
|
||||
function rowToCacheValue(
|
||||
@@ -112,9 +119,23 @@ function resolveExpiresAt(options: AgentRuntimeCacheWriteOptions, now: number):
|
||||
if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
|
||||
throw new Error("SQLite agent cache ttlMs must be a positive finite number.");
|
||||
}
|
||||
return now + options.ttlMs;
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(options.ttlMs, { nowMs: now });
|
||||
if (expiresAt === undefined) {
|
||||
throw new Error("SQLite agent cache ttlMs must resolve to a valid Date timestamp.");
|
||||
}
|
||||
return expiresAt;
|
||||
}
|
||||
return options.expiresAt ?? null;
|
||||
if (options.expiresAt !== undefined) {
|
||||
if (options.expiresAt === null) {
|
||||
return null;
|
||||
}
|
||||
const expiresAt = asDateTimestampMs(options.expiresAt);
|
||||
if (expiresAt === undefined) {
|
||||
throw new Error("SQLite agent cache expiresAt must be a valid Date timestamp.");
|
||||
}
|
||||
return expiresAt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeSqliteAgentCacheEntry(
|
||||
@@ -122,7 +143,7 @@ export function writeSqliteAgentCacheEntry(
|
||||
): AgentRuntimeCacheValue {
|
||||
const scope = normalizeScope(options);
|
||||
const key = normalizeKey(options.key);
|
||||
const updatedAt = options.now?.() ?? Date.now();
|
||||
const updatedAt = resolveDateTimestampMs(options.now?.());
|
||||
const expiresAt = resolveExpiresAt(options, updatedAt);
|
||||
const valueJson = options.value === undefined ? null : JSON.stringify(options.value);
|
||||
const blob =
|
||||
@@ -182,7 +203,7 @@ export function readSqliteAgentCacheEntry(
|
||||
.where("scope", "=", scope.scope)
|
||||
.where("key", "=", key),
|
||||
) ?? null;
|
||||
if (!row || isExpired(row, options.now?.() ?? Date.now())) {
|
||||
if (!row || isExpired(row, resolveDateTimestampMs(options.now?.()))) {
|
||||
return null;
|
||||
}
|
||||
return rowToCacheValue(row, scope);
|
||||
@@ -192,7 +213,7 @@ export function listSqliteAgentCacheEntries(
|
||||
options: SqliteAgentCacheStoreOptions,
|
||||
): AgentRuntimeCacheValue[] {
|
||||
const scope = normalizeScope(options);
|
||||
const now = options.now?.() ?? Date.now();
|
||||
const now = resolveDateTimestampMs(options.now?.());
|
||||
const database = openOpenClawAgentDatabase(toDatabaseOptions(options));
|
||||
const db = getNodeSqliteKysely<AgentCacheDatabase>(database.db);
|
||||
return executeSqliteQuerySync(
|
||||
@@ -238,7 +259,10 @@ export function clearExpiredSqliteAgentCacheEntries(
|
||||
options: SqliteAgentCacheStoreOptions & { currentTime?: number },
|
||||
): number {
|
||||
const scope = normalizeScope(options);
|
||||
const currentTime = options.currentTime ?? options.now?.() ?? Date.now();
|
||||
const currentTime = asDateTimestampMs(options.currentTime ?? options.now?.() ?? Date.now());
|
||||
if (currentTime === undefined) {
|
||||
return 0;
|
||||
}
|
||||
return runOpenClawAgentWriteTransaction((database) => {
|
||||
const db = getNodeSqliteKysely<AgentCacheDatabase>(database.db);
|
||||
const result = executeSqliteQuerySync(
|
||||
@@ -247,7 +271,13 @@ export function clearExpiredSqliteAgentCacheEntries(
|
||||
.deleteFrom("cache_entries")
|
||||
.where("scope", "=", scope.scope)
|
||||
.where("expires_at", "is not", null)
|
||||
.where("expires_at", "<=", currentTime),
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("expires_at", "<=", currentTime),
|
||||
eb("expires_at", ">", MAX_DATE_TIMESTAMP_MS),
|
||||
eb("expires_at", "<", -MAX_DATE_TIMESTAMP_MS),
|
||||
]),
|
||||
),
|
||||
);
|
||||
return Number(result.numAffectedRows ?? 0);
|
||||
}, toDatabaseOptions(options));
|
||||
|
||||
Reference in New Issue
Block a user