- 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>
520 lines
16 KiB
Python
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'
|