Skip to main content
This document explains why server-populated fields must update the dirty tracking snapshot, and why a selective (per-field) approach is required instead of a blanket 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:

The Problem: Phantom Dirty Fields After Population

When a StashObject 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_init does not run again (it only runs during Pydantic construction)
  • The snapshot taken at original construction time is never refreshed
  • The setattr calls used for merging are indistinguishable from user modifications

Real-World Impact

A downstream consumer had to manually call mark_clean() at the top of every update method:
Without the workaround, 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

The merge didn’t touch 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:
The set intersection (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:
The 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 manual mark_clean() workarounds: