Merge pull request #45 from JustinZeus/bugfix/broken-import

openalex cooldown
This commit is contained in:
JustinZeus 2026-03-03 16:17:39 +01:00 committed by GitHub
commit cfb1033110
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 118 additions and 70 deletions

View file

@ -23,7 +23,8 @@
"Bash(gh run list --branch openalex-size-reduction-scholar-hydration --limit 3 --json databaseId,status,conclusion,name,event)", "Bash(gh run list --branch openalex-size-reduction-scholar-hydration --limit 3 --json databaseId,status,conclusion,name,event)",
"Bash(gh run list --limit 10 --json databaseId,status,conclusion,name,event,headBranch)", "Bash(gh run list --limit 10 --json databaseId,status,conclusion,name,event,headBranch)",
"Bash(gh run view 22554585841 --log-failed)", "Bash(gh run view 22554585841 --log-failed)",
"Bash(gh run rerun 22554585841 --failed)" "Bash(gh run rerun 22554585841 --failed)",
"Bash(bash scripts/premerge.sh)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/home/jv/src/personal/playground/scholar_scraper/scholar-scraper/frontend/src" "/home/jv/src/personal/playground/scholar_scraper/scholar-scraper/frontend/src"

View file

@ -28,6 +28,7 @@ DATABASE_POOL_MODE=auto
DATABASE_POOL_SIZE=5 DATABASE_POOL_SIZE=5
DATABASE_POOL_MAX_OVERFLOW=10 DATABASE_POOL_MAX_OVERFLOW=10
DATABASE_POOL_TIMEOUT_SECONDS=30 DATABASE_POOL_TIMEOUT_SECONDS=30
DATABASE_RESERVED_API_CONNECTIONS=3
# ------------------------------ # ------------------------------
# Frontend Dev Overrides # Frontend Dev Overrides

View file

@ -221,11 +221,11 @@ def _spawn_background_execution(
user_settings: Any, user_settings: Any,
idempotency_key: str | None, idempotency_key: str | None,
) -> None: ) -> None:
from app.db.session import get_session_factory from app.db.background_session import background_session
task = asyncio.create_task( task = asyncio.create_task(
ingest_service.execute_run( ingest_service.execute_run(
session_factory=get_session_factory(), session_factory=background_session,
run_id=run.id, run_id=run.id,
user_id=current_user.id, user_id=current_user.id,
scholars=scholars, scholars=scholars,

View file

@ -0,0 +1,57 @@
"""Semaphore-gated DB sessions for background tasks.
Background tasks (ingestion, PDF resolution, scheduler) acquire a semaphore
permit before checking out a database connection. The semaphore cap is
``pool_size + max_overflow - reserved_for_api``, which guarantees that at
least *reserved_for_api* connections remain available for API request
handlers at all times.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_session_factory
from app.logging_utils import structured_log
from app.settings import settings
logger = logging.getLogger(__name__)
_semaphore: asyncio.Semaphore | None = None
def _build_semaphore() -> asyncio.Semaphore:
pool_capacity = max(1, settings.database_pool_size) + max(0, settings.database_pool_max_overflow)
reserved = max(0, settings.database_reserved_api_connections)
limit = max(1, pool_capacity - reserved)
structured_log(
logger,
"info",
"db.background_semaphore_initialized",
pool_capacity=pool_capacity,
reserved_for_api=reserved,
background_limit=limit,
)
return asyncio.Semaphore(limit)
def get_background_semaphore() -> asyncio.Semaphore:
global _semaphore
if _semaphore is None:
_semaphore = _build_semaphore()
return _semaphore
@asynccontextmanager
async def background_session() -> AsyncIterator[AsyncSession]:
"""Yield a DB session after acquiring the background semaphore."""
semaphore = get_background_semaphore()
async with semaphore:
session_factory = get_session_factory()
async with session_factory() as session:
yield session

View file

@ -5,8 +5,8 @@ from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from app.db.background_session import background_session
from app.db.models import QueueItemStatus, RunTriggerType, ScholarProfile, UserSetting from app.db.models import QueueItemStatus, RunTriggerType, ScholarProfile, UserSetting
from app.db.session import get_session_factory
from app.logging_utils import structured_log from app.logging_utils import structured_log
from app.services.ingestion import queue as queue_service from app.services.ingestion import queue as queue_service
from app.services.ingestion.application import ( from app.services.ingestion.application import (
@ -57,8 +57,7 @@ class QueueJobRunner:
async def drain_continuation_queue(self) -> None: async def drain_continuation_queue(self) -> None:
now = datetime.now(UTC) now = datetime.now(UTC)
session_factory = get_session_factory() async with background_session() as session:
async with session_factory() as session:
jobs = await queue_service.list_due_jobs( jobs = await queue_service.list_due_jobs(
session, session,
now=now, now=now,
@ -73,8 +72,7 @@ class QueueJobRunner:
) -> bool: ) -> bool:
if job.attempt_count < self._continuation_max_attempts: if job.attempt_count < self._continuation_max_attempts:
return False return False
session_factory = get_session_factory() async with background_session() as session:
async with session_factory() as session:
dropped = await queue_service.mark_dropped( dropped = await queue_service.mark_dropped(
session, session,
job_id=job.id, job_id=job.id,
@ -98,8 +96,7 @@ class QueueJobRunner:
self, self,
job: queue_service.ContinuationQueueJob, job: queue_service.ContinuationQueueJob,
) -> bool: ) -> bool:
session_factory = get_session_factory() async with background_session() as session:
async with session_factory() as session:
queue_item = await queue_service.mark_retrying(session, job_id=job.id) queue_item = await queue_service.mark_retrying(session, job_id=job.id)
await session.commit() await session.commit()
if queue_item is None: if queue_item is None:
@ -110,8 +107,7 @@ class QueueJobRunner:
self, self,
job: queue_service.ContinuationQueueJob, job: queue_service.ContinuationQueueJob,
) -> bool: ) -> bool:
session_factory = get_session_factory() async with background_session() as session:
async with session_factory() as session:
scholar_result = await session.execute( scholar_result = await session.execute(
select(ScholarProfile.id).where( select(ScholarProfile.id).where(
ScholarProfile.user_id == job.user_id, ScholarProfile.user_id == job.user_id,
@ -140,8 +136,7 @@ class QueueJobRunner:
return False return False
async def _reschedule_queue_job_lock_active(self, job: queue_service.ContinuationQueueJob) -> None: async def _reschedule_queue_job_lock_active(self, job: queue_service.ContinuationQueueJob) -> None:
session_factory = get_session_factory() async with background_session() as recovery_session:
async with session_factory() as recovery_session:
await queue_service.reschedule_job( await queue_service.reschedule_job(
recovery_session, recovery_session,
job_id=job.id, job_id=job.id,
@ -167,8 +162,7 @@ class QueueJobRunner:
self._tick_seconds, self._tick_seconds,
int(exc.safety_state.get("cooldown_remaining_seconds") or 0), int(exc.safety_state.get("cooldown_remaining_seconds") or 0),
) )
session_factory = get_session_factory() async with background_session() as recovery_session:
async with session_factory() as recovery_session:
await queue_service.reschedule_job( await queue_service.reschedule_job(
recovery_session, recovery_session,
job_id=job.id, job_id=job.id,
@ -193,8 +187,7 @@ class QueueJobRunner:
*, *,
exc: Exception, exc: Exception,
) -> None: ) -> None:
session_factory = get_session_factory() async with background_session() as recovery_session:
async with session_factory() as recovery_session:
queue_item = await queue_service.increment_attempt_count(recovery_session, job_id=job.id) queue_item = await queue_service.increment_attempt_count(recovery_session, job_id=job.id)
if queue_item is None: if queue_item is None:
await recovery_session.commit() await recovery_session.commit()
@ -238,8 +231,7 @@ class QueueJobRunner:
) )
async def _load_request_delay_for_user(self, user_id: int, *, floor: int) -> int: async def _load_request_delay_for_user(self, user_id: int, *, floor: int) -> int:
session_factory = get_session_factory() async with background_session() as session:
async with session_factory() as session:
result = await session.execute( result = await session.execute(
select(UserSetting.request_delay_seconds).where(UserSetting.user_id == user_id) select(UserSetting.request_delay_seconds).where(UserSetting.user_id == user_id)
) )
@ -253,8 +245,7 @@ class QueueJobRunner:
request_delay_floor: int, request_delay_floor: int,
): ):
request_delay_seconds = await self._load_request_delay_for_user(job.user_id, floor=request_delay_floor) request_delay_seconds = await self._load_request_delay_for_user(job.user_id, floor=request_delay_floor)
session_factory = get_session_factory() async with background_session() as session:
async with session_factory() as session:
ingestion = ScholarIngestionService(source=self._source) ingestion = ScholarIngestionService(source=self._source)
try: try:
return await ingestion.run_for_user( return await ingestion.run_for_user(
@ -366,8 +357,7 @@ class QueueJobRunner:
) )
async def _finalize_queue_job_after_run(self, job: queue_service.ContinuationQueueJob, run_summary) -> None: async def _finalize_queue_job_after_run(self, job: queue_service.ContinuationQueueJob, run_summary) -> None:
session_factory = get_session_factory() async with background_session() as session:
async with session_factory() as session:
if int(run_summary.failed_count) <= 0: if int(run_summary.failed_count) <= 0:
await self._finalize_successful_queue_job(session, job, run_summary) await self._finalize_successful_queue_job(session, job, run_summary)
else: else:

View file

@ -8,13 +8,13 @@ from typing import Any
from sqlalchemy import select from sqlalchemy import select
from app.db.background_session import background_session
from app.db.models import ( from app.db.models import (
CrawlRun, CrawlRun,
RunTriggerType, RunTriggerType,
User, User,
UserSetting, UserSetting,
) )
from app.db.session import get_session_factory
from app.logging_utils import structured_log from app.logging_utils import structured_log
from app.services.ingestion.application import ( from app.services.ingestion.application import (
RunAlreadyInProgressError, RunAlreadyInProgressError,
@ -148,8 +148,7 @@ class SchedulerService:
await self._run_candidate(candidate) await self._run_candidate(candidate)
async def _load_candidate_rows(self) -> list[Any]: async def _load_candidate_rows(self) -> list[Any]:
session_factory = get_session_factory() async with background_session() as session:
async with session_factory() as session:
result = await session.execute( result = await session.execute(
select( select(
UserSetting.user_id, UserSetting.user_id,
@ -204,8 +203,7 @@ class SchedulerService:
return candidates return candidates
async def _is_due(self, candidate: _AutoRunCandidate, *, now: datetime) -> bool: async def _is_due(self, candidate: _AutoRunCandidate, *, now: datetime) -> bool:
session_factory = get_session_factory() async with background_session() as session:
async with session_factory() as session:
result = await session.execute( result = await session.execute(
select(CrawlRun.start_dt) select(CrawlRun.start_dt)
.where( .where(
@ -227,8 +225,7 @@ class SchedulerService:
*, *,
candidate: _AutoRunCandidate, candidate: _AutoRunCandidate,
): ):
session_factory = get_session_factory() async with background_session() as session:
async with session_factory() as session:
ingestion = ScholarIngestionService(source=self._source) ingestion = ScholarIngestionService(source=self._source)
try: try:
return await ingestion.run_for_user( return await ingestion.run_for_user(
@ -289,9 +286,12 @@ class SchedulerService:
async def _drain_pdf_queue(self) -> None: async def _drain_pdf_queue(self) -> None:
from app.services.publications.pdf_queue import drain_ready_jobs from app.services.publications.pdf_queue import drain_ready_jobs
from app.services.publications.pdf_queue_resolution import is_budget_cooldown_active
session_factory = get_session_factory() if is_budget_cooldown_active():
async with session_factory() as session: return
async with background_session() as session:
try: try:
processed = await drain_ready_jobs( processed = await drain_ready_jobs(
session, session,

View file

@ -2,9 +2,10 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import UTC, datetime, timedelta
from app.db.background_session import background_session
from app.db.models import Publication, PublicationPdfJob from app.db.models import Publication, PublicationPdfJob
from app.db.session import get_session_factory
from app.logging_utils import structured_log from app.logging_utils import structured_log
from app.services.publication_identifiers import application as identifier_service from app.services.publication_identifiers import application as identifier_service
from app.services.publications.pdf_queue_common import ( from app.services.publications.pdf_queue_common import (
@ -29,8 +30,20 @@ PDF_EVENT_ATTEMPT_STARTED = "attempt_started"
PDF_EVENT_RESOLVED = "resolved" PDF_EVENT_RESOLVED = "resolved"
PDF_EVENT_FAILED = "failed" PDF_EVENT_FAILED = "failed"
_BUDGET_COOLDOWN_MINUTES = 15
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_scheduled_tasks: set[asyncio.Task[None]] = set() _scheduled_tasks: set[asyncio.Task[None]] = set()
_budget_cooldown_until: datetime | None = None
def is_budget_cooldown_active() -> bool:
return _budget_cooldown_until is not None and datetime.now(UTC) < _budget_cooldown_until
def _enter_budget_cooldown() -> None:
global _budget_cooldown_until
_budget_cooldown_until = datetime.now(UTC) + timedelta(minutes=_BUDGET_COOLDOWN_MINUTES)
async def _mark_attempt_started( async def _mark_attempt_started(
@ -38,8 +51,7 @@ async def _mark_attempt_started(
publication_id: int, publication_id: int,
user_id: int, user_id: int,
) -> None: ) -> None:
session_factory = get_session_factory() async with background_session() as db_session:
async with session_factory() as db_session:
job = await db_session.get(PublicationPdfJob, publication_id) job = await db_session.get(PublicationPdfJob, publication_id)
if job is None: if job is None:
job = queued_job(publication_id=publication_id, user_id=user_id) job = queued_job(publication_id=publication_id, user_id=user_id)
@ -125,8 +137,7 @@ async def _persist_outcome(
user_id: int, user_id: int,
outcome: OaResolutionOutcome, outcome: OaResolutionOutcome,
) -> None: ) -> None:
session_factory = get_session_factory() async with background_session() as db_session:
async with session_factory() as db_session:
publication = await db_session.get(Publication, publication_id) publication = await db_session.get(Publication, publication_id)
job = await db_session.get(PublicationPdfJob, publication_id) job = await db_session.get(PublicationPdfJob, publication_id)
if publication is None or job is None: if publication is None or job is None:
@ -207,8 +218,7 @@ async def _run_resolution_task(
openalex_api_key: str | None = None openalex_api_key: str | None = None
try: try:
session_factory = get_session_factory() async with background_session() as key_session:
async with session_factory() as key_session:
user_settings = await user_settings_service.get_or_create_settings(key_session, user_id=user_id) user_settings = await user_settings_service.get_or_create_settings(key_session, user_id=user_id)
openalex_api_key = getattr(user_settings, "openalex_api_key", None) or settings.openalex_api_key openalex_api_key = getattr(user_settings, "openalex_api_key", None) or settings.openalex_api_key
except Exception: except Exception:
@ -233,11 +243,13 @@ async def _run_resolution_task(
detail="arXiv temporarily disabled for remaining batch after rate limit", detail="arXiv temporarily disabled for remaining batch after rate limit",
) )
except OpenAlexBudgetExhaustedError: except OpenAlexBudgetExhaustedError:
_enter_budget_cooldown()
structured_log( structured_log(
logger, logger,
"warning", "warning",
"pdf_queue.budget_exhausted", "pdf_queue.budget_exhausted",
detail="Stopping PDF resolution batch — OpenAlex daily budget exhausted", detail="Stopping PDF resolution batch — OpenAlex daily budget exhausted",
cooldown_minutes=_BUDGET_COOLDOWN_MINUTES,
) )
break break
except Exception: except Exception:

View file

@ -275,6 +275,8 @@ class Settings:
crossref_max_lookups_per_request: int = _env_int("CROSSREF_MAX_LOOKUPS_PER_REQUEST", 8) crossref_max_lookups_per_request: int = _env_int("CROSSREF_MAX_LOOKUPS_PER_REQUEST", 8)
openalex_api_key: str | None = os.getenv("OPENALEX_API_KEY") openalex_api_key: str | None = os.getenv("OPENALEX_API_KEY")
database_reserved_api_connections: int = _env_int("DATABASE_RESERVED_API_CONNECTIONS", 3)
crossref_api_token: str | None = os.getenv("CROSSREF_API_TOKEN") crossref_api_token: str | None = os.getenv("CROSSREF_API_TOKEN")
crossref_api_mailto: str | None = os.getenv("CROSSREF_API_MAILTO") crossref_api_mailto: str | None = os.getenv("CROSSREF_API_MAILTO")

View file

@ -277,12 +277,16 @@ export function usePublicationData() {
// --- Run-triggered refresh watcher --- // --- Run-triggered refresh watcher ---
let previousRunStatusKey: string | null = null;
watch( watch(
() => runStatus.latestRun, () => runStatus.latestRun,
async (nextRun, previousRun) => { async (nextRun) => {
const nextRunId = nextRun && (nextRun.status === "running" || nextRun.status === "resolving") ? nextRun.id : null; const nextStatus = nextRun ? `${nextRun.id}:${nextRun.status}` : null;
const previousRunId = previousRun && (previousRun.status === "running" || previousRun.status === "resolving") ? previousRun.id : null; if (nextStatus === previousRunStatusKey) return;
if (nextRunId === null || nextRunId === previousRunId) return; previousRunStatusKey = nextStatus;
const isActive = nextRun && (nextRun.status === "running" || nextRun.status === "resolving");
if (!isActive) return;
resetPageAndSnapshot(); resetPageAndSnapshot();
await loadPublications(); await loadPublications();
}, },

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import AppPage from "@/components/layout/AppPage.vue"; import AppPage from "@/components/layout/AppPage.vue";
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue"; import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
@ -37,23 +37,6 @@ const selectedPublicationKeys = ref<Set<string>>(new Set());
const retryingPublicationKeys = ref<Set<string>>(new Set()); const retryingPublicationKeys = ref<Set<string>>(new Set());
const favoriteUpdatingKeys = ref<Set<string>>(new Set()); const favoriteUpdatingKeys = ref<Set<string>>(new Set());
const PUBLICATIONS_RUN_STATUS_SYNC_INTERVAL_MS = 5000;
let runStatusSyncTimer: ReturnType<typeof setInterval> | null = null;
function startRunStatusSyncLoop(): void {
if (runStatusSyncTimer !== null) return;
runStatusSyncTimer = setInterval(() => {
if (runStatus.isRunActive) return;
void runStatus.syncLatest();
}, PUBLICATIONS_RUN_STATUS_SYNC_INTERVAL_MS);
}
function stopRunStatusSyncLoop(): void {
if (runStatusSyncTimer === null) return;
clearInterval(runStatusSyncTimer);
runStatusSyncTimer = null;
}
// --- Computed --- // --- Computed ---
const selectedCount = computed(() => selectedPublicationKeys.value.size); const selectedCount = computed(() => selectedPublicationKeys.value.size);
@ -358,13 +341,8 @@ async function onStartRun(): Promise<void> {
onMounted(() => { onMounted(() => {
pub.syncFiltersFromRoute(); pub.syncFiltersFromRoute();
startRunStatusSyncLoop();
void Promise.all([pub.loadScholarFilters(), pub.loadPublications(), runStatus.syncLatest()]); void Promise.all([pub.loadScholarFilters(), pub.loadPublications(), runStatus.syncLatest()]);
}); });
onUnmounted(() => {
stopRunStatusSyncLoop();
});
</script> </script>
<template> <template>

View file

@ -9,7 +9,7 @@ import {
import { type PublicationItem } from "@/features/publications"; import { type PublicationItem } from "@/features/publications";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
export const RUN_STATUS_POLL_INTERVAL_MS = 5000; export const RUN_STATUS_POLL_INTERVAL_MS = 15000;
export const RUN_STATUS_STARTING_PHASE_MS = 1500; export const RUN_STATUS_STARTING_PHASE_MS = 1500;
export type StartManualCheckResult = export type StartManualCheckResult =

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import contextlib
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
@ -238,8 +239,10 @@ async def test_run_resolution_task_disables_arxiv_for_remaining_batch(
first = _row() first = _row()
second: Any = SimpleNamespace(**{**first.__dict__, "publication_id": 2}) second: Any = SimpleNamespace(**{**first.__dict__, "publication_id": 2})
def _raise_session_factory_error(): @contextlib.asynccontextmanager
async def _raise_background_session_error():
raise RuntimeError("skip user settings lookup in test") raise RuntimeError("skip user settings lookup in test")
yield # pragma: no cover
async def _fake_resolve_publication_row( async def _fake_resolve_publication_row(
*, *,
@ -253,7 +256,7 @@ async def test_run_resolution_task_disables_arxiv_for_remaining_batch(
calls.append((int(row.publication_id), bool(allow_arxiv_lookup))) calls.append((int(row.publication_id), bool(allow_arxiv_lookup)))
return row.publication_id == 1 return row.publication_id == 1
monkeypatch.setattr(pdf_queue_resolution, "get_session_factory", _raise_session_factory_error) monkeypatch.setattr(pdf_queue_resolution, "background_session", _raise_background_session_error)
monkeypatch.setattr(pdf_queue_resolution, "_resolve_publication_row", _fake_resolve_publication_row) monkeypatch.setattr(pdf_queue_resolution, "_resolve_publication_row", _fake_resolve_publication_row)
await pdf_queue_resolution._run_resolution_task( await pdf_queue_resolution._run_resolution_task(