Files
edx-platform/xmodule/modulestore/split_mongo/runtime.py
Kyle McCormick 834cb9482d refactor: rename ModuleStore runtimes now that XModules are gone (#35523)
* Consolidates and renames the runtime used as a base for all the others:
  * Before: `xmodule.x_module:DescriptorSystem` and
            `xmodule.mako_block:MakoDescriptorSystem`.
  * After:  `xmodule.x_module:ModuleStoreRuntime`.

* Co-locates and renames the runtimes for importing course OLX:
  * Before: `xmodule.x_module:XMLParsingSystem` and
            `xmodule.modulestore.xml:ImportSystem`.
  * After:  `xmodule.modulestore.xml:XMLParsingModuleStoreRuntime` and
            `xmodule.modulestore.xml:XMLImportingModuleStoreRuntime`.
  * Note: I would have liked to consolidate these, but it would have
          involved nontrivial test refactoring.

* Renames the stub Old Mongo runtime:
  * Before: `xmodule.modulestore.mongo.base:CachingDescriptorSystem`.
  * After: `xmodule.modulestore.mongo.base:OldModuleStoreRuntime`.

* Renames the Split Mongo runtime, the which is what runs courses in LMS and CMS:
  * Before: `xmodule.modulestore.split_mongo.caching_descriptor_system:CachingDescriptorSystem`.
  * After: `xmodule.modulestore.split_mongo.runtime:SplitModuleStoreRuntime`.

* Renames some of the dummy runtimes used only in unit tests.
2025-10-29 15:46:07 -04:00

427 lines
19 KiB
Python

# lint-amnesty, pylint: disable=missing-module-docstring
import logging
import sys
import weakref
from crum import get_current_user
from fs.osfs import OSFS
from lazy import lazy
from opaque_keys.edx.locator import BlockUsageLocator, DefinitionLocator, LocalId
from xblock.fields import ScopeIds
from xblock.runtime import KeyValueStore, KvsFieldData
from xmodule.error_block import ErrorBlock
from xmodule.errortracker import exc_info_to_str
from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.modulestore import BlockData
from xmodule.modulestore.edit_info import EditInfoRuntimeMixin
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import InheritanceMixin, inheriting_field_data
from xmodule.modulestore.split_mongo import BlockKey, CourseEnvelope
from xmodule.modulestore.split_mongo.definition_lazy_loader import DefinitionLazyLoader
from xmodule.modulestore.split_mongo.id_manager import SplitMongoIdManager
from xmodule.modulestore.split_mongo.split_mongo_kvs import SplitMongoKVS
from xmodule.util.misc import get_library_or_course_attribute
from xmodule.x_module import XModuleMixin, ModuleStoreRuntime
log = logging.getLogger(__name__)
class SplitModuleStoreRuntime(ModuleStoreRuntime, EditInfoRuntimeMixin): # pylint: disable=abstract-method
"""
A system that has a cache of a course version's json that it will use to load blocks
from, with a backup of calling to the underlying modulestore for more data.
Computes the settings (nee 'metadata') inheritance upon creation.
"""
def __init__(self, modulestore, course_entry, default_class, module_data, lazy, **kwargs): # lint-amnesty, pylint: disable=redefined-outer-name
"""
Computes the settings inheritance and sets up the cache.
modulestore: the module store that can be used to retrieve additional blocks
course_entry: the originally fetched enveloped course_structure w/ branch and course id info.
Callers to _load_item provide an override but that function ignores the provided structure and
only looks at the branch and course id
module_data: a dict mapping Location -> json that was cached from the
underlying modulestore
"""
# needed by capa_problem (as runtime.resources_fs via this.resources_fs)
course_library = get_library_or_course_attribute(course_entry.course_key)
if course_library:
root = modulestore.fs_root / course_entry.course_key.org / course_library / course_entry.course_key.run # lint-amnesty, pylint: disable=line-too-long
else:
root = modulestore.fs_root / str(course_entry.structure['_id'])
root.makedirs_p() # create directory if it doesn't exist
id_manager = SplitMongoIdManager(self)
kwargs.setdefault('id_reader', id_manager)
kwargs.setdefault('id_generator', id_manager)
super().__init__(
load_item=self._load_item,
resources_fs=OSFS(root),
**kwargs
)
self.modulestore = modulestore
self.course_entry = course_entry
# set course_id attribute to avoid problems with subsystems that expect
# it here. (grading, for example)
self.course_id = course_entry.course_key
self.lazy = lazy
self.module_data = module_data
self.default_class = default_class
self.local_modules = {}
user = get_current_user()
user_id = user.id if user else None
self._services['library_tools'] = LegacyLibraryToolsService(modulestore, user_id=user_id)
# Cache of block field datas, keyed by the XBlock instance (since the ScopeId changes!)
self.block_field_datas = weakref.WeakKeyDictionary()
@lazy
def _parent_map(self): # lint-amnesty, pylint: disable=missing-function-docstring
parent_map = {}
for block_key, block in self.course_entry.structure['blocks'].items():
for child in block.fields.get('children', []):
parent_map[child] = block_key
return parent_map
def _load_item(self, usage_key, course_entry_override=None, **kwargs):
"""
Instantiate the xblock fetching it either from the cache or from the structure
:param course_entry_override: the course_info with the course_key to use (defaults to cached)
"""
# usage_key is either a UsageKey or just the block_key. if a usage_key,
if isinstance(usage_key, BlockUsageLocator):
# trust the passed in key to know the caller's expectations of which fields are filled in.
# particularly useful for strip_keys so may go away when we're version aware
course_key = usage_key.course_key
if isinstance(usage_key.block_id, LocalId):
try:
return self.local_modules[usage_key]
except KeyError:
raise ItemNotFoundError # lint-amnesty, pylint: disable=raise-missing-from
else:
block_key = BlockKey.from_usage_key(usage_key)
version_guid = self.course_entry.course_key.version_guid
else:
block_key = usage_key
course_info = course_entry_override or self.course_entry
course_key = course_info.course_key
version_guid = course_key.version_guid
# look in cache
cached_block = self.modulestore.get_cached_block(course_key, version_guid, block_key)
if cached_block:
return cached_block
block_data = self.get_module_data(block_key, course_key)
class_ = self.load_block_type(block_data.block_type)
block = self.xblock_from_json(class_, course_key, block_key, block_data, course_entry_override, **kwargs)
# TODO Once TNL-5092 is implemented, we can expose the course version
# information within the key identifier of the block. Until then, set
# the course_version as a field on the returned block so higher layers
# can use it when needed.
block.course_version = version_guid
self.modulestore.cache_block(course_key, version_guid, block_key, block)
return block
def get_module_data(self, block_key, course_key):
"""
Get block from module_data adding it to module_data if it's not already there but is in the structure
Raises:
ItemNotFoundError if block is not in the structure
"""
json_data = self.module_data.get(block_key)
if json_data is None:
# deeper than initial descendant fetch or doesn't exist
self.modulestore.cache_items(self, [block_key], course_key, lazy=self.lazy)
json_data = self.module_data.get(block_key)
if json_data is None:
raise ItemNotFoundError(block_key)
return json_data
# xblock's runtime does not always pass enough contextual information to figure out
# which named container (course x branch) or which parent is requesting an item. Because split allows
# a many:1 mapping from named containers to structures and because item's identities encode
# context as well as unique identity, this function must sometimes infer whether the access is
# within an unspecified named container. In most cases, course_entry_override will give the
# explicit context; however, runtime.get_block(), e.g., does not. HOWEVER, there are simple heuristics
# which will work 99.999% of the time: a runtime is thread & even context specific. The likelihood that
# the thread is working with more than one named container pointing to the same specific structure is
# low; thus, the course_entry is most likely correct. If the thread is looking at > 1 named container
# pointing to the same structure, the access is likely to be chunky enough that the last known container
# is the intended one when not given a course_entry_override; thus, the caching of the last branch/course id.
def xblock_from_json(self, class_, course_key, block_key, block_data, course_entry_override=None, **kwargs):
"""
Instantiate an XBlock of the specified class, using the provided data.
"""
if course_entry_override is None:
course_entry_override = self.course_entry
else:
# most recent retrieval is most likely the right one for next caller (see comment above fn)
self.course_entry = CourseEnvelope(course_entry_override.course_key, self.course_entry.structure)
# If no usage id is provided, generate an in-memory id
if block_key is None:
block_key = BlockKey(block_data.block_type, LocalId())
# Construct the Block Usage Locator:
block_locator = course_key.make_usage_key(block_type=block_key.type, block_id=block_key.id)
# If no definition id is provided, generate an in-memory id
definition_id = block_data.definition or LocalId()
try:
block = self.construct_xblock_from_class(
class_,
ScopeIds(None, block_key.type, definition_id, block_locator),
for_parent=kwargs.get('for_parent'),
# Pass this tuple on for use by _init_field_data_for_block() when field data is initialized.
cds_init_args=(block_data, definition_id, kwargs.get("field_decorator")),
# Passing field_data here is deprecated, so we don't. Get it via block.service(block, "field-data").
# https://github.com/openedx/XBlock/blob/e89cbc5/xblock/mixins.py#L200-L207
)
except Exception: # pylint: disable=broad-except
log.warning("Failed to load descriptor", exc_info=True)
return ErrorBlock.from_json(
block_data,
self,
course_entry_override.course_key.make_usage_key(
block_type='error',
block_id=block_key.id
),
error_msg=exc_info_to_str(sys.exc_info())
)
edit_info = block_data.edit_info
block._edited_by = edit_info.edited_by # pylint: disable=protected-access
block._edited_on = edit_info.edited_on # pylint: disable=protected-access
block.previous_version = edit_info.previous_version
block.update_version = edit_info.update_version
block.source_version = edit_info.source_version
block.definition_locator = DefinitionLocator(block_key.type, definition_id)
# If this is an in-memory block, store it in this system
if isinstance(block_locator.block_id, LocalId):
self.local_modules[block_locator] = block
return block
def service(self, block, service_name):
"""
Return a service, or None.
Services are objects implementing arbitrary other interfaces.
"""
# Implement field data service:
if service_name in ('field-data', 'field-data-unbound'):
if hasattr(block, "_bound_field_data") and service_name != "field-data-unbound":
# Return the user-specific wrapped field data that gets set onto the block during XModule.bind_for_user
# This complexity is due to XModule heritage. If we didn't load the block as one step, then sometimes
# "rebind" it to be user-specific as a later step, we could load the field data and overrides at once.
return block._bound_field_data # pylint: disable=protected-access
if block not in self.block_field_datas:
try:
self._init_field_data_for_block(block)
except:
# Don't try again pointlessly every time another field is accessed
self.block_field_datas[block] = None
raise
return self.block_field_datas[block]
return super().service(block, service_name)
def _init_field_data_for_block(self, block):
"""
Initialize the field-data service for the given block.
"""
block_key = BlockKey.from_usage_key(block.scope_ids.usage_id)
course_key = block.scope_ids.usage_id.context_key
try:
(block_data, definition_id, field_decorator) = block.get_cds_init_args()
except KeyError:
# This block was not instantiate via xblock_from_json()/modulestore but rather via the "direct" XBlock API
# such as using construct_xblock_from_class(). It probably doesn't yet exist in modulestore.
block_data = BlockData()
definition_id = LocalId()
field_decorator = None
class_ = self.load_block_type(block.scope_ids.block_type)
convert_fields = lambda field: self.modulestore.convert_references_to_keys(
course_key, class_, field, self.course_entry.structure['blocks'],
)
converted_fields = convert_fields(block_data.fields)
converted_defaults = convert_fields(block_data.defaults)
if block_key in self._parent_map:
parent_key = self._parent_map[block_key]
parent = course_key.make_usage_key(parent_key.type, parent_key.id)
else:
parent = None
aside_fields = None
# for the situation if block_data has no asides attribute
# (in case it was taken from memcache)
try:
if block_data.asides:
aside_fields = {block_key.type: {}}
for aside in block_data.asides:
aside_fields[block_key.type].update(aside['fields'])
except AttributeError:
pass
if not isinstance(definition_id, LocalId) and not block_data.definition_loaded:
definition_loader = DefinitionLazyLoader(
self.modulestore,
course_key,
block_key.type,
definition_id,
convert_fields,
)
else:
definition_loader = None
kvs = SplitMongoKVS(
definition_loader,
converted_fields,
converted_defaults,
parent=parent,
aside_fields=aside_fields,
field_decorator=field_decorator,
)
if InheritanceMixin in self.modulestore.xblock_mixins:
field_data = inheriting_field_data(kvs)
else:
field_data = KvsFieldData(kvs)
# Save the field_data now, because some of the wrappers will try to read fields from the block while they
# determine if they want to wrap the field_data or not.
self.block_field_datas[block] = field_data
# Now add in any wrappers that want to be added:
for wrapper in self.modulestore.xblock_field_data_wrappers:
self.block_field_datas[block] = wrapper(block, self.block_field_datas[block])
# Note: user-specific wrappers like LmsFieldData or OverrideFieldData do not get set here. They get set later
# during bind_for_student(), which wraps the field-data and sets block._bound_field_data. Calling
# runtime.service(block, "field-data") or block._field_data (deprecated) will load block._bound_field_data if
# the block has been bound to a user, otherwise it returns the wrapped field data we just created above.
def get_edited_by(self, xblock):
"""
See :meth: cms.lib.xblock.runtime.EditInfoRuntimeMixin.get_edited_by
"""
return xblock._edited_by # lint-amnesty, pylint: disable=protected-access
def get_edited_on(self, xblock):
"""
See :class: cms.lib.xblock.runtime.EditInfoRuntimeMixin
"""
return xblock._edited_on # lint-amnesty, pylint: disable=protected-access
def get_subtree_edited_by(self, xblock):
"""
See :class: cms.lib.xblock.runtime.EditInfoRuntimeMixin
"""
# pylint: disable=protected-access
if not hasattr(xblock, '_subtree_edited_by'):
block_data = self.module_data[BlockKey.from_usage_key(xblock.location)]
if block_data.edit_info._subtree_edited_by is None:
self._compute_subtree_edited_internal(
block_data, xblock.location.course_key
)
xblock._subtree_edited_by = block_data.edit_info._subtree_edited_by
return xblock._subtree_edited_by
def get_subtree_edited_on(self, xblock):
"""
See :class: cms.lib.xblock.runtime.EditInfoRuntimeMixin
"""
# pylint: disable=protected-access
if not hasattr(xblock, '_subtree_edited_on'):
block_data = self.module_data[BlockKey.from_usage_key(xblock.location)]
if block_data.edit_info._subtree_edited_on is None:
self._compute_subtree_edited_internal(
block_data, xblock.location.course_key
)
xblock._subtree_edited_on = block_data.edit_info._subtree_edited_on
return xblock._subtree_edited_on
def get_published_by(self, xblock):
"""
See :class: cms.lib.xblock.runtime.EditInfoRuntimeMixin
"""
if not hasattr(xblock, '_published_by'):
self.modulestore.compute_published_info_internal(xblock)
return getattr(xblock, '_published_by', None)
def get_published_on(self, xblock):
"""
See :class: cms.lib.xblock.runtime.EditInfoRuntimeMixin
"""
if not hasattr(xblock, '_published_on'):
self.modulestore.compute_published_info_internal(xblock)
return getattr(xblock, '_published_on', None)
def _compute_subtree_edited_internal(self, block_data, course_key):
"""
Recurse the subtree finding the max edited_on date and its corresponding edited_by. Cache it.
"""
# pylint: disable=protected-access
max_date = block_data.edit_info.edited_on
max_date_by = block_data.edit_info.edited_by
for child in block_data.fields.get('children', []):
child_data = self.get_module_data(BlockKey(*child), course_key)
if block_data.edit_info._subtree_edited_on is None:
self._compute_subtree_edited_internal(child_data, course_key)
if child_data.edit_info._subtree_edited_on > max_date:
max_date = child_data.edit_info._subtree_edited_on
max_date_by = child_data.edit_info._subtree_edited_by
block_data.edit_info._subtree_edited_on = max_date
block_data.edit_info._subtree_edited_by = max_date_by
def get_aside_of_type(self, block, aside_type):
"""
See `runtime.Runtime.get_aside_of_type`
This override adds the field data from the block to the aside
"""
asides_cached = block.get_asides() if isinstance(block, XModuleMixin) else None
if asides_cached:
for aside in asides_cached:
if aside.scope_ids.block_type == aside_type:
return aside
new_aside = super().get_aside_of_type(block, aside_type)
new_aside._field_data = block._field_data # pylint: disable=protected-access
for key, _ in new_aside.fields.items():
if isinstance(key, KeyValueStore.Key) and block._field_data.has(new_aside, key): # pylint: disable=protected-access
try:
value = block._field_data.get(new_aside, key) # pylint: disable=protected-access
except KeyError:
pass
else:
setattr(new_aside, key, value)
block.add_aside(new_aside)
return new_aside