Skip to content

Commit 1797a52

Browse files
authored
fix: n+1 issue in send_unread_notifications_by_email_to_users_matching_filters (baserow#4802)
1 parent 0d90ca0 commit 1797a52

3 files changed

Lines changed: 96 additions & 5 deletions

File tree

backend/justfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -576,11 +576,11 @@ alias mk := makemigrations
576576

577577
# Open Django shell with shell_plus and SQL logging. Pass --print-sql to enable SQL query logging.
578578
[group('2 - development')]
579-
shell_plus: _check-dev
579+
shell_plus *args: _check-dev
580580
#!/usr/bin/env bash
581581
set -euo pipefail
582582
{{ _load_env }}
583-
{{ uv_run }} baserow shell_plus
583+
{{ uv_run }} baserow shell_plus {{ args }}
584584

585585
alias sp := shell_plus
586586

backend/src/baserow/core/notifications/handler.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -702,9 +702,9 @@ def filter_and_annotate_users_with_notifications_to_send_by_email(
702702

703703
notifications_to_send_by_email_prefetch = Prefetch(
704704
"notifications",
705-
queryset=Notification.objects.filter(
706-
id__in=unsent_notification_subquery
707-
).distinct(),
705+
queryset=Notification.objects.filter(id__in=unsent_notification_subquery)
706+
.distinct()
707+
.select_related("sender"),
708708
to_attr="unsent_email_notifications",
709709
)
710710

backend/tests/baserow/core/notifications/test_notifications_handler.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from unittest.mock import MagicMock, call, patch
22

33
from django.conf import settings
4+
from django.db import connection
45
from django.db.models import Q
56
from django.test import override_settings
7+
from django.test.utils import CaptureQueriesContext
68

79
import pytest
810

@@ -14,6 +16,11 @@
1416
UserNotificationsGrouper,
1517
)
1618
from baserow.core.notifications.models import Notification, NotificationRecipient
19+
from baserow.core.notifications.registries import (
20+
EmailNotificationTypeMixin,
21+
NotificationType,
22+
notification_type_registry,
23+
)
1724
from baserow.core.user.handler import UserHandler
1825

1926
from .utils import custom_notification_types_registered
@@ -813,3 +820,87 @@ def test_email_notifications_are_not_sent_if_already_cleared_by_user(
813820
assert res.remaining_users_to_notify_count == 0
814821

815822
mock_get_mail_connection.assert_not_called()
823+
824+
825+
@pytest.mark.django_db(transaction=True)
826+
@patch("baserow.core.notifications.handler.get_mail_connection")
827+
def test_email_notifications_queries_are_limited(
828+
mock_get_mail_connection,
829+
data_fixture,
830+
mutable_notification_type_registry,
831+
):
832+
"""
833+
The query count for sending notification emails must not scale with the
834+
number of notifications or the number of users. In particular the
835+
prefetch must include select_related("sender") so that accessing
836+
notification.sender during email rendering doesn't cause one query per
837+
notification.
838+
"""
839+
840+
mock_connection = MagicMock()
841+
mock_get_mail_connection.return_value = mock_connection
842+
843+
class SenderAccessingNotification(EmailNotificationTypeMixin, NotificationType):
844+
type = "test_sender_accessing_notification"
845+
846+
@classmethod
847+
def get_notification_title_for_email(cls, notification, context):
848+
return f"Notification from {notification.sender.first_name}"
849+
850+
@classmethod
851+
def get_notification_description_for_email(cls, notification, context):
852+
return None
853+
854+
notification_type_registry.register(SenderAccessingNotification())
855+
try:
856+
sender = data_fixture.create_user(first_name="Sender")
857+
858+
# --- Warm-up run to prime internal caches (license cache, etc.) ---
859+
warmup_user = data_fixture.create_user()
860+
data_fixture.create_notification_for_users(
861+
recipients=[warmup_user],
862+
sender=sender,
863+
notification_type=SenderAccessingNotification.type,
864+
)
865+
NotificationHandler.send_unread_notifications_by_email_to_users_matching_filters(
866+
Q(pk=warmup_user.pk)
867+
)
868+
mock_get_mail_connection.reset_mock()
869+
mock_connection.reset_mock()
870+
871+
# --- Run 1: 1 user, 1 notification ---
872+
user_a = data_fixture.create_user()
873+
data_fixture.create_notification_for_users(
874+
recipients=[user_a],
875+
sender=sender,
876+
notification_type=SenderAccessingNotification.type,
877+
)
878+
879+
with CaptureQueriesContext(connection) as ctx_small:
880+
NotificationHandler.send_unread_notifications_by_email_to_users_matching_filters(
881+
Q(pk=user_a.pk)
882+
)
883+
assert len(ctx_small) # sanity
884+
885+
# --- Run 2: 2 users, 5 notifications each ---
886+
mock_get_mail_connection.reset_mock()
887+
mock_connection.reset_mock()
888+
889+
user_b = data_fixture.create_user()
890+
user_c = data_fixture.create_user()
891+
for _ in range(5):
892+
data_fixture.create_notification_for_users(
893+
recipients=[user_b, user_c],
894+
sender=sender,
895+
notification_type=SenderAccessingNotification.type,
896+
)
897+
898+
with CaptureQueriesContext(connection) as ctx_large:
899+
NotificationHandler.send_unread_notifications_by_email_to_users_matching_filters(
900+
Q(pk__in=[user_b.pk, user_c.pk])
901+
)
902+
903+
# Query count must be identical: no N+1 on sender, users, or notifications.
904+
assert len(ctx_large) == len(ctx_small)
905+
finally:
906+
notification_type_registry.unregister(SenderAccessingNotification.type)

0 commit comments

Comments
 (0)