"""Geo re-resolve background task. Registers an APScheduler job that periodically retries IP addresses in the ``geo_cache`` table whose ``country_code`` is ``NULL``. These are IPs that previously failed to resolve (e.g. due to ip-api.com rate limiting) and were recorded as negative entries. The task runs every 10 minutes. On each invocation it: 1. Queries all ``NULL``-country rows from ``geo_cache``. 2. Clears the in-memory negative cache so those IPs are eligible for a fresh API attempt. 3. Delegates to :func:`~app.services.geo_service.lookup_batch` which already handles rate-limit throttling and retries. 4. Logs how many IPs were retried and how many resolved successfully. """ from __future__ import annotations from typing import TYPE_CHECKING, Any import structlog from app.services import geo_service if TYPE_CHECKING: from fastapi import FastAPI log: structlog.stdlib.BoundLogger = structlog.get_logger() #: How often the re-resolve job fires (seconds). 10 minutes. GEO_RE_RESOLVE_INTERVAL: int = 600 #: Stable APScheduler job ID — ensures re-registration replaces, not duplicates. JOB_ID: str = "geo_re_resolve" async def _run_re_resolve(app: Any) -> None: """Query NULL-country IPs from the database and re-resolve them. Reads shared resources from ``app.state`` and delegates to :func:`~app.services.geo_service.lookup_batch`. Args: app: The :class:`fastapi.FastAPI` application instance passed via APScheduler ``kwargs``. """ db = app.state.db http_session = app.state.http_session # Fetch all IPs with NULL country_code from the persistent cache. unresolved_ips: list[str] = [] async with db.execute( "SELECT ip FROM geo_cache WHERE country_code IS NULL" ) as cursor: async for row in cursor: unresolved_ips.append(str(row[0])) if not unresolved_ips: log.debug("geo_re_resolve_skip", reason="no_unresolved_ips") return log.info("geo_re_resolve_start", unresolved=len(unresolved_ips)) # Clear the negative cache so these IPs are eligible for fresh API calls. geo_service.clear_neg_cache() # lookup_batch handles throttling, retries, and persistence when db is # passed. This is a background task so DB writes are allowed. results = await geo_service.lookup_batch(unresolved_ips, http_session, db=db) resolved_count: int = sum( 1 for info in results.values() if info.country_code is not None ) log.info( "geo_re_resolve_complete", retried=len(unresolved_ips), resolved=resolved_count, ) def register(app: FastAPI) -> None: """Add (or replace) the geo re-resolve job in the application scheduler. Must be called after the scheduler has been started (i.e., inside the lifespan handler, after ``scheduler.start()``). The first invocation is deferred by one full interval so the initial blocklist prewarm has time to finish before re-resolve kicks in. Args: app: The :class:`fastapi.FastAPI` application instance whose ``app.state.scheduler`` will receive the job. """ app.state.scheduler.add_job( _run_re_resolve, trigger="interval", seconds=GEO_RE_RESOLVE_INTERVAL, kwargs={"app": app}, id=JOB_ID, replace_existing=True, ) log.info("geo_re_resolve_scheduled", interval_seconds=GEO_RE_RESOLVE_INTERVAL)