Persist material consumption snapshots

This commit is contained in:
2026-09-05 11:35:25 +02:00
parent 7c029759f5
commit ae13c3c35a
9 changed files with 303 additions and 17 deletions
@@ -9,6 +9,7 @@ from typing import TextIO
from production_analytics.calculations.config import finite_number
from production_analytics.enlyze.exploration import ConfigurationError, ExplorationError
from production_analytics.service.material_polling import MaterialPollingService
from production_analytics.service.postgres_material import MaterialSnapshotWriter
class MaterialStateError(RuntimeError):
@@ -22,12 +23,15 @@ def utc_now() -> datetime:
class MaterialPollingRunner:
def __init__(
self, service: MaterialPollingService, *, machine_id: str,
calculation_id: str, snapshot_writer: MaterialSnapshotWriter,
poll_interval_seconds: float, clock: Callable[[], datetime] = utc_now,
sleep: Callable[[float], None] = time.sleep,
stdout: TextIO | None = None, stderr: TextIO | None = None,
) -> None:
self.service = service
self.machine_id = machine_id
self.calculation_id = calculation_id
self.snapshot_writer = snapshot_writer
self.interval = finite_number(poll_interval_seconds, "poll interval", positive=True)
self.clock = clock
self.sleep = sleep
@@ -59,6 +63,19 @@ class MaterialPollingRunner:
if result is None:
detail = "no open Production Run / no eligible polling window"
else:
try:
self.snapshot_writer.write(
timestamp=now, calculation_id=self.calculation_id,
machine_id=self.machine_id,
production_order=result.run.production_order,
run_id=result.run.uuid,
consumption_kg=result.state.cumulative_consumption_kg,
)
except Exception:
print(
f"{prefix} error PostgreSQL snapshot write failed",
file=self.stderr, flush=True,
)
detail = (
f"production_order={result.run.production_order!r} "
f"consumption_kg={result.state.cumulative_consumption_kg:.9f} "
@@ -20,6 +20,10 @@ from production_analytics.service.material_polling import (
)
from production_analytics.service.material_runner import MaterialPollingRunner, MaterialStateError
from production_analytics.service.material_state_store import JsonMaterialStateStore
from production_analytics.service.postgres_material import (
PostgresMaterialSnapshotWriter,
PostgresSettings,
)
class _ReportingStateStore:
@@ -64,6 +68,7 @@ def build_material_runner(
raise ConfigurationError(
f"State directory startup check failed: {type(exc).__name__}"
) from exc
postgres_settings = PostgresSettings.from_environment(environment)
service = MaterialPollingService(
gateway=EnlyzeApiGateway(ExplorationClient(settings)),
state_store=_ReportingStateStore(JsonMaterialStateStore(state_directory)),
@@ -74,5 +79,7 @@ def build_material_runner(
max_sample_gap_seconds=calculation.max_sample_gap_seconds,
)
return MaterialPollingRunner(
service, machine_id=calculation.machine_ref, poll_interval_seconds=poll_interval_seconds,
service, calculation_id=calculation.id,
snapshot_writer=PostgresMaterialSnapshotWriter(postgres_settings),
machine_id=calculation.machine_ref, poll_interval_seconds=poll_interval_seconds,
)
@@ -0,0 +1,68 @@
"""PostgreSQL settings and derived material snapshot inserts."""
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol
from production_analytics.enlyze.exploration import ConfigurationError
@dataclass(frozen=True, slots=True)
class PostgresSettings:
host: str
port: int
dbname: str
user: str
password: str = field(repr=False)
@classmethod
def from_environment(cls, environment: Mapping[str, str]) -> "PostgresSettings":
values = {}
for key in ('HOST', 'PORT', 'DB', 'USER', 'PASSWORD'):
name = f'POSTGRES_{key}'
value = environment.get(name)
if not isinstance(value, str) or not value.strip() or '\x00' in value:
raise ConfigurationError(f'{name} must be a non-empty value without NUL bytes')
values[key] = value
try:
port = int(values['PORT'])
except ValueError:
raise ConfigurationError('POSTGRES_PORT must be an integer from 1 to 65535') from None
if not 1 <= port <= 65535:
raise ConfigurationError('POSTGRES_PORT must be an integer from 1 to 65535')
return cls(values['HOST'], port, values['DB'], values['USER'], values['PASSWORD'])
class MaterialSnapshotWriter(Protocol):
def write(
self, *, timestamp: datetime, calculation_id: str, machine_id: str,
production_order: str, run_id: str, consumption_kg: float,
) -> None: ...
class PostgresMaterialSnapshotWriter:
def __init__(self, settings: PostgresSettings) -> None:
self.settings = settings
def write(
self, *, timestamp: datetime, calculation_id: str, machine_id: str,
production_order: str, run_id: str, consumption_kg: float,
) -> None:
import psycopg
# One short transaction per cycle; context exit commits or rolls back and closes.
# A fresh connection lets the next cycle recover after a database outage.
with psycopg.connect(
host=self.settings.host, port=self.settings.port, dbname=self.settings.dbname,
user=self.settings.user, password=self.settings.password, connect_timeout=10,
options='-c statement_timeout=10000',
) as connection:
connection.execute(
"""INSERT INTO material_consumption_snapshots
(timestamp, calculation_id, machine_id, production_order, run_id, consumption_kg)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (calculation_id, machine_id, production_order, timestamp)
DO NOTHING""",
(timestamp, calculation_id, machine_id, production_order, run_id, consumption_kg),
)