Skip to content

Commit b956962

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 71169b2 commit b956962

3 files changed

Lines changed: 194 additions & 20 deletions

File tree

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,83 @@
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+
812
from django.core.management import BaseCommand, CommandError
913

1014
from ... import api
15+
from ...tasks import rebuild_index_incremental
1116

1217

1318
class Command(BaseCommand):
1419
"""
15-
Build or re-build the Meilisearch search index for courses and libraries in Studio.
20+
Queue incremental population of the Meilisearch search index for Studio.
1621
17-
This is separate from LMS search features like courseware search or forum search.
22+
This enqueues a Celery task that incrementally indexes all courses and
23+
libraries. Progress is tracked via IncrementalIndexCompleted, so the task
24+
can safely resume if interrupted.
25+
26+
Index creation and configuration are handled by post_migrate reconciliation
27+
(runs automatically on ./manage.py cms migrate).
1828
"""
1929

20-
# TODO: improve this - see https://github.com/openedx/edx-platform/issues/36868
30+
help = "Queue incremental population of the Studio Meilisearch search index via Celery."
2131

2232
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)
33+
# Removed flags — provide clear error messages for operators with old automation.
34+
parser.add_argument(
35+
"--reset",
36+
action="store_true",
37+
default=False,
38+
help="(Removed) Index reset is now handled by post_migrate reconciliation.",
39+
)
40+
parser.add_argument(
41+
"--init",
42+
action="store_true",
43+
default=False,
44+
help="(Removed) Index initialization is now handled by post_migrate reconciliation.",
45+
)
46+
parser.add_argument(
47+
"--incremental",
48+
action="store_true",
49+
default=False,
50+
help="(Removed) Incremental is now the default and only population mode.",
51+
)
2852

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

3657
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)
58+
raise CommandError(
59+
"The --reset flag has been removed. "
60+
"Index reset is now handled automatically by post_migrate reconciliation. "
61+
"Run: ./manage.py cms migrate"
62+
)
63+
64+
if options["init"]:
65+
raise CommandError(
66+
"The --init flag has been removed. "
67+
"Index initialization is now handled automatically by post_migrate reconciliation. "
68+
"Run: ./manage.py cms migrate"
69+
)
70+
71+
if options["incremental"]:
72+
raise CommandError(
73+
"The --incremental flag has been removed. "
74+
"Incremental population is now the default behavior of this command."
75+
)
76+
77+
result = rebuild_index_incremental.delay()
78+
79+
self.stdout.write(
80+
f"Studio search index population has been queued (task_id={result.id}). "
81+
"Population will run incrementally in a Celery worker. "
82+
"Monitor progress in Celery worker logs."
83+
)

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,37 @@ 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. "
215+
"Will retry later if re-enqueued."
216+
)
217+
return
218+
raise
219+
220+
log.info("Incremental Studio search index population complete.")
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
def test_incremental_flag_removed(self):
53+
"""Passing --incremental raises a clear error."""
54+
with pytest.raises(CommandError, match="--incremental flag has been removed"):
55+
call_command("reindex_studio", "--incremental")
56+
57+
58+
@skip_unless_cms
59+
@override_settings(MEILISEARCH_ENABLED=True)
60+
@patch("openedx.core.djangoapps.content.search.api._wait_for_meili_task", new=MagicMock(return_value=None))
61+
@patch("openedx.core.djangoapps.content.search.api.MeilisearchClient")
62+
class TestRebuildIndexIncrementalTask(TestCase):
63+
"""Tests for the rebuild_index_incremental Celery task."""
64+
65+
def setUp(self):
66+
super().setUp()
67+
api.clear_meilisearch_client()
68+
69+
@patch("openedx.core.djangoapps.content.search.api.rebuild_index")
70+
def test_calls_rebuild_incremental(self, mock_rebuild, mock_meilisearch):
71+
"""Task calls api.rebuild_index with incremental=True."""
72+
rebuild_index_incremental()
73+
74+
mock_rebuild.assert_called_once()
75+
_, kwargs = mock_rebuild.call_args
76+
assert kwargs["incremental"] is True
77+
78+
@patch("openedx.core.djangoapps.content.search.api.rebuild_index")
79+
def test_rebuild_already_in_progress(self, mock_rebuild, mock_meilisearch):
80+
"""Task exits gracefully if rebuild lock is already held."""
81+
mock_rebuild.side_effect = RuntimeError("Rebuild already in progress")
82+
83+
# Should not raise
84+
rebuild_index_incremental()
85+
86+
@patch("openedx.core.djangoapps.content.search.api.rebuild_index")
87+
def test_other_runtime_error_raised(self, mock_rebuild, mock_meilisearch):
88+
"""Task re-raises RuntimeError if it's not about lock contention."""
89+
mock_rebuild.side_effect = RuntimeError("Something else went wrong")
90+
91+
with pytest.raises(RuntimeError, match="Something else went wrong"):
92+
rebuild_index_incremental()
93+
94+
@patch("openedx.core.djangoapps.content.search.api.rebuild_index")
95+
def test_idempotent(self, mock_rebuild, mock_meilisearch):
96+
"""Task can be called multiple times safely."""
97+
rebuild_index_incremental()
98+
rebuild_index_incremental()
99+
100+
assert mock_rebuild.call_count == 2

0 commit comments

Comments
 (0)