Conversation
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>
This was referenced Oct 2, 2026
…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>
This was referenced Oct 2, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
With disk-backed holder storage (
simulation.memory_config = MemoryConfig(...)), every simulation in a family (the root, its branches and itsclone()s) wrote into one shared directory per variable,<data_storage_dir>/<variable>.Simulation.clonecopies_data_storage_dir, andHolder.create_disk_storagemadeOnDiskStorage(<data_storage_dir>/<variable>, preserve_storage_dir=False), so the storage that created it owned it and removed it in__del__.OnDiskStorage.clonepassed ownership along, but a holder a branch created itself (population.get_holderfor a variable the parent had no holder for) made a second owner of the same path.Confirmed on
master(7950c01):<dir>/income_tax, the root's holder then wrote<dir>/income_tax/default_2017-01.npy, and when the branch was garbage-collected (formulas routinelydel simulation.branches[name]) its__del__ranshutil.rmtreeon the shared directory. The root then raisedFileNotFoundErrorreading its own value.Simulation.clone()keepsbranch_name"default", soclone.set_input("salary", "2017-01", [5])overwrote the root'sdefault_2017-01.npy: the root read 5 instead of 1,000. No error was raised.Simulation.derivativeperturbs aclone()'s input this way, so with disk storagesim.derivative("income_tax", "salary", "2017-01", delta=100)returned 0 instead of 0.15 and left the simulation's own salary at 3,100 instead of 3,000.sim.get_branch("first").get_branch("nested")andsim.get_branch("second").get_branch("nested")both wrotenested_2017-01.npyin the same directory, so the first read the second's salary (2 instead of 1).Exception ignored while calling deallocator ... FileNotFoundError. When the last storage indata_storage_dirwent, its__del__also removed that directory, so a simulation that still used it failed inos.mkdirwhen it next created a holder.The new property test, run against
master, shrinks to two minimal failing sequences: settingsalaryand then replacing the holders (FileNotFoundError), and calculatingsalaryand then cloning (12FileNotFoundErrors raised in finalizers).Fix
Each storage writes only into a directory of its own.
Holder.create_disk_storage()(no explicit directory) returnsOnDiskStorage.temporary(variable, ...). That storage creates a uniquely named directory inside the simulation'sdata_storage_diron its first write.OnDiskStorage.clone()returns a storage that reads the source's files through copied mappings and writes into its own new directory.Readers are tracked per file. Each directory has one
StorageDirectoryhandle per process. For every file, the handle records (in aWeakSet) the other live storages that may read it: clones, copies, and storages thatrestore()d the directory.When a storage overwrites in place. Only when all of these hold:
os.register_at_forkcounter; a file from before a fork may be read by the other process);Otherwise it creates a new file with
O_EXCL:{key}.npyif the key has no file yet, else{key}.{n}.npynumbered above every file for the key. So a storage never overwrites a file it did not create, andrestore(), which maps each key to its highest-numbered file, finds the latest value. A simulation that never branches overwrites in place as before. A key whose file a live clone reads adds at most one more file, and once the clone is gone the storage overwrites in place again.Directories are reference-counted. A
StorageDirectoryremoves its directory in aweakref.finalize(withignore_errors=True) once nothing references it. Each storage holds its own handle and those of every directory its mappings may point into, so a directory outlives every storage that can read it.data_storage_dirhandle (Simulation.clonecopies it). Every storage inside it holds it as parent, includingcreate_disk_storage(preserve=True)'s.data_storage_dirthe user set is never removed; only the storages' own directories inside it are.Preserving.
preserve_storage_dircan still be set after construction. Preserving a directory also preserves the directories it is in, so a preserved storage survives its simulation family.Forks. A finalizer removes a directory only in the process that created it. After a fork, neither process overwrites a file created before the fork. Readers in another process are not tracked otherwise.
Copies.
copyanddeepcopyof a storage share its directory handles, and unpickling in the same process finds the live handle, so a copy keeps the directory while it reads from it. A copy is registered as a reader and never overwrites the original's files. Storages copy and pickle on Python 3.14, which dropped pickling foritertools.count.Invariants (each covered by tests)
_filesexists on disk.restore()returns each key's latest value, given that every storage writing the directory goes through these rules.data_storage_diris removed (unless something in it is preserved), and no finalizer raises.New tests (helpers in
tests/fixtures/disk_storage.py):tests/core/test_disk_storage_ownership.py: 31 example tests.derivative, andderivativeof a carried-over YEAR input (from the carry-over-order review's repro).derivativereusing files.restorepicking the latest version, including a branch name with..data_storage_dir.tests/core/test_disk_storage_differential.py: a Hypothesis property over random sequences of branch, clone,set_input(including anETERNITYvariable and an enum),calculate,derivative,apply_reform,delete_arrays, holder replacement and branch/clone drop.pytest.importorskip("hypothesis"), because the "Test Core and country packages" smoke job installs no dev dependencies.Against
master, 26 of the 32 new tests fail. 18 of them fail on the behavior above, and 8 only because they use the newOnDiskStorage.temporaryconstructor. The 6 that pass on master are:Against this branch's previous head (c7a0369), 10 of the new tests fail, one per round-3 finding. Two tests in
test_branch_scoped_delete_arrays.pypinned the old internals (clone.storage_dir == storage.storage_dir,clone.preserve_storage_dir is True,_storage_dir_owner); I rewrote them for the new contract.Not in this PR
Pre-existing, and the same on
master:_.OnDiskStorage.get_known_periods/get_known_branch_periodssplit keys on every_, which breaks for names likeno_salt. Read only periods the current branch can see when uprating or carrying over #552 already fixes that (_split_key), so the property test uses names without_.delete_arrays(var, "2017")removes the months in memory but only the exact key on disk. Another open PR, branchfix-delete-arrays-fast-cache, makesOnDiskStorage.deleteremove contained periods.:.InMemoryStorageraises for these; disk storage works.delete()and_invalidate_all_cachesforget mappings but leave files until the directory is removed, since another storage may read them.Overlap with other PRs
hypothesisdev dependency,uv.lockentry and.hypothesis/ignore. This branch mergesmaster, so those files no longer differ.on_disk_storage.py(delete,get_known_*), in nearby hunks.Tests
master:uv run pytest testsgave 1115 passed, 4 skipped, 1 xfailed (Python 3.13, with-W error::pytest.PytestUnraisableExceptionWarning).repro.py) and its earlier 16-scenario attack script pass, apart from the four pre-existing differences listed above. The local full-suite run stalled inside a test'sgc.collect()while the machine was swapping, so on this head the full suite is CI's.pytest -m smoke) in an environment without Hypothesis: collects cleanly; the property module is skipped.ruff format --check .andruff check .pass.make documentation.axiom: n/a: core storage infrastructure, no policy change
🤖 Generated with Claude Code