import { redisTest } from "@internal/testcontainers"; import Redis from "ioredis"; import { describe, expect } from "vitest"; import { RedisRealtimeStreams } from "~/services/realtime/redisRealtimeStreams.server.js"; describe("RedisRealtimeStreams", () => { redisTest( "Should ingest chunks with correct indices and retrieve last chunk index", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_test123"; const streamId = "test-stream"; // Create a mock stream with 5 chunks const chunks = [ JSON.stringify({ chunk: 0, data: "chunk 0" }), JSON.stringify({ chunk: 1, data: "chunk 1" }), JSON.stringify({ chunk: 2, data: "chunk 2" }), JSON.stringify({ chunk: 3, data: "chunk 3" }), JSON.stringify({ chunk: 4, data: "chunk 4" }), ]; // Create a ReadableStream from the chunks const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { for (const chunk of chunks) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); // Ingest the data with default client ID const response = await redisRealtimeStreams.ingestData(stream, runId, streamId, "default"); // Verify response expect(response.status).toBe(200); // Verify chunks were stored with correct indices const streamKey = `stream:${runId}:${streamId}`; const entries = await redis.xrange(streamKey, "-", "+"); // Should have 5 chunks (no END_SENTINEL anymore) expect(entries.length).toBe(5); // Verify each chunk has the correct index for (let i = 0; i < 5; i++) { const [_id, fields] = entries[i]; // Find chunkIndex and data fields let chunkIndex: number | null = null; let data: string | null = null; for (let j = 0; j < fields.length; j += 2) { if (fields[j] === "chunkIndex") { chunkIndex = parseInt(fields[j + 1], 10); } if (fields[j] === "data") { data = fields[j + 1]; } } expect(chunkIndex).toBe(i); expect(data).toBe(chunks[i] + "\n"); } // Test getLastChunkIndex for the default client const lastChunkIndex = await redisRealtimeStreams.getLastChunkIndex( runId, streamId, "default" ); expect(lastChunkIndex).toBe(4); // Last chunk should be index 4 // Cleanup await redis.del(streamKey); await redis.quit(); } ); redisTest( "Should resume from specified chunk index and skip duplicates", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_test456"; const streamId = "test-stream-resume"; // First, ingest chunks 0-2 const initialChunks = [ JSON.stringify({ chunk: 0, data: "chunk 0" }), JSON.stringify({ chunk: 1, data: "chunk 1" }), JSON.stringify({ chunk: 2, data: "chunk 2" }), ]; const encoder = new TextEncoder(); const initialStream = new ReadableStream({ start(controller) { for (const chunk of initialChunks) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(initialStream, runId, streamId, "default"); // Verify we have 3 chunks let lastChunkIndex = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "default"); expect(lastChunkIndex).toBe(2); // Now "resume" from chunk 3 with new chunks (simulating a retry) // When client queries server, server says "I have up to chunk 2" // So client resumes from chunk 3 onwards const resumeChunks = [ JSON.stringify({ chunk: 3, data: "chunk 3" }), // New JSON.stringify({ chunk: 4, data: "chunk 4" }), // New ]; const resumeStream = new ReadableStream({ start(controller) { for (const chunk of resumeChunks) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); // Resume from chunk 3 (server tells us it already has 0-2) await redisRealtimeStreams.ingestData(resumeStream, runId, streamId, "default", 3); // Verify we now have 5 chunks total (0, 1, 2, 3, 4) const streamKey = `stream:${runId}:${streamId}`; const entries = await redis.xrange(streamKey, "-", "+"); expect(entries.length).toBe(5); // Verify last chunk index is 4 lastChunkIndex = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "default"); expect(lastChunkIndex).toBe(4); // Verify chunk indices are sequential for (let i = 0; i < 5; i++) { const [_id, fields] = entries[i]; let chunkIndex: number | null = null; for (let j = 0; j < fields.length; j += 2) { if (fields[j] === "chunkIndex") { chunkIndex = parseInt(fields[j + 1], 10); } } expect(chunkIndex).toBe(i); } // Cleanup await redis.del(streamKey); await redis.quit(); } ); redisTest( "Should return -1 for getLastChunkIndex when stream does not exist", { timeout: 30_000 }, async ({ redisOptions }) => { const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const lastChunkIndex = await redisRealtimeStreams.getLastChunkIndex( "run_nonexistent", "nonexistent-stream", "default" ); expect(lastChunkIndex).toBe(-1); } ); redisTest( "Should correctly stream response data back to consumers", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_stream_test"; const streamId = "test-stream-response"; // Ingest some data first const chunks = [ JSON.stringify({ message: "chunk 0" }), JSON.stringify({ message: "chunk 1" }), JSON.stringify({ message: "chunk 2" }), ]; const encoder = new TextEncoder(); const ingestStream = new ReadableStream({ start(controller) { for (const chunk of chunks) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(ingestStream, runId, streamId, "default"); // Now stream the response const mockRequest = new Request("http://localhost/test"); const abortController = new AbortController(); const response = await redisRealtimeStreams.streamResponse( mockRequest, runId, streamId, abortController.signal ); expect(response.status).toBe(200); expect(response.headers.get("Content-Type")).toBe("text/event-stream"); // Read the stream const reader = response.body!.getReader(); const decoder = new TextDecoder(); const receivedData: string[] = []; let done = false; while (!done && receivedData.length < 3) { const { value, done: streamDone } = await reader.read(); done = streamDone; if (value) { const text = decoder.decode(value); // Parse SSE format: "id: ...\ndata: {json}\n\n" const events = text.split("\n\n").filter((event) => event.trim()); for (const event of events) { const lines = event.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { const data = line.substring(6).trim(); if (data) { receivedData.push(data); } } } } } } // Cancel the stream abortController.abort(); reader.releaseLock(); // Verify we received all chunks // Note: LineTransformStream strips newlines, so we don't expect them in output expect(receivedData.length).toBe(3); for (let i = 0; i < 3; i++) { expect(receivedData[i]).toBe(chunks[i]); } // Cleanup await redis.del(`stream:${runId}:${streamId}`); await redis.quit(); } ); redisTest( "Should handle empty stream ingestion", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_empty_test"; const streamId = "empty-stream"; // Create an empty stream const emptyStream = new ReadableStream({ start(controller) { controller.close(); }, }); const response = await redisRealtimeStreams.ingestData( emptyStream, runId, streamId, "default" ); expect(response.status).toBe(200); // Should have no entries (empty stream) const streamKey = `stream:${runId}:${streamId}`; const entries = await redis.xrange(streamKey, "-", "+"); expect(entries.length).toBe(0); // getLastChunkIndex should return -1 for empty stream const lastChunkIndex = await redisRealtimeStreams.getLastChunkIndex( runId, streamId, "default" ); expect(lastChunkIndex).toBe(-1); // Cleanup await redis.del(streamKey); await redis.quit(); } ); redisTest("Should handle resume from chunk 0", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_resume_zero"; const streamId = "test-stream-zero"; const chunks = [ JSON.stringify({ chunk: 0, data: "chunk 0" }), JSON.stringify({ chunk: 1, data: "chunk 1" }), ]; const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { for (const chunk of chunks) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); // Explicitly resume from chunk 0 (should write all chunks) await redisRealtimeStreams.ingestData(stream, runId, streamId, "default", 0); const streamKey = `stream:${runId}:${streamId}`; const entries = await redis.xrange(streamKey, "-", "+"); expect(entries.length).toBe(2); // Verify indices start at 0 for (let i = 0; i < 2; i++) { const [_id, fields] = entries[i]; let chunkIndex: number | null = null; for (let j = 0; j < fields.length; j += 2) { if (fields[j] === "chunkIndex") { chunkIndex = parseInt(fields[j + 1], 10); } } expect(chunkIndex).toBe(i); } // Cleanup await redis.del(streamKey); await redis.quit(); }); redisTest( "Should handle large number of chunks", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_large_test"; const streamId = "large-stream"; const chunkCount = 100; // Create 100 chunks const chunks: string[] = []; for (let i = 0; i < chunkCount; i++) { chunks.push(JSON.stringify({ chunk: i, data: `chunk ${i}` })); } const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { for (const chunk of chunks) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(stream, runId, streamId, "default"); // Verify last chunk index const lastChunkIndex = await redisRealtimeStreams.getLastChunkIndex( runId, streamId, "default" ); expect(lastChunkIndex).toBe(chunkCount - 1); // Verify all chunks stored const streamKey = `stream:${runId}:${streamId}`; const entries = await redis.xrange(streamKey, "-", "+"); expect(entries.length).toBe(chunkCount); // Cleanup await redis.del(streamKey); await redis.quit(); } ); redisTest( "Should handle streamResponse with legacy data format (backward compatibility)", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_legacy_test"; const streamId = "legacy-stream"; const streamKey = `stream:${runId}:${streamId}`; // Manually add entries in legacy format (without chunkIndex or clientId fields) await redis.xadd(streamKey, "*", "data", "legacy chunk 1\n"); await redis.xadd(streamKey, "*", "data", "legacy chunk 2\n"); // Stream the response const mockRequest = new Request("http://localhost/test"); const abortController = new AbortController(); const response = await redisRealtimeStreams.streamResponse( mockRequest, runId, streamId, abortController.signal ); expect(response.status).toBe(200); // Read the stream const reader = response.body!.getReader(); const decoder = new TextDecoder(); const receivedData: string[] = []; let done = false; while (!done && receivedData.length < 2) { const { value, done: streamDone } = await reader.read(); done = streamDone; if (value) { const text = decoder.decode(value); const events = text.split("\n\n").filter((event) => event.trim()); for (const event of events) { const lines = event.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { const data = line.substring(6).trim(); if (data) { receivedData.push(data); } } } } } } // Cancel the stream abortController.abort(); reader.releaseLock(); // Verify we received both legacy chunks expect(receivedData.length).toBe(2); expect(receivedData[0]).toBe("legacy chunk 1"); expect(receivedData[1]).toBe("legacy chunk 2"); // getLastChunkIndex should return -1 for legacy format (no chunkIndex field) const lastChunkIndex = await redisRealtimeStreams.getLastChunkIndex( runId, streamId, "default" ); expect(lastChunkIndex).toBe(-1); // Cleanup await redis.del(streamKey); await redis.quit(); } ); redisTest( "Should handle concurrent ingestion to the same stream", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_concurrent_test"; const streamId = "concurrent-stream"; // Create two sets of chunks that will be ingested concurrently const chunks1 = [ JSON.stringify({ source: "A", chunk: 0, data: "A-chunk 0" }), JSON.stringify({ source: "A", chunk: 1, data: "A-chunk 1" }), JSON.stringify({ source: "A", chunk: 2, data: "A-chunk 2" }), ]; const chunks2 = [ JSON.stringify({ source: "B", chunk: 0, data: "B-chunk 0" }), JSON.stringify({ source: "B", chunk: 1, data: "B-chunk 1" }), JSON.stringify({ source: "B", chunk: 2, data: "B-chunk 2" }), ]; const encoder = new TextEncoder(); // Create two streams const stream1 = new ReadableStream({ start(controller) { for (const chunk of chunks1) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); const stream2 = new ReadableStream({ start(controller) { for (const chunk of chunks2) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); // Ingest both streams concurrently - both starting from chunk 0 // Note: Using the same clientId will cause duplicate chunk indices (not recommended in practice) const [response1, response2] = await Promise.all([ redisRealtimeStreams.ingestData(stream1, runId, streamId, "default", 0), redisRealtimeStreams.ingestData(stream2, runId, streamId, "default", 0), ]); expect(response1.status).toBe(200); expect(response2.status).toBe(200); // Verify both sets of chunks were stored const streamKey = `stream:${runId}:${streamId}`; const entries = await redis.xrange(streamKey, "-", "+"); // Should have 6 total chunks (3 from each stream) expect(entries.length).toBe(6); // Verify we have chunks from both sources (though order may be interleaved) const sourceACounts = entries.filter(([_id, fields]) => { for (let j = 0; j < fields.length; j += 2) { if (fields[j] === "data" && fields[j + 1].includes('"source":"A"')) { return true; } } return false; }); const sourceBCounts = entries.filter(([_id, fields]) => { for (let j = 0; j < fields.length; j += 2) { if (fields[j] === "data" && fields[j + 1].includes('"source":"B"')) { return true; } } return false; }); expect(sourceACounts.length).toBe(3); expect(sourceBCounts.length).toBe(3); // Note: Both streams write chunks 0, 1, 2, so we'll have duplicate indices // This is expected behavior - the last-write-wins with Redis XADD // Cleanup await redis.del(streamKey); await redis.quit(); } ); redisTest( "Should handle concurrent ingestion with different clients and resume points", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_concurrent_resume_test"; const streamId = "concurrent-resume-stream"; // Client A writes initial chunks 0-2 const clientAInitial = [ JSON.stringify({ client: "A", phase: "initial", chunk: 0 }), JSON.stringify({ client: "A", phase: "initial", chunk: 1 }), JSON.stringify({ client: "A", phase: "initial", chunk: 2 }), ]; const encoder = new TextEncoder(); const streamA1 = new ReadableStream({ start(controller) { for (const chunk of clientAInitial) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(streamA1, runId, streamId, "client-A", 0); // Client B writes initial chunks 0-1 const clientBInitial = [ JSON.stringify({ client: "B", phase: "initial", chunk: 0 }), JSON.stringify({ client: "B", phase: "initial", chunk: 1 }), ]; const streamB1 = new ReadableStream({ start(controller) { for (const chunk of clientBInitial) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(streamB1, runId, streamId, "client-B", 0); // Verify each client's initial state let lastChunkA = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "client-A"); let lastChunkB = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "client-B"); expect(lastChunkA).toBe(2); expect(lastChunkB).toBe(1); // Now both clients resume concurrently from their own resume points const clientAResume = [ JSON.stringify({ client: "A", phase: "resume", chunk: 3 }), JSON.stringify({ client: "A", phase: "resume", chunk: 4 }), ]; const clientBResume = [ JSON.stringify({ client: "B", phase: "resume", chunk: 2 }), JSON.stringify({ client: "B", phase: "resume", chunk: 3 }), ]; const streamA2 = new ReadableStream({ start(controller) { for (const chunk of clientAResume) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); const streamB2 = new ReadableStream({ start(controller) { for (const chunk of clientBResume) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); // Both resume concurrently from their own points const [response1, response2] = await Promise.all([ redisRealtimeStreams.ingestData(streamA2, runId, streamId, "client-A", 3), redisRealtimeStreams.ingestData(streamB2, runId, streamId, "client-B", 2), ]); expect(response1.status).toBe(200); expect(response2.status).toBe(200); // Verify each client's final state lastChunkA = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "client-A"); lastChunkB = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "client-B"); expect(lastChunkA).toBe(4); // Client A: chunks 0-4 expect(lastChunkB).toBe(3); // Client B: chunks 0-3 // Verify total chunks in stream const streamKey = `stream:${runId}:${streamId}`; const entries = await redis.xrange(streamKey, "-", "+"); // 5 from client A (0-4) + 4 from client B (0-3) = 9 total expect(entries.length).toBe(9); // Cleanup await redis.del(streamKey); await redis.quit(); } ); redisTest( "Should track chunk indices independently for different clients", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_multi_client_test"; const streamId = "multi-client-stream"; // Client A writes chunks 0-2 const clientAChunks = [ JSON.stringify({ client: "A", chunk: 0, data: "A0" }), JSON.stringify({ client: "A", chunk: 1, data: "A1" }), JSON.stringify({ client: "A", chunk: 2, data: "A2" }), ]; const encoder = new TextEncoder(); const streamA = new ReadableStream({ start(controller) { for (const chunk of clientAChunks) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(streamA, runId, streamId, "client-A", 0); // Client B writes chunks 0-1 const clientBChunks = [ JSON.stringify({ client: "B", chunk: 0, data: "B0" }), JSON.stringify({ client: "B", chunk: 1, data: "B1" }), ]; const streamB = new ReadableStream({ start(controller) { for (const chunk of clientBChunks) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(streamB, runId, streamId, "client-B", 0); // Verify last chunk index for each client independently const lastChunkA = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "client-A"); const lastChunkB = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "client-B"); expect(lastChunkA).toBe(2); // Client A wrote 3 chunks (0-2) expect(lastChunkB).toBe(1); // Client B wrote 2 chunks (0-1) // Verify total chunks in stream (5 chunks total) const streamKey = `stream:${runId}:${streamId}`; const entries = await redis.xrange(streamKey, "-", "+"); expect(entries.length).toBe(5); // Verify each chunk has correct clientId let clientACount = 0; let clientBCount = 0; for (const [_id, fields] of entries) { let clientId: string | null = null; for (let j = 0; j < fields.length; j += 2) { if (fields[j] === "clientId") { clientId = fields[j + 1]; } } if (clientId === "client-A") clientACount++; if (clientId === "client-B") clientBCount++; } expect(clientACount).toBe(3); expect(clientBCount).toBe(2); // Cleanup await redis.del(streamKey); await redis.quit(); } ); redisTest( "Should handle one client resuming while another client is writing new chunks", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_client_resume_test"; const streamId = "client-resume-stream"; // Client A writes initial chunks 0-2 const clientAInitial = [ JSON.stringify({ client: "A", chunk: 0 }), JSON.stringify({ client: "A", chunk: 1 }), JSON.stringify({ client: "A", chunk: 2 }), ]; const encoder = new TextEncoder(); const streamA1 = new ReadableStream({ start(controller) { for (const chunk of clientAInitial) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(streamA1, runId, streamId, "client-A", 0); // Verify client A's last chunk let lastChunkA = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "client-A"); expect(lastChunkA).toBe(2); // Client B writes chunks 0-1 (different client, independent sequence) const clientBChunks = [ JSON.stringify({ client: "B", chunk: 0 }), JSON.stringify({ client: "B", chunk: 1 }), ]; const streamB = new ReadableStream({ start(controller) { for (const chunk of clientBChunks) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(streamB, runId, streamId, "client-B", 0); // Verify client B's last chunk const lastChunkB = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "client-B"); expect(lastChunkB).toBe(1); // Client A resumes from chunk 3 const clientAResume = [ JSON.stringify({ client: "A", chunk: 3 }), JSON.stringify({ client: "A", chunk: 4 }), ]; const streamA2 = new ReadableStream({ start(controller) { for (const chunk of clientAResume) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(streamA2, runId, streamId, "client-A", 3); // Verify final state lastChunkA = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "client-A"); expect(lastChunkA).toBe(4); // Client A now has chunks 0-4 // Client B's last chunk should be unchanged const lastChunkBAfter = await redisRealtimeStreams.getLastChunkIndex( runId, streamId, "client-B" ); expect(lastChunkBAfter).toBe(1); // Still 1 // Verify stream has chunks from both clients const streamKey = `stream:${runId}:${streamId}`; const entries = await redis.xrange(streamKey, "-", "+"); // 5 from client A + 2 from client B = 7 total expect(entries.length).toBe(7); // Cleanup await redis.del(streamKey); await redis.quit(); } ); redisTest( "Should return -1 for client that has never written to stream", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_client_not_found_test"; const streamId = "client-not-found-stream"; // Client A writes some chunks const clientAChunks = [ JSON.stringify({ client: "A", chunk: 0 }), JSON.stringify({ client: "A", chunk: 1 }), ]; const encoder = new TextEncoder(); const streamA = new ReadableStream({ start(controller) { for (const chunk of clientAChunks) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(streamA, runId, streamId, "client-A", 0); // Client A's last chunk should be 1 const lastChunkA = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "client-A"); expect(lastChunkA).toBe(1); // Client B never wrote anything, should return -1 const lastChunkB = await redisRealtimeStreams.getLastChunkIndex(runId, streamId, "client-B"); expect(lastChunkB).toBe(-1); // Cleanup const streamKey = `stream:${runId}:${streamId}`; await redis.del(streamKey); await redis.quit(); } ); redisTest( "Should skip legacy END_SENTINEL entries when reading and finding last chunk", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_backward_compat_test"; const streamId = "backward-compat-stream"; const streamKey = `stream:${runId}:${streamId}`; // Manually create a stream with mix of new format and legacy END_SENTINEL await redis.xadd( streamKey, "*", "clientId", "client-A", "chunkIndex", "0", "data", "chunk 0\n" ); await redis.xadd( streamKey, "*", "clientId", "client-A", "chunkIndex", "1", "data", "chunk 1\n" ); await redis.xadd(streamKey, "*", "data", "<>"); // Legacy END_SENTINEL await redis.xadd( streamKey, "*", "clientId", "client-A", "chunkIndex", "2", "data", "chunk 2\n" ); await redis.xadd(streamKey, "*", "data", "<>"); // Another legacy END_SENTINEL // getLastChunkIndex should skip END_SENTINELs and find chunk 2 const lastChunkIndex = await redisRealtimeStreams.getLastChunkIndex( runId, streamId, "client-A" ); expect(lastChunkIndex).toBe(2); // streamResponse should skip END_SENTINELs and only return actual data const mockRequest = new Request("http://localhost/test"); const abortController = new AbortController(); const response = await redisRealtimeStreams.streamResponse( mockRequest, runId, streamId, abortController.signal ); expect(response.status).toBe(200); // Read the stream const reader = response.body!.getReader(); const decoder = new TextDecoder(); const receivedData: string[] = []; let done = false; while (!done && receivedData.length < 3) { const { value, done: streamDone } = await reader.read(); done = streamDone; if (value) { const text = decoder.decode(value); const events = text.split("\n\n").filter((event) => event.trim()); for (const event of events) { const lines = event.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { const data = line.substring(6).trim(); if (data) { receivedData.push(data); } } } } } } // Cancel the stream abortController.abort(); reader.releaseLock(); // Should receive 3 chunks (END_SENTINELs skipped) expect(receivedData.length).toBe(3); expect(receivedData[0]).toBe("chunk 0"); expect(receivedData[1]).toBe("chunk 1"); expect(receivedData[2]).toBe("chunk 2"); // Cleanup await redis.del(streamKey); await redis.quit(); } ); redisTest( "Should close stream after inactivity timeout", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, inactivityTimeoutMs: 2000, // 2 seconds for faster test }); const runId = "run_inactivity_test"; const streamId = "inactivity-stream"; // Write 2 chunks const chunks = [JSON.stringify({ chunk: 0 }), JSON.stringify({ chunk: 1 })]; const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { for (const chunk of chunks) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(stream, runId, streamId, "default"); // Start streaming const mockRequest = new Request("http://localhost/test"); const abortController = new AbortController(); const response = await redisRealtimeStreams.streamResponse( mockRequest, runId, streamId, abortController.signal ); expect(response.status).toBe(200); // Read the stream const reader = response.body!.getReader(); const decoder = new TextDecoder(); const receivedData: string[] = []; const startTime = Date.now(); let streamClosed = false; try { while (true) { const { value, done } = await reader.read(); if (done) { streamClosed = true; break; } if (value) { const text = decoder.decode(value); const events = text.split("\n\n").filter((event) => event.trim()); for (const event of events) { const lines = event.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { const data = line.substring(6).trim(); if (data) { receivedData.push(data); } } } } } } } catch (_error) { // Expected to eventually close } finally { reader.releaseLock(); } const elapsedMs = Date.now() - startTime; // Verify stream closed naturally expect(streamClosed).toBe(true); // Should have received both chunks expect(receivedData.length).toBe(2); // Should have closed after inactivity timeout + one BLOCK cycle // BLOCK time is 5000ms, so minimum time is ~5s (one full BLOCK timeout) // The inactivity is checked AFTER the BLOCK returns expect(elapsedMs).toBeGreaterThan(4000); // At least one BLOCK cycle expect(elapsedMs).toBeLessThan(8000); // But not more than 2 cycles // Cleanup await redis.del(`stream:${runId}:${streamId}`); await redis.quit(); } ); redisTest( "Should format response with event IDs from Redis stream", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_event_id_test"; const streamId = "event-id-stream"; // Ingest some data with specific clientId const chunks = [ JSON.stringify({ message: "chunk 0" }), JSON.stringify({ message: "chunk 1" }), JSON.stringify({ message: "chunk 2" }), ]; const encoder = new TextEncoder(); const ingestStream = new ReadableStream({ start(controller) { for (const chunk of chunks) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(ingestStream, runId, streamId, "test-client-123"); // Stream the response const mockRequest = new Request("http://localhost/test"); const abortController = new AbortController(); const response = await redisRealtimeStreams.streamResponse( mockRequest, runId, streamId, abortController.signal ); expect(response.status).toBe(200); expect(response.headers.get("Content-Type")).toBe("text/event-stream"); // Read the stream const reader = response.body!.getReader(); const decoder = new TextDecoder(); const receivedEvents: Array<{ id: string; data: string }> = []; let done = false; while (!done && receivedEvents.length < 3) { const { value, done: streamDone } = await reader.read(); done = streamDone; if (value) { const text = decoder.decode(value); // Split by double newline to get individual events const events = text.split("\n\n").filter((event) => event.trim()); for (const event of events) { const lines = event.split("\n"); let id: string | null = null; let data: string | null = null; for (const line of lines) { if (line.startsWith("id: ")) { id = line.substring(4); } else if (line.startsWith("data: ")) { data = line.substring(6); } } if (id && data) { receivedEvents.push({ id, data }); } } } } // Cancel the stream abortController.abort(); reader.releaseLock(); // Verify we received all chunks with correct event IDs expect(receivedEvents.length).toBe(3); // Verify event IDs are Redis stream IDs (format: timestamp-sequence like "1234567890123-0") for (let i = 0; i < 3; i++) { expect(receivedEvents[i].id).toMatch(/^\d+-\d+$/); expect(receivedEvents[i].data).toBe(chunks[i]); } // Verify IDs are in order (each ID should be > previous) expect(receivedEvents[1].id > receivedEvents[0].id).toBe(true); expect(receivedEvents[2].id > receivedEvents[1].id).toBe(true); // Cleanup await redis.del(`stream:${runId}:${streamId}`); await redis.quit(); } ); redisTest( "Should support resuming from Last-Event-ID", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_resume_test"; const streamId = "resume-stream"; // Ingest data in two batches const firstBatch = [ JSON.stringify({ batch: 1, chunk: 0 }), JSON.stringify({ batch: 1, chunk: 1 }), JSON.stringify({ batch: 1, chunk: 2 }), ]; const encoder = new TextEncoder(); const firstStream = new ReadableStream({ start(controller) { for (const chunk of firstBatch) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(firstStream, runId, streamId, "client-A"); // Stream and read first batch const mockRequest1 = new Request("http://localhost/test"); const abortController1 = new AbortController(); const response1 = await redisRealtimeStreams.streamResponse( mockRequest1, runId, streamId, abortController1.signal ); expect(response1.status).toBe(200); const reader1 = response1.body!.getReader(); const decoder1 = new TextDecoder(); const firstEvents: Array<{ id: string; data: string }> = []; let done1 = false; while (!done1 && firstEvents.length < 3) { const { value, done: streamDone } = await reader1.read(); done1 = streamDone; if (value) { const text = decoder1.decode(value); const events = text.split("\n\n").filter((event) => event.trim()); for (const event of events) { const lines = event.split("\n"); let id: string | null = null; let data: string | null = null; for (const line of lines) { if (line.startsWith("id: ")) { id = line.substring(4); } else if (line.startsWith("data: ")) { data = line.substring(6); } } if (id && data) { firstEvents.push({ id, data }); } } } } abortController1.abort(); reader1.releaseLock(); expect(firstEvents.length).toBe(3); const lastEventId = firstEvents[firstEvents.length - 1].id; // Ingest second batch const secondBatch = [ JSON.stringify({ batch: 2, chunk: 0 }), JSON.stringify({ batch: 2, chunk: 1 }), ]; const secondStream = new ReadableStream({ start(controller) { for (const chunk of secondBatch) { controller.enqueue(encoder.encode(chunk + "\n")); } controller.close(); }, }); await redisRealtimeStreams.ingestData(secondStream, runId, streamId, "client-A"); // Resume streaming from lastEventId const mockRequest2 = new Request("http://localhost/test"); const abortController2 = new AbortController(); const response2 = await redisRealtimeStreams.streamResponse( mockRequest2, runId, streamId, abortController2.signal, { lastEventId } ); expect(response2.status).toBe(200); const reader2 = response2.body!.getReader(); const decoder2 = new TextDecoder(); const resumedEvents: Array<{ id: string; data: string }> = []; let done2 = false; while (!done2 && resumedEvents.length < 2) { const { value, done: streamDone } = await reader2.read(); done2 = streamDone; if (value) { const text = decoder2.decode(value); const events = text.split("\n\n").filter((event) => event.trim()); for (const event of events) { const lines = event.split("\n"); let id: string | null = null; let data: string | null = null; for (const line of lines) { if (line.startsWith("id: ")) { id = line.substring(4); } else if (line.startsWith("data: ")) { data = line.substring(6); } } if (id && data) { resumedEvents.push({ id, data }); } } } } abortController2.abort(); reader2.releaseLock(); // Verify we only received the second batch (events after lastEventId) expect(resumedEvents.length).toBe(2); expect(resumedEvents[0].data).toBe(secondBatch[0]); expect(resumedEvents[1].data).toBe(secondBatch[1]); // Verify the resumed events have IDs greater than lastEventId expect(resumedEvents[0].id > lastEventId).toBe(true); expect(resumedEvents[1].id > lastEventId).toBe(true); // Cleanup await redis.del(`stream:${runId}:${streamId}`); await redis.quit(); } ); redisTest( "Should handle chunks split mid-line (regression test)", { timeout: 30_000 }, async ({ redisOptions }) => { const redis = new Redis(redisOptions); const redisRealtimeStreams = new RedisRealtimeStreams({ redis: redisOptions, }); const runId = "run_split_test"; const streamId = "test-split-stream"; // Simulate what happens in production: a JSON line split across multiple network chunks // This reproduces the issue where we see partial chunks like: // - "{\"timestamp\":" // - "1762880245493,\"chunkIndex\":780,\"data\":\"Chunk 781/1000\"}" const fullLine = JSON.stringify({ timestamp: 1762880245493, chunkIndex: 780, data: "Chunk 781/1000", }); // Split the line at an arbitrary position (in the middle of the JSON) const splitPoint = 16; // Splits after '{"timestamp":' const chunk1 = fullLine.substring(0, splitPoint); const chunk2 = fullLine.substring(splitPoint); // Create a ReadableStream that sends split chunks const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(chunk1)); controller.enqueue(encoder.encode(chunk2 + "\n")); // Add newline at end controller.close(); }, }); // Ingest the split data await redisRealtimeStreams.ingestData(stream, runId, streamId, "client1"); // Now consume the stream and verify we get the complete line, not split chunks const abortController = new AbortController(); const response = await redisRealtimeStreams.streamResponse( new Request("http://localhost/test"), runId, streamId, abortController.signal ); const reader = response.body!.getReader(); const decoder = new TextDecoder(); let receivedData = ""; // Read all chunks from the response const readTimeout = setTimeout(() => { abortController.abort(); }, 5000); try { while (true) { const { done, value } = await reader.read(); if (done) break; receivedData += decoder.decode(value, { stream: true }); // Once we have data, we can stop if (receivedData.includes("data: ")) { break; } } } finally { clearTimeout(readTimeout); abortController.abort(); reader.releaseLock(); } // Parse the SSE data const lines = receivedData.split("\n").filter((line) => line.startsWith("data: ")); // We should receive exactly ONE complete line, not two partial lines expect(lines.length).toBe(1); // Extract the data (remove "data: " prefix) const dataLine = lines[0].substring(6); // Verify it's the complete, valid JSON expect(dataLine).toBe(fullLine); // Verify it parses correctly as JSON const parsed = JSON.parse(dataLine) as { timestamp: number; chunkIndex: number; data: string; }; expect(parsed.timestamp).toBe(1762880245493); expect(parsed.chunkIndex).toBe(780); expect(parsed.data).toBe("Chunk 781/1000"); // Cleanup await redis.del(`stream:${runId}:${streamId}`); await redis.quit(); } ); });