feat: serialize official record imports
This commit is contained in:
@@ -144,7 +144,7 @@ npm run test:e2e
|
|||||||
docker compose --profile tools run --rm importer
|
docker compose --profile tools run --rm importer
|
||||||
```
|
```
|
||||||
|
|
||||||
Импорт делает до трёх ограниченных попыток, проверяет DOM-контракт и не удаляет ранее сохранённые данные при сбое. Повторный запуск обновляет совпавшие записи по SHA-256 ключу и не создаёт дубликаты. Расписание реализовано, но намеренно не включается обычным запуском: сначала требуется согласовать допустимость регулярного опроса официального сайта.
|
Импорт делает до трёх ограниченных попыток, проверяет DOM-контракт и не удаляет ранее сохранённые данные при сбое. Повторный запуск обновляет совпавшие записи по SHA-256 ключу и не создаёт дубликаты. PostgreSQL advisory lock не допускает параллельный импорт одной source/region/category через admin и scheduler. Расписание реализовано, но намеренно не включается обычным запуском: сначала требуется согласовать допустимость регулярного опроса официального сайта.
|
||||||
|
|
||||||
Ручной административный запуск также доступен через `POST /api/v1/admin/imports/official-records`, журнал — через `GET /api/v1/admin/imports`. Импорт сохраняет HTTP-метаданные и использует `ETag`/`Last-Modified`, когда источник их предоставляет.
|
Ручной административный запуск также доступен через `POST /api/v1/admin/imports/official-records`, журнал — через `GET /api/v1/admin/imports`. Импорт сохраняет HTTP-метаданные и использует `ETag`/`Last-Modified`, когда источник их предоставляет.
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
import time as time_module
|
import time as time_module
|
||||||
|
from contextlib import contextmanager
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import date, datetime, time, timezone
|
from datetime import date, datetime, time, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from rf4_research.official_parser import RecordsContractError, parse_official_records
|
from rf4_research.official_parser import RecordsContractError, parse_official_records
|
||||||
|
|
||||||
@@ -24,6 +25,10 @@ class ImportSourceError(ValueError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ImportAlreadyRunning(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class RawRecord:
|
class RawRecord:
|
||||||
region: str
|
region: str
|
||||||
@@ -105,7 +110,42 @@ def fetch_records(
|
|||||||
raise AssertionError("unreachable")
|
raise AssertionError("unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_key(url: str, region: str, category: str) -> int:
|
||||||
|
digest = hashlib.sha256(f"{url}|{region.upper()}|{category}".encode()).digest()
|
||||||
|
return int.from_bytes(digest[:8], byteorder="big", signed=True)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _official_import_lock(session: Session, *, url: str, region: str, category: str):
|
||||||
|
bind = session.get_bind()
|
||||||
|
if bind.dialect.name != "postgresql":
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
connection = bind.connect()
|
||||||
|
key = _lock_key(url, region, category)
|
||||||
|
try:
|
||||||
|
acquired = bool(connection.scalar(text("SELECT pg_try_advisory_lock(:key)"), {"key": key}))
|
||||||
|
except Exception:
|
||||||
|
connection.close()
|
||||||
|
raise
|
||||||
|
if not acquired:
|
||||||
|
connection.close()
|
||||||
|
raise ImportAlreadyRunning("official import is already running for this source and category")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
connection.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": key})
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
def import_records(session: Session, *, url: str, region: str, category: str, html: str | None = None) -> OfficialRecordImport:
|
def import_records(session: Session, *, url: str, region: str, category: str, html: str | None = None) -> OfficialRecordImport:
|
||||||
|
with _official_import_lock(session, url=url, region=region, category=category):
|
||||||
|
return _import_records_locked(session, url=url, region=region, category=category, html=html)
|
||||||
|
|
||||||
|
|
||||||
|
def _import_records_locked(session: Session, *, url: str, region: str, category: str, html: str | None = None) -> OfficialRecordImport:
|
||||||
run = OfficialRecordImport(started_at=datetime.now(timezone.utc), status=ImportStatus.running, source_url=url, rows_seen=0, rows_created=0, rows_updated=0)
|
run = OfficialRecordImport(started_at=datetime.now(timezone.utc), status=ImportStatus.running, source_url=url, rows_seen=0, rows_created=0, rows_updated=0)
|
||||||
session.add(run)
|
session.add(run)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from .activity import activity_rows
|
|||||||
from .database import get_session
|
from .database import get_session
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
|
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
|
||||||
from .importer import ImportSourceError, import_records, normalize
|
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||||
from .readiness import readiness_report
|
from .readiness import readiness_report
|
||||||
@@ -200,6 +200,8 @@ def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> O
|
|||||||
region=settings.official_records_region,
|
region=settings.official_records_region,
|
||||||
category=settings.official_records_category,
|
category=settings.official_records_category,
|
||||||
)
|
)
|
||||||
|
except ImportAlreadyRunning as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
except (ImportSourceError, httpx.HTTPError) as exc:
|
except (ImportSourceError, httpx.HTTPError) as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc
|
raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
from .importer import import_records
|
from .importer import ImportAlreadyRunning, import_records
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .models import OfficialRecordImport
|
from .models import OfficialRecordImport
|
||||||
|
|
||||||
@@ -36,12 +36,16 @@ def run_due_import() -> bool:
|
|||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
if not import_is_due(session):
|
if not import_is_due(session):
|
||||||
return False
|
return False
|
||||||
run = import_records(
|
try:
|
||||||
session,
|
run = import_records(
|
||||||
url=settings.official_records_url,
|
session,
|
||||||
region=settings.official_records_region,
|
url=settings.official_records_url,
|
||||||
category=settings.official_records_category,
|
region=settings.official_records_region,
|
||||||
)
|
category=settings.official_records_category,
|
||||||
|
)
|
||||||
|
except ImportAlreadyRunning:
|
||||||
|
logger.info("official import skipped because it is already running", extra={"event": "official_import_locked"})
|
||||||
|
return False
|
||||||
logger.info(
|
logger.info(
|
||||||
"official import completed",
|
"official import completed",
|
||||||
extra={
|
extra={
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.pool import StaticPool
|
|||||||
|
|
||||||
from app.database import Base, get_session
|
from app.database import Base, get_session
|
||||||
from app.community_importer import stage_observations
|
from app.community_importer import stage_observations
|
||||||
|
from app.importer import ImportAlreadyRunning
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
|
from app.models import Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
|
||||||
|
|
||||||
@@ -186,6 +187,12 @@ def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
|||||||
listed = client.get("/api/v1/admin/imports?limit=1&offset=0", headers=headers)
|
listed = client.get("/api/v1/admin/imports?limit=1&offset=0", headers=headers)
|
||||||
assert listed.status_code == 200
|
assert listed.status_code == 200
|
||||||
assert listed.json()[0]["id"] == started.json()["id"]
|
assert listed.json()[0]["id"] == started.json()["id"]
|
||||||
|
def busy_import(*args, **kwargs):
|
||||||
|
raise ImportAlreadyRunning("official import is already running")
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.main.import_records", busy_import)
|
||||||
|
conflict = client.post("/api/v1/admin/imports/official-records", headers=headers)
|
||||||
|
assert conflict.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
|
def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.importer import ImportAlreadyRunning, _official_import_lock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not os.environ.get("DATABASE_URL", "").startswith("postgresql"), reason="requires PostgreSQL")
|
||||||
|
def test_postgresql_import_lock_blocks_only_same_source_category() -> None:
|
||||||
|
engine = create_engine(os.environ["DATABASE_URL"])
|
||||||
|
first = Session(engine)
|
||||||
|
second = Session(engine)
|
||||||
|
try:
|
||||||
|
with _official_import_lock(first, url="https://example.test/records", region="RU", category="records"):
|
||||||
|
with pytest.raises(ImportAlreadyRunning):
|
||||||
|
with _official_import_lock(second, url="https://example.test/records", region="RU", category="records"):
|
||||||
|
pass
|
||||||
|
with _official_import_lock(second, url="https://example.test/records", region="RU", category="weekly"):
|
||||||
|
pass
|
||||||
|
with _official_import_lock(second, url="https://example.test/records", region="RU", category="records"):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
first.close()
|
||||||
|
second.close()
|
||||||
|
engine.dispose()
|
||||||
@@ -7,7 +7,7 @@ from sqlalchemy import create_engine, func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.importer import FetchResult, ImportSourceError, import_records, parse_html
|
from app.importer import FetchResult, ImportAlreadyRunning, ImportSourceError, _lock_key, _official_import_lock, import_records, parse_html
|
||||||
from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceType
|
from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceType
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +15,26 @@ FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.
|
|||||||
WEEKLY_FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "weekly_records_sample.html"
|
WEEKLY_FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "weekly_records_sample.html"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_lock_is_stable_and_fails_closed_when_busy() -> None:
|
||||||
|
class Connection:
|
||||||
|
def scalar(self, statement, parameters):
|
||||||
|
assert "pg_try_advisory_lock" in str(statement)
|
||||||
|
assert parameters == {"key": _lock_key("https://example.test", "RU", "records")}
|
||||||
|
return False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
connection = Connection()
|
||||||
|
bind = type("Bind", (), {"dialect": type("Dialect", (), {"name": "postgresql"})(), "connect": lambda self: connection})()
|
||||||
|
session = type("Session", (), {"get_bind": lambda self: bind})()
|
||||||
|
assert _lock_key("https://example.test", "ru", "records") == _lock_key("https://example.test", "RU", "records")
|
||||||
|
with pytest.raises(ImportAlreadyRunning, match="already running"):
|
||||||
|
with _official_import_lock(session, url="https://example.test", region="RU", category="records"):
|
||||||
|
raise AssertionError("busy lock must not enter import")
|
||||||
|
assert connection.closed is True
|
||||||
|
|
||||||
|
|
||||||
def test_parser_and_import_are_idempotent() -> None:
|
def test_parser_and_import_are_idempotent() -> None:
|
||||||
html = FIXTURE.read_text(encoding="utf-8")
|
html = FIXTURE.read_text(encoding="utf-8")
|
||||||
parsed = parse_html(html, region="RU", category="records")
|
parsed = parse_html(html, region="RU", category="records")
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ docker compose --env-file .env.production -f compose.production.yaml exec api py
|
|||||||
|
|
||||||
Автоматический scheduler не входит в production-файл. RF4MAP и RF4 Posts нельзя опрашивать чаще одного раза в 30 минут; до отдельной эксплуатационной задачи используйте только контролируемые ручные запуски и staging.
|
Автоматический scheduler не входит в production-файл. RF4MAP и RF4 Posts нельзя опрашивать чаще одного раза в 30 минут; до отдельной эксплуатационной задачи используйте только контролируемые ручные запуски и staging.
|
||||||
|
|
||||||
|
Официальный импорт защищён PostgreSQL advisory lock на комбинацию source/region/category. Параллельный admin-запрос получает `409`, а scheduler записывает безопасный skip и не делает второй HTTP-запрос к источнику.
|
||||||
|
|
||||||
## 5. Обновление
|
## 5. Обновление
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке.
|
Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке.
|
||||||
|
|
||||||
Последняя сверка плана со спецификацией, кодом и UI/UX-аудитом: 6 сентября 2026 года.
|
Последняя сверка плана со спецификацией, кодом и UI/UX-аудитом: 7 сентября 2026 года.
|
||||||
|
|
||||||
Обозначения:
|
Обозначения:
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
- [x] Привести журнал импорта к административному контракту `GET /api/v1/admin/imports` с авторизацией, пагинацией и стабильной сортировкой (проверено API-тестом).
|
- [x] Привести журнал импорта к административному контракту `GET /api/v1/admin/imports` с авторизацией, пагинацией и стабильной сортировкой (проверено API-тестом).
|
||||||
- [x] Добавить HTTP-кэширование источника (`ETag`/`Last-Modified`, если источник их отдаёт) и сохранить диагностические метаданные ответа (миграция `0005`, тест условного запроса и `304`).
|
- [x] Добавить HTTP-кэширование источника (`ETag`/`Last-Modified`, если источник их отдаёт) и сохранить диагностические метаданные ответа (миграция `0005`, тест условного запроса и `304`).
|
||||||
- [x] Добавить планировщик импорта с безопасной частотой по умолчанию один раз в 60 минут; отдельный opt-in контейнер/процесс (профиль `scheduler`, обычным запуском не активируется).
|
- [x] Добавить планировщик импорта с безопасной частотой по умолчанию один раз в 60 минут; отдельный opt-in контейнер/процесс (профиль `scheduler`, обычным запуском не активируется).
|
||||||
- [ ] Защитить официальный импорт PostgreSQL advisory lock или эквивалентом, чтобы ручной endpoint и несколько scheduler-процессов не импортировали одну категорию одновременно.
|
- [x] Защитить официальный импорт PostgreSQL session advisory lock: одинаковая source/region/category не запускается параллельно, admin получает `409`, scheduler безопасно пропускает цикл; проверено двумя независимыми PostgreSQL-соединениями.
|
||||||
- [x] Проверить актуальные `robots.txt` и условия использования перед включением расписания; результат записать в `docs/data-sources.md` (`robots.txt` вернул `404`; автоматический профиль оставлен выключенным до явного разрешения).
|
- [x] Проверить актуальные `robots.txt` и условия использования перед включением расписания; результат записать в `docs/data-sources.md` (`robots.txt` вернул `404`; автоматический профиль оставлен выключенным до явного разрешения).
|
||||||
- [x] Добавить интеграционные тесты: повторный импорт не создаёт дубликаты, сбой источника не удаляет данные, изменение DOM завершается понятной ошибкой.
|
- [x] Добавить интеграционные тесты: повторный импорт не создаёт дубликаты, сбой источника не удаляет данные, изменение DOM завершается понятной ошибкой.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user