refactor: extract helpers from long functions in ingestion core

- Extract _load_unenriched_publications, _enrich_batch, _flush_and_sweep_duplicates from enrich_pending_publications
- Extract _sanitize_titles shared helper for title cleaning
- Extract _run_iteration_and_complete and _inline_enrich_and_finalize from run_for_user
- Extract _run_first_pass and _run_depth_pass from run_scholar_iteration
- Extract _finalize_successful_queue_job and _finalize_failed_queue_job from _finalize_queue_job_after_run

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Justin Visser 2026-02-27 16:18:21 +01:00
parent 3fd9132176
commit 2a6e573a52
4 changed files with 345 additions and 235 deletions

View file

@ -576,6 +576,46 @@ class ScholarIngestionService:
alert_network_failure_threshold=alert_network_failure_threshold, alert_network_failure_threshold=alert_network_failure_threshold,
alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, alert_retry_scheduled_threshold=alert_retry_scheduled_threshold,
) )
progress, failure_summary, alert_summary = await self._run_iteration_and_complete(
db_session,
run=run,
scholars=scholars,
user_id=user_id,
start_cstart_map=start_cstart_map,
paging=paging,
thresholds=thresholds,
auto_queue_continuations=auto_queue_continuations,
queue_delay_seconds=queue_delay_seconds,
idempotency_key=idempotency_key,
)
user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=user_id)
await self._inline_enrich_and_finalize(
db_session, run=run, user_settings=user_settings, intended_final_status=run.status
)
_log_run_completed(
run=run,
user_id=user_id,
scholars=scholars,
progress=progress,
failure_summary=failure_summary,
alert_summary=alert_summary,
)
return run_execution_summary(run=run, scholars=scholars, progress=progress)
async def _run_iteration_and_complete(
self,
db_session: AsyncSession,
*,
run: CrawlRun,
scholars: list[ScholarProfile],
user_id: int,
start_cstart_map: dict[int, int],
paging: dict[str, Any],
thresholds: dict[str, Any],
auto_queue_continuations: bool,
queue_delay_seconds: int,
idempotency_key: str | None,
) -> tuple[RunProgress, RunFailureSummary, RunAlertSummary]:
progress = await run_scholar_iteration( progress = await run_scholar_iteration(
db_session, db_session,
pagination=self._pagination, pagination=self._pagination,
@ -601,6 +641,16 @@ class ScholarIngestionService:
if intended_final_status not in (RunStatus.CANCELED,): if intended_final_status not in (RunStatus.CANCELED,):
run.status = RunStatus.RESOLVING run.status = RunStatus.RESOLVING
await db_session.commit() await db_session.commit()
return progress, failure_summary, alert_summary
async def _inline_enrich_and_finalize(
self,
db_session: AsyncSession,
*,
run: CrawlRun,
user_settings: Any,
intended_final_status: RunStatus,
) -> None:
try: try:
await self._enrichment.enrich_pending_publications( await self._enrichment.enrich_pending_publications(
db_session, db_session,
@ -612,15 +662,6 @@ class ScholarIngestionService:
if run.status == RunStatus.RESOLVING: if run.status == RunStatus.RESOLVING:
run.status = intended_final_status run.status = intended_final_status
await db_session.commit() await db_session.commit()
_log_run_completed(
run=run,
user_id=user_id,
scholars=scholars,
progress=progress,
failure_summary=failure_summary,
alert_summary=alert_summary,
)
return run_execution_summary(run=run, scholars=scholars, progress=progress)
async def _try_acquire_user_lock( async def _try_acquire_user_lock(
self, self,

View file

@ -25,6 +25,18 @@ from app.settings import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _sanitize_titles(publications: list) -> list[str]:
titles = []
for p in publications:
raw = getattr(p, "title_raw", None) or getattr(p, "title", None)
if not raw or not raw.strip():
continue
safe = " ".join(re.sub(r"[^\w\s]", " ", raw).split())
if safe:
titles.append(safe)
return titles
class EnrichmentRunner: class EnrichmentRunner:
"""Post-run OpenAlex enrichment logic. """Post-run OpenAlex enrichment logic.
@ -44,31 +56,15 @@ class EnrichmentRunner:
raise RuntimeError(f"Missing crawl_run for run_id={run_id}.") raise RuntimeError(f"Missing crawl_run for run_id={run_id}.")
return status == RunStatus.CANCELED return status == RunStatus.CANCELED
async def enrich_pending_publications( async def _load_unenriched_publications(
self, self,
db_session: AsyncSession, db_session: AsyncSession,
*, *,
run_id: int, run_id: int,
openalex_api_key: str | None = None, ) -> tuple[int, list[Publication]]:
) -> None:
"""Enrich unenriched publications with OpenAlex data.
Stops immediately on budget exhaustion (429 with $0 remaining).
Sleeps 60s and continues on transient rate limits.
"""
from app.services.openalex.client import (
OpenAlexBudgetExhaustedError,
OpenAlexClient,
OpenAlexRateLimitError,
)
from app.services.openalex.matching import find_best_match
run_result = await db_session.execute(select(CrawlRun.user_id).where(CrawlRun.id == run_id)) run_result = await db_session.execute(select(CrawlRun.user_id).where(CrawlRun.id == run_id))
user_id = run_result.scalar_one() user_id = run_result.scalar_one()
cooldown_threshold = datetime.now(UTC) - timedelta(days=7)
now = datetime.now(UTC)
cooldown_threshold = now - timedelta(days=7)
stmt = ( stmt = (
select(Publication) select(Publication)
.join(ScholarPublication) .join(ScholarPublication)
@ -84,88 +80,51 @@ class EnrichmentRunner:
.distinct() .distinct()
) )
result = await db_session.execute(stmt) result = await db_session.execute(stmt)
publications = list(result.scalars().all()) return user_id, list(result.scalars().all())
if not publications: async def _enrich_batch(
return self,
db_session: AsyncSession,
*,
batch: list[Publication],
run_id: int,
openalex_works: list,
now: datetime,
arxiv_lookup_allowed: bool,
) -> tuple[bool, bool]:
from app.services.openalex.matching import find_best_match
resolved_key = openalex_api_key or settings.openalex_api_key for p in batch:
client = OpenAlexClient(api_key=resolved_key, mailto=settings.crossref_api_mailto)
batch_size = 25
arxiv_lookup_allowed = True
for i in range(0, len(publications), batch_size):
if await self.run_is_canceled(db_session, run_id=run_id): if await self.run_is_canceled(db_session, run_id=run_id):
logger.info("ingestion.enrichment_aborted", extra={"run_id": run_id}) logger.info("ingestion.enrichment_aborted", extra={"run_id": run_id})
return return False, arxiv_lookup_allowed
batch = publications[i : i + batch_size] p.openalex_last_attempt_at = now
titles = [ arxiv_lookup_allowed = await self._discover_identifiers_for_enrichment(
" ".join(re.sub(r"[^\w\s]", " ", p.title_raw).split()) db_session,
for p in batch publication=p,
if p.title_raw and p.title_raw.strip() run_id=run_id,
] allow_arxiv_lookup=arxiv_lookup_allowed,
)
if not titles: match = find_best_match(
continue target_title=p.title_raw,
target_year=p.year,
try: target_authors=p.author_text or "",
openalex_works = await client.get_works_by_filter( candidates=openalex_works,
{"title.search": "|".join(titles)}, limit=batch_size * 3 )
) if match:
except OpenAlexBudgetExhaustedError: p.year = match.publication_year or p.year
structured_log( p.citation_count = match.cited_by_count or p.citation_count
logger, p.pdf_url = match.oa_url or p.pdf_url
"warning", p.openalex_enriched = True
"ingestion.openalex_budget_exhausted", return True, arxiv_lookup_allowed
run_id=run_id,
)
break
except OpenAlexRateLimitError:
structured_log(
logger,
"warning",
"ingestion.openalex_rate_limited",
run_id=run_id,
)
await asyncio.sleep(60)
continue
except Exception as e:
structured_log(
logger,
"warning",
"ingestion.openalex_enrichment_failed",
error=str(e),
run_id=run_id,
)
continue
for p in batch:
if await self.run_is_canceled(db_session, run_id=run_id):
logger.info("ingestion.enrichment_aborted", extra={"run_id": run_id})
return
p.openalex_last_attempt_at = now
arxiv_lookup_allowed = await self._discover_identifiers_for_enrichment(
db_session,
publication=p,
run_id=run_id,
allow_arxiv_lookup=arxiv_lookup_allowed,
)
match = find_best_match(
target_title=p.title_raw,
target_year=p.year,
target_authors=p.author_text or "",
candidates=openalex_works,
)
if match:
p.year = match.publication_year or p.year
p.citation_count = match.cited_by_count or p.citation_count
p.pdf_url = match.oa_url or p.pdf_url
p.openalex_enriched = True
async def _flush_and_sweep_duplicates(
self,
db_session: AsyncSession,
*,
run_id: int,
) -> None:
await db_session.flush() await db_session.flush()
from app.services.publications.dedup import sweep_identifier_duplicates from app.services.publications.dedup import sweep_identifier_duplicates
merge_count = await sweep_identifier_duplicates(db_session) merge_count = await sweep_identifier_duplicates(db_session)
@ -178,6 +137,64 @@ class EnrichmentRunner:
run_id=run_id, run_id=run_id,
) )
async def enrich_pending_publications(
self,
db_session: AsyncSession,
*,
run_id: int,
openalex_api_key: str | None = None,
) -> None:
from app.services.openalex.client import (
OpenAlexBudgetExhaustedError,
OpenAlexClient,
OpenAlexRateLimitError,
)
_, publications = await self._load_unenriched_publications(db_session, run_id=run_id)
if not publications:
return
resolved_key = openalex_api_key or settings.openalex_api_key
client = OpenAlexClient(api_key=resolved_key, mailto=settings.crossref_api_mailto)
batch_size = 25
now = datetime.now(UTC)
arxiv_lookup_allowed = True
for i in range(0, len(publications), batch_size):
if await self.run_is_canceled(db_session, run_id=run_id):
logger.info("ingestion.enrichment_aborted", extra={"run_id": run_id})
return
batch = publications[i : i + batch_size]
titles = _sanitize_titles(batch)
if not titles:
continue
try:
openalex_works = await client.get_works_by_filter(
{"title.search": "|".join(titles)}, limit=batch_size * 3
)
except OpenAlexBudgetExhaustedError:
structured_log(logger, "warning", "ingestion.openalex_budget_exhausted", run_id=run_id)
break
except OpenAlexRateLimitError:
structured_log(logger, "warning", "ingestion.openalex_rate_limited", run_id=run_id)
await asyncio.sleep(60)
continue
except Exception as e:
structured_log(logger, "warning", "ingestion.openalex_enrichment_failed", error=str(e), run_id=run_id)
continue
should_continue, arxiv_lookup_allowed = await self._enrich_batch(
db_session,
batch=batch,
run_id=run_id,
openalex_works=openalex_works,
now=now,
arxiv_lookup_allowed=arxiv_lookup_allowed,
)
if not should_continue:
return
await self._flush_and_sweep_duplicates(db_session, run_id=run_id)
async def _discover_identifiers_for_enrichment( async def _discover_identifiers_for_enrichment(
self, self,
db_session: AsyncSession, db_session: AsyncSession,
@ -275,23 +292,15 @@ class EnrichmentRunner:
for i in range(0, len(publications), batch_size): for i in range(0, len(publications), batch_size):
batch = publications[i : i + batch_size] batch = publications[i : i + batch_size]
titles = _sanitize_titles(batch)
titles = []
for p in batch:
if not p.title:
continue
safe_title = re.sub(r"[^\w\s]", " ", p.title)
safe_title = " ".join(safe_title.split())
if safe_title:
titles.append(safe_title)
if not titles: if not titles:
enriched.extend(batch) enriched.extend(batch)
continue continue
query = "|".join(t for t in titles)
try: try:
openalex_works = await client.get_works_by_filter({"title.search": query}, limit=batch_size * 3) openalex_works = await client.get_works_by_filter(
{"title.search": "|".join(titles)}, limit=batch_size * 3
)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"ingestion.openalex_enrichment_failed", "ingestion.openalex_enrichment_failed",

View file

@ -281,74 +281,72 @@ class QueueJobRunner:
await self._reschedule_queue_job_after_exception(job, exc=exc) await self._reschedule_queue_job_after_exception(job, exc=exc)
return None return None
async def _finalize_queue_job_after_run(self, job: queue_service.ContinuationQueueJob, run_summary) -> None: async def _finalize_successful_queue_job(self, session, job, run_summary) -> None:
session_factory = get_session_factory() queue_item = await queue_service.reset_attempt_count(session, job_id=job.id)
async with session_factory() as session: await session.commit()
if int(run_summary.failed_count) <= 0: if queue_item is None:
queue_item = await queue_service.reset_attempt_count(session, job_id=job.id) structured_log(
await session.commit() logger,
if queue_item is None: "info",
structured_log( "scheduler.queue_item_resolved",
logger, queue_item_id=job.id,
"info", user_id=job.user_id,
"scheduler.queue_item_resolved", run_id=run_summary.crawl_run_id,
queue_item_id=job.id, status=run_summary.status.value,
user_id=job.user_id,
run_id=run_summary.crawl_run_id,
status=run_summary.status.value,
)
return
structured_log(
logger,
"info",
"scheduler.queue_item_progressed",
queue_item_id=job.id,
user_id=job.user_id,
run_id=run_summary.crawl_run_id,
status=run_summary.status.value,
attempt_count=int(queue_item.attempt_count),
)
return
queue_item = await queue_service.increment_attempt_count(session, job_id=job.id)
if queue_item is None:
await session.commit()
structured_log(
logger,
"info",
"scheduler.queue_item_resolved",
queue_item_id=job.id,
user_id=job.user_id,
run_id=run_summary.crawl_run_id,
status=run_summary.status.value,
)
return
if int(queue_item.attempt_count) >= self._continuation_max_attempts:
await queue_service.mark_dropped(session, job_id=job.id, reason="max_attempts_after_run")
await session.commit()
structured_log(
logger,
"warning",
"scheduler.queue_item_dropped_max_attempts_after_run",
queue_item_id=job.id,
user_id=job.user_id,
attempt_count=queue_item.attempt_count,
run_id=run_summary.crawl_run_id,
status=run_summary.status.value,
)
return
delay_seconds = queue_service.compute_backoff_seconds(
base_seconds=self._continuation_base_delay_seconds,
attempt_count=int(queue_item.attempt_count),
max_seconds=self._continuation_max_delay_seconds,
)
await queue_service.reschedule_job(
session,
job_id=job.id,
delay_seconds=delay_seconds,
reason=queue_item.reason,
error=queue_item.last_error,
) )
return
structured_log(
logger,
"info",
"scheduler.queue_item_progressed",
queue_item_id=job.id,
user_id=job.user_id,
run_id=run_summary.crawl_run_id,
status=run_summary.status.value,
attempt_count=int(queue_item.attempt_count),
)
async def _finalize_failed_queue_job(self, session, job, run_summary) -> None:
queue_item = await queue_service.increment_attempt_count(session, job_id=job.id)
if queue_item is None:
await session.commit() await session.commit()
structured_log(
logger,
"info",
"scheduler.queue_item_resolved",
queue_item_id=job.id,
user_id=job.user_id,
run_id=run_summary.crawl_run_id,
status=run_summary.status.value,
)
return
if int(queue_item.attempt_count) >= self._continuation_max_attempts:
await queue_service.mark_dropped(session, job_id=job.id, reason="max_attempts_after_run")
await session.commit()
structured_log(
logger,
"warning",
"scheduler.queue_item_dropped_max_attempts_after_run",
queue_item_id=job.id,
user_id=job.user_id,
attempt_count=queue_item.attempt_count,
run_id=run_summary.crawl_run_id,
status=run_summary.status.value,
)
return
delay_seconds = queue_service.compute_backoff_seconds(
base_seconds=self._continuation_base_delay_seconds,
attempt_count=int(queue_item.attempt_count),
max_seconds=self._continuation_max_delay_seconds,
)
await queue_service.reschedule_job(
session,
job_id=job.id,
delay_seconds=delay_seconds,
reason=queue_item.reason,
error=queue_item.last_error,
)
await session.commit()
structured_log( structured_log(
logger, logger,
"info", "info",
@ -361,6 +359,14 @@ class QueueJobRunner:
delay_seconds=delay_seconds, delay_seconds=delay_seconds,
) )
async def _finalize_queue_job_after_run(self, job: queue_service.ContinuationQueueJob, run_summary) -> None:
session_factory = get_session_factory()
async with session_factory() as session:
if int(run_summary.failed_count) <= 0:
await self._finalize_successful_queue_job(session, job, run_summary)
else:
await self._finalize_failed_queue_job(session, job, run_summary)
async def _run_queue_job(self, job: queue_service.ContinuationQueueJob) -> None: async def _run_queue_job(self, job: queue_service.ContinuationQueueJob) -> None:
if await self._drop_queue_job_if_max_attempts(job): if await self._drop_queue_job_if_max_attempts(job):
return return

View file

@ -528,6 +528,87 @@ def unexpected_scholar_exception_outcome(
) )
async def _run_first_pass(
db_session: AsyncSession,
*,
scholars: list[ScholarProfile],
pagination: PaginationEngine,
run: CrawlRun,
user_id: int,
start_cstart_map: dict[int, int],
scholar_kwargs: dict[str, Any],
request_delay_seconds: int,
queue_delay_seconds: int,
progress: RunProgress,
) -> dict[int, int]:
first_pass_cstarts: dict[int, int] = {}
for index, scholar in enumerate(scholars):
await db_session.refresh(run)
if run.status == RunStatus.CANCELED:
structured_log(logger, "info", "ingestion.run_canceled", run_id=run.id, user_id=user_id)
return first_pass_cstarts
if index > 0 and request_delay_seconds > 0:
await asyncio.sleep(float(request_delay_seconds))
start_cstart = int(start_cstart_map.get(int(scholar.id), 0))
outcome = await process_scholar(
db_session,
pagination=pagination,
run=run,
scholar=scholar,
user_id=user_id,
start_cstart=start_cstart,
max_pages_per_scholar=1,
auto_queue_continuations=False,
queue_delay_seconds=queue_delay_seconds,
**scholar_kwargs,
)
apply_outcome_to_progress(progress=progress, outcome=outcome)
resume_cstart = outcome.result_entry.get("continuation_cstart")
if resume_cstart is not None and int(resume_cstart) > start_cstart:
first_pass_cstarts[int(scholar.id)] = int(resume_cstart)
return first_pass_cstarts
async def _run_depth_pass(
db_session: AsyncSession,
*,
scholars: list[ScholarProfile],
first_pass_cstarts: dict[int, int],
pagination: PaginationEngine,
run: CrawlRun,
user_id: int,
scholar_kwargs: dict[str, Any],
request_delay_seconds: int,
remaining_max: int,
auto_queue_continuations: bool,
queue_delay_seconds: int,
progress: RunProgress,
) -> None:
for index, scholar in enumerate(scholars):
resume_cstart = first_pass_cstarts.get(int(scholar.id))
if resume_cstart is None:
continue
await db_session.refresh(run)
if run.status == RunStatus.CANCELED:
structured_log(logger, "info", "ingestion.run_canceled", run_id=run.id, user_id=user_id)
break
if index > 0 and request_delay_seconds > 0:
await asyncio.sleep(float(request_delay_seconds))
outcome = await process_scholar(
db_session,
pagination=pagination,
run=run,
scholar=scholar,
user_id=user_id,
start_cstart=resume_cstart,
max_pages_per_scholar=remaining_max,
auto_queue_continuations=auto_queue_continuations,
queue_delay_seconds=queue_delay_seconds,
**scholar_kwargs,
)
apply_outcome_to_progress(progress=progress, outcome=outcome)
async def run_scholar_iteration( async def run_scholar_iteration(
db_session: AsyncSession, db_session: AsyncSession,
*, *,
@ -555,60 +636,33 @@ async def run_scholar_iteration(
"rate_limit_backoff_seconds": rate_limit_backoff_seconds, "rate_limit_backoff_seconds": rate_limit_backoff_seconds,
"page_size": page_size, "page_size": page_size,
} }
first_pass_cstarts = await _run_first_pass(
# ── Pass 1: first page of every scholar (breadth-first) ────────── db_session,
first_pass_cstarts: dict[int, int] = {} scholars=scholars,
for index, scholar in enumerate(scholars): pagination=pagination,
await db_session.refresh(run) run=run,
if run.status == RunStatus.CANCELED: user_id=user_id,
structured_log(logger, "info", "ingestion.run_canceled", run_id=run.id, user_id=user_id) start_cstart_map=start_cstart_map,
return progress scholar_kwargs=scholar_kwargs,
if index > 0 and request_delay_seconds > 0: request_delay_seconds=request_delay_seconds,
await asyncio.sleep(float(request_delay_seconds)) queue_delay_seconds=queue_delay_seconds,
start_cstart = int(start_cstart_map.get(int(scholar.id), 0)) progress=progress,
outcome = await process_scholar( )
db_session,
pagination=pagination,
run=run,
scholar=scholar,
user_id=user_id,
start_cstart=start_cstart,
max_pages_per_scholar=1,
auto_queue_continuations=False,
queue_delay_seconds=queue_delay_seconds,
**scholar_kwargs,
)
apply_outcome_to_progress(progress=progress, outcome=outcome)
resume_cstart = outcome.result_entry.get("continuation_cstart")
if resume_cstart is not None and int(resume_cstart) > start_cstart:
first_pass_cstarts[int(scholar.id)] = int(resume_cstart)
# ── Pass 2: remaining pages for each scholar (depth) ─────────────
remaining_max = max(max_pages_per_scholar - 1, 0) remaining_max = max(max_pages_per_scholar - 1, 0)
if remaining_max <= 0: if remaining_max <= 0:
return progress return progress
await _run_depth_pass(
for index, scholar in enumerate(scholars): db_session,
resume_cstart = first_pass_cstarts.get(int(scholar.id)) scholars=scholars,
if resume_cstart is None: first_pass_cstarts=first_pass_cstarts,
continue pagination=pagination,
await db_session.refresh(run) run=run,
if run.status == RunStatus.CANCELED: user_id=user_id,
structured_log(logger, "info", "ingestion.run_canceled", run_id=run.id, user_id=user_id) scholar_kwargs=scholar_kwargs,
break request_delay_seconds=request_delay_seconds,
if index > 0 and request_delay_seconds > 0: remaining_max=remaining_max,
await asyncio.sleep(float(request_delay_seconds)) auto_queue_continuations=auto_queue_continuations,
outcome = await process_scholar( queue_delay_seconds=queue_delay_seconds,
db_session, progress=progress,
pagination=pagination, )
run=run,
scholar=scholar,
user_id=user_id,
start_cstart=resume_cstart,
max_pages_per_scholar=remaining_max,
auto_queue_continuations=auto_queue_continuations,
queue_delay_seconds=queue_delay_seconds,
**scholar_kwargs,
)
apply_outcome_to_progress(progress=progress, outcome=outcome)
return progress return progress