feat(gateway): wire engines+adapters via GatewayRuntime (write/gRPC live; subscription v2 gated on 1974)
GatewayRuntime builds and owns the F4 object graph (CertProvider/pool -> WriteEngine/NatsWriteConsumer -> ActiveReadEngine -> optional GrpcServer), reusing the existing ingestor's logger/notification_handler/metrics_controller and DataManager (CertProvider shares its Mongo connection instead of opening a second one). app.py starts it after prepare_ingestor, sweeps idle OPC connections each loop iteration, and stops it on shutdown. A NATS connect failure at startup is logged/notified but never crashes the app -- the read path keeps working and idle_sweep() retries the consumer start. ingestor.py needed no changes: data_manager is already reachable via ingestor.ingestor_manager.data_manager. Also documents the new MinIO/Postgres env vars this wiring reads (via sientia_do's build_postgres_config for Postgres) in .env.example, and updates test_app.py's fixture to mock GatewayConfig/GatewayRuntime so the existing app tests keep exercising only what they mocked before.
This commit is contained in:
@@ -43,6 +43,15 @@ def mock_app_env(monkeypatch):
|
||||
monkeypatch.setattr(app, 'Ingestor', mocks['Ingestor'])
|
||||
monkeypatch.setattr(asyncio, 'sleep', mocks['asyncio_sleep'])
|
||||
|
||||
mocks['GatewayConfig'] = MagicMock()
|
||||
mocks['GatewayRuntime'] = MagicMock()
|
||||
monkeypatch.setattr(app, 'GatewayConfig', mocks['GatewayConfig'])
|
||||
monkeypatch.setattr(app, 'GatewayRuntime', mocks['GatewayRuntime'])
|
||||
mock_runtime_instance = mocks['GatewayRuntime'].return_value
|
||||
mock_runtime_instance.start = AsyncMock()
|
||||
mock_runtime_instance.idle_sweep = AsyncMock()
|
||||
mock_runtime_instance.stop = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
app.metrics.APP_UP,
|
||||
'labels',
|
||||
@@ -77,6 +86,7 @@ def mock_app_env(monkeypatch):
|
||||
mock_ingestor_instance = mocks['Ingestor'].return_value
|
||||
mock_ingestor_instance.poll_interval = 0.01
|
||||
mock_ingestor_instance.logger = MagicMock()
|
||||
mock_ingestor_instance.ingestor_manager = MagicMock()
|
||||
|
||||
# Make async methods async mocks
|
||||
mock_ingestor_instance.prepare_ingestor = AsyncMock()
|
||||
@@ -124,6 +134,21 @@ async def test_main_successful_run_one_loop(mock_app_env, capsys):
|
||||
mock_ingestor_instance.shutdown.assert_called_once()
|
||||
mock_ingestor_instance.logger.info.assert_any_call('Main loop exit_signaled.')
|
||||
|
||||
# GatewayRuntime (Task 18): built once prepare_ingestor succeeds, started before
|
||||
# the loop, idle-swept each iteration, stopped on shutdown.
|
||||
mock_app_env['GatewayRuntime'].assert_called_once_with(
|
||||
config=mock_app_env['GatewayConfig'].from_env.return_value,
|
||||
data_manager=mock_ingestor_instance.ingestor_manager.data_manager,
|
||||
logger=mock_ingestor_instance.logger,
|
||||
notification_handler=mock_ingestor_instance.notification_handler,
|
||||
metrics_controller=mock_ingestor_instance.metrics_controller,
|
||||
metadata=mock_ingestor_instance.metadata,
|
||||
)
|
||||
mock_runtime_instance = mock_app_env['GatewayRuntime'].return_value
|
||||
mock_runtime_instance.start.assert_awaited_once()
|
||||
mock_runtime_instance.idle_sweep.assert_awaited_once()
|
||||
mock_runtime_instance.stop.assert_awaited_once()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert 'Prometheus server started on port 9090.' in captured.out
|
||||
|
||||
@@ -297,12 +322,16 @@ async def test_main_prepare_ingestor_failure(mock_app_env, capsys):
|
||||
mock_exit_signal = mock_app_env['mock_exit_signal']
|
||||
|
||||
mock_ingestor_instance.prepare_ingestor.side_effect = Exception('Preparation failed')
|
||||
mock_ingestor_instance.ingestor_manager = None # prepare failed before assignment
|
||||
mock_exit_signal.is_set.side_effect = [False, True]
|
||||
|
||||
with pytest.raises(OsExitCalledError) as excinfo:
|
||||
await app.main()
|
||||
assert excinfo.value.code == 0
|
||||
|
||||
# GatewayRuntime must not be built when prepare_ingestor never got a data_manager
|
||||
mock_app_env['GatewayRuntime'].assert_not_called()
|
||||
|
||||
# Verify error metrics were incremented
|
||||
app.metrics.APP_ERRORS_TOTAL.labels.assert_called_with(pod_id='test_pod')
|
||||
mock_app_env['metrics_APP_ERRORS_TOTAL_labels_inc'].assert_called_once()
|
||||
|
||||
156
tests/unit/test_gateway_runtime.py
Normal file
156
tests/unit/test_gateway_runtime.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from ingestor import gateway_runtime as gr
|
||||
from ingestor.config import GatewayConfig
|
||||
|
||||
_PATCHED_CLASSES = (
|
||||
'MinioRepository',
|
||||
'CertProvider',
|
||||
'OpcConnectionPool',
|
||||
'Postgres',
|
||||
'build_postgres_config',
|
||||
'DataSink',
|
||||
'WriteEngine',
|
||||
'NatsWriteConsumer',
|
||||
'ActiveReadEngine',
|
||||
'GrpcServer',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mocks(monkeypatch):
|
||||
patched = {}
|
||||
for name in _PATCHED_CLASSES:
|
||||
m = MagicMock()
|
||||
monkeypatch.setattr(gr, name, m)
|
||||
patched[name] = m
|
||||
|
||||
patched['build_postgres_config'].return_value = {
|
||||
'host': 'localhost', 'port': 5432, 'user': 'u', 'password': 'p',
|
||||
'dbname': 'd', 'min_connections': 5, 'max_connections': 20,
|
||||
}
|
||||
patched['OpcConnectionPool'].return_value.close_all = AsyncMock()
|
||||
patched['OpcConnectionPool'].return_value.release_idle = AsyncMock()
|
||||
patched['NatsWriteConsumer'].return_value.start = AsyncMock()
|
||||
patched['NatsWriteConsumer'].return_value.stop = AsyncMock()
|
||||
patched['GrpcServer'].return_value.start = AsyncMock()
|
||||
patched['GrpcServer'].return_value.stop = AsyncMock()
|
||||
return patched
|
||||
|
||||
|
||||
def make_runtime(**config_overrides):
|
||||
return gr.GatewayRuntime(
|
||||
config=GatewayConfig(**config_overrides),
|
||||
data_manager=MagicMock(),
|
||||
logger=MagicMock(),
|
||||
notification_handler=MagicMock(),
|
||||
metrics_controller=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
def test_builds_object_graph_with_config_flags(mocks):
|
||||
runtime = make_runtime(
|
||||
write_timestamp=False, write_quality=False, write_min_pause_s=0.25,
|
||||
cert_bucket='my-bucket', cert_cache_dir='certs-cache', grpc_enabled=False,
|
||||
)
|
||||
|
||||
mocks['OpcConnectionPool'].assert_called_once()
|
||||
mocks['DataSink'].assert_called_once()
|
||||
|
||||
mocks['WriteEngine'].assert_called_once()
|
||||
_, kwargs = mocks['WriteEngine'].call_args
|
||||
assert kwargs['write_timestamp'] is False
|
||||
assert kwargs['write_quality'] is False
|
||||
assert kwargs['min_pause_s'] == 0.25
|
||||
|
||||
_, cert_kwargs = mocks['CertProvider'].call_args
|
||||
assert cert_kwargs['bucket'] == 'my-bucket'
|
||||
assert cert_kwargs['cache_dir'] == 'certs-cache'
|
||||
|
||||
mocks['GrpcServer'].assert_not_called()
|
||||
assert runtime.grpc_server is None
|
||||
|
||||
|
||||
def test_builds_grpc_server_when_enabled(mocks):
|
||||
runtime = make_runtime(grpc_enabled=True, grpc_port=12345)
|
||||
|
||||
mocks['GrpcServer'].assert_called_once()
|
||||
assert runtime.grpc_server is mocks['GrpcServer'].return_value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_starts_nats_consumer(mocks):
|
||||
runtime = make_runtime(grpc_enabled=False)
|
||||
|
||||
await runtime.start()
|
||||
|
||||
mocks['NatsWriteConsumer'].return_value.start.assert_awaited_once()
|
||||
mocks['GrpcServer'].return_value.start.assert_not_called()
|
||||
assert runtime._nats_started is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_also_starts_grpc_when_enabled(mocks):
|
||||
runtime = make_runtime(grpc_enabled=True)
|
||||
|
||||
await runtime.start()
|
||||
|
||||
mocks['NatsWriteConsumer'].return_value.start.assert_awaited_once()
|
||||
mocks['GrpcServer'].return_value.start.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nats_start_failure_does_not_raise_and_idle_sweep_retries(mocks):
|
||||
runtime = make_runtime()
|
||||
runtime.send_notification_async = AsyncMock()
|
||||
nats_mock = mocks['NatsWriteConsumer'].return_value
|
||||
nats_mock.start = AsyncMock(side_effect=[Exception('boom'), None])
|
||||
|
||||
await runtime.start() # must not raise
|
||||
|
||||
assert runtime._nats_started is False
|
||||
runtime.logger.error.assert_called_once()
|
||||
runtime.send_notification_async.assert_awaited_once()
|
||||
|
||||
await runtime.idle_sweep()
|
||||
|
||||
assert runtime._nats_started is True
|
||||
assert nats_mock.start.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_stops_started_components_and_closes_pool(mocks):
|
||||
runtime = make_runtime(grpc_enabled=True)
|
||||
await runtime.start()
|
||||
|
||||
await runtime.stop()
|
||||
|
||||
mocks['GrpcServer'].return_value.stop.assert_awaited_once()
|
||||
mocks['NatsWriteConsumer'].return_value.stop.assert_awaited_once()
|
||||
mocks['OpcConnectionPool'].return_value.close_all.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_skips_nats_stop_if_never_started(mocks):
|
||||
runtime = make_runtime()
|
||||
runtime.send_notification_async = AsyncMock()
|
||||
nats_mock = mocks['NatsWriteConsumer'].return_value
|
||||
nats_mock.start = AsyncMock(side_effect=Exception('boom'))
|
||||
|
||||
await runtime.start() # nats fails to start
|
||||
await runtime.stop()
|
||||
|
||||
nats_mock.stop.assert_not_called()
|
||||
mocks['OpcConnectionPool'].return_value.close_all.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_sweep_releases_idle_connections(mocks):
|
||||
runtime = make_runtime(idle_conn_max_s=42.0)
|
||||
runtime._nats_started = True # isolate from the retry path
|
||||
|
||||
await runtime.idle_sweep()
|
||||
|
||||
mocks['OpcConnectionPool'].return_value.release_idle.assert_awaited_once_with(42.0)
|
||||
Reference in New Issue
Block a user