Feat/decomposition #19

Merged
JustinZeus merged 38 commits from feat/decomposition into main 2026-03-01 22:55:33 +01:00
4 changed files with 156 additions and 87 deletions
Showing only changes of commit 72bc046215 - Show all commits

View file

@ -176,6 +176,67 @@ async def cancel_run(
) )
async def _start_manual_run(
db_session: AsyncSession,
*,
current_user: User,
ingest_service: ingestion_service.ScholarIngestionService,
idempotency_key: str | None,
) -> tuple[Any, Any, list, dict]:
user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=current_user.id)
run, scholars, start_cstart_map = await ingest_service.initialize_run(
db_session,
user_id=current_user.id,
trigger_type=RunTriggerType.MANUAL,
request_delay_seconds=user_settings.request_delay_seconds,
network_error_retries=settings.ingestion_network_error_retries,
retry_backoff_seconds=settings.ingestion_retry_backoff_seconds,
max_pages_per_scholar=settings.ingestion_max_pages_per_scholar,
page_size=settings.ingestion_page_size,
idempotency_key=idempotency_key,
alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold,
alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold,
alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold,
)
return user_settings, run, scholars, start_cstart_map
def _spawn_background_execution(
ingest_service: ingestion_service.ScholarIngestionService,
*,
run: Any,
current_user: User,
scholars: list,
start_cstart_map: dict,
user_settings: Any,
idempotency_key: str | None,
) -> None:
from app.db.session import get_session_factory
task = asyncio.create_task(
ingest_service.execute_run(
session_factory=get_session_factory(),
run_id=run.id,
user_id=current_user.id,
scholars=scholars,
start_cstart_map=start_cstart_map,
request_delay_seconds=user_settings.request_delay_seconds,
network_error_retries=settings.ingestion_network_error_retries,
retry_backoff_seconds=settings.ingestion_retry_backoff_seconds,
max_pages_per_scholar=settings.ingestion_max_pages_per_scholar,
page_size=settings.ingestion_page_size,
auto_queue_continuations=settings.ingestion_continuation_queue_enabled,
queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds,
alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold,
alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold,
alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold,
idempotency_key=idempotency_key,
)
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
@router.post( @router.post(
"/manual", "/manual",
response_model=ManualRunEnvelope, response_model=ManualRunEnvelope,
@ -202,52 +263,22 @@ async def run_manual(
return reused_payload return reused_payload
try: try:
user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=current_user.id) user_settings, run, scholars, start_cstart_map = await _start_manual_run(
# Initialize run (creates the record and performs safety checks)
run, scholars, start_cstart_map = await ingest_service.initialize_run(
db_session, db_session,
user_id=current_user.id, current_user=current_user,
trigger_type=RunTriggerType.MANUAL, ingest_service=ingest_service,
request_delay_seconds=user_settings.request_delay_seconds,
network_error_retries=settings.ingestion_network_error_retries,
retry_backoff_seconds=settings.ingestion_retry_backoff_seconds,
max_pages_per_scholar=settings.ingestion_max_pages_per_scholar,
page_size=settings.ingestion_page_size,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold,
alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold,
alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold,
) )
await db_session.commit() await db_session.commit()
_spawn_background_execution(
# Kick off background execution ingest_service,
from app.db.session import get_session_factory run=run,
current_user=current_user,
task = asyncio.create_task(
ingest_service.execute_run(
session_factory=get_session_factory(),
run_id=run.id,
user_id=current_user.id,
scholars=scholars, scholars=scholars,
start_cstart_map=start_cstart_map, start_cstart_map=start_cstart_map,
request_delay_seconds=user_settings.request_delay_seconds, user_settings=user_settings,
network_error_retries=settings.ingestion_network_error_retries,
retry_backoff_seconds=settings.ingestion_retry_backoff_seconds,
max_pages_per_scholar=settings.ingestion_max_pages_per_scholar,
page_size=settings.ingestion_page_size,
auto_queue_continuations=settings.ingestion_continuation_queue_enabled,
queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds,
alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold,
alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold,
alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
) )
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return success_payload( return success_payload(
request, request,
data={ data={

View file

@ -145,6 +145,24 @@ class PageFetcher:
if sleep_seconds > 0: if sleep_seconds > 0:
await asyncio.sleep(sleep_seconds) await asyncio.sleep(sleep_seconds)
@staticmethod
def _classify_attempt(
parsed_page: ParsedProfilePage,
*,
network_attempts: int,
rate_limit_attempts: int,
) -> tuple[int, int, int]:
if parsed_page.state == ParseState.NETWORK_ERROR:
network_attempts += 1
return network_attempts, rate_limit_attempts, network_attempts
if (
parsed_page.state == ParseState.BLOCKED_OR_CAPTCHA
and parsed_page.state_reason == "blocked_http_429_rate_limited"
):
rate_limit_attempts += 1
return network_attempts, rate_limit_attempts, rate_limit_attempts
return network_attempts, rate_limit_attempts, network_attempts + rate_limit_attempts + 1
async def fetch_and_parse_with_retry( async def fetch_and_parse_with_retry(
self, self,
*, *,
@ -169,19 +187,9 @@ class PageFetcher:
page_size=page_size, page_size=page_size,
) )
parsed_page = self.parse_page_or_layout_error(fetch_result=fetch_result) parsed_page = self.parse_page_or_layout_error(fetch_result=fetch_result)
network_attempts, rate_limit_attempts, total_attempts = self._classify_attempt(
if parsed_page.state == ParseState.NETWORK_ERROR: parsed_page, network_attempts=network_attempts, rate_limit_attempts=rate_limit_attempts
network_attempts += 1 )
total_attempts = network_attempts
elif (
parsed_page.state == ParseState.BLOCKED_OR_CAPTCHA
and parsed_page.state_reason == "blocked_http_429_rate_limited"
):
rate_limit_attempts += 1
total_attempts = rate_limit_attempts
else:
total_attempts = network_attempts + rate_limit_attempts + 1
attempt_log.append( attempt_log.append(
{ {
"attempt": total_attempts, "attempt": total_attempts,
@ -192,7 +200,6 @@ class PageFetcher:
"fetch_error": fetch_result.error, "fetch_error": fetch_result.error,
} }
) )
if not self._should_retry( if not self._should_retry(
parsed_page=parsed_page, parsed_page=parsed_page,
network_attempt_count=network_attempts, network_attempt_count=network_attempts,
@ -201,7 +208,6 @@ class PageFetcher:
rate_limit_retries=rate_limit_retries, rate_limit_retries=rate_limit_retries,
): ):
break break
await self._sleep_backoff( await self._sleep_backoff(
scholar_id=scholar_id, scholar_id=scholar_id,
cstart=cstart, cstart=cstart,

View file

@ -273,6 +273,24 @@ class PaginationEngine:
# ── Pagination loop ────────────────────────────────────────────── # ── Pagination loop ──────────────────────────────────────────────
@staticmethod
async def _upsert_page_publications(
db_session: Any,
*,
run: CrawlRun,
scholar: ScholarProfile,
publications: list,
seen_canonical: set[str],
state: PagedLoopState,
upsert_publications_fn: Any,
) -> None:
deduped = _dedupe_publication_candidates(list(publications), seen_canonical=seen_canonical)
if deduped:
discovered_count = await upsert_publications_fn(
db_session, run=run, scholar=scholar, publications=deduped
)
state.discovered_publication_count += discovered_count
async def _paginate_loop( async def _paginate_loop(
self, self,
*, *,
@ -292,14 +310,15 @@ class PaginationEngine:
seen_canonical: set[str] = set() seen_canonical: set[str] = set()
if state.parsed_page.publications: if state.parsed_page.publications:
deduped_first = _dedupe_publication_candidates( await self._upsert_page_publications(
list(state.parsed_page.publications), seen_canonical=seen_canonical db_session,
run=run,
scholar=scholar,
publications=state.parsed_page.publications,
seen_canonical=seen_canonical,
state=state,
upsert_publications_fn=upsert_publications_fn,
) )
if deduped_first:
discovered_count = await upsert_publications_fn(
db_session, run=run, scholar=scholar, publications=deduped_first
)
state.discovered_publication_count += discovered_count
while state.parsed_page.has_show_more_button: while state.parsed_page.has_show_more_button:
await db_session.refresh(run) await db_session.refresh(run)
@ -337,14 +356,15 @@ class PaginationEngine:
) )
if next_parsed_page.publications: if next_parsed_page.publications:
deduped_next = _dedupe_publication_candidates( await self._upsert_page_publications(
list(next_parsed_page.publications), seen_canonical=seen_canonical db_session,
run=run,
scholar=scholar,
publications=next_parsed_page.publications,
seen_canonical=seen_canonical,
state=state,
upsert_publications_fn=upsert_publications_fn,
) )
if deduped_next:
discovered_count = await upsert_publications_fn(
db_session, run=run, scholar=scholar, publications=deduped_next
)
state.discovered_publication_count += discovered_count
if self._handle_page_state_transition(state=state): if self._handle_page_state_transition(state=state):
return return

View file

@ -262,25 +262,13 @@ def build_failure_debug_context(
return context return context
def complete_run_for_user( def _log_alert_threshold_warnings(
*, *,
user_settings: Any,
run: CrawlRun,
scholars: list[ScholarProfile],
user_id: int, user_id: int,
progress: RunProgress, run: CrawlRun,
idempotency_key: str | None, failure_summary: RunFailureSummary,
alert_blocked_failure_threshold: int, alert_summary: RunAlertSummary,
alert_network_failure_threshold: int, ) -> None:
alert_retry_scheduled_threshold: int,
) -> tuple[RunFailureSummary, RunAlertSummary]:
failure_summary = summarize_failures(scholar_results=progress.scholar_results)
alert_summary = build_alert_summary(
failure_summary=failure_summary,
alert_blocked_failure_threshold=alert_blocked_failure_threshold,
alert_network_failure_threshold=alert_network_failure_threshold,
alert_retry_scheduled_threshold=alert_retry_scheduled_threshold,
)
if alert_summary.alert_flags["blocked_failure_threshold_exceeded"]: if alert_summary.alert_flags["blocked_failure_threshold_exceeded"]:
structured_log( structured_log(
logger, logger,
@ -311,6 +299,30 @@ def complete_run_for_user(
retries_scheduled_count=failure_summary.retries_scheduled_count, retries_scheduled_count=failure_summary.retries_scheduled_count,
threshold=alert_summary.retry_scheduled_threshold, threshold=alert_summary.retry_scheduled_threshold,
) )
def complete_run_for_user(
*,
user_settings: Any,
run: CrawlRun,
scholars: list[ScholarProfile],
user_id: int,
progress: RunProgress,
idempotency_key: str | None,
alert_blocked_failure_threshold: int,
alert_network_failure_threshold: int,
alert_retry_scheduled_threshold: int,
) -> tuple[RunFailureSummary, RunAlertSummary]:
failure_summary = summarize_failures(scholar_results=progress.scholar_results)
alert_summary = build_alert_summary(
failure_summary=failure_summary,
alert_blocked_failure_threshold=alert_blocked_failure_threshold,
alert_network_failure_threshold=alert_network_failure_threshold,
alert_retry_scheduled_threshold=alert_retry_scheduled_threshold,
)
_log_alert_threshold_warnings(
user_id=user_id, run=run, failure_summary=failure_summary, alert_summary=alert_summary
)
apply_safety_outcome(user_settings=user_settings, run=run, user_id=user_id, alert_summary=alert_summary) apply_safety_outcome(user_settings=user_settings, run=run, user_id=user_id, alert_summary=alert_summary)
run_status = resolve_run_status( run_status = resolve_run_status(
scholar_count=len(scholars), scholar_count=len(scholars),