Merge pull request #45 from JustinZeus/bugfix/broken-import
openalex cooldown
This commit is contained in:
commit
cfb1033110
12 changed files with 118 additions and 70 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
57
app/db/background_session.py
Normal file
57
app/db/background_session.py
Normal 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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue