Skip to content

Commit 7b9dd9d

Browse files
Maffoochclaude
andcommitted
Dispatch create-path notifications async to fix slow POST latency
POST /api/v2/engagements/ takes ~5s on large tenants because create_notification runs recipient enumeration and per-user Alert writes on the request thread. Move the outer create_notification to a Celery worker for the five create-path events (engagement_added, product_added, product_type_added, finding_added, test_added) by adding async_create_notification (accepts ids, re-fetches, delegates) and dispatching via dojo_dispatch_task. This extends the existing per-user async pattern (Slack/email/MSTeams/webhooks) up one level so the recipient query and Alert fan-out no longer block the response. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 577a9ed commit 7b9dd9d

9 files changed

Lines changed: 262 additions & 74 deletions

File tree

dojo/api_v2/serializers.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import dojo.risk_acceptance.helper as ra_helper
2929
from dojo.authorization.authorization import user_has_permission
3030
from dojo.authorization.roles_permissions import Permissions
31+
from dojo.celery_dispatch import dojo_dispatch_task
3132
from dojo.endpoint.utils import endpoint_filter, endpoint_meta_import
3233
from dojo.finding.helper import (
3334
save_endpoints_template,
@@ -116,7 +117,7 @@
116117
Vulnerability_Id,
117118
get_current_date,
118119
)
119-
from dojo.notifications.helper import create_notification
120+
from dojo.notifications.helper import async_create_notification
120121
from dojo.product_announcements import (
121122
LargeScanSizeProductAnnouncement,
122123
ScanTypeProductAnnouncement,
@@ -2086,10 +2087,11 @@ def create(self, validated_data):
20862087
jira_helper.push_to_jira(new_finding)
20872088

20882089
# Create a notification
2089-
create_notification(
2090+
dojo_dispatch_task(
2091+
async_create_notification,
20902092
event="finding_added",
20912093
title=_("Addition of %s") % new_finding.title,
2092-
finding=new_finding,
2094+
finding_id=new_finding.id,
20932095
description=_('Finding "%s" was added by %s') % (new_finding.title, new_finding.reporter),
20942096
url=reverse("view_finding", args=(new_finding.id,)),
20952097
icon="exclamation-triangle",

dojo/engagement/signals.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,27 @@
77
from django.urls import reverse
88
from django.utils.translation import gettext as _
99

10+
from dojo.celery_dispatch import dojo_dispatch_task
1011
from dojo.file_uploads.helper import delete_related_files
1112
from dojo.models import Engagement, Product
1213
from dojo.notes.helper import delete_related_notes
13-
from dojo.notifications.helper import create_notification
14+
from dojo.notifications.helper import async_create_notification, create_notification
1415
from dojo.pghistory_models import DojoEvents
1516

1617

1718
@receiver(post_save, sender=Engagement)
1819
def engagement_post_save(sender, instance, created, **kwargs):
1920
if created:
2021
title = _('Engagement created for "%(product)s": %(name)s') % {"product": instance.product, "name": instance.name}
21-
create_notification(event="engagement_added", title=title, engagement=instance, product=instance.product,
22-
url=reverse("view_engagement", args=(instance.id,)), url_api=reverse("engagement-detail", args=(instance.id,)))
22+
dojo_dispatch_task(
23+
async_create_notification,
24+
event="engagement_added",
25+
title=title,
26+
engagement_id=instance.id,
27+
product_id=instance.product_id,
28+
url=reverse("view_engagement", args=(instance.id,)),
29+
url_api=reverse("engagement-detail", args=(instance.id,)),
30+
)
2331

2432

2533
@receiver(pre_save, sender=Engagement)

dojo/importers/default_importer.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
Test,
1919
Test_Import,
2020
)
21-
from dojo.notifications.helper import create_notification
21+
from dojo.notifications.helper import async_create_notification
2222
from dojo.utils import get_full_url, perform_product_grading
2323
from dojo.validators import clean_tags
2424

@@ -140,12 +140,13 @@ def process_scan(
140140
)
141141
# Send out some notifications to the user
142142
logger.debug("IMPORT_SCAN: Generating notifications")
143-
create_notification(
143+
dojo_dispatch_task(
144+
async_create_notification,
144145
event="test_added",
145146
title=f"Test created for {self.test.engagement.product}: {self.test.engagement.name}: {self.test}",
146-
test=self.test,
147-
engagement=self.test.engagement,
148-
product=self.test.engagement.product,
147+
test_id=self.test.id,
148+
engagement_id=self.test.engagement_id,
149+
product_id=self.test.engagement.product_id,
149150
url=reverse("view_test", args=(self.test.id,)),
150151
url_api=reverse("test-detail", args=(self.test.id,)),
151152
)

dojo/notifications/helper.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,47 @@ def send_webhooks_notification(event: str, user_id: int | None = None, **kwargs:
911911
get_manager_class_instance()._get_manager_instance("webhooks").send_webhooks_notification(event, user=user, **kwargs)
912912

913913

914+
@app.task
915+
def async_create_notification(
916+
event: str,
917+
engagement_id: int | None = None,
918+
product_id: int | None = None,
919+
product_type_id: int | None = None,
920+
finding_id: int | None = None,
921+
test_id: int | None = None,
922+
**kwargs: dict,
923+
) -> None:
924+
# Re-fetch by id so the recipient-enumeration query and per-user Alert writes
925+
# run in the worker rather than the request thread.
926+
if engagement_id is not None:
927+
engagement = Engagement.objects.filter(pk=engagement_id).select_related("product").first()
928+
if engagement is None:
929+
return
930+
kwargs["engagement"] = engagement
931+
if product_id is not None:
932+
product = Product.objects.filter(pk=product_id).first()
933+
if product is None:
934+
return
935+
kwargs["product"] = product
936+
if product_type_id is not None:
937+
product_type = Product_Type.objects.filter(pk=product_type_id).first()
938+
if product_type is None:
939+
return
940+
kwargs["product_type"] = product_type
941+
if finding_id is not None:
942+
finding = Finding.objects.filter(pk=finding_id).select_related("test__engagement__product").first()
943+
if finding is None:
944+
return
945+
kwargs["finding"] = finding
946+
if test_id is not None:
947+
test = Test.objects.filter(pk=test_id).select_related("engagement__product").first()
948+
if test is None:
949+
return
950+
kwargs["test"] = test
951+
952+
create_notification(event=event, **kwargs)
953+
954+
914955
@app.task(ignore_result=True)
915956
def webhook_reactivation(endpoint_id: int, **_kwargs: dict) -> None:
916957
get_manager_class_instance()._get_manager_instance("webhooks")._webhook_reactivation(endpoint_id=endpoint_id)

dojo/product/signals.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
from django.urls import reverse
88
from django.utils.translation import gettext as _
99

10+
from dojo.celery_dispatch import dojo_dispatch_task
1011
from dojo.labels import get_labels
1112
from dojo.models import Product
12-
from dojo.notifications.helper import create_notification
13+
from dojo.notifications.helper import async_create_notification, create_notification
1314
from dojo.pghistory_models import DojoEvents
1415
from dojo.utils import get_current_user
1516

@@ -19,12 +20,14 @@
1920
@receiver(post_save, sender=Product)
2021
def product_post_save(sender, instance, created, **kwargs):
2122
if created:
22-
create_notification(event="product_added",
23-
title=instance.name,
24-
product=instance,
25-
url=reverse("view_product", args=(instance.id,)),
26-
url_api=reverse("product-detail", args=(instance.id,)),
27-
)
23+
dojo_dispatch_task(
24+
async_create_notification,
25+
event="product_added",
26+
title=instance.name,
27+
product_id=instance.id,
28+
url=reverse("view_product", args=(instance.id,)),
29+
url_api=reverse("product-detail", args=(instance.id,)),
30+
)
2831

2932

3033
@receiver(post_delete, sender=Product)

dojo/product_type/signals.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
from django.urls import reverse
99
from django.utils.translation import gettext as _
1010

11+
from dojo.celery_dispatch import dojo_dispatch_task
1112
from dojo.labels import get_labels
1213
from dojo.models import Product_Type
13-
from dojo.notifications.helper import create_notification
14+
from dojo.notifications.helper import async_create_notification, create_notification
1415
from dojo.pghistory_models import DojoEvents
1516

1617
labels = get_labels()
@@ -19,12 +20,14 @@
1920
@receiver(post_save, sender=Product_Type)
2021
def product_type_post_save(sender, instance, created, **kwargs):
2122
if created:
22-
create_notification(event="product_type_added",
23-
title=instance.name,
24-
product_type=instance,
25-
url=reverse("view_product_type", args=(instance.id,)),
26-
url_api=reverse("product_type-detail", args=(instance.id,)),
27-
)
23+
dojo_dispatch_task(
24+
async_create_notification,
25+
event="product_type_added",
26+
title=instance.name,
27+
product_type_id=instance.id,
28+
url=reverse("view_product_type", args=(instance.id,)),
29+
url_api=reverse("product_type-detail", args=(instance.id,)),
30+
)
2831

2932

3033
@receiver(post_delete, sender=Product_Type)

unittests/test_importers_performance.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -483,9 +483,9 @@ def test_deduplication_performance_pghistory_async(self):
483483

484484
self._deduplication_performance(
485485
expected_num_queries1=74,
486-
expected_num_async_tasks1=1,
487-
expected_num_queries2=69,
488-
expected_num_async_tasks2=1,
486+
expected_num_async_tasks1=2,
487+
expected_num_queries2=66,
488+
expected_num_async_tasks2=2,
489489
check_duplicates=False, # Async mode - deduplication happens later
490490
)
491491

@@ -503,10 +503,10 @@ def test_deduplication_performance_pghistory_no_async(self):
503503
testuser.usercontactinfo.save()
504504

505505
self._deduplication_performance(
506-
expected_num_queries1=81,
507-
expected_num_async_tasks1=1,
508-
expected_num_queries2=77,
509-
expected_num_async_tasks2=1,
506+
expected_num_queries1=89,
507+
expected_num_async_tasks1=2,
508+
expected_num_queries2=82,
509+
expected_num_async_tasks2=2,
510510
)
511511

512512

@@ -570,7 +570,7 @@ def test_import_reimport_reimport_performance_pghistory_async(self):
570570

571571
self._import_reimport_performance(
572572
expected_num_queries1=1191,
573-
expected_num_async_tasks1=6,
573+
expected_num_async_tasks1=7,
574574
expected_num_queries2=716,
575575
expected_num_async_tasks2=17,
576576
expected_num_queries3=346,
@@ -593,8 +593,8 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self):
593593
testuser.usercontactinfo.save()
594594

595595
self._import_reimport_performance(
596-
expected_num_queries1=1200,
597-
expected_num_async_tasks1=6,
596+
expected_num_queries1=1208,
597+
expected_num_async_tasks1=7,
598598
expected_num_queries2=725,
599599
expected_num_async_tasks2=17,
600600
expected_num_queries3=355,
@@ -618,8 +618,8 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr
618618
self.system_settings(enable_product_grade=True)
619619

620620
self._import_reimport_performance(
621-
expected_num_queries1=1210,
622-
expected_num_async_tasks1=8,
621+
expected_num_queries1=1218,
622+
expected_num_async_tasks1=9,
623623
expected_num_queries2=735,
624624
expected_num_async_tasks2=19,
625625
expected_num_queries3=359,
@@ -719,9 +719,9 @@ def test_deduplication_performance_pghistory_async(self):
719719

720720
self._deduplication_performance(
721721
expected_num_queries1=1411,
722-
expected_num_async_tasks1=7,
723-
expected_num_queries2=1016,
724-
expected_num_async_tasks2=7,
722+
expected_num_async_tasks1=8,
723+
expected_num_queries2=1013,
724+
expected_num_async_tasks2=8,
725725
check_duplicates=False, # Async mode - deduplication happens later
726726
)
727727

@@ -738,8 +738,8 @@ def test_deduplication_performance_pghistory_no_async(self):
738738
testuser.usercontactinfo.save()
739739

740740
self._deduplication_performance(
741-
expected_num_queries1=1420,
742-
expected_num_async_tasks1=7,
743-
expected_num_queries2=1132,
744-
expected_num_async_tasks2=7,
741+
expected_num_queries1=1428,
742+
expected_num_async_tasks1=8,
743+
expected_num_queries2=1137,
744+
expected_num_async_tasks2=8,
745745
)

0 commit comments

Comments
 (0)