/** * RuVector Streaming Tests * * Tests for streaming features including: * - Streaming large result sets * - Backpressure handling * - Stream batch inserts * - Cursor-based iteration * * @module @claude-flow/plugins/__tests__/ruvector-streaming */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { Readable, Writable, Transform } from 'stream'; import { pipeline } from 'stream/promises'; import { randomVector, normalizedVector, randomVectors, createTestConfig, createMockPgPool, measureAsync, type MockPgPool, } from './utils/ruvector-test-utils.js'; // ============================================================================ // Stream Utilities // ============================================================================ /** * Simulates a database cursor for streaming results */ interface Cursor { read(batchSize: number): Promise; close(): Promise; position: number; exhausted: boolean; } /** * Creates a mock cursor over a dataset */ function createCursor(data: T[]): Cursor { let position = 0; let exhausted = false; return { async read(batchSize: number): Promise { if (exhausted) return []; const batch = data.slice(position, position + batchSize); position += batch.length; if (position >= data.length) { exhausted = true; } // Simulate async database read await new Promise((resolve) => setTimeout(resolve, 1)); return batch; }, async close(): Promise { exhausted = true; }, get position() { return position; }, get exhausted() { return exhausted; }, }; } /** * Vector search result for streaming */ interface StreamSearchResult { id: string; score: number; distance: number; vector?: number[]; metadata?: Record; } /** * Stream of search results from cursor */ function createSearchResultStream( cursor: Cursor, batchSize: number = 100 ): Readable { return new Readable({ objectMode: true, async read() { try { const batch = await cursor.read(batchSize); if (batch.length === 0) { this.push(null); // End of stream return; } for (const item of batch) { if (!this.push(item)) { // Backpressure - pause reading return; } } } catch (error) { this.destroy(error as Error); } }, }); } /** * Transform stream that filters results by score threshold */ function createScoreFilterTransform(minScore: number): Transform { return new Transform({ objectMode: true, transform(chunk: StreamSearchResult, encoding, callback) { if (chunk.score >= minScore) { this.push(chunk); } callback(); }, }); } /** * Transform stream that enriches results with additional data */ function createEnrichmentTransform( enrichFn: (result: StreamSearchResult) => Promise ): Transform { return new Transform({ objectMode: true, async transform(chunk: StreamSearchResult, encoding, callback) { try { const enriched = await enrichFn(chunk); this.push(enriched); callback(); } catch (error) { callback(error as Error); } }, }); } /** * Batch write stream for inserting vectors */ interface BatchWriteStream extends Writable { batchCount: number; totalWritten: number; } function createBatchWriteStream( insertFn: (batch: Array<{ id: string; vector: number[]; metadata?: Record }>) => Promise, batchSize: number = 100 ): BatchWriteStream { let batch: Array<{ id: string; vector: number[]; metadata?: Record }> = []; let batchCount = 0; let totalWritten = 0; const stream = new Writable({ objectMode: true, async write(chunk, encoding, callback) { batch.push(chunk); if (batch.length >= batchSize) { try { await insertFn(batch); totalWritten += batch.length; batchCount++; batch = []; callback(); } catch (error) { callback(error as Error); } } else { callback(); } }, async final(callback) { if (batch.length > 0) { try { await insertFn(batch); totalWritten += batch.length; batchCount++; batch = []; callback(); } catch (error) { callback(error as Error); } } else { callback(); } }, }) as BatchWriteStream; Object.defineProperty(stream, 'batchCount', { get: () => batchCount, }); Object.defineProperty(stream, 'totalWritten', { get: () => totalWritten, }); return stream; } /** * Vector generator stream */ function createVectorGeneratorStream( count: number, dimensions: number = 384, generateMetadata: boolean = true ): Readable { let generated = 0; return new Readable({ objectMode: true, read() { if (generated >= count) { this.push(null); return; } const vector = { id: `gen-${Date.now()}-${generated}`, vector: normalizedVector(dimensions), ...(generateMetadata && { metadata: { index: generated, timestamp: Date.now(), batch: Math.floor(generated / 100), }, }), }; generated++; this.push(vector); }, }); } /** * Simulates slow consumer for backpressure testing */ function createSlowConsumer(delayMs: number): Writable { return new Writable({ objectMode: true, async write(chunk, encoding, callback) { await new Promise((resolve) => setTimeout(resolve, delayMs)); callback(); }, }); } // ============================================================================ // Mock Database Operations // ============================================================================ interface MockStreamingClient { searchStream(query: number[], options: { k: number; metric: 'cosine' | 'euclidean'; batchSize?: number; includeVector?: boolean; }): Readable; insertStream(options: { tableName: string; batchSize?: number; }): BatchWriteStream; createCursor(query: string, params?: unknown[]): Promise>>; data: Map }>; } function createMockStreamingClient(): MockStreamingClient { const data = new Map }>(); // Pre-populate with test data for (let i = 0; i < 10000; i++) { data.set(`vec-${i}`, { vector: normalizedVector(384), metadata: { index: i, category: i % 10 }, }); } return { data, searchStream(query, options) { // Generate mock search results const results: StreamSearchResult[] = []; for (const [id, { vector, metadata }] of data) { const dot = query.reduce((sum, v, i) => sum + v * vector[i], 0); const score = (dot + 1) / 2; // Normalize to 0-1 results.push({ id, score, distance: 1 - score, ...(options.includeVector && { vector }), metadata, }); } // Sort by score descending results.sort((a, b) => b.score - a.score); // Limit to k results const topK = results.slice(0, options.k); // Create cursor and stream const cursor = createCursor(topK); return createSearchResultStream(cursor, options.batchSize); }, insertStream(options) { return createBatchWriteStream(async (batch) => { for (const item of batch) { data.set(item.id, { vector: item.vector, metadata: item.metadata, }); } }, options.batchSize); }, async createCursor(query, params) { // Simple mock cursor that returns all data in pages const allData = Array.from(data.entries()).map(([id, { vector, metadata }]) => ({ id, vector, metadata, })); return createCursor(allData); }, }; } // ============================================================================ // Test Suites // ============================================================================ describe('RuVector Streaming', () => { let client: MockStreamingClient; beforeEach(() => { client = createMockStreamingClient(); }); // ========================================================================== // Streaming Search Results Tests // ========================================================================== describe('Streaming Search Results', () => { it('should stream large result sets', async () => { const query = normalizedVector(384); const k = 1000; const stream = client.searchStream(query, { k, metric: 'cosine', batchSize: 100, }); const results: StreamSearchResult[] = []; for await (const result of stream) { results.push(result); } // Note: Limited by mock data size, should return up to min(k, dataSize) expect(results.length).toBeGreaterThan(0); expect(results.length).toBeLessThanOrEqual(k); expect(results[0].score).toBeGreaterThanOrEqual(results[results.length - 1].score); }); it('should respect batch size during streaming', async () => { const query = normalizedVector(384); const batchSize = 50; const k = 200; const stream = client.searchStream(query, { k, metric: 'cosine', batchSize, }); let totalResults = 0; // Track batch reads through the cursor const results: StreamSearchResult[] = []; for await (const result of stream) { results.push(result); totalResults++; } // Results should be streamed in batches up to k items expect(totalResults).toBeGreaterThan(0); expect(totalResults).toBeLessThanOrEqual(k); }); it('should include vectors when requested', async () => { const query = normalizedVector(384); const stream = client.searchStream(query, { k: 10, metric: 'cosine', includeVector: true, }); const results: StreamSearchResult[] = []; for await (const result of stream) { results.push(result); } results.forEach((r) => { expect(r.vector).toBeDefined(); expect(r.vector).toHaveLength(384); }); }); it('should allow filtering with transform stream', async () => { const query = normalizedVector(384); const minScore = 0.7; const searchStream = client.searchStream(query, { k: 100, metric: 'cosine', }); const filterStream = createScoreFilterTransform(minScore); const results: StreamSearchResult[] = []; await pipeline( searchStream, filterStream, new Writable({ objectMode: true, write(chunk, encoding, callback) { results.push(chunk); callback(); }, }) ); results.forEach((r) => { expect(r.score).toBeGreaterThanOrEqual(minScore); }); }); it('should support enrichment transforms', async () => { const query = normalizedVector(384); const searchStream = client.searchStream(query, { k: 10, metric: 'cosine', }); const enrichStream = createEnrichmentTransform(async (result) => ({ ...result, metadata: { ...result.metadata, enrichedAt: new Date().toISOString(), source: 'ruvector', }, })); const results: StreamSearchResult[] = []; await pipeline( searchStream, enrichStream, new Writable({ objectMode: true, write(chunk, encoding, callback) { results.push(chunk); callback(); }, }) ); results.forEach((r) => { expect(r.metadata?.enrichedAt).toBeDefined(); expect(r.metadata?.source).toBe('ruvector'); }); }); }); // ========================================================================== // Backpressure Handling Tests // ========================================================================== describe('Backpressure Handling', () => { it('should handle backpressure from slow consumers', async () => { const query = normalizedVector(384); const searchStream = client.searchStream(query, { k: 100, metric: 'cosine', batchSize: 10, }); // Slow consumer - 5ms per item const slowConsumer = createSlowConsumer(5); let itemsProcessed = 0; await pipeline( searchStream, new Transform({ objectMode: true, transform(chunk, encoding, callback) { itemsProcessed++; this.push(chunk); callback(); }, }), slowConsumer ); // Should process all items despite backpressure expect(itemsProcessed).toBeGreaterThan(0); expect(itemsProcessed).toBeLessThanOrEqual(100); }, 10000); // Longer timeout for slow consumer it('should not overwhelm memory with large result sets', async () => { const query = normalizedVector(384); // Get memory before streaming const memBefore = process.memoryUsage().heapUsed; const stream = client.searchStream(query, { k: 5000, metric: 'cosine', batchSize: 50, includeVector: true, }); let count = 0; for await (const result of stream) { count++; // Don't store results - just count them } const memAfter = process.memoryUsage().heapUsed; const memDelta = memAfter - memBefore; // Should process items (limited by mock data size of 10000) expect(count).toBeGreaterThan(0); expect(count).toBeLessThanOrEqual(5000); // Memory should not grow excessively for streaming expect(memDelta).toBeLessThan(100 * 1024 * 1024); }); it('should pause and resume based on consumer speed', async () => { const data: StreamSearchResult[] = Array.from({ length: 1000 }, (_, i) => ({ id: `item-${i}`, score: 1 - i / 1000, distance: i / 1000, })); const cursor = createCursor(data); const stream = createSearchResultStream(cursor, 10); let pauseCount = 0; let resumeCount = 0; stream.on('pause', () => pauseCount++); stream.on('resume', () => resumeCount++); // Variable speed consumer let processed = 0; await pipeline( stream, new Transform({ objectMode: true, highWaterMark: 5, // Low watermark to trigger backpressure async transform(chunk, encoding, callback) { processed++; // Occasionally slow down if (processed % 100 === 0) { await new Promise((r) => setTimeout(r, 10)); } this.push(chunk); callback(); }, }), new Writable({ objectMode: true, write(chunk, encoding, callback) { callback(); }, }) ); expect(processed).toBe(1000); }); }); // ========================================================================== // Stream Batch Inserts Tests // ========================================================================== describe('Stream Batch Inserts', () => { it('should stream batch inserts', async () => { const vectorCount = 500; const batchSize = 50; const generator = createVectorGeneratorStream(vectorCount, 384); const inserter = client.insertStream({ tableName: 'test_vectors', batchSize, }); await pipeline(generator, inserter); expect(inserter.totalWritten).toBe(vectorCount); expect(inserter.batchCount).toBe(Math.ceil(vectorCount / batchSize)); }); it('should handle partial final batch', async () => { const vectorCount = 175; // Not divisible by batch size const batchSize = 50; const generator = createVectorGeneratorStream(vectorCount, 384); const inserter = client.insertStream({ tableName: 'test_vectors', batchSize, }); await pipeline(generator, inserter); expect(inserter.totalWritten).toBe(vectorCount); expect(inserter.batchCount).toBe(4); // 50 + 50 + 50 + 25 }); it('should respect insert throughput', async () => { const vectorCount = 1000; const batchSize = 100; const generator = createVectorGeneratorStream(vectorCount, 384); const inserter = client.insertStream({ tableName: 'test_vectors', batchSize, }); const { durationMs } = await measureAsync(async () => { await pipeline(generator, inserter); }); const throughput = vectorCount / (durationMs / 1000); expect(inserter.totalWritten).toBe(vectorCount); expect(throughput).toBeGreaterThan(100); // At least 100 vectors/sec }); it('should transform vectors before insert', async () => { const vectorCount = 100; const dimensions = 384; const generator = createVectorGeneratorStream(vectorCount, dimensions, false); // Normalize vectors before insert const normalizer = new Transform({ objectMode: true, transform(chunk, encoding, callback) { const magnitude = Math.sqrt( chunk.vector.reduce((sum: number, v: number) => sum + v * v, 0) ); const normalized = { ...chunk, vector: chunk.vector.map((v: number) => v / magnitude), metadata: { normalized: true }, }; this.push(normalized); callback(); }, }); const insertedVectors: Array<{ id: string; vector: number[] }> = []; const inserter = createBatchWriteStream(async (batch) => { insertedVectors.push(...batch); }, 25); await pipeline(generator, normalizer, inserter); expect(insertedVectors).toHaveLength(vectorCount); // Check all vectors are normalized insertedVectors.forEach(({ vector }) => { const magnitude = Math.sqrt(vector.reduce((sum, v) => sum + v * v, 0)); expect(magnitude).toBeCloseTo(1, 5); }); }); it('should handle insert errors gracefully', async () => { const vectorCount = 100; let errorTriggered = false; const generator = createVectorGeneratorStream(vectorCount, 384); const failingInserter = createBatchWriteStream(async (batch) => { if (!errorTriggered && batch.length > 0) { errorTriggered = true; throw new Error('Simulated insert failure'); } }, 50); await expect( pipeline(generator, failingInserter) ).rejects.toThrow('Simulated insert failure'); }); }); // ========================================================================== // Cursor Operations Tests // ========================================================================== describe('Cursor Operations', () => { it('should create and iterate cursor', async () => { const cursor = await client.createCursor('SELECT * FROM vectors'); const batches: Array[]> = []; let batch: Record[]; while ((batch = await cursor.read(100)).length > 0) { batches.push(batch); } expect(batches.length).toBeGreaterThan(0); expect(cursor.exhausted).toBe(true); await cursor.close(); }); it('should support cursor with small batch size', async () => { const cursor = await client.createCursor('SELECT * FROM vectors'); const items: Record[] = []; let batch: Record[]; while ((batch = await cursor.read(10)).length > 0 && items.length < 50) { items.push(...batch); } expect(items).toHaveLength(50); await cursor.close(); }); it('should close cursor properly', async () => { const cursor = await client.createCursor('SELECT * FROM vectors'); // Read some data await cursor.read(50); expect(cursor.exhausted).toBe(false); // Close cursor await cursor.close(); expect(cursor.exhausted).toBe(true); // Further reads should return empty const afterClose = await cursor.read(50); expect(afterClose).toHaveLength(0); }); it('should track cursor position', async () => { const cursor = await client.createCursor('SELECT * FROM vectors'); expect(cursor.position).toBe(0); await cursor.read(100); expect(cursor.position).toBe(100); await cursor.read(50); expect(cursor.position).toBe(150); await cursor.close(); }); }); // ========================================================================== // Pipeline Composition Tests // ========================================================================== describe('Pipeline Composition', () => { it('should compose multiple transforms', async () => { const query = normalizedVector(384); const searchStream = client.searchStream(query, { k: 100, metric: 'cosine', }); // Filter by score const filterTransform = createScoreFilterTransform(0.6); // Enrich results const enrichTransform = createEnrichmentTransform(async (result) => ({ ...result, metadata: { ...result.metadata, processed: true, }, })); // Limit results let count = 0; const limitTransform = new Transform({ objectMode: true, transform(chunk, encoding, callback) { if (count < 20) { this.push(chunk); count++; } callback(); }, }); const results: StreamSearchResult[] = []; await pipeline( searchStream, filterTransform, enrichTransform, limitTransform, new Writable({ objectMode: true, write(chunk, encoding, callback) { results.push(chunk); callback(); }, }) ); expect(results.length).toBeLessThanOrEqual(20); results.forEach((r) => { expect(r.score).toBeGreaterThanOrEqual(0.6); expect(r.metadata?.processed).toBe(true); }); }); it('should handle errors in pipeline', async () => { const query = normalizedVector(384); const searchStream = client.searchStream(query, { k: 100, metric: 'cosine', }); const failingTransform = new Transform({ objectMode: true, transform(chunk, encoding, callback) { callback(new Error('Transform error')); }, }); await expect( pipeline( searchStream, failingTransform, new Writable({ objectMode: true, write(chunk, encoding, callback) { callback(); }, }) ) ).rejects.toThrow('Transform error'); }); it('should support async generators', async () => { async function* generateResults() { for (let i = 0; i < 100; i++) { yield { id: `gen-${i}`, vector: normalizedVector(384), metadata: { index: i }, }; } } const results: Array<{ id: string }> = []; for await (const item of generateResults()) { results.push(item); } expect(results).toHaveLength(100); }); }); // ========================================================================== // Memory Efficiency Tests // ========================================================================== describe('Memory Efficiency', () => { it('should process large datasets with constant memory', async () => { const largeCount = 10000; const batchSize = 100; // Track memory at intervals const memSnapshots: number[] = []; const generator = createVectorGeneratorStream(largeCount, 384); let processed = 0; await pipeline( generator, new Transform({ objectMode: true, transform(chunk, encoding, callback) { processed++; // Take memory snapshot every 1000 items if (processed % 1000 === 0) { memSnapshots.push(process.memoryUsage().heapUsed); } // Don't accumulate - just pass through callback(); }, }), new Writable({ objectMode: true, write(chunk, encoding, callback) { callback(); }, }) ); expect(processed).toBe(largeCount); // Memory should not grow significantly if (memSnapshots.length > 2) { const firstSnapshot = memSnapshots[0]; const lastSnapshot = memSnapshots[memSnapshots.length - 1]; const growth = lastSnapshot - firstSnapshot; // Allow up to 20MB growth expect(growth).toBeLessThan(20 * 1024 * 1024); } }); it('should release references after streaming', async () => { // Force GC if available if (global.gc) { global.gc(); } const memBefore = process.memoryUsage().heapUsed; // Process a large stream const generator = createVectorGeneratorStream(5000, 384); await pipeline( generator, new Writable({ objectMode: true, write(chunk, encoding, callback) { callback(); }, }) ); // Force GC if available if (global.gc) { global.gc(); } const memAfter = process.memoryUsage().heapUsed; // Memory growth should be reasonable (allow 50MB variance for test environment) // In production with proper GC, this would be much lower expect(Math.abs(memAfter - memBefore)).toBeLessThan(50 * 1024 * 1024); }); }); // ========================================================================== // Edge Cases // ========================================================================== describe('Edge Cases', () => { it('should handle empty result stream', async () => { const cursor = createCursor([]); const stream = createSearchResultStream(cursor); const results: StreamSearchResult[] = []; for await (const result of stream) { results.push(result); } expect(results).toHaveLength(0); }); it('should handle single result', async () => { const cursor = createCursor([ { id: 'single', score: 1, distance: 0 }, ]); const stream = createSearchResultStream(cursor); const results: StreamSearchResult[] = []; for await (const result of stream) { results.push(result); } expect(results).toHaveLength(1); expect(results[0].id).toBe('single'); }); it('should handle stream destruction', async () => { const cursor = createCursor( Array.from({ length: 1000 }, (_, i) => ({ id: `item-${i}`, score: 1 - i / 1000, distance: i / 1000, })) ); const stream = createSearchResultStream(cursor, 10); const results: StreamSearchResult[] = []; for await (const result of stream) { results.push(result); if (results.length >= 50) { stream.destroy(); break; } } expect(results.length).toBeLessThanOrEqual(60); // May have some buffered }); it('should handle concurrent stream consumption', async () => { const query = normalizedVector(384); // Create multiple concurrent streams const streams = Array.from({ length: 5 }, () => client.searchStream(query, { k: 100, metric: 'cosine' }) ); const results = await Promise.all( streams.map(async (stream) => { const items: StreamSearchResult[] = []; for await (const item of stream) { items.push(item); } return items; }) ); expect(results).toHaveLength(5); results.forEach((r) => { // Each stream should return results (limited by mock data) expect(r.length).toBeGreaterThan(0); expect(r.length).toBeLessThanOrEqual(100); }); }); }); });