SIENTIAPDE-1255: Implement object destructors for Activities and ExperimentTracking to prevent AttributeError during garbage collection, and mock workflow logger in tests to avoid RuntimeWarning.

This commit is contained in:
Bruno Domingues
2025-10-20 17:53:49 -03:00
parent 52be5c4e8e
commit 4a32d712ba
3 changed files with 57 additions and 0 deletions

View File

@@ -102,6 +102,26 @@ class Activities(ExperimentTracking, MLFlow, MinIO, Training):
Training.__init__(self, logger=logger, notification_handler=notification_handler) Training.__init__(self, logger=logger, notification_handler=notification_handler)
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__'):
super().__del__()
except Exception: # noqa: S110, BLE001
# Silently ignore errors during garbage collection
# Logging here could cause issues if logger is already destroyed
pass
async def shutdown(self): async def shutdown(self):
""" """
Gracefully shutdown all activities and clean up resources. Gracefully shutdown all activities and clean up resources.
@@ -112,5 +132,7 @@ class Activities(ExperimentTracking, MLFlow, MinIO, Training):
The method should be called before the application terminates to ensure The method should be called before the application terminates to ensure
proper resource cleanup and prevent resource leaks. proper resource cleanup and prevent resource leaks.
Prefer calling this method explicitly rather than relying on __del__.
""" """
ExperimentTracking.close(self) ExperimentTracking.close(self)

View File

@@ -88,6 +88,23 @@ class ExperimentTracking(Postgres):
self.logger = logger self.logger = logger
self.notification_handler = notification_handler self.notification_handler = notification_handler
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
@activity.defn(name='update_experiment_run') @activity.defn(name='update_experiment_run')
async def update_experiment_run(self, input_data: dict[str, Any]) -> None: async def update_experiment_run(self, input_data: dict[str, Any]) -> None:
""" """

View File

@@ -89,6 +89,9 @@ async def test_run_success_complete_flow(
'removed_intervals': [], 'removed_intervals': [],
} }
# Mock workflow.logger to avoid RuntimeWarning about unawaited coroutines
workflow_mock.logger = MagicMock()
# Mock activity responses # Mock activity responses
workflow_mock.execute_activity_method = AsyncMock( workflow_mock.execute_activity_method = AsyncMock(
side_effect=[ side_effect=[
@@ -264,6 +267,9 @@ async def test_validate_training_parameters_validation_error(
input_data = {'experiment_run_id': 123} input_data = {'experiment_run_id': 123}
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}} metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
# Mock workflow.logger to avoid RuntimeWarning about unawaited coroutines
workflow_mock.logger = MagicMock()
workflow_mock.execute_activity_method = AsyncMock( workflow_mock.execute_activity_method = AsyncMock(
side_effect=[ side_effect=[
ValueError('Missing required field'), # validate_train_params fails ValueError('Missing required field'), # validate_train_params fails
@@ -318,6 +324,9 @@ async def test_download_and_train_model_download_error(
"""Test download error is handled correctly.""" """Test download error is handled correctly."""
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}} metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
# Mock workflow.logger to avoid RuntimeWarning about unawaited coroutines
workflow_mock.logger = MagicMock()
workflow_mock.execute_activity_method = AsyncMock( workflow_mock.execute_activity_method = AsyncMock(
side_effect=[ side_effect=[
Exception('MinIO connection failed'), # fetch_file_from_minio fails Exception('MinIO connection failed'), # fetch_file_from_minio fails
@@ -342,6 +351,9 @@ async def test_download_and_train_model_training_error(
"""Test training error is handled correctly.""" """Test training error is handled correctly."""
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}} metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
# Mock workflow.logger to avoid RuntimeWarning about unawaited coroutines
workflow_mock.logger = MagicMock()
workflow_mock.execute_activity_method = AsyncMock( workflow_mock.execute_activity_method = AsyncMock(
side_effect=[ side_effect=[
b'file_content', # fetch_file_from_minio succeeds b'file_content', # fetch_file_from_minio succeeds
@@ -396,6 +408,7 @@ async def test_download_and_train_model_closes_bytesio_on_error(
"""Test that BytesIO is closed in finally block even on error.""" """Test that BytesIO is closed in finally block even on error."""
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}} metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
workflow_mock.logger = MagicMock()
# Create a mock BytesIO with close method # Create a mock BytesIO with close method
mock_file = MagicMock() mock_file = MagicMock()
mock_file.close = MagicMock() mock_file.close = MagicMock()
@@ -425,6 +438,7 @@ async def test_download_and_train_model_handles_file_without_close(
"""Test that workflow handles file objects without close method gracefully.""" """Test that workflow handles file objects without close method gracefully."""
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}} metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
workflow_mock.logger = MagicMock()
# Create a mock file without close method # Create a mock file without close method
mock_file = MagicMock(spec=[]) mock_file = MagicMock(spec=[])
@@ -457,6 +471,7 @@ async def test_save_model_to_mlflow_success(
"""Test successful model saving to MLFlow.""" """Test successful model saving to MLFlow."""
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}} metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
workflow_mock.logger = MagicMock()
workflow_mock.execute_activity_method = AsyncMock( workflow_mock.execute_activity_method = AsyncMock(
side_effect=[ side_effect=[
mock_train_result, # save_model mock_train_result, # save_model
@@ -480,6 +495,7 @@ async def test_save_model_to_mlflow_error(
"""Test MLFlow save error is handled correctly.""" """Test MLFlow save error is handled correctly."""
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}} metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
workflow_mock.logger = MagicMock()
workflow_mock.execute_activity_method = AsyncMock( workflow_mock.execute_activity_method = AsyncMock(
side_effect=[ side_effect=[
Exception('MLFlow connection failed'), # save_model fails Exception('MLFlow connection failed'), # save_model fails
@@ -511,6 +527,7 @@ async def test_cleanup_resources_success(
"""Test successful resource cleanup.""" """Test successful resource cleanup."""
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}} metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
workflow_mock.logger = MagicMock()
workflow_mock.execute_activity_method = AsyncMock( workflow_mock.execute_activity_method = AsyncMock(
side_effect=[ side_effect=[
None, # cleanup_run_directory None, # cleanup_run_directory
@@ -537,6 +554,7 @@ async def test_cleanup_resources_delete_error(
"""Test cleanup handles delete errors correctly.""" """Test cleanup handles delete errors correctly."""
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}} metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
workflow_mock.logger = MagicMock()
workflow_mock.execute_activity_method = AsyncMock( workflow_mock.execute_activity_method = AsyncMock(
side_effect=[ side_effect=[
None, # cleanup_run_directory succeeds None, # cleanup_run_directory succeeds