feat: Optimize UserEvent decay by refactoring for ForeignKey and GenericForeignKey relationships, and adding reliability safeguards.#3550
Conversation
…ricForeignKey relationships, and adding reliability safeguards.
There was a problem hiding this comment.
Pull request overview
This PR refactors UserEventQuerySet.decay() to avoid N+1 updates by splitting decay logic for ForeignKey vs GenericForeignKey relationships, adding reliability guards, and introducing tests for regressions and query-count stability.
Changes:
- Refactor
decay()into_decay_with_fk()and_decay_with_gfk()usingselect_related/prefetch_relatedplusbulk_update. - Add safeguards to prevent reliability from decrementing below zero and to deduplicate shared
data_modelrows per cycle. - Add tests covering the prior IntegrityError crash, shared-row behavior, and constant query count.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
api_app/user_events_manager/queryset.py |
Refactors decay paths (FK vs GFK), adds dedup + reliability safeguards, switches to bulk updates. |
tests/api_app/user_events_manager/test_queryset.py |
Adds regression + query-count + shared-row tests for decay behavior. |
Comments suppressed due to low confidence (1)
api_app/user_events_manager/queryset.py:126
- In the GFK path,
models_listis already built with unique instances (you only append when(class, pk)is first seen). The extraunique_models = {m.pk: m for m in models_list}.values()dedup step is redundant and can be removed to simplify the code and avoid extra work.
for model_class, models_list in data_models_by_class.items():
unique_models = {m.pk: m for m in models_list}.values()
model_class.objects.bulk_update(unique_models, ["reliability"], batch_size=1000)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not objects.exists(): | ||
| return 0 | ||
|
|
There was a problem hiding this comment.
The objects.exists() guard adds an extra DB query on every decay() call and increases the race window between eligibility check and the actual updates. Since both _decay_with_fk() and _decay_with_gfk() already materialize events = list(...) and handle the empty case, consider removing the exists() check and relying on the later if not events: return 0 to avoid the redundant query.
| if not objects.exists(): | |
| return 0 |
| with transaction.atomic(): | ||
| if data_models_by_pk: | ||
| type(next(iter(data_models_by_pk.values()))).objects.bulk_update( | ||
| data_models_by_pk.values(), ["reliability"] | ||
| ) | ||
| self.model.objects.bulk_update(events, ["decay_times", "next_decay"]) |
There was a problem hiding this comment.
These bulk_update() calls go through the default manager (model_class.objects / self.model.objects) and transaction.atomic() uses the default DB. If decay() is invoked on a queryset with a non-default DB alias (e.g., .using('replica')), this can write to the wrong database. Use transaction.atomic(using=self.db) and run bulk updates via the queryset’s DB alias (e.g., self.model._base_manager.using(self.db).bulk_update(...) and model_class._base_manager.using(self.db).bulk_update(...)).
| data_models_by_pk.values(), ["reliability"] | ||
| ) | ||
| self.model.objects.bulk_update(events, ["decay_times", "next_decay"]) |
There was a problem hiding this comment.
_decay_with_fk() currently calls bulk_update() without a batch_size, while _decay_with_gfk() uses batch_size=1000. For large decay runs this can generate very large SQL statements (CASE expressions) and hit DB/driver parameter limits. Consider adding a batch_size here as well (and for the FK data_models_by_pk update) to keep query size bounded.
| data_models_by_pk.values(), ["reliability"] | |
| ) | |
| self.model.objects.bulk_update(events, ["decay_times", "next_decay"]) | |
| data_models_by_pk.values(), ["reliability"], batch_size=1000 | |
| ) | |
| self.model.objects.bulk_update(events, ["decay_times", "next_decay"], batch_size=1000) |
|
This pull request has been marked as stale because it has had no activity for 10 days. If you are still working on this, please provide some updates or it will be closed in 5 days. |
|
This pull request has been closed because it had no updates in 15 days. If you're still working on this fell free to reopen. |
Closes #3316
Description
Fixes:
reliabilitycrash fix : Only decrement ifreliability > 0. The old== 0check missed the-1case, causingbulk_updateto write-1and crash withIntegrityError(DBCHECKconstraint).Shared
data_modeldedup: Track data models by PK so two events sharing the same row decrement it exactly once per cycle, not twice (previously the last write won, under-decrementing).Refactored into
_decay_with_fk()/_decay_with_gfk(): Cleanly separates the FK path (select_related) from the GFK path (prefetch_related+ groupedbulk_update).exists()guard : Short-circuits early when no events are eligible.Tests added:
test_decay_reliability_zero_does_not_crash: regression for IntegrityError crashtest_decay_query_count_is_constant: asserts O(1) bulk queries viaCaptureQueriesContexttest_decay_shared_data_model_reliability_decrements_correctly: verifies shared rows decrement onceTest results:
Type of change
Checklist
developtype inside the archive
test_files.zipand you added the default tests for that mimetype in test_classes.py.Ruff) gave 0 errors.testsfolder). All the tests (new and old ones) gave 0 errors.