ne lever des erreurs au refresh du cache des templates que si ça dure depuis un bail (#86346) #106

Open
bdauvergne wants to merge 3 commits from wip/86346-Le-cache-de-RemoteTemplate-leve into main
2 changed files with 257 additions and 138 deletions

View File

@ -58,7 +58,7 @@ class RemoteTemplate:
if self.source != '404' and item:
# page_cache is a dict redirect_url -> page content, get the best
# match.
page_cache, expiry_time = item
page_cache = item['page_cache']
selected_cache_page = None
for page_redirect_url in sorted(page_cache.keys(), key=len):
if selected_cache_page is None:
@ -68,19 +68,21 @@ class RemoteTemplate:
continue
if len(page_redirect_url) > len(selected_cache_page):
selected_cache_page = page_redirect_url
item = (page_cache[selected_cache_page], expiry_time)
return {**item, 'template_body': page_cache[selected_cache_page], 'page_cache': None, 'count': 0}
else:
item = cache.get(self.cache_key)
return item
return cache.get(self.cache_key)
def get_page_cache_key(self):
return self.PAGE_CACHE_KEY + '-' + self.language_code
# added v2 for change of cache item structure
return self.PAGE_CACHE_KEY + '-' + self.language_code + 'v2'
@property
def cache_key(self):
# added v2 for change of cache item structure
return hashlib.md5(
urllib.parse.urlunparse(urllib.parse.urlparse(self.source)[:3] + ('', '', '')).encode('ascii')
+ self.language_code.encode('ascii')
+ b'v2'
).hexdigest()
def get_template(self):
@ -100,38 +102,50 @@ class RemoteTemplate:
else:
self.theme_skeleton_url = settings.THEME_SKELETON_URL
if item is None:
template_body = self.update_content(in_thread=False)
if template_body is None:
raise Exception('Failed to retrieve theme')
template_body = self.update_content()
else:
template_body, expiry_time = item
if expiry_time < datetime.datetime.now():
if item['expiry_time'] < datetime.datetime.now():
# stale value, put it back into the cache for other consumers and
# update the content in a different thread
self.cache(template_body)
threading.Thread(target=self.update_content).start()
self.cache(item['template_body'], old_cache=item)
threading.Thread(target=lambda: self.update_content(old_cache=item)).start()
template_body = item['template_body']
return Template(template_body)
def update_content(self, in_thread=True):
r = requests.get(
self.theme_skeleton_url,
params={'source': self.source},
headers={'Accept-Language': self.language_code},
timeout=10,
)
if r.status_code != 200:
logger.error('failed to retrieve theme (status code: %s)', r.status_code)
def update_content(self, old_cache=None):
last_update = old_cache and old_cache['last_update']
count = old_cache['count'] if old_cache else 0
try:
r = requests.get(
self.theme_skeleton_url,
params={'source': self.source},
headers={'Accept-Language': self.language_code},
timeout=10,
)
r.raise_for_status()
Outdated
Review

Juste pour m'assurer que je comprends : ce « if » nous permet tout de même de recevoir une alerte à la première panne (quand last_update n'existe pas encore), puis de les recevoir toutes après une heure, c'est bien l'idée ?

Juste pour m'assurer que je comprends : ce « if » nous permet tout de même de recevoir une alerte à la première panne (quand last_update n'existe pas encore), puis de les recevoir toutes après une heure, c'est bien l'idée ?

Oui, en cas d'erreur si le cache est vide c'est qu'on est dans le cas synchrone et il y aura encore l'exception + le log avec le statut de l'erreur. Ce qui maintenant que je j'y pense est un peu redondant, on pourrait aussi juste lancer l'exception si last_update est None et simplifier la logique, vu qu'on a on forcément in_thread=True si last_update!=None. Le but ici c'est de ne plus avoir d'erreur remontée par les mises à jour en tâche de fond (dans un thread) du cache, sauf si ça dure longtemps (à vrai dire sur des tenants peu touchés en test ça arrivera encore, idéalement il faudrait un compteur).

Oui, en cas d'erreur si le cache est vide c'est qu'on est dans le cas synchrone et il y aura encore l'exception + le log avec le statut de l'erreur. Ce qui maintenant que je j'y pense est un peu redondant, on pourrait aussi juste lancer l'exception si last_update est None et simplifier la logique, vu qu'on a on forcément in_thread=True si last_update!=None. Le but ici c'est de ne plus avoir d'erreur remontée par les mises à jour en tâche de fond (dans un thread) du cache, sauf si ça dure longtemps (à vrai dire sur des tenants peu touchés en test ça arrivera encore, idéalement il faudrait un compteur).

Donc dernière version, j'ai rendu explicite l'exception en cas d'absence de cache et d'erreur du GET, je suis passé à un dico parce que le tuple commençait à être long et j'ai ajouté l'histoire du compteur pour éviter les logs de tenants inutilisés.

Donc dernière version, j'ai rendu explicite l'exception en cas d'absence de cache et d'erreur du GET, je suis passé à un dico parce que le tuple commençait à être long et j'ai ajouté l'histoire du compteur pour éviter les logs de tenants inutilisés.

Aussi le dernier code prend en compte la migration des entrées de ache existantes vers le nouveau format (j'aurai pu m'en passer en modifiant les clés de cache aussi...). Chaque fois que j'écris un truc je pense à un autre... pfiou...

Aussi le dernier code prend en compte la migration des entrées de ache existantes vers le nouveau format (j'aurai pu m'en passer en modifiant les clés de cache aussi...). Chaque fois que j'écris un truc je pense à un autre... pfiou...
except requests.RequestException as e:
if old_cache is None:
raise Exception(f'Failed to retrieve theme: {e}')
if datetime.datetime.now() - last_update < datetime.timedelta(hours=1) or count < 3:
log_function = logger.warning
else:
log_function = logger.error
log_function('failed to retrieve theme since %s: %s', last_update, e)
return None
self.cache(r.text)
if r.headers.get('X-Combo-Skeleton-Pages'):
template_body = r.text
self.cache(template_body)
if x_combo_skeleton_pages := r.headers.get('X-Combo-Skeleton-Pages'):
# X-Combo-Skeleton-Pages header is a dict (page_id -> redirect_url),
# it is use to create page cache.
self.combo_skeleton_pages = json.loads(r.headers.get('X-Combo-Skeleton-Pages'))
if in_thread:
self.combo_skeleton_pages = json.loads(x_combo_skeleton_pages)
if last_update is not None:
# cache is filled, so we are in a thread
self.update_all_pages_cache()
else:
threading.Thread(target=self.update_all_pages_cache).start()
return r.text
return template_body
def update_all_pages_cache(self):
for lang_code, _ in settings.LANGUAGES:
@ -143,25 +157,44 @@ class RemoteTemplate:
page_cache = {}
for page_redirect_url in self.combo_skeleton_pages.values():
r = requests.get(
self.theme_skeleton_url,
params={'source': page_redirect_url},
headers={'Accept-Language': lang_code},
timeout=10,
)
if r.status_code != 200:
try:
r = requests.get(
self.theme_skeleton_url,
params={'source': page_redirect_url},
headers={'Accept-Language': lang_code},
timeout=10,
)
r.raise_for_status()
except requests.RequestException:
# abort
return
page_cache[page_redirect_url] = r.text
expiry_time = datetime.datetime.now() + datetime.timedelta(seconds=CACHE_REFRESH_TIMEOUT)
cache.set(
self.get_page_cache_key(), (page_cache, expiry_time), 2592000
self.get_page_cache_key(),
{
'page_cache': page_cache,
'expiry_time': expiry_time,
'last_update': datetime.datetime.now(),
},
2592000,
) # bypass cache level expiration time
def cache(self, template_body):
def cache(self, template_body, old_cache=None):
last_update = old_cache['last_update'] if old_cache else datetime.datetime.now()
expiry_time = datetime.datetime.now() + datetime.timedelta(seconds=CACHE_REFRESH_TIMEOUT)
cache.set(self.cache_key, (template_body, expiry_time), 2592000) # bypass cache level expiration time
count = old_cache['count'] + 1 if old_cache else 0
cache.set(
self.cache_key,
{
'template_body': template_body,
'expiry_time': expiry_time,
'last_update': last_update,
'count': count,
},
2592000,
) # bypass cache level expiration time
def theme_base(request):

View File

@ -1,123 +1,209 @@
import json
import re
import threading
import time
from unittest import mock
import pytest
import requests
import responses
from django.core.cache import cache
from django.test import override_settings
from django.test.client import RequestFactory
from django.utils import translation
from httmock import HTTMock, urlmatch
from hobo.context_processors import theme_base, user_urls
TEMPLATE = 'Feeling lucky, punk?'
TEMPLATE_PAGE2 = 'Response for page 2'
def test_theme_base(settings, rf):
settings.THEME_SKELETON_URL = 'http://combo.example.com/_skeleton_/'
seen_urls = []
TEMPLATE = 'Feeling lucky, punk?'
TEMPLATE_PAGE2 = 'Response for page 2'
@urlmatch(netloc=r'combo.example.com$')
def combo_mock(url, request):
seen_urls.append(url.geturl())
status_code = 200
content = TEMPLATE
if 'page2' in url.query:
content = TEMPLATE_PAGE2
elif 'page1' in url.query:
status_code = 500
content = 'No template sorry'
return {
'status_code': status_code,
'content': content,
'headers': {
'X-Combo-Skeleton-Pages': json.dumps(
{'1': 'http://testserver/foo', '2': 'http://testserver/page1/page2/'}
)
},
def wait_other_threads():
time.sleep(0.1)
for thread in threading.enumerate()[1:]:
thread.join(timeout=10)
assert len(threading.enumerate()) == 1
@pytest.fixture
def portal_mock():
def callback(request):
headers = {
'X-Combo-Skeleton-Pages': json.dumps(
{'1': 'http://testserver/foo', '2': 'http://testserver/page1/page2/'}
)
}
if 'page2' in request.url:
return 200, headers, TEMPLATE_PAGE2
else:
return 200, headers, TEMPLATE
cache.clear()
def check(context, value):
assert context['theme_base']().source == value
with HTTMock(combo_mock), override_settings(INSTALLED_APPS=[]):
context = theme_base(rf.get('/'))
check(context, TEMPLATE)
assert seen_urls[0] == 'http://combo.example.com/_skeleton_/?source=http%3A%2F%2Ftestserver%2F'
for dummy in range(10):
# wait for the other requests, made from a thread, to happen
time.sleep(0.1)
if len(seen_urls) == 4:
break
assert len(seen_urls) == 4
# requested page + root + pages from X-Combo-Skeleton-Pages header
seen_urls = []
context = theme_base(rf.get('/'))
check(context, TEMPLATE)
assert len(seen_urls) == 0
seen_urls = []
context = theme_base(rf.get('/page1/page2/'))
check(context, TEMPLATE_PAGE2)
assert len(seen_urls) == 0
seen_urls = []
context = theme_base(rf.get('/page1/'))
check(context, TEMPLATE)
assert len(seen_urls) == 0
context = theme_base(rf.get('/page1/page2/'))
check(context, TEMPLATE_PAGE2)
assert len(seen_urls) == 0
def test_theme_base_language(settings, rf):
settings.THEME_SKELETON_URL = 'http://combo.example.com/_skeleton_/'
seen_urls = []
@urlmatch(netloc=r'combo.example.com$')
def combo_mock(url, request):
language = request.headers['Accept-Language']
seen_urls.append((language, url.geturl()))
status_code = 200
assert language in ('en', 'fr'), 'invalid language'
if language == 'en':
content = 'Skeleton for English'
elif language == 'fr':
content = 'Skeleton for French'
return {
'status_code': status_code,
'content': content,
'headers': {'X-Combo-Skeleton-Pages': json.dumps({'1': 'http://testserver/foo'})},
}
cache.clear()
with HTTMock(combo_mock), override_settings(
INSTALLED_APPS=[], LANGUAGES=[('en', 'English'), ('fr', 'French')]
with responses.RequestsMock() as rsps, override_settings(
INSTALLED_APPS=[], THEME_SKELETON_URL='http://combo.example.com/_skeleton_/'
):
context = theme_base(rf.get('/'))
assert context['theme_base']().source == 'Skeleton for English'
for dummy in range(10):
# wait for the other requests, made from a thread, to happen
time.sleep(0.1)
if len(seen_urls) == 5:
break
assert len(seen_urls) == 5
assert ('en', 'http://combo.example.com/_skeleton_/?source=http%3A%2F%2Ftestserver%2F') in seen_urls
assert ('fr', 'http://combo.example.com/_skeleton_/?source=http%3A%2F%2Ftestserver%2F') in seen_urls
assert (
'en',
'http://combo.example.com/_skeleton_/?source=http%3A%2F%2Ftestserver%2Ffoo',
) in seen_urls
assert (
'fr',
'http://combo.example.com/_skeleton_/?source=http%3A%2F%2Ftestserver%2Ffoo',
) in seen_urls
rsps.add_callback(responses.GET, 'http://combo.example.com/_skeleton_/', callback=callback)
yield rsps
assert theme_base(rf.get('/'))['theme_base']().source == 'Skeleton for English'
with translation.override('fr'):
assert theme_base(rf.get('/'))['theme_base']().source == 'Skeleton for French'
@pytest.fixture
def theme_base_source(rf):
def theme_base_source(path):
context = theme_base(rf.get(path))
return context['theme_base']().source
return theme_base_source
def test_theme_base(portal_mock, theme_base_source):
cache.clear()
calls = portal_mock.calls
assert theme_base_source('/') == TEMPLATE
assert calls[0].request.url == 'http://combo.example.com/_skeleton_/?source=http%3A%2F%2Ftestserver%2F'
wait_other_threads()
# requested page + root + pages from X-Combo-Skeleton-Pages header
assert len(calls) == 4
calls.reset()
assert theme_base_source('/') == TEMPLATE
assert not calls
assert theme_base_source('/page1/page2/') == TEMPLATE_PAGE2
assert not calls
assert theme_base_source('/page1/') == TEMPLATE
assert not calls
assert theme_base_source('/page1/page2/') == TEMPLATE_PAGE2
assert not calls
@pytest.fixture
def portal_mock_error():
def callback(request):
headers = {
'X-Combo-Skeleton-Pages': json.dumps(
{'1': 'http://testserver/foo', '2': 'http://testserver/page1/page2/'}
)
}
if 'page2' in request.url:
raise requests.RequestException('page2 boom!')
return 200, headers, TEMPLATE
with responses.RequestsMock() as rsps, override_settings(
INSTALLED_APPS=[], THEME_SKELETON_URL='http://combo.example.com/_skeleton_/'
):
rsps.add_callback(responses.GET, 'http://combo.example.com/_skeleton_/', callback=callback)
yield rsps
def test_theme_base_error(portal_mock_error, theme_base_source, caplog, freezer):
calls = portal_mock_error.calls
cache.clear()
caplog.set_level('WARNING')
assert not caplog.messages
assert theme_base_source('/') == TEMPLATE
wait_other_threads()
assert len(calls) == 3
calls.reset()
portal_mock_error.replace(
responses.GET, 'http://combo.example.com/_skeleton_/', body=requests.RequestException('boom!')
)
assert theme_base_source('/') == TEMPLATE
assert not calls
assert not caplog.messages
# move 5 minutes later...
freezer.tick(301)
assert theme_base_source('/') == TEMPLATE
wait_other_threads()
assert calls
assert re.match('WARNING.*failed to retrieve theme.*boom!', caplog.text)
caplog.clear()
calls.reset()
# move 1 hours later...
freezer.tick(3300)
assert theme_base_source('/') == TEMPLATE
wait_other_threads()
assert len(calls) == 1
assert re.match('WARNING.*failed to retrieve theme.*boom!', caplog.text)
caplog.clear()
calls.reset()
freezer.tick(301)
assert theme_base_source('/') == TEMPLATE
wait_other_threads()
assert len(calls) == 1
assert re.match('WARNING.*failed to retrieve theme.*boom!', caplog.text)
caplog.clear()
calls.reset()
freezer.tick(301)
assert theme_base_source('/') == TEMPLATE
wait_other_threads()
assert len(calls) == 1
assert re.match('ERROR.*failed to retrieve theme.*boom!', caplog.text)
caplog.clear()
calls.reset()
# after some times cache is evicted and...
cache.clear()
with pytest.raises(Exception, match=r'Failed to retrieve theme:.*boom'):
theme_base_source('/')
@pytest.fixture
def portal_mock_language():
def callback(request):
language = request.headers['Accept-Language']
headers = {'X-Combo-Skeleton-Pages': json.dumps({'1': 'http://testserver/foo'})}
if language == 'en':
return 200, headers, 'Skeleton for English'
elif language == 'fr':
return 200, headers, 'Skeleton for French'
with responses.RequestsMock() as rsps, override_settings(
INSTALLED_APPS=[],
LANGUAGES=[('en', 'English'), ('fr', 'French')],
THEME_SKELETON_URL='http://combo.example.com/_skeleton_/',
):
rsps.add_callback(responses.GET, 'http://combo.example.com/_skeleton_/', callback=callback)
yield rsps
def test_theme_base_language(portal_mock_language, theme_base_source):
cache.clear()
calls = portal_mock_language.calls
assert theme_base_source(path='/') == 'Skeleton for English'
wait_other_threads()
assert len(calls) == 5
language_and_urls = {(call.request.headers['accept-language'], call.request.url) for call in calls}
assert {
('en', 'http://combo.example.com/_skeleton_/?source=http%3A%2F%2Ftestserver%2F'),
('fr', 'http://combo.example.com/_skeleton_/?source=http%3A%2F%2Ftestserver%2F'),
('en', 'http://combo.example.com/_skeleton_/?source=http%3A%2F%2Ftestserver%2Ffoo'),
('fr', 'http://combo.example.com/_skeleton_/?source=http%3A%2F%2Ftestserver%2Ffoo'),
} == language_and_urls
assert theme_base_source(path='/') == 'Skeleton for English'
with translation.override('fr'):
assert theme_base_source(path='/') == 'Skeleton for French'
class IdPRequestFactory(RequestFactory):