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:
@@ -7,8 +7,17 @@ from asyncua import ua
|
||||
import ingestor.metrics as metrics
|
||||
from ingestor.engines.write import WriteEngine
|
||||
|
||||
SERVER_CFG = {'url': 'opc.tcp://kepserver:49320', 'server_uri': 'urn:kep', 'use_security': True}
|
||||
|
||||
|
||||
def make_resolver(cfg=SERVER_CFG):
|
||||
resolver = MagicMock()
|
||||
resolver.resolve = AsyncMock(return_value=cfg)
|
||||
return resolver
|
||||
|
||||
|
||||
def make_engine(**kwargs):
|
||||
kwargs.setdefault('servers', make_resolver())
|
||||
eng = WriteEngine(
|
||||
pool=AsyncMock(),
|
||||
sink=AsyncMock(),
|
||||
@@ -257,3 +266,104 @@ async def test_no_rate_limit_when_min_pause_is_zero():
|
||||
await eng.write(base_cmd())
|
||||
await eng.write(base_cmd())
|
||||
sleep_mock.assert_not_awaited()
|
||||
|
||||
|
||||
# --- SIENTIAPDE-1975: server resolution (GW1) and error reporting (GW4) ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_uses_the_resolved_server_connection():
|
||||
"""The C2 command of the Laborious carries only the server_id."""
|
||||
eng = make_engine()
|
||||
eng.pool.get.return_value = make_client()
|
||||
|
||||
await eng.write(base_cmd())
|
||||
|
||||
eng.servers.resolve.assert_awaited_once_with('srv1', base_cmd())
|
||||
eng.pool.get.assert_awaited_once_with('srv1', SERVER_CFG)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_unknown_server_records_error_without_connecting():
|
||||
eng = make_engine(servers=make_resolver(cfg=None))
|
||||
|
||||
result = await eng.write(base_cmd())
|
||||
|
||||
assert result.status == 'error'
|
||||
assert result.opc_status_code == 'server_not_found'
|
||||
eng.pool.get.assert_not_awaited()
|
||||
eng.sink.update_write_status.assert_awaited_once_with(base_cmd(), result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_server_lookup_failure_records_error():
|
||||
resolver = make_resolver()
|
||||
resolver.resolve.side_effect = RuntimeError('mongo down')
|
||||
eng = make_engine(servers=resolver)
|
||||
|
||||
result = await eng.write(base_cmd())
|
||||
|
||||
assert result.status == 'error'
|
||||
assert result.opc_status_code == 'server_lookup'
|
||||
assert 'mongo down' in result.message
|
||||
eng.pool.get.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_without_resolver_uses_the_command_connection():
|
||||
eng = make_engine(servers=None)
|
||||
eng.pool.get.return_value = make_client()
|
||||
|
||||
await eng.write(base_cmd(url='opc.tcp://h:4840', server_uri='urn:h', use_security=True))
|
||||
|
||||
eng.pool.get.assert_awaited_once_with(
|
||||
'srv1', {'url': 'opc.tcp://h:4840', 'use_security': True, 'server_uri': 'urn:h'}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_without_resolver_or_url_is_server_not_found():
|
||||
eng = make_engine(servers=None)
|
||||
|
||||
result = await eng.write(base_cmd())
|
||||
|
||||
assert result.opc_status_code == 'server_not_found'
|
||||
eng.pool.get.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_error_logs_the_reason_and_sets_the_model_status():
|
||||
eng = make_engine()
|
||||
node = make_node()
|
||||
node.write_value.side_effect = RuntimeError('BadWriteNotSupported')
|
||||
eng.pool.get.return_value = make_client(node)
|
||||
|
||||
await eng.write(base_cmd())
|
||||
|
||||
logged = eng.logger.error.call_args.args[0]
|
||||
assert 'RuntimeError' in logged and 'BadWriteNotSupported' in logged
|
||||
assert 'addr=ns=2;i=5' in logged and 'model_id=m1' in logged
|
||||
status_call = next(
|
||||
c
|
||||
for c in eng.emit_metric.await_args_list
|
||||
if c.kwargs['metric_object'] is metrics.OPC_WRITE_STATUS_BY_MODEL
|
||||
)
|
||||
# Same series as a success, so it shows the last outcome.
|
||||
assert status_call.kwargs['value'] == 0
|
||||
assert status_call.kwargs['tags'] == {'pod_id': eng.pod_id, 'model_id': 'm1', 'status': 'ok'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('timestamp', [None, 'not-a-timestamp'])
|
||||
async def test_write_bad_timestamp_records_error_without_raising(timestamp):
|
||||
eng = make_engine(write_timestamp=True)
|
||||
cmd = base_cmd(timestamp=timestamp)
|
||||
if timestamp is None:
|
||||
del cmd['timestamp']
|
||||
|
||||
result = await eng.write(cmd)
|
||||
|
||||
assert result.status == 'error'
|
||||
assert result.opc_status_code == 'bad_timestamp'
|
||||
eng.pool.get.assert_not_awaited()
|
||||
eng.sink.update_write_status.assert_awaited_once()
|
||||
|
||||
Reference in New Issue
Block a user