Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions engine-bridge/src/__tests__/chain-state-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { ChainStateCache } from "../chain-state-cache";
import { RpcClient } from "../rpc-client";

function makeRpc(): RpcClient {
const rpc = new RpcClient(["http://test"]);
rpc.call = async (fn: any) => {

Check warning on line 6 in engine-bridge/src/__tests__/chain-state-cache.test.ts

View workflow job for this annotation

GitHub Actions / engine-bridge

Unexpected any. Specify a different type
return fn({});
};
return rpc;
}

describe("ChainStateCache", () => {
describe("bounded cache (maxEntries)", () => {
it("caps cache size at maxEntries when more distinct keys are inserted", async () => {
const rpc = makeRpc();
const cache = new ChainStateCache(rpc, 2000, 3);
const fetcher = async () => ({ value: Math.random() });

await cache.getSwr("a", fetcher);
await cache.getSwr("b", fetcher);
await cache.getSwr("c", fetcher);
await cache.getSwr("d", fetcher);

expect((cache as any).cache.size).toBe(3);

Check warning on line 24 in engine-bridge/src/__tests__/chain-state-cache.test.ts

View workflow job for this annotation

GitHub Actions / engine-bridge

Unexpected any. Specify a different type
});

it("evicts the least recently used entry", async () => {
const rpc = makeRpc();
const cache = new ChainStateCache(rpc, 2000, 2);
const fetcher = async () => ({ value: Math.random() });

await cache.getSwr("a", fetcher);
await cache.getSwr("b", fetcher);

await cache.getSwr("a", fetcher);

await cache.getSwr("c", fetcher);

expect((cache as any).cache.has("a")).toBe(true);

Check warning on line 39 in engine-bridge/src/__tests__/chain-state-cache.test.ts

View workflow job for this annotation

GitHub Actions / engine-bridge

Unexpected any. Specify a different type
expect((cache as any).cache.has("b")).toBe(false);

Check warning on line 40 in engine-bridge/src/__tests__/chain-state-cache.test.ts

View workflow job for this annotation

GitHub Actions / engine-bridge

Unexpected any. Specify a different type
expect((cache as any).cache.has("c")).toBe(true);

Check warning on line 41 in engine-bridge/src/__tests__/chain-state-cache.test.ts

View workflow job for this annotation

GitHub Actions / engine-bridge

Unexpected any. Specify a different type
});

it("does not evict when under maxEntries", async () => {
const rpc = makeRpc();
const cache = new ChainStateCache(rpc, 2000, 10);
const fetcher = async () => ({ value: Math.random() });

await cache.getSwr("a", fetcher);
await cache.getSwr("b", fetcher);
await cache.getSwr("c", fetcher);

expect((cache as any).cache.size).toBe(3);

Check warning on line 53 in engine-bridge/src/__tests__/chain-state-cache.test.ts

View workflow job for this annotation

GitHub Actions / engine-bridge

Unexpected any. Specify a different type
});
});

describe("SWR behavior", () => {
it("returns cached data on subsequent calls with same key", async () => {
const rpc = makeRpc();
const cache = new ChainStateCache(rpc, 5000, 10);
let callCount = 0;
const fetcher = async () => {
callCount++;
return { value: 42 };
};

const result1 = await cache.getSwr("x", fetcher);
expect(result1.value).toBe(42);
expect(callCount).toBe(1);

const result2 = await cache.getSwr("x", fetcher);
expect(result2.value).toBe(42);
expect(callCount).toBe(1);
});

it("serves stale data and revalidates in background", async () => {
const rpc = makeRpc();
const cache = new ChainStateCache(rpc, -1, 10);
let callCount = 0;
const fetcher = async () => {
await new Promise(r => setTimeout(r, 10));
callCount++;
return { value: callCount };
};

await cache.getSwr("y", fetcher);
expect(callCount).toBe(1);

await cache.getSwr("y", fetcher);
expect(callCount).toBe(1);

await new Promise(r => setTimeout(r, 50));

expect(callCount).toBe(2);
});
});

describe("invalidate / clear", () => {
it("invalidate removes a specific key", async () => {
const rpc = makeRpc();
const cache = new ChainStateCache(rpc, 2000, 10);
const fetcher = async () => ({ value: 1 });

await cache.getSwr("a", fetcher);
expect((cache as any).cache.has("a")).toBe(true);

cache.invalidate("a");
expect((cache as any).cache.has("a")).toBe(false);
});

it("clear removes all entries", async () => {
const rpc = makeRpc();
const cache = new ChainStateCache(rpc, 2000, 10);
const fetcher = async () => ({ value: 1 });

await cache.getSwr("a", fetcher);
await cache.getSwr("b", fetcher);
expect((cache as any).cache.size).toBe(2);

cache.clear();
expect((cache as any).cache.size).toBe(0);
});
});
});
28 changes: 27 additions & 1 deletion engine-bridge/src/chain-state-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,34 @@ interface CacheItem<T> {
*
* Implements SWR caching to solve slow load times when fetching chain state.
* Returns stale data immediately while fetching fresh data in the background.
* Cache is bounded by maxEntries via LRU eviction to prevent unbounded growth.
*/
export class ChainStateCache {
private cache = new Map<string, CacheItem<any>>();

constructor(
private readonly rpc: RpcClient,
private readonly defaultStaleTimeMs: number = 2000
private readonly defaultStaleTimeMs: number = 2000,
private readonly maxEntries: number = Infinity
) {}

private touch(key: string): void {
if (this.cache.has(key)) {
const item = this.cache.get(key)!;
this.cache.delete(key);
this.cache.set(key, item);
}
}

private evictIfNeeded(): void {
while (this.cache.size > this.maxEntries) {
const lruKey = this.cache.keys().next().value;
if (lruKey !== undefined) {
this.cache.delete(lruKey);
}
}
}

/**
* Fetches data using SWR strategy.
* @param key Unique cache key
Expand All @@ -40,6 +59,7 @@ export class ChainStateCache {
const item = this.cache.get(key) as CacheItem<T> | undefined;

if (item) {
this.touch(key);
const isStale = now - item.updatedAt > staleTimeMs;

if (isStale && !item.isRevalidating) {
Expand All @@ -55,6 +75,7 @@ export class ChainStateCache {
// Cache miss, fetch synchronously
const data = await fetcher(this.rpc);
this.cache.set(key, { data, updatedAt: Date.now(), isRevalidating: false });
this.evictIfNeeded();
return data;
}

Expand All @@ -80,4 +101,9 @@ export class ChainStateCache {
invalidate(key: string): void {
this.cache.delete(key);
}

/** Evict all entries */
clear(): void {
this.cache.clear();
}
}
Loading