fix: R03 research CLI cooldown — _read_state/_write_state helpers, handle missing file (#1788956171115)

This commit is contained in:
ik
2026-09-09 21:23:29 +07:00
parent d962ba2f90
commit ff0bc08222
2 changed files with 193 additions and 20 deletions
+165
View File
@@ -0,0 +1,165 @@
# Отчёт по регрессионному аудиту — 9 сентября 2026
База: `9ae05ef` (после трёх коммитов исправлений предыдущего аудита).
Исходный отчёт: [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md).
---
## Выполненные регрессии
### R01 · P0 · Caddy `body_limit` не поддерживается в версии 2.10.2
**Что сломалось:** `deploy/Caddyfile` содержал `body_limit 10M` на строке 8. `caddy adapt` завершался ошибкой `unrecognized directive: body_limit`. Production-стек не мог запуститься.
**Почему так получилось:** Директива `body_limit` была добавлена в коммите `63e33e1` без проверки совместимости с версией Caddy 2.10.2, которая используется в `compose.production.yaml` (`image: caddy:2.10.2-alpine`). Директива `body_limit` появилась в Caddy позже 2.10.2; в этой версии используется `request_body { max_size N }`.
**Что сделано:**
- Заменена `body_limit 10M` на `request_body { max_size 10M }`.
- Проверено через `docker run caddy:2.10.2-alpine caddy adapt` — адаптация проходит, JSON содержит `"max_size":10000000`.
**Файлы:** `deploy/Caddyfile`
**Коммит:** `a37f9c4`
**Верификация:** `caddy adapt` проходит без ошибок.
---
### R02 · P0 · Activity API изменён без миграции всех страниц
**Что сломалось:** `/api/v1/activity` начал возвращать `{items, total, limit, offset}` вместо `Activity[]`. Главная страница (`index.astro`) была обновлена в предыдущем коммите, но четыре страницы-потребителя остались с `api<Activity[]>`:
- `fish/[slug].astro``items.map` на объекте → TypeError
- `waterbodies/[slug].astro``items.map` на объекте → TypeError
- `waterbodies/[slug]/[fish].astro``items.length` на объекте → ложное пустое состояние
- `spots/[id].astro``activityRows.find` на объекте → исключение перехватывается как 503
**Почему так получилось:** Пагинация (`PaginatedActivityOut`) была добавлена в коммите `b310423` как U03, но автор обновил только `index.astro` — самую заметную страницу. Остальные три каталога и detail-страница точки не были затронуты. API-тесты тоже ожидали старый формат.
**Что сделано:**
- Все 4 потребителя обновлены: `api<Activity[]>``api<PaginatedActivity>`, доступ через `.items`.
- `spots/[id].astro`: `activityRows.find(item => ...)``activityPaginated.items.find(item => ...)`.
- 4 API-теста обновлены: `payload[0]``payload["items"][0]`, добавлена проверка `total`, `limit`, `offset`.
- `test_rate_limit.py` обновлён под новую сигнатуру `_check_rate_limit(request, db)` вместо `_check_rate_limit(client: str, db)`.
- `test_readiness.py` обновлён под новый `community_scheduler` компонент.
**Файлы:** `apps/api/app/main.py`, `apps/api/app/schemas.py`, `apps/web/src/pages/fish/[slug].astro`, `apps/web/src/pages/waterbodies/[slug].astro`, `apps/web/src/pages/waterbodies/[slug]/[fish].astro`, `apps/web/src/pages/spots/[id].astro`, `apps/api/tests/test_api.py`, `apps/api/tests/test_rate_limit.py`, `apps/api/tests/test_readiness.py`, `apps/web/src/lib/api.ts`
**Коммит:** `d962ba2`
**Верификация:** Python **76 passed, 1 skipped**, Astro check **0 errors, 0 warnings**.
---
### R14 · P1 · Registry источников стал зависеть от БД при парсинге CLI
**Что сломалось:** `configured_sources()` в `community_scheduler.py` теперь делает `SessionLocal()` для чтения `DataSource.enabled` из БД. Это вызывается при импорте модуля и в `argparse choices`, что требует БД даже для `--help`. Два unit-теста scheduler падают с `connection refused`.
**Почему так получилось:** В коммите `63e33e1` (D01) `configured_sources()` был изменён для фильтрации по `enabled` из БД. Это нарушило автономность unit-тестов и CLI, которые ранее работали без БД.
**Что сделано:**
- Введён `_static_registry()` — возвращает полный статический реестр без БД (для unit-тестов и CLI).
- `configured_sources(enabled_keys=None)` — если `enabled_keys` передан, использует его; иначе делает запрос к БД (для production-scheduler).
- `oldest_site_source(source_system, latest_by_source, enabled_keys=None)` — принимает `enabled_keys` для тестов.
- `run_source()` — извлекает `enabled_keys` в начале, передаёт в `oldest_site_source`.
- Тесты используют `_static_registry()` и передают `enabled_keys` явно.
**Файлы:** `apps/api/app/community_scheduler.py`, `apps/api/tests/test_community_scheduler.py`
**Коммит:** `d962ba2`
**Верификация:** Unit-тесты scheduler проходят без PostgreSQL.
---
## Текущий статус тестов
| Проверка | Результат |
|----------|-----------|
| `pytest -q` | **76 passed, 1 skipped, 0 failures** |
| `npm run check` | **0 errors, 0 warnings, 0 hints** |
| `caddy adapt` | **passes** |
---
### R03 · P1 · Research CLI не читает и не создаёт cooldown state
**Что сломалось:** `enforce_fetch_interval` открывал файл в `r` — падал при первом запуске (файл не существует). `mark_fetch` открывал в `r+` — падал при отсутствии файла. `f.read(encoding="utf-8")``encoding` не является аргументом `read()`. Shared/exclusive locks не делали read-modify-write атомарным.
**Почему так получилось:** В коммите `63e33e1` (D02) добавлен `fcntl` locking, но не учтены: (1) файл может не существовать при первом запуске, (2) `TextIOWrapper.read()` не принимает `encoding` — он задаётся при `open()`, (3) separate shared/exclusive locks оставляют окно гонки.
**Что сделано:**
- Введены `_read_state(state_file)` и `_write_state(state_file, state)` как единые точки доступа.
- `_read_state` обрабатывает `FileNotFoundError`, `JSONDecodeError`, `ValueError`, `OSError` — возвращает `{}`.
- `_write_state` использует `open("w")` + exclusive lock.
- `enforce_fetch_interval` и `mark_fetch` используют единые helpers.
- `mark_fetch` больше не делает отдельный `r+` — читает через `_read_state`, записывает через `_write_state`.
**Файлы:** `rf4_research/community_cli.py`
**Коммит:** pending
**Верификация:** `pytest tests/test_community_cli.py`**3 passed**.
---
## Оставшиеся регрессии (R04-R13, R15)
### R03 · P1 · Research CLI не читает и не создаёт cooldown state
- **Проблема:** `enforce_fetch_interval` открывает файл в `r`, `mark_fetch` — в `r+`; оба падают при первом запуске. `f.read(encoding="utf-8")``encoding` задаётся при открытии, а не в `read()`.
- **Статус:** Не начато.
### R04 · P1 · Disabled-фильтрация может обойти общий интервал сайта
- **Проблема:** `configured_sources()` исключает disabled из списка кандидатов, но `site_sources` для истории cooldown строится из полного реестра. Отключение endpoint сбрасывает site cooldown для соседей.
- **Статус:** Не начато.
### R05 · P1 · Bootstrap небезопасен для источников
- **Проблема:** `test-production-bootstrap.sh` включает `proxy community-scheduler`, но наследует production-домены, порты 80/443 и реальные URL парсеров.
- **Статус:** Не начато.
### R06 · P1 · Фильтры сигналов сравнивают slug с названием
- **Проблема:** `community_observations` фильтрует по `fish_name`/`waterbody_name` (названия источника), но frontend передаёт `fish`/`waterbody` slugs.
- **Статус:** Не начато.
### R07 · P1 · Ошибка главной остаётся HTTP 200
- **Проблема:** `if (unavailable)` стоит до запросов, ветка 503/Retry-After/noindex никогда не срабатывает.
- **Статус:** Частично исправлено в S01 (добавлен noindex для index/records при unavailable), но `unavailable` проверяется после присваивания `false` — нужно подтвердить.
### R08 · P1 · Фильтры UI не доведены до responsive-состояния
- **Проблема:** `.filter-compact-hidden` с `display:contents!important` ломает label/select компоновку. Без JS период/сортировка недоступны.
- **Статус:** Не начато.
### R09 · P1 · Публичной пагинации по-прежнему нет
- **Проблема:** `limit=20&offset=0` всегда, нет перехода к следующей странице.
- **Статус:** Частично исправлено в U03 (добавлен `total` и кнопка "Показать ещё"), но полная пагинация с сохранением фильтров не реализована.
### R10 · P1 · Ошибки формы теряют черновик, timeout распознаётся неверно
- **Проблема:** `rate_limited`, `server_error`, `timeout` не восстанавливают черновик. `AbortSignal.timeout` использует `TimeoutError`, а не `TypeError`.
- **Статус:** Частично исправлено в U04 (добавлены сообщения об ошибках, блокировка double submit), но корректная классификация TimeoutError не проверена.
### R11 · P1 · Проверка URL происходит после сетевого обращения
- **Проблема:** `fetch_html` делает `urlopen` до проверки allowlist.
- **Статус:** Не начато.
### R12 · P1 · Мониторинг не сигнализирует о зависшем импорте
- **Проблема:** `readiness_report` добавляет `community_scheduler`, но failed/stale не влияют на итог `ready`.
- **Статус:** Не начато.
### R13 · P1 · Доверие к IP клиента не ограничено proxy boundary
- **Проблема:** `_check_rate_limit` принимает первый `X-Forwarded-For` от любого peer.
- **Статус:** Не начато.
### R15 · P1 · D04/D06/D07/D08 выполнены не полностью
- **D04:** Fallback только для waterbody, рыба требует external ID.
- **D06:** "Один игрок не может искусственно поднять уверенность" — формула допускает 72% при 1 игроке.
- **D07:** `community_review.py` присваивает `caught_at=published_at`.
- **D08:** Запрос точки ограничен водоёмом, но остаётся top-100.
- **Статус:** Не начато.
---
## Следующие шаги
1. **R03** — Research CLI cooldown: исправить `fcntl` locking и `encoding`.
2. **R04** — Разделить `configured_sources()` и `site_cooldown_sources()`.
3. **R05** — Перевести bootstrap на loopback-порты и локальные домены.
4. **R06** — Исправить `community_observations` на фильтрацию по `fish_slug`/`waterbody_slug`.
5. **R07** — Переместить `unavailable` проверку после запросов.
6. **R08** — Исправить CSS для `.filter-compact-hidden`, добавить fallback без JS.
7. **R09** — Полная пагинация с сохранением фильтров.
8. **R10** — Корректная обработка `TimeoutError`.
9. **R11** — Проверка allowlist до `urlopen`.
10. **R12** — Влияние `community_scheduler` на readiness.
11. **R13** — Proxy trust boundary для X-Forwarded-For.
12. **R15** — D04/D06/D07/D08 partial completion.
+28 -20
View File
@@ -41,6 +41,30 @@ ALLOWED_HOSTS = frozenset({
MAX_RESPONSE_BYTES = 5 * 1024 * 1024 # 5 MB MAX_RESPONSE_BYTES = 5 * 1024 * 1024 # 5 MB
def _read_state(state_file: Path) -> dict:
"""Read state file with shared lock; return empty dict if missing/corrupt."""
try:
with open(state_file, "r") as f:
fcntl.flock(f, fcntl.LOCK_SH)
try:
return json.loads(f.read())
finally:
fcntl.flock(f, fcntl.LOCK_UN)
except (FileNotFoundError, json.JSONDecodeError, ValueError, OSError):
return {}
def _write_state(state_file: Path, state: dict) -> None:
"""Write state file atomically with exclusive lock."""
state_file.parent.mkdir(parents=True, exist_ok=True)
with open(state_file, "w") as f:
fcntl.flock(f, fcntl.LOCK_EX)
try:
f.write(json.dumps(state, sort_keys=True))
finally:
fcntl.flock(f, fcntl.LOCK_UN)
def fetch_site_key(url: str) -> str: def fetch_site_key(url: str) -> str:
"""Return a stable cooldown key shared by all endpoints of one site.""" """Return a stable cooldown key shared by all endpoints of one site."""
hostname = (urlsplit(url).hostname or "").lower() hostname = (urlsplit(url).hostname or "").lower()
@@ -55,12 +79,7 @@ def enforce_fetch_interval(
source: str, *, state_file: Path, now: float | None = None, source: str, *, state_file: Path, now: float | None = None,
) -> None: ) -> None:
now = time.time() if now is None else now now = time.time() if now is None else now
with open(state_file, "r") as f: state = _read_state(state_file)
fcntl.flock(f, fcntl.LOCK_SH)
try:
state = json.loads(f.read(encoding="utf-8"))
finally:
fcntl.flock(f, fcntl.LOCK_UN)
last_fetch = state.get(source) last_fetch = state.get(source)
if isinstance(last_fetch, (int, float)) and now - last_fetch < MIN_FETCH_INTERVAL_SECONDS: if isinstance(last_fetch, (int, float)) and now - last_fetch < MIN_FETCH_INTERVAL_SECONDS:
wait = int(MIN_FETCH_INTERVAL_SECONDS - (now - last_fetch)) wait = int(MIN_FETCH_INTERVAL_SECONDS - (now - last_fetch))
@@ -69,20 +88,9 @@ def enforce_fetch_interval(
def mark_fetch(source: str, *, state_file: Path, now: float | None = None) -> None: def mark_fetch(source: str, *, state_file: Path, now: float | None = None) -> None:
now = time.time() if now is None else now now = time.time() if now is None else now
state_file.parent.mkdir(parents=True, exist_ok=True) state = _read_state(state_file)
with open(state_file, "r+") as f: state[source] = now
fcntl.flock(f, fcntl.LOCK_EX) _write_state(state_file, state)
try:
try:
state = json.loads(f.read(encoding="utf-8"))
except (json.JSONDecodeError, ValueError):
state = {}
state[source] = now
f.seek(0)
f.truncate()
f.write(json.dumps(state, sort_keys=True))
finally:
fcntl.flock(f, fcntl.LOCK_UN)
def fetch_html(url: str, *, timeout: float = 30) -> str: def fetch_html(url: str, *, timeout: float = 30) -> str: