mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-20 16:58:12 +00:00
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>
569 lines
23 KiB
Markdown
569 lines
23 KiB
Markdown
# `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.
|