Skip to content

Commit 8285dba

Browse files
committed
fixup! feat: Adds a way to find the difference in Meiliseach state and come up with a migration plan and configuration plan depending on the state. This introduces a mechanism it or a drift engine which drill down the Meiliseach configuration and figures out what has changed:
1 parent 886f365 commit 8285dba

2 files changed

Lines changed: 63 additions & 68 deletions

File tree

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

Lines changed: 40 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -245,28 +245,49 @@ def _index_is_empty(index_name: str) -> bool:
245245
return index.get_stats().number_of_documents == 0
246246

247247

248-
def _configure_index(index_name):
248+
def _apply_index_settings(
249+
index_name: str,
250+
wait: bool,
251+
status_cb: Callable[[str], None] | None = None,
252+
) -> None:
249253
"""
250-
Configure the index. The following index settings are best changed on an empty index.
251-
Changing them on a populated index will "re-index all documents in the index", which can take some time.
254+
Apply the standard Meilisearch settings to an index.
255+
256+
When wait=False, settings are sent in fire-and-forget mode. This is appropriate
257+
for empty temporary indexes that will immediately be populated on the same Meilisearch
258+
task queue.
259+
260+
When wait=True, each settings task is synchronously waited on before returning.
261+
This is appropriate when reconciling a live index and we need confirmation that the
262+
settings have been applied before returning.
252263
253264
Args:
254-
index_name (str): The name of the index to configure
265+
index_name: The name of the index to configure.
266+
wait: Whether to wait for each Meilisearch settings task to complete.
267+
status_cb: Optional callback for status messages when wait=True.
255268
"""
269+
if status_cb is None:
270+
status_cb = log.info
271+
256272
client = _get_meilisearch_client()
273+
index = client.index(index_name)
274+
275+
settings_updates = (
276+
("distinct attribute", index.update_distinct_attribute, INDEX_DISTINCT_ATTRIBUTE),
277+
("filterable attributes", index.update_filterable_attributes, INDEX_FILTERABLE_ATTRIBUTES),
278+
("searchable attributes", index.update_searchable_attributes, INDEX_SEARCHABLE_ATTRIBUTES),
279+
("sortable attributes", index.update_sortable_attributes, INDEX_SORTABLE_ATTRIBUTES),
280+
("ranking rules", index.update_ranking_rules, INDEX_RANKING_RULES),
281+
)
257282

258-
# Mark usage_key as unique (it's not the primary key for the index, but nevertheless must be unique):
259-
client.index(index_name).update_distinct_attribute(INDEX_DISTINCT_ATTRIBUTE)
260-
# Mark which attributes can be used for filtering/faceted search:
261-
client.index(index_name).update_filterable_attributes(INDEX_FILTERABLE_ATTRIBUTES)
262-
# Mark which attributes are used for keyword search, in order of importance:
263-
client.index(index_name).update_searchable_attributes(INDEX_SEARCHABLE_ATTRIBUTES)
264-
# Mark which attributes can be used for sorting search results:
265-
client.index(index_name).update_sortable_attributes(INDEX_SORTABLE_ATTRIBUTES)
283+
for label, update_method, value in settings_updates:
284+
status_cb(f"Applying {label} to '{index_name}'...")
285+
if wait:
286+
_wait_for_meili_task(update_method(value))
287+
else:
288+
update_method(value)
266289

267-
# Update the search ranking rules to let the (optional) "sort" parameter take precedence over keyword relevance.
268-
# cf https://www.meilisearch.com/docs/learn/core_concepts/relevancy
269-
client.index(index_name).update_ranking_rules(INDEX_RANKING_RULES)
290+
status_cb(f"All settings applied to '{index_name}'.")
270291

271292

272293
def _recurse_children(block, fn, status_cb: Callable[[str], None] | None = None) -> None:
@@ -345,7 +366,7 @@ def reset_index(status_cb: Callable[[str], None] | None = None) -> None:
345366

346367
status_cb("Creating new empty index...")
347368
with _using_temp_index(status_cb) as temp_index_name:
348-
_configure_index(temp_index_name)
369+
_apply_index_settings(temp_index_name, wait=False)
349370
status_cb("Index recreated!")
350371
status_cb("Index reset complete.")
351372

@@ -429,41 +450,6 @@ def _compare_setting(key, expected):
429450
)
430451

431452

432-
def _apply_settings_with_waits(index_name: str, status_cb: Callable[[str], None] | None = None) -> None:
433-
"""
434-
Apply all index settings and wait for each Meilisearch task to complete.
435-
436-
Unlike _configure_index() which fires-and-forgets, this function waits for
437-
confirmation that each setting has been applied. This is used during reconciliation
438-
where we need a definitive state before returning.
439-
440-
Args:
441-
index_name (str): The name of the index to configure.
442-
status_cb: Optional callback for status messages.
443-
"""
444-
if status_cb is None:
445-
status_cb = log.info
446-
447-
client = _get_meilisearch_client()
448-
449-
status_cb(f"Applying distinct attribute to '{index_name}'...")
450-
_wait_for_meili_task(client.index(index_name).update_distinct_attribute(INDEX_DISTINCT_ATTRIBUTE))
451-
452-
status_cb(f"Applying filterable attributes to '{index_name}'...")
453-
_wait_for_meili_task(client.index(index_name).update_filterable_attributes(INDEX_FILTERABLE_ATTRIBUTES))
454-
455-
status_cb(f"Applying searchable attributes to '{index_name}'...")
456-
_wait_for_meili_task(client.index(index_name).update_searchable_attributes(INDEX_SEARCHABLE_ATTRIBUTES))
457-
458-
status_cb(f"Applying sortable attributes to '{index_name}'...")
459-
_wait_for_meili_task(client.index(index_name).update_sortable_attributes(INDEX_SORTABLE_ATTRIBUTES))
460-
461-
status_cb(f"Applying ranking rules to '{index_name}'...")
462-
_wait_for_meili_task(client.index(index_name).update_ranking_rules(INDEX_RANKING_RULES))
463-
464-
status_cb(f"All settings applied to '{index_name}'.")
465-
466-
467453
def reconcile_index(
468454
status_cb: Callable[[str], None] | None = None, warn_cb: Callable[[str], None] | None = None
469455
) -> None: # noqa: E501
@@ -511,7 +497,7 @@ def reconcile_index(
511497
if drift.is_empty:
512498
if drift.is_settings_drifted:
513499
status_cb("Empty index has drifted settings. Reconfiguring...")
514-
_apply_settings_with_waits(STUDIO_INDEX_NAME, status_cb)
500+
_apply_index_settings(STUDIO_INDEX_NAME, wait=True, status_cb=status_cb)
515501
status_cb("Reconfigured. Run './manage.py cms' reindex_studio to populate.")
516502
else:
517503
status_cb(
@@ -533,7 +519,7 @@ def reconcile_index(
533519
if match is False:
534520
warn_cb(f" - {field_name}: DRIFTED")
535521

536-
_apply_settings_with_waits(STUDIO_INDEX_NAME, status_cb)
522+
_apply_index_settings(STUDIO_INDEX_NAME, wait=True, status_cb=status_cb)
537523
warn_cb(
538524
"Settings applied. Meilisearch will re-index documents in the background. "
539525
"Consider running './manage.py cms' reindex_studio for a full rebuild "
@@ -624,7 +610,7 @@ def rebuild_index( # pylint: disable=too-many-statements
624610
# Changing them on a populated index will "re-index all documents in the index", which can take some time
625611
# and use more RAM. Instead, we configure an empty index then populate it one course/library at a time.
626612
if not incremental:
627-
_configure_index(index_name)
613+
_apply_index_settings(index_name, wait=False)
628614

629615
############## Libraries ##############
630616
status_cb("Indexing libraries...")

openedx/core/djangoapps/content/search/tests/test_reconcile.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from .. import api
1717
from ..api import (
1818
IndexDrift,
19-
_apply_settings_with_waits,
19+
_apply_index_settings,
2020
_detect_index_drift,
2121
reconcile_index,
2222
)
@@ -289,20 +289,20 @@ def test_multiple_mismatches(self, mock_meilisearch):
289289
@skip_unless_cms
290290
@override_settings(MEILISEARCH_ENABLED=True)
291291
@patch("openedx.core.djangoapps.content.search.api.MeilisearchClient")
292-
class TestApplySettingsWithWaits(TestCase):
293-
"""Tests for _apply_settings_with_waits()."""
292+
class TestApplyIndexSettings(TestCase):
293+
"""Tests for _apply_index_settings()."""
294294

295295
def setUp(self):
296296
super().setUp()
297297
api.clear_meilisearch_client()
298298

299299
@patch("openedx.core.djangoapps.content.search.api._wait_for_meili_task", new=MagicMock(return_value=None))
300300
def test_applies_all_settings(self, mock_meilisearch):
301-
"""All 5 settings are applied."""
301+
"""All 5 settings are applied in wait mode."""
302302
mock_index = mock_meilisearch.return_value.index.return_value
303303
status_cb = Mock()
304304

305-
_apply_settings_with_waits("test_index", status_cb)
305+
_apply_index_settings("test_index", wait=True, status_cb=status_cb)
306306

307307
mock_index.update_distinct_attribute.assert_called_once_with(INDEX_DISTINCT_ATTRIBUTE)
308308
mock_index.update_filterable_attributes.assert_called_once_with(INDEX_FILTERABLE_ATTRIBUTES)
@@ -312,27 +312,36 @@ def test_applies_all_settings(self, mock_meilisearch):
312312

313313
@patch("openedx.core.djangoapps.content.search.api._wait_for_meili_task")
314314
def test_waits_for_each_task(self, mock_wait, mock_meilisearch):
315-
"""Each settings update is waited on."""
315+
"""Each settings update is waited on when wait=True."""
316316
mock_index = mock_meilisearch.return_value.index.return_value
317-
# Each update returns a TaskInfo-like object
318317
task_info = Mock()
319318
mock_index.update_distinct_attribute.return_value = task_info
320319
mock_index.update_filterable_attributes.return_value = task_info
321320
mock_index.update_searchable_attributes.return_value = task_info
322321
mock_index.update_sortable_attributes.return_value = task_info
323322
mock_index.update_ranking_rules.return_value = task_info
324323

325-
_apply_settings_with_waits("test_index")
324+
_apply_index_settings("test_index", wait=True)
326325

327326
assert mock_wait.call_count == 5
328327

328+
@patch("openedx.core.djangoapps.content.search.api._wait_for_meili_task")
329+
def test_does_not_wait_when_wait_false(self, mock_wait, mock_meilisearch):
330+
"""Settings are fire-and-forget when wait=False."""
331+
status_cb = Mock()
332+
333+
_apply_index_settings("test_index", wait=False, status_cb=status_cb)
334+
335+
mock_wait.assert_not_called()
336+
status_cb.assert_called()
337+
329338
@patch("openedx.core.djangoapps.content.search.api._wait_for_meili_task")
330339
def test_raises_on_task_failure(self, mock_wait, mock_meilisearch):
331-
"""MeilisearchError is raised if a task fails."""
340+
"""MeilisearchError is raised if a waited-on task fails."""
332341
mock_wait.side_effect = MeilisearchError("Task failed")
333342

334343
with pytest.raises(MeilisearchError):
335-
_apply_settings_with_waits("test_index")
344+
_apply_index_settings("test_index", wait=True)
336345

337346

338347
@skip_unless_cms
@@ -380,7 +389,7 @@ def test_index_empty_configured(self, mock_drift, mock_meilisearch):
380389
)
381390

382391
@patch("openedx.core.djangoapps.content.search.api._detect_index_drift")
383-
@patch("openedx.core.djangoapps.content.search.api._apply_settings_with_waits")
392+
@patch("openedx.core.djangoapps.content.search.api._apply_index_settings")
384393
def test_index_empty_drifted_settings(self, mock_apply, mock_drift, mock_meilisearch):
385394
"""When index is empty and settings drifted, settings are applied."""
386395
mock_drift.return_value = IndexDrift(
@@ -397,7 +406,7 @@ def test_index_empty_drifted_settings(self, mock_apply, mock_drift, mock_meilise
397406

398407
reconcile_index(status_cb=status_cb)
399408

400-
mock_apply.assert_called_once()
409+
mock_apply.assert_called_once_with(api.STUDIO_INDEX_NAME, wait=True, status_cb=status_cb)
401410
status_cb.assert_any_call("Empty index has drifted settings. Reconfiguring...")
402411

403412
@patch("openedx.core.djangoapps.content.search.api._detect_index_drift")
@@ -441,7 +450,7 @@ def test_index_populated_configured(self, mock_drift, mock_meilisearch):
441450
status_cb.assert_any_call("Index is populated and correctly configured. No action needed.")
442451

443452
@patch("openedx.core.djangoapps.content.search.api._detect_index_drift")
444-
@patch("openedx.core.djangoapps.content.search.api._apply_settings_with_waits")
453+
@patch("openedx.core.djangoapps.content.search.api._apply_index_settings")
445454
def test_index_populated_drifted_settings(self, mock_apply, mock_drift, mock_meilisearch):
446455
"""When index is populated and drifted, settings are applied with warnings."""
447456
mock_drift.return_value = IndexDrift(
@@ -459,7 +468,7 @@ def test_index_populated_drifted_settings(self, mock_apply, mock_drift, mock_mei
459468

460469
reconcile_index(status_cb=status_cb, warn_cb=warn_cb)
461470

462-
mock_apply.assert_called_once()
471+
mock_apply.assert_called_once_with(api.STUDIO_INDEX_NAME, wait=True, status_cb=status_cb)
463472
# Check that drifted fields are logged
464473
warn_cb.assert_any_call(" - filterableAttributes: DRIFTED")
465474
warn_cb.assert_any_call(" - searchableAttributes: DRIFTED")

0 commit comments

Comments
 (0)