M8-R06: refresh Tibber prices after startup and configuration

This commit is contained in:
2026-08-24 06:45:31 +02:00
parent 0924e8df52
commit 09abe05f66
8 changed files with 275 additions and 14 deletions
+66 -3
View File
@@ -30,6 +30,7 @@ Design decisions
from __future__ import annotations
import logging
from threading import Lock, Thread
from datetime import UTC, datetime
from sqlalchemy import select
@@ -41,7 +42,10 @@ from app.models.energy import EnergyContract, TibberPrice
logger = logging.getLogger(__name__)
def _active_tibber_contract_exists(session: Session) -> bool:
_background_refresh_lock = Lock()
def active_tibber_contract_exists(session: Session) -> bool:
"""Return True if there is an active contract with kind='tibber'."""
row = session.execute(
select(EnergyContract).where(
@@ -84,14 +88,17 @@ def refresh_prices(session: Session, settings: object) -> int:
logger.debug("refresh_prices: tibber_api_token is empty — no-op")
return 0
if not _active_tibber_contract_exists(session):
if not active_tibber_contract_exists(session):
logger.debug("refresh_prices: no active tibber contract — no-op")
return 0
home_id: str = getattr(settings, "tibber_home_id", "") or ""
home_id_or_none: str | None = home_id.strip() or None
logger.info("refresh_prices: fetching Tibber price range (home_id=%r)", home_id_or_none)
# Neither the API token nor the selected home identifier is safe to emit in
# diagnostics. The fetch client receives them, but logs only describe the
# operation itself.
logger.info("refresh_prices: fetching Tibber price range")
# May raise TibberError or TibberAuthError — let them propagate.
price_points = fetch_price_range(token, home_id_or_none)
@@ -137,3 +144,59 @@ def refresh_prices(session: Session, settings: object) -> int:
logger.info("refresh_prices: upserted %d price points", upserted)
return upserted
def run_tibber_refresh_best_effort() -> bool:
"""Run one refresh with an isolated session, skipping concurrent requests.
This is shared by the hourly scheduler and immediate post-commit triggers.
It intentionally catches all failures: refresh is advisory and must never
make app startup or a successfully committed configuration/contract update
appear to have failed. The boolean reports whether this invocation owned
the work; it is primarily useful for tests and diagnostics.
"""
if not _background_refresh_lock.acquire(blocking=False):
logger.debug("Tibber refresh already running; skipping duplicate request")
return False
session: Session | None = None
try:
# Local imports keep the pure refresh service free of app startup import
# cycles, while every background invocation gets a fresh DB session.
from app.config import get_settings
from app.db import get_session_local
from app.services.config_page import build_runtime_settings
session = get_session_local()()
refresh_prices(session, build_runtime_settings(session, get_settings()))
except Exception as exc:
# Exception text can contain remote request details. Keep diagnostics
# useful without allowing a token or home id to escape through logging.
logger.warning("Tibber price refresh failed (%s)", type(exc).__name__)
if session is not None:
try:
session.rollback()
except Exception:
logger.warning("Tibber price refresh rollback failed")
finally:
if session is not None:
try:
session.close()
except Exception:
logger.warning("Tibber price refresh session close failed")
_background_refresh_lock.release()
return True
def trigger_tibber_refresh() -> None:
"""Request a non-blocking, best-effort Tibber refresh after a DB commit."""
try:
Thread(
target=run_tibber_refresh_best_effort,
name="tibber-price-refresh",
daemon=True,
).start()
except Exception as exc:
# Starting the optional worker must not turn an already committed API
# operation into a failure; avoid logging exception text for secrecy.
logger.warning("Unable to start Tibber price refresh (%s)", type(exc).__name__)