Skip to content

Commit

Permalink
Keep sessions marked as deleted
Browse files Browse the repository at this point in the history
Keep sessions marked as deleted for an hour before purging them from
the Redis store. This change is to allow the client to recover from a
session deletion when the user navigates back to the page.
  • Loading branch information
imankulov committed Feb 5, 2024
1 parent 6160625 commit 0c0a42c
Show file tree
Hide file tree
Showing 4 changed files with 91 additions and 3 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## UNRELEASED

- Kept sessions marked as deleted for an hour before purging them from the Redis store. This change is to allow the client to recover from a session deletion when the user navigates back to the page.

## 1.10.0 (2024-01-25)

- Added CancelRendering() exception. The exception makes it possible to cancel the command execution and return an empty string instead of a half-rendered component or an exception.
Expand Down
30 changes: 27 additions & 3 deletions livecomponents/manager/stores.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,26 @@ def clear_all_sessions(self) -> None:


class RedisStateStore(IStateStore):
"""Redis-based state store."""
"""Redis-based state store.
Args:
redis_url: URL of the Redis server.
state_prefix: Prefix for keys that store component states.
context_prefix: Prefix for keys that store component contexts.
templates_prefix: Prefix for keys that store component templates.
template_cache_prefix: Prefix for keys that store cached component templates.
ttl: Time-to-live for session keys. Each time the session is accessed, the TTL
is reset. If the session is not accessed for this time, it is deleted, and
subsequent accesses will result in a "Session not found" error and a 410
status code in the response. How this status is handled is up to the
client. There is no default behavior, but the client can choose to reload
the page, for example.
ttl_gc: Time-to-live for garbage collection. This TTL is lower than the session
TTL, and it's set for sessions scheduled for deletion in response to
a "clear_session" call. We don't delete the session immediately in case the
client decides to access the page again when clicking the back button,
for example.
"""

def __init__(
self,
Expand All @@ -116,13 +135,15 @@ def __init__(
templates_prefix: str = "lc:templates:",
template_cache_prefix: str = "lc:template_cache:",
ttl: datetime.timedelta = datetime.timedelta(days=1),
ttl_gc: datetime.timedelta = datetime.timedelta(hours=1),
):
self.client = Redis.from_url(redis_url) # type: ignore
self.key_prefix = state_prefix
self.context_prefix = context_prefix
self.templates_prefix = templates_prefix
self.template_cache_prefix = template_cache_prefix
self.ttl = ttl
self.ttl_gc = ttl_gc

def session_exists(self, session_id: str) -> bool:
key_name = self._get_key_name(self.key_prefix, session_id)
Expand Down Expand Up @@ -195,8 +216,11 @@ def restore_component_template(self, state_addr: StateAddress) -> bytes | None:

def clear_session(self, session_id: str) -> None:
with self.client.pipeline() as pipe:
pipe.delete(self._get_key_name(self.key_prefix, session_id))
pipe.delete(self._get_key_name(self.templates_prefix, session_id))
# Instead of deleting the keys, we set a TTL for garbage collection.
pipe.expire(self._get_key_name(self.key_prefix, session_id), self.ttl_gc)
pipe.expire(
self._get_key_name(self.templates_prefix, session_id), self.ttl_gc
)
pipe.execute()

def clear_all_sessions(self) -> None:
Expand Down
9 changes: 9 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest

from livecomponents.manager import get_state_manager
from livecomponents.manager.stores import RedisStateStore

# Playwright runs the async loop which makes Django raising a SynchronousOnlyOperation
# exception. This is a workaround to allow async code in tests.
Expand All @@ -16,3 +17,11 @@ def state_manager():
state_manager = get_state_manager()
state_manager.store.clear_all_sessions()
return state_manager


@pytest.fixture
def redis_state_store():
redis_url = os.environ.get("REDIS_URL")
if not redis_url:
pytest.skip("Redis URL not provided")
return RedisStateStore(redis_url=redis_url)
51 changes: 51 additions & 0 deletions tests/test_redis_state_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from livecomponents.types import StateAddress


def test_save_state_sets_ttl(redis_state_store):
state_addr = StateAddress(session_id="session_id", component_id="|root:0")
state = b"state"
redis_state_store.save_state(state_addr, state)

# A bit less than ttl
state_key = get_state_key(redis_state_store, state_addr)
assert (
redis_state_store.client.ttl(state_key)
> redis_state_store.ttl.total_seconds() - 10
)


def test_clear_session_sets_ttl_gc(redis_state_store):
session_id = "session_id"
state_addr = StateAddress(session_id=session_id, component_id="|root:0")
state = b"state"
redis_state_store.save_state(state_addr, state)

# A bit less than ttl_gc
redis_state_store.clear_session(session_id)
state_key = get_state_key(redis_state_store, state_addr)
assert (
redis_state_store.client.ttl(state_key)
<= redis_state_store.ttl_gc.total_seconds()
)


def test_clear_session_and_then_save_state_recovers_ttl(redis_state_store):
session_id = "session_id"
state_addr = StateAddress(session_id=session_id, component_id="|root:0")
state = b"state"
redis_state_store.save_state(state_addr, state)
redis_state_store.clear_session(session_id)
redis_state_store.save_state(state_addr, state)

# A bit less than ttl again
state_key = get_state_key(redis_state_store, state_addr)
assert (
redis_state_store.client.ttl(state_key)
> redis_state_store.ttl.total_seconds() - 10
)


def get_state_key(redis_state_store, state_addr):
return redis_state_store._get_key_name(
redis_state_store.key_prefix, state_addr.session_id
)

0 comments on commit 0c0a42c

Please sign in to comment.