Skip to content

Commit 2d6e3cc

Browse files
committed
feat: Add the command to schedule the celery task for populating the index.
Signed-off-by: Farhaan Bukhsh <farhaan@opencraft.com>
1 parent acc2797 commit 2d6e3cc

3 files changed

Lines changed: 203 additions & 20 deletions

File tree

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,87 @@
11
"""
2-
Command to build or re-build the search index for courses (in Studio, i.e. Draft
3-
mode), in Meilisearch.
2+
Command to queue incremental population of the Studio Meilisearch search index.
3+
4+
Index creation, configuration, and schema reconciliation are handled
5+
automatically via the post_migrate signal. This command is solely
6+
responsible for enqueuing the population task in Celery.
47
58
See also cms/djangoapps/contentstore/management/commands/reindex_course.py which
69
indexes LMS (published) courses in ElasticSearch.
710
"""
11+
12+
import logging
13+
814
from django.core.management import BaseCommand, CommandError
915

1016
from ... import api
17+
from ...tasks import rebuild_index_incremental
18+
19+
log = logging.getLogger(__name__)
1120

1221

1322
class Command(BaseCommand):
1423
"""
15-
Build or re-build the Meilisearch search index for courses and libraries in Studio.
24+
Add all course and library content to the Studio search index.
25+
26+
This enqueues a Celery task that incrementally indexes all courses and
27+
libraries. Progress is tracked via IncrementalIndexCompleted, so the task
28+
can safely resume if interrupted.
1629
17-
This is separate from LMS search features like courseware search or forum search.
30+
Index creation and configuration are handled by post_migrate reconciliation
31+
(runs automatically on ./manage.py cms migrate).
1832
"""
1933

20-
# TODO: improve this - see https://github.com/openedx/edx-platform/issues/36868
34+
help = "Add all course and library content to the Studio search index."
2135

2236
def add_arguments(self, parser):
23-
parser.add_argument("--experimental", action="store_true") # kept for compatibility but ignored.
24-
parser.add_argument("--reset", action="store_true")
25-
parser.add_argument("--init", action="store_true")
26-
parser.add_argument("--incremental", action="store_true")
27-
parser.set_defaults(experimental=False, reset=False, init=False, incremental=False)
37+
# Removed flags — provide clear error messages for operators with old automation.
38+
parser.add_argument(
39+
"--reset",
40+
action="store_true",
41+
default=False,
42+
help="(Removed) Index reset is now handled by post_migrate reconciliation.",
43+
)
44+
parser.add_argument(
45+
"--init",
46+
action="store_true",
47+
default=False,
48+
help="(Removed) Index initialization is now handled by post_migrate reconciliation.",
49+
)
50+
parser.add_argument(
51+
"--incremental",
52+
action="store_true",
53+
default=False,
54+
help="(Removed) Incremental is now the default and only population mode.",
55+
)
2856

2957
def handle(self, *args, **options):
30-
"""
31-
Build a new search index for Studio, containing content from courses and libraries
32-
"""
3358
if not api.is_meilisearch_enabled():
3459
raise CommandError("Meilisearch is not enabled. Please set MEILISEARCH_ENABLED to True in your settings.")
3560

3661
if options["reset"]:
37-
api.reset_index(self.stdout.write)
38-
elif options["init"]:
39-
api.init_index(self.stdout.write, self.stderr.write)
40-
elif options["incremental"]:
41-
api.rebuild_index(self.stdout.write, incremental=True)
42-
else:
43-
api.rebuild_index(self.stdout.write)
62+
raise CommandError(
63+
"The --reset flag has been removed. "
64+
"Index reset is now handled automatically by post_migrate reconciliation. "
65+
"Run: ./manage.py cms migrate"
66+
)
67+
68+
if options["init"]:
69+
raise CommandError(
70+
"The --init flag has been removed. "
71+
"Index initialization is now handled automatically by post_migrate reconciliation. "
72+
"Run: ./manage.py cms migrate"
73+
)
74+
75+
if options["incremental"]:
76+
log.warning(
77+
"The --incremental flag has been removed. "
78+
"Incremental population is now the default behavior of this command."
79+
)
80+
81+
result = rebuild_index_incremental.delay()
82+
83+
self.stdout.write(
84+
f"Studio search index population has been queued (task_id={result.id}). "
85+
"Population will run incrementally in a Celery worker. "
86+
"Monitor progress in Celery worker logs."
87+
)

openedx/core/djangoapps/content/search/tasks.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,36 @@ def delete_course_index_docs(course_key_str: str) -> None:
184184

185185
# Delete children index data for course blocks.
186186
api.delete_docs_with_context_key(course_key)
187+
188+
189+
@shared_task(
190+
base=LoggedTask,
191+
autoretry_for=(MeilisearchError, ConnectionError),
192+
max_retries=3,
193+
retry_backoff=True,
194+
)
195+
@set_code_owner_attribute
196+
def rebuild_index_incremental() -> None:
197+
"""
198+
Celery task to incrementally populate the Studio Meilisearch index.
199+
200+
Uses IncrementalIndexCompleted to track progress and resume from where
201+
it left off if interrupted. Safe to call multiple times — already-indexed
202+
contexts are skipped.
203+
204+
If a rebuild is already in progress (lock held), the task exits gracefully.
205+
"""
206+
log.info("Starting incremental Studio search index population...")
207+
208+
try:
209+
api.rebuild_index(status_cb=log.info, incremental=True)
210+
except RuntimeError as exc:
211+
# rebuild_index -> _using_temp_index or lock contention
212+
if "already in progress" in str(exc).lower():
213+
log.warning(
214+
"Studio index population skipped: a rebuild is already in progress. Will retry later if re-enqueued."
215+
)
216+
return
217+
raise
218+
219+
log.info("Incremental Studio search index population complete.")
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""
2+
Tests for the reindex_studio management command and the rebuild_index_incremental Celery task.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from unittest.mock import MagicMock, Mock, patch
8+
9+
import pytest
10+
from django.core.management import CommandError, call_command
11+
from django.test import TestCase, override_settings
12+
13+
from openedx.core.djangolib.testing.utils import skip_unless_cms
14+
15+
try:
16+
from .. import api
17+
from ..tasks import rebuild_index_incremental
18+
except RuntimeError:
19+
pass
20+
21+
22+
@skip_unless_cms
23+
@override_settings(MEILISEARCH_ENABLED=True)
24+
class TestReindexStudioCommand(TestCase):
25+
"""Tests for the reindex_studio management command."""
26+
27+
@patch("openedx.core.djangoapps.content.search.tasks.rebuild_index_incremental.delay")
28+
def test_enqueues_task(self, mock_delay):
29+
"""Command enqueues the incremental rebuild task."""
30+
mock_delay.return_value = Mock(id="fake-task-id")
31+
32+
call_command("reindex_studio")
33+
34+
mock_delay.assert_called_once_with()
35+
36+
@override_settings(MEILISEARCH_ENABLED=False)
37+
def test_disabled(self):
38+
"""Command raises error when Meilisearch is disabled."""
39+
with pytest.raises(CommandError, match="not enabled"):
40+
call_command("reindex_studio")
41+
42+
def test_reset_flag_removed(self):
43+
"""Passing --reset raises a clear error."""
44+
with pytest.raises(CommandError, match="--reset flag has been removed"):
45+
call_command("reindex_studio", "--reset")
46+
47+
def test_init_flag_removed(self):
48+
"""Passing --init raises a clear error."""
49+
with pytest.raises(CommandError, match="--init flag has been removed"):
50+
call_command("reindex_studio", "--init")
51+
52+
@patch("openedx.core.djangoapps.content.search.tasks.rebuild_index_incremental.delay")
53+
@patch("openedx.core.djangoapps.content.search.management.commands.reindex_studio.log")
54+
def test_incremental_flag_accepted_with_warning(self, mock_log, mock_delay):
55+
"""Passing --incremental logs a warning but still enqueues the task."""
56+
mock_delay.return_value = Mock(id="fake-task-id")
57+
58+
call_command("reindex_studio", "--incremental")
59+
60+
mock_log.warning.assert_called_once()
61+
mock_delay.assert_called_once_with()
62+
63+
64+
@skip_unless_cms
65+
@override_settings(MEILISEARCH_ENABLED=True)
66+
@patch("openedx.core.djangoapps.content.search.api._wait_for_meili_task", new=MagicMock(return_value=None))
67+
@patch("openedx.core.djangoapps.content.search.api.MeilisearchClient")
68+
class TestRebuildIndexIncrementalTask(TestCase):
69+
"""Tests for the rebuild_index_incremental Celery task."""
70+
71+
def setUp(self):
72+
super().setUp()
73+
api.clear_meilisearch_client()
74+
75+
@patch("openedx.core.djangoapps.content.search.api.rebuild_index")
76+
def test_calls_rebuild_incremental(self, mock_rebuild, mock_meilisearch):
77+
"""Task calls api.rebuild_index with incremental=True."""
78+
rebuild_index_incremental()
79+
80+
mock_rebuild.assert_called_once()
81+
_, kwargs = mock_rebuild.call_args
82+
assert kwargs["incremental"] is True
83+
84+
@patch("openedx.core.djangoapps.content.search.api.rebuild_index")
85+
def test_rebuild_already_in_progress(self, mock_rebuild, mock_meilisearch):
86+
"""Task exits gracefully if rebuild lock is already held."""
87+
mock_rebuild.side_effect = RuntimeError("Rebuild already in progress")
88+
89+
# Should not raise
90+
rebuild_index_incremental()
91+
92+
@patch("openedx.core.djangoapps.content.search.api.rebuild_index")
93+
def test_other_runtime_error_raised(self, mock_rebuild, mock_meilisearch):
94+
"""Task re-raises RuntimeError if it's not about lock contention."""
95+
mock_rebuild.side_effect = RuntimeError("Something else went wrong")
96+
97+
with pytest.raises(RuntimeError, match="Something else went wrong"):
98+
rebuild_index_incremental()
99+
100+
@patch("openedx.core.djangoapps.content.search.api.rebuild_index")
101+
def test_idempotent(self, mock_rebuild, mock_meilisearch):
102+
"""Task can be called multiple times safely."""
103+
rebuild_index_incremental()
104+
rebuild_index_incremental()
105+
106+
assert mock_rebuild.call_count == 2

0 commit comments

Comments
 (0)