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

271 lines (270 loc) • 10.4 kB
/** * Redis Artifact Store * * The artifact backend for more than one machine. `LocalTempArtifactStore` * writes to a pod's own `/tmp`: a replica that did not store an artifact * cannot read it, a redeploy loses all of them, and `cleanup()` can only * expire what its own process wrote. Here every replica sees every artifact, * Redis expires them by TTL, and a paged read moves only the window. * * Layout, under `keyPrefix` (default `neurolink:artifact:`): * * <prefix><id> STRING the payload, verbatim * <prefix><id>:meta STRING JSON `RedisArtifactRecord` * * Both keys carry the same TTL and are written in one MULTI, so an id either * resolves completely or not at all. * * Range reads are honest about units. `retrieve_context` addresses characters; * `GETRANGE` addresses bytes. The record stores the payload's character length * next to its byte length, and only when the two are equal — pure ASCII, which * is what JSON tool output and logs almost always are — is a byte range used * as a character range. Anything else falls back to a whole read and a slice, * which is slower and still correct. A window never starts on the wrong * character. * * The connection is the same pool Redis conversation memory uses, keyed by * host, port and database, so a deployment that already keeps sessions in * Redis adds no connection by keeping artifacts there too. * * @module artifacts/redisArtifactStore */ import { randomUUID } from "node:crypto"; import { logger } from "../utils/logger.js"; import { getNormalizedConfig, getPooledRedisClient, releasePooledRedisClient, } from "../utils/redis.js"; import { generateArtifactPreview, isSafeArtifactId, sliceArtifactWindow, } from "./artifactReader.js"; /** Key prefix when the caller does not choose one. Never the conversation prefix. */ export const DEFAULT_ARTIFACT_KEY_PREFIX = "neurolink:artifact:"; /** Suffix of the metadata key beside each payload. */ const META_SUFFIX = ":meta"; /** Expiry when the caller does not choose one, or chooses an unusable one. */ export const DEFAULT_ARTIFACT_TTL_SECONDS = 86_400; /** A TTL Redis can apply: a finite, positive number of seconds. */ function isUsableTtl(ttl) { return ttl !== undefined && Number.isFinite(ttl) && ttl > 0; } /** * Runtime shape check for a record read back from Redis. It is untrusted * input — another version wrote it, or something else owns the key — so it is * validated rather than asserted. */ function parseRecord(value) { if (!value || typeof value !== "object") { return undefined; } const row = { ...value }; const { toolName, serverId, sizeBytes, contentType, createdAt, charLength } = row; if (typeof toolName !== "string" || typeof serverId !== "string" || typeof sizeBytes !== "number" || (contentType !== "json" && contentType !== "text") || typeof createdAt !== "number" || typeof charLength !== "number") { return undefined; } const record = { toolName, serverId, sizeBytes, contentType, createdAt, charLength, }; if (typeof row.sessionId === "string") { record.sessionId = row.sessionId; } if (typeof row.label === "string") { record.label = row.label; } const kind = row.kind; if (kind === "worker-report" || kind === "command-output" || kind === "stage-output" || kind === "other") { record.kind = kind; } return record; } function parseJson(raw) { try { return JSON.parse(raw); } catch { return undefined; } } /** * node-redis may hand back a Buffer for a string command; payloads are text. * `String(buffer)` is `buffer.toString()`, which decodes UTF-8. */ function asText(value) { return value === null ? null : String(value); } /** * Redis-backed artifact store: shared across replicas, expired by TTL, * range reads for ASCII payloads. * * @example * ```typescript * const store = new RedisArtifactStore({ url: process.env.REDIS_URL }); * const neurolink = new NeuroLink({ artifacts: { store } }); * // or let NeuroLink build it: { artifacts: { storage: "redis" } } * ``` */ export class RedisArtifactStore { config; client; connecting; /** * @param config - Connection and key settings. `keyPrefix` defaults to * `neurolink:artifact:`. `ttl` is seconds and must be positive; it * defaults to 86400 (24 hours), and zero, negative or non-finite values * are replaced by that default with a warning — artifacts in Redis always * expire, there is no "keep forever". `userSessionsKeyPrefix` is * meaningless here and ignored. */ constructor(config = {}) { if (config.ttl !== undefined && !isUsableTtl(config.ttl)) { logger.warn(`[RedisArtifactStore] Ignoring ttl ${String(config.ttl)}: it must be ` + `a positive number of seconds — using ${DEFAULT_ARTIFACT_TTL_SECONDS}s`); } this.config = getNormalizedConfig({ ...config, keyPrefix: config.keyPrefix ?? DEFAULT_ARTIFACT_KEY_PREFIX, ttl: isUsableTtl(config.ttl) ? config.ttl : DEFAULT_ARTIFACT_TTL_SECONDS, }); } generatePreview(payload) { return generateArtifactPreview(payload); } async store(payload, meta) { const client = await this.getClient(); const id = randomUUID(); const createdAt = Date.now(); // Measured here rather than trusted from `meta`: the ASCII fast path in // `retrieveRange` compares these two numbers, and a caller's estimate is // not evidence. const record = { ...meta, sizeBytes: Buffer.byteLength(payload, "utf-8"), createdAt, charLength: payload.length, }; const ttl = this.config.ttl; const options = { EX: ttl }; await client .multi() .set(this.payloadKey(id), payload, options) .set(this.metaKey(id), JSON.stringify(record), options) .exec(); logger.debug(`[RedisArtifactStore] Stored artifact ${id} for tool "${meta.toolName}" ` + `(${record.sizeBytes} B, ttl ${ttl}s)`); return { id, preview: this.generatePreview(payload), sizeBytes: meta.sizeBytes, meta: { ...meta, createdAt }, }; } async retrieve(id) { if (!isSafeArtifactId(id)) { logger.debug(`[RedisArtifactStore] Rejected unsafe artifact id "${id}"`); return null; } const client = await this.getClient(); return asText(await client.get(this.payloadKey(id))); } async retrieveRange(id, range) { if (!isSafeArtifactId(id)) { logger.debug(`[RedisArtifactStore] Rejected unsafe artifact id "${id}"`); return null; } const client = await this.getClient(); const offset = Math.max(0, range.offset ?? 0); const limit = range.limit === undefined ? undefined : Math.max(0, range.limit); const rawRecord = asText(await client.get(this.metaKey(id))); const record = rawRecord === null ? undefined : parseRecord(parseJson(rawRecord)); if (!record || record.charLength !== record.sizeBytes) { // No usable record, or a multi-byte payload where a byte offset is not // a character offset: read whole and cut. Still correct, just not cheap. const content = asText(await client.get(this.payloadKey(id))); return content === null ? null : sliceArtifactWindow(content, { offset, limit }); } const totalLength = record.charLength; if (offset >= totalLength || limit === 0) { return { content: "", offset, totalLength }; } const end = limit === undefined ? -1 : offset + limit - 1; const content = asText(await client.getRange(this.payloadKey(id), offset, end)); if (content === null || content.length === 0) { // GETRANGE on a missing key is "", not nil — the payload expired between // the record read and this one. return null; } return { content, offset, totalLength }; } async delete(id) { if (!isSafeArtifactId(id)) { return; } const client = await this.getClient(); await client.del([this.payloadKey(id), this.metaKey(id)]); } /** * Nothing to sweep: Redis expires every artifact `ttl` seconds after it was * written, on every replica at once, which is what `cleanup()` on the local * store could never do. */ async cleanup(olderThanMs) { logger.debug(`[RedisArtifactStore] cleanup(${olderThanMs}) is a no-op — artifacts ` + `expire by TTL (${this.config.ttl}s)`); return 0; } /** * Release this store's reference on the pooled connection. * * Waits for a connect that is still in flight: otherwise the reference it * is about to acquire would be assigned after this returned, and nothing * would ever release it. */ async close() { if (this.connecting) { try { await this.connecting; } catch { // The connect failed, so nothing was acquired. } } if (!this.client) { return; } this.client = undefined; await releasePooledRedisClient(this.config); } payloadKey(id) { return `${this.config.keyPrefix}${id}`; } metaKey(id) { return `${this.config.keyPrefix}${id}${META_SUFFIX}`; } /** Connect on first use, once, and share the pooled client afterwards. */ async getClient() { if (this.client?.isOpen) { return this.client; } if (!this.connecting) { this.connecting = getPooledRedisClient(this.config) .then((client) => { this.client = client; return client; }) .finally(() => { this.connecting = undefined; }); } return this.connecting; } }