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
+20 -6
View File
@@ -112,7 +112,8 @@ def test_repeated_sequential_polls_and_long_cycle():
output = io.StringIO()
service = Mock(poll_once=Mock(side_effect=poll))
MaterialPollingRunner(
service, machine_id='machine', poll_interval_seconds=10,
service, calculation_id='test-calculation', snapshot_writer=Mock(),
machine_id='machine', poll_interval_seconds=10,
clock=lambda: NOW + timedelta(seconds=elapsed), sleep=sleep, stdout=output,
).run()
assert events == ['poll-start', 'poll-end', ('sleep', 10)] * 2
@@ -128,7 +129,8 @@ def test_error_then_success_preserves_opaque_order_and_reports_totals():
output, errors = io.StringIO(), io.StringIO()
sleep = Mock(side_effect=[None, KeyboardInterrupt])
MaterialPollingRunner(
service, machine_id='machine', poll_interval_seconds=5, clock=lambda: NOW,
service, calculation_id='test-calculation', snapshot_writer=Mock(),
machine_id='machine', poll_interval_seconds=5, clock=lambda: NOW,
sleep=sleep, stdout=output, stderr=errors,
).run()
assert service.poll_once.call_count == 2
@@ -143,7 +145,8 @@ def test_error_then_success_preserves_opaque_order_and_reports_totals():
def test_interrupt_during_poll():
sleep, output = Mock(), io.StringIO()
MaterialPollingRunner(
Mock(poll_once=Mock(side_effect=KeyboardInterrupt)), machine_id='m',
Mock(poll_once=Mock(side_effect=KeyboardInterrupt)),
calculation_id='test-calculation', snapshot_writer=Mock(), machine_id='m',
poll_interval_seconds=1, sleep=sleep, stdout=output,
).run()
sleep.assert_not_called()
@@ -154,6 +157,7 @@ def test_cycle_configuration_failure_is_fatal():
sleep = Mock()
runner = MaterialPollingRunner(
Mock(poll_once=Mock(side_effect=ConfigurationError('bad settings'))),
calculation_id='test-calculation', snapshot_writer=Mock(),
machine_id='m', poll_interval_seconds=1, sleep=sleep,
)
with pytest.raises(ConfigurationError):
@@ -164,7 +168,10 @@ def test_cycle_configuration_failure_is_fatal():
@pytest.mark.parametrize('interval', [0, -1, float('nan'), float('inf'), True])
def test_invalid_interval(interval):
with pytest.raises(CalculationConfigError):
MaterialPollingRunner(Mock(), machine_id='m', poll_interval_seconds=interval)
MaterialPollingRunner(
Mock(), machine_id='m', calculation_id='test', snapshot_writer=Mock(),
poll_interval_seconds=interval,
)
@pytest.mark.parametrize('operation', ['load', 'save'])
@@ -182,7 +189,10 @@ def test_state_error_classification(operation):
def test_runtime_wiring(tmp_path, monkeypatch):
monkeypatch.setenv('ENLYZE_BASE_URL', 'https://example.invalid/api/')
secrets = tmp_path / 'secret.env'
secrets.write_text('ENLYZE_API_KEY=secret-from-file\n')
for key, value in dict(HOST='localhost', PORT='5432', DB='analytics',
USER='user', PASSWORD='environment-password').items():
monkeypatch.setenv(f'POSTGRES_{key}', value)
secrets.write_text('ENLYZE_API_KEY=secret-from-file\nPOSTGRES_PASSWORD=file-password\n')
with patch('production_analytics.service.material_runtime.MaterialPollingService') as service:
runner = build_material_runner(
load_material_calculation(EXAMPLE), poll_interval_seconds=7,
@@ -198,6 +208,9 @@ def test_runtime_wiring(tmp_path, monkeypatch):
assert kwargs['state_store'].store._directory == tmp_path / 'state'
assert runner.service is service.return_value
assert runner.interval == 7
assert runner.calculation_id == 'k7-fiber-consumption'
assert runner.snapshot_writer.settings.password == 'file-password'
assert runner.snapshot_writer.settings.dbname == 'analytics'
def test_cli_bad_config(tmp_path, capsys):
@@ -257,7 +270,8 @@ def test_failed_cycle_keeps_checkpoint_and_recovers(tmp_path):
raise KeyboardInterrupt
MaterialPollingRunner(
service, machine_id='machine', poll_interval_seconds=1,
service, calculation_id='test-calculation', snapshot_writer=Mock(),
machine_id='machine', poll_interval_seconds=1,
clock=lambda: NOW + timedelta(seconds=10), sleep=sleep,
stdout=io.StringIO(), stderr=io.StringIO(),
).run()
+128
View File
@@ -0,0 +1,128 @@
import io
import sys
from datetime import UTC, datetime
from unittest.mock import MagicMock, Mock, patch
import pytest
from production_analytics.calculations.material_consumption import MaterialIntegrationState
from production_analytics.enlyze.exploration import ConfigurationError
from production_analytics.enlyze.gateway import EnlyzeProductionRun
from production_analytics.service.material_polling import MaterialPollResult
from production_analytics.service.material_runner import MaterialPollingRunner
from production_analytics.service.postgres_material import (
PostgresMaterialSnapshotWriter,
PostgresSettings,
)
ENV = dict(POSTGRES_HOST='localhost', POSTGRES_PORT='5432', POSTGRES_DB='analytics',
POSTGRES_USER='writer', POSTGRES_PASSWORD='secret-password')
NOW = datetime(2026, 1, 1, tzinfo=UTC)
@pytest.mark.parametrize('key', ENV)
@pytest.mark.parametrize('value', [None, '', ' ', '\x00'])
def test_required_settings(key, value):
environment = dict(ENV)
if value is None:
del environment[key]
else:
environment[key] = value
with pytest.raises(ConfigurationError, match=key) as error:
PostgresSettings.from_environment(environment)
assert 'secret-password' not in str(error.value)
@pytest.mark.parametrize('port', ['0', '-1', '65536', '1.5', 'secret-password'])
def test_invalid_port(port):
with pytest.raises(ConfigurationError, match='POSTGRES_PORT') as error:
PostgresSettings.from_environment(dict(ENV, POSTGRES_PORT=port))
assert str(error.value) == 'POSTGRES_PORT must be an integer from 1 to 65535'
def test_valid_settings_and_password_repr():
settings = PostgresSettings.from_environment(ENV)
assert (settings.host, settings.port, settings.dbname, settings.user) == (
'localhost', 5432, 'analytics', 'writer',
)
assert settings.password == 'secret-password'
assert settings.password not in repr(settings)
def test_parameterized_insert_and_connection_lifecycle():
driver = MagicMock()
connection = driver.connect.return_value.__enter__.return_value
writer = PostgresMaterialSnapshotWriter(PostgresSettings.from_environment(ENV))
fields = dict(timestamp=NOW, calculation_id='calc', machine_id='machine',
production_order=" 00842'; DROP TABLE x; -- ", run_id='run', consumption_kg=12.5)
with patch.dict(sys.modules, psycopg=driver):
writer.write(**fields)
sql, parameters = connection.execute.call_args.args
assert sql.count('%s') == 6
assert fields['production_order'] not in sql
assert parameters == tuple(fields.values())
assert 'ON CONFLICT' in sql
connection.execute.assert_called_once()
driver.connect.assert_called_once_with(
host='localhost', port=5432, dbname='analytics', user='writer',
password='secret-password', connect_timeout=10, options='-c statement_timeout=10000',
)
driver.connect.return_value.__exit__.assert_called_once_with(None, None, None)
def test_writer_failure_propagates_and_next_write_reconnects():
driver = MagicMock()
driver.connect.return_value.__enter__.return_value.execute.side_effect = [
RuntimeError('secret'), None,
]
writer = PostgresMaterialSnapshotWriter(PostgresSettings.from_environment(ENV))
fields = dict(timestamp=NOW, calculation_id='calc', machine_id='machine',
production_order='order', run_id='run', consumption_kg=0)
with patch.dict(sys.modules, psycopg=driver):
with pytest.raises(RuntimeError):
writer.write(**fields)
writer.write(**fields)
assert driver.connect.call_count == 2
assert driver.connect.return_value.__exit__.call_count == 2
@pytest.mark.parametrize('consumption', [0, 12.5])
def test_runner_snapshot_fields_and_none(consumption):
result = MaterialPollResult(
EnlyzeProductionRun('run', 'machine', None, ' 00842 ', NOW, None),
MaterialIntegrationState(consumption, 20),
)
writer = Mock()
output = io.StringIO()
MaterialPollingRunner(
Mock(poll_once=Mock(side_effect=[None, result])), machine_id='machine',
calculation_id='configured-calculation', snapshot_writer=writer,
poll_interval_seconds=10, clock=lambda: NOW,
sleep=Mock(side_effect=[None, KeyboardInterrupt]), stdout=output,
).run()
writer.write.assert_called_once_with(
timestamp=NOW, calculation_id='configured-calculation', machine_id='machine',
production_order=' 00842 ', run_id='run', consumption_kg=consumption,
)
assert 'no open Production Run' in output.getvalue()
assert f'consumption_kg={consumption:.9f}' in output.getvalue()
def test_snapshot_failure_reports_safely_and_continues():
result = MaterialPollResult(
EnlyzeProductionRun('run', 'machine', None, 'order', NOW, None),
MaterialIntegrationState(12.5, 20),
)
service = Mock(poll_once=Mock(return_value=result))
writer = Mock(write=Mock(side_effect=[RuntimeError('secret-password arbitrary SQL'), None]))
output, errors = io.StringIO(), io.StringIO()
MaterialPollingRunner(
service, machine_id='machine', calculation_id='calc', snapshot_writer=writer,
poll_interval_seconds=10, clock=lambda: NOW,
sleep=Mock(side_effect=[None, KeyboardInterrupt]), stdout=output, stderr=errors,
).run()
assert service.poll_once.call_count == writer.write.call_count == 2
assert errors.getvalue().count('PostgreSQL snapshot write failed') == 1
assert 'secret-password' not in errors.getvalue()
assert 'arbitrary SQL' not in errors.getvalue()
assert output.getvalue().count('consumption_kg=12.500000000') == 2