Merge pull request #15 from Aignosi/feature/SIENTIAPDE-1350

SIENTIAPDE-1350: Implement Automated File Cleanup Workflow with Temporal Schedules
This commit is contained in:
Bruno Domingues
2025-11-27 12:25:58 -03:00
committed by GitHub
28 changed files with 2347 additions and 373 deletions

View File

@@ -1,225 +1,93 @@
# Git # ============================================================================
.git # WHITELIST APPROACH: Block everything by default, then allow only what's needed
.gitignore # ============================================================================
.gitattributes
# Documentation # Block everything first
*.md *
docs/
README*
# Tests and development # ============================================================================
tests/ # ALLOW: Application source code (model_manager package)
.pytest_cache/ # ============================================================================
.coverage
htmlcov/ # Allow the main package directory and all Python files
.tox/ !model_manager/
.nox/ !model_manager/**/*.py
.mypy_cache/ !model_manager/**/__init__.py
.pyre/
coverage.xml # Allow subdirectories structure
*.cover !model_manager/activities/
.hypothesis/ !model_manager/activities/**
!model_manager/schedules/
!model_manager/schedules/**
!model_manager/sientia/
!model_manager/sientia/**
!model_manager/utils/
!model_manager/utils/**
!model_manager/utils/models/
!model_manager/utils/models/**
!model_manager/utils/repository/
!model_manager/utils/repository/**
!model_manager/worker/
!model_manager/worker/**
!model_manager/workflows/
!model_manager/workflows/**
# Allow reports directory with header.html
!model_manager/reports/
!model_manager/reports/header.html
# Allow temp directory structure (but not its contents)
!model_manager/reports/temp/
# ============================================================================
# ALLOW: Dependencies file (needed for pip install in Dockerfile)
# ============================================================================
!requirements.txt
# ============================================================================
# BLOCK: Explicitly block unwanted files even if they match above patterns
# ============================================================================
# Python cache and compiled files # Python cache and compiled files
__pycache__/ **/__pycache__/
*.py[cod] **/*.pyc
*$py.class **/*.pyo
*.so **/*.pyd
.Python **/.Python
build/ **/*.so
develop-eggs/ **/*.egg
dist/ **/*.egg-info/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments # Tests (not needed in production)
venv/ model_manager/**/test_*.py
env/ model_manager/**/*_test.py
ENV/
.venv/
.env/
# IDE and editors # IDE and editor files
.vscode/ **/.vscode/
.idea/ **/.idea/
*.swp **/*.swp
*.swo **/*.swo
*~ **/*~
.DS_Store
Thumbs.db
# OS files # OS files
.dockerignore **/.DS_Store
.dockerignore.dockerignore **/Thumbs.db
# CI/CD # Logs and temporary files
.github/ **/*.log
.gitlab-ci.yml **/*.tmp
.travis.yml **/*.temp
.circleci/
Jenkinsfile
# Local configuration # Local configuration
.env **/.env
.env.local **/.env.local
.env.*.local **/*.local
config/local/
*.local
# Logs # Documentation inside code
*.log **/*.md
logs/ **/README*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Temporary files
tmp/
temp/
*.tmp
*.temp
# Node.js (if any frontend tools)
node_modules/
npm-debug.log*
# Database
*.db
*.sqlite
*.sqlite3
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# pipenv
Pipfile.lock
# PEP 582
__pypackages__/
# Celery
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# Backup files # Backup files
*.bak **/*.bak
*.backup **/*.backup
*.old **/*.old
# Local development scripts
scripts/local/
dev-*
# Docker files (excluding the main ones)
docker-compose*.yml
docker-compose*.yaml
Dockerfile.*
!Dockerfile
# Helm charts (already in .gitignore but reinforcing)
charts/
# Kubernetes manifests
k8s/
kube-*
# Terraform
*.tfstate
*.tfstate.*
.terraform/
# Monitoring and profiling
*.prof
*.profile
.perf
# Security
*.pem
*.key
*.crt
*.p12
secrets/
*.secret
# Large binaries and datasets
*.bin
*.pkl
*.pickle
*.joblib
data/
datasets/
models/pre-trained/
# Build artifacts
build/
dist/
target/
out/
# Package manager lock files (keeping requirements.txt)
package-lock.json
yarn.lock
Pipfile.lock
# Local tools
tools/local/
bin/local/
# Cache directories
.cache/
cache/
# Development and configuration files
.env.example
requirements-dev.txt
pyproject.toml
sonar-project.properties
todo-list.txt
validate.sh
run_local.sh
LICENSE
# Helm charts (development only)
sientia-module/
*.yaml
# Local directories
data/
logs/
models/
temp/
scripts/

View File

@@ -1,44 +1,57 @@
POSTGRES_HOST="paradedb-rw.paradedb.svc.cluster.local" POSTGRES_HOST=paradedb-rw.paradedb.svc.cluster.local
POSTGRES_PORT="5432" POSTGRES_PORT=5432
POSTGRES_USER="sientia" POSTGRES_USER=sientia
POSTGRES_PASSWORD="password" POSTGRES_PASSWORD=password
POSTGRES_DBNAME="sientia" POSTGRES_DBNAME=sientia
POSTGRES_MIN_CONNECTIONS="10" POSTGRES_MIN_CONNECTIONS=10
POSTGRES_MAX_CONNECTIONS="30" POSTGRES_MAX_CONNECTIONS=30
MLFLOW_URL="http://sientia-tracker-mlflow-tracking.sientia-tracker.svc.cluster.local:80" MLFLOW_URL=http://sientia-tracker-mlflow-tracking.sientia-tracker.svc.cluster.local:80
MLFLOW_USERNAME="aignosi" MLFLOW_USERNAME=aignosi
MLFLOW_PASSWORD="mlflow_password" MLFLOW_PASSWORD=mlflow_password
LOG_LEVEL="DEBUG" LOG_LEVEL=DEBUG
HTTP_METRICS_PORT="9090" HTTP_METRICS_PORT=9090
HTTP_SDK_METRICS_PORT="9091" HTTP_SDK_METRICS_PORT=9091
PROJECT_NAME="sientia-model-manager" PROJECT_NAME=sientia-model-manager
TEMPORAL_HOST="temporal-frontend.temporal.svc.cluster.local:7233" TEMPORAL_HOST=temporal-frontend.temporal.svc.cluster.local:7233
TEMPORAL_NAMESPACE="model-manager" TEMPORAL_NAMESPACE=model-manager
TRAIN_TASK_QUEUE=train_model-queue
CLEANUP_TASK_QUEUE=cleanup-queue
TEMPORAL_USE_TLS=false
MONGODB_USERNAME="mongo_user" MONGODB_USERNAME=mongo_user
MONGODB_PASSWORD="mongo_db_password" MONGODB_PASSWORD=mongo_db_password
MONGODB_URL="my-release-mongodb.mongodb.svc.cluster.local:27017" MONGODB_URL=my-release-mongodb.mongodb.svc.cluster.local:27017
MONGODB_DATABASE="sientia" MONGODB_DATABASE=sientia
MONGODB_TTL_INDEX_HOURS="1" MONGODB_TTL_INDEX_HOURS=1
MINIO_ENDPOINT_URL="http://minio.minio.svc.cluster.local:9000" MINIO_ENDPOINT_URL=http://minio.minio.svc.cluster.local:9000
MINIO_ACCESS_KEY="minioadmin" MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY="minioadmin" MINIO_SECRET_KEY=minioadmin
MINIO_REGION="us-east-1" MINIO_REGION=us-east-1
MINIO_USE_SSL="false" MINIO_USE_SSL=false
MINIO_MAX_RETRY_ATTEMPTS="3" MINIO_MAX_RETRY_ATTEMPTS=3
MINIO_RETRY_MODE="adaptive" MINIO_RETRY_MODE=adaptive
MINIO_CONNECT_TIMEOUT="10" MINIO_CONNECT_TIMEOUT=10
MINIO_READ_TIMEOUT="60" MINIO_READ_TIMEOUT=60
# Workflow Activity Timeouts (in seconds) TIMEOUT_VALIDATE_PARAMS=30
# These timeouts are designed to handle large files (up to 200MB) TIMEOUT_TRAIN_MODEL=2700
TIMEOUT_VALIDATE_PARAMS="30" # Parameter validation (fast operation) TIMEOUT_DELETE_FILE=120
TIMEOUT_TRAIN_MODEL="2700" # Model training (30 min for large datasets) TIMEOUT_UPDATE_DATABASE=30
TIMEOUT_DELETE_FILE="120" # Delete file from MinIO (1 min)
TIMEOUT_UPDATE_DATABASE="30" # Database update operations (30 sec)
EXTRA_PIP_REQUIREMENTS="git+https://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@github.com/Aignosi/sientia-mlops-library.git" CLEANUP_RETENTION_HOURS=24
CLEANUP_DRY_RUN=false
TIMEOUT_CLEANUP_MINIO=300
TIMEOUT_CLEANUP_LOCAL=120
MAX_KEYS_CLEANUP=1000
DEFAULT_CLEANUP_BUCKET=model-training
CLEANUP_SCHEDULE_ID=cleanup-files-daily
CLEANUP_CRON="0 0 * * *"
CLEANUP_TIMEZONE=UTC
CLEANUP_EXECUTION_TIMEOUT_HOURS=1
EXTRA_PIP_REQUIREMENTS=git+https://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@github.com/Aignosi/sientia-mlops-library.git

175
README.md
View File

@@ -15,6 +15,7 @@ An enterprise-grade ML model training orchestration platform built on Temporal.
- [Security Architecture](#security-architecture) - [Security Architecture](#security-architecture)
- [Workflows](#workflows) - [Workflows](#workflows)
- [Train Model Workflow](#train-model-workflow-train_modelpy) - [Train Model Workflow](#train-model-workflow-train_modelpy)
- [Cleanup Files Workflow](#cleanup-files-workflow-cleanup_filespy)
- [Installation & Setup](#installation--setup) - [Installation & Setup](#installation--setup)
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
- [Environment Setup](#environment-setup) - [Environment Setup](#environment-setup)
@@ -66,11 +67,13 @@ An enterprise-grade ML model training orchestration platform built on Temporal.
### Core Functionality ### Core Functionality
- **ML Model Training Pipeline**: Complete training workflow from validation to deployment using MLFlow - **ML Model Training Pipeline**: Complete training workflow from validation to deployment using MLFlow
- **Automated File Cleanup**: Scheduled cleanup of stale files from MinIO and local filesystem
- **Temporal Workflow Orchestration**: Robust workflow management with granular retry policies and fault tolerance - **Temporal Workflow Orchestration**: Robust workflow management with granular retry policies and fault tolerance
- **Parameter Validation**: Defense-in-depth validation with business rules and type checking - **Parameter Validation**: Defense-in-depth validation with business rules and type checking
- **Experiment Tracking**: Comprehensive status tracking in PostgreSQL database - **Experiment Tracking**: Comprehensive status tracking in PostgreSQL database
- **Resource Management**: Automatic cleanup of temporary files and storage - **Resource Management**: Automatic cleanup of temporary files and storage
- **Comprehensive Monitoring**: Prometheus metrics and detailed logging for operational visibility - **Comprehensive Monitoring**: Prometheus metrics and detailed logging for operational visibility
- **Scheduled Jobs**: Automated daily cleanup with configurable cron schedules
### Advanced Capabilities ### Advanced Capabilities
- **Granular Retry Policies**: Different strategies for network, training, MLFlow, database, and filesystem operations - **Granular Retry Policies**: Different strategies for network, training, MLFlow, database, and filesystem operations
@@ -133,38 +136,49 @@ The Model Manager system uses a Temporal-based workflow architecture with clear
- Health check endpoints for Kubernetes liveness/readiness probes - Health check endpoints for Kubernetes liveness/readiness probes
- Graceful shutdown with cleanup procedures - Graceful shutdown with cleanup procedures
- Multi-instance deployment support - Multi-instance deployment support
- Dedicated task queue: `train_model-queue` for ML model training workflows - Two dedicated task queues:
- `train_model-queue`: ML model training workflows
- `cleanup-queue`: File cleanup workflows
- Automated cleanup schedule management
#### **Workflows (`model_manager/workflows/`)** #### **Workflows (`model_manager/workflows/`)**
- **TrainModel**: Complete ML model training pipeline from validation to deployment - **TrainModel**: Complete ML model training pipeline from validation to deployment
- **CleanupFiles**: Automated cleanup of stale files from MinIO and local filesystem
- **Key Features**: - **Key Features**:
- Temporal workflow definitions with granular retry policies - Temporal workflow definitions with granular retry policies
- Parameter validation with business rules - Parameter validation with business rules
- Comprehensive error handling and status tracking - Comprehensive error handling and status tracking
- Configurable timeouts for different operation types - Configurable timeouts for different operation types
- Automatic resource cleanup and management - Automatic resource cleanup and management
- Scheduled cleanup jobs with cron expressions
#### **Activities (`model_manager/activities/`)** #### **Activities (`model_manager/activities/`)**
- **Activities**: Main activity orchestrator combining all functionality through multiple inheritance - **Activities**: Main activity orchestrator combining all functionality through multiple inheritance
- **ExperimentTracking**: ML experiment lifecycle tracking and database operations (extends Postgres) - **ExperimentTracking**: ML experiment lifecycle tracking and database operations
- Unified `update_experiment_run()` method for all experiment status updates - Unified `update_experiment_run()` method for all experiment status updates
- Support for three update types: STATUS, STATUS_WITH_ERROR, MODEL_SAVED - Support for three update types: STATUS, STATUS_WITH_ERROR, MODEL_SAVED
- Automatic error message truncation (1024 chars) - Automatic error message truncation (1024 chars)
- Connection pooling and retry logic via Postgres base class - Connection pooling and retry logic
- **Training**: ML model training operations (standalone activity, composition pattern) - **Training**: ML model training operations with MLFlow and MinIO integration
- Unified `train_model()` method for complete training pipeline - Unified `train_model()` method for complete training pipeline
- Receives pre-downloaded files (BytesIO) to avoid memory leaks - Receives pre-downloaded files (BytesIO) to avoid memory leaks
- Returns success/failure status with TrainModelResult or error message - Returns success/failure status with TrainModelResult or error message
- No exception raising on failure - allows workflow to handle errors gracefully - No exception raising on failure - allows workflow to handle errors gracefully
- Integration with TrainingRepository for business logic separation - Integration with TrainingRepository for business logic separation
- **MLFlow**: Model saving and artifact management operations - MLFlow model saving and artifact management
- **MinIO**: Object storage operations for training data management - MinIO object storage operations
- **Cleanup**: File and directory cleanup operations
- `cleanup_minio_files()`: Removes stale files from MinIO based on timestamp prefixes
- `cleanup_temp_directories()`: Cleans local temporary directories
- Configurable retention period (default: 24 hours)
- Dry-run mode for testing
- **Key Features**: - **Key Features**:
- Multiple inheritance pattern for unified activity interface - Multiple inheritance pattern for unified activity interface
- Parameter validation with business rules - Parameter validation with business rules
- MLFlow integration for model persistence - MLFlow integration for model persistence
- Comprehensive error handling and notification integration - Comprehensive error handling and notification integration
- Experiment tracking with automatic status management - Experiment tracking with automatic status management
- Timestamp-based file cleanup with regex pattern matching
#### **Data Services (`model_manager/utils/`)** #### **Data Services (`model_manager/utils/`)**
- **Connectors Config**: Environment variable-based configuration management - **Connectors Config**: Environment variable-based configuration management
@@ -311,6 +325,79 @@ The workflow validates 10 business rules beyond type checking:
5. **target_variable**: Must be in variable_columns 5. **target_variable**: Must be in variable_columns
6. **bucket_name, file_name, experiment_name**: Cannot be empty or whitespace 6. **bucket_name, file_name, experiment_name**: Cannot be empty or whitespace
### Cleanup Files Workflow (`cleanup_files.py`)
The **CleanupFiles** workflow provides automated cleanup of stale files from MinIO storage and local temporary directories. It runs on a scheduled basis (default: daily at midnight UTC) to maintain storage hygiene.
#### Purpose
- **Storage Management**: Automatic removal of old files from MinIO and local filesystem
- **Retention Policy**: Configurable retention period (default: 24 hours)
- **Scheduled Execution**: Cron-based scheduling for automated cleanup
- **Resource Optimization**: Prevents storage bloat and reduces costs
#### Execution Flow
1. **Cleanup MinIO Files**: Scan and delete files older than retention period from MinIO bucket
2. **Cleanup Local Directories**: Remove temporary directories older than retention period
#### Key Features
- **Timestamp-Based Cleanup**: Uses filename/directory timestamps for age determination
- **Pattern Matching**: Regex patterns for MinIO (`timestamp-filename`) and directories (`name_YYYYMMDD_HHMMSS_microseconds`)
- **Configurable Retention**: Environment variable-based retention period
- **Dry-Run Mode**: Test cleanup operations without actual deletion
- **Idempotent**: Safe to run multiple times
- **Error Handling**: Continues cleanup even if individual operations fail
#### Input Parameters
```json
{
"bucket_name": "model-training" // Optional, defaults to DEFAULT_CLEANUP_BUCKET env var
}
```
#### Schedule Configuration
The cleanup schedule is automatically created when the worker starts:
| Configuration | Environment Variable | Default | Description |
|--------------|---------------------|---------|-------------|
| **Schedule ID** | `CLEANUP_SCHEDULE_ID` | `cleanup-files-daily` | Unique identifier for the schedule |
| **Cron Expression** | `CLEANUP_CRON` | `0 0 * * *` | Daily at midnight UTC |
| **Timezone** | `CLEANUP_TIMEZONE` | `UTC` | Timezone for cron execution |
| **Task Queue** | `CLEANUP_TASK_QUEUE` | `cleanup-queue` | Dedicated task queue |
| **Execution Timeout** | `CLEANUP_EXECUTION_TIMEOUT_HOURS` | `1` | Maximum execution time (hours) |
| **Retention Period** | `CLEANUP_RETENTION_HOURS` | `24` | Files older than this are deleted |
| **Dry Run** | `CLEANUP_DRY_RUN` | `false` | Test mode without actual deletion |
| **Max Keys** | `MAX_KEYS_CLEANUP` | `1000` | MinIO list operation page size |
#### Architecture Diagram
```mermaid
flowchart TD
A[Scheduled Trigger] --> B[cleanup_minio_files]
B --> C[cleanup_temp_directories]
B -.-> MinIO[MinIO Storage]
C -.-> FS[Local Filesystem]
```
#### Retry Strategies
| Operation Type | Initial Interval | Max Interval | Backoff | Max Attempts | Use Case |
|---------------|------------------|--------------|---------|--------------|----------|
| **Network** | 1s | 10s | 2.0x | 5 | MinIO operations (transient network errors) |
| **No Retry** | - | - | - | 1 | Local filesystem operations (permanent errors) |
#### Cleanup Patterns
**MinIO Files:**
- Pattern: `{timestamp}-{filename}` where timestamp is milliseconds since epoch
- Example: `1638360000000-training_data.csv`
- Retention: Files older than `CLEANUP_RETENTION_HOURS` are deleted
**Local Directories:**
- Pattern: `{name}_{YYYYMMDD}_{HHMMSS}_{microseconds}`
- Example: `temp_20231201_143052_123456`
- Retention: Directories older than `CLEANUP_RETENTION_HOURS` are deleted
## Installation & Setup ## Installation & Setup
### Prerequisites ### Prerequisites
@@ -773,11 +860,23 @@ The Model Manager system exposes comprehensive Prometheus metrics for operationa
- `app_up`: Application health status (1=healthy, 0=unhealthy) - `app_up`: Application health status (1=healthy, 0=unhealthy)
- Labels: `pod_id` - Labels: `pod_id`
### Workflow Execution Metrics
- `workflow_execution_total`: Total workflow executions
- Labels: `workflow_name`, `status` (success/failure)
- `activity_execution_total`: Total activity executions
- Labels: `activity_name`, `status` (success/failure)
### Training Metrics ### Training Metrics
- Training success/failure rates through notification system - Training success/failure rates through notification system
- Model save performance metrics - Model save performance metrics
- Experiment status tracking - Experiment status tracking
### Cleanup Metrics
- Cleanup execution success/failure rates
- Number of files deleted from MinIO
- Number of directories cleaned from local filesystem
- Cleanup duration and performance
## Configuration ## Configuration
### Environment Variables ### Environment Variables
@@ -786,6 +885,9 @@ The Model Manager system exposes comprehensive Prometheus metrics for operationa
|----------|-------------|---------|----------| |----------|-------------|---------|----------|
| `TEMPORAL_HOST` | Temporal server address | `localhost:7233` | Yes | | `TEMPORAL_HOST` | Temporal server address | `localhost:7233` | Yes |
| `TEMPORAL_NAMESPACE` | Temporal namespace | `model-manager` | No | | `TEMPORAL_NAMESPACE` | Temporal namespace | `model-manager` | No |
| `TEMPORAL_USE_TLS` | Enable TLS for Temporal connection | `false` | No |
| `TRAIN_TASK_QUEUE` | Task queue for training workflows | `train_model-queue` | No |
| `CLEANUP_TASK_QUEUE` | Task queue for cleanup workflows | `cleanup-queue` | No |
| `POSTGRES_HOST` | PostgreSQL hostname | `localhost` | Yes | | `POSTGRES_HOST` | PostgreSQL hostname | `localhost` | Yes |
| `POSTGRES_PORT` | PostgreSQL port | `5432` | Yes | | `POSTGRES_PORT` | PostgreSQL port | `5432` | Yes |
| `POSTGRES_USER` | PostgreSQL username | `sientia` | Yes | | `POSTGRES_USER` | PostgreSQL username | `sientia` | Yes |
@@ -811,6 +913,14 @@ The Model Manager system exposes comprehensive Prometheus metrics for operationa
| `MONGODB_PASSWORD` | MongoDB password | `wKZDbMNU1c` | Yes | | `MONGODB_PASSWORD` | MongoDB password | `wKZDbMNU1c` | Yes |
| `MONGODB_DATABASE_NAME` | MongoDB database name | `sientia` | Yes | | `MONGODB_DATABASE_NAME` | MongoDB database name | `sientia` | Yes |
| `MONGODB_TTL_INDEX_HOURS` | MongoDB TTL index hours | `1` | No | | `MONGODB_TTL_INDEX_HOURS` | MongoDB TTL index hours | `1` | No |
| `CLEANUP_SCHEDULE_ID` | Cleanup schedule identifier | `cleanup-files-daily` | No |
| `CLEANUP_CRON` | Cleanup cron expression | `0 0 * * *` | No |
| `CLEANUP_TIMEZONE` | Cleanup schedule timezone | `UTC` | No |
| `CLEANUP_EXECUTION_TIMEOUT_HOURS` | Cleanup execution timeout | `1` | No |
| `CLEANUP_RETENTION_HOURS` | File retention period (hours) | `24` | No |
| `CLEANUP_DRY_RUN` | Dry-run mode (no actual deletion) | `false` | No |
| `MAX_KEYS_CLEANUP` | MinIO list operation page size | `1000` | No |
| `DEFAULT_CLEANUP_BUCKET` | Default bucket for cleanup | `model-training` | No |
| `LOG_LEVEL` | Application log level | `INFO` | No | | `LOG_LEVEL` | Application log level | `INFO` | No |
| `PROJECT_NAME` | Project name for metrics | `model-manager` | No | | `PROJECT_NAME` | Project name for metrics | `model-manager` | No |
| `HTTP_METRICS_PORT` | Prometheus metrics port | `9090` | No | | `HTTP_METRICS_PORT` | Prometheus metrics port | `9090` | No |
@@ -819,18 +929,24 @@ The Model Manager system exposes comprehensive Prometheus metrics for operationa
#### Workflow Activity Timeouts #### Workflow Activity Timeouts
These timeouts control how long each activity in the training workflow can run before timing out. All values are in seconds and are designed to handle large files (up to 200MB). These timeouts control how long each activity in workflows can run before timing out. All values are in seconds.
**Training Workflow Timeouts:**
| Variable | Description | Default | Calculation Basis | | Variable | Description | Default | Calculation Basis |
|----------|-------------|---------|-------------------| |----------|-------------|---------|-------------------|
| `TIMEOUT_VALIDATE_PARAMS` | Parameter validation timeout | `30` | Fast operation, no I/O | | `TIMEOUT_VALIDATE_PARAMS` | Parameter validation timeout | `30` | Fast operation, no I/O |
| `TIMEOUT_DOWNLOAD_FILE` | File download from MinIO timeout | `600` | 200MB @ 1MB/s with 3x buffer (10 min) | | `TIMEOUT_TRAIN_MODEL` | Model training timeout | `2700` | Large dataset processing (45 min) |
| `TIMEOUT_TRAIN_MODEL` | Model training timeout | `1800` | Large dataset processing (30 min) | | `TIMEOUT_DELETE_FILE` | Delete file from MinIO timeout | `120` | MinIO delete operation (2 min) |
| `TIMEOUT_SAVE_MODEL` | Save model to MLFlow timeout | `300` | Artifact upload and logging (5 min) |
| `TIMEOUT_CLEANUP_DIRECTORY` | Cleanup temporary directory timeout | `60` | Local filesystem operation (1 min) |
| `TIMEOUT_DELETE_FILE` | Delete file from MinIO timeout | `60` | MinIO delete operation (1 min) |
| `TIMEOUT_UPDATE_DATABASE` | Database update timeout | `30` | PostgreSQL update query (30 sec) | | `TIMEOUT_UPDATE_DATABASE` | Database update timeout | `30` | PostgreSQL update query (30 sec) |
**Cleanup Workflow Timeouts:**
| Variable | Description | Default | Calculation Basis |
|----------|-------------|---------|-------------------|
| `TIMEOUT_CLEANUP_MINIO` | MinIO cleanup timeout | `300` | Scan and delete multiple files (5 min) |
| `TIMEOUT_CLEANUP_LOCAL` | Local cleanup timeout | `120` | Scan and delete directories (2 min) |
**Note**: These timeouts can be adjusted based on your infrastructure performance and file sizes. If you're processing files larger than 200MB or have slower network/compute resources, increase these values accordingly. **Note**: These timeouts can be adjusted based on your infrastructure performance and file sizes. If you're processing files larger than 200MB or have slower network/compute resources, increase these values accordingly.
## Development ## Development
@@ -908,18 +1024,23 @@ model_manager/
│ ├── __init__.py │ ├── __init__.py
│ ├── activities.py # Main activities orchestrator (combines all activities) │ ├── activities.py # Main activities orchestrator (combines all activities)
│ ├── experiment_tracking.py # Experiment status tracking and database operations │ ├── experiment_tracking.py # Experiment status tracking and database operations
│ ├── training.py # ML model training operations │ ├── training.py # ML model training operations (includes MLFlow & MinIO)
── minio.py # MinIO object storage operations ── cleanup.py # File and directory cleanup operations
│ └── mlflow.py # MLFlow model saving and artifact management
├── workflows/ # Temporal workflow definitions ├── workflows/ # Temporal workflow definitions
│ ├── __init__.py │ ├── __init__.py
── train_model.py # Complete ML model training workflow ── train_model.py # Complete ML model training workflow
│ └── cleanup_files.py # Automated file cleanup workflow
├── schedules/ # Temporal schedule configurations
│ ├── __init__.py
│ └── cleanup_schedule.py # Cleanup schedule creation and management
├── worker/ # Worker implementation ├── worker/ # Worker implementation
│ ├── __init__.py │ ├── __init__.py
│ └── worker.py # Main worker orchestrator (Temporal client setup) │ └── worker.py # Main worker orchestrator (Temporal client setup)
├── utils/ # Utility functions and helpers ├── utils/ # Utility functions and helpers
│ ├── __init__.py │ ├── __init__.py
│ ├── connectors_config.py # Environment-based configuration builders │ ├── connectors_config.py # Environment-based configuration builders
│ ├── exceptions.py # Custom exception definitions
│ ├── logger_helper.py # Logger initialization utilities
│ ├── models/ # Data models and schemas │ ├── models/ # Data models and schemas
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── train_model_params.py # Training parameters model │ │ ├── train_model_params.py # Training parameters model
@@ -928,7 +1049,19 @@ model_manager/
│ └── repository/ # Data access layer │ └── repository/ # Data access layer
│ ├── __init__.py │ ├── __init__.py
│ ├── training_repository.py # Training business logic │ ├── training_repository.py # Training business logic
── model_repository.py # MLFlow artifact management ── model_repository.py # MLFlow artifact management
│ └── storage_repository.py # MinIO storage operations
├── sientia/ # Sientia-specific implementations
│ ├── __init__.py
│ ├── exceptions.py # Custom exceptions
│ ├── metrics.py # Business metrics
│ ├── models.py # ML model implementations
│ ├── model_serving.py # Model serving utilities
│ ├── reports.py # Report generation
│ └── utils.py # Utility functions
├── reports/ # Report templates and temporary files
│ ├── header.html # HTML report header template
│ └── temp/ # Temporary report files (cleaned up automatically)
├── metrics.py # Prometheus metrics definitions ├── metrics.py # Prometheus metrics definitions
└── __init__.py └── __init__.py
``` ```
@@ -994,16 +1127,22 @@ export LOG_LEVEL=DEBUG
### Key Parameters ### Key Parameters
- **Worker Concurrency**: Adjust `max_concurrent_workflow_tasks` and `max_concurrent_activities` - **Worker Concurrency**: Adjust `max_concurrent_workflow_tasks` and `max_concurrent_activities`
- Training workflows: 10 concurrent tasks/activities
- Cleanup workflows: 20 concurrent tasks/activities
- **Connection Pools**: Optimize database connection pool sizes - **Connection Pools**: Optimize database connection pool sizes
- **Model Retention**: Configure MLFlow model retention based on requirements - **Model Retention**: Configure MLFlow model retention based on requirements
- **Batch Sizes**: Adjust data processing batch sizes for optimal throughput - **Batch Sizes**: Adjust data processing batch sizes for optimal throughput
- **Cleanup Performance**: Tune `MAX_KEYS_CLEANUP` for MinIO list operation page size
### Scaling Considerations ### Scaling Considerations
- **Horizontal Scaling**: Deploy multiple worker instances - **Horizontal Scaling**: Deploy multiple worker instances
- **Task Queue Distribution**: Use multiple task queues for different workflow types - **Task Queue Distribution**: Two dedicated task queues for workflow isolation
- `train_model-queue`: Training workflows
- `cleanup-queue`: Cleanup workflows
- **Database Performance**: Optimize indexes and connection pooling - **Database Performance**: Optimize indexes and connection pooling
- **MLFlow Performance**: Configure appropriate model serving resources - **MLFlow Performance**: Configure appropriate model serving resources
- **Storage Management**: Adjust cleanup retention period based on storage capacity and costs
## Contributing ## Contributing

View File

@@ -7,25 +7,25 @@ with workflow.unsafe.imports_passed_through():
from sientia_do.observability.logger import Logger from sientia_do.observability.logger import Logger
from sientia_do.observability.metrics_controller import MetricsController from sientia_do.observability.metrics_controller import MetricsController
from model_manager.activities.cleanup import Cleanup
from model_manager.activities.experiment_tracking import ExperimentTracking from model_manager.activities.experiment_tracking import ExperimentTracking
from model_manager.activities.training import Training from model_manager.activities.training import Training
from model_manager.utils.repository.model_repository import ModelRepository from model_manager.utils.repository.model_repository import ModelRepository
from model_manager.utils.repository.storage_repository import StorageRepository from model_manager.utils.repository.storage_repository import StorageRepository
class Activities(ExperimentTracking, Training): class Activities(ExperimentTracking, Training, Cleanup):
""" """
Main activities orchestrator for the Model Manager system. Main activities orchestrator for the Model Manager system.
This class combines functionality from multiple activity classes to provide This class combines functionality from multiple activity classes to provide
a unified interface for all workflow operations. It manages database connections, a unified interface for all workflow operations. It manages database connections,
MLFlow model interactions, MinIO storage operations, and data quality validation. MLFlow model interactions, MinIO storage operations, and cleanup operations.
The class implements multiple inheritance to combine specialized functionality: The class implements multiple inheritance to combine specialized functionality:
- ExperimentTracking: ML experiment lifecycle tracking and database operations (extends Postgres) - ExperimentTracking: ML experiment lifecycle tracking and database operations
- MLFlow: Model saving and artifact management operations - Training: ML model training operations with MLFlow and MinIO integration
- MinIO: Object storage operations (file upload/download/delete) - Cleanup: File and directory cleanup operations for MinIO and local filesystem
- Training: ML model training operations (extends BaseActivity)
Attributes: Attributes:
postgres_config (dict): PostgreSQL connection configuration postgres_config (dict): PostgreSQL connection configuration
@@ -109,6 +109,14 @@ class Activities(ExperimentTracking, Training):
metrics_controller=metrics_controller, metrics_controller=metrics_controller,
) )
Cleanup.__init__(
self,
storage_repository=self.storage_repository,
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
def __del__(self): def __del__(self):
""" """
Destructor to safely handle cleanup during garbage collection. Destructor to safely handle cleanup during garbage collection.
@@ -143,3 +151,5 @@ class Activities(ExperimentTracking, Training):
Prefer calling this method explicitly rather than relying on __del__. Prefer calling this method explicitly rather than relying on __del__.
""" """
ExperimentTracking.close(self) ExperimentTracking.close(self)
self.info('Postgres client closed')
self.storage_repository.close()

View File

@@ -0,0 +1,338 @@
"""
Cleanup activities for removing stale files from MinIO and 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 UTC, 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.metrics import ACTIVITY_EXECUTION_TOTAL, WORKFLOW_EXECUTION_TOTAL
from model_manager.utils.repository.storage_repository import StorageRepository
RETENTION_HOURS = int(os.getenv('CLEANUP_RETENTION_HOURS', '24'))
DRY_RUN = os.getenv('CLEANUP_DRY_RUN', 'false').lower() == 'true'
MAX_KEYS_CLEANUP = int(os.getenv('MAX_KEYS_CLEANUP', '1000'))
class Cleanup(SientiaMonitoring):
"""
Activity for cleaning up stale files and directories.
This activity extends SientiaMonitoring and handles cleanup of:
- MinIO files with timestamp prefixes (timestamp-filename pattern)
- Local temporary directories with timestamp suffixes
"""
def __init__(
self,
storage_repository: StorageRepository,
logger: Logger,
notification_handler: NotificationHandler,
metrics_controller: MetricsController,
):
"""
Initialize Cleanup activity.
Args:
storage_repository: Repository for MinIO operations
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)
self.storage_repository = storage_repository
# Configuration from environment variables
self.retention_hours = RETENTION_HOURS
self.dry_run = DRY_RUN
# MinIO list operation page size
self.max_keys_cleanup = MAX_KEYS_CLEANUP
# Regex patterns for timestamp extraction
self.minio_timestamp_pattern = re.compile(r'^(\d{13})-(.+)') # timestamp-filename
self.dir_timestamp_pattern = re.compile(
r'^(.+)_(\d{8}_\d{6}_\d{6})$'
) # name_YYYYMMDD_HHMMSS_microseconds
@activity.defn(name='cleanup_minio_files')
async def cleanup_minio_files(self, input_data: dict[str, Any]) -> None:
"""
Clean up stale files from MinIO based on timestamp in filename.
This activity scans a single MinIO bucket for files following the pattern
'{timestamp}-{filename}' where timestamp is milliseconds since epoch.
Files older than the retention period are deleted.
Args:
input_data: Cleanup configuration containing:
- metadata (dict): Workflow execution metadata
- bucket_name (str): Name of the bucket to scan
Returns:
None: Results are logged and tracked via metrics
Raises:
Exception: If cleanup fails (after sending notification)
"""
metadata = input_data.get('metadata', {})
bucket_name = input_data.get('bucket_name')
metrics_status = 'success'
if not bucket_name:
raise ValueError('bucket_name must be provided')
cutoff_time = datetime.now(UTC) - timedelta(hours=self.retention_hours)
cutoff_timestamp_ms = int(cutoff_time.timestamp() * 1000)
try:
self.info(
f'Starting MinIO cleanup - Bucket: {bucket_name}, '
f'Retention: {self.retention_hours}h, Dry run: {self.dry_run}, '
f'Cutoff: {cutoff_time.isoformat()}',
metadata,
)
files_scanned = 0
files_deleted = 0
errors = []
# List objects in the specified bucket
max_keys = self.max_keys_cleanup # Use environment variable for page size
objects = self.storage_repository.list_bucket_objects(bucket_name, max_keys)
for obj_key in objects:
files_scanned += 1
# Extract timestamp from filename
match = self.minio_timestamp_pattern.match(obj_key)
if not match:
self.debug(f'Skipping file without timestamp pattern: {obj_key}', metadata)
continue
file_timestamp_ms = int(match.group(1))
if file_timestamp_ms < cutoff_timestamp_ms:
if self.dry_run:
self.info(
f'[DRY RUN] Would delete: {obj_key} (age: {(cutoff_time.timestamp() - file_timestamp_ms / 1000) / 3600:.1f}h)',
metadata,
)
files_deleted += 1
else:
try:
self.storage_repository.delete_file(bucket_name, obj_key)
self.info(f'Deleted stale file: {obj_key}', metadata)
files_deleted += 1
except OSError as e:
error_msg = f'Failed to delete {obj_key}: {str(e)}'
errors.append(error_msg)
self.error(error_msg, metadata)
else:
self.debug(
f'Keeping recent file: {obj_key} (age: {(cutoff_time.timestamp() - file_timestamp_ms / 1000) / 3600:.1f}h)',
metadata,
)
self.info(
f'MinIO cleanup completed - Bucket: {bucket_name}, '
f'Scanned: {files_scanned}, Deleted: {files_deleted}, Errors: {len(errors)}',
metadata,
)
except Exception as e:
metrics_status = 'error'
error_msg = f'Error in MinIO cleanup: {str(e)}'
trace = traceback.format_exc()
self.send_notification(
metadata=metadata,
notification_id='CLEANUP_MINIO_ERROR',
message=error_msg,
block='cleanup_minio_files',
level=NotificationLevel.ERROR,
attachment_content=trace,
)
raise
finally:
await self._emit_metrics(
metadata=metadata,
metrics_status=metrics_status,
activity_name='cleanup_minio_files',
emit_workflow_metric=(metrics_status == 'error'),
)
@activity.defn(name='cleanup_temp_directories')
async 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', 'model_manager/reports/temp')
metrics_status = 'success'
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:
metrics_status = 'error'
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
finally:
await self._emit_metrics(
metadata=metadata,
metrics_status=metrics_status,
activity_name='cleanup_temp_directories',
emit_workflow_metric=True,
)
async def _emit_metrics(
self,
metadata: dict[str, Any],
metrics_status: str,
activity_name: str,
emit_workflow_metric: bool,
) -> None:
"""
Emit workflow and activity execution metrics.
Args:
metadata: Activity metadata containing pod_id and workflow_name
metrics_status: Execution status ('success' or 'error')
activity_name: Name of the activity being executed
"""
if emit_workflow_metric:
await self.emit_metric(
metric_object=WORKFLOW_EXECUTION_TOTAL,
tags={
'pod_id': metadata.get('pod_id'),
'workflow_name': metadata.get('workflow_name'),
'status': metrics_status,
},
)
await self.emit_metric(
metric_object=ACTIVITY_EXECUTION_TOTAL,
tags={
'pod_id': metadata.get('pod_id'),
'activity_name': activity_name,
'status': metrics_status,
},
)

View File

@@ -76,7 +76,8 @@ class ExperimentTracking(Postgres):
Raises: Raises:
ConnectionError: If database connection cannot be established ConnectionError: If database connection cannot be established
""" """
super().__init__( Postgres.__init__(
self,
host=host, host=host,
port=port, port=port,
user=user, user=user,
@@ -89,6 +90,8 @@ class ExperimentTracking(Postgres):
metrics_controller=metrics_controller, metrics_controller=metrics_controller,
) )
self.info(f'Postgres client initialized at {host}:{port}')
def __del__(self): def __del__(self):
""" """
Destructor to safely handle cleanup during garbage collection. Destructor to safely handle cleanup during garbage collection.

View File

@@ -51,7 +51,7 @@ class Training(SientiaMonitoring):
logger: Logger instance for observability logger: Logger instance for observability
notification_handler: Handler for sending notifications notification_handler: Handler for sending notifications
""" """
super().__init__(logger, notification_handler, metrics_controller) SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
self.training_repository = TrainingRepository(logger) self.training_repository = TrainingRepository(logger)
self.model_repository = model_repository self.model_repository = model_repository
self.storage_repository = storage_repository self.storage_repository = storage_repository

View File

View File

@@ -0,0 +1,89 @@
"""Schedule configuration for cleanup workflow."""
import os
from datetime import timedelta
from sientia_do.observability.logger import Logger as SientiaLogger
from temporalio.client import (
Client,
Schedule,
ScheduleActionStartWorkflow,
ScheduleSpec,
)
# Schedule configuration from environment variables
SCHEDULE_ID = os.getenv('CLEANUP_SCHEDULE_ID', 'cleanup-files-daily')
CLEANUP_CRON = os.getenv('CLEANUP_CRON', '0 0 * * *') # Default: midnight UTC
CLEANUP_TIMEZONE = os.getenv('CLEANUP_TIMEZONE', 'UTC')
CLEANUP_TASK_QUEUE = os.getenv('CLEANUP_TASK_QUEUE', 'cleanup-queue')
CLEANUP_EXECUTION_TIMEOUT_HOURS = int(os.getenv('CLEANUP_EXECUTION_TIMEOUT_HOURS', '1'))
async def schedule_exists(
client: Client, schedule_id: str, logger: SientiaLogger, metadata: dict[str, str | None]
) -> bool:
"""
Check if a schedule already exists.
Args:
client: Temporal client instance
schedule_id: ID of the schedule to check
logger: Logger instance for error logging
metadata: Metadata dictionary for logging context
Returns:
True if schedule exists, False otherwise
"""
try:
async for schedule in await client.list_schedules():
if schedule.id == schedule_id:
return True
return False
except Exception as e: # noqa: BLE001
logger.custom_error(f'Error checking if schedule exists: {e}', metadata)
return False
async def create_cleanup_schedule(
client: Client, logger: SientiaLogger, metadata: dict[str, str | None]
) -> None:
"""
Create or update the cleanup files schedule.
This function is idempotent and can be called multiple times safely.
It will only create the schedule if it doesn't already exist.
Args:
client: Temporal client instance
logger: Logger instance for logging schedule operations
metadata: Metadata dictionary for logging context
"""
# Check if schedule already exists
if await schedule_exists(client, SCHEDULE_ID, logger, metadata):
logger.custom_info(
f"Schedule '{SCHEDULE_ID}' already configured, skipping creation", metadata
)
return
await client.create_schedule(
SCHEDULE_ID,
Schedule(
action=ScheduleActionStartWorkflow(
'cleanup_files',
{}, # Empty input, will use default bucket from environment
id=f'cleanup-files-scheduled-{SCHEDULE_ID}',
task_queue=CLEANUP_TASK_QUEUE,
execution_timeout=timedelta(hours=CLEANUP_EXECUTION_TIMEOUT_HOURS),
),
spec=ScheduleSpec(
cron_expressions=[CLEANUP_CRON],
time_zone_name=CLEANUP_TIMEZONE,
),
),
)
logger.custom_info(
f"Schedule '{SCHEDULE_ID}' created successfully. "
f'Cleanup will run at: {CLEANUP_CRON} ({CLEANUP_TIMEZONE})',
metadata,
)

View File

@@ -84,6 +84,7 @@ def build_mongodb_config() -> dict[str, Any]:
'connection_string': connection_string, 'connection_string': connection_string,
'database_name': getenv('MONGODB_DATABASE_NAME', 'sientia'), 'database_name': getenv('MONGODB_DATABASE_NAME', 'sientia'),
'ttl_index_seconds': int(getenv('MONGODB_TTL_INDEX_HOURS', '1')) * 3600, 'ttl_index_seconds': int(getenv('MONGODB_TTL_INDEX_HOURS', '1')) * 3600,
'uri': uri,
} }

View File

@@ -31,8 +31,8 @@ warnings.filterwarnings('ignore', category=FutureWarning, message=".*'squared' i
class ModelRepository: class ModelRepository:
def __init__(self, url, username, password, logger: Logger): def __init__(self, url, username, password, logger: Logger):
self.model_serving = ModelServing(tracking_uri=url, username=username, password=password) self.model_serving = ModelServing(tracking_uri=url, username=username, password=password)
self.logger = logger self.logger = logger
self.logger.info(f'MLFlow client initialized at {url}')
def save_model(self, train_result: TrainModelResult) -> TrainModelResult: def save_model(self, train_result: TrainModelResult) -> TrainModelResult:
""" """

View File

@@ -86,7 +86,11 @@ class StorageRepository:
use_ssl=use_ssl, use_ssl=use_ssl,
) )
self.logger.info(f'MinIO client initialized successfully: {endpoint_url}') self.logger.info(f'MinIO client initialized at {endpoint_url}')
def close(self) -> None:
self.minio_client.close()
self.logger.info('MinIO client closed')
def fetch_file(self, bucket_name: str, file_name: str) -> BytesIO: def fetch_file(self, bucket_name: str, file_name: str) -> BytesIO:
""" """
@@ -125,3 +129,36 @@ class StorageRepository:
""" """
self.minio_client.delete_object(Bucket=bucket_name, Key=file_name) self.minio_client.delete_object(Bucket=bucket_name, Key=file_name)
self.logger.info(f'File deleted successfully: {bucket_name}/{file_name}') self.logger.info(f'File deleted successfully: {bucket_name}/{file_name}')
def list_bucket_objects(self, bucket_name: str, max_keys: int = 1000) -> list[str]:
"""
List objects in a MinIO bucket.
This method uses the MinIO/S3 list_objects_v2 API to retrieve objects
from the specified bucket. This is optimized for cleanup operations
by using configurable pagination.
Args:
bucket_name: Name of the bucket to list objects from.
max_keys: Maximum number of keys per page (default: 1000).
Returns:
List[str]: List of object keys (file names).
"""
# Use list_objects_v2 for efficient pagination
paginator = self.minio_client.get_paginator('list_objects_v2')
pages = paginator.paginate(Bucket=bucket_name, MaxKeys=max_keys)
objects = []
total_count = 0
for page in pages:
if 'Contents' in page:
for obj in page['Contents']:
objects.append(obj['Key'])
total_count += 1
self.logger.info(f'Listed {total_count} objects from bucket {bucket_name}')
return objects

View File

@@ -2,9 +2,11 @@
This module provides the main worker implementation for the Sientia DataOps Model Manager system. This module provides the main worker implementation for the Sientia DataOps Model Manager system.
It orchestrates Temporal workers, manages task queues, and handles the lifecycle of It orchestrates Temporal workers, manages task queues, and handles the lifecycle of
model training workflows. model training and cleanup workflows.
The worker supports the train_model-queue task queue for ML model training workflows. The worker supports two task queues:
- train_model-queue: For ML model training workflows
- cleanup-queue: For file cleanup workflows
Key Features: Key Features:
- Automatic scaling with PollerBehaviorAutoscaling - Automatic scaling with PollerBehaviorAutoscaling
@@ -12,14 +14,18 @@ Key Features:
- Comprehensive error handling and logging - Comprehensive error handling and logging
- Graceful shutdown with cleanup - Graceful shutdown with cleanup
- ML model training pipeline orchestration - ML model training pipeline orchestration
- Automated cleanup schedule management
Environment Variables: Environment Variables:
- TEMPORAL_HOST: Temporal server address (default: localhost:7233) - TEMPORAL_HOST: Temporal server address (default: localhost:7233)
- TEMPORAL_NAMESPACE: Temporal namespace (default: model_manager) - TEMPORAL_NAMESPACE: Temporal namespace (default: model-manager)
- TEMPORAL_USE_TLS: Enable TLS for Temporal connection (default: false)
- TRAIN_TASK_QUEUE: Task queue for training workflows (default: train_model-queue)
- CLEANUP_TASK_QUEUE: Task queue for cleanup workflows (default: cleanup-queue)
- POD_ID: Kubernetes pod identifier for metrics - POD_ID: Kubernetes pod identifier for metrics
- HTTP_METRICS_PORT: Prometheus metrics server port (default: 9090) - HTTP_METRICS_PORT: Prometheus metrics server port (default: 9090)
- HTTP_SDK_METRICS_PORT: Temporal SDK metrics port (default: 9091) - HTTP_SDK_METRICS_PORT: Temporal SDK metrics port (default: 9091)
- PROJECT_NAME: Project name for notifications (default: model_manager) - PROJECT_NAME: Project name for notifications (default: model-manager)
""" """
from temporalio import client, workflow from temporalio import client, workflow
@@ -33,9 +39,11 @@ with workflow.unsafe.imports_passed_through():
from prometheus_client import start_http_server from prometheus_client import start_http_server
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.observability.logger import Logger as SientiaLogger
from model_manager import metrics from model_manager import metrics
from model_manager.activities.activities import Activities from model_manager.activities.activities import Activities
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
from model_manager.utils.connectors_config import ( from model_manager.utils.connectors_config import (
build_minio_config, build_minio_config,
build_mlflow_config, build_mlflow_config,
@@ -43,10 +51,13 @@ with workflow.unsafe.imports_passed_through():
build_postgres_config, build_postgres_config,
) )
from model_manager.utils.logger_helper import get_logger from model_manager.utils.logger_helper import get_logger
from model_manager.workflows.cleanup_files import CleanupFiles
from model_manager.workflows.train_model import TrainModel from model_manager.workflows.train_model import TrainModel
POD_ID = os.getenv('POD_ID') POD_ID = os.getenv('POD_ID')
SDK_METRICS_PORT = int(os.getenv('HTTP_SDK_METRICS_PORT', '9091')) SDK_METRICS_PORT = int(os.getenv('HTTP_SDK_METRICS_PORT', '9091'))
TRAIN_TASK_QUEUE = os.getenv('TRAIN_TASK_QUEUE', 'train_model-queue')
CLEANUP_TASK_QUEUE = os.getenv('CLEANUP_TASK_QUEUE', 'cleanup-queue')
async def main(): async def main():
@@ -69,21 +80,16 @@ async def main():
SystemExit: On graceful shutdown or error conditions SystemExit: On graceful shutdown or error conditions
""" """
host = os.getenv('TEMPORAL_HOST', 'localhost:7233') host = os.getenv('TEMPORAL_HOST', 'localhost:7233')
use_tls = os.getenv('TEMPORAL_USE_TLS', 'false').lower() == 'true'
logger = get_logger(__name__) logger = get_logger(__name__)
metadata = { metadata = {
'pod_id': POD_ID, 'pod_id': POD_ID,
'workflow_name': 'train_model',
} }
logger.custom_info(f'Starting Worker with POD_ID: {POD_ID}', metadata) start_prometheus_server(logger, metadata)
logger.custom_info('Starting prometheus client...', metadata)
start_prometheus_server()
logger.custom_info('Starting Notification Handler...', metadata)
mongo_config = build_mongodb_config() mongo_config = build_mongodb_config()
notification_handler = NotificationHandler( notification_handler = NotificationHandler(
connection_string=mongo_config['connection_string'], connection_string=mongo_config['connection_string'],
database=mongo_config['database_name'], database=mongo_config['database_name'],
@@ -91,7 +97,7 @@ async def main():
project_name=os.getenv('PROJECT_NAME', 'model-manager'), project_name=os.getenv('PROJECT_NAME', 'model-manager'),
) )
logger.custom_info('Starting Activities...', metadata) logger.custom_info(f'MongoDB client initialized at {mongo_config["uri"]}', metadata)
activities = Activities( activities = Activities(
postgres_config=build_postgres_config(), postgres_config=build_postgres_config(),
@@ -101,28 +107,35 @@ async def main():
notification_handler=notification_handler, notification_handler=notification_handler,
) )
logger.custom_info(f'Starting SDK Metrics Server on port {SDK_METRICS_PORT}...', metadata)
new_runtime = Runtime( new_runtime = Runtime(
telemetry=TelemetryConfig( telemetry=TelemetryConfig(
metrics=PrometheusConfig(bind_address=f'0.0.0.0:{SDK_METRICS_PORT}') metrics=PrometheusConfig(bind_address=f'0.0.0.0:{SDK_METRICS_PORT}')
) )
) )
logger.custom_info(f'Starting Temporal Client at {host}...', metadata) logger.custom_info(f'SDK metrics server initialized on port {SDK_METRICS_PORT}', metadata)
temporal_client = await client.Client.connect( temporal_client = await client.Client.connect(
target_host=host, target_host=host,
namespace=os.getenv('TEMPORAL_NAMESPACE', 'model-manager'), namespace=os.getenv('TEMPORAL_NAMESPACE', 'model-manager'),
runtime=new_runtime, runtime=new_runtime,
tls=use_tls,
) )
logger.custom_info('Starting Workers...', metadata) logger.custom_info(f'Temporal client initialized at {host}', metadata)
# Create cleanup schedule (idempotent - only creates if doesn't exist)
try:
await create_cleanup_schedule(temporal_client, logger, metadata)
except Exception as e: # noqa: BLE001
logger.custom_error(f'Failed to configure cleanup schedule: {e}', metadata)
# Don't fail the worker startup if schedule creation fails
# The schedule can be created manually if needed
workers = [ workers = [
Worker( Worker(
temporal_client, temporal_client,
task_queue='train_model-queue', task_queue=TRAIN_TASK_QUEUE,
workflows=[TrainModel], workflows=[TrainModel],
activities=[ activities=[
activities.update_experiment_run, activities.update_experiment_run,
@@ -130,20 +143,36 @@ async def main():
activities.train_model, activities.train_model,
activities.cleanup_resources, activities.cleanup_resources,
], ],
max_concurrent_workflow_tasks=50, max_concurrent_workflow_tasks=10,
max_concurrent_activities=50, max_concurrent_activities=10,
max_concurrent_local_activities=50, max_concurrent_local_activities=10,
max_cached_workflows=200, max_cached_workflows=100,
workflow_task_poller_behavior=PollerBehaviorAutoscaling(),
activity_task_poller_behavior=PollerBehaviorAutoscaling(),
),
Worker(
temporal_client,
task_queue=CLEANUP_TASK_QUEUE,
workflows=[CleanupFiles],
activities=[
activities.cleanup_minio_files,
activities.cleanup_temp_directories,
],
max_concurrent_workflow_tasks=20,
max_concurrent_activities=20,
max_concurrent_local_activities=20,
max_cached_workflows=100,
workflow_task_poller_behavior=PollerBehaviorAutoscaling(), workflow_task_poller_behavior=PollerBehaviorAutoscaling(),
activity_task_poller_behavior=PollerBehaviorAutoscaling(), activity_task_poller_behavior=PollerBehaviorAutoscaling(),
), ),
] ]
handlers = [] handlers = []
for w in workers: for w in workers:
handlers.append(w.run()) handlers.append(w.run())
logger.custom_info('Workers started successfully', metadata) logger.custom_info('Model manager workers initialized', metadata)
try: try:
# This will run the workers and wait for them to complete. # This will run the workers and wait for them to complete.
@@ -153,13 +182,14 @@ async def main():
logger.custom_error(f'An unhandled exception occurred: {e}', metadata) logger.custom_error(f'An unhandled exception occurred: {e}', metadata)
finally: finally:
notification_handler.shutdown() notification_handler.shutdown()
logger.custom_info('MongoDB client closed', metadata)
await activities.shutdown() await activities.shutdown()
# Exit with a non-zero status code to indicate failure to Kubernetes # Exit with a non-zero status code to indicate failure to Kubernetes
metrics.APP_UP.labels(pod_id=POD_ID).set(0) # Mark app as DOWN metrics.APP_UP.labels(pod_id=POD_ID).set(0) # Mark app as DOWN
sys.exit(1) sys.exit(1)
def start_prometheus_server(): def start_prometheus_server(logger: SientiaLogger, metadata: dict[str, str | None]):
""" """
Starts the Prometheus metrics server for monitoring and observability. Starts the Prometheus metrics server for monitoring and observability.
@@ -179,10 +209,10 @@ def start_prometheus_server():
try: try:
port = int(os.getenv('HTTP_METRICS_PORT', 9090)) port = int(os.getenv('HTTP_METRICS_PORT', 9090))
start_http_server(port) start_http_server(port)
print(f'Prometheus server started on port {port}.') logger.custom_info(f'Prometheus server initialized on port {port}.', metadata)
metrics.APP_UP.labels(pod_id=POD_ID).set(1) # Mark app as UP metrics.APP_UP.labels(pod_id=POD_ID).set(1) # Mark app as UP
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
print(f'Failed to start Prometheus server: {e}') logger.custom_critical(f'Failed to start Prometheus server: {e}', metadata)
os._exit(1) os._exit(1)

View File

@@ -0,0 +1,83 @@
"""
Cleanup workflow for removing stale files from MinIO and local filesystem.
This module provides a Temporal cron workflow that runs daily to clean up
temporary files and directories older than the configured retention period.
"""
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
import os
from datetime import timedelta
from typing import Any
from model_manager.activities.activities import Activities
from model_manager.workflows.train_model import POD_ID, network_retry_policy, no_retry_policy
TIMEOUT_CLEANUP_MINIO = int(os.getenv('TIMEOUT_CLEANUP_MINIO', '300'))
TIMEOUT_CLEANUP_LOCAL = int(os.getenv('TIMEOUT_CLEANUP_LOCAL', '120'))
DEFAULT_CLEANUP_BUCKET = os.getenv('DEFAULT_CLEANUP_BUCKET', 'model-training')
@workflow.defn(name='cleanup_files')
class CleanupFiles:
"""
Cleanup workflow for removing stale files.
This workflow cleans up:
- MinIO files with timestamp prefixes
- Local temporary directories with timestamp suffixes
The workflow is designed to be simple and robust, with error handling
delegated to the individual activities.
"""
@workflow.run
async def run(self, input_data: dict[str, Any]) -> None:
"""
Execute the cleanup workflow.
This method orchestrates the cleanup of MinIO files and local directories
in sequence. No exception handling is needed as activities handle their
own errors and notifications.
Args:
input_data: Workflow configuration containing optional:
- bucket_name (str): Bucket to clean (defaults to environment variable)
"""
# Get bucket name from input or environment
bucket_name = input_data.get('bucket_name', DEFAULT_CLEANUP_BUCKET)
# Default temp path for local cleanup
temp_path = 'model_manager/reports/temp'
# Metadata for tracking
metadata = {
'metadata': {
'pod_id': POD_ID,
'workflow_name': 'cleanup_files',
}
}
# Execute MinIO cleanup
await workflow.execute_activity_method(
Activities.cleanup_minio_files,
{
**metadata,
'bucket_name': bucket_name,
},
retry_policy=network_retry_policy,
start_to_close_timeout=timedelta(seconds=TIMEOUT_CLEANUP_MINIO),
)
# Execute local directory cleanup
await workflow.execute_activity_method(
Activities.cleanup_temp_directories,
{
**metadata,
'temp_path': temp_path,
},
retry_policy=no_retry_policy,
start_to_close_timeout=timedelta(seconds=TIMEOUT_CLEANUP_LOCAL),
)

View File

@@ -3,7 +3,7 @@ psycopg2-binary==2.9.11
sqlalchemy==2.0.44 sqlalchemy==2.0.44
boto3==1.40.55 boto3==1.40.55
botocore==1.40.55 botocore==1.40.55
git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.5.2 git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.6.1
prometheus-client==0.23.1 prometheus-client==0.23.1
mlflow==2.10.1 mlflow==2.10.1
evidently==0.4.21 evidently==0.4.21

View File

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

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Run cleanup_files workflow once for manual testing.
This script starts the Temporal workflow `cleanup_files` a single time,
using the same Temporal namespace and task queue as the main worker.
It is intended only for local/manual testing; scheduling (cron) must be
configured separately in Temporal.
"""
from __future__ import annotations
import asyncio
import os
import sys
from datetime import timedelta
from typing import Any
from dotenv import load_dotenv
from pathlib import Path
from temporalio.client import Client
# Ensure project root is on PYTHONPATH when running directly
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
from model_manager.workflows.cleanup_files import CleanupFiles
# Carrega variáveis de ambiente do arquivo .env na raiz do projeto
PROJECT_ROOT = Path(__file__).resolve().parent.parent
ENV_PATH = PROJECT_ROOT / '.env'
if ENV_PATH.exists():
load_dotenv(dotenv_path=ENV_PATH)
async def main(argv: list[str]) -> None:
"""Entry point for manual cleanup workflow execution.
Args:
argv: Command-line arguments (excluding program name).
"""
# Config from environment / defaults
temporal_host = os.getenv('TEMPORAL_HOST')
temporal_namespace = os.getenv('TEMPORAL_NAMESPACE')
task_queue = os.getenv('CLEANUP_TASK_QUEUE')
default_bucket = os.getenv('DEFAULT_CLEANUP_BUCKET')
use_tls = os.getenv('TEMPORAL_USE_TLS', 'false').lower() == 'true'
# Optional CLI: bucket name override
bucket_name = default_bucket
if argv:
bucket_name = argv[0]
print(f"Connecting to Temporal at {temporal_host} (namespace={temporal_namespace})...")
client = await Client.connect(
target_host=temporal_host,
namespace=temporal_namespace,
tls=use_tls,
)
input_data: dict[str, Any] = {
'bucket_name': bucket_name,
}
workflow_id = f"cleanup-files-manual-{int(asyncio.get_event_loop().time())}"
print(f"Starting cleanup_files workflow once...\n"
f" workflow_id = {workflow_id}\n"
f" task_queue = {task_queue}\n"
f" bucket_name = {bucket_name}")
handle = await client.start_workflow(
CleanupFiles.run,
input_data,
id=workflow_id,
task_queue=task_queue,
run_timeout=timedelta(minutes=10),
)
print("Workflow started, waiting for completion...")
await handle.result()
print("cleanup_files workflow completed successfully.")
if __name__ == '__main__': # pragma: no cover - manual utility script
asyncio.run(main(sys.argv[1:]))

View File

@@ -24,29 +24,35 @@ import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv
import psycopg2 import psycopg2
from psycopg2.extras import Json from psycopg2.extras import Json
from temporalio import client from temporalio import client
# Carrega variáveis de ambiente do arquivo .env na raiz do projeto
PROJECT_ROOT = Path(__file__).resolve().parent.parent
ENV_PATH = PROJECT_ROOT / '.env'
if ENV_PATH.exists():
load_dotenv(dotenv_path=ENV_PATH)
DOCS_PATH = Path('docs/test-model-data.csv') DOCS_PATH = Path('docs/test-model-data.csv')
MINIO_ALIAS = os.getenv('MINIO_ALIAS', 'suse') MINIO_ALIAS = 'suse'
MINIO_BUCKET = os.getenv('MINIO_BUCKET', 'model-training') MINIO_BUCKET = 'model-training'
POSTGRES_CONFIG = { POSTGRES_CONFIG = {
'host': os.getenv('POSTGRES_HOST', 'localhost'), 'host': os.getenv('POSTGRES_HOST'),
'port': os.getenv('POSTGRES_PORT', '55432'), 'port': os.getenv('POSTGRES_PORT'),
'user': os.getenv('POSTGRES_USER', 'postgres'), 'user': os.getenv('POSTGRES_USER'),
'password': os.getenv( 'password': os.getenv('POSTGRES_PASSWORD'),
'POSTGRES_PASSWORD', 'dbname': os.getenv('POSTGRES_DBNAME'),
'nFqc81y6kwmr2zuAIx43DhiOosFCVPpeEfTtTWZflkNjB2j1KtEeIANkhFR9mAX3',
),
'dbname': os.getenv('POSTGRES_DBNAME', 'sientia-core-mlops-bff'),
} }
TEMPORAL_HOST = os.getenv('TEMPORAL_HOST', 'localhost:37463') TEMPORAL_HOST = os.getenv('TEMPORAL_HOST')
TEMPORAL_NAMESPACE = os.getenv('TEMPORAL_NAMESPACE', 'model-manager') TEMPORAL_NAMESPACE = os.getenv('TEMPORAL_NAMESPACE')
TEMPORAL_TASK_QUEUE = os.getenv('TEMPORAL_TASK_QUEUE', 'train_model-queue') TRAIN_TASK_QUEUE = os.getenv('TRAIN_TASK_QUEUE')
TEMPORAL_WORKFLOW = os.getenv('TEMPORAL_WORKFLOW', 'train_model') TEMPORAL_WORKFLOW = 'train_model'
BASE_REQUEST_DATA = { BASE_REQUEST_DATA = {
'experimentName': 'model-manager-test-01', 'experimentName': 'model-manager-test-01',
@@ -169,6 +175,7 @@ async def trigger_temporal_workflow(workflow_input: dict) -> str:
temporal_client = await client.Client.connect( temporal_client = await client.Client.connect(
target_host=TEMPORAL_HOST, target_host=TEMPORAL_HOST,
namespace=TEMPORAL_NAMESPACE, namespace=TEMPORAL_NAMESPACE,
tls=os.getenv('TEMPORAL_USE_TLS', False),
) )
workflow_id = f'train-model-test-{uuid.uuid4()}' workflow_id = f'train-model-test-{uuid.uuid4()}'
@@ -176,7 +183,7 @@ async def trigger_temporal_workflow(workflow_input: dict) -> str:
TEMPORAL_WORKFLOW, TEMPORAL_WORKFLOW,
workflow_input, workflow_input,
id=workflow_id, id=workflow_id,
task_queue=TEMPORAL_TASK_QUEUE, task_queue=TRAIN_TASK_QUEUE,
execution_timeout=timedelta(minutes=5), execution_timeout=timedelta(minutes=5),
run_timeout=timedelta(minutes=5), run_timeout=timedelta(minutes=5),
task_timeout=timedelta(minutes=5), task_timeout=timedelta(minutes=5),

View File

@@ -0,0 +1,569 @@
"""Unit tests for the Cleanup activity, ensuring 100% code coverage."""
import asyncio
import os
import shutil
import tempfile
from datetime import UTC, datetime, timedelta
from importlib import reload
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Define mocks at the top level to be accessible by all tests
@pytest.fixture
def mock_logger():
"""Fixture for a mock logger."""
return MagicMock()
@pytest.fixture
def mock_notification_handler():
"""Fixture for a mock notification handler."""
return MagicMock()
@pytest.fixture
def mock_metrics_controller():
"""Fixture for a mock metrics controller with async methods."""
controller = MagicMock()
controller.shutdown = AsyncMock()
controller.emit = AsyncMock()
return controller
@pytest.fixture
def mock_storage_repository():
"""Fixture for a mock storage repository."""
repo = MagicMock()
repo.delete_file = MagicMock()
repo.list_bucket_objects = MagicMock()
return repo
@pytest.fixture
def temp_dir():
"""Fixture to create and clean up a temporary directory."""
path = tempfile.mkdtemp()
yield path
shutil.rmtree(path)
# --- Initialization Tests ---
@patch.dict(
'model_manager.activities.cleanup.os.environ',
{
'CLEANUP_RETENTION_HOURS': '24',
'CLEANUP_DRY_RUN': 'false',
'MAX_KEYS_CLEANUP': '1000',
},
)
def test_cleanup_init_default_values(
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test Cleanup initialization uses default environment values."""
import model_manager.activities.cleanup
reload(model_manager.activities.cleanup)
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
assert cleanup.retention_hours == 24
assert cleanup.dry_run is False
assert cleanup.max_keys_cleanup == 1000
def test_cleanup_init_custom_env_values(
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test Cleanup initialization with custom environment values."""
with patch.dict(
os.environ,
{
'CLEANUP_RETENTION_HOURS': '48',
'CLEANUP_DRY_RUN': 'true',
'MAX_KEYS_CLEANUP': '500',
},
):
import model_manager.activities.cleanup
reload(model_manager.activities.cleanup)
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
assert cleanup.retention_hours == 48
assert cleanup.dry_run is True
assert cleanup.max_keys_cleanup == 500
@patch.dict(os.environ, {'CLEANUP_RETENTION_HOURS': 'invalid'})
def test_cleanup_init_invalid_env_value_raises_error():
"""Test Cleanup module raises ValueError for invalid environment variables on import."""
import model_manager.activities.cleanup
with pytest.raises(ValueError):
reload(model_manager.activities.cleanup)
# --- MinIO Cleanup Tests ---
def test_cleanup_minio_files_missing_bucket_name(
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test cleanup_minio_files raises ValueError if bucket_name is missing."""
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup._emit_metrics = AsyncMock()
with pytest.raises(ValueError, match='bucket_name must be provided'):
asyncio.run(cleanup.cleanup_minio_files({'metadata': {}}))
@patch.dict('model_manager.activities.cleanup.os.environ', {'CLEANUP_DRY_RUN': 'false'})
def test_cleanup_minio_files_success_with_deletions(
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test successful deletion of old files from MinIO."""
import model_manager.activities.cleanup
reload(model_manager.activities.cleanup)
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup._emit_metrics = AsyncMock()
old_ts = int((datetime.now(UTC) - timedelta(hours=48)).timestamp() * 1000)
recent_ts = int((datetime.now(UTC) - timedelta(hours=1)).timestamp() * 1000)
mock_storage_repository.list_bucket_objects.return_value = [
f'{old_ts}-old-file.txt',
f'{recent_ts}-recent-file.txt',
'no-timestamp-file.txt',
]
asyncio.run(cleanup.cleanup_minio_files({'bucket_name': 'test-bucket', 'metadata': {}}))
mock_storage_repository.delete_file.assert_called_once_with(
'test-bucket', f'{old_ts}-old-file.txt'
)
cleanup._emit_metrics.assert_called_once()
def test_cleanup_minio_files_dry_run(
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test MinIO cleanup in dry_run mode does not delete files."""
with patch.dict(os.environ, {'CLEANUP_DRY_RUN': 'true'}):
import model_manager.activities.cleanup
reload(model_manager.activities.cleanup)
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup._emit_metrics = AsyncMock()
old_ts = int((datetime.now(UTC) - timedelta(hours=48)).timestamp() * 1000)
mock_storage_repository.list_bucket_objects.return_value = [f'{old_ts}-old-file.txt']
asyncio.run(cleanup.cleanup_minio_files({'bucket_name': 'test-bucket', 'metadata': {}}))
mock_storage_repository.delete_file.assert_not_called()
cleanup._emit_metrics.assert_called_once()
@patch.dict('model_manager.activities.cleanup.os.environ', {'CLEANUP_DRY_RUN': 'false'})
def test_cleanup_minio_files_delete_error(
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test error during MinIO file deletion is handled gracefully."""
import model_manager.activities.cleanup
reload(model_manager.activities.cleanup)
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup._emit_metrics = AsyncMock()
cleanup.error = MagicMock()
old_ts = int((datetime.now(UTC) - timedelta(hours=48)).timestamp() * 1000)
mock_storage_repository.list_bucket_objects.return_value = [f'{old_ts}-old-file.txt']
mock_storage_repository.delete_file.side_effect = OSError('Permission Denied')
asyncio.run(cleanup.cleanup_minio_files({'bucket_name': 'test-bucket', 'metadata': {}}))
cleanup.error.assert_called_once()
cleanup._emit_metrics.assert_called_once()
def test_cleanup_minio_files_exception_handling(
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test exception during MinIO cleanup triggers notification and metrics."""
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup.send_notification = MagicMock()
cleanup._emit_metrics = AsyncMock()
mock_storage_repository.list_bucket_objects.side_effect = Exception('Connection Error')
with pytest.raises(Exception, match='Connection Error'):
asyncio.run(cleanup.cleanup_minio_files({'bucket_name': 'test-bucket', 'metadata': {}}))
cleanup.send_notification.assert_called_once()
cleanup._emit_metrics.assert_called_once()
# --- Temp Directory Cleanup Tests ---
def test_cleanup_temp_directories_nonexistent_path(
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test temp directory cleanup with a non-existent path."""
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup._emit_metrics = AsyncMock()
cleanup.warning = MagicMock()
asyncio.run(
cleanup.cleanup_temp_directories({'temp_path': '/nonexistent/path', 'metadata': {}})
)
cleanup.warning.assert_called_once()
cleanup._emit_metrics.assert_called_once()
@patch.dict('model_manager.activities.cleanup.os.environ', {'CLEANUP_DRY_RUN': 'false'})
def test_cleanup_temp_directories_success_with_deletions(
temp_dir,
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test successful deletion of old temporary directories."""
import model_manager.activities.cleanup
reload(model_manager.activities.cleanup)
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup._emit_metrics = AsyncMock()
old_time = (datetime.now() - timedelta(hours=48)).strftime('%Y%m%d_%H%M%S_000000')
old_dir = os.path.join(temp_dir, f'old_dir_{old_time}')
os.makedirs(old_dir)
recent_time = (datetime.now() - timedelta(hours=1)).strftime('%Y%m%d_%H%M%S_000000')
recent_dir = os.path.join(temp_dir, f'recent_dir_{recent_time}')
os.makedirs(recent_dir)
asyncio.run(cleanup.cleanup_temp_directories({'temp_path': temp_dir, 'metadata': {}}))
assert not os.path.exists(old_dir)
assert os.path.exists(recent_dir)
cleanup._emit_metrics.assert_called_once()
@patch.dict(os.environ, {'CLEANUP_DRY_RUN': 'true'})
def test_cleanup_temp_directories_dry_run(
temp_dir,
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test temp directory cleanup in dry_run mode does not delete."""
import model_manager.activities.cleanup
reload(model_manager.activities.cleanup)
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup._emit_metrics = AsyncMock()
old_time = (datetime.now() - timedelta(hours=48)).strftime('%Y%m%d_%H%M%S_000000')
old_dir = os.path.join(temp_dir, f'old_dir_{old_time}')
os.makedirs(old_dir)
asyncio.run(cleanup.cleanup_temp_directories({'temp_path': temp_dir, 'metadata': {}}))
assert os.path.exists(old_dir)
cleanup._emit_metrics.assert_called_once()
@patch.dict('model_manager.activities.cleanup.os.environ', {'CLEANUP_DRY_RUN': 'false'})
def test_cleanup_temp_directories_delete_error(
temp_dir,
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test graceful handling of errors during directory deletion."""
import model_manager.activities.cleanup
reload(model_manager.activities.cleanup)
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup._emit_metrics = AsyncMock()
cleanup.error = MagicMock()
old_time = (datetime.now() - timedelta(hours=48)).strftime('%Y%m%d_%H%M%S_000000')
old_dir = os.path.join(temp_dir, f'old_dir_{old_time}')
os.makedirs(old_dir)
with patch('shutil.rmtree', side_effect=OSError('Permission Denied')):
asyncio.run(cleanup.cleanup_temp_directories({'temp_path': temp_dir, 'metadata': {}}))
cleanup.error.assert_called_once()
cleanup._emit_metrics.assert_called_once()
# --- Metrics and Utility Tests ---
def test_emit_metrics(
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test that _emit_metrics calls the public emit_metric method."""
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup.emit_metric = AsyncMock()
asyncio.run(
cleanup._emit_metrics(
metadata={'pod_id': 'p1', 'workflow_name': 'wf1'},
metrics_status='success',
activity_name='test_activity',
emit_workflow_metric=True,
)
)
assert cleanup.emit_metric.call_count == 2
def test_cleanup_temp_directories_with_files_and_unmatched_dirs(
temp_dir,
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test that files and directories with non-matching names are skipped."""
import model_manager.activities.cleanup
reload(model_manager.activities.cleanup)
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup._emit_metrics = AsyncMock()
cleanup.debug = MagicMock()
# Create a file and a directory with a non-matching name
with open(os.path.join(temp_dir, 'a_file.txt'), 'w') as f:
f.write('hello')
os.makedirs(os.path.join(temp_dir, 'a_directory_with_no_timestamp'))
asyncio.run(cleanup.cleanup_temp_directories({'temp_path': temp_dir, 'metadata': {}}))
# Ensure the debug message for skipping was called for the unmatched directory
cleanup.debug.assert_called_with(
'Skipping directory without timestamp pattern: a_directory_with_no_timestamp', {}
)
cleanup._emit_metrics.assert_called_once()
def test_cleanup_temp_directories_invalid_timestamp_format(
temp_dir,
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test that a directory with an invalid timestamp format is handled correctly."""
import model_manager.activities.cleanup
reload(model_manager.activities.cleanup)
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup._emit_metrics = AsyncMock()
cleanup.error = MagicMock()
# Create a directory with a malformed timestamp that matches the regex but fails parsing
malformed_dir_name = 'dir_20239999_999999_999999'
os.makedirs(os.path.join(temp_dir, malformed_dir_name))
asyncio.run(cleanup.cleanup_temp_directories({'temp_path': temp_dir, 'metadata': {}}))
cleanup.error.assert_called_once()
cleanup._emit_metrics.assert_called_once()
def test_cleanup_temp_directories_generic_exception(
temp_dir,
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test that a generic exception during directory cleanup is handled."""
import model_manager.activities.cleanup
reload(model_manager.activities.cleanup)
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup._emit_metrics = AsyncMock()
cleanup.send_notification = MagicMock()
with patch('os.listdir', side_effect=Exception('Unexpected OS Error')):
with pytest.raises(Exception, match='Unexpected OS Error'):
asyncio.run(cleanup.cleanup_temp_directories({'temp_path': temp_dir, 'metadata': {}}))
cleanup.send_notification.assert_called_once()
cleanup._emit_metrics.assert_called_once()
def test_emit_metrics_activity_only(
mock_storage_repository,
mock_logger,
mock_notification_handler,
mock_metrics_controller,
):
"""Test that _emit_metrics can emit only the activity metric."""
from model_manager.activities.cleanup import Cleanup
cleanup = Cleanup(
storage_repository=mock_storage_repository,
logger=mock_logger,
notification_handler=mock_notification_handler,
metrics_controller=mock_metrics_controller,
)
cleanup.emit_metric = AsyncMock()
asyncio.run(
cleanup._emit_metrics(
metadata={'pod_id': 'p1', 'workflow_name': 'wf1'},
metrics_status='success',
activity_name='test_activity',
emit_workflow_metric=False,
)
)
cleanup.emit_metric.assert_called_once()

View File

View File

@@ -0,0 +1,362 @@
"""Tests for cleanup schedule management."""
import os
from datetime import timedelta
from importlib import reload
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture
def mock_temporal_client():
"""Fixture for a mock Temporal client."""
client = AsyncMock()
client.list_schedules = AsyncMock()
client.create_schedule = AsyncMock()
return client
@pytest.fixture
def mock_logger():
"""Fixture for a mock Sientia logger."""
logger = MagicMock()
logger.custom_info = MagicMock()
logger.custom_error = MagicMock()
return logger
@pytest.fixture
def metadata():
"""Fixture for metadata dict."""
return {'pod_id': 'test-pod', 'project_name': 'test-project'}
# --- schedule_exists Tests ---
@pytest.mark.asyncio
async def test_schedule_exists_returns_true_when_schedule_found(
mock_temporal_client, mock_logger, metadata
):
"""Test that schedule_exists returns True when schedule is found."""
from model_manager.schedules.cleanup_schedule import schedule_exists
# Mock schedule list with matching schedule
mock_schedule = MagicMock()
mock_schedule.id = 'test-schedule-id'
async def mock_list_schedules():
yield mock_schedule
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
result = await schedule_exists(mock_temporal_client, 'test-schedule-id', mock_logger, metadata)
assert result is True
mock_temporal_client.list_schedules.assert_called_once()
@pytest.mark.asyncio
async def test_schedule_exists_returns_false_when_schedule_not_found(
mock_temporal_client, mock_logger, metadata
):
"""Test that schedule_exists returns False when schedule is not found."""
from model_manager.schedules.cleanup_schedule import schedule_exists
# Mock empty schedule list
async def mock_list_schedules():
return
yield # Make it an async generator
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
result = await schedule_exists(
mock_temporal_client, 'nonexistent-schedule', mock_logger, metadata
)
assert result is False
mock_temporal_client.list_schedules.assert_called_once()
@pytest.mark.asyncio
async def test_schedule_exists_returns_false_when_different_schedule_found(
mock_temporal_client, mock_logger, metadata
):
"""Test that schedule_exists returns False when only different schedules exist."""
from model_manager.schedules.cleanup_schedule import schedule_exists
# Mock schedule list with non-matching schedule
mock_schedule = MagicMock()
mock_schedule.id = 'different-schedule-id'
async def mock_list_schedules():
yield mock_schedule
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
result = await schedule_exists(mock_temporal_client, 'test-schedule-id', mock_logger, metadata)
assert result is False
mock_temporal_client.list_schedules.assert_called_once()
@pytest.mark.asyncio
async def test_schedule_exists_handles_exception(mock_temporal_client, mock_logger, metadata):
"""Test that schedule_exists handles exceptions gracefully."""
from model_manager.schedules.cleanup_schedule import schedule_exists
# Mock list_schedules to raise an exception
mock_temporal_client.list_schedules.side_effect = Exception('Connection error')
result = await schedule_exists(mock_temporal_client, 'test-schedule-id', mock_logger, metadata)
assert result is False
mock_logger.custom_error.assert_called_once()
assert 'Error checking if schedule exists' in mock_logger.custom_error.call_args[0][0]
# --- create_cleanup_schedule Tests ---
@pytest.mark.asyncio
async def test_create_cleanup_schedule_skips_when_exists(
mock_temporal_client, mock_logger, metadata
):
"""Test that create_cleanup_schedule skips creation when schedule already exists."""
import model_manager.schedules.cleanup_schedule
reload(model_manager.schedules.cleanup_schedule)
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
# Mock schedule already exists
mock_schedule = MagicMock()
mock_schedule.id = 'cleanup-files-daily'
async def mock_list_schedules():
yield mock_schedule
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
await create_cleanup_schedule(mock_temporal_client, mock_logger, metadata)
# Verify schedule creation was NOT called
mock_temporal_client.create_schedule.assert_not_called()
# Verify info log was called
mock_logger.custom_info.assert_called_once()
assert 'already configured' in mock_logger.custom_info.call_args[0][0]
@pytest.mark.asyncio
@patch.dict(
'model_manager.schedules.cleanup_schedule.os.environ',
{
'CLEANUP_SCHEDULE_ID': 'test-cleanup-schedule',
'CLEANUP_CRON': '0 2 * * *',
'CLEANUP_TIMEZONE': 'America/Sao_Paulo',
'CLEANUP_TASK_QUEUE': 'test-cleanup-queue',
'CLEANUP_EXECUTION_TIMEOUT_HOURS': '2',
},
)
async def test_create_cleanup_schedule_creates_with_custom_config(
mock_temporal_client, mock_logger, metadata
):
"""Test that create_cleanup_schedule creates schedule with custom configuration."""
import model_manager.schedules.cleanup_schedule
reload(model_manager.schedules.cleanup_schedule)
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
# Mock schedule does not exist (empty list)
async def mock_list_schedules():
return
yield # Make it an async generator
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
await create_cleanup_schedule(mock_temporal_client, mock_logger, metadata)
# Verify schedule creation was called
mock_temporal_client.create_schedule.assert_called_once()
# Verify schedule parameters
call_args = mock_temporal_client.create_schedule.call_args
schedule_id = call_args[0][0]
schedule_obj = call_args[0][1]
assert schedule_id == 'test-cleanup-schedule'
assert schedule_obj.action.workflow == 'cleanup_files'
assert schedule_obj.action.task_queue == 'test-cleanup-queue'
assert schedule_obj.action.execution_timeout == timedelta(hours=2)
assert schedule_obj.spec.cron_expressions == ['0 2 * * *']
assert schedule_obj.spec.time_zone_name == 'America/Sao_Paulo'
# Verify success log was called
assert mock_logger.custom_info.call_count == 1
assert 'created successfully' in mock_logger.custom_info.call_args[0][0]
@pytest.mark.asyncio
@patch.dict(
'model_manager.schedules.cleanup_schedule.os.environ',
{
'CLEANUP_SCHEDULE_ID': 'default-schedule',
},
)
async def test_create_cleanup_schedule_uses_defaults(mock_temporal_client, mock_logger, metadata):
"""Test that create_cleanup_schedule uses default values when env vars not set."""
import model_manager.schedules.cleanup_schedule
# Remove optional env vars to test defaults
for key in [
'CLEANUP_CRON',
'CLEANUP_TIMEZONE',
'CLEANUP_TASK_QUEUE',
'CLEANUP_EXECUTION_TIMEOUT_HOURS',
]:
os.environ.pop(key, None)
reload(model_manager.schedules.cleanup_schedule)
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
# Mock schedule does not exist (empty list)
async def mock_list_schedules():
return
yield # Make it an async generator
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
await create_cleanup_schedule(mock_temporal_client, mock_logger, metadata)
# Verify schedule creation was called
mock_temporal_client.create_schedule.assert_called_once()
# Verify default parameters
call_args = mock_temporal_client.create_schedule.call_args
schedule_obj = call_args[0][1]
assert schedule_obj.spec.cron_expressions == ['0 0 * * *'] # Default midnight
assert schedule_obj.spec.time_zone_name == 'UTC' # Default UTC
assert schedule_obj.action.task_queue == 'cleanup-queue' # Default queue
assert schedule_obj.action.execution_timeout == timedelta(hours=1) # Default 1 hour
@pytest.mark.asyncio
async def test_create_cleanup_schedule_workflow_id_format(
mock_temporal_client, mock_logger, metadata
):
"""Test that workflow ID is correctly formatted with schedule ID."""
import model_manager.schedules.cleanup_schedule
reload(model_manager.schedules.cleanup_schedule)
from model_manager.schedules.cleanup_schedule import (
SCHEDULE_ID,
create_cleanup_schedule,
)
# Mock schedule does not exist (empty list)
async def mock_list_schedules():
return
yield # Make it an async generator
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
await create_cleanup_schedule(mock_temporal_client, mock_logger, metadata)
# Verify workflow ID format
call_args = mock_temporal_client.create_schedule.call_args
schedule_obj = call_args[0][1]
expected_workflow_id = f'cleanup-files-scheduled-{SCHEDULE_ID}'
assert schedule_obj.action.id == expected_workflow_id
@pytest.mark.asyncio
async def test_create_cleanup_schedule_empty_workflow_args(
mock_temporal_client, mock_logger, metadata
):
"""Test that workflow is created with empty args (uses env defaults)."""
import model_manager.schedules.cleanup_schedule
reload(model_manager.schedules.cleanup_schedule)
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
# Mock schedule does not exist (empty list)
async def mock_list_schedules():
return
yield # Make it an async generator
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
await create_cleanup_schedule(mock_temporal_client, mock_logger, metadata)
# Verify workflow args are empty (it's a list with one empty dict)
call_args = mock_temporal_client.create_schedule.call_args
schedule_obj = call_args[0][1]
# The args are passed as positional args, so it's a list with one element
assert schedule_obj.action.args == [{}]
# --- Environment Variable Configuration Tests ---
@patch.dict(
'model_manager.schedules.cleanup_schedule.os.environ',
{
'CLEANUP_SCHEDULE_ID': 'custom-id',
'CLEANUP_CRON': '30 3 * * 1',
'CLEANUP_TIMEZONE': 'Europe/London',
'CLEANUP_TASK_QUEUE': 'custom-queue',
'CLEANUP_EXECUTION_TIMEOUT_HOURS': '3',
},
)
def test_environment_variables_loaded_correctly():
"""Test that environment variables are loaded correctly."""
import model_manager.schedules.cleanup_schedule
reload(model_manager.schedules.cleanup_schedule)
from model_manager.schedules.cleanup_schedule import (
CLEANUP_CRON,
CLEANUP_EXECUTION_TIMEOUT_HOURS,
CLEANUP_TASK_QUEUE,
CLEANUP_TIMEZONE,
SCHEDULE_ID,
)
assert SCHEDULE_ID == 'custom-id'
assert CLEANUP_CRON == '30 3 * * 1'
assert CLEANUP_TIMEZONE == 'Europe/London'
assert CLEANUP_TASK_QUEUE == 'custom-queue'
assert CLEANUP_EXECUTION_TIMEOUT_HOURS == 3
def test_environment_variables_use_defaults_when_not_set():
"""Test that default values are used when environment variables are not set."""
import model_manager.schedules.cleanup_schedule
# Remove all env vars
for key in [
'CLEANUP_SCHEDULE_ID',
'CLEANUP_CRON',
'CLEANUP_TIMEZONE',
'CLEANUP_TASK_QUEUE',
'CLEANUP_EXECUTION_TIMEOUT_HOURS',
]:
os.environ.pop(key, None)
reload(model_manager.schedules.cleanup_schedule)
from model_manager.schedules.cleanup_schedule import (
CLEANUP_CRON,
CLEANUP_EXECUTION_TIMEOUT_HOURS,
CLEANUP_TASK_QUEUE,
CLEANUP_TIMEZONE,
SCHEDULE_ID,
)
assert SCHEDULE_ID == 'cleanup-files-daily'
assert CLEANUP_CRON == '0 0 * * *'
assert CLEANUP_TIMEZONE == 'UTC'
assert CLEANUP_TASK_QUEUE == 'cleanup-queue'
assert CLEANUP_EXECUTION_TIMEOUT_HOURS == 1

View File

@@ -1,6 +1,7 @@
"""Unit tests for ModelRepository with 100% coverage.""" """Unit tests for ModelRepository with 100% coverage."""
import os import os
import shutil
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import numpy as np import numpy as np
@@ -8,6 +9,29 @@ import pandas as pd
import pytest import pytest
@pytest.fixture(autouse=True)
def cleanup_temp_directories():
"""Clean up temporary directories after each test."""
# Get the temp directory path
current_file_dir = os.path.dirname(os.path.abspath(__file__))
model_manager_dir = os.path.dirname(os.path.dirname(os.path.dirname(current_file_dir)))
temp_dir = os.path.join(model_manager_dir, 'reports', 'temp')
# Run the test
yield
# Clean up after test
if os.path.exists(temp_dir):
for item in os.listdir(temp_dir):
item_path = os.path.join(temp_dir, item)
if os.path.isdir(item_path) and item.startswith('test_run_'):
try:
shutil.rmtree(item_path)
except (OSError, PermissionError):
# Ignore cleanup errors
pass
@pytest.fixture @pytest.fixture
def mock_logger(): def mock_logger():
"""Create a mock logger.""" """Create a mock logger."""
@@ -79,6 +103,9 @@ def test_save_model_success(mock_model_serving_class, mock_logger, mock_train_re
url='http://mlflow.test', username='user', password='pass', logger=mock_logger url='http://mlflow.test', username='user', password='pass', logger=mock_logger
) )
# Reset mock after initialization to focus on method-specific calls
mock_logger.reset_mock()
repo._get_next_run_name = MagicMock(return_value='test_experiment-1') repo._get_next_run_name = MagicMock(return_value='test_experiment-1')
repo._generate_artifacts = MagicMock(return_value=mock_train_result) repo._generate_artifacts = MagicMock(return_value=mock_train_result)
repo._save_run = MagicMock() repo._save_run = MagicMock()
@@ -105,6 +132,9 @@ def test_cleanup_run_directory_exists(
url='http://mlflow.test', username='user', password='pass', logger=mock_logger url='http://mlflow.test', username='user', password='pass', logger=mock_logger
) )
# Reset mock after initialization to focus on method-specific calls
mock_logger.reset_mock()
mock_exists.return_value = True mock_exists.return_value = True
repo.cleanup_run_directory('/tmp/test_run') # noqa: S108 repo.cleanup_run_directory('/tmp/test_run') # noqa: S108
@@ -124,6 +154,9 @@ def test_cleanup_run_directory_not_exists(mock_exists, mock_model_serving_class,
url='http://mlflow.test', username='user', password='pass', logger=mock_logger url='http://mlflow.test', username='user', password='pass', logger=mock_logger
) )
# Reset mock after initialization to focus on method-specific calls
mock_logger.reset_mock()
mock_exists.return_value = False mock_exists.return_value = False
repo.cleanup_run_directory('/tmp/test_run') # noqa: S108 repo.cleanup_run_directory('/tmp/test_run') # noqa: S108
@@ -141,6 +174,9 @@ def test_cleanup_run_directory_empty_path(mock_model_serving_class, mock_logger)
url='http://mlflow.test', username='user', password='pass', logger=mock_logger url='http://mlflow.test', username='user', password='pass', logger=mock_logger
) )
# Reset mock after initialization to focus on method-specific calls
mock_logger.reset_mock()
repo.cleanup_run_directory('') repo.cleanup_run_directory('')
mock_logger.info.assert_called_once_with('No run directory specified, skipping cleanup') mock_logger.info.assert_called_once_with('No run directory specified, skipping cleanup')
@@ -756,9 +792,14 @@ def test_generate_artifacts_no_run_name(
) )
mock_train_result.run_name = None mock_train_result.run_name = None
mock_exists.return_value = True # Mock reports directory exists
with pytest.raises(ValueError, match='run_name must be set'): # Mock _create_run_directory to avoid creating real directories
repo._generate_artifacts(mock_train_result) with patch.object(repo, '_create_run_directory') as mock_create_dir:
mock_create_dir.return_value = '/mock/run/dir'
with pytest.raises(ValueError, match='run_name must be set'):
repo._generate_artifacts(mock_train_result)
@patch('model_manager.utils.repository.model_repository.ModelServing') @patch('model_manager.utils.repository.model_repository.ModelServing')
@@ -800,8 +841,12 @@ def test_generate_artifacts_header_not_found(
mock_exists.side_effect = exists_side_effect mock_exists.side_effect = exists_side_effect
with pytest.raises(FileNotFoundError, match='Header file does not exist'): # Mock _create_run_directory to avoid creating real directories
repo._generate_artifacts(mock_train_result) with patch.object(repo, '_create_run_directory') as mock_create_dir:
mock_create_dir.return_value = '/mock/run/dir'
with pytest.raises(FileNotFoundError, match='Header file does not exist'):
repo._generate_artifacts(mock_train_result)
@patch('model_manager.utils.repository.model_repository.ModelServing') @patch('model_manager.utils.repository.model_repository.ModelServing')

View File

@@ -462,3 +462,52 @@ def test_fetch_file_logs_file_size(mock_boto3, mock_logger, storage_config):
# Verify logging includes file size # Verify logging includes file size
log_calls = [str(call) for call in mock_logger.info.call_args_list] log_calls = [str(call) for call in mock_logger.info.call_args_list]
assert any('12345 bytes' in str(call) for call in log_calls) assert any('12345 bytes' in str(call) for call in log_calls)
@patch('model_manager.utils.repository.storage_repository.boto3')
def test_close_method(mock_boto3, mock_logger, storage_config):
"""Test that the close method calls the underlying client's close method."""
from model_manager.utils.repository.storage_repository import StorageRepository
mock_s3_client = Mock()
mock_boto3.client.return_value = mock_s3_client
repo = StorageRepository(logger=mock_logger, **storage_config)
repo.close()
mock_s3_client.close.assert_called_once()
mock_logger.info.assert_called_with('MinIO client closed')
@patch('model_manager.utils.repository.storage_repository.boto3')
def test_list_bucket_objects_with_pagination(mock_boto3, mock_logger, storage_config):
"""Test list_bucket_objects with a paginated response."""
from model_manager.utils.repository.storage_repository import StorageRepository
mock_s3_client = Mock()
mock_paginator = Mock()
page1 = {
'Contents': [
{'Key': 'file1.txt'},
{'Key': 'file2.txt'},
]
}
page2 = {
'Contents': [
{'Key': 'file3.txt'},
]
}
page3 = {}
mock_paginator.paginate.return_value = [page1, page2, page3]
mock_s3_client.get_paginator.return_value = mock_paginator
mock_boto3.client.return_value = mock_s3_client
repo = StorageRepository(logger=mock_logger, **storage_config)
objects = repo.list_bucket_objects('test-bucket', max_keys=2)
assert objects == ['file1.txt', 'file2.txt', 'file3.txt']
assert len(objects) == 3
mock_s3_client.get_paginator.assert_called_once_with('list_objects_v2')
mock_paginator.paginate.assert_called_once_with(Bucket='test-bucket', MaxKeys=2)
mock_logger.info.assert_any_call('Listed 3 objects from bucket test-bucket')

View File

@@ -96,6 +96,7 @@ def test_build_mongo_db_config_with_env_vars():
'connection_string': 'mongodb://sientia1:sientia1@localhost:27018', 'connection_string': 'mongodb://sientia1:sientia1@localhost:27018',
'database_name': 'test_db', 'database_name': 'test_db',
'ttl_index_seconds': 3600, 'ttl_index_seconds': 3600,
'uri': 'localhost:27018',
} }
@@ -109,6 +110,7 @@ def test_build_mongo_db_config_with_defaults():
'connection_string': 'mongodb://root:wKZDbMNU1c@localhost:27018', 'connection_string': 'mongodb://root:wKZDbMNU1c@localhost:27018',
'database_name': 'sientia', 'database_name': 'sientia',
'ttl_index_seconds': 3600, 'ttl_index_seconds': 3600,
'uri': 'localhost:27018',
} }

View File

@@ -18,6 +18,8 @@ def mock_env_vars():
'TEMPORAL_HOST': 'localhost:7233', 'TEMPORAL_HOST': 'localhost:7233',
'TEMPORAL_NAMESPACE': 'test-namespace', 'TEMPORAL_NAMESPACE': 'test-namespace',
'PROJECT_NAME': 'test-project', 'PROJECT_NAME': 'test-project',
'TRAIN_TASK_QUEUE': 'train_model-local_queue',
'CLEANUP_TASK_QUEUE': 'cleanup-local_queue',
} }
with patch.dict(os.environ, env_vars, clear=False): with patch.dict(os.environ, env_vars, clear=False):
@@ -109,14 +111,18 @@ def test_sdk_metrics_port_from_env():
@patch('model_manager.worker.worker.POD_ID', 'test-pod-123') @patch('model_manager.worker.worker.POD_ID', 'test-pod-123')
@patch('model_manager.worker.worker.start_http_server') @patch('model_manager.worker.worker.start_http_server')
@patch('model_manager.worker.worker.metrics') @patch('model_manager.worker.worker.metrics')
def test_start_prometheus_server_success(mock_metrics, mock_start_http_server, mock_env_vars): def test_start_prometheus_server_success(
mock_metrics, mock_start_http_server, mock_env_vars, mock_logger
):
"""Test successful Prometheus server startup.""" """Test successful Prometheus server startup."""
from model_manager.worker.worker import start_prometheus_server from model_manager.worker.worker import start_prometheus_server
mock_app_up = Mock() mock_app_up = Mock()
mock_metrics.APP_UP.labels.return_value = mock_app_up mock_metrics.APP_UP.labels.return_value = mock_app_up
start_prometheus_server() metadata = {'pod_id': 'test-pod-123', 'workflow_name': 'train_model'}
start_prometheus_server(mock_logger, metadata)
# Verify HTTP server started # Verify HTTP server started
mock_start_http_server.assert_called_once_with(9090) mock_start_http_server.assert_called_once_with(9090)
@@ -124,11 +130,12 @@ def test_start_prometheus_server_success(mock_metrics, mock_start_http_server, m
# Verify APP_UP metric was set to 1 # Verify APP_UP metric was set to 1
mock_metrics.APP_UP.labels.assert_called_once_with(pod_id='test-pod-123') mock_metrics.APP_UP.labels.assert_called_once_with(pod_id='test-pod-123')
mock_app_up.set.assert_called_once_with(1) mock_app_up.set.assert_called_once_with(1)
mock_logger.custom_info.assert_called_once()
@patch('model_manager.worker.worker.start_http_server') @patch('model_manager.worker.worker.start_http_server')
@patch('model_manager.worker.worker.metrics') @patch('model_manager.worker.worker.metrics')
def test_start_prometheus_server_custom_port(mock_metrics, mock_start_http_server): def test_start_prometheus_server_custom_port(mock_metrics, mock_start_http_server, mock_logger):
"""Test Prometheus server startup with custom port.""" """Test Prometheus server startup with custom port."""
from model_manager.worker.worker import start_prometheus_server from model_manager.worker.worker import start_prometheus_server
@@ -136,7 +143,9 @@ def test_start_prometheus_server_custom_port(mock_metrics, mock_start_http_serve
mock_app_up = Mock() mock_app_up = Mock()
mock_metrics.APP_UP.labels.return_value = mock_app_up mock_metrics.APP_UP.labels.return_value = mock_app_up
start_prometheus_server() metadata = {'pod_id': 'custom-pod', 'workflow_name': 'train_model'}
start_prometheus_server(mock_logger, metadata)
mock_start_http_server.assert_called_once_with(8080) mock_start_http_server.assert_called_once_with(8080)
@@ -145,17 +154,20 @@ def test_start_prometheus_server_custom_port(mock_metrics, mock_start_http_serve
@patch('model_manager.worker.worker.metrics') @patch('model_manager.worker.worker.metrics')
@patch('model_manager.worker.worker.os._exit') @patch('model_manager.worker.worker.os._exit')
def test_start_prometheus_server_failure( def test_start_prometheus_server_failure(
mock_exit, mock_metrics, mock_start_http_server, mock_env_vars mock_exit, mock_metrics, mock_start_http_server, mock_env_vars, mock_logger
): ):
"""Test Prometheus server startup failure.""" """Test Prometheus server startup failure."""
from model_manager.worker.worker import start_prometheus_server from model_manager.worker.worker import start_prometheus_server
mock_start_http_server.side_effect = OSError('Port already in use') mock_start_http_server.side_effect = OSError('Port already in use')
start_prometheus_server() metadata = {'pod_id': 'test-pod-123', 'workflow_name': 'train_model'}
# Verify exit was called with code 1 start_prometheus_server(mock_logger, metadata)
# Verify exit was called with code 1 e log crítico emitido
mock_exit.assert_called_once_with(1) mock_exit.assert_called_once_with(1)
mock_logger.custom_critical.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -199,6 +211,7 @@ async def test_main_successful_startup(
mock_build_mongodb.return_value = { mock_build_mongodb.return_value = {
'connection_string': 'mongodb://test', 'connection_string': 'mongodb://test',
'database_name': 'test_db', 'database_name': 'test_db',
'uri': 'localhost:27018',
} }
mock_build_postgres.return_value = {} mock_build_postgres.return_value = {}
mock_build_mlflow.return_value = {} mock_build_mlflow.return_value = {}
@@ -234,7 +247,8 @@ async def test_main_successful_startup(
mock_notification_handler_class.assert_called_once() mock_notification_handler_class.assert_called_once()
mock_activities_class.assert_called_once() mock_activities_class.assert_called_once()
mock_client_class.connect.assert_called_once() mock_client_class.connect.assert_called_once()
mock_worker_class.assert_called_once() # Agora são criados dois Workers: um para train_model-queue e outro para cleanup-queue
assert mock_worker_class.call_count == 2
# Verify cleanup was performed # Verify cleanup was performed
mock_notification_handler.shutdown.assert_called_once() mock_notification_handler.shutdown.assert_called_once()
@@ -279,6 +293,7 @@ async def test_main_handles_exception(
mock_build_mongodb.return_value = { mock_build_mongodb.return_value = {
'connection_string': 'mongodb://test', 'connection_string': 'mongodb://test',
'database_name': 'test_db', 'database_name': 'test_db',
'uri': 'localhost:27018',
} }
mock_build_postgres.return_value = {} mock_build_postgres.return_value = {}
mock_build_mlflow.return_value = {} mock_build_mlflow.return_value = {}
@@ -322,6 +337,7 @@ async def test_main_handles_exception(
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('model_manager.worker.worker.create_cleanup_schedule')
@patch('model_manager.worker.worker.Worker') @patch('model_manager.worker.worker.Worker')
@patch('model_manager.worker.worker.client.Client') @patch('model_manager.worker.worker.client.Client')
@patch('model_manager.worker.worker.Runtime') @patch('model_manager.worker.worker.Runtime')
@@ -347,20 +363,28 @@ async def test_main_temporal_client_configuration(
mock_runtime_class, mock_runtime_class,
mock_client_class, mock_client_class,
mock_worker_class, mock_worker_class,
mock_create_cleanup_schedule,
mock_logger, mock_logger,
): ):
"""Test that Temporal client is configured correctly.""" """Test that Temporal client is configured correctly."""
from model_manager.worker.worker import main from model_manager.worker.worker import main
mock_create_cleanup_schedule.return_value = AsyncMock()
with patch.dict( with patch.dict(
os.environ, os.environ,
{'TEMPORAL_HOST': 'temporal.example.com:7233', 'TEMPORAL_NAMESPACE': 'production'}, {
'TEMPORAL_HOST': 'temporal.example.com:7233',
'TEMPORAL_NAMESPACE': 'production',
'TEMPORAL_USE_TLS': 'true',
},
): ):
# Setup mocks # Setup mocks
mock_get_logger.return_value = mock_logger mock_get_logger.return_value = mock_logger
mock_build_mongodb.return_value = { mock_build_mongodb.return_value = {
'connection_string': 'mongodb://test', 'connection_string': 'mongodb://test',
'database_name': 'test_db', 'database_name': 'test_db',
'uri': 'localhost:27018',
} }
mock_build_postgres.return_value = {} mock_build_postgres.return_value = {}
mock_build_mlflow.return_value = {} mock_build_mlflow.return_value = {}
@@ -393,11 +417,15 @@ async def test_main_temporal_client_configuration(
# Verify Temporal client was configured with correct parameters # Verify Temporal client was configured with correct parameters
mock_client_class.connect.assert_called_once_with( mock_client_class.connect.assert_called_once_with(
target_host='temporal.example.com:7233', namespace='production', runtime=mock_runtime target_host='temporal.example.com:7233',
namespace='production',
runtime=mock_runtime,
tls=True,
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('model_manager.worker.worker.create_cleanup_schedule')
@patch('model_manager.worker.worker.Worker') @patch('model_manager.worker.worker.Worker')
@patch('model_manager.worker.worker.client.Client') @patch('model_manager.worker.worker.client.Client')
@patch('model_manager.worker.worker.Runtime') @patch('model_manager.worker.worker.Runtime')
@@ -423,30 +451,142 @@ async def test_main_worker_configuration(
mock_runtime_class, mock_runtime_class,
mock_client_class, mock_client_class,
mock_worker_class, mock_worker_class,
mock_create_cleanup_schedule,
mock_env_vars, mock_env_vars,
mock_logger, mock_logger,
): ):
"""Test that Temporal worker is configured with correct parameters.""" """Test that Temporal worker is configured with correct parameters."""
from model_manager.worker.worker import main from model_manager.worker.worker import main
mock_create_cleanup_schedule.return_value = AsyncMock()
# Patch the task queue constants directly
with (
patch('model_manager.worker.worker.TRAIN_TASK_QUEUE', 'train_model-local_queue'),
patch('model_manager.worker.worker.CLEANUP_TASK_QUEUE', 'cleanup-local_queue'),
):
# Setup mocks
mock_get_logger.return_value = mock_logger
mock_build_mongodb.return_value = {
'connection_string': 'mongodb://test',
'database_name': 'test_db',
'uri': 'localhost:27018',
}
mock_build_postgres.return_value = {}
mock_build_mlflow.return_value = {}
mock_build_minio.return_value = {}
mock_notification_handler = Mock()
mock_notification_handler_class.return_value = mock_notification_handler
mock_activities = AsyncMock()
mock_activities.update_experiment_run = Mock()
mock_activities.validate_train_params = Mock()
mock_activities.train_model = Mock()
mock_activities.cleanup_resources = Mock()
mock_activities.shutdown = AsyncMock()
mock_activities_class.return_value = mock_activities
mock_runtime = Mock()
mock_runtime_class.return_value = mock_runtime
mock_client_instance = AsyncMock()
mock_client_class.connect = AsyncMock(return_value=mock_client_instance)
mock_worker_instance = Mock()
mock_worker_instance.run = AsyncMock(side_effect=asyncio.CancelledError())
mock_worker_class.return_value = mock_worker_instance
mock_app_up = Mock()
mock_metrics.APP_UP.labels.return_value = mock_app_up
# Run main()
with pytest.raises(SystemExit):
await main()
# Verify Worker was created with correct configuration
assert mock_worker_class.call_count == 2
# Primeira chamada: worker de treinamento (train_model-local_queue)
train_call_args = mock_worker_class.call_args_list[0]
assert train_call_args[0][0] == mock_client_instance # temporal_client
assert train_call_args[1]['task_queue'] == 'train_model-local_queue'
assert train_call_args[1]['max_concurrent_workflow_tasks'] == 10
assert train_call_args[1]['max_concurrent_activities'] == 10
assert train_call_args[1]['max_concurrent_local_activities'] == 10
assert train_call_args[1]['max_cached_workflows'] == 100
train_activities_list = train_call_args[1]['activities']
assert mock_activities.update_experiment_run in train_activities_list
assert mock_activities.validate_train_params in train_activities_list
assert mock_activities.train_model in train_activities_list
assert mock_activities.cleanup_resources in train_activities_list
# Segunda chamada: worker de cleanup (cleanup-local_queue)
cleanup_call_args = mock_worker_class.call_args_list[1]
assert cleanup_call_args[0][0] == mock_client_instance # temporal_client
assert cleanup_call_args[1]['task_queue'] == 'cleanup-local_queue'
assert cleanup_call_args[1]['max_concurrent_workflow_tasks'] == 20
assert cleanup_call_args[1]['max_concurrent_activities'] == 20
assert cleanup_call_args[1]['max_concurrent_local_activities'] == 20
assert cleanup_call_args[1]['max_cached_workflows'] == 100
@pytest.mark.asyncio
@patch('model_manager.worker.worker.create_cleanup_schedule')
@patch('model_manager.worker.worker.Worker')
@patch('model_manager.worker.worker.client.Client')
@patch('model_manager.worker.worker.Runtime')
@patch('model_manager.worker.worker.Activities')
@patch('model_manager.worker.worker.NotificationHandler')
@patch('model_manager.worker.worker.build_mongodb_config')
@patch('model_manager.worker.worker.build_postgres_config')
@patch('model_manager.worker.worker.build_mlflow_config')
@patch('model_manager.worker.worker.build_minio_config')
@patch('model_manager.worker.worker.get_logger')
@patch('model_manager.worker.worker.start_prometheus_server')
@patch('model_manager.worker.worker.metrics')
async def test_main_schedule_creation_failure_does_not_stop_worker(
mock_metrics,
mock_start_prometheus,
mock_get_logger,
mock_build_minio,
mock_build_mlflow,
mock_build_postgres,
mock_build_mongodb,
mock_notification_handler_class,
mock_activities_class,
mock_runtime_class,
mock_client_class,
mock_worker_class,
mock_create_cleanup_schedule,
mock_logger,
):
"""Test that schedule creation failure does not prevent worker startup."""
from model_manager.worker.worker import main
# Mock schedule creation to raise an exception (as coroutine)
async def mock_schedule_error(*args, **kwargs):
raise Exception('Schedule creation failed')
mock_create_cleanup_schedule.side_effect = mock_schedule_error
# Setup mocks # Setup mocks
mock_get_logger.return_value = mock_logger mock_get_logger.return_value = mock_logger
mock_build_mongodb.return_value = { mock_build_mongodb.return_value = {
'connection_string': 'mongodb://test', 'connection_string': 'mongodb://test',
'database_name': 'test_db', 'database_name': 'test_db',
'uri': 'localhost:27018',
} }
mock_build_postgres.return_value = {} mock_build_postgres.return_value = {}
mock_build_mlflow.return_value = {} mock_build_mlflow.return_value = {}
mock_build_minio.return_value = {} mock_build_minio.return_value = {}
mock_notification_handler = Mock() mock_notification_handler = Mock()
mock_notification_handler.shutdown = Mock()
mock_notification_handler_class.return_value = mock_notification_handler mock_notification_handler_class.return_value = mock_notification_handler
mock_activities = AsyncMock() mock_activities = AsyncMock()
mock_activities.update_experiment_run = Mock()
mock_activities.validate_train_params = Mock()
mock_activities.train_model = Mock()
mock_activities.cleanup_resources = Mock()
mock_activities.shutdown = AsyncMock() mock_activities.shutdown = AsyncMock()
mock_activities_class.return_value = mock_activities mock_activities_class.return_value = mock_activities
@@ -463,27 +603,27 @@ async def test_main_worker_configuration(
mock_app_up = Mock() mock_app_up = Mock()
mock_metrics.APP_UP.labels.return_value = mock_app_up mock_metrics.APP_UP.labels.return_value = mock_app_up
# Run main() # Run main() - should not fail despite schedule creation error
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
await main() await main()
# Verify Worker was created with correct configuration # Verify schedule creation was attempted
mock_worker_class.assert_called_once() mock_create_cleanup_schedule.assert_called_once()
call_args = mock_worker_class.call_args
assert call_args[0][0] == mock_client_instance # temporal_client # Verify error was logged - check all custom_error calls
assert call_args[1]['task_queue'] == 'train_model-queue' assert mock_logger.custom_error.call_count >= 1
assert call_args[1]['max_concurrent_workflow_tasks'] == 50
assert call_args[1]['max_concurrent_activities'] == 50
assert call_args[1]['max_concurrent_local_activities'] == 50
assert call_args[1]['max_cached_workflows'] == 200
# Verify activities are included # Find the call that contains the schedule error message
activities_list = call_args[1]['activities'] schedule_error_logged = False
assert mock_activities.update_experiment_run in activities_list for call in mock_logger.custom_error.call_args_list:
assert mock_activities.validate_train_params in activities_list if 'Failed to configure cleanup schedule' in call[0][0]:
assert mock_activities.train_model in activities_list schedule_error_logged = True
assert mock_activities.cleanup_resources in activities_list break
assert schedule_error_logged, 'Schedule creation error should be logged'
# Verify workers were still created (startup continued)
assert mock_worker_class.call_count == 2
@patch('model_manager.worker.worker.asyncio.run') @patch('model_manager.worker.worker.asyncio.run')
@@ -513,7 +653,7 @@ def test_worker_module_docstring():
@patch('model_manager.worker.worker.start_http_server') @patch('model_manager.worker.worker.start_http_server')
@patch('model_manager.worker.worker.metrics') @patch('model_manager.worker.worker.metrics')
def test_start_prometheus_server_prints_success( def test_start_prometheus_server_prints_success(
mock_metrics, mock_start_http_server, capsys, mock_env_vars mock_metrics, mock_start_http_server, capsys, mock_env_vars, mock_logger
): ):
"""Test that start_prometheus_server prints success message.""" """Test that start_prometheus_server prints success message."""
from model_manager.worker.worker import start_prometheus_server from model_manager.worker.worker import start_prometheus_server
@@ -521,25 +661,29 @@ def test_start_prometheus_server_prints_success(
mock_app_up = Mock() mock_app_up = Mock()
mock_metrics.APP_UP.labels.return_value = mock_app_up mock_metrics.APP_UP.labels.return_value = mock_app_up
start_prometheus_server() metadata = {'pod_id': 'test-pod-123', 'workflow_name': 'train_model'}
captured = capsys.readouterr() start_prometheus_server(mock_logger, metadata)
assert 'Prometheus server started on port 9090' in captured.out
# Agora a mensagem é enviada via logger
mock_logger.custom_info.assert_called_once()
@patch('model_manager.worker.worker.start_http_server') @patch('model_manager.worker.worker.start_http_server')
@patch('model_manager.worker.worker.metrics') @patch('model_manager.worker.worker.metrics')
@patch('model_manager.worker.worker.os._exit') @patch('model_manager.worker.worker.os._exit')
def test_start_prometheus_server_prints_failure( def test_start_prometheus_server_prints_failure(
mock_exit, mock_metrics, mock_start_http_server, capsys, mock_env_vars mock_exit, mock_metrics, mock_start_http_server, capsys, mock_env_vars, mock_logger
): ):
"""Test that start_prometheus_server prints failure message.""" """Test that start_prometheus_server prints failure message."""
from model_manager.worker.worker import start_prometheus_server from model_manager.worker.worker import start_prometheus_server
mock_start_http_server.side_effect = Exception('Test error') mock_start_http_server.side_effect = Exception('Test error')
start_prometheus_server() metadata = {'pod_id': 'test-pod-123', 'workflow_name': 'train_model'}
captured = capsys.readouterr() start_prometheus_server(mock_logger, metadata)
assert 'Failed to start Prometheus server' in captured.out
assert 'Test error' in captured.out # Agora o erro é logado via logger crítico
mock_logger.custom_critical.assert_called_once()
mock_exit.assert_called_once_with(1)

View File

@@ -0,0 +1,76 @@
"""Unit tests for the CleanupFiles workflow."""
from unittest.mock import AsyncMock, patch
import pytest
@pytest.mark.asyncio
@patch('model_manager.workflows.cleanup_files.workflow')
@patch('model_manager.workflows.cleanup_files.POD_ID', 'temporal-pod')
async def test_cleanup_files_workflow_with_input_bucket(mock_workflow_module):
"""Test the CleanupFiles workflow when bucket_name is provided in the input."""
from model_manager.workflows.cleanup_files import CleanupFiles
# Mock execute_activity_method
mock_workflow_module.execute_activity_method = AsyncMock()
# Instantiate and run the workflow
workflow_instance = CleanupFiles()
await workflow_instance.run({'bucket_name': 'input-bucket'})
# Verify that the activities were called with the correct parameters
calls = mock_workflow_module.execute_activity_method.call_args_list
assert len(calls) == 2
# Check cleanup_minio_files call
minio_call_args = calls[0][0][1]
assert minio_call_args['bucket_name'] == 'input-bucket'
assert minio_call_args['metadata'] == {
'pod_id': 'temporal-pod',
'workflow_name': 'cleanup_files',
}
# Check cleanup_temp_directories call
local_call_args = calls[1][0][1]
assert local_call_args['temp_path'] == 'model_manager/reports/temp'
assert local_call_args['metadata'] == {
'pod_id': 'temporal-pod',
'workflow_name': 'cleanup_files',
}
@pytest.mark.asyncio
@patch('model_manager.workflows.cleanup_files.workflow')
@patch('model_manager.workflows.cleanup_files.POD_ID', 'temporal-pod')
@patch('model_manager.workflows.cleanup_files.DEFAULT_CLEANUP_BUCKET', 'env-var-bucket')
async def test_cleanup_files_workflow_with_default_bucket(mock_workflow_module):
"""Test the CleanupFiles workflow when using the default bucket from environment variables."""
from model_manager.workflows.cleanup_files import CleanupFiles
# Mock execute_activity_method
mock_workflow_module.execute_activity_method = AsyncMock()
# Instantiate and run the workflow
workflow_instance = CleanupFiles()
await workflow_instance.run({}) # Empty input
# Verify that the activities were called
calls = mock_workflow_module.execute_activity_method.call_args_list
assert len(calls) == 2
# Check cleanup_minio_files call
minio_call_args = calls[0][0][1]
assert minio_call_args['bucket_name'] == 'env-var-bucket'
assert minio_call_args['metadata'] == {
'pod_id': 'temporal-pod',
'workflow_name': 'cleanup_files',
}
# Check cleanup_temp_directories call
local_call_args = calls[1][0][1]
assert local_call_args['temp_path'] == 'model_manager/reports/temp'
assert local_call_args['metadata'] == {
'pod_id': 'temporal-pod',
'workflow_name': 'cleanup_files',
}

View File

@@ -1,7 +1,5 @@
- Criar o dashboard do grafana. - Criar um gráfico no grafana para cada nova atividade.
- Atualizar o .github/workflows/quality-gate.yml para usar os pipelines genéricos do github; - Atualizar o .github/workflows/quality-gate.yml para usar os pipelines genéricos do github;
Criar um workflow para fazer o deploy no suse. Criar um workflow para fazer o deploy no suse.
Criar um workflow para criar o release no github. Criar um workflow para criar o release no github.
- Atualizar a documentação do projeto.

View File

@@ -187,6 +187,12 @@ env:
value: "temporal-frontend.temporal.svc.cluster.local:7233" value: "temporal-frontend.temporal.svc.cluster.local:7233"
- name: TEMPORAL_NAMESPACE - name: TEMPORAL_NAMESPACE
value: "model-manager" value: "model-manager"
- name: TRAIN_TASK_QUEUE
value: "train_model-queue"
- name: CLEANUP_TASK_QUEUE
value: "cleanup-queue"
- name: TEMPORAL_USE_TLS
value: "false"
- name: MONGODB_USERNAME - name: MONGODB_USERNAME
value: "root" value: "root"
@@ -227,6 +233,29 @@ env:
- name: TIMEOUT_UPDATE_DATABASE - name: TIMEOUT_UPDATE_DATABASE
value: "30" value: "30"
- name: CLEANUP_RETENTION_HOURS
value: "24"
- name: CLEANUP_DRY_RUN
value: "false"
- name: TIMEOUT_CLEANUP_MINIO
value: "300"
- name: TIMEOUT_CLEANUP_LOCAL
value: "120"
- name: MAX_KEYS_CLEANUP
value: "1000"
- name: DEFAULT_CLEANUP_BUCKET
value: "model-training"
# Cleanup Schedule Configuration
- name: CLEANUP_SCHEDULE_ID
value: "cleanup-files-daily"
- name: CLEANUP_CRON
value: "0 0 * * *" # Midnight UTC
- name: CLEANUP_TIMEZONE
value: "UTC"
- name: CLEANUP_EXECUTION_TIMEOUT_HOURS
value: "1"
- name: EXTRA_PIP_REQUIREMENTS - name: EXTRA_PIP_REQUIREMENTS
value: "git+https://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@github.com/Aignosi/sientia-mlops-library.git" value: "git+https://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@github.com/Aignosi/sientia-mlops-library.git"