Harden live material polling state
This commit is contained in:
@@ -65,8 +65,10 @@ class MaterialPollingService:
|
||||
)
|
||||
|
||||
def poll_once(self, *, now: datetime) -> MaterialPollResult | None:
|
||||
if now.tzinfo is None or now.utcoffset() is None:
|
||||
raise ValueError("now must be timezone-aware")
|
||||
run = self._gateway.get_open_production_run(self._machine_id)
|
||||
if run is None:
|
||||
if run is None or now < run.start:
|
||||
return None
|
||||
|
||||
saved_polling_state = self._state_store.load(
|
||||
@@ -92,6 +94,9 @@ class MaterialPollingService:
|
||||
)
|
||||
start = run.start
|
||||
|
||||
if now < start:
|
||||
return None
|
||||
|
||||
integrator = MaterialConsumptionIntegrator(
|
||||
self._config,
|
||||
initial_state=initial_state,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""JSON-backed persistence for material polling state."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from production_analytics.calculations.material_state import (
|
||||
material_state_from_dict,
|
||||
material_state_to_dict,
|
||||
)
|
||||
from production_analytics.service.material_polling import MaterialPollingState
|
||||
|
||||
|
||||
class JsonMaterialStateStore:
|
||||
def __init__(self, directory: str | Path) -> None:
|
||||
self._directory = Path(directory)
|
||||
|
||||
def load(
|
||||
self,
|
||||
machine_id: str,
|
||||
production_order: str,
|
||||
) -> MaterialPollingState | None:
|
||||
path = self._path(machine_id, production_order)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("run_id"), str):
|
||||
raise ValueError("run_id must be a string")
|
||||
if not data["run_id"]:
|
||||
raise ValueError("run_id must not be empty")
|
||||
return MaterialPollingState(
|
||||
run_id=data["run_id"],
|
||||
integration_state=material_state_from_dict(data["integration_state"]),
|
||||
)
|
||||
except (ValueError, KeyError, TypeError) as exc:
|
||||
raise ValueError(f"Invalid material polling state in {path}: {exc}") from exc
|
||||
|
||||
def save(
|
||||
self,
|
||||
machine_id: str,
|
||||
production_order: str,
|
||||
state: MaterialPollingState,
|
||||
) -> None:
|
||||
self._directory.mkdir(parents=True, exist_ok=True)
|
||||
path = self._path(machine_id, production_order)
|
||||
|
||||
payload = {
|
||||
"run_id": state.run_id,
|
||||
"integration_state": material_state_to_dict(state.integration_state),
|
||||
}
|
||||
|
||||
# Validate before touching the primary file, including non-finite values.
|
||||
material_state_from_dict(payload["integration_state"])
|
||||
if not isinstance(state.run_id, str) or not state.run_id:
|
||||
raise ValueError("run_id must be a non-empty string")
|
||||
temporary_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", encoding="utf-8", dir=self._directory,
|
||||
prefix=".material-state-", suffix=".tmp", delete=False,
|
||||
) as f:
|
||||
temporary_path = Path(f.name)
|
||||
json.dump(payload, f, indent=2, allow_nan=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(temporary_path, path)
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
def _path(self, machine_id: str, production_order: str) -> Path:
|
||||
# Hash the structured pair: sanitizing alone aliases distinct identifiers.
|
||||
identity = json.dumps([machine_id, production_order], ensure_ascii=True)
|
||||
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()
|
||||
return self._directory / f"{digest}.json"
|
||||
Reference in New Issue
Block a user