Code import - branch release/SIENTIAPDE-1645
This commit is contained in:
0
model_manager/activities/__init__.py
Normal file
0
model_manager/activities/__init__.py
Normal file
166
model_manager/activities/activities.py
Normal file
166
model_manager/activities/activities.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from temporalio import workflow
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
from typing import Any
|
||||
|
||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||
from sientia_do.observability.logger import Logger
|
||||
from sientia_do.observability.metrics_controller import MetricsController
|
||||
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||
from sientia_do.repository.minio_repository_sync import MinioRepository
|
||||
from sientia_model.model_repository.mlflow_repository import SientiaMLflowRepository
|
||||
from sientia_model.model_repository.plugin_store import PluginStore
|
||||
|
||||
from model_manager.activities.cleanup import Cleanup
|
||||
from model_manager.activities.experiment_tracking import ExperimentTracking
|
||||
from model_manager.activities.training import Training
|
||||
|
||||
|
||||
class Activities(ExperimentTracking, Training, Cleanup):
|
||||
"""
|
||||
Main activities orchestrator for the Model Manager system.
|
||||
|
||||
This class combines functionality from multiple activity classes to provide
|
||||
a unified interface for all workflow operations. It manages database connections,
|
||||
MLFlow model interactions, MinIO storage operations, and cleanup operations.
|
||||
|
||||
The class implements multiple inheritance to combine specialized functionality:
|
||||
- ExperimentTracking: ML experiment lifecycle tracking and database operations
|
||||
- Training: ML model training operations with MLFlow and MinIO integration
|
||||
- Cleanup: File and directory cleanup operations for MinIO and local filesystem
|
||||
|
||||
Attributes:
|
||||
postgres_config (dict): PostgreSQL connection configuration
|
||||
mlflow_config (dict): MLFlow server configuration
|
||||
minio_config (dict): MinIO storage configuration
|
||||
logger (Logger): Logging and observability instance
|
||||
notification_handler (NotificationHandler): Notification management instance
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
postgres_config: dict[str, Any],
|
||||
mlflow_config: dict[str, Any],
|
||||
minio_config: dict[str, Any],
|
||||
plugin_store: PluginStore,
|
||||
logger: Logger,
|
||||
notification_handler: NotificationHandler,
|
||||
metrics_controller: MetricsController,
|
||||
):
|
||||
"""
|
||||
Initialize the Activities orchestrator with all required configurations.
|
||||
|
||||
This constructor initializes all parent classes with their respective
|
||||
configurations and sets up the foundation for all activity operations.
|
||||
|
||||
Args:
|
||||
postgres_config: PostgreSQL connection configuration dictionary
|
||||
Required keys: host, port, user, password, dbname, min_connections, max_connections
|
||||
mlflow_config: MLFlow server configuration dictionary
|
||||
Required keys: host, port, username, password
|
||||
minio_config: MinIO storage configuration dictionary
|
||||
Required keys: endpoint_url, access_key, secret_key, region, use_ssl, default_bucket
|
||||
logger: Logger instance for observability and debugging
|
||||
notification_handler: Notification handler for alerts and monitoring
|
||||
|
||||
Raises:
|
||||
Exception: If any parent class initialization fails
|
||||
"""
|
||||
|
||||
ExperimentTracking.__init__(
|
||||
self,
|
||||
host=postgres_config['host'],
|
||||
port=postgres_config['port'],
|
||||
user=postgres_config['user'],
|
||||
password=postgres_config['password'],
|
||||
dbname=postgres_config['dbname'],
|
||||
min_connections=postgres_config['min_connections'],
|
||||
max_connections=postgres_config['max_connections'],
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
)
|
||||
|
||||
self.mlflow_repository = SientiaMLflowRepository(
|
||||
host=mlflow_config['url'],
|
||||
username=mlflow_config['username'],
|
||||
password=mlflow_config['password'],
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
)
|
||||
|
||||
# MinIO repository used for all object storage operations
|
||||
endpoint_url = minio_config['endpoint_url']
|
||||
# MinioRepository expects the endpoint without scheme
|
||||
if endpoint_url.startswith('http://'):
|
||||
endpoint = endpoint_url.removeprefix('http://')
|
||||
elif endpoint_url.startswith('https://'):
|
||||
endpoint = endpoint_url.removeprefix('https://')
|
||||
else:
|
||||
endpoint = endpoint_url
|
||||
|
||||
self.minio_repository = MinioRepository(
|
||||
endpoint=endpoint,
|
||||
access_key=minio_config['access_key'],
|
||||
secret_key=minio_config['secret_key'],
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
secure=minio_config['use_ssl'],
|
||||
bucket=minio_config['default_bucket'],
|
||||
)
|
||||
|
||||
Training.__init__(
|
||||
self,
|
||||
mlflow_repository=self.mlflow_repository,
|
||||
plugin_store=plugin_store,
|
||||
minio_repository=self.minio_repository,
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
)
|
||||
|
||||
Cleanup.__init__(
|
||||
self,
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
)
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Destructor to safely handle cleanup during garbage collection.
|
||||
|
||||
This prevents AttributeError when the parent Postgres.__del__ tries to access
|
||||
self.engine in objects with multiple inheritance. Only attempts cleanup if
|
||||
the engine attribute exists.
|
||||
"""
|
||||
# Only call parent __del__ if engine attribute exists
|
||||
# This prevents AttributeError in multiple inheritance scenarios
|
||||
if hasattr(self, 'engine'):
|
||||
try:
|
||||
# Call parent class __del__ if it exists
|
||||
if hasattr(super(), '__del__'): # pragma: no cover
|
||||
super().__del__() # pragma: no cover
|
||||
except Exception: # noqa: S110, BLE001 # pragma: no cover
|
||||
# Silently ignore errors during garbage collection
|
||||
# Logging here could cause issues if logger is already destroyed
|
||||
pass
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Gracefully shutdown all activities and clean up resources.
|
||||
|
||||
This method ensures proper cleanup of all resources including:
|
||||
- PostgreSQL connection pools (via ExperimentTracking)
|
||||
- Any other resources that need explicit cleanup
|
||||
|
||||
The method should be called before the application terminates to ensure
|
||||
proper resource cleanup and prevent resource leaks.
|
||||
|
||||
Prefer calling this method explicitly rather than relying on __del__.
|
||||
"""
|
||||
ExperimentTracking.close(self)
|
||||
self.info('Postgres client closed')
|
||||
SientiaMonitoring.shutdown(self)
|
||||
177
model_manager/activities/cleanup.py
Normal file
177
model_manager/activities/cleanup.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Cleanup activities for removing stale files from local filesystem.
|
||||
|
||||
This module provides activities for cleaning up temporary files and directories
|
||||
that are older than the configured retention period. It operates independently
|
||||
of the database, using timestamps embedded in filenames.
|
||||
"""
|
||||
|
||||
from temporalio import activity, workflow
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||
from sientia_do.notifications.models import NotificationLevel
|
||||
from sientia_do.observability.logger import Logger
|
||||
from sientia_do.observability.metrics_controller import MetricsController
|
||||
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||
|
||||
from model_manager.runtime_paths import REPORTS_TEMP_DIR
|
||||
|
||||
RETENTION_HOURS = int(os.getenv('CLEANUP_RETENTION_HOURS', '24'))
|
||||
DRY_RUN = os.getenv('CLEANUP_DRY_RUN', 'false').lower() == 'true'
|
||||
|
||||
|
||||
class Cleanup(SientiaMonitoring):
|
||||
"""
|
||||
Activity for cleaning up stale files and directories.
|
||||
|
||||
This activity extends SientiaMonitoring and handles cleanup of:
|
||||
- Local temporary directories with timestamp suffixes
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
logger: Logger,
|
||||
notification_handler: NotificationHandler,
|
||||
metrics_controller: MetricsController,
|
||||
):
|
||||
"""
|
||||
Initialize Cleanup activity.
|
||||
|
||||
Args:
|
||||
logger: Logger instance for observability
|
||||
notification_handler: Handler for sending notifications
|
||||
metrics_controller: Controller for metrics emission
|
||||
"""
|
||||
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
|
||||
|
||||
# Configuration from environment variables
|
||||
self.retention_hours = RETENTION_HOURS
|
||||
self.dry_run = DRY_RUN
|
||||
|
||||
# Regex patterns for timestamp extraction
|
||||
self.dir_timestamp_pattern = re.compile(
|
||||
r'^(.+)_(\d{8}_\d{6}_\d{6})$'
|
||||
) # name_YYYYMMDD_HHMMSS_microseconds
|
||||
|
||||
@activity.defn(name='cleanup_temp_directories')
|
||||
def cleanup_temp_directories(self, input_data: dict[str, Any]) -> None:
|
||||
"""
|
||||
Clean up stale temporary directories based on timestamp in directory name.
|
||||
|
||||
This activity scans the reports/temp directory for subdirectories following
|
||||
the pattern '{name}_{timestamp}' where timestamp is in YYYYMMDD_HHMMSS_microseconds format.
|
||||
Directories older than the retention period are deleted.
|
||||
|
||||
Args:
|
||||
input_data: Cleanup configuration containing:
|
||||
- metadata (dict): Workflow execution metadata
|
||||
- temp_path (str): Path to temp directory (optional, defaults to reports/temp)
|
||||
|
||||
Returns:
|
||||
None: Results are logged and tracked via metrics
|
||||
|
||||
Raises:
|
||||
Exception: If cleanup fails (after sending notification)
|
||||
"""
|
||||
metadata = input_data.get('metadata', {})
|
||||
temp_path = input_data.get('temp_path', REPORTS_TEMP_DIR)
|
||||
|
||||
cutoff_time = datetime.now() - timedelta(hours=self.retention_hours)
|
||||
|
||||
try:
|
||||
self.info(
|
||||
f'Starting local directory cleanup - Path: {temp_path}, '
|
||||
f'Retention: {self.retention_hours}h, Dry run: {self.dry_run}',
|
||||
metadata,
|
||||
)
|
||||
|
||||
if not os.path.exists(temp_path):
|
||||
self.warning(f'Temp directory does not exist: {temp_path}', metadata)
|
||||
return
|
||||
|
||||
directories_scanned = 0
|
||||
directories_deleted = 0
|
||||
errors = []
|
||||
|
||||
for item_name in os.listdir(temp_path):
|
||||
item_path = os.path.join(temp_path, item_name)
|
||||
|
||||
if not os.path.isdir(item_path):
|
||||
continue
|
||||
|
||||
directories_scanned += 1
|
||||
|
||||
# Extract timestamp from directory name
|
||||
match = self.dir_timestamp_pattern.match(item_name)
|
||||
if not match:
|
||||
self.debug(
|
||||
f'Skipping directory without timestamp pattern: {item_name}', metadata
|
||||
)
|
||||
continue
|
||||
|
||||
timestamp_str = match.group(2)
|
||||
try:
|
||||
# Parse YYYYMMDD_HHMMSS_microseconds
|
||||
dir_time = datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S_%f')
|
||||
|
||||
if dir_time < cutoff_time:
|
||||
age_hours = (datetime.now() - dir_time).total_seconds() / 3600
|
||||
|
||||
if self.dry_run:
|
||||
self.info(
|
||||
f'[DRY RUN] Would delete directory: {item_name} (age: {age_hours:.1f}h)',
|
||||
metadata,
|
||||
)
|
||||
directories_deleted += 1
|
||||
else:
|
||||
try:
|
||||
shutil.rmtree(item_path)
|
||||
self.info(
|
||||
f'Deleted stale directory: {item_name} (age: {age_hours:.1f}h)',
|
||||
metadata,
|
||||
)
|
||||
directories_deleted += 1
|
||||
except OSError as e:
|
||||
error_msg = f'Failed to delete directory {item_name}: {str(e)}'
|
||||
errors.append(error_msg)
|
||||
self.error(error_msg, metadata)
|
||||
else:
|
||||
age_hours = (datetime.now() - dir_time).total_seconds() / 3600
|
||||
self.debug(
|
||||
f'Keeping recent directory: {item_name} (age: {age_hours:.1f}h)',
|
||||
metadata,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
error_msg = f'Invalid timestamp format in directory {item_name}: {str(e)}'
|
||||
errors.append(error_msg)
|
||||
self.error(error_msg, metadata)
|
||||
|
||||
self.info(
|
||||
f'Directory cleanup completed - Scanned: {directories_scanned}, '
|
||||
f'Deleted: {directories_deleted}, Errors: {len(errors)}',
|
||||
metadata,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'Error in directory cleanup: {str(e)}'
|
||||
trace = traceback.format_exc()
|
||||
|
||||
self.send_notification(
|
||||
metadata=metadata,
|
||||
notification_id='CLEANUP_DIRECTORIES_ERROR',
|
||||
message=error_msg,
|
||||
block='cleanup_temp_directories',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=trace,
|
||||
)
|
||||
|
||||
raise
|
||||
280
model_manager/activities/experiment_tracking.py
Normal file
280
model_manager/activities/experiment_tracking.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Experiment tracking activities for managing ML experiment lifecycle.
|
||||
|
||||
This module provides activities for tracking and updating experiment run status
|
||||
in the PostgreSQL database, extending the synchronous Postgres client with specialized
|
||||
methods for experiment management.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from temporalio import activity, workflow
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
import traceback
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||
from sientia_do.notifications.models import NotificationLevel
|
||||
from sientia_do.observability.logger import Logger
|
||||
from sientia_do.observability.metrics_controller import MetricsController
|
||||
from sientia_do.temporal.activities.postgres_sync import Postgres
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
class UpdateType(enum.StrEnum):
|
||||
"""Types of experiment run updates."""
|
||||
|
||||
STATUS = 'status'
|
||||
STATUS_WITH_ERROR = 'status_with_error'
|
||||
MODEL_SAVED = 'model_saved'
|
||||
|
||||
|
||||
class ExperimentTracking(Postgres):
|
||||
"""
|
||||
Activity for tracking ML experiment lifecycle and status updates.
|
||||
|
||||
This activity extends the Postgres activity to provide specialized methods
|
||||
for managing experiment runs, including status updates, error tracking, and
|
||||
model registration. It maintains the experiment lifecycle from initialization
|
||||
through training, model saving, and cleanup.
|
||||
|
||||
The activity uses a SQLAlchemy engine / connection pool and adds experiment-specific
|
||||
operations with proper error handling and notifications.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
user: str,
|
||||
password: str,
|
||||
dbname: str,
|
||||
min_connections: int,
|
||||
max_connections: int,
|
||||
logger: Logger,
|
||||
notification_handler: NotificationHandler,
|
||||
metrics_controller: MetricsController,
|
||||
):
|
||||
"""
|
||||
Initialize ExperimentTracking activity with database configuration.
|
||||
|
||||
Args:
|
||||
host: PostgreSQL server hostname
|
||||
port: PostgreSQL server port
|
||||
user: Database user
|
||||
password: Database password
|
||||
dbname: Database name
|
||||
min_connections: Minimum connections in pool
|
||||
max_connections: Maximum connections in pool
|
||||
logger: Logger instance for observability
|
||||
notification_handler: Notification handler for alerts
|
||||
metrics_controller: Metrics controller for observability
|
||||
|
||||
Raises:
|
||||
ConnectionError: If database connection cannot be established
|
||||
"""
|
||||
Postgres.__init__(
|
||||
self,
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
dbname=dbname,
|
||||
min_connections=min_connections,
|
||||
max_connections=max_connections,
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
)
|
||||
|
||||
self.info(f'Postgres client initialized at {host}:{port}')
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Destructor to safely handle cleanup during garbage collection.
|
||||
|
||||
This prevents AttributeError when used in multiple inheritance scenarios
|
||||
where the parent Postgres.__del__ might be called on objects without
|
||||
the engine attribute.
|
||||
"""
|
||||
# Only call parent __del__ if engine attribute exists
|
||||
if hasattr(self, 'engine'):
|
||||
try:
|
||||
if hasattr(super(), '__del__'):
|
||||
super().__del__()
|
||||
except Exception: # noqa: S110, BLE001
|
||||
# Silently ignore errors during garbage collection
|
||||
pass
|
||||
|
||||
def _execute_update(self, query: str, params: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Execute an UPDATE SQL statement.
|
||||
|
||||
Args:
|
||||
query: Parameterized SQL string to execute.
|
||||
params: Mapping of parameters for the SQL query.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the affected row count: {'rowcount': int}.
|
||||
"""
|
||||
|
||||
with self.engine.begin() as connection:
|
||||
result = connection.execute(text(query), params)
|
||||
return {'rowcount': result.rowcount}
|
||||
|
||||
def _build_status_update_query(
|
||||
self, status: str | None, experiment_run_id: int
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""Build SQL query for simple status update."""
|
||||
if not isinstance(status, str) or not status:
|
||||
raise ValueError('status is required for STATUS update type')
|
||||
|
||||
sql_query = """
|
||||
UPDATE experiment_run
|
||||
SET status = :status, updated_at = :updated_at
|
||||
WHERE id = :experiment_run_id
|
||||
"""
|
||||
|
||||
query_params = {
|
||||
'status': status,
|
||||
'updated_at': datetime.now(UTC),
|
||||
'experiment_run_id': experiment_run_id,
|
||||
}
|
||||
|
||||
return sql_query, query_params
|
||||
|
||||
def _build_status_with_error_query(
|
||||
self, status: str | None, error_message: str | None, experiment_run_id: int
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""Build SQL query for status update with error message."""
|
||||
if not isinstance(status, str) or not status:
|
||||
raise ValueError('status is required for STATUS_WITH_ERROR update type')
|
||||
|
||||
if not isinstance(error_message, str) or not error_message:
|
||||
raise ValueError('error_message is required for STATUS_WITH_ERROR update type')
|
||||
|
||||
# Truncate error message if too long
|
||||
truncated_error = error_message[:1024] if len(error_message) > 1024 else error_message
|
||||
|
||||
sql_query = """
|
||||
UPDATE experiment_run
|
||||
SET status = :status, error_message = :error_message, updated_at = :updated_at
|
||||
WHERE id = :experiment_run_id
|
||||
"""
|
||||
|
||||
query_params = {
|
||||
'status': status,
|
||||
'error_message': truncated_error,
|
||||
'updated_at': datetime.now(UTC),
|
||||
'experiment_run_id': experiment_run_id,
|
||||
}
|
||||
|
||||
return sql_query, query_params
|
||||
|
||||
def _build_model_saved_query(
|
||||
self, run_name: str | None, status: str | None, experiment_run_id: int
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""Build SQL query for model saved update."""
|
||||
if not isinstance(run_name, str) or not run_name:
|
||||
raise ValueError('run_name is required for MODEL_SAVED update type')
|
||||
|
||||
if not isinstance(status, str) or not status:
|
||||
raise ValueError('status is required for MODEL_SAVED update type')
|
||||
|
||||
sql_query = """
|
||||
UPDATE experiment_run
|
||||
SET run_name = :run_name, status = :status, updated_at = :updated_at
|
||||
WHERE id = :experiment_run_id
|
||||
"""
|
||||
|
||||
query_params = {
|
||||
'run_name': run_name,
|
||||
'status': status,
|
||||
'updated_at': datetime.now(UTC),
|
||||
'experiment_run_id': experiment_run_id,
|
||||
}
|
||||
|
||||
return sql_query, query_params
|
||||
|
||||
def _get_update_query_and_params(
|
||||
self, update_type: str, experiment_run_id: int, input_data: dict[str, Any]
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""Get SQL query and parameters based on update type."""
|
||||
status = input_data.get('status')
|
||||
error_message = input_data.get('error_message')
|
||||
run_name = input_data.get('run_name')
|
||||
|
||||
if update_type == UpdateType.STATUS:
|
||||
return self._build_status_update_query(status, experiment_run_id)
|
||||
|
||||
if update_type == UpdateType.STATUS_WITH_ERROR:
|
||||
return self._build_status_with_error_query(status, error_message, experiment_run_id)
|
||||
|
||||
if update_type == UpdateType.MODEL_SAVED:
|
||||
return self._build_model_saved_query(run_name, status, experiment_run_id)
|
||||
|
||||
raise ValueError(f'Invalid update_type: {update_type}')
|
||||
|
||||
@activity.defn(name='update_experiment_run')
|
||||
def update_experiment_run(self, input_data: dict[str, Any]) -> None:
|
||||
"""
|
||||
Update experiment run with status, errors, or model information.
|
||||
|
||||
This activity provides a unified interface for all experiment run updates,
|
||||
supporting different update types through a single method. It automatically
|
||||
selects the appropriate SQL query based on the update type and parameters.
|
||||
|
||||
Args:
|
||||
input_data: Configuration for experiment run update operation
|
||||
Required keys:
|
||||
- metadata (dict): Workflow execution metadata
|
||||
- experiment_run_id (int): Unique identifier for the experiment run
|
||||
- update_type (str): Type of update (status, status_with_error, model_saved)
|
||||
Optional keys:
|
||||
- status (str): New status for the experiment run
|
||||
- error_message (str): Error message if update failed
|
||||
- run_name (str): MLFlow run name if model was saved
|
||||
|
||||
Raises:
|
||||
ValueError: If required parameters are missing for the update type
|
||||
RuntimeError: If update operation fails
|
||||
"""
|
||||
metadata = input_data.get('metadata')
|
||||
experiment_run_id = input_data['experiment_run_id']
|
||||
update_type = input_data['update_type']
|
||||
status = input_data.get('status')
|
||||
|
||||
try:
|
||||
sql_query, query_params = self._get_update_query_and_params(
|
||||
update_type, experiment_run_id, input_data
|
||||
)
|
||||
|
||||
result = self._execute_update(sql_query, query_params)
|
||||
|
||||
if result.get('rowcount', 0) == 0:
|
||||
error_msg = (
|
||||
f'No experiment_run row updated for id={experiment_run_id} '
|
||||
f'(row missing or id mismatch). update_type={update_type!r}, status={status!r}.'
|
||||
)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
self.info(
|
||||
f'Successfully updated experiment run {experiment_run_id} with status {status}',
|
||||
metadata,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
error_msg = f'Error updating experiment run - ID: {experiment_run_id}, Status: {status}, Error: {str(e)}'
|
||||
trace = traceback.format_exc()
|
||||
|
||||
self.send_notification(
|
||||
metadata=metadata or {},
|
||||
notification_id='UPDATE_EXPERIMENT_RUN_ERROR',
|
||||
message=error_msg,
|
||||
block='update_experiment_run',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=trace,
|
||||
)
|
||||
raise RuntimeError(error_msg) from e
|
||||
497
model_manager/activities/training.py
Normal file
497
model_manager/activities/training.py
Normal file
@@ -0,0 +1,497 @@
|
||||
"""
|
||||
Training activities for ML model training operations.
|
||||
|
||||
This module provides activities for training machine learning models.
|
||||
The activity extends BaseActivity and receives pre-downloaded files
|
||||
and raises `ModelTrainingError` when training fails.
|
||||
"""
|
||||
|
||||
from sientia_model.wrappers.sientia_model import SientiaModel
|
||||
from temporalio import activity, workflow
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
import mlflow
|
||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||
from sientia_do.notifications.models import NotificationLevel
|
||||
from sientia_do.observability.logger import Logger
|
||||
from sientia_do.observability.metrics_controller import MetricsController
|
||||
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||
from sientia_do.repository.minio_repository_sync import MinioRepository
|
||||
from sientia_model.model_repository.mlflow_repository import SientiaMLflowRepository
|
||||
from sientia_model.model_repository.plugin_store import PluginStore
|
||||
|
||||
from model_manager import metrics as mm_metrics
|
||||
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||
from model_manager.utils.models.train_model_result import TrainModelResult
|
||||
from model_manager.utils.repository.data_manager_repository import DataManagerRepository
|
||||
|
||||
|
||||
class Training(SientiaMonitoring):
|
||||
"""
|
||||
Activity for ML model training operations.
|
||||
|
||||
This activity extends SientiaMonitoring and handles machine learning model
|
||||
training with comprehensive error handling. It receives pre-downloaded
|
||||
files from the workflow and raises `ModelTrainingError` on failure so the
|
||||
workflow can map the correct experiment status.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mlflow_repository: SientiaMLflowRepository,
|
||||
plugin_store: PluginStore,
|
||||
minio_repository: MinioRepository,
|
||||
logger: Logger,
|
||||
notification_handler: NotificationHandler,
|
||||
metrics_controller: MetricsController,
|
||||
):
|
||||
"""
|
||||
Initialize Training activity.
|
||||
|
||||
Args:
|
||||
logger: Logger instance for observability
|
||||
notification_handler: Handler for sending notifications
|
||||
"""
|
||||
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
|
||||
self.data_manager_repository = DataManagerRepository(logger)
|
||||
self.mlflow_repository = mlflow_repository
|
||||
self.plugin_store = plugin_store
|
||||
self.minio_repository = minio_repository
|
||||
|
||||
@activity.defn(name='load_model_metadata')
|
||||
def load_model_metadata(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Load model metadata/schemas from the model store.
|
||||
|
||||
This activity is responsible for fetching model metadata/schemas from the
|
||||
model store index and extracting a serializable `model_metadata` dict that
|
||||
`TrainModelParams.validate_business_rules()` depends on.
|
||||
|
||||
Args:
|
||||
input_data: Workflow input at the same level as `validate_train_params`,
|
||||
including at least `model_name` and the fields required by
|
||||
`TrainModelParams.from_dict` to build wrapper kwargs.
|
||||
|
||||
Return:
|
||||
dict[str, Any]: Updated `input_data` containing `input_data['model_metadata']`.
|
||||
"""
|
||||
metadata = input_data.get('metadata', {})
|
||||
|
||||
self.info(f'Loading model metadata for {input_data}', metadata)
|
||||
|
||||
try:
|
||||
train_params = TrainModelParams.from_dict(input_data)
|
||||
model_metadata = self.plugin_store.get_model_index(
|
||||
model_type=train_params.model_type,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
train_params.model_metadata = model_metadata
|
||||
self.info(f'Model metadata loaded successfully for {input_data}', metadata)
|
||||
self.debug(f'Model metadata: {model_metadata}', metadata)
|
||||
return train_params.to_dict()
|
||||
except Exception as exc:
|
||||
trace = traceback.format_exc()
|
||||
self.send_notification(
|
||||
metadata=metadata,
|
||||
notification_id='LOAD_MODEL_METADATA_ERROR',
|
||||
message=f'Error loading model metadata: {str(exc)}',
|
||||
block='load_model_metadata',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=trace,
|
||||
)
|
||||
raise
|
||||
|
||||
@activity.defn(name='validate_train_params')
|
||||
def validate_train_params(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Validate and convert training parameters from dict to TrainModelParams.
|
||||
|
||||
This activity validates the input training parameters and converts them
|
||||
to a TrainModelParams object.
|
||||
|
||||
Args:
|
||||
input_data: Training parameters and metadata at the same level
|
||||
Required keys:
|
||||
- metadata (dict): Workflow execution metadata
|
||||
- All TrainModelParams fields (experiment_run_id, target_variable, etc.)
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Validated and converted training parameters as dictionary
|
||||
|
||||
Raises:
|
||||
Exception: If validation fails (after sending notification)
|
||||
"""
|
||||
metadata = input_data.get('metadata', {})
|
||||
|
||||
self.info(f'Validating training parameters for {input_data}', metadata)
|
||||
|
||||
try:
|
||||
train_params = TrainModelParams.from_dict(input_data)
|
||||
|
||||
train_params.validate_business_rules()
|
||||
|
||||
self.info(
|
||||
f'Training parameters validated successfully - '
|
||||
f'Target: {train_params.target_variable}, '
|
||||
f'Experiment: {train_params.experiment_name}',
|
||||
metadata,
|
||||
)
|
||||
|
||||
self.debug(
|
||||
f'Training parameters validated successfully: {train_params.to_dict()}', metadata
|
||||
)
|
||||
|
||||
return train_params.to_dict()
|
||||
except Exception as e:
|
||||
error_msg = f'Error validating training parameters: {str(e)}'
|
||||
trace = traceback.format_exc()
|
||||
|
||||
self.send_notification(
|
||||
metadata=metadata,
|
||||
notification_id='VALIDATE_TRAIN_PARAMS_ERROR',
|
||||
message=error_msg,
|
||||
block='validate_train_params',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=trace,
|
||||
)
|
||||
raise
|
||||
|
||||
@activity.defn(name='train_model')
|
||||
def train_model(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Train a machine learning model.
|
||||
|
||||
This activity orchestrates the ML training pipeline:
|
||||
1. Validate input parameters.
|
||||
2. Prepare data via DataManagerRepository.
|
||||
3. Train the model and compute metrics.
|
||||
|
||||
Args:
|
||||
input_data: Training configuration containing:
|
||||
- metadata (dict): Workflow execution metadata.
|
||||
- uploaded_file (BytesIO): Training data already downloaded from MinIO.
|
||||
- train_params (dict): Training parameters.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Serializable summary (run identifiers, run_dir for cleanup, regression metrics).
|
||||
|
||||
Raises:
|
||||
ValueError: If input validation fails.
|
||||
Exception: If training fails (after sending notification).
|
||||
"""
|
||||
metadata = input_data.get('metadata')
|
||||
train_params = TrainModelParams.from_dict(input_data['train_params'])
|
||||
labels = self._get_training_labels(train_params)
|
||||
|
||||
self.info('Starting train_model process', metadata)
|
||||
|
||||
try:
|
||||
# Download training file bytes from MinIO
|
||||
self.info(
|
||||
f'Downloading training file from MinIO for {train_params.file_name}', metadata
|
||||
)
|
||||
train_bytes = self.minio_repository.download_file(
|
||||
object_name=train_params.file_name,
|
||||
bucket=train_params.bucket_name,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# Download optional validation file bytes from the same bucket
|
||||
val_bytes: bytes | None = None
|
||||
validation_name = train_params.val_file_name
|
||||
if validation_name is not None:
|
||||
self.info(f'Downloading validation file from MinIO for {validation_name}', metadata)
|
||||
val_bytes = self.minio_repository.download_file(
|
||||
object_name=validation_name,
|
||||
bucket=train_params.bucket_name,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
self.info(f'Preparing training data for {train_params.file_name}', metadata)
|
||||
train_result = self._prepare_data(train_bytes, val_bytes, train_params, metadata)
|
||||
|
||||
mm_metrics.SIENTIA_TRAINING_DATASET_TRAIN_ROWS.labels(**labels).set(
|
||||
len(train_result.train_data)
|
||||
)
|
||||
mm_metrics.SIENTIA_TRAINING_DATASET_VAL_ROWS.labels(**labels).set(
|
||||
len(train_result.val_data)
|
||||
)
|
||||
mm_metrics.SIENTIA_TRAINING_FEATURE_COUNT.labels(**labels).set(
|
||||
len(train_params.variable_columns)
|
||||
)
|
||||
|
||||
self.info(f'Getting model wrapper for {train_params.model_type}', metadata)
|
||||
wrapper = self.plugin_store.get_model(
|
||||
model_type=train_params.model_type,
|
||||
force_download=False,
|
||||
opt_params=train_params.opt_params or {},
|
||||
model_kwargs=train_params.model_kwargs or {},
|
||||
data_model_kwargs=train_params.data_model_kwargs or {},
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
if self.logger is not None:
|
||||
wrapper.logger = self.logger.base_logger
|
||||
|
||||
self.info(f'Training model for {train_params.model_type}', metadata)
|
||||
train_result = self._fit_model(wrapper, train_result, train_params, metadata)
|
||||
|
||||
self.info(f'Computing regression metrics for {train_params.model_type}', metadata)
|
||||
train_result = self.data_manager_repository.compute_regression_metrics(
|
||||
train_result,
|
||||
wrapper,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
if train_result.mse_val is not None:
|
||||
mm_metrics.SIENTIA_TRAINING_MODEL_QUALITY_MSE.labels(**labels).set(
|
||||
train_result.mse_val
|
||||
)
|
||||
if train_result.mae_val is not None:
|
||||
mm_metrics.SIENTIA_TRAINING_MODEL_QUALITY_MAE.labels(**labels).set(
|
||||
train_result.mae_val
|
||||
)
|
||||
if train_result.r2_val is not None:
|
||||
mm_metrics.SIENTIA_TRAINING_MODEL_QUALITY_R2.labels(**labels).set(
|
||||
train_result.r2_val
|
||||
)
|
||||
|
||||
mm_metrics.SIENTIA_TRAINING_INFO.labels(
|
||||
pod_id=labels['pod_id'],
|
||||
model_name=train_params.model_name,
|
||||
model_type=train_params.model_type,
|
||||
dataset_train_rows=str(len(train_result.train_data)),
|
||||
dataset_val_rows=str(len(train_result.val_data)),
|
||||
feature_count=str(len(train_params.variable_columns)),
|
||||
mse=str(train_result.mse_val) if train_result.mse_val is not None else '',
|
||||
mae=str(train_result.mae_val) if train_result.mae_val is not None else '',
|
||||
r2=str(train_result.r2_val) if train_result.r2_val is not None else '',
|
||||
).set(time.time() * 1000)
|
||||
|
||||
self.info(f'Starting MLflow run for {train_params.model_type}', metadata)
|
||||
with self.mlflow_repository.start_run(
|
||||
model_name=train_params.model_name,
|
||||
run_name=train_result.run_name,
|
||||
experiment_name=train_result.experiment_name,
|
||||
tags=None,
|
||||
metadata=metadata,
|
||||
) as run_info:
|
||||
train_result.run_id = run_info.run_id
|
||||
self._persist_training_artifacts(train_result, train_params, wrapper, metadata)
|
||||
|
||||
self.emit_metric_sync(
|
||||
metric_object=mm_metrics.SIENTIA_TRAINING_MODEL_TRAINED_TOTAL,
|
||||
tags=labels,
|
||||
)
|
||||
|
||||
return {
|
||||
'run_name': train_result.run_name,
|
||||
'experiment_name': train_result.experiment_name,
|
||||
'run_id': train_result.run_id,
|
||||
'run_dir': train_result.run_dir,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
error_msg = f'Error training model - error: {str(e)}'
|
||||
|
||||
trace = traceback.format_exc()
|
||||
|
||||
self.send_notification(
|
||||
metadata=metadata or {},
|
||||
notification_id='TRAIN_MODEL_ERROR',
|
||||
message=error_msg,
|
||||
block='train_model',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=trace,
|
||||
)
|
||||
|
||||
raise e
|
||||
|
||||
def _get_training_labels(self, train_params: TrainModelParams) -> dict:
|
||||
return {
|
||||
'pod_id': os.getenv('HOSTNAME', 'localhost'),
|
||||
'model_name': train_params.model_name,
|
||||
'model_type': train_params.model_type,
|
||||
}
|
||||
|
||||
def _prepare_data(
|
||||
self,
|
||||
train_bytes: bytes,
|
||||
val_bytes: bytes | None,
|
||||
train_params: TrainModelParams,
|
||||
metadata: dict | None,
|
||||
) -> TrainModelResult:
|
||||
labels = self._get_training_labels(train_params)
|
||||
start_time = time.time()
|
||||
try:
|
||||
return self.data_manager_repository.prepare_training_data(
|
||||
train_file_bytes=train_bytes,
|
||||
validation_file_bytes=val_bytes,
|
||||
params=train_params,
|
||||
metadata=metadata,
|
||||
)
|
||||
except Exception:
|
||||
self.emit_metric_sync(
|
||||
metric_object=mm_metrics.SIENTIA_TRAINING_DATA_PREPARATION_ERROR_COUNT_TOTAL,
|
||||
tags=labels,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
self.observe_lag_sync(
|
||||
start_time,
|
||||
mm_metrics.SIENTIA_TRAINING_DATA_PREPARATION_LAG,
|
||||
labels,
|
||||
)
|
||||
|
||||
def _fit_model(
|
||||
self,
|
||||
wrapper: Any,
|
||||
train_result: TrainModelResult,
|
||||
train_params: TrainModelParams,
|
||||
metadata: dict | None,
|
||||
) -> TrainModelResult:
|
||||
labels = self._get_training_labels(train_params)
|
||||
train_data = train_result.train_data
|
||||
val_data = train_result.val_data
|
||||
|
||||
self.debug(
|
||||
f'train_model prepared data (head 10):\ntrain:\n{train_data.head(10).to_string()}'
|
||||
f'\nval:\n{val_data.head(10).to_string()}',
|
||||
metadata,
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
wrapper.train(
|
||||
train_data=train_data,
|
||||
val_data=val_data,
|
||||
target=train_params.target_variable,
|
||||
)
|
||||
|
||||
self.info(
|
||||
f'Generating predictions using the trained wrapper for {train_params.model_type}',
|
||||
metadata,
|
||||
)
|
||||
transformed_train, _ = wrapper.transform(train_data)
|
||||
transformed_val, _ = wrapper.transform(val_data)
|
||||
|
||||
self.debug(
|
||||
f'train_model transform (head 10):\ntrain:\n{transformed_train.head(10).to_string()}'
|
||||
f'\nval:\n{transformed_val.head(10).to_string()}',
|
||||
metadata,
|
||||
)
|
||||
|
||||
y_train_pred_df, _ = wrapper.predict({}, transformed_train)
|
||||
y_val_pred_df, _ = wrapper.predict({}, transformed_val)
|
||||
|
||||
self.debug(
|
||||
f'train_model predict (head 10):\ntrain:\n{y_train_pred_df.head(10).to_string()}'
|
||||
f'\nval:\n{y_val_pred_df.head(10).to_string()}',
|
||||
metadata,
|
||||
)
|
||||
|
||||
y_train_pred_df.sort_index(inplace=True, ascending=False)
|
||||
y_val_pred_df.sort_index(inplace=True, ascending=False)
|
||||
|
||||
train_result.y_train_pred = y_train_pred_df
|
||||
train_result.y_pred = y_val_pred_df
|
||||
return train_result
|
||||
except Exception:
|
||||
self.emit_metric_sync(
|
||||
metric_object=mm_metrics.SIENTIA_TRAINING_MODEL_FIT_ERROR_COUNT_TOTAL,
|
||||
tags=labels,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
self.observe_lag_sync(
|
||||
start_time,
|
||||
mm_metrics.SIENTIA_TRAINING_MODEL_FIT_LAG,
|
||||
labels,
|
||||
)
|
||||
|
||||
def _persist_training_artifacts(
|
||||
self,
|
||||
train_result: TrainModelResult,
|
||||
train_params: TrainModelParams,
|
||||
wrapper: SientiaModel,
|
||||
metadata: dict[str, Any] | None,
|
||||
) -> None:
|
||||
self.info(f'Generating report for {train_params.model_type}', metadata)
|
||||
train_result = self.data_manager_repository.generate_report(
|
||||
train_result,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
if (
|
||||
train_result.report_path is None
|
||||
or train_result.train_data_path is None
|
||||
or train_result.test_data_path is None
|
||||
):
|
||||
raise ValueError('Report path, train data path, or test data path is not set')
|
||||
|
||||
self.info(f'Storing model for {train_params.model_type}', metadata)
|
||||
wrapper._input_example = None
|
||||
wrapper.store_model(name=train_params.model_name)
|
||||
self._log_regression_metrics_as_params(train_result)
|
||||
self.info(f'Logging artifacts for {train_params.model_type}', metadata)
|
||||
mlflow.log_artifact(train_result.report_path)
|
||||
mlflow.log_artifact(train_result.train_data_path)
|
||||
mlflow.log_artifact(train_result.test_data_path)
|
||||
if train_result.equation_path is not None:
|
||||
mlflow.log_artifact(train_result.equation_path)
|
||||
|
||||
def _log_regression_metrics_as_params(self, train_result: TrainModelResult) -> None:
|
||||
"""
|
||||
Persist computed regression metrics as MLflow params.
|
||||
|
||||
Args:
|
||||
train_result: Training output containing computed regression metrics.
|
||||
"""
|
||||
metric_params = {
|
||||
'mse_val': train_result.mse_val,
|
||||
'mae_val': train_result.mae_val,
|
||||
'r2_val': train_result.r2_val,
|
||||
}
|
||||
|
||||
for key, value in metric_params.items():
|
||||
if value is not None:
|
||||
mlflow.log_param(key, value)
|
||||
|
||||
@activity.defn(name='cleanup_resources')
|
||||
def cleanup_resources(self, input_data: dict[str, Any]) -> None:
|
||||
"""
|
||||
Cleanup temporary resources created during training.
|
||||
|
||||
Args:
|
||||
input_data: Cleanup configuration containing:
|
||||
- metadata (dict): Workflow execution metadata.
|
||||
- run_dir (str): Temporary directory to remove.
|
||||
|
||||
Raises:
|
||||
Exception: If cleanup fails (after sending notification).
|
||||
"""
|
||||
metadata = input_data.get('metadata', {})
|
||||
run_dir = input_data.get('run_dir', '')
|
||||
|
||||
try:
|
||||
self.data_manager_repository.cleanup_run_directory(run_dir, metadata)
|
||||
except Exception as e: # noqa: BLE001
|
||||
error_msg = f'Error cleaning up resources - Run directory: {run_dir}, Error: {str(e)}'
|
||||
|
||||
trace = traceback.format_exc()
|
||||
|
||||
self.send_notification(
|
||||
metadata=metadata,
|
||||
notification_id='CLEANUP_RESOURCES_ERROR',
|
||||
message=error_msg,
|
||||
block='cleanup_resources',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=trace,
|
||||
)
|
||||
|
||||
raise
|
||||
Reference in New Issue
Block a user