1525 lines
47 KiB
TypeScript
1525 lines
47 KiB
TypeScript
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", "<<CLOSE_STREAM>>"); // Legacy END_SENTINEL
|
|
await redis.xadd(
|
|
streamKey,
|
|
"*",
|
|
"clientId",
|
|
"client-A",
|
|
"chunkIndex",
|
|
"2",
|
|
"data",
|
|
"chunk 2\n"
|
|
);
|
|
await redis.xadd(streamKey, "*", "data", "<<CLOSE_STREAM>>"); // 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();
|
|
}
|
|
);
|
|
});
|