perf: stabilize list queries for pilot
This commit is contained in:
@@ -87,7 +87,7 @@ docker compose up --build
|
|||||||
## Что реализовано
|
## Что реализовано
|
||||||
|
|
||||||
- FastAPI и SQLAlchemy 2;
|
- FastAPI и SQLAlchemy 2;
|
||||||
- PostgreSQL 17 и миграции Alembic до `0010`;
|
- PostgreSQL 17 и миграции Alembic до `0011`;
|
||||||
- идемпотентный 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,29 @@
|
|||||||
|
"""Add composite indexes for pilot list and maintenance queries."""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0011"
|
||||||
|
down_revision = "0010"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_index("ix_catch_report_activity_lookup", "catch_report", ["moderation_status", "deleted_at", "reported_at"])
|
||||||
|
op.create_index("ix_catch_report_spot_feed", "catch_report", ["spot_id", "moderation_status", "deleted_at", "reported_at"])
|
||||||
|
op.create_index("ix_catch_report_moderation_queue", "catch_report", ["source_type", "moderation_status", "deleted_at", "reported_at"])
|
||||||
|
op.create_index("ix_catch_report_official_records", "catch_report", ["source_type", "caught_at", "weight_g"])
|
||||||
|
op.create_index("ix_official_import_source_status_started", "official_record_import", ["source_url", "status", "started_at"])
|
||||||
|
op.create_index("ix_external_observation_review_queue", "external_observation", ["status", "source_system", "last_seen_at"])
|
||||||
|
op.create_index("ix_submission_attempt_client_created", "submission_attempt", ["client_hash", "created_at"])
|
||||||
|
op.create_index("ix_moderation_event_created_at", "moderation_event", ["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_moderation_event_created_at", table_name="moderation_event")
|
||||||
|
op.drop_index("ix_submission_attempt_client_created", table_name="submission_attempt")
|
||||||
|
op.drop_index("ix_external_observation_review_queue", table_name="external_observation")
|
||||||
|
op.drop_index("ix_official_import_source_status_started", table_name="official_record_import")
|
||||||
|
op.drop_index("ix_catch_report_official_records", table_name="catch_report")
|
||||||
|
op.drop_index("ix_catch_report_moderation_queue", table_name="catch_report")
|
||||||
|
op.drop_index("ix_catch_report_spot_feed", table_name="catch_report")
|
||||||
|
op.drop_index("ix_catch_report_activity_lookup", table_name="catch_report")
|
||||||
+21
-16
@@ -94,18 +94,18 @@ def ready(db: Db) -> JSONResponse:
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/fishes", response_model=list[FishOut])
|
@app.get("/api/v1/fishes", response_model=list[FishOut])
|
||||||
def fishes(db: Db) -> list[Fish]:
|
def fishes(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Fish]:
|
||||||
return list(db.scalars(select(Fish).order_by(Fish.name_ru)))
|
return list(db.scalars(select(Fish).order_by(Fish.name_ru, Fish.id).offset(offset).limit(limit)))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/waterbodies", response_model=list[WaterbodyOut])
|
@app.get("/api/v1/waterbodies", response_model=list[WaterbodyOut])
|
||||||
def waterbodies(db: Db) -> list[Waterbody]:
|
def waterbodies(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Waterbody]:
|
||||||
return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru)))
|
return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru, Waterbody.id).offset(offset).limit(limit)))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/baits", response_model=list[BaitOut])
|
@app.get("/api/v1/baits", response_model=list[BaitOut])
|
||||||
def baits(db: Db) -> list[Bait]:
|
def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Bait]:
|
||||||
return list(db.scalars(select(Bait).order_by(Bait.name)))
|
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/activity", response_model=list[ActivityOut])
|
@app.get("/api/v1/activity", response_model=list[ActivityOut])
|
||||||
@@ -119,7 +119,11 @@ def activity(
|
|||||||
if hours not in {6, 12, 24, 72}:
|
if hours not in {6, 12, 24, 72}:
|
||||||
raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72")
|
raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72")
|
||||||
rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method)
|
rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method)
|
||||||
keys = {"activity": lambda r: r.activity_score, "confidence": lambda r: r.confidence_score, "freshness": lambda r: r.last_confirmed_at}
|
keys = {
|
||||||
|
"activity": lambda r: (r.activity_score, r.confidence_score, r.last_confirmed_at, str(r.spot_id)),
|
||||||
|
"confidence": lambda r: (r.confidence_score, r.activity_score, r.last_confirmed_at, str(r.spot_id)),
|
||||||
|
"freshness": lambda r: (r.last_confirmed_at, r.activity_score, r.confidence_score, str(r.spot_id)),
|
||||||
|
}
|
||||||
rows.sort(key=keys[sort], reverse=True)
|
rows.sort(key=keys[sort], reverse=True)
|
||||||
return rows[offset:offset + limit]
|
return rows[offset:offset + limit]
|
||||||
|
|
||||||
@@ -145,7 +149,7 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
|
|||||||
@app.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
|
@app.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
|
||||||
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
|
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
|
||||||
_spot_or_404(db, spot_id)
|
_spot_or_404(db, spot_id)
|
||||||
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc()).offset(offset).limit(limit)))
|
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
||||||
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports]
|
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports]
|
||||||
|
|
||||||
|
|
||||||
@@ -160,9 +164,9 @@ def records(
|
|||||||
query = query.join(CatchReport.fish).where(Fish.slug == fish)
|
query = query.join(CatchReport.fish).where(Fish.slug == fish)
|
||||||
if waterbody:
|
if waterbody:
|
||||||
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
||||||
items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc()).offset(offset).limit(limit)))
|
|
||||||
if category:
|
if category:
|
||||||
items = [item for item in items if (item.raw_payload or {}).get("category") == category]
|
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
|
||||||
|
items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
||||||
return [OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items]
|
return [OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items]
|
||||||
|
|
||||||
|
|
||||||
@@ -174,8 +178,8 @@ def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/imports", response_model=list[ImportRunOut])
|
@app.get("/api/v1/imports", response_model=list[ImportRunOut])
|
||||||
def imports(db: Db, limit: int = Query(20, ge=1, le=100)) -> list[OfficialRecordImport]:
|
def imports(db: Db, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[OfficialRecordImport]:
|
||||||
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc()).limit(limit)))
|
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()).offset(offset).limit(limit)))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
|
@app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
|
||||||
@@ -221,7 +225,8 @@ def _external_out(item: ExternalObservation) -> ExternalObservationOut:
|
|||||||
|
|
||||||
@app.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut])
|
@app.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut])
|
||||||
def admin_external_observations(
|
def admin_external_observations(
|
||||||
db: Db, _: Annotated[str, Depends(_admin)], status: str | None = None,
|
db: Db, _: Annotated[str, Depends(_admin)],
|
||||||
|
status: Literal["staged", "mapped", "ready", "published", "rejected"] | None = None,
|
||||||
source_system: str | None = None, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
|
source_system: str | None = None, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
|
||||||
) -> list[ExternalObservationOut]:
|
) -> list[ExternalObservationOut]:
|
||||||
query = select(ExternalObservation).options(
|
query = select(ExternalObservation).options(
|
||||||
@@ -231,7 +236,7 @@ def admin_external_observations(
|
|||||||
query = query.where(ExternalObservation.status == status)
|
query = query.where(ExternalObservation.status == status)
|
||||||
if source_system:
|
if source_system:
|
||||||
query = query.where(ExternalObservation.source_system == source_system)
|
query = query.where(ExternalObservation.source_system == source_system)
|
||||||
items = db.scalars(query.order_by(ExternalObservation.last_seen_at.desc()).offset(offset).limit(limit))
|
items = db.scalars(query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()).offset(offset).limit(limit))
|
||||||
return [_external_out(item) for item in items]
|
return [_external_out(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
@@ -332,8 +337,8 @@ def add_screenshot(
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
|
@app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
|
||||||
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100)) -> list[AdminCatchReportOut]:
|
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[AdminCatchReportOut]:
|
||||||
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at).limit(limit)))
|
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).limit(limit)))
|
||||||
return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "—", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None) for r in reports]
|
return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "—", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None) for r in reports]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ def test_invalid_period_is_rejected() -> None:
|
|||||||
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
|
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_pagination_and_filter_validation() -> None:
|
||||||
|
assert client.get("/api/v1/fishes?limit=0").status_code == 422
|
||||||
|
assert client.get("/api/v1/fishes?limit=1&offset=0").status_code == 200
|
||||||
|
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||||
|
assert client.get("/api/v1/admin/external-observations?status=unknown", headers=headers).status_code == 422
|
||||||
|
assert client.get("/api/v1/admin/catch-reports?offset=-1", headers=headers).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_liveness_does_not_probe_dependencies() -> None:
|
def test_liveness_does_not_probe_dependencies() -> None:
|
||||||
response = client.get("/health?token=must-not-be-logged")
|
response = client.get("/health?token=must-not-be-logged")
|
||||||
assert response.json() == {"status": "ok"}
|
assert response.json() == {"status": "ok"}
|
||||||
@@ -80,6 +88,22 @@ def test_records_list_is_empty_before_import() -> None:
|
|||||||
assert response.json() == []
|
assert response.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_category_filter_is_applied_before_pagination() -> None:
|
||||||
|
with Session(engine) as db:
|
||||||
|
fish = db.scalar(select(Fish).where(Fish.slug == "pike"))
|
||||||
|
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == "test-lake"))
|
||||||
|
now = datetime.now(timezone.utc) - timedelta(days=30)
|
||||||
|
db.add_all([
|
||||||
|
CatchReport(fish=fish, waterbody=waterbody, weight_g=9000, caught_at=now, reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload={"category": "other"}),
|
||||||
|
CatchReport(fish=fish, waterbody=waterbody, weight_g=8000, caught_at=now - timedelta(days=1), reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload={"category": "wanted"}),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
response = client.get("/api/v1/records?category=wanted&limit=1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.json()) == 1
|
||||||
|
assert response.json()[0]["category"] == "wanted"
|
||||||
|
|
||||||
|
|
||||||
def test_user_report_requires_moderation_before_activity() -> None:
|
def test_user_report_requires_moderation_before_activity() -> None:
|
||||||
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"})
|
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"})
|
||||||
assert created.status_code == 201
|
assert created.status_code == 201
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ 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')" = "0010"
|
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0011"
|
||||||
|
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 "$($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"
|
||||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from waterbody')" = "2"
|
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from waterbody')" = "2"
|
||||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0"
|
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0"
|
||||||
|
|||||||
+7
-9
@@ -61,10 +61,10 @@
|
|||||||
- [x] Автоматизировать ежедневную цепочку backup → dry-run → retention с блокировкой параллельного запуска; ограничить Docker JSON-логи пятью файлами по 10 МБ на сервис.
|
- [x] Автоматизировать ежедневную цепочку backup → dry-run → retention с блокировкой параллельного запуска; ограничить Docker JSON-логи пятью файлами по 10 МБ на сервис.
|
||||||
- [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён).
|
- [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён).
|
||||||
- [x] Добавить Gitea Actions CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять логи Compose и Playwright-артефакты при падении (`.gitea/workflows/ci.yml`).
|
- [x] Добавить Gitea Actions CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять логи Compose и Playwright-артефакты при падении (`.gitea/workflows/ci.yml`).
|
||||||
- [x] Добавить отдельный тест полного bootstrap: пустые production volumes → миграции `0010` → seed без демо-уловов → readiness → браузерная отправка и проверка moderation API (`deploy/test-production-bootstrap.sh`, 6 сентября 2026).
|
- [x] Добавить отдельный тест полного bootstrap: пустые production volumes → миграции `0011` → seed без демо-уловов → readiness → браузерная отправка и проверка moderation API (`deploy/test-production-bootstrap.sh`, 7 сентября 2026).
|
||||||
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
|
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
|
||||||
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
|
- [x] Проверить списочные API по требованию раздела 12: все выдачи имеют ограниченные `limit`/`offset`, детерминированный tie-breaker и типизированные фильтры; фильтр категории рекордов перенесён до пагинации.
|
||||||
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота.
|
- [x] Добавить составные индексы PostgreSQL для activity, модерации, официальных рекордов, staging, импорта, аудита и очистки rate limit (миграция `0011`); бюджет и процедура проверки планов зафиксированы в `docs/query-performance.md`.
|
||||||
- [x] Провести security-проверку admin-аутентификации, CORS, headers, загрузок, контейнерных пользователей и секретов: двойная защита admin web/API, constant-time token, no-store, non-root API/web и отдельные MinIO root/app credentials; остаточные ограничения записаны в `docs/security-review.md`.
|
- [x] Провести security-проверку admin-аутентификации, CORS, headers, загрузок, контейнерных пользователей и секретов: двойная защита admin web/API, constant-time token, no-store, non-root API/web и отдельные MinIO root/app credentials; остаточные ограничения записаны в `docs/security-review.md`.
|
||||||
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
|
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
|
||||||
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
|
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
|
||||||
@@ -119,12 +119,10 @@
|
|||||||
|
|
||||||
Технический production-контур, health/readiness, backup/restore и безопасные логи готовы. Следующие пункты выполняются строго по одному:
|
Технический production-контур, health/readiness, backup/restore и безопасные логи готовы. Следующие пункты выполняются строго по одному:
|
||||||
|
|
||||||
1. защита официального импорта от конкурентных запусков;
|
1. UI/UX-пакеты B–D: мобильная главная, форма и рекорды;
|
||||||
2. пагинация/сортировка списочных API и индексы PostgreSQL;
|
2. accessibility/admin safety и Lighthouse;
|
||||||
3. UI/UX-пакеты B–D: мобильная главная, форма и рекорды;
|
3. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере;
|
||||||
4. accessibility/admin safety и Lighthouse;
|
4. финальное обновление README, лицензия кода и политика данных.
|
||||||
5. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере;
|
|
||||||
6. финальное обновление README, лицензия кода и политика данных.
|
|
||||||
|
|
||||||
После каждого пункта необходимо:
|
После каждого пункта необходимо:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Бюджет запросов для альфа-пилота
|
||||||
|
|
||||||
|
Дата фиксации: 7 сентября 2026 года.
|
||||||
|
|
||||||
|
## Контракт списочных API
|
||||||
|
|
||||||
|
Все списочные endpoint'ы принимают ограниченный `limit` и неотрицательный `offset`. Справочники ограничены 500 строками, публичные и административные журналы — 100–200 строками. Сортировка всегда имеет уникальный `id` последним ключом, поэтому соседние страницы не меняются местами при одинаковых датах или названиях. Значения перечислимых фильтров проверяет FastAPI; неподдерживаемое значение возвращает `422`.
|
||||||
|
|
||||||
|
Фильтры применяются в SQL до `offset` и `limit`. Это особенно важно для `GET /api/v1/records?category=...`: фильтрация JSON-поля после пагинации могла возвращать пустую страницу при наличии подходящих записей.
|
||||||
|
|
||||||
|
## Индексы
|
||||||
|
|
||||||
|
Миграция `0011` добавляет составные индексы для основных путей чтения:
|
||||||
|
|
||||||
|
- activity и лента точки — статус модерации, удаление, время и точка;
|
||||||
|
- очередь модерации — источник, статус, удаление и время;
|
||||||
|
- официальные рекорды — источник, дата и вес;
|
||||||
|
- журнал импорта — источник, статус и время запуска;
|
||||||
|
- staging — статус, система-источник и время последнего наблюдения;
|
||||||
|
- rate limit — отпечаток клиента и время попытки;
|
||||||
|
- retention аудита — время события модерации.
|
||||||
|
|
||||||
|
## Измеримый бюджет
|
||||||
|
|
||||||
|
На сервере альфа-пилота при объёме до 100 000 уловов и до 100 000 staging-наблюдений принимаются следующие server-side цели без учёта сети и браузерного рендера:
|
||||||
|
|
||||||
|
- p95 публичных списков и activity — не более 250 мс;
|
||||||
|
- p95 административных очередей — не более 500 мс;
|
||||||
|
- один запрос очистки rate limit или retention-пакет — не более 2 с;
|
||||||
|
- ни один интерактивный запрос не должен читать более 10 000 строк фактов по `Rows Removed by Filter`.
|
||||||
|
|
||||||
|
Это стартовый эксплуатационный бюджет, а не результат синтетического бенчмарка. Планы на пустой bootstrap-БД не показательны: PostgreSQL обоснованно выбирает последовательное чтение маленьких таблиц.
|
||||||
|
|
||||||
|
## Проверка после загрузки пилотных данных
|
||||||
|
|
||||||
|
После наполнения выполнить `EXPLAIN (ANALYZE, BUFFERS)` для activity, moderation queue, records, staging queue и удаления старых submission attempts. Проверять фактическое время, `Rows Removed by Filter`, объём buffers и соответствие выбранного индекса фильтрам. Если таблица превышает 10 000 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы.
|
||||||
|
|
||||||
|
Bootstrap-тест отдельно проверяет, что Alembic дошёл до `0011` и все восемь составных индексов созданы на чистой PostgreSQL.
|
||||||
Reference in New Issue
Block a user