agnes-the-ai-analyst/tests/test_cache_warmup.py
ZdenekSrotyr aa5921da67
release: 0.47.0 — source-agnostic catalog metadata + cache discipline (#223)
## Summary

- Catalog enrichment for `query_mode='remote'` rows: `rows`, `size_bytes`, `partition_by`, `clustered_by` per table (BQ + Keboola providers).
- `/api/v2/schema/{id}` cache miss: 2 BQ jobs → 1 (-50%) via shared `fetch_bq_columns_full`.
- All four catalog/schema/sample/metadata caches flush on registry change; single-row re-warm scheduled.
- Automatic cache warmup at server startup (bounded concurrency, opt-out via `AGNES_SKIP_CACHE_WARMUP=1`).
- SSE-driven freshness toolbar on `/admin/tables` with progress bar, log, and per-row badge.
- New admin doc `docs/admin/query-modes.md` — single source of truth on `local` / `remote` / `materialized` choice.

Closes #155.
Closes #156.

## Test plan

- [x] 65+ targeted tests pass across 11 new test modules + 3 modified ones.
- [x] No DB migration; no wire-break; `MIN_COMPAT_CLI_VERSION` unchanged.
- [ ] Reviewer: register a remote BQ table via `/admin/tables`, observe the toolbar populates within ~2 s and the per-row badge transitions warming → fresh.
- [ ] Reviewer: trigger `Re-warm all`, verify SSE log scrolls and `cacheWarmupBar` progresses.
- [ ] Reviewer: edit a registered row's bucket, verify `agnes schema <id>` returns updated columns immediately (no 1-hour staleness).
- [ ] Reviewer: confirm `agnes admin register-table --query-mode remote` prints the new IAM-smoke-check hint.

## Notable design decisions

- BigQuery `INFORMATION_SCHEMA.TABLE_STORAGE` is the only valid scope for size+rows (verified live 2026-05-07; dataset-scoped doesn't exist). Region resolved from `instance.yaml.data_source.bigquery.location` → `bq.client().get_dataset(...)` → fall back to legacy `__TABLES__`.
- VIEW handling: TABLE_STORAGE returns no rows for views, fall through to `__TABLES__` (also empty) → `TableMetadata(rows=None, size_bytes=None, partition_by=..., clustered_by=...)`. Null size signals analyst Claude to apply existing CLAUDE.md guidance.
- `size_bytes` is `active_logical_bytes + long_term_logical_bytes` — full BQ scan reads both; reporting only active undercounts aged partitioned tables.
- Source-agnostic provider seam: per-source `connectors/<source>/metadata.py:fetch(MetadataRequest)`; dispatcher in `app/api/v2_catalog.py:_metadata_provider_for` lazily imports per source_type so a Keboola-only deployment doesn't pay the BQ-extension import cost.
- Warmup non-blocking: FastAPI `lifespan` schedules `asyncio.create_task(_warm_catalog_caches_bg)` before `yield`. Per-row failures isolated.

## Out of scope

- Profile / column histograms / dimension cardinality for remote tables (separate issue).
- Onboarding nudge ("you have 0 remote tables, consider registering some BQ ones") — separate UX call.
- Provider plug-in registration via entry-points (the dispatch table is a hardcoded if-tree today; one line per future source).

## Release

Bumps `pyproject.toml` 0.46.1 → 0.47.0 (main shipped 0.46.0 + 0.46.1 during this PR — see commit `d98976ec`). New CHANGELOG section under `## [0.47.0] — 2026-05-07`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/keboola/agnes-the-ai-analyst/pull/223" target="_blank">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
    <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open in Devin Review">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-05-07 18:33:55 +02:00

154 lines
5.4 KiB
Python

"""Cache warmup framework — state, bg task, endpoints."""
import asyncio
from unittest.mock import patch
from app.api.cache_warmup import WarmupRunState
def test_warmup_run_state_starts_empty():
from app.api.cache_warmup import WARMUP_STATE
assert WARMUP_STATE is None or WARMUP_STATE.completed_at is not None
def test_warmup_skips_when_env_set(monkeypatch):
"""AGNES_SKIP_CACHE_WARMUP=1 → background warmup is a no-op."""
monkeypatch.setenv("AGNES_SKIP_CACHE_WARMUP", "1")
from app.api import cache_warmup
# When the env opt-out is set, maybe_schedule_startup_warmup must
# NOT call _warm_catalog_caches_bg.
with patch.object(cache_warmup, "_warm_catalog_caches_bg") as mock_bg:
cache_warmup.maybe_schedule_startup_warmup()
mock_bg.assert_not_called()
def test_warmup_runs_one_per_remote_row(monkeypatch):
"""`_warm_catalog_caches_bg` calls `_warm_one` once per remote row.
Uses asyncio.run rather than @pytest.mark.asyncio to match the
convention in this repo (see tests/test_selective_gzip.py).
"""
from app.api import cache_warmup
# Stub the registry to return 3 remote BQ rows + 1 local row.
fake_rows = [
{"id": "r1", "query_mode": "remote", "source_type": "bigquery"},
{"id": "r2", "query_mode": "remote", "source_type": "bigquery"},
{"id": "r3", "query_mode": "remote", "source_type": "bigquery"},
]
warmed = []
async def fake_warm_one(row, state, sem):
warmed.append(row["id"])
monkeypatch.setattr(cache_warmup, "_list_remote_rows", lambda: fake_rows)
monkeypatch.setattr(cache_warmup, "_warm_one", fake_warm_one)
asyncio.run(cache_warmup._warm_catalog_caches_bg(trigger="manual"))
assert sorted(warmed) == ["r1", "r2", "r3"]
def test_status_endpoint_before_first_run(seeded_app, monkeypatch):
"""GET /status returns {state: never_run} before any warmup."""
from app.api import cache_warmup
monkeypatch.setattr(cache_warmup, "WARMUP_STATE", None)
c = seeded_app["client"]
token = seeded_app["admin_token"]
r = c.get(
"/api/admin/cache-warmup/status",
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
assert r.json() == {"state": "never_run"}
def test_run_endpoint_starts_warmup(seeded_app, monkeypatch):
"""POST /run schedules a warmup and returns 200."""
from app.api import cache_warmup
monkeypatch.setattr(cache_warmup, "WARMUP_STATE", None)
# Patch the actual warmup so the test doesn't run a real one.
monkeypatch.setattr(cache_warmup, "_warm_catalog_caches_bg",
lambda trigger="manual", state=None: _async_noop())
c = seeded_app["client"]
token = seeded_app["admin_token"]
r = c.post(
"/api/admin/cache-warmup/run",
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
def test_run_endpoint_returns_run_id_not_none(seeded_app, monkeypatch):
"""POST /run returns a non-null run_id even when the bg task hasn't
started running yet (no race between create_task and the handler return)."""
from app.api import cache_warmup
async def fake_bg(trigger="manual", state=None):
await asyncio.sleep(0.01) # don't actually warm
monkeypatch.setattr(cache_warmup, "WARMUP_STATE", None)
monkeypatch.setattr(cache_warmup, "_warm_catalog_caches_bg", fake_bg)
c = seeded_app["client"]
token = seeded_app["admin_token"]
r = c.post(
"/api/admin/cache-warmup/run",
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "started"
assert body["run_id"] is not None
assert len(body["run_id"]) == 8 # uuid4 hex prefix
def test_list_remote_rows_filters_to_bigquery_source_type(monkeypatch):
"""Devin Review #1 regression: `_list_remote_rows` previously returned
every `query_mode='remote'` row regardless of `source_type`. The downstream
`_warm_schema_sync` always calls `get_bq_access()`, so a non-BQ remote row
(hypothetical today, plausible as connectors expand) would crash the
warmup pass.
Fix: filter on `source_type == 'bigquery'` in `_list_remote_rows` so the
BQ-only warmup path only sees rows it can handle. Rows from other sources
are simply skipped — they'll grow their own warmup paths as needed."""
from app.api import cache_warmup
fake_rows = [
{"id": "bq_remote", "query_mode": "remote", "source_type": "bigquery"},
{"id": "kbc_remote", "query_mode": "remote", "source_type": "keboola"},
{"id": "bq_local", "query_mode": "local", "source_type": "bigquery"},
{"id": "future_remote", "query_mode": "remote", "source_type": "snowflake"},
{"id": "bq_remote2", "query_mode": "remote", "source_type": "bigquery"},
]
class FakeRepo:
def __init__(self, conn):
pass
def list_all(self):
return fake_rows
class FakeConn:
def close(self):
pass
monkeypatch.setattr(
"src.repositories.table_registry.TableRegistryRepository", FakeRepo,
)
monkeypatch.setattr(
"src.db.get_system_db", lambda: FakeConn(),
)
result = cache_warmup._list_remote_rows()
ids = sorted(r["id"] for r in result)
assert ids == ["bq_remote", "bq_remote2"], (
f"only remote+bigquery rows should be warmed, got {ids}"
)
async def _async_noop():
return None