SIENTIAPDE-1975: align the gateway write path with the Laborious
- GW1: write commands (C2) carry only the server_id; ServerConfigResolver resolves the connection (command override, live pooled connection, connectors, opc-servers) with a 60 s cache and a 10 s negative cache. - GW2: write_status keeps the worst outcome (ok < error < session_bad, pending/NULL replaced), same rule as the Laborious, built with SQLAlchemy Core (no SQL formatting). - GW3: update_write_status never raises; outcome (updated, kept, row_not_found, invalid, error) logged and counted (opc_write_status_update_total); runs off the event loop. - GW4: the reason of a failed write is logged; opc_write_status_by_model shows the last outcome; a bad timestamp is a failed write, not an exception. - GW5: the NATS first connect gives up after 5 s (nats-py's defaults block the main loop for about 4 minutes while NATS is down); reconnection after it is unbounded; idle_sweep restarts a connection closed for good; stop() is safe on a closed connection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||
import ingestor.metrics as metrics
|
||||
from ingestor.connection.clock import MonotonicClock
|
||||
from ingestor.connection.pool import OpcConnectionPool
|
||||
from ingestor.connection.servers import ServerConfigResolver
|
||||
from ingestor.converters import ConversionError, to_typed_value
|
||||
from ingestor.io.data_sink import DataSink
|
||||
|
||||
@@ -77,9 +78,13 @@ class WriteEngine(SientiaMonitoring):
|
||||
write_timestamp: bool = True,
|
||||
write_quality: bool = True,
|
||||
min_pause_s: float = 0.0,
|
||||
servers: ServerConfigResolver | None = None,
|
||||
):
|
||||
self.pool = pool
|
||||
self.sink = sink
|
||||
# Resolves the server connection by server_id (SIENTIAPDE-1975 GW1). Without it,
|
||||
# only the connection carried by the command itself is used.
|
||||
self.servers = servers
|
||||
self.write_timestamp = write_timestamp
|
||||
self.write_quality = write_quality
|
||||
self.min_pause_s = min_pause_s
|
||||
@@ -105,11 +110,24 @@ class WriteEngine(SientiaMonitoring):
|
||||
except ConversionError as e:
|
||||
return await self._emit_error(cmd, 'conversion', str(e), 'error')
|
||||
|
||||
dv = self._build_datavalue(cmd, typed)
|
||||
try:
|
||||
dv = self._build_datavalue(cmd, typed)
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
# write_timestamp on with a missing or non-ISO timestamp.
|
||||
return await self._emit_error(cmd, 'bad_timestamp', repr(e), 'error')
|
||||
|
||||
try:
|
||||
server_cfg = await self._server_cfg(cmd)
|
||||
except Exception as e:
|
||||
return await self._emit_error(cmd, 'server_lookup', str(e), 'error')
|
||||
if server_cfg is None:
|
||||
return await self._emit_error(
|
||||
cmd, 'server_not_found', f'OPC server {cmd["server_id"]} is not configured', 'error'
|
||||
)
|
||||
|
||||
start = self.clock.elapsed()
|
||||
try:
|
||||
client = await self.pool.get(cmd['server_id'], self._server_cfg(cmd))
|
||||
client = await self.pool.get(cmd['server_id'], server_cfg)
|
||||
node = client.get_node(cmd['addr'])
|
||||
await node.write_value(dv)
|
||||
except Exception as e:
|
||||
@@ -127,10 +145,14 @@ class WriteEngine(SientiaMonitoring):
|
||||
now = self.clock.elapsed()
|
||||
self._last_write_at = now
|
||||
|
||||
def _server_cfg(self, cmd: dict) -> dict:
|
||||
# ponytail: minimal cfg built straight from the C2 message. Resolving
|
||||
# the full connector config (url/security) from the connectors DB by
|
||||
# server_id is F4 wiring, not this task — pool is mocked in tests.
|
||||
async def _server_cfg(self, cmd: dict) -> dict | None:
|
||||
"""Connection settings of the target server: resolved by server_id (connection
|
||||
carried by the command, live pooled connection, connectors, opc-servers), or only
|
||||
the ones carried by the command when no resolver is wired."""
|
||||
if self.servers is not None:
|
||||
return await self.servers.resolve(str(cmd['server_id']), cmd)
|
||||
if not cmd.get('url'):
|
||||
return None
|
||||
return {
|
||||
'url': cmd.get('url'),
|
||||
'use_security': cmd.get('use_security', False),
|
||||
@@ -156,6 +178,12 @@ class WriteEngine(SientiaMonitoring):
|
||||
return isinstance(exc, UaStatusCodeError) and type(exc).__name__ in _SESSION_BAD_NAMES
|
||||
|
||||
async def _emit_error(self, cmd: dict, error_type: str, message: str, status: str):
|
||||
# The reason only lives here: the row gets write_status alone (SIENTIAPDE-1975).
|
||||
self.logger.error(
|
||||
f'OPC write failed ({status}, {error_type}): server={cmd.get("server_id")} '
|
||||
f'addr={cmd.get("addr")} model_id={cmd.get("model_id")} '
|
||||
f'timestamp={cmd.get("timestamp")}: {message}'
|
||||
)
|
||||
tags = {'pod_id': self.pod_id, 'server_name': cmd.get('server_id', 'unknown')}
|
||||
await self.emit_metric(
|
||||
metric_object=metrics.OPC_ERROR_BY_TYPE, tags={**tags, 'error_type': error_type}
|
||||
@@ -164,6 +192,17 @@ class WriteEngine(SientiaMonitoring):
|
||||
metric_object=metrics.OPC_WRITE_TOTAL,
|
||||
tags={**tags, 'model_id': cmd.get('model_id', 'unknown'), 'result': 'error'},
|
||||
)
|
||||
# Same series as a success (status='ok'), so it always shows the last outcome.
|
||||
await self.emit_metric(
|
||||
metric_object=metrics.OPC_WRITE_STATUS_BY_MODEL,
|
||||
method='set',
|
||||
value=0,
|
||||
tags={
|
||||
'pod_id': self.pod_id,
|
||||
'model_id': cmd.get('model_id', 'unknown'),
|
||||
'status': 'ok',
|
||||
},
|
||||
)
|
||||
result = WriteResult(ok=False, status=status, opc_status_code=error_type, message=message)
|
||||
await self.sink.update_write_status(cmd, result)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user