40 Commits
2.0.1 ... main

Author SHA1 Message Date
github-actions[bot]
5a23e143b7 chore: bump version to 2.1.0
Some checks failed
Quality gate / quality-gate (push) Has been cancelled
2026-10-27 17:05:09 +00:00
6975f58ab5 Merge pull request #37 from Aignosi/feature/SIENTIAPDE-2226
Enhance Quality Gate Workflow for Template Management and Runtime Checks
2026-10-27 14:04:56 -03:00
ada3515688 SIENTIAPDE-2226: Assign DataFrame before pytest.raises in test
- Build the DataFrame outside the `pytest.raises` block so only the
  call under test can raise the expected ValueError.
2026-10-27 13:37:51 -03:00
3266d34620 SIENTIAPDE-2226: Add plugin store e2e docs and cover repo edge cases
- Document PluginStore wiring e2e scenarios (4.1.1-4.1.5) covering env
  config, explicit branch, missing model, bad repo and bad credentials.
- Add unit tests for DataManagerRepository date parsing, timestamp
  dropping, numeric coercion and timezone handling to keep coverage at
  100%.
- Mark the cleanup guard in TrainModel as `no branch`, since the false
  branch always exits through the re-raise.
2026-10-27 12:22:14 -03:00
51c34350f7 SIENTIAPDE-2226 ci: trigger quality gate rerun
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:09:30 -03:00
3990b2e170 SIENTIAPDE-2226: Point quality-gate workflow back to main templates
- Switch the shared python-quality-gate workflow reference from the
  feature/SIENTIAPDE-2226 branch to @main now that the template changes
  (runtime check) are merged, so the gate doesn't depend on a temporary
  branch.
2026-10-27 10:58:30 -03:00
34eaa01ca7 SIENTIAPDE-2226: Stop installing runtime in worker; unpin requirements
- The sientia-module entrypoint now merges requirements.txt with the
  Plugin Store runtime and installs both before boot (SIENTIAPDE-2223),
  so the worker no longer calls install_runtime or runs the loaded
  package version guard.
- Leave runtime libs unpinned in requirements files so the runtime
  version wins; cap sqlalchemy<2.1 and evidently<0.7 for compatibility.
- Update README, CLAUDE.md and worker tests to match.
2026-10-27 10:43:41 -03:00
c71e426b18 SIENTIAPDE-2226: Update quality-gate workflow to use feature branch for templates and enable runtime checks. 2026-10-27 10:08:38 -03:00
github-actions[bot]
ca3f37da78 chore: bump version to 2.0.5
Some checks failed
Quality gate / quality-gate (push) Has been cancelled
2026-10-26 14:11:44 +00:00
PedroHMCosme
f054bc9517 Merge pull request #35 from Aignosi/fix/SIENTIAPDE-2215
fix(metrics): ignore NaN target/prediction pairs in validation metrics (SIENTIAPDE-2215)
2026-10-26 11:11:30 -03:00
PedroHMCosme
52f9f2724b Merge remote-tracking branch 'origin/main' into fix/SIENTIAPDE-2215 2026-10-23 11:37:59 -03:00
github-actions[bot]
f18f519adb chore: bump version to 2.0.4 2026-10-22 17:06:48 +00:00
c9c85e35db Merge pull request #36 from Aignosi/fix/SIENTIAPDE-2214
fix(worker): pin the runtime versions and refuse to boot on a version swap (SIENTIAPDE-2214)
2026-10-22 14:06:33 -03:00
PedroHMCosme
29700169ad refactor(worker): iterate a sys.modules copy instead of list() (Sonar S7504) (SIENTIAPDE-2214) 2026-10-22 12:37:11 -03:00
PedroHMCosme
dfbdd4b605 fix(worker): set app_up only after the runtime version guard (SIENTIAPDE-2214)
The probes only check app_up, which was set to 1 when the metrics server started,
before install_runtime. A pod that would fail the guard could turn Ready first, so a
rollout would remove the old pod. app_up now goes to 1 right after the guard.

Also: copy sys.modules keys before iterating, raise the sientia_model floor to the
runtime's >=0.14.0, pin the same runtime versions in requirements-local.txt, test
that the snapshot is taken before the install and the real importlib.metadata path,
and document the boot failure in README and CLAUDE.md.
2026-10-22 12:17:12 -03:00
PedroHMCosme
0f5e4a5cef fix(worker): pin the runtime versions and refuse to boot when install_runtime swaps an imported package (SIENTIAPDE-2214)
Replaces the re-exec. requirements.txt now pins the union of the basic and xgboost
runtime libraries, so install_runtime has nothing to change in the packages the worker
already imported. If a runtime pin changes and requirements.txt is not updated, the
worker logs which packages changed and exits before starting the workers.

test_main_entrypoint assigned worker.main = AsyncMock() without restoring it, so every
later test of main() ran the mock; it now uses patch.object.
2026-10-22 11:34:04 -03:00
PedroHMCosme
9fa1430d75 fix(metrics): name the wrapper mock correctly and document NaN r2 with one valid pair (SIENTIAPDE-2215) 2026-10-22 10:57:53 -03:00
PedroHMCosme
fcb9bb263b test(metrics): build the metadata mock outside pytest.raises (SIENTIAPDE-2215) 2026-10-22 10:34:00 -03:00
PedroHMCosme
2678c5d638 Merge remote-tracking branch 'origin/main' into fix/SIENTIAPDE-2215 2026-10-21 21:48:06 -03:00
PedroHMCosme
993c7d7662 Merge remote-tracking branch 'origin/main' into fix/SIENTIAPDE-2214 2026-10-21 21:46:49 -03:00
dc9fd871bb fix(ci): restaura pull-requests:read no deploy.yml, exigido pelo release-tarball
reusable-deploy-dataops.yml encadeia release-tarball -> dataops-module-release.yml,
que pede pull-requests:read; GitHub valida a interseção de permissions em todo o
workflow_call encadeado, independente do if: que gate o job. Removido por engano
em 586a5b9, achando que só existia por causa do trigger pull_request antigo.

[skip ci]
2026-10-21 14:10:25 -03:00
242566f9b1 fix(ci): grant contents:write permission to version-bump workflow
Caller workflow had no permissions block, so GitHub's permission-
intersection rule capped the nested reusable-version-bump.yml call
to read-only, breaking dispatch ("requesting 'contents: write', but
is only allowed 'contents: read'").

[skip ci]
2026-10-21 11:44:53 -03:00
PedroHMCosme
959a430314 test(metrics): compare metric floats with pytest.approx (SIENTIAPDE-2215) 2026-10-21 09:43:49 -03:00
PedroHMCosme
5043137f97 refactor(metrics): build the NaN-pairs warning before the call (SIENTIAPDE-2215) 2026-10-21 09:32:26 -03:00
PedroHMCosme
7903dda730 Merge branch 'main' into fix/SIENTIAPDE-2214 2026-10-21 09:28:48 -03:00
PedroHMCosme
0920d8138e Merge branch 'main' into fix/SIENTIAPDE-2215 2026-10-21 09:14:20 -03:00
PedroHMCosme
8c81aa68bb refactor(metrics): warn with the remaining count and document NaN pairs (SIENTIAPDE-2215) 2026-10-21 09:11:40 -03:00
d0b4644f63 chore(ci): add version-bump.yml, decoupled from deploy
New workflow bumps pyproject.toml's version on PR merge, calling the
shared reusable-version-bump.yml (SIENTIAPDE-2217 version-bump reform) —
zero build/image/chart/deploy side effects. deploy.yml (workflow_dispatch-
only) will read this committed value instead of calculating one.

[skip ci]
2026-10-21 00:18:51 -03:00
PedroHMCosme
9c05871fcf fix(metrics): ignore NaN target/prediction pairs in validation metrics (SIENTIAPDE-2215) 2026-10-20 22:17:57 -03:00
ced847bbe7 chore(ci): switch deploy.yml to manual workflow_dispatch with required chart_ref
Removes auto-deploy-on-PR-merge trigger and the old PR-merge gate. Deploy now
triggers only via workflow_dispatch, with chart_ref required (no default) as
the literal sientia-helm-chart branch bumped and deployed — hard error if it
doesn't exist, no silent fallback to development/main.

- .github/workflows/deploy.yml: drop pull_request trigger, add chart_ref
  input, branch_name now github.ref_name, drop pull-requests:read permission
- Delete .github/workflows/require-chart-ref-label.yml
- README.md: update Deploy section to describe manual trigger

[skip ci]
2026-10-20 17:03:33 -03:00
PedroHMCosme
262ba8d658 docs(worker): document SIENTIA_RUNTIME_READY, log the skipped install, test install failure (SIENTIAPDE-2214)
From the code-review-python pass on #34:
- README, CLAUDE.md and the module docstring: the worker re-executes
  itself after install_runtime, and SIENTIA_RUNTIME_READY is internal;
  setting it in .env/the chart would skip the install silently.
- "Installing runtime" is now logged only on the pass that installs; the
  re-executed pass logs that the install was skipped.
- The ponytail comment notes that the install success metrics are lost
  to the exec.
- New test: a failed install propagates without setting the flag or
  calling os.execv (locks the install -> flag -> exec order).
2026-10-20 16:30:23 -03:00
PedroHMCosme
0c9c5f300b fix(worker): re-exec after install_runtime so imports match the runtime (SIENTIAPDE-2214)
The worker imports sklearn (metrics, evidently) before
PluginStore.install_runtime replaces it on disk with the runtime's pinned
version, the same order that breaks every sklearn prediction in laborious.
After installing the runtime, the worker now replaces its process once
(SIENTIA_RUNTIME_READY=1 skips the second install), so every module is
loaded from the runtime's packages.
2026-10-20 15:40:38 -03:00
49c8942a87 chore(ci): remove feature/** do push trigger (evita run duplicado) [skip ci] 2026-10-19 15:38:47 -03:00
github-actions[bot]
3b90812c12 chore: bump version to 2.0.3 2026-10-16 15:10:26 +00:00
0b673b35a1 Merge pull request #33 from Aignosi/fix/SIENTIAPDE-2201
SIENTIAPDE-2201: Keep the timestamp column in train/test data CSVs
2026-10-16 12:10:01 -03:00
PedroHMCosme
37f16f34ff SIENTIAPDE-2201: fix: keep the timestamp column in train/test data CSVs
generate_report wrote train_data.csv and test_data.csv with index=False, which
dropped the UTC time index. laborious drift reads test_data.csv from MLflow as
its reference and needs a timestamp column; without it, every model trained here
drifted against the first 30% of its own target window. Both CSVs now write the
index as timestamp. If a column already has that name, the CSV is written as
before and a warning is logged, so training never fails over drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:09:06 -03:00
dd981b8027 fix(ci): Update env_list_path to array format in deploy workflow
This commit changes the env_list_path parameter in the deploy GitHub Actions workflow to an array format, aligning with expected input types for the action.
2026-09-11 00:41:42 -03:00
github-actions[bot]
5641734c3c chore: bump version to 2.0.2 2026-09-10 13:48:02 +00:00
Bruno Domingues
c51466f304 Merge pull request #32 from Aignosi/fix/SIENTIAPDE-2068
Configure deploy workflow for automated version updates
2026-09-10 10:47:41 -03:00
44a4ffe6d0 SIENTIAPDE-2068: Configure deploy workflow to update version in pyproject.toml. 2026-09-10 10:39:49 -03:00
15 changed files with 311 additions and 85 deletions

View File

@@ -1,10 +1,15 @@
name: Deploy DataOps Worker
on:
pull_request:
types: [closed]
branches: [main, 'release/**', 'feature/**']
workflow_dispatch:
inputs:
chart_ref:
description: >
sientia-helm-chart branch to bump GITHUB_BRANCH on AND deploy — the literal
deploy target. Validated via git ls-remote; a branch that does not exist in
sientia-helm-chart is a hard error, not a silent fallback to main.
required: true
type: string
permissions:
contents: write
@@ -14,13 +19,14 @@ permissions:
jobs:
deploy:
name: Deploy
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true
uses: Aignosi/github_workflow_templates/.github/workflows/reusable-deploy-dataops.yml@main
with:
branch_name: ${{ github.event.pull_request.head.ref }}
branch_name: ${{ github.ref_name }}
chart_ref: ${{ inputs.chart_ref }}
project_name: 'model-manager'
chart_name: 'model-manager'
env_list_path: '.global.env'
env_list_path: '[".global.env"]'
update_version_in: '["pyproject"]'
create_tarball: ${{ github.event_name == 'pull_request' }}
mirror: true
bump_chart: true

View File

@@ -5,7 +5,6 @@ on:
branches:
- main
- 'release/**'
- 'feature/**'
pull_request:
branches:
- main
@@ -27,4 +26,5 @@ jobs:
with:
project_name: 'model_manager'
repositories: 'sientia-dataops-library,sientia-model-library'
runtime_check: true
secrets: inherit

View File

@@ -1,16 +0,0 @@
name: Require chart-ref label
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
branches:
- main
- 'release/**'
- 'feature/**'
permissions:
pull-requests: write
jobs:
require-chart-ref-label:
uses: Aignosi/github_workflow_templates/.github/workflows/require-chart-ref-label.yml@main

17
.github/workflows/version-bump.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Version Bump
permissions:
contents: write
on:
pull_request:
types: [closed]
branches: [main, 'release/**', 'feature/**']
jobs:
bump:
if: github.event.pull_request.merged == true
uses: Aignosi/github_workflow_templates/.github/workflows/reusable-version-bump.yml@main
with:
update_version_in: '["pyproject"]'
secrets: inherit

View File

@@ -45,7 +45,7 @@ Coverage gate in CI is 80% minimum; this repo targets 100% in practice — don't
## Architecture
### Temporal layering
- **Worker** (`model_manager/worker/worker.py`): entrypoint. Builds config from env, connects Postgres/MLFlow/MinIO/Plugin Store, installs the `RUNTIME` model runtime via `PluginStore.install_runtime`, starts two Temporal workers (one per task queue) plus Prometheus metrics server and the cleanup schedule. All heavy imports are wrapped in `workflow.unsafe.imports_passed_through()` — required because Temporal workflow sandboxing forbids non-deterministic imports at module scope outside that block.
- **Worker** (`model_manager/worker/worker.py`): entrypoint. Builds config from env, connects Postgres/MLFlow/MinIO/Plugin Store, does **not** install the model runtime (the `sientia-module` entrypoint merges `requirements.txt` with the Plugin Store runtime and installs both before boot, SIENTIAPDE-2223; leave runtime libs unpinned in `requirements.txt` so the runtime version wins, and the quality gate's `runtime_check` fails a PR that conflicts with any warehouse runtime), sets `app_up` to 1 only after initialization, starts two Temporal workers (one per task queue) plus Prometheus metrics server and the cleanup schedule. All heavy imports are wrapped in `workflow.unsafe.imports_passed_through()` — required because Temporal workflow sandboxing forbids non-deterministic imports at module scope outside that block.
- **Workflows** (`model_manager/workflows/`): pure orchestration, no I/O beyond `execute_activity_method` calls.
- `train_model.py` (`TrainModel`, workflow name `train_model`): validate `experiment_run_id` → `load_model_metadata` → `validate_train_params` → update DB status `ORCHESTRATOR_WAITING_PROC` → `train_model` activity → update DB status `TRAINING_SUCCESS`/`TRAINING_ERROR` → `cleanup_resources` (always runs in `finally` if a `run_dir` exists, even on failure). Uses distinct `RetryPolicy`s per operation class: `network_retry_policy` (transient I/O), `no_retry_policy` (training/validation — data errors are permanent, and retrying would duplicate MLflow writes), `database_retry_policy` (Postgres). Timeouts are env-driven (`TIMEOUT_*`), extend `TIMEOUT_TRAIN_MODEL` instead of adding retries for slow training.
- `cleanup_files.py` (`CleanupFiles`): scheduled (`cleanup_schedule.py`, default daily at midnight UTC via `CLEANUP_CRON`) deletion of stale local temp dirs matching `name_YYYYMMDD_HHMMSS_microseconds`, older than `CLEANUP_RETENTION_HOURS`.

View File

@@ -90,7 +90,7 @@ An enterprise-grade ML model training orchestration platform built on Temporal.
- **Scheduled Maintenance**: Automated lifecycle jobs for filesystem hygiene and stale file cleanup
### Advanced Capabilities
- **Dynamic Runtime Provisioning**: Automated installation of required model runtimes from the Plugin Store
- **Dynamic Runtime Provisioning**: The `sientia-module` entrypoint merges `requirements.txt` with the Plugin Store runtime and installs both before the worker starts
- **Granular Retry Policies**: Tailored strategies for network, MLFlow, database, and filesystem operations
- **Scalable Infrastructure**: Kubernetes-ready design with support for horizontal scaling and poller autoscaling
- **Secure Configuration**: Environment-driven connection management with fallback to sensible defaults
@@ -148,7 +148,7 @@ The Model Manager system uses a Temporal-based workflow architecture with clear
- Task queue configuration and load balancing
- Prometheus metrics server initialization
- Notification handler setup and configuration
- **Runtime Installation**: Automatically installs the required model runtime from the Plugin Store
- **Runtime libraries**: not installed by the worker. The `sientia-module` entrypoint merges `requirements.txt` with the Plugin Store runtime (`PLUGIN_RUNTIME`), writes the audited `requirements.resolved.txt` and installs it before Python starts; on a conflict the pod does not start (SIENTIAPDE-2223). `app_up` goes to 1 only after initialization
- **Key Features**:
- Automatic scaling with `PollerBehaviorAutoscaling`
- Health check endpoints for Kubernetes liveness/readiness probes
@@ -577,6 +577,18 @@ kubectl exec -n temporal <temporal-admin-tools-pod-name> -- \
pip install -r requirements-dev.txt
```
4. **Install the runtime libraries**
In the cluster, the `sientia-module` entrypoint installs the runtime libraries before the
worker starts; the worker itself installs nothing. Locally, install them by hand from
`runtime/<RUNTIME>.yaml` of the Plugin Store (`Aignosi/model-plugin-warehouse`), skipping
`sientia_do`/`sientia_model`, which the requirements file already brings. For `basic`:
```bash
pip install mlflow==3.8.1 pathspec==1.0.4 pandas==2.3.3 cloudpickle==3.1.2 scikit-learn==1.8.0
```
Copy the versions from the YAML, not from this example: they change with the runtime. With
`RUNTIME=single` there is nothing to install.
4. **Create environment configuration file**
```bash
cp .env.example .env
@@ -811,9 +823,12 @@ Runs automatically on each Pull Request to `main`:
- ✅ Tests must pass with minimum 80% coverage
- ✅ SonarQube analysis for code quality metrics
- ✅ Automatic version calculation based on branch pattern
- ✅ Runtime requirements check (`runtime_check: true`): `requirements.txt` is merged with every `runtime/*.yaml` of `model-plugin-warehouse@main`, using the `sientia-module` entrypoint's own merge script; a conflict fails the gate, "runtime prevaleceu" is a warning. The warehouse changes on its own, so a push with no code change can turn red
#### Deploy (`.github/workflows/deploy.yml`)
Runs automatically when a PR is merged to `main`:
Triggered manually via `workflow_dispatch`, with a required `chart_ref` input (no
default — the sientia-helm-chart branch to bump and deploy; a branch that doesn't
exist is a hard error, not a silent fallback):
- ✅ Builds and pushes Docker image to Azure Container Registry
- ✅ Creates GitHub release with calculated version
- ✅ Deploys to Kubernetes using Helm
@@ -1247,6 +1262,10 @@ sientia-dataops-model-manager/
- Check training parameter validation errors
- Verify input data format and required fields
5. **Pod does not start, with `# CONFLICTS` in the log**
- The `sientia-module` entrypoint found a library whose version in `requirements.txt` conflicts with the Plugin Store runtime; the log lists each library with both specs
- Leave libraries the runtime already defines without a version in `requirements.txt` (the runtime's version wins), or align the version, then redeploy. The quality gate catches this on the PR against the warehouse `main`
### Debug Mode
Enable debug logging by setting the log level:

View File

@@ -64,3 +64,15 @@ These scenarios test the business rule validations inside `validate_train_params
| `test_scenario_3_1_1_cleanup_with_no_temp_dirs` | Temp directory is empty. Activity completes without error. |
| `test_scenario_3_1_2_cleanup_removes_old_temp_dirs` | Two stale directories matching `name_YYYYMMDD_HHMMSS_microseconds` are removed when older than retention. |
| `test_scenario_3_1_3_cleanup_nonexistent_temp_path` | Target path does not exist. Handled gracefully without error. |
## 4. PluginStore Wiring (`test_plugin_store_wiring.py`)
`Activities` receives `plugin_store_config` and builds the `PluginStore` itself. These scenarios run `01-linear-regression-basic.json` on a worker built from a per-test config (`activities_factory`) against the Gitea `model-store` repo.
| Test Function | Config Change | Expected Status | Description |
|---|---|---|---|
| `test_scenario_4_1_1_plugin_store_from_env_config` | `STORE_*` env vars → `build_plugin_store_config()` | `TRAINING_SUCCESS` | Same path as the worker. With `STORE_BRANCH` unset the store resolves the branch to `main`. |
| `test_scenario_4_1_2_explicit_branch` | `branch = 'main'` | `TRAINING_SUCCESS` | Explicit branch skips resolution and is used for every read. |
| `test_scenario_4_1_3_explicit_branch_without_model` | `branch = 'e2e-no-linear-regression'` | `ORCHESTRATOR_VALIDATION_ERROR` | Branch forked from `main` without `models/linear_regression/index.yaml`; error mentions `linear_regression/index.yaml`. |
| `test_scenario_4_1_4_nonexistent_repository` | `repo = 'no-such-store'` | `ORCHESTRATOR_VALIDATION_ERROR` | `Activities` still builds (branch falls back to `main`); fails when loading model metadata. |
| `test_scenario_4_1_5_wrong_credentials` | `password = 'wrong-password'` | `ORCHESTRATOR_VALIDATION_ERROR` | Gitea rejects the read; error mentions `401`. |

View File

@@ -357,7 +357,8 @@ class DataManagerRepository(SientiaMonitoring):
This helper mirrors the previous TrainingRepository.after_train_calculation
behavior, assuming that predictions (y_pred/y_train_pred) are already on the
correct scale for metric calculation (any scaling is handled inside the
model wrapper).
model wrapper). Pairs with a NaN target or a NaN prediction are left out of
mse/mae/r2. With a single valid pair left, r2_val is NaN (R² needs 2 samples).
Args:
tmr: Training result containing:
@@ -368,6 +369,10 @@ class DataManagerRepository(SientiaMonitoring):
Return:
TrainModelResult: Same object with mse_val, mae_val and r2_val set.
Raises:
ValueError: If y_pred is not set, val_data and y_pred share no indices,
or no pair has both a valid target and a valid prediction.
"""
if tmr.y_pred is None:
raise ValueError('y_pred must be set before computing regression metrics')
@@ -405,6 +410,24 @@ class DataManagerRepository(SientiaMonitoring):
y_true_val = y_true_val.loc[common_index]
y_pred_val = y_pred_val.loc[common_index]
# NaN target, or NaN prediction for rows with NaN features, carries no error signal
# (SIENTIAPDE-2215).
valid = y_true_val.notna() & y_pred_val.notna()
if not valid.any():
raise ValueError(
'No validation rows with both target and prediction: all '
f'{len(valid)} overlapping rows have NaN'
)
if not valid.all():
n_valid = int(valid.sum())
message = (
f'Ignoring {len(valid) - n_valid} of {len(valid)} validation row(s) with NaN '
f'target or prediction; regression metrics use the remaining {n_valid}'
)
self.warning(message, metadata)
y_true_val = y_true_val[valid]
y_pred_val = y_pred_val[valid]
# Metrics helpers already round to 2 decimals.
tmr.mse_val = mse(y_true_val, y_pred_val)
tmr.mae_val = mae(y_true_val, y_pred_val)
@@ -527,6 +550,32 @@ class DataManagerRepository(SientiaMonitoring):
self.error(error_msg, metadata)
raise OSError(error_msg) from e
def _save_data_csv(
self, frame: pd.DataFrame, file_path: str, metadata: dict[str, Any] | None = None
) -> None:
"""
Write a train/test frame to CSV with its time index as a ``timestamp`` column.
laborious drift reads ``test_data.csv`` from MLflow as its reference and needs
``timestamp`` (SIENTIAPDE-2201). If a column already has that name, the frame is
written without the index and that model's drift keeps falling back to the target
window: training never fails over drift.
Args:
frame: Frame indexed by the UTC training timestamps.
file_path: Destination CSV path.
metadata: Workflow metadata for logging.
"""
if 'timestamp' in frame.columns:
self.warning(
f"Column 'timestamp' already exists; {path.basename(file_path)} saved without "
'the time column (drift reference falls back to the target window)',
metadata,
)
frame.to_csv(file_path, index=False)
return
frame.to_csv(file_path, index_label='timestamp')
def generate_report(
self, data: TrainModelResult, metadata: dict[str, Any] | None = None
) -> TrainModelResult:
@@ -598,11 +647,11 @@ class DataManagerRepository(SientiaMonitoring):
# Save training / validation CSVs using the same frames as the report (includes
# literal `target` alias for Evidently, plus predictions and float-cast features).
data.train_data_path = path.join(data.run_dir, 'train_data.csv')
reference_data_float.to_csv(data.train_data_path, index=False)
self._save_data_csv(reference_data_float, data.train_data_path, metadata)
# Save test data CSV
data.test_data_path = path.join(data.run_dir, 'test_data.csv')
current_data_float.to_csv(data.test_data_path, index=False)
self._save_data_csv(current_data_float, data.test_data_path, metadata)
# Save equation as JSON
if data.equation is not None and data.params.model_type == 'linear_regression':

View File

@@ -83,7 +83,9 @@ async def main():
This function initializes and starts all components of the worker:
1. Sets up logging and metadata
2. Starts Prometheus metrics server
3. Initializes notification handler
3. Initializes notification handler and Plugin Store client, then sets ``app_up``
(what the probes check) to 1. The runtime libraries are already installed: the
sientia-module entrypoint merges them into the resolved requirements before boot
4. Creates and configures activities
5. Starts Temporal client and workers
6. Manages worker lifecycle and graceful shutdown
@@ -125,8 +127,6 @@ async def main():
metrics_controller = MetricsController(logger=logger)
logger.custom_info(f'Installing runtime {runtime}', metadata)
plugin_store_parameters = build_plugin_store_config()
plugin_store = PluginStore(
base_url=plugin_store_parameters['base_url'],
@@ -144,7 +144,8 @@ async def main():
metrics_controller=metrics_controller,
)
await plugin_store.install_runtime(runtime_name=runtime)
# The probes only check app_up, so it goes to 1 only after initialization.
metrics.APP_UP.labels(pod_id=POD_ID).set(1)
activities = Activities(
postgres_config=build_postgres_config(),
@@ -232,8 +233,8 @@ def start_prometheus_server(logger: SientiaLogger, metadata: dict[str, str | Non
"""
Starts the Prometheus metrics server for monitoring and observability.
This function initializes the Prometheus HTTP server on the configured port
and sets the application health metric to indicate the service is running.
This function initializes the Prometheus HTTP server on the configured port.
``main`` sets ``app_up`` to 1 later, once initialization is done.
The server exposes metrics that can be scraped by Prometheus for monitoring
the health and performance of the Model Manager worker.
@@ -249,7 +250,6 @@ def start_prometheus_server(logger: SientiaLogger, metadata: dict[str, str | Non
port = int(os.getenv('HTTP_METRICS_PORT', 9090))
start_http_server(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
except Exception as e: # noqa: BLE001
logger.custom_critical(f'Failed to start Prometheus server: {e}', metadata)
os._exit(1)

View File

@@ -131,7 +131,9 @@ class TrainModel:
training_succeeded = True
finally:
try:
if train_result is not None:
# train_result is only None when training raised, so the false branch
# always leaves through the re-raise and never reaches the return below.
if train_result is not None: # pragma: no branch
await self._cleanup_resources(
run_dir=train_result.get('run_dir'),
metadata=metadata,

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "model-manager"
version = "2.0.1"
version = "2.1.0"
description = "Sientia DataOps Model Manager - ML Model Orchestration System"
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -1,11 +1,19 @@
temporalio==1.23.0
psycopg2-binary==2.9.11
sqlalchemy==2.0.49
boto3==1.42.70
botocore==1.42.70
temporalio
psycopg2-binary
# SQLAlchemy 2.1 makes psycopg (v3) the default driver for postgresql:// URLs; sientia_do 1.12.2
# builds that URL and ships only psycopg2-binary, so 2.1 fails with "No module named psycopg".
sqlalchemy<2.1
boto3
botocore
git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.12.2
git+ssh://git@github.com/Aignosi/sientia-model-library.git@0.14.0
prometheus-client==0.23.1
beautifulsoup4==4.12.3
evidently==0.6.7
jsonschema==4.26.0
prometheus-client
beautifulsoup4
# reports.py uses the Report/metric_preset API that 0.7 moved to evidently.legacy.
evidently<0.7
jsonschema
# Imported directly by the worker, without a version: the sientia-module entrypoint merges this
# file with the Plugin Store runtime, and the runtime's version wins (SIENTIAPDE-2223).
mlflow
pandas
scikit-learn

View File

@@ -1,11 +1,19 @@
temporalio==1.23.0
psycopg2-binary==2.9.11
sqlalchemy==2.0.50
boto3==1.42.70
botocore==1.42.70
temporalio
psycopg2-binary
# SQLAlchemy 2.1 makes psycopg (v3) the default driver for postgresql:// URLs; sientia_do 1.12.2
# builds that URL and ships only psycopg2-binary, so 2.1 fails with "No module named psycopg".
sqlalchemy<2.1
boto3
botocore
sientia_do>=1.12.2
sientia_model>=0.8.1
prometheus-client==0.23.1
beautifulsoup4==4.12.3
evidently==0.6.7
jsonschema==4.26.0
sientia_model>=0.14.0
prometheus-client
beautifulsoup4
# reports.py uses the Report/metric_preset API that 0.7 moved to evidently.legacy.
evidently<0.7
jsonschema
# Imported directly by the worker, without a version: the sientia-module entrypoint merges this
# file with the Plugin Store runtime, and the runtime's version wins (SIENTIAPDE-2223).
mlflow
pandas
scikit-learn

View File

@@ -92,6 +92,53 @@ def test_ensure_date_column_parsed_invalid_raises():
dmr._ensure_date_column_parsed(df, p)
def test_ensure_date_column_parsed_unmapped_format_raises():
df = pd.DataFrame({'ts': ['2024-01-01 10:00:00']})
p = _params(date_column='ts')
# Bypasses validate_business_rules, which would reject it earlier
p.date_format = 'dd/MM/yyyy HH:mm'
with pytest.raises(ValueError, match='is not mapped to a strftime pattern'):
dmr._ensure_date_column_parsed(df, p)
def test_drop_rows_with_missing_timestamp_without_date_column_returns_input():
repo = dmr.DataManagerRepository(MagicMock())
df = pd.DataFrame({'v1': [1.0, 2.0]})
assert repo._drop_rows_with_missing_timestamp(df, _params(), {}) is df
def test_drop_rows_with_missing_timestamp_non_object_dtype_drops_nat():
repo = dmr.DataManagerRepository(MagicMock())
df = pd.DataFrame(
{'timestamp': pd.to_datetime(['2024-01-01', None, '2024-01-03']), 'v1': [1, 2, 3]}
)
out = repo._drop_rows_with_missing_timestamp(df, _params(), {})
assert list(out['v1']) == [1, 3]
def test_coerce_non_timestamp_columns_to_numeric_skips_date_column():
repo = dmr.DataManagerRepository(MagicMock())
df = pd.DataFrame({'timestamp': ['2024-01-01 10:00:00'], 'v1': ['1.5']})
out = repo._coerce_non_timestamp_columns_to_numeric(df, _params(), {})
assert out['timestamp'].iloc[0] == '2024-01-01 10:00:00'
assert float(out['v1'].iloc[0]) == 1.5
def test_set_timezone_on_index_converts_aware_index_to_utc():
repo = dmr.DataManagerRepository(MagicMock())
idx = pd.DatetimeIndex(['2024-01-01 00:00'], tz='America/Sao_Paulo')
out = repo._set_timezone_on_index(pd.DataFrame({'v1': [1]}, index=idx))
assert str(out.index.tz) == 'UTC'
assert out.index[0] == pd.Timestamp('2024-01-01 03:00', tz='UTC')
def test_set_timezone_on_index_non_datetime_index_raises():
repo = dmr.DataManagerRepository(MagicMock())
df = pd.DataFrame({'v1': [1]})
with pytest.raises(ValueError, match='Index is not a DatetimeIndex'):
repo._set_timezone_on_index(df)
def test_prepare_training_data_csv_load_failure():
repo = dmr.DataManagerRepository(MagicMock())
p = _params()
@@ -309,6 +356,46 @@ def test_compute_regression_metrics_no_overlap():
repo.compute_regression_metrics(tmr, MagicMock())
def test_compute_regression_metrics_ignores_nan_pairs():
"""NaN target or NaN prediction rows are left out of mse/mae/r2 (SIENTIAPDE-2215)."""
repo = dmr.DataManagerRepository(MagicMock())
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
idx = pd.Index([0, 1, 2, 3])
tmr = TrainModelResult(
params=p,
train_data=pd.DataFrame({'t': [1.0, 2.0, 3.0, 4.0]}, index=idx),
val_data=pd.DataFrame({'t': [1.0, np.nan, 3.0, 4.0]}, index=idx),
y_pred=pd.DataFrame({'p': [1.0, 2.0, np.nan, 4.0]}, index=idx),
)
wrapper = MagicMock()
wrapper.model = object() # no sklearn .regr -> equation extraction is skipped
with patch.object(repo, 'warning') as warn:
out = repo.compute_regression_metrics(tmr, wrapper)
assert out.mse_val == pytest.approx(0.0)
assert out.mae_val == pytest.approx(0.0)
assert out.r2_val == pytest.approx(1.0)
warn.assert_called_once()
assert 'Ignoring 2 of 4' in warn.call_args.args[0]
def test_compute_regression_metrics_all_nan_pairs_raises():
repo = dmr.DataManagerRepository(MagicMock())
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
idx = pd.Index([0, 1])
tmr = TrainModelResult(
params=p,
train_data=pd.DataFrame({'t': [1.0, 2.0]}, index=idx),
val_data=pd.DataFrame({'t': [1.0, 2.0]}, index=idx),
y_pred=pd.DataFrame({'p': [np.nan, np.nan]}, index=idx),
)
wrapper = MagicMock()
with pytest.raises(ValueError, match='No validation rows with both target and prediction'):
repo.compute_regression_metrics(tmr, wrapper)
def test_compute_regression_metrics_linear_equation():
repo = dmr.DataManagerRepository(MagicMock())
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
@@ -514,6 +601,58 @@ def test_generate_report_adds_target_alias_for_reports(tmp_path):
assert test_csv['target'].equals(test_csv['t'])
_UTC_INDEX = pd.DatetimeIndex(['2024-01-01 02:00', '2024-01-01 01:00'], tz='UTC')
def _dated_result(**train_columns: list[float]) -> TrainModelResult:
"""Frames indexed by UTC time, like prepare_training_data + predict produce them."""
columns = train_columns or {'v1': [1.0, 2.0], 't': [1.0, 2.0]}
return TrainModelResult(
params=TrainModelParams.from_dict(_minimal_dict_for_prepare()),
train_data=pd.DataFrame(columns, index=_UTC_INDEX),
val_data=pd.DataFrame(columns, index=_UTC_INDEX),
y_train_pred=pd.DataFrame({'t': [1.0, 2.0]}, index=_UTC_INDEX),
y_pred=pd.DataFrame({'t': [1.0, 2.0]}, index=_UTC_INDEX),
run_name='testrun',
)
def _generate_report(repo, tmr: TrainModelResult, tmp_path) -> TrainModelResult:
with (
patch.object(repo, '_get_reports_directory', return_value=str(tmp_path)),
patch('model_manager.utils.repository.data_manager_repository.Reports'),
):
return repo.generate_report(tmr, {})
def test_generate_report_csvs_keep_the_time_index_as_timestamp(tmp_path):
"""laborious drift reads test_data.csv as its reference and needs ``timestamp``."""
repo = dmr.DataManagerRepository(MagicMock())
out = _generate_report(repo, _dated_result(), tmp_path)
for csv_path in (out.train_data_path, out.test_data_path):
csv = pd.read_csv(csv_path)
assert list(csv.columns) == ['timestamp', 'prediction', 'v1', 't', 'target']
assert pd.to_datetime(csv['timestamp']).tolist() == list(_UTC_INDEX)
def test_generate_report_csvs_skip_timestamp_when_a_column_already_has_the_name(tmp_path):
"""A variable literally named ``timestamp`` wins; training must not fail over drift."""
repo = dmr.DataManagerRepository(MagicMock())
tmr = _dated_result(timestamp=[5.0, 6.0], v1=[1.0, 2.0], t=[1.0, 2.0])
with patch.object(repo, 'warning') as warning:
out = _generate_report(repo, tmr, tmp_path)
for csv_path in (out.train_data_path, out.test_data_path):
csv = pd.read_csv(csv_path)
assert list(csv.columns) == ['prediction', 'timestamp', 'v1', 't', 'target']
assert csv['timestamp'].tolist() == [5.0, 6.0]
assert warning.call_count == 2
assert "Column 'timestamp' already exists" in warning.call_args.args[0]
def test_generate_report_skips_equation_file_when_not_linear(tmp_path):
repo = dmr.DataManagerRepository(MagicMock())
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())

View File

@@ -132,9 +132,8 @@ def test_start_prometheus_server_success(
# Verify HTTP server started
mock_start_http_server.assert_called_once_with(9090)
# Verify APP_UP metric was set to 1
mock_metrics.APP_UP.labels.assert_called_once_with(pod_id='test-pod-123')
mock_app_up.set.assert_called_once_with(1)
# app_up goes to 1 in main(), after initialization.
mock_app_up.set.assert_not_called()
mock_logger.custom_info.assert_called_once()
@@ -233,9 +232,6 @@ async def test_main_successful_startup(
mock_activities_class.return_value = mock_activities
mock_plugin_store_instance = AsyncMock()
mock_plugin_store_instance.install_runtime = AsyncMock(
return_value={'runtime': 'model-manager-worker', 'installed': []},
)
mock_plugin_store_class.return_value = mock_plugin_store_instance
mock_build_plugin_store_config.return_value = {
'base_url': 'http://sientia-plugin-store.svc.cluster.local',
@@ -284,7 +280,10 @@ async def test_main_successful_startup(
# Verify cleanup was performed
mock_notification_handler.shutdown.assert_called_once()
mock_activities.shutdown.assert_called_once()
mock_app_up.set.assert_called_with(0)
# Ready after initialization, down again on shutdown.
assert [c.args[0] for c in mock_app_up.set.call_args_list] == [1, 0]
# The runtime libraries come from the sientia-module entrypoint, not from the worker.
mock_plugin_store_instance.install_runtime.assert_not_called()
@pytest.mark.asyncio
@@ -360,9 +359,6 @@ async def test_main_handles_exception(
mock_metrics.APP_UP.labels.return_value = mock_app_up
mock_plugin_store_instance = AsyncMock()
mock_plugin_store_instance.install_runtime = AsyncMock(
return_value={'runtime': 'model-manager-worker', 'installed': []},
)
mock_plugin_store_class.return_value = mock_plugin_store_instance
mock_build_plugin_store_config.return_value = {
'base_url': 'http://sientia-plugin-store.svc.cluster.local',
@@ -459,9 +455,6 @@ async def test_main_temporal_client_configuration(
mock_build_minio.return_value = {}
mock_plugin_store_instance = AsyncMock()
mock_plugin_store_instance.install_runtime = AsyncMock(
return_value={'runtime': 'model-manager-worker', 'installed': []},
)
mock_plugin_store_class.return_value = mock_plugin_store_instance
mock_build_plugin_store_config.return_value = {
'base_url': 'http://sientia-plugin-store.svc.cluster.local',
@@ -594,9 +587,6 @@ async def test_main_worker_configuration(
mock_metrics.APP_UP.labels.return_value = mock_app_up
mock_plugin_store_instance = AsyncMock()
mock_plugin_store_instance.install_runtime = AsyncMock(
return_value={'runtime': 'model-manager-worker', 'installed': []},
)
mock_plugin_store_class.return_value = mock_plugin_store_instance
mock_build_plugin_store_config.return_value = {
'base_url': 'http://sientia-plugin-store.svc.cluster.local',
@@ -716,9 +706,6 @@ async def test_main_schedule_creation_failure_does_not_stop_worker(
mock_metrics.APP_UP.labels.return_value = mock_app_up
mock_plugin_store_instance = AsyncMock()
mock_plugin_store_instance.install_runtime = AsyncMock(
return_value={'runtime': 'model-manager-worker', 'installed': []},
)
mock_plugin_store_class.return_value = mock_plugin_store_instance
mock_build_plugin_store_config.return_value = {
'base_url': 'http://sientia-plugin-store.svc.cluster.local',
@@ -816,9 +803,6 @@ async def test_main_missing_runtime_uses_single_fallback(
mock_prepare_worker.return_value = mock_worker_instance
mock_plugin_store_instance = AsyncMock()
mock_plugin_store_instance.install_runtime = AsyncMock(
return_value={'runtime': 'single', 'installed': []},
)
mock_plugin_store_class.return_value = mock_plugin_store_instance
mock_build_plugin_store_config.return_value = {
'base_url': 'http://sientia-plugin-store.svc.cluster.local',
@@ -848,12 +832,10 @@ async def test_main_missing_runtime_uses_single_fallback(
def test_main_entrypoint(mock_asyncio_run):
"""Test the __main__ entrypoint."""
# Import and execute the main block
with patch.object(sys, 'argv', ['worker.py']):
import model_manager.worker.worker as worker_module
# Simulate running the module
worker_module.main = AsyncMock()
import model_manager.worker.worker as worker_module
# patch.object restores main afterwards; assigning it leaked the mock into later tests.
with patch.object(sys, 'argv', ['worker.py']), patch.object(worker_module, 'main', AsyncMock()):
# This would normally be called by asyncio.run(main())
# We just verify the pattern is correct
assert callable(worker_module.main)