feat: adds autosave feature

The pydase service automatically saves the current state to a file now.
The interval between automatic save events can be configured using the
`autosave_interval` argument passed to the pydase.Server.
This commit is contained in:
Mose Müller 2025-03-25 13:41:25 +01:00
parent 1fbcbc72bf
commit f3d659670f
2 changed files with 72 additions and 52 deletions

View File

@ -1,3 +1,4 @@
import asyncio
import contextlib import contextlib
import json import json
import logging import logging
@ -66,43 +67,40 @@ def has_load_state_decorator(prop: property) -> bool:
class StateManager: class StateManager:
""" """
Manages the state of a DataService instance, serving as both a cache and a Manages the state of a DataService instance, serving as both a cache and a
persistence layer. It is designed to provide quick access to the latest known state persistence layer. It provides fast access to the most recently known state of the
for newly connecting web clients without the need for expensive property accesses service and ensures consistent state updates across connected clients and service
that may involve complex calculations or I/O operations. restarts.
The StateManager listens for state change notifications from the DataService's The StateManager is used by the web server to apply updates to service attributes
callback manager and updates its cache accordingly. This cache does not always and to serve the current state to newly connected clients. Internally, it creates a
reflect the most current complex property states but rather retains the value from `DataServiceCache` instance to track the state of public attributes and properties.
the last known state, optimizing for performance and reducing the load on the
system.
While the StateManager ensures that the cached state is as up-to-date as possible, The StateManager also handles state persistence: it can load a previously saved
it does not autonomously update complex properties of the DataService. Such state from disk at startup and periodically autosave the current state to a file
properties must be updated programmatically, for instance, by invoking specific during runtime.
tasks or methods that trigger the necessary operations to refresh their state.
The cached state maintained by the StateManager is particularly useful for web
clients that connect to the system and need immediate access to the current state of
the DataService. By avoiding direct and potentially costly property accesses, the
StateManager provides a snapshot of the DataService's state that is sufficiently
accurate for initial rendering and interaction.
Args: Args:
service: service: The DataService instance whose state is being managed.
The DataService instance whose state is being managed. filename: The file name used for loading and storing the DataService's state.
filename: If provided, the state is loaded from this file at startup and saved to it
The file name used for storing the DataService's state. on shutdown or at regular intervals.
autosave_interval: Interval in seconds between automatic state save events.
If set to `None`, automatic saving is disabled.
Note: Note:
The StateManager's cache updates are triggered by notifications and do not The StateManager does not autonomously poll hardware state. It relies on the
include autonomous updates of complex DataService properties, which must be service to perform such updates. The cache maintained by
managed programmatically. The cache serves the purpose of providing immediate [`DataServiceCache`][pydase.data_service.data_service_cache.DataServiceCache]
state information to web clients, reflecting the state after the last property reflects the last known state as notified by the `DataServiceObserver`, and is
update. used by the web interface to provide fast and accurate state rendering for
connected clients.
""" """
def __init__( def __init__(
self, service: "DataService", filename: str | Path | None = None self,
service: "DataService",
filename: str | Path | None,
autosave_interval: float | None,
) -> None: ) -> None:
self.filename = getattr(service, "_filename", None) self.filename = getattr(service, "_filename", None)
@ -115,6 +113,29 @@ class StateManager:
self.service = service self.service = service
self.cache_manager = DataServiceCache(self.service) self.cache_manager = DataServiceCache(self.service)
self.autosave_interval = autosave_interval
async def autosave(self) -> None:
"""Periodically saves the current service state to the configured file.
This coroutine is automatically started by the [`pydase.Server`][pydase.Server]
when a filename is provided. It runs in the background and writes the latest
known state of the service to disk every `autosave_interval` seconds.
If `autosave_interval` is set to `None`, autosaving is disabled and this
coroutine exits immediately.
"""
if self.autosave_interval is None:
return
while True:
try:
if self.filename is not None:
self.save_state()
await asyncio.sleep(self.autosave_interval)
except Exception as e:
logger.exception(e)
@property @property
def cache_value(self) -> dict[str, SerializedObject]: def cache_value(self) -> dict[str, SerializedObject]:
@ -122,23 +143,21 @@ class StateManager:
return cast(dict[str, SerializedObject], self.cache_manager.cache["value"]) return cast(dict[str, SerializedObject], self.cache_manager.cache["value"])
def save_state(self) -> None: def save_state(self) -> None:
""" """Saves the DataService's current state to a JSON file defined by
Saves the DataService's current state to a JSON file defined by `self.filename`. `self.filename`.
Logs an error if `self.filename` is not set.
""" """
if self.filename is not None: if self.filename is not None:
with open(self.filename, "w") as f: with open(self.filename, "w") as f:
json.dump(self.cache_value, f, indent=4) json.dump(self.cache_value, f, indent=4)
else: else:
logger.info( logger.debug(
"State manager was not initialised with a filename. Skipping " "State manager was not initialised with a filename. Skipping "
"'save_state'..." "'save_state'..."
) )
def load_state(self) -> None: def load_state(self) -> None:
""" """Loads the DataService's state from a JSON file defined by `self.filename`.
Loads the DataService's state from a JSON file defined by `self.filename`.
Updates the service's attributes, respecting type and read-only constraints. Updates the service's attributes, respecting type and read-only constraints.
""" """
@ -191,8 +210,7 @@ class StateManager:
path: str, path: str,
serialized_value: SerializedObject, serialized_value: SerializedObject,
) -> None: ) -> None:
""" """Sets the value of an attribute in the service managed by the `StateManager`
Sets the value of an attribute in the service managed by the `StateManager`
given its path as a dot-separated string. given its path as a dot-separated string.
This method updates the attribute specified by 'path' with 'value' only if the This method updates the attribute specified by 'path' with 'value' only if the

View File

@ -84,21 +84,15 @@ class Server:
The `Server` class provides a flexible server implementation for the `DataService`. The `Server` class provides a flexible server implementation for the `DataService`.
Args: Args:
service: service: The DataService instance that this server will manage.
The DataService instance that this server will manage. host: The host address for the server. Defaults to `'0.0.0.0'`, which means all
host:
The host address for the server. Defaults to `'0.0.0.0'`, which means all
available network interfaces. available network interfaces.
web_port: web_port: The port number for the web server. Defaults to
The port number for the web server. Defaults to
[`ServiceConfig().web_port`][pydase.config.ServiceConfig.web_port]. [`ServiceConfig().web_port`][pydase.config.ServiceConfig.web_port].
enable_web: enable_web: Whether to enable the web server.
Whether to enable the web server. filename: Filename of the file managing the service state persistence.
filename: additional_servers: A list of additional servers to run alongside the main
Filename of the file managing the service state persistence. server.
additional_servers:
A list of additional servers to run alongside the main server.
Here's an example of how you might define an additional server: Here's an example of how you might define an additional server:
```python ```python
@ -137,8 +131,9 @@ class Server:
) )
server.run() server.run()
``` ```
**kwargs: autosave_interval: Interval in seconds between automatic state save events.
Additional keyword arguments. If set to `None`, automatic saving is disabled. Defaults to 30 seconds.
**kwargs: Additional keyword arguments.
""" """
def __init__( # noqa: PLR0913 def __init__( # noqa: PLR0913
@ -149,6 +144,7 @@ class Server:
enable_web: bool = True, enable_web: bool = True,
filename: str | Path | None = None, filename: str | Path | None = None,
additional_servers: list[AdditionalServer] | None = None, additional_servers: list[AdditionalServer] | None = None,
autosave_interval: float = 30.0,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
if additional_servers is None: if additional_servers is None:
@ -161,7 +157,11 @@ class Server:
self._additional_servers = additional_servers self._additional_servers = additional_servers
self.should_exit = False self.should_exit = False
self.servers: dict[str, asyncio.Future[Any]] = {} self.servers: dict[str, asyncio.Future[Any]] = {}
self._state_manager = StateManager(self._service, filename) self._state_manager = StateManager(
service=self._service,
filename=filename,
autosave_interval=autosave_interval,
)
self._observer = DataServiceObserver(self._state_manager) self._observer = DataServiceObserver(self._state_manager)
self._state_manager.load_state() self._state_manager.load_state()
autostart_service_tasks(self._service) autostart_service_tasks(self._service)
@ -223,6 +223,8 @@ class Server:
server_task.add_done_callback(self._handle_server_shutdown) server_task.add_done_callback(self._handle_server_shutdown)
self.servers["web"] = server_task self.servers["web"] = server_task
self._loop.create_task(self._state_manager.autosave())
def _handle_server_shutdown(self, task: asyncio.Task[Any]) -> None: def _handle_server_shutdown(self, task: asyncio.Task[Any]) -> None:
"""Handle server shutdown. If the service should exit, do nothing. Else, make """Handle server shutdown. If the service should exit, do nothing. Else, make
the service exit.""" the service exit."""