Add resumable historical backfill checkpoints

This commit is contained in:
devRaGonSa
2026-03-21 01:09:03 +01:00
parent 59d23ecc88
commit e94b6cc7f6
7 changed files with 584 additions and 22 deletions

View File

@@ -15,14 +15,20 @@ from urllib.request import Request, urlopen
from .config import (
get_historical_crcon_detail_workers,
get_historical_crcon_page_size,
get_historical_crcon_request_retries,
get_historical_crcon_request_timeout_seconds,
get_historical_crcon_retry_delay_seconds,
)
from .historical_storage import (
finalize_backfill_progress,
finalize_ingestion_run,
get_backfill_resume_page,
get_refresh_cutoff_for_server,
initialize_historical_storage,
list_historical_coverage_report,
list_historical_servers,
mark_backfill_progress_page_completed,
mark_backfill_progress_started,
start_ingestion_run,
upsert_historical_match,
)
@@ -31,7 +37,6 @@ from .historical_storage import (
PUBLIC_INFO_ENDPOINT = "/api/get_public_info"
MATCH_LIST_ENDPOINT = "/api/get_scoreboard_maps"
MATCH_DETAIL_ENDPOINT = "/api/get_map_scoreboard"
DEFAULT_DETAIL_FETCH_RETRIES = 3
@dataclass(slots=True)
@@ -57,7 +62,7 @@ def run_bootstrap(
server_slug: str | None = None,
max_pages: int | None = None,
page_size: int | None = None,
start_page: int = 1,
start_page: int | None = None,
detail_workers: int | None = None,
) -> dict[str, object]:
"""Run a first full historical import against one or all configured servers."""
@@ -77,7 +82,7 @@ def run_incremental_refresh(
server_slug: str | None = None,
max_pages: int | None = None,
page_size: int | None = None,
start_page: int = 1,
start_page: int | None = None,
detail_workers: int | None = None,
) -> dict[str, object]:
"""Refresh recent historical pages without replaying the whole archive."""
@@ -98,7 +103,7 @@ def _run_ingestion(
server_slug: str | None,
max_pages: int | None,
page_size: int | None,
start_page: int,
start_page: int | None,
detail_workers: int | None,
incremental: bool,
) -> dict[str, object]:
@@ -106,23 +111,35 @@ def _run_ingestion(
stats = IngestionStats()
selected_servers = _select_servers(server_slug)
processed_servers: list[dict[str, object]] = []
runs: list[int] = []
active_runs: dict[str, int] = {}
try:
for server in selected_servers:
run_id = start_ingestion_run(mode=mode, target_server_slug=str(server["slug"]))
runs.append(run_id)
active_runs[str(server["slug"])] = run_id
mark_backfill_progress_started(
server_slug=str(server["slug"]),
mode=mode,
run_id=run_id,
)
cutoff = (
get_refresh_cutoff_for_server(str(server["slug"]))
if incremental
else None
)
resolved_start_page = _resolve_start_page(
start_page=start_page,
server_slug=str(server["slug"]),
mode=mode,
)
server_stats = _ingest_server(
server=server,
mode=mode,
run_id=run_id,
stats=stats,
max_pages=max_pages,
page_size=page_size,
start_page=start_page,
start_page=resolved_start_page,
detail_workers=detail_workers,
cutoff=cutoff,
)
@@ -138,8 +155,16 @@ def _run_ingestion(
player_rows_updated=server_stats["player_rows_updated"],
notes=f"public_name={server_stats['public_name']}",
)
finalize_backfill_progress(
server_slug=str(server["slug"]),
mode=mode,
run_id=run_id,
status="success",
archive_exhausted=bool(server_stats["archive_exhausted"]),
)
active_runs.pop(str(server["slug"]), None)
except Exception as exc:
for run_id in runs:
for active_server_slug, run_id in active_runs.items():
finalize_ingestion_run(
run_id,
status="failed",
@@ -151,6 +176,13 @@ def _run_ingestion(
player_rows_updated=stats.player_rows_updated,
notes=str(exc),
)
finalize_backfill_progress(
server_slug=active_server_slug,
mode=mode,
run_id=run_id,
status="failed",
error_message=str(exc),
)
raise
return {
@@ -175,6 +207,8 @@ def _run_ingestion(
def _ingest_server(
*,
server: dict[str, object],
mode: str,
run_id: int,
stats: IngestionStats,
max_pages: int | None,
page_size: int | None,
@@ -190,6 +224,7 @@ def _ingest_server(
public_info = _fetch_public_info(str(server["scoreboard_base_url"]))
discovered_total_matches: int | None = None
last_page_processed: int | None = None
archive_exhausted = False
for page_number in range(start_page, start_page + page_limit):
payload = _fetch_match_page(
@@ -201,6 +236,7 @@ def _ingest_server(
discovered_total_matches = _coerce_int(payload.get("total"))
page_matches = _coerce_match_list(payload.get("maps"))
if not page_matches:
archive_exhausted = True
break
local_stats.pages_processed += 1
@@ -234,6 +270,15 @@ def _ingest_server(
local_stats.apply(delta)
stats.apply(delta)
mark_backfill_progress_page_completed(
server_slug=str(server["slug"]),
mode=mode,
page_number=page_number,
page_size=resolved_page_size,
run_id=run_id,
discovered_total_matches=discovered_total_matches,
)
if stop_after_page:
break
@@ -251,9 +296,23 @@ def _ingest_server(
"start_page": start_page,
"last_page_processed": last_page_processed,
"cutoff": cutoff,
"archive_exhausted": archive_exhausted,
}
def _resolve_start_page(
*,
start_page: int | None,
server_slug: str,
mode: str,
) -> int:
if start_page is not None:
return max(1, start_page)
if mode != "bootstrap":
return 1
return get_backfill_resume_page(server_slug, mode=mode)
def _select_servers(server_slug: str | None) -> list[dict[str, object]]:
servers = list_historical_servers()
if server_slug is None:
@@ -344,10 +403,12 @@ def _fetch_dict_payload(
query: dict[str, object] | None = None,
*,
context: str = "",
retries: int = DEFAULT_DETAIL_FETCH_RETRIES,
retries: int | None = None,
) -> dict[str, object]:
resolved_retries = retries or get_historical_crcon_request_retries()
base_retry_delay_seconds = get_historical_crcon_retry_delay_seconds()
last_error: Exception | None = None
for attempt in range(1, retries + 1):
for attempt in range(1, resolved_retries + 1):
try:
payload = _unwrap_result(_fetch_json(base_url, endpoint, query))
except Exception as exc: # pragma: no cover - network path
@@ -359,8 +420,8 @@ def _fetch_dict_payload(
f"Unexpected payload type for {base_url}{endpoint} {context}".strip()
)
if attempt < retries:
time.sleep(0.5 * attempt)
if attempt < resolved_retries:
time.sleep(base_retry_delay_seconds * attempt)
assert last_error is not None
raise last_error
@@ -442,8 +503,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--start-page",
type=int,
default=1,
help="resume bootstrap or refresh from a specific page number",
help="override the resume page; bootstrap uses persisted progress when omitted",
)
parser.add_argument(
"--detail-workers",