WEB-658 · Дефект · — · web
P1 [индекс/ядро]: большой источник роняет ВЕСЬ индексирующий воркер — 192 вызова эмбеддингов внутри одной 5-секундной транзакции Prisma → потеря аренды
Закрыт
P1 · важно
ведёт: —
Суть
## 1. Суть
Один документ на 2 МиБ (~192 куска) кладёт индексирующий воркер целиком: ~192 последовательных вызова эмбеддингов выполняются внутри ОДНОЙ интерактивной транзакции Prisma с потолком 5000 мс. Транзакция не укладывается («8105 ms passed since the start of the transaction»), обрыв каскадом даёт `ActivePassiveLeaseLostError` (`reason: 'local_expiry_checkpoint'`), и процесс умирает — не один источник, а весь воркер.
## 2. ГДЕ МЫ СЕЙЧАС (13.09)
Найдено волной 3730 на стенде Enterprise-2 при первом живом прогоне очереди индексации под нагрузкой (10 одновременных пользователей). Воспроизвелось ДВАЖДЫ за одну волну. Доказательства: `/home/ubuntu/waves/3730STANDINDEXER-evidence/07-known-issue-large-source-steady-state-lease-loss.txt` на A2. Не исправлено: мандат волны ограничивался конфигурацией стенда, ядро `src/lib` она трогать не имела права.
## 3. МЕХАНИКА / УСТРОЙСТВО
Очередь индексации держит источники `cap06-vuNNNN-upload.txt` по 2097152 байта. Обработка одного конца в конец выдаёт ~192 последовательных обращения к провайдеру эмбеддингов, и всё это, судя по логу, находится внутри одной интерактивной транзакции Prisma (потолок по умолчанию 5000 мс). Два независимых механизма складываются в отказ: форма транзакции (сетевые вызовы внутри транзакции БД) и аренда active-passive, которая при обрыве считает себя потерянной и валит процесс.
## 4. ХРОНОЛОГИЯ
- 13.09 — волна 3730 (WEB-633) подняла индексирующий воркер на стенде и впервые увидела устойчивую работу очереди; на больших источниках воркер умирал дважды.
## 5. КАРТА ДОКУМЕНТОВ
- `src/lib/activePassiveLease.ts` — потеря аренды по `local_expiry_checkpoint`.
- `scripts/process-source-indexing-queue.ts` — воркер.
- `src/lib/ingest/` — запись кусков.
- Доказательства: `3730STANDINDEXER-evidence/07-*.txt` (A2).
## 6. ОСТАТОК
1. Вынести сетевые вызовы эмбеддингов ИЗ транзакции БД (или разбить запись кусков на пакеты со своей транзакцией на пакет).
2. Потеря аренды на одном источнике не должна убивать процесс: падать должен источник, воркер обязан выжить и пометить источник.
3. Негативный тест: источник, заведомо не укладывающийся в 5000 мс, не роняет воркер.
## 7. КРИТЕРИЙ ЗАКРЫТИЯ
Источник на 2 МиБ индексируется до конца; воркер жив; тест доказывает, что искусственно затянутая обработка одного источника не убивает процесс.
## Связь
Вероятная причина части застрявших документов в WEB-593 (351/185 без единого куска). Найдено линией Enterprise-2 WEB-626 / WEB-633.
Лента
2026-09-13T18:48:12.108Z · coordinator[13.09 18:48Z координатор] ПОПРАВКА К ТЕЛУ ТИКЕТА по итогам волны 3735 — механизм я описал неточно, исправляю честно.
**Чего НЕ подтвердилось.** Формулировка «~192 последовательных вызова эмбеддингов внутри ОДНОЙ интерактивной транзакции Prisma с потолком 5000 мс» **не воспроизвелась**. Волна прошла по всему дереву (`grep '$transaction'`, 81 файл) и по полному пути пайплайна: `process-source-indexing-queue.ts` → `processSourceIndexingQueue()`/`indexSourceNow()` → `processDocument()`. Запись чанков (`vectorStore.addDocuments` → `writeSourceBoundChunks`, `src/lib/rag/sourceChunkWriter.ts`) действительно идёт через `$transaction`, но КОРОТКИМИ транзакциями, а не одной длинной с сетевыми вызовами внутри. Я взял эту формулировку из наблюдения волны 3730 и не проверил её сам — это моя ошибка, а не её.
**Что подтвердилось как настоящий, независимый дефект.** Потеря аренды на ОДНОМ источнике убивает ВЕСЬ процесс воркера. Место: `src/lib/ingest/sourceIndexingQueue.ts:1279` — единый `catch` в `runSourceIndexing()`; и в `scripts/process-source-indexing-queue.ts` единственный `withActivePassiveLease()` оборачивал ВЕСЬ цикл `do/while`, поэтому любая икота аренды валила цикл целиком, а не одну итерацию.
**Исправлено двумя уровнями** (оба нужны, проверено эмпирически): `processCandidateSurvivingLeaseHiccups()` — одна попытка обработки источника переживает `ActivePassiveLeaseError`; и снятие общей обёртки аренды со всего цикла воркера. Негативный тест `web658WorkerSurvivesLeaseHiccup.test.ts` доказывает, что НАСТОЯЩИЙ чужой держатель аренды по-прежнему останавливает воркер — защита «одна боевая копия рантайма» жива.
**Сопутствующий факт, который объясняет частоту отказов** (находка волны 3736): TTL аренды по умолчанию — **15 секунд** (`ACTIVE_PASSIVE_LEASE_DEFAULT_TTL_MS`, `src/lib/activePassiveLease.ts:11`) при продлении раз в 5 секунд. Это запас в три такта; на загруженном боксе таймер продления не успевает, и воркер честно кончает себя по собственной защите. Оба воркера стенда — и извлечения, и индексации — страдали этим сегодня.
**Практическое следствие, которое стоило нам двух ступеней лестницы:** супервизор есть только у индексирующего воркера. Экстрактор умер в 16:44Z, никто его не поднял, `ExtractionJob` накопил 26 задач в очереди, `/api/ingest/status` никогда не доходил до `done`, и засев новых пользователей падал по таймауту 60 секунд — из-за этого не стартовали ступени 25 и 50. Координатор поднял экстрактор руками в 19:0xZ: очередь ушла с 26 до 7 за 25 секунд, засев пошёл за 11 секунд на пользователя. Присмотр за экстрактором и перенос живучести 3735 в дерево стенда отданы волне 3740.
Зеркало: `refs/waves/3735` = `812654e10`. Фикс WEB-659 (самостолкновение аренды на session-end) в том же коммите, помечен FIXED.
2026-09-13T22:27:52.029Z · coordinator[13.09 22:27Z координатор] СТУПЕНЬ 25 ПОСЛЕ ПОЧИНКИ ГЕНЕРАТОРА (`rung25f`) — и здесь важнее не goodput, а то, что показала память.
Числа: 116 предложено / 81 выполнено = 69.83% — **в точности как до починки** (`rung25e`: те же 116/81). Зато задержки упали ощутимо: общая p50 2191 → **1356 мс**; session 452 → 303; save_readback p50 3376 → **2445**, p95 4939 → **3974**; retrieval 899 → 468; status 1256 → 1059. Группы те же: шесть зелёных без единой ошибки, красные — realtime (10/0) и upload_index (25/0, p50 252 с).
**ГЛАВНОЕ НАБЛЮДЕНИЕ: машина на 25 пользователях практически пуста.** В момент прогона: занято 2 ГБ из 23, доступно 20 ГБ, своп не тронут. То есть сервер и близко не у предела, а число 69.83% не про его ёмкость.
ПОЧЕМУ ПРЕДЛОЖЕНО ВСЕГО 116 ДЕЙСТВИЙ ПРИ 25 ПОЛЬЗОВАТЕЛЯХ, КОГДА ПРИ 10 ИХ БЫЛО 378. Это не парадокс, а прямое следствие дефекта WEB-658: одно действие группы upload_index занимает ~252 секунды при длине прогона 300 секунд. На 25 пользователях в эту группу попадает 25 попыток — и каждая занимает своего пользователя почти на весь прогон. Пользователи стоят в одном действии вместо того, чтобы выполнять десятки, и общее число предложенных действий падает. При 10 пользователях в upload_index попадало 10 попыток, остальные успевали крутить retrieval (170 раз) и status (138 раз).
ВЫВОД ДЛЯ ЛЕСТНИЦЫ: сейчас мы меряем не ёмкость сервера, а время застревания в сломанной группе. Пока индексация занимает четыре минуты на документ, любая ступень выше 10 будет показывать деградацию, которой на самом деле нет. Ёмкость честно измерима либо после починки индексации до разумного времени, либо с временным исключением upload_index из профиля — но второе надо делать явно и записывать в отчёт, а не молча.
Ступень 50: пул фикстур на 50 пользователей истёк в 20:30Z (сделан был на 2 часа), пересеваю с сроком 24 часа — ровно та часовая бомба, о которой предупреждала волна 3722 и которую я теперь закрываю флагом `--fixture-ttl-hours 24`.
2026-09-13T22:41:42.714Z · coordinator[13.09 22:41Z координатор] ПРОВЕРКА ДОГАДКИ ВЛАДЕЛЬЦА О «ЛЕНИВОЙ ИНДЕКСАЦИИ» — фактами, файл:строка:дата.
Владелец 13.09 22:33Z предположил: медленная индексация, возможно, сделана НАМЕРЕННО, чтобы не перегружать сервер, и делалась в те времена, когда прод крутился на малинке. **Догадка подтверждается кодом.**
`src/lib/ingest/sourceIndexingQueue.ts:203-206`:
```
const DEFAULT_QUEUE_LIMIT = 1;
const MAX_QUEUE_LIMIT = 50;
const DEFAULT_QUEUE_CONCURRENCY = 1;
const MAX_QUEUE_CONCURRENCY = 2;
```
То есть по умолчанию воркер берёт ОДИН источник за проход и обрабатывает его в ОДНУ полосу, а потолок полос — **две**, сколько бы ни попросили: `resolveSourceIndexingQueueConcurrency` (`:215-218`) молча срезает любое большее значение через `Math.min(MAX_QUEUE_CONCURRENCY, …)`.
Намерение зафиксировано в самом коде — комментарий к полю `concurrency` (`:194-199`): «Number of source-processing lanes. The safe default is one; a second lane is only an explicit rollout choice and still uses the atomic DB claim for every source.» То есть это сознательная осторожность, а не недосмотр.
Даты появления (git log по строкам): `DEFAULT_QUEUE_CONCURRENCY`/`MAX_QUEUE_CONCURRENCY` — коммит `efd06bf70` от **31.08.2026** («P1.2: add indexed source queue claim proof»); `DEFAULT_QUEUE_LIMIT` — `752c79829` от **25.08.2026**. Обе даты — до переезда прода на Oracle.
ЧТО ЭТО МЕНЯЕТ В ТРАКТОВКЕ WEB-658 И В ЛЕСТНИЦЕ:
1. Четыре минуты на документ — это не только и не столько дефект, сколько **устаревшая настройка**. Разница принципиальная: дефект чинят, настройку перенастраивают. Настоящим дефектом остаётся то, что нашла волна 3735 — смерть всего воркера от икоты аренды на одном источнике; он уже исправлен и посажен линией l115l.
2. Переменной окружения для этих чисел НЕТ — потолок зашит в код, и на четырёхъядерной машине Oracle две полосы вместо, скажем, четырёх душат нас без причины.
3. Для лестницы это значит: группа upload_index упирается не в ёмкость сервера, а в наш собственный ограничитель. Пока он не поднят, любая ступень выше 10 показывает деградацию, которой нет (ступень 50: 231 действие предложено при занятых 3 ГБ из 23).
ПРЕДЛОЖЕНИЕ (решение за владельцем, потому что это про поведение прода, а не про стенд): поднять потолок полос и сделать значение настраиваемым через окружение, с осторожным значением по умолчанию. Тогда число полос станет параметром машины, а не константой эпохи малинки. До этого решения на лестнице группа upload_index временно исключается — явно, с отметкой в выводе каждого прогона и в этом тикете.
2026-09-13T23:13:09.218Z · coordinator[13.09 23:13Z координатор] ПОТОЛОК ПОЛОС ПОДНЯТ И СТАЛ НАСТРОЙКОЙ (волна 3752, VERDICT=GO, по прямому решению владельца 13.09 22:47Z).
САМОЕ ПОЛЕЗНОЕ В ЭТОЙ РАБОТЕ — уточнение задачи. Переменная окружения **уже существовала**: `WORKER_CONCURRENCY`, читается в `scripts/process-source-indexing-queue.ts` (`resolveWorkerConcurrency`), это продовый воркер (`infra/source-indexing-worker/note-clone-source-indexing.service`, `DB_POOL_ROLE=worker`). Значение прокидывалось в `processSourceIndexingQueue({ concurrency })` — и там ЕГО МОЛЧА РЕЗАЛ второй, независимый ограничитель `resolveSourceIndexingQueueConcurrency` с потолком 2. То есть настройка была, ею пользовались, а она не работала выше двойки. Задача оказалась не «добавить настройку», а «снять искусственный потолок с уже существующей».
НОВЫЙ ПОТОЛОК — 4, и обоснован реальным ресурсом, а не круглым числом: одна полоса (`indexSourceNow`) держит не более ОДНОГО соединения Prisma одновременно — захват, чтение чекпоинта, запись и расчёт идут последовательно, ни разу не через параллельные вызовы. Значит 4 полосы = максимум 4 одновременных соединения, что ровно равно собственному бюджету воркера `WORKER_DB_POOL_LIMIT=4` из закоммиченного production worksheet. Совпадает и с числом ядер машины, что ограничивает пользу от дальнейшего наращивания работы, упирающейся в процессор (нарезка и хеширование).
ВОЛНА ПОПРАВИЛА МЕНЯ, И ПРАВИЛЬНО СДЕЛАЛА. В брифе я предложил опереться на `role=web connection_limit=40` — строку, которую видел в живом логе воркера на стенде. Волна прошла по исходникам и показала, что единственное совпадение `40` в коде относится к отчёту про Telegram-бота, то есть к другой системе с похожим именем поля, и выбрала другой, надёжно закоммиченный якорь. Она написала прямо: «правлю факт явно, а не тихо». Это ровно то поведение, которого мы добиваемся.
АТОМАРНОСТЬ ДОКАЗАНА НА НАСТОЯЩЕЙ POSTGRES — это было условием, без которого потолок поднимать запрещалось: 4 полосы, один и тот же источник → ровно один победитель, exit=0. Блокирующих находок нет.
ЧЕСТНЫЙ РИСК В KNOWN ISSUES: больше полос — больше одновременных обращений к платному провайдеру. Мы упрёмся в лимит запросов учётки OpenAI или в скорость траты кошелька РАНЬШЕ, чем в ядра. Отказом это не станет: повтор с отступом превращает превышение лимита в задержку, а не в ошибку, и трата всё равно проходит через заслон кошелька до вызова. Риск реальный, не гипотетический, и он записан.
Следующее: независимая приёмка, затем правка едет в ближайшую линию. До посадки поведение прода не меняется — умолчание оставлено прежним намеренно, посадка не имеет права молча начать грузить машину вчетверо сильнее.
2026-09-13T23:50:51.978Z · coordinator[13.09 23:50Z координатор] ПРИЁМКА ОСТАНОВИЛА ПОСАДКУ ПОТОЛКА ПОЛОС. Волна 3756 (независимый исполнитель, Luna): `VERDICT=NO-GO`, `3752=FAIL 3751=PASS`.
ЧТО ИМЕННО ОПРОВЕРГНУТО. Потолок 4 обосновывался посылкой «одна полоса держит не более ОДНОГО соединения Prisma одновременно», отсюда 4 полосы = 4 соединения = ровно бюджет воркера `WORKER_DB_POOL_LIMIT=4`. Приёмщик проверил эту посылку прямым чтением пути и **опроверг её**:
- `src/lib/documents/processDocumentRuntime.ts:766-790` — после записи кусков запускает `maybeBuildDocumentOutline` через `void (async () => …)()` и **НЕ ждёт его завершения**;
- `src/lib/digest/documentOutlineRuntime.ts:141-145` — этот отсоединённый путь получает СВОЙ реальный Prisma-клиент и сразу делает чтение;
- после отсоединения основная полоса продолжает работу по своему пути.
Значит при включённом `ENABLE_DOC_OUTLINE` и подходящем размере документа **одна полоса держит ДВА соединения одновременно**. Тогда 4 полосы — это до 8 соединений при бюджете 4, то есть прямой путь к исчерпанию пула на живой системе. Посылка, на которой стоял выбор числа, неверна — значит и число выбрано неверно.
Отдельно приёмщик подтвердил остальное по 3752: старый ограничитель действительно резал значение на двойке (в BASE оба предела равны 2), в ветке оба подняты до 4, умолчание 1 сохранено, мусор в переменной обрабатывается корректно. То есть работа сделана добротно — неверна именно опора для числа.
**3751 (исключение групп) — PASS.** Проверено независимо: без исключения расписание и веса исходные; с исключением `upload_index,status` группы отсутствуют и в расписании, и в покрытии, сумма весов остаётся 100; строка исключения печатается и попадает в JSON-сводку; при ПУСТОМ значении поле присутствует (а не отсутствует — это был обязательный негатив); опечатка `sessoin` бросает `unknown group`; список, оставляющий меньше двух групп, тоже отвергается.
ЧТО ДАЛЬШЕ. Потолок в прод НЕ едет, пока не выбрано число, опирающееся на верную посылку. Варианты, которые надо взвесить фактами: (а) потолок 2 при нынешнем бюджете пула (2 полосы × 2 соединения = 4); (б) поднять бюджет пула воркера и обосновать его отдельно; (в) заставить отсоединённый путь построения структуры документа использовать то же соединение или дождаться его. Решение должно приниматься по измерению, а не по желанию получить число побольше.
2026-09-13T23:52:06.517Z · coordinator[13.09 23:52Z координатор] ПОСТ-QA ЛИНИИ l115l (волна 3755, VERDICT=GO). Пять тикетов последней посадки, все пять — `PROVEN-OFFLINE`, с прогонами на ДВУХ коммитах: до фикса и на коммите, который сейчас в проде (`1628b697d`).
| Тикет | Тест | Коммит | Результат |
|---|---|---|---|
| WEB-188 | `videoRunPolling.test.ts` | `4a6e99ed4` (до фикса) | **16/17, одно НАСТОЯЩЕЕ падение проверки** |
| WEB-188 | тот же | `1628b697d` (прод) | 17/17 |
| WEB-195 | `web195TranscriptOnlyPdfQuality.test.ts` | `4a6e99ed4` (до фикса) | **4/6, два НАСТОЯЩИХ падения** |
| WEB-195 | тот же | `1628b697d` (прод) | 6/6 |
| WEB-658 | `source-indexing-worker-lease-hiccup.test.ts` | `1628b697d` | 5/5 |
| WEB-658 | `web658WorkerSurvivesLeaseHiccup.test.ts` | `1628b697d` | 6/6, включая красную форму и 3 негатива |
| WEB-659 | `activePassiveLease.web659.real-db.test.ts` (настоящая Postgres) | `1628b697d` | 3/3 — красный, зелёный, негатив |
| WEB-659 | `web659SharedOwnerAdmission.test.ts` | `1628b697d` | 1/1 |
Ключевое отличие от прошлого раза: у WEB-188 и WEB-195 красное до фикса — это **настоящие падения проверок**, а не ошибка сборки и не отсутствие модуля. Именно на этом отличии независимая приёмка 3743 в прошлый раз вернула мне три закрытия, и теперь оно соблюдено.
**Закрываю: WEB-188 и WEB-195** — доказательство полное, пары настоящие, оба дефекта клиентские/отчётные и живой пробы сверх этого не требуют.
**НЕ закрываю пока: WEB-658 и WEB-659.** Их офлайн-доказательства сильные (красная форма плюс негативы, для WEB-659 — на настоящей Postgres), но у обоих есть живая проверка, которая вот-вот будет: стенд ёмкости сейчас пересобирается под коммит прода, и группа `realtime` там обязана позеленеть. Если позеленеет — закрою WEB-659 живым наблюдением, а не только тестами. Если нет — значит причина не та, что мы думаем, и это важнее любого зелёного теста.
**WEB-593 остаётся открытым по существу:** доказан ИНСТРУМЕНТ повторной загрузки, а не восстановление документов. Тикет закроется, когда 185 застрявших документов реально вернутся в индекс.
2026-09-14T00:50:49.818Z · coordinator[14.09 00:50Z координатор] ИЗМЕРЕНО: ЧЕСТНЫЙ ПОТОЛОК ПОЛОС — 2, И ЭТО РОВНО ТО, ЧТО БЫЛО. Волна 3758, VERDICT=GO.
Напомню путь: владелец разрешил поднять потолок; волна 3752 подняла его до 4, опираясь на посылку «одна полоса = одно соединение»; независимая приёмка 3756 посылку опровергла; волна 3758 получила задание не спорить, а ИЗМЕРИТЬ.
**ИЗМЕРЕНИЕ (6 прогонов, две конфигурации):** пик соединений на одну полосу — **2 при `ENABLE_DOC_OUTLINE=on` (это значение по умолчанию) и 1 при выключенном**. В одном из прогонов оба соединения пойманы одновременно в состоянии `state='active'`, на двух РАЗНЫХ backend-процессах PostgreSQL — то есть это не теория и не гонка измерителя, а два реально одновременно занятых соединения.
**АРИФМЕТИКА ПОТОЛКА:** бюджет пула воркера 4 соединения ÷ 2 соединения на полосу = **2 полосы**. Волна ставит 2 — то есть ровно прежнее значение.
**ЧТО ЭТО ЗНАЧИТ.** Старый потолок 2, который мы весь день считали «константой эпохи малинки», оказался ПРАВИЛЬНЫМ — просто причина нигде не была записана, и поэтому выглядел произволом. Теперь причина измерена и записана. Ценность работы не в новом числе, а в том, что (а) настройка `WORKER_CONCURRENCY` больше не режется молча — раньше её выставляли, а она не работала выше двойки без всякого сообщения; (б) у числа появилось обоснование, которое можно перепроверить командой.
**ПОЧЕМУ НЕЛЬЗЯ ПРОСТО ДОЖДАТЬСЯ ОТСОЕДИНЁННОГО ПУТИ.** Второе соединение держит построение сводки документа (`processDocumentRuntime.ts:766-790`), запущенное без ожидания. Собственный комментарий в коде объясняет, почему оно отсоединено: это «примерно 20 вызовов дешёвой модели, может занять минуты». Дождаться — значит держать полосу занятой минутами на каждом документе. Лекарство хуже болезни.
**ДОРОГА К БОЛЬШЕМУ ЧИСЛУ ПОЛОС, если она понадобится** (ни одна не бесплатна, и решение за владельцем):
1. Увести построение сводки из процесса воркера — тогда полоса снова держит одно соединение и потолок арифметически возвращается к 4.
2. Поднять бюджет пула воркера — но это отдельный ресурс, и его надо обосновывать своим измерением, а не желанием.
3. Выключать `ENABLE_DOC_OUTLINE` на время массовой переиндексации — тогда пик 1 и потолок 4, но продукт теряет сводки.
Пока: потолок 2, настройка работает и печатает срезание, умолчание не изменилось, поведение прода без явной настройки прежнее.
2026-09-14T04:14:25.851Z · coordinator[14.09 04:14Z координатор] **WEB-658 и WEB-659 ПОДТВЕРЖДЕНЫ ЖИВЬЁМ НА СТЕНДЕ. Проба 4/4, включая негативный контроль.**
Пост-QA линии l115l (волна 3755) честно оставил эти два тикета открытыми: «не закрываю до живого подтверждения на стенде». Механику активной-пассивной аренды нельзя доказать офлайн — нужны две копии, реально спорящие за одну аренду.
Волна **3789** написала пробу (живые прогоны делает координатор — правило после девяти волн, умерших на «запустил в фоне»), я её прогнал против стенда 3610 (`1628b697`, тот же коммит, что на проде).
```
PROBE RESULT: 4/4 scenarios OK
OK: a-two-copies-simultaneous
OK: b-passive-takeover-on-expiry
OK: c-sharedowner-admits-same-owner-workers
OK: d-negative-control-must-go-red
```
**Что именно доказано, по маякам `ACTIVE_PASSIVE_LEASE`:**
- **(a) две копии стартуют одновременно — активной становится ровно одна.** Вторая получает `type=denied reason=active_holder` с именем держателя.
- **(b) перехват при истечении срока — в границах.** Пассивная копия забрала аренду через **147 мс после `expiresAt`**, при объявленных границах «не раньше `expiresAt−20 мс` и не позже `expiresAt + опрос(150) + запас(500) = 650 мс`». Переход виден как `type=owner_changed reason=slot_handoff`. То есть **не перехватила раньше срока** (что означало бы двух активных сразу) и **не опоздала**.
- **(c) это и есть фикс WEB-659.** При `sharedOwner` все три воркера одного владельца допущены — `admitted=["workerA","workerB","workerC"]`, — а посторонний владелец в тот же момент отвергнут как `active_holder`. Ровно то поведение, ради которого фикс и делался: завершение сессии не должно упираться в собственную аренду, но и не должно открывать дверь чужому.
- **(d) негативный контроль краснеет как надо.** При `ACTIVE_PASSIVE_LEASE_REQUIRED=0` и при подменённом хранилище, которое всегда допускает, проверка исключительности **корректно провалилась** (`fulfilled=2`). Без этого пункта проба ничего бы не доказывала: зелёное на сломанной аренде означало бы, что она не смотрит туда, куда обещает.
Проба идемпотентна, убирает за собой, использует собственный префикс области (`PROBE_SCOPE_PREFIX`), **порт 3610 не занимает** — ей нужен только `DATABASE_URL` стенда. Прогон занял секунды.
**Команда для повтора** (из готового дерева волны, пересобирать не нужно):
```
cd /home/ubuntu/waves/wt-3789-lease && \
DATABASE_URL="$(grep -m1 '^DATABASE_URL=' /home/ubuntu/waves/.3610-stand/app.env | cut -d= -f2-)" \
PROBE_SCOPE_PREFIX="lease-probe-3610-$(date +%s)" \
timeout 60 node node_modules/tsx/dist/cli.mjs scripts/lease-live-probe.ts
```
**Чего проба не доказывает** (названо волной честно): это две копии **одной машины**, а не настоящая пара узлов. Свойства, зависящие от сетевого разделения между узлами, остаются непроверенными — для них нужен настоящий второй узел.
**Статусы WEB-658 и WEB-659 двигаю сейчас, в момент вердикта.** Сдача: `refs/waves/3789/wave/3789-lease-live-probe`.
2026-09-14T12:07:47.478Z · coordinator[14.09 12:07Z координатор] WEB-658 — большой документ убивает индексацию целиком
Обновление от 2026-09-14. Затронутая посадка: l115l (1628b697d9b20a4446b9bdba20ada0a6c2820d11, 13.09 20:38:15Z).
Карточка написана для человека, который открывает её впервые и не имеет ни журнала смены, ни переписки.
1. ЧТО БЫЛО СЛОМАНО
Пользователь загружает большой документ. Он не индексируется — и вместе с ним перестают индексироваться ВСЕ остальные документы, включая чужие. Со стороны человека это выглядит как «поиск перестал видеть новое», без сообщения об ошибке.
2. КАК НАШЛИ И ПОЧЕМУ НЕ ПОЙМАЛИ РАНЬШЕ
Нашли нагрузкой. На повторном прогоне ступени 10 пользователей на измерительном стенде группа загрузки документов не укладывалась в окно, и стало видно, что индексирующий процесс умирает целиком, а не «медленно работает».
Почему не поймали раньше: в обычной работе большой документ приходит редко, а смерть воркера выглядит как временная пауза — очередь потом разбирается следующим запуском. Нагрузка сделала редкое событие частым.
3. ЭВОЛЮЦИЯ, ВКЛЮЧАЯ ТУПИКИ
3.1. ТУПИК — механизм, записанный в карточку, НЕ СУЩЕСТВОВАЛ.
В тело тикета было записано: «примерно 192 последовательных вызова эмбеддингов внутри одной интерактивной транзакции Prisma с потолком 5000 мс, отсюда `ActivePassiveLeaseLostError (local_expiry_checkpoint)` и смерть процесса». Волна 3735 попыталась это ВОСПРОИЗВЕСТИ и не смогла: запись кусков идёт КОРОТКИМИ транзакциями.
Формулировка была взята координатором из наблюдения другой волны и не проверена. Тикет поправлен на доске честно; независимая приёмка 3741 отдельно подтвердила, что описанного механизма не существует.
Это первый прецедент, на котором позже был построен канон обогащения тикетов: описанный в карточке механизм — заявка, пока кто-то не воспроизвёл его сам.
3.2. НАСТОЯЩИЙ ДЕФЕКТ — независимый от исходной гипотезы.
Единый вызов `withActivePassiveLease()` оборачивал ВЕСЬ цикл воркера. Аренда «активный-пассивный» — это механизм, гарантирующий, что боевую работу делает ровно одна копия рантайма. Икота аренды на ОДНОМ источнике валила процесс ЦЕЛИКОМ, вместе со всеми остальными источниками в очереди.
3.3. СОПУТСТВУЮЩАЯ НАХОДКА — запас аренды слишком мал.
Срок жизни аренды 15 секунд при продлении раз в 5 секунд (`src/lib/activePassiveLease.ts`, константы `ACTIVE_PASSIVE_LEASE_DEFAULT_TTL_MS` и `ACTIVE_PASSIVE_LEASE_DEFAULT_RENEW_MS`) — запас в три такта. На загруженной машине продление не успевает, и воркер честно кончает себя по своей же защите. Болели оба воркера стенда. Это НЕ исправлено, это измерено.
4. ЧТО СДЕЛАЛИ В ИТОГЕ
Волна 3735, сдача `812654e10`. Посажена в линии l115l (`1628b697d9b20a4446b9bdba20ada0a6c2820d11`) 13.09 20:38:15Z, в составе: l115k + 3731 + 3733 + 3735 + 3738 + 3739.
Изменение: `scripts/process-source-indexing-queue.ts` — КАЖДАЯ порция получает СВОЙ вызов `lease.withActivePassiveLease` (свою огороженную сессию), а не один вызов, обёрнутый вокруг всего цикла наблюдения. Именно это позволяет икоте аренды на текущей порции не задеть ни одну из последующих. В коде это помечено комментарием с номером тикета, и рядом — обёртка, переживающая икоту аренды.
5. ЧЕМ ДОКАЗАНО И ГРАНИЦЫ ЗАЯВЛЕНИЯ
ДОКАЗАНО ОФЛАЙН (независимая приёмка 3741, исполнитель — не автор):
Главный вопрос приёмки — не ослабили ли мы защиту «одна боевая копия рантайма» — закрыт фактами: `withActivePassiveLease` не убран, а перенесён на каждую порцию; атомарный SQL-захват сериализован по первичному ключу области, живой чужой держатель не проходит условие и получает пустой результат (`src/lib/activePassiveLease.ts`); признак общего владельца НЕ является обходом для ЧУЖОГО владельца — доказано ОТДЕЛЬНЫМ route-тестом приёмщика, а не тестом автора.
Опасного окна «две копии взяли один источник» не найдено.
ДОКАЗАНО ЖИВЬЁМ (волна 3789 написала пробу, координатор прогнал её против стенда на коммите `1628b697` — том же, что был на проде): 4 сценария из 4 OK.
(а) две копии стартуют одновременно → активной становится ровно одна, вторая получает `type=denied reason=active_holder`;
(б) перехват при истечении — через 147 мс после срока, при заданных границах «не раньше срока минус 20 мс, не позже плюс 650 мс»: не раньше срока (иначе было бы двое активных) и не с опозданием; виден как `owner_changed reason=slot_handoff`;
(в) при признаке общего владельца допущены все три воркера ОДНОГО владельца, а посторонний владелец в тот же момент отвергнут;
(г) ОТРИЦАТЕЛЬНЫЙ КОНТРОЛЬ КРАСНЕЕТ КАК НАДО: при выключенном требовании аренды и при подменённом всегда-допускающем хранилище проверка исключительности корректно провалилась. Без этого пункта зелёное ничего бы не значило.
Статус переведён в `done` в момент вердикта.
ГРАНИЦЫ — читать обязательно:
Проба проверяет ДВЕ КОПИИ ОДНОЙ МАШИНЫ. Свойства при СЕТЕВОМ РАЗДЕЛЕНИИ УЗЛОВ остаются непроверенными. Это ограничение назвала сама волна, и его нельзя сглаживать.
Срок жизни аренды 15 секунд при продлении раз в 5 секунд не изменён. На загруженной машине воркер по-прежнему может честно кончить себя по своей же защите — теперь это стоит одной порции, а не всей очереди.
6. ЧТО ОСТАЛОСЬ ОТКРЫТЫМ
Запас аренды (три такта) — измерен, не изменён.
Смежная проблема, из-за которой тикет и всплыл: группа загрузки документа занимает 96 секунд – 6.5 минут и по этой причине ИСКЛЮЧЕНА из всех нагрузочных замеров. Ускорение эмбеддингов внутри порции в 1.5 раза (посажено линией l115m) это не закрывает — см. WEB-626.
Поведение при сетевом разделении узлов.
7. TROUBLESHOOTER — ЕСЛИ ДЕФЕКТ ПОВТОРИТСЯ
Шаг 1. Отличить норму от аварии по журналу воркера:
`type=denied reason=active_holder` — НОРМА. Вторая копия честно уступила первой.
`owner_changed reason=slot_handoff` — НОРМА. Аренда перешла после истечения срока.
`ActivePassiveLeaseLostError` — ВОТ ЭТО смерть. Смотреть дальше.
Шаг 2. Прогнать живую пробу аренды. Она лежит в `refs/waves/3789/wave/3789-lease-live-probe` = `28a8fa116c`. Проба идемпотентна, имеет свой префикс области, ПОРТ 3610 НЕ ЗАНИМАЕТ, ей нужен только `DATABASE_URL` стенда, прогон занимает секунды.
4/4 — машинерия аренды исправна, искать в другом месте.
Провал сценария (г) — значит отрицательный контроль не краснеет, и всем остальным зелёным верить нельзя.
Шаг 3. Если аренда исправна, а воркер всё равно умирает — смотреть на нагрузку машины против такта продления: срок 15 секунд, продление раз в 5 секунд, запас три такта. При нехватке процессора или свопе продление не успевает.
Шаг 4. Не начинать с гипотезы «длинная транзакция с вызовами эмбеддингов». Она была записана в этой карточке как причина и НЕ ВОСПРОИЗВЕЛАСЬ: запись кусков идёт короткими транзакциями. Проверено дважды — автором фикса и независимой приёмкой.
2026-09-15T01:25:27.652Z · coordinator[15.09 01:25Z координатор] ## 15.09 01:40Z — ВОШЁЛ В ПОСАДКУ l115o. Требуется пост-QA на проде.
Прод работает на `l115o-68e25d8d` (коммит `68e25d8df5ae8263c5ac5466353631f57a17cfcc`, артефакт `cd2773f65d66b09ec03d39b553041d2814ce09413e72d72b07bc9de89df536c8`) с 00:56Z 15.09. Посадка проверена: `ready=true`, коммит совпал, браузерная проба открыла документ, 0 новых ошибок.
**Работу по этому тикету принесли волны:** 3741, 3752, 3758.
**Что это значит для этого тикета.** Работа по нему пролежала принятой, но НЕ посаженной — в некоторых случаях неделями. Теперь она на проде. Приёмка волной доказывала, что код правильный в дереве волны; она НЕ доказывала, что фича работает на живом проде. Это разные вещи, и мы на этом уже обжигались.
**Поэтому статус — `review`, а не `done`.** Закрыть тикет имеет право только пост-QA, который проверит поведение на работающем проде и приложит доказательство. До тех пор «сделано» — это заявка.
### Для нулевого агента (тот, кто будет делать пост-QA)
Ты приходишь на этот тикет без нашей истории. Что надо знать:
1. **Проверяй на проде, не на стенде.** Стенд сейчас вообще не поднят — у него не было своего артефакта, он запускался из каталога боевого релиза и заблокировал проверку посадки; пересобирается волной 3948 (WEB-662).
2. **`/api/health` ничего не доказывает** — он проходит и на пустом приложении. Нужно деловое действие: аутентифицированная сессия делает то, про что этот тикет.
3. **Отсутствие ошибок в логе — не доказательство жизни.** Нужен положительный результат, а не тишина.
4. **Числа в обосновании закрытия получай командой в момент закрытия.** Я однажды закрыла тикет числом `4255`, взятым из `pg_stat_user_tables.n_live_tup` — это ОЦЕНКА. Настоящее `count(*)` дало `107 817`. Разница в 25 раз.
5. **Не верь имени волны в теме коммита** — проверяй наличие содержимого (`git cherry`), а не упоминание номера.
6. Запускатель тестов в проекте — `node --test`. Vitest нет.
2026-09-15T12:20:23.463Z · coordinator[15.09 12:20Z координатор] Post-QA 3999 на exact l115o: VERDICT=REVIEW.
Fix реально присутствует. Focused 5/5 и отдельный loop-double доказали: потеря аренды на первой порции не убивает цикл, следующая порция обрабатывается; настоящий чужой holder по-прежнему отказан. Evidence 7/7 SHA256.
Закрыть нельзя: после старта l115o в доступном production journal найдено 0 записей source-indexing worker, поэтому lease-loss/recovery/success все NOT_OBSERVED, а не PASS. Нужен один очищенный положительный runtime receipt на l115o — естественное событие либо отдельно разрешённый bounded canary без provider/production-data риска.
2026-09-15T14:01:13.663Z · coordinator[15.09 14:01Z координатор] [15.09 14:00Z координатор] Post-QA 4028: VERDICT=DONE. Exact l115o executable worker-loop receipt принят; evidence 13/13 SHA256.
Recovery: exit 0, две порции очереди, один lease hiccup, бесспорный re-acquire, следующая portion-02 завершилась indexed; processed=1, succeeded=1, failed=0. Foreign-holder control: exit 2, второй batch и terminal write отсутствуют. Provider заменён локальным deterministic stub, networkCalls=0. В обоих сценариях ровно один lease release и Prisma disconnect; процессы и synthetic stores удалены.
Это положительная ticket-specific runtime-квитанция текущей линии, закрывающая единственный остаток после 3999. WEB-658 → done.
Воркер
не привязан — привязать:
curl -X POST https://bugs.wool2.online/api/web/assign -H 'content-type: application/json' \
-d '{"issueId":"WEB-658","session":"<имя tmux-сессии>","host":"m4"}'
Обновлён
2026-09-15T14:01:14.192Z