diff --git a/src/pydase/data_service/state_manager.py b/src/pydase/data_service/state_manager.py index 13f93a7..404367c 100644 --- a/src/pydase/data_service/state_manager.py +++ b/src/pydase/data_service/state_manager.py @@ -1,3 +1,4 @@ +import asyncio import contextlib import json import logging @@ -66,43 +67,40 @@ def has_load_state_decorator(prop: property) -> bool: class StateManager: """ 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 - for newly connecting web clients without the need for expensive property accesses - that may involve complex calculations or I/O operations. + persistence layer. It provides fast access to the most recently known state of the + service and ensures consistent state updates across connected clients and service + restarts. - The StateManager listens for state change notifications from the DataService's - callback manager and updates its cache accordingly. This cache does not always - reflect the most current complex property states but rather retains the value from - the last known state, optimizing for performance and reducing the load on the - system. + The StateManager is used by the web server to apply updates to service attributes + and to serve the current state to newly connected clients. Internally, it creates a + `DataServiceCache` instance to track the state of public attributes and properties. - While the StateManager ensures that the cached state is as up-to-date as possible, - it does not autonomously update complex properties of the DataService. Such - properties must be updated programmatically, for instance, by invoking specific - 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. + The StateManager also handles state persistence: it can load a previously saved + state from disk at startup and periodically autosave the current state to a file + during runtime. Args: - service: - The DataService instance whose state is being managed. - filename: - The file name used for storing the DataService's state. + service: The DataService instance whose state is being managed. + filename: The file name used for loading and storing the DataService's state. + If provided, the state is loaded from this file at startup and saved to it + 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: - The StateManager's cache updates are triggered by notifications and do not - include autonomous updates of complex DataService properties, which must be - managed programmatically. The cache serves the purpose of providing immediate - state information to web clients, reflecting the state after the last property - update. + The StateManager does not autonomously poll hardware state. It relies on the + service to perform such updates. The cache maintained by + [`DataServiceCache`][pydase.data_service.data_service_cache.DataServiceCache] + reflects the last known state as notified by the `DataServiceObserver`, and is + used by the web interface to provide fast and accurate state rendering for + connected clients. """ def __init__( - self, service: "DataService", filename: str | Path | None = None + self, + service: "DataService", + filename: str | Path | None, + autosave_interval: float | None, ) -> None: self.filename = getattr(service, "_filename", None) @@ -115,6 +113,29 @@ class StateManager: self.service = 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 def cache_value(self) -> dict[str, SerializedObject]: @@ -122,23 +143,21 @@ class StateManager: return cast(dict[str, SerializedObject], self.cache_manager.cache["value"]) def save_state(self) -> None: - """ - Saves the DataService's current state to a JSON file defined by `self.filename`. - Logs an error if `self.filename` is not set. + """Saves the DataService's current state to a JSON file defined by + `self.filename`. """ if self.filename is not None: with open(self.filename, "w") as f: json.dump(self.cache_value, f, indent=4) else: - logger.info( + logger.debug( "State manager was not initialised with a filename. Skipping " "'save_state'..." ) 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. """ @@ -191,8 +210,7 @@ class StateManager: path: str, serialized_value: SerializedObject, ) -> 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. This method updates the attribute specified by 'path' with 'value' only if the diff --git a/src/pydase/server/server.py b/src/pydase/server/server.py index ebc71e2..61102a1 100644 --- a/src/pydase/server/server.py +++ b/src/pydase/server/server.py @@ -84,21 +84,15 @@ class Server: The `Server` class provides a flexible server implementation for the `DataService`. Args: - service: - The DataService instance that this server will manage. - host: - The host address for the server. Defaults to `'0.0.0.0'`, which means all + service: The DataService instance that this server will manage. + host: The host address for the server. Defaults to `'0.0.0.0'`, which means all available network interfaces. - web_port: - The port number for the web server. Defaults to + web_port: The port number for the web server. Defaults to [`ServiceConfig().web_port`][pydase.config.ServiceConfig.web_port]. - enable_web: - Whether to enable the web server. - filename: - Filename of the file managing the service state persistence. - additional_servers: - A list of additional servers to run alongside the main server. - + enable_web: Whether to enable the web server. + filename: Filename of the file managing the service state persistence. + 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: ```python @@ -137,8 +131,9 @@ class Server: ) server.run() ``` - **kwargs: - Additional keyword arguments. + autosave_interval: Interval in seconds between automatic state save events. + If set to `None`, automatic saving is disabled. Defaults to 30 seconds. + **kwargs: Additional keyword arguments. """ def __init__( # noqa: PLR0913 @@ -149,6 +144,7 @@ class Server: enable_web: bool = True, filename: str | Path | None = None, additional_servers: list[AdditionalServer] | None = None, + autosave_interval: float = 30.0, **kwargs: Any, ) -> None: if additional_servers is None: @@ -161,7 +157,11 @@ class Server: self._additional_servers = additional_servers self.should_exit = False 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._state_manager.load_state() autostart_service_tasks(self._service) @@ -223,6 +223,8 @@ class Server: server_task.add_done_callback(self._handle_server_shutdown) self.servers["web"] = server_task + self._loop.create_task(self._state_manager.autosave()) + def _handle_server_shutdown(self, task: asyncio.Task[Any]) -> None: """Handle server shutdown. If the service should exit, do nothing. Else, make the service exit."""