forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathkgWriteQueue.test.ts
More file actions
351 lines (276 loc) · 11.7 KB
/
Copy pathkgWriteQueue.test.ts
File metadata and controls
351 lines (276 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
import { describe, it, expect, beforeEach } from 'vitest'
import { KgWriteQueue } from './kgWriteQueue'
import type { LocalKnowledgeGraph } from '../../shared/types'
// ---------------------------------------------------------------------------
// Minimal Worker stand-in — controllable from the test.
// ---------------------------------------------------------------------------
type MsgListener = (msg: { type: string; ms?: number; message?: string }) => void
type ErrListener = (err: Error) => void
type ExitListener = (code: number) => void
class MockWorker {
private msgListeners: MsgListener[] = []
private errListeners: ErrListener[] = []
private exitListeners: ExitListener[] = []
readonly posted: unknown[] = []
terminated = false
on(event: 'message', fn: MsgListener): void
on(event: 'error', fn: ErrListener): void
on(event: 'exit', fn: ExitListener): void
on(event: string, fn: unknown): void {
if (event === 'message') this.msgListeners.push(fn as MsgListener)
else if (event === 'error') this.errListeners.push(fn as ErrListener)
else if (event === 'exit') this.exitListeners.push(fn as ExitListener)
}
postMessage(msg: unknown): void {
this.posted.push(msg)
}
terminate(): Promise<number> {
this.terminated = true
return Promise.resolve(1)
}
// All emit helpers snapshot the listener list before iterating, mirroring
// Node.js EventEmitter's behaviour where listeners added inside a handler
// don't fire in the same emission cycle.
/** Simulate the worker posting { type:'done' } */
emitDone(ms = 1): void {
for (const fn of this.msgListeners.slice()) fn({ type: 'done', ms })
}
/** Simulate the worker posting { type:'error' } */
emitWorkerError(message: string): void {
for (const fn of this.msgListeners.slice()) fn({ type: 'error', message })
}
/** Simulate the worker thread crashing (Worker 'error' event) */
emitCrash(err: Error): void {
for (const fn of this.errListeners.slice()) fn(err)
}
/** Simulate the worker thread exiting (Worker 'exit' event) */
emitExit(code = 1): void {
for (const fn of this.exitListeners.slice()) fn(code)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeGraph(label: string, nodeCount = 1): LocalKnowledgeGraph {
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
id: `${label}-node-${i}`,
label: `${label} node ${i}`,
nodeType: 'project' as const,
summary: '',
source: 'files' as const,
createdAt: Date.now() + i,
}))
return { nodes, edges: [] }
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('KgWriteQueue', () => {
let worker: MockWorker
let queue: KgWriteQueue
beforeEach(() => {
worker = new MockWorker()
queue = new KgWriteQueue(() => worker as unknown as import('worker_threads').Worker)
})
// -------------------------------------------------------------------------
// Basic round-trip
// -------------------------------------------------------------------------
it('resolves after the worker posts done', async () => {
const graph = makeGraph('A')
const p = queue.enqueue(graph)
expect(worker.posted).toHaveLength(1)
expect((worker.posted[0] as { type: string }).type).toBe('replace')
worker.emitDone()
await expect(p).resolves.toBeUndefined()
})
it('populates snapshot only after done (not before)', async () => {
const graph = makeGraph('A')
const p = queue.enqueue(graph)
expect(queue.snapshot).toBeNull()
worker.emitDone()
await p
expect(queue.snapshot).toBe(graph)
})
// -------------------------------------------------------------------------
// Coalescing
// -------------------------------------------------------------------------
it('coalesces rapid enqueues: only dispatches latest when first write finishes', async () => {
const graphA = makeGraph('A')
const graphB = makeGraph('B')
const graphC = makeGraph('C')
const pA = queue.enqueue(graphA)
const pB = queue.enqueue(graphB) // queued behind A
const pC = queue.enqueue(graphC) // replaces B in the pending slot
// Only A is dispatched so far
expect(worker.posted).toHaveLength(1)
// Finish A
worker.emitDone()
await pA
// C (not B) should now be dispatched
expect(worker.posted).toHaveLength(2)
const secondMsg = worker.posted[1] as { type: string; nodes: { id: string }[] }
expect(secondMsg.nodes[0].id).toBe(graphC.nodes[0].id)
// Finish C — both B and C callers resolve
worker.emitDone()
await Promise.all([pB, pC])
expect(queue.snapshot).toBe(graphC)
})
it('snapshot is updated to the latest written graph after coalescing', async () => {
const graphA = makeGraph('A')
const graphB = makeGraph('B')
const pA = queue.enqueue(graphA)
queue.enqueue(graphB) // pending, will be dispatched after A
worker.emitDone() // A finishes
await pA
expect(queue.snapshot).toBe(graphA)
worker.emitDone() // B finishes
expect(queue.snapshot).toBe(graphB)
})
// -------------------------------------------------------------------------
// Error paths — protocol error
// -------------------------------------------------------------------------
it('rejects when the worker posts { type:"error" }', async () => {
const graph = makeGraph('A')
const p = queue.enqueue(graph)
worker.emitWorkerError('db locked')
await expect(p).rejects.toThrow('db locked')
})
it('rejects when the worker thread crashes (error event)', async () => {
const graph = makeGraph('A')
const p = queue.enqueue(graph)
worker.emitCrash(new Error('SIGKILL'))
await expect(p).rejects.toThrow('SIGKILL')
})
it('retries pending graph on a fresh worker after crash', async () => {
const graphA = makeGraph('A')
const graphB = makeGraph('B')
const pA = queue.enqueue(graphA)
const pB = queue.enqueue(graphB) // pending
worker.emitCrash(new Error('crash'))
await expect(pA).rejects.toThrow('crash')
// flush() re-dispatches B on the same MockWorker (factory returns same instance).
worker.emitDone()
await expect(pB).resolves.toBeUndefined()
})
it('rejects when the worker factory throws at construction time', async () => {
const throwingQueue = new KgWriteQueue(() => {
throw new Error('kgWorker.js not found')
})
const graphA = makeGraph('A')
const graphB = makeGraph('B')
const pA = throwingQueue.enqueue(graphA)
const pB = throwingQueue.enqueue(graphB) // pending at time of dispatch failure
// Both should reject — factory throws drain the entire queue
await expect(pA).rejects.toThrow('kgWorker.js not found')
await expect(pB).rejects.toThrow('kgWorker.js not found')
})
// -------------------------------------------------------------------------
// Error paths — exit event
// -------------------------------------------------------------------------
it('rejects active waiter when worker exits without an error event', async () => {
const graph = makeGraph('A')
const p = queue.enqueue(graph)
// Simulate native crash / OOM kill: exit fires, no 'error' event.
worker.emitExit(137)
await expect(p).rejects.toThrow('exited unexpectedly (code 137)')
})
it('retries pending graph after unexpected exit', async () => {
const graphA = makeGraph('A')
const graphB = makeGraph('B')
const pA = queue.enqueue(graphA)
const pB = queue.enqueue(graphB) // pending
worker.emitExit(1)
await expect(pA).rejects.toThrow('exited unexpectedly')
// flush() re-dispatches B; same MockWorker instance used by factory.
worker.emitDone()
await expect(pB).resolves.toBeUndefined()
})
it('does not double-reject when both error and exit fire for the same crash', async () => {
const graph = makeGraph('A')
const p = queue.enqueue(graph)
// Node.js worker_threads can emit 'error' then 'exit' for the same crash.
worker.emitCrash(new Error('crash'))
worker.emitExit(1) // should be a no-op — guard catches it
// Only one rejection, not two.
await expect(p).rejects.toThrow('crash')
})
// -------------------------------------------------------------------------
// Shutdown — terminate()
// -------------------------------------------------------------------------
it('terminate() rejects active waiter and calls worker.terminate()', async () => {
const graph = makeGraph('A')
const p = queue.enqueue(graph)
expect(worker.terminated).toBe(false)
queue.terminate()
await expect(p).rejects.toThrow('terminated')
expect(worker.terminated).toBe(true)
})
it('terminate() rejects both active and pending waiters', async () => {
const pA = queue.enqueue(makeGraph('A'))
const pB = queue.enqueue(makeGraph('B')) // pending
queue.terminate()
await expect(pA).rejects.toThrow('terminated')
await expect(pB).rejects.toThrow('terminated')
})
it("terminate() exit event does not re-reject after terminate() clears the worker ref", async () => {
const p = queue.enqueue(makeGraph('A'))
queue.terminate()
await expect(p).rejects.toThrow('terminated')
// Firing exit after terminate() should be a silent no-op via the guard.
expect(() => worker.emitExit(1)).not.toThrow()
})
it('terminate() is safe when no worker has been created', () => {
// Queue lazily creates the worker; terminate() before any enqueue is a no-op.
expect(() => queue.terminate()).not.toThrow()
})
it('late message from stale worker after terminate() does not corrupt snapshot or waiters', async () => {
const graphA = makeGraph('A')
const p = queue.enqueue(graphA)
// Terminate while write is in flight — rejects the waiter.
queue.terminate()
await expect(p).rejects.toThrow('terminated')
expect(queue.snapshot).toBeNull()
// A buffered 'done' arrives from the now-stale worker after terminate().
// The 'message' guard (this.worker !== w) must discard it so snapshot stays
// null and no new flush is triggered.
worker.emitDone()
expect(queue.snapshot).toBeNull()
})
// -------------------------------------------------------------------------
// Snapshot hot-path correctness
// -------------------------------------------------------------------------
it('snapshot reflects only successfully written graphs', async () => {
const graphA = makeGraph('A')
const p = queue.enqueue(graphA)
worker.emitWorkerError('fail')
await expect(p).rejects.toThrow()
// snapshot stays null — no successful write
expect(queue.snapshot).toBeNull()
})
it('snapshot is not updated when the worker crashes', async () => {
const graphFirst = makeGraph('first')
const p1 = queue.enqueue(graphFirst)
worker.emitDone()
await p1
expect(queue.snapshot).toBe(graphFirst)
const graphSecond = makeGraph('second')
const p2 = queue.enqueue(graphSecond)
worker.emitCrash(new Error('crash'))
await expect(p2).rejects.toThrow()
// snapshot stays as first successful write
expect(queue.snapshot).toBe(graphFirst)
})
// -------------------------------------------------------------------------
// Sequential saves (no contention)
// -------------------------------------------------------------------------
it('handles multiple sequential saves correctly', async () => {
for (let i = 0; i < 3; i++) {
const graph = makeGraph(`seq-${i}`)
const p = queue.enqueue(graph)
worker.emitDone()
await p
expect(queue.snapshot).toEqual(graph)
}
expect(worker.posted).toHaveLength(3)
})
})