Skip to content

Commit 9db202b

Browse files
authored
Use database to keep track of session cleaning (#167)
- Use database to keep track of when ReactPy last performs a clean. - Previously done via a cache entry for performance. But realistically we should assume the user is either utilizing a performant DB, or doesn't care about performance to begin with. - Store web module file contents in cache, instead of the HTTP Response - Add a timeout to the web module cached files to allow old entries to be deleted.
1 parent 01946d5 commit 9db202b

File tree

13 files changed

+146
-84
lines changed

13 files changed

+146
-84
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Using the following categories, list your changes in this order:
4444
- If using `settings.py:REACTPY_DATABASE`, `reactpy_django.database.Router` must now be registered in `settings.py:DATABASE_ROUTERS`.
4545
- By default, ReactPy will now use a backhaul thread to increase performance.
4646
- Minimum Python version required is now `3.9`
47+
- A thread-safe cache is no longer required.
4748

4849
## [3.2.1] - 2023-06-29
4950

docs/python/settings.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Cache used to store ReactPy web modules.
2-
# ReactPy requires a multiprocessing-safe and thread-safe cache.
2+
# ReactPy benefits from a fast, well indexed cache.
33
# We recommend redis or python-diskcache.
44
REACTPY_CACHE = "default"
55

docs/src/contribute/running-tests.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,5 @@ Alternatively, if you want to only run Django related tests, you can use the fol
4141

4242
```bash linenums="0"
4343
cd tests
44-
python mange.py test
44+
python manage.py test
4545
```

src/reactpy_django/checks.py

+53-19
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import sys
2+
3+
from django.contrib.staticfiles.finders import find
14
from django.core.checks import Error, Tags, Warning, register
25

36

@@ -6,6 +9,8 @@ def reactpy_warnings(app_configs, **kwargs):
69
from django.conf import settings
710
from django.urls import reverse
811

12+
from reactpy_django.config import REACTPY_FAILED_COMPONENTS
13+
914
warnings = []
1015

1116
# REACTPY_DATABASE is not an in-memory database.
@@ -25,23 +30,6 @@ def reactpy_warnings(app_configs, **kwargs):
2530
)
2631
)
2732

28-
# REACTPY_CACHE is not an in-memory cache.
29-
if getattr(settings, "CACHES", {}).get(
30-
getattr(settings, "REACTPY_CACHE", "default"), {}
31-
).get("BACKEND", None) in {
32-
"django.core.cache.backends.dummy.DummyCache",
33-
"django.core.cache.backends.locmem.LocMemCache",
34-
}:
35-
warnings.append(
36-
Warning(
37-
"Using ReactPy with an in-memory cache can cause unexpected "
38-
"behaviors.",
39-
hint="Configure settings.py:CACHES[REACTPY_CACHE], to use a "
40-
"multiprocessing and thread safe cache.",
41-
id="reactpy_django.W002",
42-
)
43-
)
44-
4533
# ReactPy URLs exist
4634
try:
4735
reverse("reactpy:web_modules", kwargs={"file": "example"})
@@ -52,10 +40,47 @@ def reactpy_warnings(app_configs, **kwargs):
5240
"ReactPy URLs have not been registered.",
5341
hint="""Add 'path("reactpy/", include("reactpy_django.http.urls"))' """
5442
"to your application's urlpatterns.",
43+
id="reactpy_django.W002",
44+
)
45+
)
46+
47+
# Warn if REACTPY_BACKHAUL_THREAD is set to True on Linux with Daphne
48+
if (
49+
sys.argv
50+
and sys.argv[0].endswith("daphne")
51+
and getattr(settings, "REACTPY_BACKHAUL_THREAD", True)
52+
and sys.platform == "linux"
53+
):
54+
warnings.append(
55+
Warning(
56+
"REACTPY_BACKHAUL_THREAD is enabled but you running with Daphne on Linux. "
57+
"This configuration is known to be unstable.",
58+
hint="Set settings.py:REACTPY_BACKHAUL_THREAD to False or use a different webserver.",
5559
id="reactpy_django.W003",
5660
)
5761
)
5862

63+
# Check if reactpy_django/client.js is available
64+
if not find("reactpy_django/client.js"):
65+
warnings.append(
66+
Warning(
67+
"ReactPy client.js could not be found within Django static files!",
68+
hint="Check your Django static file configuration.",
69+
id="reactpy_django.W004",
70+
)
71+
)
72+
73+
# Check if any components failed to be registered
74+
if REACTPY_FAILED_COMPONENTS:
75+
warnings.append(
76+
Warning(
77+
"ReactPy failed to register the following components:\n\t+ "
78+
+ "\n\t+ ".join(REACTPY_FAILED_COMPONENTS),
79+
hint="Check if these paths are valid, or if an exception is being raised during import.",
80+
id="reactpy_django.W005",
81+
)
82+
)
83+
5984
return warnings
6085

6186

@@ -69,8 +94,7 @@ def reactpy_errors(app_configs, **kwargs):
6994
if not getattr(settings, "ASGI_APPLICATION", None):
7095
errors.append(
7196
Error(
72-
"ASGI_APPLICATION is not defined."
73-
" ReactPy requires ASGI to be enabled.",
97+
"ASGI_APPLICATION is not defined, but ReactPy requires ASGI.",
7498
hint="Add ASGI_APPLICATION to settings.py.",
7599
id="reactpy_django.E001",
76100
)
@@ -150,4 +174,14 @@ def reactpy_errors(app_configs, **kwargs):
150174
)
151175
)
152176

177+
# Check for dependencies
178+
if "channels" not in settings.INSTALLED_APPS:
179+
errors.append(
180+
Error(
181+
"Django Channels is not installed.",
182+
hint="Add 'channels' to settings.py:INSTALLED_APPS.",
183+
id="reactpy_django.E009",
184+
)
185+
)
186+
153187
return errors

src/reactpy_django/components.py

-1
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,6 @@ def _cached_static_contents(static_path: str):
210210
)
211211

212212
# Fetch the file from cache, if available
213-
# Cache is preferrable to `use_memo` due to multiprocessing capabilities
214213
last_modified_time = os.stat(abs_path).st_mtime
215214
cache_key = f"reactpy_django:static_contents:{static_path}"
216215
file_contents = caches[REACTPY_CACHE].get(

src/reactpy_django/config.py

+1-19
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
from __future__ import annotations
22

3-
import logging
4-
import sys
5-
63
from django.conf import settings
74
from django.core.cache import DEFAULT_CACHE_ALIAS
85
from django.db import DEFAULT_DB_ALIAS
@@ -16,12 +13,10 @@
1613
)
1714
from reactpy_django.utils import import_dotted_path
1815

19-
_logger = logging.getLogger(__name__)
20-
21-
2216
# Non-configurable values
2317
REACTPY_DEBUG_MODE.set_current(getattr(settings, "DEBUG"))
2418
REACTPY_REGISTERED_COMPONENTS: dict[str, ComponentConstructor] = {}
19+
REACTPY_FAILED_COMPONENTS: set[str] = set()
2520
REACTPY_VIEW_COMPONENT_IFRAMES: dict[str, ViewComponentIframe] = {}
2621

2722

@@ -68,16 +63,3 @@
6863
"REACTPY_BACKHAUL_THREAD",
6964
True,
7065
)
71-
72-
# Settings checks (separate from Django checks)
73-
if (
74-
sys.platform == "linux"
75-
and sys.argv
76-
and sys.argv[0].endswith("daphne")
77-
and REACTPY_BACKHAUL_THREAD
78-
):
79-
_logger.warning(
80-
"ReactPy is running on Linux with Daphne, but REACTPY_BACKHAUL_THREAD is set "
81-
"to True. This configuration is known to be unstable. Either set "
82-
"REACTPY_BACKHAUL_THREAD to False, or run ReactPy with a different ASGI server."
83-
)

src/reactpy_django/http/views.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse:
1515
from reactpy_django.config import REACTPY_CACHE
1616

1717
web_modules_dir = REACTPY_WEB_MODULES_DIR.current
18-
path = os.path.abspath(web_modules_dir.joinpath(*file.split("/")))
18+
path = os.path.abspath(web_modules_dir.joinpath(file))
1919

2020
# Prevent attempts to walk outside of the web modules dir
2121
if str(web_modules_dir) != os.path.commonpath((path, web_modules_dir)):
@@ -25,18 +25,18 @@ async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse:
2525

2626
# Fetch the file from cache, if available
2727
last_modified_time = os.stat(path).st_mtime
28-
cache_key = create_cache_key("web_module", str(path).lstrip(str(web_modules_dir)))
29-
response = await caches[REACTPY_CACHE].aget(
28+
cache_key = create_cache_key("web_modules", path)
29+
file_contents = await caches[REACTPY_CACHE].aget(
3030
cache_key, version=int(last_modified_time)
3131
)
32-
if response is None:
32+
if file_contents is None:
3333
async with async_open(path, "r") as fp:
34-
response = HttpResponse(await fp.read(), content_type="text/javascript")
34+
file_contents = await fp.read()
3535
await caches[REACTPY_CACHE].adelete(cache_key)
3636
await caches[REACTPY_CACHE].aset(
37-
cache_key, response, timeout=None, version=int(last_modified_time)
37+
cache_key, file_contents, timeout=604800, version=int(last_modified_time)
3838
)
39-
return response
39+
return HttpResponse(file_contents, content_type="text/javascript")
4040

4141

4242
async def view_to_component_iframe(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Generated by Django 4.2.3 on 2023-08-04 05:49
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("reactpy_django", "0003_componentsession_delete_componentparams"),
9+
]
10+
11+
operations = [
12+
migrations.CreateModel(
13+
name="Config",
14+
fields=[
15+
(
16+
"id",
17+
models.BigAutoField(
18+
auto_created=True,
19+
primary_key=True,
20+
serialize=False,
21+
verbose_name="ID",
22+
),
23+
),
24+
("cleaned_at", models.DateTimeField(auto_now_add=True)),
25+
],
26+
),
27+
]

src/reactpy_django/models.py

+16
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,19 @@ class ComponentSession(models.Model):
99
uuid = models.UUIDField(primary_key=True, editable=False, unique=True) # type: ignore
1010
params = models.BinaryField(editable=False) # type: ignore
1111
last_accessed = models.DateTimeField(auto_now_add=True) # type: ignore
12+
13+
14+
class Config(models.Model):
15+
"""A singleton model for storing ReactPy configuration."""
16+
17+
cleaned_at = models.DateTimeField(auto_now_add=True) # type: ignore
18+
19+
def save(self, *args, **kwargs):
20+
"""Singleton save method."""
21+
self.pk = 1
22+
super().save(*args, **kwargs)
23+
24+
@classmethod
25+
def load(cls):
26+
obj, created = cls.objects.get_or_create(pk=1)
27+
return obj

src/reactpy_django/utils.py

+19-29
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,13 @@
55
import logging
66
import os
77
import re
8-
from datetime import datetime, timedelta
8+
from datetime import timedelta
99
from fnmatch import fnmatch
1010
from importlib import import_module
1111
from inspect import iscoroutinefunction
1212
from typing import Any, Callable, Sequence
1313

1414
from channels.db import database_sync_to_async
15-
from django.core.cache import caches
1615
from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects
1716
from django.db.models.base import Model
1817
from django.db.models.query import QuerySet
@@ -24,7 +23,6 @@
2423

2524
from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError
2625

27-
2826
_logger = logging.getLogger(__name__)
2927
_component_tag = r"(?P<tag>component)"
3028
_component_path = r"(?P<path>\"[^\"'\s]+\"|'[^\"'\s]+')"
@@ -88,14 +86,18 @@ def _register_component(dotted_path: str) -> Callable:
8886
"""Adds a component to the mapping of registered components.
8987
This should only be called on startup to maintain synchronization during mulitprocessing.
9088
"""
91-
from reactpy_django.config import REACTPY_REGISTERED_COMPONENTS
89+
from reactpy_django.config import (
90+
REACTPY_FAILED_COMPONENTS,
91+
REACTPY_REGISTERED_COMPONENTS,
92+
)
9293

9394
if dotted_path in REACTPY_REGISTERED_COMPONENTS:
9495
return REACTPY_REGISTERED_COMPONENTS[dotted_path]
9596

9697
try:
9798
REACTPY_REGISTERED_COMPONENTS[dotted_path] = import_dotted_path(dotted_path)
9899
except AttributeError as e:
100+
REACTPY_FAILED_COMPONENTS.add(dotted_path)
99101
raise ComponentDoesNotExistError(
100102
f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}."
101103
) from e
@@ -266,7 +268,7 @@ def django_query_postprocessor(
266268
# Force the query to execute
267269
getattr(data, field.name, None)
268270

269-
if many_to_one and type(field) == ManyToOneRel:
271+
if many_to_one and type(field) == ManyToOneRel: # noqa: #E721
270272
prefetch_fields.append(field.related_name or f"{field.name}_set")
271273

272274
elif many_to_many and isinstance(field, ManyToManyField):
@@ -332,35 +334,23 @@ def create_cache_key(*args):
332334
def db_cleanup(immediate: bool = False):
333335
"""Deletes expired component sessions from the database.
334336
This function may be expanded in the future to include additional cleanup tasks."""
335-
from .config import REACTPY_CACHE, REACTPY_DEBUG_MODE, REACTPY_RECONNECT_MAX
336-
from .models import ComponentSession
337-
338-
clean_started_at = datetime.now()
339-
cache_key: str = create_cache_key("last_cleaned")
340-
now_str: str = datetime.strftime(timezone.now(), DATE_FORMAT)
341-
cleaned_at_str: str = caches[REACTPY_CACHE].get(cache_key)
342-
cleaned_at: datetime = timezone.make_aware(
343-
datetime.strptime(cleaned_at_str or now_str, DATE_FORMAT)
344-
)
345-
clean_needed_by = cleaned_at + timedelta(seconds=REACTPY_RECONNECT_MAX)
346-
expires_by: datetime = timezone.now() - timedelta(seconds=REACTPY_RECONNECT_MAX)
337+
from .config import REACTPY_DEBUG_MODE, REACTPY_RECONNECT_MAX
338+
from .models import ComponentSession, Config
347339

348-
# Component params exist in the DB, but we don't know when they were last cleaned
349-
if not cleaned_at_str and ComponentSession.objects.all():
350-
_logger.warning(
351-
"ReactPy has detected component sessions in the database, "
352-
"but no timestamp was found in cache. This may indicate that "
353-
"the cache has been cleared."
354-
)
340+
config = Config.load()
341+
start_time = timezone.now()
342+
cleaned_at = config.cleaned_at
343+
clean_needed_by = cleaned_at + timedelta(seconds=REACTPY_RECONNECT_MAX)
355344

356345
# Delete expired component parameters
357-
# Use timestamps in cache (`cleaned_at_str`) as a no-dependency rate limiter
358-
if immediate or not cleaned_at_str or timezone.now() >= clean_needed_by:
359-
ComponentSession.objects.filter(last_accessed__lte=expires_by).delete()
360-
caches[REACTPY_CACHE].set(cache_key, now_str, timeout=None)
346+
if immediate or timezone.now() >= clean_needed_by:
347+
expiration_date = timezone.now() - timedelta(seconds=REACTPY_RECONNECT_MAX)
348+
ComponentSession.objects.filter(last_accessed__lte=expiration_date).delete()
349+
config.cleaned_at = timezone.now()
350+
config.save()
361351

362352
# Check if cleaning took abnormally long
363-
clean_duration = datetime.now() - clean_started_at
353+
clean_duration = timezone.now() - start_time
364354
if REACTPY_DEBUG_MODE and clean_duration.total_seconds() > 1:
365355
_logger.warning(
366356
"ReactPy has taken %s seconds to clean up expired component sessions. "

tests/test_app/admin.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from django.contrib import admin
2+
from reactpy_django.models import ComponentSession, Config
3+
24
from test_app.models import (
35
AsyncForiegnChild,
46
AsyncRelationalChild,
@@ -10,8 +12,6 @@
1012
TodoItem,
1113
)
1214

13-
from reactpy_django.models import ComponentSession
14-
1515

1616
@admin.register(TodoItem)
1717
class TodoItemAdmin(admin.ModelAdmin):
@@ -55,4 +55,9 @@ class AsyncForiegnChildAdmin(admin.ModelAdmin):
5555

5656
@admin.register(ComponentSession)
5757
class ComponentSessionAdmin(admin.ModelAdmin):
58-
list_display = ("uuid", "last_accessed")
58+
list_display = ["uuid", "last_accessed"]
59+
60+
61+
@admin.register(Config)
62+
class ConfigAdmin(admin.ModelAdmin):
63+
list_display = ["pk", "cleaned_at"]

0 commit comments

Comments
 (0)