assistant-stream
Version:
Streaming utilities for AI assistants
558 lines (502 loc) • 16.7 kB
text/typescript
import { DEFAULT_TTL_MS } from "../constants";
import { ResumableStreamError, validateStreamId } from "../errors";
import { generateId } from "../../core/utils/generateId";
import type {
ResumableStreamAcquireOptions,
ResumableStreamAcquisition,
ResumableStreamLease,
ResumableStreamEntry,
ResumableStreamRole,
ResumableStreamStatus,
ResumableStreamStore,
} from "../types";
const DEFAULT_POLL_INTERVAL_MS = 100;
const DEFAULT_KEY_PREFIX = "aui:resumable";
const FIELD_CHUNK = "c";
const FIELD_FIN = "fin";
const FIELD_ERROR = "error";
const FIN_DONE = "done";
const FIN_ERROR = "error";
const STREAM_START_ID = "0-0";
export type PipelineCommand =
| {
readonly type: "xAdd";
readonly key: string;
readonly fields: Record<string, string | Uint8Array>;
}
| { readonly type: "expire"; readonly key: string; readonly ttlSec: number };
export type RedisFinalizeOptions = {
readonly metaKey: string;
readonly expectedMeta: string;
readonly nextMeta: string;
readonly dataKey: string;
readonly fields: Record<string, string>;
readonly ttlSec: number;
};
export type RedisAppendOptions = {
readonly metaKey: string;
readonly expectedMeta: string;
readonly dataKey: string;
readonly fields: Record<string, string | Uint8Array>;
readonly ttlSec: number;
};
export type RedisDeleteOptions = {
readonly metaKey: string;
readonly expectedMeta: string;
readonly dataKeys: readonly string[];
};
// `XADD *` is non-deterministic; Redis 5.x and 6.x configured with
// `lua-replicate-commands no` reject it unless effects replication is requested.
export const FINALIZE_IF_UNCHANGED_SCRIPT = `
redis.replicate_commands()
if redis.call("GET", KEYS[1]) ~= ARGV[1] then
return 0
end
local xadd = { "XADD", KEYS[2], "*" }
for i = 4, #ARGV do
table.insert(xadd, ARGV[i])
end
redis.call(unpack(xadd))
redis.call("EXPIRE", KEYS[2], ARGV[3])
redis.call("SET", KEYS[1], ARGV[2], "EX", ARGV[3])
return 1
`;
export const FINALIZE_IF_UNCHANGED_KEY_COUNT = 2;
export const APPEND_IF_UNCHANGED_SCRIPT = `
redis.replicate_commands()
if redis.call("GET", KEYS[1]) ~= ARGV[1] then
return 0
end
local xadd = { "XADD", KEYS[2], "*" }
for i = 3, #ARGV do
table.insert(xadd, ARGV[i])
end
redis.call(unpack(xadd))
redis.call("EXPIRE", KEYS[2], ARGV[2])
redis.call("EXPIRE", KEYS[1], ARGV[2])
return 1
`;
export const APPEND_IF_UNCHANGED_KEY_COUNT = 2;
export const DELETE_IF_UNCHANGED_SCRIPT = `
if redis.call("GET", KEYS[1]) ~= ARGV[1] then
return 0
end
redis.call("DEL", unpack(KEYS))
return 1
`;
export function finalizeIfUnchangedArgs(
options: RedisFinalizeOptions,
): string[] {
return [
options.metaKey,
options.dataKey,
options.expectedMeta,
options.nextMeta,
String(options.ttlSec),
...Object.entries(options.fields).flat(),
];
}
export function appendIfUnchangedArgs(
options: RedisAppendOptions,
): Array<string | Uint8Array> {
return [
options.metaKey,
options.dataKey,
options.expectedMeta,
String(options.ttlSec),
...Object.entries(options.fields).flat(),
];
}
export function deleteIfUnchangedArgs(options: RedisDeleteOptions): string[] {
return [options.metaKey, ...options.dataKeys, options.expectedMeta];
}
/**
* Structural Redis-client interface. The bundled `redis` and `ioredis`
* adapters wrap their respective clients to satisfy it.
*/
export interface RedisLikeClient {
setNX(key: string, value: string, ttlSec: number): Promise<boolean>;
get(key: string): Promise<string | null>;
del(keys: string[]): Promise<void>;
xRange(
key: string,
start: string,
end: string,
): Promise<
Array<{ id: string; fields: Record<string, string | Uint8Array> }>
>;
/** Executes the commands as a single pipeline batch (one round trip). */
pipeline(commands: readonly PipelineCommand[]): Promise<void>;
/** Omitting this capability keeps the legacy unfenced append behavior. */
appendIfUnchanged?(options: RedisAppendOptions): Promise<boolean>;
/**
* Atomically finalizes a stream only while its metadata is unchanged, so a
* producer superseded by a newer acquisition cannot finalize the replacement.
*/
finalizeIfUnchanged(options: RedisFinalizeOptions): Promise<boolean>;
/** Omitting this capability keeps the legacy unfenced delete behavior. */
deleteIfUnchanged?(options: RedisDeleteOptions): Promise<boolean>;
}
export type RedisResumableStreamStoreOptions = {
readonly keyPrefix?: string;
readonly defaultTtlMs?: number;
/** Defaults to 100ms. Lower values reduce read latency, raise traffic. */
readonly pollIntervalMs?: number;
readonly maxChunkBytes?: number;
};
export class RedisResumableStreamStore implements ResumableStreamStore {
private readonly client: RedisLikeClient;
private readonly keyPrefix: string;
private readonly defaultTtlMs: number;
private readonly pollIntervalMs: number;
private readonly maxChunkBytes: number | undefined;
constructor(
client: RedisLikeClient,
options: RedisResumableStreamStoreOptions = {},
) {
this.client = client;
this.keyPrefix = options.keyPrefix ?? DEFAULT_KEY_PREFIX;
this.defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS;
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
this.maxChunkBytes = options.maxChunkBytes;
}
// Fencing state for lease-less callers on this instance: an append or
// finalize whose current metadata carries a different generation lost the
// stream to a newer acquisition and must not write into it.
private readonly acquiredGenerations = new Map<string, string>();
async acquire(
streamId: string,
options?: ResumableStreamAcquireOptions,
): Promise<ResumableStreamRole> {
return (await this.acquireLease(streamId, options)).role;
}
async acquireLease(
streamId: string,
options?: ResumableStreamAcquireOptions,
): Promise<ResumableStreamAcquisition> {
validateStreamId(streamId);
const ttlSec = msToSec(options?.ttlMs ?? this.defaultTtlMs);
const generation = generateId();
const meta = JSON.stringify({
status: "streaming",
ttlSec,
generation,
});
const acquired = await this.client.setNX(
this.metaKey(streamId),
meta,
ttlSec,
);
if (!acquired) return { role: "consumer" };
this.acquiredGenerations.set(streamId, generation);
return { role: "producer", lease: { token: generation } };
}
private isSupersededGeneration(
streamId: string,
meta: ParsedMeta,
lease?: ResumableStreamLease,
): boolean {
if (lease) return meta.generation !== lease.token;
const acquired = this.acquiredGenerations.get(streamId);
return acquired !== undefined && meta.generation !== acquired;
}
private assertOwnedGeneration(
streamId: string,
meta: ParsedMeta,
lease?: ResumableStreamLease,
): void {
if (this.isSupersededGeneration(streamId, meta, lease)) {
throw new ResumableStreamError(
"missing",
`Stream superseded by a new acquisition: ${streamId}`,
);
}
}
async append(
streamId: string,
chunk: Uint8Array,
lease?: ResumableStreamLease,
): Promise<void> {
validateStreamId(streamId);
if (
this.maxChunkBytes !== undefined &&
chunk.byteLength > this.maxChunkBytes
) {
throw new Error(
`Chunk exceeds maxChunkBytes (${chunk.byteLength} > ${this.maxChunkBytes})`,
);
}
const metaKey = this.metaKey(streamId);
const existingRaw = await this.client.get(metaKey);
if (existingRaw === null) {
throw new Error(`Stream not found: ${streamId}`);
}
const meta = parseMeta(existingRaw);
if (!meta) throw new Error(`Stream not found: ${streamId}`);
this.assertOwnedGeneration(streamId, meta, lease);
if (meta.status !== "streaming") {
throw new ResumableStreamError(
"finalized",
`Stream already finalized: ${streamId}`,
);
}
const ttlSec = meta.ttlSec ?? msToSec(this.defaultTtlMs);
const dataKey = this.dataKey(streamId, meta.generation);
if (!this.client.appendIfUnchanged) {
await this.client.pipeline([
{ type: "xAdd", key: dataKey, fields: { [FIELD_CHUNK]: chunk } },
{ type: "expire", key: dataKey, ttlSec },
{ type: "expire", key: metaKey, ttlSec },
]);
return;
}
const appended = await this.client.appendIfUnchanged({
metaKey,
expectedMeta: existingRaw,
dataKey,
fields: { [FIELD_CHUNK]: chunk },
ttlSec,
});
if (appended) return;
const current = await this.readMeta(streamId);
if (!current) throw new Error(`Stream not found: ${streamId}`);
this.assertOwnedGeneration(streamId, current, lease);
if (current.status !== "streaming") {
throw new ResumableStreamError(
"finalized",
`Stream already finalized: ${streamId}`,
);
}
throw new ResumableStreamError(
"missing",
`Stream changed while appending: ${streamId}`,
);
}
async finalize(
streamId: string,
status: "done" | "error",
error?: string,
lease?: ResumableStreamLease,
): Promise<boolean> {
validateStreamId(streamId);
const metaKey = this.metaKey(streamId);
const existingRaw = await this.client.get(metaKey);
if (existingRaw === null) {
if (lease) this.clearAcquiredGeneration(streamId, lease.token);
throw new Error(`Stream not found: ${streamId}`);
}
const existing = parseMeta(existingRaw);
if (!existing) {
throw new Error(`Stream not found: ${streamId}`);
}
if (
existing.status !== "streaming" ||
this.isSupersededGeneration(streamId, existing, lease)
) {
if (lease) this.clearAcquiredGeneration(streamId, lease.token);
return false;
}
const ttlSec = existing.ttlSec ?? msToSec(this.defaultTtlMs);
const meta = JSON.stringify({
status,
...(status === "error" && { error: error ?? "Stream errored" }),
ttlSec,
...(existing.generation !== undefined && {
generation: existing.generation,
}),
});
const fields: Record<string, string> = {
[FIELD_FIN]: status === "error" ? FIN_ERROR : FIN_DONE,
};
if (status === "error") {
fields[FIELD_ERROR] = error ?? "Stream errored";
}
const dataKey = this.dataKey(streamId, existing.generation);
const finalized = await this.client.finalizeIfUnchanged({
metaKey,
expectedMeta: existingRaw,
nextMeta: meta,
dataKey,
fields,
ttlSec,
});
// Lease-less producers still need their local fence after a lost finalize.
if (finalized || lease) {
this.clearAcquiredGeneration(
streamId,
lease?.token ?? existing.generation,
);
}
return finalized;
}
async *read(
streamId: string,
cursor: string,
signal: AbortSignal,
): AsyncIterable<ResumableStreamEntry> {
validateStreamId(streamId);
const metaKey = this.metaKey(streamId);
const initialMeta = await this.client.get(metaKey);
if (initialMeta === null) {
throw new Error(`Stream not found: ${streamId}`);
}
const generation = parseMeta(initialMeta)?.generation;
const dataKey = this.dataKey(streamId, generation);
let lastId = cursor === "" ? STREAM_START_ID : cursor;
while (true) {
if (signal.aborted) return;
const start = lastId === STREAM_START_ID ? "-" : `(${lastId}`;
const entries = await this.client.xRange(dataKey, start, "+");
for (const entry of entries) {
if (signal.aborted) return;
lastId = entry.id;
const fin = readString(entry.fields[FIELD_FIN]);
if (fin === FIN_DONE) return;
if (fin === FIN_ERROR) {
throw new Error(
readString(entry.fields[FIELD_ERROR]) ?? "Stream errored",
);
}
const raw = entry.fields[FIELD_CHUNK];
if (raw === undefined) continue;
yield { cursor: entry.id, chunk: toBytes(raw) };
}
if (entries.length > 0) continue;
const currentMeta = await this.client.get(metaKey);
if (currentMeta === null) return;
if (parseMeta(currentMeta)?.generation !== generation) return;
await sleep(this.pollIntervalMs, signal);
}
}
async status(streamId: string): Promise<ResumableStreamStatus> {
validateStreamId(streamId);
const meta = await this.client.get(this.metaKey(streamId));
if (meta === null) return "missing";
const parsed = parseMeta(meta);
if (parsed?.status === "streaming") return "streaming";
if (parsed?.status === "done") return "done";
if (parsed?.status === "error") return "error";
return "missing";
}
async delete(streamId: string): Promise<void> {
validateStreamId(streamId);
const metaKey = this.metaKey(streamId);
const acquiredGeneration = this.acquiredGenerations.get(streamId);
let existingRaw = await this.client.get(metaKey);
const legacyDataKey = this.dataKey(streamId);
if (existingRaw === null) {
this.clearAcquiredGeneration(streamId, acquiredGeneration);
await this.client.del([
...(acquiredGeneration === undefined
? []
: [this.dataKey(streamId, acquiredGeneration)]),
legacyDataKey,
]);
return;
}
const existing = parseMeta(existingRaw);
const generation = existing?.generation;
if (!this.client.deleteIfUnchanged) {
this.clearAcquiredGeneration(streamId, acquiredGeneration);
await this.client.del([
metaKey,
...new Set([this.dataKey(streamId, generation), legacyDataKey]),
]);
return;
}
while (true) {
const deleted = await this.client.deleteIfUnchanged({
metaKey,
expectedMeta: existingRaw,
dataKeys: [
...new Set([this.dataKey(streamId, generation), legacyDataKey]),
],
});
if (deleted) {
this.clearAcquiredGeneration(streamId, acquiredGeneration);
return;
}
const currentRaw = await this.client.get(metaKey);
if (
currentRaw === null ||
parseMeta(currentRaw)?.generation !== generation
) {
this.clearAcquiredGeneration(streamId, acquiredGeneration);
if (generation !== undefined) {
await this.client.del([this.dataKey(streamId, generation)]);
}
return;
}
existingRaw = currentRaw;
}
}
private clearAcquiredGeneration(
streamId: string,
generation: string | undefined,
): void {
if (this.acquiredGenerations.get(streamId) === generation) {
this.acquiredGenerations.delete(streamId);
}
}
private async readMeta(streamId: string): Promise<ParsedMeta | undefined> {
const raw = await this.client.get(this.metaKey(streamId));
if (raw === null) return undefined;
return parseMeta(raw);
}
// {streamId} is a Redis Cluster hash tag so both keys live on the same
// shard; multi-key DEL and same-stream pipelines stay single-slot.
private metaKey(streamId: string): string {
return `${this.keyPrefix}:{${streamId}}:meta`;
}
private dataKey(streamId: string, generation?: string): string {
const base = `${this.keyPrefix}:{${streamId}}:data`;
return generation ? `${base}:${generation}` : base;
}
}
type ParsedMeta = {
status?: string;
error?: string;
ttlSec?: number;
generation?: string;
};
function parseMeta(value: string): ParsedMeta | undefined {
try {
const parsed = JSON.parse(value) as ParsedMeta;
return parsed && typeof parsed === "object" ? parsed : undefined;
} catch {
return undefined;
}
}
function msToSec(ms: number): number {
return Math.max(1, Math.ceil(ms / 1000));
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise<void>((resolve) => {
if (signal.aborted) {
resolve();
return;
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
resolve();
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
const SHARED_DECODER = new TextDecoder();
const SHARED_ENCODER = new TextEncoder();
function readString(
value: string | Uint8Array | undefined,
): string | undefined {
if (value === undefined) return undefined;
if (typeof value === "string") return value;
return SHARED_DECODER.decode(value);
}
function toBytes(value: string | Uint8Array): Uint8Array {
if (value instanceof Uint8Array) return value;
return SHARED_ENCODER.encode(value);
}