DSMR hardening: restart-free config + decouple from telegram id
Config hot-reload (no restart): - MqttManager.unsubscribe(); apply_dsmr_subscription(settings) re-applies the DSMR subscription (subscribe/unsubscribe/topic-change, fresh snapshot so the sample interval also takes effect). - Called from lifespan AND PUT /api/config, so toggling DSMR ingest in the UI takes effect immediately without restarting the app. Decouple ingest from the DSMR telegram id (overflows / resets to zero): - Migration 20260624_12: drop UNIQUE(source_id), make recorded_at UNIQUE. - Idempotency now keys on recorded_at (telegram timestamp); the table's own autoincrement PK is the stable identity. source_id kept only as a reference. - Regression test: colliding telegram ids no longer drop new data.
This commit is contained in:
+48
-13
@@ -117,21 +117,21 @@ def test_dsmr_reading_columns(energy_db):
|
||||
assert "payload" in columns and not columns["payload"]["nullable"]
|
||||
|
||||
|
||||
def test_dsmr_reading_source_id_unique(energy_db):
|
||||
"""dsmr_reading.source_id must have a unique constraint."""
|
||||
def test_dsmr_reading_recorded_at_unique(energy_db):
|
||||
"""dsmr_reading.recorded_at is the UNIQUE de-dup key (telegram-id-independent)."""
|
||||
inspector = inspect(energy_db)
|
||||
unique_constraints = inspector.get_unique_constraints("dsmr_reading")
|
||||
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]]
|
||||
assert "source_id" in unique_cols, "source_id must have a unique constraint"
|
||||
assert "recorded_at" in unique_cols, "recorded_at must have a unique constraint"
|
||||
|
||||
|
||||
def test_dsmr_reading_recorded_at_index(energy_db):
|
||||
"""dsmr_reading.recorded_at must have an index."""
|
||||
def test_dsmr_reading_source_id_not_unique(energy_db):
|
||||
"""dsmr_reading.source_id (telegram id) must NOT be unique — it overflows/resets,
|
||||
so it is kept only as a reference value and never relied on for dedup."""
|
||||
inspector = inspect(energy_db)
|
||||
indexes = {idx["name"]: idx for idx in inspector.get_indexes("dsmr_reading")}
|
||||
assert "ix_dsmr_reading_recorded_at" in indexes, (
|
||||
"ix_dsmr_reading_recorded_at missing from dsmr_reading"
|
||||
)
|
||||
unique_constraints = inspector.get_unique_constraints("dsmr_reading")
|
||||
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]]
|
||||
assert "source_id" not in unique_cols, "source_id must NOT have a unique constraint"
|
||||
|
||||
|
||||
def test_energy_contract_columns(energy_db):
|
||||
@@ -268,6 +268,40 @@ def test_downgrade_removes_energy_tables(tmp_path: Path):
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_dsmr_decouple_migration_downgrade_restores_source_id_unique(tmp_path: Path):
|
||||
"""Downgrading the telegram-id-decouple migration to 20260623_11 must restore the
|
||||
original constraints: source_id UNIQUE, recorded_at non-unique index."""
|
||||
db_path = tmp_path / "dsmr_decouple_downgrade.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
alembic_cfg = _make_app_alembic_config(db_url)
|
||||
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
# At head: recorded_at is unique, source_id is not.
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
inspector = inspect(engine)
|
||||
head_unique = [
|
||||
c for uc in inspector.get_unique_constraints("dsmr_reading") for c in uc["column_names"]
|
||||
]
|
||||
assert "recorded_at" in head_unique
|
||||
assert "source_id" not in head_unique
|
||||
engine.dispose()
|
||||
|
||||
# Downgrade just this migration.
|
||||
command.downgrade(alembic_cfg, "20260623_11_energy_tables")
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
inspector = inspect(engine)
|
||||
pre_unique = [
|
||||
c for uc in inspector.get_unique_constraints("dsmr_reading") for c in uc["column_names"]
|
||||
]
|
||||
assert "source_id" in pre_unique, "source_id unique must be restored on downgrade"
|
||||
assert "recorded_at" not in pre_unique
|
||||
index_names = {idx["name"] for idx in inspector.get_indexes("dsmr_reading")}
|
||||
assert "ix_dsmr_reading_recorded_at" in index_names
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. ORM metadata checks (Base.metadata)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -332,11 +366,12 @@ def test_energy_cost_period_fk_ondelete_restrict():
|
||||
)
|
||||
|
||||
|
||||
def test_dsmr_reading_source_id_unique_in_metadata():
|
||||
"""DsmrReading.source_id must be declared unique in ORM metadata."""
|
||||
def test_dsmr_reading_recorded_at_unique_in_metadata():
|
||||
"""DsmrReading.recorded_at must be the unique de-dup key in ORM metadata,
|
||||
and source_id must NOT be unique (decoupled from the telegram id)."""
|
||||
table = Base.metadata.tables["dsmr_reading"]
|
||||
col = table.columns["source_id"]
|
||||
assert col.unique, "source_id must be declared unique"
|
||||
assert table.columns["recorded_at"].unique, "recorded_at must be declared unique"
|
||||
assert not table.columns["source_id"].unique, "source_id must NOT be unique"
|
||||
|
||||
|
||||
def test_tibber_price_starts_at_unique_in_metadata():
|
||||
|
||||
Reference in New Issue
Block a user