Skip to content

Commit 06114f3

Browse files
authored
Robust auto-cleaning functionality (#222)
Enables configuration options for auto cleaning functionality, as well as a management command to manually clean.
1 parent 2dde58c commit 06114f3

File tree

17 files changed

+420
-89
lines changed

17 files changed

+420
-89
lines changed

Diff for: CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ Using the following categories, list your changes in this order:
3737
### Added
3838

3939
- Built-in cross-process communication mechanism via the `reactpy_django.hooks.use_channel_layer` hook.
40+
- More robust control over ReactPy clean up tasks!
41+
- `settings.py:REACTPY_CLEAN_INTERVAL` to control how often ReactPy automatically performs cleaning tasks.
42+
- `settings.py:REACTPY_CLEAN_SESSIONS` to control whether ReactPy automatically cleans up expired sessions.
43+
- `settings.py:REACTPY_CLEAN_USER_DATA` to control whether ReactPy automatically cleans up orphaned user data.
44+
- `python manage.py clean_reactpy` command to manually perform ReactPy clean up tasks.
4045

4146
## [3.7.0] - 2024-01-30
4247

Diff for: docs/mkdocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ nav:
1212
- Utilities: reference/utils.md
1313
- Template Tag: reference/template-tag.md
1414
- Settings: reference/settings.md
15+
- Management Commands: reference/management-commands.md
1516
- About:
1617
- Changelog: about/changelog.md
1718
- Contributor Guide:

Diff for: docs/src/reference/components.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ We supply some pre-designed that components can be used to help simplify develop
1212

1313
Automatically convert a Django view into a component.
1414

15-
At this time, this works best with static views that do not rely on HTTP methods other than `GET`.
15+
At this time, this works best with static views with no interactivity.
1616

1717
Compatible with sync or async [Function Based Views](https://docs.djangoproject.com/en/dev/topics/http/views/) and [Class Based Views](https://docs.djangoproject.com/en/dev/topics/class-based-views/).
1818

@@ -186,7 +186,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject.
186186

187187
- No built-in method of signalling events back to the parent component.
188188
- All provided `#!python *args` and `#!python *kwargs` must be serializable values, since they are encoded into the URL.
189-
- The `#!python iframe`'s contents will always load **after** the parent component.
189+
- The `#!python iframe` will always load **after** the parent component.
190190
- CSS styling for `#!python iframe` elements tends to be awkward/difficult.
191191

192192
??? question "How do I use this for Class Based Views?"

Diff for: docs/src/reference/management-commands.md

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## Overview
2+
3+
<p class="intro" markdown>
4+
5+
ReactPy exposes Django management commands that can be used to perform various ReactPy-related tasks.
6+
7+
</p>
8+
9+
---
10+
11+
## Clean ReactPy Command
12+
13+
Command used to manually clean ReactPy data.
14+
15+
When using this command without arguments, it will perform all cleaning operations. You can specify only performing specific cleaning operations through arguments such as `--sessions`.
16+
17+
!!! example "Terminal"
18+
19+
```bash linenums="0"
20+
python manage.py clean_reactpy
21+
```
22+
23+
??? example "See Interface"
24+
25+
Type `python manage.py clean_reactpy --help` to see the available options.

Diff for: docs/src/reference/settings.md

+47-6
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ Set `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `#!python None` to disable
5656

5757
Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
5858

59-
1. You are using `#!python AuthMiddlewareStack` and...
60-
2. You are using Django's `#!python AUTHENTICATION_BACKENDS` setting and...
61-
3. Your Django user model does not define a `#!python backend` attribute.
59+
1. You are using `#!python settings.py:REACTPY_AUTO_RELOGIN=True` and...
60+
2. You are using `#!python AuthMiddlewareStack` and...
61+
3. You are using Django's `#!python AUTHENTICATION_BACKENDS` setting and...
62+
4. Your Django user model does not define a `#!python backend` attribute.
6263

6364
---
6465

@@ -84,7 +85,7 @@ This is useful to continuously update `#!python last_login` timestamps and refre
8485

8586
**Example Value(s):** `#!python "my-reactpy-database"`
8687

87-
Multiprocessing-safe database used by ReactPy, typically for session data.
88+
Multiprocessing-safe database used by ReactPy for database-backed hooks and features.
8889

8990
If configuring this value, it is mandatory to enable our database router like such:
9091

@@ -104,7 +105,7 @@ If configuring this value, it is mandatory to enable our database router like su
104105

105106
Cache used by ReactPy, typically for caching disk operations.
106107

107-
We recommend using [`redis`](https://docs.djangoproject.com/en/dev/topics/cache/#redis), [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache), or [`LocMemCache`](https://docs.djangoproject.com/en/dev/topics/cache/#local-memory-caching).
108+
We recommend using [`redis`](https://docs.djangoproject.com/en/dev/topics/cache/#redis), [`memcache`](https://docs.djangoproject.com/en/dev/topics/cache/#memcached), or [`local-memory caching`](https://docs.djangoproject.com/en/dev/topics/cache/#local-memory-caching).
108109

109110
---
110111

@@ -211,8 +212,48 @@ Maximum number of reconnection attempts before the client gives up.
211212

212213
**Example Value(s):** `#!python 0`, `#!python 60`, `#!python 96000`
213214

214-
Maximum seconds to store ReactPy component sessions.
215+
Maximum seconds a ReactPy component session is valid for. Invalid sessions are deleted during [ReactPy clean up](#auto-clean-settings).
215216

216217
ReactPy sessions include data such as `#!python *args` and `#!python **kwargs` passed into your `#!jinja {% component %}` template tag.
217218

218219
Use `#!python 0` to not store any session data.
220+
221+
---
222+
223+
## Auto-Clean Settings
224+
225+
---
226+
227+
### `#!python REACTPY_CLEAN_INTERVAL`
228+
229+
**Default:** `#!python 604800`
230+
231+
**Example Value(s):** `#!python 0`, `#!python 3600`, `#!python 86400`, `#!python None`
232+
233+
Minimum seconds between ReactPy automatic clean up operations.
234+
235+
The server will check if the interval has passed after every component disconnection, and will perform a clean if needed.
236+
237+
Set this value to `#!python None` to disable automatic clean up operations.
238+
239+
---
240+
241+
### `#!python REACTPY_CLEAN_SESSIONS`
242+
243+
**Default:** `#!python True`
244+
245+
**Example Value(s):** `#!python False`
246+
247+
Configures whether ReactPy should clean up expired component sessions during automatic clean up operations.
248+
249+
---
250+
251+
### `#!python REACTPY_CLEAN_USER_DATA`
252+
253+
**Default:** `#!python True`
254+
255+
**Example Value(s):** `#!python False`
256+
257+
Configures whether ReactPy should clean up orphaned user data during automatic clean up operations.
258+
259+
Typically, user data does not become orphaned unless the server crashes during a `#!python User` delete operation.

Diff for: docs/src/reference/utils.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Typically, this function is automatically called on all components contained wit
8484

8585
This is the default postprocessor for the `#!python use_query` hook.
8686

87-
This postprocessor is designed to avoid Django's `#!python SynchronousOnlyException` by recursively fetching all fields within a `#!python Model` or `#!python QuerySet` to prevent [lazy execution](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy).
87+
Since ReactPy is rendered within an `#!python asyncio` loop, this postprocessor is exists to prevent Django's `#!python SynchronousOnlyException` by recursively prefetching fields within a `#!python Model` or `#!python QuerySet`. This prefetching step works to eliminate Django's [lazy execution](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy) behavior.
8888

8989
=== "components.py"
9090

Diff for: src/reactpy_django/checks.py

+48
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,15 @@ def reactpy_warnings(app_configs, **kwargs):
255255
)
256256
)
257257

258+
if getattr(settings, "REACTPY_CLEAN_SESSION", None):
259+
warnings.append(
260+
Warning(
261+
"REACTPY_CLEAN_SESSION is not a valid property value.",
262+
hint="Did you mean to use REACTPY_CLEAN_SESSIONS instead?",
263+
id="reactpy_django.W019",
264+
)
265+
)
266+
258267
return warnings
259268

260269

@@ -491,4 +500,43 @@ def reactpy_errors(app_configs, **kwargs):
491500
)
492501
)
493502

503+
if not isinstance(config.REACTPY_CLEAN_INTERVAL, (int, type(None))):
504+
errors.append(
505+
Error(
506+
"Invalid type for REACTPY_CLEAN_INTERVAL.",
507+
hint="REACTPY_CLEAN_INTERVAL should be an integer or None.",
508+
id="reactpy_django.E023",
509+
)
510+
)
511+
512+
if (
513+
isinstance(config.REACTPY_CLEAN_INTERVAL, int)
514+
and config.REACTPY_CLEAN_INTERVAL < 0
515+
):
516+
errors.append(
517+
Error(
518+
"Invalid value for REACTPY_CLEAN_INTERVAL.",
519+
hint="REACTPY_CLEAN_INTERVAL should be a positive integer or None.",
520+
id="reactpy_django.E024",
521+
)
522+
)
523+
524+
if not isinstance(config.REACTPY_CLEAN_SESSIONS, bool):
525+
errors.append(
526+
Error(
527+
"Invalid type for REACTPY_CLEAN_SESSIONS.",
528+
hint="REACTPY_CLEAN_SESSIONS should be a boolean.",
529+
id="reactpy_django.E025",
530+
)
531+
)
532+
533+
if not isinstance(config.REACTPY_CLEAN_USER_DATA, bool):
534+
errors.append(
535+
Error(
536+
"Invalid type for REACTPY_CLEAN_USER_DATA.",
537+
hint="REACTPY_CLEAN_USER_DATA should be a boolean.",
538+
id="reactpy_django.E026",
539+
)
540+
)
541+
494542
return errors

Diff for: src/reactpy_django/clean.py

+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from datetime import datetime, timedelta
5+
from typing import TYPE_CHECKING, Literal
6+
7+
from django.contrib.auth import get_user_model
8+
from django.utils import timezone
9+
10+
_logger = logging.getLogger(__name__)
11+
12+
if TYPE_CHECKING:
13+
from reactpy_django.models import Config
14+
15+
CLEAN_NEEDED_BY: datetime = datetime(
16+
year=1, month=1, day=1, tzinfo=timezone.now().tzinfo
17+
)
18+
19+
20+
def clean(
21+
*args: Literal["all", "sessions", "user_data"],
22+
immediate: bool = False,
23+
verbosity: int = 1,
24+
):
25+
from reactpy_django.config import (
26+
REACTPY_CLEAN_SESSIONS,
27+
REACTPY_CLEAN_USER_DATA,
28+
)
29+
from reactpy_django.models import Config
30+
31+
config = Config.load()
32+
if immediate or is_clean_needed(config):
33+
config.cleaned_at = timezone.now()
34+
config.save()
35+
sessions = REACTPY_CLEAN_SESSIONS
36+
user_data = REACTPY_CLEAN_USER_DATA
37+
38+
if args:
39+
sessions = any(value in args for value in {"sessions", "all"})
40+
user_data = any(value in args for value in {"user_data", "all"})
41+
42+
if sessions:
43+
clean_sessions(verbosity)
44+
if user_data:
45+
clean_user_data(verbosity)
46+
47+
48+
def clean_sessions(verbosity: int = 1):
49+
"""Deletes expired component sessions from the database.
50+
As a performance optimization, this is only run once every REACTPY_SESSION_MAX_AGE seconds.
51+
"""
52+
from reactpy_django.config import REACTPY_DEBUG_MODE, REACTPY_SESSION_MAX_AGE
53+
from reactpy_django.models import ComponentSession
54+
55+
if verbosity >= 2:
56+
print("Cleaning ReactPy component sessions...")
57+
58+
start_time = timezone.now()
59+
expiration_date = timezone.now() - timedelta(seconds=REACTPY_SESSION_MAX_AGE)
60+
session_objects = ComponentSession.objects.filter(
61+
last_accessed__lte=expiration_date
62+
)
63+
64+
if verbosity >= 2:
65+
print(f"Deleting {session_objects.count()} expired component sessions...")
66+
67+
session_objects.delete()
68+
69+
if REACTPY_DEBUG_MODE or verbosity >= 2:
70+
inspect_clean_duration(start_time, "component sessions", verbosity)
71+
72+
73+
def clean_user_data(verbosity: int = 1):
74+
"""Delete any user data that is not associated with an existing `User`.
75+
This is a safety measure to ensure that we don't have any orphaned data in the database.
76+
77+
Our `UserDataModel` is supposed to automatically get deleted on every `User` delete signal.
78+
However, we can't use Django to enforce this relationship since ReactPy can be configured to
79+
use any database.
80+
"""
81+
from reactpy_django.config import REACTPY_DEBUG_MODE
82+
from reactpy_django.models import UserDataModel
83+
84+
if verbosity >= 2:
85+
print("Cleaning ReactPy user data...")
86+
87+
start_time = timezone.now()
88+
user_model = get_user_model()
89+
all_users = user_model.objects.all()
90+
all_user_pks = all_users.values_list(user_model._meta.pk.name, flat=True) # type: ignore
91+
92+
# Django doesn't support using QuerySets as an argument with cross-database relations.
93+
if user_model.objects.db != UserDataModel.objects.db:
94+
all_user_pks = list(all_user_pks) # type: ignore
95+
96+
user_data_objects = UserDataModel.objects.exclude(user_pk__in=all_user_pks)
97+
98+
if verbosity >= 2:
99+
print(
100+
f"Deleting {user_data_objects.count()} user data objects not associated with an existing user..."
101+
)
102+
103+
user_data_objects.delete()
104+
105+
if REACTPY_DEBUG_MODE or verbosity >= 2:
106+
inspect_clean_duration(start_time, "user data", verbosity)
107+
108+
109+
def is_clean_needed(config: Config | None = None) -> bool:
110+
"""Check if a clean is needed. This function avoids unnecessary database reads by caching the
111+
CLEAN_NEEDED_BY date."""
112+
from reactpy_django.config import REACTPY_CLEAN_INTERVAL
113+
from reactpy_django.models import Config
114+
115+
global CLEAN_NEEDED_BY
116+
117+
if REACTPY_CLEAN_INTERVAL is None:
118+
return False
119+
120+
if timezone.now() >= CLEAN_NEEDED_BY:
121+
config = config or Config.load()
122+
CLEAN_NEEDED_BY = config.cleaned_at + timedelta(seconds=REACTPY_CLEAN_INTERVAL)
123+
124+
return timezone.now() >= CLEAN_NEEDED_BY
125+
126+
127+
def inspect_clean_duration(start_time: datetime, task_name: str, verbosity: int):
128+
clean_duration = timezone.now() - start_time
129+
130+
if verbosity >= 3:
131+
print(
132+
f"Cleaned ReactPy {task_name} in {clean_duration.total_seconds()} seconds."
133+
)
134+
135+
if clean_duration.total_seconds() > 1:
136+
_logger.warning(
137+
"ReactPy has taken %s seconds to clean %s. "
138+
"This may indicate a performance issue with your system, cache, or database.",
139+
clean_duration.total_seconds(),
140+
task_name,
141+
)

Diff for: src/reactpy_django/components.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ def _django_js(static_path: str):
259259
return html.script(_cached_static_contents(static_path))
260260

261261

262-
def _cached_static_contents(static_path: str):
262+
def _cached_static_contents(static_path: str) -> str:
263263
from reactpy_django.config import REACTPY_CACHE
264264

265265
# Try to find the file within Django's static files
@@ -272,7 +272,7 @@ def _cached_static_contents(static_path: str):
272272
# Fetch the file from cache, if available
273273
last_modified_time = os.stat(abs_path).st_mtime
274274
cache_key = f"reactpy_django:static_contents:{static_path}"
275-
file_contents = caches[REACTPY_CACHE].get(
275+
file_contents: str | None = caches[REACTPY_CACHE].get(
276276
cache_key, version=int(last_modified_time)
277277
)
278278
if file_contents is None:

Diff for: src/reactpy_django/config.py

+15
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,18 @@
118118
"REACTPY_AUTO_RELOGIN",
119119
False,
120120
)
121+
REACTPY_CLEAN_INTERVAL: int | None = getattr(
122+
settings,
123+
"REACTPY_CLEAN_INTERVAL",
124+
604800, # Default to 7 days
125+
)
126+
REACTPY_CLEAN_SESSIONS: bool = getattr(
127+
settings,
128+
"REACTPY_CLEAN_SESSIONS",
129+
True,
130+
)
131+
REACTPY_CLEAN_USER_DATA: bool = getattr(
132+
settings,
133+
"REACTPY_CLEAN_USER_DATA",
134+
True,
135+
)

0 commit comments

Comments
 (0)