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