SIENTIAPDE-1974
SIENTIAPDE-1974 Write OPC tags through the tag database and drop the PI Web API path
- predictions_batch write_tags are now [{tag_id}] only: resolve_write_tags builds
the opc_output_config from connector_tags + connectors (server = connector_id,
Write / Prediction Confidence -> prediction_tags / confidence_tags, address and
data_type from the tag, float by default). The legacy server_id/type/addr write
tags are no longer read and are notified as invalid.
- A pipeline's effective updated_at is the latest of its own and of the
connector_tags it references, so renaming, moving or deactivating a tag
redeploys it (removes the rename limitation).
- The connector_tags query loads the referenced tags of every category and
status; the code filters them. aggregate_documents_in_mongodb formats only
datetime timestamp fields.
- Remove the PI Web API tag database path (connectors-pi-web-api patch, PI loads,
resolution, connection and held schedules): PI pipelines behave as on main.
- tag_id is canonical text and the workflow-side id collection never raises;
a malformed pipeline no longer fails the whole OPC slot.
- Replay test against histories recorded on the e2e stack (main and this
version), e2e scenarios for write tags and tag changes, and the runbook
script scripts/sientiapde_1974_backfill_write_tags.js.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ from pytest import fixture
|
||||
from sientia_do.notifications.models import NotificationLevel
|
||||
|
||||
from orchestrator.activities.formatters import Formatters
|
||||
from orchestrator.utils.pipeline_configs import pi_web_api_scouter, predictions_batch
|
||||
from orchestrator.utils.pipeline_configs import predictions_batch
|
||||
|
||||
|
||||
@fixture
|
||||
@@ -141,7 +141,10 @@ def test_process_schedules(formatters):
|
||||
}
|
||||
|
||||
mock_scouter.assert_called_once_with(input_data['pipelines'][0])
|
||||
mock_predictions_batch.assert_called_once_with(input_data['pipelines'][1])
|
||||
# A predictions_batch gets its opc_output_config from its (here absent) write_tags
|
||||
mock_predictions_batch.assert_called_once_with(
|
||||
{**input_data['pipelines'][1], 'opc_output_config': {}}
|
||||
)
|
||||
mock_minimal_retrain.assert_called_once_with(input_data['pipelines'][2])
|
||||
mock_drift.assert_called_once_with(input_data['pipelines'][3])
|
||||
mock_simple_metrics.assert_called_once_with(input_data['pipelines'][4])
|
||||
@@ -492,8 +495,9 @@ def test_process_slots_notifies_unresolved_tags_once(formatters):
|
||||
metadata=metadata['metadata'],
|
||||
notification_id='ORCHESTRATOR_INVALID_READ_TAG_DURING_SLOT_CONFIGURATION',
|
||||
message=(
|
||||
'Read tags sin/ns=2;s=C have no integer frequency '
|
||||
'or lack server_id, tag_address or tag_name'
|
||||
'Read tags sin/ns=2;s=C have no integer frequency, an active_read without '
|
||||
'an integer cycle_multiple, or lack server_id, tag_address or tag_name; or '
|
||||
'their pipeline lacks schedule_name, model_id or model.name'
|
||||
),
|
||||
block='orchestrator',
|
||||
level=NotificationLevel.ERROR,
|
||||
@@ -1431,224 +1435,220 @@ def test_create_schedule_config_no_chunk_period_warning_when_unchanged(formatter
|
||||
|
||||
# PI Web API through the tag database (connectors-pi-web-api)
|
||||
|
||||
PI_SECRET = {'name': 'pi-web-api-auth-token', 'key': 'token'}
|
||||
PI_CONNECTOR = {
|
||||
'connector_id': 'pi-usina',
|
||||
'name': 'PI Usina',
|
||||
'url': 'https://pi.example/piwebapi',
|
||||
'pi_web_api': {'auth': {'type': 'bearer', 'secret': PI_SECRET}, 'period': '*-2h'},
|
||||
PLANT_CONNECTOR = {
|
||||
'connector_id': 'plant',
|
||||
'name': 'plant',
|
||||
'url': 'opc.tcp://plant:4840',
|
||||
'server_uri': 'urn:plant',
|
||||
'status': 'Active',
|
||||
}
|
||||
|
||||
|
||||
def _pi_tag(tag_id, address, tag_name, category='Read', connector_id='pi-usina'):
|
||||
def _write_tag(tag_id, address, category='Write', **extra):
|
||||
return {
|
||||
'tag_id': tag_id,
|
||||
'connector_id': connector_id,
|
||||
'connector_id': 'plant',
|
||||
'address': address,
|
||||
'tag_name': tag_name,
|
||||
'tag_name': f'tag-{tag_id}',
|
||||
'category': category,
|
||||
'status': 'Active',
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _pi_pipeline(schedule_name, read_tags):
|
||||
return {
|
||||
'workflow_type': 'pi_web_api_scouter',
|
||||
'schedule_name': schedule_name,
|
||||
'model_id': 'm1',
|
||||
'model': {'name': 'model'},
|
||||
'frequency': '1m',
|
||||
'read_tags': read_tags,
|
||||
'pi_web_api_config': {'endpoint': '/streamsets/recorded', 'max_count': 5},
|
||||
'updated_at': '2021-01-01',
|
||||
}
|
||||
|
||||
|
||||
def _predictions_pipeline(schedule_name, output_config):
|
||||
def _batch_pipeline(schedule_name, write_tags, updated_at='2026-10-01 00:00:00.000+0000'):
|
||||
return {
|
||||
'workflow_type': 'predictions_batch',
|
||||
'schedule_name': schedule_name,
|
||||
'model_id': 'm2',
|
||||
'model': {'name': 'model'},
|
||||
'model_id': 'model_id',
|
||||
'model': {'name': 'model_name'},
|
||||
'query': 'SELECT 1',
|
||||
'pi_web_api_output_config': output_config,
|
||||
'updated_at': '2021-01-01',
|
||||
'write_tags': write_tags,
|
||||
'updated_at': updated_at,
|
||||
}
|
||||
|
||||
|
||||
def test_process_schedules_legacy_pi_pipelines_unchanged(formatters):
|
||||
"""Legacy PI Web API pipelines produce byte-identical schedule input"""
|
||||
scouter = _pi_pipeline('pi', {'TI-101': {'webid': 'F1DP-X', 'aggr_func': 'avg'}})
|
||||
batch = _predictions_pipeline(
|
||||
'batch', {'prediction_tags': {'PRED-101': 'F1DP-P'}, 'confidence_tags': {}}
|
||||
)
|
||||
def test_process_schedules_resolves_write_tags_through_tag_database(formatters):
|
||||
batch = _batch_pipeline('batch', [{'tag_id': 77}, {'tag_id': 78}])
|
||||
|
||||
with_tag_database = formatters.process_schedules(
|
||||
result = formatters.process_schedules(
|
||||
{
|
||||
'pipelines': [scouter, batch],
|
||||
'pi_connectors': [PI_CONNECTOR],
|
||||
'pi_connector_tags': [_pi_tag(42, 'F1DP-A', 'TI-101')],
|
||||
'pipelines': [batch],
|
||||
'connectors': [PLANT_CONNECTOR],
|
||||
'connector_tags': [
|
||||
_write_tag(77, 'ns=2;s=P1'),
|
||||
_write_tag(78, 'ns=2;s=C1', category='Prediction Confidence', data_type='int'),
|
||||
],
|
||||
**metadata,
|
||||
}
|
||||
)
|
||||
before_change = {
|
||||
'scouter': {'pi': {**pi_web_api_scouter(scouter), 'updated_at': '2021-01-01'}},
|
||||
'laborious': {'batch': {**predictions_batch(batch), 'updated_at': '2021-01-01'}},
|
||||
}
|
||||
|
||||
assert json.dumps(with_tag_database, sort_keys=True) == json.dumps(
|
||||
before_change, sort_keys=True
|
||||
)
|
||||
assert 'connection' not in with_tag_database['scouter']['pi']
|
||||
assert with_tag_database['laborious']['batch']['pi_web_api_output_config'] == {
|
||||
'prediction_tags': {'PRED-101': 'F1DP-P'},
|
||||
'confidence_tags': {},
|
||||
schedule = result['laborious']['batch']
|
||||
assert schedule['opc_output_config'] == {
|
||||
'plant': {
|
||||
'prediction_tags': {'ns=2;s=P1': {'data_type': 'float'}},
|
||||
'confidence_tags': {'ns=2;s=C1': {'data_type': 'int'}},
|
||||
}
|
||||
}
|
||||
assert 'write_tags' not in schedule
|
||||
formatters.send_notification.assert_not_called()
|
||||
|
||||
|
||||
def test_process_schedules_resolves_pi_tags_through_tag_database(formatters):
|
||||
scouter = _pi_pipeline('pi', {'TI-101': {'tag_id': 42, 'aggr_func': 'avg'}})
|
||||
batch = _predictions_pipeline('batch', {'prediction_tags': [{'tag_id': 77}]})
|
||||
def test_process_schedules_notifies_unresolved_write_tags_once(formatters):
|
||||
pipelines = [
|
||||
_batch_pipeline('a', [{'tag_id': 404}, {'tag_id': 7}, {'tag_id': 9}]),
|
||||
_batch_pipeline('b', [{'server_id': '1', 'type': 'prediction', 'addr': 'ns=2;s=L'}]),
|
||||
]
|
||||
|
||||
result = formatters.process_schedules(
|
||||
{
|
||||
'pipelines': [scouter, batch],
|
||||
'pi_connectors': [PI_CONNECTOR],
|
||||
'pi_connector_tags': [
|
||||
_pi_tag(42, 'F1DP-A', 'TI-101'),
|
||||
_pi_tag(77, 'F1DP-P', 'PRED-101', category='Write'),
|
||||
'pipelines': pipelines,
|
||||
'connectors': [PLANT_CONNECTOR],
|
||||
'connector_tags': [
|
||||
_write_tag(7, 'ns=2;s=R', category='Read'),
|
||||
{**_write_tag(9, 'ns=2;s=X'), 'connector_id': 'gone'},
|
||||
],
|
||||
**metadata,
|
||||
}
|
||||
)
|
||||
|
||||
pi = result['scouter']['pi']
|
||||
assert pi['model_tags'] == {
|
||||
'TI-101': {'webid': 'F1DP-A', 'aggr_func': 'avg', 'data_range': [-100, 100]}
|
||||
}
|
||||
assert pi['pi_web_api_query'] == {
|
||||
'endpoint': '/streamsets/recorded',
|
||||
'period': '*-2h',
|
||||
'max_count': 5,
|
||||
'api_timeout': 60,
|
||||
}
|
||||
assert pi['connection'] == {
|
||||
'connector_id': 'pi-usina',
|
||||
'base_url': 'https://pi.example/piwebapi',
|
||||
'auth': {'type': 'bearer', 'secret': PI_SECRET},
|
||||
}
|
||||
assert result['laborious']['batch']['pi_web_api_output_config'] == {
|
||||
'prediction_tags': {'PRED-101': 'F1DP-P'}
|
||||
}
|
||||
# The builder input is never the pipeline document itself
|
||||
assert 'pi_web_api_connector' not in scouter
|
||||
formatters.send_notification.assert_not_called()
|
||||
|
||||
|
||||
def test_process_schedules_secret_value_never_in_schedule_input(formatters):
|
||||
connector = {
|
||||
**PI_CONNECTOR,
|
||||
'pi_web_api': {
|
||||
'auth': {
|
||||
'type': 'basic',
|
||||
'secret': {**PI_SECRET, 'value': 'leaked-credential'},
|
||||
'password': 'leaked-credential',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
result = formatters.process_schedules(
|
||||
{
|
||||
'pipelines': [_pi_pipeline('pi', {'TI-101': {'tag_id': 42}})],
|
||||
'pi_connectors': [connector],
|
||||
'pi_connector_tags': [_pi_tag(42, 'F1DP-A', 'TI-101')],
|
||||
**metadata,
|
||||
}
|
||||
# Unresolved tags are left out; the pipelines are still scheduled
|
||||
assert result['laborious']['a']['opc_output_config'] == {}
|
||||
assert result['laborious']['b']['opc_output_config'] == {}
|
||||
formatters.send_notification.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
metadata=metadata['metadata'],
|
||||
notification_id='ORCHESTRATOR_WRITE_TAG_NOT_FOUND_DURING_SCHEDULE_CONFIGURATION',
|
||||
message=(
|
||||
'Write tags a/404, a/7 not found among the active, complete Write and '
|
||||
'Prediction Confidence tags of connector_tags'
|
||||
),
|
||||
block='orchestrator',
|
||||
level=NotificationLevel.ERROR,
|
||||
),
|
||||
call(
|
||||
metadata=metadata['metadata'],
|
||||
notification_id=(
|
||||
'ORCHESTRATOR_WRITE_TAG_CONNECTOR_NOT_FOUND_DURING_SCHEDULE_CONFIGURATION'
|
||||
),
|
||||
message='Connectors a/gone of write tags not found, not active or incomplete in connectors',
|
||||
block='orchestrator',
|
||||
level=NotificationLevel.ERROR,
|
||||
),
|
||||
call(
|
||||
metadata=metadata['metadata'],
|
||||
notification_id='ORCHESTRATOR_INVALID_WRITE_TAG_DURING_SCHEDULE_CONFIGURATION',
|
||||
message=(
|
||||
"Write tags b/{'server_id': '1', 'type': 'prediction', 'addr': 'ns=2;s=L'} "
|
||||
'have no tag_id or a data_type the OPC Gateway does not accept'
|
||||
),
|
||||
block='orchestrator',
|
||||
level=NotificationLevel.ERROR,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert result['scouter']['pi']['connection']['auth'] == {'type': 'basic', 'secret': PI_SECRET}
|
||||
assert 'leaked-credential' not in json.dumps(result)
|
||||
assert formatters.send_notification.call_count == 3
|
||||
|
||||
|
||||
def test_process_schedules_notifies_unresolved_pi_tags(formatters):
|
||||
scouter = _pi_pipeline('pi', {'TI-101': {'tag_id': 42}, 'TI-404': {'tag_id': 404}})
|
||||
batch = _predictions_pipeline('batch', {'prediction_tags': [{'tag_id': 42}]})
|
||||
def test_process_schedules_updated_at_follows_the_tag_database(formatters):
|
||||
"""A tag changed after the pipeline redeploys the pipelines that reference it"""
|
||||
batch = _batch_pipeline('batch', [{'tag_id': 77}], updated_at='2026-10-01 00:00:00.000+0000')
|
||||
scouter = {
|
||||
'workflow_type': 'scouter',
|
||||
'schedule_name': 'sin',
|
||||
'model_id': 'm1',
|
||||
'model': {'name': 'model m1'},
|
||||
'read_tags': [{'tag_id': 7, 'frequency': 1000}],
|
||||
'updated_at': '2026-10-01 00:00:00.000+0000',
|
||||
}
|
||||
untouched = _batch_pipeline('old', [{'tag_id': 78}], updated_at='2026-10-20 00:00:00.000+0000')
|
||||
|
||||
result = formatters.process_schedules(
|
||||
schedule_config = formatters.process_schedules(
|
||||
{
|
||||
'pipelines': [scouter, batch],
|
||||
'pi_connectors': [PI_CONNECTOR],
|
||||
'pi_connector_tags': [
|
||||
_pi_tag(42, 'F1DP-A', 'TI-101'),
|
||||
_pi_tag(43, 'F1DP-B', 'TI-OFF', connector_id='pi-off'),
|
||||
'pipelines': [batch, scouter, untouched],
|
||||
'connectors': [PLANT_CONNECTOR],
|
||||
'connector_tags': [
|
||||
_write_tag(77, 'ns=2;s=P2', updated_at='2026-10-10 00:00:00.000+0000'),
|
||||
# Deactivated: no longer usable, but its change still redeploys
|
||||
_write_tag(
|
||||
7,
|
||||
'ns=3;i=1001',
|
||||
category='Read',
|
||||
status='Inactive',
|
||||
updated_at='2026-10-11 00:00:00.000+0000',
|
||||
),
|
||||
_write_tag(78, 'ns=2;s=C', updated_at='2026-10-02 00:00:00.000+0000'),
|
||||
],
|
||||
**metadata,
|
||||
}
|
||||
)
|
||||
|
||||
# Still scheduled, without the unresolved tags
|
||||
assert list(result['scouter']['pi']['model_tags']) == ['TI-101']
|
||||
assert result['laborious']['batch']['pi_web_api_output_config'] == {'prediction_tags': {}}
|
||||
assert schedule_config['laborious']['batch']['updated_at'] == '2026-10-10 00:00:00.000+0000'
|
||||
assert schedule_config['scouter']['sin']['updated_at'] == '2026-10-11 00:00:00.000+0000'
|
||||
assert schedule_config['laborious']['old']['updated_at'] == '2026-10-20 00:00:00.000+0000'
|
||||
|
||||
notified = {
|
||||
c.kwargs['notification_id']: c.kwargs['message']
|
||||
for c in formatters.send_notification.call_args_list
|
||||
}
|
||||
assert set(notified) == {
|
||||
'ORCHESTRATOR_PI_TAG_NOT_FOUND_DURING_SCHEDULE_CONFIGURATION',
|
||||
'ORCHESTRATOR_PI_TAG_WRONG_CATEGORY_DURING_SCHEDULE_CONFIGURATION',
|
||||
}
|
||||
assert 'pi/404' in notified['ORCHESTRATOR_PI_TAG_NOT_FOUND_DURING_SCHEDULE_CONFIGURATION']
|
||||
assert (
|
||||
'batch/42' in notified['ORCHESTRATOR_PI_TAG_WRONG_CATEGORY_DURING_SCHEDULE_CONFIGURATION']
|
||||
)
|
||||
for c in formatters.send_notification.call_args_list:
|
||||
assert c.kwargs['level'] == NotificationLevel.ERROR
|
||||
|
||||
|
||||
def test_process_schedules_holds_mixed_connector_pipeline(formatters):
|
||||
second = {**PI_CONNECTOR, 'connector_id': 'pi-other'}
|
||||
pipeline = _pi_pipeline('pi', {'A': {'tag_id': 1}, 'B': {'tag_id': 2}})
|
||||
|
||||
result = formatters.process_schedules(
|
||||
# Last orchestrated on 2026-10-05: only the pipelines whose tags changed since update
|
||||
actions = formatters.create_schedule_config(
|
||||
{
|
||||
'pipelines': [pipeline],
|
||||
'pi_connectors': [PI_CONNECTOR, second],
|
||||
'pi_connector_tags': [
|
||||
_pi_tag(1, 'F1DP-1', 'A'),
|
||||
_pi_tag(2, 'F1DP-2', 'B', connector_id='pi-other'),
|
||||
],
|
||||
**metadata,
|
||||
}
|
||||
)
|
||||
|
||||
assert result['scouter'] == {'pi': {'held': True}}
|
||||
formatters.send_notification.assert_called_once()
|
||||
kwargs = formatters.send_notification.call_args.kwargs
|
||||
assert (
|
||||
kwargs['notification_id']
|
||||
== 'ORCHESTRATOR_PI_MIXED_CONNECTORS_DURING_SCHEDULE_CONFIGURATION'
|
||||
)
|
||||
assert 'pi/pi-other, pi-usina' in kwargs['message']
|
||||
|
||||
|
||||
def test_create_schedule_config_leaves_held_schedule_untouched(formatters):
|
||||
result = formatters.create_schedule_config(
|
||||
{
|
||||
'current_schedule_config': {'scouter': {'pi': '2021-01-01'}, 'laborious': {}},
|
||||
'schedule_config': {
|
||||
'scouter': {'pi': {'held': True}, 'new_held': {'held': True}},
|
||||
'laborious': {},
|
||||
'current_schedule_config': {
|
||||
'scouter': {'sin': '2026-10-05 00:00:00.000+0000'},
|
||||
'laborious': {
|
||||
'batch': '2026-10-05 00:00:00.000+0000',
|
||||
'old': '2026-10-21 00:00:00.000+0000',
|
||||
},
|
||||
},
|
||||
'schedule_config': schedule_config,
|
||||
**metadata,
|
||||
}
|
||||
)
|
||||
|
||||
assert result == {
|
||||
'to_update': {'scouter': {}, 'laborious': {}},
|
||||
'to_create': {'scouter': {}, 'laborious': {}},
|
||||
'to_delete': {'scouter': [], 'laborious': []},
|
||||
assert set(actions['to_update']['scouter']) == {'sin'}
|
||||
assert set(actions['to_update']['laborious']) == {'batch'}
|
||||
|
||||
|
||||
def test_process_schedules_without_updated_at_redeploys_every_run(formatters):
|
||||
batch = _batch_pipeline('batch', [{'tag_id': 77}])
|
||||
del batch['updated_at']
|
||||
|
||||
with patch(
|
||||
'orchestrator.activities.formatters.now',
|
||||
return_value=MagicMock(strftime=MagicMock(return_value='2026-10-28 12:00:00.000+0000')),
|
||||
):
|
||||
result = formatters.process_schedules(
|
||||
{
|
||||
'pipelines': [batch],
|
||||
'connectors': [PLANT_CONNECTOR],
|
||||
'connector_tags': [
|
||||
_write_tag(77, 'ns=2;s=P1', updated_at='2026-10-10 00:00:00.000+0000')
|
||||
],
|
||||
**metadata,
|
||||
}
|
||||
)
|
||||
|
||||
assert result['laborious']['batch']['updated_at'] == '2026-10-28 12:00:00.000+0000'
|
||||
|
||||
|
||||
def test_predictions_batch_schedule_matches_the_builder(formatters):
|
||||
"""process_schedules only adds the resolved opc_output_config to the builder's input"""
|
||||
batch = _batch_pipeline('batch', [{'tag_id': 77}])
|
||||
|
||||
result = formatters.process_schedules(
|
||||
{
|
||||
'pipelines': [batch],
|
||||
'connectors': [PLANT_CONNECTOR],
|
||||
'connector_tags': [_write_tag(77, 'ns=2;s=P1')],
|
||||
**metadata,
|
||||
}
|
||||
)
|
||||
|
||||
resolved = {
|
||||
**batch,
|
||||
'opc_output_config': {'plant': {'prediction_tags': {'ns=2;s=P1': {'data_type': 'float'}}}},
|
||||
}
|
||||
assert json.dumps(result['laborious']['batch'], sort_keys=True) == json.dumps(
|
||||
{**predictions_batch(resolved), 'updated_at': batch['updated_at']}, sort_keys=True
|
||||
)
|
||||
|
||||
|
||||
def test_format_schedule_config_groups_schedules_of_one_namespace(formatters):
|
||||
|
||||
@@ -192,6 +192,30 @@ def test_aggregate_documents_in_mongodb_success(mongo_db):
|
||||
)
|
||||
|
||||
|
||||
def test_aggregate_documents_in_mongodb_formats_only_datetimes(mongo_db):
|
||||
"""A timestamp field stored as text, or missing, is left as it is"""
|
||||
mongo_db.mongo_db_repository.aggregate = MagicMock(
|
||||
return_value=[
|
||||
{'tag_id': 1, 'updated_at': datetime(2026, 10, 1, 12, 0, 0)},
|
||||
{'tag_id': 2, 'updated_at': '2026-10-02 00:00:00.000+0000'},
|
||||
{'tag_id': 3},
|
||||
]
|
||||
)
|
||||
|
||||
result = mongo_db.aggregate_documents_in_mongodb(
|
||||
{
|
||||
'query': {'collection': 'connector_tags', 'aggregation': [{'$match': {}}]},
|
||||
'timestamp_fields': ['updated_at'],
|
||||
}
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{'tag_id': 1, 'updated_at': '2026-10-01 12:00:00.000000+0000'},
|
||||
{'tag_id': 2, 'updated_at': '2026-10-02 00:00:00.000+0000'},
|
||||
{'tag_id': 3},
|
||||
]
|
||||
|
||||
|
||||
def test_aggregate_documents_in_mongodb_failure(mongo_db):
|
||||
input_data = {
|
||||
'collection': 'test_collection',
|
||||
|
||||
@@ -27,38 +27,37 @@ TAGS_BY_ID = {
|
||||
}
|
||||
|
||||
|
||||
# PI Web API through the tag database (connectors-pi-web-api)
|
||||
# Write tags (predictions_batch write_tags through the tag database)
|
||||
|
||||
PI_SECRET = {'name': 'pi-web-api-auth-token', 'key': 'token'}
|
||||
OPC_CONNECTORS = {
|
||||
'plant': {
|
||||
'connector_id': 'plant',
|
||||
'name': 'plant',
|
||||
'url': 'opc.tcp://plant:4840',
|
||||
'server_uri': 'urn:plant',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def pi_connector_doc(connector_id='pi-usina', **pi_web_api):
|
||||
return {
|
||||
'connector_id': connector_id,
|
||||
'name': 'PI Usina',
|
||||
'url': f'https://{connector_id}.example/piwebapi',
|
||||
'pi_web_api': {'auth': {'type': 'bearer', 'secret': dict(PI_SECRET)}, **pi_web_api},
|
||||
}
|
||||
|
||||
|
||||
def pi_tag_doc(tag_id, address, tag_name, category='Read', connector_id='pi-usina'):
|
||||
def write_tag_doc(tag_id, address, category='Write', connector_id='plant', **extra):
|
||||
return {
|
||||
'tag_id': tag_id,
|
||||
'connector_id': connector_id,
|
||||
'address': address,
|
||||
'tag_name': tag_name,
|
||||
'tag_name': f'tag-{tag_id}',
|
||||
'category': category,
|
||||
'status': 'Active',
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def pi_scouter_pipeline_doc(read_tags, **fields):
|
||||
def batch_pipeline_doc(schedule_name, write_tags, **fields):
|
||||
return {
|
||||
'workflow_type': 'pi_web_api_scouter',
|
||||
'schedule_name': 'pi_schedule',
|
||||
'workflow_type': 'predictions_batch',
|
||||
'schedule_name': schedule_name,
|
||||
'model_id': 'model_id',
|
||||
'model': {'name': 'model_name'},
|
||||
'frequency': '1m',
|
||||
'read_tags': read_tags,
|
||||
'pi_web_api_config': {'endpoint': '/streamsets/recorded'},
|
||||
'query': 'SELECT 1',
|
||||
'write_tags': write_tags,
|
||||
**fields,
|
||||
}
|
||||
|
||||
@@ -472,3 +472,42 @@ def test_gather_read_tags_server_key_never_collides():
|
||||
assert len(set(keys)) == 3
|
||||
assert {server['server_id'] for server in slot.values()} == {'1', '2', '3'}
|
||||
assert all(len(server['tags']) == 1 for server in slot.values())
|
||||
|
||||
|
||||
def test_gather_read_tags_invalid_cycle_multiple_does_not_fail_the_slot():
|
||||
"""A bad active_read is reported as invalid instead of failing every pipeline's slot"""
|
||||
pipelines = [
|
||||
scouter_pipeline_doc(
|
||||
'sin',
|
||||
'm1',
|
||||
[
|
||||
legacy_tag('ns=2;s=A', 'a'),
|
||||
legacy_tag('ns=2;s=B', 'b', active_read={'enabled': True, 'cycle_multiple': 'x'}),
|
||||
legacy_tag('ns=2;s=C', 'c', active_read={'enabled': True, 'cycle_multiple': None}),
|
||||
# Not a dict: treated as no active read
|
||||
legacy_tag('ns=2;s=D', 'd', active_read=True),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
entries, issues = _gather(pipelines)
|
||||
|
||||
assert [e['address'] for e in entries] == ['ns=2;s=A', 'ns=2;s=D']
|
||||
assert entries[1]['active_read'] == {'enabled': False}
|
||||
assert issues['invalid'] == {'sin/ns=2;s=B', 'sin/ns=2;s=C'}
|
||||
|
||||
|
||||
def test_gather_read_tags_skips_incomplete_pipelines():
|
||||
"""A pipeline without schedule_name, model_id or model.name is reported, not raised"""
|
||||
no_model = scouter_pipeline_doc('no-model', 'm1', [legacy_tag('ns=2;s=A', 'a')])
|
||||
del no_model['model']
|
||||
no_name = scouter_pipeline_doc('no-name', 'm2', [legacy_tag('ns=2;s=B', 'b')])
|
||||
no_name['model'] = {}
|
||||
no_model_id = scouter_pipeline_doc('no-id', None, [legacy_tag('ns=2;s=C', 'c')])
|
||||
no_schedule = scouter_pipeline_doc(None, 'm4', [legacy_tag('ns=2;s=D', 'd')])
|
||||
ok = scouter_pipeline_doc('ok', 'm5', [legacy_tag('ns=2;s=E', 'e')])
|
||||
|
||||
entries, issues = _gather([no_model, no_name, no_model_id, no_schedule, ok])
|
||||
|
||||
assert [e['address'] for e in entries] == ['ns=2;s=E']
|
||||
assert issues['invalid'] == {'no-model', 'no-name', 'no-id', 'None'}
|
||||
|
||||
@@ -16,11 +16,6 @@ from orchestrator.utils.pipeline_configs import (
|
||||
scouter,
|
||||
simple_metrics,
|
||||
)
|
||||
from tests.orchestrator.utils.tag_database_fixtures import (
|
||||
PI_SECRET,
|
||||
pi_connector_doc,
|
||||
pi_scouter_pipeline_doc,
|
||||
)
|
||||
|
||||
|
||||
def test_common_config():
|
||||
@@ -362,10 +357,14 @@ def test_predictions_batch(mock_process_path_priority, mock_overlap_filter_confi
|
||||
'model_id': 'test_model_id',
|
||||
'model': {'name': 'test_model_name', 'model_config': {'test_config': 'test_config'}},
|
||||
'query': 'test_query',
|
||||
'write_tags': [
|
||||
{'server_id': 'test_server_id', 'type': 'prediction', 'addr': 'test_addr'},
|
||||
{'server_id': 'test_server_id', 'type': 'confidence', 'addr': 'test_addr'},
|
||||
],
|
||||
'write_tags': [{'tag_id': 77}, {'tag_id': 78}],
|
||||
# Resolved from write_tags by process_schedules
|
||||
'opc_output_config': {
|
||||
'test_server_id': {
|
||||
'prediction_tags': {'test_addr': {'data_type': 'float'}},
|
||||
'confidence_tags': {'test_addr': {'data_type': 'float'}},
|
||||
}
|
||||
},
|
||||
'pi_web_api_output_config': {
|
||||
'endpoint': 'test_endpoint',
|
||||
'prediction_tags': {
|
||||
@@ -685,16 +684,23 @@ def test_frequency_alert_warning(frequency_alert, warns):
|
||||
assert (frequency_alert_warning(frequency_alert) is not None) is warns
|
||||
|
||||
|
||||
def test_pi_web_api_scouter_legacy_shape():
|
||||
"""Without a connector the output is exactly the pre-tag-database one"""
|
||||
config = pi_scouter_pipeline_doc(
|
||||
{'TI-101': {'webid': 'F1DP-X'}},
|
||||
pi_web_api_config={'endpoint': '/streamsets/recorded', 'max_count': 5, 'api_timeout': 10},
|
||||
)
|
||||
def test_pi_web_api_scouter_caps_api_timeout_at_frequency_only_when_larger():
|
||||
config = {
|
||||
'workflow_type': 'pi_web_api_scouter',
|
||||
'schedule_name': 'pi_schedule',
|
||||
'model_id': 'model_id',
|
||||
'model': {'name': 'model_name'},
|
||||
'frequency': '1m',
|
||||
'read_tags': {'TI-101': {'webid': 'F1DP-X'}},
|
||||
'pi_web_api_config': {
|
||||
'endpoint': '/streamsets/recorded',
|
||||
'max_count': 5,
|
||||
'api_timeout': 10,
|
||||
},
|
||||
}
|
||||
|
||||
result = pi_web_api_scouter(config)
|
||||
|
||||
assert 'connection' not in result
|
||||
assert result['model_tags'] == {
|
||||
'TI-101': {'webid': 'F1DP-X', 'aggr_func': 'lts', 'data_range': [-100, 100]}
|
||||
}
|
||||
@@ -706,73 +712,31 @@ def test_pi_web_api_scouter_legacy_shape():
|
||||
}
|
||||
|
||||
|
||||
def test_pi_web_api_scouter_connector_defaults_with_pipeline_override():
|
||||
connector = pi_connector_doc(
|
||||
period='*-2h', max_count=10, api_timeout=600, data_server_webid='DS1'
|
||||
)
|
||||
config = pi_scouter_pipeline_doc(
|
||||
{'TI-101': {'tag_id': 42, 'webid': 'F1DP-A', 'aggr_func': 'avg'}},
|
||||
pi_web_api_config={'endpoint': '/streamsets/recorded', 'max_count': 5},
|
||||
pi_web_api_connector=connector,
|
||||
)
|
||||
|
||||
result = pi_web_api_scouter(config)
|
||||
|
||||
assert result['model_tags'] == {
|
||||
'TI-101': {'webid': 'F1DP-A', 'aggr_func': 'avg', 'data_range': [-100, 100]}
|
||||
}
|
||||
assert result['pi_web_api_query'] == {
|
||||
'endpoint': '/streamsets/recorded',
|
||||
'period': '*-2h',
|
||||
'max_count': 5,
|
||||
# The connector default is still capped at the 1m frequency
|
||||
'api_timeout': 60,
|
||||
}
|
||||
assert result['connection'] == {
|
||||
'connector_id': 'pi-usina',
|
||||
'base_url': 'https://pi-usina.example/piwebapi',
|
||||
'auth': {'type': 'bearer', 'secret': PI_SECRET},
|
||||
'data_server_webid': 'DS1',
|
||||
}
|
||||
|
||||
|
||||
def test_pi_web_api_scouter_connection_carries_only_the_secret_reference():
|
||||
connector = pi_connector_doc()
|
||||
connector['pi_web_api']['auth']['secret']['value'] = 'leaked-credential'
|
||||
connector['pi_web_api']['auth']['token'] = 'leaked-credential'
|
||||
config = pi_scouter_pipeline_doc(
|
||||
{'TI-101': {'tag_id': 42, 'webid': 'F1DP-A'}}, pi_web_api_connector=connector
|
||||
)
|
||||
|
||||
result = pi_web_api_scouter(config)
|
||||
|
||||
assert result['connection']['auth'] == {'type': 'bearer', 'secret': PI_SECRET}
|
||||
assert 'leaked-credential' not in str(result)
|
||||
|
||||
|
||||
def test_predictions_batch_groups_write_tags_by_server_and_type():
|
||||
def test_predictions_batch_copies_the_resolved_opc_output_config():
|
||||
opc_output_config = {'plant': {'prediction_tags': {'ns=2;s=P1': {'data_type': 'int'}}}}
|
||||
config = {
|
||||
'workflow_type': 'predictions_batch',
|
||||
'schedule_name': 'batch',
|
||||
'model_id': 'model_id',
|
||||
'model': {'name': 'model_name'},
|
||||
'query': 'SELECT 1',
|
||||
'write_tags': [
|
||||
{'server_id': '1', 'type': 'prediction', 'addr': 'ns=2;s=P1'},
|
||||
{'server_id': '1', 'type': 'prediction', 'addr': 'ns=2;s=P2', 'data_type': 'int'},
|
||||
# Neither prediction nor confidence: the server is known, the tag is ignored
|
||||
{'server_id': '2', 'type': 'other', 'addr': 'ns=2;s=O1'},
|
||||
],
|
||||
'write_tags': [{'tag_id': 77}],
|
||||
'opc_output_config': opc_output_config,
|
||||
}
|
||||
|
||||
result = predictions_batch(config)
|
||||
|
||||
assert result['opc_output_config'] == {
|
||||
'1': {
|
||||
'prediction_tags': {
|
||||
'ns=2;s=P1': {'data_type': 'float'},
|
||||
'ns=2;s=P2': {'data_type': 'int'},
|
||||
}
|
||||
},
|
||||
'2': {},
|
||||
assert result['opc_output_config'] == opc_output_config
|
||||
assert 'write_tags' not in result
|
||||
|
||||
|
||||
def test_predictions_batch_without_write_tags_has_no_opc_output():
|
||||
config = {
|
||||
'workflow_type': 'predictions_batch',
|
||||
'schedule_name': 'batch',
|
||||
'model_id': 'model_id',
|
||||
'model': {'name': 'model_name'},
|
||||
'query': 'SELECT 1',
|
||||
}
|
||||
|
||||
assert predictions_batch(config)['opc_output_config'] == {}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
from orchestrator.utils.tag_database import (
|
||||
WRITE_CATEGORIES,
|
||||
filter_by_tag_ids,
|
||||
index_connector_tags,
|
||||
index_connectors,
|
||||
index_pi_connectors,
|
||||
referenced_pi_tag_ids,
|
||||
index_tag_documents,
|
||||
latest_updated_at,
|
||||
referenced_tag_ids,
|
||||
resolve_pi_output_tags,
|
||||
resolve_pi_read_tags,
|
||||
resolve_tag_names,
|
||||
resolve_write_tags,
|
||||
tag_documents_updated_at,
|
||||
tag_id_text,
|
||||
)
|
||||
from tests.orchestrator.utils.tag_database_fixtures import (
|
||||
OPC_CONNECTORS,
|
||||
TAGS_BY_ID,
|
||||
batch_pipeline_doc,
|
||||
legacy_tag,
|
||||
pi_connector_doc,
|
||||
pi_scouter_pipeline_doc,
|
||||
pi_tag_doc,
|
||||
scouter_pipeline_doc,
|
||||
write_tag_doc,
|
||||
)
|
||||
|
||||
|
||||
@@ -97,195 +99,151 @@ def test_index_skips_incomplete_documents():
|
||||
assert list(index_connectors(connector_list)) == ['3']
|
||||
|
||||
|
||||
def test_index_pi_connectors_leaves_out_incomplete():
|
||||
complete = pi_connector_doc()
|
||||
no_url = {**pi_connector_doc('pi-a'), 'url': ''}
|
||||
no_auth = {**pi_connector_doc('pi-b'), 'pi_web_api': {}}
|
||||
inline_token = {
|
||||
**pi_connector_doc('pi-c'),
|
||||
'pi_web_api': {'auth': {'type': 'bearer', 'token': 'secret-value'}},
|
||||
}
|
||||
no_secret_key = {
|
||||
**pi_connector_doc('pi-d'),
|
||||
'pi_web_api': {'auth': {'type': 'basic', 'secret': {'name': 'x'}}},
|
||||
}
|
||||
|
||||
index = index_pi_connectors([complete, no_url, no_auth, inline_token, no_secret_key])
|
||||
|
||||
assert index == {'pi-usina': complete}
|
||||
def test_tag_id_text_is_canonical_for_integers():
|
||||
assert tag_id_text({'tag_id': 7}) == '7'
|
||||
assert tag_id_text({'tag_id': '007'}) == '7'
|
||||
assert tag_id_text({'tag_id': ' 12 '}) == '12'
|
||||
assert tag_id_text({'tag_id': '-3'}) == '-3'
|
||||
assert tag_id_text({'tag_id': 'abc'}) == 'abc'
|
||||
assert tag_id_text({'tag_id': ''}) is None
|
||||
assert tag_id_text({'tag_id': None}) is None
|
||||
assert tag_id_text({}) is None
|
||||
|
||||
|
||||
def test_referenced_pi_tag_ids():
|
||||
def test_referenced_tag_ids_never_raises_on_non_ascii_digits():
|
||||
"""'²'.isdigit() is True but int('²') raises: it must stay text (runs in the workflow)"""
|
||||
pipelines = [scouter_pipeline_doc('sin', 'm1', [{'tag_id': '²'}, {'tag_id': '007'}])]
|
||||
|
||||
assert referenced_tag_ids(pipelines) == [7, '7', '²']
|
||||
|
||||
|
||||
def test_referenced_tag_ids_includes_write_tags():
|
||||
pipelines = [
|
||||
pi_scouter_pipeline_doc({'TI-101': {'tag_id': 42}, 'TI-102': {'webid': 'F1DP-X'}}),
|
||||
{
|
||||
'workflow_type': 'predictions_batch',
|
||||
'pi_web_api_output_config': {
|
||||
'prediction_tags': [{'tag_id': '77'}],
|
||||
'confidence_tags': {'CONF-1': 'F1DP-C'},
|
||||
},
|
||||
},
|
||||
{
|
||||
'workflow_type': 'xgboost_predictions_batch',
|
||||
'pi_web_api_output_config': {'confidence_tags': [{'tag_id': 78}]},
|
||||
},
|
||||
# OPC scouter tag_ids belong to the OPC loads
|
||||
{'workflow_type': 'scouter', 'read_tags': [{'tag_id': 7}]},
|
||||
{'workflow_type': 'drift'},
|
||||
scouter_pipeline_doc('sin', 'm1', [{'tag_id': 7}]),
|
||||
batch_pipeline_doc('pred', [{'tag_id': 77}, {'tag_id': 78}, {'server_id': '1'}]),
|
||||
batch_pipeline_doc('xgb', [{'tag_id': 79}], workflow_type='xgboost_predictions_batch'),
|
||||
batch_pipeline_doc('retrain', [{'tag_id': 99}], workflow_type='minimal_retrain'),
|
||||
]
|
||||
|
||||
assert referenced_pi_tag_ids(pipelines) == [42, 77, 78, '42', '77', '78']
|
||||
assert referenced_tag_ids(pipelines) == [7, 77, 78, 79, '7', '77', '78', '79']
|
||||
|
||||
|
||||
def test_referenced_pi_tag_ids_none():
|
||||
assert referenced_pi_tag_ids([pi_scouter_pipeline_doc({'TI-101': {'webid': 'F1DP-X'}})]) == []
|
||||
def test_index_connector_tags_filters_status_and_category():
|
||||
tags = [
|
||||
write_tag_doc(1, 'ns=2;s=R', category='Read'),
|
||||
write_tag_doc(2, 'ns=2;s=W'),
|
||||
write_tag_doc(3, 'ns=2;s=C', category='Prediction Confidence'),
|
||||
write_tag_doc(4, 'ns=2;s=I', status='Inactive'),
|
||||
# An override that projects neither status nor category
|
||||
{'tag_id': 5, 'connector_id': 'plant', 'address': 'ns=2;s=N', 'tag_name': 'n'},
|
||||
]
|
||||
|
||||
assert list(index_connector_tags(tags)) == ['1', '5']
|
||||
assert list(index_connector_tags(tags, WRITE_CATEGORIES)) == ['2', '3', '5']
|
||||
# Every document, whatever its status or category, for the updated_at
|
||||
assert list(index_tag_documents(tags)) == ['1', '2', '3', '4', '5']
|
||||
|
||||
|
||||
def test_resolve_pi_read_tags_by_tag_id():
|
||||
connectors = index_pi_connectors([pi_connector_doc()])
|
||||
tags = index_connector_tags([pi_tag_doc(42, 'F1DP-A', 'TI-101-db')])
|
||||
def test_index_connectors_leaves_out_inactive():
|
||||
connectors = [
|
||||
{**OPC_CONNECTORS['plant'], 'status': 'Active'},
|
||||
{**OPC_CONNECTORS['plant'], 'connector_id': 'old', 'status': 'Inactive'},
|
||||
]
|
||||
|
||||
resolved, connector, issues = resolve_pi_read_tags(
|
||||
{'TI-101': {'tag_id': 42, 'aggr_func': 'avg'}}, tags, connectors
|
||||
)
|
||||
|
||||
# The key stays the model tag name; only the WebID comes from the tag database
|
||||
assert resolved == {'TI-101': {'tag_id': 42, 'aggr_func': 'avg', 'webid': 'F1DP-A'}}
|
||||
assert connector == connectors['pi-usina']
|
||||
assert not any(issues.values())
|
||||
assert list(index_connectors(connectors)) == ['plant']
|
||||
|
||||
|
||||
def test_resolve_pi_read_tags_legacy_untouched():
|
||||
legacy = {'TI-101': {'webid': 'F1DP-X'}}
|
||||
|
||||
resolved, connector, issues = resolve_pi_read_tags(legacy, {}, {})
|
||||
|
||||
assert resolved == legacy
|
||||
assert resolved['TI-101'] is legacy['TI-101']
|
||||
assert connector is None
|
||||
assert not any(issues.values())
|
||||
|
||||
|
||||
def test_resolve_pi_read_tags_issues():
|
||||
connectors = index_pi_connectors([pi_connector_doc()])
|
||||
def test_resolve_write_tags():
|
||||
tags = index_connector_tags(
|
||||
[
|
||||
pi_tag_doc(42, 'F1DP-A', 'TI-101'),
|
||||
pi_tag_doc(43, 'F1DP-W', 'PRED-1', category='Write'),
|
||||
pi_tag_doc(44, 'F1DP-O', 'TI-OFF', connector_id='pi-inactive'),
|
||||
]
|
||||
write_tag_doc(77, 'ns=2;s=P1'),
|
||||
write_tag_doc(78, 'ns=2;s=C1', category='Prediction Confidence', data_type='double'),
|
||||
write_tag_doc(79, 'ns=2;s=P2', connector_id='other', data_type='int'),
|
||||
],
|
||||
WRITE_CATEGORIES,
|
||||
)
|
||||
connectors = {**OPC_CONNECTORS, 'other': {**OPC_CONNECTORS['plant'], 'connector_id': 'other'}}
|
||||
|
||||
output_config, issues = resolve_write_tags(
|
||||
[{'tag_id': 77}, {'tag_id': '78'}, {'tag_id': 79}], tags, connectors
|
||||
)
|
||||
|
||||
resolved, connector, issues = resolve_pi_read_tags(
|
||||
{
|
||||
'TI-101': {'tag_id': 42},
|
||||
'UNKNOWN': {'tag_id': 99},
|
||||
'WRITE': {'tag_id': 43},
|
||||
'OFF': {'tag_id': 44},
|
||||
'LEGACY': {'webid': 'F1DP-X'},
|
||||
assert output_config == {
|
||||
'plant': {
|
||||
'prediction_tags': {'ns=2;s=P1': {'data_type': 'float'}},
|
||||
'confidence_tags': {'ns=2;s=C1': {'data_type': 'double'}},
|
||||
},
|
||||
tags,
|
||||
connectors,
|
||||
)
|
||||
|
||||
# Unresolvable tags are left out; the rest of the pipeline is still scheduled
|
||||
assert set(resolved) == {'TI-101', 'LEGACY'}
|
||||
assert connector == connectors['pi-usina']
|
||||
assert issues == {
|
||||
'tags': {'99'},
|
||||
'categories': {'43'},
|
||||
'connectors': {'pi-inactive'},
|
||||
'mixed': set(),
|
||||
}
|
||||
|
||||
|
||||
def test_resolve_pi_read_tags_mixed_connectors():
|
||||
connectors = index_pi_connectors([pi_connector_doc('pi-a'), pi_connector_doc('pi-b')])
|
||||
tags = index_connector_tags(
|
||||
[
|
||||
pi_tag_doc(1, 'F1DP-1', 'A', connector_id='pi-a'),
|
||||
pi_tag_doc(2, 'F1DP-2', 'B', connector_id='pi-b'),
|
||||
]
|
||||
)
|
||||
|
||||
_resolved, connector, issues = resolve_pi_read_tags(
|
||||
{'A': {'tag_id': 1}, 'B': {'tag_id': 2}}, tags, connectors
|
||||
)
|
||||
|
||||
assert connector is None
|
||||
assert issues['mixed'] == {'pi-a, pi-b'}
|
||||
|
||||
|
||||
def test_resolve_pi_output_tags_by_tag_id():
|
||||
connectors = index_pi_connectors([pi_connector_doc()])
|
||||
tags = index_connector_tags(
|
||||
[
|
||||
pi_tag_doc(77, 'F1DP-P', 'PRED-101', category='Write'),
|
||||
pi_tag_doc(78, 'F1DP-C', 'CONF-101', category='Prediction Confidence'),
|
||||
]
|
||||
)
|
||||
|
||||
resolved, issues = resolve_pi_output_tags(
|
||||
{
|
||||
'prediction_tags': [{'tag_id': 77}],
|
||||
'confidence_tags': [{'tag_id': 78}],
|
||||
'other': 'kept',
|
||||
},
|
||||
tags,
|
||||
connectors,
|
||||
)
|
||||
|
||||
assert resolved == {
|
||||
'prediction_tags': {'PRED-101': 'F1DP-P'},
|
||||
'confidence_tags': {'CONF-101': 'F1DP-C'},
|
||||
'other': 'kept',
|
||||
'other': {'prediction_tags': {'ns=2;s=P2': {'data_type': 'int'}}},
|
||||
}
|
||||
assert not any(issues.values())
|
||||
|
||||
|
||||
def test_resolve_pi_output_tags_legacy_untouched():
|
||||
legacy = {'prediction_tags': {'PRED-101': 'F1DP-P'}, 'confidence_tags': {}}
|
||||
|
||||
resolved, issues = resolve_pi_output_tags(legacy, {}, {})
|
||||
|
||||
assert resolved == legacy
|
||||
assert resolved['prediction_tags'] is legacy['prediction_tags']
|
||||
assert not any(issues.values())
|
||||
|
||||
|
||||
def test_resolve_pi_output_tags_issues():
|
||||
connectors = index_pi_connectors([pi_connector_doc()])
|
||||
def test_resolve_write_tags_issues():
|
||||
tags = index_connector_tags(
|
||||
[
|
||||
pi_tag_doc(42, 'F1DP-R', 'TI-101', category='Read'),
|
||||
pi_tag_doc(77, 'F1DP-P', 'PRED-101', category='Write'),
|
||||
write_tag_doc(1, 'ns=2;s=R', category='Read'),
|
||||
write_tag_doc(2, 'ns=2;s=X', connector_id='gone'),
|
||||
write_tag_doc(3, 'ns=2;s=B', data_type='Double'),
|
||||
write_tag_doc(4, 'ns=2;s=I', status='Inactive'),
|
||||
# No category: a write tag cannot tell prediction from confidence
|
||||
{'tag_id': 5, 'connector_id': 'plant', 'address': 'ns=2;s=N', 'tag_name': 'n'},
|
||||
write_tag_doc(6, 'ns=2;s=OK'),
|
||||
],
|
||||
WRITE_CATEGORIES,
|
||||
)
|
||||
write_tags = [
|
||||
{'tag_id': 1},
|
||||
{'tag_id': 2},
|
||||
{'tag_id': 3},
|
||||
{'tag_id': 4},
|
||||
{'tag_id': 5},
|
||||
{'tag_id': 404},
|
||||
# Legacy entries are no longer accepted
|
||||
{'server_id': '1', 'type': 'prediction', 'addr': 'ns=2;s=L'},
|
||||
{'tag_id': None},
|
||||
'ns=2;s=raw',
|
||||
{'tag_id': 6},
|
||||
]
|
||||
|
||||
output_config, issues = resolve_write_tags(write_tags, tags, OPC_CONNECTORS)
|
||||
|
||||
assert output_config == {'plant': {'prediction_tags': {'ns=2;s=OK': {'data_type': 'float'}}}}
|
||||
assert issues['tags'] == {'1', '4', '5', '404'}
|
||||
assert issues['connectors'] == {'gone'}
|
||||
assert issues['invalid'] == {
|
||||
'3',
|
||||
"{'server_id': '1', 'type': 'prediction', 'addr': 'ns=2;s=L'}",
|
||||
"{'tag_id': None}",
|
||||
'ns=2;s=raw',
|
||||
}
|
||||
|
||||
|
||||
def test_latest_updated_at():
|
||||
assert latest_updated_at(
|
||||
'2026-10-01 00:00:00.000+0000', None, '', '2026-10-02 00:00:00.000+0000'
|
||||
) == ('2026-10-02 00:00:00.000+0000')
|
||||
assert latest_updated_at(None, '') is None
|
||||
|
||||
|
||||
def test_tag_documents_updated_at():
|
||||
documents = index_tag_documents(
|
||||
[
|
||||
write_tag_doc(
|
||||
7, 'ns=2;s=R', category='Read', updated_at='2026-10-05 00:00:00.000+0000'
|
||||
),
|
||||
# Inactive documents count: deactivating a tag redeploys its pipelines
|
||||
write_tag_doc(
|
||||
77, 'ns=2;s=P', status='Inactive', updated_at='2026-10-09 00:00:00.000+0000'
|
||||
),
|
||||
write_tag_doc(78, 'ns=2;s=C', updated_at='2026-10-03 00:00:00.000+0000'),
|
||||
]
|
||||
)
|
||||
|
||||
resolved, issues = resolve_pi_output_tags(
|
||||
{
|
||||
# A Read tag as prediction, a Write tag as confidence, an unknown tag_id
|
||||
'prediction_tags': [{'tag_id': 42}, {'tag_id': 99}, {'PRED-LEGACY': 'F1DP-L'}],
|
||||
'confidence_tags': [{'tag_id': 77}],
|
||||
},
|
||||
tags,
|
||||
connectors,
|
||||
scouter_pipeline = scouter_pipeline_doc(
|
||||
'sin', 'm1', [{'tag_id': 7}, legacy_tag('ns=2;s=A', 'a')]
|
||||
)
|
||||
batch_pipeline = batch_pipeline_doc('pred', [{'tag_id': '77'}, {'tag_id': 78}, {'tag_id': 404}])
|
||||
|
||||
assert resolved['prediction_tags'] == {'PRED-LEGACY': 'F1DP-L'}
|
||||
assert resolved['confidence_tags'] == {}
|
||||
assert issues['tags'] == {'99'}
|
||||
assert issues['categories'] == {'42', '77'}
|
||||
|
||||
|
||||
def test_referenced_pi_tag_ids_ignores_malformed_read_tags():
|
||||
assert referenced_pi_tag_ids([pi_scouter_pipeline_doc([{'tag_id': 42}])]) == []
|
||||
|
||||
|
||||
def test_resolve_pi_output_tags_ignores_non_dict_entries():
|
||||
resolved, issues = resolve_pi_output_tags(
|
||||
{'prediction_tags': ['F1DP-P', None, {'PRED-101': 'F1DP-P'}]}, {}, {}
|
||||
)
|
||||
|
||||
assert resolved == {'prediction_tags': {'PRED-101': 'F1DP-P'}}
|
||||
assert not any(issues.values())
|
||||
assert tag_documents_updated_at(scouter_pipeline, documents) == '2026-10-05 00:00:00.000+0000'
|
||||
assert tag_documents_updated_at(batch_pipeline, documents) == '2026-10-09 00:00:00.000+0000'
|
||||
assert tag_documents_updated_at(batch_pipeline_doc('none', []), documents) is None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,6 @@ from orchestrator.activities.activities import Activities
|
||||
from orchestrator.workflows.orchestrator import (
|
||||
DEFAULT_CONNECTOR_TAGS_QUERY,
|
||||
DEFAULT_CONNECTORS_QUERY,
|
||||
DEFAULT_PI_CONNECTOR_TAGS_QUERY,
|
||||
DEFAULT_PI_CONNECTORS_QUERY,
|
||||
PI_WEB_API_PATCH,
|
||||
TAG_DATABASE_PATCH,
|
||||
Orchestrator,
|
||||
)
|
||||
@@ -119,10 +116,7 @@ async def test_run(workflow_mock, referenced_tag_ids_mock, orchestrator):
|
||||
]
|
||||
)
|
||||
|
||||
assert workflow_mock.patched.call_args_list == [
|
||||
call(TAG_DATABASE_PATCH),
|
||||
call(PI_WEB_API_PATCH),
|
||||
]
|
||||
assert workflow_mock.patched.call_args_list == [call(TAG_DATABASE_PATCH)]
|
||||
referenced_tag_ids_mock.assert_called_once_with(
|
||||
workflow_mock.start_local_activity_method.return_value
|
||||
)
|
||||
@@ -131,7 +125,11 @@ async def test_run(workflow_mock, referenced_tag_ids_mock, orchestrator):
|
||||
[
|
||||
call(
|
||||
Activities.aggregate_documents_in_mongodb,
|
||||
{**metadata, 'query': DEFAULT_CONNECTORS_QUERY},
|
||||
{
|
||||
**metadata,
|
||||
'query': DEFAULT_CONNECTORS_QUERY,
|
||||
'timestamp_fields': ['updated_at'],
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
),
|
||||
@@ -146,6 +144,7 @@ async def test_run(workflow_mock, referenced_tag_ids_mock, orchestrator):
|
||||
*DEFAULT_CONNECTOR_TAGS_QUERY['aggregation'],
|
||||
],
|
||||
},
|
||||
'timestamp_fields': ['updated_at'],
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
@@ -168,10 +167,8 @@ async def test_run(workflow_mock, referenced_tag_ids_mock, orchestrator):
|
||||
{
|
||||
**metadata,
|
||||
'pipelines': workflow_mock.start_local_activity_method.return_value,
|
||||
'connectors': workflow_mock.start_local_activity_method.return_value,
|
||||
'connector_tags': workflow_mock.start_local_activity_method.return_value,
|
||||
# No PI Web API tag with tag_id: nothing loaded
|
||||
'pi_connectors': [],
|
||||
'pi_connector_tags': [],
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
@@ -475,10 +472,7 @@ async def test_run_without_tag_ids_skips_tag_database(workflow_mock, _ids, orche
|
||||
|
||||
await orchestrator.run(input_data)
|
||||
|
||||
assert workflow_mock.patched.call_args_list == [
|
||||
call(TAG_DATABASE_PATCH),
|
||||
call(PI_WEB_API_PATCH),
|
||||
]
|
||||
assert workflow_mock.patched.call_args_list == [call(TAG_DATABASE_PATCH)]
|
||||
assert _tag_database_calls(workflow_mock) == []
|
||||
slots_input = _process_slots_input(workflow_mock)
|
||||
assert slots_input['connectors'] == []
|
||||
@@ -513,25 +507,20 @@ def _process_schedules_input(workflow_mock):
|
||||
)
|
||||
|
||||
|
||||
def _pi_tag_database_calls(workflow_mock):
|
||||
"""The PI Web API loads: the ones whose aggregation is not the OPC one"""
|
||||
return [
|
||||
c
|
||||
for c in _tag_database_calls(workflow_mock)
|
||||
if c.args[1]['query']['aggregation'][-1]
|
||||
in (
|
||||
DEFAULT_PI_CONNECTORS_QUERY['aggregation'][-1],
|
||||
DEFAULT_PI_CONNECTOR_TAGS_QUERY['aggregation'][-1],
|
||||
)
|
||||
]
|
||||
def test_default_connector_tags_query_loads_every_category_and_status():
|
||||
"""The status / category filters are applied in the code, so changes still redeploy"""
|
||||
stages = DEFAULT_CONNECTOR_TAGS_QUERY['aggregation']
|
||||
assert all('$match' not in stage for stage in stages)
|
||||
projected = stages[-1]['$project']
|
||||
for field in ('category', 'data_type', 'status', 'updated_at'):
|
||||
assert projected[field] == 1
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('orchestrator.workflows.orchestrator.referenced_pi_tag_ids', return_value=[42, '42'])
|
||||
@patch('orchestrator.workflows.orchestrator.referenced_tag_ids', return_value=[])
|
||||
@patch('orchestrator.workflows.orchestrator.referenced_tag_ids', return_value=[77])
|
||||
@patch('orchestrator.workflows.orchestrator.workflow', new_callable=AsyncMock)
|
||||
async def test_run_loads_pi_tag_database(workflow_mock, _ids, _pi_ids, orchestrator):
|
||||
"""A new run loads the PI connectors and tags and hands them to process_schedules only"""
|
||||
async def test_run_hands_the_tag_database_to_process_schedules(workflow_mock, _ids, orchestrator):
|
||||
"""The write tags of process_schedules need the connectors as well as the tags"""
|
||||
workflow_mock.patched = MagicMock(return_value=True)
|
||||
input_data = {
|
||||
'pipelines_query': 'SELECT * FROM bucket',
|
||||
@@ -541,97 +530,13 @@ async def test_run_loads_pi_tag_database(workflow_mock, _ids, _pi_ids, orchestra
|
||||
|
||||
await orchestrator.run(input_data)
|
||||
|
||||
assert workflow_mock.patched.call_args_list == [
|
||||
call(TAG_DATABASE_PATCH),
|
||||
call(PI_WEB_API_PATCH),
|
||||
]
|
||||
queries = [c.args[1]['query'] for c in _tag_database_calls(workflow_mock)]
|
||||
assert queries == [
|
||||
DEFAULT_PI_CONNECTORS_QUERY,
|
||||
{
|
||||
**DEFAULT_PI_CONNECTOR_TAGS_QUERY,
|
||||
'aggregation': [
|
||||
{'$match': {'tag_id': {'$in': [42, '42']}}},
|
||||
*DEFAULT_PI_CONNECTOR_TAGS_QUERY['aggregation'],
|
||||
],
|
||||
},
|
||||
]
|
||||
for c in _tag_database_calls(workflow_mock):
|
||||
assert c.args[1]['query']['aggregation'] is not DEFAULT_PI_CONNECTORS_QUERY['aggregation']
|
||||
|
||||
loaded = workflow_mock.start_local_activity_method.return_value
|
||||
schedules_input = _process_schedules_input(workflow_mock)
|
||||
assert schedules_input['pi_connectors'] is loaded
|
||||
assert schedules_input['pi_connector_tags'] is loaded
|
||||
|
||||
# process_slots input unchanged: no PI key, OPC loads skipped (no OPC tag_id)
|
||||
slots_input = _process_slots_input(workflow_mock)
|
||||
assert 'pi_connectors' not in slots_input
|
||||
assert 'pi_connector_tags' not in slots_input
|
||||
assert slots_input['connectors'] == []
|
||||
assert slots_input['connector_tags'] == []
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('orchestrator.workflows.orchestrator.referenced_pi_tag_ids', return_value=[42])
|
||||
@patch('orchestrator.workflows.orchestrator.referenced_tag_ids', return_value=[])
|
||||
@patch('orchestrator.workflows.orchestrator.workflow', new_callable=AsyncMock)
|
||||
async def test_run_with_pi_tag_database_queries_from_input(
|
||||
workflow_mock, _ids, _pi_ids, orchestrator
|
||||
):
|
||||
"""The input overrides the default PI Web API aggregations"""
|
||||
workflow_mock.patched = MagicMock(return_value=True)
|
||||
pi_connectors_query = {
|
||||
'collection': 'connectors',
|
||||
'aggregation': [{'$match': {'type': 'pi_web_api'}}],
|
||||
}
|
||||
pi_connector_tags_query = {
|
||||
'collection': 'connector_tags',
|
||||
'aggregation': [{'$match': {'status': 'Active'}}],
|
||||
}
|
||||
input_data = {
|
||||
'pipelines_query': 'SELECT * FROM bucket',
|
||||
'opc_servers_query': 'SELECT * FROM servers',
|
||||
'pi_connectors_query': pi_connectors_query,
|
||||
'pi_connector_tags_query': pi_connector_tags_query,
|
||||
'schedule_name': 'test-schedule-name',
|
||||
}
|
||||
|
||||
await orchestrator.run(input_data)
|
||||
|
||||
queries = [c.args[1]['query'] for c in _tag_database_calls(workflow_mock)]
|
||||
assert queries == [
|
||||
pi_connectors_query,
|
||||
{
|
||||
'collection': 'connector_tags',
|
||||
'aggregation': [
|
||||
{'$match': {'tag_id': {'$in': [42]}}},
|
||||
{'$match': {'status': 'Active'}},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('orchestrator.workflows.orchestrator.referenced_pi_tag_ids', return_value=[42])
|
||||
@patch('orchestrator.workflows.orchestrator.referenced_tag_ids', return_value=[7])
|
||||
@patch('orchestrator.workflows.orchestrator.workflow', new_callable=AsyncMock)
|
||||
async def test_run_replay_before_pi_web_api_patch(workflow_mock, _ids, _pi_ids, orchestrator):
|
||||
"""A run started after the tag database patch but before the PI one replays without PI loads"""
|
||||
workflow_mock.patched = MagicMock(side_effect=lambda patch_id: patch_id == TAG_DATABASE_PATCH)
|
||||
input_data = {
|
||||
'pipelines_query': 'SELECT * FROM bucket',
|
||||
'opc_servers_query': 'SELECT * FROM servers',
|
||||
'schedule_name': 'test-schedule-name',
|
||||
}
|
||||
|
||||
await orchestrator.run(input_data)
|
||||
|
||||
assert _pi_tag_database_calls(workflow_mock) == []
|
||||
assert len(_tag_database_calls(workflow_mock)) == 2
|
||||
schedules_input = _process_schedules_input(workflow_mock)
|
||||
assert schedules_input['pi_connectors'] == []
|
||||
assert schedules_input['pi_connector_tags'] == []
|
||||
assert schedules_input['connectors'] is loaded
|
||||
assert schedules_input['connector_tags'] is loaded
|
||||
assert 'pi_connectors' not in schedules_input
|
||||
for c in _tag_database_calls(workflow_mock):
|
||||
assert c.args[1]['timestamp_fields'] == ['updated_at']
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
|
||||
33
tests/orchestrator/workflows/test_orchestrator_replay.py
Normal file
33
tests/orchestrator/workflows/test_orchestrator_replay.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Replay real Orchestrator histories against the current workflow code.
|
||||
|
||||
The histories were recorded on the e2e stack (real Temporal, Mongo and Redis):
|
||||
- orchestrator_before_tag_database.json: main before SIENTIAPDE-1974 (no patch marker,
|
||||
no tag database load), as a run in flight during the deploy would replay;
|
||||
- orchestrator_tag_database.json: this version, with the SIENTIAPDE-1974-tag-database
|
||||
patch and the connectors / connector_tags loads of read and write tags by tag_id.
|
||||
|
||||
A NondeterminismError here means the change breaks runs started on those versions.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pytest import mark
|
||||
from temporalio.client import WorkflowHistory
|
||||
from temporalio.worker import Replayer
|
||||
|
||||
from orchestrator.workflows.orchestrator import Orchestrator
|
||||
|
||||
HISTORIES = Path(__file__).parent / 'histories'
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@mark.parametrize(
|
||||
'history_file', ['orchestrator_before_tag_database.json', 'orchestrator_tag_database.json']
|
||||
)
|
||||
async def test_replay_recorded_history(history_file):
|
||||
history = WorkflowHistory.from_json(
|
||||
'orchestrator-history', (HISTORIES / history_file).read_text()
|
||||
)
|
||||
|
||||
await Replayer(workflows=[Orchestrator]).replay_workflow(history)
|
||||
Reference in New Issue
Block a user