refactor(storage): declare record children through one ChildItems interface - #905
Open
pmrv wants to merge 1 commit into
Open
refactor(storage): declare record children through one ChildItems interface#905pmrv wants to merge 1 commit into
pmrv wants to merge 1 commit into
Conversation
…erface Both mending layers wrap a value in a record of their own — the `Digested` wrappers a destructuring save writes, the `FileBlob` / `DirectoryBlob` a path save writes — which makes each record a node in the value store's reference graph rather than a leaf. Until now each layer described its records separately to each reader that walks them: the loader learned the children from `mend` / `_materialize`, and the sweep behind `gc` / `count_reuses` learned them from a hand-written `_raw_sub_digests` override per mixin. Describing the same edges once per reader is what let `gc` destroy stored paths: the blobs told the loader what they held and told the sweep nothing, so the sweep saw leaves, judged the content bytes unreferenced, and reclaimed them out from under records still pointing at them. The missing half was silence, not an error, which is why nothing caught it. Add `storage.base.ChildItems`: one interface, below both mixins, that a record implements to enumerate its `(label, child)` slots, with a concrete `child_digests()` derived from that enumeration. `Digested`, `FileBlob` and `DirectoryBlob` implement it for their own types; `ValueStorage._raw_sub_digests` becomes a single generic reader over it, and both per-mixin overrides go away. Directory materialization now walks the same declaration the sweep does, so a record cannot describe itself to one reader and not the other. This also closes the gap tracked as #883: the old `match` in `DestructuringMixin._raw_sub_digests` enumerated the three built-in wrapper types by name, so a wrapper registered through `register_destructurer` reported zero children whatever it held — `gc` reclaimed its sub-values as orphans and the next load raised `KeyError`, reported as an ordinary cache miss. Reading the record's own declaration, a registered type participates automatically. `test_gc_follows_a_custom_destructurer_s_wrapper` pins it and is differential: 0 children before, 2 after. Behaviour and performance are otherwise unchanged. `mend` still reads its own attributes rather than routing through `child_items` — the two read the same attribute, so there is no drift to remove, and the tuple-per-element the indirection costs measured ~70% slower on a 10k-element `mend`. Full suite green (1917 passed, 11 skipped), `ty` clean, docs build clean. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESPWjrgZ3RsY3znPkfYB1g
Contributor
Benchmark ResultsBaseline: bb3fe62 (recomputed) · HEAD~: cfe0ed9 DeltaSignificant changes (|Δ| > 10%): 0 — 240 other rows hiddenno significant changes Full resultsCall Storagecalls
Digest
Integrationcompute_heavy
data_heavy
lightweight
Value Storagenested_structures
numpy_arrays
small_strings
|
pmrv
added a commit
that referenced
this pull request
Aug 31, 2026
Scheduled agents-docs audit. No commits landed on `main` since the last pass (`a28d51f`, PR #904), so the module map and tracker entries were left alone; the only delta is PR #905 (opened 2026-08-30). - #883 tracker entry now points at PR #905: `storage.base.ChildItems` interface, `child_items()` implemented on the record classes, generic `child_digests()` replacing the hardcoded `match` and the blob branches, plus the differential gc test the entry asked for. Notes that it targets the `temppath` branch and that `mend` deliberately does not route through `child_items` (~70% slower, no correctness gain). - In-flight PR list gains #905; "nothing else open" date bumped 2026-08-29 → 2026-08-31. --- _Generated by [Claude Code](https://claude.ai/code/session_01RZSG1UwLcWtahZ6h45aiMz)_ Co-authored-by: Claude <noreply@anthropic.com>
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.
A minimal take on your suggestion in #797 (comment):
Based on
temppathand targeting it, so #797's diff stays reviewable on its own.The shape
storage.base.ChildItems— one interface, below both mixins:Implemented for their specific types by the two layers' record classes:
child_items()DigestedIterable[(None, v) for v in self.items]DigestedMappingDigesttoo)DigestedFieldslist(self.fields.items())FileBlob[(self.name, self.content)]DirectoryBloblist(self.contents.items())ValueStorage._raw_sub_digestsbecomes one generic reader over it —raw.child_digests() if isinstance(raw, ChildItems) else set()— and both per-mixin overrides go away: the 22-linematchinDestructuringMixinand the blob branches inPathValueMixin. Directory materialization walksblob.child_items(), the same list the sweep walks._raw_sub_digestsstays overridable, for a storage whose records are of a type it cannot make implement the interface.What this fixes, and what it only tidies
The path blobs are already fixed. Here this is locality, not a fix.
28df4cdclosed finding 1 with two changes, and it is worth being exact about them because the first is easy to misremember as a missing override. Onmain,_raw_sub_digestsis a private helper ofDestructuringMixin— there is noValueStorage._raw_sub_digestsand noload_raw. That commit is what lifted the method ontoValueStorage, made it a cooperative chain, and added the path override. There was no unfilled slot; the fix built the slot and filled it.The second cause is the one an override alone would not have reached: the walk read through
load, which mends, and mending resolves the child references away — a materializedPathno longer knows which blob it came from. Notably that read (super().load) had been effectively raw onmain, landing onValueMixin.load; insertingPathValueMixinbelowDestructuringMixinin the MRO is what turned the same call into a mending one. Henceload_raw.So for the blobs alone,
ChildItemsmoves the declaration ontoFileBlob/DirectoryBlob, next to the fields it describes. That is cleanup. It is not what makes this worth merging.The
Digestedside is an open correctness bug: #883.The
matchinDestructuringMixin._raw_sub_digestsenumerates the three built-in wrapper types by name.register_destructurer's extension point is a class, not a mixin — so a user's wrapper is named by no override anywhere and reports zero children whatever it holds.gcreclaims its sub-values as orphans and the next load raisesKeyError, which the wrapper reports as an ordinary cache miss. Silent data loss on routine maintenance, and no amount of overriding on any mixin can reach it, because there is no mixin involved in registering the type.Your tracker entry proposes exactly this hook (
child_digests()on the classes). The only addition here is that the path blobs implement the same one rather than a parallel mechanism.test_gc_follows_a_custom_destructurer_s_wrapperpins it and is differential — run againsttemppath'ssrc/to be sure it fails for the right reason:That is the "differential gc test with a custom destructurer" #883 asks for; nothing exercised that combination before.
Summary: one open correctness bug closed, plus a consolidation that removes two hand-written overrides. If you would rather take #883 on its own later,
temppathneeds nothing from this PR.A smaller alternative, for the record
#883 can also be closed without a new interface: keep
DestructuringMixin._raw_sub_digests, but make it generic overDigested.underlying()— shallow-scan that forDigests instead of matching three type names.underlying()is already abstract, so every subclass has one. Smaller diff, no contract change, andPathValueMixinkeeps its blob arm.I did not take it because
underlying()'s documented contract is digest-equivalence, not child enumeration — the two coincide today rather than by design, and a shallow scan needs its own rule for a wrapper holding a plain container ofDigests one level down. It also leaves the path side declared away from the type. Both are judgement calls; say the word if you prefer the smaller one.What I deliberately did not do
mendstill reads its own attributes rather than routing throughchild_items. I tried it. The two read the same attribute, so there is no drift to eliminate, and the tuple-per-element the indirection costs measured ~70% slower on a 10k-elementmend(555 µs → 943 µs) — a real cost on the load path for no correctness gain.child_itemsis the declaration for readers that walk the record; reconstruction stays each type's own business._slots/sunder/PathValueMixin.savestay as they are. Unifying admission too would mean reconciling destructuring's inline-vs-store depth logic with "a path always writes out", which is a much bigger change than the gc problem needs.find_pathchanges. It mirrorsdigest, deliberately, not this.Contract change, worth a look
child_itemsis abstract, so an out-of-treeDigestedsubclass must now implement it — it breaks loudly at instantiation instead of silently reporting no children. I think loud is right for a hook whose failure mode is data loss, and there are no such subclasses in-repo, but it is a judgement call and easy to reverse: a concrete default oftype(self)._slots(self.underlying())covers every shape-preserving wrapper, at the cost of going quiet again for one that isn't.docs/dev/extending_destructurer.rstdocuments the contract either way.Verification
pytest tests/— 1917 passed, 11 skipped (the +1 overtemppathis the new test)ty check src/— clean, on the pinnedty == 0.0.69sql-backends,ty,triagemenddecision abovepandoc, and none are from the new referencesdocs/dev/path_storage.rst("Blobs must declare their references"),docs/dev/extending_destructurer.rst(the subclass contract), and thedestructuring.pymodule-map line inagents/DEVELOPING.mdLeft alone for you: #883's tracker entry, and the four questions still open on #797 — none of them are blocked by this.
Body revised after checking the branch history: the original claimed the path bug was a layer describing its records "once per reader", which reads as if an override slot sat unfilled. It didn't —
28df4cdcreated the slot. The case for this PR is #883, not a recurrence of finding 1.