diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a9669ad..05824bf 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -32,7 +32,7 @@ R-пункты уточняют критерии существующих B/G/U/ - [x] **R07 · P1 · Схема координат по водоёмам (U04).** ActivityMap теперь строит отдельную сетку для каждого водоёма, явно подписывает водоём и число точек, разносит совпадающие ячейки, добавляет полную tooltip/accessible label и не обрезает длинные названия карточек. `astro check` проходит; browser acceptance на 320px остаётся отдельным визуальным gate. - [x] **R08 · P1 · Достоверная аналитика снастей (G07).** Публичные пороги закреплены минимум на 3 наблюдениях и 2 игроках независимо от URL-параметров; окно и freshness используют время улова с fallback на время импорта, ответ ограничен `limit`, а canonical ID назначается только при полном однозначном покрытии компонентных наблюдений. `1/1` нельзя превратить в рекомендацию, unresolved не получает ложную привязку. - [x] **R09 · P1 · Полное удаление личных данных.** Retention и admin-delete загружают дочерние компоненты, очищают их `source_url/raw_payload` вместе с родительским report и сбрасывают upload-token; retention теперь обнаруживает старые reports даже при уже очищенных родительских полях, если provenance осталась в component. Повторная очистка остаётся безопасной; S3/БД failure drill — отдельный эксплуатационный gate. -- [ ] **R10 · P1 · Единый cooldown и стадии запроса.** Координация CLI/media/scheduler; release только до доказанного обращения. Критерий: DNS до первого запроса освобождает резерв, ошибка после redirect — нет; конкурентные пути делят одно окно. Проверять offline. +- [x] **R10 · P1 · Единый cooldown и стадии запроса.** Community CLI уже освобождал резерв до HTTP-ответа; media CLI теперь использует тот же контракт: DNS/connection/OSError освобождают только собственный резерв, HTTPError или ошибка валидированного тела сохраняют cooldown, batch освобождает окно только если ни один asset не получил ответ. Offline regression добавлен; scheduler сохраняет site-wide координацию. - [ ] **R18 · P1 · Границы локального и production запуска.** Loopback для dev-портов с явным opt-in LAN; проверить доверенные proxy/client-IP и rate-limit на двух клиентах. Критерий: default dev не открыт в LAN, production различает клиентов. Реальные серверные gates — A07. - [ ] **R11 · P2 · Честное качество и происхождение media (B25).** Unknown вместо ложного official, hostname allowlist, размеры отдельно от наличия альтернатив, compact подписи и cache/revocation-политика. Критерий: длинные подписи читаемы, неизвестный источник не становится официальным. - [ ] **R14 · P2 · Подтверждённый каталог и demo seed.** Отделить справочник от неподтверждённых описаний/точек, показывать loaded count и внешнюю полноту/unknown раздельно. Критерий: production без demo не создаёт вымышленных описаний, media count не подменяет canonical count. diff --git a/rf4_research/media_cli.py b/rf4_research/media_cli.py index 2cce159..d489aa0 100644 --- a/rf4_research/media_cli.py +++ b/rf4_research/media_cli.py @@ -7,7 +7,7 @@ from pathlib import Path import urllib.error import urllib.request -from .community_cli import MIN_FETCH_INTERVAL_SECONDS, USER_AGENT, _StrictRedirectHandler, _read_state, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key +from .community_cli import MIN_FETCH_INTERVAL_SECONDS, USER_AGENT, _StrictRedirectHandler, _read_state, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key, release_reservation from .media_assets import approve_stored_assets, audit_media_catalog, compare_quality_upgrades, extract_media_candidates, generate_media_derivatives, generate_quality_contact_sheets, media_coverage, media_quality_report, merge_manifest, publish_quality_upgrades, queue_quality_upgrades, reconcile_queued_duplicates, reclassify_manifest, review_asset, rollback_quality_upgrade, store_asset @@ -104,6 +104,11 @@ def _attempt_asset(root: Path, manifest_path: Path, manifest: dict, queued: dict return f"stored {url} as {relative}", None +def _response_was_received(error: Exception) -> bool: + """Only an HTTP response or validated body is allowed to consume cooldown.""" + return isinstance(error, (urllib.error.HTTPError, ValueError)) + + def _download_one(root: Path, state_file: Path, asset_url: str | None = None) -> str: manifest_path = root / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) @@ -112,9 +117,11 @@ def _download_one(root: Path, state_file: Path, asset_url: str | None = None) -> return "queue is empty" if asset_url is None else "asset is not queued" url = queued["asset_url"] _validate_url_before_io(url) - check_and_reserve(fetch_site_key(url), state_file=state_file) + reserved_at = check_and_reserve(fetch_site_key(url), state_file=state_file) message, error = _attempt_asset(root, manifest_path, manifest, queued) if error is not None: + if not _response_was_received(error): + release_reservation(fetch_site_key(url), state_file=state_file, reserved_at=reserved_at) raise error return message @@ -142,6 +149,7 @@ def _download_batch(root: Path, state_file: Path, *, limit: int = MAX_BATCH_ASSE domains[site] = {"attempted": 0, "stored": 0, "failed": 0, "skipped": str(exc)} continue stored = failed = consecutive_failures = 0 + response_received = False messages: list[str] = [] stopped_reason: str | None = None for item in items[:limit]: @@ -149,17 +157,24 @@ def _download_batch(root: Path, state_file: Path, *, limit: int = MAX_BATCH_ASSE messages.append(message) if error is None: stored += 1 + response_received = True consecutive_failures = 0 continue failed += 1 consecutive_failures += 1 status = item.get("status") + if _response_was_received(error): + response_received = True if status in {"blocked", "queued"}: stopped_reason = f"domain stopped after {status} response" break if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: stopped_reason = f"domain stopped after {MAX_CONSECUTIVE_FAILURES} consecutive failures" break + if not response_received and failed and stored == 0: + # No HTTP response was observed in this reservation window. It is + # safe to release this process's reservation for a later retry. + release_reservation(site, state_file=state_file, reserved_at=reserved_at) domains[site] = { "attempted": stored + failed, "stored": stored, "failed": failed, "stopped_reason": stopped_reason, "messages": messages, diff --git a/tests/test_media_cli.py b/tests/test_media_cli.py index 1481fb3..4923ba0 100644 --- a/tests/test_media_cli.py +++ b/tests/test_media_cli.py @@ -2,7 +2,7 @@ import json from pathlib import Path from rf4_research import media_cli -from rf4_research.media_cli import _download_batch, media_queue_plan +from rf4_research.media_cli import _download_batch, _download_one, media_queue_plan def test_queue_plan_prioritizes_entity_gaps_and_respects_domain_cooldown(tmp_path: Path) -> None: @@ -101,3 +101,22 @@ def test_batch_downloads_quality_upgrade_without_unpublishing_fallback(tmp_path: assert report["stored_total"] == 1 assets = json.loads((root / "manifest.json").read_text(encoding="utf-8"))["assets"] assert assets[0]["status"] == "upgrade_stored" + + +def test_local_failure_releases_media_reservation(tmp_path: Path, monkeypatch) -> None: + root = tmp_path / "media" + root.mkdir() + (root / "manifest.json").write_text(json.dumps({"version": 1, "assets": [ + {"status": "queued", "asset_url": "https://rf4map.ru/fish.png"}, + ]}), encoding="utf-8") + state = tmp_path / "state.json" + monkeypatch.setattr(media_cli, "_attempt_asset", lambda *_: ("queued", OSError("DNS unavailable"))) + + try: + _download_one(root, state, "https://rf4map.ru/fish.png") + except OSError: + pass + else: + raise AssertionError("local media failure must be raised") + + assert not state.exists() or "rf4map.ru" not in json.loads(state.read_text(encoding="utf-8"))