Cache Invalidation
TTL expiry, event-driven purge, and delivery acknowledgment patterns
Contents
Overview
TTL Expiry
5-minute window for presence entries; lazy eviction on next read access.
Event-Driven Purge
Full cache reset for test isolation; FIFO overflow for bounded queues.
Delivery Ack/Fail
File-system hard delete on ack; move-to-failed on permanent error (14 regex patterns).
Approval Expiry
30-min default expiration; resolved approvals consumed once then discarded.
TTL-Based Invalidation
SystemPresence entries expire after 5 minutes (PRESENCE_TTL_MS = 300_000). Unlike active eviction with timers, OpenClaw uses lazy eviction โ stale entries are removed only when the cache map is accessed. This avoids the overhead of background cleanup tasks and ensures that a quiet system does not spin up unnecessary work.
On every cache read, iterate entries and evict any whose lastSeen timestamp exceeds the TTL window.
const PRESENCE_TTL_MS = 5 * 60 * 1000;
function evictStaleEntries(
cache: Map<string, SystemPresence>
): number {
const now = Date.now();
let evicted = 0;
for (const [key, entry] of cache) {
if (now - entry.lastSeen > PRESENCE_TTL_MS) {
cache.delete(key);
evicted++;
}
}
return evicted;
}
function getPresence(
cache: Map<string, SystemPresence>,
key: string
): SystemPresence | null {
evictStaleEntries(cache);
return cache.get(key) ?? null;
}
After eviction, check if the requested entry still exists. If it was stale, the caller receives null and must decide whether to re-fetch or proceed without it.
Event-Driven Invalidation
For test isolation, the heartbeat event cache provides a resetHeartbeatEventsForTest() function that clears the entire cache. This ensures no state leaks between test cases. Session queues use FIFO overflow as their invalidation mechanism โ when a queue reaches its 20-entry cap, the oldest event is shifted out to make room.
/**
* Reset the entire heartbeat cache โ for test isolation only.
* In production this is a no-op guarded by NODE_ENV check.
*/
export function resetHeartbeatEventsForTest(): void {
if (process.env.NODE_ENV !== "test") {
console.warn("resetHeartbeatEventsForTest called outside test env");
return;
}
const state = getHeartbeatState();
state.lastEvent = null;
state.listeners.clear();
}
/**
* FIFO overflow for session event queues.
* Called internally by pushSessionEvent when queue is full.
*/
function enforceQueueBound(queue: SessionQueue): SystemEvent | null {
if (queue.events.length > queue.maxSize) {
return queue.events.shift() ?? null; // evict oldest
}
return null;
}
Delivery Queue Invalidation
The delivery queue uses file-system operations for invalidation. Each delivery is a .json file on disk. Three invalidation paths exist:
ackDelivery(id)
Hard-deletes the .json file. The delivery was successfully consumed.
failDelivery(id, error?)
Marks the .delivered flag. Retains the file for debugging, but stops retry attempts.
moveToFailed(id)
Moves the file to failed/ subdirectory. Used when a permanent error is detected.
Acknowledge a successful delivery by removing its file from the queue directory.
import { unlink, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
const PERMANENT_ERROR_PATTERNS = [
/400 Bad Request/i,
/401 Unauthorized/i,
/403 Forbidden/i,
/404 Not Found/i,
/405 Method Not Allowed/i,
/409 Conflict/i,
/410 Gone/i,
/413 Payload Too Large/i,
/415 Unsupported Media/i,
/422 Unprocessable/i,
/INVALID_TOKEN/i,
/MALFORMED_PAYLOAD/i,
/SCHEMA_VIOLATION/i,
/DUPLICATE_DELIVERY/i,
];
async function ackDelivery(queueDir: string, id: string): Promise<void> {
const filePath = join(queueDir, `${id}.json`);
await unlink(filePath);
}
On failure, check whether the error matches any permanent pattern. If so, move to failed/. Otherwise, mark as delivered to prevent immediate retry.
function isPermanentError(error: string): boolean {
return PERMANENT_ERROR_PATTERNS.some(re => re.test(error));
}
async function failDelivery(
queueDir: string,
id: string,
error?: string
): Promise<void> {
const filePath = join(queueDir, `${id}.json`);
if (error && isPermanentError(error)) {
await moveToFailed(queueDir, id);
return;
}
// Mark .delivered flag โ file stays for manual review
await writeFile(`${filePath}.delivered`, error ?? "unknown");
}
async function moveToFailed(
queueDir: string,
id: string
): Promise<void> {
const src = join(queueDir, `${id}.json`);
const dest = join(queueDir, "failed", `${id}.json`);
await rename(src, dest);
}
Approval Invalidation
Approval requests carry an expiresAtMs field (defaulting to 30 minutes from creation). When the approval is checked, the system compares the current time against the expiration. Expired approvals are treated as rejected. Resolved approvals (approved or denied) are consumed exactly once โ after reading, the entry is deleted.
interface ApprovalRequest {
id: string;
createdAtMs: number;
expiresAtMs: number; // default: createdAtMs + 30 * 60 * 1000
status: "pending" | "approved" | "denied";
resolvedAtMs?: number;
}
const DEFAULT_APPROVAL_TTL_MS = 30 * 60 * 1000;
function checkApproval(
approvals: Map<string, ApprovalRequest>,
id: string
): ApprovalRequest["status"] {
const req = approvals.get(id);
if (!req) return "denied";
if (Date.now() > req.expiresAtMs) {
approvals.delete(id);
return "denied"; // expired โ treat as denied
}
if (req.status !== "pending") {
approvals.delete(id); // consume once
return req.status;
}
return "pending";
}
Config & Session Invalidation
Configuration backups are rotated keeping the most recent 5 versions. Older backups are deleted during the rotation step. Session data is pruned based on age โ entries older than the configured threshold (in days) are removed during periodic sweeps.
config.backup.1 through config.backup.5. On each save, the current config is shifted into slot 1 and older backups move up. Slot 6+ are deleted. This provides a simple undo mechanism without unbounded disk growth.
Decision Diagrams
TTL Check โ Evict โ Repopulate Flow
sequenceDiagram
participant Caller
participant Cache
participant Evictor
participant Source
Caller->>Cache: getPresence(key)
Cache->>Evictor: evictStaleEntries()
Evictor-->>Cache: evicted N entries
alt Entry exists and fresh
Cache-->>Caller: return entry
else Entry missing or was stale
Cache-->>Caller: return null
Caller->>Source: fetchPresence(key)
Source-->>Caller: fresh presence data
Caller->>Cache: updatePresence(key, data)
Cache-->>Caller: SystemPresenceUpdate
end
Delivery Invalidation Decision Tree
flowchart TD
A[Delivery Callback Received] --> B{Success?}
B -- Yes --> C[ackDelivery: delete .json file]
C --> D[Done โ entry removed]
B -- No --> E{Error message available?}
E -- No --> F[failDelivery: mark .delivered flag]
E -- Yes --> G{Matches permanent pattern?}
G -- Yes --> H[moveToFailed: move to failed/ dir]
G -- No --> I{Retry count exceeded?}
I -- Yes --> H
I -- No --> J[Schedule retry with backoff]
J --> K[Re-attempt delivery]
K --> A
F --> L[Manual review needed]
H --> M[Permanently archived]