SIENTIAPDE-1646

SIENTIAPDE-1646 Enhance OPC integration and E2E testing framework

- Updated .gitignore to exclude '.cursor/*' for better file management.
- Added new marker in pyproject.toml for E2E tests using OPC.
- Introduced async OPC server fixtures and improved connection handling in conftest.py.
- Enhanced error handling in OPC activities and added metrics for session management in metrics.py.
- Updated E2E tests to cover new OPC scenarios, including session errors and reconnect handling.
- Refactored helper functions to improve prediction assertion logic in helpers.py.
- Documented new OPC scenarios in scenarios.md for clarity on expected outcomes.
This commit is contained in:
2026-05-18 16:40:56 -03:00
parent b37ec60b5d
commit 83d3a4482c
18 changed files with 2294 additions and 525 deletions

View File

@@ -15,6 +15,45 @@ with workflow.unsafe.imports_passed_through():
from laborious.utils.repository.opc_repository import OpcRepository
OPC_WRITTING_ERROR_CONFIDENCE = 12
OPC_SESSION_BAD_CONFIDENCE = 14
OPC_SESSION_BAD_COMMENT_PREFIX = 'OPC UA session/channel error:'
OPC_WRITTING_ERROR_MESSAGE = 'Some data could not be written to OPC servers'
OPC_RECONNECT_IN_PROGRESS_COMMENT = 'OPC UA reconnect in progress'
OPC_COMMENT_SEPARATOR = ' | '
def _opc_session_bad_comment(opc_status: str | None) -> str:
status = opc_status or 'Unknown'
return f'{OPC_SESSION_BAD_COMMENT_PREFIX} {status}'
def _apply_opc_write_error(
error_info: dict[str, Any] | None,
session_bad_seen: bool,
session_bad_status: str | None,
reconnect_in_progress_seen: bool,
) -> tuple[bool, str | None, bool]:
"""
Update session/reconnect flags from an OPC write error payload.
Args:
error_info: Repository error details, or None when the write succeeded.
session_bad_seen: Whether a session_bad error was seen so far.
session_bad_status: Last known OPC status for session errors.
reconnect_in_progress_seen: Whether reconnect_in_progress was seen so far.
Return:
Updated (session_bad_seen, session_bad_status, reconnect_in_progress_seen).
"""
if not error_info:
return session_bad_seen, session_bad_status, reconnect_in_progress_seen
kind = error_info.get('opc_error_kind')
if kind == 'session_bad':
return True, error_info.get('opc_status', session_bad_status), reconnect_in_progress_seen
if kind == 'reconnect_in_progress':
return session_bad_seen, session_bad_status, True
return session_bad_seen, session_bad_status, reconnect_in_progress_seen
class OPC(SientiaMonitoring):
@@ -67,13 +106,14 @@ class OPC(SientiaMonitoring):
logger=self.logger,
notification_handler=self.notification_handler,
metrics_controller=self.metrics_controller,
reconnection_interval=server.get('reconnection_interval', 60),
server_uri=server['server_uri'],
cert_path=server['cert_path'],
private_key_path=server['private_key_path'],
server_cert_path=server['server_cert_path'],
)
ok, err = self.opc_repository[opc_id].connect()
if not ok:
is_connected, error_data = self.opc_repository[opc_id].connect()
if not is_connected:
self.send_notification(
metadata={
'model_id': '-',
@@ -81,11 +121,11 @@ class OPC(SientiaMonitoring):
'workflow_name': '-',
'schedule_name': 'INITIALIZATION',
},
notification_id=f'OPC_CONNECTION_ERROR_{server.get("id", opc_id)}',
message=err.get('message', 'Failed to connect to OPC server'),
block='opc_repository',
level=NotificationLevel.ERROR,
attachment_content=err.get('attachment_content', traceback.format_exc()),
notification_id=error_data['notification_id'],
message=error_data['message'],
block=error_data['block'],
level=error_data.get('level', NotificationLevel.ERROR),
attachment_content=error_data.get('attachment_content', None),
)
else:
self.info(
@@ -101,19 +141,13 @@ class OPC(SientiaMonitoring):
data_type: str,
tag_type: str,
metadata: dict[str, Any],
) -> float | None:
) -> tuple[float | None, dict[str, Any] | None]:
"""
Write data to a specific OPC server tag with comprehensive error handling.
Args:
- server_id (str): The id of the OPC server.
- tag (str): The tag to write to.
- data (Any): The data to write.
- data_type (str): The data type.
- tag_type (str): The tag type.
Returns:
- float | None: Response time in seconds if successful, None otherwise.
Return:
tuple[float | None, dict[str, Any] | None]: Response time on success, or
(None, error info_data) on repository failure.
"""
try:
@@ -129,8 +163,8 @@ class OPC(SientiaMonitoring):
level=info_data.get('level', NotificationLevel.ERROR),
attachment_content=info_data.get('attachment_content', None),
)
return None
return info_data['response_time']
return None, info_data
return info_data['response_time'], None
except Exception as e:
trace = traceback.format_exc()
self.send_notification(
@@ -147,10 +181,6 @@ class OPC(SientiaMonitoring):
"""
Validate that an OPC repository exists for the requested server identifier.
This guard prevents write attempts against unknown/uninitialized servers.
When the server is missing, it emits an error notification with the list
of available repositories to help operators diagnose configuration drift.
Args:
- server_id (str): OPC server identifier from workflow output config.
- metadata (dict[str, Any]): Workflow metadata used for logs/alerts.
@@ -172,72 +202,116 @@ class OPC(SientiaMonitoring):
return False
return True
def _write_tags_from_config(
self,
server_id: str,
tags_config: dict[str, dict[str, Any]],
data: DataFrame,
data_column: str,
tag_type: str,
log_label: str,
metadata: dict[str, Any],
) -> tuple[dict[str, float | None], bool, str | None, bool]:
"""
Write a group of OPC tags and collect response times and error flags.
Args:
server_id: Target OPC server identifier.
tags_config: Tag name to configuration mapping.
data: DataFrame with prediction/confidence columns.
data_column: Column name whose first row value is written.
tag_type: Tag category passed to write_data ('prediction' or 'confidence').
log_label: Human-readable label for success logs.
metadata: Context metadata for logging and notifications.
Return:
(response_times, session_bad_seen, session_bad_status, reconnect_in_progress_seen)
"""
response_times: dict[str, float | None] = {}
session_bad_seen = False
session_bad_status: str | None = None
reconnect_in_progress_seen = False
for tag, tag_config in tags_config.items():
response_time, error_info = self.write_data(
server_id=server_id,
tag=tag,
data=data.head(1)[data_column].values[0],
data_type=tag_config['data_type'],
tag_type=tag_type,
metadata=metadata,
)
session_bad_seen, session_bad_status, reconnect_in_progress_seen = (
_apply_opc_write_error(
error_info,
session_bad_seen,
session_bad_status,
reconnect_in_progress_seen,
)
)
if response_time is not None:
self.info(
f'{log_label} written to OPC server {server_id} for tag {tag}.',
metadata,
)
response_times[tag] = response_time
return response_times, session_bad_seen, session_bad_status, reconnect_in_progress_seen
def manage_output_tags(
self,
server_id: str,
config: dict[str, Any],
data: DataFrame,
metadata: dict[str, Any],
) -> tuple[bool, dict[str, float | None]]:
) -> tuple[bool, dict[str, float | None], bool, str | None, bool]:
"""
Write prediction and confidence values for one OPC server configuration.
The method iterates through optional ``prediction_tags`` and
``confidence_tags``, performs synchronous writes for each tag, collects
per-tag response times, and returns an aggregate success flag
(all tags successful) with a metrics-friendly response map.
Args:
- server_id (str): Target OPC server id.
- config (dict[str, Any]): Server output configuration containing optional
``prediction_tags`` and ``confidence_tags`` sections.
- data (DataFrame): Prediction dataframe used as source values.
- metadata (dict[str, Any]): Workflow metadata for logging/notifications.
Return:
tuple[bool, dict[str, float | None]]: Global success flag and response-time
map per tag (``None`` for failed writes).
tuple: success flag, per-tag response times, session_bad flags.
"""
response_times: dict[str, float | None] = {}
session_bad_seen = False
session_bad_status: str | None = None
reconnect_in_progress_seen = False
if 'prediction_tags' in config:
for tag, tag_config in config['prediction_tags'].items():
response_time = self.write_data(
server_id=server_id,
tag=tag,
data=data.head(1)['prediction'].values[0],
data_type=tag_config['data_type'],
tag_type='prediction',
metadata=metadata,
)
if response_time is not None:
self.info(
f'Prediction data written to OPC server {server_id} for tag {tag}.',
metadata,
)
response_times[tag] = response_time
if 'confidence_tags' in config:
for tag, tag_config in config['confidence_tags'].items():
response_time = self.write_data(
server_id=server_id,
tag=tag,
data=data.head(1)['prediction_confidence'].values[0],
data_type=tag_config['data_type'],
tag_type='confidence',
metadata=metadata,
)
if response_time is not None:
self.info(
f'Confidence data written to OPC server {server_id} for tag {tag}.',
metadata,
)
response_times[tag] = response_time
tag_groups = (
('prediction_tags', 'prediction', 'prediction', 'Prediction data'),
('confidence_tags', 'prediction_confidence', 'confidence', 'Confidence data'),
)
for config_key, data_column, tag_type, log_label in tag_groups:
if config_key not in config:
continue
(
group_times,
group_session_bad,
group_status,
group_reconnect,
) = self._write_tags_from_config(
server_id=server_id,
tags_config=config[config_key],
data=data,
data_column=data_column,
tag_type=tag_type,
log_label=log_label,
metadata=metadata,
)
response_times.update(group_times)
if group_session_bad:
session_bad_seen = True
session_bad_status = group_status or session_bad_status
if group_reconnect:
reconnect_in_progress_seen = True
success = None not in response_times.values()
return success, response_times
return (
success,
response_times,
session_bad_seen,
session_bad_status,
reconnect_in_progress_seen,
)
@activity.defn(name='write_opc_data')
def write_opc_data(
@@ -246,19 +320,13 @@ class OPC(SientiaMonitoring):
"""
Execute OPC writes across all configured servers and collect per-tag metrics.
For each server in ``opc_output_config``, this activity validates server
availability, writes enabled prediction/confidence tags, accumulates
response-time metrics, and then normalizes confidence/comments in the
returned prediction payload when at least one write fails.
Args:
- input_data (dict[str, Any]): Payload containing workflow metadata, data
to write, and ``opc_output_config`` server/tag definitions.
Return:
tuple[dict[Hashable, Any], dict[str, dict[str, float | None]]]: Updated
prediction payload dict and nested metrics
``{server_id: {tag_name: response_time_or_none}}``.
prediction payload dict and nested metrics per server/tag.
"""
metadata = input_data['metadata']
self.info('Writing data to OPC servers...', metadata)
@@ -267,20 +335,32 @@ class OPC(SientiaMonitoring):
self.info(f'Data to write: {data.size} rows', metadata)
success = True
session_bad_seen = False
session_bad_status: str | None = None
reconnect_in_progress_seen = False
metrics: dict[str, dict[str, float | None]] = {}
opc_metrics: dict[str, dict[str, float | None]] = {}
for server_id, config in opc_output_config.items():
if not self.validate_server(server_id, metadata):
success = False
continue
local_success, local_response_times = self.manage_output_tags(
server_id, config, data, metadata
)
metrics[server_id] = local_response_times
(
local_success,
local_response_times,
local_session_bad,
local_status,
local_reconnect_in_progress,
) = self.manage_output_tags(server_id, config, data, metadata)
opc_metrics[server_id] = local_response_times
local_count = len(local_response_times)
success = success and local_success
if local_session_bad:
session_bad_seen = True
session_bad_status = local_status or session_bad_status
if local_reconnect_in_progress:
reconnect_in_progress_seen = True
n_pred = len(config.get('prediction_tags') or {})
n_conf = len(config.get('confidence_tags') or {})
@@ -289,34 +369,55 @@ class OPC(SientiaMonitoring):
metadata,
)
return self.process_confidence(data, success, metadata), metrics
return (
self.process_confidence(
data,
success,
metadata,
session_bad=session_bad_seen,
opc_status=session_bad_status,
reconnect_in_progress=reconnect_in_progress_seen,
),
opc_metrics,
)
def process_confidence(
self, data: DataFrame, success: bool, metadata: dict[str, Any]
self,
data: DataFrame,
success: bool,
metadata: dict[str, Any],
*,
session_bad: bool = False,
opc_status: str | None = None,
reconnect_in_progress: bool = False,
) -> dict[Hashable, Any]:
"""
Apply fallback confidence/comment values when OPC writes are not fully successful.
Args:
- data (DataFrame): Prediction dataframe to be returned to downstream steps.
- success (bool): Aggregate write status across all attempted OPC tags.
- metadata (dict[str, Any]): Workflow metadata used for debug logs.
Return:
dict[Hashable, Any]: Serialized dataframe dict with original values on success,
or downgraded confidence/comment fields on failure.
dict[Hashable, Any]: Serialized dataframe dict with updated confidence/comments on failure.
"""
message = 'Some data could not be written to OPC servers'
if not success:
data['prediction_confidence'] = OPC_WRITTING_ERROR_CONFIDENCE
data['comments'] = message
comment_parts: list[str] = []
confidence = OPC_WRITTING_ERROR_CONFIDENCE
if session_bad:
comment_parts.append(_opc_session_bad_comment(opc_status))
confidence = OPC_SESSION_BAD_CONFIDENCE
if reconnect_in_progress:
comment_parts.append(OPC_RECONNECT_IN_PROGRESS_COMMENT)
confidence = OPC_SESSION_BAD_CONFIDENCE
if not comment_parts:
comment_parts.append(OPC_WRITTING_ERROR_MESSAGE)
comments = OPC_COMMENT_SEPARATOR.join(comment_parts)
data['prediction_confidence'] = confidence
data['comments'] = comments
self.debug(
f'{message}, setting confidence to {OPC_WRITTING_ERROR_CONFIDENCE}.',
f'OPC write issues, confidence={confidence}, comments={comments}',
metadata,
)
else:
self.debug('Data written to OPC servers successfully.', metadata)
@@ -325,9 +426,6 @@ class OPC(SientiaMonitoring):
def close(self) -> None:
"""
Disconnect all tracked OPC repositories and clear in-memory references.
This method should be called during worker shutdown to ensure every
synchronous OPC session is explicitly closed before process exit.
"""
for opc in self.opc_repository.values():

View File

@@ -89,6 +89,40 @@ OPC_CONNECTION_STATUS = Gauge(
['pod_id', 'server_name', 'server_url'],
)
_OPC_SESSION_DEBUG_LABELS = ['pod_id', 'server_name', 'runtime', 'opc_server_id', 'session_id']
OPC_SESSION_CREATED_TOTAL = Counter(
'opc_session_created_total',
'OPC UA sessions established (after successful connect)',
_OPC_SESSION_DEBUG_LABELS,
)
OPC_SESSION_CLOSED_TOTAL = Counter(
'opc_session_closed_total',
'OPC UA client disconnects completed (session tear-down initiated)',
_OPC_SESSION_DEBUG_LABELS,
)
OPC_SESSION_REVISED_TIMEOUT_MS = Gauge(
'opc_session_revised_timeout_milliseconds',
'Server-revised OPC UA session timeout (RevisedSessionTimeout) in ms after connect',
_OPC_SESSION_DEBUG_LABELS,
)
OPC_WRITE_ATTEMPT_LABELS = [*_OPC_SESSION_DEBUG_LABELS, 'model_id', 'model_name', 'result']
OPC_WRITE_ATTEMPTS_TOTAL = Counter(
'opc_write_attempts_total',
'OPC UA write attempts with session and outcome (result=OK or exception class name)',
OPC_WRITE_ATTEMPT_LABELS,
)
OPC_WRITE_INTER_ARRIVAL_OVER_SESSION_TIMEOUT_TOTAL = Counter(
'opc_write_inter_arrival_over_session_timeout_total',
'Successful writes where seconds since the previous successful write exceeded RevisedSessionTimeout (ms)',
_OPC_SESSION_DEBUG_LABELS,
)
# ================== Model metrics ==================
MODEL_READ_LAG = Histogram(

View File

@@ -3,8 +3,6 @@ from os import getenv
from typing import Any
def build_mlflow_config() -> dict[str, Any]:
"""
Read MLflow tracking and registry credentials from the environment.

View File

@@ -1,12 +1,14 @@
"""
Synchronous OPC UA client repository using asyncua (opcua-asyncio) ``sync`` API.
Synchronous OPC UA client repository using asyncua ``sync`` API.
``asyncua.sync.Client`` runs the asyncio client on a background thread so callers
stay synchronous. Connect/disconnect, optional Basic256 security, session checks,
and typed writes mirror the previous python-opcua integration.
``asyncua.sync.Client`` runs the asyncio stack on a background thread so Temporal
activities and other callers stay blocking while preserving the same session
lifecycle, security policy, reconnect semantics, and write error classification
as the async ``origin/main`` implementation.
"""
import json
import threading
import time
import traceback
from datetime import datetime
@@ -16,6 +18,7 @@ from typing import Any
from asyncua import ua
from asyncua.crypto import security_policies
from asyncua.sync import Client
from asyncua.ua.uaerrors import UaStatusCodeError
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.notifications.models import NotificationLevel
from sientia_do.observability.logger import Logger
@@ -24,6 +27,119 @@ from sientia_do.observability.sientia_monitoring import SientiaMonitoring
from laborious import metrics
# Requested session and secure channel lifetime (ms) before server revision; 10 minutes.
OPC_UA_SESSION_AND_CHANNEL_TIMEOUT_MS = 10 * 60 * 1000
class OpcClientAlreadyExistsError(RuntimeError):
"""Raised when _create_client is called while self.client is already set."""
class OpcSessionAlreadyConnectedError(RuntimeError):
"""Raised when _open_session is called while a UA session is already open."""
class OpcClientNotInitializedError(RuntimeError):
"""Raised when _open_session is called before _create_client."""
RECONNECTABLE_OPC_BAD_NAMES: frozenset[str] = frozenset(
{
'BadSessionIdInvalid',
'BadSessionClosed',
'BadSessionNotActivated',
'BadSecureChannelIdInvalid',
'BadSecureChannelClosed',
'BadSecureChannelTokenUnknown',
'BadTcpSecureChannelUnknown',
'BadServerNotConnected',
'BadConnectionClosed',
'BadDisconnect',
'BadConnectionRejected',
'BadCommunicationError',
'BadRequestInterrupted',
'BadUnknownResponse',
'BadTimeout',
'BadRequestTimeout',
'BadSequenceNumberInvalid',
'BadSequenceNumberUnknown',
'BadSecurityModeInsufficient',
'BadRequestHeaderInvalid',
'BadInvalidState',
}
)
def _opc_authentication_token_str(client: Client | None) -> str:
"""
Serialize the current OPC UA authentication token (session handle) for logging and metrics.
Return:
str: Token string, or "unknown" if unavailable.
"""
if client is None:
return 'unknown'
try:
proto = client.aio_obj.uaclient.protocol
if proto is None:
return 'unknown'
tok = getattr(proto, 'authentication_token', None)
if tok is None:
return 'unknown'
return str(tok)
except Exception:
return 'unknown'
def _opc_status_from_exception(exc: BaseException) -> str:
"""
Resolve OPC UA status name from an exception, including chained UaStatusCodeError causes.
Args:
exc (BaseException): Raised error from asyncua.
Return:
str: Status class name or generic Python exception name.
"""
current: BaseException | None = exc
while current is not None:
if isinstance(current, UaStatusCodeError):
return type(current).__name__
current = current.__cause__
return type(exc).__name__
def is_reconnectable_opcua_bad(exc: BaseException) -> bool:
"""
Return whether the exception is a Tier-1 OPC UA Bad* that should trigger reconnect.
Args:
exc (BaseException): Raised error from get_node or write_value.
Return:
bool: True if reconnect should be scheduled.
"""
return _opc_status_from_exception(exc) in RECONNECTABLE_OPC_BAD_NAMES
def _model_labels_from_write_metadata(metadata: dict[str, Any] | None) -> dict[str, str]:
"""
Extract model_id and model_name from write metadata for Prometheus labels.
Args:
metadata (dict[str, Any] | None): Context passed into write_data; may omit keys.
Return:
dict[str, str]: Labels model_id and model_name, defaulting to "unknown".
"""
if not metadata:
return {'model_id': 'unknown', 'model_name': 'unknown'}
return {
'model_id': str(metadata.get('model_id', 'unknown')),
'model_name': str(metadata.get('model_name', 'unknown')),
}
data_type_map = {
'float': {
'converter': float,
@@ -52,11 +168,8 @@ class OpcRepository(SientiaMonitoring):
"""
Synchronous OPC UA repository for connect/disconnect and typed writes.
Attributes:
url: OPC UA endpoint URL.
id: Server identifier used in metrics and notifications.
server_name: Human-readable server name for labels.
client: Active ``asyncua.sync.Client`` while connected.
Uses ``asyncua.sync.Client`` with the same session metrics, Tier-1 Bad* reconnect,
and structured write error payloads as the async repository on ``origin/main``.
"""
def __init__(
@@ -80,10 +193,10 @@ class OpcRepository(SientiaMonitoring):
self.cert_path = cert_path
self.private_key_path = private_key_path
self.server_cert_path = server_cert_path
self.error_count = 0
self.reconnection_interval = reconnection_interval
self.last_reconnection_time: datetime | None = None
self.disconnection_interval = 10.0
self.notification_handler = notification_handler
self.client: Client | None = None
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
@@ -94,15 +207,63 @@ class OpcRepository(SientiaMonitoring):
'workflow_name': 'opc_repository',
'schedule_name': '-',
}
self._last_write_mono: float | None = None
self._connection_lock = threading.Lock()
self._session_ready = threading.Event()
self._reconnect_thread: threading.Thread | None = None
def _opc_debug_tags(self, session_id: str) -> dict[str, str]:
return {
'pod_id': str(getattr(self, 'pod_id', 'unknown')),
'server_name': self.server_name,
'runtime': str(getattr(self, 'runtime', 'unknown')),
'opc_server_id': self.id,
'session_id': session_id,
}
def _is_session_open(self) -> bool:
"""
Return whether the asyncua client has an open transport session.
Return:
bool: True when protocol exists and is not closed.
"""
if self.client is None:
return False
try:
proto = self.client.aio_obj.uaclient.protocol
return proto is not None and proto.state != 'closed'
except Exception:
return False
def _reconnection_window_elapsed(self) -> bool:
"""
Return whether enough time has passed since the last reconnect attempt.
Return:
bool: True if a new reconnect is allowed.
"""
if self.last_reconnection_time is None:
return True
return (
datetime.now() - self.last_reconnection_time
).total_seconds() > self.reconnection_interval
def _not_connected_error(self) -> dict[str, Any]:
return {
'notification_id': f'OPC_CONNECTION_NOT_READY_{self.id}',
'message': f'OPC server {self.id} is not connected',
'block': 'opc_repository',
'level': NotificationLevel.WARNING,
}
def set_security(self) -> None:
"""
Configure Basic256 security policy, certificates, and long channel/session timeouts.
Configure certificates and timeouts on the sync asyncua client.
Raises:
ValueError: If certificate paths are missing or client is not initialized.
ValueError: If cert paths or client are missing.
"""
if self.cert_path is None or self.private_key_path is None:
raise ValueError(
'Certificate and private key paths must be provided for secure connection.'
@@ -115,7 +276,7 @@ class OpcRepository(SientiaMonitoring):
if self.client is None:
raise ValueError('Client must be initialized before setting security')
self.client.application_uri = self.server_uri or self.client.application_uri
self.client.application_uri = self.server_uri
self.info('Setting security...', self.metadata)
self.client.set_security(
security_policies.SecurityPolicyBasic256,
@@ -124,75 +285,105 @@ class OpcRepository(SientiaMonitoring):
None,
str(server_cert) if server_cert else None,
)
self.client.aio_obj.secure_channel_timeout = 10000000
self.client.aio_obj.session_timeout = 10000000
aio = self.client.aio_obj
aio.secure_channel_timeout = OPC_UA_SESSION_AND_CHANNEL_TIMEOUT_MS
aio.session_timeout = OPC_UA_SESSION_AND_CHANNEL_TIMEOUT_MS
def connect(self) -> tuple[bool, dict[str, Any]]:
def _create_client(self) -> None:
"""
Create the synchronous client, optionally apply security, and connect to the server.
Instantiate the sync Client and apply security when configured.
Return:
tuple[bool, dict[str, Any]]: Success flag and error payload when False.
Caller must hold _connection_lock. Does not open a UA session.
Raises:
OpcClientAlreadyExistsError: If self.client is already set.
"""
if self.client is not None:
raise OpcClientAlreadyExistsError(
f'OPC client already exists for server {self.id}; '
'call disconnect() before creating a new client'
)
self.client = Client(self.url, timeout=10)
self.client.aio_obj.name = self.pod_id
self.client.aio_obj.description = self.pod_id
aio = self.client.aio_obj
if hasattr(aio, 'watchdog_intervall'):
aio.watchdog_intervall = 50
aio.name = self.pod_id
aio.description = self.pod_id
pod_uri = self.pod_id.replace('-', ':')
self.client.application_uri = pod_uri
self.client.aio_obj.product_uri = pod_uri
aio.product_uri = pod_uri
if self.cert_path:
self.set_security()
self.info(
f'Starting connection to OPC server {self.id}:{self.server_name}...', self.metadata
)
return self.try_connect()
def try_connect(self) -> tuple[bool, dict[str, Any]]:
def _open_session(self) -> tuple[bool, dict[str, Any]]:
"""
Perform the TCP/session handshake and emit connection metrics.
Open the OPC UA session on the existing client.
Caller must hold _connection_lock.
Raises:
OpcClientNotInitializedError: If self.client is None.
OpcSessionAlreadyConnectedError: If a session is already open.
Return:
tuple[bool, dict[str, Any]]: Success flag and structured error when False.
tuple[bool, dict[str, Any]]: Success flag and error payload on connect failure.
"""
if self.client is None:
raise OpcClientNotInitializedError(
f'OPC client is not initialized for server {self.id}; '
'call _create_client() before opening a session'
)
if self._is_session_open():
raise OpcSessionAlreadyConnectedError(
f'OPC session already connected for server {self.id}; '
'call disconnect() before connecting again'
)
tags = {
'pod_id': self.pod_id,
'server_name': self.server_name,
}
self.emit_metric_sync(metrics.OPC_CONNECTIONS_TOTAL, tags)
try:
self.last_reconnection_time = datetime.now()
if self.client is None:
return False, {
'notification_id': f'OPC_CONNECTION_ERROR_{self.id}',
'message': 'Client is not initialized',
'block': 'opc_repository',
'level': NotificationLevel.ERROR,
}
self.client.connect()
aio = self.client.aio_obj
session_id = _opc_authentication_token_str(self.client)
revised_session_timeout_ms = int(aio.session_timeout)
revised_secure_channel_timeout_ms = int(aio.secure_channel_timeout)
self.info(
f'OPC new session connected opc_server_id={self.id} session_id={session_id} '
f'revised_session_timeout_ms={revised_session_timeout_ms} '
f'revised_secure_channel_timeout_ms={revised_secure_channel_timeout_ms}',
self.metadata,
)
self.emit_metric_sync(
metrics.OPC_SESSION_CREATED_TOTAL, self._opc_debug_tags(session_id)
)
self.emit_metric_sync(
metric_object=metrics.OPC_SESSION_REVISED_TIMEOUT_MS,
method='set',
tags=self._opc_debug_tags(session_id),
value=revised_session_timeout_ms,
)
self.emit_metric_sync(
metric_object=metrics.OPC_CONNECTION_STATUS,
method='set',
tags={
**tags,
'server_url': self.url,
},
tags={**tags, 'server_url': self.url},
value=1,
)
self._last_write_mono = None
self._session_ready.set()
return True, {}
except Exception as e:
self.disconnect()
except Exception as e:
self._disconnect_locked()
trace = traceback.format_exc()
self.error(trace, self.metadata)
self.emit_metric_sync(metrics.OPC_CONNECTIONS_FAILED, tags)
return False, {
'notification_id': f'OPC_CONNECTION_ERROR_{self.id}',
'message': f'Failed to connect to OPC server: {e}',
@@ -201,20 +392,38 @@ class OpcRepository(SientiaMonitoring):
'attachment_content': trace,
}
def disconnection_fallback(self) -> list[dict[str, Any]]:
def _connect_locked(self) -> tuple[bool, dict[str, Any]]:
"""
Retry disconnect up to five times with linear backoff.
Create the client when absent, then open a UA session.
Caller must hold _connection_lock.
Raises:
OpcSessionAlreadyConnectedError: If a session is already open.
Return:
list[dict[str, Any]]: Empty on success, otherwise error records per attempt.
tuple[bool, dict[str, Any]]: Result from _open_session on connect failure.
"""
if self._is_session_open():
raise OpcSessionAlreadyConnectedError(
f'OPC session already connected for server {self.id}; '
'call disconnect() before connecting again'
)
if self.client is None:
self._create_client()
return self._open_session()
def _disconnection_fallback(self) -> list[dict[str, Any]]:
"""
Try up to five times to disconnect from the OPC UA server.
"""
assert self.client is not None
error_stack = []
error_stack: list[dict[str, Any]] = []
for i in range(5):
try:
self.info(
f'Disconnecting from OPC UA server, attempt {i + 1} of 5', self.metadata
f'Disconnecting from OPC UA server, attempt {i + 1} of 5',
self.metadata,
)
self.client.disconnect()
return []
@@ -233,15 +442,26 @@ class OpcRepository(SientiaMonitoring):
time.sleep(self.disconnection_interval * i)
return error_stack
def disconnect(self) -> None:
def _disconnect_locked(self) -> None:
"""
Tear down the UA session and reset connection metrics.
Tear down the current session and client.
Caller must hold _connection_lock.
"""
self._last_write_mono = None
self._session_ready.clear()
if self.client is None:
return
errors = self.disconnection_fallback()
session_id = _opc_authentication_token_str(self.client)
self.info(
f'OPC disconnecting opc_server_id={self.id} session_id={session_id}',
self.metadata,
)
self.emit_metric_sync(metrics.OPC_SESSION_CLOSED_TOTAL, self._opc_debug_tags(session_id))
errors = self._disconnection_fallback()
if errors:
self.send_notification(
metadata=self.metadata,
@@ -252,9 +472,8 @@ class OpcRepository(SientiaMonitoring):
attachment_content=json.dumps(errors, indent=4),
)
else:
self.warning(
f'Disconnected from OPC server {self.id} successfully', self.metadata
)
self.warning(f'Disconnected from OPC server {self.id} successfully', self.metadata)
self.emit_metric_sync(
metric_object=metrics.OPC_CONNECTION_STATUS,
method='set',
@@ -265,139 +484,313 @@ class OpcRepository(SientiaMonitoring):
},
value=0,
)
self.client = None
def _session_alive(self) -> bool:
def _reconnect_locked(self) -> tuple[bool, dict[str, Any]]:
"""
Best-effort check that the synchronous client still has a working session.
Close the current session and open a new one.
Caller must hold _connection_lock. Records last_reconnection_time for interval gating.
Return:
bool: True if a root browse succeeds, False otherwise.
tuple[bool, dict[str, Any]]: Result from _connect_locked after teardown.
"""
self.last_reconnection_time = datetime.now()
self._disconnect_locked()
return self._connect_locked()
if self.client is None:
return False
try:
self.client.get_root_node()
return True
except Exception:
return False
def connect(self) -> tuple[bool, dict[str, Any]]:
"""
Open an OPC UA session under the connection lock (worker initialization).
"""
with self._connection_lock:
self.info(
f'Starting connection to OPC server {self.id}:{self.server_name}...',
self.metadata,
)
return self._connect_locked()
def disconnect(self) -> None:
"""
Gracefully disconnect from the OPC server under the connection lock.
"""
with self._connection_lock:
self._disconnect_locked()
def validate_connection(self) -> tuple[bool, dict[str, Any]]:
"""
Ensure the UA session is usable; reconnect when outside the backoff window.
Read-only check that the asyncua protocol is open.
Caller must ensure _session_ready before writing. Does not connect or reconnect.
Return:
tuple[bool, dict[str, Any]]: Whether the session is ready and optional error payload.
tuple[bool, dict[str, Any]]: (True, {}) when open, otherwise (False, error).
"""
if self.client is None:
return self.connect()
try:
if not self._session_alive():
self.error(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()
> self.reconnection_interval
):
self.disconnect()
self.info(
f'Trying to reconnect to OPC server {self.id}...', self.metadata
)
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,
}
if self._is_session_open():
return True, {}
except Exception as e:
trace = traceback.format_exc()
message = f'Failed to validate connection to OPC server: {e}'
self.error(message, self.metadata)
return False, {
'notification_id': f'OPC_CONNECTION_CHECK_ERROR_{self.id}',
'message': message,
'block': 'opc_repository',
'level': NotificationLevel.ERROR,
'attachment_content': trace,
}
self.error(f'OPC server {self.id} is not connected', self.metadata)
return False, self._not_connected_error()
def _start_reconnect_on_bad(self, opc_status: str, session_id: str) -> None:
"""
Schedule a background reconnect if interval and thread state allow it.
Args:
opc_status (str): OPC UA status name that triggered reconnect.
session_id (str): Session token before failure.
"""
if not self._reconnection_window_elapsed():
self.warning(
f'OPC reconnect skipped reason=reconnection_window opc_server_id={self.id} '
f'opc_status={opc_status}',
self.metadata,
)
return
if self._reconnect_thread is not None and self._reconnect_thread.is_alive():
self.warning(
f'OPC reconnect skipped reason=in_progress opc_server_id={self.id} '
f'opc_status={opc_status}',
self.metadata,
)
return
self._session_ready.clear()
self.info(
f'OPC reconnect scheduled after opc_status={opc_status} opc_server_id={self.id} '
f'old_session_id={session_id}',
self.metadata,
)
self._reconnect_thread = threading.Thread(
target=self._run_reconnect_on_bad,
args=(opc_status, session_id),
daemon=True,
)
self._reconnect_thread.start()
def _run_reconnect_on_bad(self, opc_status: str, session_id: str) -> None:
"""
Tear down and re-establish the OPC UA session under the connection lock.
Args:
opc_status (str): OPC UA status that triggered reconnect.
session_id (str): Previous session token string.
"""
try:
with self._connection_lock:
self.info(
f'OPC reconnect started opc_status={opc_status} opc_server_id={self.id} '
f'old_session_id={session_id}',
self.metadata,
)
self._reconnect_locked()
except Exception:
self.error(
f'OPC reconnect task failed opc_server_id={self.id} opc_status={opc_status}',
self.metadata,
)
self.error(traceback.format_exc(), self.metadata)
def _log_write_inter_arrival(self, session_id: str, node: str) -> None:
"""
Log elapsed wall time since the previous successful OPC write on this repository.
Args:
session_id (str): Current OPC UA session token string.
node (str): Node id written in this operation.
"""
now = time.monotonic()
if self._last_write_mono is not None:
delta_s = now - self._last_write_mono
self.info(
f'OPC write inter-arrival_s={delta_s:.6f} opc_server_id={self.id} '
f'session_id={session_id} node={node}',
self.metadata,
)
if self.client is not None:
session_timeout_ms = float(self.client.aio_obj.session_timeout)
if session_timeout_ms > 0 and delta_s > (session_timeout_ms / 1000.0):
self.emit_metric_sync(
metrics.OPC_WRITE_INTER_ARRIVAL_OVER_SESSION_TIMEOUT_TOTAL,
self._opc_debug_tags(session_id),
)
self._last_write_mono = now
def _emit_opc_write_metric(
self, session_id: str, result: str, metadata: dict[str, Any] | None
) -> None:
self.emit_metric_sync(
metrics.OPC_WRITE_ATTEMPTS_TOTAL,
{
**self._opc_debug_tags(session_id),
**_model_labels_from_write_metadata(metadata),
'result': result,
},
)
def _write_failure_payload(
self,
notification_id: str,
message: str,
level: NotificationLevel = NotificationLevel.ERROR,
attachment_content: str | None = None,
opc_error_kind: str | None = None,
opc_status: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
'notification_id': notification_id,
'message': message,
'block': 'opc_repository',
'level': level,
}
if attachment_content is not None:
payload['attachment_content'] = attachment_content
if opc_error_kind is not None:
payload['opc_error_kind'] = opc_error_kind
if opc_status is not None:
payload['opc_status'] = opc_status
return payload
def _handle_tier1_bad(
self,
exc: BaseException,
session_id: str,
node: str,
metadata: dict[str, Any],
phase: str,
) -> tuple[bool, dict[str, Any]]:
"""
Record metrics/logs and schedule reconnect after a Tier-1 Bad* error.
Args:
exc (BaseException): Tier-1 OPC UA error.
session_id (str): Session token at failure time.
node (str): Node id being written.
metadata (dict[str, Any]): Write context.
phase (str): get_node or write_value.
Return:
tuple[bool, dict[str, Any]]: Always (False, error payload).
"""
opc_status = _opc_status_from_exception(exc)
trace = traceback.format_exc()
self.error(trace, metadata)
self._emit_opc_write_metric(session_id, opc_status, metadata)
self.error(
f'OPC write failed opc_status={opc_status} opc_server_id={self.id} '
f'session_id={session_id} model_id={metadata.get("model_id", "unknown")} '
f'model_name={metadata.get("model_name", "unknown")} node={node} phase={phase}',
metadata,
)
self._start_reconnect_on_bad(opc_status, session_id)
return False, self._write_failure_payload(
notification_id=f'OPC_WRITE_DATA_ERROR_{self.id}',
message=f'Failed to {phase} on OPC server: {exc} | metadata: {metadata}',
attachment_content=trace,
opc_error_kind='session_bad',
opc_status=opc_status,
)
@staticmethod
def _write_node_value(
node_obj: Any,
ua_data: ua.DataValue,
data: Any,
variant_type: ua.VariantType,
) -> None:
"""
Write a DataValue to a node, falling back to set_value when write_value is unavailable.
Args:
node_obj: Sync or async node wrapper from asyncua.
ua_data (ua.DataValue): Encoded value for write_value.
data: Scalar converted value for set_value fallback.
variant_type (ua.VariantType): OPC UA type for set_value fallback.
"""
if hasattr(node_obj, 'write_value'):
try:
node_obj.write_value(ua_data)
return
except (AttributeError, TypeError):
pass
node_obj.set_value(data, variant_type)
def write_data(
self, node: str, value: Any, data_type: str, metadata: dict[str, Any]
) -> tuple[bool, dict[str, Any]]:
"""
Write a typed value to an OPC UA node after validating connectivity.
Args:
node: Node id string accepted by ``Client.get_node``.
value: Scalar value to encode.
data_type: Key into ``data_type_map`` (e.g. float, str).
metadata: Workflow metadata for error context.
Return:
tuple[bool, dict[str, Any]]: Success flag and either ``response_time`` or error fields.
Write data to OPC server with a single attempt and Tier-1 Bad* reconnect scheduling.
"""
if not self._session_ready.is_set():
self._emit_opc_write_metric('unknown', 'ReconnectInProgress', metadata)
self.warning(
f'OPC write rejected reconnect_in_progress opc_server_id={self.id} '
f'model_id={metadata.get("model_id", "unknown")} '
f'model_name={metadata.get("model_name", "unknown")}',
metadata,
)
return False, {
'notification_id': f'OPC_WRITE_RECONNECT_IN_PROGRESS_{self.id}',
'message': f'OPC write skipped: reconnect in progress | metadata: {metadata}',
'block': 'opc_repository',
'level': NotificationLevel.WARNING,
'opc_error_kind': 'reconnect_in_progress',
}
is_connected, error = self.validate_connection()
if not is_connected:
self._emit_opc_write_metric('unknown', 'NotConnected', metadata)
return False, error
start_time = time.time()
session_id = _opc_authentication_token_str(self.client)
try:
assert self.client is not None
node_obj = self.client.get_node(node)
except Exception as e:
if is_reconnectable_opcua_bad(e):
return self._handle_tier1_bad(e, session_id, node, metadata, 'get_node')
trace = traceback.format_exc()
self.error(trace, metadata)
self.error_count += 1
return False, {
'notification_id': f'OPC_WRITE_GET_NODE_ERROR_{self.id}',
'message': f'Failed to get node from OPC server: {e} | metadata: {metadata}',
'block': 'opc_repository',
'level': NotificationLevel.ERROR,
'attachment_content': trace,
}
self._emit_opc_write_metric(session_id, f'GetNodeError:{type(e).__name__}', metadata)
return False, self._write_failure_payload(
notification_id=f'OPC_WRITE_GET_NODE_ERROR_{self.id}',
message=f'Failed to get node from OPC server: {e} | metadata: {metadata}',
attachment_content=trace,
)
if data_type not in data_type_map:
return False, {
'notification_id': f'OPC_WRITE_DATA_TYPE_ERROR_{self.id}',
'message': f'Unsupported data type: {data_type} | metadata: {metadata}',
'block': 'opc_repository',
'level': NotificationLevel.ERROR,
}
self._emit_opc_write_metric(session_id, 'UnsupportedDataType', metadata)
return False, self._write_failure_payload(
notification_id=f'OPC_WRITE_DATA_TYPE_ERROR_{self.id}',
message=f'Unsupported data type: {data_type} | metadata: {metadata}',
)
data = data_type_map[data_type]['converter'](value)
self.info(f'Writing {data} - {type(data)} to {node}', metadata)
variant_type = data_type_map[data_type]['opc_type']
ua_data = ua.DataValue(
ua.Variant(data, variant_type),
)
try:
node_obj.set_value(data, variant_type)
self._write_node_value(node_obj, ua_data, data, variant_type)
end_time = time.time()
response_time = end_time - start_time
except Exception as e:
if is_reconnectable_opcua_bad(e):
return self._handle_tier1_bad(e, session_id, node, metadata, 'write_value')
trace = traceback.format_exc()
self.error(trace, metadata)
self.error_count += 1
return False, {
'notification_id': f'OPC_WRITE_DATA_ERROR_{self.id}',
'message': f'Failed to write data to OPC server: {e} | metadata: {metadata}',
'block': 'opc_repository',
'level': NotificationLevel.ERROR,
'attachment_content': trace,
}
self.error_count = 0
self._emit_opc_write_metric(session_id, type(e).__name__, metadata)
return False, self._write_failure_payload(
notification_id=f'OPC_WRITE_DATA_ERROR_{self.id}',
message=f'Failed to write data to OPC server: {e} | metadata: {metadata}',
attachment_content=trace,
)
self._emit_opc_write_metric(session_id, 'OK', metadata)
self._log_write_inter_arrival(session_id, node)
return True, {
'response_time': response_time,