Skip to content

Commit 3f5ac6d

Browse files
authored
fix: Update on_commit_changes_to of modulestore to check MySQL transaction [FC-0097] (#37485)
- `handle_update_xblock_upstream_link` is called asynchronously with celery. In `update_upstream_downstream_link_handler`, the xblock has the updated version, but when calling `handle_update_xblock_upstream_link` inside Celery, the xblock is outdated in a previous version, which is why the error occurs. This happens because `on_commit_changes_to` is executed before the MySQL transaction ends. - Added `ImmediateOnCommitMixin` to be used in tests that need to call `on_commit_changes_to`. See #37485 (comment) for more info
1 parent 23295c5 commit 3f5ac6d

10 files changed

Lines changed: 79 additions & 24 deletions

File tree

cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313
from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest
1414
from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_string
1515
from xmodule.modulestore.django import modulestore
16-
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
16+
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ImmediateOnCommitMixin
1717
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
1818

1919

2020
@ddt.ddt
21-
class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
21+
class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ImmediateOnCommitMixin, ModuleStoreTestCase):
2222
"""
2323
Tests that involve syncing content from libraries to courses.
2424
"""

cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from common.djangoapps.student.tests.factories import UserFactory
2424
from openedx.core.djangoapps.content_libraries import api as lib_api
2525
from xmodule.modulestore.django import modulestore
26-
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
26+
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, ImmediateOnCommitMixin
2727
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
2828

2929
from .. import downstreams as downstreams_views
@@ -406,7 +406,7 @@ def test_400(self, sync: str):
406406
assert video_after.upstream is None
407407

408408

409-
class DeleteDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
409+
class DeleteDownstreamViewTest(SharedErrorTestCases, ImmediateOnCommitMixin, SharedModuleStoreTestCase):
410410
"""
411411
Test that `DELETE /api/v2/contentstore/downstreams/...` severs a downstream's link to an upstream.
412412
"""
@@ -596,6 +596,7 @@ def test_204(self, mock_decline_sync):
596596
@ddt.ddt
597597
class GetUpstreamViewTest(
598598
_BaseDownstreamViewTestMixin,
599+
ImmediateOnCommitMixin,
599600
SharedModuleStoreTestCase,
600601
):
601602
"""
@@ -1424,6 +1425,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self):
14241425

14251426
class GetDownstreamSummaryViewTest(
14261427
_BaseDownstreamViewTestMixin,
1428+
ImmediateOnCommitMixin,
14271429
SharedModuleStoreTestCase,
14281430
):
14291431
"""

cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from common.djangoapps.student.tests.factories import UserFactory
1616
from openedx.core.djangolib.testing.utils import skip_unless_cms
17-
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
17+
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ImmediateOnCommitMixin
1818
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
1919

2020
from ..models import ContainerLink, LearningContextLinksStatus, LearningContextLinksStatusChoices, ComponentLink
@@ -265,7 +265,12 @@ def test_call_for_nonexistent_course(self):
265265

266266

267267
@skip_unless_cms
268-
class TestUpstreamLinksEvents(ModuleStoreTestCase, OpenEdxEventsTestMixin, BaseUpstreamLinksHelpers):
268+
class TestUpstreamLinksEvents(
269+
ImmediateOnCommitMixin,
270+
ModuleStoreTestCase,
271+
OpenEdxEventsTestMixin,
272+
BaseUpstreamLinksHelpers,
273+
):
269274
"""
270275
Test signals related to managing upstream->downstream links.
271276
"""

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from openedx_tagging.core.tagging.models import Tag
1717
from organizations.models import Organization
1818
from xmodule.modulestore.django import contentstore, modulestore
19-
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course
19+
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course, ImmediateOnCommitMixin
2020
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory, LibraryFactory
2121

2222
from cms.djangoapps.contentstore.utils import reverse_usage_url
@@ -400,7 +400,7 @@ def test_paste_with_assets(self):
400400
assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged.
401401

402402

403-
class ClipboardPasteFromV2LibraryTestCase(OpenEdxEventsTestMixin, ModuleStoreTestCase):
403+
class ClipboardPasteFromV2LibraryTestCase(OpenEdxEventsTestMixin, ImmediateOnCommitMixin, ModuleStoreTestCase):
404404
"""
405405
Test Clipboard Paste functionality with a "new" (as of Sumac) library
406406
"""

openedx/core/djangoapps/content/course_overviews/tests/test_signals.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313

1414
from xmodule.data import CertificatesDisplayBehaviors
1515
from xmodule.modulestore import ModuleStoreEnum
16-
from xmodule.modulestore.tests.django_utils import TEST_DATA_ONLY_SPLIT_MODULESTORE_DRAFT_PREFERRED, ModuleStoreTestCase
16+
from xmodule.modulestore.tests.django_utils import (
17+
TEST_DATA_ONLY_SPLIT_MODULESTORE_DRAFT_PREFERRED,
18+
ModuleStoreTestCase,
19+
ImmediateOnCommitMixin,
20+
)
1721
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
1822

1923
from ..models import CourseOverview
@@ -23,7 +27,7 @@
2327

2428

2529
@ddt.ddt
26-
class CourseOverviewSignalsTestCase(ModuleStoreTestCase):
30+
class CourseOverviewSignalsTestCase(ImmediateOnCommitMixin, ModuleStoreTestCase):
2731
"""
2832
Tests for CourseOverview signals.
2933
"""
@@ -43,16 +47,14 @@ def assert_changed_signal_sent(self, changes, mock_signal):
4347
)
4448

4549
# changing display name doesn't fire the signal
46-
with self.captureOnCommitCallbacks(execute=True) as callbacks:
47-
course.display_name = course.display_name + 'changed'
48-
course = self.store.update_item(course, ModuleStoreEnum.UserID.test)
50+
course.display_name = course.display_name + 'changed'
51+
course = self.store.update_item(course, ModuleStoreEnum.UserID.test)
4952
assert not mock_signal.called
5053

5154
# changing the given field fires the signal
52-
with self.captureOnCommitCallbacks(execute=True) as callbacks:
53-
for change in changes:
54-
setattr(course, change.field_name, change.changed_value)
55-
self.store.update_item(course, ModuleStoreEnum.UserID.test)
55+
for change in changes:
56+
setattr(course, change.field_name, change.changed_value)
57+
self.store.update_item(course, ModuleStoreEnum.UserID.test)
5658
assert mock_signal.called
5759

5860
def test_caching(self):

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
from common.djangoapps.student.tests.factories import UserFactory
1212
from openedx.core.djangoapps.content_libraries import api as library_api
1313
from openedx.core.djangolib.testing.utils import skip_unless_cms
14-
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
14+
from xmodule.modulestore.tests.django_utils import (
15+
TEST_DATA_SPLIT_MODULESTORE,
16+
ModuleStoreTestCase,
17+
ImmediateOnCommitMixin,
18+
)
1519

1620

1721
try:
@@ -26,7 +30,7 @@
2630
@patch("openedx.core.djangoapps.content.search.api.MeilisearchClient")
2731
@override_settings(MEILISEARCH_ENABLED=True)
2832
@skip_unless_cms
29-
class TestUpdateIndexHandlers(ModuleStoreTestCase, LiveServerTestCase):
33+
class TestUpdateIndexHandlers(ImmediateOnCommitMixin, ModuleStoreTestCase, LiveServerTestCase):
3034
"""
3135
Test that the search index is updated when XBlocks and Library Blocks are modified
3236
"""
@@ -80,7 +84,9 @@ def test_create_delete_xblock(self, meilisearch_client):
8084
"access_id": course_access.id,
8185
"modified": created_date.timestamp(),
8286
}
87+
8388
meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_sequential])
89+
8490
with freeze_time(created_date):
8591
vertical = self.store.create_child(self.user_id, sequential.location, "vertical", "test_vertical")
8692
doc_vertical = {
@@ -119,6 +125,7 @@ def test_create_delete_xblock(self, meilisearch_client):
119125
doc_sequential["display_name"] = "Updated Sequential"
120126
doc_vertical["breadcrumbs"][1]["display_name"] = "Updated Sequential"
121127
doc_sequential["modified"] = modified_date.timestamp()
128+
122129
meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([
123130
doc_sequential,
124131
doc_vertical,

openedx/core/djangoapps/content_tagging/tests/test_tasks.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313

1414
from common.djangoapps.student.tests.factories import UserFactory
1515
from openedx.core.djangolib.testing.utils import skip_unless_cms
16-
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
16+
from xmodule.modulestore.tests.django_utils import (
17+
TEST_DATA_SPLIT_MODULESTORE,
18+
ModuleStoreTestCase,
19+
ImmediateOnCommitMixin,
20+
)
1721
from openedx.core.djangoapps.content_libraries.api import (
1822
create_library, create_library_block, delete_library_block, restore_library_block
1923
)
@@ -59,6 +63,7 @@ def setUp(self):
5963
@override_waffle_flag(CONTENT_TAGGING_AUTO, active=True)
6064
class TestAutoTagging( # type: ignore[misc]
6165
LanguageTaxonomyTestMixin,
66+
ImmediateOnCommitMixin,
6267
ModuleStoreTestCase,
6368
LiveServerTestCase
6469
):

xmodule/modulestore/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from collections import defaultdict
1313
from contextlib import contextmanager
1414
from operator import itemgetter
15+
from django.db import transaction
1516

1617
from opaque_keys.edx.keys import AssetKey, CourseKey
1718
from opaque_keys.edx.locations import Location # For import backwards compatibility
@@ -322,16 +323,20 @@ def on_commit_changes_to(self, course_key, fn):
322323
"""
323324
Call some callback when the currently active bulk operation has saved
324325
"""
326+
# If we're in a MySQL transaction, so the new version will only be committed to the
327+
# SplitModulestoreCourseIndex table after the MySQL transaction is closed.
328+
def wrapped_fn():
329+
transaction.on_commit(fn)
325330
# Check if a bulk op is active. If so, defer fn(); otherwise call it immediately.
326331
# Note: calling _get_bulk_ops_record() here and then checking .active can have side-effects in some cases
327332
# because it creates an entry in the defaultdict if none exists, so we check if the record is active using
328333
# the same code as _clear_bulk_ops_record(), which doesn't modify the defaultdict.
329334
# so we check it this way:
330335
if course_key and course_key.for_branch(None) in self._active_bulk_ops.records:
331336
bulk_ops_record = self._active_bulk_ops.records[course_key.for_branch(None)]
332-
bulk_ops_record.defer_until_commit(fn)
337+
bulk_ops_record.defer_until_commit(wrapped_fn)
333338
else:
334-
fn() # There is no active bulk operation - call fn() now.
339+
wrapped_fn() # There is no active bulk operation - call wrapped_fn() now.
335340

336341
def _is_in_bulk_operation(self, course_key, ignore_case=False):
337342
"""

xmodule/modulestore/tests/django_utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,35 @@ def update_course(self, course, user_id):
613613
return updated_course
614614

615615

616+
class ImmediateOnCommitMixin:
617+
"""
618+
Mixin for tests that want `on_commit` callbacks to run immediately,
619+
even under TestCase (which normally wraps tests in a transaction
620+
that never commits).
621+
Especially useful when the test needs to execute an event that occurs after an `on_commit`
622+
"""
623+
624+
@classmethod
625+
def setUpClass(cls):
626+
super_cls = super()
627+
if hasattr(super_cls, 'setUpClass'):
628+
super_cls.setUpClass()
629+
# Patch `transaction.on_commit` so that callbacks run immediately
630+
cls._on_commit_patcher = patch(
631+
'django.db.transaction.on_commit',
632+
side_effect=lambda func, **kwargs: func()
633+
)
634+
cls._on_commit_patcher.start()
635+
636+
@classmethod
637+
def tearDownClass(cls):
638+
# Stop patching, restore original behavior
639+
cls._on_commit_patcher.stop()
640+
super_cls = super()
641+
if hasattr(super_cls, 'tearDownClass'):
642+
super_cls.tearDownClass()
643+
644+
616645
def upload_file_to_course(course_key, contentstore, source_file, target_filename):
617646
'''
618647
Uploads the given source file to the given course, and returns the content of the file.

xmodule/tests/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from functools import wraps
1414
from unittest.mock import Mock
1515

16-
from django.test import TestCase
16+
from django.test import TransactionTestCase
1717

1818
from opaque_keys.edx.keys import CourseKey
1919
from path import Path as path
@@ -306,7 +306,7 @@ def __getitem__(self, index):
306306
return str(self)[index]
307307

308308

309-
class CourseComparisonTest(TestCase):
309+
class CourseComparisonTest(TransactionTestCase):
310310
"""
311311
Mixin that has methods for comparing courses for equality.
312312
"""

0 commit comments

Comments
 (0)