Skip to content

Commit

Permalink
Merge pull request #8651 from edx/feanil/hotfix_commits_for_rc
Browse files Browse the repository at this point in the history
Hotfix for performance of course structures
  • Loading branch information
nasthagiri committed Jun 24, 2015
2 parents e7cbcff + b1346fc commit 66e4e28
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 10 deletions.
5 changes: 4 additions & 1 deletion cms/envs/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'edx_location_mem_cache',
},

'course_structure_cache': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'edx_course_structure_mem_cache',
},
}

# Make the keyedcache startup warnings go away
Expand Down
4 changes: 3 additions & 1 deletion cms/envs/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'edx_location_mem_cache',
},

'course_structure_cache': {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
},
}

# Add external_auth to Installed apps for testing
Expand Down
26 changes: 26 additions & 0 deletions common/lib/xmodule/xmodule/modulestore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,19 @@ def __repr__(self):
source_version="UNSET" if self.source_version is None else self.source_version,
) # pylint: disable=bad-continuation

def __eq__(self, edit_info):
"""
Two EditInfo instances are equal iff their storable representations
are equal.
"""
return self.to_storable() == edit_info.to_storable()

def __neq__(self, edit_info):
"""
Two EditInfo instances are not equal if they're not equal.
"""
return not self == edit_info


class BlockData(object):
"""
Expand Down Expand Up @@ -426,6 +439,19 @@ def __repr__(self):
classname=self.__class__.__name__,
) # pylint: disable=bad-continuation

def __eq__(self, block_data):
"""
Two BlockData objects are equal iff all their attributes are equal.
"""
attrs = ['fields', 'block_type', 'definition', 'defaults', 'edit_info']
return all(getattr(self, attr) == getattr(block_data, attr) for attr in attrs)

def __neq__(self, block_data):
"""
Just define this as not self.__eq__(block_data)
"""
return not self == block_data


new_contract('BlockData', BlockData)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
Segregation of pymongo functions from the data modeling mechanisms for split modulestore.
"""
import datetime
import cPickle as pickle
import math
import zlib
import pymongo
import pytz
import re
Expand All @@ -11,6 +13,8 @@

# Import this just to export it
from pymongo.errors import DuplicateKeyError # pylint: disable=unused-import
from django.core.cache import get_cache, InvalidCacheBackendError
import dogstats_wrapper as dog_stats_api

from contracts import check, new_contract
from mongodb_proxy import autoretry_read, MongoProxy
Expand Down Expand Up @@ -203,6 +207,50 @@ def structure_to_mongo(structure, course_context=None):
return new_structure


class CourseStructureCache(object):
"""
Wrapper around django cache object to cache course structure objects.
The course structures are pickled and compressed when cached.
If the 'course_structure_cache' doesn't exist, then don't do anything for
for set and get.
"""
def __init__(self):
self.no_cache_found = False
try:
self.cache = get_cache('course_structure_cache')
except InvalidCacheBackendError:
self.no_cache_found = True

def get(self, key):
"""Pull the compressed, pickled struct data from cache and deserialize."""
if self.no_cache_found:
return None

compressed_pickled_data = self.cache.get(key)
if compressed_pickled_data is None:
return None
return pickle.loads(zlib.decompress(compressed_pickled_data))

def set(self, key, structure):
"""Given a structure, will pickle, compress, and write to cache."""
if self.no_cache_found:
return None

pickled_data = pickle.dumps(structure, pickle.HIGHEST_PROTOCOL)
# 1 = Fastest (slightly larger results)
compressed_pickled_data = zlib.compress(pickled_data, 1)

# record compressed course structure sizes
dog_stats_api.histogram(
'compressed_course_structure.size',
len(compressed_pickled_data),
tags=[key]
)
# Stuctures are immutable, so we set a timeout of "never"
self.cache.set(key, compressed_pickled_data, None)


class MongoConnection(object):
"""
Segregation of pymongo functions from the data modeling mechanisms for split modulestore.
Expand Down Expand Up @@ -256,15 +304,23 @@ def heartbeat(self):

def get_structure(self, key, course_context=None):
"""
Get the structure from the persistence mechanism whose id is the given key
Get the structure from the persistence mechanism whose id is the given key.
This method will use a cached version of the structure if it is availble.
"""
with TIMER.timer("get_structure", course_context) as tagger_get_structure:
with TIMER.timer("get_structure.find_one", course_context) as tagger_find_one:
doc = self.structures.find_one({'_id': key})
tagger_find_one.measure("blocks", len(doc['blocks']))
tagger_get_structure.measure("blocks", len(doc['blocks']))

return structure_from_mongo(doc, course_context)
cache = CourseStructureCache()

structure = cache.get(key)
tagger_get_structure.tag(from_cache='true' if structure else 'false')
if not structure:
with TIMER.timer("get_structure.find_one", course_context) as tagger_find_one:
doc = self.structures.find_one({'_id': key})
tagger_find_one.measure("blocks", len(doc['blocks']))
structure = structure_from_mongo(doc, course_context)
cache.set(key, structure)

return structure

@autoretry_read()
def find_structures_by_id(self, ids, course_context=None):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from contracts import contract
from nose.plugins.attrib import attr
from django.core.cache import get_cache, InvalidCacheBackendError

from openedx.core.lib import tempdir
from xblock.fields import Reference, ReferenceList, ReferenceValueDict
Expand All @@ -29,6 +30,7 @@
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore
from xmodule.modulestore.tests.test_modulestore import check_has_course_method
from xmodule.modulestore.split_mongo import BlockKey
from xmodule.modulestore.tests.factories import check_mongo_calls
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.modulestore.tests.utils import mock_tab_from_json
from xmodule.modulestore.edit_info import EditInfoMixin
Expand Down Expand Up @@ -771,6 +773,79 @@ def test_course_successors(self, _from_json):
self.assertEqual(result.children[0].children[0].locator.version_guid, versions[0])


class TestCourseStructureCache(SplitModuleTest):
"""Tests for the CourseStructureCache"""

def setUp(self):
# use the default cache, since the `course_structure_cache`
# is a dummy cache during testing
self.cache = get_cache('default')

# make sure we clear the cache before every test...
self.cache.clear()
# ... and after
self.addCleanup(self.cache.clear)

# make a new course:
self.user = random.getrandbits(32)
self.new_course = modulestore().create_course(
'org', 'course', 'test_run', self.user, BRANCH_NAME_DRAFT,
)

super(TestCourseStructureCache, self).setUp()

@patch('xmodule.modulestore.split_mongo.mongo_connection.get_cache')
def test_course_structure_cache(self, mock_get_cache):
# force get_cache to return the default cache so we can test
# its caching behavior
mock_get_cache.return_value = self.cache

with check_mongo_calls(1):
not_cached_structure = self._get_structure(self.new_course)

# when cache is warmed, we should have one fewer mongo call
with check_mongo_calls(0):
cached_structure = self._get_structure(self.new_course)

# now make sure that you get the same structure
self.assertEqual(cached_structure, not_cached_structure)

@patch('xmodule.modulestore.split_mongo.mongo_connection.get_cache')
def test_course_structure_cache_no_cache_configured(self, mock_get_cache):
mock_get_cache.side_effect = InvalidCacheBackendError

with check_mongo_calls(1):
not_cached_structure = self._get_structure(self.new_course)

# if the cache isn't configured, we expect to have to make
# another mongo call here if we want the same course structure
with check_mongo_calls(1):
cached_structure = self._get_structure(self.new_course)

# now make sure that you get the same structure
self.assertEqual(cached_structure, not_cached_structure)

def test_dummy_cache(self):
with check_mongo_calls(1):
not_cached_structure = self._get_structure(self.new_course)

# Since the test is using the dummy cache, it's not actually caching
# anything
with check_mongo_calls(1):
cached_structure = self._get_structure(self.new_course)

# now make sure that you get the same structure
self.assertEqual(cached_structure, not_cached_structure)

def _get_structure(self, course):
"""
Helper function to get a structure from a course.
"""
return modulestore().db_connection.get_structure(
course.location.as_object_id(course.location.version_guid)
)


class SplitModuleItemTests(SplitModuleTest):
'''
Item read tests including inheritance
Expand Down
4 changes: 4 additions & 0 deletions lms/envs/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'edx_location_mem_cache',
},
'course_structure_cache': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'edx_course_structure_mem_cache',
},
}


Expand Down
4 changes: 3 additions & 1 deletion lms/envs/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,9 @@
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'edx_location_mem_cache',
},

'course_structure_cache': {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
},
}

# Dummy secret key for dev
Expand Down

0 comments on commit 66e4e28

Please sign in to comment.