diff --git a/cms/djangoapps/contentstore/management/commands/clone_course.py b/cms/djangoapps/contentstore/management/commands/clone_course.py index a18924418e..f6df8bae42 100644 --- a/cms/djangoapps/contentstore/management/commands/clone_course.py +++ b/cms/djangoapps/contentstore/management/commands/clone_course.py @@ -38,7 +38,7 @@ class Command(BaseCommand): print("Cloning course {0} to {1}".format(source_course_id, dest_course_id)) - with mstore.bulk_write_operations(dest_course_id): + with mstore.bulk_operations(dest_course_id): if mstore.clone_course(source_course_id, dest_course_id, ModuleStoreEnum.UserID.mgmt_command): print("copying User permissions...") # purposely avoids auth.add_user b/c it doesn't have a caller to authorize diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 946500e299..ec46484922 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -867,7 +867,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): # so we don't need to make an extra query to compute it. # set the branch to 'publish' in order to prevent extra lookups of draft versions with mongo_store.branch_setting(ModuleStoreEnum.Branch.published_only): - with check_mongo_calls(mongo_store, 4, 0): + with check_mongo_calls(4, 0): course = mongo_store.get_course(course_id, depth=2) # make sure we pre-fetched a known sequential which should be at depth=2 @@ -879,7 +879,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): # Now, test with the branch set to draft. No extra round trips b/c it doesn't go deep enough to get # beyond direct only categories with mongo_store.branch_setting(ModuleStoreEnum.Branch.draft_preferred): - with check_mongo_calls(mongo_store, 4, 0): + with check_mongo_calls(4, 0): mongo_store.get_course(course_id, depth=2) def test_export_course_without_content_store(self): diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index 3bca7d16a2..7622e2d54b 100644 --- a/cms/djangoapps/contentstore/tests/test_course_listing.py +++ b/cms/djangoapps/contentstore/tests/test_course_listing.py @@ -201,10 +201,11 @@ class TestCourseListing(ModuleStoreTestCase): # Now count the db queries store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) - with check_mongo_calls(store, USER_COURSES_COUNT): + with check_mongo_calls(USER_COURSES_COUNT): _accessible_courses_list_from_groups(self.request) - with check_mongo_calls(store, 1): + # TODO: LMS-11220: Document why this takes 6 calls + with check_mongo_calls(6): _accessible_courses_list(self.request) def test_get_course_list_with_same_course_id(self): diff --git a/cms/djangoapps/contentstore/tests/test_crud.py b/cms/djangoapps/contentstore/tests/test_crud.py index 08d67db3d3..1c09bf2f8a 100644 --- a/cms/djangoapps/contentstore/tests/test_crud.py +++ b/cms/djangoapps/contentstore/tests/test_crud.py @@ -205,10 +205,9 @@ class TemplateTests(unittest.TestCase): data="" ) - # course root only updated 2x + # The draft course root has 2 revisions: the published revision, and then the subsequent + # changes to the draft revision version_history = self.split_store.get_block_generations(test_course.location) - # create course causes 2 versions for the time being; skip the first. - version_history = version_history.children[0] self.assertEqual(version_history.locator.version_guid, test_course.location.version_guid) self.assertEqual(len(version_history.children), 1) self.assertEqual(version_history.children[0].children, []) diff --git a/cms/djangoapps/contentstore/tests/test_import.py b/cms/djangoapps/contentstore/tests/test_import.py index fe6b1bbbb2..89d1a3c316 100644 --- a/cms/djangoapps/contentstore/tests/test_import.py +++ b/cms/djangoapps/contentstore/tests/test_import.py @@ -33,7 +33,7 @@ class ContentStoreImportTest(ModuleStoreTestCase): """ def setUp(self): password = super(ContentStoreImportTest, self).setUp() - + self.client = Client() self.client.login(username=self.user.username, password=password) @@ -157,15 +157,15 @@ class ContentStoreImportTest(ModuleStoreTestCase): store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) # we try to refresh the inheritance tree for each update_item in the import - with check_exact_number_of_calls(store, store.refresh_cached_metadata_inheritance_tree, 28): + with check_exact_number_of_calls(store, 'refresh_cached_metadata_inheritance_tree', 28): # _get_cached_metadata_inheritance_tree should be called only once - with check_exact_number_of_calls(store, store._get_cached_metadata_inheritance_tree, 1): + with check_exact_number_of_calls(store, '_get_cached_metadata_inheritance_tree', 1): # with bulk-edit in progress, the inheritance tree should be recomputed only at the end of the import # NOTE: On Jenkins, with memcache enabled, the number of calls here is only 1. # Locally, without memcache, the number of calls is actually 2 (once more during the publish step) - with check_number_of_calls(store, store._compute_metadata_inheritance_tree, 2): + with check_number_of_calls(store, '_compute_metadata_inheritance_tree', 2): self.load_test_import_course() def test_rewrite_reference_list(self): diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index f7eb22f99a..2f61345f9a 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -72,7 +72,7 @@ def delete_course_and_groups(course_key, user_id): """ module_store = modulestore() - with module_store.bulk_write_operations(course_key): + with module_store.bulk_operations(course_key): module_store.delete_course(course_key, user_id) print 'removing User permissions from course....' diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index eab477925f..8ba35628c2 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -423,7 +423,7 @@ def course_index(request, course_key): """ # A depth of None implies the whole course. The course outline needs this in order to compute has_changes. # A unit may not have a draft version, but one of its components could, and hence the unit itself has changes. - with modulestore().bulk_write_operations(course_key): + with modulestore().bulk_operations(course_key): course_module = _get_course_module(course_key, request.user, depth=None) lms_link = get_lms_link_for_item(course_module.location) sections = course_module.get_children() diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index 4cb269994a..681c9f7975 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -310,6 +310,13 @@ class ModuleStoreRead(object): """ pass + @contextmanager + def bulk_operations(self, course_id): + """ + A context manager for notifying the store of bulk operations. This affects only the current thread. + """ + yield + class ModuleStoreWrite(ModuleStoreRead): """ @@ -543,6 +550,33 @@ class ModuleStoreReadBase(ModuleStoreRead): raise ValueError(u"Cannot set default store to type {}".format(store_type)) yield + @contextmanager + def bulk_operations(self, course_id): + """ + A context manager for notifying the store of bulk operations. This affects only the current thread. + + In the case of Mongo, it temporarily disables refreshing the metadata inheritance tree + until the bulk operation is completed. + """ + # TODO: Make this multi-process-safe if future operations need it. + try: + self._begin_bulk_operation(course_id) + yield + finally: + self._end_bulk_operation(course_id) + + def _begin_bulk_operation(self, course_id): + """ + Begin a bulk write operation on course_id. + """ + pass + + def _end_bulk_operation(self, course_id): + """ + End the active bulk write operation on course_id. + """ + pass + class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite): ''' @@ -643,29 +677,6 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite): parent.children.append(item.location) self.update_item(parent, user_id) - @contextmanager - def bulk_write_operations(self, course_id): - """ - A context manager for notifying the store of bulk write events. - - In the case of Mongo, it temporarily disables refreshing the metadata inheritance tree - until the bulk operation is completed. - """ - # TODO - # Make this multi-process-safe if future operations need it. - # Right now, only Import Course, Clone Course, and Delete Course use this, so - # it's ok if the cached metadata in the memcache is invalid when another - # request comes in for the same course. - try: - if hasattr(self, '_begin_bulk_write_operation'): - self._begin_bulk_write_operation(course_id) - yield - finally: - # check for the begin method here, - # since it's an error if an end method is not defined when a begin method is - if hasattr(self, '_begin_bulk_write_operation'): - self._end_bulk_write_operation(course_id) - def only_xmodules(identifier, entry_points): """Only use entry_points that are supplied by the xmodule package""" diff --git a/common/lib/xmodule/xmodule/modulestore/exceptions.py b/common/lib/xmodule/xmodule/modulestore/exceptions.py index 0f29816df5..e6fa9edc0f 100644 --- a/common/lib/xmodule/xmodule/modulestore/exceptions.py +++ b/common/lib/xmodule/xmodule/modulestore/exceptions.py @@ -54,20 +54,16 @@ class DuplicateItemError(Exception): self, Exception.__str__(self, *args, **kwargs) ) + class VersionConflictError(Exception): """ The caller asked for either draft or published head and gave a version which conflicted with it. """ def __init__(self, requestedLocation, currentHeadVersionGuid): - super(VersionConflictError, self).__init__() - self.requestedLocation = requestedLocation - self.currentHeadVersionGuid = currentHeadVersionGuid - - def __str__(self, *args, **kwargs): - """ - Print requested and current head info - """ - return u'Requested {} but {} is current head'.format(self.requestedLocation, self.currentHeadVersionGuid) + super(VersionConflictError, self).__init__(u'Requested {}, but current head is {}'.format( + requestedLocation, + currentHeadVersionGuid + )) class DuplicateCourseError(Exception): diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py index 4092306e9e..9df05ad219 100644 --- a/common/lib/xmodule/xmodule/modulestore/mixed.py +++ b/common/lib/xmodule/xmodule/modulestore/mixed.py @@ -645,11 +645,11 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): yield @contextmanager - def bulk_write_operations(self, course_id): + def bulk_operations(self, course_id): """ - A context manager for notifying the store of bulk write events. + A context manager for notifying the store of bulk operations. If course_id is None, the default store is used. """ store = self._get_modulestore_for_courseid(course_id) - with store.bulk_write_operations(course_id): + with store.bulk_operations(course_id): yield diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py index e2ccc55a1f..7cc1a31b5b 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py @@ -17,6 +17,7 @@ import sys import logging import copy import re +import threading from uuid import uuid4 from bson.son import SON @@ -414,7 +415,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): # performance optimization to prevent updating the meta-data inheritance tree during # bulk write operations - self.ignore_write_events_on_courses = set() + self.ignore_write_events_on_courses = threading.local() self._course_run_cache = {} def close_connections(self): @@ -435,27 +436,36 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): connection.drop_database(self.collection.database) connection.close() - def _begin_bulk_write_operation(self, course_id): + def _begin_bulk_operation(self, course_id): """ Prevent updating the meta-data inheritance cache for the given course """ - self.ignore_write_events_on_courses.add(course_id) + if not hasattr(self.ignore_write_events_on_courses, 'courses'): + self.ignore_write_events_on_courses.courses = set() - def _end_bulk_write_operation(self, course_id): + self.ignore_write_events_on_courses.courses.add(course_id) + + def _end_bulk_operation(self, course_id): """ Restart updating the meta-data inheritance cache for the given course. Refresh the meta-data inheritance cache now since it was temporarily disabled. """ - if course_id in self.ignore_write_events_on_courses: - self.ignore_write_events_on_courses.remove(course_id) + if not hasattr(self.ignore_write_events_on_courses, 'courses'): + return + + if course_id in self.ignore_write_events_on_courses.courses: + self.ignore_write_events_on_courses.courses.remove(course_id) self.refresh_cached_metadata_inheritance_tree(course_id) def _is_bulk_write_in_progress(self, course_id): """ Returns whether a bulk write operation is in progress for the given course. """ + if not hasattr(self.ignore_write_events_on_courses, 'courses'): + return False + course_id = course_id.for_branch(None) - return course_id in self.ignore_write_events_on_courses + return course_id in self.ignore_write_events_on_courses.courses def fill_in_run(self, course_key): """ diff --git a/common/lib/xmodule/xmodule/modulestore/search.py b/common/lib/xmodule/xmodule/modulestore/search.py index 161e6e2cef..8854aa1865 100644 --- a/common/lib/xmodule/xmodule/modulestore/search.py +++ b/common/lib/xmodule/xmodule/modulestore/search.py @@ -68,39 +68,40 @@ def path_to_location(modulestore, usage_key): newpath = (next_usage, path) queue.append((parent, newpath)) - if not modulestore.has_item(usage_key): - raise ItemNotFoundError(usage_key) + with modulestore.bulk_operations(usage_key.course_key): + if not modulestore.has_item(usage_key): + raise ItemNotFoundError(usage_key) - path = find_path_to_course() - if path is None: - raise NoPathToItem(usage_key) + path = find_path_to_course() + if path is None: + raise NoPathToItem(usage_key) - n = len(path) - course_id = path[0].course_key - # pull out the location names - chapter = path[1].name if n > 1 else None - section = path[2].name if n > 2 else None - # Figure out the position - position = None + n = len(path) + course_id = path[0].course_key + # pull out the location names + chapter = path[1].name if n > 1 else None + section = path[2].name if n > 2 else None + # Figure out the position + position = None - # This block of code will find the position of a module within a nested tree - # of modules. If a problem is on tab 2 of a sequence that's on tab 3 of a - # sequence, the resulting position is 3_2. However, no positional modules - # (e.g. sequential and videosequence) currently deal with this form of - # representing nested positions. This needs to happen before jumping to a - # module nested in more than one positional module will work. - if n > 3: - position_list = [] - for path_index in range(2, n - 1): - category = path[path_index].block_type - if category == 'sequential' or category == 'videosequence': - section_desc = modulestore.get_item(path[path_index]) - # this calls get_children rather than just children b/c old mongo includes private children - # in children but not in get_children - child_locs = [c.location for c in section_desc.get_children()] - # positions are 1-indexed, and should be strings to be consistent with - # url parsing. - position_list.append(str(child_locs.index(path[path_index + 1]) + 1)) - position = "_".join(position_list) + # This block of code will find the position of a module within a nested tree + # of modules. If a problem is on tab 2 of a sequence that's on tab 3 of a + # sequence, the resulting position is 3_2. However, no positional modules + # (e.g. sequential and videosequence) currently deal with this form of + # representing nested positions. This needs to happen before jumping to a + # module nested in more than one positional module will work. + if n > 3: + position_list = [] + for path_index in range(2, n - 1): + category = path[path_index].block_type + if category == 'sequential' or category == 'videosequence': + section_desc = modulestore.get_item(path[path_index]) + # this calls get_children rather than just children b/c old mongo includes private children + # in children but not in get_children + child_locs = [c.location for c in section_desc.get_children()] + # positions are 1-indexed, and should be strings to be consistent with + # url parsing. + position_list.append(str(child_locs.index(path[path_index + 1]) + 1)) + position = "_".join(position_list) - return (course_id, chapter, section, position) + return (course_id, chapter, section, position) diff --git a/common/lib/xmodule/xmodule/modulestore/split_migrator.py b/common/lib/xmodule/xmodule/modulestore/split_migrator.py index 5f8c987f47..e7b1b63c7f 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_migrator.py +++ b/common/lib/xmodule/xmodule/modulestore/split_migrator.py @@ -55,23 +55,27 @@ class SplitMigrator(object): new_run = source_course_key.run new_course_key = CourseLocator(new_org, new_course, new_run, branch=ModuleStoreEnum.BranchName.published) - new_fields = self._get_fields_translate_references(original_course, new_course_key, None) - if fields: - new_fields.update(fields) - new_course = self.split_modulestore.create_course( - new_org, new_course, new_run, user_id, - fields=new_fields, - master_branch=ModuleStoreEnum.BranchName.published, - skip_auto_publish=True, - **kwargs - ) + with self.split_modulestore.bulk_operations(new_course_key): + new_fields = self._get_fields_translate_references(original_course, new_course_key, None) + if fields: + new_fields.update(fields) + new_course = self.split_modulestore.create_course( + new_org, new_course, new_run, user_id, + fields=new_fields, + master_branch=ModuleStoreEnum.BranchName.published, + skip_auto_publish=True, + **kwargs + ) - with self.split_modulestore.bulk_write_operations(new_course.id): self._copy_published_modules_to_course( new_course, original_course.location, source_course_key, user_id, **kwargs ) - # create a new version for the drafts - with self.split_modulestore.bulk_write_operations(new_course.id): + + # TODO: This should be merged back into the above transaction, but can't be until split.py + # is refactored to have more coherent access patterns + with self.split_modulestore.bulk_operations(new_course_key): + + # create a new version for the drafts self._add_draft_modules_to_course(new_course.location, source_course_key, user_id, **kwargs) return new_course.id @@ -80,7 +84,7 @@ class SplitMigrator(object): """ Copy all of the modules from the 'direct' version of the course to the new split course. """ - course_version_locator = new_course.id + course_version_locator = new_course.id.version_agnostic() # iterate over published course elements. Wildcarding rather than descending b/c some elements are orphaned (e.g., # course about pages, conditionals) @@ -101,7 +105,6 @@ class SplitMigrator(object): fields=self._get_fields_translate_references( module, course_version_locator, new_course.location.block_id ), - continue_version=True, skip_auto_publish=True, **kwargs ) @@ -109,7 +112,7 @@ class SplitMigrator(object): index_info = self.split_modulestore.get_course_index_info(course_version_locator) versions = index_info['versions'] versions[ModuleStoreEnum.BranchName.draft] = versions[ModuleStoreEnum.BranchName.published] - self.split_modulestore.update_course_index(index_info) + self.split_modulestore.update_course_index(course_version_locator, index_info) # clean up orphans in published version: in old mongo, parents pointed to the union of their published and draft # children which meant some pointers were to non-existent locations in 'direct' diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py index 66de16bfd7..7c84f5e061 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py @@ -2,7 +2,7 @@ import sys import logging from xblock.runtime import KvsFieldData from xblock.fields import ScopeIds -from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator +from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator, DefinitionLocator from xmodule.mako_module import MakoDescriptorSystem from xmodule.error_module import ErrorDescriptor from xmodule.errortracker import exc_info_to_str @@ -10,6 +10,7 @@ from xmodule.modulestore.split_mongo import encode_key_for_mongo from ..exceptions import ItemNotFoundError from .split_mongo_kvs import SplitMongoKVS from fs.osfs import OSFS +from .definition_lazy_loader import DefinitionLazyLoader log = logging.getLogger(__name__) @@ -120,9 +121,24 @@ class CachingDescriptorSystem(MakoDescriptorSystem): self.course_entry['org'] = course_entry_override['org'] self.course_entry['course'] = course_entry_override['course'] self.course_entry['run'] = course_entry_override['run'] - # most likely a lazy loader or the id directly - definition = json_data.get('definition', {}) - definition_id = self.modulestore.definition_locator(definition) + + definition_id = json_data.get('definition') + block_type = json_data['category'] + + if definition_id is not None and not json_data.get('definition_loaded', False): + definition_loader = DefinitionLazyLoader( + self.modulestore, block_type, definition_id, + lambda fields: self.modulestore.convert_references_to_keys( + course_key, self.load_block_type(block_type), + fields, self.course_entry['structure']['blocks'], + ) + ) + else: + definition_loader = None + + # If no definition id is provide, generate an in-memory id + if definition_id is None: + definition_id = LocalId() # If no usage id is provided, generate an in-memory id if block_id is None: @@ -130,7 +146,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): block_locator = BlockUsageLocator( course_key, - block_type=json_data.get('category'), + block_type=block_type, block_id=block_id, ) @@ -138,7 +154,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): block_locator.course_key, class_, json_data.get('fields', {}), self.course_entry['structure']['blocks'], ) kvs = SplitMongoKVS( - definition, + definition_loader, converted_fields, json_data.get('_inherited_settings'), **kwargs @@ -148,7 +164,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): try: module = self.construct_xblock_from_class( class_, - ScopeIds(None, json_data.get('category'), definition_id, block_locator), + ScopeIds(None, block_type, definition_id, block_locator), field_data, ) except Exception: @@ -174,7 +190,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): module.previous_version = edit_info.get('previous_version') module.update_version = edit_info.get('update_version') module.source_version = edit_info.get('source_version', None) - module.definition_locator = definition_id + module.definition_locator = DefinitionLocator(block_type, definition_id) # decache any pending field settings module.save() diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py index a9b91fe4a5..1ce6693e96 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py @@ -1,4 +1,5 @@ from opaque_keys.edx.locator import DefinitionLocator +from bson import SON class DefinitionLazyLoader(object): @@ -24,3 +25,9 @@ class DefinitionLazyLoader(object): loader pointer with the result so as not to fetch more than once """ return self.modulestore.db_connection.get_definition(self.definition_locator.definition_id) + + def as_son(self): + return SON(( + ('category', self.definition_locator.block_type), + ('definition', self.definition_locator.definition_id) + )) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py index 9109d31be5..324a4d8063 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py @@ -57,24 +57,42 @@ class MongoConnection(object): """ return self.structures.find_one({'_id': key}) - def find_matching_structures(self, query): + def find_structures_by_id(self, ids): """ - Find the structure matching the query. Right now the query must be a legal mongo query - :param query: a mongo-style query of {key: [value|{$in ..}|..], ..} - """ - return self.structures.find(query) + Return all structures that specified in ``ids``. - def insert_structure(self, structure): + Arguments: + ids (list): A list of structure ids """ - Create the structure in the db - """ - self.structures.insert(structure) + return self.structures.find({'_id': {'$in': ids}}) - def update_structure(self, structure): + def find_structures_derived_from(self, ids): """ - Update the db record for structure + Return all structures that were immediately derived from a structure listed in ``ids``. + + Arguments: + ids (list): A list of structure ids """ - self.structures.update({'_id': structure['_id']}, structure) + return self.structures.find({'previous_version': {'$in': ids}}) + + def find_ancestor_structures(self, original_version, block_id): + """ + Find all structures that originated from ``original_version`` that contain ``block_id``. + + Arguments: + original_version (str or ObjectID): The id of a structure + block_id (str): The id of the block in question + """ + return self.structures.find({ + 'original_version': original_version, + 'blocks.{}.edit_info.update_version'.format(block_id): {'$exists': True} + }) + + def upsert_structure(self, structure): + """ + Update the db record for structure, creating that record if it doesn't already exist + """ + self.structures.update({'_id': structure['_id']}, structure, upsert=True) def get_course_index(self, key, ignore_case=False): """ @@ -88,11 +106,23 @@ class MongoConnection(object): ]) ) - def find_matching_course_indexes(self, query): + def find_matching_course_indexes(self, branch=None, search_targets=None): """ - Find the course_index matching the query. Right now the query must be a legal mongo query - :param query: a mongo-style query of {key: [value|{$in ..}|..], ..} + Find the course_index matching particular conditions. + + Arguments: + branch: If specified, this branch must exist in the returned courses + search_targets: If specified, this must be a dictionary specifying field values + that must exist in the search_targets of the returned courses """ + query = son.SON() + if branch is not None: + query['versions.{}'.format(branch)] = {'$exists': True} + + if search_targets: + for key, value in search_targets.iteritems(): + query['search_targets.{}'.format(key)] = value + return self.course_index.find(query) def insert_course_index(self, course_index): @@ -101,13 +131,21 @@ class MongoConnection(object): """ self.course_index.insert(course_index) - def update_course_index(self, course_index): + def update_course_index(self, course_index, from_index=None): """ - Update the db record for course_index + Update the db record for course_index. + + Arguments: + from_index: If set, only update an index if it matches the one specified in `from_index`. """ self.course_index.update( - son.SON([('org', course_index['org']), ('course', course_index['course']), ('run', course_index['run'])]), - course_index + from_index or son.SON([ + ('org', course_index['org']), + ('course', course_index['course']), + ('run', course_index['run']) + ]), + course_index, + upsert=False, ) def delete_course_index(self, course_index): diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index 6e63c927bd..88551f3f7b 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -57,6 +57,7 @@ from path import path import copy from pytz import UTC from bson.objectid import ObjectId +from pymongo.errors import DuplicateKeyError from xblock.core import XBlock from xblock.fields import Scope, Reference, ReferenceList, ReferenceValueDict @@ -72,7 +73,6 @@ from xmodule.modulestore import ( ) from ..exceptions import ItemNotFoundError -from .definition_lazy_loader import DefinitionLazyLoader from .caching_descriptor_system import CachingDescriptorSystem from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection from xmodule.error_module import ErrorDescriptor @@ -82,6 +82,7 @@ from types import NoneType log = logging.getLogger(__name__) + #============================================================================== # # Known issue: @@ -104,7 +105,406 @@ log = logging.getLogger(__name__) EXCLUDE_ALL = '*' -class SplitMongoModuleStore(ModuleStoreWriteBase): +class BulkWriteRecord(object): + def __init__(self): + self._active_count = 0 + self.initial_index = None + self.index = None + self.structures = {} + self.structures_in_db = set() + + @property + def active(self): + """ + Return whether this bulk write is active. + """ + return self._active_count > 0 + + def nest(self): + """ + Record another level of nesting of this bulk write operation + """ + self._active_count += 1 + + def unnest(self): + """ + Record the completion of a level of nesting of the bulk write operation + """ + self._active_count -= 1 + + @property + def is_root(self): + """ + Return whether the bulk write is at the root (first) level of nesting + """ + return self._active_count == 1 + + # TODO: This needs to track which branches have actually been modified/versioned, + # so that copying one branch to another doesn't update the original branch. + @property + def dirty_branches(self): + """ + Return a list of which branch version ids differ from what was stored + in the database at the beginning of this bulk operation. + """ + # If no course index has been set, then no branches have changed + if self.index is None: + return [] + + # If there was no index in the database to start with, then all branches + # are dirty by definition + if self.initial_index is None: + return self.index.get('versions', {}).keys() + + # Return branches whose ids differ between self.index and self.initial_index + return [ + branch + for branch, _id + in self.index.get('versions', {}).items() + if self.initial_index.get('versions', {}).get(branch) != _id + ] + + def structure_for_branch(self, branch): + return self.structures.get(self.index.get('versions', {}).get(branch)) + + def set_structure_for_branch(self, branch, structure): + self.index.get('versions', {})[branch] = structure['_id'] + self.structures[structure['_id']] = structure + + def __repr__(self): + return u"BulkWriteRecord<{!r}, {!r}, {!r}, {!r}, {!r}>".format( + self._active_count, + self.initial_index, + self.index, + self.structures, + self.structures_in_db, + ) + + +class BulkWriteMixin(object): + """ + This implements the :meth:`bulk_operations` modulestore semantics for the :class:`SplitMongoModuleStore`. + + In particular, it implements :meth:`_begin_bulk_operation` and + :meth:`_end_bulk_operation` to provide the external interface, and then exposes a set of methods + for interacting with course_indexes and structures that can be used by :class:`SplitMongoModuleStore`. + + Internally, this mixin records the set of all active bulk operations (keyed on the active course), + and only writes those values to ``self.mongo_connection`` when :meth:`_end_bulk_operation` is called. + If a bulk write operation isn't active, then the changes are immediately written to the underlying + mongo_connection. + """ + def __init__(self, *args, **kwargs): + super(BulkWriteMixin, self).__init__(*args, **kwargs) + self._active_bulk_writes = threading.local() + + def _get_bulk_write_record(self, course_key, ignore_case=False): + """ + Return the :class:`.BulkWriteRecord` for this course. + """ + if course_key is None: + return BulkWriteRecord() + + if not isinstance(course_key, CourseLocator): + raise TypeError(u'{!r} is not a CourseLocator'.format(course_key)) + if not hasattr(self._active_bulk_writes, 'records'): + self._active_bulk_writes.records = defaultdict(BulkWriteRecord) + + # Retrieve the bulk record based on matching org/course/run (possibly ignoring case) + if course_key.org and course_key.course and course_key.run: + if ignore_case: + for key, record in self._active_bulk_writes.records.iteritems(): + if ( + key.org.lower() == course_key.org.lower() and + key.course.lower() == course_key.course.lower() and + key.run.lower() == course_key.run.lower() + ): + return record + # If nothing matches case-insensitively, fall through to creating a new record with the passed in case + return self._active_bulk_writes.records[course_key.replace(branch=None, version_guid=None)] + else: + # If nothing org/course/run aren't set, use a bulk record that is identified just by the version_guid + return self._active_bulk_writes.records[course_key.replace(org=None, course=None, run=None, branch=None)] + + @property + def _active_records(self): + """ + Yield all active (CourseLocator, BulkWriteRecord) tuples. + """ + for course_key, record in getattr(self._active_bulk_writes, 'records', {}).iteritems(): + if record.active: + yield (course_key, record) + + def _clear_bulk_write_record(self, course_key): + if not isinstance(course_key, CourseLocator): + raise TypeError('{!r} is not a CourseLocator'.format(course_key)) + + if not hasattr(self._active_bulk_writes, 'records'): + return + + if course_key.org and course_key.course and course_key.run: + del self._active_bulk_writes.records[course_key.replace(branch=None, version_guid=None)] + else: + del self._active_bulk_writes.records[course_key.replace(org=None, course=None, run=None, branch=None)] + + def _begin_bulk_operation(self, course_key): + """ + Begin a bulk write operation on course_key. + """ + bulk_write_record = self._get_bulk_write_record(course_key) + + # Increment the number of active bulk operations (bulk operations + # on the same course can be nested) + bulk_write_record.nest() + + # If this is the highest level bulk operation, then initialize it + if bulk_write_record.is_root: + bulk_write_record.initial_index = self.db_connection.get_course_index(course_key) + # Ensure that any edits to the index don't pollute the initial_index + bulk_write_record.index = copy.deepcopy(bulk_write_record.initial_index) + + def _end_bulk_operation(self, course_key): + """ + End the active bulk write operation on course_key. + """ + # If no bulk write is active, return + bulk_write_record = self._get_bulk_write_record(course_key) + if not bulk_write_record.active: + return + + bulk_write_record.unnest() + + # If this wasn't the outermost context, then don't close out the + # bulk write operation. + if bulk_write_record.active: + return + + # This is the last active bulk write. If the content is dirty, + # then update the database + for _id in bulk_write_record.structures.viewkeys() - bulk_write_record.structures_in_db: + self.db_connection.upsert_structure(bulk_write_record.structures[_id]) + + if bulk_write_record.index is not None and bulk_write_record.index != bulk_write_record.initial_index: + if bulk_write_record.initial_index is None: + self.db_connection.insert_course_index(bulk_write_record.index) + else: + self.db_connection.update_course_index(bulk_write_record.index, from_index=bulk_write_record.initial_index) + + self._clear_bulk_write_record(course_key) + + def _is_in_bulk_write_operation(self, course_key, ignore_case=False): + """ + Return whether a bulk write is active on `course_key`. + """ + return self._get_bulk_write_record(course_key, ignore_case).active + + def get_course_index(self, course_key, ignore_case=False): + """ + Return the index for course_key. + """ + if self._is_in_bulk_write_operation(course_key, ignore_case): + return self._get_bulk_write_record(course_key, ignore_case).index + else: + return self.db_connection.get_course_index(course_key, ignore_case) + + def insert_course_index(self, course_key, index_entry): + bulk_write_record = self._get_bulk_write_record(course_key) + if bulk_write_record.active: + bulk_write_record.index = index_entry + else: + self.db_connection.insert_course_index(index_entry) + + def update_course_index(self, course_key, updated_index_entry): + """ + Change the given course's index entry. + + Note, this operation can be dangerous and break running courses. + + Does not return anything useful. + """ + bulk_write_record = self._get_bulk_write_record(course_key) + if bulk_write_record.active: + bulk_write_record.index = updated_index_entry + else: + self.db_connection.update_course_index(updated_index_entry) + + def get_structure(self, course_key, version_guid): + bulk_write_record = self._get_bulk_write_record(course_key) + if bulk_write_record.active: + structure = bulk_write_record.structures.get(version_guid) + + # The structure hasn't been loaded from the db yet, so load it + if structure is None: + structure = self.db_connection.get_structure(version_guid) + bulk_write_record.structures[version_guid] = structure + if structure is not None: + bulk_write_record.structures_in_db.add(version_guid) + + return structure + else: + # cast string to ObjectId if necessary + version_guid = course_key.as_object_id(version_guid) + return self.db_connection.get_structure(version_guid) + + def update_structure(self, course_key, structure): + """ + Update a course structure, respecting the current bulk operation status + (no data will be written to the database if a bulk operation is active.) + """ + self._clear_cache(structure['_id']) + bulk_write_record = self._get_bulk_write_record(course_key) + if bulk_write_record.active: + bulk_write_record.structures[structure['_id']] = structure + else: + self.db_connection.upsert_structure(structure) + + def version_structure(self, course_key, structure, user_id): + """ + Copy the structure and update the history info (edited_by, edited_on, previous_version) + """ + bulk_write_record = self._get_bulk_write_record(course_key) + + # If we have an active bulk write, and it's already been edited, then just use that structure + if bulk_write_record.active and course_key.branch in bulk_write_record.dirty_branches: + return bulk_write_record.structure_for_branch(course_key.branch) + + # Otherwise, make a new structure + new_structure = copy.deepcopy(structure) + new_structure['_id'] = ObjectId() + new_structure['previous_version'] = structure['_id'] + new_structure['edited_by'] = user_id + new_structure['edited_on'] = datetime.datetime.now(UTC) + new_structure['schema_version'] = self.SCHEMA_VERSION + + # If we're in a bulk write, update the structure used there, and mark it as dirty + if bulk_write_record.active: + bulk_write_record.set_structure_for_branch(course_key.branch, new_structure) + + return new_structure + + def version_block(self, block_info, user_id, update_version): + """ + Update the block_info dictionary based on it having been edited + """ + if block_info['edit_info'].get('update_version') == update_version: + return + + block_info['edit_info'] = { + 'edited_on': datetime.datetime.now(UTC), + 'edited_by': user_id, + 'previous_version': block_info['edit_info']['update_version'], + 'update_version': update_version, + } + + def find_matching_course_indexes(self, branch=None, search_targets=None): + """ + Find the course_indexes which have the specified branch and search_targets. + """ + indexes = self.db_connection.find_matching_course_indexes(branch, search_targets) + + for _, record in self._active_records: + if branch and branch not in record.index.get('versions', {}): + continue + + if search_targets: + if any( + 'search_targets' not in record.index or + field not in record.index['search_targets'] or + record.index['search_targets'][field] != value + for field, value in search_targets.iteritems() + ): + continue + + indexes.append(record.index) + + return indexes + + def find_structures_by_id(self, ids): + """ + Return all structures that specified in ``ids``. + + If a structure with the same id is in both the cache and the database, + the cached version will be preferred. + + Arguments: + ids (list): A list of structure ids + """ + structures = [] + ids = set(ids) + + for _, record in self._active_records: + for structure in record.structures.values(): + structure_id = structure.get('_id') + if structure_id in ids: + ids.remove(structure_id) + structures.append(structure) + + structures.extend(self.db_connection.find_structures_by_id(list(ids))) + return structures + + def find_structures_derived_from(self, ids): + """ + Return all structures that were immediately derived from a structure listed in ``ids``. + + Arguments: + ids (list): A list of structure ids + """ + found_structure_ids = set() + structures = [] + + for _, record in self._active_records: + for structure in record.structures.values(): + if structure.get('previous_version') in ids: + structures.append(structure) + if '_id' in structure: + found_structure_ids.add(structure['_id']) + + structures.extend( + structure + for structure in self.db_connection.find_structures_derived_from(ids) + if structure['_id'] not in found_structure_ids + ) + return structures + + def find_ancestor_structures(self, original_version, block_id): + """ + Find all structures that originated from ``original_version`` that contain ``block_id``. + + Any structure found in the cache will be preferred to a structure with the same id from the database. + + Arguments: + original_version (str or ObjectID): The id of a structure + block_id (str): The id of the block in question + """ + found_structure_ids = set() + structures = [] + + for _, record in self._active_records: + for structure in record.structures.values(): + if 'original_version' not in structure: + continue + + if structure['original_version'] != original_version: + continue + + if block_id not in structure.get('blocks', {}): + continue + + if 'update_version' not in structure['blocks'][block_id].get('edit_info', {}): + continue + + structures.append(structure) + found_structure_ids.add(structure['_id']) + + structures.extend( + structure + for structure in self.db_connection.find_ancestor_structures(original_version, block_id) + if structure['_id'] not in found_structure_ids + ) + return structures + + +class SplitMongoModuleStore(BulkWriteMixin, ModuleStoreWriteBase): """ A Mongodb backed ModuleStore supporting versions, inheritance, and sharing. @@ -173,50 +573,45 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ''' Handles caching of items once inheritance and any other one time per course per fetch operations are done. - :param system: a CachingDescriptorSystem - :param base_block_ids: list of block_ids to fetch - :param course_key: the destination course providing the context - :param depth: how deep below these to prefetch - :param lazy: whether to fetch definitions or use placeholders + + Arguments: + system: a CachingDescriptorSystem + base_block_ids: list of block_ids to fetch + course_key: the destination course providing the context + depth: how deep below these to prefetch + lazy: whether to fetch definitions or use placeholders ''' - new_module_data = {} - for block_id in base_block_ids: - new_module_data = self.descendants( - system.course_entry['structure']['blocks'], - block_id, - depth, - new_module_data - ) - - if lazy: - for block in new_module_data.itervalues(): - block['definition'] = DefinitionLazyLoader( - self, block['category'], block['definition'], - lambda fields: self.convert_references_to_keys( - course_key, system.load_block_type(block['category']), - fields, system.course_entry['structure']['blocks'], - ) + with self.bulk_operations(course_key): + new_module_data = {} + for block_id in base_block_ids: + new_module_data = self.descendants( + system.course_entry['structure']['blocks'], + block_id, + depth, + new_module_data ) - else: - # Load all descendants by id - descendent_definitions = self.db_connection.find_matching_definitions({ - '_id': {'$in': [block['definition'] - for block in new_module_data.itervalues()]}}) - # turn into a map - definitions = {definition['_id']: definition - for definition in descendent_definitions} - for block in new_module_data.itervalues(): - if block['definition'] in definitions: - converted_fields = self.convert_references_to_keys( - course_key, system.load_block_type(block['category']), - definitions[block['definition']].get('fields'), - system.course_entry['structure']['blocks'], - ) - block['fields'].update(converted_fields) + if not lazy: + # Load all descendants by id + descendent_definitions = self.db_connection.find_matching_definitions({ + '_id': {'$in': [block['definition'] + for block in new_module_data.itervalues()]}}) + # turn into a map + definitions = {definition['_id']: definition + for definition in descendent_definitions} - system.module_data.update(new_module_data) - return system.module_data + for block in new_module_data.itervalues(): + if block['definition'] in definitions: + converted_fields = self.convert_references_to_keys( + course_key, system.load_block_type(block['category']), + definitions[block['definition']].get('fields'), + system.course_entry['structure']['blocks'], + ) + block['fields'].update(converted_fields) + block['definition_loaded'] = True + + system.module_data.update(new_module_data) + return system.module_data def _load_items(self, course_entry, block_ids, depth=0, lazy=True, **kwargs): ''' @@ -265,6 +660,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): :param course_version_guid: if provided, clear only this entry """ if course_version_guid: + if not hasattr(self.thread_cache, 'course_cache'): + self.thread_cache.course_cache = {} try: del self.thread_cache.course_cache[course_version_guid] except KeyError: @@ -272,7 +669,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): else: self.thread_cache.course_cache = {} - def _lookup_course(self, course_locator): + def _lookup_course(self, course_key): ''' Decode the locator into the right series of db access. Does not return the CourseDescriptor! It returns the actual db json from @@ -283,45 +680,50 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): it raises VersionConflictError (the version now differs from what it was when you got your reference) - :param course_locator: any subclass of CourseLocator + :param course_key: any subclass of CourseLocator ''' - if course_locator.org and course_locator.course and course_locator.run: - if course_locator.branch is None: - raise InsufficientSpecificationError(course_locator) + if course_key.org and course_key.course and course_key.run: + if course_key.branch is None: + raise InsufficientSpecificationError(course_key) + # use the course id - index = self.db_connection.get_course_index(course_locator) + index = self.get_course_index(course_key) + if index is None: - raise ItemNotFoundError(course_locator) - if course_locator.branch not in index['versions']: - raise ItemNotFoundError(course_locator) - version_guid = index['versions'][course_locator.branch] - if course_locator.version_guid is not None and version_guid != course_locator.version_guid: + raise ItemNotFoundError(course_key) + if course_key.branch not in index['versions']: + raise ItemNotFoundError(course_key) + + version_guid = index['versions'][course_key.branch] + + if course_key.version_guid is not None and version_guid != course_key.version_guid: # This may be a bit too touchy but it's hard to infer intent - raise VersionConflictError(course_locator, version_guid) - elif course_locator.version_guid is None: - raise InsufficientSpecificationError(course_locator) + raise VersionConflictError(course_key, version_guid) + + elif course_key.version_guid is None: + raise InsufficientSpecificationError(course_key) else: # TODO should this raise an exception if branch was provided? - version_guid = course_locator.version_guid + version_guid = course_key.version_guid - # cast string to ObjectId if necessary - version_guid = course_locator.as_object_id(version_guid) - entry = self.db_connection.get_structure(version_guid) + entry = self.get_structure(course_key, version_guid) + if entry is None: + raise ItemNotFoundError('Structure: {}'.format(version_guid)) # b/c more than one course can use same structure, the 'org', 'course', # 'run', and 'branch' are not intrinsic to structure # and the one assoc'd w/ it by another fetch may not be the one relevant to this fetch; so, # add it in the envelope for the structure. envelope = { - 'org': course_locator.org, - 'course': course_locator.course, - 'run': course_locator.run, - 'branch': course_locator.branch, + 'org': course_key.org, + 'course': course_key.course, + 'run': course_key.run, + 'branch': course_key.branch, 'structure': entry, } return envelope - def get_courses(self, branch, qualifiers=None, **kwargs): + def get_courses(self, branch, **kwargs): ''' Returns a list of course descriptors matching any given qualifiers. @@ -332,12 +734,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): To get specific versions via guid use get_course. :param branch: the branch for which to return courses. - :param qualifiers: an optional dict restricting which elements should match ''' - if qualifiers is None: - qualifiers = {} - qualifiers.update({"versions.{}".format(branch): {"$exists": True}}) - matching_indexes = self.db_connection.find_matching_course_indexes(qualifiers) + matching_indexes = self.find_matching_course_indexes(branch) # collect ids and then query for those version_guids = [] @@ -347,7 +745,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): version_guids.append(version_guid) id_version_map[version_guid] = course_index - matching_structures = self.db_connection.find_matching_structures({'_id': {'$in': version_guids}}) + matching_structures = self.find_structures_by_id(version_guids) # get the blocks for each course index (s/b the root) result = [] @@ -401,7 +799,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): # The supplied CourseKey is of the wrong type, so it can't possibly be stored in this modulestore. return False - course_index = self.db_connection.get_course_index(course_id, ignore_case) + course_index = self.get_course_index(course_id, ignore_case) return CourseLocator(course_index['org'], course_index['course'], course_index['run'], course_id.branch) if course_index else None def has_item(self, usage_key): @@ -413,7 +811,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): if usage_key.block_id is None: raise InsufficientSpecificationError(usage_key) try: - course_structure = self._lookup_course(usage_key)['structure'] + course_structure = self._lookup_course(usage_key.course_key)['structure'] except ItemNotFoundError: # this error only occurs if the course does not exist return False @@ -433,7 +831,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): # The supplied UsageKey is of the wrong type, so it can't possibly be stored in this modulestore. raise ItemNotFoundError(usage_key) - course = self._lookup_course(usage_key) + course = self._lookup_course(usage_key.course_key) items = self._load_items(course, [usage_key.block_id], depth, lazy=True, **kwargs) if len(items) == 0: raise ItemNotFoundError(usage_key) @@ -511,7 +909,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): :param locator: BlockUsageLocator restricting search scope ''' - course = self._lookup_course(locator) + course = self._lookup_course(locator.course_key) parent_id = self._get_parent_from_structure(locator.block_id, course['structure']) if parent_id is None: return None @@ -541,12 +939,12 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): for block_id in items ] - def get_course_index_info(self, course_locator): + def get_course_index_info(self, course_key): """ The index records the initial creation of the indexed course and tracks the current version heads. This function is primarily for test verification but may serve some more general purpose. - :param course_locator: must have a org, course, and run set + :param course_key: must have a org, course, and run set :return {'org': string, versions: {'draft': the head draft version id, 'published': the head published version id if any, @@ -555,24 +953,24 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): 'edited_on': when the course was originally created } """ - if not (course_locator.course and course_locator.run and course_locator.org): + if not (course_key.course and course_key.run and course_key.org): return None - index = self.db_connection.get_course_index(course_locator) + index = self.get_course_index(course_key) return index # TODO figure out a way to make this info accessible from the course descriptor - def get_course_history_info(self, course_locator): + def get_course_history_info(self, course_key): """ Because xblocks doesn't give a means to separate the course structure's meta information from the course xblock's, this method will get that info for the structure as a whole. - :param course_locator: + :param course_key: :return {'original_version': the version guid of the original version of this course, 'previous_version': the version guid of the previous version, 'edited_by': who made the last change, 'edited_on': when the change was made } """ - course = self._lookup_course(course_locator)['structure'] + course = self._lookup_course(course_key)['structure'] return { 'original_version': course['original_version'], 'previous_version': course['previous_version'], @@ -613,22 +1011,20 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): # TODO if depth is significant, it may make sense to get all that have the same original_version # and reconstruct the subtree from version_guid - next_entries = self.db_connection.find_matching_structures({'previous_version': version_guid}) + next_entries = self.find_structures_derived_from([version_guid]) # must only scan cursor's once next_versions = [struct for struct in next_entries] result = {version_guid: [CourseLocator(version_guid=struct['_id']) for struct in next_versions]} depth = 1 while depth < version_history_depth and len(next_versions) > 0: depth += 1 - next_entries = self.db_connection.find_matching_structures({'previous_version': - {'$in': [struct['_id'] for struct in next_versions]}}) + next_entries = self.find_structures_derived_from([struct['_id'] for struct in next_versions]) next_versions = [struct for struct in next_entries] for course_structure in next_versions: result.setdefault(course_structure['previous_version'], []).append( CourseLocator(version_guid=struct['_id'])) return VersionTree(course_locator, result) - def get_block_generations(self, block_locator): ''' Find the history of this block. Return as a VersionTree of each place the block changed (except @@ -639,14 +1035,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ''' # course_agnostic means we don't care if the head and version don't align, trust the version - course_struct = self._lookup_course(block_locator.course_agnostic())['structure'] + course_struct = self._lookup_course(block_locator.course_key.course_agnostic())['structure'] block_id = block_locator.block_id - update_version_field = 'blocks.{}.edit_info.update_version'.format(block_id) - all_versions_with_block = self.db_connection.find_matching_structures( - { - 'original_version': course_struct['original_version'], - update_version_field: {'$exists': True}, - } + all_versions_with_block = self.find_ancestor_structures( + original_version=course_struct['original_version'], + block_id=block_id ) # find (all) root versions and build map {previous: {successors}..} possible_roots = [] @@ -772,7 +1165,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): def create_item( self, user_id, course_key, block_type, block_id=None, definition_locator=None, fields=None, - force=False, continue_version=False, **kwargs + force=False, **kwargs ): """ Add a descriptor to persistence as an element @@ -799,10 +1192,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): :param block_id: if provided, must not already exist in the structure. Provides the block id for the new item in this structure. Otherwise, one is computed using the category appended w/ a few digits. - :param continue_version: continue changing the current structure at the head of the course. Very dangerous - unless used in the same request as started the change! See below about version conflicts. - - This method creates a new version of the course structure unless continue_version is True. + This method creates a new version of the course structure unless the course has a bulk_write operation + active. It creates and inserts the new block, makes the block point to the definition which may be new or a new version of an existing or an existing. @@ -818,91 +1209,77 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): the course id'd by version_guid but instead in one w/ a new version_guid. Ensure in this case that you get the new version_guid from the locator in the returned object! """ - # split handles all the fields in one dict not separated by scope - fields = fields or {} - fields.update(kwargs.pop('metadata', {}) or {}) - definition_data = kwargs.pop('definition_data', {}) - if definition_data: - if not isinstance(definition_data, dict): - definition_data = {'data': definition_data} # backward compatibility to mongo's hack - fields.update(definition_data) + with self.bulk_operations(course_key): + # split handles all the fields in one dict not separated by scope + fields = fields or {} + fields.update(kwargs.pop('metadata', {}) or {}) + definition_data = kwargs.pop('definition_data', {}) + if definition_data: + if not isinstance(definition_data, dict): + definition_data = {'data': definition_data} # backward compatibility to mongo's hack + fields.update(definition_data) - # find course_index entry if applicable and structures entry - index_entry = self._get_index_if_valid(course_key, force, continue_version) - structure = self._lookup_course(course_key)['structure'] + # find course_index entry if applicable and structures entry + index_entry = self._get_index_if_valid(course_key, force) + structure = self._lookup_course(course_key)['structure'] - partitioned_fields = self.partition_fields_by_scope(block_type, fields) - new_def_data = partitioned_fields.get(Scope.content, {}) - # persist the definition if persisted != passed - if (definition_locator is None or isinstance(definition_locator.definition_id, LocalId)): - definition_locator = self.create_definition_from_data(new_def_data, block_type, user_id) - elif new_def_data is not None: - definition_locator, _ = self.update_definition_from_data(definition_locator, new_def_data, user_id) + partitioned_fields = self.partition_fields_by_scope(block_type, fields) + new_def_data = partitioned_fields.get(Scope.content, {}) + # persist the definition if persisted != passed + if (definition_locator is None or isinstance(definition_locator.definition_id, LocalId)): + definition_locator = self.create_definition_from_data(new_def_data, block_type, user_id) + elif new_def_data is not None: + definition_locator, _ = self.update_definition_from_data(definition_locator, new_def_data, user_id) - # copy the structure and modify the new one - if continue_version: - new_structure = structure - else: - new_structure = self._version_structure(structure, user_id) + # copy the structure and modify the new one + new_structure = self.version_structure(course_key, structure, user_id) - new_id = new_structure['_id'] + new_id = new_structure['_id'] - edit_info = { - 'edited_on': datetime.datetime.now(UTC), - 'edited_by': user_id, - 'previous_version': None, - 'update_version': new_id, - } - # generate usage id - if block_id is not None: - if encode_key_for_mongo(block_id) in new_structure['blocks']: - raise DuplicateItemError(block_id, self, 'structures') + # generate usage id + if block_id is not None: + if encode_key_for_mongo(block_id) in new_structure['blocks']: + raise DuplicateItemError(block_id, self, 'structures') + else: + new_block_id = block_id else: - new_block_id = block_id - else: - new_block_id = self._generate_block_id(new_structure['blocks'], block_type) + new_block_id = self._generate_block_id(new_structure['blocks'], block_type) - block_fields = partitioned_fields.get(Scope.settings, {}) - if Scope.children in partitioned_fields: - block_fields.update(partitioned_fields[Scope.children]) - self._update_block_in_structure(new_structure, new_block_id, { - "category": block_type, - "definition": definition_locator.definition_id, - "fields": self._serialize_fields(block_type, block_fields), - 'edit_info': edit_info, - }) + block_fields = partitioned_fields.get(Scope.settings, {}) + if Scope.children in partitioned_fields: + block_fields.update(partitioned_fields[Scope.children]) + self._update_block_in_structure(new_structure, new_block_id, self._new_block( + user_id, + block_type, + block_fields, + definition_locator.definition_id, + new_id, + )) - if continue_version: - # db update - self.db_connection.update_structure(new_structure) - # clear cache so things get refetched and inheritance recomputed - self._clear_cache(new_id) - else: - self.db_connection.insert_structure(new_structure) + self.update_structure(course_key, new_structure) - # update the index entry if appropriate - if index_entry is not None: - # see if any search targets changed - if fields is not None: - self._update_search_targets(index_entry, fields) - if not continue_version: - self._update_head(index_entry, course_key.branch, new_id) - item_loc = BlockUsageLocator( - course_key.version_agnostic(), - block_type=block_type, - block_id=new_block_id, - ) - else: - item_loc = BlockUsageLocator( - CourseLocator(version_guid=new_id), - block_type=block_type, - block_id=new_block_id, - ) + # update the index entry if appropriate + if index_entry is not None: + # see if any search targets changed + if fields is not None: + self._update_search_targets(index_entry, fields) + self._update_head(course_key, index_entry, course_key.branch, new_id) + item_loc = BlockUsageLocator( + course_key.version_agnostic(), + block_type=block_type, + block_id=new_block_id, + ) + else: + item_loc = BlockUsageLocator( + CourseLocator(version_guid=new_id), + block_type=block_type, + block_id=new_block_id, + ) - # reconstruct the new_item from the cache - return self.get_item(item_loc) + # reconstruct the new_item from the cache + return self.get_item(item_loc) - def create_child(self, user_id, parent_usage_key, block_type, block_id=None, fields=None, continue_version=False, **kwargs): + def create_child(self, user_id, parent_usage_key, block_type, block_id=None, fields=None, **kwargs): """ Creates and saves a new xblock that as a child of the specified block @@ -918,32 +1295,28 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): fields (dict): A dictionary specifying initial values for some or all fields in the newly created block """ - xblock = self.create_item( - user_id, parent_usage_key.course_key, block_type, block_id=block_id, fields=fields, - continue_version=continue_version, - **kwargs) + with self.bulk_operations(parent_usage_key.course_key): + xblock = self.create_item( + user_id, parent_usage_key.course_key, block_type, block_id=block_id, fields=fields, + **kwargs) - # don't version the structure as create_item handled that already. - new_structure = self._lookup_course(xblock.location.course_key)['structure'] + # don't version the structure as create_item handled that already. + new_structure = self._lookup_course(xblock.location.course_key)['structure'] - # add new block as child and update parent's version - encoded_block_id = encode_key_for_mongo(parent_usage_key.block_id) - parent = new_structure['blocks'][encoded_block_id] - parent['fields'].setdefault('children', []).append(xblock.location.block_id) - if parent['edit_info']['update_version'] != new_structure['_id']: - # if the parent hadn't been previously changed in this bulk transaction, indicate that it's - # part of the bulk transaction - parent['edit_info'] = { - 'edited_on': datetime.datetime.now(UTC), - 'edited_by': user_id, - 'previous_version': parent['edit_info']['update_version'], - 'update_version': new_structure['_id'], - } + # add new block as child and update parent's version + encoded_block_id = encode_key_for_mongo(parent_usage_key.block_id) + if encoded_block_id not in new_structure['blocks']: + raise ItemNotFoundError(parent_usage_key) - # db update - self.db_connection.update_structure(new_structure) - # clear cache so things get refetched and inheritance recomputed - self._clear_cache(new_structure['_id']) + parent = new_structure['blocks'][encoded_block_id] + parent['fields'].setdefault('children', []).append(xblock.location.block_id) + if parent['edit_info']['update_version'] != new_structure['_id']: + # if the parent hadn't been previously changed in this bulk transaction, indicate that it's + # part of the bulk transaction + self.version_block(parent, user_id, new_structure['_id']) + + # db update + self.update_structure(parent_usage_key.course_key, new_structure) # don't need to update the index b/c create_item did it for this version return xblock @@ -1023,7 +1396,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): assert master_branch is not None # check course and run's uniqueness locator = CourseLocator(org=org, course=course, run=run, branch=master_branch) - index = self.db_connection.get_course_index(locator) + index = self.get_course_index(locator) if index is not None: raise DuplicateCourseError(locator, index) @@ -1061,18 +1434,16 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ) new_id = draft_structure['_id'] - self.db_connection.insert_structure(draft_structure) - if versions_dict is None: versions_dict = {master_branch: new_id} else: versions_dict[master_branch] = new_id - elif definition_fields or block_fields: # pointing to existing course w/ some overrides + elif block_fields or definition_fields: # pointing to existing course w/ some overrides # just get the draft_version structure draft_version = CourseLocator(version_guid=versions_dict[master_branch]) draft_structure = self._lookup_course(draft_version)['structure'] - draft_structure = self._version_structure(draft_structure, user_id) + draft_structure = self.version_structure(locator, draft_structure, user_id) new_id = draft_structure['_id'] encoded_block_id = encode_key_for_mongo(draft_structure['root']) root_block = draft_structure['blocks'][encoded_block_id] @@ -1093,27 +1464,33 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): root_block['edit_info']['previous_version'] = root_block['edit_info'].get('update_version') root_block['edit_info']['update_version'] = new_id - self.db_connection.insert_structure(draft_structure) versions_dict[master_branch] = new_id + else: # Pointing to an existing course structure + new_id = versions_dict[master_branch] + draft_version = CourseLocator(version_guid=new_id) + draft_structure = self._lookup_course(draft_version)['structure'] - index_entry = { - '_id': ObjectId(), - 'org': org, - 'course': course, - 'run': run, - 'edited_by': user_id, - 'edited_on': datetime.datetime.now(UTC), - 'versions': versions_dict, - 'schema_version': self.SCHEMA_VERSION, - 'search_targets': search_targets or {}, - } - if fields is not None: - self._update_search_targets(index_entry, fields) - self.db_connection.insert_course_index(index_entry) + locator = locator.replace(version_guid=new_id) + with self.bulk_operations(locator): + self.update_structure(locator, draft_structure) + index_entry = { + '_id': ObjectId(), + 'org': org, + 'course': course, + 'run': run, + 'edited_by': user_id, + 'edited_on': datetime.datetime.now(UTC), + 'versions': versions_dict, + 'schema_version': self.SCHEMA_VERSION, + 'search_targets': search_targets or {}, + } + if fields is not None: + self._update_search_targets(index_entry, fields) + self.insert_course_index(locator, index_entry) - # expensive hack to persist default field values set in __init__ method (e.g., wiki_slug) - course = self.get_course(locator, **kwargs) - return self.update_item(course, user_id, **kwargs) + # expensive hack to persist default field values set in __init__ method (e.g., wiki_slug) + course = self.get_course(locator, **kwargs) + return self.update_item(course, user_id, **kwargs) def update_item(self, descriptor, user_id, allow_not_found=False, force=False, **kwargs): """ @@ -1143,87 +1520,83 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): """ Broke out guts of update_item for short-circuited internal use only """ - if allow_not_found and isinstance(block_id, (LocalId, NoneType)): - fields = {} - for subfields in partitioned_fields.itervalues(): - fields.update(subfields) - return self.create_item( - user_id, course_key, block_type, fields=fields, force=force - ) - - original_structure = self._lookup_course(course_key)['structure'] - index_entry = self._get_index_if_valid(course_key, force) - - original_entry = self._get_block_from_structure(original_structure, block_id) - if original_entry is None: - if allow_not_found: + with self.bulk_operations(course_key): + if allow_not_found and isinstance(block_id, (LocalId, NoneType)): fields = {} for subfields in partitioned_fields.itervalues(): fields.update(subfields) return self.create_item( - user_id, course_key, block_type, block_id=block_id, fields=fields, force=force, + user_id, course_key, block_type, fields=fields, force=force ) - else: - raise ItemNotFoundError(course_key.make_usage_key(block_type, block_id)) - is_updated = False - definition_fields = partitioned_fields[Scope.content] - if definition_locator is None: - definition_locator = DefinitionLocator(original_entry['category'], original_entry['definition']) - if definition_fields: - definition_locator, is_updated = self.update_definition_from_data( - definition_locator, definition_fields, user_id - ) + original_structure = self._lookup_course(course_key)['structure'] + index_entry = self._get_index_if_valid(course_key, force) - # check metadata - settings = partitioned_fields[Scope.settings] - settings = self._serialize_fields(block_type, settings) - if not is_updated: - is_updated = self._compare_settings(settings, original_entry['fields']) + original_entry = self._get_block_from_structure(original_structure, block_id) + if original_entry is None: + if allow_not_found: + fields = {} + for subfields in partitioned_fields.itervalues(): + fields.update(subfields) + return self.create_item( + user_id, course_key, block_type, block_id=block_id, fields=fields, force=force, + ) + else: + raise ItemNotFoundError(course_key.make_usage_key(block_type, block_id)) - # check children - if partitioned_fields.get(Scope.children, {}): # purposely not 'is not None' - serialized_children = [child.block_id for child in partitioned_fields[Scope.children]['children']] - is_updated = is_updated or original_entry['fields'].get('children', []) != serialized_children + is_updated = False + definition_fields = partitioned_fields[Scope.content] + if definition_locator is None: + definition_locator = DefinitionLocator(original_entry['category'], original_entry['definition']) + if definition_fields: + definition_locator, is_updated = self.update_definition_from_data( + definition_locator, definition_fields, user_id + ) + + # check metadata + settings = partitioned_fields[Scope.settings] + settings = self._serialize_fields(block_type, settings) + if not is_updated: + is_updated = self._compare_settings(settings, original_entry['fields']) + + # check children + if partitioned_fields.get(Scope.children, {}): # purposely not 'is not None' + serialized_children = [child.block_id for child in partitioned_fields[Scope.children]['children']] + is_updated = is_updated or original_entry['fields'].get('children', []) != serialized_children + if is_updated: + settings['children'] = serialized_children + + # if updated, rev the structure if is_updated: - settings['children'] = serialized_children + new_structure = self.version_structure(course_key, original_structure, user_id) + block_data = self._get_block_from_structure(new_structure, block_id) - # if updated, rev the structure - if is_updated: - new_structure = self._version_structure(original_structure, user_id) - block_data = self._get_block_from_structure(new_structure, block_id) + block_data["definition"] = definition_locator.definition_id + block_data["fields"] = settings - block_data["definition"] = definition_locator.definition_id - block_data["fields"] = settings + new_id = new_structure['_id'] + self.version_block(block_data, user_id, new_id) + self.update_structure(course_key, new_structure) + # update the index entry if appropriate + if index_entry is not None: + self._update_search_targets(index_entry, definition_fields) + self._update_search_targets(index_entry, settings) + course_key = CourseLocator( + org=index_entry['org'], + course=index_entry['course'], + run=index_entry['run'], + branch=course_key.branch, + version_guid=new_id + ) + self._update_head(course_key, index_entry, course_key.branch, new_id) + else: + course_key = CourseLocator(version_guid=new_id) - new_id = new_structure['_id'] - block_data['edit_info'] = { - 'edited_on': datetime.datetime.now(UTC), - 'edited_by': user_id, - 'previous_version': block_data['edit_info']['update_version'], - 'update_version': new_id, - } - self.db_connection.insert_structure(new_structure) - # update the index entry if appropriate - if index_entry is not None: - self._update_search_targets(index_entry, definition_fields) - self._update_search_targets(index_entry, settings) - self._update_head(index_entry, course_key.branch, new_id) - course_key = CourseLocator( - org=index_entry['org'], - course=index_entry['course'], - run=index_entry['run'], - branch=course_key.branch, - version_guid=new_id - ) + # fetch and return the new item--fetching is unnecessary but a good qc step + new_locator = course_key.make_usage_key(block_type, block_id) + return self.get_item(new_locator, **kwargs) else: - course_key = CourseLocator(version_guid=new_id) - - # fetch and return the new item--fetching is unnecessary but a good qc step - new_locator = course_key.make_usage_key(block_type, block_id) - return self.get_item(new_locator, **kwargs) - else: - return None + return None # pylint: disable=unused-argument def create_xblock( @@ -1286,23 +1659,25 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): :param user_id: who's doing the change """ # find course_index entry if applicable and structures entry - index_entry = self._get_index_if_valid(xblock.location, force) - structure = self._lookup_course(xblock.location)['structure'] - new_structure = self._version_structure(structure, user_id) - new_id = new_structure['_id'] - is_updated = self._persist_subdag(xblock, user_id, new_structure['blocks'], new_id) + course_key = xblock.location.course_key + with self.bulk_operations(course_key): + index_entry = self._get_index_if_valid(course_key, force) + structure = self._lookup_course(course_key)['structure'] + new_structure = self.version_structure(course_key, structure, user_id) + new_id = new_structure['_id'] + is_updated = self._persist_subdag(xblock, user_id, new_structure['blocks'], new_id) - if is_updated: - self.db_connection.insert_structure(new_structure) + if is_updated: + self.update_structure(course_key, new_structure) - # update the index entry if appropriate - if index_entry is not None: - self._update_head(index_entry, xblock.location.branch, new_id) + # update the index entry if appropriate + if index_entry is not None: + self._update_head(course_key, index_entry, xblock.location.branch, new_id) - # fetch and return the new item--fetching is unnecessary but a good qc step - return self.get_item(xblock.location.for_version(new_id)) - else: - return xblock + # fetch and return the new item--fetching is unnecessary but a good qc step + return self.get_item(xblock.location.for_version(new_id)) + else: + return xblock def _persist_subdag(self, xblock, user_id, structure_blocks, new_id): # persist the definition if persisted != passed @@ -1350,18 +1725,22 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): block_fields['children'] = children if is_updated: - previous_version = None if is_new else structure_blocks[encoded_block_id]['edit_info'].get('update_version') - structure_blocks[encoded_block_id] = { - "category": xblock.category, - "definition": xblock.definition_locator.definition_id, - "fields": block_fields, - 'edit_info': { - 'previous_version': previous_version, - 'update_version': new_id, - 'edited_by': user_id, - 'edited_on': datetime.datetime.now(UTC) - } - } + if is_new: + block_info = self._new_block( + user_id, + xblock.category, + block_fields, + xblock.definition_locator.definition_id, + new_id, + raw=True + ) + else: + block_info = structure_blocks[encoded_block_id] + block_info['fields'] = block_fields + block_info['definition'] = xblock.definition_locator.definition_id + self.version_block(block_info, user_id, new_id) + + structure_blocks[encoded_block_id] = block_info return is_updated @@ -1415,71 +1794,63 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): subtree but the ancestors up to and including the course root are not published. """ # get the destination's index, and source and destination structures. - source_structure = self._lookup_course(source_course)['structure'] - index_entry = self.db_connection.get_course_index(destination_course) - if index_entry is None: - # brand new course - raise ItemNotFoundError(destination_course) - if destination_course.branch not in index_entry['versions']: - # must be copying the dag root if there's no current dag - root_block_id = source_structure['root'] - if not any(root_block_id == subtree.block_id for subtree in subtree_list): - raise ItemNotFoundError(u'Must publish course root {}'.format(root_block_id)) - root_source = source_structure['blocks'][root_block_id] - # create branch - destination_structure = self._new_structure( - user_id, root_block_id, root_category=root_source['category'], - # leave off the fields b/c the children must be filtered - definition_id=root_source['definition'], - ) - else: - destination_structure = self._lookup_course(destination_course)['structure'] - destination_structure = self._version_structure(destination_structure, user_id) + with self.bulk_operations(source_course): + with self.bulk_operations(destination_course): + source_structure = self._lookup_course(source_course)['structure'] + index_entry = self.get_course_index(destination_course) + if index_entry is None: + # brand new course + raise ItemNotFoundError(destination_course) + if destination_course.branch not in index_entry['versions']: + # must be copying the dag root if there's no current dag + root_block_id = source_structure['root'] + if not any(root_block_id == subtree.block_id for subtree in subtree_list): + raise ItemNotFoundError(u'Must publish course root {}'.format(root_block_id)) + root_source = source_structure['blocks'][root_block_id] + # create branch + destination_structure = self._new_structure( + user_id, root_block_id, root_category=root_source['category'], + # leave off the fields b/c the children must be filtered + definition_id=root_source['definition'], + ) + else: + destination_structure = self._lookup_course(destination_course)['structure'] + destination_structure = self.version_structure(destination_course, destination_structure, user_id) - if blacklist != EXCLUDE_ALL: - blacklist = [shunned.block_id for shunned in blacklist or []] - # iterate over subtree list filtering out blacklist. - orphans = set() - destination_blocks = destination_structure['blocks'] - for subtree_root in subtree_list: - if subtree_root.block_id != source_structure['root']: - # find the parents and put root in the right sequence - parent = self._get_parent_from_structure(subtree_root.block_id, source_structure) - if parent is not None: # may be a detached category xblock - if not parent in destination_blocks: - raise ItemNotFoundError(parent) + if blacklist != EXCLUDE_ALL: + blacklist = [shunned.block_id for shunned in blacklist or []] + # iterate over subtree list filtering out blacklist. + orphans = set() + destination_blocks = destination_structure['blocks'] + for subtree_root in subtree_list: + if subtree_root.block_id != source_structure['root']: + # find the parents and put root in the right sequence + parent = self._get_parent_from_structure(subtree_root.block_id, source_structure) + if parent is not None: # may be a detached category xblock + if not parent in destination_blocks: + raise ItemNotFoundError(parent) + orphans.update( + self._sync_children( + source_structure['blocks'][parent], + destination_blocks[parent], + subtree_root.block_id + ) + ) + # update/create the subtree and its children in destination (skipping blacklist) orphans.update( - self._sync_children( - source_structure['blocks'][parent], - destination_blocks[parent], - subtree_root.block_id + self._copy_subdag( + user_id, destination_structure['_id'], + subtree_root.block_id, source_structure['blocks'], destination_blocks, blacklist ) ) - # update/create the subtree and its children in destination (skipping blacklist) - orphans.update( - self._copy_subdag( - user_id, destination_structure['_id'], - subtree_root.block_id, source_structure['blocks'], destination_blocks, blacklist - ) - ) - # remove any remaining orphans - for orphan in orphans: - # orphans will include moved as well as deleted xblocks. Only delete the deleted ones. - self._delete_if_true_orphan(orphan, destination_structure) + # remove any remaining orphans + for orphan in orphans: + # orphans will include moved as well as deleted xblocks. Only delete the deleted ones. + self._delete_if_true_orphan(orphan, destination_structure) - # update the db - self.db_connection.insert_structure(destination_structure) - self._update_head(index_entry, destination_course.branch, destination_structure['_id']) - - def update_course_index(self, updated_index_entry): - """ - Change the given course's index entry. - - Note, this operation can be dangerous and break running courses. - - Does not return anything useful. - """ - self.db_connection.update_course_index(updated_index_entry) + # update the db + self.update_structure(destination_course, destination_structure) + self._update_head(destination_course, index_entry, destination_course.branch, destination_structure['_id']) def delete_item(self, usage_locator, user_id, force=False): """ @@ -1500,46 +1871,47 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): # The supplied UsageKey is of the wrong type, so it can't possibly be stored in this modulestore. raise ItemNotFoundError(usage_locator) - original_structure = self._lookup_course(usage_locator.course_key)['structure'] - if original_structure['root'] == usage_locator.block_id: - raise ValueError("Cannot delete the root of a course") - if encode_key_for_mongo(usage_locator.block_id) not in original_structure['blocks']: - raise ValueError("Cannot delete a block that does not exist") - index_entry = self._get_index_if_valid(usage_locator, force) - new_structure = self._version_structure(original_structure, user_id) - new_blocks = new_structure['blocks'] - new_id = new_structure['_id'] - encoded_block_id = self._get_parent_from_structure(usage_locator.block_id, original_structure) - if encoded_block_id: - parent_block = new_blocks[encoded_block_id] - parent_block['fields']['children'].remove(usage_locator.block_id) - parent_block['edit_info']['edited_on'] = datetime.datetime.now(UTC) - parent_block['edit_info']['edited_by'] = user_id - parent_block['edit_info']['previous_version'] = parent_block['edit_info']['update_version'] - parent_block['edit_info']['update_version'] = new_id + with self.bulk_operations(usage_locator.course_key): + original_structure = self._lookup_course(usage_locator.course_key)['structure'] + if original_structure['root'] == usage_locator.block_id: + raise ValueError("Cannot delete the root of a course") + if encode_key_for_mongo(usage_locator.block_id) not in original_structure['blocks']: + raise ValueError("Cannot delete a block that does not exist") + index_entry = self._get_index_if_valid(usage_locator.course_key, force) + new_structure = self.version_structure(usage_locator.course_key, original_structure, user_id) + new_blocks = new_structure['blocks'] + new_id = new_structure['_id'] + encoded_block_id = self._get_parent_from_structure(usage_locator.block_id, original_structure) + if encoded_block_id: + parent_block = new_blocks[encoded_block_id] + parent_block['fields']['children'].remove(usage_locator.block_id) + parent_block['edit_info']['edited_on'] = datetime.datetime.now(UTC) + parent_block['edit_info']['edited_by'] = user_id + parent_block['edit_info']['previous_version'] = parent_block['edit_info']['update_version'] + parent_block['edit_info']['update_version'] = new_id - def remove_subtree(block_id): - """ - Remove the subtree rooted at block_id - """ - encoded_block_id = encode_key_for_mongo(block_id) - for child in new_blocks[encoded_block_id]['fields'].get('children', []): - remove_subtree(child) - del new_blocks[encoded_block_id] + def remove_subtree(block_id): + """ + Remove the subtree rooted at block_id + """ + encoded_block_id = encode_key_for_mongo(block_id) + for child in new_blocks[encoded_block_id]['fields'].get('children', []): + remove_subtree(child) + del new_blocks[encoded_block_id] - remove_subtree(usage_locator.block_id) + remove_subtree(usage_locator.block_id) - # update index if appropriate and structures - self.db_connection.insert_structure(new_structure) + # update index if appropriate and structures + self.update_structure(usage_locator.course_key, new_structure) - if index_entry is not None: - # update the index entry if appropriate - self._update_head(index_entry, usage_locator.branch, new_id) - result = usage_locator.course_key.for_version(new_id) - else: - result = CourseLocator(version_guid=new_id) + if index_entry is not None: + # update the index entry if appropriate + self._update_head(usage_locator.course_key, index_entry, usage_locator.branch, new_id) + result = usage_locator.course_key.for_version(new_id) + else: + result = CourseLocator(version_guid=new_id) - return result + return result def delete_course(self, course_key, user_id): """ @@ -1549,7 +1921,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): with a versions hash to restore the course; however, the edited_on and edited_by won't reflect the originals, of course. """ - index = self.db_connection.get_course_index(course_key) + index = self.get_course_index(course_key) if index is None: raise ItemNotFoundError(course_key) # this is the only real delete in the system. should it do something else? @@ -1610,23 +1982,10 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): if depth is None or depth > 0: depth = depth - 1 if depth is not None else None for child in descendent_map[block_id]['fields'].get('children', []): - descendent_map = self.descendants(block_map, child, depth, - descendent_map) + descendent_map = self.descendants(block_map, child, depth, descendent_map) return descendent_map - def definition_locator(self, definition): - ''' - Pull the id out of the definition w/ correct semantics for its - representation - ''' - if isinstance(definition, DefinitionLazyLoader): - return definition.definition_locator - elif '_id' not in definition: - return DefinitionLocator(definition.get('category'), LocalId()) - else: - return DefinitionLocator(definition['category'], definition['_id']) - def get_modulestore_type(self, course_key=None): """ Returns an enumeration-like type reflecting the type of this modulestore, per ModuleStoreEnum.Type. @@ -1651,9 +2010,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): block_id for block_id in block['fields']["children"] if encode_key_for_mongo(block_id) in original_structure['blocks'] ] - self.db_connection.update_structure(original_structure) - # clear cache again b/c inheritance may be wrong over orphans - self._clear_cache(original_structure['_id']) + self.update_structure(course_locator, original_structure) def convert_references_to_keys(self, course_key, xblock_class, jsonfields, blocks): """ @@ -1681,69 +2038,50 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): return course_key.make_usage_key('unknown', block_id) xblock_class = self.mixologist.mix(xblock_class) - for field_name, value in jsonfields.iteritems(): + # Make a shallow copy, so that we aren't manipulating a cached field dictionary + output_fields = dict(jsonfields) + for field_name, value in output_fields.iteritems(): if value: field = xblock_class.fields.get(field_name) if field is None: continue elif isinstance(field, Reference): - jsonfields[field_name] = robust_usage_key(value) + output_fields[field_name] = robust_usage_key(value) elif isinstance(field, ReferenceList): - jsonfields[field_name] = [robust_usage_key(ele) for ele in value] + output_fields[field_name] = [robust_usage_key(ele) for ele in value] elif isinstance(field, ReferenceValueDict): for key, subvalue in value.iteritems(): assert isinstance(subvalue, basestring) value[key] = robust_usage_key(subvalue) - return jsonfields + return output_fields - def _get_index_if_valid(self, locator, force=False, continue_version=False): + def _get_index_if_valid(self, course_key, force=False): """ - If the locator identifies a course and points to its draft (or plausibly its draft), + If the course_key identifies a course and points to its draft (or plausibly its draft), then return the index entry. raises VersionConflictError if not the right version - :param locator: a courselocator + :param course_key: a CourseLocator :param force: if false, raises VersionConflictError if the current head of the course != the one identified - by locator. Cannot be True if continue_version is True - :param continue_version: if True, assumes this operation requires a head version and will not create a new - version but instead continue an existing transaction on this version. This flag cannot be True if force is True. + by course_key """ - if locator.org is None or locator.course is None or locator.run is None or locator.branch is None: - if continue_version: - raise InsufficientSpecificationError( - "To continue a version, the locator must point to one ({}).".format(locator) - ) - else: - return None + if course_key.org is None or course_key.course is None or course_key.run is None or course_key.branch is None: + return None else: - index_entry = self.db_connection.get_course_index(locator) + index_entry = self.get_course_index(course_key) is_head = ( - locator.version_guid is None or - index_entry['versions'][locator.branch] == locator.version_guid + course_key.version_guid is None or + index_entry['versions'][course_key.branch] == course_key.version_guid ) - if (is_head or (force and not continue_version)): + if (is_head or force): return index_entry else: raise VersionConflictError( - locator, - index_entry['versions'][locator.branch] + course_key, + index_entry['versions'][course_key.branch] ) - def _version_structure(self, structure, user_id): - """ - Copy the structure and update the history info (edited_by, edited_on, previous_version) - :param structure: - :param user_id: - """ - new_structure = copy.deepcopy(structure) - new_structure['_id'] = ObjectId() - new_structure['previous_version'] = structure['_id'] - new_structure['edited_by'] = user_id - new_structure['edited_on'] = datetime.datetime.now(UTC) - new_structure['schema_version'] = self.SCHEMA_VERSION - return new_structure - def _find_local_root(self, element_to_find, possibility, tree): if possibility not in tree: return False @@ -1766,7 +2104,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): if field_name in self.SEARCH_TARGET_DICT: index_entry.setdefault('search_targets', {})[field_name] = field_value - def _update_head(self, index_entry, branch, new_id): + def _update_head(self, course_key, index_entry, branch, new_id): """ Update the active index for the given course's branch to point to new_id @@ -1774,8 +2112,10 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): :param course_locator: :param new_id: """ + if not isinstance(new_id, ObjectId): + raise TypeError('new_id must be an ObjectId, but is {!r}'.format(new_id)) index_entry['versions'][branch] = new_id - self.db_connection.update_course_index(index_entry) + self.insert_course_index(course_key, index_entry) def partition_xblock_fields_by_scope(self, xblock): """ @@ -2010,8 +2350,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): Returns: list of branch-agnostic course_keys """ - entries = self.db_connection.find_matching_course_indexes( - {'search_targets.{}'.format(field_name): field_value} + entries = self.find_matching_course_indexes( + search_targets={field_name: field_value} ) return [ CourseLocator(entry['org'], entry['course'], entry['run']) # Branch agnostic diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py index 3c29b23b78..d534e91ac3 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py @@ -9,6 +9,7 @@ from xmodule.modulestore.exceptions import InsufficientSpecificationError from xmodule.modulestore.draft_and_published import ( ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError ) +from opaque_keys.edx.locator import CourseLocator class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleStore): @@ -30,24 +31,25 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS Returns: a CourseDescriptor """ master_branch = kwargs.pop('master_branch', ModuleStoreEnum.BranchName.draft) - item = super(DraftVersioningModuleStore, self).create_course( - org, course, run, user_id, master_branch=master_branch, **kwargs - ) - if master_branch == ModuleStoreEnum.BranchName.draft and not skip_auto_publish: - # any other value is hopefully only cloning or doing something which doesn't want this value add - self._auto_publish_no_children(item.location, item.location.category, user_id, **kwargs) + with self.bulk_operations(CourseLocator(org, course, run)): + item = super(DraftVersioningModuleStore, self).create_course( + org, course, run, user_id, master_branch=master_branch, **kwargs + ) + if master_branch == ModuleStoreEnum.BranchName.draft and not skip_auto_publish: + # any other value is hopefully only cloning or doing something which doesn't want this value add + self._auto_publish_no_children(item.location, item.location.category, user_id, **kwargs) - # create any other necessary things as a side effect: ensure they populate the draft branch - # and rely on auto publish to populate the published branch: split's create course doesn't - # call super b/c it needs the auto publish above to have happened before any of the create_items - # in this. The explicit use of SplitMongoModuleStore is intentional - with self.branch_setting(ModuleStoreEnum.Branch.draft_preferred, item.id): - # pylint: disable=bad-super-call - super(SplitMongoModuleStore, self).create_course( - org, course, run, user_id, runtime=item.runtime, **kwargs - ) + # create any other necessary things as a side effect: ensure they populate the draft branch + # and rely on auto publish to populate the published branch: split's create course doesn't + # call super b/c it needs the auto publish above to have happened before any of the create_items + # in this. The explicit use of SplitMongoModuleStore is intentional + with self.branch_setting(ModuleStoreEnum.Branch.draft_preferred, item.id): + # pylint: disable=bad-super-call + super(SplitMongoModuleStore, self).create_course( + org, course, run, user_id, runtime=item.runtime, **kwargs + ) - return item + return item def get_course(self, course_id, depth=0, **kwargs): course_id = self._map_revision_to_branch(course_id) @@ -87,45 +89,48 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS def update_item(self, descriptor, user_id, allow_not_found=False, force=False, **kwargs): descriptor.location = self._map_revision_to_branch(descriptor.location) - item = super(DraftVersioningModuleStore, self).update_item( - descriptor, - user_id, - allow_not_found=allow_not_found, - force=force, - **kwargs - ) - self._auto_publish_no_children(item.location, item.location.category, user_id, **kwargs) - return item + with self.bulk_operations(descriptor.location.course_key): + item = super(DraftVersioningModuleStore, self).update_item( + descriptor, + user_id, + allow_not_found=allow_not_found, + force=force, + **kwargs + ) + self._auto_publish_no_children(item.location, item.location.category, user_id, **kwargs) + return item def create_item( self, user_id, course_key, block_type, block_id=None, definition_locator=None, fields=None, - force=False, continue_version=False, skip_auto_publish=False, **kwargs + force=False, skip_auto_publish=False, **kwargs ): """ See :py:meth `ModuleStoreDraftAndPublished.create_item` """ course_key = self._map_revision_to_branch(course_key) - item = super(DraftVersioningModuleStore, self).create_item( - user_id, course_key, block_type, block_id=block_id, - definition_locator=definition_locator, fields=fields, - force=force, continue_version=continue_version, **kwargs - ) - if not skip_auto_publish: - self._auto_publish_no_children(item.location, item.location.category, user_id, **kwargs) - return item + with self.bulk_operations(course_key): + item = super(DraftVersioningModuleStore, self).create_item( + user_id, course_key, block_type, block_id=block_id, + definition_locator=definition_locator, fields=fields, + force=force, **kwargs + ) + if not skip_auto_publish: + self._auto_publish_no_children(item.location, item.location.category, user_id, **kwargs) + return item def create_child( self, user_id, parent_usage_key, block_type, block_id=None, - fields=None, continue_version=False, **kwargs + fields=None, **kwargs ): parent_usage_key = self._map_revision_to_branch(parent_usage_key) - item = super(DraftVersioningModuleStore, self).create_child( - user_id, parent_usage_key, block_type, block_id=block_id, - fields=fields, continue_version=continue_version, **kwargs - ) - self._auto_publish_no_children(parent_usage_key, item.location.category, user_id, **kwargs) - return item + with self.bulk_operations(parent_usage_key.course_key): + item = super(DraftVersioningModuleStore, self).create_child( + user_id, parent_usage_key, block_type, block_id=block_id, + fields=fields, **kwargs + ) + self._auto_publish_no_children(parent_usage_key, item.location.category, user_id, **kwargs) + return item def delete_item(self, location, user_id, revision=None, **kwargs): """ @@ -141,26 +146,27 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS currently only provided by contentstore.views.item.orphan_handler Otherwise, raises a ValueError. """ - if revision == ModuleStoreEnum.RevisionOption.published_only: - branches_to_delete = [ModuleStoreEnum.BranchName.published] - elif revision == ModuleStoreEnum.RevisionOption.all: - branches_to_delete = [ModuleStoreEnum.BranchName.published, ModuleStoreEnum.BranchName.draft] - elif revision is None: - branches_to_delete = [ModuleStoreEnum.BranchName.draft] - else: - raise UnsupportedRevisionError( - [ - None, - ModuleStoreEnum.RevisionOption.published_only, - ModuleStoreEnum.RevisionOption.all - ] - ) + with self.bulk_operations(location.course_key): + if revision == ModuleStoreEnum.RevisionOption.published_only: + branches_to_delete = [ModuleStoreEnum.BranchName.published] + elif revision == ModuleStoreEnum.RevisionOption.all: + branches_to_delete = [ModuleStoreEnum.BranchName.published, ModuleStoreEnum.BranchName.draft] + elif revision is None: + branches_to_delete = [ModuleStoreEnum.BranchName.draft] + else: + raise UnsupportedRevisionError( + [ + None, + ModuleStoreEnum.RevisionOption.published_only, + ModuleStoreEnum.RevisionOption.all + ] + ) - for branch in branches_to_delete: - branched_location = location.for_branch(branch) - parent_loc = self.get_parent_location(branched_location) - SplitMongoModuleStore.delete_item(self, branched_location, user_id) - self._auto_publish_no_children(parent_loc, parent_loc.category, user_id, **kwargs) + for branch in branches_to_delete: + branched_location = location.for_branch(branch) + parent_loc = self.get_parent_location(branched_location) + SplitMongoModuleStore.delete_item(self, branched_location, user_id) + self._auto_publish_no_children(parent_loc, parent_loc.category, user_id, **kwargs) def _map_revision_to_branch(self, key, revision=None): """ @@ -231,7 +237,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS :return: True if the draft and published versions differ """ def get_block(branch_name): - course_structure = self._lookup_course(xblock.location.for_branch(branch_name))['structure'] + course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch_name))['structure'] return self._get_block_from_structure(course_structure, xblock.location.block_id) draft_block = get_block(ModuleStoreEnum.BranchName.draft) @@ -255,7 +261,9 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS # because for_branch obliterates the version_guid and will lead to missed version conflicts. # TODO Instead, the for_branch implementation should be fixed in the Opaque Keys library. location.course_key.replace(branch=ModuleStoreEnum.BranchName.draft), - location.course_key.for_branch(ModuleStoreEnum.BranchName.published), + # We clear out the version_guid here because the location here is from the draft branch, and that + # won't have the same version guid + location.course_key.replace(branch=ModuleStoreEnum.BranchName.published, version_guid=None), [location], blacklist=blacklist ) @@ -266,8 +274,9 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS Deletes the published version of the item. Returns the newly unpublished item. """ - self.delete_item(location, user_id, revision=ModuleStoreEnum.RevisionOption.published_only) - return self.get_item(location.for_branch(ModuleStoreEnum.BranchName.draft), **kwargs) + with self.bulk_operations(location.course_key): + self.delete_item(location, user_id, revision=ModuleStoreEnum.RevisionOption.published_only) + return self.get_item(location.for_branch(ModuleStoreEnum.BranchName.draft), **kwargs) def revert_to_published(self, location, user_id): """ @@ -348,32 +357,33 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS """ Split-based modulestores need to import published blocks to both branches """ - # hardcode course root block id - if block_type == 'course': - block_id = self.DEFAULT_ROOT_BLOCK_ID - new_usage_key = course_key.make_usage_key(block_type, block_id) + with self.bulk_operations(course_key): + # hardcode course root block id + if block_type == 'course': + block_id = self.DEFAULT_ROOT_BLOCK_ID + new_usage_key = course_key.make_usage_key(block_type, block_id) - if self.get_branch_setting() == ModuleStoreEnum.Branch.published_only: - # if importing a direct only, override existing draft - if block_type in DIRECT_ONLY_CATEGORIES: - draft_course = course_key.for_branch(ModuleStoreEnum.BranchName.draft) - with self.branch_setting(ModuleStoreEnum.Branch.draft_preferred, draft_course): - draft = self.import_xblock(user_id, draft_course, block_type, block_id, fields, runtime) - self._auto_publish_no_children(draft.location, block_type, user_id) - return self.get_item(new_usage_key.for_branch(ModuleStoreEnum.BranchName.published)) - # if new to published - elif not self.has_item(new_usage_key.for_branch(ModuleStoreEnum.BranchName.published)): - # check whether it's new to draft - if not self.has_item(new_usage_key.for_branch(ModuleStoreEnum.BranchName.draft)): - # add to draft too + if self.get_branch_setting() == ModuleStoreEnum.Branch.published_only: + # if importing a direct only, override existing draft + if block_type in DIRECT_ONLY_CATEGORIES: draft_course = course_key.for_branch(ModuleStoreEnum.BranchName.draft) with self.branch_setting(ModuleStoreEnum.Branch.draft_preferred, draft_course): draft = self.import_xblock(user_id, draft_course, block_type, block_id, fields, runtime) - return self.publish(draft.location, user_id, blacklist=EXCLUDE_ALL) + self._auto_publish_no_children(draft.location, block_type, user_id) + return self.get_item(new_usage_key.for_branch(ModuleStoreEnum.BranchName.published)) + # if new to published + elif not self.has_item(new_usage_key.for_branch(ModuleStoreEnum.BranchName.published)): + # check whether it's new to draft + if not self.has_item(new_usage_key.for_branch(ModuleStoreEnum.BranchName.draft)): + # add to draft too + draft_course = course_key.for_branch(ModuleStoreEnum.BranchName.draft) + with self.branch_setting(ModuleStoreEnum.Branch.draft_preferred, draft_course): + draft = self.import_xblock(user_id, draft_course, block_type, block_id, fields, runtime) + return self.publish(draft.location, user_id, blacklist=EXCLUDE_ALL) - # do the import - partitioned_fields = self.partition_fields_by_scope(block_type, fields) - course_key = self._map_revision_to_branch(course_key) # cast to branch_setting - return self._update_item_from_fields( - user_id, course_key, block_type, block_id, partitioned_fields, None, allow_not_found=True, force=True - ) + # do the import + partitioned_fields = self.partition_fields_by_scope(block_type, fields) + course_key = self._map_revision_to_branch(course_key) # cast to branch_setting + return self._update_item_from_fields( + user_id, course_key, block_type, block_id, partitioned_fields, None, allow_not_found=True, force=True + ) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 15bf4282ff..e7a329be40 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -287,7 +287,7 @@ class ModuleStoreTestCase(TestCase): course_loc: the CourseKey for the created course """ with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, None): -# with self.store.bulk_write_operations(self.store.make_course_key(org, course, run)): +# with self.store.bulk_operations(self.store.make_course_key(org, course, run)): course = self.store.create_course(org, course, run, self.user.id, fields=course_fields) self.course_loc = course.location @@ -314,7 +314,7 @@ class ModuleStoreTestCase(TestCase): """ Create an equivalent to the toy xml course """ -# with self.store.bulk_write_operations(self.store.make_course_key(org, course, run)): +# with self.store.bulk_operations(self.store.make_course_key(org, course, run)): self.toy_loc = self.create_sample_course( org, course, run, TOY_BLOCK_INFO_TREE, { diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 381b0a08e8..b394df3ac5 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -1,3 +1,6 @@ +import pprint +import pymongo.message + from factory import Factory, lazy_attribute_sequence, lazy_attribute from factory.containers import CyclicDefinitionError from uuid import uuid4 @@ -214,88 +217,83 @@ class ItemFactory(XModuleFactory): @contextmanager -def check_exact_number_of_calls(object_with_method, method, num_calls, method_name=None): +def check_exact_number_of_calls(object_with_method, method_name, num_calls): """ Instruments the given method on the given object to verify the number of calls to the method is exactly equal to 'num_calls'. """ - with check_number_of_calls(object_with_method, method, num_calls, num_calls, method_name): + with check_number_of_calls(object_with_method, method_name, num_calls, num_calls): yield -@contextmanager -def check_number_of_calls(object_with_method, method, maximum_calls, minimum_calls=1, method_name=None): +def check_number_of_calls(object_with_method, method_name, maximum_calls, minimum_calls=1): """ Instruments the given method on the given object to verify the number of calls to the method is less than or equal to the expected maximum_calls and greater than or equal to the expected minimum_calls. """ - method_wrap = Mock(wraps=method) - wrap_patch = patch.object(object_with_method, method_name or method.__name__, method_wrap) - - try: - wrap_patch.start() - yield - - finally: - wrap_patch.stop() - - # verify the counter actually worked by ensuring we have counted greater than (or equal to) the minimum calls - assert_greater_equal(method_wrap.call_count, minimum_calls) - - # now verify the number of actual calls is less than (or equal to) the expected maximum - assert_less_equal(method_wrap.call_count, maximum_calls) + return check_sum_of_calls(object_with_method, [method_name], maximum_calls, minimum_calls) @contextmanager -def check_mongo_calls(mongo_store, num_finds=0, num_sends=None): +def check_sum_of_calls(object_, methods, maximum_calls, minimum_calls=1): + """ + Instruments the given methods on the given object to verify that the total sum of calls made to the + methods falls between minumum_calls and maximum_calls. + """ + mocks = { + method: Mock(wraps=getattr(object_, method)) + for method in methods + } + + with patch.multiple(object_, **mocks): + yield + + call_count = sum(mock.call_count for mock in mocks.values()) + calls = pprint.pformat({ + method_name: mock.call_args_list + for method_name, mock in mocks.items() + }) + + # Assertion errors don't handle multi-line values, so pretty-print to std-out instead + if not minimum_calls <= call_count <= maximum_calls: + print "Expected between {} and {} calls, {} were made. Calls: {}".format( + minimum_calls, + maximum_calls, + call_count, + calls, + ) + + # verify the counter actually worked by ensuring we have counted greater than (or equal to) the minimum calls + assert_greater_equal(call_count, minimum_calls) + + # now verify the number of actual calls is less than (or equal to) the expected maximum + assert_less_equal(call_count, maximum_calls) + + +@contextmanager +def check_mongo_calls(num_finds=0, num_sends=None): """ Instruments the given store to count the number of calls to find (incl find_one) and the number of calls to send_message which is for insert, update, and remove (if you provide num_sends). At the end of the with statement, it compares the counts to the num_finds and num_sends. - :param mongo_store: the MongoModulestore or subclass to watch or a SplitMongoModuleStore :param num_finds: the exact number of find calls expected :param num_sends: If none, don't instrument the send calls. If non-none, count and compare to the given int value. """ - if mongo_store.get_modulestore_type() == ModuleStoreEnum.Type.mongo: - with check_exact_number_of_calls(mongo_store.collection, mongo_store.collection.find, num_finds): - if num_sends is not None: - with check_exact_number_of_calls( - mongo_store.database.connection, - mongo_store.database.connection._send_message, # pylint: disable=protected-access - num_sends, - ): - yield - else: + with check_sum_of_calls( + pymongo.message, + ['query', 'get_more'], + num_finds, + num_finds + ): + if num_sends is not None: + with check_sum_of_calls( + pymongo.message, + ['insert', 'update', 'delete'], + num_sends, + num_sends + ): yield - elif mongo_store.get_modulestore_type() == ModuleStoreEnum.Type.split: - collections = [ - mongo_store.db_connection.course_index, - mongo_store.db_connection.structures, - mongo_store.db_connection.definitions, - ] - # could add else clause which raises exception or just rely on the below to suss that out - try: - find_wraps = [] - wrap_patches = [] - for collection in collections: - find_wrap = Mock(wraps=collection.find) - find_wraps.append(find_wrap) - wrap_patch = patch.object(collection, 'find', find_wrap) - wrap_patches.append(wrap_patch) - wrap_patch.start() - if num_sends is not None: - connection = mongo_store.db_connection.database.connection - with check_exact_number_of_calls( - connection, - connection._send_message, # pylint: disable=protected-access - num_sends, - ): - yield - else: - yield - finally: - map(lambda wrap_patch: wrap_patch.stop(), wrap_patches) - call_count = sum([find_wrap.call_count for find_wrap in find_wraps]) - assert_equal(call_count, num_finds) + else: + yield diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index 02140b62fb..22b79d5190 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -127,15 +127,16 @@ class TestMixedModuleStore(unittest.TestCase): Create a course w/ one item in the persistence store using the given course & item location. """ # create course - self.course = self.store.create_course(course_key.org, course_key.course, course_key.run, self.user_id) - if isinstance(self.course.id, CourseLocator): - self.course_locations[self.MONGO_COURSEID] = self.course.location - else: - self.assertEqual(self.course.id, course_key) + with self.store.bulk_operations(course_key): + self.course = self.store.create_course(course_key.org, course_key.course, course_key.run, self.user_id) + if isinstance(self.course.id, CourseLocator): + self.course_locations[self.MONGO_COURSEID] = self.course.location + else: + self.assertEqual(self.course.id, course_key) - # create chapter - chapter = self.store.create_child(self.user_id, self.course.location, 'chapter', block_id='Overview') - self.writable_chapter_location = chapter.location + # create chapter + chapter = self.store.create_child(self.user_id, self.course.location, 'chapter', block_id='Overview') + self.writable_chapter_location = chapter.location def _create_block_hierarchy(self): """ @@ -188,8 +189,9 @@ class TestMixedModuleStore(unittest.TestCase): create_sub_tree(block, tree) setattr(self, block_info.field_name, block.location) - for tree in trees: - create_sub_tree(self.course, tree) + with self.store.bulk_operations(self.course.id): + for tree in trees: + create_sub_tree(self.course, tree) def _course_key_from_string(self, string): """ @@ -270,8 +272,13 @@ class TestMixedModuleStore(unittest.TestCase): with self.assertRaises(DuplicateCourseError): self.store.create_course('org_x', 'course_y', 'run_z', self.user_id) + # Draft: + # - One lookup to locate an item that exists + # - Two lookups to determine an item doesn't exist (one to check mongo, one to check split) # split has one lookup for the course and then one for the course items - @ddt.data(('draft', 1, 0), ('split', 2, 0)) + # TODO: LMS-11220: Document why draft find count is [1, 1] + # TODO: LMS-11220: Document why split find count is [2, 2] + @ddt.data(('draft', [1, 1], 0), ('split', [2, 2], 0)) @ddt.unpack def test_has_item(self, default_ms, max_find, max_send): self.initdb(default_ms) @@ -279,15 +286,14 @@ class TestMixedModuleStore(unittest.TestCase): self.assertTrue(self.store.has_item(self.course_locations[self.XML_COURSEID1])) - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find.pop(0), max_send): self.assertTrue(self.store.has_item(self.problem_x1a_1)) # try negative cases self.assertFalse(self.store.has_item( self.course_locations[self.XML_COURSEID1].replace(name='not_findable', category='problem') )) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find.pop(0), max_send): self.assertFalse(self.store.has_item(self.fake_location)) # verify that an error is raised when the revision is not valid @@ -296,7 +302,9 @@ class TestMixedModuleStore(unittest.TestCase): # draft is 2 to compute inheritance # split is 2 (would be 3 on course b/c it looks up the wiki_slug in definitions) - @ddt.data(('draft', 2, 0), ('split', 2, 0)) + # TODO: LMS-11220: Document why draft find count is [2, 2] + # TODO: LMS-11220: Document why split find count is [3, 3] + @ddt.data(('draft', [2, 2], 0), ('split', [3, 3], 0)) @ddt.unpack def test_get_item(self, default_ms, max_find, max_send): self.initdb(default_ms) @@ -304,8 +312,7 @@ class TestMixedModuleStore(unittest.TestCase): self.assertIsNotNone(self.store.get_item(self.course_locations[self.XML_COURSEID1])) - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find.pop(0), max_send): self.assertIsNotNone(self.store.get_item(self.problem_x1a_1)) # try negative cases @@ -313,7 +320,7 @@ class TestMixedModuleStore(unittest.TestCase): self.store.get_item( self.course_locations[self.XML_COURSEID1].replace(name='not_findable', category='problem') ) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find.pop(0), max_send): with self.assertRaises(ItemNotFoundError): self.store.get_item(self.fake_location) @@ -322,7 +329,8 @@ class TestMixedModuleStore(unittest.TestCase): self.store.get_item(self.fake_location, revision=ModuleStoreEnum.RevisionOption.draft_preferred) # compared to get_item for the course, draft asks for both draft and published - @ddt.data(('draft', 8, 0), ('split', 2, 0)) + # TODO: LMS-11220: Document why split find count is 3 + @ddt.data(('draft', 8, 0), ('split', 3, 0)) @ddt.unpack def test_get_items(self, default_ms, max_find, max_send): self.initdb(default_ms) @@ -334,9 +342,8 @@ class TestMixedModuleStore(unittest.TestCase): self.assertEqual(len(modules), 1) self.assertEqual(modules[0].location, course_locn) - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) course_locn = self.course_locations[self.MONGO_COURSEID] - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find, max_send): # NOTE: use get_course if you just want the course. get_items is expensive modules = self.store.get_items(course_locn.course_key, qualifiers={'category': 'problem'}) self.assertEqual(len(modules), 6) @@ -349,10 +356,9 @@ class TestMixedModuleStore(unittest.TestCase): ) # draft: 2 to look in draft and then published and then 5 for updating ancestors. - # split: 3 to get the course structure & the course definition (show_calculator is scope content) - # before the change. 1 during change to refetch the definition. 3 afterward (b/c it calls get_item to return the "new" object). - # 2 sends to update index & structure (calculator is a setting field) - @ddt.data(('draft', 7, 5), ('split', 6, 2)) + # split: 1 for the course index, 1 for the course structure before the change, 1 for the structure after the change + # 2 sends: insert structure, update index_entry + @ddt.data(('draft', 11, 5), ('split', 3, 2)) @ddt.unpack def test_update_item(self, default_ms, max_find, max_send): """ @@ -368,12 +374,11 @@ class TestMixedModuleStore(unittest.TestCase): self.store.update_item(course, self.user_id) # now do it for a r/w db - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) problem = self.store.get_item(self.problem_x1a_1) # if following raised, then the test is really a noop, change it self.assertNotEqual(problem.max_attempts, 2, "Default changed making test meaningless") problem.max_attempts = 2 - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find, max_send): problem = self.store.update_item(problem, self.user_id) self.assertEqual(problem.max_attempts, 2, "Update didn't persist") @@ -434,7 +439,10 @@ class TestMixedModuleStore(unittest.TestCase): component = self.store.publish(component.location, self.user_id) self.assertFalse(self.store.has_changes(component)) - @ddt.data(('draft', 7, 2), ('split', 13, 4)) + # TODO: LMS-11220: Document why split find count is 4 + # TODO: LMS-11220: Document why draft find count is 8 + # TODO: LMS-11220: Document why split send count is 3 + @ddt.data(('draft', 8, 2), ('split', 4, 3)) @ddt.unpack def test_delete_item(self, default_ms, max_find, max_send): """ @@ -446,14 +454,16 @@ class TestMixedModuleStore(unittest.TestCase): with self.assertRaises(NotImplementedError): self.store.delete_item(self.xml_chapter_location, self.user_id) - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find, max_send): self.store.delete_item(self.writable_chapter_location, self.user_id) + # verify it's gone with self.assertRaises(ItemNotFoundError): self.store.get_item(self.writable_chapter_location) - @ddt.data(('draft', 8, 2), ('split', 13, 4)) + # TODO: LMS-11220: Document why draft find count is 9 + # TODO: LMS-11220: Document why split send count is 3 + @ddt.data(('draft', 9, 2), ('split', 4, 3)) @ddt.unpack def test_delete_private_vertical(self, default_ms, max_find, max_send): """ @@ -484,8 +494,7 @@ class TestMixedModuleStore(unittest.TestCase): self.assertIn(vert_loc, course.children) # delete the vertical and ensure the course no longer points to it - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find, max_send): self.store.delete_item(vert_loc, self.user_id) course = self.store.get_course(self.course_locations[self.MONGO_COURSEID].course_key, 0) if hasattr(private_vert.location, 'version_guid'): @@ -499,7 +508,9 @@ class TestMixedModuleStore(unittest.TestCase): self.assertFalse(self.store.has_item(leaf_loc)) self.assertNotIn(vert_loc, course.children) - @ddt.data(('draft', 4, 1), ('split', 5, 2)) + # TODO: LMS-11220: Document why split send count is 2 + # TODO: LMS-11220: Document why draft find count is 5 + @ddt.data(('draft', 5, 1), ('split', 2, 2)) @ddt.unpack def test_delete_draft_vertical(self, default_ms, max_find, max_send): """ @@ -528,24 +539,24 @@ class TestMixedModuleStore(unittest.TestCase): self.store.publish(private_vert.location, self.user_id) private_leaf.display_name = 'change me' private_leaf = self.store.update_item(private_leaf, self.user_id) - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) # test succeeds if delete succeeds w/o error - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find, max_send): self.store.delete_item(private_leaf.location, self.user_id) - @ddt.data(('draft', 2, 0), ('split', 3, 0)) + # TODO: LMS-11220: Document why split find count is 5 + # TODO: LMS-11220: Document why draft find count is 4 + @ddt.data(('draft', 4, 0), ('split', 5, 0)) @ddt.unpack def test_get_courses(self, default_ms, max_find, max_send): self.initdb(default_ms) # we should have 3 total courses across all stores - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find, max_send): courses = self.store.get_courses() - course_ids = [course.location for course in courses] - self.assertEqual(len(courses), 3, "Not 3 courses: {}".format(course_ids)) - self.assertIn(self.course_locations[self.MONGO_COURSEID], course_ids) - self.assertIn(self.course_locations[self.XML_COURSEID1], course_ids) - self.assertIn(self.course_locations[self.XML_COURSEID2], course_ids) + course_ids = [course.location for course in courses] + self.assertEqual(len(courses), 3, "Not 3 courses: {}".format(course_ids)) + self.assertIn(self.course_locations[self.MONGO_COURSEID], course_ids) + self.assertIn(self.course_locations[self.XML_COURSEID1], course_ids) + self.assertIn(self.course_locations[self.XML_COURSEID2], course_ids) with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred): draft_courses = self.store.get_courses(remove_branch=True) @@ -579,8 +590,9 @@ class TestMixedModuleStore(unittest.TestCase): xml_store.create_course("org", "course", "run", self.user_id) # draft is 2 to compute inheritance - # split is 3 b/c it gets the definition to check whether wiki is set - @ddt.data(('draft', 2, 0), ('split', 3, 0)) + # split is 3 (one for the index, one for the definition to check if the wiki is set, and one for the course structure + # TODO: LMS-11220: Document why split find count is 4 + @ddt.data(('draft', 2, 0), ('split', 4, 0)) @ddt.unpack def test_get_course(self, default_ms, max_find, max_send): """ @@ -588,8 +600,7 @@ class TestMixedModuleStore(unittest.TestCase): of getting an item whose scope.content fields are looked at. """ self.initdb(default_ms) - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find, max_send): course = self.store.get_item(self.course_locations[self.MONGO_COURSEID]) self.assertEqual(course.id, self.course_locations[self.MONGO_COURSEID].course_key) @@ -598,7 +609,8 @@ class TestMixedModuleStore(unittest.TestCase): # notice this doesn't test getting a public item via draft_preferred which draft would have 2 hits (split # still only 2) - @ddt.data(('draft', 1, 0), ('split', 2, 0)) + # TODO: LMS-11220: Document why draft find count is 2 + @ddt.data(('draft', 2, 0), ('split', 2, 0)) @ddt.unpack def test_get_parent_locations(self, default_ms, max_find, max_send): """ @@ -607,10 +619,9 @@ class TestMixedModuleStore(unittest.TestCase): self.initdb(default_ms) self._create_block_hierarchy() - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find, max_send): parent = self.store.get_parent_location(self.problem_x1a_1) - self.assertEqual(parent, self.vertical_x1a) + self.assertEqual(parent, self.vertical_x1a) parent = self.store.get_parent_location(self.xml_chapter_location) self.assertEqual(parent, self.course_locations[self.XML_COURSEID1]) @@ -692,7 +703,21 @@ class TestMixedModuleStore(unittest.TestCase): (child_to_delete_location, None, ModuleStoreEnum.RevisionOption.published_only), ]) - @ddt.data(('draft', [10, 3], 0), ('split', [14, 6], 0)) + # Mongo reads: + # First location: + # - count problem (1) + # - For each level of ancestors: (5) + # - Count ancestor + # - retrieve ancestor + # - compute inheritable data + # Second location: + # - load vertical + # - load inheritance data + + # TODO: LMS-11220: Document why draft send count is 6 + # TODO: LMS-11220: Document why draft find count is 18 + # TODO: LMS-11220: Document why split find count is 16 + @ddt.data(('draft', [19, 6], 0), ('split', [2, 2], 0)) @ddt.unpack def test_path_to_location(self, default_ms, num_finds, num_sends): """ @@ -711,9 +736,8 @@ class TestMixedModuleStore(unittest.TestCase): (course_key, "Chapter_x", None, None)), ) - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) for location, expected in should_work: - with check_mongo_calls(mongo_store, num_finds.pop(0), num_sends): + with check_mongo_calls(num_finds.pop(0), num_sends): self.assertEqual(path_to_location(self.store, location), expected) not_found = ( @@ -881,10 +905,9 @@ class TestMixedModuleStore(unittest.TestCase): block_id=location.block_id ) - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find, max_send): found_orphans = self.store.get_orphans(self.course_locations[self.MONGO_COURSEID].course_key) - self.assertEqual(set(found_orphans), set(orphan_locations)) + self.assertItemsEqual(found_orphans, orphan_locations) @ddt.data('draft') def test_create_item_from_parent_location(self, default_ms): @@ -924,7 +947,9 @@ class TestMixedModuleStore(unittest.TestCase): self.assertEqual(self.user_id, block.subtree_edited_by) self.assertGreater(datetime.datetime.now(UTC), block.subtree_edited_on) - @ddt.data(('draft', 1, 0), ('split', 1, 0)) + # TODO: LMS-11220: Document why split find count is 2 + # TODO: LMS-11220: Document why draft find count is 2 + @ddt.data(('draft', 2, 0), ('split', 2, 0)) @ddt.unpack def test_get_courses_for_wiki(self, default_ms, max_find, max_send): """ @@ -941,8 +966,7 @@ class TestMixedModuleStore(unittest.TestCase): self.assertIn(self.course_locations[self.XML_COURSEID2].course_key, wiki_courses) # Test Mongo wiki - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find, max_send): wiki_courses = self.store.get_courses_for_wiki('999') self.assertEqual(len(wiki_courses), 1) self.assertIn( @@ -953,7 +977,15 @@ class TestMixedModuleStore(unittest.TestCase): self.assertEqual(len(self.store.get_courses_for_wiki('edX.simple.2012_Fall')), 0) self.assertEqual(len(self.store.get_courses_for_wiki('no_such_wiki')), 0) - @ddt.data(('draft', 2, 6), ('split', 7, 2)) + # Mongo reads: + # - load vertical + # - load vertical children + # - get last error + # Split takes 1 query to read the course structure, deletes all of the entries in memory, and loads the module from an in-memory cache + # Sends: + # - insert structure + # - write index entry + @ddt.data(('draft', 3, 6), ('split', 3, 2)) @ddt.unpack def test_unpublish(self, default_ms, max_find, max_send): """ @@ -971,8 +1003,7 @@ class TestMixedModuleStore(unittest.TestCase): self.assertIsNotNone(published_xblock) # unpublish - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find, max_send): self.store.unpublish(self.vertical_x1a, self.user_id) with self.assertRaises(ItemNotFoundError): @@ -1000,8 +1031,7 @@ class TestMixedModuleStore(unittest.TestCase): # start off as Private item = self.store.create_child(self.user_id, self.writable_chapter_location, 'problem', 'test_compute_publish_state') item_location = item.location - mongo_store = self.store._get_modulestore_for_courseid(self._course_key_from_string(self.MONGO_COURSEID)) - with check_mongo_calls(mongo_store, max_find, max_send): + with check_mongo_calls(max_find, max_send): self.assertFalse(self.store.has_published_version(item)) # Private -> Public diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_publish.py b/common/lib/xmodule/xmodule/modulestore/tests/test_publish.py index e7678de926..76511faebd 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_publish.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_publish.py @@ -19,23 +19,35 @@ class TestPublish(SplitWMongoCourseBoostrapper): # There are 12 created items and 7 parent updates # create course: finds: 1 to verify uniqueness, 1 to find parents # sends: 1 to create course, 1 to create overview - with check_mongo_calls(self.draft_mongo, 5, 2): + with check_mongo_calls(5, 2): super(TestPublish, self)._create_course(split=False) # 2 inserts (course and overview) # with bulk will delay all inheritance computations which won't be added into the mongo_calls - with self.draft_mongo.bulk_write_operations(self.old_course_key): + with self.draft_mongo.bulk_operations(self.old_course_key): # finds: 1 for parent to add child # sends: 1 for insert, 1 for parent (add child) - with check_mongo_calls(self.draft_mongo, 1, 2): + with check_mongo_calls(1, 2): self._create_item('chapter', 'Chapter1', {}, {'display_name': 'Chapter 1'}, 'course', 'runid', split=False) - with check_mongo_calls(self.draft_mongo, 2, 2): + with check_mongo_calls(2, 2): self._create_item('chapter', 'Chapter2', {}, {'display_name': 'Chapter 2'}, 'course', 'runid', split=False) - # update info propagation is 2 levels. create looks for draft and then published and then creates - with check_mongo_calls(self.draft_mongo, 8, 6): + # For each vertical (2) created: + # - load draft + # - load non-draft + # - get last error + # - load parent + # - load inheritable data + with check_mongo_calls(10, 6): self._create_item('vertical', 'Vert1', {}, {'display_name': 'Vertical 1'}, 'chapter', 'Chapter1', split=False) self._create_item('vertical', 'Vert2', {}, {'display_name': 'Vertical 2'}, 'chapter', 'Chapter1', split=False) - with check_mongo_calls(self.draft_mongo, 20, 12): + # For each (4) item created + # - load draft + # - load non-draft + # - get last error + # - load parent + # - load inheritable data + # - load parent + with check_mongo_calls(24, 12): self._create_item('html', 'Html1', "

Goodbye

", {'display_name': 'Parented Html'}, 'vertical', 'Vert1', split=False) self._create_item( 'discussion', 'Discussion1', @@ -63,7 +75,7 @@ class TestPublish(SplitWMongoCourseBoostrapper): split=False ) - with check_mongo_calls(self.draft_mongo, 0, 2): + with check_mongo_calls(0, 2): # 2 finds b/c looking for non-existent parents self._create_item('static_tab', 'staticuno', "

tab

", {'display_name': 'Tab uno'}, None, None, split=False) self._create_item('course_info', 'updates', "
  1. Sep 22

    test

", {}, None, None, split=False) @@ -76,10 +88,11 @@ class TestPublish(SplitWMongoCourseBoostrapper): vert_location = self.old_course_key.make_usage_key('vertical', block_id='Vert1') item = self.draft_mongo.get_item(vert_location, 2) # Vert1 has 3 children; so, publishes 4 nodes which may mean 4 inserts & 1 bulk remove + # TODO: LMS-11220: Document why find count is 25 # 25-June-2014 find calls are 19. Probably due to inheritance recomputation? # 02-July-2014 send calls are 7. 5 from above, plus 2 for updating subtree edit info for Chapter1 and course # find calls are 22. 19 from above, plus 3 for finding the parent of Vert1, Chapter1, and course - with check_mongo_calls(self.draft_mongo, 22, 7): + with check_mongo_calls(25, 7): self.draft_mongo.publish(item.location, self.user_id) # verify status diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py index 05f5497d3c..cee7f08897 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py @@ -2,12 +2,12 @@ Test split modulestore w/o using any django stuff. """ import datetime +import random +import re import unittest import uuid from importlib import import_module from path import path -import re -import random from xmodule.course_module import CourseDescriptor from xmodule.modulestore import ModuleStoreEnum @@ -492,13 +492,14 @@ class SplitModuleTest(unittest.TestCase): new_ele_dict[spec['id']] = child course = split_store.persist_xblock_dag(course, revision['user_id']) # publish "testx.wonderful" + source_course = CourseLocator(org="testx", course="wonderful", run="run", branch=BRANCH_NAME_DRAFT) to_publish = BlockUsageLocator( - CourseLocator(org="testx", course="wonderful", run="run", branch=BRANCH_NAME_DRAFT), + source_course, block_type='course', block_id="head23456" ) destination = CourseLocator(org="testx", course="wonderful", run="run", branch=BRANCH_NAME_PUBLISHED) - split_store.copy("test@edx.org", to_publish, destination, [to_publish], None) + split_store.copy("test@edx.org", source_course, destination, [to_publish], None) def setUp(self): self.user_id = random.getrandbits(32) @@ -607,13 +608,6 @@ class SplitModuleCourseTests(SplitModuleTest): _verify_published_course(modulestore().get_courses(branch=BRANCH_NAME_PUBLISHED)) - def test_search_qualifiers(self): - # query w/ search criteria - courses = modulestore().get_courses(branch=BRANCH_NAME_DRAFT, qualifiers={'org': 'testx'}) - self.assertEqual(len(courses), 2) - self.assertIsNotNone(self.findByIdInResult(courses, "head12345")) - self.assertIsNotNone(self.findByIdInResult(courses, "head23456")) - def test_has_course(self): ''' Test the various calling forms for has_course @@ -985,7 +979,7 @@ class TestItemCrud(SplitModuleTest): # grab link to course to ensure new versioning works locator = CourseLocator(org='testx', course='GreekHero', run="run", branch=BRANCH_NAME_DRAFT) premod_course = modulestore().get_course(locator) - premod_history = modulestore().get_course_history_info(premod_course.location) + premod_history = modulestore().get_course_history_info(locator) # add minimal one w/o a parent category = 'sequential' new_module = modulestore().create_item( @@ -999,7 +993,7 @@ class TestItemCrud(SplitModuleTest): current_course = modulestore().get_course(locator) self.assertEqual(new_module.location.version_guid, current_course.location.version_guid) - history_info = modulestore().get_course_history_info(current_course.location) + history_info = modulestore().get_course_history_info(current_course.location.course_key) self.assertEqual(history_info['previous_version'], premod_course.location.version_guid) self.assertEqual(history_info['original_version'], premod_history['original_version']) self.assertEqual(history_info['edited_by'], "user123") @@ -1112,84 +1106,82 @@ class TestItemCrud(SplitModuleTest): chapter = modulestore().get_item(chapter_locator) self.assertIn(problem_locator, version_agnostic(chapter.children)) - def test_create_continue_version(self): + def test_create_bulk_operations(self): """ - Test create_item using the continue_version flag + Test create_item using bulk_operations """ # start transaction w/ simple creation user = random.getrandbits(32) - new_course = modulestore().create_course('test_org', 'test_transaction', 'test_run', user, BRANCH_NAME_DRAFT) - new_course_locator = new_course.id - index_history_info = modulestore().get_course_history_info(new_course.location) - course_block_prev_version = new_course.previous_version - course_block_update_version = new_course.update_version - self.assertIsNotNone(new_course_locator.version_guid, "Want to test a definite version") - versionless_course_locator = new_course_locator.version_agnostic() + course_key = CourseLocator('test_org', 'test_transaction', 'test_run') + with modulestore().bulk_operations(course_key): + new_course = modulestore().create_course('test_org', 'test_transaction', 'test_run', user, BRANCH_NAME_DRAFT) + new_course_locator = new_course.id + index_history_info = modulestore().get_course_history_info(new_course.location.course_key) + course_block_prev_version = new_course.previous_version + course_block_update_version = new_course.update_version + self.assertIsNotNone(new_course_locator.version_guid, "Want to test a definite version") + versionless_course_locator = new_course_locator.version_agnostic() - # positive simple case: no force, add chapter - new_ele = modulestore().create_child( - user, new_course.location, 'chapter', - fields={'display_name': 'chapter 1'}, - continue_version=True - ) - # version info shouldn't change - self.assertEqual(new_ele.update_version, course_block_update_version) - self.assertEqual(new_ele.update_version, new_ele.location.version_guid) - refetch_course = modulestore().get_course(versionless_course_locator) - self.assertEqual(refetch_course.location.version_guid, new_course.location.version_guid) - self.assertEqual(refetch_course.previous_version, course_block_prev_version) - self.assertEqual(refetch_course.update_version, course_block_update_version) - refetch_index_history_info = modulestore().get_course_history_info(refetch_course.location) - self.assertEqual(refetch_index_history_info, index_history_info) - self.assertIn(new_ele.location.version_agnostic(), version_agnostic(refetch_course.children)) - - # try to create existing item - with self.assertRaises(DuplicateItemError): - _fail = modulestore().create_child( + # positive simple case: no force, add chapter + new_ele = modulestore().create_child( user, new_course.location, 'chapter', - block_id=new_ele.location.block_id, - fields={'display_name': 'chapter 2'}, - continue_version=True + fields={'display_name': 'chapter 1'}, ) + # version info shouldn't change + self.assertEqual(new_ele.update_version, course_block_update_version) + self.assertEqual(new_ele.update_version, new_ele.location.version_guid) + refetch_course = modulestore().get_course(versionless_course_locator) + self.assertEqual(refetch_course.location.version_guid, new_course.location.version_guid) + self.assertEqual(refetch_course.previous_version, course_block_prev_version) + self.assertEqual(refetch_course.update_version, course_block_update_version) + refetch_index_history_info = modulestore().get_course_history_info(refetch_course.location.course_key) + self.assertEqual(refetch_index_history_info, index_history_info) + self.assertIn(new_ele.location.version_agnostic(), version_agnostic(refetch_course.children)) + + # try to create existing item + with self.assertRaises(DuplicateItemError): + _fail = modulestore().create_child( + user, new_course.location, 'chapter', + block_id=new_ele.location.block_id, + fields={'display_name': 'chapter 2'}, + ) # start a new transaction - new_ele = modulestore().create_child( - user, new_course.location, 'chapter', - fields={'display_name': 'chapter 2'}, - continue_version=False - ) - transaction_guid = new_ele.location.version_guid - # ensure force w/ continue gives exception - with self.assertRaises(VersionConflictError): - _fail = modulestore().create_child( + with modulestore().bulk_operations(course_key): + new_ele = modulestore().create_child( user, new_course.location, 'chapter', fields={'display_name': 'chapter 2'}, - force=True, continue_version=True ) + transaction_guid = new_ele.location.version_guid + # ensure force w/ continue gives exception + with self.assertRaises(VersionConflictError): + _fail = modulestore().create_child( + user, new_course.location, 'chapter', + fields={'display_name': 'chapter 2'}, + force=True + ) - # ensure trying to continue the old one gives exception - with self.assertRaises(VersionConflictError): - _fail = modulestore().create_child( - user, new_course.location, 'chapter', - fields={'display_name': 'chapter 3'}, - continue_version=True + # ensure trying to continue the old one gives exception + with self.assertRaises(VersionConflictError): + _fail = modulestore().create_child( + user, new_course.location, 'chapter', + fields={'display_name': 'chapter 3'}, + ) + + # add new child to old parent in continued (leave off version_guid) + course_module_locator = new_course.location.version_agnostic() + new_ele = modulestore().create_child( + user, course_module_locator, 'chapter', + fields={'display_name': 'chapter 4'}, ) + self.assertNotEqual(new_ele.update_version, course_block_update_version) + self.assertEqual(new_ele.location.version_guid, transaction_guid) - # add new child to old parent in continued (leave off version_guid) - course_module_locator = new_course.location.version_agnostic() - new_ele = modulestore().create_child( - user, course_module_locator, 'chapter', - fields={'display_name': 'chapter 4'}, - continue_version=True - ) - self.assertNotEqual(new_ele.update_version, course_block_update_version) - self.assertEqual(new_ele.location.version_guid, transaction_guid) - - # check children, previous_version - refetch_course = modulestore().get_course(versionless_course_locator) - self.assertIn(new_ele.location.version_agnostic(), version_agnostic(refetch_course.children)) - self.assertEqual(refetch_course.previous_version, course_block_update_version) - self.assertEqual(refetch_course.update_version, transaction_guid) + # check children, previous_version + refetch_course = modulestore().get_course(versionless_course_locator) + self.assertIn(new_ele.location.version_agnostic(), version_agnostic(refetch_course.children)) + self.assertEqual(refetch_course.previous_version, course_block_update_version) + self.assertEqual(refetch_course.update_version, transaction_guid) def test_update_metadata(self): """ @@ -1221,7 +1213,7 @@ class TestItemCrud(SplitModuleTest): current_course = modulestore().get_course(locator.course_key) self.assertEqual(updated_problem.location.version_guid, current_course.location.version_guid) - history_info = modulestore().get_course_history_info(current_course.location) + history_info = modulestore().get_course_history_info(current_course.location.course_key) self.assertEqual(history_info['previous_version'], pre_version_guid) self.assertEqual(history_info['edited_by'], self.user_id) @@ -1396,16 +1388,13 @@ class TestCourseCreation(SplitModuleTest): ) new_locator = new_course.location # check index entry - index_info = modulestore().get_course_index_info(new_locator) + index_info = modulestore().get_course_index_info(new_locator.course_key) self.assertEqual(index_info['org'], 'test_org') self.assertEqual(index_info['edited_by'], 'create_user') # check structure info - structure_info = modulestore().get_course_history_info(new_locator) - # TODO LMS-11098 "Implement bulk_write in Split" - # Right now, these assertions will not pass because create_course calls update_item, - # resulting in two versions. Bulk updater will fix this. - # self.assertEqual(structure_info['original_version'], index_info['versions'][BRANCH_NAME_DRAFT]) - # self.assertIsNone(structure_info['previous_version']) + structure_info = modulestore().get_course_history_info(new_locator.course_key) + self.assertEqual(structure_info['original_version'], index_info['versions'][BRANCH_NAME_DRAFT]) + self.assertIsNone(structure_info['previous_version']) self.assertEqual(structure_info['edited_by'], 'create_user') # check the returned course object @@ -1433,7 +1422,7 @@ class TestCourseCreation(SplitModuleTest): self.assertEqual(new_draft.edited_by, 'test@edx.org') self.assertEqual(new_draft_locator.version_guid, original_index['versions'][BRANCH_NAME_DRAFT]) # however the edited_by and other meta fields on course_index will be this one - new_index = modulestore().get_course_index_info(new_draft_locator) + new_index = modulestore().get_course_index_info(new_draft_locator.course_key) self.assertEqual(new_index['edited_by'], 'leech_master') new_published_locator = new_draft_locator.course_key.for_branch(BRANCH_NAME_PUBLISHED) @@ -1483,7 +1472,7 @@ class TestCourseCreation(SplitModuleTest): self.assertEqual(new_draft.edited_by, 'leech_master') self.assertNotEqual(new_draft_locator.version_guid, original_index['versions'][BRANCH_NAME_DRAFT]) # however the edited_by and other meta fields on course_index will be this one - new_index = modulestore().get_course_index_info(new_draft_locator) + new_index = modulestore().get_course_index_info(new_draft_locator.course_key) self.assertEqual(new_index['edited_by'], 'leech_master') self.assertEqual(new_draft.display_name, fields['display_name']) self.assertDictEqual( @@ -1504,13 +1493,13 @@ class TestCourseCreation(SplitModuleTest): head_course = modulestore().get_course(locator) versions = course_info['versions'] versions[BRANCH_NAME_DRAFT] = head_course.previous_version - modulestore().update_course_index(course_info) + modulestore().update_course_index(None, course_info) course = modulestore().get_course(locator) self.assertEqual(course.location.version_guid, versions[BRANCH_NAME_DRAFT]) # an allowed but not recommended way to publish a course versions[BRANCH_NAME_PUBLISHED] = versions[BRANCH_NAME_DRAFT] - modulestore().update_course_index(course_info) + modulestore().update_course_index(None, course_info) course = modulestore().get_course(locator.for_branch(BRANCH_NAME_PUBLISHED)) self.assertEqual(course.location.version_guid, versions[BRANCH_NAME_DRAFT]) @@ -1715,6 +1704,7 @@ class TestPublish(SplitModuleTest): dest_cursor += 1 self.assertEqual(dest_cursor, len(dest_children)) + class TestSchema(SplitModuleTest): """ Test the db schema (and possibly eventually migrations?) @@ -1736,6 +1726,7 @@ class TestSchema(SplitModuleTest): "{0.name} has records with wrong schema_version".format(collection) ) + #=========================================== def modulestore(): """ diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore_bulk_operations.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore_bulk_operations.py new file mode 100644 index 0000000000..8c75800f02 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore_bulk_operations.py @@ -0,0 +1,525 @@ +import copy +import ddt +import unittest +from bson.objectid import ObjectId +from mock import MagicMock, Mock, call +from xmodule.modulestore.split_mongo.split import BulkWriteMixin +from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection + +from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator, VersionTree, LocalId + + +class TestBulkWriteMixin(unittest.TestCase): + def setUp(self): + super(TestBulkWriteMixin, self).setUp() + self.bulk = BulkWriteMixin() + self.bulk.SCHEMA_VERSION = 1 + self.clear_cache = self.bulk._clear_cache = Mock(name='_clear_cache') + self.conn = self.bulk.db_connection = MagicMock(name='db_connection', spec=MongoConnection) + self.conn.get_course_index.return_value = {'initial': 'index'} + + self.course_key = CourseLocator('org', 'course', 'run-a') + self.course_key_b = CourseLocator('org', 'course', 'run-b') + self.structure = {'this': 'is', 'a': 'structure', '_id': ObjectId()} + self.index_entry = {'this': 'is', 'an': 'index'} + + def assertConnCalls(self, *calls): + self.assertEqual(list(calls), self.conn.mock_calls) + + def assertCacheNotCleared(self): + self.assertFalse(self.clear_cache.called) + + +class TestBulkWriteMixinPreviousTransaction(TestBulkWriteMixin): + """ + Verify that opening and closing a transaction doesn't affect later behaviour. + """ + def setUp(self): + super(TestBulkWriteMixinPreviousTransaction, self).setUp() + self.bulk._begin_bulk_operation(self.course_key) + self.bulk.insert_course_index(self.course_key, MagicMock('prev-index-entry')) + self.bulk.update_structure(self.course_key, {'this': 'is', 'the': 'previous structure', '_id': ObjectId()}) + self.bulk._end_bulk_operation(self.course_key) + self.conn.reset_mock() + self.clear_cache.reset_mock() + + +@ddt.ddt +class TestBulkWriteMixinClosed(TestBulkWriteMixin): + """ + Tests of the bulk write mixin when bulk operations aren't active. + """ + @ddt.data('deadbeef1234' * 2, u'deadbeef1234' * 2, ObjectId()) + def test_no_bulk_read_structure(self, version_guid): + # Reading a structure when no bulk operation is active should just call + # through to the db_connection + result = self.bulk.get_structure(self.course_key, version_guid) + self.assertConnCalls(call.get_structure(self.course_key.as_object_id(version_guid))) + self.assertEqual(result, self.conn.get_structure.return_value) + self.assertCacheNotCleared() + + def test_no_bulk_write_structure(self): + # Writing a structure when no bulk operation is active should just + # call through to the db_connection. It should also clear the + # system cache + self.bulk.update_structure(self.course_key, self.structure) + self.assertConnCalls(call.upsert_structure(self.structure)) + self.clear_cache.assert_called_once_with(self.structure['_id']) + + @ddt.data(True, False) + def test_no_bulk_read_index(self, ignore_case): + # Reading a course index when no bulk operation is active should just call + # through to the db_connection + result = self.bulk.get_course_index(self.course_key, ignore_case=ignore_case) + self.assertConnCalls(call.get_course_index(self.course_key, ignore_case)) + self.assertEqual(result, self.conn.get_course_index.return_value) + self.assertCacheNotCleared() + + def test_no_bulk_write_index(self): + # Writing a course index when no bulk operation is active should just call + # through to the db_connection + self.bulk.insert_course_index(self.course_key, self.index_entry) + self.assertConnCalls(call.insert_course_index(self.index_entry)) + self.assertCacheNotCleared() + + def test_out_of_order_end(self): + # Calling _end_bulk_operation without a corresponding _begin... + # is a noop + self.bulk._end_bulk_operation(self.course_key) + + def test_write_new_index_on_close(self): + self.conn.get_course_index.return_value = None + self.bulk._begin_bulk_operation(self.course_key) + self.conn.reset_mock() + self.bulk.insert_course_index(self.course_key, self.index_entry) + self.assertConnCalls() + self.bulk._end_bulk_operation(self.course_key) + self.conn.insert_course_index.assert_called_once_with(self.index_entry) + + def test_write_updated_index_on_close(self): + old_index = {'this': 'is', 'an': 'old index'} + self.conn.get_course_index.return_value = old_index + self.bulk._begin_bulk_operation(self.course_key) + self.conn.reset_mock() + self.bulk.insert_course_index(self.course_key, self.index_entry) + self.assertConnCalls() + self.bulk._end_bulk_operation(self.course_key) + self.conn.update_course_index.assert_called_once_with(self.index_entry, from_index=old_index) + + def test_write_structure_on_close(self): + self.conn.get_course_index.return_value = None + self.bulk._begin_bulk_operation(self.course_key) + self.conn.reset_mock() + self.bulk.update_structure(self.course_key, self.structure) + self.assertConnCalls() + self.bulk._end_bulk_operation(self.course_key) + self.assertConnCalls(call.upsert_structure(self.structure)) + + def test_write_multiple_structures_on_close(self): + self.conn.get_course_index.return_value = None + self.bulk._begin_bulk_operation(self.course_key) + self.conn.reset_mock() + self.bulk.update_structure(self.course_key.replace(branch='a'), self.structure) + other_structure = {'another': 'structure', '_id': ObjectId()} + self.bulk.update_structure(self.course_key.replace(branch='b'), other_structure) + self.assertConnCalls() + self.bulk._end_bulk_operation(self.course_key) + self.assertItemsEqual( + [call.upsert_structure(self.structure), call.upsert_structure(other_structure)], + self.conn.mock_calls + ) + + def test_write_index_and_structure_on_close(self): + original_index = {'versions': {}} + self.conn.get_course_index.return_value = copy.deepcopy(original_index) + self.bulk._begin_bulk_operation(self.course_key) + self.conn.reset_mock() + self.bulk.update_structure(self.course_key, self.structure) + self.bulk.insert_course_index(self.course_key, {'versions': {self.course_key.branch: self.structure['_id']}}) + self.assertConnCalls() + self.bulk._end_bulk_operation(self.course_key) + self.assertConnCalls( + call.upsert_structure(self.structure), + call.update_course_index( + {'versions': {self.course_key.branch: self.structure['_id']}}, + from_index=original_index + ) + ) + + def test_write_index_and_multiple_structures_on_close(self): + original_index = {'versions': {'a': ObjectId(), 'b': ObjectId()}} + self.conn.get_course_index.return_value = copy.deepcopy(original_index) + self.bulk._begin_bulk_operation(self.course_key) + self.conn.reset_mock() + self.bulk.update_structure(self.course_key.replace(branch='a'), self.structure) + other_structure = {'another': 'structure', '_id': ObjectId()} + self.bulk.update_structure(self.course_key.replace(branch='b'), other_structure) + self.bulk.insert_course_index(self.course_key, {'versions': {'a': self.structure['_id'], 'b': other_structure['_id']}}) + self.bulk._end_bulk_operation(self.course_key) + self.assertItemsEqual( + [ + call.upsert_structure(self.structure), + call.upsert_structure(other_structure), + call.update_course_index( + {'versions': {'a': self.structure['_id'], 'b': other_structure['_id']}}, + from_index=original_index + ) + ], + self.conn.mock_calls + ) + + def test_version_structure_creates_new_version(self): + self.assertNotEquals( + self.bulk.version_structure(self.course_key, self.structure, 'user_id')['_id'], + self.structure['_id'] + ) + +class TestBulkWriteMixinClosedAfterPrevTransaction(TestBulkWriteMixinClosed, TestBulkWriteMixinPreviousTransaction): + """ + Test that operations on with a closed transaction aren't affected by a previously executed transaction + """ + pass + + +@ddt.ddt +class TestBulkWriteMixinFindMethods(TestBulkWriteMixin): + """ + Tests of BulkWriteMixin methods for finding many structures or indexes + """ + def test_no_bulk_find_matching_course_indexes(self): + branch = Mock(name='branch') + search_targets = MagicMock(name='search_targets') + self.conn.find_matching_course_indexes.return_value = [Mock(name='result')] + result = self.bulk.find_matching_course_indexes(branch, search_targets) + self.assertConnCalls(call.find_matching_course_indexes(branch, search_targets)) + self.assertEqual(result, self.conn.find_matching_course_indexes.return_value) + self.assertCacheNotCleared() + + @ddt.data( + (None, None, [], []), + ( + 'draft', + None, + [{'versions': {'draft': '123'}}], + [ + {'versions': {'published': '123'}}, + {} + ], + ), + ( + 'draft', + {'f1': 'v1'}, + [{'versions': {'draft': '123'}, 'search_targets': {'f1': 'v1'}}], + [ + {'versions': {'draft': '123'}, 'search_targets': {'f1': 'value2'}}, + {'versions': {'published': '123'}, 'search_targets': {'f1': 'v1'}}, + {'search_targets': {'f1': 'v1'}}, + {'versions': {'draft': '123'}}, + ], + ), + ( + None, + {'f1': 'v1'}, + [ + {'versions': {'draft': '123'}, 'search_targets': {'f1': 'v1'}}, + {'versions': {'published': '123'}, 'search_targets': {'f1': 'v1'}}, + {'search_targets': {'f1': 'v1'}}, + ], + [ + {'versions': {'draft': '123'}, 'search_targets': {'f1': 'v2'}}, + {'versions': {'draft': '123'}, 'search_targets': {'f2': 'v1'}}, + {'versions': {'draft': '123'}}, + ], + ), + ( + None, + {'f1': 'v1', 'f2': 2}, + [ + {'search_targets': {'f1': 'v1', 'f2': 2}}, + {'search_targets': {'f1': 'v1', 'f2': 2}}, + ], + [ + {'versions': {'draft': '123'}, 'search_targets': {'f1': 'v1'}}, + {'search_targets': {'f1': 'v1'}}, + {'versions': {'draft': '123'}, 'search_targets': {'f1': 'v2'}}, + {'versions': {'draft': '123'}}, + ], + ), + ) + @ddt.unpack + def test_find_matching_course_indexes(self, branch, search_targets, matching, unmatching): + db_indexes = [Mock(name='from_db')] + for n, index in enumerate(matching + unmatching): + course_key = CourseLocator('org', 'course', 'run{}'.format(n)) + self.bulk._begin_bulk_operation(course_key) + self.bulk.insert_course_index(course_key, index) + + expected = matching + db_indexes + self.conn.find_matching_course_indexes.return_value = db_indexes + result = self.bulk.find_matching_course_indexes(branch, search_targets) + self.assertItemsEqual(result, expected) + for item in unmatching: + self.assertNotIn(item, result) + + def test_no_bulk_find_structures_by_id(self): + ids = [Mock(name='id')] + self.conn.find_structures_by_id.return_value = [MagicMock(name='result')] + result = self.bulk.find_structures_by_id(ids) + self.assertConnCalls(call.find_structures_by_id(ids)) + self.assertEqual(result, self.conn.find_structures_by_id.return_value) + self.assertCacheNotCleared() + + @ddt.data( + ([], [], []), + ([1, 2, 3], [1, 2], [1, 2]), + ([1, 2, 3], [1], [1, 2]), + ([1, 2, 3], [], [1, 2]), + ) + @ddt.unpack + def test_find_structures_by_id(self, search_ids, active_ids, db_ids): + db_structure = lambda _id: {'db': 'structure', '_id': _id} + active_structure = lambda _id: {'active': 'structure', '_id': _id} + + db_structures = [db_structure(_id) for _id in db_ids if _id not in active_ids] + for n, _id in enumerate(active_ids): + course_key = CourseLocator('org', 'course', 'run{}'.format(n)) + self.bulk._begin_bulk_operation(course_key) + self.bulk.update_structure(course_key, active_structure(_id)) + + self.conn.find_structures_by_id.return_value = db_structures + results = self.bulk.find_structures_by_id(search_ids) + self.conn.find_structures_by_id.assert_called_once_with(list(set(search_ids) - set(active_ids))) + for _id in active_ids: + if _id in search_ids: + self.assertIn(active_structure(_id), results) + else: + self.assertNotIn(active_structure(_id), results) + for _id in db_ids: + if _id in search_ids and _id not in active_ids: + self.assertIn(db_structure(_id), results) + else: + self.assertNotIn(db_structure(_id), results) + + def test_no_bulk_find_structures_derived_from(self): + ids = [Mock(name='id')] + self.conn.find_structures_derived_from.return_value = [MagicMock(name='result')] + result = self.bulk.find_structures_derived_from(ids) + self.assertConnCalls(call.find_structures_derived_from(ids)) + self.assertEqual(result, self.conn.find_structures_derived_from.return_value) + self.assertCacheNotCleared() + + @ddt.data( + # Test values are: + # - previous_versions to search for + # - documents in the cache with $previous_version.$_id + # - documents in the db with $previous_version.$_id + ([], [], []), + (['1', '2', '3'], ['1.a', '1.b', '2.c'], ['1.a', '2.c']), + (['1', '2', '3'], ['1.a'], ['1.a', '2.c']), + (['1', '2', '3'], [], ['1.a', '2.c']), + (['1', '2', '3'], ['4.d'], ['1.a', '2.c']), + ) + @ddt.unpack + def test_find_structures_derived_from(self, search_ids, active_ids, db_ids): + def db_structure(_id): + previous, _, current = _id.partition('.') + return {'db': 'structure', 'previous_version': previous, '_id': current} + def active_structure(_id): + previous, _, current = _id.partition('.') + return {'active': 'structure', 'previous_version': previous, '_id': current} + + db_structures = [db_structure(_id) for _id in db_ids] + active_structures = [] + for n, _id in enumerate(active_ids): + course_key = CourseLocator('org', 'course', 'run{}'.format(n)) + self.bulk._begin_bulk_operation(course_key) + structure = active_structure(_id) + self.bulk.update_structure(course_key, structure) + active_structures.append(structure) + + self.conn.find_structures_derived_from.return_value = db_structures + results = self.bulk.find_structures_derived_from(search_ids) + self.conn.find_structures_derived_from.assert_called_once_with(search_ids) + for structure in active_structures: + if structure['previous_version'] in search_ids: + self.assertIn(structure, results) + else: + self.assertNotIn(structure, results) + for structure in db_structures: + if ( + structure['previous_version'] in search_ids and # We're searching for this document + not any(active.endswith(structure['_id']) for active in active_ids) # This document doesn't match any active _ids + ): + self.assertIn(structure, results) + else: + self.assertNotIn(structure, results) + + def test_no_bulk_find_ancestor_structures(self): + original_version = Mock(name='original_version') + block_id = Mock(name='block_id') + self.conn.find_ancestor_structures.return_value = [MagicMock(name='result')] + result = self.bulk.find_ancestor_structures(original_version, block_id) + self.assertConnCalls(call.find_ancestor_structures(original_version, block_id)) + self.assertEqual(result, self.conn.find_ancestor_structures.return_value) + self.assertCacheNotCleared() + + @ddt.data( + # Test values are: + # - original_version + # - block_id + # - matching documents in the cache + # - non-matching documents in the cache + # - expected documents returned from the db + # - unexpected documents returned from the db + ('ov', 'bi', [{'original_version': 'ov', 'blocks': {'bi': {'edit_info': {'update_version': 'foo'}}}}], [], [], []), + ('ov', 'bi', [{'original_version': 'ov', 'blocks': {'bi': {'edit_info': {'update_version': 'foo'}}}, '_id': 'foo'}], [], [], [{'_id': 'foo'}]), + ('ov', 'bi', [], [{'blocks': {'bi': {'edit_info': {'update_version': 'foo'}}}}], [], []), + ('ov', 'bi', [], [{'original_version': 'ov'}], [], []), + ('ov', 'bi', [], [], [{'original_version': 'ov', 'blocks': {'bi': {'edit_info': {'update_version': 'foo'}}}}], []), + ( + 'ov', + 'bi', + [{'original_version': 'ov', 'blocks': {'bi': {'edit_info': {'update_version': 'foo'}}}}], + [], + [{'original_version': 'ov', 'blocks': {'bi': {'edit_info': {'update_version': 'bar'}}}}], + [] + ), + ) + @ddt.unpack + def test_find_ancestor_structures(self, original_version, block_id, active_match, active_unmatch, db_match, db_unmatch): + for structure in active_match + active_unmatch + db_match + db_unmatch: + structure.setdefault('_id', ObjectId()) + + for n, structure in enumerate(active_match + active_unmatch): + course_key = CourseLocator('org', 'course', 'run{}'.format(n)) + self.bulk._begin_bulk_operation(course_key) + self.bulk.update_structure(course_key, structure) + + self.conn.find_ancestor_structures.return_value = db_match + db_unmatch + results = self.bulk.find_ancestor_structures(original_version, block_id) + self.conn.find_ancestor_structures.assert_called_once_with(original_version, block_id) + self.assertItemsEqual(active_match + db_match, results) + +@ddt.ddt +class TestBulkWriteMixinOpen(TestBulkWriteMixin): + """ + Tests of the bulk write mixin when bulk write operations are open + """ + def setUp(self): + super(TestBulkWriteMixinOpen, self).setUp() + self.bulk._begin_bulk_operation(self.course_key) + + @ddt.data('deadbeef1234' * 2, u'deadbeef1234' * 2, ObjectId()) + def test_read_structure_without_write_from_db(self, version_guid): + # Reading a structure before it's been written (while in bulk operation mode) + # returns the structure from the database + result = self.bulk.get_structure(self.course_key, version_guid) + self.assertEquals(self.conn.get_structure.call_count, 1) + self.assertEqual(result, self.conn.get_structure.return_value) + self.assertCacheNotCleared() + + @ddt.data('deadbeef1234' * 2, u'deadbeef1234' * 2, ObjectId()) + def test_read_structure_without_write_only_reads_once(self, version_guid): + # Reading the same structure multiple times shouldn't hit the database + # more than once + for _ in xrange(2): + result = self.bulk.get_structure(self.course_key, version_guid) + self.assertEquals(self.conn.get_structure.call_count, 1) + self.assertEqual(result, self.conn.get_structure.return_value) + self.assertCacheNotCleared() + + @ddt.data('deadbeef1234' * 2, u'deadbeef1234' * 2, ObjectId()) + def test_read_structure_after_write_no_db(self, version_guid): + # Reading a structure that's already been written shouldn't hit the db at all + self.structure['_id'] = version_guid + self.bulk.update_structure(self.course_key, self.structure) + result = self.bulk.get_structure(self.course_key, version_guid) + self.assertEquals(self.conn.get_structure.call_count, 0) + self.assertEqual(result, self.structure) + + @ddt.data('deadbeef1234' * 2, u'deadbeef1234' * 2, ObjectId()) + def test_read_structure_after_write_after_read(self, version_guid): + # Reading a structure that's been updated after being pulled from the db should + # still get the updated value + self.structure['_id'] = version_guid + self.bulk.get_structure(self.course_key, version_guid) + self.bulk.update_structure(self.course_key, self.structure) + result = self.bulk.get_structure(self.course_key, version_guid) + self.assertEquals(self.conn.get_structure.call_count, 1) + self.assertEqual(result, self.structure) + + @ddt.data(True, False) + def test_read_index_without_write_from_db(self, ignore_case): + # Reading the index without writing to it should pull from the database + result = self.bulk.get_course_index(self.course_key, ignore_case=ignore_case) + self.assertEquals(self.conn.get_course_index.call_count, 1) + self.assertEquals(self.conn.get_course_index.return_value, result) + + @ddt.data(True, False) + def test_read_index_without_write_only_reads_once(self, ignore_case): + # Reading the index multiple times should only result in one read from + # the database + for _ in xrange(2): + result = self.bulk.get_course_index(self.course_key, ignore_case=ignore_case) + self.assertEquals(self.conn.get_course_index.call_count, 1) + self.assertEquals(self.conn.get_course_index.return_value, result) + + @ddt.data(True, False) + def test_read_index_after_write(self, ignore_case): + # Reading the index after a write still should hit the database once to fetch the + # initial index, and should return the written index_entry + self.bulk.insert_course_index(self.course_key, self.index_entry) + result = self.bulk.get_course_index(self.course_key, ignore_case=ignore_case) + self.assertEquals(self.conn.get_course_index.call_count, 1) + self.assertEquals(self.index_entry, result) + + def test_read_index_ignore_case(self): + # Reading using ignore case should find an already written entry with a different case + self.bulk.insert_course_index(self.course_key, self.index_entry) + result = self.bulk.get_course_index( + self.course_key.replace( + org=self.course_key.org.upper(), + course=self.course_key.course.title(), + run=self.course_key.run.upper() + ), + ignore_case=True + ) + self.assertEquals(self.conn.get_course_index.call_count, 1) + self.assertEquals(self.index_entry, result) + + def test_version_structure_creates_new_version_before_read(self): + self.assertNotEquals( + self.bulk.version_structure(self.course_key, self.structure, 'user_id')['_id'], + self.structure['_id'] + ) + + def test_version_structure_creates_new_version_after_read(self): + self.conn.get_structure.return_value = copy.deepcopy(self.structure) + self.bulk.get_structure(self.course_key, self.structure['_id']) + self.assertNotEquals( + self.bulk.version_structure(self.course_key, self.structure, 'user_id')['_id'], + self.structure['_id'] + ) + + def test_copy_branch_versions(self): + # Directly updating an index so that the draft branch points to the published index + # version should work, and should only persist a single structure + self.maxDiff = None + published_structure = {'published': 'structure', '_id': ObjectId()} + self.bulk.update_structure(self.course_key, published_structure) + index = {'versions': {'published': published_structure['_id']}} + self.bulk.insert_course_index(self.course_key, index) + index_copy = copy.deepcopy(index) + index_copy['versions']['draft'] = index['versions']['published'] + self.bulk.update_course_index(self.course_key, index_copy) + self.bulk._end_bulk_operation(self.course_key) + self.conn.upsert_structure.assert_called_once_with(published_structure) + self.conn.update_course_index.assert_called_once_with(index_copy, from_index=self.conn.get_course_index.return_value) + self.conn.get_course_index.assert_called_once_with(self.course_key) + + +class TestBulkWriteMixinOpenAfterPrevTransaction(TestBulkWriteMixinOpen, TestBulkWriteMixinPreviousTransaction): + """ + Test that operations on with an open transaction aren't affected by a previously executed transaction + """ + pass diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 0de12cae7c..092fb08696 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -206,7 +206,7 @@ def import_from_xml( ) continue - with store.bulk_write_operations(dest_course_id): + with store.bulk_operations(dest_course_id): source_course = xml_module_store.get_course(course_key) # STEP 1: find and import course module course, course_data_path = _import_course_module( @@ -607,7 +607,7 @@ def _import_course_draft( _import_module(descriptor) except Exception: - logging.exception('There while importing draft descriptor %s', descriptor) + logging.exception('while importing draft descriptor %s', descriptor) def allowed_metadata_by_category(category): diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 2023dbd941..eec7aa7265 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -287,9 +287,9 @@ class CourseComparisonTest(unittest.TestCase): self.assertEqual(expected_item.has_children, actual_item.has_children) if expected_item.has_children: expected_children = [ - (course1_item_child.location.block_type, course1_item_child.location.block_id) + (expected_item_child.location.block_type, expected_item_child.location.block_id) # get_children() rather than children to strip privates from public parents - for course1_item_child in expected_item.get_children() + for expected_item_child in expected_item.get_children() ] actual_children = [ (item_child.location.block_type, item_child.location.block_id) diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index da45edf731..b7bb361a57 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -21,6 +21,7 @@ from django.contrib.auth.models import User from xblock.runtime import KeyValueStore from xblock.exceptions import KeyValueMultiSaveError, InvalidScopeError from xblock.fields import Scope, UserScope +from xmodule.modulestore.django import modulestore log = logging.getLogger(__name__) @@ -109,7 +110,8 @@ class FieldDataCache(object): return descriptors - descriptors = get_child_descriptors(descriptor, depth, descriptor_filter) + with modulestore().bulk_operations(descriptor.location.course_key): + descriptors = get_child_descriptors(descriptor, depth, descriptor_filter) return FieldDataCache(descriptors, course_id, user, select_for_update) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 8df1087a9e..e14a51b8ff 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -326,13 +326,15 @@ class TestTOC(ModuleStoreTestCase): self.request = factory.get(chapter_url) self.request.user = UserFactory() self.modulestore = self.store._get_modulestore_for_courseid(self.course_key) - with check_mongo_calls(self.modulestore, num_finds, num_sends): - self.toy_course = self.store.get_course(self.toy_loc, depth=2) + self.toy_course = self.store.get_course(self.toy_loc, depth=2) + with check_mongo_calls(num_finds, num_sends): self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents( - self.toy_loc, self.request.user, self.toy_course, depth=2) + self.toy_loc, self.request.user, self.toy_course, depth=2 + ) - @ddt.data((ModuleStoreEnum.Type.mongo, 3, 0), (ModuleStoreEnum.Type.split, 7, 0)) + # TODO: LMS-11220: Document why split find count is 21 + @ddt.data((ModuleStoreEnum.Type.mongo, 1, 0), (ModuleStoreEnum.Type.split, 5, 0)) @ddt.unpack def test_toc_toy_from_chapter(self, default_ms, num_finds, num_sends): with self.store.default_store(default_ms): @@ -352,14 +354,15 @@ class TestTOC(ModuleStoreTestCase): 'format': '', 'due': None, 'active': False}], 'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) - with check_mongo_calls(self.modulestore, 0, 0): + with check_mongo_calls(0, 0): actual = render.toc_for_course( self.request.user, self.request, self.toy_course, self.chapter, None, self.field_data_cache ) for toc_section in expected: self.assertIn(toc_section, actual) - @ddt.data((ModuleStoreEnum.Type.mongo, 3, 0), (ModuleStoreEnum.Type.split, 7, 0)) + # TODO: LMS-11220: Document why split find count is 21 + @ddt.data((ModuleStoreEnum.Type.mongo, 1, 0), (ModuleStoreEnum.Type.split, 5, 0)) @ddt.unpack def test_toc_toy_from_section(self, default_ms, num_finds, num_sends): with self.store.default_store(default_ms): diff --git a/pavelib/tests.py b/pavelib/tests.py index cf392bdcac..38a21e1f61 100644 --- a/pavelib/tests.py +++ b/pavelib/tests.py @@ -91,7 +91,10 @@ def test_lib(options): } if test_id: - lib = '/'.join(test_id.split('/')[0:3]) + if '/' in test_id: + lib = '/'.join(test_id.split('/')[0:3]) + else: + lib = 'common/lib/' + test_id.split('.')[0] opts['test_id'] = test_id lib_tests = [suites.LibTestSuite(lib, **opts)] else: diff --git a/requirements/edx/paver.txt b/requirements/edx/paver.txt index 489f3a844e..2d110fc9dd 100644 --- a/requirements/edx/paver.txt +++ b/requirements/edx/paver.txt @@ -4,3 +4,4 @@ psutil==1.2.1 lazy==1.1 path.py==3.0.1 watchdog==0.7.1 +python-memcached