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>
135 lines
5.4 KiB
Python
135 lines
5.4 KiB
Python
"""GatewayRuntime (Task 18 F4 capstone): builds and owns the write/active-read/gRPC
|
|
object graph and wires it into the app's lifecycle.
|
|
|
|
Reuses the existing ingestor's logger/notification_handler/metrics_controller and
|
|
its already-connected DataManager. The OpcConnectionPool (and its CertProvider,
|
|
MinIO + Mongo) belongs to the read path in IngestorManager (SIENTIAPDE-1974):
|
|
reads start in prepare_ingestor, before this runtime exists, and must keep running
|
|
if anything here fails. The runtime only borrows the pool for writes and gRPC.
|
|
|
|
# ponytail: scheduled_tick + gRPC slot_lookup wiring land in the next 1974 RC.
|
|
"""
|
|
|
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
|
from sientia_do.notifications.models import NotificationLevel
|
|
from sientia_do.observability.logger import Logger
|
|
from sientia_do.observability.metrics_controller import MetricsController
|
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
|
from sientia_do.temporal.activities.postgres import Postgres
|
|
from sientia_do.utils.connectors_config import build_postgres_config
|
|
|
|
from ingestor.adapters.grpc_server import GrpcServer
|
|
from ingestor.adapters.nats_consumer import NatsWriteConsumer
|
|
from ingestor.config import GatewayConfig
|
|
from ingestor.connection.pool import OpcConnectionPool
|
|
from ingestor.engines.active_read import ActiveReadEngine
|
|
from ingestor.engines.write import WriteEngine
|
|
from ingestor.io.data_sink import DataSink
|
|
from ingestor.managers.data_manager import DataManager
|
|
|
|
|
|
class GatewayRuntime(SientiaMonitoring):
|
|
"""Owns the gateway write/active-read/gRPC object graph. Resilient to NATS
|
|
being unreachable at startup: the read path (existing subscription loop,
|
|
gRPC on-demand reads) must keep working even if the write path can't
|
|
connect yet -- so a NATS connect failure is logged/notified, not raised,
|
|
and retried from `idle_sweep()` on each main-loop iteration."""
|
|
|
|
def __init__(
|
|
self,
|
|
config: GatewayConfig,
|
|
data_manager: DataManager,
|
|
pool: OpcConnectionPool,
|
|
logger: Logger,
|
|
notification_handler: NotificationHandler,
|
|
metrics_controller: MetricsController,
|
|
metadata: dict | None = None,
|
|
):
|
|
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
|
|
self.config = config
|
|
self.metadata = metadata or {}
|
|
self._nats_started = False
|
|
self._nats_notified = False # notify once per outage, not per 5s retry
|
|
|
|
self.pool = pool # owned by IngestorManager (read path); closed there
|
|
|
|
postgres = Postgres(
|
|
**build_postgres_config(),
|
|
logger=logger,
|
|
notification_handler=notification_handler,
|
|
metrics_controller=metrics_controller,
|
|
)
|
|
self.sink = DataSink(data_manager, postgres)
|
|
|
|
self.write_engine = WriteEngine(
|
|
self.pool,
|
|
self.sink,
|
|
logger,
|
|
notification_handler,
|
|
metrics_controller,
|
|
write_timestamp=config.write_timestamp,
|
|
write_quality=config.write_quality,
|
|
min_pause_s=config.write_min_pause_s,
|
|
)
|
|
self.nats_consumer = NatsWriteConsumer(
|
|
config.nats_url,
|
|
self.write_engine,
|
|
logger,
|
|
subject=config.nats_write_subject,
|
|
queue=config.nats_queue,
|
|
)
|
|
self.active_read = ActiveReadEngine(
|
|
self.pool, self.sink, logger, notification_handler, metrics_controller
|
|
)
|
|
|
|
# slot_lookup=None for now: address -> names resolution needs slot v2
|
|
# (1974); until then ReadTag responses just aren't persisted.
|
|
self.grpc_server = (
|
|
GrpcServer(self.active_read, logger, port=config.grpc_port, slot_lookup=None)
|
|
if config.grpc_enabled
|
|
else None
|
|
)
|
|
|
|
async def start(self) -> None:
|
|
await self._start_nats()
|
|
if self.grpc_server is not None:
|
|
await self.grpc_server.start()
|
|
self.logger.info(
|
|
f'GatewayRuntime started: nats_started={self._nats_started} '
|
|
f'grpc_enabled={self.grpc_server is not None}'
|
|
)
|
|
|
|
async def stop(self) -> None:
|
|
if self.grpc_server is not None:
|
|
await self.grpc_server.stop()
|
|
if self._nats_started:
|
|
await self.nats_consumer.stop()
|
|
self._nats_started = False
|
|
# The pool is not closed here: IngestorManager.shutdown() owns it and runs
|
|
# after this, once its readers have dropped their subscriptions.
|
|
|
|
async def idle_sweep(self) -> None:
|
|
await self.pool.release_idle(self.config.idle_conn_max_s)
|
|
if not self._nats_started:
|
|
await self._start_nats()
|
|
|
|
async def _start_nats(self) -> None:
|
|
if self._nats_started:
|
|
return
|
|
try:
|
|
await self.nats_consumer.start()
|
|
self._nats_started = True
|
|
self._nats_notified = False
|
|
except Exception as e:
|
|
self.logger.error(f'GatewayRuntime: failed to start NATS write consumer: {e}')
|
|
if self._nats_notified:
|
|
return
|
|
self._nats_notified = True
|
|
await self.send_notification_async(
|
|
metadata=self.metadata,
|
|
notification_id='GATEWAY_NATS_CONSUMER_START_FAILED',
|
|
message=f'Failed to start NATS write consumer: {e}',
|
|
block='gateway_runtime',
|
|
level=NotificationLevel.ERROR,
|
|
)
|