Files
EOS/tests/test_database.py
T

879 lines
25 KiB
Python
Raw Normal View History

2026-07-15 16:38:53 +02:00
"""Pytest tests for async database persistence module.
2026-07-15 16:38:53 +02:00
Tests the async Database interface and concrete implementations (LMDB, SQLite).
"""
2026-07-15 16:38:53 +02:00
from __future__ import annotations
import asyncio
import shutil
import tempfile
import time
from pathlib import Path
2026-07-15 16:38:53 +02:00
from typing import AsyncIterator, Optional, Type
import pytest
2026-07-15 16:38:53 +02:00
import pytest_asyncio
from pydantic import Field
from akkudoktoreos.core.coreabc import get_database
2026-07-15 16:38:53 +02:00
from akkudoktoreos.core.dataabc import DataProvider, DataRecord, DataSequence
from akkudoktoreos.core.database import Database, LMDBDatabase, SQLiteDatabase
from akkudoktoreos.core.databaseabc import (
DatabaseRecordProtocolLoadPhase,
DatabaseTimestamp,
)
from akkudoktoreos.utils.datetimeutil import (
DateTime,
Duration,
to_datetime,
to_duration,
)
# ==================== Test Fixtures ====================
2026-07-15 16:38:53 +02:00
@pytest.fixture
def temp_dir():
"""Create a temporary directory for test databases."""
temp_path = Path(tempfile.mkdtemp())
yield temp_path
shutil.rmtree(temp_path, ignore_errors=True)
@pytest.fixture(params=["LMDB", "SQLite"])
def database_provider(request) -> str:
"""Parametrize all database backend tests."""
return request.param
2026-07-15 16:38:53 +02:00
@pytest_asyncio.fixture
async def async_database_instance(
config_eos,
database_provider: str,
) -> AsyncIterator[Database]:
"""Open a database instance for testing and close it afterwards."""
config_eos.database.compression_level = 6
config_eos.database.provider = database_provider
2026-07-15 16:38:53 +02:00
db = get_database()
2026-07-15 16:38:53 +02:00
await db.open()
assert db.provider_id() == database_provider
2026-07-15 16:38:53 +02:00
assert db.is_open is True
yield db
2026-07-15 16:38:53 +02:00
await db.close()
config_eos.database.provider = None
2026-07-15 16:38:53 +02:00
# ==================== Database Backend Tests ====================
@pytest.mark.asyncio
class TestDatabase:
"""Tests for the async Database interface."""
async def test_database_creation(self, config_eos, database_provider):
config_eos.database.compression_level = 6
config_eos.database.provider = database_provider
db = get_database()
await db.open()
assert db.is_open is True
assert db.compression is True
assert db.compression_level == 6
assert db.storage_path == (
config_eos.general.data_folder_path
/ "db"
/ db._db.__class__.__name__.lower()
)
await db.close()
async def test_database_open_close(self, async_database_instance):
assert async_database_instance.is_open is True
assert async_database_instance._db.connection is not None
await async_database_instance.close()
assert async_database_instance._db.is_open is False
async def test_save_and_load_single_record(self, async_database_instance):
key = b"2024-01-01T00:00:00+00:00"
value = b"test_data_12345"
saved = await async_database_instance.save_records([(key, value)])
assert saved == 1
records = [
record
async for record in async_database_instance.iterate_records(
key,
key + b"\xff",
)
]
assert len(records) == 1
assert records[0] == (key, value)
async def test_save_multiple_records(self, async_database_instance):
records = [
(b"2024-01-01T00:00:00+00:00", b"data1"),
(b"2024-01-02T00:00:00+00:00", b"data2"),
(b"2024-01-03T00:00:00+00:00", b"data3"),
]
saved = await async_database_instance.save_records(records)
assert saved == len(records)
loaded = [
record
async for record in async_database_instance.iterate_records()
]
assert len(loaded) == len(records)
for expected, actual in zip(records, loaded):
assert expected == actual
async def test_load_records_with_range(self, async_database_instance):
records = [
(b"2024-01-01T00:00:00+00:00", b"data1"),
(b"2024-01-02T00:00:00+00:00", b"data2"),
(b"2024-01-03T00:00:00+00:00", b"data3"),
(b"2024-01-04T00:00:00+00:00", b"data4"),
(b"2024-01-05T00:00:00+00:00", b"data5"),
]
await async_database_instance.save_records(records)
start_key = b"2024-01-02T00:00:00+00:00"
end_key = b"2024-01-04T00:00:00+00:00"
loaded = [
record
async for record in async_database_instance.iterate_records(
start_key,
end_key,
)
]
assert len(loaded) == 2
assert loaded[0][0] == b"2024-01-02T00:00:00+00:00"
assert loaded[1][0] == b"2024-01-03T00:00:00+00:00"
async def test_delete_record(self, async_database_instance):
key = b"2024-01-01T00:00:00+00:00"
await async_database_instance.save_records([(key, b"test_data")])
assert await async_database_instance.count_records() == 1
deleted = await async_database_instance.delete_records([key])
assert deleted == 1
assert await async_database_instance.count_records() == 0
deleted = await async_database_instance.delete_records([key])
assert deleted == 0
async def test_count_records(self, async_database_instance):
assert await async_database_instance.count_records() == 0
for i in range(10):
key = f"2024-01-{i + 1:02d}T00:00:00+00:00".encode()
await async_database_instance.save_records([(key, b"data")])
assert await async_database_instance.count_records() == 10
async def test_get_key_range_empty(self, async_database_instance):
min_key, max_key = await async_database_instance.get_key_range()
assert min_key is None
assert max_key is None
async def test_get_key_range_with_records(self, async_database_instance):
keys = [
b"2024-01-01T00:00:00+00:00",
b"2024-01-05T00:00:00+00:00",
b"2024-01-03T00:00:00+00:00",
]
for key in keys:
await async_database_instance.save_records([(key, b"data")])
min_key, max_key = await async_database_instance.get_key_range()
assert min_key == b"2024-01-01T00:00:00+00:00"
assert max_key == b"2024-01-05T00:00:00+00:00"
async def test_iterate_records_forward(self, async_database_instance):
keys = [
b"2024-01-01T00:00:00+00:00",
b"2024-01-02T00:00:00+00:00",
b"2024-01-03T00:00:00+00:00",
]
for key in keys:
await async_database_instance.save_records([(key, b"data")])
result_keys = [
k
async for k, _ in async_database_instance.iterate_records()
]
assert result_keys == keys
async def test_iterate_records_reverse(self, async_database_instance):
keys = [
b"2024-01-01T00:00:00+00:00",
b"2024-01-02T00:00:00+00:00",
b"2024-01-03T00:00:00+00:00",
]
for key in keys:
await async_database_instance.save_records([(key, b"data")])
result_keys = [
k
async for k, _ in async_database_instance.iterate_records(reverse=True)
]
assert result_keys == list(reversed(keys))
async def test_reverse_iteration_with_bounds(self, async_database_instance):
keys = [f"{i:03d}".encode() for i in range(10)]
await async_database_instance.save_records(
[(k, b"v") for k in keys]
)
result = [
k
async for k, _ in async_database_instance.iterate_records(
start_key=b"003",
end_key=b"007",
reverse=True,
)
]
assert result == [
b"006",
b"005",
b"004",
b"003",
]
async def test_empty_iteration(self, async_database_instance):
result = [
record
async for record in async_database_instance.iterate_records()
]
assert result == []
async def test_compression_reduces_size(self, config_eos, async_database_instance):
large_data = b"A" * 10_000
config_eos.database.compression_level = 9
compressed = async_database_instance.serialize_data(large_data)
assert async_database_instance.deserialize_data(compressed) == large_data
config_eos.database.compression_level = 0
uncompressed = async_database_instance.serialize_data(large_data)
assert async_database_instance.deserialize_data(uncompressed) == large_data
assert len(compressed) < len(uncompressed)
async def test_flush(self, async_database_instance):
key = b"2024-01-01T00:00:00+00:00"
await async_database_instance.save_records([(key, b"test_data")])
await async_database_instance.flush()
loaded = [
record
async for record in async_database_instance.iterate_records()
]
assert len(loaded) == 1
assert loaded[0] == (key, b"test_data")
async def test_backend_stats(self, async_database_instance):
stats = await async_database_instance.get_backend_stats()
assert isinstance(stats, dict)
assert "backend" in stats
for i in range(10):
key = f"2024-01-{i + 1:02d}T00:00:00+00:00".encode()
await async_database_instance.save_records(
[(key, b"data" * 100)]
)
stats = await async_database_instance.get_backend_stats()
assert stats is not None
async def test_metadata_roundtrip(self, async_database_instance):
await async_database_instance.set_metadata(
b"metadata",
namespace="test",
)
result = await async_database_instance.get_metadata(
namespace="test",
)
assert result == b"metadata"
async def test_metadata_excluded_from_count(self, async_database_instance):
await async_database_instance.save_records(
[(b"2024-01-01T00:00:00+00:00", b"data")]
)
count = await async_database_instance.count_records()
assert count == 1
async def test_namespace_isolation(self, async_database_instance):
await async_database_instance.save_records(
[(b"k1", b"ns1")],
namespace="a",
)
await async_database_instance.save_records(
[(b"k1", b"ns2")],
namespace="b",
)
records_a = [
r
async for r in async_database_instance.iterate_records(
namespace="a"
)
]
records_b = [
r
async for r in async_database_instance.iterate_records(
namespace="b"
)
]
assert records_a == [(b"k1", b"ns1")]
assert records_b == [(b"k1", b"ns2")]
async def test_concurrent_writes(self, async_database_instance):
async def writer(start: int):
records = [
(f"{i:08d}".encode(), b"data")
for i in range(start, start + 100)
]
await async_database_instance.save_records(records)
await asyncio.gather(
writer(0),
writer(1000),
writer(2000),
)
count = await async_database_instance.count_records()
assert count == 300
async def test_concurrent_reads_and_writes(self, async_database_instance):
async def writer():
for i in range(100):
await async_database_instance.save_records(
[(f"{i:08d}".encode(), b"data")]
)
async def reader():
total = 0
for _ in range(20):
records = [
r
async for r in async_database_instance.iterate_records()
]
total += len(records)
return total
results = await asyncio.gather(
writer(),
reader(),
reader(),
)
assert results[1] >= 0
assert results[2] >= 0
final_count = await async_database_instance.count_records()
assert final_count == 100
async def test_delete_multiple_records(self, async_database_instance):
records = [
(b"k1", b"v1"),
(b"k2", b"v2"),
(b"k3", b"v3"),
]
await async_database_instance.save_records(records)
deleted = await async_database_instance.delete_records(
[b"k1", b"k3"]
)
assert deleted == 2
remaining = [
r
async for r in async_database_instance.iterate_records()
]
assert remaining == [(b"k2", b"v2")]
async def test_delete_empty_input(self, async_database_instance):
deleted = await async_database_instance.delete_records([])
assert deleted == 0
async def test_save_empty_input(self, async_database_instance):
saved = await async_database_instance.save_records([])
assert saved == 0
async def test_count_with_bounds(self, async_database_instance):
keys = [f"{i:03d}".encode() for i in range(10)]
await async_database_instance.save_records(
[(k, b"v") for k in keys]
)
count = await async_database_instance.count_records(
start_key=b"003",
end_key=b"007",
)
assert count == 4
# ==================== Backend-Specific Tests ====================
class TestLMDBDatabase:
"""LMDB-specific tests."""
@pytest.mark.asyncio
async def test_lmdb_compact(self, config_eos):
config_eos.database.compression_level = 0
config_eos.database.provider = "LMDB"
db = get_database()
await db.open()
assert db.is_open
for i in range(1000):
key = f"2024-01-01T{i:06d}+00:00".encode()
await db.save_records([(key, b"X" * 1000)])
for i in range(500):
key = f"2024-01-01T{i:06d}+00:00".encode()
await db.delete_records([key])
assert await db.count_records() == 500
lmdb = db._db
assert isinstance(lmdb, LMDBDatabase)
# compact() itself is synchronous
await asyncio.to_thread(lmdb.compact)
assert await db.count_records() == 500
await db.close()
@pytest.mark.asyncio
async def test_lmdb_namespace_isolation(self, config_eos):
"""Records in different namespaces must not interfere."""
config_eos.database.provider = "LMDB"
db = get_database()
await db.open()
assert db.is_open
key = b"2024-01-01T00:00:00+00:00"
await db.save_records(
[(key, b"ns_a_data")],
namespace="ns_a",
)
await db.save_records(
[(key, b"ns_b_data")],
namespace="ns_b",
)
ns_a = [
record
async for record in db.iterate_records(namespace="ns_a")
]
ns_b = [
record
async for record in db.iterate_records(namespace="ns_b")
]
assert ns_a[0][1] == b"ns_a_data"
assert ns_b[0][1] == b"ns_b_data"
await db.close()
class TestSQLiteDatabase:
"""SQLite-specific tests."""
@pytest.mark.asyncio
async def test_sqlite_vacuum(self, config_eos):
config_eos.database.compression_level = 0
config_eos.database.provider = "SQLite"
db = get_database()
await db.open()
assert db.is_open
records = [
(
f"2024-01-{i + 1:02d}T00:00:00+00:00".encode(),
b"data" * 100,
)
for i in range(100)
]
await db.save_records(records)
keys_to_delete = [
f"2024-01-{i + 1:02d}T00:00:00+00:00".encode()
for i in range(50)
]
await db.delete_records(keys_to_delete)
assert await db.count_records() == 50
sqlitedb = db._db
assert isinstance(sqlitedb, SQLiteDatabase)
# vacuum() itself is synchronous
await asyncio.to_thread(sqlitedb.vacuum)
assert await db.count_records() == 50
await db.close()
@pytest.mark.asyncio
async def test_sqlite_namespace_isolation(self, config_eos):
"""Records in different namespaces must not interfere."""
config_eos.database.provider = "SQLite"
db = get_database()
await db.open()
assert db.is_open
key = b"2024-01-01T00:00:00+00:00"
await db.save_records(
[(key, b"ns_a_data")],
namespace="ns_a",
)
await db.save_records(
[(key, b"ns_b_data")],
namespace="ns_b",
)
ns_a = [
record
async for record in db.iterate_records(namespace="ns_a")
]
ns_b = [
record
async for record in db.iterate_records(namespace="ns_b")
]
assert ns_a[0][1] == b"ns_a_data"
assert ns_b[0][1] == b"ns_b_data"
await db.close()
# Helpers
async def _clear_sequence_state(sequence) -> None:
"""Clear runtime DB state without re-instantiating the singleton.
Does _NOT_ initialize the DB state.
"""
await sequence.db_delete_records()
try:
sequence._db_metadata = None
await sequence.database().set_metadata(None, namespace=sequence.db_namespace())
except Exception:
# Database may not be available, just skip
pass
try:
del sequence._db_initialized
except Exception:
# May not be set
pass
async def _reset_sequence_state(sequence) -> None:
"""Reset runtime DB state without re-instantiating the singleton."""
try:
sequence.records = []
del sequence._db_initialized
except Exception:
# May not be set
pass
await sequence._db_ensure_initialized()
# Sample Data
class SampleDataRecord(DataRecord):
"""Minimal DataRecord for testing."""
temperature: float = Field(default=0.0)
humidity: float = Field(default=0.0)
pressure: float = Field(default=0.0)
class SampleDataSequence(DataSequence):
"""DataSequence subclass with database support."""
2026-07-15 16:38:53 +02:00
records: list[SampleDataRecord] = Field(default_factory=list)
@classmethod
def record_class(cls) -> Type[SampleDataRecord]:
return SampleDataRecord
def db_namespace(self) -> str:
return "SampleDataSequence"
class SampleDataProvider(DataProvider):
"""DataProvider subclass with database support."""
2026-07-15 16:38:53 +02:00
records: list[SampleDataRecord] = Field(default_factory=list)
@classmethod
def record_class(cls) -> Type[SampleDataRecord]:
return SampleDataRecord
def provider_id(self) -> str:
return "SampleDataProvider"
def enabled(self) -> bool:
return True
2026-07-15 16:38:53 +02:00
async def _update_data(self, force_update: Optional[bool] = False) -> None:
pass
def db_namespace(self) -> str:
return "SampleDataProvider"
# ==================== Integration Tests ====================
2026-07-15 16:38:53 +02:00
@pytest.mark.asyncio
class TestIntegration:
"""Full end-to-end workflow tests."""
2026-07-15 16:38:53 +02:00
async def test_full_workflow(self, config_eos, async_database_instance):
"""Save → partial load → update → vacuum → verify."""
sequence = SampleDataSequence()
2026-07-15 16:38:53 +02:00
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
# Step 1: Insert 100 records and persist
for i in range(100):
2026-07-15 16:38:53 +02:00
await sequence.db_insert_record(
SampleDataRecord(
date_time=base_time.add(hours=i),
temperature=20.0 + i * 0.1,
humidity=60.0,
)
)
2026-07-15 16:38:53 +02:00
await sequence.db_save_records()
2026-07-15 16:38:53 +02:00
storage_count = await sequence.database.count_records(namespace="SampleDataSequence")
assert storage_count == 100
2026-07-15 16:38:53 +02:00
assert await sequence.db_count_records() == 100
# Step 2: Clear memory and load a specific range
2026-07-15 16:38:53 +02:00
await _reset_sequence_state(sequence)
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=20))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=40))
2026-07-15 16:38:53 +02:00
loaded = await sequence.db_load_records(db_start, db_end)
assert loaded == 20
assert len(sequence.records) == 20
# Step 3: Update records in memory and persist
for record in sequence.records:
record.humidity = 75.0
2026-07-15 16:38:53 +02:00
await sequence.db_mark_dirty_record(record)
await sequence.db_save_records()
# Step 4: Reload the range and verify updates
2026-07-15 16:38:53 +02:00
await _reset_sequence_state(sequence)
await sequence.db_load_records(db_start, db_end)
assert all(r.humidity == 75.0 for r in sequence.records)
# Step 5: Vacuum — keep from hours=75 onward (delete first 75)
db_cutoff = DatabaseTimestamp.from_datetime(base_time.add(hours=75))
2026-07-15 16:38:53 +02:00
deleted = await sequence.db_vacuum(keep_timestamp=db_cutoff)
assert deleted == 75
2026-07-15 16:38:53 +02:00
assert await sequence.db_count_records() == 25
# Step 6: Stats reflect vacuum result
2026-07-15 16:38:53 +02:00
await _reset_sequence_state(sequence)
stats = await sequence.db_get_stats()
assert stats["total_records"] == 25
2026-07-15 16:38:53 +02:00
async def test_error_handling_db_disabled(self, config_eos):
"""Operations on a disabled DB raise clearly."""
config_eos.database.provider = None
sequence = SampleDataSequence()
2026-07-15 16:38:53 +02:00
await _reset_sequence_state(sequence)
assert sequence.db_enabled is False
# Save is a no-op and returns 0 when disabled — no RuntimeError
# (mixin returns 0 early when not enabled)
2026-07-15 16:38:53 +02:00
result = await sequence.db_save_records()
assert result == 0
2026-07-15 16:38:53 +02:00
async def test_persistence_across_resets(self, async_database_instance):
"""Data written in one memory session is available after reset."""
sequence = SampleDataSequence()
2026-07-15 16:38:53 +02:00
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-06-01T00:00:00Z")
for i in range(20):
2026-07-15 16:38:53 +02:00
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i))
)
2026-07-15 16:38:53 +02:00
await sequence.db_save_records()
# Simulate a restart: reset memory state
2026-07-15 16:38:53 +02:00
await _reset_sequence_state(sequence)
assert len(sequence.records) == 0
2026-07-15 16:38:53 +02:00
loaded = await sequence.db_load_records()
assert loaded == 20
assert sequence.records[0].temperature == 0.0
assert sequence.records[-1].temperature == 19.0
# ==================== Performance Tests ====================
2026-07-15 16:38:53 +02:00
@pytest.mark.asyncio
class TestPerformance:
"""Throughput benchmarks — not correctness tests."""
2026-07-15 16:38:53 +02:00
async def test_insert_throughput(self, config_eos, async_database_instance):
sequence = SampleDataSequence()
2026-07-15 16:38:53 +02:00
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
n = 10_000
start = time.perf_counter()
for i in range(n):
2026-07-15 16:38:53 +02:00
await sequence.db_insert_record(
SampleDataRecord(
date_time=base_time.add(minutes=i),
temperature=20.0 + (i % 100) * 0.1,
)
)
insert_duration = time.perf_counter() - start
print(f"\nInserted {n} records in {insert_duration:.2f}s "
f"({n / insert_duration:.0f} rec/s)")
assert len(sequence.records) == n
2026-07-15 16:38:53 +02:00
async def test_save_throughput(self, config_eos, async_database_instance):
sequence = SampleDataSequence()
2026-07-15 16:38:53 +02:00
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
n = 10_000
for i in range(n):
2026-07-15 16:38:53 +02:00
await sequence.db_insert_record(
SampleDataRecord(
date_time=base_time.add(minutes=i),
temperature=20.0 + (i % 100) * 0.1,
)
)
start = time.perf_counter()
2026-07-15 16:38:53 +02:00
saved = await sequence.db_save_records()
save_duration = time.perf_counter() - start
assert saved == n
print(f"\nSaved {n} records in {save_duration:.2f}s "
f"({n / save_duration:.0f} rec/s)")
2026-07-15 16:38:53 +02:00
async def test_load_throughput(self, config_eos, async_database_instance):
sequence = SampleDataSequence()
2026-07-15 16:38:53 +02:00
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
n = 10_000
for i in range(n):
2026-07-15 16:38:53 +02:00
await sequence.db_insert_record(
SampleDataRecord(
date_time=base_time.add(minutes=i),
temperature=20.0 + (i % 100) * 0.1,
)
)
2026-07-15 16:38:53 +02:00
await sequence.db_save_records()
await _reset_sequence_state(sequence)
start = time.perf_counter()
2026-07-15 16:38:53 +02:00
loaded = await sequence.db_load_records()
load_duration = time.perf_counter() - start
assert loaded == n
print(f"\nLoaded {n} records in {load_duration:.2f}s "
f"({n / load_duration:.0f} rec/s)")