fix: use update_fields in ModelEntry.save() and related methods - #1038
fix: use update_fields in ModelEntry.save() and related methods#1038qizwiz wants to merge 8 commits into
Conversation
ModelEntry.save() already re-fetches the model and copies exactly the fields listed in save_fields, but then calls obj.save() without update_fields — causing a full-row write that can clobber concurrent updates to other columns (e.g. task metadata edited in the admin while a beat tick is in flight). Fix all three sites in ModelEntry: - save() → obj.save(update_fields=self.save_fields) - _disable() → model.save(update_fields=['no_changes', 'enabled']) - is_due() → model.save(update_fields=['enabled', 'total_run_count', 'no_changes']) The comment on save() already says "only change the fields we care about" — this makes that intent enforceable at the database level.
auvipy
left a comment
There was a problem hiding this comment.
without tests I am not sure about the changes
Verify that _disable(), is_due() one-off expiry, and ModelEntry.save() each pass update_fields rather than doing a full-model overwrite. These guard against the concurrent-write races the PR fixes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Thanks @auvipy — good call. I've added three regression tests in the latest commit:
Each test patches |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Fixed — shortened both docstrings to fit within the 88-char limit. |
…column no_changes is a class-level Python attribute used as a signal sentinel (PeriodicTasks.changed() reads instance.no_changes to decide whether to update last_change). Django rejects it in update_fields because it has no corresponding database column. Fix all three call sites: - _disable(): update_fields=['enabled'] only - is_due() one-off expiry: update_fields=['enabled', 'total_run_count'] - save(): filter no_changes out of save_fields before passing to update_fields Update regression tests to assert the corrected field lists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Found and fixed the CI failure — sorry for the breakage. Root cause: Fix: exclude
The attribute is still set on the instance before |
Django raises ValueError when update_fields is passed to save() on an object with no primary key (forces UPDATE but no row exists). In production _disable() and the one-off path in is_due() always operate on DB-persisted instances, but unit tests construct in-memory PeriodicTask objects without saving them first. Add `if model.pk:` guards so update_fields is used when the object exists in the DB and plain save() is used otherwise. Update regression tests to set pk=1 on mocked instances so they still exercise the update_fields path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Tests were added in commit f2182ea ( The test suite found a genuine issue in our own fix: |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1038 +/- ##
==========================================
+ Coverage 87.68% 87.70% +0.02%
==========================================
Files 32 32
Lines 1015 1017 +2
Branches 81 81
==========================================
+ Hits 890 892 +2
Misses 107 107
Partials 18 18 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Thanks @cclauss — those E501 violations were fixed in commit |
|
Hi @auvipy — three tests are included in the diff at
All 20 CI matrix jobs (Django 3.2–6.0, Python 3.9–3.14 + PyPy) pass. Let me know if you'd like a different style of test — happy to add an integration-level test that hits the actual database if that would be more convincing. |
| def _disable(self, model): | ||
| model.no_changes = True | ||
| model.enabled = False | ||
| model.save() |
There was a problem hiding this comment.
@qizwiz ,
It would be better to use:
If self.model._state.adding:
update_fields = None
else:
update_fields = ['enabled']
self.model.save(update_fields=update_fields)- there's nothing stopping a future developer from setting the pk before save.
- setting the var
update_fieldsallows the save method to be on the same line, which allows for better error reporting.
Definition:
https://github.com/django/django/blob/708482587943ee02e744ab030d70401d67f21da5/django/db/models/base.py#L457
Initial:
https://github.com/django/django/blob/stable/5.2.x/django/db/models/base.py#L474
When loaded from db:
https://github.com/django/django/blob/708482587943ee02e744ab030d70401d67f21da5/django/db/models/base.py#L585
Note: this is my personal opinion, this isn't a project I maintain, just use heavily.
There was a problem hiding this comment.
Good point — _state.adding is more correct for exactly the reason you describe: a future developer could set pk manually before the first save, and our if model.pk: guard wouldn't catch that case. I'll update all three call sites to use the _state.adding pattern. Thanks for the reference links.
|
Hi @auvipy — happy to address any remaining concerns. The current PR has:
Would love your eyes on it when you get a chance. Thanks! |
|
Hi @auvipy — following up. The current PR includes:
Is there anything else you'd like to see before this can move forward? Happy to adjust the approach or add more coverage. |
Replace 'if model.pk:' with '_state.adding' which is the idiomatic Django way to detect whether an instance is new. Using pk can produce incorrect results when pk is set manually before the first save. Update tests to set '_state.adding = False' instead of 'pk = 1' to simulate a DB-persisted instance.
|
Updated — both # _disable()
update_fields = None if model._state.adding else ['enabled']
model.save(update_fields=update_fields)
# is_due() one-off path
update_fields = (
None if self.model._state.adding
else ['enabled', 'total_run_count']
)
self.model.save(update_fields=update_fields)Tests updated to use |
There was a problem hiding this comment.
Pull request overview
This PR aims to prevent Celery beat’s ModelEntry from issuing full-row UPDATEs when it only intends to persist run-metadata/disable-state, reducing the risk of clobbering concurrent admin edits.
Changes:
- Use
update_fieldsinModelEntry.save()to update only run-metadata columns. - Use
update_fieldsin_disable()and one-off expiry handling inis_due(). - Add unit tests asserting the
update_fieldsbehavior; register the customcelerypytest marker.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
django_celery_beat/schedulers.py |
Restricts DB writes to specific fields via update_fields in ModelEntry save/disable paths. |
t/unit/test_schedulers.py |
Adds tests asserting scheduler save/disable calls pass update_fields. |
setup.cfg |
Registers the celery pytest marker used by the test suite. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| self.model.save() | ||
| update_fields = ( | ||
| None if self.model._state.adding | ||
| else ['enabled', 'total_run_count'] |
| with patch.object(m, 'save') as mock_save: | ||
| e.is_due() | ||
| mock_save.assert_called_once_with( | ||
| update_fields=['enabled', 'total_run_count'] |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
@qizwiz can you please cross check the suggestions? |
|
also fix the merge conflicts |
Problem
ModelEntry.save()re-fetches the model from the database and copies exactly the fields insave_fields = ['last_run_at', 'total_run_count', 'no_changes'], but then callsobj.save()withoutupdate_fields. This issues a full-rowUPDATE, overwriting every column with the values from the re-fetched object — including any concurrent changes made by another process (e.g. a task being edited in the admin while a beat tick is in flight).The same issue exists in
_disable()and the one-off task branch ofis_due().The comment on
save()already says:This PR makes that intent enforceable at the database level.
Fix
Three one-line changes in
ModelEntry:save()obj.save()obj.save(update_fields=self.save_fields)_disable()model.save()model.save(update_fields=['no_changes', 'enabled'])is_due()(one-off)self.model.save()self.model.save(update_fields=['enabled', 'total_run_count', 'no_changes'])Impact
Without
update_fields, every beat tick that processes a changed entry writes every column back to the database. In a multi-process Celery deployment (multiple beat instances, or beat + admin), this is a silent data-loss race: admin edits toname,task,args,kwargs,queue, etc. can be clobbered by a concurrent beat save.Found by pact, a static analysis tool for Django/async constraint violations.