Skip to content

Commit 24c6d92

Browse files
committed
Use Django cache to store instantiated components.
1 parent f8d0e63 commit 24c6d92

File tree

11 files changed

+239
-70
lines changed

11 files changed

+239
-70
lines changed

django_unicorn/components/unicorn_view.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,21 @@
44
import pickle
55
from typing import Any, Callable, Dict, List, Optional, Sequence, Type, Union
66

7+
from django.core.cache import caches
78
from django.core.exceptions import ImproperlyConfigured
89
from django.db.models import Model
910
from django.http import HttpRequest
1011
from django.views.generic.base import TemplateView
1112

1213
from cachetools.lru import LRUCache
1314

15+
from django_unicorn.settings import get_cache_alias
16+
1417
from .. import serializer
1518
from ..decorators import timed
16-
from ..errors import ComponentLoadError
19+
from ..errors import ComponentLoadError, UnicornCacheError
1720
from ..settings import get_setting
21+
from ..utils import get_cacheable_component
1822
from .fields import UnicornField
1923
from .unicorn_template_response import UnicornTemplateResponse
2024

@@ -29,6 +33,7 @@
2933
# Module cache for constructed component classes
3034
# This can create a subtle race condition so a more long-term solution needs to be found
3135
constructed_views_cache = LRUCache(maxsize=100)
36+
COMPONENTS_MODULE_CACHE_ENABLED = True
3237

3338

3439
def convert_to_snake_case(s: str) -> str:
@@ -646,6 +651,9 @@ def create(
646651
assert component_id, "Component id is required"
647652
assert component_name, "Component name is required"
648653

654+
cache = caches[get_cache_alias()]
655+
component_cache_key = f"unicorn:component:{component_id}"
656+
649657
@timed
650658
def _get_component_class(
651659
module_name: str, class_name: str
@@ -658,16 +666,30 @@ def _get_component_class(
658666

659667
return component_class
660668

661-
if use_cache and component_id in constructed_views_cache:
669+
cached_component = cache.get(component_cache_key)
670+
671+
if cached_component:
672+
if cached_component.parent:
673+
parent_component_cache_key = (
674+
f"unicorn:component:{cached_component.parent.component_id}"
675+
)
676+
cached_parent_component = cache.get(parent_component_cache_key)
677+
678+
if cached_parent_component:
679+
cached_component.parent = cached_parent_component
680+
cached_component.parent.setup(request)
681+
else:
682+
cached_component = constructed_views_cache.get(component_id)
683+
684+
if use_cache and cached_component:
662685
# Note that `hydrate()` and `complete` don't need to be called here
663686
# because this path only happens for re-rendering from the view
664-
component = constructed_views_cache[component_id]
665-
component.setup(request)
666-
component._validate_called = False
667-
component.calls = []
687+
cached_component.setup(request)
688+
cached_component._validate_called = False
689+
cached_component.calls = []
668690
logger.debug(f"Retrieve {component_id} from constructed views cache")
669691

670-
return component
692+
return cached_component
671693

672694
if component_id in views_cache:
673695
(component_class, parent, kwargs) = views_cache[component_id]
@@ -705,9 +727,22 @@ def _get_component_class(
705727
**kwargs,
706728
)
707729

708-
# Put the component's class in a "cache" to skip looking for the component on the next request
730+
# Put the component's class in a module cache to skip looking for the component again
709731
views_cache[component_id] = (component_class, parent, kwargs)
710-
constructed_views_cache[component_id] = component
732+
733+
# Put the instantiated component into a module cache and the Django cache
734+
cacheable_component = None
735+
736+
try:
737+
cacheable_component = get_cacheable_component(component)
738+
except UnicornCacheError as e:
739+
logger.exception(e)
740+
741+
if cacheable_component:
742+
if COMPONENTS_MODULE_CACHE_ENABLED:
743+
constructed_views_cache[component_id] = cacheable_component
744+
745+
cache.set(component_cache_key, cacheable_component)
711746

712747
return component
713748
except ModuleNotFoundError as e:

django_unicorn/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
class UnicornCacheError(Exception):
2+
pass
3+
4+
15
class UnicornViewError(Exception):
26
pass
37

django_unicorn/utils.py

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import hmac
2+
import logging
3+
import pickle
24

35
from django.conf import settings
46

57
import shortuuid
68

9+
from django_unicorn.errors import UnicornCacheError
710

8-
def generate_checksum(data_bytes):
9-
if isinstance(data_bytes, str):
10-
data_bytes = str.encode(data_bytes)
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def generate_checksum(data):
16+
"""
17+
Generates a checksum for the passed-in data.
18+
"""
19+
if isinstance(data, str):
20+
data_bytes = str.encode(data)
21+
else:
22+
data_bytes = data
1123

1224
checksum = hmac.new(
1325
str.encode(settings.SECRET_KEY), data_bytes, digestmod="sha256",
@@ -17,8 +29,37 @@ def generate_checksum(data_bytes):
1729
return checksum
1830

1931

20-
def dicts_equal(d1, d2):
21-
""" return True if all keys and values are the same """
22-
return all(k in d2 and d1[k] == d2[k] for k in d1) and all(
23-
k in d1 and d1[k] == d2[k] for k in d2
32+
def dicts_equal(dictionary_one, dictionary_two):
33+
"""
34+
Return True if all keys and values are the same between two dictionaries.
35+
"""
36+
return all(
37+
k in dictionary_two and dictionary_one[k] == dictionary_two[k]
38+
for k in dictionary_one
39+
) and all(
40+
k in dictionary_one and dictionary_one[k] == dictionary_two[k]
41+
for k in dictionary_two
2442
)
43+
44+
45+
def get_cacheable_component(component):
46+
"""
47+
Converts a component into something that is cacheable/pickleable.
48+
"""
49+
component.request = None
50+
51+
if component.parent:
52+
component.parent = get_cacheable_component(component.parent)
53+
54+
try:
55+
pickle.dumps(component)
56+
except TypeError as e:
57+
raise UnicornCacheError(
58+
"Cannot cache component because it is not picklable."
59+
) from e
60+
except AttributeError as e:
61+
raise UnicornCacheError(
62+
"Cannot cache component because it is not picklable."
63+
) from e
64+
65+
return component

django_unicorn/views.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from .message import ComponentRequest, Return
2222
from .serializer import dumps, loads
2323
from .settings import get_cache_alias, get_serial_enabled, get_serial_timeout
24-
from .utils import generate_checksum
24+
from .utils import generate_checksum, get_cacheable_component
2525

2626

2727
logger = logging.getLogger(__name__)
@@ -508,6 +508,11 @@ def _process_component_request(
508508
rendered_component = component.render()
509509
component.rendered(rendered_component)
510510

511+
cache = caches[get_cache_alias()]
512+
component_cache_key = f"unicorn:component:{component.component_id}"
513+
cacheable_component = get_cacheable_component(component)
514+
cache.set(component_cache_key, cacheable_component)
515+
511516
partial_doms = []
512517

513518
if partials and all(partials):
@@ -582,6 +587,7 @@ def _process_component_request(
582587
parent_component = component.parent
583588

584589
if parent_component:
590+
# TODO: Should parent_component.hydrate() be called?
585591
parent_frontend_context_variables = loads(
586592
parent_component.get_frontend_context_variables()
587593
)
@@ -596,6 +602,10 @@ def _process_component_request(
596602
parent_dom = parent_component.render()
597603
component.parent_rendered(parent_dom)
598604

605+
component_cache_key = f"unicorn:component:{parent_component.component_id}"
606+
cacheable_component = get_cacheable_component(parent_component)
607+
cache.set(component_cache_key, cacheable_component)
608+
599609
parent.update(
600610
{
601611
"dom": parent_dom,

tests/call_method_parser/test_parse_call_method_name.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ def test_multiple_args():
2222
assert actual == expected
2323

2424

25+
def test_multiple_args_2():
26+
expected = ("set_name", [1, 2], {})
27+
actual = parse_call_method_name("set_name(1, 2)")
28+
29+
assert actual == expected
30+
31+
2532
def test_var_with_curly_braces():
2633
expected = ("set_name", ["{}",], {})
2734
actual = parse_call_method_name('set_name("{}")')
@@ -36,13 +43,6 @@ def test_one_arg():
3643
assert actual == expected
3744

3845

39-
def test_multiple_args():
40-
expected = ("set_name", [1, 2], {})
41-
actual = parse_call_method_name("set_name(1, 2)")
42-
43-
assert actual == expected
44-
45-
4646
def test_kwargs():
4747
expected = ("set_name", [], {"kwarg1": "wow"})
4848
actual = parse_call_method_name("set_name(kwarg1='wow')")

tests/views/message/test_call_method.py

Lines changed: 99 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,21 @@
33
import orjson
44
import shortuuid
55

6+
from django_unicorn.components import UnicornView, unicorn_view
67
from django_unicorn.utils import generate_checksum
8+
from tests.views.message.utils import post_and_get_response
79

810

911
def test_message_call_method(client):
1012
data = {"method_count": 0}
11-
message = {
12-
"actionQueue": [{"payload": {"name": "test_method"}, "type": "callMethod",}],
13-
"data": data,
14-
"checksum": generate_checksum(orjson.dumps(data)),
15-
"id": shortuuid.uuid()[:8],
16-
"epoch": time.time(),
17-
}
18-
19-
response = client.post(
20-
"/message/tests.views.fake_components.FakeComponent",
21-
message,
22-
content_type="application/json",
13+
response = post_and_get_response(
14+
client,
15+
url="/message/tests.views.fake_components.FakeComponent",
16+
data=data,
17+
action_queue=[{"payload": {"name": "test_method"}, "type": "callMethod",}],
2318
)
2419

25-
body = orjson.loads(response.content)
26-
27-
assert body["data"].get("method_count") == 1
20+
assert response["data"].get("method_count") == 1
2821

2922

3023
def test_message_call_method_redirect(client):
@@ -414,3 +407,94 @@ def test_message_call_method_refresh(client):
414407
# `data` should contain all data (not just the diffs) for refreshes
415408
assert body["data"].get("check") is not None
416409
assert body["data"].get("dictionary") is not None
410+
411+
412+
def test_message_call_method_caches_disabled(client, monkeypatch, settings):
413+
monkeypatch.setattr(unicorn_view, "COMPONENTS_MODULE_CACHE_ENABLED", False)
414+
settings.CACHES["default"][
415+
"BACKEND"
416+
] = "django.core.cache.backends.dummy.DummyCache"
417+
418+
component_id = shortuuid.uuid()[:8]
419+
response = post_and_get_response(
420+
client,
421+
url="/message/tests.views.fake_components.FakeComponent",
422+
data={"method_count": 0},
423+
action_queue=[{"payload": {"name": "test_method"}, "type": "callMethod",}],
424+
component_id=component_id,
425+
)
426+
427+
method_count = response["data"].get("method_count")
428+
429+
assert method_count == 1
430+
431+
# Get the component again
432+
view = UnicornView.create(
433+
component_name="tests.views.fake_components.FakeComponent",
434+
component_id=component_id,
435+
use_cache=True,
436+
)
437+
438+
# Component is not retrieved from any caches
439+
assert view.method_count == 0
440+
441+
442+
def test_message_call_method_module_cache_disabled(client, monkeypatch, settings):
443+
monkeypatch.setattr(unicorn_view, "COMPONENTS_MODULE_CACHE_ENABLED", False)
444+
settings.UNICORN["CACHE_ALIAS"] = "default"
445+
settings.CACHES["default"][
446+
"BACKEND"
447+
] = "django.core.cache.backends.locmem.LocMemCache"
448+
449+
component_id = shortuuid.uuid()[:8]
450+
response = post_and_get_response(
451+
client,
452+
url="/message/tests.views.fake_components.FakeComponent",
453+
data={"method_count": 0},
454+
action_queue=[{"payload": {"name": "test_method"}, "type": "callMethod",}],
455+
component_id=component_id,
456+
)
457+
458+
method_count = response["data"].get("method_count")
459+
460+
assert method_count == 1
461+
462+
# Get the component again and it should be found in local memory cache
463+
view = UnicornView.create(
464+
component_name="tests.views.fake_components.FakeComponent",
465+
component_id=component_id,
466+
use_cache=True,
467+
)
468+
469+
# Component is retrieved from the local memory cache
470+
assert view.method_count == method_count
471+
472+
473+
def test_message_call_method_cache_backend_dummy(client, monkeypatch, settings):
474+
monkeypatch.setattr(unicorn_view, "COMPONENTS_MODULE_CACHE_ENABLED", True)
475+
settings.CACHES["default"][
476+
"BACKEND"
477+
] = "django.core.cache.backends.dummy.DummyCache"
478+
479+
component_id = shortuuid.uuid()[:8]
480+
response = post_and_get_response(
481+
client,
482+
url="/message/tests.views.fake_components.FakeComponent",
483+
data={"method_count": 0},
484+
action_queue=[{"payload": {"name": "test_method"}, "type": "callMethod",}],
485+
component_id=component_id,
486+
)
487+
488+
method_count = response["data"].get("method_count")
489+
490+
assert method_count == 1
491+
492+
# Get the component again and it should be found in local memory cache
493+
view = UnicornView.create(
494+
component_name="tests.views.fake_components.FakeComponent",
495+
component_id=component_id,
496+
use_cache=True,
497+
)
498+
499+
# Component is retrieved from the module cache
500+
assert view.method_count == method_count

tests/views/message/test_call_method_multiple.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,6 @@ def test_message_second_request_not_queued_because_after_first(client, settings)
228228

229229

230230
@pytest.mark.slow
231-
@pytest.mark.skip
232231
def test_message_second_request_not_queued_because_serial_timeout(client, settings):
233232
_set_serial(settings, True, 0.1)
234233

@@ -256,7 +255,6 @@ def test_message_second_request_not_queued_because_serial_timeout(client, settin
256255

257256

258257
@pytest.mark.slow
259-
@pytest.mark.skip
260258
def test_message_second_request_not_queued_because_serial_disabled(client, settings):
261259
_set_serial(settings, False, 5)
262260

0 commit comments

Comments
 (0)