tenant-instance-pool.test.ts219 lines · main
1import { describe, expect, test } from 'bun:test';
2
3import { TenantInstancePool } from './tenant-instance-pool.js';
4
5interface FakeEngine {
6 id: string;
7 created: number;
8}
9
10function makeFactory() {
11 let calls = 0;
12 const factory = async (projectId: string): Promise<FakeEngine> => {
13 calls += 1;
14 return { id: projectId, created: Date.now() };
15 };
16 return { factory, callCount: () => calls };
17}
18
19describe('TenantInstancePool — Layer 2 primitive (ARCHITECTURE.md §3)', () => {
20 test('cache miss creates via factory; second get returns the same instance', async () => {
21 const { factory, callCount } = makeFactory();
22 const pool = new TenantInstancePool<FakeEngine>({
23 maxSize: 4,
24 idleTtlMs: 60_000,
25 factory,
26 });
27 const a = await pool.get('p_alpha');
28 const b = await pool.get('p_alpha');
29 expect(a).toBe(b);
30 expect(callCount()).toBe(1);
31 expect(pool.size).toBe(1);
32 });
33
34 test('different projectIds get different instances', async () => {
35 const { factory } = makeFactory();
36 const pool = new TenantInstancePool<FakeEngine>({
37 maxSize: 4,
38 idleTtlMs: 60_000,
39 factory,
40 });
41 const a = await pool.get('p_alpha');
42 const b = await pool.get('p_bravo');
43 expect(a).not.toBe(b);
44 expect(a.id).toBe('p_alpha');
45 expect(b.id).toBe('p_bravo');
46 expect(pool.size).toBe(2);
47 });
48
49 test('LRU eviction kicks in at maxSize', async () => {
50 const evicted: string[] = [];
51 const { factory } = makeFactory();
52 const pool = new TenantInstancePool<FakeEngine>({
53 maxSize: 2,
54 idleTtlMs: 60_000,
55 factory,
56 onEvict: (projectId) => {
57 evicted.push(projectId);
58 },
59 });
60 await pool.get('p_a'); // [p_a]
61 await pool.get('p_b'); // [p_a, p_b]
62 await pool.get('p_c'); // evicts p_a → [p_b, p_c]
63 expect(evicted).toEqual(['p_a']);
64 expect(pool.size).toBe(2);
65 expect(pool.has('p_a')).toBe(false);
66 expect(pool.has('p_b')).toBe(true);
67 expect(pool.has('p_c')).toBe(true);
68 });
69
70 test('LRU touch moves the entry to the tail (most recently used)', async () => {
71 const evicted: string[] = [];
72 const { factory } = makeFactory();
73 const pool = new TenantInstancePool<FakeEngine>({
74 maxSize: 2,
75 idleTtlMs: 60_000,
76 factory,
77 onEvict: (projectId) => {
78 evicted.push(projectId);
79 },
80 });
81 await pool.get('p_a'); // [p_a]
82 await pool.get('p_b'); // [p_a, p_b]
83 await pool.get('p_a'); // touch a → [p_b, p_a]
84 await pool.get('p_c'); // evicts p_b → [p_a, p_c]
85 expect(evicted).toEqual(['p_b']);
86 expect(pool.has('p_a')).toBe(true);
87 expect(pool.has('p_c')).toBe(true);
88 });
89
90 test('idle TTL eviction: stale entries are dropped on next get', async () => {
91 const evicted: string[] = [];
92 let factoryCalls = 0;
93 const pool = new TenantInstancePool<FakeEngine>({
94 maxSize: 4,
95 idleTtlMs: 10, // 10ms
96 factory: async (projectId) => {
97 factoryCalls += 1;
98 return { id: projectId, created: Date.now() };
99 },
100 onEvict: (projectId) => {
101 evicted.push(projectId);
102 },
103 });
104 const first = await pool.get('p_alpha');
105 await new Promise((r) => setTimeout(r, 20));
106 const second = await pool.get('p_alpha');
107 expect(second).not.toBe(first); // fresh instance after TTL
108 expect(factoryCalls).toBe(2);
109 expect(evicted).toEqual(['p_alpha']); // the stale one was evicted
110 });
111
112 test('force-evict drops the entry and fires onEvict', async () => {
113 const evicted: string[] = [];
114 const { factory } = makeFactory();
115 const pool = new TenantInstancePool<FakeEngine>({
116 maxSize: 4,
117 idleTtlMs: 60_000,
118 factory,
119 onEvict: (projectId) => {
120 evicted.push(projectId);
121 },
122 });
123 await pool.get('p_alpha');
124 await pool.evict('p_alpha');
125 expect(pool.size).toBe(0);
126 expect(evicted).toEqual(['p_alpha']);
127 // force-evict of unknown is a no-op:
128 await pool.evict('p_does_not_exist');
129 expect(evicted).toEqual(['p_alpha']);
130 });
131
132 test('clear() evicts every entry', async () => {
133 const evicted: string[] = [];
134 const { factory } = makeFactory();
135 const pool = new TenantInstancePool<FakeEngine>({
136 maxSize: 4,
137 idleTtlMs: 60_000,
138 factory,
139 onEvict: (projectId) => {
140 evicted.push(projectId);
141 },
142 });
143 await pool.get('p_a');
144 await pool.get('p_b');
145 await pool.get('p_c');
146 await pool.clear();
147 expect(pool.size).toBe(0);
148 expect(evicted.sort()).toEqual(['p_a', 'p_b', 'p_c']);
149 });
150
151 test('concurrent gets on same projectId share a single factory invocation', async () => {
152 let factoryCalls = 0;
153 const pool = new TenantInstancePool<FakeEngine>({
154 maxSize: 4,
155 idleTtlMs: 60_000,
156 factory: async (projectId) => {
157 factoryCalls += 1;
158 await new Promise((r) => setTimeout(r, 10));
159 return { id: projectId, created: Date.now() };
160 },
161 });
162 const [a, b, c] = await Promise.all([
163 pool.get('p_alpha'),
164 pool.get('p_alpha'),
165 pool.get('p_alpha'),
166 ]);
167 expect(factoryCalls).toBe(1);
168 expect(a).toBe(b);
169 expect(b).toBe(c);
170 });
171
172 test('factory rejection clears the in-flight entry (next get can retry)', async () => {
173 let attempts = 0;
174 const pool = new TenantInstancePool<FakeEngine>({
175 maxSize: 4,
176 idleTtlMs: 60_000,
177 factory: async (projectId) => {
178 attempts += 1;
179 if (attempts === 1) throw new Error('boom');
180 return { id: projectId, created: Date.now() };
181 },
182 });
183 await expect(pool.get('p_alpha')).rejects.toThrow('boom');
184 const inst = await pool.get('p_alpha');
185 expect(inst.id).toBe('p_alpha');
186 expect(attempts).toBe(2);
187 });
188
189 test('onEvictError swallows hook errors so they do not poison the caller', async () => {
190 const seen: Array<{ id: string; message: string }> = [];
191 const { factory } = makeFactory();
192 const pool = new TenantInstancePool<FakeEngine>({
193 maxSize: 1,
194 idleTtlMs: 60_000,
195 factory,
196 onEvict: () => {
197 throw new Error('teardown failed');
198 },
199 onEvictError: (projectId, err) => {
200 seen.push({ id: projectId, message: err instanceof Error ? err.message : String(err) });
201 },
202 });
203 await pool.get('p_a');
204 await pool.get('p_b'); // evicts p_a; hook throws; pool keeps going
205 expect(seen.length).toBe(1);
206 expect(seen[0]).toEqual({ id: 'p_a', message: 'teardown failed' });
207 expect(pool.has('p_b')).toBe(true);
208 });
209
210 test('constructor rejects invalid options', () => {
211 const { factory } = makeFactory();
212 expect(
213 () => new TenantInstancePool<FakeEngine>({ maxSize: 0, idleTtlMs: 1000, factory }),
214 ).toThrow();
215 expect(
216 () => new TenantInstancePool<FakeEngine>({ maxSize: 1, idleTtlMs: 0, factory }),
217 ).toThrow();
218 });
219});