Focus Page

Cache Strategies

In-memory caching patterns and Symbol-based singleton management

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

Contents

  1. Overview
  2. Symbol-Based Singletons
  3. System Presence Cache
  4. Heartbeat Event Cache
  5. Session-Scoped Ephemeral Events
  6. Cache Read/Write Patterns
  7. Architecture Diagrams

Overview

๐ŸŽฏ What & Why Cache strategies determine how in-memory state is organized, accessed, and shared across the application. Using Symbol-based singletons ensures exactly one cache instance exists per process, eliminating the risk of duplicated state when modules are loaded multiple times by different bundlers or import paths.
๐Ÿ’ก Analogy Like a shared notice board in an office. Everyone reads the same board (Symbol singleton). Notices older than the morning shift are removed (TTL). Only 200 post-it notes fit (max entries). New critical notices push off the oldest ones.

Symbol Singletons

Global registry ensures one cache instance per key across all module copies.

Presence Cache

200-entry map with 5-min TTL for system-wide presence state tracking.

Heartbeat Events

Single-slot cache holding the last heartbeat event with pub/sub broadcast.

Session Queues

Per-session bounded queues (max 20) with dedup for ephemeral events.

Symbol-Based Singletons

The cache layer uses Symbol.for() to guarantee a single instance across the entire Node.js process. Because Symbol.for() operates on the global Symbol registry rather than module scope, even if the module is loaded multiple times through different paths, the same cache instance is returned.

Heartbeat Event State

The heartbeat event cache stores the most recent heartbeat event and a list of subscribers. The Symbol key "openclaw.heartbeatEvents.state" anchors the cache to the global registry.

1

Register the Symbol key in the global registry and retrieve or initialize the cache state.

const HEARTBEAT_KEY = Symbol.for("openclaw.heartbeatEvents.state");

interface HeartbeatState {
  lastEvent: HeartbeatEvent | null;
  listeners: Set<(evt: HeartbeatEvent) => void>;
}

function getHeartbeatState(): HeartbeatState {
  const g = globalThis as Record<symbol, unknown>;
  if (!g[HEARTBEAT_KEY]) {
    g[HEARTBEAT_KEY] = {
      lastEvent: null,
      listeners: new Set(),
    };
  }
  return g[HEARTBEAT_KEY] as HeartbeatState;
}
2

Emit an event: update the cached value and broadcast to all subscribers with a timestamp.

export function emitHeartbeatEvent(evt: HeartbeatEvent): void {
  const state = getHeartbeatState();
  state.lastEvent = { ...evt, timestamp: Date.now() };
  for (const listener of state.listeners) {
    try {
      listener(state.lastEvent);
    } catch {
      // listener errors must not crash the emitter
    }
  }
}
3

Subscribe: returns an unsubscribe function for clean teardown. Read the last event without subscribing.

export function onHeartbeatEvent(
  listener: (evt: HeartbeatEvent) => void
): () => void {
  const state = getHeartbeatState();
  state.listeners.add(listener);
  return () => { state.listeners.delete(listener); };
}

export function getLastHeartbeatEvent(): HeartbeatEvent | null {
  return getHeartbeatState().lastEvent;
}

Session Queue Singletons

System event queues are resolved per session using resolveGlobalMap. Each session context receives a bounded queue (max 20 entries) that deduplicates events before insertion.

function resolveGlobalMap<K, V>(): Map<K, V> {
  const MAP_KEY = Symbol.for("openclaw.systemEvents.map");
  const g = globalThis as Record<symbol, unknown>;
  if (!g[MAP_KEY]) {
    g[MAP_KEY] = new Map<K, V>();
  }
  return g[MAP_KEY] as Map<K, V>;
}

interface SessionQueue {
  events: SystemEvent[];
  maxSize: number;
}

function getSessionQueue(sessionId: string): SessionQueue {
  const map = resolveGlobalMap<string, SessionQueue>();
  if (!map.has(sessionId)) {
    map.set(sessionId, { events: [], maxSize: 20 });
  }
  return map.get(sessionId)!;
}

System Presence Cache

The system presence cache tracks up to 200 active system entries in a global in-memory map. Each entry has a 5-minute TTL and is lazily evicted on read. The cache is initialized with self-presence data: hostname, IP address, macOS model identifier, and application version.

๐Ÿ“‹ SystemPresenceUpdate Tracking Every presence update produces a SystemPresenceUpdate delta object with previous, next, and a changes array describing which fields changed. This enables downstream consumers to react only to meaningful transitions.
interface SystemPresence {
  hostname: string;
  ip: string;
  model: string;      // macOS model identifier
  version: string;
  lastSeen: number;   // Unix timestamp in ms
}

interface SystemPresenceUpdate {
  previous: SystemPresence | null;
  next: SystemPresence;
  changes: (keyof SystemPresence)[];
}

const MAX_PRESENCE_ENTRIES = 200;
const PRESENCE_TTL_MS = 5 * 60 * 1000; // 5 minutes

function updatePresence(
  cache: Map<string, SystemPresence>,
  key: string,
  presence: SystemPresence
): SystemPresenceUpdate {
  const previous = cache.get(key) ?? null;
  const next = { ...presence, lastSeen: Date.now() };
  cache.set(key, next);

  // Enforce max entries โ€” evict oldest
  if (cache.size > MAX_PRESENCE_ENTRIES) {
    let oldestKey = "";
    let oldestTime = Infinity;
    for (const [k, v] of cache) {
      if (v.lastSeen < oldestTime) {
        oldestTime = v.lastSeen;
        oldestKey = k;
      }
    }
    if (oldestKey) cache.delete(oldestKey);
  }

  const changes: (keyof SystemPresence)[] = [];
  if (previous) {
    for (const field of ["hostname", "ip", "model", "version"] as const) {
      if (previous[field] !== next[field]) changes.push(field);
    }
  }

  return { previous, next, changes };
}

Heartbeat Event Cache

The heartbeat event cache is a single-slot write-through cache. emitHeartbeatEvent(evt) records the event locally and broadcasts it to all registered listeners. getLastHeartbeatEvent() returns the cached event or null if none has been emitted.

โš ๏ธ Important Listener exceptions are silently swallowed to prevent a single faulty subscriber from disrupting the heartbeat pipeline. Monitor error logs separately if listener reliability is critical.

Session-Scoped Ephemeral Events

Each session context maintains its own bounded event queue via resolveGlobalMap<string, SessionQueue>(). Queues are capped at 20 entries. Before pushing a new event, the queue checks for duplicates (matching event ID) and drops the incoming event if already present. When the queue overflows, the oldest event is shifted out.

function pushSessionEvent(sessionId: string, event: SystemEvent): boolean {
  const queue = getSessionQueue(sessionId);

  // Dedup check
  if (queue.events.some(e => e.id === event.id)) {
    return false;
  }

  queue.events.push(event);

  // Enforce bounded size โ€” FIFO eviction
  while (queue.events.length > queue.maxSize) {
    queue.events.shift();
  }

  return true;
}

Cache Read/Write Patterns

Write-Through

Used for presence: update local cache immediately then broadcast the change event to peers.

Read-Through

Used for heartbeat events: check cache first, return cached value or null. No remote fetch.

FIFO Eviction

Session queues evict the oldest entry when full. No priority, no LRU โ€” simple and fast.

Lazy TTL

Presence entries evicted on read, not on timer. Reduces background bookkeeping.

Architecture Diagrams

Cache Lookup / Update Pattern with TTL Check

Click to expand
flowchart TD
    A[Incoming Request] --> B{Key in cache?}
    B -- Yes --> C{TTL expired?}
    C -- No --> D[Return cached value]
    C -- Yes --> E[Evict stale entry]
    E --> F[Fetch fresh value]
    B -- No --> F
    F --> G{Cache full?}
    G -- Yes --> H[Evict oldest entry]
    H --> I[Store new entry]
    G -- No --> I
    I --> J[Return fresh value]
    D --> K[Update lastAccessed]
    K --> L[Done]
    J --> L
    

Cache Entry Lifecycle

Click to expand
stateDiagram-v2
    [*] --> Empty : initialize
    Empty --> Populated : set(key, value)
    Populated --> Populated : update(key, newValue)
    Populated --> Stale : TTL expires (lazy check)
    Stale --> Evicted : next read triggers eviction
    Populated --> Evicted : capacity overflow (oldest)
    Evicted --> Populated : re-populated on next write
    Evicted --> [*]