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:
2026-10-29 08:25:01 -03:00
parent 9eaec54fc8
commit 2c331b5f16
28 changed files with 4451 additions and 1385 deletions

View File

@@ -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):

View File

@@ -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',

View File

@@ -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,
}

View File

@@ -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'}

View File

@@ -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'] == {}

View File

@@ -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

View File

@@ -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

View 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)