Periodic jobstate time-index reconcile (heals best-effort index gaps)¶
Motivation¶
The jobstate time-ordered index (#50, shipped flag-OFF in b4142db) maintains
jobstateindex with best-effort writes: _index_put / _index_delete are
wrapped in try/except so an index write failure never fails the source-of-truth
jobstate row. The documented residual gap: if _index_put fails after the
row write (a transient Table error) while JOBSTATE_TIME_INDEX_ENABLED is on,
that one job is silently omitted from the indexed /api/blast/jobs listing until
a backfill re-runs. Both the issue and the repo design notes flagged a periodic
reconcile as the hardening prerequisite before trusting the flag long-term.
User-facing change¶
No user-visible behaviour change while the flag is OFF (the default): the task
returns early before touching Storage. Once JOBSTATE_TIME_INDEX_ENABLED=true,
the listing self-heals — a job missed by a transient index-write failure is
re-added on the next reconcile tick instead of staying invisible until a manual
backfill.
API / IaC diff summary¶
api/services/state/repository.py— newJobStateRepository.reconcile_time_index(*, dry_run=False, batch_log_every=500) -> (scanned, written): the idempotent scan-and-upsert (read-only againstjobstate, upsert-only againstjobstateindex) extracted so the one-shot backfill and the periodic reconcile share one implementation and can never drift. Upsert-only is sufficient: a failed_index_deleteleaves a stale index row for a soft-deleted job, butlist_owner_pagealready skipsstatus='deleted'/missing rows at read time, so the reconcile only needs to add missed rows (and never re-adds tombstones via thestatus ne 'deleted'filter).scripts/dev/backfill_jobstate_time_index.py—backfill()is now a thin CLI wrapper overreconcile_time_index(); the stdout summary ({mode}done scanned=<n> backfilled=<n>) and idempotency contract are unchanged.api/tasks/blast/time_index_reconcile_task.py— new@shared_taskapi.tasks.blast.reconcile_time_index. No-op (returns{"skipped": "flag_off", ...}) unlessJOBSTATE_TIME_INDEX_ENABLEDis set; otherwise callsrepo.reconcile_time_index()and returns{scanned, written}.api/tasks/blast/__init__.py— register the new task for Celery discovery.api/celery_app.py— beat entryblast-reconcile-time-indexon thereconcilequeue,CELERY_BEAT_TIME_INDEX_RECONCILE_SECONDS(default 3600 s). Free to leave scheduled on every deployment — one cheap env check per tick when the flag is off (same pattern as the Service Bus beat tasks).
Charter §12a Rule 4 compliant: new behaviour ships additive and default-OFF
behind the existing JOBSTATE_TIME_INDEX_ENABLED flag.
Validation evidence¶
uv run pytest -q api/tests/test_jobstate_time_index.py— 19 passed (16 prior + 3 new): repo-method dry-run touches no table; task no-ops when the flag is off (no index table created); task heals un-indexed rows when the flag is on, skips tombstones, and is idempotent on a second pass.uv run pytest -q api/tests/test_blast_tasks.py api/tests/test_celery_failure_visibility.py— 167 passed (task package + Celery wiring unaffected).uv run pytest -q api/tests/test_state_repo.py api/tests/test_blast_jobs_routes.py api/tests/test_persona_matrix.py api/tests/test_smoke.py api/tests/test_state_singletons.py— 206 passed.uv run ruff check— clean on all touched files.- Registration verified:
api.tasks.blast.reconcile_time_indexregisters on the Celery app (via the existinginclude=["api.tasks.blast", …]) and theblast-reconcile-time-indexbeat entry resolves to thereconcilequeue.
Remaining (#50, still open)¶
- Flip after a verified in-cluster backfill (maintainer/ops action) — now de-risked by this reconcile.
- Route/SPA
next_cursorwiring (complicated by the external/v1/jobsmerge in_compute_blast_jobs_response). list_for_scope/list_allindex buckets (operator/dev surfaces) stay on the legacy scan.