diff --git a/bun.lock b/bun.lock index bcac538c1..64367ba33 100644 --- a/bun.lock +++ b/bun.lock @@ -754,6 +754,7 @@ "version": "0.0.1", "dependencies": { "@corbits/agent-runtime": "workspace:*", + "@corbits/collections": "workspace:*", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", diff --git a/packages/collections/README.md b/packages/collections/README.md index 40c598f80..c4f5bae71 100644 --- a/packages/collections/README.md +++ b/packages/collections/README.md @@ -21,6 +21,7 @@ lastSeenByUser.get(userId); // undefined once ttlMs has elapsed This is the first primitive in the package. `apps/hub/src/launch-caches.ts`'s `BoundedCache` (size-capped LRU, no TTL) is a sibling that predates this -package — CL-7229 and CL-7223 are expected to either consume -`createExpiringMap` directly or contribute the size-capped-LRU shape here -so `BoundedCache` can retire in favor of one place for this problem. +package. `@corbits/folded-runs`' `createCryptoProviderCache` now consumes +`createExpiringMap` directly (CL-7223); CL-7229 is expected to either do +the same or contribute the size-capped-LRU shape here so `BoundedCache` +can retire in favor of one place for this problem. diff --git a/packages/folded-runs/README.md b/packages/folded-runs/README.md index f73b1986c..744cee711 100644 --- a/packages/folded-runs/README.md +++ b/packages/folded-runs/README.md @@ -20,9 +20,11 @@ or a host-specific package such as `@corbits/chat`. imported from `@intx/hub-api`'s hub-api-internal helper. - **Crypto provider caching** (`./src/crypto-cache.ts`) — `createCryptoProviderCache` mints one `CryptoProvider` per cache key - (a workbench id, an instance id, ...) and reuses it for the cache's - lifetime; never evicted, since a key going momentarily unreachable does - not mean it is gone for good. + (a workbench id, an instance id, ...) and reuses it while the key stays + in active use, via `@corbits/collections`' `createExpiringMap` with a + 7-day idle ttl refreshed on every access (CL-7223): a key going + momentarily unreachable (idle sleep, a sweep) does not evict it, so + only a key nobody has asked for in a week is treated as gone for good. - **Run lookups** (`./src/runs.ts`) — resolving a run by id or address, and bridging a run's principal to its live session via the shared-principal bridge. diff --git a/packages/folded-runs/package.json b/packages/folded-runs/package.json index f1fa976aa..c76e1a090 100644 --- a/packages/folded-runs/package.json +++ b/packages/folded-runs/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@corbits/agent-runtime": "workspace:*", + "@corbits/collections": "workspace:*", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", diff --git a/packages/folded-runs/src/crypto-cache.test.ts b/packages/folded-runs/src/crypto-cache.test.ts new file mode 100644 index 000000000..5bce413b7 --- /dev/null +++ b/packages/folded-runs/src/crypto-cache.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { createCryptoProviderCache } from "./crypto-cache"; + +/** A controllable clock: advances only when the test tells it to, so + * eviction timing is asserted exactly rather than raced against a real + * timer. */ +function fakeClock(startAt = 0) { + let now = startAt; + return { + now: () => now, + advance(ms: number) { + now += ms; + }, + }; +} + +describe("createCryptoProviderCache", () => { + test("reuses the same provider for a key accessed within its ttl", async () => { + const clock = fakeClock(); + const cache = createCryptoProviderCache({ ttlMs: 1_000, now: clock.now }); + + const first = await cache.get("workbench-1"); + clock.advance(999); + const second = await cache.get("workbench-1"); + + expect(second).toBe(first); + }); + + test("mints a fresh provider once a key has gone untouched past its ttl", async () => { + const clock = fakeClock(); + const cache = createCryptoProviderCache({ ttlMs: 1_000, now: clock.now }); + + const first = await cache.get("workbench-1"); + clock.advance(1_000); + const second = await cache.get("workbench-1"); + + expect(second).not.toBe(first); + expect(second.getPublicKey()).not.toEqual(first.getPublicKey()); + }); + + test("an access refreshes the ttl, so a key in steady use never expires", async () => { + const clock = fakeClock(); + const cache = createCryptoProviderCache({ ttlMs: 1_000, now: clock.now }); + + const first = await cache.get("workbench-1"); + clock.advance(600); + await cache.get("workbench-1"); // refreshes the ttl + clock.advance(600); + const third = await cache.get("workbench-1"); + + // 1200ms since the first access, but only 600ms since the refresh. + expect(third).toBe(first); + }); + + test("distinct keys mint distinct providers", async () => { + const cache = createCryptoProviderCache(); + + const a = await cache.get("workbench-1"); + const b = await cache.get("workbench-2"); + + expect(a).not.toBe(b); + expect(a.getPublicKey()).not.toEqual(b.getPublicKey()); + }); +}); diff --git a/packages/folded-runs/src/crypto-cache.ts b/packages/folded-runs/src/crypto-cache.ts index 999cc62ff..c8fef7937 100644 --- a/packages/folded-runs/src/crypto-cache.ts +++ b/packages/folded-runs/src/crypto-cache.ts @@ -1,32 +1,50 @@ -// One `CryptoProvider` per cache key, minted once and reused for the -// cache's lifetime — mirroring the per-instance signing-key cache the +// One `CryptoProvider` per cache key, minted once and reused while the +// key stays active — mirroring the per-instance signing-key cache the // platform's own mail route keeps. A caller picks its own key (a // workbench id, an instance id, ...); this module knows nothing about // what the key means. import { createEd25519Crypto, generateKeyPair } from "@intx/crypto"; +import { createExpiringMap } from "@corbits/collections"; import type { CryptoProvider } from "@intx/types/runtime"; export type CryptoProviderCache = { get(key: string): Promise; }; -export function createCryptoProviderCache(): CryptoProviderCache { - // Never evicted: a key going momentarily unreachable (idle sleep, a - // sweep) does not mean it is gone for good, so tearing this down on - // that signal would rotate its signing key on the next wake for no - // reason. Grows only with the number of distinct keys this process - // ever mints a provider for, not by traffic. - const providers = new Map>(); +/** A key untouched for this long is treated as gone for good rather + * than merely idle: an idle-sleep sweep or a long weekend away is + * routinely shorter than this, so a re-wake almost never rotates its + * signing key; only a workbench/instance nobody has come back to in a + * week does. Re-minting is cheap (one Ed25519 keypair generation) and + * nothing in this codebase persists or re-checks a signing key's + * public half against an earlier value, so a rare rotation for a truly + * abandoned key is not a correctness risk. */ +const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +export function createCryptoProviderCache(options?: { + readonly ttlMs?: number; + readonly now?: () => number; +}): CryptoProviderCache { + const providers = createExpiringMap>({ + ttlMs: options?.ttlMs ?? DEFAULT_TTL_MS, + ...(options?.now !== undefined ? { now: options.now } : {}), + }); return { get(key: string): Promise { - let pending = providers.get(key); - if (pending !== undefined) return pending; - pending = generateKeyPair().then((keyPair) => + const pending = providers.get(key); + if (pending !== undefined) { + // Touch the entry so a key in active use never expires out + // from under it — only a key nobody has asked for within a + // full ttl window is treated as abandoned. + providers.set(key, pending); + return pending; + } + const minted = generateKeyPair().then((keyPair) => createEd25519Crypto(keyPair), ); - providers.set(key, pending); - return pending; + providers.set(key, minted); + return minted; }, }; }