Skip to content

Commit 296d9fc

Browse files
committed
Fix Flight denormalized field locking issues
Remove real-time updates to Flight.total_views and Flight.total_clicks to eliminate database lock contention on high-traffic flights. Changes: - Remove F() expression updates in Advertisement.incr() that were causing row-level locks on every impression - Add Flight.refresh_denormalized_totals() method to calculate these fields from AdImpression on-demand - Add refresh_flight_denormalized_totals() Celery task for periodic background updates - Add management command to manually refresh flight totals - Update Offer.refund() to skip Flight field updates - Update test to manually refresh denormalized fields The denormalized fields are now calculated from the source of truth (AdImpression) either on-demand or via scheduled tasks, avoiding the lock contention that was blocking concurrent ad impressions.
1 parent 4772841 commit 296d9fc

File tree

4 files changed

+116
-21
lines changed

4 files changed

+116
-21
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Management command to refresh denormalized total_views and total_clicks on Flight objects."""
2+
3+
import logging
4+
5+
from django.core.management.base import BaseCommand
6+
7+
from ...models import Flight
8+
9+
10+
log = logging.getLogger(__name__)
11+
12+
13+
class Command(BaseCommand):
14+
"""Refresh denormalized total_views and total_clicks for flights."""
15+
16+
help = "Refresh denormalized total_views and total_clicks fields for all flights"
17+
18+
def add_arguments(self, parser):
19+
parser.add_argument(
20+
"--live-only",
21+
action="store_true",
22+
help="Only refresh live flights",
23+
)
24+
parser.add_argument(
25+
"--flight-slug",
26+
type=str,
27+
help="Refresh only a specific flight by slug",
28+
)
29+
30+
def handle(self, *args, **options):
31+
live_only = options.get("live_only", False)
32+
flight_slug = options.get("flight_slug")
33+
34+
if flight_slug:
35+
flights = Flight.objects.filter(slug=flight_slug)
36+
if not flights.exists():
37+
self.stderr.write(self.style.ERROR(f"Flight '{flight_slug}' not found"))
38+
return
39+
elif live_only:
40+
flights = Flight.objects.filter(live=True)
41+
else:
42+
flights = Flight.objects.all()
43+
44+
count = flights.count()
45+
self.stdout.write(f"Refreshing denormalized totals for {count} flight(s)...")
46+
47+
for i, flight in enumerate(flights.iterator(), 1):
48+
try:
49+
flight.refresh_denormalized_totals()
50+
if i % 100 == 0:
51+
self.stdout.write(f" Processed {i}/{count} flights...")
52+
except Exception as e:
53+
self.stderr.write(
54+
self.style.ERROR(f"Failed to refresh flight '{flight.slug}': {e}")
55+
)
56+
57+
self.stdout.write(
58+
self.style.SUCCESS(
59+
f"Successfully refreshed denormalized totals for {count} flight(s)"
60+
)
61+
)

adserver/models.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1492,6 +1492,22 @@ def ecpc(self):
14921492
return value_delivered / clicks
14931493
return 0
14941494

1495+
def refresh_denormalized_totals(self):
1496+
"""
1497+
Refresh the denormalized total_views and total_clicks fields from AdImpression.
1498+
1499+
This should be called periodically (e.g., hourly or daily) instead of updating
1500+
these fields in real-time to avoid lock contention.
1501+
"""
1502+
aggregation = AdImpression.objects.filter(advertisement__flight=self).aggregate(
1503+
total_views=models.Sum("views"),
1504+
total_clicks=models.Sum("clicks"),
1505+
)
1506+
1507+
self.total_views = aggregation["total_views"] or 0
1508+
self.total_clicks = aggregation["total_clicks"] or 0
1509+
self.save(update_fields=["total_views", "total_clicks"])
1510+
14951511
def copy_niche_targeting_urls(self, other_flight):
14961512
"""Copy niche targeting URLs from another flight."""
14971513
if "adserver.analyzer" in settings.INSTALLED_APPS:
@@ -1909,15 +1925,11 @@ def incr(self, impression_type, publisher, offer=None):
19091925
for imp_type in impression_types:
19101926
assert imp_type in IMPRESSION_TYPES
19111927

1912-
# Update the denormalized fields on the Flight
1913-
if imp_type == VIEWS:
1914-
Flight.objects.filter(pk=self.flight_id).update(
1915-
total_views=models.F("total_views") + 1
1916-
)
1917-
elif imp_type == CLICKS:
1918-
Flight.objects.filter(pk=self.flight_id).update(
1919-
total_clicks=models.F("total_clicks") + 1
1920-
)
1928+
# NOTE: We no longer update the denormalized total_views/total_clicks fields
1929+
# in real-time to avoid lock contention on the Flight table.
1930+
# These fields are now calculated on-demand from AdImpression or
1931+
# refreshed periodically by a background task.
1932+
# See: Flight.refresh_denormalized_totals()
19211933

19221934
# Ensure that an impression object exists for today
19231935
# and make sure to query the writable DB for this
@@ -2917,19 +2929,14 @@ def refund(self):
29172929
**{self.impression_type: models.F(self.impression_type) - 1}
29182930
)
29192931

2920-
# Update the denormalized impressions on the Flight
2921-
# And the denormalized aggregate AdImpressions
2932+
# Update the denormalized aggregate AdImpressions
2933+
# Note: We no longer update Flight.total_views/total_clicks in real-time
2934+
# to avoid lock contention. These are refreshed periodically.
29222935
if self.viewed:
2923-
Flight.objects.filter(pk=self.advertisement.flight_id).update(
2924-
total_views=models.F("total_views") - 1
2925-
)
29262936
AdImpression.objects.filter(pk=impression.pk).update(
29272937
**{VIEWS: models.F(VIEWS) - 1}
29282938
)
29292939
if self.clicked:
2930-
Flight.objects.filter(pk=self.advertisement.flight_id).update(
2931-
total_clicks=models.F("total_clicks") - 1
2932-
)
29332940
AdImpression.objects.filter(pk=impression.pk).update(
29342941
**{CLICKS: models.F(CLICKS) - 1}
29352942
)

adserver/tasks.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,33 @@ def calculate_ad_ctrs(days=7, min_views=1_000):
924924
ad.save()
925925

926926

927+
@app.task()
928+
def refresh_flight_denormalized_totals():
929+
"""
930+
Refresh denormalized total_views and total_clicks fields for all live flights.
931+
932+
This task should be run periodically (e.g., hourly) to update the denormalized
933+
fields without causing lock contention on the Flight table.
934+
"""
935+
log.info("Refreshing denormalized totals for live flights")
936+
937+
# Only refresh active flights to avoid unnecessary work
938+
flights = Flight.objects.filter(live=True)
939+
940+
for flight in flights:
941+
try:
942+
flight.refresh_denormalized_totals()
943+
except Exception as e:
944+
log.error(
945+
"Failed to refresh denormalized totals for flight %s: %s",
946+
flight.slug,
947+
e,
948+
exc_info=True,
949+
)
950+
951+
log.info("Finished refreshing denormalized totals for %d flights", flights.count())
952+
953+
927954
@app.task()
928955
def notify_on_ad_image_change(advertisement_id):
929956
ad = Advertisement.objects.filter(id=advertisement_id).first()

adserver/tests/test_decision_engine.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,8 @@ def test_flight_clicks(self):
300300
self.advertisement1.incr(CLICKS, self.publisher)
301301
self.advertisement1.incr(CLICKS, self.publisher)
302302

303-
# Refresh the data on the include_flight - gets the denormalized views
304-
self.include_flight.refresh_from_db()
303+
# Refresh the denormalized totals manually since they're no longer updated in real-time
304+
self.include_flight.refresh_denormalized_totals()
305305

306306
self.assertEqual(self.include_flight.clicks_remaining(), 998)
307307
self.assertEqual(self.include_flight.total_clicks, 2)
@@ -315,8 +315,8 @@ def test_flight_clicks(self):
315315
# Add 1 click for today
316316
self.advertisement1.incr(CLICKS, self.publisher)
317317

318-
# Refresh the data on the include_flight - gets the denormalized views
319-
self.include_flight.refresh_from_db()
318+
# Refresh the denormalized totals manually since they're no longer updated in real-time
319+
self.include_flight.refresh_denormalized_totals()
320320

321321
self.assertEqual(self.include_flight.clicks_remaining(), 997)
322322
self.assertEqual(self.include_flight.clicks_today(), 1)

0 commit comments

Comments
 (0)