Cache Strategies
In-memory caching patterns and Symbol-based singleton management
Contents
Overview
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.
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;
}
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
}
}
}
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 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.
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
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
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 --> [*]