Skip to content

Инкрементальный манифест, статус неполного прогона, дозапись (--resume-dir) - #10

Merged
axisrow merged 4 commits into
mainfrom
ao/wordstat-6/issue2-incremental-manifest
Aug 21, 2026
Merged

axisrow merged 4 commits into
mainfrom
ao/wordstat-6/issue2-incremental-manifest

Conversation

@axisrow

@axisrow axisrow commented Aug 21, 2026 •

Copy link
Copy Markdown
Owner

Closes #2.

Что сделано

  1. write_manifest стала атомарной (tempfile.mkstemp(dir=path.parent) + os.replace). Issue содержала фактическую ошибку — утверждала, что запись уже атомарна; это было не так (Path.write_text — усечение + запись). Это была предпосылка для остального: как только манифест начинает переписываться после каждого вида, а не один раз в конце, окно между усечением и записью становится реальным, и наивная «вызвать в цикле» создала бы ровно ту порчу манифеста, которую issue просит устранить.
  2. Манифест пишется инкрементально: один раз до цикла видов (exports=[]) и заново после каждого успешно собранного вида.
  3. Статус неполного прогона виден в JSON на диске. CollectionManifest.status/missing_views — pydantic computed_field, выводятся из exports; сконструировать манифест, где они расходятся с exports, невозможно (в отличие от первой версии, где это были независимые поля — advisor поймал это до мержа).
  4. Дозапись существующего run-каталога через --resume-dir (CLI) / resume_directory (WordstatCollector.collect/collect_many). Реализовано в storage.py:
    • prepare_resume_directory — жёстко отклоняет (ResumeMismatchError) каталог с несовпадающей (после .strip()) фразой/регионом, отсутствующим/битым manifest.json, или путь, который не каталог. Это защита от главного риска порчи данных здесь — смешения exports двух разных фраз в одном каталоге.
    • views_to_collect — вид считается уже собранным только если он и есть в manifest.exports, и его <view>.parquet реально лежит на диске (не доверяет манифесту, если файл удалили руками).
    • merge_export — добавляет один вид, сохраняя порядок по WordstatView, не по порядку дозаписи.
    • --resume-dir вместе с несколькими фразами отклоняется (в collect_many и заранее в CLI) — один run-каталог = одна фраза.
    • CLI дополнительно делает pre-flight проверку через prepare_resume_directory до старта Chrome (быстрый отказ на опечатку); _collect_one всё равно перепроверяет то же самое перед стартом — CLI-проверка не единственный гейт.
    • Резюм уже полностью собранного каталога — no-op: возвращает существующий манифест, не трогая браузер.

Правки в collector.py держал минимальными — основная логика (валидация резюма, слияние экспортов, список недостающих видов) вынесена в storage.py; в _collect_one только ветка выбора run_directory/списка pending_views и вызов write_manifest внутри цикла видов. Не трогал _select_view — это зона параллельного воркера (issue #3).

PhraseFailure/error_type сознательно не тронуты — issue поднимала это условно («если решение захочет сохранять состояние батча на диск»), моё решение этого не делает.

Тесты

tests/test_storage.py (новые): атомарность (сбой os.replace не портит существующий манифест, temp-файл создаётся рядом с целью — не в системном /tmp, не остаётся мусора ни при успехе, ни при сбое), статус/missing_views на диске для полного и неполного прогона, views_to_collect/merge_export, prepare_resume_directory (отклонение чужой фразы/региона, битого/отсутствующего манифеста, не-каталога; допускает разницу в пробелах).

tests/test_collector_batch.py (новые, через реальный _collect_one): манифест пишется после каждого вида, дозапись собирает только недостающие виды и не трогает уже записанные (mtime файла не меняется), отклонение дозаписи с другой фразой, отклонение --resume-dir с несколькими фразами.

tests/test_cli.py (новые): pre-flight отклонение --resume-dir при нескольких фразах и при несовпадающей фразе — без запуска браузера.

Проверка (фактический вывод)

$ pytest -q
........................................................................ [ 75%]
.......................                                                  [100%]
95 passed in 0.68s

$ ruff check .
All checks passed!

CI в репозитории нет. Живой прогон на авторизованном Chrome не делал — задача файловая, collector.py в юнит-тестах не покрыт (см. Testing notes в CLAUDE.md), а любые правки там держал минимальными.

CLAUDE.md обновлён (Commands, разделы про storage.py и collector.py).

🤖 Generated with Claude Code

https://claude.ai/code/session_01MgwQ3S5tRax71SuWQx5reZ


Доработка после сравнения трёх независимых решений

Этот PR выбран базой (computed_field для status/missing_views, проверка parquet на диске в views_to_collect, 27 тестов исходно). По итогам сравнения добавлены два чери-пика из закрытых PR и один самостоятельно найденный баг:

  1. fsync перед os.replace (чери-пик). os.replace гарантирует только порядок операций (переименование), а не то, что байты временного файла физически дошли до диска — при потере питания между переименованием и сбросом page cache возможен пустой/битый manifest.json на месте валидного. Добавлено handle.flush() + os.fsync(handle.fileno()) перед os.replace в write_manifest. Тест проверяет порядок вызовов (fsync затем replace), а не просто факт вызова — тест «просто вызван» прошёл бы и при бесполезном fsync после replace.

  2. Валидатор уникальности видов (чери-пик). CollectionManifest теперь отклоняет (model_validator) exports с двумя записями на один WordstatView. Штатная запись (merge_export, ключует по view в словаре) такое произвести не может — это защита load_manifest от вручную отредактированного или битого manifest.json на диске перед резюмом: без неё views_to_collect молча взял бы первую попавшуюся дублирующую запись. pydantic.ValidationError — подкласс ValueError, так что существующий except ValueError в load_manifest уже превращает это в ResumeMismatchError.

  3. Баг с created_at/source_url при резюме (не замечен ни одним из трёх решений). Резюм дописывал exports, не трогая created_at/source_url — манифест продолжал утверждать, что все виды собраны в момент первого, прерванного прогона. Явная семантика (докстринг CollectionManifest):

    • created_at — время старта прогона; резюм его не трогает (виды резюмированного прогона законно могут быть собраны в разные сессии);
    • updated_at (новое поле, Optional — манифесты без него, написанные до этого изменения, по-прежнему читаются) — момент последней успешной записи. Первая запись теперь тоже его выставляет (равным created_at), иначе null был бы неоднозначен между «манифест от старой версии инструмента» и «прогон начался, ничего не собрано»;
    • source_url — URL на момент последней записи, не первой; резюм обновляет его сразу после _set_phrase в _collect_one (до этого момента страница ещё показывает предыдущую фразу/вкладку).
      Ранний return резюма уже полностью собранного каталога ничего не бампает — там реально ничего не изменилось, обновление updated_at было бы ложью.

Тесты на все три пункта

  • test_write_manifest_fsyncs_before_replacing_the_target — порядок fsync→replace.
  • test_collection_manifest_rejects_duplicate_view_exports, test_load_manifest_rejects_a_hand_edited_file_with_duplicate_views, test_merge_export_does_not_reintroduce_duplicate_views — валидатор через конструктор и через файл на диске.
  • test_updated_at_defaults_to_none, test_load_manifest_accepts_a_manifest_json_written_before_updated_at_existed, test_merge_export_sets_updated_at_to_the_given_now, test_merge_export_does_not_touch_created_at — модельные гарантии.
  • test_collect_one_resume_updates_source_url_and_updated_at_but_not_created_at (сквозной, через реальный _collect_one) — created_at неизменен, source_url заменён на новый (строгое != со старым), updated_at строго возрастает (>, не >= — иначе тест не ловит регрессию, где обновление вообще убрали).
  • test_collect_one_resume_of_an_already_complete_run_does_not_touch_the_manifest — ранний return не переписывает manifest.json побайтово.

Проверка (фактический вывод)

$ pytest -q
........................................................................ [ 68%]
.................................                                        [100%]
105 passed in 0.49s

$ ruff check .
All checks passed!

CI в репозитории нет. Живой прогон на авторизованном Chrome не делал — доработка файловая, _select_view и остальной браузерный код не тронуты.

Не мержил.

axisrow added a commit that referenced this pull request Aug 21, 2026
…езюме

Доработка PR #10 по итогам сравнения трёх независимых решений issue #2.

1. write_manifest: flush() + os.fsync() перед os.replace. os.replace даёт
   только порядок операций, не гарантию, что байты временного файла дошли
   до диска — без fsync потеря питания между переименованием и сбросом
   page cache могла оставить пустой/битый manifest.json на месте валидного.
   Тест проверяет порядок вызовов (fsync затем replace), не факт вызова —
   тест "просто вызван" прошёл бы и при бесполезном fsync после replace.

2. CollectionManifest: model_validator отклоняет exports с дублирующимся
   view. Штатная запись (merge_export, ключует по view в словаре) такое
   произвести не может — это защита load_manifest от вручную
   отредактированного/битого manifest.json на диске перед резюмом, без
   которой views_to_collect молча взял бы первую попавшуюся дублирующую
   запись. ValidationError — подкласс ValueError, так что существующий
   except в load_manifest уже превращает это в ResumeMismatchError.

3. Баг, не замеченный ни одним из трёх независимых решений: резюм не
   обновлял created_at/source_url, оставляя манифест утверждать, что все
   виды собраны в момент первого, прерванного прогона. Явная семантика
   (докстринг CollectionManifest):
   - created_at — время старта прогона, резюм не трогает;
   - updated_at (новое, Optional для обратной совместимости с манифестами
     без этого поля) — момент последней успешной записи; первая запись
     теперь тоже его выставляет, иначе null был бы неоднозначен между
     "манифест от старой версии" и "прогон начался, ничего не собрано";
   - source_url — URL на момент последней записи, резюм обновляет его
     сразу после _set_phrase (до этого страница ещё на предыдущей фразе).
   Ранний return резюма уже полного каталога ничего не бампает — там
   реально ничего не изменилось.

Тесты: порядок fsync/replace, отклонение дублей через конструктор и через
load_manifest (файл на диске), merge_export не создаёт дублей, updated_at
default None и round-trip манифеста без этого поля, сквозной тест на
резюме (created_at неизменен, source_url и updated_at обновлены строго),
резюм уже полного каталога не трогает манифест на диске (побайтово).

pytest -q: 105 passed
ruff check .: All checks passed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgwQ3S5tRax71SuWQx5reZ
axisrow and others added 4 commits August 21, 2026 09:42
…-resume-dir)

- write_manifest теперь атомарна (tmp-файл в том же каталоге + os.replace),
  чтобы обрыв процесса не оставлял усечённый manifest.json — это
  предпосылка для инкрементальной записи ниже, не декоративная правка.
- CollectionManifest.status/missing_views — computed_field, выводятся из
  exports; сконструировать манифест, где они расходятся с exports,
  невозможно.
- _collect_one пишет манифест после каждого успешно собранного вида
  (не только в конце), так что прерванный прогон честно помечен
  status=incomplete на диске, а не пропадает вовсе.
- storage.py: prepare_resume_directory/views_to_collect/merge_export —
  дозапись существующего run-каталога. Жёстко отклоняет несовпадение
  фразы/региона (сравнение после .strip()), отсутствующий/битый
  manifest.json, не-каталог. Вид считается собранным только если он есть
  и в manifest.exports, и как файл на диске.
- collect_many/CLI отклоняют --resume-dir при более чем одной фразе (один
  run-каталог = одна фраза). CLI дополнительно делает pre-flight проверку
  до запуска Chrome.
- errors.py: новый ResumeMismatchError.
- Тесты: атомарность (сбой os.replace не портит существующий файл, нет
  утечки temp-файлов, temp-файл создаётся рядом с целью), статус на диске
  для полного/неполного прогона, дозапись добавляет недостающие виды и не
  трогает уже записанные (mtime не меняется), отклонение дозаписи в чужой
  каталог — на уровне storage.py, collector.py и cli.py.

Issue #2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgwQ3S5tRax71SuWQx5reZ
…езюме

Доработка PR #10 по итогам сравнения трёх независимых решений issue #2.

1. write_manifest: flush() + os.fsync() перед os.replace. os.replace даёт
   только порядок операций, не гарантию, что байты временного файла дошли
   до диска — без fsync потеря питания между переименованием и сбросом
   page cache могла оставить пустой/битый manifest.json на месте валидного.
   Тест проверяет порядок вызовов (fsync затем replace), не факт вызова —
   тест "просто вызван" прошёл бы и при бесполезном fsync после replace.

2. CollectionManifest: model_validator отклоняет exports с дублирующимся
   view. Штатная запись (merge_export, ключует по view в словаре) такое
   произвести не может — это защита load_manifest от вручную
   отредактированного/битого manifest.json на диске перед резюмом, без
   которой views_to_collect молча взял бы первую попавшуюся дублирующую
   запись. ValidationError — подкласс ValueError, так что существующий
   except в load_manifest уже превращает это в ResumeMismatchError.

3. Баг, не замеченный ни одним из трёх независимых решений: резюм не
   обновлял created_at/source_url, оставляя манифест утверждать, что все
   виды собраны в момент первого, прерванного прогона. Явная семантика
   (докстринг CollectionManifest):
   - created_at — время старта прогона, резюм не трогает;
   - updated_at (новое, Optional для обратной совместимости с манифестами
     без этого поля) — момент последней успешной записи; первая запись
     теперь тоже его выставляет, иначе null был бы неоднозначен между
     "манифест от старой версии" и "прогон начался, ничего не собрано";
   - source_url — URL на момент последней записи, резюм обновляет его
     сразу после _set_phrase (до этого страница ещё на предыдущей фразе).
   Ранний return резюма уже полного каталога ничего не бампает — там
   реально ничего не изменилось.

Тесты: порядок fsync/replace, отклонение дублей через конструктор и через
load_manifest (файл на диске), merge_export не создаёт дублей, updated_at
default None и round-trip манифеста без этого поля, сквозной тест на
резюме (created_at неизменен, source_url и updated_at обновлены строго),
резюм уже полного каталога не трогает манифест на диске (побайтово).

pytest -q: 105 passed
ruff check .: All checks passed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgwQ3S5tRax71SuWQx5reZ
Минимальный GitHub Actions workflow на push/PR: только ruff check.
pytest намеренно не включён — тесты тянут browser-use как editable-
зависимость из соседнего каталога (../browser-use, см. pyproject.toml),
которого в CI нет; это отдельная задача, не 'простой CI'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgwQ3S5tRax71SuWQx5reZ
…o main

Rebasing onto main (issue #3, PR #7) changed _select_view's signature to
take a view parameter. The two fakes shared with that PR's own tests were
already updated by the merge; five fakes local to this branch's own
resume/incremental-manifest tests were not — fix their signatures to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgwQ3S5tRax71SuWQx5reZ
@axisrow
axisrow force-pushed the ao/wordstat-6/issue2-incremental-manifest branch from 1ccd71a to 6b20e65 Compare August 21, 2026 02:44
@axisrow
axisrow merged commit d2f5466 into main Aug 21, 2026
1 check passed
@axisrow
axisrow deleted the ao/wordstat-6/issue2-incremental-manifest branch August 21, 2026 02:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Устойчивость к сбою на середине прогона: инкрементальный манифест и дозапись

1 participant