SIENTIAPDE-1030

Add unit tests for orchestrator activities and workflows

- Implement tests for Activities class, covering initialization and prepare_activity method.
- Create tests for Couchbase class, including successful and failed query loading.
- Add tests for SlotManager class, verifying OPC slot loading and active ingestor retrieval.
- Develop tests for TemporalManager class, focusing on schedule loading functionality.
- Introduce tests for Orchestrator class, ensuring proper execution of workflow activities.
- Establish a new test suite for orchestrator activities and workflows in the tests directory.
This commit is contained in:
vitor-aignosi
2025-05-30 16:24:52 -03:00
parent f6584314b2
commit 4f37b2be02
59 changed files with 1827 additions and 4377 deletions

View File

View File

@@ -0,0 +1,47 @@
from temporalio import activity, workflow
from temporalio.client import Client
with workflow.unsafe.imports_passed_through():
from orchestrator.activities.couchbase import Couchbase
from orchestrator.activities.temporal_manager import TemporalManager
from orchestrator.activities.slot_manager import SlotManager
from typing import Any
from logging import Logger
from sientia_do.notifications.handlers import NotificationHandler
class Activities(Couchbase, TemporalManager, SlotManager):
def __init__(self,
temporal_client: Client,
couchbase_config: dict[str, Any],
redis_config: dict[str, Any],
logger: Logger,
notification_handler: NotificationHandler):
# Initialize parent classes
Couchbase.__init__(self, connection_string=couchbase_config['connection_string'],
username=couchbase_config['username'],
password=couchbase_config['password'],
logger=logger,
notification_handler=notification_handler)
TemporalManager.__init__(self,
temporal_client=temporal_client,
logger=logger,
notification_handler=notification_handler)
SlotManager.__init__(self,
host=redis_config['host'],
port=redis_config['port'],
username=redis_config['username'],
password=redis_config['password'],
logger=logger,
notification_handler=notification_handler)
@activity.defn(name="prepare_activity")
async def prepare_activity(self, input_data: dict[str, Any]):
await super().prepare_activity(input_data)
def shutdown(self):
Couchbase.shutdown(self)

View File

@@ -0,0 +1,94 @@
from temporalio import activity, workflow
with workflow.unsafe.imports_passed_through():
from typing import Any
from logging import Logger
from datetime import timedelta
import json
import traceback
from couchbase.auth import PasswordAuthenticator
from couchbase.cluster import Cluster
from couchbase.options import ClusterOptions
from sientia_do.notifications.handlers import NotificationHandler
from sientia_do.notifications.models import NotificationLevel
from sientia_do.temporal.activities.base import BaseActivity
class Couchbase(BaseActivity):
def __init__(self, connection_string: str, username: str,
password: str, logger: Logger,
notification_handler: NotificationHandler):
self.connection_string = connection_string
self.username = username
self.password = password
logger.info("Initializing Couchbase connection...")
self.cluster = Cluster(
connection_string,
ClusterOptions(
authenticator=PasswordAuthenticator(
username=username,
password=password
)
)
)
logger.info("Awaiting Couchbase connection...")
self.cluster.wait_until_ready(timeout=timedelta(seconds=10))
logger.info("Couchbase connection ready")
BaseActivity.__init__(self,
logger=logger,
notification_handler=notification_handler)
def shutdown(self):
try:
self.cluster.close()
except Exception as e:
self.logger.error("Failed to close Couchbase connection: %s", e)
def __del__(self):
self.shutdown()
@activity.defn(name="load_query_from_couchbase")
async def load_query_from_couchbase(self, input_data: dict[str, Any]) -> list[dict[str, Any]]:
"""
Load a query from couchbase
Args:
input_data (dict[str, Any]): The input data containing the query to execute
Returns:
list[dict[str, Any]]: The result of the query
"""
query = input_data['query']
self.logger.info("Executing couchbase query: %s", query)
try:
result = self.cluster.query(query)
except Exception as e:
trace = traceback.format_exc()
self.notification_handler.build_and_send_notification(
notification_id="COUCHBASE_LOAD_QUERY_ERROR",
message=f"Failed to execute couchbase query: {e}",
block="load_query_from_couchbase",
level=NotificationLevel.ERROR,
attachment_content=trace,
)
self.logger.error(trace)
raise e
rows = []
for row in result.rows():
rows.append(row)
self.logger.info("Fetched %d rows from couchbase", len(rows))
self.logger.debug("Rows: \n %s",
json.dumps(rows, indent=4, sort_keys=True))
return rows

View File

@@ -0,0 +1,130 @@
from temporalio import activity, workflow
with workflow.unsafe.imports_passed_through():
from sientia_do.temporal.activities.base import BaseActivity
from typing import Any
from logging import Logger
from sientia_do.notifications.handlers import NotificationHandler
class Formatters(BaseActivity):
def __init__(self, logger: Logger, notification_handler: NotificationHandler):
BaseActivity.__init__(self, logger=logger,
notification_handler=notification_handler)
@activity.defn(name="process_schedules")
async def process_schedules(self, input_data: dict[str, Any]):
pipelines = input_data['pipelines']
schedule_config = {}
for pipeline in pipelines:
if pipeline['workflow_type'] == 'scouter':
schedule_config[pipeline['schedule_name']] = scouter(pipeline)
return schedule_config
def common_config(config: dict[str, Any]):
return {
"workflow_type": "scouter",
"schedule_name": config['schedule_name'],
"frequency": config.get('frequency', '1m'),
"max_retry_policy": config.get('max_retry_policy', 1),
"model_id": config['model_id'],
"model_name": config['model_name'],
}
def scouter(config: dict[str, Any]):
filters = {}
for f in config['filters']:
filters[f['filter_name']] = {
"policy": f['policy']
}
tags = {}
for tag in config['read_tags']:
tags[tag['tag_name']] = {
"aggr_func": tag.get('aggr_func', 'lts'),
"data_range": tag.get('data_range', [-100, 100])
}
return {
**common_config(config),
"topic": f"raw_{config['schedule_name']}",
"trigger_laborious": False,
"filters": filters,
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": config.get('tag_retention_minutes', 60) * 60,
"model_tags": tags
}
def overlap_filter_config(base_filter_config: dict[str, Any], config: dict[str, Any]):
for fil in config['filters']:
base_filter_config[fil['filter_name']] = {
"policy": fil['policy'],
"config": fil.get('config', {})
}
return base_filter_config
def predictions_batch(config: dict[str, Any]):
tags = {}
for tag in config['write_tags']:
if tag['server_name'] not in tags:
tags[tag['server_name']] = {}
tag_type = tag['type']
if tag_type == 'prediction' or tag_type == 'confidence':
tag_type_str = f"{tag_type}_tags"
if tag_type_str not in tags[tag['server_name']]:
tags[tag['server_name']][tag_type_str] = {}
tags[tag['server_name']][tag_type_str][tag['addr']] = {
"data_type": tag.get('data_type', 'float'),
}
path_priority = config.get('path_priority', ["STOP", "CONTINUE", "REPEAT"])
for priority in path_priority[:]:
if priority not in ["STOP", "CONTINUE", "REPEAT"]:
path_priority.remove(priority)
if len(path_priority) != 3:
for priority in ["STOP", "CONTINUE", "REPEAT"]:
if priority not in path_priority:
path_priority.append(priority)
return {
**common_config(config),
"query": config['query'],
"schema": "sientia_data",
"table_name": "predictions",
"retention_time": config.get('model_retention_minutes', 60) * 60,
"opc_output_config": tags,
"input_filters": overlap_filter_config({
"EMPTY_DATA": {
"policy": "STOP"
}
}, config['input_filters']),
"mlflow_transform_filters": overlap_filter_config({
"API_ERROR": {
"policy": "STOP"
}
}, config['mlflow_transform_filters']),
"mlflow_predict_filters": overlap_filter_config({
"API_ERROR": {
"policy": "STOP"
}
}, config['mlflow_predict_filters']),
"path_priority": path_priority
}

View File

View File

@@ -0,0 +1,73 @@
from temporalio import activity, workflow
with workflow.unsafe.imports_passed_through():
from typing import Any
import json
from logging import Logger
from sientia_do.temporal.activities.redis_base import Redis
from sientia_do.notifications.handlers import NotificationHandler
class SlotManager(Redis):
def __init__(self, host: str, port: int,
username: str, password: str,
logger: Logger, notification_handler: NotificationHandler):
Redis.__init__(self, host, port, username,
password, logger, notification_handler)
@activity.defn(name="load_opc_slots")
async def load_opc_slots(self) -> dict[str, Any]:
"""
Load all OPC slots from Redis
Returns:
dict[str, Any]: A dictionary of OPC slots
"""
self.logger.info("Loading OPC slots...")
opc_slots = {}
slot_keys = self.redis_client.keys("slot:opc_tags:*")
if slot_keys:
decoded_keys = [key.decode('utf-8') for key in slot_keys]
values = self.redis_client.mget(decoded_keys)
for i, key in enumerate(decoded_keys):
value = values[i]
if value is not None:
try:
opc_slots[key] = value.decode('utf-8')
except (UnicodeDecodeError, AttributeError):
opc_slots[key] = value
else:
opc_slots[key] = None
self.logger.info(f"Loaded {len(opc_slots)} OPC slots")
self.logger.debug("OPC slots: \n %s",
json.dumps(opc_slots, indent=4, sort_keys=True))
return opc_slots
@activity.defn(name="load_active_ingestors")
async def load_active_ingestors(self) -> list[str]:
"""
Load all active ingestors from Redis
Returns:
list[str]: A list of active ingestors
"""
self.logger.info("Loading active ingestors...")
active_ingestors = self.redis_client.keys("heartbeat:ingestor:*")
self.logger.info(f"Loaded {len(active_ingestors)} active ingestors")
self.logger.debug("Active ingestors: \n %s", active_ingestors)
return [ingestor.decode('utf-8') for ingestor in active_ingestors]

View File

@@ -0,0 +1,66 @@
from temporalio import activity, workflow
from temporalio.client import Client
with workflow.unsafe.imports_passed_through():
from typing import Any
from logging import Logger
from sientia_do.notifications.handlers import NotificationHandler
from sientia_do.temporal.activities.base import BaseActivity
from google.protobuf.json_format import MessageToDict
import base64
import json
class TemporalManager(BaseActivity):
def __init__(self, temporal_client: Client, logger: Logger,
notification_handler: NotificationHandler):
self.temporal_client = temporal_client
BaseActivity.__init__(self,
logger=logger,
notification_handler=notification_handler)
@activity.defn(name="load_schedule")
async def load_schedule(self) -> dict[str, Any]:
"""
Load all orchestrated schedules from Temporal. Filters by search attribute
"Orchestrated" set to "true" and returns a dictionary of schedule_id:
{frequency, data, handle}
Returns:
dict[str, Any]: A dictionary of orchestrated schedules
"""
self.logger.info("Getting orchestrated schedules...")
orchestrated_schedules = {}
async for schedule in await self.temporal_client.list_schedules():
search_attrs = getattr(schedule, "search_attributes", {})
if search_attrs.get("Orchestrated", ["false"]) == ["true"]:
schedule_id = schedule.id
handle = self.temporal_client.get_schedule(schedule_id)
desc = await handle.describe()
for arg in desc.schedule.action.args:
data = MessageToDict(arg)['data']
data = base64.b64decode(data).decode('utf-8')
frequency = desc.schedule.spec.intervals[0].every.seconds
orchestrated_schedules[schedule_id] = {
'frequency': frequency,
'data': json.loads(data),
'handle': handle
}
self.logger.info("Found %d orchestrated schedules",
len(orchestrated_schedules))
self.logger.debug("Orchestrated schedules: %s",
orchestrated_schedules)
return orchestrated_schedules