Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0015c12
Test re-running a stored economy report or simulation on a later cata…
anth-volk Nov 12, 2026
f757524
Read a stored report or simulation, and create its re-run on the curr…
anth-volk Nov 12, 2026
aa3d01a
Test re-running economy reports and simulations through POST /reports…
anth-volk Nov 12, 2026
593e894
Re-run economy reports and simulations through POST /reports/{id}/run…
anth-volk Nov 12, 2026
668c1b2
Document re-running economy reports and simulations, and add a change…
anth-volk Nov 12, 2026
ca33a90
Test review round 1's findings: rows deleted during a re-run, and the…
anth-volk Nov 12, 2026
93f5d65
Fix review round 1's findings: name a re-run's refused rows as the st…
anth-volk Nov 12, 2026
b2fcda5
Test review round 2's finding: a re-run of a deleted record raises it…
anth-volk Nov 12, 2026
77aea9a
Fix review round 2's findings: the persistence library tells a delete…
anth-volk Nov 12, 2026
ec1e7e8
Fix review round 3's wording: which codes name a re-run's stored rows…
anth-volk Nov 12, 2026
86d11cc
Merge remote-tracking branch 'origin/main' into v2plan/c1-17-re-runs
anth-volk Nov 12, 2026
12d8824
Merge main into v2plan/c1-17-re-runs
anth-volk Nov 12, 2026
206c32f
Test re-running a stored budget-window report on a later catalog vers…
anth-volk Nov 12, 2026
a61f799
Test re-running a budget-window report through POST /reports/{id}/run…
anth-volk Nov 12, 2026
f92e1f4
Re-run a budget-window report as a new budget-window run
anth-volk Nov 12, 2026
2068202
Document re-running a budget-window report, with a changelog fragment
anth-volk Nov 12, 2026
4356480
Test review round 4's findings for budget-window re-runs (failing)
anth-volk Nov 12, 2026
a7ca243
Fix review round 4's findings: share the window code, check a re-run'…
anth-volk Nov 12, 2026
8803608
Fix review round 5's findings: type a window's years by the fields re…
anth-volk Nov 12, 2026
fe8756e
Fix review round 6's finding: build a window year's run IDs in one place
anth-volk Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/c1-17-budget-window-reruns.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`POST /reports/{report_id}/runs` also re-runs a stored budget-window report, as a new budget-window run with a report run per year on each year's own simulations, its first years started as a new window's are.
1 change: 1 addition & 0 deletions changelog.d/c1-17-reruns.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Simulation Entrypoint re-runs a stored economy report or simulation on the current .py version through `POST /reports/{report_id}/runs` and `POST /simulations/{simulation_id}/runs`, as a new run started by run ID, keeping every earlier run. A report's re-run reuses a run of each side's own simulation that succeeded on the same versions unless `force` is set; a record with a run pending or running is refused with 409 `run_in_progress`.
12 changes: 12 additions & 0 deletions docs/migration/stage-12-modal-worker-foundation.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,18 @@ as for a report comparing it with itself, so a report whose plan needs more
columns, for example a reform that turns on labour supply responses, can't
reuse it and fails with `reused_simulation_output_incompatible`.

**A re-run.** The service re-runs a stored report or simulation with the same
two calls, by run ID. It creates the record's new run (a report run with
trigger `manual`) and, for a report, a new `simulation_runs` row of each side's
own simulation, or names that simulation's run that already succeeded on the
same versions, unless the re-run is forced. The new runs record the catalog
version, dataset and region of the current .py version's catalog; the
`simulations` and `reports` rows are unchanged. A budget-window report's
re-run is a new `budget_window_runs` row (trigger `manual`) with a report run
per year, each on its year's own simulations, started as a new window's
first years are. The coordinator and workers claim and calculate them as
they would a new record's: nothing on the Modal side tells a re-run apart.

**Each worker** claims its simulation run under its own Modal call ID: a pending
or running run that records no call or its own. It records its output file, or
its failure, only while it holds the run running. It returns the output's
Expand Down
43 changes: 43 additions & 0 deletions libs/policyengine-simulation-persistence/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,49 @@ commit fails after its rows were inserted raises
`EconomyRecordOutcomeUnknownError`: PostgreSQL may have applied the commit,
so the rows may exist.

## Re-running a report or simulation

`economy_rerun_records.py` holds a re-run's reads and writes. A re-run
calculates a stored record again on the current versions, as a new run, and
changes no `reports` or `simulations` row, which the service's role may not
update: a report keeps its simulations, and each new run records the catalog
version, dataset and region it calculates on.

- `read_report_definition` and `read_simulation_definition` read what the
record calculates: its kind (`economy`, `budget_window`, `household` or
`unsupported`), country, year, its region's code and dataset's name, and
each simulation's policy and dynamic.
- `find_reusable_run_of` searches one simulation's runs, by its ID, under
`find_reusable_run`'s conditions on a run (versions, the run's own catalog
version, dataset, region and year, the output file, a succeeded report run
with the same options, no refused reuse); it doesn't compare the
simulation row, which keeps the catalog rows it was created on.
- `insert_report_rerun` creates a report's new run, trigger `manual`, and a
new run of each side's simulation that reuses none;
`insert_simulation_rerun` creates a simulation's new run. Each refuses with
`RunInProgressError`, naming the run, while the record has a run `pending`
or `running` (for a report, a report run; for a simulation, any of its
runs), and first takes `lock_reruns_of`, a transaction-level
advisory lock on the record's ID, so concurrent re-runs of one record
wait for each other. When the database refuses the new run's key to the
record itself, the record was deleted after it was read:
`RerunRecordDeletedError`.

A budget window's definition also names its newest budget-window run's
years (`WindowYearDefinition`): each year's sides are the simulations of the
runs that year's report run names. `insert_window_rerun` creates the
window's new budget-window run (trigger `manual`, `recalculate` set), a
report run per year and each year's new runs of its own simulations. Every
report re-run is refused while a budget-window run or a report run of the
report is `pending` or `running`.

`EconomyRunStore.read_report_rerun_inputs` reads a re-run's catalog rows by
the stored region code and dataset name, and each side's values and
reusable run of its own simulation, never giving both sides the same run.
`read_window_rerun_inputs` does the same for each year of a window.
`create_report_rerun`, `create_window_rerun` and `create_simulation_rerun`
run the inserts in a transaction of their own.

## Reading a policy

`read_policy_reform` (`policy_reform.py`) reads a stored policy as the reform
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,30 @@
from policyengine_simulation_persistence.budget_window_records import (
BudgetWindowYear,
NewBudgetWindowReport,
WindowSide,
WindowYearRuns,
insert_budget_window_report,
)
from policyengine_simulation_persistence.economy_rerun_records import (
NewReportRerun,
NewSimulationRerun,
NewWindowRerun,
RecordKind,
ReportDefinition,
RerunRecordDeletedError,
RerunSide,
RunInProgressError,
SideDefinition,
SimulationDefinition,
WindowRerunYear,
WindowYearDefinition,
insert_report_rerun,
insert_simulation_rerun,
insert_window_rerun,
lock_reruns_of,
read_report_definition,
read_simulation_definition,
)
from policyengine_simulation_persistence.economy_run_reads import (
EconomyReportState,
EconomySimulationState,
Expand All @@ -33,6 +55,7 @@
fail_unstarted_report_run,
fail_unstarted_simulation_run,
find_reusable_run,
find_reusable_run_of,
insert_economy_report,
insert_economy_simulation,
read_dataset,
Expand Down Expand Up @@ -139,19 +162,29 @@
"NewEconomyReport",
"NewEconomySimulation",
"NewHouseholdJob",
"NewReportRerun",
"NewSimulationRerun",
"NewWindowRerun",
"OverlappingReformError",
"PolicyReform",
"RecordKind",
"ReportDefinition",
"ReportRunState",
"ReportRunStatus",
"ReportRunTrigger",
"ReportSide",
"ReportSideError",
"ReportSimulation",
"RerunRecordDeletedError",
"RerunSide",
"ReusableRun",
"RunInProgressError",
"RunReuse",
"RunVersions",
"SideDefinition",
"SideRefusal",
"SimulationChoice",
"SimulationDefinition",
"SimulationInputs",
"SimulationRunState",
"SimulationStatus",
Expand All @@ -163,6 +196,10 @@
"UnknownHouseholdError",
"UnknownPolicyError",
"UnknownRegionError",
"WindowRerunYear",
"WindowSide",
"WindowYearDefinition",
"WindowYearRuns",
"bounded_transaction",
"budget_window_runs",
"combine_reforms",
Expand All @@ -171,12 +208,17 @@
"fail_unstarted_report_run",
"fail_unstarted_simulation_run",
"find_reusable_run",
"find_reusable_run_of",
"household_jobs",
"input_metadata",
"insert_budget_window_report",
"insert_economy_report",
"insert_economy_simulation",
"insert_household_job",
"insert_report_rerun",
"insert_simulation_rerun",
"insert_window_rerun",
"lock_reruns_of",
"missing_table_privileges",
"normalized_database_url",
"read_catalog_version_id",
Expand All @@ -190,6 +232,8 @@
"read_model_id",
"read_policy_reform",
"read_region",
"read_report_definition",
"read_simulation_definition",
"read_stored_household",
"record_household_call",
"record_report_run_call",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from __future__ import annotations

from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Protocol

from sqlalchemy import insert

Expand All @@ -40,11 +40,59 @@
)

if TYPE_CHECKING:
from collections.abc import Sequence
from uuid import UUID

from sqlalchemy import Connection


class WindowSide(Protocol):
"""A window year's side: its simulation and the run the year names."""

@property
def simulation_id(self) -> UUID:
"""The side's simulation."""
...

@property
def simulation_run_id(self) -> UUID:
"""The run of it the year names."""
...

@property
def reused(self) -> bool:
"""Whether the run already succeeded, rather than being created."""
...


class WindowYearRuns(Protocol):
"""A year of a window run: its year, its report run and each side.

A new window's ``BudgetWindowYear`` and a re-run's ``WindowRerunYear``
are both one.
"""

@property
def year(self) -> int:
"""The year."""
...

@property
def report_run_id(self) -> UUID:
"""The year's report run."""
...

@property
def baseline(self) -> WindowSide:
"""The baseline side."""
...

@property
def reform(self) -> WindowSide:
"""The reform side."""
...


@dataclass(frozen=True)
class BudgetWindowYear:
"""One year of a window: its report run and its two sides' runs."""
Expand Down Expand Up @@ -78,20 +126,7 @@ class NewBudgetWindowReport:

def __post_init__(self) -> None:
"""Refuse years that aren't 1 to 75 consecutive ones from the first."""
first_year = self.population.year
expected = tuple(range(first_year, first_year + len(self.years)))
if (
not self.years
or len(self.years) > MAX_BUDGET_WINDOW_YEARS
or tuple(year.year for year in self.years) != expected
or expected[-1] > LATEST_YEAR
):
msg = (
"a budget window's years must be 1 to "
f"{MAX_BUDGET_WINDOW_YEARS} consecutive years from its first, "
f"ending by {LATEST_YEAR}"
)
raise ValueError(msg)
check_window_years(self.population.year, [year.year for year in self.years])

@property
def first_year(self) -> BudgetWindowYear:
Expand All @@ -103,6 +138,26 @@ def population_in(self, year: int) -> EconomyPopulation:
return replace(self.population, year=year)


def check_window_years(first_year: int, years: Sequence[int]) -> None:
"""Raise ValueError unless ``years`` are 1 to 75 in a row from ``first_year``.

The last of them must also be no later than the tables' latest year.
"""
expected = tuple(range(first_year, first_year + len(years)))
if (
not years
or len(years) > MAX_BUDGET_WINDOW_YEARS
or tuple(years) != expected
or expected[-1] > LATEST_YEAR
):
msg = (
"a budget window's years must be 1 to "
f"{MAX_BUDGET_WINDOW_YEARS} consecutive years from its first, "
f"ending by {LATEST_YEAR}"
)
raise ValueError(msg)


def insert_budget_window_report(
connection: Connection, report: NewBudgetWindowReport
) -> None:
Expand Down Expand Up @@ -159,24 +214,42 @@ def insert_budget_window_report(
)
connection.execute(
insert(report_runs),
[_year_run_row(report, window_year) for window_year in report.years],
[
year_run_row(
window_year,
report_id=report.id,
window_run_id=report.window_run_id,
versions=report.versions,
trigger=ReportRunTrigger.INITIAL,
)
for window_year in report.years
],
)


def _year_run_row(
report: NewBudgetWindowReport, window_year: BudgetWindowYear
def year_run_row(
window_year: WindowYearRuns,
*,
report_id: UUID,
window_run_id: UUID,
versions: RunVersions,
trigger: ReportRunTrigger,
) -> dict[str, Any]:
"""Return a year's report run, pending with no call, as inserted."""
"""Return a year's report run in a window run, pending with no call, as inserted.

``window_year`` is a new window's year or a re-run's; ``trigger`` says
which.
"""
return {
"id": window_year.report_run_id,
"report_id": report.id,
"report_id": report_id,
"baseline_simulation_run_id": window_year.baseline.simulation_run_id,
"reform_simulation_run_id": window_year.reform.simulation_run_id,
"budget_window_run_id": report.window_run_id,
"budget_window_run_id": window_run_id,
"year": window_year.year,
"country_package_version": report.versions.country_package_version,
"policyengine_version": report.versions.policyengine_version,
"country_package_version": versions.country_package_version,
"policyengine_version": versions.policyengine_version,
"status": ReportRunStatus.PENDING,
"trigger": ReportRunTrigger.INITIAL,
"trigger": trigger,
"modal_call_id": None,
}
Loading
Loading