From ca37b7c0f389bfb0ed837e479f87f16d48ef74b7 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 17 Aug 2026 15:42:04 +0800 Subject: [PATCH 1/4] fix(express-context): isolate bounded loader caches --- packages/express-context/README.md | 7 +- .../__tests__/loaders/cache-lifecycle.test.ts | 232 ++++++++++++++++++ .../src/loaders/create-loader.ts | 173 ++++++++++--- packages/express-context/src/loaders/index.ts | 6 +- .../express-context/src/loaders/registry.ts | 16 +- packages/express-context/src/loaders/types.ts | 17 +- 6 files changed, 396 insertions(+), 55 deletions(-) create mode 100644 packages/express-context/__tests__/loaders/cache-lifecycle.test.ts diff --git a/packages/express-context/README.md b/packages/express-context/README.md index 72534d75cf..f1c19d2e6f 100644 --- a/packages/express-context/README.md +++ b/packages/express-context/README.md @@ -57,7 +57,12 @@ app.post('/v1/chat', async (req, res) => { ## Module Loaders -Each loader encapsulates a SQL query + type transform + per-databaseId LRU cache for one piece of per-database configuration. Loaders are registered in a `LoaderRegistry` and resolved lazily via `useModule(name)`. +Each loader encapsulates a SQL query + type transform + bounded LRU cache for +one piece of per-database configuration. Entries are isolated by the exact +routing pool, tenant pool, routing schema, database, and API contract. TTLs are +hard expiry bounds, and concurrent misses for one exact contract share a single +resolution. Loaders are registered in a `LoaderRegistry` and resolved lazily +via `useModule(name)`. ### Built-in loaders diff --git a/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts b/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts new file mode 100644 index 0000000000..80d704c7dc --- /dev/null +++ b/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts @@ -0,0 +1,232 @@ +import type { Pool } from 'pg'; + +import { createModuleLoader } from '../../src/loaders/create-loader'; +import { createLoaderRegistry } from '../../src/loaders/registry'; +import type { + LoaderContext, + ModuleLoader +} from '../../src/loaders/types'; + +const pool = (): Pool => ({} as Pool); + +const context = ( + overrides: Partial = {} +): LoaderContext => ({ + routingPool: pool(), + routingSchema: 'routing_public', + tenantPool: pool(), + databaseId: 'database-a', + apiId: 'api-a', + dbname: 'tenant_a', + ...overrides +}); + +describe('module loader cache lifecycle', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('isolates identical logical IDs across physical pools and routing schemas', async () => { + const routingA = pool(); + const routingB = pool(); + const tenantA = pool(); + const tenantB = pool(); + const ctxA = context({ routingPool: routingA, tenantPool: tenantA }); + const ctxB = context({ routingPool: routingB, tenantPool: tenantA }); + const ctxC = context({ routingPool: routingA, tenantPool: tenantB }); + const ctxD = context({ + routingPool: routingA, + routingSchema: 'routing_shadow', + tenantPool: tenantA + }); + const resolve = jest.fn(async (ctx: LoaderContext) => { + if (ctx.routingSchema === 'routing_shadow') return 'schema-d'; + if (ctx.routingPool === routingB) return 'routing-b'; + if (ctx.tenantPool === tenantB) return 'tenant-c'; + return 'contract-a'; + }); + const loader = createModuleLoader({ name: 'isolation', resolve }); + + await expect(loader.resolve(ctxA)).resolves.toBe('contract-a'); + await expect(loader.resolve(ctxB)).resolves.toBe('routing-b'); + await expect(loader.resolve(ctxC)).resolves.toBe('tenant-c'); + await expect(loader.resolve(ctxD)).resolves.toBe('schema-d'); + await expect(loader.resolve(ctxA)).resolves.toBe('contract-a'); + + expect(resolve).toHaveBeenCalledTimes(4); + expect(loader.cacheSize).toBe(4); + }); + + it('invalidates one physical contract without evicting its logical twin', async () => { + const ctxA = context(); + const ctxB = context(); + let generation = 0; + const resolve = jest.fn(async () => ++generation); + const loader = createModuleLoader({ name: 'exact-invalidation', resolve }); + + const firstA = await loader.resolve(ctxA); + const firstB = await loader.resolve(ctxB); + loader.invalidate(ctxA.databaseId, ctxA); + + await expect(loader.resolve(ctxB)).resolves.toBe(firstB); + await expect(loader.resolve(ctxA)).resolves.not.toBe(firstA); + expect(resolve).toHaveBeenCalledTimes(3); + }); + + it('invalidates a logical database across every physical contract', async () => { + const ctxA = context(); + const ctxB = context(); + let generation = 0; + const resolve = jest.fn(async () => ++generation); + const loader = createModuleLoader({ name: 'logical-invalidation', resolve }); + + await loader.resolve(ctxA); + await loader.resolve(ctxB); + loader.invalidate('database-a'); + await loader.resolve(ctxA); + await loader.resolve(ctxB); + + expect(resolve).toHaveBeenCalledTimes(4); + }); + + it('coalesces concurrent misses for one exact contract', async () => { + const ctx = context(); + const resolve = jest.fn(async () => 'shared-config'); + const loader = createModuleLoader({ name: 'coalescing', resolve }); + + await expect( + Promise.all([ + loader.resolve(ctx), + loader.resolve(ctx), + loader.resolve(ctx) + ]) + ).resolves.toEqual(['shared-config', 'shared-config', 'shared-config']); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it('does not publish a resolution invalidated while it is in flight', async () => { + const ctx = context(); + let complete!: (value: string) => void; + const first = new Promise((resolve) => { + complete = resolve; + }); + const resolve = jest.fn() + .mockImplementationOnce(() => first) + .mockResolvedValueOnce('fresh-config'); + const loader = createModuleLoader({ + name: 'inflight-invalidation', + resolve + }); + + const stale = loader.resolve(ctx); + loader.invalidate(ctx.databaseId, ctx); + const fresh = loader.resolve(ctx); + await expect(fresh).resolves.toBe('fresh-config'); + complete('stale-config'); + await expect(stale).resolves.toBe('stale-config'); + await expect(loader.resolve(ctx)).resolves.toBe('fresh-config'); + expect(resolve).toHaveBeenCalledTimes(2); + }); + + it('uses a hard TTL that cache hits cannot extend', async () => { + let now = 1; + jest.spyOn(performance, 'now').mockImplementation(() => now); + const ctx = context(); + let generation = 0; + const resolve = jest.fn(async () => `config-${++generation}`); + const loader = createModuleLoader({ + name: 'hard-expiry', + ttlMs: 100, + resolve + }); + + await expect(loader.resolve(ctx)).resolves.toBe('config-1'); + now = 76; + await expect(loader.resolve(ctx)).resolves.toBe('config-1'); + now = 106; + await expect(loader.resolve(ctx)).resolves.toBe('config-2'); + expect(resolve).toHaveBeenCalledTimes(2); + }); + + it('keeps the default cache bounded to 100 completed contracts', async () => { + const routingPool = pool(); + const tenantPool = pool(); + const resolve = jest.fn(async (ctx: LoaderContext) => ctx.databaseId); + const loader = createModuleLoader({ name: 'bounded-default', resolve }); + + for (let index = 0; index <= 100; index++) { + await loader.resolve(context({ + routingPool, + tenantPool, + databaseId: `database-${index}` + })); + } + + expect(loader.cacheSize).toBe(100); + await loader.resolve(context({ + routingPool, + tenantPool, + databaseId: 'database-0' + })); + expect(resolve).toHaveBeenCalledTimes(102); + }); + + it('caches undefined results without confusing them for misses', async () => { + const resolve = jest.fn(async (): Promise => undefined); + const loader = createModuleLoader({ name: 'absent-module', resolve }); + const ctx = context(); + + await expect(loader.resolve(ctx)).resolves.toBeUndefined(); + await expect(loader.resolve(ctx)).resolves.toBeUndefined(); + + expect(resolve).toHaveBeenCalledTimes(1); + expect(loader.cacheSize).toBe(1); + }); + + it('caches an absent module reported by PostgreSQL undefined_table', async () => { + const error = Object.assign(new Error('module table absent'), { + code: '42P01' + }); + const resolve = jest.fn().mockRejectedValue(error); + const loader = createModuleLoader({ name: 'missing-table', resolve }); + const ctx = context(); + + await expect(loader.resolve(ctx)).resolves.toBeUndefined(); + await expect(loader.resolve(ctx)).resolves.toBeUndefined(); + + expect(resolve).toHaveBeenCalledTimes(1); + expect(loader.cacheSize).toBe(1); + }); + + it('preserves other resolution errors and never caches them', async () => { + const error = new Error('routing query failed'); + const resolve = jest.fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce('recovered'); + const loader = createModuleLoader({ name: 'failed-query', resolve }); + const ctx = context(); + + await expect(loader.resolve(ctx)).rejects.toBe(error); + await expect(loader.resolve(ctx)).resolves.toBe('recovered'); + + expect(resolve).toHaveBeenCalledTimes(2); + expect(loader.cacheSize).toBe(1); + }); + + it('forwards exact invalidation context through the registry', () => { + const ctx = context(); + const invalidate = jest.fn(); + const loader = { + name: 'registered', + resolve: jest.fn(), + invalidate, + cacheSize: 0 + } as ModuleLoader; + const registry = createLoaderRegistry(); + registry.register(loader); + + registry.invalidate(ctx.databaseId, ctx); + + expect(invalidate).toHaveBeenCalledWith(ctx.databaseId, ctx); + }); +}); diff --git a/packages/express-context/src/loaders/create-loader.ts b/packages/express-context/src/loaders/create-loader.ts index 25aabd333e..7d77fc7112 100644 --- a/packages/express-context/src/loaders/create-loader.ts +++ b/packages/express-context/src/loaders/create-loader.ts @@ -1,15 +1,19 @@ /** * create-loader — Factory for building cached ModuleLoader instances. * - * Wraps a raw resolve function with an LRU cache keyed by databaseId:apiId. - * Each loader gets its own independent cache with configurable TTL and - * max entries. + * Wraps a raw resolve function with an LRU cache keyed by the physical routing + * and tenant pools, routing schema, databaseId, and apiId. Each loader gets its + * own independent cache with a configurable hard TTL and maximum size. */ import { Logger } from '@pgpmjs/logger'; import { LRUCache } from 'lru-cache'; -import type { LoaderContext, ModuleLoader } from './types'; +import { + type LoaderContext, + type ModuleLoader, + routingSchemaOf +} from './types'; export interface CreateLoaderOptions { /** Unique loader name (used in log prefix and modules map key) */ @@ -25,61 +29,158 @@ export interface CreateLoaderOptions { const DEFAULT_TTL_MS = 60_000; const DEFAULT_MAX = 100; +let nextPoolIdentity = 0; +const poolIdentities = new WeakMap(); + +const poolIdentity = (pool: object): number => { + let identity = poolIdentities.get(pool); + if (identity === undefined) { + identity = ++nextPoolIdentity; + poolIdentities.set(pool, identity); + } + return identity; +}; + +interface LoaderCacheContract { + databaseId: string; + routingSchema: string; + routingPoolIdentity: number; + tenantPoolIdentity: number; +} + +const cacheContract = (ctx: LoaderContext): LoaderCacheContract => ({ + databaseId: ctx.databaseId, + routingSchema: routingSchemaOf(ctx), + routingPoolIdentity: poolIdentity(ctx.routingPool), + tenantPoolIdentity: poolIdentity(ctx.tenantPool) +}); + +const cacheKey = (ctx: LoaderContext, contract: LoaderCacheContract): string => + JSON.stringify([ + contract.routingPoolIdentity, + contract.tenantPoolIdentity, + contract.routingSchema, + contract.databaseId, + ctx.apiId ?? null + ]); + +interface LoaderCacheEntry { + contract: LoaderCacheContract; + value: T | undefined; +} + +interface PendingResolution { + contract: LoaderCacheContract; + invalidated: boolean; + promise: Promise; +} + +const samePhysicalContract = ( + left: LoaderCacheContract, + right: LoaderCacheContract +): boolean => + left.routingPoolIdentity === right.routingPoolIdentity + && left.tenantPoolIdentity === right.tenantPoolIdentity + && left.routingSchema === right.routingSchema; + export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoader { const log = new Logger(`loader:${opts.name}`); - const cache = new LRUCache({ + const cache = new LRUCache>({ max: opts.max ?? DEFAULT_MAX, ttl: opts.ttlMs ?? DEFAULT_TTL_MS, - updateAgeOnGet: true, + ttlResolution: 0, + updateAgeOnGet: false, allowStale: false, }); + const pending = new Map>(); return { name: opts.name, async resolve(ctx: LoaderContext): Promise { - const key = ctx.apiId ? `${ctx.databaseId}:${ctx.apiId}` : ctx.databaseId; + const logicalKey = ctx.apiId + ? `${ctx.databaseId}:${ctx.apiId}` + : ctx.databaseId; + const contract = cacheContract(ctx); + const key = cacheKey(ctx, contract); - if (cache.has(key)) { - log.debug(`Cache HIT databaseId=${key}`); - return cache.get(key); + const cached = cache.get(key); + if (cached !== undefined) { + log.debug(`Cache HIT databaseId=${logicalKey}`); + return cached.value; } - log.debug(`Cache MISS databaseId=${key}, resolving`); + const existing = pending.get(key); + if (existing && !existing.invalidated) { + log.debug(`Cache COALESCE databaseId=${logicalKey}`); + return existing.promise; + } + + log.debug(`Cache MISS databaseId=${logicalKey}, resolving`); // "Not provisioned" is expressed by the loader returning undefined, or // by the module's tables not existing at all (42P01 undefined_table). // Any other resolution error (bad query, ambiguous config) propagates — // never silently coerced into "module absent". - try { - const value = await opts.resolve(ctx); - cache.set(key, value); - return value; - } catch (e: any) { - if (e.code === '42P01') { - log.debug(`Module tables absent for databaseId=${key}: ${e.message}`); - cache.set(key, undefined); - return undefined; + const resolution: PendingResolution = { + contract, + invalidated: false, + promise: Promise.resolve(undefined) + }; + resolution.promise = Promise.resolve().then(async () => { + try { + const value = await opts.resolve(ctx); + if (!resolution.invalidated) { + cache.set(key, { contract, value }); + } + return value; + } catch (e: any) { + if (e.code === '42P01') { + log.debug( + `Module tables absent for databaseId=${logicalKey}: ${e.message}` + ); + if (!resolution.invalidated) { + cache.set(key, { contract, value: undefined }); + } + return undefined; + } + log.warn(`Failed to resolve databaseId=${logicalKey}: ${e.message}`); + throw e; + } finally { + if (pending.get(key) === resolution) { + pending.delete(key); + } } - log.warn(`Failed to resolve databaseId=${key}: ${e.message}`); - throw e; - } + }); + pending.set(key, resolution); + return resolution.promise; }, - invalidate(databaseId?: string): void { - if (databaseId) { - // Clear the plain databaseId key and any composite databaseId:apiId keys - let cleared = 0; - for (const k of cache.keys()) { - if (k === databaseId || k.startsWith(`${databaseId}:`)) { - cache.delete(k); - cleared++; - } - } - log.debug(`Invalidated ${cleared} entries for databaseId=${databaseId}`); - } else { + invalidate(databaseId?: string, context?: LoaderContext): void { + if (!databaseId && !context) { + const previousSize = cache.size; cache.clear(); - log.debug(`Invalidated all entries (was size=${cache.size})`); + for (const resolution of pending.values()) { + resolution.invalidated = true; + } + log.debug(`Invalidated all entries (was size=${previousSize})`); + return; + } + + const exact = context ? cacheContract(context) : null; + const matches = (contract: LoaderCacheContract): boolean => + (!databaseId || contract.databaseId === databaseId) + && (!exact || samePhysicalContract(contract, exact)); + let cleared = 0; + for (const [key, entry] of cache.entries()) { + if (!matches(entry.contract)) continue; + if (cache.delete(key)) cleared++; + } + for (const resolution of pending.values()) { + if (matches(resolution.contract)) resolution.invalidated = true; } + log.debug( + `Invalidated ${cleared} entries${databaseId ? ` for databaseId=${databaseId}` : ''}` + ); }, get cacheSize(): number { diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index 09bf40e49a..402eaa1a78 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -1,9 +1,9 @@ /** * Module Loaders — pluggable per-database cached lookups. * - * Each loader encapsulates a SQL query + type transform + LRU cache - * for one piece of per-database configuration. Register loaders in - * a LoaderRegistry and pass it to createContextMiddleware(). + * Each loader encapsulates a SQL query + type transform + bounded exact-context + * LRU cache for one piece of per-database configuration. Register loaders in a + * LoaderRegistry and pass it to createContextMiddleware(). * * Built-in loaders cover the standard Constructive modules: * - rlsModule (routing-plane rls_settings) diff --git a/packages/express-context/src/loaders/registry.ts b/packages/express-context/src/loaders/registry.ts index d4d8701eff..fa43539d9d 100644 --- a/packages/express-context/src/loaders/registry.ts +++ b/packages/express-context/src/loaders/registry.ts @@ -9,8 +9,8 @@ * parallel. Useful for pre-warming or migration from the monolithic * svcCache pattern. * - * Each loader's result is independently cached per databaseId — resolving - * one module never invalidates another. + * Each loader's result is independently cached per exact pool/schema/database + * contract — resolving one module never invalidates another. */ import { Logger } from '@pgpmjs/logger'; @@ -26,8 +26,8 @@ export interface LoaderRegistry { /** * Resolve a single loader by name (lazy, on-demand). * Returns undefined if the loader isn't registered or the module - * isn't provisioned for this database. Results are cached per databaseId - * inside the loader's own LRU — repeated calls are cheap. + * isn't provisioned for this database. Results are cached per exact context + * contract inside the loader's own LRU — repeated calls are cheap. */ resolve(name: string, ctx: LoaderContext): Promise; @@ -40,8 +40,8 @@ export interface LoaderRegistry { /** Check whether a loader is registered. */ has(name: string): boolean; - /** Invalidate caches for one database (or all databases if omitted). */ - invalidate(databaseId?: string): void; + /** Invalidate one database, optionally limited to an exact pool pair. */ + invalidate(databaseId?: string, context?: LoaderContext): void; /** List all registered loader names. */ readonly names: string[]; @@ -96,9 +96,9 @@ export function createLoaderRegistry(): LoaderRegistry { return loaders.has(name); }, - invalidate(databaseId?: string): void { + invalidate(databaseId?: string, context?: LoaderContext): void { for (const loader of loaders.values()) { - loader.invalidate(databaseId); + loader.invalidate(databaseId, context); } log.debug( databaseId diff --git a/packages/express-context/src/loaders/types.ts b/packages/express-context/src/loaders/types.ts index cec903d7b3..57f74ad893 100644 --- a/packages/express-context/src/loaders/types.ts +++ b/packages/express-context/src/loaders/types.ts @@ -1,9 +1,9 @@ /** * Module Loader Types * - * A ModuleLoader is a per-database cached lookup that resolves config - * from the routing DB or tenant DB. Each loader owns its own LRU cache - * keyed by databaseId, with independent TTL and eviction. + * A ModuleLoader is a cached lookup that resolves config from the routing DB + * or tenant DB. Each loader owns an independent, bounded LRU keyed by the exact + * pool/schema/database/API contract. * * Loaders are registered in a LoaderRegistry and resolved in parallel * during context building. The result is a typed modules map on @@ -69,16 +69,19 @@ export interface LoaderContext { } /** - * A single module loader. Encapsulates the SQL query, type transform, - * and per-databaseId LRU cache for one piece of per-database config. + * A single module loader. Encapsulates the SQL query, type transform, and + * exact-contract LRU cache for one piece of per-database config. */ export interface ModuleLoader { /** Unique name (used in log prefix and as the key in the modules map) */ readonly name: string; /** Resolve the module config for a given database. Returns undefined if not provisioned. */ resolve(ctx: LoaderContext): Promise; - /** Invalidate the cache for one database (or all databases if omitted) */ - invalidate(databaseId?: string): void; + /** + * Invalidate one logical database across all physical pools, or only the + * exact pool pair represented by `context`. Omitting both clears everything. + */ + invalidate(databaseId?: string, context?: LoaderContext): void; /** Current number of cached entries */ readonly cacheSize: number; } From 8950eaf6824fa129801391345f5c98b497373669 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 7 Sep 2026 04:47:18 +0000 Subject: [PATCH 2/4] fix(express-context): preserve invalidation and absence semantics --- packages/express-context/README.md | 14 ++ .../__tests__/loaders/cache-lifecycle.test.ts | 201 ++++++++++++------ .../src/loaders/create-loader.ts | 40 ++-- .../express-context/src/loaders/registry.ts | 8 +- packages/express-context/src/loaders/types.ts | 6 +- 5 files changed, 174 insertions(+), 95 deletions(-) diff --git a/packages/express-context/README.md b/packages/express-context/README.md index f1c19d2e6f..e32afbecf3 100644 --- a/packages/express-context/README.md +++ b/packages/express-context/README.md @@ -64,6 +64,20 @@ hard expiry bounds, and concurrent misses for one exact contract share a single resolution. Loaders are registered in a `LoaderRegistry` and resolved lazily via `useModule(name)`. +The existing `invalidate(databaseId?)` API is retained by design. Passing a +database ID invalidates that database across all pools, routing schemas, and +APIs; omitting it clears the loader's entire cache. Registry invalidation applies +the same operation to every registered loader. Matching in-flight resolutions +cannot repopulate an invalidated cache, although their existing callers can still +receive the results. Pool/schema-specific invalidation is not required for this +cache isolation change. + +Absence remains uncached by design: neither `undefined` results nor PostgreSQL +`42P01` (undefined table) results are stored, so subsequent calls can discover +newly available configuration. Concurrent calls may still share the same +in-flight resolution. Negative caching is not required for this change; other +resolution errors continue to propagate without being cached. + ### Built-in loaders | Loader | Source | Description | diff --git a/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts b/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts index 80d704c7dc..448823900e 100644 --- a/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts +++ b/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts @@ -57,36 +57,46 @@ describe('module loader cache lifecycle', () => { expect(loader.cacheSize).toBe(4); }); - it('invalidates one physical contract without evicting its logical twin', async () => { + it('invalidates every database when called without a database ID', async () => { const ctxA = context(); - const ctxB = context(); + const ctxB = context({ databaseId: 'database-b' }); let generation = 0; const resolve = jest.fn(async () => ++generation); - const loader = createModuleLoader({ name: 'exact-invalidation', resolve }); + const loader = createModuleLoader({ name: 'global-invalidation', resolve }); const firstA = await loader.resolve(ctxA); const firstB = await loader.resolve(ctxB); - loader.invalidate(ctxA.databaseId, ctxA); + loader.invalidate(); - await expect(loader.resolve(ctxB)).resolves.toBe(firstB); + expect(loader.cacheSize).toBe(0); await expect(loader.resolve(ctxA)).resolves.not.toBe(firstA); - expect(resolve).toHaveBeenCalledTimes(3); + await expect(loader.resolve(ctxB)).resolves.not.toBe(firstB); + expect(resolve).toHaveBeenCalledTimes(4); }); - it('invalidates a logical database across every physical contract', async () => { + it('invalidates all pools, schemas, and APIs for one database only', async () => { const ctxA = context(); - const ctxB = context(); + const contexts = [ + ctxA, + context(), + context({ ...ctxA, routingSchema: 'routing_shadow' }), + context({ ...ctxA, apiId: 'api-b' }) + ]; + const otherDatabase = context({ ...ctxA, databaseId: 'database-b' }); let generation = 0; const resolve = jest.fn(async () => ++generation); const loader = createModuleLoader({ name: 'logical-invalidation', resolve }); - await loader.resolve(ctxA); - await loader.resolve(ctxB); + const previous = await Promise.all(contexts.map((ctx) => loader.resolve(ctx))); + const otherValue = await loader.resolve(otherDatabase); loader.invalidate('database-a'); - await loader.resolve(ctxA); - await loader.resolve(ctxB); - expect(resolve).toHaveBeenCalledTimes(4); + expect(loader.cacheSize).toBe(1); + await expect(loader.resolve(otherDatabase)).resolves.toBe(otherValue); + for (let index = 0; index < contexts.length; index++) { + await expect(loader.resolve(contexts[index])).resolves.not.toBe(previous[index]); + } + expect(resolve).toHaveBeenCalledTimes(contexts.length * 2 + 1); }); it('coalesces concurrent misses for one exact contract', async () => { @@ -104,29 +114,73 @@ describe('module loader cache lifecycle', () => { expect(resolve).toHaveBeenCalledTimes(1); }); - it('does not publish a resolution invalidated while it is in flight', async () => { - const ctx = context(); - let complete!: (value: string) => void; - const first = new Promise((resolve) => { - complete = resolve; - }); - const resolve = jest.fn() - .mockImplementationOnce(() => first) - .mockResolvedValueOnce('fresh-config'); - const loader = createModuleLoader({ - name: 'inflight-invalidation', - resolve - }); - - const stale = loader.resolve(ctx); - loader.invalidate(ctx.databaseId, ctx); - const fresh = loader.resolve(ctx); - await expect(fresh).resolves.toBe('fresh-config'); - complete('stale-config'); - await expect(stale).resolves.toBe('stale-config'); - await expect(loader.resolve(ctx)).resolves.toBe('fresh-config'); - expect(resolve).toHaveBeenCalledTimes(2); - }); + it.each(['database', 'global'])( + 'does not republish an old result after %s invalidation and a fresh result', + async (scope) => { + const ctx = context(); + let complete!: (value: string) => void; + const first = new Promise((resolve) => { + complete = resolve; + }); + const resolve = jest.fn() + .mockImplementationOnce(() => first) + .mockResolvedValueOnce('fresh-config'); + const loader = createModuleLoader({ + name: 'inflight-invalidation', + resolve + }); + + const stale = loader.resolve(ctx); + await Promise.resolve(); + loader.invalidate(scope === 'database' ? ctx.databaseId : undefined); + const fresh = loader.resolve(ctx); + await expect(fresh).resolves.toBe('fresh-config'); + complete('stale-config'); + await expect(stale).resolves.toBe('stale-config'); + await expect(loader.resolve(ctx)).resolves.toBe('fresh-config'); + expect(resolve).toHaveBeenCalledTimes(2); + } + ); + + it.each(['database', 'global'])( + 'keeps the new query pending when an old result finishes after %s invalidation', + async (scope) => { + const ctx = context(); + let completeOld!: (value: string) => void; + let completeFresh!: (value: string) => void; + const oldResult = new Promise((resolve) => { + completeOld = resolve; + }); + const freshResult = new Promise((resolve) => { + completeFresh = resolve; + }); + const resolve = jest.fn() + .mockImplementationOnce(() => oldResult) + .mockImplementationOnce(() => freshResult); + const loader = createModuleLoader({ + name: 'pending-invalidation', + resolve + }); + + const stale = loader.resolve(ctx); + await Promise.resolve(); + loader.invalidate(scope === 'database' ? ctx.databaseId : undefined); + const fresh = loader.resolve(ctx); + + completeOld('stale-config'); + await expect(stale).resolves.toBe('stale-config'); + expect(loader.cacheSize).toBe(0); + const coalesced = loader.resolve(ctx); + completeFresh('fresh-config'); + + await expect(Promise.all([fresh, coalesced])).resolves.toEqual([ + 'fresh-config', + 'fresh-config' + ]); + await expect(loader.resolve(ctx)).resolves.toBe('fresh-config'); + expect(resolve).toHaveBeenCalledTimes(2); + } + ); it('uses a hard TTL that cache hits cannot extend', async () => { let now = 1; @@ -171,33 +225,58 @@ describe('module loader cache lifecycle', () => { expect(resolve).toHaveBeenCalledTimes(102); }); - it('caches undefined results without confusing them for misses', async () => { - const resolve = jest.fn(async (): Promise => undefined); + it('discovers newly available config after an uncached undefined result', async () => { + const resolve = jest.fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce('new-config'); const loader = createModuleLoader({ name: 'absent-module', resolve }); const ctx = context(); await expect(loader.resolve(ctx)).resolves.toBeUndefined(); - await expect(loader.resolve(ctx)).resolves.toBeUndefined(); + expect(loader.cacheSize).toBe(0); + await expect(loader.resolve(ctx)).resolves.toBe('new-config'); + await expect(loader.resolve(ctx)).resolves.toBe('new-config'); - expect(resolve).toHaveBeenCalledTimes(1); + expect(resolve).toHaveBeenCalledTimes(2); expect(loader.cacheSize).toBe(1); }); - it('caches an absent module reported by PostgreSQL undefined_table', async () => { + it('retries PostgreSQL undefined_table on the next call without invalidation', async () => { const error = Object.assign(new Error('module table absent'), { code: '42P01' }); - const resolve = jest.fn().mockRejectedValue(error); + const resolve = jest.fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce('new-config'); const loader = createModuleLoader({ name: 'missing-table', resolve }); const ctx = context(); await expect(loader.resolve(ctx)).resolves.toBeUndefined(); - await expect(loader.resolve(ctx)).resolves.toBeUndefined(); + expect(loader.cacheSize).toBe(0); + await expect(loader.resolve(ctx)).resolves.toBe('new-config'); + await expect(loader.resolve(ctx)).resolves.toBe('new-config'); - expect(resolve).toHaveBeenCalledTimes(1); + expect(resolve).toHaveBeenCalledTimes(2); expect(loader.cacheSize).toBe(1); }); + it('coalesces concurrent absence checks without caching their result', async () => { + const resolve = jest.fn(async (): Promise => undefined); + const loader = createModuleLoader({ name: 'absent-coalescing', resolve }); + const ctx = context(); + + await expect(Promise.all([ + loader.resolve(ctx), + loader.resolve(ctx), + loader.resolve(ctx) + ])).resolves.toEqual([undefined, undefined, undefined]); + expect(resolve).toHaveBeenCalledTimes(1); + expect(loader.cacheSize).toBe(0); + + await expect(loader.resolve(ctx)).resolves.toBeUndefined(); + expect(resolve).toHaveBeenCalledTimes(2); + }); + it('preserves other resolution errors and never caches them', async () => { const error = new Error('routing query failed'); const resolve = jest.fn() @@ -213,20 +292,22 @@ describe('module loader cache lifecycle', () => { expect(loader.cacheSize).toBe(1); }); - it('forwards exact invalidation context through the registry', () => { - const ctx = context(); - const invalidate = jest.fn(); - const loader = { - name: 'registered', - resolve: jest.fn(), - invalidate, - cacheSize: 0 - } as ModuleLoader; - const registry = createLoaderRegistry(); - registry.register(loader); - - registry.invalidate(ctx.databaseId, ctx); - - expect(invalidate).toHaveBeenCalledWith(ctx.databaseId, ctx); - }); + it.each([undefined, 'database-a'])( + 'forwards database ID %s through registry invalidation', + (databaseId) => { + const invalidate = jest.fn(); + const loader = { + name: 'registered', + resolve: jest.fn(), + invalidate, + cacheSize: 0 + } as ModuleLoader; + const registry = createLoaderRegistry(); + registry.register(loader); + + registry.invalidate(databaseId); + + expect(invalidate).toHaveBeenCalledWith(databaseId); + } + ); }); diff --git a/packages/express-context/src/loaders/create-loader.ts b/packages/express-context/src/loaders/create-loader.ts index 7d77fc7112..30d85b71d7 100644 --- a/packages/express-context/src/loaders/create-loader.ts +++ b/packages/express-context/src/loaders/create-loader.ts @@ -65,24 +65,16 @@ const cacheKey = (ctx: LoaderContext, contract: LoaderCacheContract): string => ]); interface LoaderCacheEntry { - contract: LoaderCacheContract; - value: T | undefined; + databaseId: string; + value: T; } interface PendingResolution { - contract: LoaderCacheContract; + databaseId: string; invalidated: boolean; promise: Promise; } -const samePhysicalContract = ( - left: LoaderCacheContract, - right: LoaderCacheContract -): boolean => - left.routingPoolIdentity === right.routingPoolIdentity - && left.tenantPoolIdentity === right.tenantPoolIdentity - && left.routingSchema === right.routingSchema; - export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoader { const log = new Logger(`loader:${opts.name}`); const cache = new LRUCache>({ @@ -122,15 +114,16 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade // Any other resolution error (bad query, ambiguous config) propagates — // never silently coerced into "module absent". const resolution: PendingResolution = { - contract, + databaseId: contract.databaseId, invalidated: false, promise: Promise.resolve(undefined) }; resolution.promise = Promise.resolve().then(async () => { try { const value = await opts.resolve(ctx); - if (!resolution.invalidated) { - cache.set(key, { contract, value }); + // Keep absence uncached so subsequent calls can discover new config. + if (!resolution.invalidated && value !== undefined) { + cache.set(key, { databaseId: contract.databaseId, value }); } return value; } catch (e: any) { @@ -138,9 +131,6 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade log.debug( `Module tables absent for databaseId=${logicalKey}: ${e.message}` ); - if (!resolution.invalidated) { - cache.set(key, { contract, value: undefined }); - } return undefined; } log.warn(`Failed to resolve databaseId=${logicalKey}: ${e.message}`); @@ -155,8 +145,8 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade return resolution.promise; }, - invalidate(databaseId?: string, context?: LoaderContext): void { - if (!databaseId && !context) { + invalidate(databaseId?: string): void { + if (!databaseId) { const previousSize = cache.size; cache.clear(); for (const resolution of pending.values()) { @@ -166,21 +156,15 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade return; } - const exact = context ? cacheContract(context) : null; - const matches = (contract: LoaderCacheContract): boolean => - (!databaseId || contract.databaseId === databaseId) - && (!exact || samePhysicalContract(contract, exact)); let cleared = 0; for (const [key, entry] of cache.entries()) { - if (!matches(entry.contract)) continue; + if (entry.databaseId !== databaseId) continue; if (cache.delete(key)) cleared++; } for (const resolution of pending.values()) { - if (matches(resolution.contract)) resolution.invalidated = true; + if (resolution.databaseId === databaseId) resolution.invalidated = true; } - log.debug( - `Invalidated ${cleared} entries${databaseId ? ` for databaseId=${databaseId}` : ''}` - ); + log.debug(`Invalidated ${cleared} entries for databaseId=${databaseId}`); }, get cacheSize(): number { diff --git a/packages/express-context/src/loaders/registry.ts b/packages/express-context/src/loaders/registry.ts index fa43539d9d..2cf7e40ae6 100644 --- a/packages/express-context/src/loaders/registry.ts +++ b/packages/express-context/src/loaders/registry.ts @@ -40,8 +40,8 @@ export interface LoaderRegistry { /** Check whether a loader is registered. */ has(name: string): boolean; - /** Invalidate one database, optionally limited to an exact pool pair. */ - invalidate(databaseId?: string, context?: LoaderContext): void; + /** Invalidate caches for one database (or all databases if omitted). */ + invalidate(databaseId?: string): void; /** List all registered loader names. */ readonly names: string[]; @@ -96,9 +96,9 @@ export function createLoaderRegistry(): LoaderRegistry { return loaders.has(name); }, - invalidate(databaseId?: string, context?: LoaderContext): void { + invalidate(databaseId?: string): void { for (const loader of loaders.values()) { - loader.invalidate(databaseId, context); + loader.invalidate(databaseId); } log.debug( databaseId diff --git a/packages/express-context/src/loaders/types.ts b/packages/express-context/src/loaders/types.ts index 57f74ad893..35e1b4edac 100644 --- a/packages/express-context/src/loaders/types.ts +++ b/packages/express-context/src/loaders/types.ts @@ -78,10 +78,10 @@ export interface ModuleLoader { /** Resolve the module config for a given database. Returns undefined if not provisioned. */ resolve(ctx: LoaderContext): Promise; /** - * Invalidate one logical database across all physical pools, or only the - * exact pool pair represented by `context`. Omitting both clears everything. + * Invalidate one logical database across all pools, schemas, and APIs. + * Omitting the database ID clears everything. */ - invalidate(databaseId?: string, context?: LoaderContext): void; + invalidate(databaseId?: string): void; /** Current number of cached entries */ readonly cacheSize: number; } From 724b4400183a0ef7debf17615be874ad3aea60ab Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 7 Sep 2026 06:06:11 +0000 Subject: [PATCH 3/4] fix(express-context): retain logical loader cache keys --- packages/express-context/README.md | 25 +++--- .../__tests__/loaders/cache-lifecycle.test.ts | 69 ++++++++------- .../src/loaders/create-loader.ts | 83 ++++--------------- packages/express-context/src/loaders/index.ts | 6 +- .../express-context/src/loaders/registry.ts | 8 +- packages/express-context/src/loaders/types.ts | 8 +- 6 files changed, 82 insertions(+), 117 deletions(-) diff --git a/packages/express-context/README.md b/packages/express-context/README.md index e32afbecf3..ea6b17e937 100644 --- a/packages/express-context/README.md +++ b/packages/express-context/README.md @@ -58,19 +58,24 @@ app.post('/v1/chat', async (req, res) => { ## Module Loaders Each loader encapsulates a SQL query + type transform + bounded LRU cache for -one piece of per-database configuration. Entries are isolated by the exact -routing pool, tenant pool, routing schema, database, and API contract. TTLs are -hard expiry bounds, and concurrent misses for one exact contract share a single -resolution. Loaders are registered in a `LoaderRegistry` and resolved lazily -via `useModule(name)`. +one piece of per-database configuration. The existing cache key is retained: +`databaseId:apiId` when an API ID is present, otherwise `databaseId`. TTLs are +hard expiry bounds, and concurrent misses for the same key share a single +resolution. Loaders are registered in a `LoaderRegistry` and resolved lazily via +`useModule(name)`. + +Logical database IDs already distinguish tenant databases, and the optional API +ID distinguishes their API configuration. Pool identities and routing schemas +are not added to the key; extending cache identity is not required for these +TTL, concurrency, and invalidation improvements. The existing `invalidate(databaseId?)` API is retained by design. Passing a -database ID invalidates that database across all pools, routing schemas, and -APIs; omitting it clears the loader's entire cache. Registry invalidation applies -the same operation to every registered loader. Matching in-flight resolutions +database ID invalidates that database and all of its API entries; omitting it +clears the loader's entire cache. Registry invalidation applies the same operation +to every registered loader. Matching in-flight resolutions cannot repopulate an invalidated cache, although their existing callers can still -receive the results. Pool/schema-specific invalidation is not required for this -cache isolation change. +receive the results. Global and database-level invalidation are sufficient; +additional invalidation parameters are not required for this change. Absence remains uncached by design: neither `undefined` results nor PostgreSQL `42P01` (undefined table) results are stored, so subsequent calls can discover diff --git a/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts b/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts index 448823900e..c8bb769fb1 100644 --- a/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts +++ b/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts @@ -26,37 +26,45 @@ describe('module loader cache lifecycle', () => { jest.restoreAllMocks(); }); - it('isolates identical logical IDs across physical pools and routing schemas', async () => { - const routingA = pool(); - const routingB = pool(); - const tenantA = pool(); - const tenantB = pool(); - const ctxA = context({ routingPool: routingA, tenantPool: tenantA }); - const ctxB = context({ routingPool: routingB, tenantPool: tenantA }); - const ctxC = context({ routingPool: routingA, tenantPool: tenantB }); - const ctxD = context({ - routingPool: routingA, - routingSchema: 'routing_shadow', - tenantPool: tenantA - }); - const resolve = jest.fn(async (ctx: LoaderContext) => { - if (ctx.routingSchema === 'routing_shadow') return 'schema-d'; - if (ctx.routingPool === routingB) return 'routing-b'; - if (ctx.tenantPool === tenantB) return 'tenant-c'; - return 'contract-a'; - }); + it('isolates databases and optional APIs that share the same pools', async () => { + const ctxA = context(); + const ctxB = context({ ...ctxA, databaseId: 'database-b' }); + const ctxC = context({ ...ctxA, apiId: 'api-b' }); + const ctxD = context({ ...ctxA, apiId: undefined }); + let generation = 0; + const resolve = jest.fn(async () => ++generation); const loader = createModuleLoader({ name: 'isolation', resolve }); - await expect(loader.resolve(ctxA)).resolves.toBe('contract-a'); - await expect(loader.resolve(ctxB)).resolves.toBe('routing-b'); - await expect(loader.resolve(ctxC)).resolves.toBe('tenant-c'); - await expect(loader.resolve(ctxD)).resolves.toBe('schema-d'); - await expect(loader.resolve(ctxA)).resolves.toBe('contract-a'); + for (const [index, ctx] of [ctxA, ctxB, ctxC, ctxD].entries()) { + await expect(loader.resolve(ctx)).resolves.toBe(index + 1); + } + for (const [index, ctx] of [ctxA, ctxB, ctxC, ctxD].entries()) { + await expect(loader.resolve(ctx)).resolves.toBe(index + 1); + } expect(resolve).toHaveBeenCalledTimes(4); expect(loader.cacheSize).toBe(4); }); + it('reuses the same logical key across pool and routing schema changes', async () => { + const ctx = context(); + const resolve = jest.fn(async () => 'shared-config'); + const loader = createModuleLoader({ name: 'logical-key', resolve }); + + await expect(loader.resolve(ctx)).resolves.toBe('shared-config'); + for (const overrides of [ + { routingPool: pool() }, + { tenantPool: pool() }, + { routingSchema: 'routing_shadow' } + ]) { + await expect(loader.resolve(context({ ...ctx, ...overrides }))) + .resolves.toBe('shared-config'); + } + + expect(resolve).toHaveBeenCalledTimes(1); + expect(loader.cacheSize).toBe(1); + }); + it('invalidates every database when called without a database ID', async () => { const ctxA = context(); const ctxB = context({ databaseId: 'database-b' }); @@ -74,12 +82,11 @@ describe('module loader cache lifecycle', () => { expect(resolve).toHaveBeenCalledTimes(4); }); - it('invalidates all pools, schemas, and APIs for one database only', async () => { + it('invalidates the plain database key and all of its API entries only', async () => { const ctxA = context(); const contexts = [ ctxA, - context(), - context({ ...ctxA, routingSchema: 'routing_shadow' }), + context({ ...ctxA, apiId: undefined }), context({ ...ctxA, apiId: 'api-b' }) ]; const otherDatabase = context({ ...ctxA, databaseId: 'database-b' }); @@ -99,7 +106,7 @@ describe('module loader cache lifecycle', () => { expect(resolve).toHaveBeenCalledTimes(contexts.length * 2 + 1); }); - it('coalesces concurrent misses for one exact contract', async () => { + it('coalesces concurrent misses for the same logical key', async () => { const ctx = context(); const resolve = jest.fn(async () => 'shared-config'); const loader = createModuleLoader({ name: 'coalescing', resolve }); @@ -107,8 +114,8 @@ describe('module loader cache lifecycle', () => { await expect( Promise.all([ loader.resolve(ctx), - loader.resolve(ctx), - loader.resolve(ctx) + loader.resolve(context()), + loader.resolve(context({ routingSchema: 'routing_shadow' })) ]) ).resolves.toEqual(['shared-config', 'shared-config', 'shared-config']); expect(resolve).toHaveBeenCalledTimes(1); @@ -202,7 +209,7 @@ describe('module loader cache lifecycle', () => { expect(resolve).toHaveBeenCalledTimes(2); }); - it('keeps the default cache bounded to 100 completed contracts', async () => { + it('keeps the default cache bounded to 100 completed entries', async () => { const routingPool = pool(); const tenantPool = pool(); const resolve = jest.fn(async (ctx: LoaderContext) => ctx.databaseId); diff --git a/packages/express-context/src/loaders/create-loader.ts b/packages/express-context/src/loaders/create-loader.ts index 30d85b71d7..e47d39e65e 100644 --- a/packages/express-context/src/loaders/create-loader.ts +++ b/packages/express-context/src/loaders/create-loader.ts @@ -1,19 +1,15 @@ /** * create-loader — Factory for building cached ModuleLoader instances. * - * Wraps a raw resolve function with an LRU cache keyed by the physical routing - * and tenant pools, routing schema, databaseId, and apiId. Each loader gets its - * own independent cache with a configurable hard TTL and maximum size. + * Wraps a raw resolve function with an LRU cache keyed by databaseId:apiId. + * Each loader gets its own independent cache with a configurable hard TTL and + * maximum size. */ import { Logger } from '@pgpmjs/logger'; import { LRUCache } from 'lru-cache'; -import { - type LoaderContext, - type ModuleLoader, - routingSchemaOf -} from './types'; +import type { LoaderContext, ModuleLoader } from './types'; export interface CreateLoaderOptions { /** Unique loader name (used in log prefix and modules map key) */ @@ -29,46 +25,6 @@ export interface CreateLoaderOptions { const DEFAULT_TTL_MS = 60_000; const DEFAULT_MAX = 100; -let nextPoolIdentity = 0; -const poolIdentities = new WeakMap(); - -const poolIdentity = (pool: object): number => { - let identity = poolIdentities.get(pool); - if (identity === undefined) { - identity = ++nextPoolIdentity; - poolIdentities.set(pool, identity); - } - return identity; -}; - -interface LoaderCacheContract { - databaseId: string; - routingSchema: string; - routingPoolIdentity: number; - tenantPoolIdentity: number; -} - -const cacheContract = (ctx: LoaderContext): LoaderCacheContract => ({ - databaseId: ctx.databaseId, - routingSchema: routingSchemaOf(ctx), - routingPoolIdentity: poolIdentity(ctx.routingPool), - tenantPoolIdentity: poolIdentity(ctx.tenantPool) -}); - -const cacheKey = (ctx: LoaderContext, contract: LoaderCacheContract): string => - JSON.stringify([ - contract.routingPoolIdentity, - contract.tenantPoolIdentity, - contract.routingSchema, - contract.databaseId, - ctx.apiId ?? null - ]); - -interface LoaderCacheEntry { - databaseId: string; - value: T; -} - interface PendingResolution { databaseId: string; invalidated: boolean; @@ -77,7 +33,7 @@ interface PendingResolution { export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoader { const log = new Logger(`loader:${opts.name}`); - const cache = new LRUCache>({ + const cache = new LRUCache({ max: opts.max ?? DEFAULT_MAX, ttl: opts.ttlMs ?? DEFAULT_TTL_MS, ttlResolution: 0, @@ -90,31 +46,27 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade name: opts.name, async resolve(ctx: LoaderContext): Promise { - const logicalKey = ctx.apiId - ? `${ctx.databaseId}:${ctx.apiId}` - : ctx.databaseId; - const contract = cacheContract(ctx); - const key = cacheKey(ctx, contract); + const key = ctx.apiId ? `${ctx.databaseId}:${ctx.apiId}` : ctx.databaseId; const cached = cache.get(key); if (cached !== undefined) { - log.debug(`Cache HIT databaseId=${logicalKey}`); - return cached.value; + log.debug(`Cache HIT databaseId=${key}`); + return cached; } const existing = pending.get(key); if (existing && !existing.invalidated) { - log.debug(`Cache COALESCE databaseId=${logicalKey}`); + log.debug(`Cache COALESCE databaseId=${key}`); return existing.promise; } - log.debug(`Cache MISS databaseId=${logicalKey}, resolving`); + log.debug(`Cache MISS databaseId=${key}, resolving`); // "Not provisioned" is expressed by the loader returning undefined, or // by the module's tables not existing at all (42P01 undefined_table). // Any other resolution error (bad query, ambiguous config) propagates — // never silently coerced into "module absent". const resolution: PendingResolution = { - databaseId: contract.databaseId, + databaseId: ctx.databaseId, invalidated: false, promise: Promise.resolve(undefined) }; @@ -123,17 +75,17 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade const value = await opts.resolve(ctx); // Keep absence uncached so subsequent calls can discover new config. if (!resolution.invalidated && value !== undefined) { - cache.set(key, { databaseId: contract.databaseId, value }); + cache.set(key, value); } return value; } catch (e: any) { if (e.code === '42P01') { log.debug( - `Module tables absent for databaseId=${logicalKey}: ${e.message}` + `Module tables absent for databaseId=${key}: ${e.message}` ); return undefined; } - log.warn(`Failed to resolve databaseId=${logicalKey}: ${e.message}`); + log.warn(`Failed to resolve databaseId=${key}: ${e.message}`); throw e; } finally { if (pending.get(key) === resolution) { @@ -157,9 +109,10 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade } let cleared = 0; - for (const [key, entry] of cache.entries()) { - if (entry.databaseId !== databaseId) continue; - if (cache.delete(key)) cleared++; + for (const key of cache.keys()) { + if (key === databaseId || key.startsWith(`${databaseId}:`)) { + if (cache.delete(key)) cleared++; + } } for (const resolution of pending.values()) { if (resolution.databaseId === databaseId) resolution.invalidated = true; diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index 402eaa1a78..09bf40e49a 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -1,9 +1,9 @@ /** * Module Loaders — pluggable per-database cached lookups. * - * Each loader encapsulates a SQL query + type transform + bounded exact-context - * LRU cache for one piece of per-database configuration. Register loaders in a - * LoaderRegistry and pass it to createContextMiddleware(). + * Each loader encapsulates a SQL query + type transform + LRU cache + * for one piece of per-database configuration. Register loaders in + * a LoaderRegistry and pass it to createContextMiddleware(). * * Built-in loaders cover the standard Constructive modules: * - rlsModule (routing-plane rls_settings) diff --git a/packages/express-context/src/loaders/registry.ts b/packages/express-context/src/loaders/registry.ts index 2cf7e40ae6..2d612a0472 100644 --- a/packages/express-context/src/loaders/registry.ts +++ b/packages/express-context/src/loaders/registry.ts @@ -9,8 +9,8 @@ * parallel. Useful for pre-warming or migration from the monolithic * svcCache pattern. * - * Each loader's result is independently cached per exact pool/schema/database - * contract — resolving one module never invalidates another. + * Each loader's result is independently cached per databaseId and optional + * apiId — resolving one module never invalidates another. */ import { Logger } from '@pgpmjs/logger'; @@ -26,8 +26,8 @@ export interface LoaderRegistry { /** * Resolve a single loader by name (lazy, on-demand). * Returns undefined if the loader isn't registered or the module - * isn't provisioned for this database. Results are cached per exact context - * contract inside the loader's own LRU — repeated calls are cheap. + * isn't provisioned for this database. Results are cached per databaseId and + * optional apiId inside the loader's own LRU — repeated calls are cheap. */ resolve(name: string, ctx: LoaderContext): Promise; diff --git a/packages/express-context/src/loaders/types.ts b/packages/express-context/src/loaders/types.ts index 35e1b4edac..f80621e509 100644 --- a/packages/express-context/src/loaders/types.ts +++ b/packages/express-context/src/loaders/types.ts @@ -2,8 +2,8 @@ * Module Loader Types * * A ModuleLoader is a cached lookup that resolves config from the routing DB - * or tenant DB. Each loader owns an independent, bounded LRU keyed by the exact - * pool/schema/database/API contract. + * or tenant DB. Each loader owns an independent, bounded LRU keyed by + * databaseId and optional apiId. * * Loaders are registered in a LoaderRegistry and resolved in parallel * during context building. The result is a typed modules map on @@ -70,7 +70,7 @@ export interface LoaderContext { /** * A single module loader. Encapsulates the SQL query, type transform, and - * exact-contract LRU cache for one piece of per-database config. + * per-database/API LRU cache for one piece of per-database config. */ export interface ModuleLoader { /** Unique name (used in log prefix and as the key in the modules map) */ @@ -78,7 +78,7 @@ export interface ModuleLoader { /** Resolve the module config for a given database. Returns undefined if not provisioned. */ resolve(ctx: LoaderContext): Promise; /** - * Invalidate one logical database across all pools, schemas, and APIs. + * Invalidate one logical database, including all of its API entries. * Omitting the database ID clears everything. */ invalidate(databaseId?: string): void; From f0f4e7411a498e8210d600d04980d3f974aee169 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 8 Sep 2026 01:08:30 +0000 Subject: [PATCH 4/4] Use default loader cache TTL resolution --- .../__tests__/loaders/cache-lifecycle.test.ts | 24 ------------------- .../src/loaders/create-loader.ts | 1 - 2 files changed, 25 deletions(-) diff --git a/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts b/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts index c8bb769fb1..3b26fc3505 100644 --- a/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts +++ b/packages/express-context/__tests__/loaders/cache-lifecycle.test.ts @@ -22,10 +22,6 @@ const context = ( }); describe('module loader cache lifecycle', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - it('isolates databases and optional APIs that share the same pools', async () => { const ctxA = context(); const ctxB = context({ ...ctxA, databaseId: 'database-b' }); @@ -189,26 +185,6 @@ describe('module loader cache lifecycle', () => { } ); - it('uses a hard TTL that cache hits cannot extend', async () => { - let now = 1; - jest.spyOn(performance, 'now').mockImplementation(() => now); - const ctx = context(); - let generation = 0; - const resolve = jest.fn(async () => `config-${++generation}`); - const loader = createModuleLoader({ - name: 'hard-expiry', - ttlMs: 100, - resolve - }); - - await expect(loader.resolve(ctx)).resolves.toBe('config-1'); - now = 76; - await expect(loader.resolve(ctx)).resolves.toBe('config-1'); - now = 106; - await expect(loader.resolve(ctx)).resolves.toBe('config-2'); - expect(resolve).toHaveBeenCalledTimes(2); - }); - it('keeps the default cache bounded to 100 completed entries', async () => { const routingPool = pool(); const tenantPool = pool(); diff --git a/packages/express-context/src/loaders/create-loader.ts b/packages/express-context/src/loaders/create-loader.ts index e47d39e65e..e147d11ee2 100644 --- a/packages/express-context/src/loaders/create-loader.ts +++ b/packages/express-context/src/loaders/create-loader.ts @@ -36,7 +36,6 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade const cache = new LRUCache({ max: opts.max ?? DEFAULT_MAX, ttl: opts.ttlMs ?? DEFAULT_TTL_MS, - ttlResolution: 0, updateAgeOnGet: false, allowStale: false, });