tenant-instance-pool.ts132 lines · main
| 1 | /** |
| 2 | * Generic per-tenant engine instance pool — Layer 2 primitive shared |
| 3 | * between briven auth and (future) briven pay. |
| 4 | * |
| 5 | * Each service hands the pool a factory `(projectId) => Promise<TEngine>`; |
| 6 | * the pool caches instances by projectId with idle-TTL eviction and an |
| 7 | * LRU cap. See `ARCHITECTURE.md` §3 for the lifecycle contract. |
| 8 | * |
| 9 | * Concurrency guarantee: two `get(projectId)` calls in the same tick share |
| 10 | * a single factory invocation via the in-flight promise map — important |
| 11 | * when the factory builds a heavy resource (Better Auth instance + drizzle |
| 12 | * client + decrypted secrets). |
| 13 | */ |
| 14 | |
| 15 | export interface TenantInstancePoolOpts<TEngine> { |
| 16 | /** Maximum number of cached instances. LRU eviction at the cap. */ |
| 17 | readonly maxSize: number; |
| 18 | /** Idle TTL in ms. Instances unused for this long are evicted on next access. */ |
| 19 | readonly idleTtlMs: number; |
| 20 | /** Build a fresh instance for a project id. Called on cache miss. */ |
| 21 | readonly factory: (projectId: string) => Promise<TEngine>; |
| 22 | /** |
| 23 | * Optional hook fired when an instance is evicted (idle, LRU, or forced). |
| 24 | * Use it to release resources held by the instance (DB connections, etc). |
| 25 | * Errors are forwarded to `onEvictError` but do not fail the calling get(). |
| 26 | */ |
| 27 | readonly onEvict?: (projectId: string, instance: TEngine) => void | Promise<void>; |
| 28 | /** Receives errors thrown from `onEvict`. Default: no-op. */ |
| 29 | readonly onEvictError?: (projectId: string, error: unknown) => void; |
| 30 | } |
| 31 | |
| 32 | interface Entry<TEngine> { |
| 33 | instance: TEngine; |
| 34 | lastAccess: number; |
| 35 | } |
| 36 | |
| 37 | export class TenantInstancePool<TEngine> { |
| 38 | private readonly map = new Map<string, Entry<TEngine>>(); |
| 39 | private readonly pending = new Map<string, Promise<TEngine>>(); |
| 40 | |
| 41 | constructor(private readonly opts: TenantInstancePoolOpts<TEngine>) { |
| 42 | if (opts.maxSize <= 0) throw new Error('TenantInstancePool: maxSize must be > 0'); |
| 43 | if (opts.idleTtlMs <= 0) throw new Error('TenantInstancePool: idleTtlMs must be > 0'); |
| 44 | } |
| 45 | |
| 46 | /** |
| 47 | * Fetch (or create) the instance for a project. Concurrent calls for the |
| 48 | * same projectId share a single factory invocation. |
| 49 | */ |
| 50 | async get(projectId: string): Promise<TEngine> { |
| 51 | // Dedupe concurrent first-creates. |
| 52 | const inflight = this.pending.get(projectId); |
| 53 | if (inflight) return inflight; |
| 54 | |
| 55 | const now = Date.now(); |
| 56 | const hit = this.map.get(projectId); |
| 57 | if (hit) { |
| 58 | if (now - hit.lastAccess > this.opts.idleTtlMs) { |
| 59 | // Stale; evict + cold-create below. |
| 60 | await this.evict(projectId); |
| 61 | } else { |
| 62 | // LRU touch: re-insert to move to the tail of insertion order. |
| 63 | hit.lastAccess = now; |
| 64 | this.map.delete(projectId); |
| 65 | this.map.set(projectId, hit); |
| 66 | return hit.instance; |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | const p = this.opts |
| 71 | .factory(projectId) |
| 72 | .then(async (instance) => { |
| 73 | this.pending.delete(projectId); |
| 74 | // Evict the LRU if we're at the cap. Map insertion order = oldest first. |
| 75 | while (this.map.size >= this.opts.maxSize) { |
| 76 | const oldest = this.map.keys().next().value; |
| 77 | if (!oldest) break; |
| 78 | await this.evict(oldest); |
| 79 | } |
| 80 | this.map.set(projectId, { instance, lastAccess: Date.now() }); |
| 81 | return instance; |
| 82 | }) |
| 83 | .catch((err: unknown) => { |
| 84 | this.pending.delete(projectId); |
| 85 | throw err; |
| 86 | }); |
| 87 | this.pending.set(projectId, p); |
| 88 | return p; |
| 89 | } |
| 90 | |
| 91 | /** |
| 92 | * Force-evict a single project. Called by the dashboard's PATCH handlers |
| 93 | * when a tenant's config changes (provider toggled, secret rotated, |
| 94 | * branding updated) so the next request creates a fresh instance. |
| 95 | */ |
| 96 | async evict(projectId: string): Promise<void> { |
| 97 | const entry = this.map.get(projectId); |
| 98 | if (!entry) return; |
| 99 | this.map.delete(projectId); |
| 100 | if (this.opts.onEvict) { |
| 101 | try { |
| 102 | await this.opts.onEvict(projectId, entry.instance); |
| 103 | } catch (err) { |
| 104 | this.opts.onEvictError?.(projectId, err); |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /** Drop every cached instance. Test + shutdown hook. */ |
| 110 | async clear(): Promise<void> { |
| 111 | for (const projectId of [...this.map.keys()]) { |
| 112 | await this.evict(projectId); |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /** Number of cached instances (excludes in-flight creates). */ |
| 117 | get size(): number { |
| 118 | return this.map.size; |
| 119 | } |
| 120 | |
| 121 | /** True iff a fresh-or-warm entry exists for this projectId. */ |
| 122 | has(projectId: string): boolean { |
| 123 | const hit = this.map.get(projectId); |
| 124 | if (!hit) return false; |
| 125 | return Date.now() - hit.lastAccess <= this.opts.idleTtlMs; |
| 126 | } |
| 127 | |
| 128 | /** Snapshot of project ids in LRU order (oldest first). Test helper. */ |
| 129 | __snapshot(): string[] { |
| 130 | return [...this.map.keys()]; |
| 131 | } |
| 132 | } |