Skip to content

Commit 7857ff8

Browse files
fix(watson): fix + batch full-text index updates on Celery worker tasks (#15172)
* fix(watson): batch full-text index updates on Celery worker tasks Celery workers serve no HTTP request, so AsyncSearchContextMiddleware never opens a watson search_context. Without an active context, django-watson's post_save receiver indexes every saved object synchronously (one watson_searchentry DELETE+INSERT per object), so a bulk import running in a worker re-indexes findings one at a time — thousands of index writes. Add watson_search_context_for_task, a context manager wired via CELERY_TASK_CONTEXT_MANAGERS, that opens a search_context around each task so those saves accumulate and drain in WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE-sized async batches on exit, mirroring the request path. Also gate the intermediate size-flush hook on the shared global manager instance. The terminal index task (update_watson_search_index_for_model) builds its own local SearchContextManager to index one already-bounded batch; the class-level add_to_context monkeypatch would otherwise re-trigger the flush and re-dispatch that task for the same batch — infinite recursion under eager celery, an infinite re-dispatch loop (nothing ever indexed) on a real worker. * chore(perf): update importer perf counts for batched watson index task The new CELERY_TASK_CONTEXT_MANAGERS default (watson_search_context_for_task) makes finding-saving tasks dispatch one batched watson index task, so the product-grading import/reimport perf cases gain +1 async task and shed a couple of per-finding index queries. Counts taken from the CI auto-update (scripts/update_performance_test_counts.py) for the standalone OSS environment. * refactor(watson): bind intermediate-flush hook to the singleton instance Patch add_to_context on the module-global search_context_manager instance instead of the SearchContextManager class. Ad-hoc contexts (e.g. the one update_watson_search_index_for_model builds to index its own batch) keep the stock method, so the identity guard inside the wrapper is no longer needed -- the re-dispatch loop becomes unrepresentable instead of guarded against. watson's post_save receivers and the request/task paths all go through the singleton, so coverage is unchanged. Also adds the ad-hoc-context regression test (mirrors the guard test shipped to bugfix in #15187).
1 parent 193f4b7 commit 7857ff8

5 files changed

Lines changed: 100 additions & 26 deletions

File tree

dojo/middleware.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
import re
33
import time
4-
from contextlib import suppress
4+
from contextlib import contextmanager, suppress
55
from threading import local
66
from urllib.parse import quote
77

@@ -291,35 +291,75 @@ def _drain_search_context_to_async(objects, source):
291291
objects.discard(entry)
292292

293293

294+
@contextmanager
295+
def watson_search_context_for_task():
296+
"""
297+
Batch watson index updates for saves that happen inside a Celery task.
298+
299+
Celery workers serve no HTTP request, so ``AsyncSearchContextMiddleware`` never runs
300+
and no watson ``search_context`` is active while the task executes. Without an active
301+
context, django-watson's ``post_save`` receiver indexes every saved object
302+
synchronously — one DELETE + INSERT into ``watson_searchentry`` per object. A bulk
303+
import running in a worker (Pro's ``AsyncImporter``) then re-indexes findings one at a
304+
time, flooding the DB with thousands of index writes.
305+
306+
Opening a search_context around the task makes those saves accumulate and drain in
307+
``WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE``-sized async batches on exit, exactly like the
308+
request path does via ``AsyncSearchContextMiddleware``. Wire it in through
309+
``CELERY_TASK_CONTEXT_MANAGERS`` so ``PluggableContextTask`` enters it around every
310+
task. An empty context (a task that saved no indexed models) drains to nothing, so this
311+
is a cheap no-op for tasks that do not touch registered models.
312+
"""
313+
search_context_manager.start()
314+
try:
315+
yield
316+
finally:
317+
# Drain accumulated objects to async batched index-update tasks, then end the
318+
# (now-empty) context so watson's bulk-save short-circuits — mirrors
319+
# AsyncSearchContextMiddleware._close_search_context for the request path.
320+
if search_context_manager.is_active():
321+
objects, _is_invalid = search_context_manager._stack[-1]
322+
_drain_search_context_to_async(objects, source="watson_search_context_for_task")
323+
search_context_manager.end()
324+
325+
294326
def install_intermediate_flush_hook():
295327
"""
296-
Wrap `watson.search.search_context_manager.add_to_context` with a
297-
size-based flush. Once the per-request set reaches
328+
Wrap `add_to_context` on the module-global `watson.search.search_context_manager`
329+
with a size-based flush. Once the shared accumulation set reaches
298330
`WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE`, drain it into async tasks
299331
and clear it in place. Bounds memory on long-running requests
300332
(large imports) and starts celery batches earlier instead of
301333
dispatching all at end-of-request.
302334
335+
The wrapper is bound to the singleton INSTANCE, not the class: only the shared
336+
request/task accumulation context batches. A throwaway local SearchContextManager
337+
— e.g. the one update_watson_search_index_for_model builds to index one
338+
already-bounded batch — keeps the stock method and indexes its own batch on
339+
end(). If local contexts flushed too, that index task would re-dispatch itself
340+
for the same batch (infinite recursion under eager celery / re-dispatch loop on
341+
a worker). watson's post_save always adds to the module-global instance, so the
342+
singleton is the only place the flush is needed.
343+
303344
Idempotent — safe to call multiple times.
304345
Setting WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE to 0 or below disables
305346
the hook at runtime.
306347
"""
307-
cls = search_context_manager.__class__
308-
if getattr(cls, "_dd_intermediate_flush_installed", False):
348+
if getattr(search_context_manager, "_dd_intermediate_flush_installed", False):
309349
return
310350

311-
original_add = cls.add_to_context
351+
original_add = search_context_manager.add_to_context # bound method
312352

313-
def add_to_context_with_flush(self, engine, obj):
314-
original_add(self, engine, obj)
353+
def add_to_context_with_flush(engine, obj):
354+
original_add(engine, obj)
315355
threshold = getattr(settings, "WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE", 1000)
316-
if threshold <= 0 or not self._stack:
356+
if threshold <= 0 or not search_context_manager._stack:
317357
return
318-
objects, is_invalid = self._stack[-1]
358+
objects, is_invalid = search_context_manager._stack[-1]
319359
if is_invalid or len(objects) < threshold:
320360
return
321361
_drain_search_context_to_async(objects, source="AsyncSearchContextMiddleware[intermediate]")
322362

323-
cls.add_to_context = add_to_context_with_flush
324-
cls._dd_intermediate_flush_installed = True
325-
logger.debug("AsyncSearchContextMiddleware: intermediate flush hook installed on %s", cls.__name__)
363+
search_context_manager.add_to_context = add_to_context_with_flush
364+
search_context_manager._dd_intermediate_flush_installed = True
365+
logger.debug("AsyncSearchContextMiddleware: intermediate flush hook installed on the global search_context_manager")

dojo/settings/settings.dist.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,16 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
902902
WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE = env("DD_WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE")
903903
WATSON_INDEX_PREFETCH_ENABLED = env("DD_WATSON_INDEX_PREFETCH_ENABLED")
904904

905+
# Context managers wrapped around every Celery task by PluggableContextTask (see
906+
# dojo/celery.py). watson_search_context_for_task opens a watson search_context so bulk
907+
# finding saves that happen inside a worker task (which serves no HTTP request, so
908+
# AsyncSearchContextMiddleware never runs) accumulate and drain in batched async index
909+
# updates instead of indexing one finding at a time. Extend this list downstream (e.g. Pro)
910+
# rather than replacing it, so this batching stays wired.
911+
CELERY_TASK_CONTEXT_MANAGERS = [
912+
"dojo.middleware.watson_search_context_for_task",
913+
]
914+
905915
# Celery beat scheduled tasks
906916
CELERY_BEAT_SCHEDULE = {
907917
"add-alerts": {

dojo/tasks.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ def update_watson_search_index_for_model(model_name, pk_list, *args, **kwargs):
204204
instances_added = 0
205205
instances_skipped = 0
206206

207+
# This task IS the terminal drain: it accumulates one already-bounded batch (<= 1000
208+
# PKs) into its own local SearchContextManager and bulk-saves it once via end(). The
209+
# intermediate size-flush hook is bound to the global singleton instance only, so
210+
# this private context keeps the stock add_to_context, never re-triggers the flush,
211+
# and cannot re-dispatch itself.
207212
for instance in instances:
208213
try:
209214
# Add to watson context (this will trigger indexing on end())

unittests/test_importers_performance.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -392,14 +392,14 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr
392392
self.system_settings(enable_product_grade=True)
393393

394394
self._import_reimport_performance(
395-
expected_num_queries1=183,
396-
expected_num_async_tasks1=4,
397-
expected_num_queries2=140,
398-
expected_num_async_tasks2=3,
395+
expected_num_queries1=181,
396+
expected_num_async_tasks1=5,
397+
expected_num_queries2=138,
398+
expected_num_async_tasks2=4,
399399
expected_num_queries3=44,
400400
expected_num_async_tasks3=3,
401-
expected_num_queries4=109,
402-
expected_num_async_tasks4=2,
401+
expected_num_queries4=107,
402+
expected_num_async_tasks4=3,
403403
)
404404

405405
# Deduplication is enabled in the tests above, but to properly test it we must run the same import twice and capture the results.
@@ -719,14 +719,14 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr
719719
self.system_settings(enable_product_grade=True)
720720

721721
self._import_reimport_performance(
722-
expected_num_queries1=195,
723-
expected_num_async_tasks1=4,
724-
expected_num_queries2=154,
725-
expected_num_async_tasks2=3,
722+
expected_num_queries1=193,
723+
expected_num_async_tasks1=5,
724+
expected_num_queries2=152,
725+
expected_num_async_tasks2=4,
726726
expected_num_queries3=54,
727727
expected_num_async_tasks3=3,
728-
expected_num_queries4=113,
729-
expected_num_async_tasks4=2,
728+
expected_num_queries4=111,
729+
expected_num_async_tasks4=3,
730730
)
731731

732732
def _deduplication_performance(self, expected_num_queries1, expected_num_async_tasks1, expected_num_queries2, expected_num_async_tasks2, *, check_duplicates=True):

unittests/test_watson_intermediate_flush.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from unittest.mock import patch
1111

1212
from django.test import override_settings
13-
from watson.search import search_context_manager
13+
from watson.search import SearchContextManager, search_context_manager
1414

1515
from dojo.middleware import (
1616
_drain_search_context_to_async, # noqa: PLC2701 -- internal helper under test
@@ -119,3 +119,22 @@ def test_invalidated_context_skips_drain(self):
119119
# hook should detect the invalid flag and bail out.
120120
search_context_manager.add_to_context(engine_marker, p)
121121
drain.assert_not_called()
122+
123+
@override_settings(WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE=2)
124+
def test_ad_hoc_context_manager_does_not_drain(self):
125+
"""
126+
The flush wrapper is bound to the global singleton instance only. An ad-hoc
127+
SearchContextManager -- e.g. the one update_watson_search_index_for_model builds to
128+
index its own batch -- keeps the stock add_to_context and must NOT drain, or it would
129+
dispatch a clone of itself and loop forever (queue ~0, worker pegged, nothing indexed).
130+
"""
131+
adhoc = SearchContextManager()
132+
adhoc.start()
133+
try:
134+
with patch("dojo.middleware._drain_search_context_to_async") as drain:
135+
for p in self.products[:3]: # past the threshold of 2
136+
adhoc.add_to_context(object(), p)
137+
drain.assert_not_called()
138+
finally:
139+
adhoc.invalidate()
140+
adhoc.end()

0 commit comments

Comments
 (0)