c96fafc4ad
Раньше каждый onStepFinish переписывал ВСЮ строку ассистента (растущий metadata.parts jsonb со всеми выводами инструментов) → O(n²) объёма записи на прогон: под MVCC/TOAST апдейт jsonb переписывает всю версию строки, так что шаг k пишет ~k×вывод. Прогон из 50 шагов по ~100 КБ = сотни МБ WAL и мёртвых кортежей за ход, что молотит autovacuum. (#490 убрал только ВТОРУЮ копию в tool_calls; сам metadata.parts всё ещё рос и переписывался.) Теперь каждый завершённый шаг ДОПИСЫВАЕТСЯ отдельной строкой в лёгкую таблицу ai_chat_run_steps (только парты этого шага), а строка сообщения получает дешёвый маркер (stepsPersisted + toolTraceVersion, без растущего блоба parts). Полный metadata.parts собирается ОДИН раз на финализации. НЕ jsonb-append (||): апдейт всё равно переписывает всю TOAST-версию — экономится только сетевой payload, а WAL/мёртвые кортежи остаются; поэтому именно ОТДЕЛЬНАЯ таблица + INSERT. Три обязательные интеграции: - reconstructRunParts(row, stepRows) → { parts, stepsPersisted }: единый шов переключения бэкенда. Читает парты из СТРОКИ, если она уже несёт inline-parts (старые записи + ЛЮБАЯ финализированная), иначе из ТАБЛИЦЫ ШАГОВ (mid-run запись #492). Дискриминатор — наличие непустого metadata.parts (флаг схемы не нужен). Потребители (attach-seed, delta-poll, export, reconnect) прогоняют строки через hydrateAssistantParts на границе чтения — их контракт/вывод не меняется, старые и новые записи восстанавливаются идентично. - сигнал ротации кольца реестра #491 (confirmPersistedStep) теперь стреляет на подтверждённый INSERT шага, под тем же контрактом (updateStreaming возвращает stepsPersisted / null). - era-marker toolTraceVersion (#490) больше не ставится полной переписью — ставится в маркере шага и на финализации (flushAssistant), остаётся консистентным. Полная обратная совместимость: прогон, записанный по-старому (полная строка, без строк шагов), восстанавливается/attach/export идентично. При отсутствии репозитория шагов (позиционные тест-конструкции) — фолбэк на прежний полнострочный flush (без регрессии, только без выигрыша WAL). Тесты (реальный pg, int-lane): - WAL-дельта (pg_current_wal_lsn) на прогоне 40×100КБ: new=4.3МБ vs old=90.3МБ (20.8x) — O(Σ шагов) против O(n²); старый путь в тесте И есть ревертнутое поведение (мутация-проверка). - reconstruct-контракт: new-style (таблица шагов) и old-style (inline) прогоны восстанавливаются в идентичные parts; hydrate заполняет строку. - миграция up/down roundtrip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
64 lines
2.0 KiB
TypeScript
64 lines
2.0 KiB
TypeScript
import { Kysely, sql } from 'kysely';
|
|
import {
|
|
up,
|
|
down,
|
|
} from '../../src/database/migrations/20260708T120000-ai-chat-run-steps';
|
|
import { getTestDb, destroyTestDb } from './db';
|
|
|
|
/**
|
|
* #492 migration up/down roundtrip on a LIVE Postgres. global-setup already
|
|
* migrated docmost_test to latest (so the table exists at start); this drives the
|
|
* migration's own down()/up() and asserts the table presence toggles, then leaves
|
|
* it PRESENT (up) so the shared test DB is intact for any spec that runs after.
|
|
*/
|
|
async function tableExists(db: Kysely<any>): Promise<boolean> {
|
|
const row = (
|
|
await sql<{ t: string | null }>`select to_regclass('ai_chat_run_steps') as t`.execute(
|
|
db,
|
|
)
|
|
).rows[0];
|
|
return row.t !== null;
|
|
}
|
|
|
|
async function uniqueIndexExists(db: Kysely<any>): Promise<boolean> {
|
|
const row = (
|
|
await sql<{
|
|
t: string | null;
|
|
}>`select to_regclass('ai_chat_run_steps_message_step_uidx') as t`.execute(db)
|
|
).rows[0];
|
|
return row.t !== null;
|
|
}
|
|
|
|
describe('20260708 ai_chat_run_steps migration roundtrip [integration]', () => {
|
|
let db: Kysely<any>;
|
|
|
|
beforeAll(() => {
|
|
db = getTestDb();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Belt-and-suspenders: guarantee the table is present for later specs even if
|
|
// an assertion threw mid-roundtrip.
|
|
if (!(await tableExists(db))) await up(db);
|
|
await destroyTestDb();
|
|
});
|
|
|
|
it('down() drops the table+index and up() recreates them (idempotent)', async () => {
|
|
// Starts applied (global-setup migrated to latest).
|
|
expect(await tableExists(db)).toBe(true);
|
|
expect(await uniqueIndexExists(db)).toBe(true);
|
|
|
|
await down(db);
|
|
expect(await tableExists(db)).toBe(false);
|
|
expect(await uniqueIndexExists(db)).toBe(false);
|
|
|
|
await up(db);
|
|
expect(await tableExists(db)).toBe(true);
|
|
expect(await uniqueIndexExists(db)).toBe(true);
|
|
|
|
// up() is idempotent (ifNotExists) — a second run is a harmless no-op.
|
|
await expect(up(db)).resolves.not.toThrow();
|
|
expect(await tableExists(db)).toBe(true);
|
|
});
|
|
});
|