Резюме
Сборщики системы аналитики рынка забирают открытые данные о закупках и вакансиях и работают как задачи Temporal: собранное пишется в базу и доставляется в CRM, а сторожа Better Stack ждут отзвона после каждого прогона.
Задача: прогон, собравший не всё, должен выглядеть как сбой, а не как успех.
Поводом стал соседний сборщик, который терял около 10 % ленты при зелёном стороже (раздел 6). 13 августа 2026 года мы проверили, доходит ли «собрал не всё» до вердикта прогона. Не доходило нигде: в истории 44 прогона и ни одного сбоя, у сторожей за неделю 100,00 % доступности. Это значило не «всё собрано», а «недобор невидим по построению». За день мы добавили знаменатель, второй проход по недобранному и вердикт «собрано полностью», проверили изменения тестами в обе стороны и на развёрнутом коде. Строгие проверки появились только на тех путях, где прогон с подсчётом каждого запроса не нашёл ни одной потери: сторож, который краснеет на здоровых прогонах, хуже никакого.
1. Почему недобор был невидим
Путь данных: сбор → активность Temporal → воркфлоу → мост доставки → сторож.
Temporal повторяет активность и поднимает отказ наверх, только если активность бросила исключение. А все разобранные активности возвращали словарь статистики. Значит, при частичном сборе активность завершалась успешно, воркфлоу тоже, мост отзванивал сторожу. Политика повторов была настроена, но не включалась никогда: ей не о чем было узнать.
Замер этого утверждения: 44 исполнения в истории, ни одного сбоя; у сторожей за неделю 100,00 % доступности.
2. Где терялись данные
- Сбор карточек. Если страницу не удавалось получить после повторов, карточка исчезала молча: счётчика потерь не было. Не было и знаменателя. «1 439 из 1 439» нельзя было отличить от «1 439 из 1 600» ни сторожу, ни человеку, читающему результат прогона.
- Пагинация. Потерянная страница поиска выглядела как конец выдачи: пустое тело ответа и «совпадений больше нет» давали один и тот же пустой список.
- Запись пачками. Упавшая пачка глоталась молча, и недобор был виден только сложением двух чисел вручную. Причём одна и та же потеря была то громкой, то немой: смотря по тому, успел ли сервер базы ответить ошибкой или соединение оборвалось.
- Доставка в CRM. Счётчик ошибок был, но на исход прогона не влиял: отзвон сторожу шёл безусловно.
3. Сначала замер частоты, потом строгость
Сторожа, который краснеет на здоровом прогоне, через неделю выключат, и станет хуже, чем было. Поэтому до решения о строгости нужна частота недобора. История прогонов ответить не могла: знаменателя в ней не было.
Сборщики прогнали с подсчётом каждого запроса: с боевыми параметрами, на том же узле, тем же кодом (контрольные суммы развёрнутых файлов сверены с репозиторием) и без записи в базу.
- Контракты: 1 439 из 1 439, на 1 475 запросов ни одного отказа.
- Тендеры: 293 из 293, 311 запросов; окно здесь было 7 дней, шире боевого.
- Вакансии: 30 из 30 страниц.
Там, где знаменатель в истории всё-таки печатался, ошибки нашлись в доставке в CRM: в 2 прогонах из 27. В одном не доехали 6 карточек из 60 (CRM ответила «слишком много запросов», HTTP 429), в другом 1 из 76 (дубль имени).
4. Что изменили
- Знаменатель. Сбор считает найденное, пропущенное и потерянные страницы.
- Второй проход по недобранному медленным темпом. Порог поэтому не нужен: одиночное моргание источника лечится повтором, а то, что не собралось и после паузы, уже не моргание.
- Вердикт. «Собрано полностью» едет из сбора в мост, и на неполном сборе отзвон сторожу гасится. Активность при этом намеренно не падает: собранное надо довезти, а краснеть должен сторож, не доставка.
- Пагинация. Потерянная страница больше не выглядит концом выдачи.
- Запись. Упавшие пачки называются поимённо.
Где строгость намеренно не включили: на доставке. Ошибки там были в 2 прогонах из 27, и сторож краснел бы раз в две недели на живой системе. Сначала нужен повтор, безопасный от дублей: запись, повторённая вслепую после таймаута, может создать вторую карточку той же организации.
Рис. 1. Вердикт «собрано полностью» передаётся из сбора в мост. На неполном сборе отзвон сторожу гасится, а с 26.09 воркфлоу ещё и падает, уже после того как собранное доставлено.
5. Проверка
- Тесты в обе стороны. 19 проверок: страж срабатывает на неполном сборе и молчит на полном. Второе важнее: сторожа, который краснеет зря, выключат. На старом коде падают все 19, на новом проходят все.
- Проверка на развёрнутом коде шла по всем четырём изменённым путям, а не по одному соседнему. Сбор тендеров: найдено 120, разобрано 120, сбор полный. Мост получил вердикт от сбора и отработал без ошибок, доставка тоже. Сбор вакансий: 559 собрано, 559 записано.
6. Соседний сборщик: около 10 % ленты при зелёном стороже
С этого класса ошибки всё и началось: в тот же день его нашли и закрыли в соседнем сборщике открытых данных о торгах. Отказом там считался только полный ноль строк, а любой частичный сбор считался успехом. Шесть прогонов подряд он терял 41, 45, 49, 57, 58 и 58 карточек из примерно 550, то есть около 10 % ленты, при коде возврата 0 и зелёном стороже.
Кто виноват, видно по форме накопления сбоев. В каждом прогоне первая сотня карточек проходила чисто, дальше отказов прибавлялось ровно по 13–14 на сотню. Случайная нестабильность источника забрала бы свою долю и из первой сотни. Так выглядит исчерпаемый бюджет запросов: недобор создавал темп самого сборщика. Проверка: те же карточки, запрошенные поодиночке и с паузой, отдались без ошибок.
Обратную связь надо считать. Напрашивается адаптивный темп: после отказа пауза между запросами удваивается, после успеха умножается на 0,9. Выглядит как защита от перегрузки, но у такой пары есть равновесие. Это доля отказов p, при которой пауза в среднем не меняется:
p · ln 2 = (1 − p) · ln(1/0,9), откуда p ≈ 13,2 %.
Это ровно та доля отказов, что наблюдалась после первой сотни карточек (13–14 на сотню): такая правка не изменила бы ничего. Общий недобор за прогон меньше, около 10 %. По нашей оценке, его снижает первая сотня карточек, которая проходит чисто. С множителем 0,99 вместо 0,9 равновесие сдвигается к 1,4 %. Константы обратной связи задают долю отказов, поэтому их считают, а не подбирают на глаз.
Что сделано в этом сборщике. Один бюджет темпа на весь прогон; стандартный темп снижен, потому что прежний сам был причиной недобора. Снятый лот (ответ 404) считается убылью ленты, а не сбоем. Второй проход по недобранному идёт медленным темпом. Уцелевший недобор возвращает отдельный код 3 и гасит отзвон сторожу.
7. С 26.09 неполный сбор виден и как сбой воркфлоу
Решение «активность не падает, краснеет сторож» осталось верным. Но зелёный статус прогона при красном стороже говорит только половину правды: в Temporal неполный прогон был неотличим от полного. С 26.09 воркфлоу падает с явной ошибкой: в тексте ожидаемое против фактического, в деталях полный результат, а сама ошибка помечена как неповторяемая. Падает он уже после доставки: собранное доставлено, сторож не отзвонён, статус сбоя виден в Temporal и в алерте на сбои воркфлоу.
8. Что осталось открытым
- Смена вёрстки источника всё ещё выглядит здоровым прогоном. Страница пришла с кодом 200, но ссылок в ней не найдено: сбор решает, что страниц больше нет, и объявляет себя полным. Не доехавшую страницу сбор теперь ловит, а изменившуюся нет. На 26.09 это открыто. Строгость по признаку «ноль ссылок за прогон» ждёт замера нижней границы выдачи, иначе снова получится сторож, который краснеет на здоровом прогоне.
- Строгость доставки ждёт повтора, безопасного от дублей, и замера ошибок, которые останутся после него.
Выводы
- Сторож доказывает, что прогон дошёл до конца, а не что данные собраны. Пока эти утверждения расходятся молча, зелёный сторож ничего не значит.
- У сбора должны быть знаменатель, второй проход и вердикт. Это проверка полноты данных (data completeness): собранное сверяется с тем, сколько записей объявил сам источник. Платформы наблюдения за данными (data observability) чаще ловят аномалии объёма и свежести статистически; знаменатель от источника точнее, если источник его отдаёт.
- Строгость включают после замера частоты: сторожа, который краснеет зря, выключат.
- Константы обратной связи считают, а не подбирают на глаз.
- Проверяют каждый изменённый путь на развёрнутом коде, а не соседний.