import type { LogLevel } from "@trigger.dev/core/logger"; import { Logger } from "@trigger.dev/core/logger"; import type { RedisOptions } from "ioredis"; import Redis from "ioredis"; import { defaultReconnectOnError } from "@internal/redis"; import { env } from "~/env.server"; import type { StreamIngestor, StreamResponder, StreamResponseOptions } from "./types"; export type RealtimeStreamsOptions = { redis: RedisOptions | undefined; logger?: Logger; logLevel?: LogLevel; inactivityTimeoutMs?: number; // Close stream after this many ms of no new data (default: 15000) }; // Legacy constant for backward compatibility (no longer written, but still recognized when reading) const END_SENTINEL = "<>"; // Internal types for stream pipeline type StreamChunk = | { type: "ping" } | { type: "data"; redisId: string; data: string } | { type: "legacy-data"; redisId: string; data: string }; // Class implementing both interfaces export class RedisRealtimeStreams implements StreamIngestor, StreamResponder { private logger: Logger; private inactivityTimeoutMs: number; // Shared connection for short-lived non-blocking operations (XADD, XREVRANGE, EXPIRE). // Lazily created on first use so we don't open a connection if only streamResponse is called. private _sharedRedis: Redis | undefined; constructor(private options: RealtimeStreamsOptions) { this.logger = options.logger ?? new Logger("RedisRealtimeStreams", options.logLevel ?? "info"); this.inactivityTimeoutMs = options.inactivityTimeoutMs ?? 15000; // Default: 15 seconds } private get sharedRedis(): Redis { if (!this._sharedRedis) { this._sharedRedis = new Redis({ reconnectOnError: defaultReconnectOnError, ...this.options.redis, connectionName: "realtime:shared", }); } return this._sharedRedis; } async initializeStream( runId: string, streamId: string ): Promise<{ responseHeaders?: Record }> { return {}; } async streamResponse( request: Request, runId: string, streamId: string, signal: AbortSignal, options?: StreamResponseOptions ): Promise { const redis = new Redis({ reconnectOnError: defaultReconnectOnError, ...this.options.redis, connectionName: "realtime:streamResponse", }); const streamKey = `stream:${runId}:${streamId}`; let isCleanedUp = false; const stream = new ReadableStream({ start: async (controller) => { // Start from lastEventId if provided, otherwise from beginning let lastId = options?.lastEventId ?? "0"; let retryCount = 0; const maxRetries = 3; let lastDataTime = Date.now(); let lastEnqueueTime = Date.now(); const blockTimeMs = 5000; const pingIntervalMs = 10000; // 10 seconds if (options?.lastEventId) { this.logger.debug("[RealtimeStreams][streamResponse] Resuming from lastEventId", { streamKey, lastEventId: options?.lastEventId, }); } try { while (!signal.aborted) { // Check if we need to send a ping const timeSinceLastEnqueue = Date.now() - lastEnqueueTime; if (timeSinceLastEnqueue >= pingIntervalMs) { controller.enqueue({ type: "ping" }); lastEnqueueTime = Date.now(); } // Compute inactivity threshold once to use consistently in both branches const inactivityThresholdMs = options?.timeoutInSeconds ? options.timeoutInSeconds * 1000 : this.inactivityTimeoutMs; try { const messages = await redis.xread( "COUNT", 100, "BLOCK", blockTimeMs, "STREAMS", streamKey, lastId ); retryCount = 0; if (messages && messages.length > 0) { const [_key, entries] = messages[0]; let foundData = false; for (let i = 0; i < entries.length; i++) { const [id, fields] = entries[i]; lastId = id; if (fields && fields.length >= 2) { // Extract the data field from the Redis entry // Fields format: ["field1", "value1", "field2", "value2", ...] let data: string | null = null; for (let j = 0; j < fields.length; j += 2) { if (fields[j] === "data") { data = fields[j + 1]; break; } } // Handle legacy entries that don't have field names (just data at index 1) if (data === null && fields.length >= 2) { data = fields[1]; } if (data) { // Skip legacy END_SENTINEL entries (backward compatibility) if (data === END_SENTINEL) { continue; } // Enqueue structured chunk with Redis stream ID controller.enqueue({ type: "data", redisId: id, data, }); foundData = true; lastDataTime = Date.now(); lastEnqueueTime = Date.now(); if (signal.aborted) { controller.close(); return; } } } } // If we didn't find any data in this batch, might have only seen sentinels if (!foundData) { // Check for inactivity timeout const inactiveMs = Date.now() - lastDataTime; if (inactiveMs >= inactivityThresholdMs) { this.logger.debug( "[RealtimeStreams][streamResponse] Closing stream due to inactivity", { streamKey, inactiveMs, threshold: inactivityThresholdMs, } ); controller.close(); return; } } } else { // No messages received (timed out on BLOCK) // Check for inactivity timeout const inactiveMs = Date.now() - lastDataTime; if (inactiveMs >= inactivityThresholdMs) { this.logger.debug( "[RealtimeStreams][streamResponse] Closing stream due to inactivity", { streamKey, inactiveMs, threshold: inactivityThresholdMs, } ); controller.close(); return; } } } catch (error) { if (signal.aborted) break; this.logger.error( "[RealtimeStreams][streamResponse] Error reading from Redis stream:", { error, } ); retryCount++; if (retryCount >= maxRetries) throw error; await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount)); } } } catch (error) { this.logger.error("[RealtimeStreams][streamResponse] Fatal error in stream processing:", { error, }); controller.error(error); } finally { await cleanup(); } }, cancel: async () => { await cleanup(); }, }) .pipeThrough( // Transform 1: Buffer partial lines across Redis entries (() => { let buffer = ""; let lastRedisId = "0"; return new TransformStream({ transform(chunk, controller) { if (chunk.type === "ping") { controller.enqueue(chunk as any); } else if (chunk.type === "data" || chunk.type === "legacy-data") { // Buffer partial lines: accumulate until we see newlines buffer += chunk.data; // Split on newlines const lines = buffer.split("\n"); // The last element might be incomplete, hold it back in buffer buffer = lines.pop() || ""; // Emit complete lines with the Redis ID of the chunk that completed them for (const line of lines) { if (line.trim().length > 0) { controller.enqueue({ ...chunk, line, }); } } // Update last Redis ID for next iteration lastRedisId = chunk.redisId; } }, flush(controller) { // On stream end, emit any leftover buffered text if (buffer.trim().length > 0) { controller.enqueue({ type: "data", redisId: lastRedisId, data: "", line: buffer.trim(), }); } }, }); })() ) .pipeThrough( // Transform 2: Format as SSE new TransformStream({ transform(chunk, controller) { if (chunk.type === "ping") { controller.enqueue(`: ping\n\n`); } else if ((chunk.type === "data" || chunk.type === "legacy-data") && chunk.line) { // Use Redis stream ID as SSE event ID controller.enqueue(`id: ${chunk.redisId}\ndata: ${chunk.line}\n\n`); } }, }) ) .pipeThrough(new TextEncoderStream()); async function cleanup() { if (isCleanedUp) return; isCleanedUp = true; // disconnect() tears down the TCP socket immediately, which causes any // pending XREAD BLOCK to reject right away instead of waiting for the // block timeout to elapse. quit() would queue behind the blocking command. redis.disconnect(); } signal.addEventListener("abort", cleanup, { once: true }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); } async ingestData( stream: ReadableStream, runId: string, streamId: string, clientId: string, resumeFromChunk?: number ): Promise { const redis = this.sharedRedis; const streamKey = `stream:${runId}:${streamId}`; const startChunk = resumeFromChunk ?? 0; // Start counting from the resume point, not from 0 let currentChunkIndex = startChunk; try { const textStream = stream.pipeThrough(new TextDecoderStream()); const reader = textStream.getReader(); while (true) { const { done, value } = await reader.read(); if (done || !value) { break; } // Write each chunk with its index and clientId this.logger.debug("[RedisRealtimeStreams][ingestData] Writing chunk", { streamKey, runId, clientId, chunkIndex: currentChunkIndex, resumeFromChunk: startChunk, value, }); await redis.xadd( streamKey, "MAXLEN", "~", String(env.REALTIME_STREAM_MAX_LENGTH), "*", "clientId", clientId, "chunkIndex", currentChunkIndex.toString(), "data", value ); currentChunkIndex++; } // Set TTL for cleanup when stream is done await redis.expire(streamKey, env.REALTIME_STREAM_TTL); return new Response(null, { status: 200 }); } catch (error) { if (error instanceof Error) { if ("code" in error && error.code === "ECONNRESET") { this.logger.info("[RealtimeStreams][ingestData] Connection reset during ingestData:", { error, }); return new Response(null, { status: 500 }); } } this.logger.error("[RealtimeStreams][ingestData] Error in ingestData:", { error }); return new Response(null, { status: 500 }); } } async appendPart(part: string, partId: string, runId: string, streamId: string): Promise { const redis = this.sharedRedis; const streamKey = `stream:${runId}:${streamId}`; await redis.xadd( streamKey, "MAXLEN", "~", String(env.REALTIME_STREAM_MAX_LENGTH), "*", "clientId", "", "chunkIndex", "0", "data", JSON.stringify(part) + "\n" ); // Set TTL for cleanup when stream is done await redis.expire(streamKey, env.REALTIME_STREAM_TTL); } async getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise { const redis = this.sharedRedis; const streamKey = `stream:${runId}:${streamId}`; try { // Paginate through the stream from newest to oldest until we find this client's last chunk const batchSize = 100; let lastId = "+"; // Start from newest while (true) { const entries = await redis.xrevrange(streamKey, lastId, "-", "COUNT", batchSize); if (!entries || entries.length === 0) { // Reached the beginning of the stream, no chunks from this client this.logger.debug( "[RedisRealtimeStreams][getLastChunkIndex] No chunks found for client", { streamKey, clientId, } ); return -1; } // Search through this batch for the client's last chunk for (const [_id, fields] of entries) { let entryClientId: string | null = null; let chunkIndex: number | null = null; let data: string | null = null; for (let i = 0; i < fields.length; i += 2) { if (fields[i] === "clientId") { entryClientId = fields[i + 1]; } if (fields[i] === "chunkIndex") { chunkIndex = parseInt(fields[i + 1], 10); } if (fields[i] === "data") { data = fields[i + 1]; } } // Skip legacy END_SENTINEL entries (backward compatibility) if (data === END_SENTINEL) { continue; } // Check if this entry is from our client and has a chunkIndex if (entryClientId === clientId && chunkIndex !== null) { this.logger.debug("[RedisRealtimeStreams][getLastChunkIndex] Found last chunk", { streamKey, clientId, chunkIndex, }); return chunkIndex; } } // Move to next batch (older entries) // Use the ID of the last entry in this batch as the new cursor lastId = `(${entries[entries.length - 1][0]}`; // Exclusive range with ( } } catch (error) { this.logger.error("[RedisRealtimeStreams][getLastChunkIndex] Error getting last chunk:", { error, streamKey, clientId, }); // Return -1 to indicate we don't know what the server has return -1; } } async readRecords(): Promise { throw new Error("readRecords is not implemented for Redis realtime streams"); } }