203 lines
6.5 KiB
Python
203 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import httpx
|
|
from sqlalchemy import select, text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.models import ArxivRuntimeState
|
|
from app.db.session import get_session_factory
|
|
from app.logging_utils import structured_log
|
|
from app.services.arxiv.constants import (
|
|
ARXIV_RATE_LIMIT_LOCK_KEY,
|
|
ARXIV_RATE_LIMIT_LOCK_NAMESPACE,
|
|
ARXIV_RUNTIME_STATE_KEY,
|
|
ARXIV_SOURCE_PATH_UNKNOWN,
|
|
)
|
|
from app.services.arxiv.errors import ArxivRateLimitError
|
|
from app.settings import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ArxivCooldownStatus:
|
|
is_active: bool
|
|
remaining_seconds: float
|
|
cooldown_until: datetime | None
|
|
|
|
|
|
async def run_with_global_arxiv_limit(
|
|
*,
|
|
fetch: Callable[[], Awaitable[httpx.Response]],
|
|
source_path: str = ARXIV_SOURCE_PATH_UNKNOWN,
|
|
) -> httpx.Response:
|
|
response, hit_rate_limit = await _run_serialized_fetch(
|
|
fetch=fetch,
|
|
source_path=source_path,
|
|
)
|
|
if hit_rate_limit:
|
|
raise ArxivRateLimitError("arXiv rate limit hit (429) — stopping batch")
|
|
return response
|
|
|
|
|
|
async def get_arxiv_cooldown_status(*, now_utc: datetime | None = None) -> ArxivCooldownStatus:
|
|
timestamp = _normalize_datetime(now_utc) or datetime.now(UTC)
|
|
session_factory = get_session_factory()
|
|
async with session_factory() as db_session:
|
|
result = await db_session.execute(
|
|
select(ArxivRuntimeState.cooldown_until).where(ArxivRuntimeState.state_key == ARXIV_RUNTIME_STATE_KEY)
|
|
)
|
|
cooldown_until = _normalize_datetime(result.scalar_one_or_none())
|
|
remaining_seconds = _cooldown_remaining_seconds(cooldown_until, now_utc=timestamp)
|
|
return ArxivCooldownStatus(
|
|
is_active=remaining_seconds > 0.0,
|
|
remaining_seconds=float(remaining_seconds),
|
|
cooldown_until=cooldown_until,
|
|
)
|
|
|
|
|
|
async def _run_serialized_fetch(
|
|
*,
|
|
fetch: Callable[[], Awaitable[httpx.Response]],
|
|
source_path: str,
|
|
) -> tuple[httpx.Response, bool]:
|
|
session_factory = get_session_factory()
|
|
async with session_factory() as db_session, db_session.begin():
|
|
await _acquire_arxiv_lock(db_session)
|
|
runtime_state = await _load_runtime_state_for_update(db_session)
|
|
wait_seconds = await _wait_for_allowed_slot_or_raise(
|
|
runtime_state,
|
|
source_path=source_path,
|
|
)
|
|
response = await fetch()
|
|
hit_rate_limit = _record_post_response_state(
|
|
runtime_state,
|
|
response_status=int(response.status_code),
|
|
source_path=source_path,
|
|
)
|
|
structured_log(
|
|
logger,
|
|
"info",
|
|
"arxiv.request_completed",
|
|
status_code=int(response.status_code),
|
|
wait_seconds=wait_seconds,
|
|
cooldown_remaining_seconds=_cooldown_remaining_seconds(
|
|
runtime_state.cooldown_until, now_utc=datetime.now(UTC)
|
|
),
|
|
source_path=source_path,
|
|
)
|
|
return response, hit_rate_limit
|
|
|
|
|
|
async def _acquire_arxiv_lock(db_session: AsyncSession) -> None:
|
|
await db_session.execute(
|
|
text("SELECT pg_advisory_xact_lock(:namespace, :lock_key)"),
|
|
{
|
|
"namespace": ARXIV_RATE_LIMIT_LOCK_NAMESPACE,
|
|
"lock_key": ARXIV_RATE_LIMIT_LOCK_KEY,
|
|
},
|
|
)
|
|
|
|
|
|
async def _load_runtime_state_for_update(db_session: AsyncSession) -> ArxivRuntimeState:
|
|
result = await db_session.execute(
|
|
select(ArxivRuntimeState).where(ArxivRuntimeState.state_key == ARXIV_RUNTIME_STATE_KEY).with_for_update()
|
|
)
|
|
state = result.scalar_one_or_none()
|
|
if state is not None:
|
|
return state
|
|
state = ArxivRuntimeState(state_key=ARXIV_RUNTIME_STATE_KEY)
|
|
db_session.add(state)
|
|
await db_session.flush()
|
|
return state
|
|
|
|
|
|
async def _wait_for_allowed_slot_or_raise(
|
|
runtime_state: ArxivRuntimeState,
|
|
*,
|
|
source_path: str,
|
|
) -> float:
|
|
now_utc = datetime.now(UTC)
|
|
cooldown_seconds = _cooldown_remaining_seconds(runtime_state.cooldown_until, now_utc=now_utc)
|
|
if cooldown_seconds > 0:
|
|
structured_log(
|
|
logger,
|
|
"info",
|
|
"arxiv.request_scheduled",
|
|
wait_seconds=0.0,
|
|
source_path=source_path,
|
|
cooldown_remaining_seconds=cooldown_seconds,
|
|
)
|
|
raise ArxivRateLimitError(f"arXiv global cooldown active ({cooldown_seconds:.0f}s remaining)")
|
|
wait_seconds = _next_allowed_wait_seconds(runtime_state.next_allowed_at, now_utc=now_utc)
|
|
structured_log(
|
|
logger,
|
|
"info",
|
|
"arxiv.request_scheduled",
|
|
wait_seconds=wait_seconds,
|
|
source_path=source_path,
|
|
cooldown_remaining_seconds=0.0,
|
|
)
|
|
if wait_seconds > 0:
|
|
await asyncio.sleep(wait_seconds)
|
|
return wait_seconds
|
|
|
|
|
|
def _record_post_response_state(
|
|
runtime_state: ArxivRuntimeState,
|
|
*,
|
|
response_status: int,
|
|
source_path: str,
|
|
) -> bool:
|
|
now_utc = datetime.now(UTC)
|
|
runtime_state.next_allowed_at = now_utc + timedelta(seconds=_min_interval_seconds())
|
|
if response_status == 429:
|
|
cooldown_seconds = _cooldown_seconds()
|
|
runtime_state.cooldown_until = now_utc + timedelta(seconds=cooldown_seconds)
|
|
structured_log(
|
|
logger,
|
|
"warning",
|
|
"arxiv.cooldown_activated",
|
|
cooldown_remaining_seconds=cooldown_seconds,
|
|
source_path=source_path,
|
|
)
|
|
return True
|
|
if _cooldown_remaining_seconds(runtime_state.cooldown_until, now_utc=now_utc) <= 0:
|
|
runtime_state.cooldown_until = None
|
|
return False
|
|
|
|
|
|
def _cooldown_remaining_seconds(cooldown_until: datetime | None, *, now_utc: datetime) -> float:
|
|
bounded = _normalize_datetime(cooldown_until)
|
|
if bounded is None:
|
|
return 0.0
|
|
return max((bounded - now_utc).total_seconds(), 0.0)
|
|
|
|
|
|
def _next_allowed_wait_seconds(next_allowed_at: datetime | None, *, now_utc: datetime) -> float:
|
|
bounded = _normalize_datetime(next_allowed_at)
|
|
if bounded is None:
|
|
return 0.0
|
|
return max((bounded - now_utc).total_seconds(), 0.0)
|
|
|
|
|
|
def _normalize_datetime(value: datetime | None) -> datetime | None:
|
|
if value is None:
|
|
return None
|
|
if value.tzinfo is None:
|
|
return value.replace(tzinfo=UTC)
|
|
return value
|
|
|
|
|
|
def _min_interval_seconds() -> float:
|
|
return max(float(settings.arxiv_min_interval_seconds), 0.0)
|
|
|
|
|
|
def _cooldown_seconds() -> float:
|
|
return max(float(settings.arxiv_rate_limit_cooldown_seconds), 0.0)
|