UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

399 lines (398 loc) 14.2 kB
/** * Redis Utilities for NeuroLink * Helper functions for Redis storage operations */ import { createClient } from "redis"; import { logger } from "./logger.js"; const SESSION_ONLY_PREFIX = "session-only:"; // Connection pool - keyed by host:port:db const connectionPool = new Map(); const pendingConnections = new Map(); /** * Get a pooled Redis connection. Multiple callers with the same host:port:db * share a single connection, reducing connection count. */ export async function getPooledRedisClient(config) { const key = config.url ? `url:${config.url.replace(/:\/\/[^:]+:[^@]+@/, "://[redacted]@")}` : `${config.host}:${config.port}:${config.db}:${config.password ? "auth" : "noauth"}`; const existing = connectionPool.get(key); if (existing && existing.client.isOpen) { existing.refCount++; logger.debug("[Redis] Reusing pooled connection", { key, refCount: existing.refCount, }); return existing.client; } // Check pending BEFORE cleaning up stale entries to prevent TOCTOU race: // Two callers seeing a stale entry could both skip this check and both // attempt to create new connections if we cleaned up first. const pending = pendingConnections.get(key); if (pending) { const client = await pending; const entry = connectionPool.get(key); if (entry) { if (!entry.client.isOpen) { // Client was closed while awaiting; clean up and create fresh connectionPool.delete(key); const freshClient = await createRedisClient(config); connectionPool.set(key, { client: freshClient, refCount: 1 }); return freshClient; } entry.refCount++; } else if (client.isOpen) { // Entry was released while we awaited; re-register connectionPool.set(key, { client, refCount: 1 }); } else { // Client was closed while we awaited; create fresh connection const freshClient = await createRedisClient(config); connectionPool.set(key, { client: freshClient, refCount: 1 }); return freshClient; } return client; } // Clean up stale entry if exists (connection closed) if (existing) { connectionPool.delete(key); } // Create the promise and register it in pendingConnections atomically // (synchronous) before any await, so concurrent callers will find it. const connectPromise = createRedisClient(config); pendingConnections.set(key, connectPromise); try { const client = await connectPromise; connectionPool.set(key, { client, refCount: 1 }); logger.info("[Redis] Created pooled connection", { key, refCount: 1 }); return client; } finally { pendingConnections.delete(key); } } /** * Release a pooled Redis connection. Only closes when refCount reaches 0. */ export async function releasePooledRedisClient(config) { const key = `${config.host}:${config.port}:${config.db}:${config.password ? "auth" : "noauth"}`; const entry = connectionPool.get(key); if (!entry) { return; } entry.refCount--; logger.debug("[Redis] Released pooled connection", { key, refCount: entry.refCount, }); if (entry.refCount <= 0) { try { if (entry.client.isOpen) { await entry.client.quit(); } } catch (e) { logger.warn("[Redis] Error closing pooled connection", { key, error: String(e), }); } connectionPool.delete(key); logger.info("[Redis] Closed pooled connection", { key }); } } /** * Get stats about the connection pool */ export function getPoolStats() { return Array.from(connectionPool.entries()).map(([key, entry]) => ({ key, refCount: entry.refCount, isOpen: entry.client.isOpen, })); } /** * Creates a Redis client with the provided configuration */ export async function createRedisClient(config) { const url = config.url || `redis://${config.host}:${config.port}/${config.db}`; // Create client options const clientOptions = { url, socket: { connectTimeout: config.connectionOptions?.connectTimeout, reconnectStrategy: (retries) => { if (retries > (config.connectionOptions?.maxRetriesPerRequest || 3)) { logger.error("Redis connection retries exhausted"); return new Error("Redis connection retries exhausted"); } const delay = Math.min((config.connectionOptions?.retryDelayOnFailover || 100) * 2 ** retries, 10000); return delay; }, }, }; if (config.password && !config.url) { clientOptions.password = config.password; } // Create client with secured options const client = createClient(clientOptions); client.on("error", (err) => { const sanitizedMessage = err.message.replace(/redis:\/\/.*?@/g, "redis://[redacted]@"); logger.error("Redis client error", { error: sanitizedMessage }); }); client.on("connect", () => { logger.debug("Redis client connected", { host: config.host, port: config.port, db: config.db, }); }); client.on("reconnecting", () => { logger.debug("Redis client reconnecting"); }); if (!client.isOpen) { await client.connect(); } return client; } /** * Generates a Redis key for session messages */ export function getSessionKey(config, sessionId, userId) { if (!userId) { logger.warn("[REDIS] getSessionKey called without userId", { sessionId }); return `${config.keyPrefix}${SESSION_ONLY_PREFIX}${sessionId}`; } const key = `${config.keyPrefix}${userId}:${sessionId}`; logger.debug("[redisUtils] Generated session key", { sessionId, userId, keyPrefix: config.keyPrefix, fullKey: key, }); return key; } /** * Generates a Redis key for user sessions mapping */ export function getUserSessionsKey(config, userId) { return `${config.userSessionsKeyPrefix}${userId}`; } /** * Serializes conversation object for Redis storage */ export function serializeConversation(conversation) { try { const serialized = JSON.stringify(conversation); return serialized; } catch (error) { logger.error("[redisUtils] Failed to serialize conversation", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, sessionId: conversation?.sessionId, userId: conversation?.userId, }); throw error; } } /** * Deserializes conversation object from Redis storage */ export function deserializeConversation(data) { if (!data) { return null; } try { // Parse as unknown first, then validate before casting const parsedData = JSON.parse(String(data)); // Check if the parsed data is an object with required properties if (typeof parsedData !== "object" || parsedData === null || !("title" in parsedData) || !("sessionId" in parsedData) || !("userId" in parsedData) || !("createdAt" in parsedData) || !("updatedAt" in parsedData) || !("messages" in parsedData)) { logger.warn("[redisUtils] Deserialized data is not a valid conversation object", { type: typeof parsedData, hasRequiredFields: parsedData && typeof parsedData === "object" ? Object.keys(parsedData).join(", ") : "none", preview: JSON.stringify(parsedData).substring(0, 100), }); return null; } const conversation = parsedData; // Validate messages is an array if (!Array.isArray(conversation.messages)) { logger.warn("[redisUtils] messages is not an array", { type: typeof conversation.messages, }); return null; } // Validate each message in the messages array const isValidHistory = conversation.messages.every((m) => typeof m === "object" && m !== null && "role" in m && "content" in m && typeof m.role === "string" && typeof m.content === "string" && (m.role === "user" || m.role === "assistant" || m.role === "system" || m.role === "tool_call" || m.role === "tool_result")); if (!isValidHistory) { logger.warn("[redisUtils] Invalid messages structure", { messageCount: conversation.messages.length, firstMessage: conversation.messages.length > 0 ? JSON.stringify(conversation.messages[0]) : null, }); return null; } logger.debug("[redisUtils] Conversation deserialized successfully", { sessionId: conversation.sessionId, userId: conversation.userId, title: conversation.title, messageCount: conversation.messages.length, createdAt: conversation.createdAt, updatedAt: conversation.updatedAt, }); return conversation; } catch (error) { logger.error("[redisUtils] Failed to deserialize conversation", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, dataLength: data.length, dataPreview: "[REDACTED]", // Prevent exposure of potentially sensitive data }); return null; } } /** * Checks if Redis client is healthy */ export async function isRedisHealthy(client) { try { const pong = await client.ping(); return pong === "PONG"; } catch (error) { logger.error("Redis health check failed", { error }); return false; } } /** * Scan Redis keys matching a pattern without blocking the server * This is a non-blocking alternative to the KEYS command * * @param client Redis client * @param pattern Pattern to match keys (e.g. "prefix:*") * @param batchSize Number of keys to scan in each iteration (default: 100) * @returns Array of keys matching the pattern */ export async function scanKeys(client, pattern, batchSize = 100) { logger.debug("[redisUtils] Starting SCAN operation", { pattern, batchSize, }); const allKeys = []; let cursor = "0"; let iterations = 0; let totalScanned = 0; try { do { iterations++; // Use SCAN instead of KEYS to avoid blocking the server const result = await client.scan(cursor, { MATCH: pattern, COUNT: batchSize, }); // Extract cursor and keys from result cursor = String(result.cursor); const keys = (result.keys || []).map(String); // Add keys to result array allKeys.push(...keys); totalScanned += keys.length; logger.debug("[redisUtils] SCAN iteration completed", { iteration: iterations, currentCursor: cursor, keysInBatch: keys.length, totalKeysFound: allKeys.length, }); } while (cursor !== "0"); // Continue until cursor is 0 logger.info("[redisUtils] SCAN operation completed", { pattern, totalIterations: iterations, totalKeysFound: allKeys.length, totalScanned, }); return allKeys; } catch (error) { logger.error("[redisUtils] Error during SCAN operation", { pattern, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); throw error; } } /** * Get normalized Redis configuration with defaults */ export function getNormalizedConfig(config) { const keyPrefix = config.keyPrefix || "neurolink:conversation:"; // Intelligent default: derive user sessions prefix from conversation prefix const defaultUserSessionsPrefix = keyPrefix.replace(/conversation:?$/, "user:sessions:"); let host = config.host || "localhost"; let port = config.port || 6379; let username = config.username || ""; let password = config.password || ""; let db = config.db || 0; let url = config.url; if (url) { try { const parsedUrl = new URL(url); host = parsedUrl.hostname; port = parsedUrl.port ? parseInt(parsedUrl.port) : 6379; username = parsedUrl.username || username; password = parsedUrl.password || password; db = parsedUrl.pathname ? parseInt(parsedUrl.pathname.replace("/", "")) || 0 : 0; } catch (e) { const sanitizedUrl = url.replace(/:\/\/[^@]+@/, "://[redacted]@"); logger.warn("[redisUtils] Failed to parse Redis URL, falling back to component-based connection", { url: sanitizedUrl, error: e instanceof Error ? e.message : String(e), }); url = undefined; } } return { url: url || "", host, port, password, username, db, keyPrefix, userSessionsKeyPrefix: config.userSessionsKeyPrefix || defaultUserSessionsPrefix, ttl: config.ttl || 86400, connectionOptions: { connectTimeout: 30000, lazyConnect: true, retryDelayOnFailover: 100, maxRetriesPerRequest: 3, ...config.connectionOptions, }, }; }