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 a8a3203428..c434a6e034 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; }