Skip to content

Commit 829f7e2

Browse files
committed
Fixed django-cms#5947 -- Don't delete menu cache keys from the database
1 parent cc3d146 commit 829f7e2

File tree

6 files changed

+114
-60
lines changed

6 files changed

+114
-60
lines changed

CHANGELOG.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
* Fixed an issue with JS not being able to determine correct path to async bundle
1818
* Fixed a ``DoesNotExist`` db error raised when moving a page marked as published but whose public
1919
translation does not exist.
20+
* Fixed a bug where the menu would render nodes using the site session variable (set in the admin),
21+
instead of the current request site.
22+
* Fixed a race condition bug where the cache keys on the database were deleted without syncing with the
23+
cache server and as a result old menu items would continue to be displayed.
2024

2125

2226
=== 3.4.3 (2017-04-24) ===

cms/cms_menus.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,9 @@ def page_to_node(renderer, page, home, language):
151151
# Now finally, build the NavigationNode object and return it.
152152
ret_node = CMSNavigationNode(
153153
translation.menu_title or translation.title,
154-
'',
155-
page.pk,
156-
parent_id,
154+
url='',
155+
id=page.pk,
156+
parent_id=parent_id,
157157
attr=attr,
158158
visible=page.in_navigation,
159159
path=translation.path or translation.slug

cms/tests/test_cache.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,12 @@ def test_cache_placeholder(self):
9292
with self.settings(**overrides):
9393
with self.assertNumQueries(FuzzyInt(13, 25)):
9494
self.client.get('/en/')
95-
with self.assertNumQueries(FuzzyInt(5, 10)):
95+
with self.assertNumQueries(FuzzyInt(5, 11)):
9696
self.client.get('/en/')
9797

9898
overrides['CMS_PLACEHOLDER_CACHE'] = False
9999
with self.settings(**overrides):
100-
with self.assertNumQueries(FuzzyInt(7, 14)):
100+
with self.assertNumQueries(FuzzyInt(7, 15)):
101101
self.client.get('/en/')
102102

103103
def test_no_cache_plugin(self):
@@ -174,7 +174,7 @@ def test_no_cache_plugin(self):
174174
with self.assertNumQueries(4):
175175
output2 = self.render_template_obj(template, {}, request)
176176
with self.settings(CMS_PAGE_CACHE=False):
177-
with self.assertNumQueries(FuzzyInt(8, 13)):
177+
with self.assertNumQueries(FuzzyInt(8, 14)):
178178
response = self.client.get('/en/')
179179
resp2 = response.content.decode('utf8').split("$$$")[1]
180180
self.assertNotEqual(output, output2)

cms/tests/test_menu.py

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -317,17 +317,15 @@ def test_show_menu(self):
317317
def test_show_menu_num_queries(self):
318318
context = self.get_context()
319319
# test standard show_menu
320-
with self.assertNumQueries(8):
320+
with self.assertNumQueries(6):
321321
"""
322322
The queries should be:
323323
get all public pages
324324
get all draft pages from public pages
325325
get all page permissions
326326
get all titles
327327
get the menu cache key
328-
create a savepoint
329328
set the menu cache key
330-
release the savepoint
331329
"""
332330
tpl = Template("{% load menu_tags %}{% show_menu %}")
333331
tpl.render(context)
@@ -393,18 +391,57 @@ def test_menu_cache_live_only(self):
393391

394392
self.assertEqual(len(node_ids), page_count, msg='Not all pages in the public menu are public')
395393

396-
def test_menu_keys_duplicate_truncates(self):
394+
def test_menu_cache_respects_database_keys(self):
395+
public_page = self.get_page(1)
396+
397+
# Prime the public menu cache
398+
context = self.get_context(path=public_page.get_absolute_url(), page=public_page)
399+
context['request'].session['cms_edit'] = False
400+
401+
# Prime the cache
402+
with self.assertNumQueries(6):
403+
# The queries should be:
404+
# get all public pages
405+
# get all draft pages from public pages
406+
# get all page permissions
407+
# get all titles
408+
# get the menu cache key
409+
# set the menu cache key
410+
Template("{% load menu_tags %}{% show_menu %}").render(context)
411+
412+
# One new CacheKey should have been created
413+
self.assertEqual(CacheKey.objects.count(), 1)
414+
415+
# Because its cached, only one query is made to the db
416+
with self.assertNumQueries(1):
417+
# The queries should be:
418+
# get the menu cache key
419+
Template("{% load menu_tags %}{% show_menu %}").render(context)
420+
421+
# Delete the current cache key but don't touch the cache
422+
CacheKey.objects.all().delete()
423+
424+
# The menu should be recalculated
425+
with self.assertNumQueries(6):
426+
# The queries should be:
427+
# get all public pages
428+
# get all draft pages from public pages
429+
# get all page permissions
430+
# get all titles
431+
# get the menu cache key
432+
# set the menu cache key
433+
Template("{% load menu_tags %}{% show_menu %}").render(context)
434+
435+
def test_menu_keys_duplicate_clear(self):
397436
"""
398-
When two objects with the same characteristics are present in the
399-
database, get_or_create truncates the database table to "invalidate"
400-
the cache, before retrying. This can happen after migrations, and since
401-
it's only cache, we don't want any propagation of errors.
437+
Tests that the menu clears all keys, including duplicates.
402438
"""
403439
CacheKey.objects.create(language="fr", site=1, key="a")
404440
CacheKey.objects.create(language="fr", site=1, key="a")
405-
CacheKey.objects.get_or_create(language="fr", site=1, key="a")
406441

407-
self.assertEqual(CacheKey.objects.count(), 1)
442+
self.assertEqual(CacheKey.objects.count(), 2)
443+
menu_pool.clear(site_id=1, language='fr')
444+
self.assertEqual(CacheKey.objects.count(), 0)
408445

409446
def test_only_active_tree(self):
410447
context = self.get_context(page=self.get_page(1))
@@ -994,17 +1031,15 @@ def test_show_submenu_num_queries(self):
9941031
context = self.get_context(page.get_absolute_url(), page=page)
9951032

9961033
# test standard show_menu
997-
with self.assertNumQueries(8):
1034+
with self.assertNumQueries(6):
9981035
"""
9991036
The queries should be:
10001037
get all public pages
10011038
get all draft pages for public pages
10021039
get all page permissions
10031040
get all titles
10041041
get the menu cache key
1005-
create a savepoint
10061042
set the menu cache key
1007-
release the savepoint
10081043
"""
10091044
tpl = Template("{% load menu_tags %}{% show_sub_menu %}")
10101045
tpl.render(context)
@@ -1171,17 +1206,15 @@ def test_not_in_navigation_num_queries(self):
11711206

11721207
with LanguageOverride('en'):
11731208
context = self.get_context(a.get_absolute_url())
1174-
with self.assertNumQueries(8):
1209+
with self.assertNumQueries(6):
11751210
"""
11761211
The queries should be:
11771212
get all public pages
11781213
get all draft pages for public pages
11791214
get all page permissions
11801215
get all titles
11811216
get the menu cache key
1182-
create a savepoint
11831217
set the menu cache key
1184-
release the savepoint
11851218
"""
11861219
# Actually seems to run:
11871220
tpl = Template("{% load menu_tags %}{% show_menu_below_id 'a' 0 100 100 100 %}")

menus/menu_pool.py

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,38 @@ def __init__(self, pool, request):
107107
self.site = Site.objects.get_current(request)
108108
self.draft_mode_active = use_draft(request)
109109

110+
def _get_cache_key(self, site_id):
111+
# This internal will change to a cached property on 3.5
112+
113+
prefix = getattr(settings, 'CMS_CACHE_PREFIX', 'menu_cache_')
114+
115+
key = '%smenu_nodes_%s_%s' % (prefix, self.language, site_id)
116+
117+
if self.request.user.is_authenticated():
118+
key += '_%s_user' % self.request.user.pk
119+
120+
if self.draft_mode_active:
121+
key += ':draft'
122+
else:
123+
key += ':public'
124+
return key
125+
126+
def _is_cached(self, site_id):
127+
# This internal will change to a cached property on 3.5
128+
129+
_internal_cache = '_is_cached_{}'.format(site_id)
130+
131+
if not hasattr(self, _internal_cache):
132+
cache_key = self._get_cache_key(site_id=site_id)
133+
db_cache_key_lookup = CacheKey.objects.filter(
134+
key=cache_key,
135+
language=self.language,
136+
site=site_id,
137+
)
138+
# Cache the lookup to avoid a query on every call to a menu tag
139+
setattr(self, _internal_cache, db_cache_key_lookup.exists())
140+
return getattr(self, _internal_cache)
141+
110142
def _build_nodes(self, site_id):
111143
"""
112144
This is slow. Caching must be used.
@@ -130,28 +162,17 @@ def _build_nodes(self, site_id):
130162
"It will be removed in django CMS 3.5",
131163
PendingDeprecationWarning
132164
)
133-
134-
if not site_id:
135-
# Backwards compatibility shim for < 3.5 projects
136-
# On 3.5, this method will change to no longer receive
137-
# a site id.
165+
else:
138166
site_id = self.site.pk
139167

140-
prefix = getattr(settings, 'CMS_CACHE_PREFIX', 'menu_cache_')
141-
142-
key = '%smenu_nodes_%s_%s' % (prefix, self.language, site_id)
143-
144-
if self.request.user.is_authenticated():
145-
key += '_%s_user' % self.request.user.pk
146-
147-
if self.draft_mode_active:
148-
key += ':draft'
149-
else:
150-
key += ':public'
168+
key = self._get_cache_key(site_id)
151169

152170
cached_nodes = cache.get(key, None)
153171

154-
if cached_nodes:
172+
if cached_nodes and self._is_cached(site_id):
173+
# Only use the cache if the key is present in the database.
174+
# This prevents a condition where keys which have been removed
175+
# from the database due to a change in content, are still used.
155176
return cached_nodes
156177

157178
final_nodes = []
@@ -177,12 +198,17 @@ def _build_nodes(self, site_id):
177198
final_nodes += _build_nodes_inner_for_one_menu(nodes, menu_class_name)
178199

179200
cache.set(key, final_nodes, get_cms_setting('CACHE_DURATIONS')['menus'])
180-
# We need to have a list of the cache keys for languages and sites that
181-
# span several processes - so we follow the Django way and share through
182-
# the database. It's still cheaper than recomputing every time!
183-
# This way we can selectively invalidate per-site and per-language,
184-
# since the cache shared but the keys aren't
185-
CacheKey.objects.get_or_create(key=key, language=self.language, site=site_id)
201+
202+
if not self._is_cached(site_id):
203+
# No need to invalidate the internal lookup cache,
204+
# just set the value directly.
205+
setattr(self, '_is_cached_{}'.format(site_id), True)
206+
# We need to have a list of the cache keys for languages and sites that
207+
# span several processes - so we follow the Django way and share through
208+
# the database. It's still cheaper than recomputing every time!
209+
# This way we can selectively invalidate per-site and per-language,
210+
# since the cache is shared but the keys aren't
211+
CacheKey.objects.create(key=key, language=self.language, site=site_id)
186212
return final_nodes
187213

188214
def _mark_selected(self, nodes):
@@ -243,7 +269,7 @@ def get_renderer(self, request):
243269
def discover_menus(self):
244270
if self.discovered:
245271
return
246-
# FIXME: Remove in 3.4
272+
# FIXME: Remove in 3.5
247273
load('menu')
248274
load('cms_menus')
249275
from menus.modifiers import register
@@ -321,7 +347,7 @@ def register_menu(self, menu_cls):
321347

322348
if menu_cls.__module__.split('.')[-1] == 'menu':
323349
warnings.warn('menu.py filename is deprecated, '
324-
'and it will be removed in version 3.4; '
350+
'and it will be removed in version 3.5; '
325351
'please rename it to cms_menus.py', DeprecationWarning)
326352
from menus.base import Menu
327353
assert issubclass(menu_cls, Menu)
@@ -339,7 +365,7 @@ def register_modifier(self, modifier_class):
339365
source_file = os.path.basename(inspect.stack()[1][1])
340366
if source_file == 'menu.py':
341367
warnings.warn('menu.py filename is deprecated, '
342-
'and it will be removed in version 3.4; '
368+
'and it will be removed in version 3.5; '
343369
'please rename it to cms_menus.py', DeprecationWarning)
344370
from menus.base import Modifier
345371
assert issubclass(modifier_class, Modifier)
@@ -368,7 +394,7 @@ def get_nodes_by_attribute(self, nodes, name, value):
368394
def apply_modifiers(self, nodes, request, namespace=None, root_id=None,
369395
post_cut=False, breadcrumb=False):
370396
warnings.warn('menu_pool.apply_modifiers is deprecated '
371-
'and it will be removed in version 3.4; '
397+
'and it will be removed in version 3.5; '
372398
'please use the menu renderer instead.', DeprecationWarning)
373399
renderer = self.get_renderer(request)
374400
nodes = renderer.apply_modifiers(
@@ -383,7 +409,7 @@ def apply_modifiers(self, nodes, request, namespace=None, root_id=None,
383409
def get_nodes(self, request, namespace=None, root_id=None, site_id=None,
384410
breadcrumb=False):
385411
warnings.warn('menu_pool.get_nodes is deprecated '
386-
'and it will be removed in version 3.4; '
412+
'and it will be removed in version 3.5; '
387413
'please use the menu renderer instead.', DeprecationWarning)
388414
renderer = self.get_renderer(request)
389415
nodes = renderer.get_nodes(

menus/models.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44

55
class CacheKeyManager(models.Manager):
6+
67
def get_keys(self, site_id=None, language=None):
7-
ret = self.none()
88
if not site_id and not language:
99
# Both site and language are None - return everything
1010
ret = self.all()
@@ -17,15 +17,6 @@ def get_keys(self, site_id=None, language=None):
1717
ret = self.filter(site=site_id).filter(language=language)
1818
return ret
1919

20-
def get_or_create(self, **kwargs):
21-
try:
22-
return super(CacheKeyManager, self).get_or_create(**kwargs)
23-
except CacheKey.MultipleObjectsReturned:
24-
# Truncate the table, we don't want a funny cache object to cause
25-
# mayhem!
26-
CacheKey.objects.all().delete()
27-
return super(CacheKeyManager, self).get_or_create(**kwargs)
28-
2920

3021
class CacheKey(models.Model):
3122
'''

0 commit comments

Comments
 (0)