fix(ai): show live reindex progress so the embeddings counter resets to 0 and climbs #242

Open
Ghost wants to merge 6 commits from fix/embeddings-reindex-progress into develop

Баг: счётчик «Indexed N of N» не обнуляется при Reindex

Симптом (репорт владельца со скриншотом): в Workspace → AI → карточка Embeddings/Semantic search футер «Indexed 478 of 478 pages» + кнопка «Reindex now». При нажатии реиндекс идёт, но счётчик НЕ сбрасывается в 0 и не растёт с нуля — висит «478 of 478», следить за прогрессом нельзя.

Корень

Статус, который поллит клиент, отдаёт indexedPages = pageEmbeddingRepo.countIndexedPages() — число РАЗЛИЧНЫХ неудалённых страниц, у которых есть ≥1 строка эмбеддинга. Реиндекс (BullMQ-джоба WORKSPACE_CREATE_EMBEDDINGSEmbeddingIndexerService.reindexWorkspace) идёт по страницам и для каждой делает HARD-replace (delete+insert) в СВОЕЙ маленькой транзакции. Поэтому в любой момент почти у всех страниц строки на месте → distinct-count держится ≈ total всю пробежку. Клиент при этом УЖЕ корректно поллит каждые 3с и останавливается при indexed≥total — проблема чисто серверная: статус никогда не отдаёт число ниже total.

Фикс — отдавать ЖИВОЙ прогресс пробежки (0→total)

  • Новый EmbeddingReindexProgressService поверх Redis (переиспользован стандартный RedisService/ioredis, что уже бэкит BullMQ и лимитеры — нового конфига нет). HASH ai:reindex:progress:<workspaceId> {total,done,startedAt}, TTL 1ч, done через атомарный HINCRBY. Всё best-effort: на ошибке Redis деградирует к старому DB-счётчику.
  • AiSettingsService.reindex() сидит {total: countEmbeddablePages, done:0} ДО enqueue → первый поллинг сразу показывает 0.
  • reindexWorkspace в try/finally: start(total) на старте, increment после каждой обработанной страницы, clear в finally (краш/abort/несконфигурировано не оставляют залипший статус). Per-page реиндекс (правка одной страницы), delete-cap/abort — не тронуты. Массового up-front удаления НЕТ (поиск не ломается).
  • GET-статус: если активная запись прогресса есть → indexedPages=done, totalPages=total (+reindexing:true); иначе — прежнее поведение (countIndexedPages/countEmbeddablePages). Когда done==total клиент останавливается, finally чистит запись, дальше fallback к DB-счёту (== total) — стабильный вид сохранён.
  • Процесс-агностично: джоба может крутиться в отдельном воркере, поэтому состояние в Redis, а не в памяти.

Тесты

embedding-indexer.service + ai-settings.service — 2 suites / 17 tests pass: total на старте, инкремент per-page, clear в finally (успех / fatal-abort без инкремента / unconfigured early-return); getMasked отдаёт прогресс при активной записи и fallback при отсутствии. server tsc + client tsc чисто.

Closes #— (баг семантик-серча на develop)

🤖 Generated with Claude Code

## Баг: счётчик «Indexed N of N» не обнуляется при Reindex **Симптом** (репорт владельца со скриншотом): в Workspace → AI → карточка Embeddings/Semantic search футер «Indexed 478 of 478 pages» + кнопка «Reindex now». При нажатии реиндекс идёт, но счётчик НЕ сбрасывается в 0 и не растёт с нуля — висит «478 of 478», следить за прогрессом нельзя. ### Корень Статус, который поллит клиент, отдаёт `indexedPages = pageEmbeddingRepo.countIndexedPages()` — число РАЗЛИЧНЫХ неудалённых страниц, у которых есть ≥1 строка эмбеддинга. Реиндекс (BullMQ-джоба `WORKSPACE_CREATE_EMBEDDINGS` → `EmbeddingIndexerService.reindexWorkspace`) идёт по страницам и для каждой делает HARD-replace (delete+insert) в СВОЕЙ маленькой транзакции. Поэтому в любой момент почти у всех страниц строки на месте → distinct-count держится ≈ total всю пробежку. Клиент при этом УЖЕ корректно поллит каждые 3с и останавливается при indexed≥total — проблема чисто серверная: статус никогда не отдаёт число ниже total. ### Фикс — отдавать ЖИВОЙ прогресс пробежки (0→total) - Новый `EmbeddingReindexProgressService` поверх Redis (переиспользован стандартный `RedisService`/ioredis, что уже бэкит BullMQ и лимитеры — нового конфига нет). HASH `ai:reindex:progress:<workspaceId>` `{total,done,startedAt}`, TTL 1ч, `done` через атомарный `HINCRBY`. Всё best-effort: на ошибке Redis деградирует к старому DB-счётчику. - `AiSettingsService.reindex()` сидит `{total: countEmbeddablePages, done:0}` ДО enqueue → первый поллинг сразу показывает 0. - `reindexWorkspace` в try/finally: `start(total)` на старте, `increment` после каждой обработанной страницы, `clear` в finally (краш/abort/несконфигурировано не оставляют залипший статус). Per-page реиндекс (правка одной страницы), delete-cap/abort — не тронуты. Массового up-front удаления НЕТ (поиск не ломается). - GET-статус: если активная запись прогресса есть → `indexedPages=done`, `totalPages=total` (+`reindexing:true`); иначе — прежнее поведение (`countIndexedPages`/`countEmbeddablePages`). Когда done==total клиент останавливается, finally чистит запись, дальше fallback к DB-счёту (== total) — стабильный вид сохранён. - Процесс-агностично: джоба может крутиться в отдельном воркере, поэтому состояние в Redis, а не в памяти. ### Тесты `embedding-indexer.service` + `ai-settings.service` — 2 suites / 17 tests pass: total на старте, инкремент per-page, clear в finally (успех / fatal-abort без инкремента / unconfigured early-return); getMasked отдаёт прогресс при активной записи и fallback при отсутствии. server tsc + client tsc чисто. Closes #— (баг семантик-серча на develop) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- state:review reviewed_head: bdc033e68993f3c2e2ac89e450d3d85abfc88a42 baseline_head: bdc033e68993f3c2e2ac89e450d3d85abfc88a42 verdict: changes-requested round: 5 max_rounds: 6 open_findings: [F6, F7, F8, F9, F10] reopened: {} -->
Ghost added 1 commit 2026-06-28 01:45:48 +03:00
The "Indexed X of Y pages" counter stayed stuck at "478 of 478" during a
manual "Reindex now" run instead of resetting to 0 and climbing. The status
reports indexedPages = countIndexedPages (DISTINCT pages with >=1 embedding
row), but reindex hard-replaces each page in its OWN small transaction, so
nearly all pages always have rows -> the count never drops.

Add a per-workspace live reindex-progress record in Redis (reusing the
existing global ioredis client via RedisService, no new Redis config):
- EmbeddingReindexProgressService: start/increment/clear/get over a Redis hash
  with a 1h TTL self-clean; all best-effort/cosmetic so a Redis failure degrades
  to the existing DB-count behavior.
- AiSettingsService.reindex seeds {total, done:0, startedAt} at enqueue time so
  the very first poll already reports done=0.
- EmbeddingIndexerService.reindexWorkspace overwrites total with the real page
  count at start, increments done per processed page (success or handled
  failure), and clears the record in a finally (covers success, fatal abort,
  and the unconfigured early-return) so a failed run never sticks.
- AiSettingsService.getMasked returns the live run numbers when a progress
  record is active (plus an optional reindexing flag), else falls back to
  countIndexedPages/countEmbeddablePages.

Per-page edits (reindexPage) never touch the workspace progress record, and no
mass up-front delete is introduced (search availability preserved).

Tests: indexer sets/increments/clears progress (incl. fatal abort and
unconfigured early-return); status reports run progress when active and falls
back when not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-28 01:51:52 +03:00
Make the "Indexed N of N" counter update near-realtime during a reindex by
tracking the server's active-run state instead of a pure time window:

- Set REINDEX_POLL_INTERVAL to 5000ms (kept bounded by the cap).
- Extract two pure, exported, unit-tested helpers:
  - nextReindexPollInterval: keep polling while the server reports an ACTIVE run
    (reindexing===true) OR within the deadline and not yet done; stop once the
    run is finished AND fully indexed (reindexing===false && indexed>=total) or
    the deadline cap is hit (the cap always wins, so a stuck/never-clearing
    progress record can't poll forever).
  - isReindexComplete: deadline-clear predicate mirroring that stop condition.
- Wire the refetchInterval and the deadline-clearing effect to those helpers.
- Keep the Reindex button spinner active for the whole run (loading also while
  settings.reindexing), reusing the existing loading prop; also blocks a
  redundant mid-run re-trigger (server de-dupes regardless).

No SSE/websockets: polling keyed on the reindexing flag is the intended scope.
The counter now tracks the actual active-reindex state and stops promptly when
the server reports the run is done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner

Code review — живой прогресс реиндексации эмбеддингов

Вердикт: Request changes. Фикс по сути рабочий — счётчик действительно начинает расти с нуля, поллинг останавливается корректно, конструкторная DI-разводка и try/finally не ломают существующее поведение. Но есть один блокирующий дефект: добавленный комментарий внутренне противоречит коду (описывает поведение, которого нет), а корень этого — два разных источника total, из-за чего знаменатель счётчика во время прогона «прыгает». Критичных проблем (outage / потеря данных / эксплойт) нет, жёсткой блокировки мерджа не требуется.

Область ревью: дифф PR #242 (база develop c5109aa, голова fix/embeddings-reindex-progress 21cc2e9), 10 файлов, сервер + клиент. Security, conventions, regressions, simplification и architecture — чисто (LGTM).

🔴 Must fix before merge

  • [documentation/stability] Привести total в воркере к тому же источнику, что и сид/статус — иначе знаменатель счётчика прыгает, а комментарий лжётapps/server/src/integrations/ai/ai-settings.service.ts:105-112 (+ воркер в apps/server/src/core/ai-chat/embedding/embedding-indexer.service.ts).
    Сид при enqueue считает total через pageRepo.countEmbeddablePages (только не-удалённые страницы с текстом ИЛИ уже имеющимся эмбеддингом), а воркер перезатирает total через pageRepo.getIdsByWorkspace().length (ВСЕ не-удалённые страницы, без фильтра по тексту). Для любого воркспейса, где есть пустые/безтекстовые/ещё-не-проиндексированные страницы, getIdsByWorkspace().length > countEmbeddablePages. В итоге знаменатель, который видит UI, идёт 478 → 500 → 478 (сид → активный прогон → откат к DB-счёту после clear), что прямо подрывает заявленную цель «плавный рост 0 → total». При этом комментарий в том же блоке утверждает и «The worker overwrites total with the real page count», и «the counter denominator matches» — это взаимно исключающие утверждения.
    Fix: сделать так, чтобы воркер и считал, и итерировал тот же набор, что countEmbeddablePages (либо везде использовать один источник total), чтобы живой и стационарный знаменатели совпадали; как минимум — исправить комментарий, убрав ложное «the counter denominator matches».
    ⚠️ При правке: если просто поменять источник total в воркере, не меняя того, что он итерирует (getIdsByWorkspace), то done превысит total (счётчик уйдёт за 100%) — менять надо согласованно и источник счёта, и набор итерации.

  • [warning][stability] Не сбрасывать done в 0 при повторном запуске реиндекса на ходуapps/server/src/integrations/ai/ai-settings.service.ts:108-117.
    reindex() безусловно зовёт reindexProgress.start(workspaceId, total) (HSET done='0') перед enqueue, но если воркер уже работает, add() дедуплицирует активную джобу — второй воркер не стартует. Итог повторного клика / второго админа / второй вкладки: видимый счётчик отбрасывается на 0 / total, пока живой воркер продолжает инкрементить с нуля, и до конца прогона прогресс занижен. Новый guard «кнопка крутится, пока reindexing === true» гасит только одиночный дабл-клик, но не мульти-таб / мульти-админ.
    Fix: сеять прогресс только когда активной записи нет (reindexProgress.get() вернул null), либо отдать сброс done исключительно воркеру (start() на старте прогона), а не пути enqueue.

  • [suggestion][stability] TTL прогресса обновляется только в increment()apps/server/src/integrations/ai/embedding-reindex-progress.service.ts:50-52.
    1-часовой TTL рефрешится только при обработке страницы. Если один embedding-вызов зависнет дольше TTL (предусловие маловероятное — обычно секунды), запись истечёт на ходу: getMasked вернёт reindexing:false, клиент перестанет поллить, а воркер ещё реально «висит» на странице. Риск низкий; помечаю как замечание о свойстве дизайна «TTL привязан к прогрессу».

🧪 Test coverage

Покрытие основной логики добротное: reindexWorkspace (старт-total, инкремент per-page, clear в finally на успехе / fatal-abort / non-fatal / unconfigured), getMasked (живой прогресс vs DB-fallback) и клиентские чистые функции — все с осмысленными ассертами. Пробелы:

  • [warning] Нет прямого юнит-теста для нового EmbeddingReindexProgressServiceapps/server/src/integrations/ai/embedding-reindex-progress.service.ts. Сервис исполняется только через jest.fn()-моки в чужих сьютах, его собственная логика не выполняется под тестом. Непокрыты ветки get() (нет ключа / total === undefinednull; Number()-коэрсия; не-числовой total/donenull; не-конечный startedAt0) и контракт деградации (любой Redis-throw глотается, get()null).
    Fix: добавить embedding-reindex-progress.service.spec.ts с фейковым ioredis: валидный хэш → ReindexProgress; пустой / без totalnull; не-числовой totalnull; не-конечный startedAt0; hgetall бросает → null; start/increment шлют hset/hincrby+expire и глотают исключение.

  • [warning] Не протестирован сид прогресса в reindex()apps/server/src/integrations/ai/ai-settings.service.ts:105-112. Это несущая часть фикса («первый поллинг сразу показывает 0»); если вызов countEmbeddablePages → start() уберут или переставят после aiQueue.add, тест не упадёт.
    Fix: тест на service.reindex(WORKSPACE_ID), проверяющий, что reindexProgress.start вызван с (WORKSPACE_ID, <count>) и ДО aiQueue.add (порядок вызовов).

  • [suggestion] Тест живого прогресса getMasked не различает источник totalPagesapps/server/src/integrations/ai/ai-settings.service.spec.ts:633-647. В кейсе live-прогресса progress.total (478) совпадает с DB-счётом (478), поэтому expect(totalPages).toBe(478) проходит при любой ветке тернарника progress ? progress.total : totalPages.
    Fix: задать progress.total = 500 при DB-счёте 478 и проверить totalPages === 500.

## Code review — живой прогресс реиндексации эмбеддингов **Вердикт: Request changes.** Фикс по сути рабочий — счётчик действительно начинает расти с нуля, поллинг останавливается корректно, конструкторная DI-разводка и `try/finally` не ломают существующее поведение. Но есть один блокирующий дефект: добавленный комментарий внутренне противоречит коду (описывает поведение, которого нет), а корень этого — два разных источника `total`, из-за чего знаменатель счётчика во время прогона «прыгает». Критичных проблем (outage / потеря данных / эксплойт) нет, жёсткой блокировки мерджа не требуется. Область ревью: дифф PR #242 (база `develop` `c5109aa`, голова `fix/embeddings-reindex-progress` `21cc2e9`), 10 файлов, сервер + клиент. Security, conventions, regressions, simplification и architecture — чисто (LGTM). ### 🔴 Must fix before merge - **[documentation/stability] Привести `total` в воркере к тому же источнику, что и сид/статус — иначе знаменатель счётчика прыгает, а комментарий лжёт** — `apps/server/src/integrations/ai/ai-settings.service.ts:105-112` (+ воркер в `apps/server/src/core/ai-chat/embedding/embedding-indexer.service.ts`). Сид при enqueue считает `total` через `pageRepo.countEmbeddablePages` (только не-удалённые страницы с текстом ИЛИ уже имеющимся эмбеддингом), а воркер перезатирает `total` через `pageRepo.getIdsByWorkspace().length` (ВСЕ не-удалённые страницы, без фильтра по тексту). Для любого воркспейса, где есть пустые/безтекстовые/ещё-не-проиндексированные страницы, `getIdsByWorkspace().length > countEmbeddablePages`. В итоге знаменатель, который видит UI, идёт `478 → 500 → 478` (сид → активный прогон → откат к DB-счёту после `clear`), что прямо подрывает заявленную цель «плавный рост 0 → total». При этом комментарий в том же блоке утверждает и «The worker overwrites `total` with the real page count», и «the counter denominator matches» — это взаимно исключающие утверждения. **Fix:** сделать так, чтобы воркер и считал, и итерировал тот же набор, что `countEmbeddablePages` (либо везде использовать один источник `total`), чтобы живой и стационарный знаменатели совпадали; как минимум — исправить комментарий, убрав ложное «the counter denominator matches». ⚠️ При правке: если просто поменять источник `total` в воркере, не меняя того, что он итерирует (`getIdsByWorkspace`), то `done` превысит `total` (счётчик уйдёт за 100%) — менять надо согласованно и источник счёта, и набор итерации. - **[warning][stability] Не сбрасывать `done` в 0 при повторном запуске реиндекса на ходу** — `apps/server/src/integrations/ai/ai-settings.service.ts:108-117`. `reindex()` безусловно зовёт `reindexProgress.start(workspaceId, total)` (HSET `done='0'`) перед enqueue, но если воркер уже работает, `add()` дедуплицирует активную джобу — второй воркер не стартует. Итог повторного клика / второго админа / второй вкладки: видимый счётчик отбрасывается на `0 / total`, пока живой воркер продолжает инкрементить с нуля, и до конца прогона прогресс занижен. Новый guard «кнопка крутится, пока `reindexing === true`» гасит только одиночный дабл-клик, но не мульти-таб / мульти-админ. **Fix:** сеять прогресс только когда активной записи нет (`reindexProgress.get()` вернул `null`), либо отдать сброс `done` исключительно воркеру (`start()` на старте прогона), а не пути enqueue. - **[suggestion][stability] TTL прогресса обновляется только в `increment()`** — `apps/server/src/integrations/ai/embedding-reindex-progress.service.ts:50-52`. 1-часовой TTL рефрешится только при обработке страницы. Если один embedding-вызов зависнет дольше TTL (предусловие маловероятное — обычно секунды), запись истечёт на ходу: `getMasked` вернёт `reindexing:false`, клиент перестанет поллить, а воркер ещё реально «висит» на странице. Риск низкий; помечаю как замечание о свойстве дизайна «TTL привязан к прогрессу». ### 🧪 Test coverage Покрытие основной логики добротное: `reindexWorkspace` (старт-total, инкремент per-page, `clear` в `finally` на успехе / fatal-abort / non-fatal / unconfigured), `getMasked` (живой прогресс vs DB-fallback) и клиентские чистые функции — все с осмысленными ассертами. Пробелы: - **[warning] Нет прямого юнит-теста для нового `EmbeddingReindexProgressService`** — `apps/server/src/integrations/ai/embedding-reindex-progress.service.ts`. Сервис исполняется только через `jest.fn()`-моки в чужих сьютах, его собственная логика не выполняется под тестом. Непокрыты ветки `get()` (нет ключа / `total === undefined` → `null`; `Number()`-коэрсия; не-числовой `total`/`done` → `null`; не-конечный `startedAt` → `0`) и контракт деградации (любой Redis-throw глотается, `get()` → `null`). **Fix:** добавить `embedding-reindex-progress.service.spec.ts` с фейковым ioredis: валидный хэш → `ReindexProgress`; пустой / без `total` → `null`; не-числовой `total` → `null`; не-конечный `startedAt` → `0`; `hgetall` бросает → `null`; `start`/`increment` шлют `hset`/`hincrby`+`expire` и глотают исключение. - **[warning] Не протестирован сид прогресса в `reindex()`** — `apps/server/src/integrations/ai/ai-settings.service.ts:105-112`. Это несущая часть фикса («первый поллинг сразу показывает 0»); если вызов `countEmbeddablePages → start()` уберут или переставят после `aiQueue.add`, тест не упадёт. **Fix:** тест на `service.reindex(WORKSPACE_ID)`, проверяющий, что `reindexProgress.start` вызван с `(WORKSPACE_ID, <count>)` и ДО `aiQueue.add` (порядок вызовов). - **[suggestion] Тест живого прогресса `getMasked` не различает источник `totalPages`** — `apps/server/src/integrations/ai/ai-settings.service.spec.ts:633-647`. В кейсе live-прогресса `progress.total` (478) совпадает с DB-счётом (478), поэтому `expect(totalPages).toBe(478)` проходит при любой ветке тернарника `progress ? progress.total : totalPages`. **Fix:** задать `progress.total = 500` при DB-счёте 478 и проверить `totalPages === 500`.
Ghost added 1 commit 2026-06-28 02:45:29 +03:00
Review fixes for the reindex-progress counter (#242):

1. Denominator jump (478 -> 500 -> 478): reindexWorkspace iterated
   getIdsByWorkspace() (ALL non-deleted pages) but the seed/status use
   countEmbeddablePages (text OR existing-embedding), so the live total exceeded
   the steady-state total whenever empty/text-less pages existed. Add
   PageRepo.getEmbeddablePageIds() that selects the IDs of the EXACT same set
   countEmbeddablePages counts (deletedAt IS NULL AND (text_content matches a
   non-whitespace char OR an EXISTS non-deleted pageEmbeddings row)), and have
   reindexWorkspace iterate THAT set with total = its length. Iteration set and
   count source change together, so done reaches exactly total == the
   steady-state denominator. Dropping text-less pages is correct (reindexPage
   no-ops on them; a page that lost its text but still has stale embeddings is in
   the set via the EXISTS clause and still gets its stale rows cleared). Removed
   the contradictory "worker overwrites with the real page count" / "denominator
   matches" comment.

2. Mid-run re-trigger reset: reindex() unconditionally re-seeded done=0 before an
   enqueue that de-dupes a running job, so a second click/admin/tab reset the
   visible counter while the worker kept incrementing. Now seed only when
   get(workspaceId) === null; the worker's own start() remains the single
   authoritative reset.

3. TTL: documented that it is intentionally tied to write progress
   (start/increment) and never refreshed on get(), so a dead worker's record
   can't be kept alive forever by client polling.

Tests: new embedding-reindex-progress.service.spec.ts (fake ioredis: hash ->
ReindexProgress, malformed/missing/non-numeric -> null, non-finite startedAt ->
0, hgetall throws -> null, start/increment issue hset/hincrby+expire and swallow
Redis errors); reindex() seed order + no-reseed-when-active guard; getMasked
live test now uses progress.total=500 vs DB 478 to pin the progress branch;
indexer specs updated to mock getEmbeddablePageIds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner

Code review — живой прогресс реиндексации embeddings

Вердикт: Request changes (запросить правки). Изменение качественное, аккуратное и хорошо покрыто тестами — серьёзных дефектов нет. Единственный must-fix формальный, но обязательный: смена вызова в reindexWorkspace оставила метод getIdsByWorkspace без вызывающих, а его docstring теперь противоречит коду (мёртвый код + рассинхрон «код vs документация», внесённые этим PR). Правка тривиальна.

Ревью прогнано по 8 аспектам (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture); ключевые утверждения перепроверены по исходникам PR-головы d2a6fcc7.

Must fix before merge

  • [conventions/documentation] Удалить осиротевший getIdsByWorkspace (или хотя бы исправить его docstring)apps/server/src/database/repos/page/page.repo.ts:271-283
    PR переключил единственного потребителя метода — reindexWorkspace — на новый getEmbeddablePageIds (embedding-indexer.service.ts:225). На PR-голове у getIdsByWorkspace не осталось ни одного вызова, при этом его docstring всё ещё гласит «Used by the RAG bulk reindex to (re)build embeddings for every existing page» — что прямо противоречит коду, а соседний getEmbeddablePageIds тоже заявляет «The bulk reindex iterates THIS set». Два docstring'а претендуют быть источником страниц для реиндекса, но истинен только один.
    Fix: удалить метод getIdsByWorkspace целиком; если он намеренно сохраняется как публичное API репозитория — убрать из docstring ложное упоминание про bulk reindex.

  • [warning][stability] Очищать только что засеянную запись прогресса, если aiQueue.add() бросает исключениеapps/server/src/integrations/ai/ai-settings.service.ts (блок seed перед enqueue в reindex())
    reindex() сидит запись прогресса (start(workspaceId, total)) до aiQueue.add(), а очищается она только в finally воркера reindexWorkspace(). Если add() упадёт после успешного start() (реалистичный транзиент: моргнул Redis, закрылась очередь при shutdown), воркер не запустится, clear() не выполнится, и засеянная запись проживёт весь TTL (1 ч): всё это время getMasked() отдаёт reindexing:true и «0 of N», кнопка Reindex висит в loading. Само-восстанавливается через TTL, данные не портятся — поэтому non-blocking. start()/clear() глотают ошибки Redis сами, так что единственный бросающий вызов здесь — add().
    Fix: обернуть seed+enqueue так, чтобы при ошибке add() вызвать await this.reindexProgress.clear(workspaceId) перед ре-throw (чистя только если seed сделал именно этот вызов, чтобы не затереть параллельный активный прогон).

Test coverage

Юнит-логика покрыта основательно: EmbeddingReindexProgressService (get валидный/пустой/частичный/нечисловой/ошибка, start/increment/clear + глотание ошибок), reindex() seed (порядок seed-до-enqueue и отсутствие ре-seed при активном прогоне), getMasked() (живой прогресс vs DB-fallback), reindexWorkspace() (start/increment×N/clear, очистка при fatal-abort и при unconfigured-return), клиентские чистые функции nextReindexPollInterval/isReindexComplete (все ветки).

Один реальный пробел:

  • [warning][test-coverage] Добавить интеграционный тест на сырой SQL getEmbeddablePageIds и его «lockstep» с countEmbeddablePagesapps/server/src/database/repos/page/page.repo.ts:297-320
    Этот запрос — смысловой стержень фикса: его WHERE (p.text_content ~ '[^[:space:]]' OR EXISTS не-удалённой строки pageEmbeddings, плюс deletedAt is null) обязан возвращать ровно то множество, которое считает countEmbeddablePages — иначе живой счётчик и стационарный знаменатель снова разойдутся (тот самый баг «478 of 478»). Но во всех тестах метод замокан (getEmbeddablePageIds: jest.fn().mockResolvedValue([...])), так что ни регистр пробелов, ни ветка OR-EXISTS (страница без текста, но со старыми embeddings), ни сам инвариант равенства мощностей не проверяются — дрейф предикатов пройдёт сборку «зелёным». В проекте уже есть дешёвый паттерн (*.int-spec.ts на реальном Postgres через getTestDb()).
    Fix: int-тест, засевающий воркспейс (страница с текстом; с пустым/пробельным текстом; без текста, но с живой строкой embeddings; удалённая; только с soft-deleted embeddings) и проверяющий точный набор id плюс инвариант getEmbeddablePageIds(ws).length === countEmbeddablePages(ws).

Architecture & design

  • Реестр прогресса: свой Redis-store vs существующий паттерн fileTasks / нативный прогресс BullMQ. Это уже третий механизм отдачи состояния фоновой задачи: BullMQ-воркер (но job.updateProgress нигде не используется), DB-таблица fileTasks с опросным контроллером (ZIP-импорт, PDF-экспорт), и новый bespoke Redis-хэш. Для данного случая bespoke-store оправдан (читатель — другой процесс, чтение по workspaceId, данные косметические/эфемерные, TTL сам чистит).

    • A — оставить как осознанный one-off (s): ноль переделок, нет нового конфига, crash-safety через TTL. Минус: три механизма прогресса сосуществуют. + одна строка в docstring/README, фиксирующая, что это и есть выбранный паттерн для cross-process косметического прогресса.
  • Дублирование SQL-предиката getEmbeddablePageIdscountEmbeddablePages (page.repo.ts). Оба метода держат байт-в-байт одинаковый WHERE, синхронизируемый только комментарием «MUST stay in lockstep». Сейчас идентичны (проверено), но правка одного предиката в будущем молча вернёт баг рассинхрона знаменателя
    сделать общий билдер запроса* (s): извлечь приватный embeddablePagesQuery(workspaceId), из которого один метод делает SELECT id, другой COUNT — инвариант становится структурным.

## Code review — живой прогресс реиндексации embeddings **Вердикт: Request changes (запросить правки).** Изменение качественное, аккуратное и хорошо покрыто тестами — серьёзных дефектов нет. Единственный must-fix формальный, но обязательный: смена вызова в `reindexWorkspace` оставила метод `getIdsByWorkspace` без вызывающих, а его docstring теперь противоречит коду (мёртвый код + рассинхрон «код vs документация», внесённые этим PR). Правка тривиальна. Ревью прогнано по 8 аспектам (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture); ключевые утверждения перепроверены по исходникам PR-головы `d2a6fcc7`. ### Must fix before merge - **[conventions/documentation] Удалить осиротевший `getIdsByWorkspace` (или хотя бы исправить его docstring)** — `apps/server/src/database/repos/page/page.repo.ts:271-283` PR переключил единственного потребителя метода — `reindexWorkspace` — на новый `getEmbeddablePageIds` (`embedding-indexer.service.ts:225`). На PR-голове у `getIdsByWorkspace` не осталось ни одного вызова, при этом его docstring всё ещё гласит «Used by the RAG bulk reindex to (re)build embeddings for every existing page» — что прямо противоречит коду, а соседний `getEmbeddablePageIds` тоже заявляет «The bulk reindex iterates THIS set». Два docstring'а претендуют быть источником страниц для реиндекса, но истинен только один. **Fix:** удалить метод `getIdsByWorkspace` целиком; если он намеренно сохраняется как публичное API репозитория — убрать из docstring ложное упоминание про bulk reindex. - **[warning][stability] Очищать только что засеянную запись прогресса, если `aiQueue.add()` бросает исключение** — `apps/server/src/integrations/ai/ai-settings.service.ts` (блок seed перед enqueue в `reindex()`) `reindex()` сидит запись прогресса (`start(workspaceId, total)`) **до** `aiQueue.add()`, а очищается она только в `finally` воркера `reindexWorkspace()`. Если `add()` упадёт после успешного `start()` (реалистичный транзиент: моргнул Redis, закрылась очередь при shutdown), воркер не запустится, `clear()` не выполнится, и засеянная запись проживёт весь TTL (1 ч): всё это время `getMasked()` отдаёт `reindexing:true` и «0 of N», кнопка Reindex висит в `loading`. Само-восстанавливается через TTL, данные не портятся — поэтому non-blocking. `start()`/`clear()` глотают ошибки Redis сами, так что единственный бросающий вызов здесь — `add()`. **Fix:** обернуть seed+enqueue так, чтобы при ошибке `add()` вызвать `await this.reindexProgress.clear(workspaceId)` перед ре-throw (чистя только если seed сделал именно этот вызов, чтобы не затереть параллельный активный прогон). ### Test coverage Юнит-логика покрыта основательно: `EmbeddingReindexProgressService` (get валидный/пустой/частичный/нечисловой/ошибка, start/increment/clear + глотание ошибок), `reindex()` seed (порядок seed-до-enqueue и отсутствие ре-seed при активном прогоне), `getMasked()` (живой прогресс vs DB-fallback), `reindexWorkspace()` (start/increment×N/clear, очистка при fatal-abort и при unconfigured-return), клиентские чистые функции `nextReindexPollInterval`/`isReindexComplete` (все ветки). Один реальный пробел: - **[warning][test-coverage] Добавить интеграционный тест на сырой SQL `getEmbeddablePageIds` и его «lockstep» с `countEmbeddablePages`** — `apps/server/src/database/repos/page/page.repo.ts:297-320` Этот запрос — смысловой стержень фикса: его WHERE (`p.text_content ~ '[^[:space:]]'` OR EXISTS не-удалённой строки `pageEmbeddings`, плюс `deletedAt is null`) обязан возвращать ровно то множество, которое считает `countEmbeddablePages` — иначе живой счётчик и стационарный знаменатель снова разойдутся (тот самый баг «478 of 478»). Но во всех тестах метод замокан (`getEmbeddablePageIds: jest.fn().mockResolvedValue([...])`), так что ни регистр пробелов, ни ветка OR-EXISTS (страница без текста, но со старыми embeddings), ни сам инвариант равенства мощностей не проверяются — дрейф предикатов пройдёт сборку «зелёным». В проекте уже есть дешёвый паттерн (`*.int-spec.ts` на реальном Postgres через `getTestDb()`). **Fix:** int-тест, засевающий воркспейс (страница с текстом; с пустым/пробельным текстом; без текста, но с живой строкой embeddings; удалённая; только с soft-deleted embeddings) и проверяющий точный набор id плюс инвариант `getEmbeddablePageIds(ws).length === countEmbeddablePages(ws)`. ### Architecture & design - **Реестр прогресса: свой Redis-store vs существующий паттерн `fileTasks` / нативный прогресс BullMQ.** Это уже третий механизм отдачи состояния фоновой задачи: BullMQ-воркер (но `job.updateProgress` нигде не используется), DB-таблица `fileTasks` с опросным контроллером (ZIP-импорт, PDF-экспорт), и новый bespoke Redis-хэш. Для данного случая bespoke-store оправдан (читатель — другой процесс, чтение по `workspaceId`, данные косметические/эфемерные, TTL сам чистит). - *A — оставить как осознанный one-off* (s): ноль переделок, нет нового конфига, crash-safety через TTL. Минус: три механизма прогресса сосуществуют. + одна строка в docstring/README, фиксирующая, что это и есть выбранный паттерн для cross-process косметического прогресса. - **Дублирование SQL-предиката `getEmbeddablePageIds` ↔ `countEmbeddablePages`** (`page.repo.ts`). Оба метода держат байт-в-байт одинаковый WHERE, синхронизируемый только комментарием «MUST stay in lockstep». Сейчас идентичны (проверено), но правка одного предиката в будущем молча вернёт баг рассинхрона знаменателя сделать общий билдер запроса* (s): извлечь приватный `embeddablePagesQuery(workspaceId)`, из которого один метод делает SELECT id, другой COUNT — инвариант становится структурным.
Ghost force-pushed fix/embeddings-reindex-progress from d2a6fcc752 to bf09eec4e1 2026-06-28 04:39:42 +03:00 Compare
Ghost added the review/changes-requested label 2026-06-28 22:10:21 +03:00
Ghost added 1 commit 2026-06-28 23:39:40 +03:00
F1: clear the "Reindex now" spinner once the poll cap fires. Gate the
reindexing part of the button's loading state on the active poll window
(reindexDeadline !== null) so a run that outlives the 120s cap no longer
leaves the button stuck-disabled with a stale `reindexing: true`; the
admin can restart.

F2: rewrite reindexWorkspace JSDoc to describe the EMBEDDABLE page set
(text OR existing embeddings), matching getEmbeddablePageIds /
countEmbeddablePages instead of the old "every non-deleted page".

F3: extract the shared embeddable-content predicate into a private
PageRepo.embeddablePredicate helper, called by both countEmbeddablePages
and getEmbeddablePageIds, removing the verbatim duplication. Behavior is
identical (lockstep int-spec stays green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added review/needs and removed review/changes-requested labels 2026-06-28 23:40:07 +03:00
Ghost added the status/in-progress label 2026-06-29 00:17:06 +03:00
Ghost added review/approved and removed review/needs labels 2026-06-29 00:29:20 +03:00
Ghost added review/changes-requested and removed review/approved labels 2026-06-29 01:11:45 +03:00
Ghost added 1 commit 2026-06-29 01:50:18 +03:00
F4: extract the reindex button `loading` predicate into a pure, unit-tested
`isReindexButtonLoading({ mutationPending, deadline, status })` next to the
other reindex helpers, replacing the inline JSX expression. Covers the
load-bearing post-cap case (deadline nulled, reindexing stale-true -> not
loading) plus mutationPending, active-run, and finished cases.

F5: rewrite the `useAiSettingsQuery` poll comment to match the actual
`nextReindexPollInterval` stop condition (continues while reindexing===true OR
within deadline and not fully indexed; stops only when reindexing===false &&
indexed>=total, or the deadline cap) instead of the stale "until indexed===total".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added review/needs and removed review/changes-requested labels 2026-06-29 01:51:55 +03:00
Ghost added review/approved and removed review/needs labels 2026-06-29 02:01:16 +03:00
Ghost added needs-human and removed review/approved labels 2026-06-29 02:16:54 +03:00
Ghost added review/changes-requested and removed needs-human labels 2026-06-29 02:22:46 +03:00
Collaborator

F10 [suggestion] apps/server/src/integrations/ai/ai-settings.service.ts:117-128 — узкая гонка seed/dedup может оставить залипшее «reindexing: 0 of N» на весь TTL (1ч). Сид прогресса гейтится по reindexProgress.get()===null, а дедуп запуска — по существованию BullMQ-джобы (jobId). Это два разных источника истины, расходящиеся на хвосте предыдущего прогона: воркерский finally вызывает clear() ДО того, как removeOnComplete удалит джобу. Если triggerReindex попадает в это окно (двойной клик / второй админ-вкладка / авто-триггер при включении AI Search, совпавший с завершением), то get() уже null → seeded=true (ставится 0 of N), но aiQueue.add дедупится против завершающейся джобы → новый воркер НЕ стартует → сид никто не инкрементит и не чистит → статус отдаёт reindexing:true, indexed:0 до истечения TTL (1ч) или следующего ручного клика. Косметика, самоустраняется, потому suggestion/low.

Fix: сделать пред-сид (в triggerReindex, до старта воркера) короткоживущим — добавить параметр ttlSeconds в start() и вызывать из triggerReindex с коротким TTL (30-60с); воркерский start() в начале реального прогона перезапишет запись и поднимет TTL до полного. Если воркер не стартовал (дедуп), фантомная запись истечёт за секунды. Альтернатива — сидить только ПОСЛЕ подтверждённого add() новой джобы.

F10 [suggestion] `apps/server/src/integrations/ai/ai-settings.service.ts:117-128` — узкая гонка seed/dedup может оставить залипшее «reindexing: 0 of N» на весь TTL (1ч). Сид прогресса гейтится по `reindexProgress.get()===null`, а дедуп запуска — по существованию BullMQ-джобы (jobId). Это два разных источника истины, расходящиеся на хвосте предыдущего прогона: воркерский finally вызывает clear() ДО того, как removeOnComplete удалит джобу. Если triggerReindex попадает в это окно (двойной клик / второй админ-вкладка / авто-триггер при включении AI Search, совпавший с завершением), то get() уже null → seeded=true (ставится 0 of N), но aiQueue.add дедупится против завершающейся джобы → новый воркер НЕ стартует → сид никто не инкрементит и не чистит → статус отдаёт reindexing:true, indexed:0 до истечения TTL (1ч) или следующего ручного клика. Косметика, самоустраняется, потому suggestion/low. Fix: сделать пред-сид (в triggerReindex, до старта воркера) короткоживущим — добавить параметр ttlSeconds в start() и вызывать из triggerReindex с коротким TTL (30-60с); воркерский start() в начале реального прогона перезапишет запись и поднимет TTL до полного. Если воркер не стартовал (дедуп), фантомная запись истечёт за секунды. Альтернатива — сидить только ПОСЛЕ подтверждённого add() новой джобы.
Collaborator

Ревью bdc033e68 — переревью ПОЛНЫМИ 8 аспектами (отдельный субагент на каждый). Вердикт: CHANGES.
Раскладка: security / test-coverage / conventions / architecture — LGTM. stability — новая F10. regressions — F6 (открыта). documentation — F7 (открыта). simplification — F8, F9 (открыты).
Открыто:

  • F6 (warning, regressions/DO) — массовый reindex теряет страницы с text_content=NULL + непустой content (предикат не ловит, а reindexPage индексирует из content). Фикс (DO): расширить embeddablePredicate третьим OR на content-bearing, либо бэкфилл text_content. (architecture-аспект счёл зазор узким, т.к. textContent обычно поддерживается из content; regressions подтвердил реальные null-строки — оставляю как DO.)
  • F7 (suggestion, docs) — JSDoc start() «placeholder/real page count» противоречит lockstep (оба total реальны и совпадают).
  • F8 (suggestion, simpl) — nextReindexPollInterval дублирует условие isReindexComplete — переиспользовать.
  • F9 (suggestion, simpl) — getMasked считает оба COUNT даже при активном progress (горячий путь, опрос 5с) — читать progress первым.
  • F10 (suggestion, stability) — гонка seed/dedup → залипшее «0 of N» на TTL 1ч в узком окне; пред-сид с коротким TTL.
Ревью bdc033e68 — переревью ПОЛНЫМИ 8 аспектами (отдельный субагент на каждый). Вердикт: CHANGES. Раскладка: security / test-coverage / conventions / architecture — LGTM. stability — новая F10. regressions — F6 (открыта). documentation — F7 (открыта). simplification — F8, F9 (открыты). Открыто: - F6 (warning, regressions/DO) — массовый reindex теряет страницы с text_content=NULL + непустой content (предикат не ловит, а reindexPage индексирует из content). Фикс (DO): расширить embeddablePredicate третьим OR на content-bearing, либо бэкфилл text_content. (architecture-аспект счёл зазор узким, т.к. textContent обычно поддерживается из content; regressions подтвердил реальные null-строки — оставляю как DO.) - F7 (suggestion, docs) — JSDoc start() «placeholder/real page count» противоречит lockstep (оба total реальны и совпадают). - F8 (suggestion, simpl) — nextReindexPollInterval дублирует условие isReindexComplete — переиспользовать. - F9 (suggestion, simpl) — getMasked считает оба COUNT даже при активном progress (горячий путь, опрос 5с) — читать progress первым. - F10 (suggestion, stability) — гонка seed/dedup → залипшее «0 of N» на TTL 1ч в узком окне; пред-сид с коротким TTL.
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/embeddings-reindex-progress:fix/embeddings-reindex-progress
git checkout fix/embeddings-reindex-progress
Sign in to join this conversation.
No Reviewers
3 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#242