Merge pull request #41 from Aignosi/feature/SIENTIAPDE-1646-legacy-laborious-worker

SIENTIAPDE-1646: Refactor Worker Task Queue Management and Update Dependencies
This commit is contained in:
vitor-aignosi
2026-05-21 15:30:09 -03:00
committed by GitHub
11 changed files with 144 additions and 102 deletions

View File

@@ -8,7 +8,7 @@ on:
jobs: jobs:
quality-gate: quality-gate:
uses: Aignosi/github_workflow_templates/.github/workflows/dataops-module-quality-gate.yml@main uses: Aignosi/github_workflow_templates/.github/workflows/python-quality-gate.yml@main
permissions: write-all permissions: write-all
with: with:
project_name: 'laborious' project_name: 'laborious'

View File

@@ -126,10 +126,15 @@ Laborious uses a Temporal-based architecture with strong separation of concerns
### Key Components ### Key Components
#### **Worker (`laborious/worker/worker.py`)** #### **Worker (`laborious/worker/worker.py`)**
- Temporal client setup, worker lifecycle, task queues - Temporal client setup, four workers via `sientia_do.temporal.worker.prepare_worker`
- Runtime-scoped task queues: `{workflow}-{RUNTIME}-queue` for all workflows
- Metrics server initialization, notification handler setup - Metrics server initialization, notification handler setup
- Graceful shutdown and autoscaling-friendly behavior - Graceful shutdown and autoscaling-friendly behavior
**Breaking (schedulers):** drift and simple_metrics queues are no longer `drift-queue` /
`simple_metrics-queue`. Use `drift-{RUNTIME}-queue` and `simple_metrics-{RUNTIME}-queue`
matching the worker pod `RUNTIME` env (same as `predictions_batch` / `minimal_retrain`).
#### **Workflows (`laborious/workflows/`)** #### **Workflows (`laborious/workflows/`)**
- `predictions_batch.py`: Batch prediction entry point - `predictions_batch.py`: Batch prediction entry point
- `sub_workflows/prediction_process.py`: Core prediction pipeline - `sub_workflows/prediction_process.py`: Core prediction pipeline
@@ -802,6 +807,7 @@ See [OPC UA Communication](#opc-ua-communication) for semantics, concurrency, an
|----------|-------------|---------|----------| |----------|-------------|---------|----------|
| `TEMPORAL_HOST` | Temporal server address | `localhost:7233` | Yes | | `TEMPORAL_HOST` | Temporal server address | `localhost:7233` | Yes |
| `TEMPORAL_NAMESPACE` | Temporal namespace | `laborious` | No | | `TEMPORAL_NAMESPACE` | Temporal namespace | `laborious` | No |
| `RUNTIME` | Task queue suffix for all workflows (`{workflow}-{RUNTIME}-queue`) | _(none)_ | Yes |
| `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 |
@@ -1097,8 +1103,7 @@ laborious/
│ ├── prediction_process.py # Core prediction workflow │ ├── prediction_process.py # Core prediction workflow
│ └── format_and_export_prediction.py # Export workflow │ └── format_and_export_prediction.py # Export workflow
├── worker/ # Worker implementation ├── worker/ # Worker implementation
── worker.py # Main worker orchestrator ── worker.py # Main worker orchestrator (uses sientia_do prepare_worker)
│ └── prepare_worker.py # Worker factory with autoscaling config
├── utils/ # Utility functions ├── utils/ # Utility functions
│ ├── connectors_config.py # Environment-driven config builders │ ├── connectors_config.py # Environment-driven config builders
│ ├── models/ # Data models │ ├── models/ # Data models
@@ -1179,7 +1184,9 @@ export LOG_LEVEL=DEBUG
### 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**: One worker pod per `RUNTIME`; queues are
`predictions_batch-{RUNTIME}-queue`, `minimal_retrain-{RUNTIME}-queue`,
`drift-{RUNTIME}-queue`, `simple_metrics-{RUNTIME}-queue`
- **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

View File

@@ -170,8 +170,19 @@ class MLFlowRepository(SientiaMonitoring):
# Sort by version number to get the latest # Sort by version number to get the latest
latest_version = max(stage_versions, key=lambda v: int(v.version)) latest_version = max(stage_versions, key=lambda v: int(v.version))
run_id = latest_version.source.split('/') source = latest_version.source
return run_id[2] if source is None:
raise mlflow.exceptions.MlflowException(
f"Model '{model_name}' version '{latest_version.version}' in stage '{stage}' "
'has no source URI to resolve run ID.'
)
parts = source.split('/')
if len(parts) <= 2 or not parts[2]:
raise mlflow.exceptions.MlflowException(
f"Model '{model_name}' version '{latest_version.version}' in stage '{stage}' "
f"has invalid source URI '{source}' for run ID resolution."
)
return parts[2]
def get_next_run_name(self, model_name: str) -> str: def get_next_run_name(self, model_name: str) -> str:
""" """

View File

@@ -1,73 +0,0 @@
import os
import re
from collections.abc import Sequence
from typing import Any
from sientia_do.observability.logger import Logger
from temporalio.client import Client
from temporalio.worker import PollerBehaviorAutoscaling, Worker
parameters = [
('MAX_CONCURRENT_WORKFLOW_TASKS', '200'),
('MAX_CONCURRENT_ACTIVITIES', '200'),
('MAX_CONCURRENT_LOCAL_ACTIVITIES', '200'),
('MAX_CACHED_WORKFLOWS', '200'),
('WORKFLOW_POLLER_BEHAVIOUR_MINIMUM', '10'),
('WORKFLOW_POLLER_BEHAVIOUR_INITIAL', '100'),
('WORKFLOW_POLLER_BEHAVIOUR_MAXIMUM', '200'),
('ACTIVITY_POLLER_BEHAVIOUR_MINIMUM', '10'),
('ACTIVITY_POLLER_BEHAVIOUR_INITIAL', '100'),
('ACTIVITY_POLLER_BEHAVIOUR_MAXIMUM', '200'),
]
def camel_to_snake(text: str) -> str:
"""Convert camelCase or PascalCase to snake_case."""
text = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', text)
text = re.sub('([a-z0-9])([A-Z])', r'\1_\2', text)
return text.lower()
def prepare_worker(
main_workflow: type,
other_workflows: Sequence[type],
activities: Sequence[Any],
temporal_client: Client,
logger: Logger,
) -> Worker:
main_workflow_name = main_workflow.__name__.upper()
queue_name = f'{camel_to_snake(main_workflow.__name__)}-queue'
local_workflow_parameters = {}
for parameter in parameters:
local_workflow_parameters[parameter[0]] = int(
os.getenv(main_workflow_name + '_' + parameter[0], parameter[1])
)
logger.info(f'Preparing worker for {main_workflow_name} with queue {queue_name}')
logger.info(f'Worker runtime config: {local_workflow_parameters}')
return Worker(
temporal_client,
task_queue=queue_name,
workflows=[main_workflow, *other_workflows],
activities=[*activities],
max_concurrent_workflow_tasks=local_workflow_parameters['MAX_CONCURRENT_WORKFLOW_TASKS'],
max_concurrent_activities=local_workflow_parameters['MAX_CONCURRENT_ACTIVITIES'],
max_concurrent_local_activities=local_workflow_parameters[
'MAX_CONCURRENT_LOCAL_ACTIVITIES'
],
max_cached_workflows=local_workflow_parameters['MAX_CACHED_WORKFLOWS'],
workflow_task_poller_behavior=PollerBehaviorAutoscaling(
minimum=local_workflow_parameters['WORKFLOW_POLLER_BEHAVIOUR_MINIMUM'],
initial=local_workflow_parameters['WORKFLOW_POLLER_BEHAVIOUR_INITIAL'],
maximum=local_workflow_parameters['WORKFLOW_POLLER_BEHAVIOUR_MAXIMUM'],
),
activity_task_poller_behavior=PollerBehaviorAutoscaling(
minimum=local_workflow_parameters['ACTIVITY_POLLER_BEHAVIOUR_MINIMUM'],
initial=local_workflow_parameters['ACTIVITY_POLLER_BEHAVIOUR_INITIAL'],
maximum=local_workflow_parameters['ACTIVITY_POLLER_BEHAVIOUR_MAXIMUM'],
),
)

View File

@@ -5,12 +5,14 @@ This module provides the main worker implementation for the Sientia DataOps Labo
It orchestrates Temporal workers, manages task queues, and handles the lifecycle of It orchestrates Temporal workers, manages task queues, and handles the lifecycle of
prediction and retraining workflows. prediction and retraining workflows.
The worker supports multiple task queues: The worker supports multiple runtime-scoped task queues (via ``sientia_do.temporal.worker.prepare_worker``):
- predictions_batch-queue: Handles batch prediction workflows (heavy workload) - predictions_batch-{runtime}-queue: Batch prediction workflows (heavy workload)
Includes activities for MLFlow, data quality gates, OPC export, PI Web API export, and PostgreSQL - minimal_retrain-{runtime}-queue: Model retraining workflows
- minimal_retrain-queue: Handles model retraining workflows - drift-{runtime}-queue: Drift detection workflows
- drift-queue: Handles drift detection workflows - simple_metrics-{runtime}-queue: Simple metrics workflows
- simple_metrics-queue: Handles simple metrics calculation workflows
``RUNTIME`` must be set; it is passed to every ``prepare_worker`` call. Schedulers must use the
same queue names (breaking change vs legacy ``drift-queue`` / ``simple_metrics-queue``).
Key Features: Key Features:
- Resource-based scaling with WorkerTuner (CPU and memory aware) - Resource-based scaling with WorkerTuner (CPU and memory aware)
@@ -21,6 +23,7 @@ Key Features:
- Multiple worker instances for different workflow types - Multiple worker instances for different workflow types
Environment Variables: Environment Variables:
- RUNTIME: Required non-empty string; suffix for all task queue names
- TEMPORAL_HOST: Temporal server address (default: localhost:7233) - TEMPORAL_HOST: Temporal server address (default: localhost:7233)
- TEMPORAL_NAMESPACE: Temporal namespace (default: laborious) - TEMPORAL_NAMESPACE: Temporal namespace (default: laborious)
- POD_ID: Kubernetes pod identifier for metrics - POD_ID: Kubernetes pod identifier for metrics
@@ -40,6 +43,7 @@ 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 get_logger from sientia_do.observability.logger import get_logger
from sientia_do.temporal.worker.prepare_worker import prepare_worker
from sientia_do.utils.connectors_config import ( from sientia_do.utils.connectors_config import (
build_api_config, build_api_config,
build_mongodb_config, build_mongodb_config,
@@ -53,7 +57,6 @@ with workflow.unsafe.imports_passed_through():
build_mlflow_config, build_mlflow_config,
build_opc_config, build_opc_config,
) )
from laborious.worker.prepare_worker import prepare_worker
from laborious.workflows.drift import Drift from laborious.workflows.drift import Drift
from laborious.workflows.minimal_retrain import MinimalRetrain from laborious.workflows.minimal_retrain import MinimalRetrain
from laborious.workflows.predictions_batch import PredictionsBatch from laborious.workflows.predictions_batch import PredictionsBatch
@@ -100,10 +103,21 @@ async def main():
logger.custom_info(f'Starting Worker with POD_ID: {POD_ID}', metadata) logger.custom_info(f'Starting Worker with POD_ID: {POD_ID}', metadata)
logger.custom_info('Starting prometheus client...', metadata) runtime = os.getenv('RUNTIME', '').strip()
if not runtime:
logger.custom_critical(
'RUNTIME environment variable is required and must be non-empty',
metadata,
)
metrics.APP_UP.labels(pod_id=POD_ID).set(0)
sys.exit(1)
metadata_runtime = {**metadata, 'runtime': runtime}
logger.custom_info('Starting prometheus client...', metadata_runtime)
start_prometheus_server() start_prometheus_server()
logger.custom_info('Starting Notification Handler...', metadata) logger.custom_info('Starting Notification Handler...', metadata_runtime)
mongo_config = build_mongodb_config() mongo_config = build_mongodb_config()
notification_handler = NotificationHandler( notification_handler = NotificationHandler(
@@ -113,7 +127,7 @@ async def main():
project_name=os.getenv('PROJECT_NAME', 'laborious'), project_name=os.getenv('PROJECT_NAME', 'laborious'),
) )
logger.custom_info('Starting Activities...', metadata) logger.custom_info('Starting Activities...', metadata_runtime)
activities = Activities( activities = Activities(
postgres_config=build_postgres_config(), postgres_config=build_postgres_config(),
@@ -125,10 +139,13 @@ async def main():
notification_handler=notification_handler, notification_handler=notification_handler,
) )
logger.custom_info('Initializing OPC...', metadata) logger.custom_info('Initializing OPC...', metadata_runtime)
await activities.init_opc() await activities.init_opc()
logger.custom_info(f'Starting SDK Metrics Server on port {SDK_METRICS_PORT}...', metadata) logger.custom_info(
f'Starting SDK Metrics Server on port {SDK_METRICS_PORT}...',
metadata_runtime,
)
new_runtime = Runtime( new_runtime = Runtime(
telemetry=TelemetryConfig( telemetry=TelemetryConfig(
@@ -136,7 +153,7 @@ async def main():
) )
) )
logger.custom_info(f'Starting Temporal Client at {host}...', metadata) logger.custom_info(f'Starting Temporal Client at {host}...', metadata_runtime)
temporal_client = await client.Client.connect( temporal_client = await client.Client.connect(
target_host=host, target_host=host,
@@ -144,7 +161,7 @@ async def main():
runtime=new_runtime, runtime=new_runtime,
) )
logger.custom_info('Starting Workers...', metadata) logger.custom_info(f'Starting Workers (runtime={runtime})...', metadata_runtime)
workers = [ workers = [
prepare_worker( prepare_worker(
@@ -159,6 +176,7 @@ async def main():
activities.export_data_to_postgres, activities.export_data_to_postgres,
], ],
logger=logger, logger=logger,
runtime=runtime,
), ),
prepare_worker( prepare_worker(
temporal_client=temporal_client, temporal_client=temporal_client,
@@ -170,6 +188,7 @@ async def main():
activities.export_data_to_postgres, activities.export_data_to_postgres,
], ],
logger=logger, logger=logger,
runtime=runtime,
), ),
prepare_worker( prepare_worker(
temporal_client=temporal_client, temporal_client=temporal_client,
@@ -182,6 +201,7 @@ async def main():
activities.export_data_to_postgres, activities.export_data_to_postgres,
], ],
logger=logger, logger=logger,
runtime=runtime,
), ),
prepare_worker( prepare_worker(
temporal_client=temporal_client, temporal_client=temporal_client,
@@ -211,6 +231,7 @@ async def main():
activities.write_pi_web_api_data, activities.write_pi_web_api_data,
], ],
logger=logger, logger=logger,
runtime=runtime,
), ),
] ]
@@ -218,7 +239,7 @@ async def main():
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('Workers started successfully', metadata_runtime)
exit_code = 0 exit_code = 0
try: try:

View File

@@ -1,12 +1,18 @@
temporalio temporalio
psycopg2-binary psycopg2-binary
sqlalchemy sqlalchemy
asyncua asyncua==1.0.6
redis redis
git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.12.0 sientia_do>=1.12.1
mlflow
prometheus-client prometheus-client
botocore botocore
boto3 boto3
s3fs s3fs
pyarrow pyarrow
mlflow kaleido
hyperopt
shap
pycurl
scipy<1.14.0
scikit-learn==1.5.2

18
requirements-local.txt Normal file
View File

@@ -0,0 +1,18 @@
temporalio
psycopg2-binary
sqlalchemy
asyncua==1.0.6
redis
git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.12.1
git+ssh://git@github.com/Aignosi/sientia-model-library.git@0.10.0
prometheus-client
botocore
boto3
s3fs
pyarrow
kaleido
hyperopt
shap
pycurl
scipy<1.14.0
scikit-learn==1.5.2

View File

@@ -1,10 +1,10 @@
temporalio temporalio
psycopg2-binary psycopg2-binary
sqlalchemy sqlalchemy
asyncua asyncua==1.0.6
redis redis
git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.10.4 sientia_do>=1.12.1
git+ssh://git@github.com/Aignosi/sientia-mlops-library.git@0.41.0 sientia>0.40.0
prometheus-client prometheus-client
botocore botocore
boto3 boto3
@@ -15,4 +15,4 @@ hyperopt
shap shap
pycurl pycurl
scipy<1.14.0 scipy<1.14.0
scikit-learn==1.5.2 scikit-learn==1.5.2

View File

@@ -142,6 +142,41 @@ def test_get_model_run_id_success(mlflow_repository):
assert output == '1' assert output == '1'
def test_get_model_run_id_missing_source(mlflow_repository):
mlflow_repository.client.search_registered_models.return_value = [MagicMock(name='test')]
mlflow_repository.client.search_model_versions.return_value = [
MagicMock(current_stage='Production', version='1', source='runs/test/0'),
MagicMock(current_stage='Production', version='2', source=None),
]
with pytest.raises(mlflow_lib.exceptions.MlflowException) as exc_info:
mlflow_repository.get_model_run_id('test')
assert (
str(exc_info.value)
== "Model 'test' version '2' in stage 'Production' has no source URI to resolve run ID."
)
def test_get_model_run_id_invalid_source(mlflow_repository):
mlflow_repository.client.search_registered_models.return_value = [MagicMock(name='test')]
mlflow_repository.client.search_model_versions.return_value = [
MagicMock(current_stage='Production', version='1', source='runs/test/0'),
MagicMock(current_stage='Production', version='2', source='runs/test'),
]
with pytest.raises(mlflow_lib.exceptions.MlflowException) as exc_info:
mlflow_repository.get_model_run_id('test')
assert (
str(exc_info.value)
== "Model 'test' version '2' in stage 'Production' has invalid source URI "
"'runs/test' for run ID resolution."
)
def test_get_next_run_name(mlflow, mlflow_repository): def test_get_next_run_name(mlflow, mlflow_repository):
mlflow.search_runs.return_value = [1, 2, 3] mlflow.search_runs.return_value = [1, 2, 3]
output = mlflow_repository.get_next_run_name('run') output = mlflow_repository.get_next_run_name('run')

View File

@@ -0,0 +1,14 @@
from sientia_do.temporal.worker.prepare_worker import build_queue_name
from laborious.workflows.drift import Drift
from laborious.workflows.minimal_retrain import MinimalRetrain
from laborious.workflows.predictions_batch import PredictionsBatch
from laborious.workflows.simple_metrics import SimpleMetrics
def test_runtime_scoped_queue_names():
runtime = 'prod-a'
assert build_queue_name(PredictionsBatch.__name__, runtime) == 'predictions_batch-prod-a-queue'
assert build_queue_name(MinimalRetrain.__name__, runtime) == 'minimal_retrain-prod-a-queue'
assert build_queue_name(Drift.__name__, runtime) == 'drift-prod-a-queue'
assert build_queue_name(SimpleMetrics.__name__, runtime) == 'simple_metrics-prod-a-queue'

View File

@@ -209,6 +209,9 @@ env:
value: "temporal-frontend.temporal.svc.cluster.local:7233" value: "temporal-frontend.temporal.svc.cluster.local:7233"
- name: TEMPORAL_NAMESPACE - name: TEMPORAL_NAMESPACE
value: "laborious" value: "laborious"
# Suffix for all Temporal task queues: {workflow}-{RUNTIME}-queue
- name: RUNTIME
value: "legacy"
- name: MONGODB_USERNAME - name: MONGODB_USERNAME
value: "root" value: "root"