Skip to content

Give each disk-backed holder storage its own directory - #558

Merged
MaxGhenis merged 13 commits into
masterfrom
fix-disk-storage-branch-ownership
Oct 9, 2026
Merged

MaxGhenis merged 13 commits into
masterfrom
fix-disk-storage-branch-ownership

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Problem and change

After adopting #585, independently recreated holders for the same variable still used the same temporary subdirectory. Collecting an old holder could remove files needed by its replacement or a clone. A missing caller-supplied storage directory also failed on first use, and overwriting an enum with a plain array retained stale enum metadata.

This PR's runtime changes relative to its merged master base are:

  • Holder.create_disk_storage() uses mkdtemp(prefix=f"{variable}_") for each independently created temporary storage. Explicit directory arguments and preserve=True retain the directory/variable dump layout.
  • Simulation.data_storage_dir creates missing caller-supplied directories and inherited parent directories on first use.
  • OnDiskStorage.put() removes enum metadata when the replacement is a plain array.

The implementation retains #585's TemporaryStorageDirectory ownership and clone/copy lifetime model. Cloned storages share the source directory and copy their mappings. Writes to shared files use mkstemp in replaced/; later writes reuse the writer's own unshared replacement. restore() reads direct key files only, ignores replaced/, and does not select the latest write across independently constructed storages. Independent writers using the same explicit directory have no snapshot-isolation guarantee. Files restored after cloning are also outside that guarantee; clone or copy after restoring to protect the restored values.

The API page describes these contracts and warns that exits skipping finalizers, such as os._exit in multiprocessing workers, can leave uniquely named holder subdirectories accumulating in a caller-owned directory.

Invariants and regression coverage

Existing property and differential coverage is preserved. No load-bearing runtime logic changed in this review follow-up.

  • Replacing holders leaves earlier holders' and clones' files readable; each independently created temporary holder owns a distinct directory.
  • Clone/copy protects the files shared when it is created from later family writes. Restoring additional files after cloning and independent same-directory writers are outside this guarantee.
  • Explicit and preserved storages retain the dump layout; caller-owned directories outside simulation-created temporary folders remain after normal collection. Temporary folders and nested caller-selected directories are removed when their owning references are released, subject to preservation and the documented fork lifetime limit.
  • Plain overwrites of enum values read back as plain arrays.
  • The disk/memory differential compares returned values or exception class names after generated operations, checks every live mapped file exists, and checks final cleanup without finalizer errors. It runs 75 generated examples of up to 30 operations plus two explicit holder-replacement examples. The directory-lifetime property retains its 150 generated examples.
  • Restore tests pin direct-file reads, including ignoring replacements after cloning. They provide no latest-writer invariant.

test_disk_storage_ownership.py contains 26 test functions and 33 parametrized cases. Most cover contracts inherited from #585; no claim is made that every case fails on the base. This review's untracked description, validation-provenance and cleanup-documentation probes failed on the saved original body/page and pass on the corrected versions. These are documentation/evidence regressions; the review identified no runtime P1/P2 defect.

Validation

Local results below are from c616364cd65d8a79ef10bbe275e94b91ef6167b4 on macOS with Python 3.13.9. Runtime and test files are identical to reviewed head 85c3e787be26e38966970dae2ba60362fbe26b30; the follow-up commit adds only the cleanup sentence. Dependency setup in a new environment was interrupted before tests ran; local runs use an existing read-only environment with Hypothesis 6.141.1 in a separate overlay, rather than claiming a fresh lockfile installation. Both property modules run without skipping.

Recorded local dependency versions: NumPy 2.5.3, pytest 9.1.1, h5py 3.16.0 and numexpr 2.14.2.

Each file ran separately in the foreground using uv run --python 3.13 --active --frozen --no-sync --with hypothesis==6.141.1 python -m pytest -q tests/core/<file> -W error::pytest.PytestUnraisableExceptionWarning. The environment selects the current checkout through PYTHONPATH and disables bytecode writes to keep external dependencies read-only.

File Result
test_disk_storage_ownership.py 33 passed
test_disk_storage_differential.py 1 passed
test_data_storage_dir.py 40 passed, 2 expected xfails
test_data_storage_dir_property.py 1 passed
test_on_disk_storage_clones.py 27 passed
test_branch_scoped_delete_arrays.py 6 passed
test_dump_restore.py 1 passed
test_holders.py 22 passed

Total: 131 passed, 2 expected xfails.

The two existing strict xfails in test_data_storage_dir.py document a forked child reading after its parent removes the inherited directory. They remain unchanged.

For the reviewed head, CI run 37847856790 has 19 successful checks, including core tests and country-package smoke checks on Linux and Windows with Python 3.11–3.14, lint, and changelog validation. CI checked out synthetic merge 501a2c27379612f5b123754b8a9b1470af312309, containing the reviewed head; it did not test the PR-only checkout. Its Ubuntu Python 3.13 core job reported 1791 passed, 4 skipped and 3 xfailed, plus 39 country-template cases; package and documentation builds also succeeded. These CI results precede the documentation follow-up and are recorded separately from the local results.

Full suites, Windows tests, country-package tests, package builds and make documentation were not rerun locally. ruff format --check and ruff check on the 10 Python files in the PR diff, plus git diff --check, passed.

Impact

Measured: focused synthetic storage/holder/dump tests, disk-versus-memory differential comparisons, directory-lifetime properties, and the existing CI checks described above. This is core disk-storage infrastructure used through MemoryConfig, with no policy formula change.

Microsimulations were not run. Country model-output differences, aggregate fiscal effects, partner baseline differences, performance and disk-space impact were not measured. CI's country smoke checks are initialization checks; microsimulation checks were skipped. The hub owns US/UK microsimulation and partner-impact measurement. No partner test files were edited.

Overlap, ordering and HOLD

Documentation review: durable storage contracts match the implementation; the review follow-up adds the caller-directory cleanup caveat. Impact is low and confidence is high for the documented storage behavior; cross-process lifetime limits and unmeasured country impact remain explicit.

MaxGhenis and others added 4 commits October 1, 2026 15:48
A holder created lazily in a branch made an OnDiskStorage that owned the
family's shared <data_storage_dir>/<variable> directory, so garbage-collecting
the branch deleted the parent's files. The shared directory also let a
Simulation.clone() or a same-named branch under another parent overwrite the
parent's .npy files, and let a parent's later write change what its branch
read.

Each storage now writes only into a directory of its own (created on first
write inside the simulation's data_storage_dir), a clone keeps alive every
directory it reads from, and a file a clone reads is never overwritten.
Directories are removed by finalizers on reference-counted StorageDirectory
handles, only in the process that created them.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Simulation.derivative perturbs a clone's input; with the shared directory it
overwrote the simulation's own input file and returned 0.

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

The country-package smoke job installs no dev dependencies, so a module-level
hypothesis import failed its collection. Shared helpers move to
tests/fixtures/disk_storage.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits October 2, 2026 01:58
…branch-ownership

# Conflicts:
#	policyengine_core/simulations/simulation.py
Another storage, possibly in another process, may have restored the same
directory and read those files.

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

Python 3.14 removed pickle/copy support from itertools objects, so
itertools.count made copy.deepcopy and pickle of an OnDiskStorage raise.
Found by the independent review.

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

Round-3 review (REQUEST_CHANGES) found that a preserved storage inside the
simulation's directory was removed with it, and that writing after restore()
could overwrite an older clone's file or make a later restore read a stale
value. The earlier attack script also showed permanent "shared" marks made
every re-store after any clone (such as derivative's) add a file.

- StorageDirectory keeps, per file, a WeakSet of the storages that may read
  it besides its creator (clones, copies, restorers); one handle per path per
  process.
- A storage overwrites only the newest file it created itself, since the
  last fork, that no live reader may read (and, outside its own temporary
  directory, that is still the key's highest-numbered file). Otherwise it
  creates a new file with O_EXCL, numbered above every file for the key.
- Preserving a directory preserves its parents; create_disk_storage(preserve=
  True) inside data_storage_dir takes the simulation's handle as parent.
- copy/deepcopy return the same directory handle and pickles find the live
  one, so copies keep the directory; copies never overwrite the original's
  files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 5 commits October 7, 2026 16:57
Preserve the recovered #585-based storage repair and the current master release workflow without rewriting either history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep the #594 release version and changelog alongside the unchanged disk storage repair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 9, 2026 00:27
@MaxGhenis
MaxGhenis merged commit f929fe5 into master Oct 9, 2026
19 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

US + core hub merge audit, core#558 at c616364cd65d8a79ef10bbe275e94b91ef6167b4 (squash)

@MaxGhenis
MaxGhenis deleted the fix-disk-storage-branch-ownership branch October 9, 2026 01:14
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.

1 participant