fix(watson): fix + batch full-text index updates on Celery worker tasks#15172
Merged
mtesauro merged 3 commits intoJul 9, 2026
Merged
Conversation
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.
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.
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 DefectDojo#15187).
Maffooch
approved these changes
Jul 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Celery workers serve no HTTP request, so
AsyncSearchContextMiddlewarenever opens a watsonsearch_context. Without an active context, django-watson'spost_savereceiver falls into its else-branch and indexes every saved object synchronously — onewatson_searchentryDELETE + INSERT per object. Any bulk save that runs in a worker (e.g. a large async import) then re-indexes objects one at a time, flooding the DB with thousands of index writes.This PR refactors the performance test to perform a real API call. This results in queries and celery tasks triggered by middleware is now also captured. This explains the small increase in counts in this PR. But this actually a decrease because of the watson fix bundled in this PR.
Approach
Phased, to confirm the problem before fixing it (regression test added downstream in DefectDojo Pro, where the async-import worker path lives — see the paired Pro PR):
watson_searchentryINSERTs (query-count explosion). The failing test asserted the batched expectation.Changes
watson_search_context_for_task(dojo/middleware.py) — a context manager that opens a watsonsearch_contextfor the duration of a Celery task so saves accumulate and drain inWATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE-sized async batches on exit, mirroringAsyncSearchContextMiddlewarefor the request path. An empty context (task saved no indexed models) drains to nothing — a cheap no-op.CELERY_TASK_CONTEXT_MANAGERSdefault (dojo/settings/settings.dist.py) — wires the CM in;PluggableContextTaskenters it around every task. Downstreams (e.g. Pro) extend this list rather than replacing it.dojo/middleware.py,dojo/tasks.py) —install_intermediate_flush_hook()now binds the flush wrapper to the module-globalsearch_context_managerinstance instead of monkeypatchingSearchContextManagerat class level. The terminal index taskupdate_watson_search_index_for_modelbuilds its own localSearchContextManagerto index one already-bounded batch; with the class-level patch, a full 1000-PK batch re-triggered the flush from inside the task and re-dispatched a clone of itself — infinite recursion under eager celery, an infinite re-dispatch loop (nothing ever indexed) on a real worker. Binding to the singleton makes that unrepresentable: watson'spost_saveand the request/task paths all go through the module-global instance (which flushes), while private throwaway contexts keep the stockadd_to_contextand simply index their batch onend(). The same loop is fixed on the release line by the minimal identity guard in fix(watson): only intermediate-flush the global search context singleton #15187 (extracted from perf(importers): add defer_product_grading option + harden watson index-flush hook #15165); this PR supersedes the guard ondevwith the structural fix.Test results
Verified in the integrated (Pro) environment; a downstream regression test drives the worker path (
AsyncImporter) end-to-end:watson_searchentryINSERTs ~1000+ → 14 (ceil(1000/100) + ceil(373/100)), with 1373/1373 findings still indexed exactly once (no findings dropped, no recursion).unittests/test_watson_intermediate_flush.pypasses (5/5), including the newtest_ad_hoc_context_manager_does_not_drainregression test: an ad-hocSearchContextManagerpushed past the flush threshold must not drain (it would re-dispatch a clone of itself and loop). The existing tests exercise the global manager, which still flushes.update_watson_search_index_for_modeltask on unguarded code self-replicates without bound (10 → 200+ tasks in ~3 min); on this branch the same dispatch executes exactly once and indexes its batch.OSS-standalone CI will run the full
unittests/suite for final confirmation.Labels
CELERY_TASK_CONTEXT_MANAGERSinsettings.dist.py)