Repository navigation
Prune older v2 catalog versions after publication, and restore them on republish - #4225
Merged
Merged
Conversation
After a publication commits, catalog publication is to keep, for each country's model, the installed version and the most recently created other version, and delete every other stored version's current-law parameter values, parameter tree rows and parameter search file, one transaction each, recording when on the model version. Publishing a pruned version again is to restore those rows identically. The tests cover choosing the versions, the exact deletion scope as the catalog writer (saved reforms', dynamics' and every other table's rows unchanged), restoration equal to a first publication, a failure that rolls back one version and leaves publication successful (also when reading the stored versions fails), the evidence and stderr reports, the 503 with Cache-Control: no-store that routes serving the deleted rows answer for a pruned version, the marker column and its revision, the writer's privileges and the integration workflow entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…epublish Each stored catalog version costs about 550 MB per environment, mostly its current-law parameter values, and nothing removed old versions. After the publication transaction commits, publish_catalog keeps, for each country's model, the installed version and the most recently created other one (the previous release's), and prunes every other stored version not pruned already (publication_pruning). One transaction per version takes the publication lock, deletes the version's parameter_values rows with neither a policy_id nor a dynamic_id, its parameter_tree_children rows and its parameter_search_files rows, and sets the new nullable tax_benefit_model_versions.catalog_values_pruned_at. It never deletes parameters, whose deletion would cascade to saved reforms' values, or any other row. A database error rolls back that version alone, or prunes nothing if reading the stored versions fails; each failure is reported in the evidence and on stderr, and the command still succeeds. Publishing a pruned version again inserts its current-law values from the installed packages and clears the column inside the publication transaction, before the stored version is compared; the usual writers then write its tree and search file. The rows equal a first publication's. The metadata routes that serve the deleted rows (parameter values, parameter children, the search file, and the model catalog variables and parameters, which embed values) answer 503 with Cache-Control: no-store for a pruned version. A selection kept for the process reads the column again when a read finds no rows. The whole version response cache passes a failure's Cache-Control to waiting requests. Revision ca12427ceb63, autogenerated, adds the column. The catalog writer gains DELETE on the three tables. The integration workflow runs the new integration tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Alembic skill said a package-derived catalog seeder must not delete retained domain data. Its new "Catalog pruning" section names pruning as the one exception, with its exact scope: an older version's current-law parameter_values, parameter_tree_children and parameter_search_files rows, never parameters or rows a policy or dynamic owns, rebuilt identically when the version is published again. The maintainers' approval is the review of the pull request that added it. The Stage 9 runbook, the v2 code organization skill and the docstrings of SelectedCatalogStore and the v2 read session factory said a stored catalog version never changes, or changes only in its time-period options. They now say that its current-law values may be pruned and restored identically from the same packages. The runbook also describes which versions are pruned, the evidence fields, the 503 a pruned version answers, and what a traffic rollback to an older revision serves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A process that kept a selection made while a version was pruned is to read the version's marker again before answering 503, and to serve the version once a publication has restored it, keeping the new selection. - Each per-version pruning transaction is to choose again under the publication lock and skip a version the rule now keeps, reporting it neither as pruned nor as failed. - Any exception pruning one version, not only a database error, is to roll that version back and be reported, naming only its class. - A run is to prune at most two versions, oldest first, and report how many chosen versions are left for later runs. - A process keeps serving the variables catalog body it built before a version was pruned, and answers 304 to its ETag (already the case). - Pruning a version that holds none of the rows it deletes marks it and reports zero counts (already the case). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
services.py was 5 lines under the 800-line limit. Choosing the catalog version a read uses, with the process's kept selections and the pruned version check, moves to MetadataCatalogSelection in services/v2/metadata/catalog_selection/services.py, which V2MetadataService builds in its constructor and calls. The code moves unchanged apart from these renames, since another module now calls them: _select_catalog to select, _select_paginated_catalog to select_page and _require_unpruned to require_unpruned, each with a docstring. services.py is now 755 lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A selection a process kept while a version was pruned answered 503 for the process's life, even after a publication restored the version. MetadataCatalogSelection.select now takes reads_current_law, which the seven reads of pruned rows pass: for a kept selection whose catalog_values_pruned_at is set it reads the column again first, one more query, and raises before reading the rows while it is set; once it is clear it selects the version again and keeps the new selection. A freshly read pruned selection also raises before the rows are read. - Pruning chose versions without the publication lock, so a publication that committed in between could have made the rule keep a chosen version. Each per-version transaction now takes the lock, reads the model's stored versions again and skips the version if catalog_versions_to_prune no longer chooses it (_prune_if_still_chosen); the choosing function stays the one source of the rule. - Only database errors were caught after the publication committed. Any exception pruning a version, or reading the stored versions, is now recorded (_returned_or_raised); pruning_failure names only the class of an exception that isn't a database error. It needs one inline suppression, BLE001, with its reason. - A run now prunes at most MAX_CATALOG_VERSIONS_PRUNED_PER_RUN (2) versions, oldest created first (catalog_versions_to_prune_now), and the evidence reports how many chosen versions it left for later runs (catalog_versions_left_to_prune). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Stage 9 runbook and the Alembic skill now describe the limit of two pruned versions per run and the evidence count of those left, the recheck under the publication lock, and that any exception is recorded. The runbook said a process that selected a version before its pruning answers 503. It now says what happens: such a process answers 503 only when a read finds none of the deleted rows; one that built the version's /v2/model-catalog/variables body before the pruning keeps serving it, with 200 and 304, and the body equals what restoration writes; one that selected the version while it was pruned serves it again once a publication restores it. Downgrading past the pruning revision drops the record of which versions are pruned, and the parent revision's code can't restore them. The runbook's rollback section and the Alembic skill say a downgrade is safe only while no version is pruned, give the query that checks it, and say what to do otherwise. The code organization skill names the catalog selection subpackage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release workflow seeds before it deploys, and puts the previous revision back when promotion fails, so keeping only the most recently created other version can prune the version that is still serving. A new nullable tax_benefit_model_versions.catalog_last_published_at is to record when catalog publication last published each version: every publication sets it for the versions it publishes, new, unchanged or restored. Pruning is to keep the installed version and the two most recently published others, ordered by that column, or created_at where it is null, then created_at and version text. The tests cover a seed whose promotion failed, a rollback followed by a new release, the fallback to created_at, the tie-break, both columns in the model and the one revision, and each publication recording the time. The integration tests now store four versions before one is pruned, and a concurrent republish sets the new column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release workflow seeds before it deploys, and puts the previous revision back when promotion fails, so keeping the installed version and the most recently created other one could prune the version still serving after a failed promotion or a rollback to an older version. Every publication now sets the new nullable tax_benefit_model_versions.catalog_last_published_at for each catalog version it publishes, new, unchanged or restored, inside its transaction (record_catalog_publication). catalog_versions_to_prune, still the one place the rule is decided and applied again under the lock before each version is pruned, keeps the installed version and the KEPT_OTHER_CATALOG_VERSIONS (2) most recently published others, ordered by that column, or created_at where it is null, then created_at and version text. Two consecutive failed promotions followed by a third release can still prune the serving version; the docs say so. The per-run limit of two pruned versions stays. Revision 334859ef4fec replaces ca12427ceb63, which only the disposable database had applied: one autogenerated revision adds both columns, formatted with ruff format as the other revisions are. The catalog writer's UPDATE on tax_benefit_model_versions covers the new write. Pruning stays on in this pull request. The Alembic skill and the Stage 9 runbook record the maintainers' accepted exception: during the first release with pruning, the previous revision, which doesn't read catalog_values_pruned_at, answers requests naming a pruned version on /v2/parameter-values and the model catalog parameters and variables routes with empty, day-cacheable 200s until promotion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three integration tests pin behaviour the code already has, so none of them fails on its own; each was shown to fail with the code it protects broken temporarily, then restored. - test_a_pruning_transaction_chooses_again_after_waiting_for_the_lock: another connection holds the publication lock while the pruning of a version waits for it in a thread, found waiting through pg_locks, and sets the version's publication time before releasing it; the pruning then deletes nothing. It fails without the lock call in _prune_if_still_chosen. - test_a_failed_republish_of_a_pruned_version_leaves_it_pruned: a republish that fails after restoring leaves the version with no current-law values, tree rows or search file, and both its pruning and publication times unchanged. It fails if restoration clears the pruning time outside the publication transaction. - test_restoring_a_version_stored_before_catalog_format_2_writes_values_only: such a version gets back its values alone, with no tree or search file, as publication treats it. It fails if the tree writer stops skipping a version without a catalog format. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The Stage 9 runbook's credential table now lists catalog pruning's DELETE on parameter_values, parameter_tree_children and parameter_search_files. - The runbook, the code organization skill, and the docstrings of the read session factory, SelectedCatalogStore, MetadataResponseCache and the explicit-version cache lifetime said a stored version's row changes only in its time-period options and its pruning column. Each publication also sets catalog_last_published_at; they now say so, and that no response serves either column. The SelectedCatalogStore docstring's account of reading the column again is rewritten. - The rollback section said any revision running a pruned version answers 503. A revision from before catalog pruning answers its running version's values, parameters and variables catalog routes with empty 200s cacheable for five minutes (a day for a named version); the section says so, and that only a publication by code with pruning, with that version installed, restores the rows. - The runbook says why, for a pruned version, the variables catalog with no parameter-defined parts and the parameters catalog with no known name answer 200 with a selection from before the pruning and 503 with a fresh one. - MetadataCatalogSelection.select's reads_current_law is renamed reads_pruned_data, since tree and search file reads pass it too. - prune_catalog_version's check for a version already marked stays as a guard; a comment says why publication can't reach it. - The privilege manifest's comment names both updates of a version row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #4225 +/- ##
==========================================
+ Coverage 92.14% 92.19% +0.05%
==========================================
Files 296 298 +2
Lines 16696 16895 +199
Branches 2722 2756 +34
==========================================
+ Hits 15384 15577 +193
- Misses 795 800 +5
- Partials 517 518 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The simulation API now runs PolicyEngine.py 6.16.0, and the release check requires the Public API's bundle to be the simulation API's latest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ction test_no_file_names_the_former_mixed_module refuses any dotted path ending in .catalog_selection, which named the removed policyengine_api.data.v2.catalog.catalog_selection module, and the new metadata.catalog_selection package matched it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parametrized path used uuid4() at import, so each release test shard collected a different test ID and the check that every test runs in exactly one shard failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A release that publishes a new PolicyEngine.py version's catalog (17 to 20 minutes in production) now also prunes older catalog versions in the same job, which the 30-minute limit left little room for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4224
Summary
Each stored v2 catalog version costs about 550 MB per environment, mostly its current-law
parameter_values(about 1.2 million rows for both countries, about 391 MB) and itsparameter_tree_children(about 108 MB). Nothing removed old versions, so every release added another 550 MB.Catalog publication (
scripts/publish_v2_metadata_catalog.py, run by the release workflow's seed jobs aspolicyengine_v2_catalog_writer) now records when it last published each version and, after its own transaction commits, removes those package-derived rows from older stored versions, at most two versions per run. A release that installs a pruned version again restores them, identically, from the installed packages. For a pruned version, the metadata routes that serve the removed rows answer 503 withCache-Control: no-store; every other metadata route answers as before.Migration scope: this changes the v2 catalog publication command and the
/v2metadata routes only. The Flask routes and/{country}/metadataare unchanged.PolicyEngine.py version: this pull request includes the PolicyEngine bundle update to 6.16.0, the simulation API's latest, by merging #4223's branch, so the release check that the Public API's bundle matches the simulation API's passes. It is meant to merge on its own: it replaces #4223, and Fixes #4222, that update's issue. Once this merges, #4223's commit is in master.
Design
record_catalog_publicationinpolicyengine_api/data/v2/catalog/publication_pruning.py). Every publication sets the new nullabletax_benefit_model_versions.catalog_last_published_atfor each version it publishes, inside its transaction: a new version, a republish with no change, and a republish that restores a pruned version.catalog_versions_to_prune). For each country's model, publication keeps the installed version and the two most recently published other versions (KEPT_OTHER_CATALOG_VERSIONS), ordered bycatalog_last_published_at, or bycreated_atwhere that is null (rows stored before the column existed), with ties ordered bycreated_atand then version text. It chooses every other stored version not pruned already. Two are kept because the release workflow seeds before it deploys and promotes, and puts the previous revision back when promotion fails: after one failed promotion the serving version is the second most recently published other one, and after a rollback to an older version, the rollback's publication made that version the most recently published. This function is the only place the rule is decided.catalog_versions_to_prune_now). A run prunes at most two of the chosen versions (MAX_CATALOG_VERSIONS_PRUNED_PER_RUN), oldestcreated_atfirst. A release's US and UK versions are created in one transaction, so two is one release. The evidence'scatalog_versions_left_to_prunecounts the rest, which later publications prune._prune_older_versionsinpublication.py). After the publication transaction has committed, one transaction per version (_prune_if_still_chosen). Each takes the publication advisory lock, reads the model's stored versions again and appliescatalog_versions_to_pruneagain; a version the rule no longer chooses (a publication that republished it committed meanwhile) is skipped and reported neither as pruned nor as failed. Otherwiseprune_catalog_versionsetscatalog_values_pruned_atonly if it is still null, then deletes, so the marker and the deletes commit or roll back together._returned_or_raised). Any exception while pruning a version, or while reading the stored versions, is recorded instead of raised, and that version's transaction rolls back; the others are still pruned. Each failure is listed in the evidence'scatalog_pruning_failures(country, version or null, exception class, and a message naming only the database's error class and SQLSTATE, or for any other exception only its class, never the statement, its parameters or row data) and printed to stderr. The command exits 0, because the publication has committed, and the next publication retries.restore_pruned_catalog_versions). When publication runs for a stored version whosecatalog_values_pruned_atis set, it inserts the version's current-law values from the staged catalog with the statement a new version's publication uses (so with the same catalog value IDs) and clears the column, inside the publication transaction and before the usual exact comparison of the stored version with the staged catalog. The existing writers then write the version's parameter tree and search file, as for any version that has none. The integration test checks that the restored rows equal what the version's first publication wrote.require_current_law_valuesraisesCatalogValuesPrunedError(aMetadataCatalogUnavailableError);run_metadata_readmaps it to 503 withCache-Control: no-store. Catalog selection moved fromV2MetadataServicetoMetadataCatalogSelectioninservices/v2/metadata/catalog_version_selection/services.py(a move:services.pywas 5 lines under its length limit). The seven service reads of pruned rows select withreads_pruned_data=True: a pruned selection answers before the rows are read, and a selection the process kept while the version was pruned is checked against the database again first (one more query, only then), so once a publication restores the version the process serves it again and keeps the new selection. A selection kept from before the pruning readscatalog_values_pruned_atagain when a read finds no rows.Renamed in the move, since another module now calls them:
_select_catalogtoMetadataCatalogSelection.select,_select_paginated_catalogtoselect_page,_require_unprunedtorequire_unpruned.Exactly what is deleted
For each pruned version, in one transaction:
parameter_values: rows whoseparameter_idis one of the version's parameters and whosepolicy_idanddynamic_idare both null;parameter_tree_childrenrows;parameter_search_filesrows;and its
tax_benefit_model_versions.catalog_values_pruned_atis set to the current time.Nothing else is deleted or changed. In particular: no
parametersrow (parameter_values.parameter_idreferences it withON DELETE CASCADE, so deleting one would delete saved reforms' values), notax_benefit_model_versionsrow or other column of it, noparameter_nodes,variables,regions,datasets,policiesordynamicsrow, and noparameter_valuesrow with apolicy_idordynamic_id. No foreign key references the three tables, so the deletes cascade nowhere (checked in the disposable database withpg_constraint).What the first runs in each environment delete
Before this, no version has
catalog_last_published_at, so the first publication orders the stored versions bycreated_at: it keeps the installed version and the two most recently created others, chooses the rest, and prunes the oldest release's two versions (about 1.2 million value rows, about 550 MB). Each later publication prunes the next oldest release's versions until none are left; the evidence'scatalog_versions_left_to_pruneshows how many remain. The stored versions in staging and production weren't counted for this pull request; an earlier estimate put production at about nine older versions.503 for a pruned version
For a pruned version these routes answer 503, with
Cache-Control: no-storeand noETag, and the message "PolicyEngine.py<version>'s current-law parameter values for<country>were removed from the catalog, with its parameter tree and search file; they return when PolicyEngine.py<version>is installed again":GET /v2/parameter-valuesGET /v2/parameter-values/{value_id}GET /v2/parameters/childrenGET /v2/parameters/search-file, also when revalidated withIf-None-MatchGET /v2/model-catalog/variables(embeds the current-law values of the parameters variables add or subtract)GET /v2/model-catalog/parameters(embeds each parameter's current-law values)The OpenAPI document describes the 503 on each. The other metadata routes serve kept rows and answer 200. A process that built a version's
/v2/model-catalog/variablesbody before the version was pruned keeps serving that body (200, and 304 to itsETag) for the process's life; the body equals what restoring the version writes. The process's whole-version response cache never keeps a 503, and requests waiting on the same read receive itsCache-Controltoo.Privileges and migration
policyengine_api/data/v2/privileges.py:policyengine_v2_catalog_writergainsDELETEonparameter_values,parameter_tree_childrenandparameter_search_files. It already heldUPDATEontax_benefit_model_versions, which both new columns need. No other role changes. The deploy's existingscripts/reconcile_v2_privileges.py applystep grants them before the catalog is seeded.334859ef4fec(parentc614b6bcccfc), autogenerated withuv run alembic -c alembic-v2.ini revision --autogenerateand formatted withruff formatas the other revisions are, adds the nullablecatalog_last_published_atandcatalog_values_pruned_at(timestamp with time zone) and drops them on downgrade. It replaces an earlier revision of this branch,ca12427ceb63, which only a disposable database had applied.V2_ALEMBIC_HEAD_REVISIONis updated.334859ef4fecis safe only while no version is pruned; the Alembic skill and the Stage 9 runbook's rollback section give the check and what to do otherwise.docs/engineering/skills/alembic-migrations.mdsaid a package-derived catalog seeder "must not transform or delete retained domain data". Its new section "Catalog pruning: the seeder's one deletion" names pruning as the one exception, with the scope above, and says that the maintainers' approval of the exception is this pull request's review. It also records the accepted first-release window below as an exception to the rule that the serving revision works with the database changed on its own. The Stage 9 runbook, the v2 code organization skill and two docstrings said a stored catalog version never changes; they now say its current-law values may be pruned and restored identically from the same packages.Environment variables
Seed job time limit.
.github/workflows/seed-v2-database.yml'sseedjob now hastimeout-minutes: 60(was 30), because its release may publish a new version's catalog and prune in the same job. No step, variable or secret changes.This change adds no environment variable and changes no secret or variable wiring. I searched the diff against
origin/masterforos.environ,getenv,environ,BaseSettings, settings fields,env:blocks,secrets.andvars.; the only match is the new integration tests readingV2_MIGRATION_DATABASE_URL.V2_MIGRATION_DATABASE_URLenv:ofcatalog-runtime-integrationin.github/workflows/v2-integration-check.yml, pointing at that job'spostgres:17service; the changed step is in that jobV2_ALEMBIC_DISPOSABLE_TEST,DEPLOYMENT_ENVIRONMENTenv:V2_DATA_WRITE_DATABASE_URLstagingandproduction(gh secret list --env), passed in the publication step'senv:inseed-v2-database.ymlHUGGING_FACE_TOKENPE_UK_PRIVATE_HF_READ_TOKEN, readable by this repository (gh api repos/PolicyEngine/policyengine-api/actions/organization-secrets), passed in the same step'senv:V2_MIGRATION_DATABASE_URL(seed job)stagingandproduction(gh secret list --env), set in each step'senv:V2_SUPABASE_PROJECT_REF,V2_SUPABASE_ENVIRONMENTstagingandproduction(gh variable list --env), set in the job'senv:PRODUCTION_V2_SUPABASE_PROJECT_REFenv:Suppressions
policyengine_api/data/v2/catalog/publication.py,_returned_or_raised:except Exception as error: # noqa: BLE001 # pruning failures are recorded, never raised. Pruning runs after the publication has committed, so no exception may fail the command, and the exception is returned to be recorded without its message. Ruff's BLE001 accepts a broadexceptonly when it re-raises or logs the traceback (logging.exceptionorexc_info), and a traceback can print the statement's parameters and row data to the seed job's log. One helper holds the only suppression and serves both the read of the stored versions and each version's pruning.Testing
All test runs use the disposable PostgreSQL 17 database
policyengine_v2_alembic_teston127.0.0.1:55473, withV2_MIGRATION_DATABASE_URL,V2_ALEMBIC_DISPOSABLE_TEST=1,DEPLOYMENT_ENVIRONMENT=testandUV_PYTHON=3.12set, and each test selected by its node ID:Red, before each change
2026-11-06 14:52:24 UTC, the first new test files: collection failed with
ModuleNotFoundError: No module named 'policyengine_api.data.v2.catalog.publication_pruning'andImportError: cannot import name 'CatalogValuesPrunedError'/'CatalogPruningFailure'.2026-11-06 14:52:46 UTC, the first changed tests in existing files: no attribute
PrunedCatalogVersion/CatalogPruningFailure; the writer held DELETE on no table; the revision chain lacked the new revision;KeyError: 'catalog_values_pruned_at'; waiting requests' copies had noCache-Control; the workflow didn't run the new test file;UndefinedColumn: column "catalog_values_pruned_at" does not exist.2026-11-06 16:44:45 UTC, a failed read of the stored versions: the message and stderr named version "None", and
sqlalchemy.exc.DataError(DivisionByZero) was raised out ofpublish_catalogafter the publication committed.2026-11-06 17:26:34 UTC, review round 1, batch 1 (10 failed, 2 passed):
test_a_failure_that_is_not_a_database_error_names_only_its_class: the message said "the database reported KeyError";test_a_run_prunes_at_most_two_versions_oldest_first,test_a_run_with_no_more_than_the_limit_prunes_them_all: no attributecatalog_versions_to_prune_now;test_completion_evidence_contains_only_reviewed_non_secret_fields: unexpected keywordcatalog_versions_left_to_prune;test_a_selection_kept_while_pruned_reads_only_the_marker_before_answering: the one statement was the parameter values read, not the marker read;test_a_selection_kept_while_pruned_serves_the_version_once_restored:CatalogValuesPrunedErrorafter the restoration;test_a_process_that_selected_a_pruned_version_serves_it_once_restored: 503 after the restoration;test_a_version_kept_by_the_time_its_transaction_runs_is_not_pruned: the US old version was pruned anyway;test_a_non_database_error_rolls_back_that_version_and_publication_succeeds:KeyErrorraised out ofpublish_catalog;test_a_run_prunes_at_most_two_versions_and_the_next_run_the_rest: the first run pruned all four;test_a_process_that_selected_a_version_before_its_pruning_answers_503, andtest_a_version_without_current_law_rows_is_marked_pruned_with_zero_counts.2026-11-06 17:40:54 UTC, review round 1, batch 2 (22 failed, 2 passed): the rule tests failed with
TypeError: ... unexpected keyword argument 'last_published_at'or with the old rule's choice (for example[('policyengine-us', '5.0.2')] == [('policyengine-us', '5.0.0')]);KeyError: 'catalog_last_published_at'in the model test; the revision test found oneop.add_column, not two;UndefinedColumn: column "catalog_last_published_at" does not existin the migration and publication-time integration tests; the integration tests with four stored versions found the old rule pruning a version the new rule keeps. The route test and the process restoration test passed, since both rules prune their oldest version.Review round 2: three tests of behaviour the code already had. On unchanged code (18:16:20 UTC) all three passed; each was then run with the code it protects broken temporarily, and the code restored exactly afterwards (
git checkout, emptygit diffofpolicyengine_api/data/v2/catalog):test_a_pruning_transaction_chooses_again_after_waiting_for_the_lock, with_acquire_publication_lock(connection)deleted from_prune_if_still_chosen(18:16:48 UTC):assert PrunedRows(parameter_values=2, parameter_tree_children=3, parameter_search_files=1) is None;test_a_failed_republish_of_a_pruned_version_leaves_it_pruned, with restoration clearingcatalog_values_pruned_atthrough a separate connection committed outside the publication transaction (18:17:06 UTC):assert None == datetime.datetime(2026, 11, 6, 18, 17, 9, 56630, tzinfo=datetime.timezone.utc), whereNone = _pruned_at(..., 'policyengine-us', '5.0.1');test_restoring_a_version_stored_before_catalog_format_2_writes_values_only, with the parameter tree writer's skip of a version without a catalog format removed (18:17:19 UTC):{'tree_rows': [('', 'gov', 0, 'Gov', 'node', 1, ...), ...]} != {'tree_rows': []}.The lock test synchronizes on
pg_locks: it waits until the pruning thread is blocked on the advisory lock, or has finished, and its sleep only paces the polling. It passed five runs in a row.Green, on the branch tip
2026-11-06 18:20:04 UTC, commit
6d5ef4d64: 64 passed (53 node IDs, 64 cases with parametrization).The deletion-scope and restoration integration tests publish as
policyengine_v2_catalog_writer, and the route integration tests read aspolicyengine_v2_runtime, each holding only its declared privileges.Fixes after the pull request was opened
test_no_file_names_the_former_mixed_modulerefuses any dotted path ending in.catalog_selection(the removedpolicyengine_api.data.v2.catalog.catalog_selectionmodule), and the newmetadata.catalog_selectionpackage matched it. Red: the test failed locally onpolicyengine_api/services/v2/metadata/services.py. The package is nowcatalog_version_selection; green: the test and the branch's 48 changed unit test cases pass.test_routes_serving_pruned_data_answer_503_that_no_cache_keepsbuilt one parametrized path withuuid4()at import, so each release test shard collected a different test ID and the shard check failed. Red: twopytest --collect-onlyruns gave different IDs. The path now uses a fixed UUID; green: two collections give identical IDs and the tests pass.Other checks
uv run alembic -c alembic-v2.ini upgrade head(to334859ef4fec),alembic -c alembic-v2.ini check("No new upgrade operations detected."),downgrade -1(toc614b6bcccfc),upgrade head, andcheckagain: all passed.uv run python scripts/reconcile_v2_privileges.py apply --create-missing-roles, thenverify: both exit 0 ("Every declared v2 table privilege is held (test).").python scripts/export_migration_contracts.py: no generated file changed.python -m scripts.rules validate: passed.make qualityon the branch tip: passed and changed no file.uv run mypyon the changed modules underpolicyengine_api/data/v2(which the configured mypy run doesn't cover): 13 errors, 11 of them identical on the base commit. The other two are the new columns'Field(default=None, sa_type=sa.DateTime(timezone=True)), the declaration every timestamp column in the v2 models uses; mypy reports the same error for the existing ones (for exampleParameterValue.end_date). The pruning module has none.BASE_SHA=origin/master bash .github/scripts/check_changelog_fragment.sh: one fragment added.Risks
catalog_values_pruned_at, still serves. From the seed job until promotion, and until the next successful release if promotion fails, that revision answers a request naming a pruned version inpolicyengine_versionon/v2/parameter-values,/v2/model-catalog/parametersand/v2/model-catalog/variableswith a 200 holding no current-law values,Cache-Control: public, max-age=86400and anETag, so a client or shared cache can keep that empty answer for a day;/v2/parameters/childrenand/v2/parameters/search-fileanswer 503 and/v2/parameter-values/{value_id}404. Requests withoutpolicyengine_versionread the running version, which is kept. The maintainers decided to ship pruning turned on in this pull request and accept the window, because nothing in production calls these routes with an older version until the v3 switch. The Alembic skill records it as an exception to the serving-revision compatibility rule.VACUUM FULLor an equivalent rewrite, which this doesn't run, so the database's reported size won't fall right away.334859ef4fecdrops the record of which versions are pruned; the parent revision's code would serve them with empty values and fail to republish them. It is safe only while no version is pruned.catalog_values_pruned_at: for its running version, so also for every request withoutpolicyengine_version, it answers/v2/parameter-valuesand the model catalog parameters and variables routes with empty 200s,Cache-Control: public, max-age=300(a day for a named version) and anETag. Either way only a publication by code with this change, with that PolicyEngine.py version installed, restores the rows.🤖 Generated with Claude Code