Files
openclaw/src/config/cache-utils.ts
2026-03-22 22:10:48 +00:00

160 lines
3.9 KiB
TypeScript

import fs from "node:fs";
import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js";
export function resolveCacheTtlMs(params: {
envValue: string | undefined;
defaultTtlMs: number;
}): number {
const { envValue, defaultTtlMs } = params;
if (envValue) {
const parsed = parseStrictNonNegativeInteger(envValue);
if (parsed !== undefined) {
return parsed;
}
}
return defaultTtlMs;
}
export function isCacheEnabled(ttlMs: number): boolean {
return ttlMs > 0;
}
type CacheTtlResolver = number | (() => number);
type CachePruneIntervalResolver = number | ((ttlMs: number) => number);
type ExpiringMapCacheEntry<TValue> = {
storedAt: number;
value: TValue;
};
export type ExpiringMapCache<TKey, TValue> = {
get: (key: TKey) => TValue | undefined;
set: (key: TKey, value: TValue) => void;
delete: (key: TKey) => void;
clear: () => void;
keys: () => TKey[];
size: () => number;
pruneExpired: () => void;
};
function resolveCacheNumeric(value: CacheTtlResolver): number {
return typeof value === "function" ? value() : value;
}
function resolvePruneIntervalMs(
ttlMs: number,
pruneIntervalMs: CachePruneIntervalResolver | undefined,
): number {
if (typeof pruneIntervalMs === "function") {
return Math.max(0, Math.floor(pruneIntervalMs(ttlMs)));
}
if (typeof pruneIntervalMs === "number") {
return Math.max(0, Math.floor(pruneIntervalMs));
}
return ttlMs;
}
function isCacheEntryExpired(storedAt: number, now: number, ttlMs: number): boolean {
return now - storedAt > ttlMs;
}
export function createExpiringMapCache<TKey, TValue>(options: {
ttlMs: CacheTtlResolver;
pruneIntervalMs?: CachePruneIntervalResolver;
clock?: () => number;
}): ExpiringMapCache<TKey, TValue> {
const cache = new Map<TKey, ExpiringMapCacheEntry<TValue>>();
const now = options.clock ?? Date.now;
let lastPruneAt = 0;
function getTtlMs(): number {
return Math.max(0, Math.floor(resolveCacheNumeric(options.ttlMs)));
}
function maybePruneExpiredEntries(nowMs: number, ttlMs: number): void {
if (!isCacheEnabled(ttlMs)) {
return;
}
if (nowMs - lastPruneAt < resolvePruneIntervalMs(ttlMs, options.pruneIntervalMs)) {
return;
}
for (const [key, entry] of cache.entries()) {
if (isCacheEntryExpired(entry.storedAt, nowMs, ttlMs)) {
cache.delete(key);
}
}
lastPruneAt = nowMs;
}
return {
get: (key) => {
const ttlMs = getTtlMs();
if (!isCacheEnabled(ttlMs)) {
return undefined;
}
const nowMs = now();
maybePruneExpiredEntries(nowMs, ttlMs);
const entry = cache.get(key);
if (!entry) {
return undefined;
}
if (isCacheEntryExpired(entry.storedAt, nowMs, ttlMs)) {
cache.delete(key);
return undefined;
}
return entry.value;
},
set: (key, value) => {
const ttlMs = getTtlMs();
if (!isCacheEnabled(ttlMs)) {
return;
}
const nowMs = now();
maybePruneExpiredEntries(nowMs, ttlMs);
cache.set(key, {
storedAt: nowMs,
value,
});
},
delete: (key) => {
cache.delete(key);
},
clear: () => {
cache.clear();
lastPruneAt = 0;
},
keys: () => [...cache.keys()],
size: () => cache.size,
pruneExpired: () => {
const ttlMs = getTtlMs();
if (!isCacheEnabled(ttlMs)) {
return;
}
const nowMs = now();
for (const [key, entry] of cache.entries()) {
if (isCacheEntryExpired(entry.storedAt, nowMs, ttlMs)) {
cache.delete(key);
}
}
lastPruneAt = nowMs;
},
};
}
export type FileStatSnapshot = {
mtimeMs: number;
sizeBytes: number;
};
export function getFileStatSnapshot(filePath: string): FileStatSnapshot | undefined {
try {
const stats = fs.statSync(filePath);
return {
mtimeMs: stats.mtimeMs,
sizeBytes: stats.size,
};
} catch {
return undefined;
}
}