Skip to content

Commit 24d02cb

Browse files
committed
Fixes #15194: Prevent enqueuing duplicate events for an object
1 parent 6027544 commit 24d02cb

File tree

5 files changed

+57
-40
lines changed

5 files changed

+57
-40
lines changed

netbox/extras/context_managers.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@ def event_tracking(request):
1313
:param request: WSGIRequest object with a unique `id` set
1414
"""
1515
current_request.set(request)
16-
events_queue.set([])
16+
events_queue.set({})
1717

1818
yield
1919

2020
# Flush queued webhooks to RQ
21-
flush_events(events_queue.get())
21+
if events := list(events_queue.get().values()):
22+
flush_events(events)
2223

2324
# Clear context vars
2425
current_request.set(None)
25-
events_queue.set([])
26+
events_queue.set({})

netbox/extras/events.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,21 @@ def enqueue_object(queue, instance, user, request_id, action):
5858
if model_name not in registry['model_features']['event_rules'].get(app_label, []):
5959
return
6060

61-
queue.append({
62-
'content_type': ContentType.objects.get_for_model(instance),
63-
'object_id': instance.pk,
64-
'event': action,
65-
'data': serialize_for_event(instance),
66-
'snapshots': get_snapshots(instance, action),
67-
'username': user.username,
68-
'request_id': request_id
69-
})
61+
assert instance.pk is not None
62+
key = f'{app_label}.{model_name}:{instance.pk}'
63+
if key in queue:
64+
queue[key]['data'] = serialize_for_event(instance)
65+
queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
66+
else:
67+
queue[key] = {
68+
'content_type': ContentType.objects.get_for_model(instance),
69+
'object_id': instance.pk,
70+
'event': action,
71+
'data': serialize_for_event(instance),
72+
'snapshots': get_snapshots(instance, action),
73+
'username': user.username,
74+
'request_id': request_id
75+
}
7076

7177

7278
def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
@@ -163,14 +169,14 @@ def process_event_queue(events):
163169
)
164170

165171

166-
def flush_events(queue):
172+
def flush_events(events):
167173
"""
168-
Flush a list of object representation to RQ for webhook processing.
174+
Flush a list of object representations to RQ for event processing.
169175
"""
170-
if queue:
176+
if events:
171177
for name in settings.EVENTS_PIPELINE:
172178
try:
173179
func = import_string(name)
174-
func(queue)
180+
func(events)
175181
except Exception as e:
176182
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))

netbox/extras/signals.py

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,6 @@ def run_validators(instance, validators):
5555
clear_events = Signal()
5656

5757

58-
def is_same_object(instance, webhook_data, request_id):
59-
"""
60-
Compare the given instance to the most recent queued webhook object, returning True
61-
if they match. This check is used to avoid creating duplicate webhook entries.
62-
"""
63-
return (
64-
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
65-
instance.pk == webhook_data['object_id'] and
66-
request_id == webhook_data['request_id']
67-
)
68-
69-
7058
@receiver((post_save, m2m_changed))
7159
def handle_changed_object(sender, instance, **kwargs):
7260
"""
@@ -112,14 +100,13 @@ def handle_changed_object(sender, instance, **kwargs):
112100
objectchange.request_id = request.id
113101
objectchange.save()
114102

115-
# If this is an M2M change, update the previously queued webhook (from post_save)
103+
# Ensure that we're working with fresh M2M assignments
104+
if m2m_changed:
105+
instance.refresh_from_db()
106+
107+
# Enqueue the object for event processing
116108
queue = events_queue.get()
117-
if m2m_changed and queue and is_same_object(instance, queue[-1], request.id):
118-
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
119-
queue[-1]['data'] = serialize_for_event(instance)
120-
queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
121-
else:
122-
enqueue_object(queue, instance, request.user, request.id, action)
109+
enqueue_object(queue, instance, request.user, request.id, action)
123110
events_queue.set(queue)
124111

125112
# Increment metric counters
@@ -179,7 +166,7 @@ def handle_deleted_object(sender, instance, **kwargs):
179166
obj.snapshot() # Ensure the change record includes the "before" state
180167
getattr(obj, related_field_name).remove(instance)
181168

182-
# Enqueue webhooks
169+
# Enqueue the object for event processing
183170
queue = events_queue.get()
184171
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
185172
events_queue.set(queue)
@@ -195,7 +182,7 @@ def clear_events_queue(sender, **kwargs):
195182
"""
196183
logger = logging.getLogger('events')
197184
logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
198-
events_queue.set([])
185+
events_queue.set({})
199186

200187

201188
#

netbox/extras/tests/test_event_rules.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import django_rq
66
from django.http import HttpResponse
7+
from django.test import RequestFactory
78
from django.urls import reverse
89
from requests import Session
910
from rest_framework import status
@@ -12,6 +13,7 @@
1213
from dcim.choices import SiteStatusChoices
1314
from dcim.models import Site
1415
from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
16+
from extras.context_managers import event_tracking
1517
from extras.events import enqueue_object, flush_events, serialize_for_event
1618
from extras.models import EventRule, Tag, Webhook
1719
from extras.webhooks import generate_signature, send_webhook
@@ -360,7 +362,7 @@ def dummy_send(_, request, **kwargs):
360362
return HttpResponse()
361363

362364
# Enqueue a webhook for processing
363-
webhooks_queue = []
365+
webhooks_queue = {}
364366
site = Site.objects.create(name='Site 1', slug='site-1')
365367
enqueue_object(
366368
webhooks_queue,
@@ -369,11 +371,32 @@ def dummy_send(_, request, **kwargs):
369371
request_id=request_id,
370372
action=ObjectChangeActionChoices.ACTION_CREATE
371373
)
372-
flush_events(webhooks_queue)
374+
flush_events(list(webhooks_queue.values()))
373375

374376
# Retrieve the job from queue
375377
job = self.queue.jobs[0]
376378

377379
# Patch the Session object with our dummy_send() method, then process the webhook for sending
378380
with patch.object(Session, 'send', dummy_send) as mock_send:
379381
send_webhook(**job.kwargs)
382+
383+
def test_duplicate_triggers(self):
384+
"""
385+
Test for erroneous duplicate event triggers resulting from saving an object multiple times
386+
within the span of a single request.
387+
"""
388+
url = reverse('dcim:site_add')
389+
request = RequestFactory().get(url)
390+
request.id = uuid.uuid4()
391+
request.user = self.user
392+
393+
self.assertEqual(self.queue.count, 0, msg="Unexpected jobs found in queue")
394+
395+
with event_tracking(request):
396+
site = Site(name='Site 1', slug='site-1')
397+
site.save()
398+
399+
# Save the site a second time
400+
site.save()
401+
402+
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")

netbox/netbox/context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77

88

99
current_request = ContextVar('current_request', default=None)
10-
events_queue = ContextVar('events_queue', default=[])
10+
events_queue = ContextVar('events_queue', default=dict())

0 commit comments

Comments
 (0)