Skip to content

Serve a whole v2 metadata catalog version in one response (GET /v2/model-catalog) - #3969

Closed
anth-volk wants to merge 3 commits into
apiv2/1e-user-policy-repointfrom
apiv2/1e-metadata-catalog-route
Closed

anth-volk wants to merge 3 commits into
apiv2/1e-user-policy-repointfrom
apiv2/1e-metadata-catalog-route

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3966

Summary

Adds GET /v2/model-catalog?country_id=…[&policyengine_version=…], which returns a whole catalog version in one response:

  • every parameter node (name, label, description);
  • every parameter, with its current-law values as a map from start date (YYYY-MM-DD) to value (a value applies until the day before the next start date; the last has no end);
  • every variable (the same fields as /v2/variables).

Why: the app's v3 workspace loads the whole model at start-up, as it does today from GET /{country}/metadata. The paged v2 routes allow 500 rows per request, and the US catalog has 100,276 government parameters and 1,174,586 values, so loading it through /v2/parameters and /v2/parameter-values would take about 2,550 requests. This is the first of the two PRs that fill the plan's item 1E-1 (v2 metadata gaps for v3); the full gap list is in #3970.

How it works:

  • The service selects the catalog version (the running PolicyEngine.py version unless one is named), then builds the serialized success envelope once per country and version: nodes, parameter rows and variables are read in name order, and values are streamed from Postgres in batches of 10,000 and serialized per parameter, so the 1.2 million rows are never held at once.
  • Catalog versions never change, so each instance keeps the four most recently used documents (MetadataCatalogCache, created once per process in fastapi_routes/dependencies.py). Concurrent requests for a document that is being built wait for that build; a failed build keeps nothing.
  • The response carries ETag: "<country>:<version>:<format>" and Cache-Control: public, max-age=86400 when the request names policyengine_version, max-age=300 otherwise (the running version changes on deploy). A matching If-None-Match returns 304. The existing gzip middleware compresses the body.
  • The query is read through the shared CatalogQuery model (query_dependency), as the repository's route rules require for new routes: unknown or repeated parameters, an unsupported country and an invalid version return 422 before any database read, and country_id is normalized to lower case. The paged metadata routes, which predate that rule, return 400 for an unsupported country or an invalid version.
  • The route belongs to the metadata route group and the metadata_resources_v2_preview contract.

Measured locally against the real 6.7.5 catalog published to a disposable Postgres: the US document builds in 5.8 s and is 60.7 MB (12.1 MB gzipped); the UK document builds in 1.0 s and is 1.3 MB. With #3970's fields the US document is 70.1 MB (12.5 MB gzipped, 6.8 s). For comparison, v1's GET /us/metadata is 74.7 MB. The building process peaked at 544 MB resident.

Note on naming: /v2/metadata-catalog would collide with the migration-contract guard, which treats every path starting with /v2/me as user-owned (USER_OWNED_PATH_PREFIXES in scripts/guards/migration_contracts.py). This PR uses /v2/model-catalog instead and leaves the guard unchanged; the prefix match may deserve a separate fix.

API contract change

Additive: one new GET route, with MetadataCatalogResponse, MetadataCatalogResult, MetadataCatalogParameter and MetadataParameterNode schemas in /v2/openapi.json. The Stage 9 runbook's statement that the routes "never assemble the complete catalog into one response" is updated to describe this exception.

What stays on the v1 routes

Everything. GET /{country}/metadata and its callers are unchanged; nothing calls the new route until the app's switch (plan item 4A-1). PolicyEngine/policyengine-app-v2#1347 adds the unused caller.

Testing

  • New tests/unit/v2/test_metadata_catalog.py (SQLite): the document holds every node, parameter, value and variable; its body validates against the published response model; only current-law values are included, in start-date order, with empty maps for parameters without values; an explicit version reads that version; a second request is served from the cache without re-reading values or variables; the cache evicts the least recently used document, builds once for concurrent requests and keeps no failed build.
  • tests/unit/v2/test_metadata_routes.py: headers and body, 304 on a matching ETag, documented error statuses with no internal detail, OpenAPI schema references, and the default reader receiving the shared cache; the query contract (normalized country, 422 for missing, unknown or repeated parameters, an unsupported country or an invalid version, without calling the reader) and its OpenAPI parameters.
  • tests/integration/test_v2_metadata_routes.py (disposable Postgres): the published catalog's document matches the paged routes and revalidates with 304.
  • tests/integration/test_live_v2_metadata.py: the release workflow's deployed checks now request the UK document (pinned to the installed version) and revalidate it.
  • Results: uv run pytest tests/unit/v2 tests/contract tests/unit/test_migration_contract_artifacts.py -q passed; Postgres integration suites passed; mypy, ruff format --check, ruff check, run_quality_guards.py passed; export_migration_contracts.py regenerated docs/engineering/migration-contracts.md and docs/generated/migration_contracts.json with the new request.

Merge order and deployment

Possible conflicts

The open ruff refactor PRs (#3920, #3924, #3932–#3934) may touch the metadata service and route modules.

🤖 Generated with Claude Code

@anth-volk
anth-volk added this pull request to stack #3971 October 20, 2026 01:26
@anth-volk anth-volk changed the title apiv2/1e metadata catalog route Serve a whole v2 metadata catalog version in one response (GET /v2/model-catalog) Oct 20, 2026
@codecov

codecov Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.39326% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.13%. Comparing base (38db56f) to head (929bcf1).

Files with missing lines Patch % Lines
...engine_api/services/v2/metadata/transformations.py 70.96% 4 Missing and 5 partials ⚠️
...cyengine_api/services/v2/metadata/catalog_cache.py 86.04% 4 Missing and 2 partials ⚠️
...2/metadata/database_connectors/reads_parameters.py 68.75% 4 Missing and 1 partial ⚠️
...e_api/fastapi_routes/v2/metadata/catalog_routes.py 88.57% 2 Missing and 2 partials ⚠️
policyengine_api/fastapi_routes/dependencies.py 71.42% 2 Missing ⚠️
Additional details and impacted files
@@                        Coverage Diff                        @@
##           apiv2/1e-user-policy-repoint    #3969       +/-   ##
=================================================================
+ Coverage                         46.19%   84.13%   +37.93%     
=================================================================
  Files                               239      257       +18     
  Lines                             13997    15553     +1556     
  Branches                           2275     2587      +312     
=================================================================
+ Hits                               6466    13085     +6619     
+ Misses                             6960     1699     -5261     
- Partials                            571      769      +198     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

anth-volk and others added 3 commits October 20, 2026 17:13
GET /v2/model-catalog returns every parameter node, every parameter with
its current-law values (keyed by start date) and every variable of the
selected catalog version. The app's v3 workspace loads the whole model at
start-up; through the paged routes the US catalog (100,276 gov parameters
and 1,174,586 values) would take about 2,550 requests of 500 rows.

Each instance builds the serialized document once per country and catalog
version, streaming the values from the database, and keeps the four most
recently used. Concurrent requests for a document being built wait for
that build. The response carries an ETag (country, version, format) and a
Cache-Control lifetime of a day when the request names its version, five
minutes otherwise. The release workflow's deployed checks request the UK
document and revalidate it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /v2/model-catalog declared country_id and policyengine_version as plain
function parameters. As a new route it must use the shared typed query
models (api.routes.shared-query-parameters): it ignored misspelled
parameters (so a mistyped policyengine_version silently served the running
version with the five-minute cache policy), took the last value of a repeated
parameter, passed country_id through without normalizing it, and its OpenAPI
operation omitted the country enum and the version bounds.

The route now takes CatalogQuery through query_dependency, as the policy
routes do. Unknown or repeated parameters, an unsupported country and an
invalid version return 422 before any database read; "US" reads the "us"
catalog. The Stage 9 runbook notes the difference from the paged metadata
routes, which return 400 for the last two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the apiv2/1e-metadata-catalog-route branch from 929bcf1 to adc603d Compare October 20, 2026 13:25
@anth-volk anth-volk closed this Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serve a whole v2 metadata catalog version in one response

1 participant