Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce redis storage which syncs between multiple instances #4074

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7aff68d
extracted classes from storage.py and first minimal Redis sync for ap…
rodja Dec 8, 2024
c98a79d
cleanup
rodja Dec 8, 2024
8acae80
restructure location of files to ensure we do not break existing code
rodja Dec 15, 2024
a387927
add documentation for tab storage
rodja Dec 15, 2024
c72a11e
only use redis if NICEGUI_REDIS_URL is provided as environment variable
rodja Dec 15, 2024
be819e6
implement redis storage for app.storage.user and began with redis_sto…
rodja Dec 15, 2024
7fb3c88
make redis an optional dependency
rodja Dec 15, 2024
31c9a23
ensure we have an async initialized app.storage.user
rodja Dec 15, 2024
74dffcf
fix argument passing
rodja Dec 15, 2024
740dd9e
also use new async init for file storage
rodja Dec 15, 2024
5045b2d
update lock file
rodja Dec 15, 2024
285001b
make loading from local persistence async
rodja Dec 15, 2024
37a717c
cleanup
rodja Dec 15, 2024
deaf35d
load persistence in middleware to ensure it's available for fastAPI c…
rodja Dec 15, 2024
224eed5
fix storage id naming
rodja Dec 15, 2024
c3df384
app.storage.tab now works with redis storage
rodja Dec 15, 2024
2dd7ed5
allow to configure redis key prefix and add some documentation
rodja Dec 15, 2024
2679102
ensure we always have a tab storage available
rodja Dec 15, 2024
004d7c5
make method private
rodja Dec 16, 2024
7c411e8
code review
falkoschindler Dec 16, 2024
be9afd0
re-order columns
falkoschindler Dec 16, 2024
d85602f
tab and user survive server restarts
falkoschindler Dec 16, 2024
6c03b99
fix max_tab_storage_age demo
rodja Jan 11, 2025
e2a0d45
mention limitations in documentation
rodja Jan 11, 2025
cda3fd0
Merge commit 'e6fa211cf9f1f4b43b0ed5c4eb72c57236b49d3b' into redis-st…
rodja Jan 11, 2025
dad02ce
fix typing
rodja Jan 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ services:
image: traefik:v2.3
command:
- --providers.docker
- --api.insecure=true
- --accesslog # http access log
- --log #Traefik log, for configurations and errors
- --api # Enable the Dashboard and API
Expand Down
3 changes: 3 additions & 0 deletions examples/redis_storage/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM zauberzeug/nicegui:2.8.0

RUN python -m pip install redis
35 changes: 35 additions & 0 deletions examples/redis_storage/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
services:
x-nicegui: &nicegui-service
build:
context: .
environment:
- NICEGUI_REDIS_URL=redis://redis:6379
volumes:
- ./:/app
- ../../nicegui:/app/nicegui
labels:
- traefik.enable=true
- traefik.http.routers.nicegui.rule=PathPrefix(`/`)
- traefik.http.services.nicegui.loadbalancer.server.port=8080
- traefik.http.services.nicegui.loadbalancer.sticky.cookie=true

nicegui1:
<<: *nicegui-service

nicegui2:
<<: *nicegui-service

redis:
image: redis:alpine
ports:
- "6379:6379"

proxy:
image: traefik:v2.10
command:
- --providers.docker
- --entrypoints.web.address=:80
ports:
- "8080:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
13 changes: 13 additions & 0 deletions examples/redis_storage/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env python3
from nicegui import app, ui


@ui.page('/')
async def index():
ui.input('general').bind_value(app.storage.general, 'text')
ui.input('user').bind_value(app.storage.user, 'text')
await ui.context.client.connected()
ui.input('tab').bind_value(app.storage.tab, 'text')


ui.run(storage_secret='your private key to secure the browser session cookie')
3 changes: 2 additions & 1 deletion nicegui/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from . import elements, html, run, ui
from . import elements, html, run, storage, ui
from .api_router import APIRouter
from .app.app import App
from .client import Client
Expand All @@ -20,5 +20,6 @@
'elements',
'html',
'run',
'storage',
'ui',
]
3 changes: 3 additions & 0 deletions nicegui/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ def __init__(self, **kwargs) -> None:
self._disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
self._exception_handlers: List[Callable[..., Any]] = [log.exception]

self.on_startup(self.storage.general.initialize)
self.on_shutdown(self.storage.on_shutdown)

@property
def is_starting(self) -> bool:
"""Return whether NiceGUI is starting."""
Expand Down
2 changes: 2 additions & 0 deletions nicegui/nicegui.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ async def _on_handshake(sid: str, data: Dict[str, Any]) -> bool:
client.environ = sio.get_environ(sid)
await sio.enter_room(sid, client.id)
client.handle_handshake(data.get('next_message_id'))
assert client.tab_id is not None
await core.app.storage._create_tab_storage(client.tab_id) # pylint: disable=protected-access
return True


Expand Down
3 changes: 2 additions & 1 deletion nicegui/optional_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
'plotly',
'polars',
'pyecharts',
'webview',
'redis',
'sass',
'webview',
]


Expand Down
11 changes: 11 additions & 0 deletions nicegui/persistence/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from .file_persistent_dict import FilePersistentDict
from .persistent_dict import PersistentDict
from .read_only_dict import ReadOnlyDict
from .redis_persistent_dict import RedisPersistentDict

__all__ = [
'FilePersistentDict',
'PersistentDict',
'ReadOnlyDict',
'RedisPersistentDict',
]
48 changes: 48 additions & 0 deletions nicegui/persistence/file_persistent_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from pathlib import Path
from typing import Optional

import aiofiles

from nicegui import background_tasks, core, json
from nicegui.logging import log

from .persistent_dict import PersistentDict


class FilePersistentDict(PersistentDict):

def __init__(self, filepath: Path, encoding: Optional[str] = None, *, indent: bool = False) -> None:
self.filepath = filepath
self.encoding = encoding
self.indent = indent
super().__init__(data={}, on_change=self.backup)

async def initialize(self) -> None:
try:
if self.filepath.exists():
async with aiofiles.open(self.filepath, encoding=self.encoding) as f:
data = json.loads(await f.read())
else:
data = {}
self.update(data)
except Exception:
log.warning(f'Could not load storage file {self.filepath}')

def backup(self) -> None:
"""Back up the data to the given file path."""
if not self.filepath.exists():
if not self:
return
self.filepath.parent.mkdir(exist_ok=True)

async def backup() -> None:
async with aiofiles.open(self.filepath, 'w', encoding=self.encoding) as f:
await f.write(json.dumps(self, indent=self.indent))
if core.loop:
background_tasks.create_lazy(backup(), name=self.filepath.stem)
else:
core.app.on_startup(backup())

def clear(self) -> None:
super().clear()
self.filepath.unlink(missing_ok=True)
13 changes: 13 additions & 0 deletions nicegui/persistence/persistent_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import abc

from nicegui import observables


class PersistentDict(observables.ObservableDict, abc.ABC):

@abc.abstractmethod
async def initialize(self) -> None:
"""Load initial data from the persistence layer."""

async def close(self) -> None:
"""Clean up the persistence layer."""
24 changes: 24 additions & 0 deletions nicegui/persistence/read_only_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from collections.abc import MutableMapping
from typing import Any, Dict, Iterator


class ReadOnlyDict(MutableMapping):

def __init__(self, data: Dict[Any, Any], write_error_message: str = 'Read-only dict') -> None:
self._data: Dict[Any, Any] = data
self._write_error_message: str = write_error_message

def __getitem__(self, item: Any) -> Any:
return self._data[item]

def __setitem__(self, key: Any, value: Any) -> None:
raise TypeError(self._write_error_message)

def __delitem__(self, key: Any) -> None:
raise TypeError(self._write_error_message)

def __iter__(self) -> Iterator:
return iter(self._data)

def __len__(self) -> int:
return len(self._data)
60 changes: 60 additions & 0 deletions nicegui/persistence/redis_persistent_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from .. import background_tasks, core, json, optional_features
from ..logging import log
from .persistent_dict import PersistentDict

try:
import redis.asyncio as redis
optional_features.register('redis')
except ImportError:
pass


class RedisPersistentDict(PersistentDict):

def __init__(self, *, url: str, id: str, key_prefix: str = 'nicegui:') -> None: # pylint: disable=redefined-builtin
if not optional_features.has('redis'):
raise ImportError('Redis is not installed. Please run "pip install nicegui[redis]".')
self.redis_client = redis.from_url(url)
self.pubsub = self.redis_client.pubsub()
self.key = key_prefix + id
super().__init__(data={}, on_change=self.publish)

async def initialize(self) -> None:
"""Load initial data from Redis and start listening for changes."""
try:
data = await self.redis_client.get(self.key)
self.update(json.loads(data) if data else {})
except Exception:
log.warning(f'Could not load data from Redis with key {self.key}')
await self.pubsub.subscribe(self.key + 'changes')

async def listen():
async for message in self.pubsub.listen():
if message['type'] == 'message':
new_data = json.loads(message['data'])
if new_data != self:
self.update(new_data)

background_tasks.create(listen(), name=f'redis-listen-{self.key}')

def publish(self) -> None:
"""Publish the data to Redis and notify other instances."""
async def backup() -> None:
pipeline = self.redis_client.pipeline()
pipeline.set(self.key, json.dumps(self))
pipeline.publish(self.key + 'changes', json.dumps(self))
await pipeline.execute()
if core.loop:
background_tasks.create_lazy(backup(), name=f'redis-{self.key}')
else:
core.app.on_startup(backup())

async def close(self) -> None:
"""Close Redis connection and subscription."""
await self.pubsub.unsubscribe()
await self.pubsub.close()
await self.redis_client.close()

def clear(self) -> None:
super().clear()
self.redis_client.delete(self.key)
Loading
Loading