mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-24 18:58:12 +00:00
fix: move data management to async (#1015)
FAstAPI is an async framework. Data may be imported and exported, load and save, set and get asynchronously. Prevent interleaving data operations to corrupt the data. In the previous design sync and async data access was intermixed leading to data corruption. The basic data classes DataSequence and DataContainer and the derived classes like Provider and Measurement now are async. Data access is protected by several async locks. To support the async design of the data classes the database interface became async. The energy management is also adapted to the new async design. Optimization is still off-loaded to another thread, but the prepration for the optimization and the post optimization actions now follow the async design. Adapter operations are now also protected by async locks. Tests were adapted to the async design and new tests were created. Besides this major fix several other improvements and fixes are included in this PR. * fix: key_to_dict/list/array only regard data records with key value set. Before the exclusion of no value data records was only done if the dropna flag was set. * fix: test for visual result pdf generation Due to updates in the library the generated charts text was a little bit different. Adapt the test to create the comaprison pdf in the test data durectory and update the reference pdf. * chore: Remove MutableMapping from DataSequence and DataContainer. Mutable Mapping does not fit to the now async design. * chore: Add NoDB database backend This backend implements the full database backend interface but performs no actual persistence. It is intended for configurations where database persistence is disabled (`provider=None`). * chore: Improve measurement data import testing with real world scenarios. Added two new endpoints to support testing. * chore: Add mermaid to supported documentation tools * chore: Add documentation about async design * chore: Add documentation about generic data handling Covers the basics of measurement and prediction time series data handling. * chore: Add empty lines around markdown lists. * chore: sync pre-commit config to updated package versions Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -9,25 +9,34 @@ namespaces with a `namespace` column.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Dict,
|
||||
Iterable,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
import lmdb
|
||||
from loguru import logger
|
||||
from pydantic import Field, computed_field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||
from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin
|
||||
from akkudoktoreos.core.databaseabc import (
|
||||
DATABASE_METADATA_KEY,
|
||||
DatabaseABC,
|
||||
DatabaseBackendABC,
|
||||
)
|
||||
|
||||
# Valid database providers
|
||||
database_providers: List[str] = ["LMDB", "SQLite"]
|
||||
database_providers: List[str] = ["LMDB", "SQLite", "NoDB"]
|
||||
|
||||
|
||||
class DatabaseCommonSettings(SettingsBaseModel):
|
||||
@@ -40,6 +49,8 @@ class DatabaseCommonSettings(SettingsBaseModel):
|
||||
batch_size: Batch size for batch operations.
|
||||
"""
|
||||
|
||||
model_config = {"validate_assignment": True} # keeps field validation on assignment
|
||||
|
||||
provider: Optional[str] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
@@ -177,12 +188,17 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
"""Return the unique identifier for the database provider."""
|
||||
return "LMDB"
|
||||
|
||||
def open(self, namespace: Optional[str] = None) -> None:
|
||||
def open(self, *, namespace: Optional[str] = None) -> None:
|
||||
"""Open LMDB environment and optionally ensure a namespace DBI.
|
||||
|
||||
Args:
|
||||
namespace: Optional default namespace to open (DBI created on demand).
|
||||
"""
|
||||
if self.is_open:
|
||||
if namespace is not None:
|
||||
self._ensure_dbi(namespace=namespace)
|
||||
return
|
||||
|
||||
self.storage_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.env = lmdb.open(
|
||||
@@ -201,7 +217,7 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
self.default_namespace = namespace
|
||||
|
||||
if namespace is not None:
|
||||
self._ensure_dbi(namespace)
|
||||
self._ensure_dbi(namespace=namespace)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the LMDB environment and clear cached DBIs."""
|
||||
@@ -214,7 +230,7 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
self._dbis.clear()
|
||||
logger.debug("Closed LMDB at %s", self.storage_path)
|
||||
|
||||
def flush(self, namespace: Optional[str] = None) -> None:
|
||||
def flush(self, *, namespace: Optional[str] = None) -> None:
|
||||
"""Sync LMDB environment (writes to disk)."""
|
||||
if not isinstance(self.env, lmdb.Environment):
|
||||
raise ValueError(f"LMDB Environment is of wrong tpe `{type(self.env)}`.")
|
||||
@@ -230,7 +246,7 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
"""Return explicit namespace or default if None."""
|
||||
return namespace if namespace is not None else self.default_namespace
|
||||
|
||||
def _ensure_dbi(self, namespace: Optional[str]) -> Optional[Any]:
|
||||
def _ensure_dbi(self, *, namespace: Optional[str]) -> Optional[Any]:
|
||||
"""Open and cache a DBI for the given namespace.
|
||||
|
||||
Args:
|
||||
@@ -271,15 +287,15 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
if not isinstance(self.env, lmdb.Environment):
|
||||
raise RuntimeError(f"LMDB Environment is of wrong tpe `{type(self.env)}`.")
|
||||
|
||||
dbi = self._ensure_dbi(namespace)
|
||||
dbi = self._ensure_dbi(namespace=namespace)
|
||||
|
||||
with self.env.begin(write=True) as txn:
|
||||
if metadata is None:
|
||||
txn.delete(DATABASE_METADATA_KEY)
|
||||
txn.delete(DATABASE_METADATA_KEY, db=dbi)
|
||||
else:
|
||||
txn.put(DATABASE_METADATA_KEY, metadata)
|
||||
txn.put(DATABASE_METADATA_KEY, metadata, db=dbi)
|
||||
|
||||
def get_metadata(self, namespace: Optional[str] = None) -> Optional[bytes]:
|
||||
def get_metadata(self, *, namespace: Optional[str] = None) -> Optional[bytes]:
|
||||
"""Load metadata for a given namespace.
|
||||
|
||||
Returns None if no metadata exists.
|
||||
@@ -293,10 +309,10 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
if not isinstance(self.env, lmdb.Environment):
|
||||
raise RuntimeError(f"LMDB Environment is of wrong tpe `{type(self.env)}`.")
|
||||
|
||||
dbi = self._ensure_dbi(namespace)
|
||||
dbi = self._ensure_dbi(namespace=namespace)
|
||||
|
||||
with self.env.begin(write=False) as txn:
|
||||
return txn.get(DATABASE_METADATA_KEY)
|
||||
return txn.get(DATABASE_METADATA_KEY, db=dbi)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bulk Write Operations
|
||||
@@ -305,6 +321,7 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
def save_records(
|
||||
self,
|
||||
records: Iterable[tuple[bytes, bytes]],
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Save multiple records into the specified namespace (or default).
|
||||
@@ -324,7 +341,7 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
if not isinstance(self.env, lmdb.Environment):
|
||||
raise RuntimeError(f"LMDB Environment is of wrong tpe `{type(self.env)}`.")
|
||||
|
||||
dbi = self._ensure_dbi(namespace)
|
||||
dbi = self._ensure_dbi(namespace=namespace)
|
||||
|
||||
saved = 0
|
||||
with self.lock:
|
||||
@@ -338,6 +355,7 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
def delete_records(
|
||||
self,
|
||||
keys: Iterable[bytes],
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Delete multiple records by key from the specified namespace.
|
||||
@@ -352,7 +370,7 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
if not isinstance(self.env, lmdb.Environment):
|
||||
raise RuntimeError("Database not open")
|
||||
|
||||
dbi = self._ensure_dbi(namespace)
|
||||
dbi = self._ensure_dbi(namespace=namespace)
|
||||
|
||||
deleted = 0
|
||||
with self.lock:
|
||||
@@ -371,6 +389,7 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
self,
|
||||
start_key: Optional[bytes] = None,
|
||||
end_key: Optional[bytes] = None,
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
reverse: bool = False,
|
||||
) -> Iterator[tuple[bytes, bytes]]:
|
||||
@@ -391,11 +410,12 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
if not isinstance(self.env, lmdb.Environment):
|
||||
raise RuntimeError(f"LMDB Environment is of wrong type `{type(self.env)}`.")
|
||||
|
||||
dbi = self._ensure_dbi(namespace)
|
||||
dbi = self._ensure_dbi(namespace=namespace)
|
||||
META = DATABASE_METADATA_KEY
|
||||
|
||||
results: list[tuple[bytes, bytes]] = []
|
||||
|
||||
cursor = None
|
||||
txn = self.env.begin(write=False)
|
||||
try:
|
||||
cursor = txn.cursor(dbi)
|
||||
@@ -454,7 +474,8 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
|
||||
finally:
|
||||
# Ensure reader slot is always released
|
||||
cursor.close()
|
||||
if cursor is not None:
|
||||
cursor.close()
|
||||
txn.abort()
|
||||
|
||||
# Transaction is closed here — safe to yield
|
||||
@@ -478,7 +499,7 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
if not isinstance(self.env, lmdb.Environment):
|
||||
raise RuntimeError(f"LMDB Environment is of wrong tpe `{type(self.env)}`.")
|
||||
|
||||
dbi = self._ensure_dbi(namespace)
|
||||
dbi = self._ensure_dbi(namespace=namespace)
|
||||
META = DATABASE_METADATA_KEY
|
||||
|
||||
count = 0
|
||||
@@ -510,13 +531,14 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
|
||||
def get_key_range(
|
||||
self,
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> tuple[Optional[bytes], Optional[bytes]]:
|
||||
"""Return (min_key, max_key) in the given namespace or (None, None) if empty."""
|
||||
if not isinstance(self.env, lmdb.Environment):
|
||||
raise RuntimeError(f"LMDB Environment is of wrong tpe `{type(self.env)}`.")
|
||||
|
||||
dbi = self._ensure_dbi(namespace)
|
||||
dbi = self._ensure_dbi(namespace=namespace)
|
||||
|
||||
with self.env.begin(write=False) as txn:
|
||||
cursor = txn.cursor(db=dbi)
|
||||
@@ -541,12 +563,12 @@ class LMDBDatabase(DatabaseBackendABC):
|
||||
|
||||
return min_key, max_key
|
||||
|
||||
def get_backend_stats(self, namespace: Optional[str] = None) -> dict[str, Any]:
|
||||
def get_backend_stats(self, *, namespace: Optional[str] = None) -> dict[str, Any]:
|
||||
"""Get LMDB backend-specific statistics."""
|
||||
if not self.env:
|
||||
return {}
|
||||
|
||||
dbi = self._ensure_dbi(namespace)
|
||||
dbi = self._ensure_dbi(namespace=namespace)
|
||||
|
||||
with self.env.begin(write=False) as txn:
|
||||
stat = txn.stat(db=dbi)
|
||||
@@ -657,12 +679,15 @@ class SQLiteDatabase(DatabaseBackendABC):
|
||||
"""Return the unique identifier for the database provider."""
|
||||
return "SQLite"
|
||||
|
||||
def open(self, namespace: Optional[str] = None) -> None:
|
||||
def open(self, *, namespace: Optional[str] = None) -> None:
|
||||
"""Open SQLite connection and optionally set default namespace.
|
||||
|
||||
Args:
|
||||
namespace: Optional default namespace to use when operations omit namespace.
|
||||
"""
|
||||
if self.is_open:
|
||||
return
|
||||
|
||||
self.storage_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.conn = sqlite3.connect(
|
||||
@@ -700,7 +725,7 @@ class SQLiteDatabase(DatabaseBackendABC):
|
||||
self._is_open = False
|
||||
logger.debug("Closed SQLite at %s", self.db_file)
|
||||
|
||||
def flush(self, namespace: Optional[str] = None) -> None:
|
||||
def flush(self, *, namespace: Optional[str] = None) -> None:
|
||||
"""Commit any pending transactions to disk (no-op if autocommit)."""
|
||||
if not isinstance(self.conn, sqlite3.Connection):
|
||||
raise RuntimeError(f"SQLite connection is of wrong tpe `{type(self.conn)}`.")
|
||||
@@ -745,7 +770,7 @@ class SQLiteDatabase(DatabaseBackendABC):
|
||||
(ns, metadata),
|
||||
)
|
||||
|
||||
def get_metadata(self, namespace: Optional[str] = None) -> Optional[bytes]:
|
||||
def get_metadata(self, *, namespace: Optional[str] = None) -> Optional[bytes]:
|
||||
"""Load metadata for a given namespace.
|
||||
|
||||
Returns None if no metadata exists.
|
||||
@@ -777,6 +802,7 @@ class SQLiteDatabase(DatabaseBackendABC):
|
||||
def save_records(
|
||||
self,
|
||||
records: Iterable[tuple[bytes, bytes]],
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Bulk insert or replace records.
|
||||
@@ -794,18 +820,18 @@ class SQLiteDatabase(DatabaseBackendABC):
|
||||
return 0
|
||||
|
||||
with self.lock:
|
||||
self.conn.execute("BEGIN")
|
||||
self.conn.executemany(
|
||||
"INSERT OR REPLACE INTO records (namespace, key, value) VALUES (?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
self.conn.execute("COMMIT")
|
||||
with self.conn:
|
||||
self.conn.executemany(
|
||||
"INSERT OR REPLACE INTO records (namespace, key, value) VALUES (?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
|
||||
return len(rows)
|
||||
|
||||
def delete_records(
|
||||
self,
|
||||
keys: Iterable[bytes],
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Delete multiple records by key.
|
||||
@@ -817,21 +843,25 @@ class SQLiteDatabase(DatabaseBackendABC):
|
||||
|
||||
ns = self._ns(namespace)
|
||||
|
||||
deleted: int = 0
|
||||
with self.lock:
|
||||
for key in keys:
|
||||
cursor = self.conn.execute(
|
||||
"DELETE FROM records WHERE namespace = ? AND key = ?",
|
||||
(ns, key),
|
||||
)
|
||||
deleted += cursor.rowcount
|
||||
rows = [(ns, key) for key in keys]
|
||||
|
||||
return deleted
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
with self.lock:
|
||||
with self.conn:
|
||||
cursor = self.conn.executemany(
|
||||
"DELETE FROM records WHERE namespace = ? AND key = ?",
|
||||
rows,
|
||||
)
|
||||
|
||||
return cursor.rowcount
|
||||
|
||||
def iterate_records(
|
||||
self,
|
||||
start_key: Optional[bytes] = None,
|
||||
end_key: Optional[bytes] = None,
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
reverse: bool = False,
|
||||
) -> Iterator[Tuple[bytes, bytes]]:
|
||||
@@ -913,7 +943,7 @@ class SQLiteDatabase(DatabaseBackendABC):
|
||||
return int(cursor.fetchone()[0])
|
||||
|
||||
def get_key_range(
|
||||
self, namespace: Optional[str] = None
|
||||
self, *, namespace: Optional[str] = None
|
||||
) -> Tuple[Optional[bytes], Optional[bytes]]:
|
||||
"""Return (min_key, max_key) for the namespace or (None, None) if empty."""
|
||||
if not isinstance(self.conn, sqlite3.Connection):
|
||||
@@ -928,7 +958,7 @@ class SQLiteDatabase(DatabaseBackendABC):
|
||||
result = cursor.fetchone()
|
||||
return result[0], result[1]
|
||||
|
||||
def get_backend_stats(self, namespace: Optional[str] = None) -> Dict[str, Any]:
|
||||
def get_backend_stats(self, *, namespace: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Return SQLite-specific stats and namespace metrics."""
|
||||
if not self.conn:
|
||||
return {}
|
||||
@@ -959,10 +989,216 @@ class SQLiteDatabase(DatabaseBackendABC):
|
||||
logger.info("SQLite vacuum completed")
|
||||
|
||||
|
||||
# ==================== Generic Database Implementation ====================
|
||||
# ==================== NoDB Implementation ====================
|
||||
|
||||
|
||||
class Database(DatabaseABC, SingletonMixin):
|
||||
class NoDB(DatabaseBackendABC):
|
||||
"""No-op database backend.
|
||||
|
||||
This backend implements the full database backend interface but performs
|
||||
no actual persistence. It is intended for configurations where database
|
||||
persistence is disabled (`provider=None`).
|
||||
|
||||
Characteristics:
|
||||
- All write operations are ignored.
|
||||
- All read operations return empty results.
|
||||
- No files or external resources are created.
|
||||
- Lifecycle operations are no-ops.
|
||||
- Compression is disabled.
|
||||
|
||||
This backend allows the database wrapper to avoid special-case handling
|
||||
for a missing database provider by always providing a valid backend
|
||||
implementation.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
"""Initialize the no-op backend."""
|
||||
super().__init__()
|
||||
self._is_open = True # Stateless
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def provider_id(self) -> str:
|
||||
"""Return the unique identifier for the database provider."""
|
||||
return "NoDB"
|
||||
|
||||
def open(self, *, namespace: Optional[str] = None) -> None:
|
||||
"""Mark backend as open.
|
||||
|
||||
Args:
|
||||
namespace: Ignored.
|
||||
"""
|
||||
self.default_namespace = namespace
|
||||
|
||||
def close(self) -> None:
|
||||
"""Mark backend as closed."""
|
||||
return
|
||||
|
||||
def flush(self, *, namespace: Optional[str] = None) -> None:
|
||||
"""Flush pending writes (no-op).
|
||||
|
||||
Args:
|
||||
namespace: Ignored.
|
||||
"""
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Effective backend configuration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Return dummy not open."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def storage_path(self) -> Path:
|
||||
"""Return dummy storage path."""
|
||||
return Path("/dev/null")
|
||||
|
||||
@property
|
||||
def compression_level(self) -> int:
|
||||
"""Compression is disabled."""
|
||||
return 0
|
||||
|
||||
@property
|
||||
def compression(self) -> bool:
|
||||
"""Compression is disabled."""
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Metadata operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_metadata(
|
||||
self,
|
||||
metadata: Optional[bytes],
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Store metadata (ignored).
|
||||
|
||||
Args:
|
||||
metadata: Ignored.
|
||||
namespace: Ignored.
|
||||
"""
|
||||
return
|
||||
|
||||
def get_metadata(
|
||||
self,
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> Optional[bytes]:
|
||||
"""Load metadata.
|
||||
|
||||
Always returns:
|
||||
None
|
||||
"""
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Record operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def save_records(
|
||||
self,
|
||||
records: Iterable[tuple[bytes, bytes]],
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Pretend to save records.
|
||||
|
||||
Args:
|
||||
records: Ignored.
|
||||
namespace: Ignored.
|
||||
|
||||
Returns:
|
||||
Always 0.
|
||||
"""
|
||||
return 0
|
||||
|
||||
def delete_records(
|
||||
self,
|
||||
keys: Iterable[bytes],
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Pretend to delete records.
|
||||
|
||||
Args:
|
||||
keys: Ignored.
|
||||
namespace: Ignored.
|
||||
|
||||
Returns:
|
||||
Always 0.
|
||||
"""
|
||||
return 0
|
||||
|
||||
def iterate_records(
|
||||
self,
|
||||
start_key: Optional[bytes] = None,
|
||||
end_key: Optional[bytes] = None,
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
reverse: bool = False,
|
||||
) -> Iterator[tuple[bytes, bytes]]:
|
||||
"""Iterate records.
|
||||
|
||||
Always yields:
|
||||
Nothing
|
||||
"""
|
||||
return iter(())
|
||||
|
||||
def count_records(
|
||||
self,
|
||||
start_key: Optional[bytes] = None,
|
||||
end_key: Optional[bytes] = None,
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Count records.
|
||||
|
||||
Returns:
|
||||
Always 0.
|
||||
"""
|
||||
return 0
|
||||
|
||||
def get_key_range(
|
||||
self,
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> tuple[Optional[bytes], Optional[bytes]]:
|
||||
"""Return key range.
|
||||
|
||||
Returns:
|
||||
Always (None, None).
|
||||
"""
|
||||
return None, None
|
||||
|
||||
def get_backend_stats(
|
||||
self,
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return backend statistics.
|
||||
|
||||
Returns:
|
||||
Minimal backend information.
|
||||
"""
|
||||
return {
|
||||
"backend": "none",
|
||||
"namespace": namespace,
|
||||
"persistent": False,
|
||||
"records": 0,
|
||||
}
|
||||
|
||||
|
||||
# ==================== Generic Database ====================
|
||||
|
||||
|
||||
class Database(ConfigMixin, SingletonMixin):
|
||||
"""Generic database.
|
||||
|
||||
All operations accept an optional `namespace` argument. Implementations should
|
||||
@@ -971,16 +1207,15 @@ class Database(DatabaseABC, SingletonMixin):
|
||||
a namespace column).
|
||||
"""
|
||||
|
||||
_db: Optional[DatabaseBackendABC] = None
|
||||
_db: DatabaseBackendABC = NoDB()
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls) -> None:
|
||||
"""Resets the singleton instance, forcing it to be recreated on next access."""
|
||||
with cls._lock:
|
||||
# Close current database backend
|
||||
if cls._db:
|
||||
cls._db.close()
|
||||
cls._db = None
|
||||
cls._db.close()
|
||||
cls._db = NoDB()
|
||||
# Remove current database instance
|
||||
if cls in cls._instances:
|
||||
del cls._instances[cls]
|
||||
@@ -989,95 +1224,228 @@ class Database(DatabaseABC, SingletonMixin):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize database."""
|
||||
super().__init__()
|
||||
self._db = None
|
||||
self._db = NoDB()
|
||||
|
||||
def _setup_db(self) -> None:
|
||||
"""Setup database."""
|
||||
# Database helpers
|
||||
|
||||
@property
|
||||
def _database_lock(self) -> asyncio.Lock:
|
||||
"""Per-instance asyncio lock guarding database operations.
|
||||
|
||||
The lock guards the database state during async operations.
|
||||
"""
|
||||
# Lock must be a loop-local asyncio lock to provide singleton + pytest async compatibility.
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
try:
|
||||
locks = object.__getattribute__(self, "_database_locks")
|
||||
except AttributeError:
|
||||
locks = {}
|
||||
object.__setattr__(self, "_database_locks", locks)
|
||||
|
||||
lock = locks.get(loop)
|
||||
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
locks[loop] = lock
|
||||
|
||||
return lock
|
||||
|
||||
async def _ensure_open(
|
||||
self,
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> DatabaseBackendABC:
|
||||
"""Ensure the configured backend exists, is open, and namespace is prepared.
|
||||
|
||||
Expects the database lock to already be held when called.
|
||||
|
||||
Args:
|
||||
namespace: Optional namespace to prepare/open.
|
||||
|
||||
Returns:
|
||||
The active and opened backend instance.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no database provider is configured.
|
||||
"""
|
||||
provider_id = self.config.database.provider
|
||||
database: Optional[DatabaseBackendABC] = None
|
||||
if provider_id is None:
|
||||
database = None
|
||||
elif provider_id == "LMDB":
|
||||
database = LMDBDatabase()
|
||||
elif provider_id == "SQLite":
|
||||
database = SQLiteDatabase()
|
||||
else:
|
||||
raise RuntimeError("Invalid database provider '{provider_id}'")
|
||||
if self._db is not None:
|
||||
self._db.close()
|
||||
self._db = database
|
||||
old_provider_id = self._db.provider_id()
|
||||
|
||||
def _database(self) -> DatabaseBackendABC:
|
||||
"""Get database."""
|
||||
provider_id = self.config.database.provider
|
||||
if provider_id is None:
|
||||
raise RuntimeError("Database not configured")
|
||||
|
||||
if self._db is None or self._db.provider_id() != provider_id:
|
||||
if old_provider_id != provider_id:
|
||||
# No database or configuration does not match
|
||||
self._setup_db()
|
||||
if self._db is None:
|
||||
raise RuntimeError("Database not configured")
|
||||
logger.debug(
|
||||
f"Switching database provider from '{old_provider_id}' to '{provider_id}'."
|
||||
)
|
||||
old_db = self._db
|
||||
|
||||
database: DatabaseBackendABC
|
||||
if provider_id is None or provider_id == "NoDB":
|
||||
database = NoDB()
|
||||
elif provider_id == "LMDB":
|
||||
database = LMDBDatabase()
|
||||
elif provider_id == "SQLite":
|
||||
database = SQLiteDatabase()
|
||||
else:
|
||||
raise RuntimeError(f"Invalid database provider '{provider_id}'")
|
||||
|
||||
# Auto-close database that was used before
|
||||
if old_db.is_open:
|
||||
old_db.close()
|
||||
|
||||
self._db = database
|
||||
|
||||
if not self._db.is_open:
|
||||
self._db.open()
|
||||
await asyncio.to_thread(self._db.open, namespace=namespace)
|
||||
elif namespace is not None:
|
||||
# Allow backend to lazily prepare namespace resources
|
||||
await asyncio.to_thread(self._db.open, namespace=namespace)
|
||||
|
||||
return self._db
|
||||
|
||||
async def _run_db(
|
||||
self,
|
||||
method_name: str,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Execute a synchronous database backend operation in a thread-safe, non-blocking way.
|
||||
|
||||
The backend is automatically initialized/opened before execution.
|
||||
If a ``namespace`` keyword argument is present, the namespace is
|
||||
prepared during backend initialization.
|
||||
|
||||
This helper ensures that all interactions with the underlying synchronous
|
||||
database backend are:
|
||||
|
||||
- **Serialized** using an instance-level ``asyncio.Lock`` to protect backend state.
|
||||
- **Non-blocking** by offloading execution to a worker thread via
|
||||
``asyncio.to_thread``.
|
||||
|
||||
It should be used for all backend calls that may perform blocking I/O or
|
||||
CPU-bound work.
|
||||
|
||||
Args:
|
||||
method_name: The synchronous callable to execute (a backend method).
|
||||
*args: Positional arguments forwarded to ``method``.
|
||||
**kwargs: Keyword arguments forwarded to ``method``.
|
||||
|
||||
Returns:
|
||||
The return value of ``method``.
|
||||
|
||||
Raises:
|
||||
Any exception raised by ``func`` is propagated unchanged.
|
||||
|
||||
Notes:
|
||||
- The callable is executed in a separate thread, so it must be thread-safe.
|
||||
- The database lock is held for the duration of the call, ensuring that
|
||||
no concurrent operations interfere with backend state.
|
||||
- Avoid passing coroutines or async functions to this method; it is
|
||||
intended strictly for synchronous callables.
|
||||
"""
|
||||
namespace = kwargs.get("namespace")
|
||||
|
||||
async with self._database_lock:
|
||||
db = await self._ensure_open(namespace=namespace)
|
||||
|
||||
# Get actual method. _ensure_open may have changed the backend
|
||||
method = getattr(db, method_name)
|
||||
|
||||
def backend_call() -> Any:
|
||||
result = method(
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Materialize iterators inside worker thread
|
||||
if isinstance(result, Iterator):
|
||||
return list(result)
|
||||
|
||||
return result
|
||||
|
||||
return await asyncio.to_thread(backend_call)
|
||||
|
||||
def provider_id(self) -> str:
|
||||
"""Return the unique identifier for the database provider."""
|
||||
try:
|
||||
return self._database().provider_id()
|
||||
except:
|
||||
return "None"
|
||||
return self._db.provider_id()
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Return whether the database connection is open."""
|
||||
try:
|
||||
return self._database().is_open
|
||||
except:
|
||||
return False
|
||||
return self._db.is_open
|
||||
|
||||
@property
|
||||
def storage_path(self) -> Path:
|
||||
"""Storage path for the database."""
|
||||
return self._database().storage_path
|
||||
"""Return effective storage path of active backend."""
|
||||
return self._db.storage_path
|
||||
|
||||
@property
|
||||
def compression_level(self) -> int:
|
||||
"""Compression level for database record data."""
|
||||
return self._database().compression_level
|
||||
"""Return effective compression level of active backend."""
|
||||
return self._db.compression_level
|
||||
|
||||
@property
|
||||
def compression(self) -> bool:
|
||||
"""Whether to compress stored values."""
|
||||
return self._database().compression_level > 0
|
||||
"""Return whether the active backend compresses stored values.
|
||||
|
||||
Returns:
|
||||
True if compression is enabled for the active backend,
|
||||
False if disabled,
|
||||
or None if no backend is currently initialized.
|
||||
"""
|
||||
return self._db.compression_level > 0
|
||||
|
||||
# Lifecycle
|
||||
|
||||
def open(self, namespace: Optional[str] = None) -> None:
|
||||
"""Open database connection and optionally set default namespace.
|
||||
async def ensure_open(
|
||||
self,
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
) -> DatabaseBackendABC:
|
||||
"""Ensure the configured backend exists, is open, and namespace is prepared.
|
||||
|
||||
Args:
|
||||
namespace: Optional default namespace to prepare.
|
||||
namespace: Optional namespace to prepare/open.
|
||||
|
||||
Returns:
|
||||
The active and opened backend instance.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the database cannot be opened.
|
||||
RuntimeError: If no database provider is configured.
|
||||
"""
|
||||
self._database().open(namespace)
|
||||
async with self._database_lock:
|
||||
return await self._ensure_open(namespace=namespace)
|
||||
|
||||
def close(self) -> None:
|
||||
async def open(self, *, namespace: Optional[str] = None) -> None:
|
||||
"""Ensure the database backend is initialized and open.
|
||||
|
||||
If the backend is already open, this operation is a no-op except
|
||||
that the backend may prepare the specified namespace.
|
||||
|
||||
Args:
|
||||
namespace: Optional namespace to prepare/open.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no database provider is configured or opening fails.
|
||||
"""
|
||||
async with self._database_lock:
|
||||
await self._ensure_open(namespace=namespace)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the database connection and cleanup resources."""
|
||||
self._database().close()
|
||||
async with self._database_lock:
|
||||
if self._db is not None and self._db.is_open:
|
||||
await asyncio.to_thread(self._db.close)
|
||||
|
||||
def flush(self, namespace: Optional[str] = None) -> None:
|
||||
async def flush(self, *, namespace: Optional[str] = None) -> None:
|
||||
"""Force synchronization of pending writes to storage (optional per-namespace)."""
|
||||
return self._database().flush(namespace)
|
||||
await self._run_db("flush", namespace=namespace)
|
||||
|
||||
# Metadata operations
|
||||
|
||||
def set_metadata(self, metadata: Optional[bytes], *, namespace: Optional[str] = None) -> None:
|
||||
async def set_metadata(
|
||||
self, metadata: Optional[bytes], *, namespace: Optional[str] = None
|
||||
) -> None:
|
||||
"""Save metadata for a given namespace.
|
||||
|
||||
Metadata is treated separately from data records and stored as a single object.
|
||||
@@ -1086,9 +1454,13 @@ class Database(DatabaseABC, SingletonMixin):
|
||||
metadata (bytes): Arbitrary metadata to save or None to delete metadata.
|
||||
namespace (Optional[str]): Optional namespace under which to store metadata.
|
||||
"""
|
||||
self._database().set_metadata(metadata, namespace=namespace)
|
||||
await self._run_db(
|
||||
"set_metadata",
|
||||
metadata,
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
def get_metadata(self, namespace: Optional[str] = None) -> Optional[bytes]:
|
||||
async def get_metadata(self, *, namespace: Optional[str] = None) -> Optional[bytes]:
|
||||
"""Load metadata for a given namespace.
|
||||
|
||||
Returns None if no metadata exists.
|
||||
@@ -1099,12 +1471,15 @@ class Database(DatabaseABC, SingletonMixin):
|
||||
Returns:
|
||||
Optional[bytes]: The loaded metadata, or None if not found.
|
||||
"""
|
||||
return self._database().get_metadata(namespace=namespace)
|
||||
return await self._run_db(
|
||||
"get_metadata",
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
# Basic record operations
|
||||
|
||||
def save_records(
|
||||
self, records: Iterable[tuple[bytes, bytes]], namespace: Optional[str] = None
|
||||
async def save_records(
|
||||
self, records: Iterable[tuple[bytes, bytes]], *, namespace: Optional[str] = None
|
||||
) -> int:
|
||||
"""Save multiple records into the specified namespace (or default).
|
||||
|
||||
@@ -1120,9 +1495,15 @@ class Database(DatabaseABC, SingletonMixin):
|
||||
Raises:
|
||||
RuntimeError: If DB not open or write failed.
|
||||
"""
|
||||
return self._database().save_records(records, namespace)
|
||||
return await self._run_db(
|
||||
"save_records",
|
||||
records,
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
def delete_records(self, keys: Iterable[bytes], namespace: Optional[str] = None) -> int:
|
||||
async def delete_records(
|
||||
self, keys: Iterable[bytes], *, namespace: Optional[str] = None
|
||||
) -> int:
|
||||
"""Delete multiple records by key from the specified namespace.
|
||||
|
||||
Args:
|
||||
@@ -1132,15 +1513,20 @@ class Database(DatabaseABC, SingletonMixin):
|
||||
Returns:
|
||||
Number of records actually deleted.
|
||||
"""
|
||||
return self._database().delete_records(keys, namespace)
|
||||
return await self._run_db(
|
||||
"delete_records",
|
||||
keys,
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
def iterate_records(
|
||||
async def iterate_records(
|
||||
self,
|
||||
start_key: Optional[bytes] = None,
|
||||
end_key: Optional[bytes] = None,
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
reverse: bool = False,
|
||||
) -> Iterator[tuple[bytes, bytes]]:
|
||||
) -> AsyncIterator[tuple[bytes, bytes]]:
|
||||
"""Iterate over records for a namespace with optional bounds.
|
||||
|
||||
Args:
|
||||
@@ -1152,9 +1538,18 @@ class Database(DatabaseABC, SingletonMixin):
|
||||
Yields:
|
||||
Tuples of (key, record).
|
||||
"""
|
||||
return self._database().iterate_records(start_key, end_key, namespace, reverse)
|
||||
records: list[tuple[bytes, bytes]] = await self._run_db(
|
||||
"iterate_records",
|
||||
start_key,
|
||||
end_key,
|
||||
namespace=namespace,
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
def count_records(
|
||||
for item in records:
|
||||
yield item
|
||||
|
||||
async def count_records(
|
||||
self,
|
||||
start_key: Optional[bytes] = None,
|
||||
end_key: Optional[bytes] = None,
|
||||
@@ -1165,14 +1560,49 @@ class Database(DatabaseABC, SingletonMixin):
|
||||
|
||||
Excludes metadata records.
|
||||
"""
|
||||
return self._database().count_records(start_key, end_key, namespace=namespace)
|
||||
return await self._run_db(
|
||||
"count_records",
|
||||
start_key,
|
||||
end_key,
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
def get_key_range(
|
||||
self, namespace: Optional[str] = None
|
||||
async def get_key_range(
|
||||
self, *, namespace: Optional[str] = None
|
||||
) -> Tuple[Optional[bytes], Optional[bytes]]:
|
||||
"""Return (min_key, max_key) in the given namespace or (None, None) if empty."""
|
||||
return self._database().get_key_range(namespace)
|
||||
return await self._run_db(
|
||||
"get_key_range",
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
def get_backend_stats(self, namespace: Optional[str] = None) -> Dict[str, Any]:
|
||||
async def get_backend_stats(self, *, namespace: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Get backend-specific statistics; implementations may return namespace-specific data."""
|
||||
return self._database().get_backend_stats(namespace)
|
||||
return await self._run_db(
|
||||
"get_backend_stats",
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
# Compression helpers
|
||||
|
||||
def serialize_data(self, data: bytes) -> bytes:
|
||||
"""Optionally compress raw pickled data before storage.
|
||||
|
||||
Args:
|
||||
data: Raw pickled bytes.
|
||||
|
||||
Returns:
|
||||
Possibly compressed bytes.
|
||||
"""
|
||||
return self._db.serialize_data(data)
|
||||
|
||||
def deserialize_data(self, data: bytes) -> bytes:
|
||||
"""Optionally decompress stored data.
|
||||
|
||||
Args:
|
||||
data: Stored bytes.
|
||||
|
||||
Returns:
|
||||
Raw pickled bytes (decompressed if needed).
|
||||
"""
|
||||
return self._db.deserialize_data(data)
|
||||
|
||||
@@ -12,6 +12,7 @@ from threading import Lock
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Final,
|
||||
Generic,
|
||||
Iterable,
|
||||
@@ -46,20 +47,38 @@ DATABASE_METADATA_KEY: bytes = b"__metadata__"
|
||||
# ==================== Abstract Database Interface ====================
|
||||
|
||||
|
||||
class DatabaseABC(ABC, ConfigMixin):
|
||||
"""Abstract base class for database.
|
||||
class DatabaseBackendABC(ABC, ConfigMixin, SingletonMixin):
|
||||
"""Abstract base class for database backends.
|
||||
|
||||
All operations accept an optional `namespace` argument. Implementations should
|
||||
treat None as the default/root namespace. Concrete implementations can map
|
||||
namespace -> native namespace (LMDB DBI) or emulate namespaces (SQLite uses
|
||||
a namespace column).
|
||||
|
||||
The database backend provides a synchronous interface. Asynchrounous access is
|
||||
handled by the generic database class.
|
||||
"""
|
||||
|
||||
connection: Any
|
||||
lock: Lock
|
||||
_is_open: bool
|
||||
default_namespace: Optional[str]
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
"""Initialize the DatabaseBackendABC base.
|
||||
|
||||
Args:
|
||||
**kwargs: Backend-specific options (ignored by base).
|
||||
"""
|
||||
self.connection = None
|
||||
self.lock = Lock()
|
||||
self._is_open = False
|
||||
self.default_namespace = None
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_open(self) -> bool:
|
||||
"""Return whether the database connection is open."""
|
||||
raise NotImplementedError
|
||||
return self._is_open
|
||||
|
||||
@property
|
||||
def storage_path(self) -> Path:
|
||||
@@ -87,7 +106,7 @@ class DatabaseABC(ABC, ConfigMixin):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def open(self, namespace: Optional[str] = None) -> None:
|
||||
def open(self, *, namespace: Optional[str] = None) -> None:
|
||||
"""Open database connection and optionally set default namespace.
|
||||
|
||||
Args:
|
||||
@@ -104,7 +123,7 @@ class DatabaseABC(ABC, ConfigMixin):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def flush(self, namespace: Optional[str] = None) -> None:
|
||||
def flush(self, *, namespace: Optional[str] = None) -> None:
|
||||
"""Force synchronization of pending writes to storage (optional per-namespace)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -123,7 +142,7 @@ class DatabaseABC(ABC, ConfigMixin):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_metadata(self, namespace: Optional[str] = None) -> Optional[bytes]:
|
||||
def get_metadata(self, *, namespace: Optional[str] = None) -> Optional[bytes]:
|
||||
"""Load metadata for a given namespace.
|
||||
|
||||
Returns None if no metadata exists.
|
||||
@@ -140,7 +159,7 @@ class DatabaseABC(ABC, ConfigMixin):
|
||||
|
||||
@abstractmethod
|
||||
def save_records(
|
||||
self, records: Iterable[tuple[bytes, bytes]], namespace: Optional[str] = None
|
||||
self, records: Iterable[tuple[bytes, bytes]], *, namespace: Optional[str] = None
|
||||
) -> int:
|
||||
"""Save multiple records into the specified namespace (or default).
|
||||
|
||||
@@ -159,7 +178,7 @@ class DatabaseABC(ABC, ConfigMixin):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete_records(self, keys: Iterable[bytes], namespace: Optional[str] = None) -> int:
|
||||
def delete_records(self, keys: Iterable[bytes], *, namespace: Optional[str] = None) -> int:
|
||||
"""Delete multiple records by key from the specified namespace.
|
||||
|
||||
Args:
|
||||
@@ -176,6 +195,7 @@ class DatabaseABC(ABC, ConfigMixin):
|
||||
self,
|
||||
start_key: Optional[bytes] = None,
|
||||
end_key: Optional[bytes] = None,
|
||||
*,
|
||||
namespace: Optional[str] = None,
|
||||
reverse: bool = False,
|
||||
) -> Iterator[tuple[bytes, bytes]]:
|
||||
@@ -208,13 +228,13 @@ class DatabaseABC(ABC, ConfigMixin):
|
||||
|
||||
@abstractmethod
|
||||
def get_key_range(
|
||||
self, namespace: Optional[str] = None
|
||||
self, *, namespace: Optional[str] = None
|
||||
) -> tuple[Optional[bytes], Optional[bytes]]:
|
||||
"""Return (min_key, max_key) in the given namespace or (None, None) if empty."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_backend_stats(self, namespace: Optional[str] = None) -> dict[str, Any]:
|
||||
def get_backend_stats(self, *, namespace: Optional[str] = None) -> dict[str, Any]:
|
||||
"""Get backend-specific statistics; implementations may return namespace-specific data."""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -250,37 +270,6 @@ class DatabaseABC(ABC, ConfigMixin):
|
||||
return data
|
||||
|
||||
|
||||
class DatabaseBackendABC(DatabaseABC, SingletonMixin):
|
||||
"""Abstract base class for database backends.
|
||||
|
||||
All operations accept an optional `namespace` argument. Implementations should
|
||||
treat None as the default/root namespace. Concrete implementations can map
|
||||
namespace -> native namespace (LMDB DBI) or emulate namespaces (SQLite uses
|
||||
a namespace column).
|
||||
"""
|
||||
|
||||
connection: Any
|
||||
lock: Lock
|
||||
_is_open: bool
|
||||
default_namespace: Optional[str]
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
"""Initialize the DatabaseBackendABC base.
|
||||
|
||||
Args:
|
||||
**kwargs: Backend-specific options (ignored by base).
|
||||
"""
|
||||
self.connection = None
|
||||
self.lock = Lock()
|
||||
self._is_open = False
|
||||
self.default_namespace = None
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Return whether the database connection is open."""
|
||||
return self._is_open
|
||||
|
||||
|
||||
# ==================== Database Record Protocol Mixin ====================
|
||||
|
||||
|
||||
@@ -442,7 +431,7 @@ class DatabaseRecordProtocol(Protocol, Generic[T_Record]):
|
||||
@property
|
||||
def db_enabled(self) -> bool: ...
|
||||
|
||||
def db_timestamp_range(self) -> tuple[DatabaseTimestampType, DatabaseTimestampType]: ...
|
||||
async def db_timestamp_range(self) -> tuple[DatabaseTimestampType, DatabaseTimestampType]: ...
|
||||
|
||||
def db_generate_timestamps(
|
||||
self,
|
||||
@@ -451,52 +440,49 @@ class DatabaseRecordProtocol(Protocol, Generic[T_Record]):
|
||||
interval: Optional[Duration] = None,
|
||||
) -> Iterator[DatabaseTimestamp]: ...
|
||||
|
||||
def db_get_record(self, target_timestamp: DatabaseTimestamp) -> Optional[T_Record]: ...
|
||||
async def db_get_record(self, target_timestamp: DatabaseTimestamp) -> Optional[T_Record]: ...
|
||||
|
||||
def db_insert_record(
|
||||
async def db_insert_record(
|
||||
self,
|
||||
record: T_Record,
|
||||
*,
|
||||
mark_dirty: bool = True,
|
||||
) -> None: ...
|
||||
|
||||
def db_iterate_records(
|
||||
async def db_iterate_records(
|
||||
self,
|
||||
start_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
end_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
) -> Iterator[T_Record]: ...
|
||||
) -> AsyncIterator[T_Record]: ...
|
||||
|
||||
def db_load_records(
|
||||
async def db_load_records(
|
||||
self,
|
||||
start_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
end_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
) -> int: ...
|
||||
|
||||
def db_delete_records(
|
||||
async def db_delete_records(
|
||||
self,
|
||||
start_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
end_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
) -> int: ...
|
||||
|
||||
# ---- dirty tracking ----
|
||||
def db_mark_dirty_record(self, record: T_Record) -> None: ...
|
||||
async def db_mark_dirty_record(self, record: T_Record) -> None: ...
|
||||
|
||||
def db_save_records(self) -> int: ...
|
||||
|
||||
# ---- autosave ----
|
||||
def db_autosave(self) -> int: ...
|
||||
async def db_save_records(self) -> int: ...
|
||||
|
||||
# ---- Remove old records from database to free space ----
|
||||
def db_vacuum(
|
||||
async def db_vacuum(
|
||||
self,
|
||||
keep_hours: Optional[int] = None,
|
||||
keep_datetime: Optional[DatabaseTimestampType] = None,
|
||||
) -> int: ...
|
||||
|
||||
# ---- statistics about database storage ----
|
||||
def db_count_records(self) -> int: ...
|
||||
async def db_count_records(self) -> int: ...
|
||||
|
||||
def db_get_stats(self) -> dict: ...
|
||||
async def db_get_stats(self) -> dict: ...
|
||||
|
||||
|
||||
T_DatabaseRecordProtocol = TypeVar("T_DatabaseRecordProtocol", bound="DatabaseRecordProtocol")
|
||||
@@ -533,10 +519,12 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
Completely manages in memory records and database storage.
|
||||
|
||||
Expects records with date_time (DatabaseTimestamp) property and the a record list
|
||||
Expects records with date_time (DatabaseTimestamp) property and a record list
|
||||
in self.records of the derived class.
|
||||
|
||||
DatabaseRecordProtocolMixin expects the derived classes to be singletons.
|
||||
DatabaseRecordProtocolMixin expects the derived classes to be singletons and to have
|
||||
the sequence guarded against asynchronous sequence state (_sequence_lock) and
|
||||
asynchronous record state (_record_lock) changes.
|
||||
"""
|
||||
|
||||
# Tell mypy these attributes exist (will be provided by subclasses)
|
||||
@@ -549,7 +537,7 @@ class DatabaseRecordProtocolMixin(
|
||||
@property
|
||||
def record_keys_writable(self) -> list[str]: ...
|
||||
|
||||
def key_to_array(
|
||||
async def key_to_array(
|
||||
self,
|
||||
key: str,
|
||||
start_datetime: Optional[DateTime] = None,
|
||||
@@ -583,7 +571,18 @@ class DatabaseRecordProtocolMixin(
|
||||
# Initialization
|
||||
# -----------------------------------------------------
|
||||
|
||||
def _db_ensure_initialized(self) -> None:
|
||||
async def _db_init_metadata(self) -> None:
|
||||
"""Initialize DB metadata."""
|
||||
self._db_metadata: Optional[dict] = {
|
||||
"version": self._db_version,
|
||||
"created": to_datetime(as_string=True),
|
||||
"provider_id": getattr(self, "provider_id", lambda: "unknown")(),
|
||||
"compression": self.database.compression,
|
||||
"backend": self.database.__class__.__name__,
|
||||
}
|
||||
await self._db_save_metadata(self._db_metadata)
|
||||
|
||||
async def _db_ensure_initialized(self) -> None:
|
||||
"""Initialize DB runtime state.
|
||||
|
||||
Idempotent — safe to call multiple times.
|
||||
@@ -613,25 +612,18 @@ class DatabaseRecordProtocolMixin(
|
||||
self._db_version: int = 1
|
||||
|
||||
# Storage
|
||||
self._db_metadata: Optional[dict] = None
|
||||
self._db_metadata = None
|
||||
self._db_storage_initialized: bool = False
|
||||
|
||||
self._db_initialized: bool = True
|
||||
|
||||
if not self._db_storage_initialized and self.db_enabled:
|
||||
# Metadata
|
||||
existing_metadata = self._db_load_metadata()
|
||||
existing_metadata = await self._db_load_metadata()
|
||||
if existing_metadata:
|
||||
self._db_metadata = existing_metadata
|
||||
else:
|
||||
self._db_metadata = {
|
||||
"version": self._db_version,
|
||||
"created": to_datetime(as_string=True),
|
||||
"provider_id": getattr(self, "provider_id", lambda: "unknown")(),
|
||||
"compression": self.database.compression,
|
||||
"backend": self.database.__class__.__name__,
|
||||
}
|
||||
self._db_save_metadata(self._db_metadata)
|
||||
await self._db_init_metadata()
|
||||
|
||||
logger.info(
|
||||
f"Initialized {self.database.__class__.__name__}:{self.db_namespace()} storage at "
|
||||
@@ -641,12 +633,6 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
self._db_storage_initialized = True
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Initialize DB state attributes immediately after Pydantic construction."""
|
||||
# Always call super() first — other mixins may also define model_post_init
|
||||
super().model_post_init(__context) # type: ignore[misc]
|
||||
self._db_ensure_initialized()
|
||||
|
||||
# -----------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------
|
||||
@@ -669,7 +655,7 @@ class DatabaseRecordProtocolMixin(
|
||||
db_datetime_after = DatabaseTimestamp.from_datetime(target.add(seconds=1))
|
||||
return db_datetime_after
|
||||
|
||||
def db_previous_timestamp(
|
||||
async def db_previous_timestamp(
|
||||
self,
|
||||
timestamp: DatabaseTimestamp,
|
||||
) -> Optional[DatabaseTimestamp]:
|
||||
@@ -677,7 +663,7 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
Search memory-first, then fallback to database if necessary.
|
||||
"""
|
||||
self._db_ensure_initialized()
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
# Step 1: Memory-first search
|
||||
if self._db_sorted_timestamps:
|
||||
@@ -689,7 +675,7 @@ class DatabaseRecordProtocolMixin(
|
||||
if not self.db_enabled:
|
||||
return None
|
||||
|
||||
db_min_key, _ = self.database.get_key_range(self.db_namespace())
|
||||
db_min_key, _ = await self.database.get_key_range(namespace=self.db_namespace())
|
||||
if db_min_key is None:
|
||||
return None
|
||||
|
||||
@@ -710,7 +696,7 @@ class DatabaseRecordProtocolMixin(
|
||||
start_key = self._db_key_from_timestamp(loaded_start)
|
||||
|
||||
previous_ts: Optional[DatabaseTimestamp] = None
|
||||
for key, _ in self.database.iterate_records(
|
||||
async for key, _ in self.database.iterate_records(
|
||||
start_key=start_key,
|
||||
end_key=end_key,
|
||||
namespace=self.db_namespace(),
|
||||
@@ -722,7 +708,7 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
return previous_ts
|
||||
|
||||
def db_next_timestamp(
|
||||
async def db_next_timestamp(
|
||||
self,
|
||||
timestamp: DatabaseTimestamp,
|
||||
) -> Optional[DatabaseTimestamp]:
|
||||
@@ -730,7 +716,7 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
Search memory-first, then fallback to database if necessary.
|
||||
"""
|
||||
self._db_ensure_initialized()
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
# Step 1: Memory-first search
|
||||
if self._db_sorted_timestamps:
|
||||
@@ -742,7 +728,7 @@ class DatabaseRecordProtocolMixin(
|
||||
if not self.db_enabled:
|
||||
return None
|
||||
|
||||
_, db_max_key = self.database.get_key_range(self.db_namespace())
|
||||
_, db_max_key = await self.database.get_key_range(namespace=self.db_namespace())
|
||||
if db_max_key is None:
|
||||
return None
|
||||
|
||||
@@ -762,7 +748,7 @@ class DatabaseRecordProtocolMixin(
|
||||
if isinstance(loaded_end, DatabaseTimestamp) and timestamp < loaded_end:
|
||||
start_key = self._db_key_from_timestamp(max(timestamp, loaded_end))
|
||||
|
||||
for key, _ in self.database.iterate_records(
|
||||
async for key, _ in self.database.iterate_records(
|
||||
start_key=start_key,
|
||||
end_key=end_key,
|
||||
namespace=self.db_namespace(),
|
||||
@@ -796,22 +782,22 @@ class DatabaseRecordProtocolMixin(
|
||||
record_data = pickle.loads(data) # noqa: S301
|
||||
return self.record_class()(**record_data)
|
||||
|
||||
def _db_save_metadata(self, metadata: dict) -> None:
|
||||
async def _db_save_metadata(self, metadata: dict) -> None:
|
||||
"""Save metadata to database."""
|
||||
if not self.db_enabled:
|
||||
return
|
||||
|
||||
key = DATABASE_METADATA_KEY
|
||||
value = pickle.dumps(metadata)
|
||||
self.database.set_metadata(value, namespace=self.db_namespace())
|
||||
await self.database.set_metadata(value, namespace=self.db_namespace())
|
||||
|
||||
def _db_load_metadata(self) -> Optional[dict]:
|
||||
async def _db_load_metadata(self) -> Optional[dict]:
|
||||
"""Load metadata from database."""
|
||||
if not self.db_enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = self.database.get_metadata(namespace=self.db_namespace())
|
||||
value = await self.database.get_metadata(namespace=self.db_namespace())
|
||||
return pickle.loads(value) # noqa: S301
|
||||
except Exception:
|
||||
logger.debug("Can not load metadata.")
|
||||
@@ -942,7 +928,7 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
return loaded_start <= start_timestamp and end_timestamp <= loaded_end
|
||||
|
||||
def _db_load_initial_window(
|
||||
async def _db_load_initial_window(
|
||||
self,
|
||||
center_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
) -> None:
|
||||
@@ -1001,11 +987,11 @@ class DatabaseRecordProtocolMixin(
|
||||
window = to_duration(window_h * 3600)
|
||||
start, end = self._search_window(center_timestamp, window)
|
||||
|
||||
self.db_load_records(start, end)
|
||||
await self.db_load_records(start, end)
|
||||
|
||||
self._db_load_phase = DatabaseRecordProtocolLoadPhase.INITIAL
|
||||
|
||||
def _db_load_full(self) -> int:
|
||||
async def _db_load_full(self) -> int:
|
||||
"""Load all remaining records from the database into memory.
|
||||
|
||||
This method performs a **full load** of the database, ensuring that all
|
||||
@@ -1038,14 +1024,14 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
# Perform full database load (memory is authoritative; skips duplicates)
|
||||
# This also sets _db_loaded_range
|
||||
loaded_count = self.db_load_records()
|
||||
loaded_count = await self.db_load_records()
|
||||
|
||||
# Update state
|
||||
self._db_load_phase = DatabaseRecordProtocolLoadPhase.FULL
|
||||
|
||||
return loaded_count
|
||||
|
||||
def _extend_boundaries(
|
||||
async def _extend_boundaries(
|
||||
self,
|
||||
start_timestamp: DatabaseTimestampType,
|
||||
end_timestamp: DatabaseTimestampType,
|
||||
@@ -1069,7 +1055,7 @@ class DatabaseRecordProtocolMixin(
|
||||
):
|
||||
# There may be earlier DB records
|
||||
# Reverse iterate to get nearest smaller key
|
||||
for key, _ in self.database.iterate_records(
|
||||
async for key, _ in self.database.iterate_records(
|
||||
start_key=UNBOUND_START,
|
||||
end_key=self._db_key_from_timestamp(start_timestamp),
|
||||
namespace=self.db_namespace(),
|
||||
@@ -1091,7 +1077,7 @@ class DatabaseRecordProtocolMixin(
|
||||
and end_timestamp > self._db_sorted_timestamps[-1]
|
||||
):
|
||||
# There may be later DB records
|
||||
for key, _ in self.database.iterate_records(
|
||||
async for key, _ in self.database.iterate_records(
|
||||
start_key=self._db_key_from_timestamp(end_timestamp),
|
||||
end_key=UNBOUND_END,
|
||||
namespace=self.db_namespace(),
|
||||
@@ -1107,7 +1093,7 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
return new_start, new_end
|
||||
|
||||
def _db_ensure_loaded(
|
||||
async def _db_ensure_loaded(
|
||||
self,
|
||||
start_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
end_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
@@ -1172,11 +1158,11 @@ class DatabaseRecordProtocolMixin(
|
||||
# Phase 0: NOTHING LOADED
|
||||
if self._db_load_phase is DatabaseRecordProtocolLoadPhase.NONE:
|
||||
if start_timestamp is UNBOUND_START and end_timestamp is UNBOUND_END:
|
||||
self._db_load_initial_window(center_timestamp)
|
||||
await self._db_load_initial_window(center_timestamp)
|
||||
# _db_load_initial_window sets _db_loaded_range and _db_load_phase
|
||||
else:
|
||||
# Load the records
|
||||
loaded = self.db_load_records(start_timestamp, end_timestamp)
|
||||
loaded = await self.db_load_records(start_timestamp, end_timestamp)
|
||||
self._db_load_phase = DatabaseRecordProtocolLoadPhase.INITIAL
|
||||
return
|
||||
|
||||
@@ -1196,7 +1182,7 @@ class DatabaseRecordProtocolMixin(
|
||||
return # already have it
|
||||
|
||||
if start_timestamp == UNBOUND_START and end_timestamp == UNBOUND_END:
|
||||
self._db_load_full()
|
||||
await self._db_load_full()
|
||||
return
|
||||
|
||||
current_start, current_end = self._db_loaded_range
|
||||
@@ -1207,11 +1193,11 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
# Left expansion
|
||||
if start_timestamp < current_start:
|
||||
self.db_load_records(start_timestamp, current_start)
|
||||
await self.db_load_records(start_timestamp, current_start)
|
||||
|
||||
# Right expansion
|
||||
if end_timestamp > current_end:
|
||||
self.db_load_records(current_end, end_timestamp)
|
||||
await self.db_load_records(current_end, end_timestamp)
|
||||
|
||||
return
|
||||
|
||||
@@ -1251,15 +1237,15 @@ class DatabaseRecordProtocolMixin(
|
||||
def db_enabled(self) -> bool:
|
||||
return self.database.is_open
|
||||
|
||||
def db_timestamp_range(
|
||||
async def db_timestamp_range(
|
||||
self,
|
||||
) -> tuple[Optional[DatabaseTimestamp], Optional[DatabaseTimestamp]]:
|
||||
"""Get the timestamp range of records in database.
|
||||
|
||||
Regards records in storage plus extra records in memory.
|
||||
"""
|
||||
# Defensive call - model_post_init() may not have initialized metadata
|
||||
self._db_ensure_initialized()
|
||||
# Ensure db in memory data and metadata is initialized
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
if self._db_sorted_timestamps:
|
||||
memory_min_timestamp: Optional[DatabaseTimestamp] = self._db_sorted_timestamps[0]
|
||||
@@ -1271,7 +1257,7 @@ class DatabaseRecordProtocolMixin(
|
||||
if not self.db_enabled:
|
||||
return memory_min_timestamp, memory_max_timestamp
|
||||
|
||||
db_min_key, db_max_key = self.database.get_key_range(self.db_namespace())
|
||||
db_min_key, db_max_key = await self.database.get_key_range(namespace=self.db_namespace())
|
||||
|
||||
if db_min_key is None or db_max_key is None:
|
||||
return memory_min_timestamp, memory_max_timestamp
|
||||
@@ -1331,7 +1317,7 @@ class DatabaseRecordProtocolMixin(
|
||||
yield DatabaseTimestamp.from_datetime(current_utc)
|
||||
current_utc = current_utc.add(seconds=step_seconds)
|
||||
|
||||
def db_get_record(
|
||||
async def db_get_record(
|
||||
self,
|
||||
target_timestamp: DatabaseTimestamp,
|
||||
*,
|
||||
@@ -1353,11 +1339,11 @@ class DatabaseRecordProtocolMixin(
|
||||
Returns:
|
||||
Exact match, nearest record within the window, or None.
|
||||
"""
|
||||
self._db_ensure_initialized()
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
if time_window is None:
|
||||
# Exact match only — load the minimal range containing this point
|
||||
self._db_ensure_loaded(
|
||||
await self._db_ensure_loaded(
|
||||
target_timestamp,
|
||||
self._db_timestamp_after(target_timestamp),
|
||||
center_timestamp=target_timestamp,
|
||||
@@ -1367,7 +1353,7 @@ class DatabaseRecordProtocolMixin(
|
||||
# load the relevant range
|
||||
# in case of unbounded escalates to FULL
|
||||
search_start, search_end = self._search_window(target_timestamp, time_window)
|
||||
self._db_ensure_loaded(search_start, search_end, center_timestamp=target_timestamp)
|
||||
await self._db_ensure_loaded(search_start, search_end, center_timestamp=target_timestamp)
|
||||
|
||||
# Exact match first (works for all three cases once loaded)
|
||||
record = self._db_record_index.get(target_timestamp, None)
|
||||
@@ -1406,19 +1392,19 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
return record
|
||||
|
||||
def db_insert_record(
|
||||
async def db_insert_record(
|
||||
self,
|
||||
record: T_Record,
|
||||
*,
|
||||
mark_dirty: bool = True,
|
||||
) -> None:
|
||||
# Defensive call - model_post_init() may not have initialized metadata
|
||||
self._db_ensure_initialized()
|
||||
# Ensure db in memory data and metadata is initialized
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
# Ensure normalized to UTC
|
||||
db_record_date_time = DatabaseTimestamp.from_datetime(record.date_time)
|
||||
|
||||
self._db_ensure_loaded(
|
||||
await self._db_ensure_loaded(
|
||||
start_timestamp=db_record_date_time,
|
||||
end_timestamp=db_record_date_time,
|
||||
)
|
||||
@@ -1446,7 +1432,7 @@ class DatabaseRecordProtocolMixin(
|
||||
# Load (range)
|
||||
# -----------------------------------------------------
|
||||
|
||||
def db_load_records(
|
||||
async def db_load_records(
|
||||
self,
|
||||
start_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
end_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
@@ -1475,8 +1461,8 @@ class DatabaseRecordProtocolMixin(
|
||||
Note:
|
||||
record.date_time shall be DateTime or None
|
||||
"""
|
||||
# Defensive call - model_post_init() may not have initialized metadata
|
||||
self._db_ensure_initialized()
|
||||
# Ensure db in memory data and metadata is initialized
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
if not self.db_enabled:
|
||||
return 0
|
||||
@@ -1488,7 +1474,7 @@ class DatabaseRecordProtocolMixin(
|
||||
end_timestamp = UNBOUND_END
|
||||
|
||||
# Extend boundaries to include first record < start and first record >= end
|
||||
query_start, query_end = self._extend_boundaries(start_timestamp, end_timestamp)
|
||||
query_start, query_end = await self._extend_boundaries(start_timestamp, end_timestamp)
|
||||
|
||||
if isinstance(query_start, _DatabaseTimestampUnbound):
|
||||
start_key = None
|
||||
@@ -1504,7 +1490,7 @@ class DatabaseRecordProtocolMixin(
|
||||
loaded_count = 0
|
||||
|
||||
# Iterate DB records (already sorted by key)
|
||||
for db_key, value in self.database.iterate_records(
|
||||
async for db_key, value in self.database.iterate_records(
|
||||
start_key=start_key,
|
||||
end_key=end_key,
|
||||
namespace=namespace,
|
||||
@@ -1551,16 +1537,16 @@ class DatabaseRecordProtocolMixin(
|
||||
# Delete (range)
|
||||
# -----------------------------------------------------
|
||||
|
||||
def db_delete_records(
|
||||
async def db_delete_records(
|
||||
self,
|
||||
start_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
end_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
) -> int:
|
||||
# Defensive call - model_post_init() may not have initialized metadata
|
||||
self._db_ensure_initialized()
|
||||
# Ensure db in memory data and metadata is initialized
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
# Deletion is global — ensure we see everything
|
||||
self._db_ensure_loaded(
|
||||
await self._db_ensure_loaded(
|
||||
start_timestamp=start_timestamp,
|
||||
end_timestamp=end_timestamp,
|
||||
)
|
||||
@@ -1598,21 +1584,21 @@ class DatabaseRecordProtocolMixin(
|
||||
# Iteration from DB (no duplicates)
|
||||
# -----------------------------------------------------
|
||||
|
||||
def db_iterate_records(
|
||||
async def db_iterate_records(
|
||||
self,
|
||||
start_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
end_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
) -> Iterator[T_Record]:
|
||||
) -> AsyncIterator[T_Record]:
|
||||
"""Iterate records in requested range.
|
||||
|
||||
Ensures storage is loaded into memory first,
|
||||
then iterates over in-memory records only.
|
||||
"""
|
||||
# Defensive call - model_post_init() may not have initialized metadata
|
||||
self._db_ensure_initialized()
|
||||
# Ensure db in memory data and metadata is initialized
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
# Ensure memory contains required range
|
||||
self._db_ensure_loaded(
|
||||
await self._db_ensure_loaded(
|
||||
start_timestamp=start_timestamp,
|
||||
end_timestamp=end_timestamp,
|
||||
)
|
||||
@@ -1635,9 +1621,9 @@ class DatabaseRecordProtocolMixin(
|
||||
# Dirty tracking
|
||||
# -----------------------------------------------------
|
||||
|
||||
def db_mark_dirty_record(self, record: T_Record) -> None:
|
||||
# Defensive call - model_post_init() may not have initialized metadata
|
||||
self._db_ensure_initialized()
|
||||
async def db_mark_dirty_record(self, record: T_Record) -> None:
|
||||
# Ensure db in memory data and metadata is initialized
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
record_date_time_timestamp = DatabaseTimestamp.from_datetime(record.date_time)
|
||||
self._db_dirty_timestamps.add(record_date_time_timestamp)
|
||||
@@ -1646,9 +1632,9 @@ class DatabaseRecordProtocolMixin(
|
||||
# Bulk save (flush dirty only)
|
||||
# -----------------------------------------------------
|
||||
|
||||
def db_save_records(self) -> int:
|
||||
# Defensive call - model_post_init() may not have initialized metadata
|
||||
self._db_ensure_initialized()
|
||||
async def db_save_records(self) -> int:
|
||||
# Ensure db in memory data and metadata is initialized
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
if not self.db_enabled:
|
||||
return 0
|
||||
@@ -1670,23 +1656,20 @@ class DatabaseRecordProtocolMixin(
|
||||
save_items.append((key, value))
|
||||
saved_count = len(save_items)
|
||||
if saved_count:
|
||||
self.database.save_records(save_items, namespace=namespace)
|
||||
await self.database.save_records(save_items, namespace=namespace)
|
||||
self._db_dirty_timestamps.clear()
|
||||
self._db_new_timestamps.clear()
|
||||
|
||||
# --- handle deletions ---
|
||||
if self._db_deleted_timestamps:
|
||||
delete_keys = [self._db_key_from_timestamp(dt) for dt in self._db_deleted_timestamps]
|
||||
self.database.delete_records(delete_keys, namespace=namespace)
|
||||
await self.database.delete_records(delete_keys, namespace=namespace)
|
||||
deleted_count = len(self._db_deleted_timestamps)
|
||||
self._db_deleted_timestamps.clear()
|
||||
|
||||
return saved_count + deleted_count
|
||||
|
||||
def db_autosave(self) -> int:
|
||||
return self.db_save_records()
|
||||
|
||||
def db_vacuum(
|
||||
async def db_vacuum(
|
||||
self,
|
||||
keep_hours: Optional[int] = None,
|
||||
keep_timestamp: Optional[DatabaseTimestampType] = None,
|
||||
@@ -1708,8 +1691,8 @@ class DatabaseRecordProtocolMixin(
|
||||
Returns:
|
||||
Number of records deleted
|
||||
"""
|
||||
# Defensive call - model_post_init() may not have initialized metadata
|
||||
self._db_ensure_initialized()
|
||||
# Ensure db in memory data and metadata is initialized
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
if keep_hours is None and keep_timestamp is None:
|
||||
keep_duration = self.db_keep_duration()
|
||||
@@ -1722,7 +1705,7 @@ class DatabaseRecordProtocolMixin(
|
||||
keep_hours = keep_duration.hours
|
||||
|
||||
if keep_hours is not None:
|
||||
_, db_max = self.db_timestamp_range()
|
||||
_, db_max = await self.db_timestamp_range()
|
||||
if db_max is None or isinstance(db_max, _DatabaseTimestampUnbound):
|
||||
# No records
|
||||
return 0 # nothing to delete
|
||||
@@ -1740,9 +1723,9 @@ class DatabaseRecordProtocolMixin(
|
||||
raise ValueError("Must specify either keep_hours or keep_timestamp")
|
||||
|
||||
# Delete records
|
||||
deleted_count = self.db_delete_records(end_timestamp=db_cutoff_timestamp)
|
||||
deleted_count = await self.db_delete_records(end_timestamp=db_cutoff_timestamp)
|
||||
|
||||
self.db_save_records()
|
||||
await self.db_save_records()
|
||||
|
||||
logger.info(
|
||||
f"Vacuumed {deleted_count} old records from database '{self.db_namespace()}' "
|
||||
@@ -1750,14 +1733,14 @@ class DatabaseRecordProtocolMixin(
|
||||
)
|
||||
return deleted_count
|
||||
|
||||
def db_count_records(self) -> int:
|
||||
async def db_count_records(self) -> int:
|
||||
"""Return total logical number of records.
|
||||
|
||||
Memory is authoritative. If DB is enabled but not fully loaded,
|
||||
we conservatively include storage-only records.
|
||||
"""
|
||||
# Defensive call - model_post_init() may not have initialized metadata
|
||||
self._db_ensure_initialized()
|
||||
# Ensure db in memory data and metadata is initialized
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
if not self.db_enabled:
|
||||
return len(self.records)
|
||||
@@ -1766,13 +1749,13 @@ class DatabaseRecordProtocolMixin(
|
||||
if self._db_load_phase is DatabaseRecordProtocolLoadPhase.FULL:
|
||||
return len(self.records)
|
||||
|
||||
storage_count = self.database.count_records(namespace=self.db_namespace())
|
||||
storage_count = await self.database.count_records(namespace=self.db_namespace())
|
||||
pending_deletes = len(self._db_deleted_timestamps)
|
||||
new_count = len(self._db_new_timestamps)
|
||||
|
||||
return storage_count + new_count - pending_deletes
|
||||
|
||||
def db_get_stats(self) -> dict:
|
||||
async def db_get_stats(self) -> dict:
|
||||
"""Get comprehensive statistics about database storage.
|
||||
|
||||
Returns:
|
||||
@@ -1783,6 +1766,8 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
ns = self.db_namespace()
|
||||
|
||||
total_records = await self.database.count_records(namespace=ns)
|
||||
|
||||
stats = {
|
||||
"enabled": True,
|
||||
"backend": self.database.__class__.__name__,
|
||||
@@ -1791,13 +1776,14 @@ class DatabaseRecordProtocolMixin(
|
||||
"compression_enabled": self.database.compression,
|
||||
"keep_duration_h": self.config.database.keep_duration_h,
|
||||
"autosave_interval_sec": self.config.database.autosave_interval_sec,
|
||||
"total_records": self.database.count_records(namespace=ns),
|
||||
"total_records": total_records,
|
||||
}
|
||||
|
||||
# Add backend-specific stats
|
||||
stats.update(self.database.get_backend_stats(namespace=ns))
|
||||
backend_stats = await self.database.get_backend_stats(namespace=ns)
|
||||
stats.update(backend_stats)
|
||||
|
||||
min_timestamp, max_timestamp = self.db_timestamp_range()
|
||||
min_timestamp, max_timestamp = await self.db_timestamp_range()
|
||||
stats["timestamp_range"] = {
|
||||
"min": str(min_timestamp),
|
||||
"max": str(max_timestamp),
|
||||
@@ -1866,7 +1852,7 @@ class DatabaseRecordProtocolMixin(
|
||||
cutoff_str = self._db_metadata.get(key)
|
||||
return DatabaseTimestamp(cutoff_str) if cutoff_str else None
|
||||
|
||||
def _db_set_compact_state(
|
||||
async def _db_set_compact_state(
|
||||
self,
|
||||
tier_interval: Duration,
|
||||
cutoff_ts: DatabaseTimestamp,
|
||||
@@ -1881,13 +1867,13 @@ class DatabaseRecordProtocolMixin(
|
||||
self._db_metadata = {}
|
||||
key = f"last_compact_cutoff_{int(tier_interval.total_seconds())}"
|
||||
self._db_metadata[key] = str(cutoff_ts)
|
||||
self._db_save_metadata(self._db_metadata)
|
||||
await self._db_save_metadata(self._db_metadata)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Single-tier worker
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _db_compact_tier(
|
||||
async def _db_compact_tier(
|
||||
self,
|
||||
age_threshold: Duration,
|
||||
target_interval: Duration,
|
||||
@@ -1923,14 +1909,14 @@ class DatabaseRecordProtocolMixin(
|
||||
Number of original records deleted (before re-insertion of downsampled
|
||||
records). Returns 0 if skipped.
|
||||
"""
|
||||
self._db_ensure_initialized()
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
interval_sec = int(target_interval.total_seconds())
|
||||
if interval_sec <= 0:
|
||||
return 0
|
||||
|
||||
# ---- Determine raw new cutoff ------------------------------------
|
||||
_, db_max = self.db_timestamp_range()
|
||||
_, db_max = await self.db_timestamp_range()
|
||||
if db_max is None or isinstance(db_max, _DatabaseTimestampUnbound):
|
||||
return 0
|
||||
|
||||
@@ -1955,7 +1941,7 @@ class DatabaseRecordProtocolMixin(
|
||||
)
|
||||
return 0
|
||||
|
||||
db_min, _ = self.db_timestamp_range()
|
||||
db_min, _ = await self.db_timestamp_range()
|
||||
if db_min is None or isinstance(db_min, _DatabaseTimestampUnbound):
|
||||
return 0
|
||||
|
||||
@@ -1981,7 +1967,11 @@ class DatabaseRecordProtocolMixin(
|
||||
window_end_ts = new_cutoff_ts
|
||||
|
||||
# ---- Sparse-data guard -------------------------------------------
|
||||
existing_count = self.database.count_records(
|
||||
# Ensure the window is loaded into memory so the sparse guard's
|
||||
# records_in_window list comprehension sees actual data.
|
||||
await self._db_ensure_loaded(window_start_ts, window_end_ts)
|
||||
|
||||
existing_count = await self.database.count_records(
|
||||
start_key=self._db_key_from_timestamp(window_start_ts),
|
||||
end_key=self._db_key_from_timestamp(window_end_ts),
|
||||
namespace=self.db_namespace(),
|
||||
@@ -1993,7 +1983,7 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
if existing_count == 0:
|
||||
# Nothing in window — just advance the cutoff
|
||||
self._db_set_compact_state(target_interval, new_cutoff_ts)
|
||||
await self._db_set_compact_state(target_interval, new_cutoff_ts)
|
||||
return 0
|
||||
|
||||
if existing_count <= resampled_count:
|
||||
@@ -2016,7 +2006,7 @@ class DatabaseRecordProtocolMixin(
|
||||
f"and all timestamps already aligned "
|
||||
f"(window={window_start_dt}..{window_end_dt})"
|
||||
)
|
||||
self._db_set_compact_state(target_interval, new_cutoff_ts)
|
||||
await self._db_set_compact_state(target_interval, new_cutoff_ts)
|
||||
return 0
|
||||
|
||||
# ---- Sparse but misaligned: full window rewrite -----------------
|
||||
@@ -2049,7 +2039,7 @@ class DatabaseRecordProtocolMixin(
|
||||
bucket[key] = val
|
||||
|
||||
# Delete entire window (aligned + misaligned)
|
||||
deleted = self.db_delete_records(
|
||||
deleted = await self.db_delete_records(
|
||||
start_timestamp=window_start_ts,
|
||||
end_timestamp=window_end_ts,
|
||||
)
|
||||
@@ -2060,10 +2050,10 @@ class DatabaseRecordProtocolMixin(
|
||||
continue
|
||||
snapped_dt = DateTime.fromtimestamp(snapped_epoch, tz="UTC")
|
||||
record = self.record_class()(date_time=snapped_dt, **values)
|
||||
self.db_insert_record(record, mark_dirty=True)
|
||||
await self.db_insert_record(record, mark_dirty=True)
|
||||
|
||||
self.db_save_records()
|
||||
self._db_set_compact_state(target_interval, new_cutoff_ts)
|
||||
await self.db_save_records()
|
||||
await self._db_set_compact_state(target_interval, new_cutoff_ts)
|
||||
logger.info(
|
||||
f"Rewrote sparse window in namespace '{self.db_namespace()}' "
|
||||
f"tier {target_interval}: deleted={deleted}, "
|
||||
@@ -2086,7 +2076,7 @@ class DatabaseRecordProtocolMixin(
|
||||
if key == "date_time":
|
||||
continue
|
||||
try:
|
||||
array = self.key_to_array(
|
||||
array = await self.key_to_array(
|
||||
key,
|
||||
start_datetime=window_start_dt,
|
||||
end_datetime=window_end_dt,
|
||||
@@ -2095,7 +2085,9 @@ class DatabaseRecordProtocolMixin(
|
||||
boundary="context",
|
||||
align_to_interval=True,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
logger.debug(f"key={key}, array_len={len(array)}")
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
logger.error(f"key_to_array failed for {key}: {e}")
|
||||
continue # non-numeric or missing key — skip silently
|
||||
|
||||
if len(array) == 0:
|
||||
@@ -2125,11 +2117,11 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
if not compacted_data or not compacted_timestamps:
|
||||
# Nothing to write back — still advance cutoff
|
||||
self._db_set_compact_state(target_interval, new_cutoff_ts)
|
||||
await self._db_set_compact_state(target_interval, new_cutoff_ts)
|
||||
return 0
|
||||
|
||||
# ---- Delete originals, re-insert downsampled records -------------
|
||||
deleted = self.db_delete_records(
|
||||
deleted = await self.db_delete_records(
|
||||
start_timestamp=window_start_ts,
|
||||
end_timestamp=window_end_ts,
|
||||
)
|
||||
@@ -2142,12 +2134,12 @@ class DatabaseRecordProtocolMixin(
|
||||
}
|
||||
if values:
|
||||
record = self.record_class()(date_time=dt, **values)
|
||||
self.db_insert_record(record, mark_dirty=True)
|
||||
await self.db_insert_record(record, mark_dirty=True)
|
||||
|
||||
self.db_save_records()
|
||||
await self.db_save_records()
|
||||
|
||||
# Persist the aligned new cutoff for this tier
|
||||
self._db_set_compact_state(target_interval, new_cutoff_ts)
|
||||
await self._db_set_compact_state(target_interval, new_cutoff_ts)
|
||||
|
||||
logger.info(
|
||||
f"Compacted tier {target_interval}: deleted {deleted} records in "
|
||||
@@ -2161,7 +2153,7 @@ class DatabaseRecordProtocolMixin(
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def db_compact(
|
||||
async def db_compact(
|
||||
self,
|
||||
compact_tiers: Optional[list[tuple[Duration, Duration]]] = None,
|
||||
) -> int:
|
||||
@@ -2183,12 +2175,13 @@ class DatabaseRecordProtocolMixin(
|
||||
compact_tiers = self.db_compact_tiers()
|
||||
|
||||
if not compact_tiers:
|
||||
logger.debug(f"Compaction called but no compact_tiers '{compact_tiers}' given.")
|
||||
return 0
|
||||
|
||||
total_deleted = 0
|
||||
|
||||
# Coarsest tier first (reversed) to avoid redundant work
|
||||
for age_threshold, target_interval in reversed(compact_tiers):
|
||||
total_deleted += self._db_compact_tier(age_threshold, target_interval)
|
||||
total_deleted += await self._db_compact_tier(age_threshold, target_interval)
|
||||
|
||||
return total_deleted
|
||||
|
||||
@@ -2,8 +2,7 @@ import traceback
|
||||
from asyncio import Lock, get_running_loop
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from enum import StrEnum
|
||||
from functools import partial
|
||||
from typing import ClassVar, Optional
|
||||
from typing import ClassVar, Optional, cast
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import computed_field
|
||||
@@ -147,11 +146,11 @@ class EnergyManagement(
|
||||
"""
|
||||
return cls._genetic_solution
|
||||
|
||||
@classmethod
|
||||
def _run(
|
||||
cls,
|
||||
start_datetime: DateTime,
|
||||
mode: EnergyManagementMode,
|
||||
async def run(
|
||||
self,
|
||||
start_datetime: Optional[DateTime] = None,
|
||||
mode: Optional[EnergyManagementMode] = None,
|
||||
algorithm: Optional[str] = None,
|
||||
genetic_parameters: Optional[GeneticOptimizationParameters] = None,
|
||||
genetic_individuals: Optional[int] = None,
|
||||
genetic_seed: Optional[int] = None,
|
||||
@@ -161,7 +160,6 @@ class EnergyManagement(
|
||||
"""Run the energy management.
|
||||
|
||||
This method initializes the energy management run by setting its
|
||||
|
||||
start datetime, updating predictions, and optionally starting
|
||||
optimization depending on the selected mode or configuration.
|
||||
|
||||
@@ -171,161 +169,20 @@ class EnergyManagement(
|
||||
- "OPTIMIZATION": Runs the optimization process.
|
||||
- "PREDICTION": Updates the forecast without optimization.
|
||||
- "DISABLED": Does not run.
|
||||
genetic_parameters (GeneticOptimizationParameters, optional): The
|
||||
parameter set for the genetic algorithm. If not provided, it will
|
||||
be constructed based on the current configuration and predictions.
|
||||
genetic_individuals (int, optional): The number of individuals for the
|
||||
genetic algorithm. Defaults to the algorithm's internal default (400)
|
||||
if not specified.
|
||||
genetic_seed (int, optional): The seed for the genetic algorithm. Defaults
|
||||
to the algorithm's internal random seed if not specified.
|
||||
force_enable (bool, optional): If True, bypasses any disabled state
|
||||
to force the update process. This is mostly applicable to
|
||||
prediction providers.
|
||||
force_update (bool, optional): If True, forces data to be refreshed
|
||||
even if a cached version is still valid.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
# Ensure there is only one optimization/ energy management run at a time
|
||||
if not mode in EnergyManagementMode._value2member_map_:
|
||||
raise ValueError(f"Unknown energy management mode {mode}.")
|
||||
if mode == EnergyManagementMode.DISABLED:
|
||||
return
|
||||
|
||||
logger.info("Starting energy management run.")
|
||||
|
||||
cls._stage = EnergyManagementStage.DATA_ACQUISITION
|
||||
|
||||
# Remember/ set the start datetime of this energy management run.
|
||||
# None leads
|
||||
cls.set_start_datetime(start_datetime)
|
||||
|
||||
# Throw away any memory cached results of the last energy management run.
|
||||
CacheEnergyManagementStore().clear()
|
||||
|
||||
# Do data aquisition by adapters
|
||||
try:
|
||||
cls.adapter.update_data(force_enable)
|
||||
except Exception as e:
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
error_msg = f"Adapter update failed - phase {cls._stage}:\n{e}\n{trace}"
|
||||
logger.error(error_msg)
|
||||
|
||||
cls._stage = EnergyManagementStage.FORECAST_RETRIEVAL
|
||||
|
||||
if mode == EnergyManagementMode.PREDICTION:
|
||||
# Update the predictions
|
||||
cls.prediction.update_data(force_enable=force_enable, force_update=force_update)
|
||||
logger.info("Energy management run done (predictions updated)")
|
||||
cls._stage = EnergyManagementStage.IDLE
|
||||
return
|
||||
|
||||
# Prepare optimization parameters
|
||||
# This also creates default configurations for missing values and updates the predictions
|
||||
logger.info(
|
||||
"Starting energy management prediction update and optimzation parameter preparation."
|
||||
)
|
||||
if genetic_parameters is None:
|
||||
genetic_parameters = GeneticOptimizationParameters.prepare()
|
||||
|
||||
if not genetic_parameters:
|
||||
logger.error(
|
||||
"Energy management run canceled. Could not prepare optimisation parameters."
|
||||
)
|
||||
cls._stage = EnergyManagementStage.IDLE
|
||||
return
|
||||
|
||||
cls._stage = EnergyManagementStage.OPTIMIZATION
|
||||
logger.info("Starting energy management optimization.")
|
||||
|
||||
# Take values from config if not given
|
||||
if genetic_individuals is None:
|
||||
genetic_individuals = cls.config.optimization.genetic.individuals
|
||||
if genetic_seed is None:
|
||||
genetic_seed = cls.config.optimization.genetic.seed
|
||||
|
||||
if cls._start_datetime is None: # Make mypy happy - already set by us
|
||||
raise RuntimeError("Start datetime not set.")
|
||||
|
||||
try:
|
||||
optimization = GeneticOptimization(
|
||||
verbose=bool(cls.config.server.verbose),
|
||||
fixed_seed=genetic_seed,
|
||||
)
|
||||
solution = optimization.optimierung_ems(
|
||||
start_hour=cls._start_datetime.hour,
|
||||
parameters=genetic_parameters,
|
||||
ngen=genetic_individuals,
|
||||
)
|
||||
except:
|
||||
logger.exception("Energy management optimization failed.")
|
||||
cls._stage = EnergyManagementStage.IDLE
|
||||
return
|
||||
|
||||
cls._stage = EnergyManagementStage.CONTROL_DISPATCH
|
||||
|
||||
# Make genetic solution public
|
||||
cls._genetic_solution = solution
|
||||
|
||||
# Make optimization solution public
|
||||
cls._optimization_solution = solution.optimization_solution()
|
||||
|
||||
# Make plan public
|
||||
cls._plan = solution.energy_management_plan()
|
||||
|
||||
logger.debug("Energy management genetic solution:\n{}", cls._genetic_solution)
|
||||
logger.debug("Energy management optimization solution:\n{}", cls._optimization_solution)
|
||||
logger.debug("Energy management plan:\n{}", cls._plan)
|
||||
logger.info("Energy management run done (optimization updated)")
|
||||
|
||||
# Do control dispatch by adapters
|
||||
try:
|
||||
cls.adapter.update_data(force_enable)
|
||||
except Exception as e:
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
error_msg = f"Adapter update failed - phase {cls._stage}:\n{e}\n{trace}"
|
||||
logger.error(error_msg)
|
||||
|
||||
# Remember energy run datetime.
|
||||
EnergyManagement._last_run_datetime = to_datetime()
|
||||
|
||||
# energy management run finished
|
||||
cls._stage = EnergyManagementStage.IDLE
|
||||
|
||||
async def run(
|
||||
self,
|
||||
start_datetime: Optional[DateTime] = None,
|
||||
mode: Optional[EnergyManagementMode] = None,
|
||||
genetic_parameters: Optional[GeneticOptimizationParameters] = None,
|
||||
genetic_individuals: Optional[int] = None,
|
||||
genetic_seed: Optional[int] = None,
|
||||
force_enable: Optional[bool] = False,
|
||||
force_update: Optional[bool] = False,
|
||||
) -> None:
|
||||
"""Run the energy management.
|
||||
|
||||
This method initializes the energy management run by setting its
|
||||
start datetime, updating predictions, and optionally starting
|
||||
optimization depending on the selected mode or configuration.
|
||||
|
||||
Args:
|
||||
start_datetime (DateTime, optional): The starting timestamp
|
||||
of the energy management run. Defaults to the current datetime
|
||||
if not provided.
|
||||
mode (EnergyManagementMode, optional): The management mode to use. Must be one of:
|
||||
- "OPTIMIZATION": Runs the optimization process.
|
||||
- "PREDICTION": Updates the forecast without optimization.
|
||||
|
||||
Defaults to the mode defined in the current configuration.
|
||||
algorithm (str, optional):
|
||||
The algorithm to use. Must be one of:
|
||||
- "GENETIC": Optimization uses the `GENETIC` optimization algorithm.
|
||||
|
||||
Defaults to the algorithm defined in the current configuration.
|
||||
genetic_parameters (GeneticOptimizationParameters, optional): The
|
||||
parameter set for the genetic algorithm. If not provided, it will
|
||||
parameter set for the `GENETIC` algorithm. If not provided, it will
|
||||
be constructed based on the current configuration and predictions.
|
||||
genetic_individuals (int, optional): The number of individuals for the
|
||||
genetic algorithm. Defaults to the algorithm's internal default (400)
|
||||
`GENETIC` algorithm. Defaults to the algorithm's internal default (400)
|
||||
if not specified.
|
||||
genetic_seed (int, optional): The seed for the genetic algorithm. Defaults
|
||||
genetic_seed (int, optional): The seed for the `GENETIC` algorithm. Defaults
|
||||
to the algorithm's internal random seed if not specified.
|
||||
force_enable (bool, optional): If True, bypasses any disabled state
|
||||
to force the update process. This is mostly applicable to
|
||||
@@ -336,22 +193,205 @@ class EnergyManagement(
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
async with self._run_lock:
|
||||
loop = get_running_loop()
|
||||
# Create a partial function with parameters "baked in"
|
||||
if start_datetime is None:
|
||||
start_datetime = to_datetime()
|
||||
async with EnergyManagement._run_lock:
|
||||
if mode is None:
|
||||
mode = self.config.ems.mode
|
||||
func = partial(
|
||||
EnergyManagement._run,
|
||||
start_datetime=start_datetime,
|
||||
mode=mode,
|
||||
genetic_parameters=genetic_parameters,
|
||||
genetic_individuals=genetic_individuals,
|
||||
genetic_seed=genetic_seed,
|
||||
force_enable=force_enable,
|
||||
force_update=force_update,
|
||||
|
||||
if mode not in EnergyManagementMode._value2member_map_:
|
||||
raise ValueError(f"Unknown energy management mode {mode}.")
|
||||
if mode == EnergyManagementMode.DISABLED:
|
||||
logger.info("Energy management run disabled.")
|
||||
return
|
||||
|
||||
logger.info("Starting energy management run.")
|
||||
|
||||
# --- Data Aquisition ---
|
||||
EnergyManagement._stage = EnergyManagementStage.DATA_ACQUISITION
|
||||
|
||||
# Remember/ set the start datetime of this energy management run.
|
||||
# None leads to current time as start datetime
|
||||
self.set_start_datetime(start_datetime)
|
||||
|
||||
# Throw away any memory cached results of the last energy management run.
|
||||
CacheEnergyManagementStore().clear()
|
||||
|
||||
# --- Adapter update ---
|
||||
try:
|
||||
await self.adapter.update_data(force_enable)
|
||||
except Exception as e:
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
error_msg = (
|
||||
f"Adapter update failed - phase {EnergyManagement._stage}:\n{e}\n{trace}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
|
||||
# --- Prediction ---
|
||||
EnergyManagement._stage = EnergyManagementStage.FORECAST_RETRIEVAL
|
||||
|
||||
# Update the predictions
|
||||
logger.info("Starting energy management prediction update.")
|
||||
await self.prediction.update_data(force_enable=force_enable, force_update=force_update)
|
||||
|
||||
if mode == EnergyManagementMode.PREDICTION:
|
||||
logger.info("Energy management run done (predictions updated)")
|
||||
EnergyManagement._stage = EnergyManagementStage.IDLE
|
||||
return
|
||||
|
||||
# --- Optimization ---
|
||||
EnergyManagement._stage = EnergyManagementStage.OPTIMIZATION
|
||||
optimization_start = to_datetime()
|
||||
logger.info("Starting energy management optimization.")
|
||||
|
||||
if algorithm is None:
|
||||
algorithm = self.config.optimization.algorithm
|
||||
|
||||
if algorithm == "GENETIC":
|
||||
# Prepare optimization parameters
|
||||
# This also creates default configurations for missing values and updates the predictions
|
||||
logger.info("Starting optimzation parameter preparation.")
|
||||
if genetic_parameters is None:
|
||||
genetic_parameters = await GeneticOptimizationParameters.prepare()
|
||||
if genetic_parameters is None:
|
||||
logger.error(
|
||||
"Energy management run canceled. Could not prepare optimisation parameters."
|
||||
)
|
||||
EnergyManagement._stage = EnergyManagementStage.IDLE
|
||||
return
|
||||
|
||||
# Take values from config if not given
|
||||
if genetic_individuals is None:
|
||||
genetic_individuals = self.config.optimization.genetic.individuals
|
||||
if genetic_seed is None:
|
||||
genetic_seed = self.config.optimization.genetic.seed
|
||||
|
||||
if EnergyManagement._start_datetime is None: # Make mypy happy - already set by us
|
||||
raise RuntimeError("Start datetime not set.")
|
||||
|
||||
# --- Optimization (CPU-bound → MUST offload) ---
|
||||
try:
|
||||
optimization = GeneticOptimization(
|
||||
verbose=bool(self.config.server.verbose),
|
||||
fixed_seed=genetic_seed,
|
||||
)
|
||||
|
||||
loop = get_running_loop()
|
||||
start_hour = EnergyManagement._start_datetime.hour
|
||||
solution = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: optimization.optimierung_ems(
|
||||
start_hour=start_hour,
|
||||
parameters=cast(
|
||||
GeneticOptimizationParameters, genetic_parameters
|
||||
), # cast for mypy
|
||||
ngen=genetic_individuals,
|
||||
),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Energy management optimization failed.")
|
||||
EnergyManagement._stage = EnergyManagementStage.IDLE
|
||||
return
|
||||
|
||||
else:
|
||||
logger.error(f"Unknown optimization algorithm: '{algorithm}'. Skipping.")
|
||||
EnergyManagement._stage = EnergyManagementStage.IDLE
|
||||
return
|
||||
|
||||
optimization_duration = to_datetime() - optimization_start
|
||||
logger.info(
|
||||
"Energy management optimization ({}) completed in {:.1f} seconds.",
|
||||
algorithm,
|
||||
optimization_duration.total_seconds(),
|
||||
)
|
||||
# Run optimization in background thread to avoid blocking event loop
|
||||
await loop.run_in_executor(executor, func)
|
||||
|
||||
logger.debug(
|
||||
"Energy management optimization solution:\n{}",
|
||||
EnergyManagement._optimization_solution,
|
||||
)
|
||||
logger.debug("Energy management plan:\n{}", EnergyManagement._plan)
|
||||
|
||||
# --- Control dispatch by adapters ---
|
||||
EnergyManagement._stage = EnergyManagementStage.CONTROL_DISPATCH
|
||||
|
||||
# Make genetic solution public
|
||||
EnergyManagement._genetic_solution = solution
|
||||
|
||||
# Make optimization solution public
|
||||
EnergyManagement._optimization_solution = await solution.optimization_solution()
|
||||
|
||||
# Make plan public
|
||||
EnergyManagement._plan = solution.energy_management_plan()
|
||||
|
||||
logger.debug(
|
||||
"Energy management genetic solution:\n{}", EnergyManagement._genetic_solution
|
||||
)
|
||||
|
||||
if genetic_parameters is None:
|
||||
genetic_parameters = await GeneticOptimizationParameters.prepare()
|
||||
|
||||
if not genetic_parameters:
|
||||
logger.error("Energy management run canceled. Could not prepare parameters.")
|
||||
EnergyManagement._stage = EnergyManagementStage.IDLE
|
||||
return
|
||||
|
||||
EnergyManagement._stage = EnergyManagementStage.OPTIMIZATION
|
||||
|
||||
if genetic_individuals is None:
|
||||
genetic_individuals = self.config.optimization.genetic.individuals
|
||||
if genetic_seed is None:
|
||||
genetic_seed = self.config.optimization.genetic.seed
|
||||
|
||||
if EnergyManagement._start_datetime is None:
|
||||
raise RuntimeError("Start datetime not set.")
|
||||
|
||||
# --- Optimization (CPU-bound → MUST offload) ---
|
||||
try:
|
||||
optimization = GeneticOptimization(
|
||||
verbose=bool(self.config.server.verbose),
|
||||
fixed_seed=genetic_seed,
|
||||
)
|
||||
|
||||
loop = get_running_loop()
|
||||
start_hour = EnergyManagement._start_datetime.hour
|
||||
solution = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: optimization.optimierung_ems(
|
||||
start_hour=start_hour,
|
||||
parameters=genetic_parameters,
|
||||
ngen=genetic_individuals,
|
||||
),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Energy management optimization failed.")
|
||||
EnergyManagement._stage = EnergyManagementStage.IDLE
|
||||
return
|
||||
|
||||
EnergyManagement._genetic_solution = solution
|
||||
EnergyManagement._optimization_solution = await solution.optimization_solution()
|
||||
EnergyManagement._plan = solution.energy_management_plan()
|
||||
|
||||
logger.debug("Genetic solution:\n{}", EnergyManagement._genetic_solution)
|
||||
logger.debug("Optimization solution:\n{}", EnergyManagement._optimization_solution)
|
||||
logger.debug("Plan:\n{}", EnergyManagement._plan)
|
||||
logger.info("Energy management run done (optimization updated)")
|
||||
|
||||
# --- Dispatch control by adapters ---
|
||||
EnergyManagement._stage = EnergyManagementStage.CONTROL_DISPATCH
|
||||
|
||||
# Dispatch (sync → optionally offload)
|
||||
try:
|
||||
await self.adapter.update_data(force_enable)
|
||||
except Exception as e:
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
error_msg = (
|
||||
f"Adapter update failed - phase {EnergyManagement._stage}:\n{e}\n{trace}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
|
||||
# --- Idle ---
|
||||
# Remember energy run datetime.
|
||||
EnergyManagement._last_run_datetime = to_datetime()
|
||||
|
||||
# energy management run finished
|
||||
EnergyManagement._stage = EnergyManagementStage.IDLE
|
||||
|
||||
@@ -543,10 +543,10 @@ class PydanticModelNestedValueMixin:
|
||||
if not inspect.isclass(model):
|
||||
raise TypeError(f"Model '{model}' is not of class type.")
|
||||
|
||||
if key not in model.model_fields:
|
||||
if key not in model.model_fields: # type: ignore[attr-defined]
|
||||
raise TypeError(f"Field '{key}' does not exist in model '{model.__name__}'.")
|
||||
|
||||
field_annotation = model.model_fields[key].annotation
|
||||
field_annotation = model.model_fields[key].annotation # type: ignore[attr-defined]
|
||||
if not field_annotation:
|
||||
raise TypeError(
|
||||
f"Missing type annotation for field '{key}' in model '{model.__name__}'."
|
||||
@@ -692,7 +692,7 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
||||
return super().model_dump(*args, **kwargs)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert this PredictionRecord instance to a dictionary representation.
|
||||
"""Convert this pydantic model instance to a dictionary representation.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary where the keys are the field names of the PydanticBaseModel,
|
||||
|
||||
Reference in New Issue
Block a user