Skip to content

Commit 1523e09

Browse files
committed
Merge branch 'main' into copilot/update-docker-config-ubuntu-24-04
2 parents aa8d6e5 + b826a87 commit 1523e09

File tree

14 files changed

+278
-40
lines changed

14 files changed

+278
-40
lines changed

CHANGELOG.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@ CHANGELOG
66
.. This is included by docs/developer/changelog.rst
77
88
9+
Version v5.31.0
10+
---------------
11+
12+
This release added Redis and Celery monitoring to Sentry in production
13+
as well as Sentry profiling and tracing.
14+
There was also a change to fix some DB locking issues with the Flight denormalized fields
15+
which requires the Celery task queue to update these fields every few minutes
16+
(hence more monitoring of Celery).
17+
18+
:Date: November 20, 2025
19+
20+
* @davidfischer: Add Redis and Celery to Sentry monitoring (#1101)
21+
* @ericholscher: Fix Flight denormalized field locking issues (#1099)
22+
23+
924
Version v5.30.0
1025
---------------
1126

adserver/admin.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,9 +928,14 @@ def refund_impressions(self, request, queryset):
928928
)
929929

930930
count = 0
931+
flights = set()
931932
for impression in queryset:
932933
if impression.refund():
933934
count += 1
935+
flights.add(impression.advertisement.flight)
936+
937+
for flight in flights:
938+
flight.refresh_denormalized_totals()
934939

935940
messages.add_message(
936941
request,

adserver/models.py

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1492,6 +1492,28 @@ 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 every few minutes 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+
1510+
if self.sold_clicks > 0 and self.total_clicks > self.sold_clicks:
1511+
self.total_clicks = self.sold_clicks
1512+
if self.sold_impressions > 0 and self.total_views > self.sold_impressions:
1513+
self.total_views = self.sold_impressions
1514+
1515+
self.save(update_fields=["total_views", "total_clicks"])
1516+
14951517
def copy_niche_targeting_urls(self, other_flight):
14961518
"""Copy niche targeting URLs from another flight."""
14971519
if "adserver.analyzer" in settings.INSTALLED_APPS:
@@ -1909,15 +1931,11 @@ def incr(self, impression_type, publisher, offer=None):
19091931
for imp_type in impression_types:
19101932
assert imp_type in IMPRESSION_TYPES
19111933

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-
)
1934+
# NOTE: We no longer update the denormalized total_views/total_clicks fields
1935+
# in real-time to avoid lock contention on the Flight table.
1936+
# These fields are now calculated on-demand from AdImpression or
1937+
# refreshed periodically by a background task.
1938+
# See: Flight.refresh_denormalized_totals()
19211939

19221940
# Ensure that an impression object exists for today
19231941
# and make sure to query the writable DB for this
@@ -2917,19 +2935,14 @@ def refund(self):
29172935
**{self.impression_type: models.F(self.impression_type) - 1}
29182936
)
29192937

2920-
# Update the denormalized impressions on the Flight
2921-
# And the denormalized aggregate AdImpressions
2938+
# Update the denormalized aggregate AdImpressions
2939+
# Note: We no longer update Flight.total_views/total_clicks in real-time
2940+
# to avoid lock contention. These are refreshed periodically.
29222941
if self.viewed:
2923-
Flight.objects.filter(pk=self.advertisement.flight_id).update(
2924-
total_views=models.F("total_views") - 1
2925-
)
29262942
AdImpression.objects.filter(pk=impression.pk).update(
29272943
**{VIEWS: models.F(VIEWS) - 1}
29282944
)
29292945
if self.clicked:
2930-
Flight.objects.filter(pk=self.advertisement.flight_id).update(
2931-
total_clicks=models.F("total_clicks") - 1
2932-
)
29332946
AdImpression.objects.filter(pk=impression.pk).update(
29342947
**{CLICKS: models.F(CLICKS) - 1}
29352948
)

adserver/tasks.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.conf import settings
88
from django.contrib.sites.shortcuts import get_current_site
99
from django.core import mail
10+
from django.core.cache import cache
1011
from django.db.models import Count
1112
from django.db.models import F
1213
from django.db.models import FloatField
@@ -24,6 +25,7 @@
2425
from .constants import FLIGHT_STATE_CURRENT
2526
from .constants import FLIGHT_STATE_UPCOMING
2627
from .constants import PAID_CAMPAIGN
28+
from .constants import PUBLISHER_HOUSE_CAMPAIGN
2729
from .importers import psf
2830
from .models import AdImpression
2931
from .models import Advertisement
@@ -924,6 +926,41 @@ def calculate_ad_ctrs(days=7, min_views=1_000):
924926
ad.save()
925927

926928

929+
@app.task()
930+
def refresh_flight_denormalized_totals():
931+
"""
932+
Refresh denormalized total_views and total_clicks fields for all live flights.
933+
934+
This task should be run periodically (e.g., every 5-10 minutes) to update
935+
the denormalized fields without causing lock contention on the Flight table.
936+
"""
937+
start_time = timezone.now()
938+
log.info("Starting refresh of denormalized totals for live flights")
939+
940+
# Only refresh active flights to avoid unnecessary work
941+
flights = Flight.objects.filter(live=True).exclude(
942+
campaign__campaign_type=PUBLISHER_HOUSE_CAMPAIGN
943+
)
944+
total_flights = flights.count()
945+
946+
for flight in flights:
947+
flight.refresh_denormalized_totals()
948+
949+
# Update cache with last successful run timestamp
950+
cache.set(
951+
"flight_totals_last_refresh",
952+
timezone.now().isoformat(),
953+
timeout=None, # Never expire
954+
)
955+
956+
duration = (timezone.now() - start_time).total_seconds()
957+
log.info(
958+
"Finished refreshing denormalized totals: %d flights, took %.2fs",
959+
total_flights,
960+
duration,
961+
)
962+
963+
927964
@app.task()
928965
def notify_on_ad_image_change(advertisement_id):
929966
ad = Advertisement.objects.filter(id=advertisement_id).first()

adserver/tests/test_decision_engine.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -271,13 +271,13 @@ def test_custom_interval(self):
271271
self.assertTrue(self.backend.filter_flight(self.cpm_flight))
272272

273273
self.cpm_flight.total_views = 10_000 - pace - 1
274-
self.cpm_flight.save()
274+
self.cpm_flight.save(update_fields=['total_views'])
275275
self.assertEqual(self.cpm_flight.views_needed_this_interval(), 1)
276276
self.assertTrue(self.backend.filter_flight(self.cpm_flight))
277277

278278
# Don't show this flight anymore. It is above pace
279279
self.cpm_flight.total_views = 10_000 - pace
280-
self.cpm_flight.save()
280+
self.cpm_flight.save(update_fields=['total_views'])
281281
self.assertEqual(self.cpm_flight.views_needed_this_interval(), 0)
282282
self.assertFalse(self.backend.filter_flight(self.cpm_flight))
283283

@@ -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)
@@ -466,8 +466,8 @@ def test_clicks_needed(self):
466466
for _ in range(clicks_to_simulate):
467467
self.advertisement1.incr(CLICKS, self.publisher)
468468

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

472472
self.assertEqual(self.include_flight.clicks_needed_this_interval(), 23)
473473

@@ -491,8 +491,8 @@ def test_views_needed(self):
491491
for _ in range(views_to_simulate):
492492
self.advertisement1.incr(VIEWS, self.publisher)
493493

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

497497
self.assertEqual(self.cpm_flight.views_needed_this_interval(), 313)
498498

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Tests for health check endpoints."""
2+
3+
from datetime import timedelta
4+
5+
from django.core.cache import cache
6+
from django.test import TestCase
7+
from django.urls import reverse
8+
from django.utils import timezone
9+
10+
11+
class FlightTotalsHealthTest(TestCase):
12+
"""Tests for the flight totals health check endpoint."""
13+
14+
def setUp(self):
15+
"""Clear cache before each test."""
16+
cache.clear()
17+
18+
def test_health_check_no_cache(self):
19+
"""Test health check returns 503 when task has never run."""
20+
response = self.client.get(reverse("health-flight-totals"))
21+
self.assertEqual(response.status_code, 503)
22+
data = response.json()
23+
self.assertEqual(data["status"], "error")
24+
self.assertIn("never run", data["message"])
25+
26+
def test_health_check_recent_run(self):
27+
"""Test health check returns 200 when task ran recently."""
28+
# Set cache to indicate task ran 5 minutes ago
29+
cache.set("flight_totals_last_refresh", timezone.now().isoformat())
30+
31+
response = self.client.get(reverse("health-flight-totals"))
32+
self.assertEqual(response.status_code, 200)
33+
data = response.json()
34+
self.assertEqual(data["status"], "ok")
35+
self.assertLessEqual(data["minutes_since_refresh"], 1)
36+
37+
def test_health_check_stale_run(self):
38+
"""Test health check returns 503 when task hasn't run in a while."""
39+
# Set cache to indicate task ran 30 minutes ago (stale)
40+
stale_time = timezone.now() - timedelta(minutes=30)
41+
cache.set("flight_totals_last_refresh", stale_time.isoformat())
42+
43+
response = self.client.get(reverse("health-flight-totals"))
44+
self.assertEqual(response.status_code, 503)
45+
data = response.json()
46+
self.assertEqual(data["status"], "error")
47+
self.assertGreater(data["minutes_since_refresh"], 20)
48+
49+
def test_health_check_invalid_timestamp(self):
50+
"""Test health check returns 503 when cache contains invalid data."""
51+
cache.set("flight_totals_last_refresh", "invalid-timestamp")
52+
53+
response = self.client.get(reverse("health-flight-totals"))
54+
self.assertEqual(response.status_code, 503)
55+
data = response.json()
56+
self.assertEqual(data["status"], "error")
57+
self.assertIn("Invalid timestamp", data["message"])

adserver/tests/test_models.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,8 @@ def test_campaign_totals(self):
522522
self.ad1.incr(CLICKS, self.publisher)
523523
self.ad1.incr(CLICKS, self.publisher)
524524

525+
self.flight.refresh_denormalized_totals()
526+
525527
self.assertAlmostEqual(self.campaign.total_value(), 4.0)
526528

527529
cpm_flight = get(
@@ -551,6 +553,10 @@ def test_campaign_totals(self):
551553
ad2.incr(VIEWS, self.publisher)
552554
ad2.incr(VIEWS, self.publisher)
553555

556+
# Refresh denormalized totals for both flights
557+
self.flight.refresh_denormalized_totals()
558+
cpm_flight.refresh_denormalized_totals()
559+
554560
self.assertAlmostEqual(self.campaign.total_value(), 4.3)
555561

556562
def test_flight_state(self):
@@ -576,12 +582,12 @@ def test_flight_value_remaining(self):
576582
self.flight.save()
577583
self.assertAlmostEqual(self.flight.value_remaining(), 100 * 2)
578584

579-
# Each click is worth $2
585+
# Each click is $2
580586
self.ad1.incr(CLICKS, self.publisher)
581587
self.ad1.incr(CLICKS, self.publisher)
582588
self.ad1.incr(CLICKS, self.publisher)
583589

584-
self.flight.refresh_from_db()
590+
self.flight.refresh_denormalized_totals()
585591
self.assertAlmostEqual(self.flight.value_remaining(), 97 * 2)
586592

587593
self.flight.cpm = 50.0
@@ -596,7 +602,7 @@ def test_flight_value_remaining(self):
596602
for _ in range(25):
597603
self.ad1.incr(VIEWS, self.publisher)
598604

599-
self.flight.refresh_from_db()
605+
self.flight.refresh_denormalized_totals()
600606
self.assertAlmostEqual(self.flight.value_remaining(), 5.0 - (25 * 0.05))
601607

602608
def test_projected_total_value(self):
@@ -607,7 +613,7 @@ def test_projected_total_value(self):
607613
self.ad1.incr(CLICKS, self.publisher)
608614
self.ad1.incr(CLICKS, self.publisher)
609615

610-
self.flight.refresh_from_db()
616+
self.flight.refresh_denormalized_totals()
611617
self.assertAlmostEqual(self.flight.projected_total_value(), 1000 * 2)
612618

613619
self.flight.cpm = 50.0
@@ -727,7 +733,7 @@ def test_refund(self):
727733
for click in (click1, click2, click3):
728734
self.assertIsNotNone(click)
729735

730-
self.flight.refresh_from_db()
736+
self.flight.refresh_denormalized_totals()
731737

732738
self.assertEqual(self.flight.total_clicks, 3)
733739

@@ -760,7 +766,7 @@ def test_refund(self):
760766
self.assertTrue(offer.clicked)
761767

762768
# Reload data from the DB
763-
self.flight.refresh_from_db()
769+
self.flight.refresh_denormalized_totals()
764770
impression.refresh_from_db()
765771

766772
self.assertEqual(self.flight.total_clicks, 1)

adserver/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
from .views import dashboard
6868
from .views import do_not_track
6969
from .views import do_not_track_policy
70+
from .views import flight_totals_health
7071

7172

7273
urlpatterns = [
@@ -82,6 +83,12 @@
8283
# Do not Track
8384
path(r".well-known/dnt/", do_not_track, name="dnt-status"),
8485
path(r".well-known/dnt-policy.txt", do_not_track_policy, name="dnt-policy"),
86+
# Health checks
87+
path(
88+
r"health/flight-totals/",
89+
flight_totals_health,
90+
name="health-flight-totals",
91+
),
8592
# Ad API
8693
path(r"api/v1/", include("adserver.api.urls")),
8794
# Staff interface

0 commit comments

Comments
 (0)