fix: release cooldown on local media failures
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+20
-1
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user