Инкрементальный манифест, статус неполного прогона, дозапись (--resume-dir) - #10
Merged
Merged
Conversation
This was referenced Aug 21, 2026
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
…-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
force-pushed
the
ao/wordstat-6/issue2-incremental-manifest
branch
from
August 21, 2026 02:44
1ccd71a to
6b20e65
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2.
Что сделано
write_manifestстала атомарной (tempfile.mkstemp(dir=path.parent)+os.replace). Issue содержала фактическую ошибку — утверждала, что запись уже атомарна; это было не так (Path.write_text— усечение + запись). Это была предпосылка для остального: как только манифест начинает переписываться после каждого вида, а не один раз в конце, окно между усечением и записью становится реальным, и наивная «вызвать в цикле» создала бы ровно ту порчу манифеста, которую issue просит устранить.exports=[]) и заново после каждого успешно собранного вида.CollectionManifest.status/missing_views—pydanticcomputed_field, выводятся изexports; сконструировать манифест, где они расходятся сexports, невозможно (в отличие от первой версии, где это были независимые поля — advisor поймал это до мержа).--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-каталог = одна фраза.prepare_resume_directoryдо старта Chrome (быстрый отказ на опечатку);_collect_oneвсё равно перепроверяет то же самое перед стартом — CLI-проверка не единственный гейт.Правки в
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при нескольких фразах и при несовпадающей фразе — без запуска браузера.Проверка (фактический вывод)
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 и один самостоятельно найденный баг:fsyncпередos.replace(чери-пик).os.replaceгарантирует только порядок операций (переименование), а не то, что байты временного файла физически дошли до диска — при потере питания между переименованием и сбросом page cache возможен пустой/битыйmanifest.jsonна месте валидного. Добавленоhandle.flush()+os.fsync(handle.fileno())передos.replaceвwrite_manifest. Тест проверяет порядок вызовов (fsyncзатемreplace), а не просто факт вызова — тест «просто вызван» прошёл бы и при бесполезномfsyncпослеreplace.Валидатор уникальности видов (чери-пик).
CollectionManifestтеперь отклоняет (model_validator)exportsс двумя записями на одинWordstatView. Штатная запись (merge_export, ключует поviewв словаре) такое произвести не может — это защитаload_manifestот вручную отредактированного или битогоmanifest.jsonна диске перед резюмом: без неёviews_to_collectмолча взял бы первую попавшуюся дублирующую запись.pydantic.ValidationError— подклассValueError, так что существующийexcept ValueErrorвload_manifestуже превращает это вResumeMismatchError.Баг с
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побайтово.Проверка (фактический вывод)
CI в репозитории нет. Живой прогон на авторизованном Chrome не делал — доработка файловая,
_select_viewи остальной браузерный код не тронуты.Не мержил.