Files
sientia-dataops-opc-ingestor/tests/unit/engines/test_write.py
Eduardo Rios 5318c82677 feat(SIENTIAPDE-2202): write timestamp and quality per OPC server
- write_timestamp/write_quality come from the command, then the server
  document (connectors/opc-servers, read even when the reads have the
  connection open, same 60 s cache), then the WRITE_TIMESTAMP/WRITE_QUALITY
  env defaults.
- The quality comes from the command's `quality` (the prediction status:
  Good/Bad/Uncertain, Good when absent); an unknown one is a bad_quality error.
- A server that refuses them (BadWriteNotSupported, e.g. Kepware) gets the
  value written again alone and is remembered until restart: one warning and
  opc_write_value_only_fallback_total per server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:29:33 -03:00

520 lines
16 KiB
Python

from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from asyncua import ua
from asyncua.ua.uaerrors import BadTypeMismatch, BadWriteNotSupported
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, write_options=None):
resolver = MagicMock()
resolver.resolve = AsyncMock(return_value=cfg)
resolver.write_options = AsyncMock(return_value=write_options or {})
return resolver
def make_engine(**kwargs):
kwargs.setdefault('servers', make_resolver())
eng = WriteEngine(
pool=AsyncMock(),
sink=AsyncMock(),
logger=MagicMock(),
notification_handler=MagicMock(),
metrics_controller=AsyncMock(),
**kwargs,
)
eng.emit_metric = AsyncMock()
return eng
def make_client(node=None):
node = node or make_node()
client = MagicMock()
client.get_node = MagicMock(return_value=node)
return client
def make_node():
node = MagicMock()
node.write_value = AsyncMock()
return node
def base_cmd(**overrides):
cmd = {
'server_id': 'srv1',
'addr': 'ns=2;i=5',
'value': 42.0,
'data_type': 'float',
'tag_type': 'prediction',
'model_id': 'm1',
'model_name': 'model-1',
'timestamp': '2026-07-13T12:00:00Z',
'schema': 'sientia_data',
'table_name': 'predictions_m1',
}
cmd.update(overrides)
return cmd
@pytest.mark.asyncio
async def test_write_success_writes_datavalue_and_returns_ok():
eng = make_engine()
node = make_node()
client = make_client(node)
eng.pool.get.return_value = client
result = await eng.write(base_cmd())
client.get_node.assert_called_once_with('ns=2;i=5')
node.write_value.assert_awaited_once()
dv = node.write_value.await_args.args[0]
assert isinstance(dv, ua.DataValue)
assert dv.Value.Value == 42.0
assert dv.Value.VariantType == ua.VariantType.Float
assert result.ok is True
assert result.status == 'ok'
assert result.opc_status_code == 'Good'
eng.sink.update_write_status.assert_awaited_once_with(base_cmd(), result)
@pytest.mark.asyncio
async def test_write_success_emits_metrics():
eng = make_engine()
eng.pool.get.return_value = make_client()
await eng.write(base_cmd())
eng.emit_metric.assert_any_await(
metric_object=metrics.OPC_WRITE_TOTAL,
tags={'pod_id': eng.pod_id, 'server_name': 'srv1', 'model_id': 'm1', 'result': 'success'},
)
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
)
assert status_call.kwargs['value'] == 1
assert status_call.kwargs['tags'] == {'pod_id': eng.pod_id, 'model_id': 'm1', 'status': 'ok'}
latency_call = next(
c
for c in eng.emit_metric.await_args_list
if c.kwargs['metric_object'] is metrics.OPC_WRITE_LATENCY
)
assert latency_call.kwargs['method'] == 'observe'
assert latency_call.kwargs['value'] >= 0
@pytest.mark.asyncio
async def test_write_defaults_carry_statuscode_and_timestamp():
eng = make_engine(write_timestamp=True, write_quality=True)
node = make_node()
eng.pool.get.return_value = make_client(node)
await eng.write(base_cmd())
dv = node.write_value.await_args.args[0]
assert dv.StatusCode is not None
assert dv.StatusCode.is_good()
assert dv.SourceTimestamp == datetime.fromisoformat('2026-07-13T12:00:00Z')
@pytest.mark.asyncio
async def test_write_engine_flags_off_omit_statuscode_and_timestamp():
eng = make_engine(write_timestamp=False, write_quality=False)
node = make_node()
eng.pool.get.return_value = make_client(node)
await eng.write(base_cmd())
dv = node.write_value.await_args.args[0]
assert dv.StatusCode is None
assert dv.SourceTimestamp is None
@pytest.mark.asyncio
async def test_per_message_flag_override_disables_timestamp():
# Engine default is True, but the command explicitly turns it off.
eng = make_engine(write_timestamp=True, write_quality=True)
node = make_node()
eng.pool.get.return_value = make_client(node)
await eng.write(base_cmd(write_timestamp=False))
dv = node.write_value.await_args.args[0]
assert dv.SourceTimestamp is None
assert dv.StatusCode is not None # write_quality untouched, stays on
@pytest.mark.asyncio
async def test_write_bad_type_returns_error_without_raising():
eng = make_engine()
result = await eng.write(base_cmd(value='abc', data_type='int'))
assert result.ok is False
assert result.status == 'error'
eng.sink.update_write_status.assert_awaited_once()
eng.pool.get.assert_not_awaited()
eng.emit_metric.assert_any_await(
metric_object=metrics.OPC_ERROR_BY_TYPE,
tags={'pod_id': eng.pod_id, 'server_name': 'srv1', 'error_type': 'conversion'},
)
eng.emit_metric.assert_any_await(
metric_object=metrics.OPC_WRITE_TOTAL,
tags={'pod_id': eng.pod_id, 'server_name': 'srv1', 'model_id': 'm1', 'result': 'error'},
)
@pytest.mark.asyncio
async def test_write_none_data_type_returns_error_without_raising():
eng = make_engine()
result = await eng.write(base_cmd(data_type=None))
assert result.ok is False
assert result.status == 'error'
eng.sink.update_write_status.assert_awaited_once()
eng.pool.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_write_missing_value_key_returns_error_without_raising():
eng = make_engine()
cmd = base_cmd()
del cmd['value']
result = await eng.write(cmd)
assert result.ok is False
assert result.status == 'error'
eng.sink.update_write_status.assert_awaited_once()
eng.pool.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_write_missing_data_type_key_returns_error_without_raising():
eng = make_engine()
cmd = base_cmd()
del cmd['data_type']
result = await eng.write(cmd)
assert result.ok is False
assert result.status == 'error'
eng.sink.update_write_status.assert_awaited_once()
eng.pool.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_write_value_raises_maps_to_error_result_not_raised():
eng = make_engine()
node = make_node()
node.write_value.side_effect = RuntimeError('boom')
eng.pool.get.return_value = make_client(node)
result = await eng.write(base_cmd())
assert result.ok is False
assert result.status == 'error'
assert result.opc_status_code == 'RuntimeError'
eng.sink.update_write_status.assert_awaited_once()
eng.emit_metric.assert_any_await(
metric_object=metrics.OPC_ERROR_BY_TYPE,
tags={'pod_id': eng.pod_id, 'server_name': 'srv1', 'error_type': 'RuntimeError'},
)
@pytest.mark.asyncio
async def test_write_session_bad_status_code_error_maps_to_session_bad():
from asyncua.ua.uaerrors import BadSessionClosed
eng = make_engine()
node = make_node()
node.write_value.side_effect = BadSessionClosed('closed')
eng.pool.get.return_value = make_client(node)
result = await eng.write(base_cmd())
assert result.ok is False
assert result.status == 'session_bad'
@pytest.mark.asyncio
async def test_rate_limit_sleeps_between_back_to_back_writes():
eng = make_engine(min_pause_s=0.05)
eng.pool.get.return_value = make_client()
with patch('ingestor.engines.write.asyncio.sleep', new_callable=AsyncMock) as sleep_mock:
await eng.write(base_cmd())
sleep_mock.assert_not_awaited()
await eng.write(base_cmd())
sleep_mock.assert_awaited_once()
remaining = sleep_mock.await_args.args[0]
assert 0 < remaining <= 0.05
@pytest.mark.asyncio
async def test_no_rate_limit_when_min_pause_is_zero():
eng = make_engine(min_pause_s=0.0)
eng.pool.get.return_value = make_client()
with patch('ingestor.engines.write.asyncio.sleep', new_callable=AsyncMock) as sleep_mock:
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()
# --- SIENTIAPDE-2202: timestamp/quality options, quality from the command, value-only retry ---
@pytest.mark.asyncio
async def test_server_options_override_the_gateway_defaults():
eng = make_engine(
write_timestamp=True,
write_quality=True,
servers=make_resolver(write_options={'write_timestamp': False}),
)
node = make_node()
eng.pool.get.return_value = make_client(node)
await eng.write(base_cmd())
eng.servers.write_options.assert_awaited_once_with('srv1')
dv = node.write_value.await_args.args[0]
assert dv.SourceTimestamp is None
assert dv.StatusCode is not None # not set on the server: the gateway default applies
@pytest.mark.asyncio
async def test_command_options_override_the_server_options():
eng = make_engine(
servers=make_resolver(write_options={'write_timestamp': False, 'write_quality': False})
)
node = make_node()
eng.pool.get.return_value = make_client(node)
await eng.write(base_cmd(write_timestamp=True))
dv = node.write_value.await_args.args[0]
assert dv.SourceTimestamp == datetime.fromisoformat('2026-07-13T12:00:00Z')
assert dv.StatusCode is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
('quality', 'expected'),
[
('Good', ua.StatusCodes.Good),
('Bad', ua.StatusCodes.Bad),
('uncertain', ua.StatusCodes.Uncertain),
(None, ua.StatusCodes.Good),
],
)
async def test_quality_comes_from_the_command(quality, expected):
eng = make_engine(write_quality=True)
node = make_node()
eng.pool.get.return_value = make_client(node)
result = await eng.write(base_cmd(quality=quality))
assert result.status == 'ok'
assert node.write_value.await_args.args[0].StatusCode.value == expected
@pytest.mark.asyncio
async def test_unknown_quality_records_error_without_writing():
eng = make_engine(write_quality=True)
result = await eng.write(base_cmd(quality='Excellent'))
assert result.status == 'error'
assert result.opc_status_code == 'bad_quality'
eng.pool.get.assert_not_awaited()
eng.sink.update_write_status.assert_awaited_once()
@pytest.mark.asyncio
async def test_unknown_quality_is_ignored_when_quality_is_off():
eng = make_engine(write_quality=False)
node = make_node()
eng.pool.get.return_value = make_client(node)
result = await eng.write(base_cmd(quality='Excellent'))
assert result.status == 'ok'
assert node.write_value.await_args.args[0].StatusCode is None
@pytest.mark.asyncio
async def test_refused_timestamp_and_quality_retry_with_the_value_alone():
eng = make_engine(write_timestamp=True, write_quality=True)
node = make_node()
node.write_value.side_effect = [BadWriteNotSupported(), None]
eng.pool.get.return_value = make_client(node)
result = await eng.write(base_cmd())
assert result.status == 'ok'
first, retry = (c.args[0] for c in node.write_value.await_args_list)
assert first.SourceTimestamp is not None and first.StatusCode is not None
assert retry.SourceTimestamp is None and retry.StatusCode is None
assert retry.Value.Value == 42.0
assert 'srv1' in eng.logger.warning.call_args.args[0]
eng.emit_metric.assert_any_await(
metric_object=metrics.OPC_WRITE_VALUE_ONLY_FALLBACK,
tags={'pod_id': eng.pod_id, 'server_name': 'srv1'},
)
eng.sink.update_write_status.assert_awaited_once_with(base_cmd(), result)
@pytest.mark.asyncio
async def test_server_that_refused_gets_the_value_alone_next_time():
eng = make_engine(write_timestamp=True, write_quality=True)
node = make_node()
node.write_value.side_effect = [BadWriteNotSupported(), None, None]
eng.pool.get.return_value = make_client(node)
await eng.write(base_cmd())
result = await eng.write(base_cmd())
assert result.status == 'ok'
assert node.write_value.await_count == 3 # refused + retry, then one value-only write
last = node.write_value.await_args.args[0]
assert last.SourceTimestamp is None and last.StatusCode is None
eng.servers.write_options.assert_awaited_once() # not looked up once remembered
assert eng.logger.warning.call_count == 1
@pytest.mark.asyncio
async def test_refused_value_alone_is_an_error():
eng = make_engine(write_timestamp=False, write_quality=False)
node = make_node()
node.write_value.side_effect = BadWriteNotSupported()
eng.pool.get.return_value = make_client(node)
result = await eng.write(base_cmd())
assert result.status == 'error'
assert result.opc_status_code == 'BadWriteNotSupported'
assert node.write_value.await_count == 1
@pytest.mark.asyncio
async def test_failed_value_only_retry_is_reported():
eng = make_engine(write_timestamp=True, write_quality=True)
node = make_node()
node.write_value.side_effect = [BadWriteNotSupported(), BadTypeMismatch()]
eng.pool.get.return_value = make_client(node)
result = await eng.write(base_cmd())
assert result.status == 'error'
assert result.opc_status_code == 'BadTypeMismatch'