Стабилизация, итерация 6: resume-стек — дельта-поллинг, step-aligned registry, tail-only attach (один PR, 3 коммита) #491
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Один PR. Зависит от #488 (интерфейс attachStrategy и автомат — клиентская часть attach здесь подменяет стратегию) и #490 (желательно: нераздутые строки). Координаты — develop@363f20ab.
Контекст: реестр стримов буферизует до 32 МБ сырых SSE-фреймов НА КАЖДЫЙ активный ран (
ai-chat-stream-registry.service.ts:18-40; в памяти V8 до ×2) + attach-реплей пишет весь буфер в сокет синхронно без drain (ai-chat.controller.ts:333) — 4–6 марафонов на контейнере 1 ГБ = OOM. Degraded poll рефетчит ВСЕ страницы infinite-query каждые 2.5 c с полными parts. Персист отстаёт от live-стрима на целый текущий шаг (in-progress текст не персистится —updateStreamingпередаёт'', service:1145) — это делает персист выровненным по границе шага, на чём и строится схема.Коммит 1 — perf(ai-chat): дельта-эндпоинт поллинга + run-факт в ответе
Эндпоинт «rows updated after cursor»: курсор от часов БД; стампы
updatedAtдельта-релевантных записей переводятся на SQL-now()(сейчас репы пишут app-клокомnew Date()—ai-chat-message.repo.ts:174,ai-chat-run.repo.ts:139; смешанные источники часов — независимый источник пропусков); окно перекрытияnow()−5s(одиночные автокоммитные UPDATE, длинных транзакций нет). Окно ГАРАНТИРУЕТ повторы → контракт: клиентский merge идемпотентен по id (mergeByIdуже такой — зафиксировать контрактом). Ответ:{ rows, run: {id, status} | null }— run-факт для автомата #488 едет в дельте (отдельный /run-поллинг = удвоение QPS, отвергнуто); rows несутstepsPersisted(коммит 2). Тесты: дельта-семантика; race «коммит позже с updatedAt раньше курсора» (окно ловит); идемпотентность merge; «стампы идут от часов БД» (юнит по репе).Коммит 2 — feat(ai-chat): персист step-маркера
metadata.stepsPersistedпишется ТЕМ ЖЕ flush'ем, что и parts (атомарно, в flushAssistant).run.stepCountкак источник НЕгоден (recordStep fire-and-forget, не атомарен с parts → рассинхрон сид↔маркер). КонтрактreconstructRunParts(runId) → { parts, stepsPersisted }— единственный интерфейс чтения живого рана; потребители: attach (коммит 3), дельта (коммит 1), экспорт./messagesи дельта отдают маркер в row.Коммит 3 — refactor(ai-chat): registry — step-aligned retention + tail-only attach
Retention: кадры стампуются номером шага при tee (конвенция: run-level кадры ДО первого шага = шаг 0); кольцо ротируется ТОЛЬКО по подтверждённому персисту шага N — требуется рефактор:
updateStreamingдолжен СИГНАЛИЗИРОВАТЬ исход (сейчас глотает ошибку внутри catch→warn, :1148-1154; наивный хук в.then()ротировал бы после НЕзаписанного шага, инвертируя безопасность). Провал персиста ⇒ нет ротации ⇒ кольцо покрывает больше (auto-safe); переполнение кольца ⇒ гэп-флаг ⇒ attach 204. Гэп-флаг НЕ липкий (очищается, когда запоздавший персист подтвердился и ротация вычистила дырявые шаги); floor покрытия = первый ПОЛНОСТЬЮ присутствующий шаг (не стамп первого кадра). Кап кольца — env-tunable, дефолт 4 МБ (кадры несут НЕкомпактированные outputs + фрейминг-оверхед ×1.5–2; шаг «2–3 крупных чтения + reasoning» пробивает 2 МБ штатно) + метрика/лог gap-204; SUBSCRIBER-кап — производная от капа кольца.Attach: step-маркер N — только ОТ КЛИЕНТА (параметр, аналог anchor; сервер строку НЕ читает — серверное определение N даёт молчаливую дыру-в-один-шаг при устаревшем сиде) → attach полностью синхронный, same-tick инвариант реестра СОХРАНЯЕТСЯ: [один тик: регистрация подписчика (paused) + слайс кольца + проверка «floorStep слайса ≤ N+1»] → не покрывает → 204 → существующий restore+poll обновляет сид → повторный attach со свежим N; покрывает → синтетический
start-фрейм сmessageMetadata: {runId, chatId}(источник run-факта на re-attach) + кадры слайса с фильтром «шаг > N» → drain pending тем же фильтром → live. Слайс иммунен к конкурентной ротации. Отсечка по N — во ВСЕХ ветках, включая finished-retained replay (registry :191-199 — отдельный код-путь; N=N_final ⇒ пустой хвост + finish-кадр, чтобы клиент закрыл стрим). Семантикаexpect=live/anchorи док-комментарии про стрип переписываются. Запись хвоста — чанками с учётом drain.Клиент: сид из rows существующим путём (
rowToUiMessage), streaming-строка НЕ стрипается (содержит шаги ≤N); вход в attaching/reconnecting — ВСЕГДА через re-seed из персиста (fetch → mergeById → N из строки); live-стор никогда не база для tail-apply (при живом дисконнекте он впереди границы шага — tail-apply задублировал бы частичный шаг). Подмена attachStrategy из #488; strip-механика удаляется.Опора на SDK (проверено на ai@6.0.207 построчно):
createStreamingUIMessageStateпродолжает последний assistant-message;start-фрейм НЕ сбрасывает parts; text/reasoning-части не пересекают границу шага (finish-stepсбрасывает active*Parts). Пин-спека (трипвайр) на эту континуацию обязательна — апгрейдaiбез пина молча сломает attach.Тесты: property эквивалентности реконструкции по НОРМАЛИЗОВАННЫМ транскриптам (step-start-parts выкинуть, text-parts склеить в пределах шага — live/persisted формы легально различаются); «провал персиста шага N, кольцо влезло → attach УСПЕШЕН (хвост включает N)» и «провал персиста + переполнение → 204» — ДВА разных кейса; «stale N (сид отстал) → 204 → после рефетча успех»; «attach в середине шага»; «finished-retained + N=N_final → пустой хвост»; смок памяти «5 параллельных марафонов ≤ кап».