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:
Bobby Noelte
2026-07-15 16:38:53 +02:00
committed by GitHub
parent 38011780c5
commit eb9e966de9
99 changed files with 11971 additions and 5981 deletions

View File

@@ -64,7 +64,8 @@ None indicates forever. Database namespaces may have diverging definitions. |
"batch_size": 100,
"providers": [
"LMDB",
"SQLite"
"SQLite",
"NoDB"
]
}
}

View File

@@ -1,6 +1,6 @@
# Akkudoktor-EOS
**Version**: `v0.3.0.dev2604141105859917`
**Version**: `v0.3.0.dev2607150960221650`
<!-- pyml disable line-length -->
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.
@@ -338,6 +338,31 @@ Returns:
---
## POST /v1/admin/database/save
<!-- pyml disable line-length -->
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_database_save_post_v1_admin_database_save_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_database_save_post_v1_admin_database_save_post)
<!-- pyml enable line-length -->
Fastapi Admin Database Save Post
<!-- pyml disable line-length -->
```python
"""
Save in memory data to database.
Returns:
data (dict): The database stats after saving the records.
"""
```
<!-- pyml enable line-length -->
**Responses**:
- **200**: Successful Response
---
## GET /v1/admin/database/stats
<!-- pyml disable line-length -->
@@ -882,6 +907,38 @@ Fastapi Measurement Keys Get
---
## DELETE /v1/measurement/range
<!-- pyml disable line-length -->
**Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_range_delete_v1_measurement_range_delete), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_range_delete_v1_measurement_range_delete)
<!-- pyml enable line-length -->
Fastapi Measurement Range Delete
<!-- pyml disable line-length -->
```python
"""
Delete measurement values for a key within a datetime range.
"""
```
<!-- pyml enable line-length -->
**Parameters**:
- `key` (query, required): Measurement key.
- `start_datetime` (query, optional): Start datetime.
- `end_datetime` (query, optional): End datetime.
**Responses**:
- **200**: Successful Response
- **422**: Validation Error
---
## GET /v1/measurement/series
<!-- pyml disable line-length -->

View File

@@ -38,6 +38,7 @@ Typical use cases with Node-RED:
#### 1. Enable and configure the Node-RED adapter
EOS must be configured with access to the Node-RED instance in Config->adapter.
* prerequisite is an already installed and running Node-RED instance
* adapter.nodered.host: 192.168.1.109 (example IP of your Node-RED instance)
* adapter.nodered.port: 1880 (default)
@@ -46,10 +47,12 @@ EOS must be configured with access to the Node-RED instance in Config->adapter.
#### 2. Run energy optimisation
Before the run, EOS receives:
* EOS receives measurement values via HTTP-IN "eos_data_acquisition" before optimisation.
(The HTTP-IN Node "eos_data_acquisition" is NOT yet functional)
After the run, EOS provides:
* The device instruction and solution entities for the current time slot via HTTP-IN "Control Dispatch".
### Configuration steps in NodeRED

View File

@@ -284,6 +284,7 @@ DatabaseTimestamp.from_datetime(dt: DateTime) -> "20241027T123456[Z]"
```
**Properties:**
- Always stored in UTC (timezone-aware required)
- Lexicographically sortable
- Bijective conversion to/from `pendulum.DateTime`
@@ -330,6 +331,7 @@ The system uses a progressive loading model to minimize memory footprint:
### Boundary Extension
When loading a range `[start, end)`, the system automatically extends boundaries to include:
- **First record before** `start` (for interpolation/context)
- **First record at or after** `end` (for closing boundary)
@@ -348,6 +350,7 @@ SELECT * FROM records WHERE namespace='measurement'
```
**Default Namespace:**
- Can be set during `open(namespace="default")`
- Operations with `namespace=None` use the default
- Each record class typically defines its own namespace via `db_namespace()`
@@ -537,6 +540,7 @@ db_vacuum(keep_timestamp=cutoff) # Keep from cutoff onward
```
**Strategy:**
- Computes cutoff relative to `max_timestamp - keep_hours`
- Deletes all records before cutoff
- Immediately persists changes via `db_save_records()`

View File

@@ -223,6 +223,7 @@ penalty = ac_wh_charged × (break_even_price best_uncovered_price) × factor
```
where:
- `break_even_price = charge_price / η_round_trip`
- `best_uncovered_price` = highest future price not already covered by free PV battery energy
- `factor` = `optimization.genetic.penalties.ac_charge_break_even` (default `1.0`)

View File

@@ -33,6 +33,7 @@ extensions = [
"sphinx_rtd_theme",
"myst_parser",
"sphinx_tabs.tabs",
"sphinxcontrib.mermaid",
]
templates_path = ["_templates"]
@@ -141,3 +142,6 @@ napoleon_use_rtype = True
napoleon_preprocess_types = False
napoleon_type_aliases = None
napoleon_attr_annotations = True
# -- Options for mermaid -------------------------------------------------
mermaid_output_format = "html" # no mmdc to be installed

184
docs/develop/async.md Normal file
View File

@@ -0,0 +1,184 @@
# Asynchronous Design of AkkudoktorEOS
The AkkudoktorEOS server is built on **FastAPI** and is transitioning to a fully asynchronous
design to improve scalability, responsiveness, and resource utilisation, especially for
longrunning or I/Obound operations.
The server manages a variety of background tasks through a central **Retention Manager**, which
schedules and supervises all periodic and asynchronous work.
## Core Asynchronous Components
- **FastAPI REST Interface**
All HTTP endpoints are defined as `async def` handlers (or synchronous where no I/O waiting is
required), allowing the server to handle many concurrent connections without blocking the event
loop.
- **Retention Manager**
A dedicated component responsible for orchestrating all background tasks. It runs an asynchronous
tick loop that executes registered functions at configurable intervals. The manager also provides
graceful shutdown handling, waiting for inflight jobs to finish.
<!-- pyml disable line-length -->
- **Managed Asynchronous Tasks**
The following tasks are registered with the Retention Manager and run periodically:
| Task Name | Function | Interval Configuration | Description |
|-----------|----------|------------------------|-------------|
| `supervise_eosdash` | `supervise_eosdash` | `server/eosdash_supervise_interval_sec` | Monitors and restarts the EOSdash UI process if needed. |
| `autosave_config` | `autosave_config` | `general/config_save_interval_sec` | Saves the current configuration to disk automatically. |
| `cache_clear` | `cache_clear` | `cache/cleanup_interval` | Removes expired entries from the cache. |
| `save_eos_database` | `save_eos_database` | `database/autosave_interval_sec` | Persists inmemory measurement and prediction data to the database. |
| `compact_eos_database` | `compact_eos_database` | `database/compaction_interval_sec` | Compacts and vacuums the database to reclaim space and improve performance. |
| `manage_energy` | `ems_manage_energy` | `ems/interval` | Core energy management loop: triggers predictions, optimisation, and device control. |
The `manage_energy` task is the central orchestrator that itself calls asynchronous prediction
updates, adapter scheduling and optimisation runs. It uses the `EnergyManagementSystem`
(`get_ems()`) which internally manages concurrency to ensure only one energy management run
happens at a time.
<!-- pyml enable line-length -->
- **OnDemand Asynchronous Endpoints**
Several REST endpoints are asynchronous and delegate heavy work to the `EnergyManagementSystem`.
Examples include:
- `POST /v1/prediction/update` updates all prediction providers asynchronously.
- `POST /optimize` runs a genetic optimisation (deprecated, but still async).
- `POST /v1/admin/server/restart` spawns a new process and schedules a shutdown task.
## Asynchronous Workflow
The diagram below shows how periodic tasks are registered and executed by the Retention Manager,
and how a client request can trigger an asynchronous update.
```mermaid
sequenceDiagram
participant Client
participant FastAPI as FastAPI (Async)
participant Retention as Retention Manager (Async)
participant EMS as EnergyManagementSystem (Async)
participant Adapter as Adapter (Async)
participant Prediction as Prediction (Async)
participant Measurement as Measurement (Async)
participant DatabaseRecords as DatabaseRecords (Async)
participant Database as Database (Sync)
Note over Retention: On startup (lifespan)
FastAPI->>Retention: register tasks with intervals
Retention-->>FastAPI: tasks registered
Retention->>Retention: start tick loop
loop every EMS interval
Retention->>EMS: run(mode=PREDICTION+OPTIMIZATION)
EMS->>Adapter: update_data, DATA_AQUISITION
Adapter->>Measurement: update_value
Measurement-->>Adapter: done
Adapter-->>EMS: done
EMS->>Prediction: update_data, FORECAST_RETRIEVAL
Prediction-->>EMS: done
EMS->>Adapter: update_data, CONTROL_DISPATCH
Adapter-->>EMS: done
EMS-->>Retention: done
end
loop every DatabaseRecords autosave interval
Retention->>Prediction: save
Prediction->>DatabaseRecords: db_save_records
DatabaseRecords-->>Prediction: done
Prediction-->>Retention: done
Retention->>Measurement: save
Measurement->>DatabaseRecords: db_save_records
DatabaseRecords-->>Measurement: done
Measurement-->>Retention: done
end
loop every DatabaseRecords compaction interval
Retention->>Prediction: db_compact
Prediction->>DatabaseRecords: db_delete_records
DatabaseRecords-->>Prediction: done
Prediction->>DatabaseRecords: db_save_records
DatabaseRecords-->>Prediction: done
Prediction-->>Retention: done
Retention->>Measurement: db_compact
Measurement->>DatabaseRecords: db_delete_records
DatabaseRecords-->>Measurement: done
Measurement->>DatabaseRecords: db_save_records
DatabaseRecords-->>Measurement: done
Measurement-->>Retention: done
end
Client->>FastAPI: POST /v1/prediction/update
FastAPI->>EMS: await run(mode=PREDICTION)
EMS-->>FastAPI: predictions updated
FastAPI-->>Client: 200 OK
```
For server shutdown or restart, the Retention Managers task is cancelled, and the server waits
for inflight jobs to finish (shutdown timeout = 10 seconds). The state is saved via
`save_eos_state()`.
## Asynchronous vs. Synchronous
- **REST endpoints** that perform I/O or heavy computation are `async def`.
- **Retention Manager** uses `asyncio.create_task()` to run the tick loop and individual task
executions.
- **Database** that synchronizes database access is `async def`, but the database backends
**DataBaseBackendABC** are synchronous.
- **DatabaseRecordProtocolMixin** provides asynchronous access to in memory data records and
database storage.
- **Prediction** and **Measurement** provide asynchronous access to the undelying database using
the **DatabaseRecordProtocolMixin**.
- **Energy management runs** are serialised using an internal lock (via `EMS.run()`) to avoid
overlapping optimisation cycles.
- **Process management** for shutdown/restart uses `asyncio.create_task(server_shutdown_task())`
to gracefully terminate after a delay.
## Benefits of Full Asynchrony
- **Higher throughput** FastAPIs event loop can handle thousands of idle keepalive connections.
- **Lower latency** Longrunning tasks (database compaction, prediction updates) do not block HTTP
responses.
- **Easier maintenance** Uniform async patterns replace mixed sync/async code.
- **Better resource usage** The Retention Manager can throttle, skip, or prioritise tasks based on
configuration and system load.
- **Graceful shutdown** All background tasks are cancelled cooperatively, and state is saved
before exit.
## Additional Asynchronous Patterns
Beyond the Retention Manager, the server uses:
- **Asynchronous shutdown/restart** When a restart is requested, a new process is spawned, and the
current process schedules a delayed termination (`server_shutdown_task`). This ensures zero
downtime if the new process starts before the old one exits.
- **Concurrent request handling** Multiple clients can call prediction update endpoints
simultaneously; the `EMS.run()` method serialises them internally, preventing race conditions.
- **Nonblocking logging** Log entries are written asynchronously (via logurus async sinks when
configured).
The combination of FastAPI, the Retention Manager, and asynchronous I/O enables AkkudoktorEOS to
run efficiently on resourceconstrained devices (e.g., Raspberry Pi) while maintaining responsive
REST APIs and reliable background data maintenance.
## Asynchronous Detailed Design
### Database
The database backends are synchronous. The selected backend is wrapped in the asynchronous
thread-safe database singleton defined by the Database class.
### DatabaseRecordProtocol, DataRecordProtocol
The DatabaseRecordProtocol completely manages in memory records and database storage. It acesses
the database backend by the database singleton and is therefor asynchronous. The
DatabaseRecordProtocol has a minimum expectation for data records defined by the DataRecordProtocol.
Data records are expected to be synchronous.
### Data Records
Data records are synchronous.
### Data Sequence, Data Provide, Data Container
Data sequences, the derived data provider, and the data provider aggregation data containers are
asynchronous. Data sequences' data can be backed by the database using the
DatabaseRecordProtocol to access the data. That is the reason why all the classes are
asynchronous.

568
docs/develop/dataabc.md Normal file
View File

@@ -0,0 +1,568 @@
# `dataabc` — Generic Data Handling
## Overview
The `dataabc` module provides the foundational abstractions for managing time-series data
in EOS. It defines a layered class hierarchy that covers individual data records, ordered
sequences of records, singleton data providers, and multi-provider containers.
All classes in this module are designed for use in predictive modelling workflows and share
three cross-cutting concerns:
- **Configuration access** via `ConfigMixin`, exposing the global EOS configuration as
`self.config`.
- **Database persistence** via `DatabaseRecordProtocolMixin`, providing optional storage
in a time-series database alongside in-memory records.
- **Async safety** via a three-level locking scheme described in detail in the
[Concurrency and Locking](#concurrency-and-locking) section.
## Class Hierarchy
```text
PydanticBaseModel
└── DataABC (ConfigMixin, StartMixin)
├── DataRecord (MutableMapping)
└── DataSequence (DatabaseRecordProtocolMixin)
└── DataProvider (SingletonMixin)
└── DataImportProvider (DataImportMixin)
DataContainer (SingletonMixin, MutableMapping)
DataImportMixin (StartMixin)
```
## Classes
### `DataABC`
Base class for all data-handling objects. Inherits from `ConfigMixin` and `StartMixin`,
making the global EOS configuration available as `self.config` on every derived instance.
Not intended to be instantiated directly.
### `DataRecord`
A single measurement or forecast point at a specific datetime, implemented as a
`MutableMapping` so that field values can be accessed and mutated both as dictionary
entries (`record["field"]`) and as attributes (`record.field`).
#### Key concepts
**Static fields** are declared as Pydantic model fields in the class body. They are always
present and validated on assignment.
**Configured fields** are dynamic: their names are returned by the classmethod
`configured_data_keys()`, which derived classes override to pull key names from the EOS
configuration. These keys are stored in the internal `configured_data` dict but appear
transparent to callers — they show up in `dir()`, `iter()`, and attribute access just like
static fields.
```python
class MeasurementDataRecord(DataRecord):
@classmethod
def configured_data_keys(cls) -> Optional[list[str]]:
return cls.config.measurement.keys
```
#### Important methods
| Method | Description |
|---|---|
| `record_keys()` | All field names, including configured keys. |
| `record_keys_writable()` | Subset of `record_keys()` that can be written. |
| `key_from_description(desc)` | Fuzzy-matches a description string to a field name. |
| `keys_from_descriptions(descs)` | Batch version of `key_from_description`. |
---
### `DataSequence`
An ordered, mutable collection of `DataRecord` instances with time-series behaviour.
Records are always kept sorted in ascending `date_time` order. `DataSequence` is also the
level at which **async safety is enforced** — see
[Concurrency and Locking](#concurrency-and-locking).
Derived classes must redeclare the `records` field with the concrete record type:
```python
class Measurement(DataSequence):
records: list[MeasurementDataRecord] = Field(default_factory=list)
```
#### Public async interface of `DataSequence`
All methods that mutate sequence state are `async`. Callers in an async context (e.g.
FastAPI endpoint handlers) must `await` them.
| Method | Description |
|---|---|
| `await insert_by_datetime(record)` | Insert or merge a record by its datetime. |
| `await update_value(date, key, value)` | Insert or update a single field value at a datetime. |
| `await key_from_lists(key, dates, values)` | Populate a field from parallel date/value lists. |
| `await key_from_series(key, series)` | Populate a field from a `pd.Series`. |
| `await save()` | Persist all records to the configured storage backend. |
| `await load()` | Load records from the configured storage backend. |
| `await import_from_dict(data)` | Import records from a key-value dictionary. |
| `await import_from_dataframe(df)` | Import records from a `pd.DataFrame`. |
| `await import_from_json(json_str)` | Import records from a JSON string. |
| `await import_from_file(path)` | Import records from a JSON file. |
#### Internal sync interface of `DataSequence`
Each public async method has a private sync counterpart prefixed with `_`. These are
intended **only** for callers that already hold the appropriate locks, or that are running
in a purely sequential context (startup, `_load()` internals, tests). See
[Choosing the right call site](#choosing-the-right-call-site).
| Internal method | Corresponding public method |
|---|---|
| `_insert_by_datetime(record)` | `.insert_by_datetime()` |
| `_update_value(date, ...)` | `.update_value()` |
| `_key_from_lists(key, dates, values)` | `.key_from_lists()` |
| `_key_from_series(key, series)` | `.key_from_series()` |
| `_save()` | `.save()` |
| `_load()` | `.load()` |
| `_import_from_dict(...)` | `.import_from_dict()` |
| `_import_from_dataframe(...)` | `.import_from_dataframe()` |
| `_import_from_json(...)` | `.import_from_json()` |
| `_import_from_file(...)` | `.import_from_file()` |
#### Read-only methods (always sync) of `DataSequence`
These methods do not modify sequence state and require no locking:
| Method | Description |
|---|---|
| `get_by_datetime(dt)` | Exact or nearest record lookup. |
| `get_nearest_by_datetime(dt)` | Nearest record within an optional time window. |
| `key_to_dict(key, ...)` | Extract a `{datetime: value}` dict for a key. |
| `key_to_lists(key, ...)` | Extract parallel date and value lists. |
| `key_to_series(key, ...)` | Extract a `pd.Series` indexed by datetime. |
| `key_to_array(key, ...)` | Extract a resampled `np.ndarray` at a fixed interval. |
| `key_to_value(key, dt)` | Scalar lookup nearest to a datetime. |
| `to_dataframe(...)` | Convert all records to a `pd.DataFrame`. |
| `delete_by_datetime(...)` | Delete records within a datetime range. |
| `key_delete_by_datetime(key, ...)` | Set a field to `None` across a datetime range. |
#### Computed properties of `DataSequence`
| Property | Description |
|---|---|
| `min_datetime` | Earliest datetime in the sequence. |
| `max_datetime` | Latest datetime in the sequence. |
| `record_keys` | All field names for this sequence's record type. |
| `record_keys_writable` | Writable subset of `record_keys`. |
### `DataProvider`
Abstract singleton base class for objects that own and update a `DataSequence`. Each
concrete provider represents one data source (e.g. weather forecast, load measurement,
grid price). `DataProvider` is a specialisation of `DataSequence` and inherits its full
async interface and locking infrastructure — `_record_lock` and `_sequence_lock` — without
adding any new locks of its own.
Derived classes must implement:
| Abstract method | Description |
|---|---|
| `provider_id() -> str` | Unique string identifier for this provider. |
| `enabled() -> bool` | Whether this provider is active per configuration. |
| `_update_data(force_update)` | Custom data fetch/update logic. |
#### `_update_data` contract of `DataProvider`
`_update_data` is always called while the provider's own `_sequence_lock` and
`_record_lock` are both held by `update_data`. Implementations must therefore observe
the following constraints to avoid deadlock:
- Use only the internal sync methods (`_insert_by_datetime`, `_update_value`,
`_key_from_lists`, `_key_from_series`). Never call their public `async` counterparts,
which would attempt to re-acquire `_record_lock`.
- Do not call `save()`, `load()`, `_save()`, or `_load()`. Both locks are already held;
attempting to re-acquire either will deadlock.
- Network or I/O calls are permitted but should be kept short. Offload long-running I/O
to a thread via `asyncio.to_thread` in the caller before entering the lock scope.
```python
# Correct — both _sequence_lock and _record_lock are already held by the caller
def _update_data(self, force_update=False):
for dt, value in self._fetch_from_api():
self._update_value(dt, "temperature_c", value)
# Wrong — would deadlock: _record_lock is not reentrant
def _update_data(self, force_update=False):
for dt, value in self._fetch_from_api():
await self.update_value(dt, "temperature_c", value)
```
#### Public async interface of `DataProvider`
<!-- pyml disable line-length -->
| Method | Description |
|---|---|
| `await update_data(force_enable, force_update)` | Call `_update_data` if enabled or forced, holding both `_sequence_lock` and `_record_lock` for the duration. |
<!-- pyml enable line-length -->
### `DataImportMixin`
Mixin that adds bulk import capability to any class that also provides `update_value` and
`record_keys_writable`. Provides `import_from_dict`, `import_from_dataframe`,
`import_from_json`, and `import_from_file`.
The mixin expects values to be lists aligned to a fixed time interval starting from a
`start_datetime`. Two special dictionary keys are handled automatically:
- `start_datetime` — overrides the start of the import window.
- `interval` — overrides the fixed time step between values.
### `DataImportProvider`
Convenience base class combining `DataImportMixin` and `DataProvider`. Derive from this
when a provider's data arrives via JSON, file, or dict import rather than a live API.
### `DataContainer`
A singleton `MutableMapping` that aggregates multiple `DataProvider` instances and
presents their combined data through a single interface. Providers are tried in order;
the first one that contains the requested key wins.
`DataContainer` carries its own pair of locks (`_record_lock` and `_container_lock`)
that are independent of the locks on each provider. See
[Concurrency and Locking](#concurrency-and-locking) for the full acquisition matrix.
#### Public async interface of `DataContainer`
| Method | Description |
|---|---|
| `await __setitem__(key, series)` | Write a `pd.Series` into the appropriate provider. |
| `await __delitem__(key)` | Clear a field across all providers. |
| `await update_data(force_enable, force_update)` | Update all providers. |
| `await save()` | Save all providers to persistent storage. |
| `await load()` | Load all providers from persistent storage. |
| `await db_vacuum()` | Remove old records from all provider databases. |
| `await db_compact()` | Apply tiered compaction to all provider databases. |
| `await keys_to_dataframe(keys, ...)` | Consistent cross-provider snapshot as a `pd.DataFrame`. |
#### Read-only methods (always sync) of `DataContainer`
| Method | Description |
|---|---|
| `__getitem__(key)` | Return a `pd.Series` for a key from the first matching provider. |
| `__iter__()` | Iterate over all unique keys across enabled providers. |
| `__len__()` | Total number of unique keys. |
| `key_to_series(key, ...)` | Extract a series from the first matching provider. |
| `key_to_array(key, ...)` | Extract a resampled array from the first matching provider. |
| `provider_by_id(provider_id)` | Look up a provider by its string identifier. |
| `enabled_providers` | List of currently active providers. |
| `record_keys` | Union of all record keys across enabled providers. |
| `record_keys_writable` | Union of all writable record keys across enabled providers. |
## Concurrency and Locking
### Problem
EOS runs under FastAPI with an async event loop. Multiple HTTP requests are handled
concurrently as coroutines on a single thread. Because coroutines yield at `await` points,
two coroutines can interleave between a read and a subsequent write, producing a
**check-then-act race condition**:
```text
Coroutine A: db_get_record(ts) → None # record does not exist yet
Coroutine B: db_get_record(ts) → None # same — A has not inserted yet
Coroutine A: db_insert_record(new_rec) # inserts successfully
Coroutine B: db_insert_record(new_rec) # raises: duplicate timestamp
```
This is the root cause of the `ValueError: Duplicate timestamp` errors seen in production.
### Solution: three-level locking
The solution uses three distinct `asyncio.Lock` objects, each protecting a different scope
of state. Each name answers the question "what is being protected":
<!-- pyml disable line-length -->
| Lock | Defined on | Attribute | Protects |
|---|---|---|---|
| Record lock | `DataSequence` | `_record_lock` | A single check-then-act on one record. |
| Sequence lock | `DataSequence` | `_sequence_lock` | The full sequence state during operations that touch many records at once. |
| Container lock | `DataContainer` | `_container_lock` | Cross-provider consistency during container-level bulk operations. |
<!-- pyml enable line-length -->
`DataProvider` inherits `_record_lock` and `_sequence_lock` from `DataSequence` without
adding any new locks of its own. It is a specialisation of `DataSequence`, not a new
locking scope.
The fixed acquisition order across all levels is:
```text
_container_lock → _record_lock → provider _sequence_lock → provider _record_lock
```
No code path may acquire a finer-grained lock and then wait for a coarser one at the
same level. This invariant prevents deadlock.
### Lock creation
All locks are created lazily on first access via `_get_or_create_lock()`, which bypasses
Pydantic's `__setattr__` using `object.__setattr__` directly. This is necessary because
Pydantic v2 rejects attributes that are not declared model fields, and `asyncio.Lock`
cannot be safely created outside a running event loop, making `__init__`-time creation
unsafe. `cached_property` is not used for the same reason — Pydantic v2's tight
`__dict__` control makes it unreliable on model instances.
```python
def _get_or_create_lock(self, attr: str) -> asyncio.Lock:
try:
return object.__getattribute__(self, attr)
except AttributeError:
lock = asyncio.Lock()
object.__setattr__(self, attr, lock)
return lock
@property
def _record_lock(self) -> asyncio.Lock:
return self._get_or_create_lock("_record_lock_instance")
@property
def _sequence_lock(self) -> asyncio.Lock: # DataSequence / DataProvider
return self._get_or_create_lock("_sequence_lock_instance")
@property
def _container_lock(self) -> asyncio.Lock: # DataContainer only
return self._get_or_create_lock("_container_lock_instance")
```
### Lock acquisition rules
**Individual record writes** acquire only `_record_lock`, held for the minimum time
needed to make the check-then-act atomic:
```python
async def update_value(self, date, *args, **kwargs):
async with self._record_lock:
self._update_value(date, *args, **kwargs)
```
**Sequence-level bulk operations** (`save`, `load`, `import_from_*`) acquire
`_sequence_lock` first, then `_record_lock`. This blocks both concurrent bulk operations
and concurrent individual writes for the full duration:
```python
async def load(self):
async with self._sequence_lock:
async with self._record_lock:
self._load()
```
**`update_data`** on a provider acquires both `_sequence_lock` and `_record_lock`,
protecting the provider's full sequence state against concurrent saves, loads, imports,
or individual record writes for the entire duration of `_update_data`:
```python
async def update_data(self, force_enable=False, force_update=False):
if not force_enable and not self.enabled():
return
async with self._sequence_lock:
async with self._record_lock:
self._update_data(force_update=force_update)
```
**Container-level bulk operations** acquire `_container_lock` and `_record_lock` on the
container, then delegate to each provider (which independently acquires its own
`_sequence_lock` and `_record_lock`):
```python
async def save(self): # DataContainer
async with self._container_lock:
async with self._record_lock:
for provider in self.providers:
await provider.save() # provider acquires _sequence_lock + _record_lock
```
**Cross-provider consistent reads** acquire only the container's `_record_lock`, which
prevents concurrent writes from producing an inconsistent snapshot without blocking
other read operations:
```python
async def keys_to_dataframe(self, keys, ...):
async with self._record_lock:
... # read from multiple providers atomically
```
### Lock acquisition matrix
#### `DataSequence` / `DataProvider` lock
| Operation | `_sequence_lock` | `_record_lock` |
|---|---|---|
| `insert_by_datetime` | | ✓ |
| `update_value` | | ✓ |
| `key_from_lists` | | ✓ |
| `key_from_series` | | ✓ |
| `update_data` | ✓ | ✓ |
| `save` | ✓ | ✓ |
| `load` | ✓ | ✓ |
| `import_from_dict` | ✓ | ✓ |
| `import_from_dataframe` | ✓ | ✓ |
| `import_from_json` | ✓ | ✓ |
| `import_from_file` | ✓ | ✓ |
| Read-only methods | | |
#### `DataContainer` lock
<!-- pyml disable line-length -->
| Operation | `_container_lock` | `_record_lock` | Provider `_sequence_lock` | Provider `_record_lock` |
|---|---|---|---|---|
| `update_data` | ✓ | ✓ | ✓ | ✓ |
| `__setitem__` | | ✓ | | |
| `__delitem__` | | ✓ | | |
| `keys_to_dataframe` | | ✓ | | |
| `save` | ✓ | ✓ | ✓ | ✓ |
| `load` | ✓ | ✓ | ✓ | ✓ |
| `db_vacuum` | ✓ | ✓ | | |
| `db_compact` | ✓ | ✓ | | |
| Read-only methods | | | | |
<!-- pyml enable line-length -->
### Why `asyncio.Lock` and not `threading.Lock`
FastAPI's default async workers run all coroutines on a **single OS thread**. Coroutines
interleave at `await` points, not at thread boundaries. `threading.Lock` would not protect
against this interleaving and would risk deadlock if the same coroutine attempts to
re-acquire it. `asyncio.Lock` yields control correctly at `await` points while keeping
other coroutines blocked.
### Why `asyncio.Lock` is not reentrant
Python's `asyncio.Lock` is intentionally non-reentrant. If a coroutine that already holds
`_record_lock` calls a public async method (which also tries to acquire `_record_lock`),
it will deadlock. This is why `_update_data()` implementations and all other internal
callers must use the private `_method()` variants rather than `await self.method()`.
### Choosing the right call site
<!-- pyml disable line-length -->
| Caller context | Correct call |
|---|---|
| FastAPI endpoint or any `async def` | `await sequence.insert_by_datetime(record)` |
| `_update_data()` implementation | `self._insert_by_datetime(record)` |
| Startup, `_load()` internals, tests | `sequence._insert_by_datetime(record)` |
| `DataContainer` delegating to a provider | `await provider.save()` — container holds its own locks; provider independently holds its own |
<!-- pyml enable line-length -->
## Usage Examples
### Reading data (sync, no locking required)
```python
measurement = get_measurement()
# Scalar lookup
value = measurement.key_to_value("grid_import_w", target_datetime=now)
# Resampled array for the next 24 hours
array = measurement.key_to_array(
key="grid_import_w",
start_datetime=now,
end_datetime=now.add(hours=24),
interval=to_duration("1 hour"),
)
# Pandas Series
series = measurement.key_to_series("grid_import_w", start_datetime=now)
```
### Writing a single value from a FastAPI endpoint
```python
@router.put("/measurement/value")
async def put_measurement_value(datetime: str, key: str, value: float):
dt = to_datetime(datetime)
await get_measurement().update_value(dt, key, value)
```
### Bulk import from a FastAPI endpoint
```python
@router.put("/measurement/import")
async def put_measurement_import(data: dict):
await get_measurement().import_from_dict(data)
```
### Writing from a sync context (startup / tests)
```python
def load_initial_data(measurement: Measurement, records: list[MeasurementDataRecord]):
# Sequential — no concurrency, use internal sync methods directly
for record in records:
measurement._insert_by_datetime(record)
```
### Implementing a custom `DataProvider`
```python
class MyProvider(DataProvider):
records: list[MyRecord] = Field(default_factory=list)
def provider_id(self) -> str:
return "MyProvider"
def enabled(self) -> bool:
return self.config.my_provider.enabled
def _update_data(self, force_update: Optional[bool] = False) -> None:
# Both _sequence_lock and _record_lock are already held by the caller.
# Use internal sync methods only — never await public async counterparts.
for dt, reading in fetch_from_hardware():
self._update_value(dt, "sensor_w", reading)
def db_namespace(self) -> str:
return "MyProvider"
def db_keep_datetime(self) -> Optional[DateTime]:
return to_datetime().subtract(hours=48)
```
---
## Design Notes
### Singleton providers and containers
`DataProvider` and `DataContainer` both inherit from `SingletonMixin`. A single instance
is shared across all coroutines handling concurrent requests. This is precisely why
locking is necessary — every concurrent request operates on the same in-memory object.
### Lock naming rationale
The three lock names were chosen to reflect *what is being protected*, not *how coarse
the operation is*:
- `_record_lock` — makes it immediately clear that a single record's check-then-act is
being made atomic. The same name is used at both `DataSequence` and `DataContainer`
level because it plays the same role in both: serialising individual write operations.
- `_sequence_lock` — defined on `DataSequence` and inherited unchanged by `DataProvider`.
The name reflects that the entire sequence state is held exclusively, not just one
record. `DataProvider` is a specialisation of `DataSequence`, so the name remains
accurate at both levels without needing a separate `_provider_lock`.
- `_container_lock` — exclusive to `DataContainer`, reflecting that cross-provider
container-level state is held exclusively during bulk operations.
This naming also makes lock misuse visible in code review: a method that acquires
`_sequence_lock` without also acquiring `_record_lock` (or acquires them in the wrong
order) stands out immediately against the documented fixed acquisition order.
### Database vs in-memory storage
`DataSequence` uses `DatabaseRecordProtocolMixin` to optionally back its records with a
persistent time-series database. When a database is configured, `save()` and `load()`
delegate to it; otherwise they fall back to a JSON file. The in-memory `records` list acts
as a write-through cache. The locking scheme covers both paths — the database operations
are included inside the lock scope so that in-memory state and database state remain
consistent.
### Pydantic v2 compatibility
The `asyncio.Lock` instances are stored on the instance using `object.__setattr__`,
bypassing Pydantic's field validation entirely. This is intentional: `asyncio.Lock` is not
serialisable and must not appear in `model_dump()` or `model_dump_json()` output. The
locks are therefore invisible to Pydantic serialisation and are reconstructed fresh on
each process start, which is correct behaviour for a lock.

View File

@@ -63,6 +63,7 @@ akkudoktoreos/api.rst
develop/develop.md
develop/release.md
develop/async.md
develop/CHANGELOG.md
```