fix: release cooldown on local media failures
CI / backend-and-migrations (push) Failing after 2m26s
CI / astro-build (push) Failing after 37s
CI / dependency-audit (push) Failing after 31s
CI / compose-e2e (push) Failing after 40s

This commit is contained in:
ik
2026-09-22 20:01:20 +07:00
parent 96c29ab9d9
commit 1920475580
3 changed files with 38 additions and 4 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ R-пункты уточняют критерии существующих B/G/U/
- [x] **R07 · P1 · Схема координат по водоёмам (U04).** ActivityMap теперь строит отдельную сетку для каждого водоёма, явно подписывает водоём и число точек, разносит совпадающие ячейки, добавляет полную tooltip/accessible label и не обрезает длинные названия карточек. `astro check` проходит; browser acceptance на 320px остаётся отдельным визуальным gate. - [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] **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. - [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. - [ ] **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-политика. Критерий: длинные подписи читаемы, неизвестный источник не становится официальным. - [ ] **R11 · P2 · Честное качество и происхождение media (B25).** Unknown вместо ложного official, hostname allowlist, размеры отдельно от наличия альтернатив, compact подписи и cache/revocation-политика. Критерий: длинные подписи читаемы, неизвестный источник не становится официальным.
- [ ] **R14 · P2 · Подтверждённый каталог и demo seed.** Отделить справочник от неподтверждённых описаний/точек, показывать loaded count и внешнюю полноту/unknown раздельно. Критерий: production без demo не создаёт вымышленных описаний, media count не подменяет canonical count. - [ ] **R14 · P2 · Подтверждённый каталог и demo seed.** Отделить справочник от неподтверждённых описаний/точек, показывать loaded count и внешнюю полноту/unknown раздельно. Критерий: production без demo не создаёт вымышленных описаний, media count не подменяет canonical count.
+17 -2
View File
@@ -7,7 +7,7 @@ from pathlib import Path
import urllib.error import urllib.error
import urllib.request 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 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 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: def _download_one(root: Path, state_file: Path, asset_url: str | None = None) -> str:
manifest_path = root / "manifest.json" manifest_path = root / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8")) 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" return "queue is empty" if asset_url is None else "asset is not queued"
url = queued["asset_url"] url = queued["asset_url"]
_validate_url_before_io(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) message, error = _attempt_asset(root, manifest_path, manifest, queued)
if error is not None: 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 raise error
return message 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)} domains[site] = {"attempted": 0, "stored": 0, "failed": 0, "skipped": str(exc)}
continue continue
stored = failed = consecutive_failures = 0 stored = failed = consecutive_failures = 0
response_received = False
messages: list[str] = [] messages: list[str] = []
stopped_reason: str | None = None stopped_reason: str | None = None
for item in items[:limit]: 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) messages.append(message)
if error is None: if error is None:
stored += 1 stored += 1
response_received = True
consecutive_failures = 0 consecutive_failures = 0
continue continue
failed += 1 failed += 1
consecutive_failures += 1 consecutive_failures += 1
status = item.get("status") status = item.get("status")
if _response_was_received(error):
response_received = True
if status in {"blocked", "queued"}: if status in {"blocked", "queued"}:
stopped_reason = f"domain stopped after {status} response" stopped_reason = f"domain stopped after {status} response"
break break
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
stopped_reason = f"domain stopped after {MAX_CONSECUTIVE_FAILURES} consecutive failures" stopped_reason = f"domain stopped after {MAX_CONSECUTIVE_FAILURES} consecutive failures"
break 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] = { domains[site] = {
"attempted": stored + failed, "stored": stored, "failed": failed, "attempted": stored + failed, "stored": stored, "failed": failed,
"stopped_reason": stopped_reason, "messages": messages, "stopped_reason": stopped_reason, "messages": messages,
+20 -1
View File
@@ -2,7 +2,7 @@ import json
from pathlib import Path from pathlib import Path
from rf4_research import media_cli 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: 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 assert report["stored_total"] == 1
assets = json.loads((root / "manifest.json").read_text(encoding="utf-8"))["assets"] assets = json.loads((root / "manifest.json").read_text(encoding="utf-8"))["assets"]
assert assets[0]["status"] == "upgrade_stored" 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"))