Skip to content

Release cached test systems and simulations at module end - #2358

Draft
MaxGhenis wants to merge 4 commits into
mainfrom
release-test-module-caches
Draft

MaxGhenis wants to merge 4 commits into
mainfrom
release-test-module-caches

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2357

What and why

Five test modules cached tax-benefit systems or simulations in module-level functools.lru_caches that nothing cleared, so a pytest worker kept them for the rest of its run. Each cached system also keeps the last simulation built on it (Simulation.__init__ sets tax_benefit_system.simulation = self). On CI run 38165445604, the worker that ran test_two_child_limit_age_exemption_properties.py built 13 systems, went from 1.10 to 1.65 GiB RSS during that file and stayed there.

This PR changes test code only. It does not touch conftest.py, and it branches from main, independent of #2269, #2317 and #2331.

Changes

  1. Two-child limit property test. tax_benefit_system(parameter, value) returned override(CountryTaxBenefitSystem()) from an unbounded cache: a new full system plus a reform for every threshold Hypothesis drew. It now builds one baseline system and builds each reform over it. Reform.__init__ clones only the baseline's parameter tree and copies its variables dict, so a reform over the shared baseline keeps 20 MiB and takes 0.53 s of CPU, against 54 MiB and 1.69 s for a reform over its own fresh system (tracemalloc, Python 3.13.9, policyengine-core 3.33.2). The reform cache holds the last two reforms. Each example uses the repealed limit and then one threshold, so the repealed limit's reform is never evicted and a reform is built only when the threshold changes.
  2. Cliff property test. Same shared baseline; the cache holds the four reforms that mtr_adults in st.integers(0, 3) can ask for.
  3. Module-end release. Each of the five modules gets a module-scoped autouse fixture that calls cache_clear() after its yield: the two files above, test_state_pension_age.py (differential_simulation, situation_template), test_state_pension_credit_qualifying_age.py (one_person_units_template and its own copy of differential_simulation) and test_uc_work_related_groups_properties.py (qualifying_age_couples).
    • The SPC file imports differential_simulation from policyengine_uk.tests.test_state_pension_age. Pytest collects the files in this directory by bare name (no __init__.py), so that import is a second module object with its own cache: sys.modules holds both test_state_pension_age and policyengine_uk.tests.test_state_pension_age, with different differential_simulation objects. The two files never shared the simulation, and each now clears its own.
  4. Guard. code_health/test_test_module_caches_released.py is a static check that every functools cache a test module defines, imports from another test file, or fills through a helper imported from another test file is cleared after the yield of a module-scoped autouse fixture. It reports all five modules as they are on main.

Measured

Before is main at c6d15c8 in a clean worktree; after is this branch. Each pair ran at the same time on the same host (macOS 26.6.2, Apple M5 Max, Python 3.13.9, serial pytest), so both sides saw the same load. The host was heavily loaded, so compare within a pair and prefer CPU time to wall time. A pytest plugin outside the repo recorded, per module, wall and CPU time, RSS (sampled every 20 ms), tracemalloc, the systems and reforms built, and the TaxBenefitSystem, Reform and Simulation objects alive after the module's teardown and a gc.collect().

The five files in one process, in collection order

File Wall, s CPU, s RSS added and kept, MiB Systems + reforms built
test_cliff_evaluated_properties.py 5.1 → 3.4 4.9 → 3.2 +314 → +91 4 + 4 → 1 + 4
test_state_pension_age.py 24.9 → 26.0 24.0 → 25.2 +233 → +138 0 → 0
test_state_pension_credit_qualifying_age.py 18.4 → 18.1 17.4 → 17.3 +314 → +168 0 → 0
test_two_child_limit_age_exemption_properties.py 14.2 → 5.6 13.3 → 5.1 +805 → −4 13 + 12 → 1 + 16
test_uc_work_related_groups_properties.py 198.8 → 184.0 184.8 → 173.3 +710 → +90 145 + 0 → 145 + 0
  • RSS after the last file: 2,761 → 750 MiB.
  • Objects alive after the last file: 28 systems, 16 reforms and 33 simulations → 1 system (the package's own system), 0 reforms and 0 simulations. After this PR that is the count after every one of the five modules.
  • macOS does not return all freed memory to the system, so RSS "kept" overstates what is still referenced. The tracemalloc table shows the Python heap itself.

Python heap (tracemalloc), same five files

File Heap growth at its peak during the file, MiB Heap kept after the file, MiB
test_cliff_evaluated_properties.py +302 → +196 +286 → +11
test_state_pension_age.py +330 → +282 +156 → 0
test_state_pension_credit_qualifying_age.py +395 → +338 +362 → 0
test_two_child_limit_age_exemption_properties.py +806 → +164 +739 → +11
test_uc_work_related_groups_properties.py +653 → +394 +109 → +43

Heap after the last file: 1,660 → 74 MiB.

Each property file alone in a fresh process, three runs

File Wall, s CPU, s RSS peak during the file, MiB RSS after the file, MiB
Two-child limit, before 13.2, 14.1, 14.6 10.8, 12.7, 12.5 1,077 to 1,078 1,077 to 1,078
Two-child limit, after 4.8, 6.5, 6.3 4.6, 5.5, 5.3 409 to 411 347 to 352
Cliff, before 6.2, 6.1, 5.8 5.4, 5.4, 5.2 571 to 574 571 to 574
Cliff, after 4.8, 4.0, 3.9 3.6, 3.5, 3.5 460 to 461 346 to 348

RSS before each file's first test was 255 to 260 MiB in all twelve runs.

Under coverage, as CI measures it

Cliff then two-child limit in one process with --cov=policyengine_uk and COVERAGE_CORE=sysmon (coverage 7.16.2 reports the SysMonitor core with that setting): the two-child limit file took 15.2 → 5.9 s, peaked at 1,453 → 458 MiB RSS and kept +839 → +63 MiB; the cliff file took 6.0 → 4.1 s and kept +325 → +99 MiB.

Time

The caches exist for speed, and this PR does not cost any. The two property files are faster because they build 1 system instead of 13 and 4. The other three files only gain a cache_clear() at module end. Their CPU times moved by +5% (State Pension age), −1% (SPC) and −6% (UC work-related groups) in the paired run; three identical runs of the two-child limit file on main ranged from 10.8 to 12.7 s on this host, so those differences are within run-to-run variation.

One cost to know about: CI runs -n 4 --dist worksteal. If a worker runs a module's tests in two stretches with another module's tests between them, the fixture's teardown runs in between and the next call rebuilds the cache. Results do not change. Before this PR, a module split across workers already built its cache once per worker.

Outcomes are unchanged

  • Same tests. pytest --collect-only policyengine_uk/tests gives the same 5,111 test IDs before and after, plus the guard's 5 new tests.
  • Same results. All 87 tests in the five files passed on both sides of all six paired runs of those files.
  • Same computed values. A second plugin recorded the variable, period, dtype, shape and SHA-1 of every top-level Simulation.calculate result. With --hypothesis-seed=0, the 8,390 records from the five files are identical and in the same order before and after.
    • Without a fixed seed, the Hypothesis tests in the State Pension age and SPC files draw different examples on every run, on main too, because they do not set derandomize=True. The two-child limit, cliff and UC work-related groups files do set it, and their 6,253 records matched without a seed.
  • Same Hypothesis examples. derandomize=True seeds from the test function's own source and signature. All 32 test functions in the five files, 16 of them Hypothesis tests, have byte-identical source to main.

Reviews

  • Two independent Opus 5.5 reviews approved at a30aa90 with no blocking findings (the Codex lanes were closed then, so neither ran on GPT-6.1 Sol).
  • A GPT-6.1 Sol re-review approved at da5a9c8 with nits only.
  • Commits da5a9c8 and 5420c06 take the reviewers' non-blocking points: the guard follows aliased and relative imports, helper functions and assignment-form caches, and three comments are more exact. Seven mutations of the guard's scan each fail one of its self-tests.
  • A Sol re-review of 5420c06 is running.

None of the three finished reviews completed a pytest run, because their sandboxes were read-only. The Sol re-review ran ruff, the guard's scan and four of its self-tests in memory. The pytest runs in this description are this session's.

Not in this PR

  • Six other test modules build a system or a clone at import time and keep it as a module global (SYSTEM = CountryTaxBenefitSystem(), _PROPERTY_SYSTEM = system.clone()). Every xdist worker imports every test module, so each worker holds them for the whole session: collecting the suite holds 315 MiB of heap and 8 systems, against 148 MiB and 2 systems with those six files ignored. Queued as a separate task.
  • test_uc_work_related_groups_properties.py builds a new system for every Simulation (145 here), which is Follow-ups from #2160: qualifying-age docstring, UC work-related groups property-file speed, changelog fragment example #2162 item 2. Queued as a separate task.

Test plan

  • Not a policy change: no YAML tests apply.
  • ruff format and ruff check on the six touched files (run on those files rather than make format, to leave other files alone).
  • Changelog fragment changelog.d/2357.fixed.md.
  • The five touched test files and the guard pass locally on Python 3.13: 92 passed.
  • The guard reports unreleased caches in all five modules as they are on main, and none on this branch.
  • The full suite was not run locally, because the host was heavily loaded. CI runs it.

axiom: n/a: test infrastructure

🤖 Generated with Claude Code

Reproducer: policyengine_uk/tests/code_health/test_test_module_caches_released.py::test_every_test_module_cache_is_released, policyengine_uk/tests/code_health/test_test_module_caches_released.py::test_the_scan_finds_caches_and_releases, policyengine_uk/tests/code_health/test_test_module_caches_released.py::test_the_scan_follows_functions_to_the_caches_they_fill, policyengine_uk/tests/code_health/test_test_module_caches_released.py::test_the_scan_follows_imports_from_other_test_files, policyengine_uk/tests/code_health/test_test_module_caches_released.py::test_the_scan_resolves_this_directorys_imports

MaxGhenis and others added 3 commits October 11, 2026 17:38
Five test modules cached tax-benefit systems or simulations in module-level
lru_caches that were never cleared, so a pytest worker kept them for the rest
of its run. The two-child limit file kept one fresh system per parameter value
drawn (13 on CI, +0.54 GiB RSS that stayed).

- two-child limit and cliff property tests: build reforms over one shared
  baseline system (a reform clones only the parameters), keep the last two
  reforms (two-child) or the four mtr_adults values (cliff), and clear the
  caches in a module-scoped autouse fixture.
- test_state_pension_age, test_state_pension_credit_qualifying_age and
  test_uc_work_related_groups_properties: clear their cached simulations in a
  module-scoped autouse fixture.
- code_health/test_test_module_caches_released.py: every module-level
  functools cache a test module defines or imports must be cleared after the
  yield of a module-scoped autouse fixture.

Test functions' source is unchanged, so Hypothesis's derandomized examples are
the same.

Fixes #2357

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard only saw a yield written directly in the fixture body, so a
fixture that yields inside a with or try block (as reuse_country_model
does) was reported as not releasing its caches. Count any cache_clear()
after the fixture's first yield, and test decorator forms, that case and
imports from other test files. Drop the leading "- " from the changelog
fragment: towncrier's default template adds the bullet (#2162).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the two reviews of #2358 (both approve, no blocking findings):

- The guard now also sees async cached functions and
  `name = lru_cache(...)(function)`, follows aliased and relative imports
  from other test files, and requires a module to clear a cache it fills
  through a helper imported from another test file. It ignores a yield or
  cache_clear inside a function nested in the fixture, and only treats
  `lru_cache`/`cache` and `functools.*` as caches. Its docstring says what a
  static scan cannot see.
- The two-child comment states why a reform is built only when the
  threshold changes instead of quoting a count that depends on the
  Hypothesis version.
- The SPC comment says which module the import loads.

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

Copy link
Copy Markdown
Collaborator Author

CI run cancelled to free runners (UK hub, 2026-10-11 23:30Z). The PolicyEngine org is at GitHub's limit of 60 concurrent jobs, with about 360 queued. Fleet ops asked the hub to let the Budget-critical #2305 take runners first. This PR's queued run had not started, so I cancelled it. Nothing is wrong with the PR.

Please don't re-run it or push just to retrigger CI. The hub re-runs this run itself once #2305 has runners. The record is ~/reviews/uk-hub/ci-yield-2026-10-11.json; see notice 17 in ~/reviews/uk-hub/notices.md.

From the GPT-6.1 Sol re-review of #2358 at da5a9c8 (approve, nits only):

- A cache made by assignment (`name = cache(function)`) is followed into
  the function it wraps, and `name = lru_cache(maxsize=1)` is not taken for
  a cache.
- A cache imported under several names is released by clearing any of them.
- The self-tests cover a relative import from a subdirectory, which only
  the import's level resolves. Seven mutations of the scan each fail a
  self-test.
- The docstring and the SPC comment say the second module copy arises under
  pytest's default import mode, and the docstring lists re-exports through a
  third test file among what the scan does not see.

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

This branch has not been deployed

No deployments
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.

Test modules keep cached tax-benefit systems and simulations for the rest of the test run

1 participant