|
1 | 1 | import logging |
2 | 2 | import re |
3 | 3 | import time |
4 | | -from contextlib import suppress |
| 4 | +from contextlib import contextmanager, suppress |
5 | 5 | from threading import local |
6 | 6 | from urllib.parse import quote |
7 | 7 |
|
@@ -291,35 +291,75 @@ def _drain_search_context_to_async(objects, source): |
291 | 291 | objects.discard(entry) |
292 | 292 |
|
293 | 293 |
|
| 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 | + |
294 | 326 | def install_intermediate_flush_hook(): |
295 | 327 | """ |
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 |
298 | 330 | `WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE`, drain it into async tasks |
299 | 331 | and clear it in place. Bounds memory on long-running requests |
300 | 332 | (large imports) and starts celery batches earlier instead of |
301 | 333 | dispatching all at end-of-request. |
302 | 334 |
|
| 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 | +
|
303 | 344 | Idempotent — safe to call multiple times. |
304 | 345 | Setting WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE to 0 or below disables |
305 | 346 | the hook at runtime. |
306 | 347 | """ |
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): |
309 | 349 | return |
310 | 350 |
|
311 | | - original_add = cls.add_to_context |
| 351 | + original_add = search_context_manager.add_to_context # bound method |
312 | 352 |
|
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) |
315 | 355 | 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: |
317 | 357 | return |
318 | | - objects, is_invalid = self._stack[-1] |
| 358 | + objects, is_invalid = search_context_manager._stack[-1] |
319 | 359 | if is_invalid or len(objects) < threshold: |
320 | 360 | return |
321 | 361 | _drain_search_context_to_async(objects, source="AsyncSearchContextMiddleware[intermediate]") |
322 | 362 |
|
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") |
0 commit comments