480 lines
16 KiB
TypeScript
480 lines
16 KiB
TypeScript
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 = "<<CLOSE_STREAM>>";
|
|
|
|
// 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<string, string> }> {
|
|
return {};
|
|
}
|
|
|
|
async streamResponse(
|
|
request: Request,
|
|
runId: string,
|
|
streamId: string,
|
|
signal: AbortSignal,
|
|
options?: StreamResponseOptions
|
|
): Promise<Response> {
|
|
const redis = new Redis({
|
|
reconnectOnError: defaultReconnectOnError,
|
|
...this.options.redis,
|
|
connectionName: "realtime:streamResponse",
|
|
});
|
|
const streamKey = `stream:${runId}:${streamId}`;
|
|
let isCleanedUp = false;
|
|
|
|
const stream = new ReadableStream<StreamChunk>({
|
|
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<StreamChunk, StreamChunk & { line: string }>({
|
|
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<StreamChunk & { line?: string }, string>({
|
|
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<Uint8Array>,
|
|
runId: string,
|
|
streamId: string,
|
|
clientId: string,
|
|
resumeFromChunk?: number
|
|
): Promise<Response> {
|
|
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<void> {
|
|
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<number> {
|
|
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<never> {
|
|
throw new Error("readRecords is not implemented for Redis realtime streams");
|
|
}
|
|
}
|