feat: schedule authorized community imports
This commit is contained in:
@@ -13,5 +13,8 @@ OFFICIAL_RECORDS_CATEGORY=records
|
|||||||
# true только если scheduler официального импорта обязателен для readiness
|
# true только если scheduler официального импорта обязателен для readiness
|
||||||
OFFICIAL_IMPORT_REQUIRED=false
|
OFFICIAL_IMPORT_REQUIRED=false
|
||||||
IMPORT_INTERVAL_SECONDS=3600
|
IMPORT_INTERVAL_SECONDS=3600
|
||||||
|
COMMUNITY_IMPORT_INTERVAL_SECONDS=1800
|
||||||
|
RF4MAP_POINT_URL=https://rf4map.ru/points/275
|
||||||
|
RF4POSTS_SPOT_URL=https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee
|
||||||
RATE_LIMIT_SECRET=change-rate-limit-secret
|
RATE_LIMIT_SECRET=change-rate-limit-secret
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
|||||||
|
|
||||||
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL.
|
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL.
|
||||||
|
|
||||||
|
Все пять community-парсеров подключены к отдельному scheduler-процессу. Состояние запусков и ошибок хранится в PostgreSQL, параллельный запуск одного источника блокируется, минимальный интервал жёстко ограничен 1800 секундами. Локально процесс включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
||||||
|
|
||||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
||||||
|
|
||||||
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных.
|
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных.
|
||||||
@@ -118,7 +120,7 @@ docker compose up --build
|
|||||||
## Что реализовано
|
## Что реализовано
|
||||||
|
|
||||||
- FastAPI и SQLAlchemy 2;
|
- FastAPI и SQLAlchemy 2;
|
||||||
- PostgreSQL 17 и миграции Alembic до `0012`;
|
- PostgreSQL 17 и миграции Alembic до `0013`;
|
||||||
- идемпотентный seed с двумя точками и свежими демо-уловами;
|
- идемпотентный seed с двумя точками и свежими демо-уловами;
|
||||||
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
|
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
|
||||||
- `GET /api/v1/spots/{id}` и `/catches`;
|
- `GET /api/v1/spots/{id}` и `/catches`;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add persistent community scheduler journal."""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0013"
|
||||||
|
down_revision = "0012"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table("community_import_run",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("source_system", sa.String(50), sa.ForeignKey("data_source.key"), nullable=False),
|
||||||
|
sa.Column("source_url", sa.Text(), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("status", sa.String(30), nullable=False),
|
||||||
|
sa.Column("rows_seen", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("rows_created", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("rows_updated", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("error_summary", sa.Text()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_community_import_run_source_system", "community_import_run", ["source_system"])
|
||||||
|
op.create_index("ix_community_import_run_started_at", "community_import_run", ["started_at"])
|
||||||
|
op.create_index("ix_community_import_run_status", "community_import_run", ["status"])
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("community_import_run")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
from rf4_research.community_cli import SOURCES, fetch_html
|
||||||
|
from rf4_research.community_sources import parse_rf4map_point, parse_rf4posts_spot
|
||||||
|
from .community_importer import stage_observations
|
||||||
|
from .config import settings
|
||||||
|
from .database import SessionLocal
|
||||||
|
from .logging_config import configure_logging
|
||||||
|
from .models import CommunityImportRun, DataSource
|
||||||
|
|
||||||
|
logger = logging.getLogger("rf4.community_scheduler")
|
||||||
|
|
||||||
|
def configured_sources():
|
||||||
|
return {
|
||||||
|
"rf4db": SOURCES["rf4db"],
|
||||||
|
"rf4stat-fishing": SOURCES["rf4stat-fishing"],
|
||||||
|
"rf4stat-post": (SOURCES["rf4stat-posts"][0], SOURCES["rf4stat-posts"][1]),
|
||||||
|
"rf4map": (settings.rf4map_point_url, parse_rf4map_point),
|
||||||
|
"rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot),
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
||||||
|
current = now or datetime.now(timezone.utc)
|
||||||
|
url, parser = configured_sources()[source_system]
|
||||||
|
with SessionLocal() as session:
|
||||||
|
source = session.get(DataSource, source_system)
|
||||||
|
if source is None or not source.enabled:
|
||||||
|
return False
|
||||||
|
latest = session.scalar(select(CommunityImportRun.started_at).where(CommunityImportRun.source_system == source_system).order_by(CommunityImportRun.started_at.desc()).limit(1))
|
||||||
|
if latest and (latest if latest.tzinfo else latest.replace(tzinfo=timezone.utc)) > current - timedelta(seconds=settings.community_import_interval_seconds):
|
||||||
|
return False
|
||||||
|
if session.bind and session.bind.dialect.name == "postgresql" and not session.scalar(text("select pg_try_advisory_xact_lock(hashtext(:key))"), {"key": f"community:{source_system}"}):
|
||||||
|
return False
|
||||||
|
run = CommunityImportRun(source_system=source_system, source_url=url, started_at=current, status="running")
|
||||||
|
session.add(run); session.commit()
|
||||||
|
try:
|
||||||
|
html = fetch_html(url)
|
||||||
|
records = parser(html, source_url=url) if source_system in {"rf4map", "rf4posts-spot"} else parser(html)
|
||||||
|
created, updated = stage_observations(session, [asdict(item) for item in records])
|
||||||
|
run.status, run.rows_seen, run.rows_created, run.rows_updated = "success", len(records), created, updated
|
||||||
|
except Exception as exc:
|
||||||
|
run.status, run.error_summary = "failed", f"{type(exc).__name__}: {str(exc)[:500]}"
|
||||||
|
logger.exception("community import failed", extra={"event":"community_import_failed", "source_system":source_system})
|
||||||
|
run.finished_at = datetime.now(timezone.utc); session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
configure_logging(settings.log_level)
|
||||||
|
while True:
|
||||||
|
for source_system in configured_sources():
|
||||||
|
run_source(source_system)
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -24,6 +24,9 @@ class Settings(BaseSettings):
|
|||||||
retention_audit_days: int = Field(default=365, ge=90)
|
retention_audit_days: int = Field(default=365, ge=90)
|
||||||
retention_published_payload_days: int = Field(default=365, ge=90)
|
retention_published_payload_days: int = Field(default=365, ge=90)
|
||||||
import_interval_seconds: int = Field(default=3600, ge=3600)
|
import_interval_seconds: int = Field(default=3600, ge=3600)
|
||||||
|
community_import_interval_seconds: int = Field(default=1800, ge=1800)
|
||||||
|
rf4map_point_url: str = "https://rf4map.ru/points/275"
|
||||||
|
rf4posts_spot_url: str = "https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee"
|
||||||
rate_limit_secret: str = "change-rate-limit-secret"
|
rate_limit_secret: str = "change-rate-limit-secret"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
|
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
|
||||||
|
|||||||
@@ -148,6 +148,20 @@ class DataSource(Base):
|
|||||||
enabled: Mapped[bool] = mapped_column(default=True)
|
enabled: Mapped[bool] = mapped_column(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CommunityImportRun(Base):
|
||||||
|
__tablename__ = "community_import_run"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
source_system: Mapped[str] = mapped_column(ForeignKey("data_source.key"), index=True)
|
||||||
|
source_url: Mapped[str] = mapped_column(Text)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
status: Mapped[str] = mapped_column(String(30), index=True)
|
||||||
|
rows_seen: Mapped[int] = mapped_column(default=0)
|
||||||
|
rows_created: Mapped[int] = mapped_column(default=0)
|
||||||
|
rows_updated: Mapped[int] = mapped_column(default=0)
|
||||||
|
error_summary: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
||||||
class ExternalObservation(Base):
|
class ExternalObservation(Base):
|
||||||
__tablename__ = "external_observation"
|
__tablename__ = "external_observation"
|
||||||
__table_args__ = (UniqueConstraint("source_system", "source_external_id"),)
|
__table_args__ = (UniqueConstraint("source_system", "source_external_id"),)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.community_scheduler import configured_sources
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_authorized_sources_are_scheduled() -> None:
|
||||||
|
assert set(configured_sources()) == {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_community_interval_cannot_be_less_than_30_minutes() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Settings(community_import_interval_seconds=1799)
|
||||||
@@ -166,6 +166,35 @@ services:
|
|||||||
resources:
|
resources:
|
||||||
limits: {cpus: "0.75", memory: 512M}
|
limits: {cpus: "0.75", memory: 512M}
|
||||||
|
|
||||||
|
community-scheduler:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/api/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DEPLOYMENT_ENVIRONMENT: production
|
||||||
|
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL}
|
||||||
|
ADMIN_TOKEN: ${ADMIN_TOKEN:?Set ADMIN_TOKEN}
|
||||||
|
CORS_ORIGINS: '["https://${SITE_DOMAIN:?Set SITE_DOMAIN}"]'
|
||||||
|
S3_PUBLIC_ENDPOINT_URL: https://${FILES_DOMAIN:?Set FILES_DOMAIN}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
|
||||||
|
SEED_DEMO_DATA: "false"
|
||||||
|
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
|
||||||
|
COMMUNITY_IMPORT_INTERVAL_SECONDS: ${COMMUNITY_IMPORT_INTERVAL_SECONDS:-1800}
|
||||||
|
RF4MAP_POINT_URL: ${RF4MAP_POINT_URL:-https://rf4map.ru/points/275}
|
||||||
|
RF4POSTS_SPOT_URL: ${RF4POSTS_SPOT_URL:-https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
depends_on:
|
||||||
|
api: {condition: service_healthy}
|
||||||
|
command: ["python", "-m", "app.community_scheduler"]
|
||||||
|
networks: [backend]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
logging: *default-logging
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits: {cpus: "0.50", memory: 384M}
|
||||||
|
|
||||||
storage-tool:
|
storage-tool:
|
||||||
image: minio/mc:RELEASE.2025-07-21T05-28-08Z
|
image: minio/mc:RELEASE.2025-07-21T05-28-08Z
|
||||||
profiles: [tools]
|
profiles: [tools]
|
||||||
|
|||||||
@@ -107,6 +107,22 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
command: ["python", "-m", "app.scheduler"]
|
command: ["python", "-m", "app.scheduler"]
|
||||||
|
|
||||||
|
community-scheduler:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/api/Dockerfile
|
||||||
|
profiles: ["scheduler"]
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
|
||||||
|
COMMUNITY_IMPORT_INTERVAL_SECONDS: ${COMMUNITY_IMPORT_INTERVAL_SECONDS:-1800}
|
||||||
|
RF4MAP_POINT_URL: ${RF4MAP_POINT_URL:-https://rf4map.ru/points/275}
|
||||||
|
RF4POSTS_SPOT_URL: ${RF4POSTS_SPOT_URL:-https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
depends_on:
|
||||||
|
api: {condition: service_healthy}
|
||||||
|
command: ["python", "-m", "app.community_scheduler"]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ curl -fsS "http://127.0.0.1:$BOOTSTRAP_API_PORT/ready" >/dev/null
|
|||||||
curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/dev/null
|
curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/dev/null
|
||||||
curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^x-frame-options: DENY'
|
curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^x-frame-options: DENY'
|
||||||
curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^cross-origin-opener-policy: same-origin'
|
curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^cross-origin-opener-policy: same-origin'
|
||||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0012"
|
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0013"
|
||||||
index_count=$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c "select count(*) from pg_indexes where schemaname = 'public' and indexname in ('ix_catch_report_activity_lookup','ix_catch_report_spot_feed','ix_catch_report_moderation_queue','ix_catch_report_official_records','ix_official_import_source_status_started','ix_external_observation_review_queue','ix_submission_attempt_client_created','ix_moderation_event_created_at')")
|
index_count=$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c "select count(*) from pg_indexes where schemaname = 'public' and indexname in ('ix_catch_report_activity_lookup','ix_catch_report_spot_feed','ix_catch_report_moderation_queue','ix_catch_report_official_records','ix_official_import_source_status_started','ix_external_observation_review_queue','ix_submission_attempt_client_created','ix_moderation_event_created_at')")
|
||||||
test "$index_count" = "8"
|
test "$index_count" = "8"
|
||||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from fish')" = "2"
|
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from fish')" = "2"
|
||||||
|
|||||||
+2
-1
@@ -135,7 +135,8 @@
|
|||||||
|
|
||||||
### Производительность, данные и эксплуатация
|
### Производительность, данные и эксплуатация
|
||||||
|
|
||||||
- [ ] Добавить управляемый scheduler всех разрешённых community-парсеров с cooldown ≥30 минут, PostgreSQL lock, backoff и журналом запусков.
|
- [x] Добавить scheduler всех пяти разрешённых community-парсеров с устойчивым cooldown ≥30 минут, PostgreSQL lock, журналом запусков и opt-in локальным профилем; backoff после повторных ошибок остаётся отдельным улучшением (7 сентября 2026).
|
||||||
|
- [ ] Добавить экспоненциальный backoff community scheduler после повторных ошибок, не уменьшая базовый интервал 30 минут.
|
||||||
- [ ] Показывать публичное безопасное состояние источников: актуален, устарел, временно ограничен или изменился DOM.
|
- [ ] Показывать публичное безопасное состояние источников: актуален, устарел, временно ограничен или изменился DOM.
|
||||||
- [ ] Добавить короткое серверное кэширование публичных GET API и проверить корректную инвалидацию после публикации.
|
- [ ] Добавить короткое серверное кэширование публичных GET API и проверить корректную инвалидацию после публикации.
|
||||||
- [ ] Настроить долгий immutable cache для хешированных assets и разумный cache для изображений/favicon.
|
- [ ] Настроить долгий immutable cache для хешированных assets и разумный cache для изображений/favicon.
|
||||||
|
|||||||
@@ -32,9 +32,9 @@
|
|||||||
- RF4DB: один запрос списка за ручной запуск; detail-страницы запрашиваются только адресно, без массового обхода.
|
- RF4DB: один запрос списка за ручной запуск; detail-страницы запрашиваются только адресно, без массового обхода.
|
||||||
- RF4MAP: не чаще одного запроса раз в 30 минут.
|
- RF4MAP: не чаще одного запроса раз в 30 минут.
|
||||||
- RF4 Posts: не чаще одного запроса раз в 30 минут.
|
- RF4 Posts: не чаще одного запроса раз в 30 минут.
|
||||||
- все пять разрешённых community-адаптеров включены в реестре staging; регулярные scheduler-задачи остаются выключены;
|
- все пять разрешённых community-адаптеров включены; scheduler хранит cooldown в PostgreSQL и не запускает источник чаще одного раза в 30 минут;
|
||||||
- при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
|
- при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
|
||||||
|
|
||||||
## Что ещё требуется зафиксировать
|
## Что ещё требуется зафиксировать
|
||||||
|
|
||||||
Перед внешней публикацией необходимо приложить стороны и носитель исходного разрешения, обязательную формулировку атрибуции, срок хранения и процедуру удаления. Для RF4MAP/RF4 Posts точный минимальный интервал уже зафиксирован; автоматический scheduler пока не добавлен.
|
Перед внешней публикацией необходимо приложить стороны и носитель исходного разрешения, обязательную формулировку атрибуции, срок хранения и процедуру удаления. Для RF4MAP/RF4 Posts минимальный интервал зафиксирован и технически ограничен scheduler до 30 минут.
|
||||||
|
|||||||
Reference in New Issue
Block a user