PolicyEngine / PolicyEngine/policyengine.py
Simulation cache is write-only: uuid4 keys mean ensure() never reloads results, and data_folder accumulates orphaned h5 files
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 7
- Forks
- 9
- Avg merge
- 15h 51m
- Merged PRs (30d)
- 9
Description
Symptom
The simulation result cache is write-only across processes: identical reruns recompute everything, and data_folder silently accumulates orphaned output files.
Observed after one evening of normal use (three microsimulation scripts over a 2026–2035 window on the certified national dataset, policyengine 4.18.10):
./datacontained 71 orphaned<uuid>.h5output files (2.0 GB) alongside the 10 reusablepopulace_us_2024_year_*.h5dataset files- rerunning the identical script recomputed all 20 simulations (~64 minutes) instead of loading them
Mechanics
Simulation.iddefaults to a randomuuid4(src/policyengine/core/simulation.py:57).Simulation.ensure()checks the in-memory cache with_cache.get(self.id)(simulation.py:133) — only the same object can ever hit, since no twoSimulationobjects share an id.- The disk path is keyed on the same id:
load()looks for<dataset_dir>/<simulation.id>.h5(src/policyengine/tax_benefit_models/common/model_version.py:316). A freshSimulationin a new process (or even the same process) can never name a previous run's file, soensure()always falls through torun()+save()— which writes yet another<uuid>.h5that nothing will ever read.
Net: ensure()'s documented behavior ("loads a cached result if one exists, or runs and caches on miss" — docs/microsim.md) only holds within the lifetime of a single Simulation object.
By contrast, ensure_datasets keys year-uprated datasets deterministically ({stem}_year_{year}.h5, src/policyengine/tax_benefit_models/us/datasets.py) and reuses them correctly across runs — the pattern the simulation layer is missing.
Second-order: the in-memory LRU pins whole simulations
ensure() ends with _cache.add(self.id, self) — the entire Simulation object, including its output_dataset, into a count-bounded LRU (max_size=100, src/policyengine/core/cache.py). In a multi-year loop this pins every year's outputs in RSS long after the loop variables go out of scope; we watched RSS climb steadily across a 10-year run for this reason. The memory warning it emits is also misleading: it reports process-wide RSS ("Memory usage has reached 32.77GB… Cache contains 1 items") — the RSS is dominated by the country simulation internals, not the one cached item.
Proposed design
- Content-addressed cache key. A stable digest over what actually determines the result:
- dataset identity (logical name + year + data build/version, e.g. the release-bundle
certified_data_build_idfor managed datasets, or file digest for custom ones) - tax-benefit model package + version
policyanddynamicparameter values, canonicalized (name, start, stop, value)- scoping strategy and resolved entity/extra variables
Expose asSimulation.cache_key; keep theuuid4idfor run identity/provenance (TRO run records keep their owncomposition_fingerprint).
- dataset identity (logical name + year + data build/version, e.g. the release-bundle
- Key
save()/load()and the in-memory LRU oncache_keyinstead ofid. Same-inputs reruns then load in seconds, across objects and processes. - Bound the in-memory cache by what it holds. Cache
output_datasetrather than the wholeSimulation, with a much smaller default (or byte-based bound), so multi-year loops don't accumulate RSS. - Garbage collection. With deterministic keys, orphans stop being produced; a
policyengine cache clear/prune for existing UUID orphans (or prune-unreadable-on-run) cleans up installations that already have gigabytes of them.
Impact
Multi-year revenue scoring (the 10-year budget window use case) currently costs a full recompute per invocation — ~2.5 hours for a 3-scenario × 10-year set that a functioning cache would serve in seconds after the first run — plus ~2 GB/evening of disk litter.
Related: #328 (example memory footprint — the LRU pinning above is one contributor), #320 (local reproducibility snapshots — a content-addressed key is also the natural handle for those).
Versions: policyengine 4.18.10, Python 3.13/3.14, macOS.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing Simulation.ensure(), save(), and load() in src/policyengine/core/simulation.py and src/policyengine/tax_benefit_models/common/model_version.py, then compare the deterministic dataset handling in src/policyengine/tax_benefit_models/us/datasets.py. Read src/policyengine/core/cache.py and docs/microsim.md to understand the current cache contract. Done means identical simulations reuse results across objects and processes without pinning whole simulations, and existing orphan files have a cleanup path.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend, performance
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100