mark_clean().
Fixed in: v0.11.0b2
Location: stash_graphql_client/types/base.py (_update_snapshot_for_fields), stash_graphql_client/store.py (populate())
Related docs:
- Pydantic Internals — Issue 1 covers how
_snapshotis stored viaPrivateAttr - Identity Map — covers the cache-hit merge path
- UnsetType Pattern — user-facing guide to
is_dirty(),mark_clean(), and_snapshot
The Problem: Phantom Dirty Fields After Population
When aStashObject is first cached as a minimal stub (e.g., a Scene nested inside a Performer response with just id + title), its _snapshot captures all tracked fields at their initial state — mostly UNSET. When the object is later populated with full server data via identity map merge or store.populate(), the snapshot is never updated. Every populated field appears dirty.
The Sequence
Why the Snapshot Isn’t Updated
The identity map merge path (lines 936-963 in_identity_map_validator) returns the existing cached object — it does not construct a new instance. This means:
model_post_initdoes not run again (it only runs during Pydantic construction)- The snapshot taken at original construction time is never refreshed
- The
setattrcalls used for merging are indistinguishable from user modifications
Real-World Impact
A downstream consumer had to manually callmark_clean() at the top of every update method:
save() sends full update mutations for every entity on every pass — even when nothing changed. On a dataset with 3,000 posts, that’s 3,000+ unnecessary GraphQL mutations per run.
Why Not Blanket mark_clean()?
A blanket mark_clean() after every merge would snapshot all tracked fields — including fields the user has locally modified but that weren’t part of the merge response. This silently discards pending user changes.
The Edge Case
title, but mark_clean() snapshotted it anyway.
The Fix: _update_snapshot_for_fields()
Instead of mark_clean() (which snapshots all tracked fields), a new method updates the snapshot only for fields that were in the merge data:
field_names & __tracked_fields__) ensures only declared tracked fields are touched — non-tracked fields and fields not in the merge data are left alone.
Call Site 1: Identity Map Merge (base.py)
After merging server data into a cached instance:
Call Site 2: populate() Return Path (store.py)
After populate() fetches and merges fields:
if fields_to_fetch guard avoids unnecessary work when populate() determined nothing needed fetching.
Behavior Matrix
Row 3 is the critical case: user modifications to fields not in the merge are preserved as dirty.
Row 2 (server overwriting a user modification) is pre-existing behavior of the identity map merge — the merge loop does
setattr() unconditionally for all fields in the response. Protecting user-modified fields from merge overwrite would be a separate concern.
Interaction Between the Two Call Sites
The two call sites are complementary, not redundant:
Both paths selectively update only the fields they touched.
Note:
populate() delegates to store.get() which calls from_graphql() which triggers the identity map merge (call site 1). Call site 2 then runs after any additional nested population is complete, capturing the final state.
Impact on Downstream Consumers
With this fix, consumers no longer need manualmark_clean() workarounds: