Skip to content

Share detailed-save reports and runs by key, and record each user's report runs - #4303

Draft
anth-volk wants to merge 18 commits into
masterfrom
v2plan/c1-25-shared-report-keys
Draft

anth-volk wants to merge 18 commits into
masterfrom
v2plan/c1-25-shared-report-keys

Conversation

@anth-volk

@anth-volk anth-volk commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4302

Summary

Revision 94dc43079186 (on 1e1e3f079f2c), autogenerated, lets the simulation service find each simulation and report by a key and join an existing run instead of creating new rows. It covers economy reports, household reports and budget windows.

  • Keys. simulations and reports gain nullable key_version and definition_key, unique on the pair (uq_simulations_key_version_definition_key, uq_reports_key_version_definition_key).
    • reports also gains dataset_independent_key, which a national report and its local-area report share. It is indexed on its own (ix_reports_dataset_independent_key), because the app finds one report by the other's key, and the key holds its own key version.
    • Checks require 64 lowercase hexadecimal digits, a positive key_version, and every key column or none.
    • Existing rows stay unkeyed, and row IDs stay random.
  • Forced re-runs. simulation_runs and report_runs gain forced, false by default. For a budget window, recalculate is the forced marker.
  • One run per version set. Three partial unique indexes leave out failed and forced runs. So a failed run never counts, and a forced re-run is an extra run nothing joins:
    • uq_simulation_runs_unfailed_unforced_versions on (simulation_id, year, tax_benefit_model_version_id, country_package_version, policyengine_version) where status <> 'failed' AND NOT forced;
    • uq_report_runs_unfailed_unforced_versions on (report_id, country_package_version, policyengine_version) where budget_window_run_id IS NULL AND status <> 'failed' AND NOT forced;
    • uq_budget_window_runs_unfailed_unforced_versions on (report_id, tax_benefit_model_version_id, policyengine_version) where status <> 'failed' AND NOT recalculate. budget_window_runs gains policyengine_version for it; it is nullable, and unversioned window runs are never joined.
    • All three are declared for SQLite too, so the unit tests' databases keep the rule.
  • Each user's requests. user_report_run_associations records each user's request for a report run:
    • the user (CASCADE) and the run (RESTRICT, indexed);
    • requested_at (required, no default);
    • trigger (initial or manual, reusing v2_report_run_trigger);
    • one row per user and run.
  • Per-user columns of shared rows. reports.label, report_runs.trigger and budget_window_runs.trigger become nullable, with no default. reports.description and user_id already were. This lets the simulation service stop writing them. The report read and the run lists answer a missing label or trigger as null, and the /v2/reports response models allow it. A later revision (tracker task C1 · 41) drops them.
  • Grants (privileges.py): the runtime role may read, insert and delete the new table's rows, and no other role has access. The simulation service needs nothing new: ON CONFLICT with a target needs SELECT and INSERT, which it already holds. No role may change a key, forced or a window run's policyengine_version.
  • The one post-generation correction: the new table's trigger reuses the existing enum type (create_type=False).

It follows the shared-reports design decisions (tracker task C1 · 24):

  • 1a, 1c, 4: the keys' contents, computed by the simulation service; key columns beside random row IDs.
  • 1b: dynamics keyed by ID for now.
  • 2: a failed run never counts.
  • 3: a forced re-run is an extra, marked run.
  • 6: the per-user columns go. This PR makes them nullable; C1 · 41 drops them.
  • 7: a repeated request keeps the first requested_at, so there is no UPDATE grant.
  • 8: household reports and budget windows are keyed now.
  • 10: no user_simulation_run_associations.

Beyond the design's column list, the simulation-run index also covers year and country_package_version. That only allows rows the shorter index would refuse.

Deploy order and checks

  • Order: apply this revision in staging and production before the simulation service's find-or-create change (C1 · 27, PolicyEngine/policyengine-sim-api). That change uses these indexes with INSERT ... ON CONFLICT DO NOTHING and stops writing the per-user columns.
    • Until it deploys, the simulation service's re-runs on unchanged versions answer 500, because the new indexes refuse the second run. This happens beside a succeeded run, and after a failed report run whose other side succeeded; household re-runs are included.
    • Creates are unaffected, and nothing deployed calls the re-run routes.
    • The Wales staging check's switch to forced re-runs has to come with or after that change.
  • Before merging: the upgrade fails, changing nothing, if a database already has two runs that would break an index. Run the two read-only queries in the revision's section of docs/engineering/skills/alembic-migrations.md in staging and production first.
  • The previous revision: it maps none of the new columns or table and writes none of these tables, so it keeps working while the simulation service still writes the per-user columns. Once that service writes nulls there, don't roll the Public API back below this revision: the previous revision takes label and trigger as required.
  • The downgrade: it makes the per-user columns required again, so it fails while any report has no label or any run no trigger.
  • The simulation service's schema check: its weekly comparison with this repository's tables fails after the merge until its table models follow; that is expected.

Changed existing tests

They stored two unfailed runs on the same versions, which the new indexes refuse. Each keeps its intent:

  • a forced re-run (RunSettings.forced in tests/integration/stored_report_rows.py);
  • each report side, budget-window year or withheld-codes run gets its own simulation;
  • a run is finished instead of adding a second one.

test_alembic_v2, test_import_side_effects (49 tables), test_simulation_run_models and test_models now pin the new head, chain, table count, columns and indexes.

CI

  • tests/integration/test_alembic_v2_shared_report_keys.py joins .github/scripts/test_alembic_v2_lifecycle.sh.
  • tests/integration/test_v2_shared_report_rows.py joins the managed-roles step of v2-integration-check.yml.
  • No environment variable is added. Both jobs already set V2_MIGRATION_DATABASE_URL and V2_ALEMBIC_DISPOSABLE_TEST, and the integration job DEPLOYMENT_ENVIRONMENT, as literal job env: values for their own PostgreSQL service container.
  • The deploy's existing reconcile_v2_privileges.py apply/verify steps grant and check the new table. Those steps are unchanged.

Testing

  • Database: a disposable PostgreSQL 17 database, built fresh with alembic -c alembic-v2.ini upgrade head, then scripts/reconcile_v2_privileges.py apply --create-missing-roles and verify.
  • Drift: alembic -c alembic-v2.ini check found no new operations, and there is one head. Generating 94dc43079186 again on 1e1e3f079f2c gives the committed operations.
  • Tests: every added or changed test, run individually by node ID (67 cases), passed, as did the tests the merge of master affects. Each new test was seen failing first.
  • Rows: test_v2_shared_report_rows.py runs the simulation service's ON CONFLICT find-or-create, forced, failed and window runs, rows without per-user columns, and the runtime role's records, each as its managed role.
  • Static checks:
    • ruff, ruff format, lint-imports, python -m scripts.rules validate, python scripts/run_quality_guards.py and mypy all passed.
    • Explicit mypy on the changed policyengine_api/data/v2/ files shows the 20 errors master has there, plus 2 of the same kind (SQLModel's Field overloads and sa_type=) on the new table's requested_at and trigger.
  • Reviews: six adversarial review rounds.
    • Rounds 1 and 2 found existing unit tests that pinned the old revision chain, table count, columns and indexes.
    • Rounds 3 to 6 found no bugs. Their documentation and test fixes are included.

🤖 Generated with Claude Code

anth-volk and others added 7 commits November 13, 2026 23:21
…ailing)

Identical requests are to share simulations, reports and runs: the
simulation service finds each simulation and report by a key and joins an
unfailed run on the same versions. These tests describe the schema that
lets it, and fail until the models, revision and grants exist:

- nullable key columns on simulations and reports, unique on the key and
  its version, with format and completeness checks, and the
  dataset-independent key on reports, indexed;
- a forced marker on simulation and report runs, false by default;
- one unfailed, unforced run per definition and version set, enforced by
  partial unique indexes (in PostgreSQL and in the unit tests' SQLite),
  leaving budget-window year runs out;
- user_report_run_associations, which the Public API's runtime role reads,
  inserts and deletes, and no other role reads;
- the revision's shape and its exact downgrade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The simulation service is to find each simulation and report by a key and
join a run on the current versions instead of creating new rows. Revision
94dc43079186, autogenerated, gives it the schema:

- simulations and reports gain nullable key_version and definition_key,
  unique on both, and reports dataset_independent_key, indexed with
  key_version; checks require 64 lowercase characters, a positive version
  and every key column or none.
- simulation_runs and report_runs gain forced, false by default, which
  marks a forced re-run's extra runs.
- Partial unique indexes allow one run that hasn't failed and isn't forced
  per simulation, year, catalog version and package versions, and per
  report and package versions outside budget windows. They are declared
  for SQLite too, so the unit tests' databases keep the rule.
- user_report_run_associations records each user's request for a report
  run (CASCADE with the user, RESTRICT on the run, one per user and run),
  which the runtime role reads, inserts and deletes.

The one post-generation correction makes the new table's trigger reuse
the v2_report_run_trigger type. Tests that stored two unfailed runs on
the same versions now store a forced re-run, give each side or year its
own simulation, or fail the first run. CI runs the new integration tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d checks (failing)

- A key must be 64 lowercase hexadecimal characters; a non-hexadecimal
  key of the right length and case is still accepted, so the format tests
  fail until the check changes.
- The revision chain test lists the SPM availability revision under the
  new head, and the baseline's table count leaves out the new table; the
  import test counts 48 tables.
- The output checks' refused inserts name the check they hit, since the
  new index would refuse them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A key's check now accepts only 64 lowercase hexadecimal digits
  (length 64, and nothing left after trimming the hex digits), the same in
  PostgreSQL and SQLite. Revision 94dc43079186 is generated again from the
  models, since Alembic compares checks by name only; its operations differ
  only in the three key checks, and its one correction is unchanged.
- The skill document says an ON CONFLICT with a target needs SELECT as well
  as INSERT, and that the indexes also refuse a plain re-run after a failed
  report run whose other side succeeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in SQLite

- The simulation-run model tests list the forced column and the
  one-unfailed-run index among the table's exact columns and indexes.
- A new SQLite test inserts keys that aren't 64 lowercase hex digits
  (uppercase, short, non-hex, blank, empty) and expects the format check
  to refuse each; with the earlier lowercase-only check it fails.
- The skill document names each index's whole condition in its ON
  CONFLICT example: report runs also need budget_window_run_id IS NULL,
  or PostgreSQL finds no matching index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The Simulation and Report docstrings list the country among what each
  key covers; the User docstring says a deletion removes the user's
  report-run records; the privileges docstring counts forced among what no
  role may change about a run.
- The Stage 9 and Stage 12A downgrade notes say the revision's downgrade
  also drops the forced markers and the run indexes.
- The SQLite index tests expect the unique index's refusal by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (d7042b2) to head (63cce82).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4303      +/-   ##
==========================================
- Coverage   93.47%   93.45%   -0.03%     
==========================================
  Files         369      369              
  Lines       21455    21478      +23     
  Branches     3400     3401       +1     
==========================================
+ Hits        20056    20072      +16     
- Misses        816      823       +7     
  Partials      583      583              
Flag Coverage Δ
integration 56.66% <100.00%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ 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 November 14, 2026 01:13
The app finds a report's local-area report by the national report's
dataset_independent_key, which holds its own key version, so a lookup
names the key alone. The tests expect an index on that column alone and
a lookup that uses it; today's index leads with key_version, which a
key-only lookup can only scan in full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app finds a report's local-area report by the national report's
dataset_independent_key, and the key holds its own key version, so a
lookup names the key alone. ix_reports_dataset_independent_key replaces
the index that led with key_version, which such a lookup could only scan
in full. Revision 94dc43079186 is generated again; only that index's
create and drop changed, and its one correction is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lookup test now reads its results before the next statement and
checks they are the two reports it stored, and names the index the plan
uses in full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A simulation's key covers everything that changes its numbers, now with
the SPM selection; a report's key names the SPM selection once and holds
no request options, so the key shared across datasets is the report key
without the dataset. Docstrings and the skill document only; no schema
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 4 commits November 14, 2026 02:27
C1 · 24's answer 8 gives budget windows the same keys: a report has one
window run per version set that hasn't failed, and recalculate is a
window run's forced marker. The tests expect budget_window_runs to record
its .py version (optional, so older window runs stay unversioned and are
never joined) and a partial unique index on the report, catalog version
and .py version that leaves out failed and recalculated runs, in
PostgreSQL and in the unit tests' SQLite, and the simulation service to
find or create a window run against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C1 · 24's answer 8: budget windows share runs like other reports.
budget_window_runs gains policyengine_version (nullable, so window runs
written before it stay unversioned and are never joined), and
uq_budget_window_runs_unfailed_unforced_versions allows one window run per
report, catalog version and .py version that hasn't failed and isn't
recalculated: recalculate is a window run's forced marker. No role may
change the version once written. Revision 94dc43079186 is generated
again; it adds only the column and the index, and its one correction is
unchanged. Household runs already fall under the same run indexes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt keys

master gained revision 1e1e3f079f2c (withheld_area_codes) on the same
parent, 0c136a278f29, so revision 94dc43079186 now follows it: its
down_revision, V2_ALEMBIC_HEAD_REVISION, the revision chain and
single-child lookups in test_alembic_v2, the shape test's parent, the
49-table count, and the skill document's section. Generating 94dc43079186
again on 1e1e3f079f2c gives the same operations. The withheld codes test
gives each of its runs a simulation of its own, as a simulation has one
unfailed run per version set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…found

The revision's docstring, the skill document and the Stage 9 and Stage 12A
downgrade notes say the downgrade also drops budget_window_runs'
policyengine_version, and the shape test's docstring lists it among the
revision's changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 3 commits November 14, 2026 03:00
C1 · 24's answer 6, as the user settled it: the simulation service stops
writing reports.label, report_runs.trigger and budget_window_runs.trigger
in C1 · 27, and C1 · 41 drops them later. Until then they must be
nullable, with no default. The tests expect the three columns nullable in
the models and the revision (and required again after its downgrade), the
simulation service to store a report and runs without them, and the
report read and run lists to answer a missing label or trigger as null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C1 · 24's answer 6, as the user settled it: a shared report or run
describes no one caller, so the simulation service stops writing
reports.label, report_runs.trigger and budget_window_runs.trigger (C1 · 27),
and a later revision drops them (C1 · 41). Revision 94dc43079186, generated
again, makes the three nullable with no default (reports.description and
user_id already were); its downgrade makes them required again. The report
read and the run lists pass a missing label or trigger on as null, and the
/v2/reports response models allow it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The skill document says a Public API rollback below this revision isn't
  safe once the simulation service writes null labels and triggers, and
  the downgrade notes (skill document, Stage 9, Stage 12A) say the
  downgrade needs every report to have a label and every run a trigger.
- The re-run route's description and ReportRunRead's docstring no longer
  promise a trigger.
- Test docstrings name the three nullability changes, and the run
  transformation test is named for what it checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit to PolicyEngine/policyengine-sim-api that referenced this pull request Nov 14, 2026
…d until it merges

The Public API schema job built the tables at 429d348ae, before #4303, so
every test needing the key columns, forced markers and nullable per-user
columns failed. 63cce8273 is #4303's pushed head, which this branch's
models match (B1 · 5's comparison passes locally against it). The job's
last step requires a commit on the Public API's master, so it fails until
#4303 merges; then the pin moves to #4303's merge commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
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.

Share detailed-save reports and runs by key, and record each user's report runs

1 participant