fix: produce() snapshots mutable values when recording patches#34
Merged
Conversation
PatchRecorder stored raw value references, so a recipe that mutated a value after assigning it through the proxy would silently mutate the earlier patch's stored value. Replaying the patches on the base produced a different result than the recipe itself. Snapshot mutable containers (dict/list/set-like, by duck typing) at record time for both the forward value and the reverse old_value. Primitives skip the deepcopy since they're already immutable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
copy.deepcopy already has a fast-path for atomic types — three hasattr calls per record were slower than just dispatching to the deepcopy atomic handler. Removing the gating speeds up every produce benchmark slightly and removes a helper that duplicated the duck-typing logic already in _wrap. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collaborator
|
I just want to recommend creating a local reference to Otherwise - jeez, expensive code! But I guess it is necessary :/ I don't recall immer.js doing this though... |
PatchRecorder calls deepcopy on every recorded op. Aliasing once at module scope replaces the per-call `copy` global lookup + `.deepcopy` attribute lookup with a single global lookup. Microbench on the atomic fast-path: ~18% faster per call. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cleaner than `import copy` + `_deepcopy = copy.deepcopy`, same effect: deepcopy is bound directly in the module namespace, so each call is a single global lookup with no attribute access. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collaborator
Author
|
Yeah, it ain't great, but I expect most changes made in the recipe to be relatively small changes. |
Merged
berendkleinhaneveld
added a commit
that referenced
this pull request
May 26, 2026
This version includes the following: * ci: Update versions of steps and let setup-uv manage the python version (#40) * perf: trim common prefix/suffix in diff_lists (#36) * perf: use str.replace in Pointer escape/unescape (#37) * fix: produce() snapshots mutable values when recording patches (#34) * perf: linear reverse-op construction in diff_dicts/diff_sets (#38) * fix: iapply raises on invalid patch paths (#35) * fix: to_json mutating caller's ops (#33)
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.
Summary
PatchRecorder.record_add/record_replace/record_removestored the rawvaluereference. If the recipe later mutated that container through the proxy, the earlier patch's storedvaluemutated with it, so replaying the patches diverged from the recipe's own result.The fix is one line per call site:
copy.deepcopy(value)before storing it in the patch dict, for both the forwardvalueand the reverseold_value.Reproducer (failing on
master)Output on
master:On this branch the assertion passes.
Regression tests added
In
tests/test_produce_core.py::TestPatchSnapshotting:test_dict_value_mutated_after_assignment_is_snapshotted— assigns a list to a dict key then.appends to it; verifies both forward and reverse patches replay.test_list_element_mutated_after_append_is_snapshotted— appends a dict to a list then mutates the appended dict's field viadraft[-1]['x'] = 2.test_nested_dict_in_list_in_dict_mutation_is_snapshotted— dict containing a list of dicts; mutates the nested tags lists and dict fields after the parent list was assigned.All three assert
apply(base, patches) == resultandapply(result, reverse) == base.Snapshot strategy: B (unconditional deepcopy)
The plan suggested starting with A (deepcopy only containers, decided by duck-typed
hasattrchecks). I tried both and measured.copy.deepcopyalready has a fast-path for atomic types (registered in_deepcopy_dispatch) that just returns the same object — cheaper than threehasattrMRO walks. Microbench on mixed primitive/container input: unconditional is ~0.92× the time of gated. The full produce benchmark suite agrees: unconditional matches or beats the gated version on every test (worst regression vs master dropped from +15.6% with gating to +8.9% without). So option B is both simpler and faster here.(
to_rawis a different tool — it unwraps an observ reactive proxy to the underlying raw container, but that container is still a shared mutable reference. The bug is about subsequent mutation, which needs an independent copy.produce()already callsto_rawon the base at entry, so reactive values shouldn't reach the recorder in normal use.)Performance:
produce-vs-diff-*groupsBaseline =
master@ a9ac89e (saved as0001). NOW = this branch.produce_deep_nested_mutation[in_place]produce_deep_nested_mutation[copy]produce_dict_small_mutations[in_place]produce_dict_small_mutations[copy]produce_dict_many_mutations[in_place]produce_dict_many_mutations[copy]produce_list_small_mutations[in_place]produce_list_small_mutations[copy]produce_list_many_appends[in_place]produce_list_many_appends[copy]produce_nested_structure[in_place]produce_nested_structure[copy]produce_set_mutations[in_place]produce_set_mutations[copy]produce_sparse_mutations_large_object[in_place]produce_sparse_mutations_large_object[copy]Worst single regression is +8.9% on
dict_many_mutations[in_place], well under the 30% threshold from the plan. Most benchmarks are within run-to-run noise.Test plan
uv run pytest tests/test_produce_core.py -v— 50 passed (incl. 3 new)uv run pytest -x -q— 271 passeduv run ruff check— cleanuv run ruff format --check— cleanproduce-vs-diff-*benchmarks compared againstmasterbaseline (table above)🤖 Generated with Claude Code