SIENTIAPDE-1974: feat(read): slot v2 reads via SubscriptionEngine, OPC cert from MinIO
Swaps the legacy OpcManager read path for the gateway's SubscriptionEngine over the shared OpcConnectionPool. The OPC client certificate now always comes from MinIO + Mongo (CertProvider, Basic256Sha256) — no more certs/ shipped in the Gitea repository, so a new version branch no longer breaks the reads. - io/slots.py: parse_slot accepts slot v2 (names[]) and converts the legacy v1 (tag_name + topics; cert_path => use_security, path dropped), so gateway and Orchestrator can be deployed in any order. - managers/server_reader.py: new per-server reader with the OpcManager interface the IngestorManager already drives (connect, create_subscription, subscribe, unsubscribe, check_cycles, check_opc_listenning, shutdown). - IngestorManager owns the read path: lazy-MinIO CertProvider, the pool and a read DataSink, built without touching the network. Slots from Redis are normalized on lease and refresh; a malformed slot is notified and the last good config is kept. - GatewayRuntime borrows the pool (writes/gRPC) instead of building it, and no longer closes it on stop: a Postgres/NATS failure can't stop the reads. - pool: pin/unpin per active subscription so release_idle never evicts a subscribed connection; public evict() for lost servers / changed config. - SubscriptionEngine pins on subscribe, unpins on unsubscribe, and drops a subscription locally even when the server-side delete fails. - DataSink: model_id only written when the name has one, keeping v1 documents identical to the legacy ones. - config: CERT_BUCKET default is the legacy streamlit-connectors bucket (D7). - Removes OpcManager, its tests and scripts/generate-and-push-opc-cert.sh; README/CLAUDE.md document the MinIO certificate and the slot v2 format. Tests: 218 unit tests pass; ruff check/format clean. Local end-to-end against an asyncua server: v1 slot with two topics -> one doc per collection, legacy doc shape, idle sweep keeps the subscription, lost-server shutdown and re-initialization work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,6 @@ from ingestor import gateway_runtime as gr
|
||||
from ingestor.config import GatewayConfig
|
||||
|
||||
_PATCHED_CLASSES = (
|
||||
'MinioRepository',
|
||||
'CertProvider',
|
||||
'OpcConnectionPool',
|
||||
'Postgres',
|
||||
'build_postgres_config',
|
||||
'DataSink',
|
||||
@@ -36,8 +33,10 @@ def mocks(monkeypatch):
|
||||
'min_connections': 5,
|
||||
'max_connections': 20,
|
||||
}
|
||||
patched['OpcConnectionPool'].return_value.close_all = AsyncMock()
|
||||
patched['OpcConnectionPool'].return_value.release_idle = AsyncMock()
|
||||
# The pool is owned by IngestorManager (read path) and only borrowed here.
|
||||
patched['pool'] = MagicMock()
|
||||
patched['pool'].close_all = AsyncMock()
|
||||
patched['pool'].release_idle = AsyncMock()
|
||||
patched['NatsWriteConsumer'].return_value.start = AsyncMock()
|
||||
patched['NatsWriteConsumer'].return_value.stop = AsyncMock()
|
||||
patched['GrpcServer'].return_value.start = AsyncMock()
|
||||
@@ -45,10 +44,13 @@ def mocks(monkeypatch):
|
||||
return patched
|
||||
|
||||
|
||||
def make_runtime(**config_overrides):
|
||||
def make_runtime(pool=None, **config_overrides):
|
||||
if pool is None:
|
||||
pool = MagicMock(close_all=AsyncMock(), release_idle=AsyncMock())
|
||||
return gr.GatewayRuntime(
|
||||
config=GatewayConfig(**config_overrides),
|
||||
data_manager=MagicMock(),
|
||||
pool=pool,
|
||||
logger=MagicMock(),
|
||||
notification_handler=MagicMock(),
|
||||
metrics_controller=MagicMock(),
|
||||
@@ -57,26 +59,26 @@ def make_runtime(**config_overrides):
|
||||
|
||||
def test_builds_object_graph_with_config_flags(mocks):
|
||||
runtime = make_runtime(
|
||||
pool=mocks['pool'],
|
||||
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()
|
||||
# Writes and active reads share the pool built by the read path.
|
||||
assert runtime.pool is mocks['pool']
|
||||
mocks['DataSink'].assert_called_once()
|
||||
|
||||
mocks['WriteEngine'].assert_called_once()
|
||||
_, kwargs = mocks['WriteEngine'].call_args
|
||||
args, kwargs = mocks['WriteEngine'].call_args
|
||||
assert args[0] is mocks['pool']
|
||||
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'
|
||||
args, _ = mocks['ActiveReadEngine'].call_args
|
||||
assert args[0] is mocks['pool']
|
||||
|
||||
mocks['GrpcServer'].assert_not_called()
|
||||
assert runtime.grpc_server is None
|
||||
@@ -137,20 +139,21 @@ async def test_nats_start_failure_does_not_raise_and_idle_sweep_retries(mocks):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_stops_started_components_and_closes_pool(mocks):
|
||||
runtime = make_runtime(grpc_enabled=True)
|
||||
async def test_stop_stops_started_components_but_not_the_pool(mocks):
|
||||
runtime = make_runtime(pool=mocks['pool'], 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()
|
||||
# IngestorManager.shutdown() closes it, after its readers unsubscribe.
|
||||
mocks['pool'].close_all.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_skips_nats_stop_if_never_started(mocks):
|
||||
runtime = make_runtime()
|
||||
runtime = make_runtime(pool=mocks['pool'])
|
||||
runtime.send_notification_async = AsyncMock()
|
||||
nats_mock = mocks['NatsWriteConsumer'].return_value
|
||||
nats_mock.start = AsyncMock(side_effect=Exception('boom'))
|
||||
@@ -159,14 +162,14 @@ async def test_stop_skips_nats_stop_if_never_started(mocks):
|
||||
await runtime.stop()
|
||||
|
||||
nats_mock.stop.assert_not_called()
|
||||
mocks['OpcConnectionPool'].return_value.close_all.assert_awaited_once()
|
||||
mocks['pool'].close_all.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_sweep_releases_idle_connections(mocks):
|
||||
runtime = make_runtime(idle_conn_max_s=42.0)
|
||||
runtime = make_runtime(pool=mocks['pool'], 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)
|
||||
mocks['pool'].release_idle.assert_awaited_once_with(42.0)
|
||||
|
||||
Reference in New Issue
Block a user