Persist material consumption snapshots
This commit is contained in:
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user