@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
JavaScript
/**
* 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,
},
};
}