Merge pull request #18 from Aignosi/SIENTIAPDE-1205-alterar-opc-para-assincrono

SIENTIAPDE-1205: Refactor OPC Repository for Asynchronous Handling and Configuration Updates
This commit is contained in:
Bruno Domingues
2025-08-29 14:00:56 +00:00
committed by GitHub
12 changed files with 358 additions and 191 deletions

4
.env
View File

@@ -1,4 +0,0 @@
# === Simulator Git Repo ===
# Use SSH format because the Dockerfile uses SSH to clone
SIMULATOR_GIT_REPO=git@github.com:Aignosi/sientia-dataops-opc_simulator.git
SIMULATOR_GIT_BRANCH=main

2
.gitignore vendored
View File

@@ -42,3 +42,5 @@ htmlcov/
git_key* git_key*
git_log git_log
.env

View File

@@ -44,6 +44,6 @@ class Activities(Postgres, MLFlow, Gates, OPC):
logger=logger, logger=logger,
notification_handler=notification_handler) notification_handler=notification_handler)
def shutdown(self): async def shutdown(self):
Postgres.close(self) Postgres.close(self)
OPC.shutdown(self) await OPC.shutdown(self)

View File

@@ -26,7 +26,12 @@ class OPC(BaseActivity):
self, logger, notification_handler, set_error_counter=True) self, logger, notification_handler, set_error_counter=True)
self.opc_repository: dict[str, OpcRepository] = {} self.opc_repository: dict[str, OpcRepository] = {}
for id, server in opc_servers.items(): self.opc_servers = opc_servers
async def init_opc(self):
self.logger.info("Initializing OPC servers...")
for id, server in self.opc_servers.items():
self.opc_repository[id] = OpcRepository( self.opc_repository[id] = OpcRepository(
id=server['id'], id=server['id'],
url=server['url'], url=server['url'],
@@ -39,7 +44,7 @@ class OPC(BaseActivity):
reconnection_interval=server['reconnection_interval'], reconnection_interval=server['reconnection_interval'],
pod_id=self.pod_id pod_id=self.pod_id
) )
is_connected, error_data = self.opc_repository[id].connect() is_connected, error_data = await self.opc_repository[id].connect()
if not is_connected: if not is_connected:
self.send_notification( self.send_notification(
metadata={ metadata={
@@ -55,9 +60,12 @@ class OPC(BaseActivity):
attachment_content=error_data.get( attachment_content=error_data.get(
'attachment_content', None) 'attachment_content', None)
) )
else:
self.logger.info(
f"OPC server {id} connected successfully.")
def write_data(self, server_id: str, tag: str, data: Any, async def write_data(self, server_id: str, tag: str, data: Any,
data_type: str, tag_type: str, metadata: dict[str, Any]) -> bool: data_type: str, tag_type: str, metadata: dict[str, Any]) -> bool:
""" """
Write data to OPC server. Write data to OPC server.
@@ -73,7 +81,7 @@ class OPC(BaseActivity):
""" """
try: try:
is_success, error_data = self.opc_repository[server_id].write_data( is_success, error_data = await self.opc_repository[server_id].write_data(
tag, data, data_type, self.logger, metadata) tag, data, data_type, self.logger, metadata)
if not is_success: if not is_success:
self.send_notification( self.send_notification(
@@ -113,14 +121,14 @@ class OPC(BaseActivity):
return False return False
return True return True
def manage_output_tags( async def manage_output_tags(
self, server_id: str, config: dict[str, Any], data: DataFrame, self, server_id: str, config: dict[str, Any], data: DataFrame,
metadata: dict[str, Any], success: bool) -> tuple[bool, int]: metadata: dict[str, Any], success: bool) -> tuple[bool, int]:
count = 0 count = 0
if 'prediction_tags' in config: if 'prediction_tags' in config:
for tag, tag_config in config['prediction_tags'].items(): for tag, tag_config in config['prediction_tags'].items():
local_success = self.write_data( local_success = await self.write_data(
server_id=server_id, server_id=server_id,
tag=tag, tag=tag,
data=data.head(1)['prediction'].values[0], data=data.head(1)['prediction'].values[0],
@@ -136,7 +144,7 @@ class OPC(BaseActivity):
if 'confidence_tags' in config: if 'confidence_tags' in config:
for tag, tag_config in config['confidence_tags'].items(): for tag, tag_config in config['confidence_tags'].items():
local_success = self.write_data( local_success = await self.write_data(
server_id=server_id, server_id=server_id,
tag=tag, tag=tag,
data=data.head(1)['prediction_confidence'].values[0], data=data.head(1)['prediction_confidence'].values[0],
@@ -185,12 +193,12 @@ class OPC(BaseActivity):
success = False success = False
continue continue
local_success, local_count = self.manage_output_tags( local_success, local_count = await self.manage_output_tags(
server_id, config, data, metadata, success) server_id, config, data, metadata, success)
success = success and local_success success = success and local_success
self.info( self.info(
f"Data written to OPC server {server_id}: {local_count} of {len(config['prediction_tags'])} prediction tags and {len(config['confidence_tags'])} confidence tags", metadata) f"Process completed for OPC server {server_id}: {local_count} of {len(config['prediction_tags'])} prediction tags and {len(config['confidence_tags'])} confidence tags", metadata)
return self.process_confidence(data, success, metadata) return self.process_confidence(data, success, metadata)
@@ -221,6 +229,6 @@ class OPC(BaseActivity):
return data.to_dict() return data.to_dict()
def shutdown(self): async def shutdown(self):
for opc in self.opc_repository.values(): for opc in self.opc_repository.values():
opc.disconnect() await opc.disconnect()

View File

@@ -1,9 +1,10 @@
import asyncio
import traceback import traceback
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from asyncua.sync import Client from asyncua import Client
from asyncua.crypto.security_policies import SecurityPolicyBasic256 from asyncua.crypto.security_policies import SecurityPolicyBasic256
from asyncua.ua import DataValue, Variant, VariantType, DateTime from asyncua.ua import DataValue, Variant, VariantType, DateTime
from regex import F from regex import F
@@ -62,7 +63,7 @@ class OpcRepository():
'schedule_name': '-' 'schedule_name': '-'
} }
def set_security(self): async def set_security(self):
""" """
Configures the security settings for the OPC UA client. Configures the security settings for the OPC UA client.
This method sets up the security policy, certificates, and timeouts This method sets up the security policy, certificates, and timeouts
@@ -92,7 +93,7 @@ class OpcRepository():
self.client.application_uri = self.server_uri self.client.application_uri = self.server_uri
self.logger.custom_info('Setting security...', self.metadata) self.logger.custom_info('Setting security...', self.metadata)
self.client.set_security( await self.client.set_security(
SecurityPolicyBasic256, SecurityPolicyBasic256,
certificate=str(cert), certificate=str(cert),
private_key=str(private_key), private_key=str(private_key),
@@ -101,7 +102,7 @@ class OpcRepository():
self.client.secure_channel_timeout = 10000000 self.client.secure_channel_timeout = 10000000
self.client.session_timeout = 10000000 self.client.session_timeout = 10000000
def connect(self) -> tuple[bool, dict[str, Any]]: async def connect(self) -> tuple[bool, dict[str, Any]]:
""" """
Establishes a connection to the OPC server. Establishes a connection to the OPC server.
This method initializes the OPC client using the provided URL and This method initializes the OPC client using the provided URL and
@@ -113,12 +114,12 @@ class OpcRepository():
self.client = Client(self.url) self.client = Client(self.url)
if self.cert_path: if self.cert_path:
self.set_security() await self.set_security()
self.logger.custom_info( self.logger.custom_info(
f'Starting connection to OPC server {self.id}...', self.metadata) f'Starting connection to OPC server {self.id}...', self.metadata)
return self.try_connect() return await self.try_connect()
def try_connect(self) -> tuple[bool, dict[str, Any]]: async def try_connect(self) -> tuple[bool, dict[str, Any]]:
""" """
Tries to connect to the OPC server. Tries to connect to the OPC server.
@@ -128,7 +129,7 @@ class OpcRepository():
try: try:
self.last_reconnection_time = datetime.now() self.last_reconnection_time = datetime.now()
self.client.connect() await self.client.connect()
return True, {} return True, {}
except Exception as e: except Exception as e:
trace = traceback.format_exc() trace = traceback.format_exc()
@@ -142,14 +143,14 @@ class OpcRepository():
"attachment_content": trace "attachment_content": trace
} }
def disconnect(self): async def disconnect(self):
""" """
Disconnects from the OPC server. Disconnects from the OPC server.
""" """
if self.client is None: if self.client is None:
return return
try: try:
self.client.disconnect() await self.client.disconnect()
self.logger.custom_info( self.logger.custom_info(
'Disconnected from OPC server', self.metadata) 'Disconnected from OPC server', self.metadata)
except Exception as e: except Exception as e:
@@ -157,19 +158,10 @@ class OpcRepository():
f"Failed to disconnect from OPC server: {e}", self.metadata) f"Failed to disconnect from OPC server: {e}", self.metadata)
self.client = None self.client = None
def __del__(self): async def validate_connection(self) -> tuple[bool, dict[str, Any]]:
""" """
Disconnects from the OPC server when the object is destroyed. Validates the connection to the OPC server using protocol state checking.
"""
try:
self.disconnect()
except Exception as e:
self.logger.custom_error(
f"Error in destructor: {e}", self.metadata)
def validate_connection(self) -> tuple[bool, dict[str, Any]]:
"""
Validates the connection to the OPC server.
If the connection is not established, it attempts to reconnect. If the connection is not established, it attempts to reconnect.
If the connection is established but the client is not connected, If the connection is established but the client is not connected,
it attempts to reconnect. it attempts to reconnect.
@@ -179,13 +171,13 @@ class OpcRepository():
If the client is connected, it returns True. If the client is connected, it returns True.
""" """
if self.client is None: if self.client is None:
return self.connect() return await self.connect()
if self.error_count > 5: if self.error_count > 5:
self.logger.custom_warning( self.logger.custom_warning(
f"OPC server {self.id} will be disconnected due to multiple errors", self.metadata) f"OPC server {self.id} will be disconnected due to multiple errors", self.metadata)
try: try:
self.disconnect() await self.disconnect()
except Exception as e: except Exception as e:
trace = traceback.format_exc() trace = traceback.format_exc()
self.logger.custom_error( self.logger.custom_error(
@@ -193,33 +185,42 @@ class OpcRepository():
self.logger.custom_error(trace, self.metadata) self.logger.custom_error(trace, self.metadata)
self.logger.custom_info( self.logger.custom_info(
f"Attempting to reconnect to OPC server {self.id}...", self.metadata) f"Attempting to reconnect to OPC server {self.id}...", self.metadata)
return self.connect() return await self.connect()
if hasattr(self.client, 'aio_obj') and self.client.aio_obj.uaclient.protocol is None or \ # Check if client is connected using asyncua's connection state
(hasattr(self.client.aio_obj.uaclient, 'protocol') and try:
self.client.aio_obj.uaclient.protocol.state == "closed"): if self.client.uaclient.protocol is None or self.client.uaclient.protocol.state == "closed":
# OPC server is not connected
self.logger.custom_error( self.logger.custom_error(
f"OPC server {self.id} is not connected", self.metadata) f"OPC server {self.id} is not connected", self.metadata)
if self.last_reconnection_time is None or (datetime.now() - self.last_reconnection_time).total_seconds(
if self.last_reconnection_time is None or (datetime.now() - self.last_reconnection_time).total_seconds( ) > self.reconnection_interval:
) > self.reconnection_interval: await self.disconnect()
self.disconnect() self.logger.custom_info(
self.logger.custom_info( f"Trying to reconnect to OPC server {self.id}...", self.metadata)
f"Trying to reconnect to OPC server {self.id}...", self.metadata) return await self.connect()
return self.connect()
return False, {
"notification_id": f"OPC_CONNECTION_AWAITING_RECONNECTION_WINDOW_{self.id}",
"message": f"OPC server {self.id} is not connected, waiting for next reconnection window...",
"block": "opc_repository",
"level": NotificationLevel.WARNING
}
return True, {}
except Exception as e:
trace = traceback.format_exc()
message = f"Failed to validate connection to OPC server: {e}"
self.logger.custom_error(message, self.metadata)
return False, { return False, {
"notification_id": f"OPC_CONNECTION_AWAITING_RECONNECTION_WINDOW_{self.id}", "notification_id": f"OPC_CONNECTION_CHECK_ERROR_{self.id}",
"message": f"OPC server {self.id} is not connected, waiting for next reconnection window...", "message": message,
"block": "opc_repository", "block": "opc_repository",
"level": NotificationLevel.WARNING "level": NotificationLevel.ERROR,
"attachment_content": trace
} }
return True, {} async def write_data(self, node: str, value: Any, data_type: str,
logger: Logger, metadata: dict[str, Any]) -> tuple[bool, dict[str, Any]]:
def write_data(self, node: str, value: Any, data_type: str,
logger: Logger, metadata: dict[str, Any]) -> tuple[bool, dict[str, Any]]:
""" """
Writes data to the OPC server. Writes data to the OPC server.
If the connection is not established, it attempts to reconnect. If the connection is not established, it attempts to reconnect.
@@ -231,7 +232,7 @@ class OpcRepository():
If the client is connected, it returns True. If the client is connected, it returns True.
""" """
is_connected, error = self.validate_connection() is_connected, error = await self.validate_connection()
if not is_connected: if not is_connected:
return False, error return False, error
@@ -239,7 +240,7 @@ class OpcRepository():
start_time = time.time() start_time = time.time()
try: try:
node = self.client.get_node(node) node_obj = self.client.get_node(node)
except Exception as e: except Exception as e:
trace = traceback.format_exc() trace = traceback.format_exc()
logger.custom_error(trace, metadata.get('schedule_name', 'N/A')) logger.custom_error(trace, metadata.get('schedule_name', 'N/A'))
@@ -278,7 +279,7 @@ class OpcRepository():
) )
try: try:
node.write_value(ua_data) await node_obj.write_value(ua_data)
metrics.PREDICTION_OPC_WRITING_COUNT.labels( metrics.PREDICTION_OPC_WRITING_COUNT.labels(
pod_id=self.pod_id, pod_id=self.pod_id,

View File

@@ -64,6 +64,9 @@ async def main():
notification_handler=notification_handler notification_handler=notification_handler
) )
logger.custom_info('Initializing OPC...', metadata)
await activities.init_opc()
logger.custom_info( logger.custom_info(
f'Starting SDK Metrics Server on port {SDK_METRICS_PORT}...', metadata) f'Starting SDK Metrics Server on port {SDK_METRICS_PORT}...', metadata)
@@ -74,7 +77,7 @@ async def main():
) )
) )
logger.custom_info('Starting Temporal Client...', metadata) logger.custom_info(f'Starting Temporal Client at {host}...', metadata)
temporal_client = await client.Client.connect( temporal_client = await client.Client.connect(
target_host=host, target_host=host,
@@ -151,7 +154,7 @@ async def main():
if notification_handler: if notification_handler:
notification_handler.shutdown() notification_handler.shutdown()
if activities: if activities:
activities.shutdown() await activities.shutdown()
# Exit with a non-zero status code to indicate failure to Kubernetes # Exit with a non-zero status code to indicate failure to Kubernetes
metrics.APP_UP.labels(pod_id=POD_ID).set(0) # Mark app as DOWN metrics.APP_UP.labels(pod_id=POD_ID).set(0) # Mark app as DOWN
sys.exit(1) sys.exit(1)

11
run_coverage.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# Exit on any error
set -e
echo "Activating virtual environment..."
source ./venv/bin/activate
pytest --cov=laborious --cov-report=html
xdg-open htmlcov/index.html

18
run_local.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# Exit on any error
set -e
echo "Activating virtual environment..."
source ./venv/bin/activate
echo "Loading environment variables from .env..."
if [ -f .env ]; then
export $(cat .env | grep -v '^#' | xargs)
echo "Environment variables loaded from .env"
else
echo "Warning: .env file not found. Continuing without environment variables."
fi
echo "Starting ingestor application..."
python -m laborious.worker.worker

View File

@@ -90,11 +90,12 @@ def test___init__(mock_gates_init, mock_opc_init, mock_mlflow_init, mock_postgre
) )
@mark.asyncio
@patch('laborious.activities.activities.Postgres', return_value=MagicMock()) @patch('laborious.activities.activities.Postgres', return_value=MagicMock())
@patch('laborious.activities.activities.MLFlow', return_value=MagicMock()) @patch('laborious.activities.activities.MLFlow', return_value=MagicMock())
@patch('laborious.activities.activities.OPC', return_value=MagicMock()) @patch('laborious.activities.activities.OPC', return_value=MagicMock())
def test_shutdown(mock_opc_init, async def test_shutdown(mock_opc_init,
_mock_mlflow_init, mock_postgres_init): _mock_mlflow_init, mock_postgres_init):
postgres_config = { postgres_config = {
'host': 'localhost', 'host': 'localhost',
'port': 5432, 'port': 5432,
@@ -129,6 +130,6 @@ def test_shutdown(mock_opc_init,
notification_handler=notification_handler notification_handler=notification_handler
) )
activities.shutdown() await activities.shutdown()
mock_opc_init.shutdown.assert_called_once() mock_opc_init.shutdown.assert_called_once()
mock_postgres_init.close.assert_called_once() mock_postgres_init.close.assert_called_once()

View File

@@ -1,7 +1,8 @@
from unittest.mock import patch, MagicMock, ANY, call from unittest.mock import patch, MagicMock, ANY, call, AsyncMock
from pandas import DataFrame from pandas import DataFrame
from pytest import fixture, mark from pytest import fixture, mark
from laborious.activities.opc import NotificationLevel import pytest_asyncio
from sientia_do.notifications.models import NotificationLevel
from laborious.activities.opc import OPC from laborious.activities.opc import OPC
@@ -15,27 +16,42 @@ metadata = {
} }
def test__init__():
servers = {
'server1': 'config'
}
opc = OPC(
opc_servers=servers,
logger=MagicMock(),
notification_handler=MagicMock()
)
assert opc.opc_servers == servers
assert opc.opc_repository == {}
@mark.asyncio
@patch("laborious.activities.opc.OpcRepository") @patch("laborious.activities.opc.OpcRepository")
@patch("laborious.activities.opc.OPC.send_notification") @patch("laborious.activities.opc.OPC.send_notification")
def test___init__(mock_send_notification, mock_opc_repository): async def test_init_opc(mock_send_notification, mock_opc_repository):
mock_logger = MagicMock() mock_logger = MagicMock()
server1 = MagicMock( server1 = MagicMock(
connect=MagicMock(return_value=(True, {})), connect=AsyncMock(return_value=(True, {})),
write_data=MagicMock(return_value=(True, {})) write_data=AsyncMock(return_value=(True, {}))
) )
server2 = MagicMock( server2 = MagicMock(
connect=MagicMock(return_value=(True, {})), connect=AsyncMock(return_value=(True, {})),
write_data=MagicMock(return_value=(True, {})) write_data=AsyncMock(return_value=(True, {}))
) )
server3 = MagicMock( server3 = MagicMock(
connect=MagicMock(return_value=(False, { connect=AsyncMock(return_value=(False, {
'notification_id': 'OPC_CONNECTION_ERROR_server3', 'notification_id': 'OPC_CONNECTION_ERROR_server3',
'message': 'Failed to connect to OPC server: Test error', 'message': 'Failed to connect to OPC server: Test error',
'block': 'opc_repository', 'block': 'opc_repository',
'level': NotificationLevel.ERROR, 'level': NotificationLevel.ERROR,
'attachment_content': 'Test error' 'attachment_content': 'Test error'
})), })),
write_data=MagicMock(return_value=(True, {})) write_data=AsyncMock(return_value=(True, {}))
) )
mock_opc_repository.side_effect = [server1, server2, server3] mock_opc_repository.side_effect = [server1, server2, server3]
mock_notification_handler = MagicMock() mock_notification_handler = MagicMock()
@@ -73,6 +89,7 @@ def test___init__(mock_send_notification, mock_opc_repository):
logger=mock_logger, logger=mock_logger,
notification_handler=mock_notification_handler notification_handler=mock_notification_handler
) )
await opc.init_opc()
assert opc.opc_servers == servers assert opc.opc_servers == servers
assert opc.logger == mock_logger assert opc.logger == mock_logger
@@ -129,9 +146,9 @@ def test___init__(mock_send_notification, mock_opc_repository):
]) ])
@fixture @pytest_asyncio.fixture
@patch("laborious.activities.opc.OpcRepository") @patch("laborious.activities.opc.OpcRepository")
def opc(mock_opc_repository): async def opc(mock_opc_repository):
servers = { servers = {
'server1': { 'server1': {
'id': 'server1', 'id': 'server1',
@@ -144,10 +161,10 @@ def opc(mock_opc_repository):
} }
} }
mock_opc_repository.return_value.write_data = MagicMock( mock_opc_repository.return_value.write_data = AsyncMock(
return_value=(True, {}) return_value=(True, {})
) )
mock_opc_repository.return_value.connect = MagicMock( mock_opc_repository.return_value.connect = AsyncMock(
return_value=(True, {}) return_value=(True, {})
) )
opc = OPC( opc = OPC(
@@ -155,7 +172,7 @@ def opc(mock_opc_repository):
logger=MagicMock(), logger=MagicMock(),
notification_handler=MagicMock() notification_handler=MagicMock()
) )
await opc.init_opc()
opc.send_notification = MagicMock() opc.send_notification = MagicMock()
return opc return opc
@@ -169,14 +186,17 @@ WRITE_DATA_CASES = [
@mark.parametrize('tag,data_type,data', WRITE_DATA_CASES) @mark.parametrize('tag,data_type,data', WRITE_DATA_CASES)
def test_write_data_success(opc, tag, data_type, data): @mark.asyncio
assert opc.write_data(server_id='server1', tag=tag, data=data, async def test_write_data_success(opc, tag, data_type, data):
data_type=data_type, tag_type='prediction', metadata=metadata) result = await opc.write_data(server_id='server1', tag=tag, data=data,
data_type=data_type, tag_type='prediction', metadata=metadata)
assert result is True
opc.opc_repository['server1'].write_data.assert_called_once_with( opc.opc_repository['server1'].write_data.assert_called_once_with(
tag, data, data_type, opc.logger, metadata) tag, data, data_type, opc.logger, metadata)
def test_write_data_failed(opc): @mark.asyncio
async def test_write_data_failed(opc):
opc.opc_repository['server1'].write_data.return_value = (False, { opc.opc_repository['server1'].write_data.return_value = (False, {
'notification_id': 'OPC_WRITE_DATA_ERROR_server1', 'notification_id': 'OPC_WRITE_DATA_ERROR_server1',
'message': 'Failed to write data to OPC server: Test error', 'message': 'Failed to write data to OPC server: Test error',
@@ -185,8 +205,9 @@ def test_write_data_failed(opc):
'attachment_content': 'Test error' 'attachment_content': 'Test error'
}) })
assert opc.write_data(server_id='server1', tag='tag1', data=50, result = await opc.write_data(server_id='server1', tag='tag1', data=50,
data_type='int', tag_type='prediction', metadata=metadata) is False data_type='int', tag_type='prediction', metadata=metadata)
assert result is False
opc.send_notification.assert_called_once_with( opc.send_notification.assert_called_once_with(
metadata=metadata, metadata=metadata,
@@ -198,13 +219,14 @@ def test_write_data_failed(opc):
) )
def test_write_data_exception(opc): @mark.asyncio
async def test_write_data_exception(opc):
opc.opc_repository['server1'].write_data.side_effect = Exception( opc.opc_repository['server1'].write_data.side_effect = Exception(
"Test error") "Test error")
try: try:
opc.write_data(server_id='server1', tag='tag1', data=50, await opc.write_data(server_id='server1', tag='tag1', data=50,
data_type='int', tag_type='prediction', metadata=metadata) data_type='int', tag_type='prediction', metadata=metadata)
except Exception: except Exception:
opc.send_notification.assert_called_once_with( opc.send_notification.assert_called_once_with(
@@ -242,7 +264,7 @@ async def test_write_opc_data_success(opc):
} }
# Act # Act
opc.write_data = MagicMock() opc.write_data = AsyncMock(return_value=True)
opc.process_confidence = MagicMock(return_value={'data': 'data'}) opc.process_confidence = MagicMock(return_value={'data': 'data'})
output = await opc.write_opc_data(input_data) output = await opc.write_opc_data(input_data)
@@ -281,8 +303,38 @@ async def test_write_opc_data_empty_config(opc):
}, },
'opc_servers': ['server1'], 'opc_servers': ['server1'],
'opc_output_config': { 'opc_output_config': {
'prediction_tags': {}, 'server1': {
'confidence_tags': {} 'prediction_tags': {},
'confidence_tags': {}
}
}
}
# Act
await opc.write_opc_data(input_data)
# Assert
opc.opc_repository['server1'].write_data.assert_not_called()
@mark.asyncio
async def test_write_opc_data_no_validate_server(opc):
opc.validate_server = MagicMock(return_value=False)
input_data = {
**metadata,
'data': {
'prediction': [0.75],
'prediction_confidence': [0.95]
},
'opc_output_config': {
'server1': {
'prediction_tags': {
'tag1': {'data_type': 'float'}
},
'confidence_tags': {
'tag2': {'data_type': 'float'}
}
}
} }
} }
@@ -305,6 +357,13 @@ def test_process_confidence(opc, data, success, expected):
assert result['prediction_confidence'][0] == expected assert result['prediction_confidence'][0] == expected
def test_shutdown(opc): def test_validate_server(opc):
opc.shutdown() assert opc.validate_server('server1', metadata) is True
assert opc.validate_server('server2', metadata) is False
@mark.asyncio
async def test_shutdown(opc):
opc.opc_repository['server1'].disconnect = AsyncMock(return_value=True)
await opc.shutdown()
opc.opc_repository['server1'].disconnect.assert_called_once() opc.opc_repository['server1'].disconnect.assert_called_once()

View File

@@ -1,17 +1,17 @@
from unittest.mock import Mock, patch, MagicMock, ANY, call import pytest
from unittest.mock import AsyncMock, Mock, patch, MagicMock, ANY, call
from asyncua.crypto.security_policies import SecurityPolicyBasic256 from asyncua.crypto.security_policies import SecurityPolicyBasic256
from pytest import fixture
from laborious.utils.repository.opc_repository import OpcRepository from laborious.utils.repository.opc_repository import OpcRepository
from sientia_do.notifications.models import NotificationLevel from sientia_do.notifications.models import NotificationLevel
from datetime import datetime from datetime import datetime
@fixture @pytest.fixture
def mock_logger(): def mock_logger():
return Mock() return Mock()
@fixture @pytest.fixture
def opc_repository(mock_logger): def opc_repository(mock_logger):
return OpcRepository( return OpcRepository(
id="test_repo", id="test_repo",
@@ -26,10 +26,10 @@ def opc_repository(mock_logger):
) )
@fixture @pytest.fixture
def mock_client(): def mock_client():
with patch('laborious.utils.repository.opc_repository.Client') as mock: with patch('laborious.utils.repository.opc_repository.Client') as mock:
client_instance = MagicMock() client_instance = AsyncMock()
mock.return_value = client_instance mock.return_value = client_instance
yield client_instance yield client_instance
@@ -57,9 +57,10 @@ def test_init(opc_repository):
assert opc_repository.error_count == 0 assert opc_repository.error_count == 0
def test_set_security(opc_repository, mock_client): @pytest.mark.asyncio
async def test_set_security(opc_repository, mock_client):
opc_repository.client = mock_client opc_repository.client = mock_client
opc_repository.set_security() await opc_repository.set_security()
mock_client.application_uri = "urn:test:server" mock_client.application_uri = "urn:test:server"
mock_client.set_security.assert_called_once_with( mock_client.set_security.assert_called_once_with(
@@ -72,50 +73,59 @@ def test_set_security(opc_repository, mock_client):
assert mock_client.session_timeout == 10000000 assert mock_client.session_timeout == 10000000
def test_set_security_missing_certificates(opc_repository): @pytest.mark.asyncio
async def test_set_security_missing_certificates(opc_repository):
opc_repository.cert_path = None opc_repository.cert_path = None
opc_repository.private_key_path = None opc_repository.private_key_path = None
try: try:
opc_repository.set_security() await opc_repository.set_security()
except ValueError as e: except ValueError as e:
assert str( assert str(
e) == "Certificate and private key paths must be provided for secure connection." e) == "Certificate and private key paths must be provided for secure connection."
def test_connect_with_security(opc_repository, mock_client): @pytest.mark.asyncio
opc_repository.try_connect = MagicMock() async def test_connect_with_security(opc_repository, mock_client):
opc_repository.connect() opc_repository.try_connect = AsyncMock(return_value=(True, {}))
result = await opc_repository.connect()
opc_repository.try_connect.assert_called_once() opc_repository.try_connect.assert_called_once()
assert opc_repository.client == mock_client assert opc_repository.client == mock_client
assert result == (True, {})
def test_connect_without_security(opc_repository, mock_client): @pytest.mark.asyncio
async def test_connect_without_security(opc_repository, mock_client):
opc_repository.cert_path = None opc_repository.cert_path = None
opc_repository.try_connect = MagicMock() opc_repository.try_connect = AsyncMock(return_value=(True, {}))
opc_repository.set_security = MagicMock() opc_repository.set_security = AsyncMock()
opc_repository.connect() result = await opc_repository.connect()
opc_repository.try_connect.assert_called_once() opc_repository.try_connect.assert_called_once()
opc_repository.set_security.assert_not_called() opc_repository.set_security.assert_not_called()
assert opc_repository.client == mock_client assert opc_repository.client == mock_client
assert result == (True, {})
def test_try_connect_sucess(opc_repository): @pytest.mark.asyncio
async def test_try_connect_success(opc_repository):
opc_repository.last_reconnection_time = None opc_repository.last_reconnection_time = None
opc_repository.client = MagicMock() opc_repository.client = AsyncMock()
opc_repository.try_connect() result = await opc_repository.try_connect()
opc_repository.client.connect.assert_called_once() opc_repository.client.connect.assert_called_once()
assert opc_repository.last_reconnection_time is not None assert opc_repository.last_reconnection_time is not None
assert result == (True, {})
def test_try_connect_fail(opc_repository): @pytest.mark.asyncio
async def test_try_connect_fail(opc_repository):
opc_repository.last_reconnection_time = None opc_repository.last_reconnection_time = None
opc_repository.client = MagicMock() opc_repository.client = MagicMock()
opc_repository.client.connect.side_effect = Exception("Test error") opc_repository.client.connect.side_effect = Exception("Test error")
is_connected, error_data = opc_repository.try_connect() is_connected, error_data = await opc_repository.try_connect()
opc_repository.client.connect.assert_called_once() opc_repository.client.connect.assert_called_once()
assert is_connected is False assert is_connected is False
@@ -126,18 +136,26 @@ def test_try_connect_fail(opc_repository):
assert error_data['attachment_content'] is not None assert error_data['attachment_content'] is not None
def test_disconnect(opc_repository, mock_client): @pytest.mark.asyncio
async def test_disconnect(opc_repository, mock_client):
opc_repository.client = mock_client opc_repository.client = mock_client
opc_repository.disconnect() await opc_repository.disconnect()
mock_client.disconnect.assert_called_once() mock_client.disconnect.assert_called_once()
assert opc_repository.client is None assert opc_repository.client is None
def test_disconnect_error(opc_repository, mock_client): @pytest.mark.asyncio
async def test_disconnect_no_client(opc_repository):
opc_repository.client = None
assert await opc_repository.disconnect() is None
@pytest.mark.asyncio
async def test_disconnect_error(opc_repository, mock_client):
opc_repository.client = mock_client opc_repository.client = mock_client
mock_client.disconnect.side_effect = Exception("Test error") mock_client.disconnect.side_effect = Exception("Test error")
opc_repository.disconnect() await opc_repository.disconnect()
opc_repository.logger.custom_error.assert_called_once_with( opc_repository.logger.custom_error.assert_called_once_with(
"Failed to disconnect from OPC server: Test error", "Failed to disconnect from OPC server: Test error",
@@ -146,21 +164,25 @@ def test_disconnect_error(opc_repository, mock_client):
assert opc_repository.client is None assert opc_repository.client is None
def test_validate_connection_none_client(opc_repository): @pytest.mark.asyncio
async def test_validate_connection_none_client(opc_repository):
opc_repository.client = None opc_repository.client = None
opc_repository.connect = MagicMock() opc_repository.connect = AsyncMock(return_value=(True, {}))
response = opc_repository.validate_connection() response = await opc_repository.validate_connection()
assert response assert response == (True, {})
opc_repository.connect.assert_called_once() opc_repository.connect.assert_called_once()
def test_validate_connection_error_count_disconnect_error(opc_repository): @pytest.mark.asyncio
async def test_validate_connection_error_count_disconnect_error(opc_repository):
opc_repository.error_count = 6 opc_repository.error_count = 6
opc_repository.client = MagicMock() opc_repository.client = AsyncMock()
opc_repository.disconnect = MagicMock(side_effect=Exception("Test error")) opc_repository.disconnect = AsyncMock(
opc_repository.connect = MagicMock() side_effect=Exception("Test error")
)
opc_repository.connect = AsyncMock(return_value=(True, {}))
response = opc_repository.validate_connection() response = await opc_repository.validate_connection()
assert response == opc_repository.connect.return_value assert response == opc_repository.connect.return_value
opc_repository.disconnect.assert_called_once() opc_repository.disconnect.assert_called_once()
opc_repository.connect.assert_called_once() opc_repository.connect.assert_called_once()
@@ -171,18 +193,37 @@ def test_validate_connection_error_count_disconnect_error(opc_repository):
) )
@patch('laborious.utils.repository.opc_repository.hasattr', return_value=True) @pytest.mark.asyncio
@patch('laborious.utils.repository.opc_repository.datetime', async def test_validate_connection_error_validate_connection_error(opc_repository):
MagicMock(now=MagicMock(return_value=datetime(2025, 1, 1, 0, 0, 0)))) opc_repository.client = MagicMock(
def test_validate_connection_lost_not_time_to_reconect(_mock_datetime, opc_repository): uaclient=Exception("Test error")
)
opc_repository.error_count = 0
response = await opc_repository.validate_connection()
assert response == (False, {
"notification_id": f"OPC_CONNECTION_CHECK_ERROR_{opc_repository.id}",
"message": "Failed to validate connection to OPC server: 'Exception' object has no attribute 'protocol'",
"block": "opc_repository",
"level": NotificationLevel.ERROR,
"attachment_content": ANY
})
@pytest.mark.asyncio
@patch('laborious.utils.repository.opc_repository.datetime')
async def test_validate_connection_lost_not_time_to_reconnect(_mock_datetime, opc_repository):
_mock_datetime.now = MagicMock(
return_value=datetime(2025, 1, 1, 0, 0, 0))
opc_repository.error_count = 0 opc_repository.error_count = 0
opc_repository.client = MagicMock() opc_repository.client = MagicMock()
opc_repository.client.aio_obj.uaclient.protocol = None opc_repository.client.uaclient.protocol = None
opc_repository.last_reconnection_time = datetime(2025, 1, 1, 0, 0, 0) opc_repository.last_reconnection_time = datetime(2025, 1, 1, 0, 0, 0)
opc_repository.try_connect = MagicMock() opc_repository.connect = MagicMock(return_value=(True, {}))
response = opc_repository.validate_connection() response = await opc_repository.validate_connection()
opc_repository.try_connect.assert_not_called() opc_repository.connect.assert_not_called()
assert response == (False, { assert response == (False, {
"notification_id": f"OPC_CONNECTION_AWAITING_RECONNECTION_WINDOW_{opc_repository.id}", "notification_id": f"OPC_CONNECTION_AWAITING_RECONNECTION_WINDOW_{opc_repository.id}",
"message": f"OPC server {opc_repository.id} is not connected, waiting for next reconnection window...", "message": f"OPC server {opc_repository.id} is not connected, waiting for next reconnection window...",
@@ -191,96 +232,120 @@ def test_validate_connection_lost_not_time_to_reconect(_mock_datetime, opc_repos
}) })
@patch('laborious.utils.repository.opc_repository.hasattr', return_value=True) @pytest.mark.asyncio
@patch('laborious.utils.repository.opc_repository.datetime', @patch('laborious.utils.repository.opc_repository.datetime')
MagicMock(now=MagicMock(return_value=datetime(2025, 1, 1, 1, 0, 0)))) async def test_validate_connection_lost_time_to_reconnect(mock_datetime, opc_repository):
def test_validate_connection_lost_time_to_reconect(_mock_datetime, opc_repository): mock_datetime.now = MagicMock(
return_value=datetime(2025, 1, 1, 1, 0, 0))
opc_repository.error_count = 0 opc_repository.error_count = 0
opc_repository.client = MagicMock() opc_repository.client = AsyncMock()
opc_repository.client.aio_obj.uaclient.protocol = None opc_repository.client.uaclient.protocol = None
opc_repository.last_reconnection_time = datetime(2025, 1, 1, 0, 0, 0) opc_repository.last_reconnection_time = datetime(2025, 1, 1, 0, 0, 0)
opc_repository.connect = MagicMock() opc_repository.connect = AsyncMock(return_value=(True, {}))
response = opc_repository.validate_connection() response = await opc_repository.validate_connection()
opc_repository.connect.assert_called_once() opc_repository.connect.assert_called_once()
assert response == opc_repository.connect.return_value assert response == opc_repository.connect.return_value
def test_validate_connection_failed(opc_repository): @pytest.mark.asyncio
async def test_validate_connection_success(opc_repository):
opc_repository.client = MagicMock() opc_repository.client = MagicMock()
opc_repository.error_count = 0 opc_repository.error_count = 0
opc_repository.client.uaclient.protocol = MagicMock()
opc_repository.client.uaclient.protocol.state = "open"
output = opc_repository.validate_connection() output = await opc_repository.validate_connection()
assert output == (True, {}) assert output == (True, {})
def test_write_data_validate_connection_do_nothing(opc_repository): @pytest.mark.asyncio
opc_repository.validate_connection = MagicMock(return_value=(True, {})) async def test_write_data_validate_connection_do_nothing(opc_repository):
opc_repository.client = MagicMock() opc_repository.validate_connection = AsyncMock(return_value=(True, {}))
opc_repository.write_data("ns=2;s=TestNode", 42.0, opc_repository.client = AsyncMock(
"float", opc_repository.logger, metadata) get_node=MagicMock()
)
mock_node = AsyncMock()
opc_repository.client.get_node.return_value = mock_node
result = await opc_repository.write_data("ns=2;s=TestNode", 42.0,
"float", opc_repository.logger, metadata['metadata'])
opc_repository.validate_connection.assert_called_once() opc_repository.validate_connection.assert_called_once()
opc_repository.client.get_node.assert_called_once_with("ns=2;s=TestNode") opc_repository.client.get_node.assert_called_once_with("ns=2;s=TestNode")
assert result == (True, {})
def test_write_data_validate_connection_failed(opc_repository): @pytest.mark.asyncio
opc_repository.validate_connection = MagicMock(return_value=(False, {})) async def test_write_data_validate_connection_failed(opc_repository):
opc_repository.client = MagicMock() opc_repository.validate_connection = AsyncMock(return_value=(False, {}))
opc_repository.client = AsyncMock()
opc_repository.error_count = 0 opc_repository.error_count = 0
opc_repository.write_data("ns=2;s=TestNode", 42.0,
"float", opc_repository.logger, metadata) result = await opc_repository.write_data("ns=2;s=TestNode", 42.0,
"float", opc_repository.logger, metadata['metadata'])
opc_repository.validate_connection.assert_called_once() opc_repository.validate_connection.assert_called_once()
opc_repository.client.get_node.assert_not_called() opc_repository.client.get_node.assert_not_called()
assert result == (False, {})
def test_write_data_get_node_failed(opc_repository): @pytest.mark.asyncio
opc_repository.validate_connection = MagicMock(return_value=(True, {})) async def test_write_data_get_node_failed(opc_repository):
opc_repository.client = MagicMock() opc_repository.validate_connection = AsyncMock(return_value=(True, {}))
opc_repository.client = AsyncMock()
opc_repository.error_count = 0 opc_repository.error_count = 0
opc_repository.client.get_node.side_effect = Exception("Test error") opc_repository.client.get_node = MagicMock(
is_success, error_data = opc_repository.write_data("ns=2;s=TestNode", 42.0, side_effect=Exception("Test error"))
"float", opc_repository.logger, metadata)
is_success, error_data = await opc_repository.write_data("ns=2;s=TestNode", 42.0,
"float", opc_repository.logger, metadata['metadata'])
opc_repository.validate_connection.assert_called_once() opc_repository.validate_connection.assert_called_once()
opc_repository.client.get_node.assert_called_once_with("ns=2;s=TestNode") opc_repository.client.get_node.assert_called_once_with("ns=2;s=TestNode")
assert is_success is False assert is_success is False
assert error_data['notification_id'] == f"OPC_WRITE_GET_NODE_ERROR_{opc_repository.id}" assert error_data['notification_id'] == f"OPC_WRITE_GET_NODE_ERROR_{opc_repository.id}"
assert error_data['message'] == "Failed to get node from OPC server: Test error | metadata: {'metadata': {'model_id': 'test_model', 'model_name': 'test_model', 'workflow_name': 'test_workflow', 'schema_name': 'test_schedule'}}" assert error_data['message'] == "Failed to get node from OPC server: Test error | metadata: {'model_id': 'test_model', 'model_name': 'test_model', 'workflow_name': 'test_workflow', 'schema_name': 'test_schedule'}"
assert error_data['block'] == "opc_repository" assert error_data['block'] == "opc_repository"
assert error_data['level'] == NotificationLevel.ERROR assert error_data['level'] == NotificationLevel.ERROR
assert error_data['attachment_content'] is not None assert error_data['attachment_content'] is not None
def test_write_data_invalid_data_type(opc_repository, mock_client): @pytest.mark.asyncio
opc_repository.validate_connection = MagicMock(return_value=(True, {})) async def test_write_data_invalid_data_type(opc_repository, mock_client):
opc_repository.validate_connection = AsyncMock(return_value=(True, {}))
opc_repository.client = mock_client opc_repository.client = mock_client
mock_node = MagicMock() mock_node = AsyncMock()
mock_client.get_node.return_value = mock_node mock_client.get_node = MagicMock(return_value=mock_node)
is_success, error_data = await opc_repository.write_data("ns=2;s=TestNode", 42.0,
"invalid_type", opc_repository.logger, metadata['metadata'])
is_success, error_data = opc_repository.write_data("ns=2;s=TestNode", 42.0,
"invalid_type", opc_repository.logger, metadata)
opc_repository.validate_connection.assert_called_once() opc_repository.validate_connection.assert_called_once()
mock_client.get_node.assert_called_once_with("ns=2;s=TestNode") mock_client.get_node.assert_called_once_with("ns=2;s=TestNode")
assert is_success is False assert is_success is False
assert error_data['notification_id'] == f"OPC_WRITE_DATA_TYPE_ERROR_{opc_repository.id}" assert error_data['notification_id'] == f"OPC_WRITE_DATA_TYPE_ERROR_{opc_repository.id}"
assert error_data['message'] == "Unsupported data type: invalid_type | metadata: {'metadata': {'model_id': 'test_model', 'model_name': 'test_model', 'workflow_name': 'test_workflow', 'schema_name': 'test_schedule'}}" assert error_data['message'] == "Unsupported data type: invalid_type | metadata: {'model_id': 'test_model', 'model_name': 'test_model', 'workflow_name': 'test_workflow', 'schema_name': 'test_schedule'}"
assert error_data['block'] == "opc_repository" assert error_data['block'] == "opc_repository"
assert error_data['level'] == NotificationLevel.ERROR assert error_data['level'] == NotificationLevel.ERROR
assert error_data.get('attachment_content') is None assert error_data.get('attachment_content') is None
@pytest.mark.asyncio
@patch('laborious.utils.repository.opc_repository.metrics') @patch('laborious.utils.repository.opc_repository.metrics')
def test_write_data(mock_metrics, opc_repository, mock_client): async def test_write_data(mock_metrics, opc_repository, mock_client):
opc_repository.validate_connection = MagicMock(return_value=(True, {})) opc_repository.validate_connection = AsyncMock(return_value=(True, {}))
opc_repository.client = mock_client opc_repository.client = mock_client
mock_node = MagicMock() mock_node = AsyncMock()
mock_client.get_node.return_value = mock_node mock_client.get_node = MagicMock(return_value=mock_node)
opc_repository.write_data("ns=2;s=TestNode", 42.0, result = await opc_repository.write_data("ns=2;s=TestNode", 42.0,
"float", opc_repository.logger, metadata['metadata']) "float", opc_repository.logger, metadata['metadata'])
mock_client.get_node.assert_called_once_with("ns=2;s=TestNode") mock_client.get_node.assert_called_once_with("ns=2;s=TestNode")
mock_node.write_value.assert_called_once() mock_node.write_value.assert_called_once()
assert result == (True, {})
mock_metrics.PREDICTION_OPC_WRITING_COUNT.labels.assert_called_once_with( mock_metrics.PREDICTION_OPC_WRITING_COUNT.labels.assert_called_once_with(
pod_id=opc_repository.pod_id, pod_id=opc_repository.pod_id,
@@ -300,21 +365,24 @@ def test_write_data(mock_metrics, opc_repository, mock_client):
ANY) ANY)
def test_write_data_write_value_failed(opc_repository, mock_client): @pytest.mark.asyncio
opc_repository.validate_connection = MagicMock(return_value=(True, {})) async def test_write_data_write_value_failed(opc_repository, mock_client):
opc_repository.validate_connection = AsyncMock(return_value=(True, {}))
opc_repository.client = mock_client opc_repository.client = mock_client
mock_node = MagicMock() mock_node = AsyncMock()
opc_repository.error_count = 0 opc_repository.error_count = 0
mock_client.get_node.return_value = mock_node mock_client.get_node = MagicMock(return_value=mock_node)
mock_node.write_value.side_effect = Exception("Test error") mock_node.write_value.side_effect = Exception("Test error")
is_success, error_data = opc_repository.write_data("ns=2;s=TestNode", 42.0,
"float", opc_repository.logger, metadata) is_success, error_data = await opc_repository.write_data("ns=2;s=TestNode", 42.0,
"float", opc_repository.logger, metadata['metadata'])
opc_repository.validate_connection.assert_called_once() opc_repository.validate_connection.assert_called_once()
mock_client.get_node.assert_called_once_with("ns=2;s=TestNode") mock_client.get_node.assert_called_once_with("ns=2;s=TestNode")
mock_node.write_value.assert_called_once() mock_node.write_value.assert_called_once()
assert is_success is False assert is_success is False
assert error_data['notification_id'] == f"OPC_WRITE_DATA_ERROR_{opc_repository.id}" assert error_data['notification_id'] == f"OPC_WRITE_DATA_ERROR_{opc_repository.id}"
assert error_data['message'] == "Failed to write data to OPC server: Test error | metadata: {'metadata': {'model_id': 'test_model', 'model_name': 'test_model', 'workflow_name': 'test_workflow', 'schema_name': 'test_schedule'}}" assert error_data['message'] == "Failed to write data to OPC server: Test error | metadata: {'model_id': 'test_model', 'model_name': 'test_model', 'workflow_name': 'test_workflow', 'schema_name': 'test_schedule'}"
assert error_data['block'] == "opc_repository" assert error_data['block'] == "opc_repository"
assert error_data['level'] == NotificationLevel.ERROR assert error_data['level'] == NotificationLevel.ERROR
assert error_data['attachment_content'] is not None assert error_data['attachment_content'] is not None

View File

@@ -3,7 +3,7 @@
# Declare variables to be passed into your templates. # Declare variables to be passed into your templates.
# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ # This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/
replicaCount: 5 replicaCount: 1
# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/
image: image:
@@ -151,7 +151,7 @@ env:
- name: GITHUB_REPO_URL - name: GITHUB_REPO_URL
value: "git@github.com:Aignosi/sientia-dataops-laborious_temporal.git" value: "git@github.com:Aignosi/sientia-dataops-laborious_temporal.git"
- name: GITHUB_BRANCH - name: GITHUB_BRANCH
value: "SIENTIAPDE-1193-conferir-como-a-escrita-de-datetime-ocorre-no-temporal" value: "SIENTIAPDE-1205-alterar-opc-para-assincrono"
- name: PYTHON_APP - name: PYTHON_APP
value: "laborious.worker.worker" value: "laborious.worker.worker"