Skip to content

Commit 48f9809

Browse files
fix(mass_model_updater): read tracked fields via attribute access for skip_unchanged (#15114)
The skip-unchanged no-op check snapshotted each tracked field with model.__dict__.get(f) to avoid triggering a deferred-field load. But when a tracked field is deferred (the caller's queryset used .only()/.defer() and omitted it), __dict__ has no entry and .get() returns None. If function then recomputes that field to None, None == None marks the row unchanged and the write is silently dropped, persisting a stale value. Read the pre-function value with normal attribute access (getattr) instead. Deferred tracked fields are loaded and compared against their real persisted value, so a real change can never be mistaken for a no-op. The cost of a deferred tracked field is now a visible per-row load rather than silent data loss; callers needing the fast path must load the tracked fields in their queryset (documented). Adds regression tests: a deferred tracked field recomputed to None must issue an UPDATE and persist; one left unchanged must still skip the write.
1 parent 5e4c8d8 commit 48f9809

2 files changed

Lines changed: 57 additions & 6 deletions

File tree

dojo/utils.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1646,8 +1646,10 @@ def mass_model_updater(model_type, models, function, fields, page_size=1000, ord
16461646
16471647
When ``fields`` is given:
16481648
- skip_unchanged (default True): rows whose tracked ``fields`` were not changed by
1649-
``function`` are not written (compared against the values loaded from the page
1650-
query; deferred fields are read from ``__dict__`` so no extra query is issued).
1649+
``function`` are not written. The pre-``function`` value of each tracked field is
1650+
read with normal attribute access, so callers must ensure the tracked ``fields``
1651+
are loaded by the queryset (i.e. not excluded via ``.only()``/``.defer()``);
1652+
otherwise accessing a deferred tracked field issues a per-row query.
16511653
- writer (optional): a callable ``writer(model_type, batch, fields)`` used to persist
16521654
each batch instead of Django's ``bulk_update`` (e.g. a backend-specific fast path).
16531655
Defaults to ``bulk_update``.
@@ -1689,16 +1691,17 @@ def mass_model_updater(model_type, models, function, fields, page_size=1000, ord
16891691
i += 1
16901692
last_id = model.id
16911693

1692-
# snapshot tracked fields before mutation (read from __dict__ to avoid
1693-
# triggering a deferred-field load); used to skip no-op writes
1694+
# snapshot tracked fields before mutation; used to skip no-op writes.
1695+
# Read via normal attribute access so the real persisted value is compared
1696+
# (callers must load the tracked fields; see docstring).
16941697
before = None
16951698
if fields and skip_unchanged:
1696-
before = [model.__dict__.get(f) for f in fields]
1699+
before = [getattr(model, f) for f in fields]
16971700

16981701
function(model)
16991702

17001703
if fields and skip_unchanged and before is not None and all(
1701-
model.__dict__.get(f) == old for f, old in zip(fields, before, strict=True)
1704+
getattr(model, f) == old for f, old in zip(fields, before, strict=True)
17021705
):
17031706
# nothing changed for this row -> no write needed
17041707
pass

unittests/test_mass_model_updater.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,54 @@ def test_skip_unchanged_can_be_disabled(self):
130130
msg="skip_unchanged=False must still write unchanged rows",
131131
)
132132

133+
def test_writes_deferred_tracked_field_recomputed_to_none(self):
134+
# Regression (dojo-pro risk-mode SLA recalc): the before-snapshot reads each
135+
# tracked field with normal attribute access, so a deferred tracked field is
136+
# loaded and its real persisted value is compared -- never mistaken for an
137+
# absent/None value. Recomputing a populated field to None must still persist.
138+
ids = self._finding_ids()
139+
# Seed a non-None value via a normally-loaded queryset.
140+
mass_model_updater(
141+
Finding, Finding.objects.filter(id__in=ids),
142+
lambda f: setattr(f, "hash_code", f"seed-{f.id}"), fields=["hash_code"], order="asc",
143+
)
144+
# Reload deferring hash_code (the tracked field), then recompute it to None.
145+
deferred_qs = Finding.objects.filter(id__in=ids).only("id")
146+
with CaptureQueriesContext(connection) as ctx:
147+
mass_model_updater(
148+
Finding, deferred_qs,
149+
lambda f: setattr(f, "hash_code", None), fields=["hash_code"], order="asc",
150+
)
151+
self.assertGreater(
152+
len(_updates(ctx.captured_queries)), 0,
153+
msg="deferred tracked field recomputed to None must still issue an UPDATE",
154+
)
155+
for fid in ids:
156+
self.assertIsNone(
157+
Finding.objects.get(id=fid).hash_code,
158+
msg=f"finding {fid}: deferred field recomputed to None was not persisted",
159+
)
160+
161+
def test_skips_deferred_tracked_field_left_unchanged(self):
162+
# The flip side: a deferred tracked field that function() does NOT change must
163+
# still be recognised as unchanged (real value loaded and compared equal) and
164+
# issue no UPDATE -- the skip-unchanged optimization keeps working with .only().
165+
ids = self._finding_ids()
166+
mass_model_updater(
167+
Finding, Finding.objects.filter(id__in=ids),
168+
lambda f: setattr(f, "hash_code", f"keep-{f.id}"), fields=["hash_code"], order="asc",
169+
)
170+
deferred_qs = Finding.objects.filter(id__in=ids).only("id")
171+
with CaptureQueriesContext(connection) as ctx:
172+
mass_model_updater(
173+
Finding, deferred_qs,
174+
lambda f: setattr(f, "hash_code", f"keep-{f.id}"), fields=["hash_code"], order="asc",
175+
)
176+
self.assertEqual(
177+
len(_updates(ctx.captured_queries)), 0,
178+
msg="deferred tracked field left unchanged must not issue an UPDATE",
179+
)
180+
133181
def test_hashcode_values_writer_uses_values_sql_on_postgres(self):
134182
if connection.vendor != "postgresql":
135183
self.skipTest("VALUES fast path is postgres-only")

0 commit comments

Comments
 (0)