diff --git a/packages/core/sdk/src/executor-cache.test.ts b/packages/core/sdk/src/executor-cache.test.ts new file mode 100644 index 000000000..839e9c5cf --- /dev/null +++ b/packages/core/sdk/src/executor-cache.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { createExecutor, type Executor } from "./executor"; +import { Tenant } from "./ids"; + +// Keep in sync with the unexported fallback cache defaults in executor.ts. +const MEMORY_CACHE_CAPACITY = 2_048; +const MEMORY_CACHE_TTL_MS = 10 * 60 * 1000; + +const makeExecutor = Effect.acquireRelease( + createExecutor({ + tenant: Tenant.make("test-tenant"), + onElicitation: "accept-all", + }), + (executor) => executor.close().pipe(Effect.ignore), +); + +const withFakeNow = ( + initialNow: number, + run: (clock: { readonly advance: (ms: number) => void }) => Effect.Effect, +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + const originalNow = Date.now; + let now = initialNow; + Date.now = () => now; + return { + advance: (ms: number) => { + now += ms; + }, + restore: () => { + Date.now = originalNow; + }, + }; + }), + (clock) => run({ advance: clock.advance }), + (clock) => Effect.sync(clock.restore), + ); + +describe("executor cache", () => { + it.effect("uses an in-memory fallback when no cache is configured", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* makeExecutor; + + yield* executor.cache.set("a", "value"); + expect(yield* executor.cache.get("a")).toBe("value"); + + yield* executor.cache.remove("a"); + expect(yield* executor.cache.get("a")).toBeUndefined(); + }), + ), + ); + + it.effect("expires fallback entries by TTL on get and size", () => + withFakeNow(1_000, ({ advance }) => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* makeExecutor; + + yield* executor.cache.set("a", "value"); + expect(yield* executor.cache.size).toBe(1); + + advance(MEMORY_CACHE_TTL_MS); + + expect(yield* executor.cache.get("a")).toBeUndefined(); + expect(yield* executor.cache.size).toBe(0); + }), + ), + ), + ); + + it.effect("refreshes fallback LRU position when an existing key is written", () => + Effect.scoped( + Effect.gen(function* () { + const executor: Executor = yield* makeExecutor; + + yield* executor.cache.set("a", "old"); + for (let index = 0; index < MEMORY_CACHE_CAPACITY - 1; index += 1) { + yield* executor.cache.set(`key-${index}`, String(index)); + } + + yield* executor.cache.set("a", "new"); + yield* executor.cache.set("overflow", "value"); + + expect(yield* executor.cache.get("a")).toBe("new"); + expect(yield* executor.cache.get("key-0")).toBeUndefined(); + expect(yield* executor.cache.get("key-1")).toBe("1"); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 3ab3757dc..d60cb70e6 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -511,6 +511,7 @@ const makeMemoryCacheStore = (): KeyValueStore.KeyValueStore => { Effect.sync(() => { const now = Date.now(); evictExpired(now); + rows.delete(key); rows.set(key, { value, expiresAt: now + MEMORY_CACHE_TTL_MS }); evictCapacity(); }),