fix(agents): bound sqlite cache expiry

This commit is contained in:
Peter Steinberger
2026-05-30 14:07:32 -04:00
parent 1ee751ddb1
commit 9ad7f5bbde
2 changed files with 158 additions and 8 deletions

View File

@@ -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({

View File

@@ -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));