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
15export 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
32interface Entry<TEngine> {
33 instance: TEngine;
34 lastAccess: number;
35}
36
37export 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}