UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

590 lines (589 loc) • 21.8 kB
/** * Redis Utilities for NeuroLink * Helper functions for Redis storage operations */ import { randomUUID } from "crypto"; import { logger } from "./logger.js"; const SESSION_ONLY_PREFIX = "session-only:"; /** Characters that end the authority (`user:pass@host:port`) of a URL in text. */ const AUTHORITY_TERMINATORS = new Set(["/", "?", "#", " ", "\t", "\n", "\r"]); /** * Redact the userinfo of every `scheme://user:pass@host` in `text`. * * Deliberately not a regex. `/:\/\/[^@]+@/` and its relatives backtrack * quadratically on input with many `://` and no `@` (CodeQL * js/polynomial-redos), and the URL here can come straight from a caller — * `RedisStorageConfig.url` reaches this through `RedisArtifactStore` and the * conversation memory manager. One forward pass, every character visited a * bounded number of times. * * Redacts everything before the LAST `@` of the authority, so a password * containing `@` is still hidden. A URL with only a user name is redacted * too; the old regexes let that through. */ export function redactUrlCredentials(text) { let out = ""; let cursor = 0; for (;;) { const schemeEnd = text.indexOf("://", cursor); if (schemeEnd === -1) { return out + text.slice(cursor); } const authorityStart = schemeEnd + 3; let authorityEnd = authorityStart; let lastAt = -1; while (authorityEnd < text.length) { const ch = text[authorityEnd]; if (AUTHORITY_TERMINATORS.has(ch)) { break; } if (ch === "@") { lastAt = authorityEnd; } authorityEnd += 1; } if (lastAt === -1) { out += text.slice(cursor, authorityEnd); cursor = authorityEnd; } else { out += `${text.slice(cursor, authorityStart)}[redacted]@`; cursor = lastAt + 1; } } } // Connection pool - keyed by host:port:db const connectionPool = new Map(); const pendingConnections = new Map(); /** * One pool key per distinct connection. Shared by acquire AND release: they * used to compute it separately, and the release side never knew about the * `url:` form, so a URL-configured client was acquired under one key and * "released" under another that did not exist — its reference count never * dropped and the connection outlived every owner. Credentials never appear * in the key; it is logged. */ function poolKeyFor(config) { return config.url ? `url:${redactUrlCredentials(config.url)}` : `${config.host}:${config.port}:${config.db}:${config.password ? "auth" : "noauth"}`; } /** * 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 = poolKeyFor(config); 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 = poolKeyFor(config); 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; } // Imported here, not at the top: the redis client is ~590 modules, a quarter // of everything importing the SDK loaded, and most callers never connect. const { createClient } = await import("redis"); // Create client with secured options const client = createClient(clientOptions); client.on("error", (err) => { const sanitizedMessage = redactUrlCredentials(err.message); 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; } } /** The exact role set `ChatMessage` allows. */ const STORED_MESSAGE_ROLES = new Set([ "user", "assistant", "system", "tool_call", "tool_result", ]); function isStoredMessageRole(role) { return typeof role === "string" && STORED_MESSAGE_ROLES.has(role); } /** * True for a complete `ChatMessage` — `id` included, because callers keying * summary pointers and condensation groups off it are entitled to find one. */ export function isStoredChatMessage(value) { if (typeof value !== "object" || value === null) { return false; } const candidate = value; return (typeof candidate.id === "string" && typeof candidate.content === "string" && isStoredMessageRole(candidate.role)); } /** * Coerce a stored entry into a complete `ChatMessage`, or `undefined` when its * shape is unusable. * * Shared by BOTH read paths on purpose. Inline blobs have always been * validated; once messages moved into the companion LIST the split read * bypassed that check, so `null`, a number or a bare string survived * `JSON.parse` and reached callers that dereference `.role`, `.content` and * `.metadata`. The two storage formats must offer the same read guarantee. * * `id` is a separate matter: it is required on `ChatMessage`, but no read path * has ever enforced it, so history written before it existed carries none. * Rejecting those records would empty otherwise-healthy sessions, so a missing * id is backfilled rather than fatal — prefixed, so a synthesized id is never * mistaken for one an existing pointer could reference. */ export function normalizeStoredMessage(value) { if (isStoredChatMessage(value)) { return value; } if (typeof value !== "object" || value === null) { return undefined; } const candidate = value; if (typeof candidate.content !== "string" || !isStoredMessageRole(candidate.role)) { return undefined; } return { ...candidate, id: `legacy-${randomUUID()}`, role: candidate.role, content: candidate.content, }; } /** * 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 normalizedMessages = conversation.messages.map(normalizeStoredMessage); if (normalizedMessages.some((message) => message === undefined)) { logger.warn("[redisUtils] Invalid messages structure", { messageCount: conversation.messages.length, firstMessage: conversation.messages.length > 0 ? JSON.stringify(conversation.messages[0]) : null, }); return null; } conversation.messages = normalizedMessages.filter((message) => message !== undefined); 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 = redactUrlCredentials(url); 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, }, }; } // --------------------------------------------------------------------------- // Split message storage // --------------------------------------------------------------------------- /** * Suffix of the companion LIST key holding a session's messages. * * Every `storeConversationTurn` used to re-serialize and SET the ENTIRE * conversation, so per-turn write cost grew with history size. Measured * against local Redis: 200 turns of 2KB messages stayed flat at 2ms, but 400 * turns of 20KB messages (~16MB blob) went 2ms -> 61ms, a 30x degradation on * exactly the agentic tool-output profile. Splitting messages into an * append-only LIST makes the per-turn write O(1) in conversation size. */ export const MESSAGES_KEY_SUFFIX = ":msgs"; /** * Marker on a stored blob meaning "messages live in the companion LIST". * A blob WITHOUT it is a legacy record whose inline `messages` array is * authoritative — it is read as-is and converted on its next write, which is * what makes this migration backward compatible. */ export const MESSAGES_IN_LIST_MARKER = "__nlMessagesInList"; /** Redis key holding a session's messages as an append-only LIST. */ export function getSessionMessagesKey(config, sessionId, userId) { return `${getSessionKey(config, sessionId, userId)}${MESSAGES_KEY_SUFFIX}`; } /** * True for a companion messages LIST key. * * Scan-then-GET paths (`getStats`, session listing) match `${keyPrefix}*`, so * they now also see these LIST keys — and `GET` on a LIST raises WRONGTYPE. * They must filter with this, exactly as they already skip `:sessions` index * keys. */ export function isSessionMessagesKey(key) { return key.endsWith(MESSAGES_KEY_SUFFIX); } /** Serialize the conversation WITHOUT its messages, flagged for split reads. */ export function serializeConversationMetadata(conversation) { return JSON.stringify({ ...conversation, messages: [], [MESSAGES_IN_LIST_MARKER]: true, }); } /** True when a deserialized blob's messages live in the companion LIST. */ export function usesSplitMessageStorage(conversation) { if (!conversation) { return false; } const record = conversation; return record[MESSAGES_IN_LIST_MARKER] === true; } /** Parse LRANGE entries, skipping any single entry that is not usable. */ export function parseStoredMessages(entries) { const messages = []; for (const entry of entries) { try { // `JSON.parse` succeeds for `null`, numbers and bare strings, so the // catch below cannot filter them — the shape has to be checked. const parsed = JSON.parse(entry); const message = normalizeStoredMessage(parsed); if (!message) { logger.warn("[redisUtils] Skipping stored message with invalid shape"); continue; } messages.push(message); } catch (error) { // One corrupt entry must not destroy a whole session's history. logger.warn("[redisUtils] Skipping unparseable stored message", { error: error instanceof Error ? error.message : String(error), }); } } return messages; } /** Encode messages for RPUSH. */ export function encodeStoredMessages(messages) { return messages.map((message) => JSON.stringify(message)); }