Skip to content

Commit 2e91a05

Browse files
fix: Bugs with "Publish All Changes" in Library [FC-0083] (#36640)
* fix: "[created] received a naive datetime" * fix: leaky "isolation" of events was causing test failures * fix: make lib events more specific, emit them async, handle hierarchy correctly * chore: bump openedx-events to 10.2.0 for new library PUBLISHED events
1 parent 4ff7c39 commit 2e91a05

File tree

23 files changed

+1043
-827
lines changed

23 files changed

+1043
-827
lines changed

cms/djangoapps/contentstore/views/tests/test_block.py

+6
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,12 @@ def setUpClass(cls):
804804
super().setUpClass()
805805
cls.start_events_isolation()
806806

807+
@classmethod
808+
def tearDownClass(cls):
809+
""" Don't let our event isolation affect other test cases """
810+
super().tearDownClass()
811+
cls.enable_all_events() # Re-enable events other than the ENABLED_OPENEDX_EVENTS subset we isolated.
812+
807813
def setUp(self):
808814
"""Creates the test course structure and a few components to 'duplicate'."""
809815
super().setUp()

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

-23
Original file line numberDiff line numberDiff line change
@@ -653,29 +653,6 @@ def _delete_index_doc(doc_id) -> None:
653653
_wait_for_meili_tasks(tasks)
654654

655655

656-
def delete_all_draft_docs_for_library(library_key: LibraryLocatorV2) -> None:
657-
"""
658-
Deletes draft documents for the given XBlocks from the search index
659-
"""
660-
current_rebuild_index_name = _get_running_rebuild_index_name()
661-
client = _get_meilisearch_client()
662-
# Delete all documents where last_published is null i.e. never published before.
663-
delete_filter = [
664-
f'{Fields.context_key}="{library_key}"',
665-
# This field should only be NULL or have a value, but we're also checking IS EMPTY just in case.
666-
# Inner arrays are connected by an OR
667-
[f'{Fields.last_published} IS EMPTY', f'{Fields.last_published} IS NULL'],
668-
]
669-
670-
tasks = []
671-
if current_rebuild_index_name:
672-
# If there is a rebuild in progress, the documents will also be deleted from the new index.
673-
tasks.append(client.index(current_rebuild_index_name).delete_documents(filter=delete_filter))
674-
tasks.append(client.index(STUDIO_INDEX_NAME).delete_documents(filter=delete_filter))
675-
676-
_wait_for_meili_tasks(tasks)
677-
678-
679656
def upsert_library_block_index_doc(usage_key: UsageKey) -> None:
680657
"""
681658
Creates or updates the document for the given Library Block in the search index

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

+67-18
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@
2323
LIBRARY_BLOCK_CREATED,
2424
LIBRARY_BLOCK_DELETED,
2525
LIBRARY_BLOCK_UPDATED,
26+
LIBRARY_BLOCK_PUBLISHED,
2627
LIBRARY_COLLECTION_CREATED,
2728
LIBRARY_COLLECTION_DELETED,
2829
LIBRARY_COLLECTION_UPDATED,
2930
LIBRARY_CONTAINER_CREATED,
3031
LIBRARY_CONTAINER_DELETED,
3132
LIBRARY_CONTAINER_UPDATED,
33+
LIBRARY_CONTAINER_PUBLISHED,
3234
XBLOCK_CREATED,
3335
XBLOCK_DELETED,
3436
XBLOCK_UPDATED,
@@ -37,6 +39,7 @@
3739

3840
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
3941
from openedx.core.djangoapps.content.search.models import SearchAccess
42+
from openedx.core.djangoapps.content_libraries import api as lib_api
4043

4144
from .api import (
4245
only_if_meilisearch_enabled,
@@ -136,6 +139,32 @@ def library_block_updated_handler(**kwargs) -> None:
136139
upsert_library_block_index_doc.apply(args=[str(library_block_data.usage_key)])
137140

138141

142+
@receiver(LIBRARY_BLOCK_PUBLISHED)
143+
@only_if_meilisearch_enabled
144+
def library_block_published_handler(**kwargs) -> None:
145+
"""
146+
Update the index for the content library block when its published version
147+
has changed.
148+
"""
149+
library_block_data = kwargs.get("library_block", None)
150+
if not library_block_data or not isinstance(library_block_data, LibraryBlockData): # pragma: no cover
151+
log.error("Received null or incorrect data for event")
152+
return
153+
154+
# The PUBLISHED event is sent for any change to the published version including deletes, so check if it exists:
155+
try:
156+
lib_api.get_library_block(library_block_data.usage_key)
157+
except lib_api.ContentLibraryBlockNotFound:
158+
log.info(f"Observed published deletion of library block {str(library_block_data.usage_key)}.")
159+
# The document should already have been deleted from the search index
160+
# via the DELETED handler, so there's nothing to do now.
161+
return
162+
163+
# Update content library index synchronously to make sure that search index is updated before
164+
# the frontend invalidates/refetches results. This is only a single document update so is very fast.
165+
upsert_library_block_index_doc.apply(args=[str(library_block_data.usage_key)])
166+
167+
139168
@receiver(LIBRARY_BLOCK_DELETED)
140169
@only_if_meilisearch_enabled
141170
def library_block_deleted(**kwargs) -> None:
@@ -162,14 +191,14 @@ def content_library_updated_handler(**kwargs) -> None:
162191
if not content_library_data or not isinstance(content_library_data, ContentLibraryData): # pragma: no cover
163192
log.error("Received null or incorrect data for event")
164193
return
194+
library_key = content_library_data.library_key
165195

166-
# Update content library index synchronously to make sure that search index is updated before
167-
# the frontend invalidates/refetches index.
168-
# Currently, this is only required to make sure that removed/discarded components are removed
169-
# from the search index and displayed to user properly. If it becomes a performance bottleneck
170-
# for other update operations other than discard, we can update CONTENT_LIBRARY_UPDATED event
171-
# to include a parameter which can help us decide if the task needs to run sync or async.
172-
update_content_library_index_docs.apply(args=[str(content_library_data.library_key)])
196+
# For now we assume the library has been renamed. Few other things will trigger this event.
197+
198+
# Update ALL items in the library, because their breadcrumbs will be outdated.
199+
# TODO: just patch the "breadcrumbs" field? It's the same on every one.
200+
# TODO: check if the library display_name has actually changed before updating all items?
201+
update_content_library_index_docs.apply(args=[str(library_key)])
173202

174203

175204
@receiver(LIBRARY_COLLECTION_CREATED)
@@ -248,17 +277,34 @@ def library_container_updated_handler(**kwargs) -> None:
248277
log.error("Received null or incorrect data for event")
249278
return
250279

251-
if library_container.background:
252-
update_library_container_index_doc.delay(
253-
str(library_container.container_key),
254-
)
255-
else:
256-
# Update container index synchronously to make sure that search index is updated before
257-
# the frontend invalidates/refetches index.
258-
# See content_library_updated_handler for more details.
259-
update_library_container_index_doc.apply(args=[
260-
str(library_container.container_key),
261-
])
280+
update_library_container_index_doc.apply(args=[
281+
str(library_container.container_key),
282+
])
283+
284+
285+
@receiver(LIBRARY_CONTAINER_PUBLISHED)
286+
@only_if_meilisearch_enabled
287+
def library_container_published_handler(**kwargs) -> None:
288+
"""
289+
Update the index for the content library container when its published
290+
version has changed.
291+
"""
292+
library_container = kwargs.get("library_container", None)
293+
if not library_container or not isinstance(library_container, LibraryContainerData): # pragma: no cover
294+
log.error("Received null or incorrect data for event")
295+
return
296+
# The PUBLISHED event is sent for any change to the published version including deletes, so check if it exists:
297+
try:
298+
lib_api.get_container(library_container.container_key)
299+
except lib_api.ContentLibraryContainerNotFound:
300+
log.info(f"Observed published deletion of container {str(library_container.container_key)}.")
301+
# The document should already have been deleted from the search index
302+
# via the DELETED handler, so there's nothing to do now.
303+
return
304+
305+
update_library_container_index_doc.apply(args=[
306+
str(library_container.container_key),
307+
])
262308

263309

264310
@receiver(LIBRARY_CONTAINER_DELETED)
@@ -275,3 +321,6 @@ def library_container_deleted(**kwargs) -> None:
275321
# Update content library index synchronously to make sure that search index is updated before
276322
# the frontend invalidates/refetches results. This is only a single document update so is very fast.
277323
delete_library_container_index_doc.apply(args=[str(library_container.container_key)])
324+
# TODO: post-Teak, move all the celery tasks directly inline into this handlers? Because now the
325+
# events are emitted in an [async] worker, so it doesn't matter if the handlers are synchronous.
326+
# See https://github.com/openedx/edx-platform/pull/36640 discussion.

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

-3
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,6 @@ def update_content_library_index_docs(library_key_str: str) -> None:
8686
log.info("Updating content index documents for library with id: %s", library_key)
8787

8888
api.upsert_content_library_index_docs(library_key)
89-
# Delete all documents in this library that were not published by above function
90-
# as this task is also triggered on discard event.
91-
api.delete_all_draft_docs_for_library(library_key)
9289

9390

9491
@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError))

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

-15
Original file line numberDiff line numberDiff line change
@@ -734,21 +734,6 @@ def test_index_content_library_metadata(self, mock_meilisearch):
734734
[self.doc_problem1, self.doc_problem2]
735735
)
736736

737-
@override_settings(MEILISEARCH_ENABLED=True)
738-
def test_delete_all_drafts(self, mock_meilisearch):
739-
"""
740-
Test deleting all draft documents from the index.
741-
"""
742-
api.delete_all_draft_docs_for_library(self.library.key)
743-
744-
delete_filter = [
745-
f'context_key="{self.library.key}"',
746-
['last_published IS EMPTY', 'last_published IS NULL'],
747-
]
748-
mock_meilisearch.return_value.index.return_value.delete_documents.assert_called_once_with(
749-
filter=delete_filter
750-
)
751-
752737
@override_settings(MEILISEARCH_ENABLED=True)
753738
def test_index_tags_in_collections(self, mock_meilisearch):
754739
# Tag collection

openedx/core/djangoapps/content_libraries/api/blocks.py

+9-21
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,9 @@
6363
ContainerMetadata,
6464
ContainerType,
6565
)
66-
from .libraries import (
67-
library_collection_locator,
68-
PublishableItem,
69-
)
66+
from .collections import library_collection_locator
67+
from .libraries import PublishableItem
68+
from .. import tasks
7069

7170
# This content_libraries API is sometimes imported in the LMS (should we prevent that?), but the content_staging app
7271
# cannot be. For now we only need this one type import at module scope, so only import it during type checks.
@@ -836,24 +835,13 @@ def publish_component_changes(usage_key: LibraryUsageLocatorV2, user: UserType):
836835
# The core publishing API is based on draft objects, so find the draft that corresponds to this component:
837836
drafts_to_publish = authoring_api.get_all_drafts(learning_package.id).filter(entity__key=component.key)
838837
# Publish the component and update anything that needs to be updated (e.g. search index):
839-
authoring_api.publish_from_drafts(learning_package.id, draft_qset=drafts_to_publish, published_by=user.id)
840-
LIBRARY_BLOCK_UPDATED.send_event(
841-
library_block=LibraryBlockData(
842-
library_key=usage_key.lib_key,
843-
usage_key=usage_key,
844-
)
838+
publish_log = authoring_api.publish_from_drafts(
839+
learning_package.id, draft_qset=drafts_to_publish, published_by=user.id,
845840
)
846-
847-
# For each container, trigger LIBRARY_CONTAINER_UPDATED signal and set background=True to trigger
848-
# container indexing asynchronously.
849-
affected_containers = get_containers_contains_component(usage_key)
850-
for container in affected_containers:
851-
LIBRARY_CONTAINER_UPDATED.send_event(
852-
library_container=LibraryContainerData(
853-
container_key=container.container_key,
854-
background=True,
855-
)
856-
)
841+
# Since this is a single component, it should be safe to process synchronously and in-process:
842+
tasks.send_events_after_publish(publish_log.pk, str(library_key))
843+
# IF this is found to be a performance issue, we could instead make it async where necessary:
844+
# tasks.wait_for_post_publish_events(publish_log, library_key=library_key)
857845

858846

859847
def _component_exists(usage_key: UsageKeyV2) -> bool:

openedx/core/djangoapps/content_libraries/api/containers.py

+9-25
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from __future__ import annotations
55

66
from dataclasses import dataclass
7-
from datetime import datetime
7+
from datetime import datetime, timezone
88
from enum import Enum
99
import logging
1010
from uuid import uuid4
@@ -14,13 +14,11 @@
1414
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
1515
from openedx_events.content_authoring.data import (
1616
ContentObjectChangedData,
17-
LibraryBlockData,
1817
LibraryCollectionData,
1918
LibraryContainerData,
2019
)
2120
from openedx_events.content_authoring.signals import (
2221
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
23-
LIBRARY_BLOCK_UPDATED,
2422
LIBRARY_COLLECTION_UPDATED,
2523
LIBRARY_CONTAINER_CREATED,
2624
LIBRARY_CONTAINER_DELETED,
@@ -34,8 +32,9 @@
3432

3533
from ..models import ContentLibrary
3634
from .exceptions import ContentLibraryContainerNotFound
37-
from .libraries import PublishableItem, library_component_usage_key
35+
from .libraries import PublishableItem
3836
from .block_metadata import LibraryXBlockMetadata
37+
from .. import tasks
3938

4039
# The public API is only the following symbols:
4140
__all__ = [
@@ -250,7 +249,7 @@ def create_container(
250249
content_library.learning_package_id,
251250
key=slug,
252251
title=title,
253-
created=created or datetime.now(),
252+
created=created or datetime.now(tz=timezone.utc),
254253
created_by=user_id,
255254
)
256255
case _:
@@ -280,7 +279,7 @@ def update_container(
280279
unit_version = authoring_api.create_next_unit_version(
281280
container.unit,
282281
title=display_name,
283-
created=datetime.now(),
282+
created=datetime.now(tz=timezone.utc),
284283
created_by=user_id,
285284
)
286285

@@ -427,7 +426,7 @@ def update_container_children(
427426
new_version = authoring_api.create_next_unit_version(
428427
container.unit,
429428
components=components, # type: ignore[arg-type]
430-
created=datetime.now(),
429+
created=datetime.now(tz=timezone.utc),
431430
created_by=user_id,
432431
entities_action=entities_action,
433432
)
@@ -478,21 +477,6 @@ def publish_container_changes(container_key: LibraryContainerLocator, user_id: i
478477
draft_qset=drafts_to_publish,
479478
published_by=user_id,
480479
)
481-
# Update anything that needs to be updated (e.g. search index):
482-
for record in publish_log.records.select_related("entity", "entity__container", "entity__component").all():
483-
if hasattr(record.entity, "component"):
484-
# This is a child component like an XBLock in a Unit that was published:
485-
usage_key = library_component_usage_key(library_key, record.entity.component)
486-
LIBRARY_BLOCK_UPDATED.send_event(
487-
library_block=LibraryBlockData(library_key=library_key, usage_key=usage_key)
488-
)
489-
elif hasattr(record.entity, "container"):
490-
# This is a child container like a Unit, or is the same "container" we published above.
491-
LIBRARY_CONTAINER_UPDATED.send_event(
492-
library_container=LibraryContainerData(container_key=container_key)
493-
)
494-
else:
495-
log.warning(
496-
f"PublishableEntity {record.entity.pk} / {record.entity.key} was modified during publish operation "
497-
"but is of unknown type."
498-
)
480+
# Update the search index (and anything else) for the affected container + blocks
481+
# This is mostly synchronous but may complete some work asynchronously if there are a lot of changes.
482+
tasks.wait_for_post_publish_events(publish_log, library_key)

0 commit comments

Comments
 (0)