k8s Load Reconciliation

Every k8s CronJob run that uploads data to Spaces now writes a _run.json
manifest alongside the data objects. The VM-side reconciliation script reads
those manifests, compares them against the DuckDB stage status, and reports
what's pending, staged, failed, or empty.

Object layout

raw/nrl/dataset=odds_ticks/
  collector=k8s/
    season=2026/
      round=20/
        snapshot_label=refresh/
          run_id=20260714_060000/
            _run.json          ← manifest written by the CronJob
            ticks.ndjson.gz    ← raw tick data

snapshot_label is part of the path (open / refresh / close).

Manifest fields (_run.json)

Field Description
run_id YYYYMMDD_HHMMSS timestamp of the pod run
task odds_snapshot
collector k8s / vm / manual
dataset odds_ticks
season / round / snapshot_label routing keys
started_at_utc / finished_at_utc ISO-8601 wall-clock times
status runningsuccess / failure / zero_rows
record_count Number of tick rows in the NDJSON
object_keys List of S3 keys written (ticks.ndjson.gz)
error_message Set on failure
pod_name / job_name / image_tag k8s metadata (from env vars)
duckdb_staged_at_utc / duckdb_stage_status Set after VM staging

Reconciliation workflow

1. List Spaces manifests and report

python scripts/reconcile_k8s_raw_runs.py --season 2026 --round 20

Output:

Listing _run.json manifests under raw/nrl/dataset=odds_ticks/ …
Found 3 manifest(s)

Reconciliation summary (3 manifests found in Spaces)
  staged:    2
  pending:   1
  failed:    0
  zero_rows: 0

PENDING (not yet staged):
  run_id=20260714_120000 round=20 label=refresh records=84

Exit code: 0 if all staged, 1 if any pending.

2. Stage pending runs

python scripts/reconcile_k8s_raw_runs.py --season 2026 --round 20 --stage-pending

This calls stage_odds_ticks.py --ticks-key <key> for each pending run and
marks it as staged in ops_nrl_raw_run_reconciliation.

3. Stage a single known run

python scripts/stage_odds_ticks.py \
  --ticks-key "raw/nrl/dataset=odds_ticks/collector=k8s/season=2026/round=20/snapshot_label=refresh/run_id=20260714_060000/ticks.ndjson.gz"

DuckDB table

ops_nrl_raw_run_reconciliation tracks all known runs:

SELECT * FROM ops_nrl_raw_run_reconciliation
WHERE season=2026 AND round=20
ORDER BY reconciled_at_utc DESC;

Columns: run_id, collector, task, dataset, season, round,
snapshot_label, manifest_key, record_count, raw_status,
stage_status, staged_at_utc, staged_row_count, error_message,
reconciled_at_utc.

Gate integration

check_nrl_recommendation_gate.py includes a k8s_raw_data_staged check
(WARNING, non-blocking) that fires when pending runs exist:

python scripts/check_nrl_recommendation_gate.py --season 2026 --round 20 --market all

The check is non-blocking — it won't stop recommendations, but it will show
up as a warning on /ops.

/ops visibility

The /ops page shows a "Raw Run Reconciliation" section. It's populated from
ops_nrl_raw_run_reconciliation and updates whenever the reconciliation
script runs. A fresh cluster with no reconciliation runs yet shows an empty
section with a prompt to run the script.

Collect with manifest (--upload-to-spaces)

The collect_nrl_odds_snapshot.py script writes a manifest when
--upload-to-spaces is passed:

python scripts/collect_nrl_odds_snapshot.py \
  --season 2026 --round 20 --snapshot-label refresh \
  --upload-to-spaces --collector k8s

The k8s CronJob's command does not pass --upload-to-spaces by default —
add it when the cluster is the primary odds collector and reconciliation is
needed. The VM can also use this flag for its own runs.

Deploy test — verifying a tlwindow deploy (two-phase)

After redeploying the scrapers image (e.g. after a registry revert or a new
image tag), run the two-phase deploy test to confirm the tlwindow CronJobs
are healthy end-to-end.

Phase 1 — kubectl (from a machine with cluster access)

# Confirm 4 CronJobs registered with correct image:
kubectl -n doks-ci-cd get cronjobs \
  -l 'app.kubernetes.io/component=teamlist-window' -o wide

# Trigger a smoke-test job manually (pre-Tuesday check):
kubectl -n doks-ci-cd create job \
  --from=cronjob/scrapers-tlwindow-t0 tlwindow-smoketest
kubectl -n doks-ci-cd logs -l job-name=tlwindow-smoketest -f

# Check for ImagePullBackOff:
kubectl -n doks-ci-cd describe pod -l job-name=tlwindow-smoketest \
  | grep -A5 Events:

# After Tuesday 18:00 UTC — confirm all 4 fired:
kubectl -n doks-ci-cd get jobs \
  -l 'app.kubernetes.io/component=teamlist-window'

Phase 2 — VM (Spaces → DuckDB audit)

Run after the Tuesday jobs have had time to fire and upload to Spaces:

source nrl-bet-advisor/.venv/bin/activate
bash scripts/verify_k8s_tlwindow.sh --season 2026 --round <round>
# Expects: "✓ Phase 2 PASS" and tlwindow_clusters=4/4

The script reconciles Spaces _run.json manifests, stages any pending rows
into DuckDB, and runs audit_snapshot_coverage.py — exits 0 on pass, 1 on
fail. Phase 1 (kubectl) commands are also printed so you can copy-paste them.

One-writer rule

Staging (writing to movement_odds_snapshots or DuckDB) always happens on the
VM. The k8s CronJob only uploads raw artifacts to Spaces. Never write DuckDB
from a k8s pod.