Focus Page

Cache Invalidation

TTL expiry, event-driven purge, and delivery acknowledgment patterns

๐Ÿ“… 2026-04-02 ๐Ÿ“– โ€“ min read ๐Ÿ”– src/infra/ ๐Ÿ”— 047b701859

Contents

  1. Overview
  2. TTL-Based Invalidation
  3. Event-Driven Invalidation
  4. Delivery Queue Invalidation
  5. Approval Invalidation
  6. Config & Session Invalidation
  7. Decision Diagrams

Overview

๐ŸŽฏ What & Why Cache invalidation is famously one of the two hard problems in computer science (alongside naming things and off-by-one errors). Getting it wrong means serving stale data, missing critical updates, or wasting resources on entries that should have been purged long ago. OpenClaw uses a layered approach: TTL for time-sensitive caches, event-driven purge for test resets, and file-system operations for delivery queues.
๐Ÿ’ก Analogy Like a grocery store managing expiration dates. Some items (TTL caches) have a "best before" date and get pulled from shelves automatically. Others (delivery queue) are removed only when a customer picks them up (ack) or they're recalled (permanent error). The store never throws away everything at once โ€” it's always surgical.

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.

1

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;
}
2

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.

โš ๏ธ Lazy Eviction Trade-off Lazy eviction means stale entries persist in memory until the next read. For the 200-entry presence cache this is acceptable โ€” worst case is a few kilobytes of stale data. For larger caches, consider periodic sweeps.

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.

1

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);
}
2

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.

๐Ÿ“‹ Backup Rotation Details The system maintains files named 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

Click to expand
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

Click to expand
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]