refactor(ai-chat): registry — step-aligned retention + tail-only attach (#491)
Реестр ран-стримов больше не буферизует до 32МБ сырых SSE-кадров на активный
ран и не выливает весь буфер в сокет синхронно при attach — это давало OOM на
1ГБ-контейнере при нескольких марафонских ранах. Теперь кольцо ограничено
(env-настраиваемо, по умолчанию 4МБ) и держится в границах за счёт ротации по
шагам.
Серверная часть (суть коммита):
- Штамповка кадров по шагам в ingestFrame. Штамп кадра = число `finish-step`
кадров ДО него (с 0); сам finish-step несёт текущее значение, затем счётчик
инкрементится. Так штамп совпадает с `metadata.stepsPersisted`: клиент с N
персистнутыми шагами имеет 0..N-1 в сиде и просит хвост `stamp >= N`. Границу
ловим дешёвым startsWith по `data: {"type":"finish-step"` — форма кадра
проверена эмпирически против ai@6.0.207 (одна часть на кадр, type всегда
первый ключ; кавычки в text-delta экранированы, ложных срабатываний нет).
- Кольцо ротируется ТОЛЬКО на подтверждённом персисте шага N
(`confirmPersistedStep`), сбрасывая кадры `stamp < N` (эти шаги уже на диске и
придут в свежем сиде). `updateStreaming` теперь СИГНАЛИЗИРУЕТ исход (число
персистнутых шагов или null), и ротация вызывается лишь при не-null возврате —
провал персиста ничего не ротирует, кольцо покрывает БОЛЬШЕ (анти-инверсия:
наивная ротация в .then() после НЕзаписанного шага дырявила бы гарантию).
- Переполнение кольца сверх байтового капа вытесняет старейшие кадры; вытеснение
ещё-не-персистнутого кадра открывает GAP. Гэп НЕ липкий: floor покрытия
считается из кольца, поздний персист, проротировав дырявые шаги, его чистит.
- attach(chatId, anchor, n): маркер шага N приходит ТОЛЬКО от клиента (сервер не
читает строку — N из устаревшего сида дал бы тихую дыру в один шаг). Покрытие
ОК ⟺ coverageFloor <= n; иначе 204 → клиент рефетчит (больший N) и
переподключается. Хвост = синтетический `start`-кадр (ран-факт runId/chatId) +
кадры `stamp >= n`. Инвариант 6 (нет кросс-ран реплея) сохранён через anchor;
инвариант 4 (снапшот+регистрация в один синхронный тик) сохранён. N-срез
применяется во ВСЕХ ветках, включая finished-retained: finished + N=N_final →
пустой хвост + finish-кадр, клиент закрывает стрим.
- Контроллер пишет хвост чанками с учётом drain (writeTailRespectingDrain), а не
синхронным залпом (вторая половина OOM). Кап подписчика — производное 2× кап
кольца, обе величины env-резолвятся на инстансе.
Клиент: в тип строки добавлен `metadata.stepsPersisted` (источник N). PIN-SPEC
трип-вайр на ai@6.0.207: `readUIMessageStream({ message })` продолжает последнее
сообщение, `start`-кадр не сбрасывает parts, текст не пересекает finish-step —
на этом держится продолжение при attach; апгрейд ai теперь падает громко.
Тесты (observable-property против РЕАЛЬНОГО реестра/БД): детектор границы на
реальной форме кадра, N-срез (в т.ч. посреди шага), ротация только на
подтверждённом персисте, «персист провалился но кольцо влезло → attach успешен /
провал + переполнение → 204», «устаревший N → 204 → после рефетча успех», очистка
гэпа поздним персистом, finished-retained + N_final, memory-bound (5 параллельных
марафонов сверх 32МБ, каждое кольцо ≤ кап). Обновлены registry/controller specs и
DB-backed интеграционный attach-spec под новую сигнатуру/семантику.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -181,6 +181,12 @@ export interface IAiChatMessageRow {
|
||||
toolCalls?: unknown;
|
||||
metadata?: {
|
||||
parts?: UIMessage["parts"];
|
||||
// #491 step-alignment anchor: the count of FINISHED steps whose parts are in
|
||||
// THIS row, written atomically with `parts` server-side (flushAssistant). The
|
||||
// resume client reads it as its persisted step frontier N — the tail-only
|
||||
// attach asks the run-stream registry for the frames of step N onward (the
|
||||
// seed already carries steps 0..N-1). Absent on pre-#491 rows -> read as 0.
|
||||
stepsPersisted?: number;
|
||||
// AI SDK v6 `totalUsage` persisted on assistant rows. Legacy cumulative
|
||||
// figure (sum of every step's usage for the turn); kept for back-compat and
|
||||
// as the fallback for older rows that have no `contextTokens`.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readUIMessageStream, type UIMessage } from "ai";
|
||||
import pkg from "../../../../package.json";
|
||||
|
||||
/**
|
||||
* PIN-SPEC TRIP-WIRE (#491). The tail-only attach continuation relies on THREE
|
||||
* behaviors of `ai@6.0.207`, verified line-by-line in the issue. Without this
|
||||
* test, an `ai` bump could silently break attach (the client would append the
|
||||
* live tail to the wrong message, or duplicate a step):
|
||||
*
|
||||
* 1. `readUIMessageStream({ message })` CONTINUES the passed message — it does
|
||||
* not start a fresh one — so the tail streamed after a re-seed is appended to
|
||||
* the seeded assistant row (the same DB id).
|
||||
* 2. A `start` frame does NOT reset the existing message's parts (so the seeded
|
||||
* steps 0..N-1 survive; the synthetic `start` the registry prepends only
|
||||
* carries the run-fact metadata).
|
||||
* 3. Text parts do NOT cross a `finish-step` boundary — a new `text-start` after
|
||||
* `finish-step` is a NEW part — so the reconstructed steps stay separated and
|
||||
* the step frontier stays meaningful.
|
||||
*
|
||||
* If an `ai` upgrade changes any of these, this test fails LOUD instead of the
|
||||
* resume path silently corrupting.
|
||||
*/
|
||||
describe("ai SDK continuation trip-wire (#491, tail-only attach)", () => {
|
||||
it("is pinned to the exact ai version the continuation was verified against", () => {
|
||||
// A caret/range bump is exactly what would silently break attach — require an
|
||||
// exact pin. Bumping ai MUST re-verify the behavior asserted below, then this.
|
||||
expect((pkg as { dependencies: Record<string, string> }).dependencies.ai).toBe(
|
||||
"6.0.207",
|
||||
);
|
||||
});
|
||||
|
||||
it("continues the seeded message: start does not reset parts, the tail appends as new parts", async () => {
|
||||
// A seeded assistant row with ONE finished step already reconstructed.
|
||||
const seeded: UIMessage = {
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "step-start" },
|
||||
{ type: "text", text: "STEP0", state: "done" },
|
||||
],
|
||||
} as UIMessage;
|
||||
|
||||
// The tail the registry delivers on re-attach: a synthetic start (run-fact),
|
||||
// then step 1's frames, then finish. As UI-message chunks (what the SSE frames
|
||||
// decode to).
|
||||
const chunks = [
|
||||
{ type: "start", messageMetadata: { runId: "r1", chatId: "c1" } },
|
||||
{ type: "start-step" },
|
||||
{ type: "text-start", id: "t1" },
|
||||
{ type: "text-delta", id: "t1", delta: "STEP1" },
|
||||
{ type: "text-end", id: "t1" },
|
||||
{ type: "finish-step" },
|
||||
{ type: "finish" },
|
||||
];
|
||||
const stream = new ReadableStream({
|
||||
start(c) {
|
||||
for (const ch of chunks) c.enqueue(ch);
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
|
||||
let last: UIMessage | undefined;
|
||||
for await (const msg of readUIMessageStream({ message: seeded, stream })) {
|
||||
last = msg;
|
||||
}
|
||||
|
||||
expect(last).toBeDefined();
|
||||
// Same message id (continuation, not a fresh message).
|
||||
expect(last!.id).toBe("assistant-1");
|
||||
// The seeded step-0 parts SURVIVED the `start` frame, and step 1 was appended
|
||||
// as SEPARATE parts (text did not cross the finish-step boundary).
|
||||
const shape = last!.parts.map((p) => `${p.type}:${(p as { text?: string }).text ?? ""}`);
|
||||
expect(shape).toEqual([
|
||||
"step-start:",
|
||||
"text:STEP0",
|
||||
"step-start:",
|
||||
"text:STEP1",
|
||||
]);
|
||||
// The run-fact metadata from the synthetic start frame is applied.
|
||||
expect(last!.metadata).toMatchObject({ runId: "r1", chatId: "c1" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user