|
1 | 1 | import logging
|
| 2 | +from contextlib import contextmanager |
2 | 3 | from typing import Any, Union
|
3 | 4 |
|
4 | 5 | from django.conf import settings
|
5 | 6 | from django.contrib.sessions.backends.base import SessionBase
|
| 7 | +from django.core.cache import caches |
6 | 8 | from django.http import HttpRequest
|
7 | 9 | from django.utils import translation
|
8 | 10 |
|
|
37 | 39 |
|
38 | 40 | logger = logging.getLogger(__name__)
|
39 | 41 |
|
| 42 | +# with the interval of 0.1s, this gives us 2.0 / 0.1 = 20 concurrent requests, |
| 43 | +# which is far above the typical browser concurrency mode (~6-8 requests). |
| 44 | +SESSION_LOCK_TIMEOUT_SECONDS = 2.0 |
| 45 | + |
| 46 | + |
| 47 | +@contextmanager |
| 48 | +def _session_lock(session: SessionBase, key: str): |
| 49 | + """ |
| 50 | + Helper to manage session data mutations for the specified key. |
| 51 | +
|
| 52 | + Concurrent session updates see stale data from when the request initially |
| 53 | + got processed, so any added items from parallel requests is not taken into |
| 54 | + account. This context manager refreshes the session data just-in-time and uses |
| 55 | + a Redis distributed lock to synchronize access. |
| 56 | +
|
| 57 | + .. note:: this is pretty deep in Django internals, there doesn't appear to be a |
| 58 | + public API for things like these :( |
| 59 | + """ |
| 60 | + # only existing session have an existing key. If this is a new session, it hasn't |
| 61 | + # been persisted to the backend yet, so there is also no possible race condition. |
| 62 | + is_new = session.session_key is None |
| 63 | + if is_new: |
| 64 | + yield |
| 65 | + return |
| 66 | + |
| 67 | + # See TODO in settings about renaming this cache |
| 68 | + redis_cache = caches["portalocker"] |
| 69 | + |
| 70 | + # make the lock tied to the session itself, so that we don't affect other people's |
| 71 | + # sessions. |
| 72 | + cache_key = f"django:session-update:{session.session_key}" |
| 73 | + |
| 74 | + # this is... tricky. To ensure we aren't still operating on stale data, we refresh |
| 75 | + # the session data after acquiring a lock so that we're the only one that will be |
| 76 | + # writing to it. |
| 77 | + # |
| 78 | + # For the locking interface, see redis-py :meth:`redis.client.Redis.lock` |
| 79 | + |
| 80 | + logger.debug("Acquiring session lock for session %s", session.session_key) |
| 81 | + with redis_cache.lock( |
| 82 | + cache_key, |
| 83 | + # max lifetime for the lock itself, must always be provided in case something |
| 84 | + # crashes and we fail to call release |
| 85 | + timeout=SESSION_LOCK_TIMEOUT_SECONDS, |
| 86 | + # wait rather than failing immediately, we are trying to handle parallel |
| 87 | + # requests here. Can't explicitly specify this, see |
| 88 | + # https://github.com/jazzband/django-redis/issues/596. redis-py default is True. |
| 89 | + # blocking=True, |
| 90 | + # how long we can try to acquire the lock |
| 91 | + blocking_timeout=SESSION_LOCK_TIMEOUT_SECONDS, |
| 92 | + ): |
| 93 | + logger.debug("Got session lock for session %s", session.session_key) |
| 94 | + # nasty bit... the session itself can already be modified with *other* |
| 95 | + # information that isn't relevant. So, we load the data from the storage again |
| 96 | + # and only look at the provided key. If that one is different, we update our |
| 97 | + # local data. We can not just reset to the result of session.load(), as that |
| 98 | + # would discard modifications that should be persisted. |
| 99 | + persisted_data = session.load() |
| 100 | + if (data_slice := persisted_data.get(key)) != (current := session.get(key)): |
| 101 | + logger.debug( |
| 102 | + "Data from storage is different than what we currently have. " |
| 103 | + "Session %s, key '%s' - in storage: %s, our view: %s", |
| 104 | + session.session_key, |
| 105 | + key, |
| 106 | + data_slice, |
| 107 | + current, |
| 108 | + ) |
| 109 | + session[key] = data_slice |
| 110 | + logger.debug( |
| 111 | + "Updated key '%s' from storage for session %s", key, session.session_key |
| 112 | + ) |
| 113 | + |
| 114 | + # execute the calling code and exit, clearing the lock. |
| 115 | + yield |
| 116 | + |
| 117 | + logger.debug( |
| 118 | + "New session data for session %s is: %s", |
| 119 | + session.session_key, |
| 120 | + session._session, |
| 121 | + ) |
| 122 | + |
| 123 | + # ensure we save in-between to persist the modifications, before the request |
| 124 | + # may even be finished |
| 125 | + session.save() |
| 126 | + logger.debug("Saved session data for session %s", session.session_key) |
| 127 | + |
40 | 128 |
|
41 | 129 | def append_to_session_list(session: SessionBase, session_key: str, value: Any) -> None:
|
42 |
| - # note: possible race condition with concurrent requests |
43 |
| - active = session.get(session_key, []) |
44 |
| - if value not in active: |
45 |
| - active.append(value) |
46 |
| - session[session_key] = active |
| 130 | + with _session_lock(session, session_key): |
| 131 | + active = session.get(session_key, []) |
| 132 | + if value not in active: |
| 133 | + active.append(value) |
| 134 | + session[session_key] = active |
47 | 135 |
|
48 | 136 |
|
49 | 137 | def remove_from_session_list(
|
50 | 138 | session: SessionBase, session_key: str, value: Any
|
51 | 139 | ) -> None:
|
52 |
| - # note: possible race condition with concurrent requests |
53 |
| - active = session.get(session_key, []) |
54 |
| - if value in active: |
55 |
| - active.remove(value) |
56 |
| - session[session_key] = active |
| 140 | + with _session_lock(session, session_key): |
| 141 | + active = session.get(session_key, []) |
| 142 | + if value in active: |
| 143 | + active.remove(value) |
| 144 | + session[session_key] = active |
57 | 145 |
|
58 | 146 |
|
59 | 147 | def add_submmission_to_session(submission: Submission, session: SessionBase) -> None:
|
|
0 commit comments