10 Commits
0.7.2 ... 0.9.2

Author SHA1 Message Date
github-actions[bot]
50956a55a7 chore: bump version to 0.9.2 2026-10-09 13:32:00 +00:00
cbd27aaa36 Merge pull request #62 from Aignosi/fix/laborious-legacy-worker-release
Point Deploy Workflow to laborious-legacy-worker Release
2026-10-09 10:31:36 -03:00
7f60aeefef Point deploy workflow at laborious-legacy-worker release
- Deploy the repo as its own opt-in release, laborious-legacy-worker, a
  single Deployment with RUNTIME=legacy, instead of the legacy runtime
  of laborious-worker. New code now lives in sientia-dataops-laborious.
- Set project_name to laborious-legacy so the Gitea mirror target
  resolves to gitea_admin/sientia-dataops-laborious-legacy.
- Bump GITHUB_BRANCH in the chart base .env and the gcp profile .env
  rather than the old .runtimes.legacy.env path.
- Rename the Sonar project to sientia-dataops-laborious-legacy.
2026-10-09 10:19:45 -03:00
github-actions[bot]
47d10f6a0e chore: bump version to 0.9.1 2026-10-08 15:00:39 +00:00
f027e98560 Merge pull request #57 from Aignosi/fix/SAM0123-320-evict-stale-artifact-modules
SAM0123 320 evict stale artifact modules
2026-10-08 12:00:11 -03:00
9e27688a70 SAM0123-320: refactor: snapshot sys.modules with copy() before eviction scan
Sonar flagged `list(sys.modules.items())` as an unnecessary `list()`. The
snapshot is required (a concurrent import or a lazy `__getattr__` can mutate
`sys.modules` mid-scan), so make it explicit with `copy()`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:22:44 -03:00
d91a0d069c Pin sqlalchemy below 2.1 in requirements files
- SQLAlchemy 2.1 switches the default Postgres driver to psycopg v3,
  which breaks sientia_do against the installed psycopg2-binary.
- Pin sqlalchemy<2.1 in requirements.txt, requirements-light.txt and
  requirements-local.txt until the driver migration is done.
2026-10-07 17:04:13 -03:00
fdeafe9768 SAM0123-320: fix: call pyfunc data model predict with wrapper signature
When a data model's `fit` returns the refit object instead of a DataFrame,
`fit_models` called `predict(data)` sklearn-style. A pyfunc wrapper's
`predict` takes `(context, model_input)`, so Courier Stratified retrain
failed with "missing 1 required positional argument: 'model_input'".

Mirror `get_prediction_data`: pass `{}` as context when the transform
flavor is pyfunc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:47:31 -03:00
25f11fa943 SAM0123-320: fix: evict stale artifact modules before loading a model
A long-lived worker kept the first `utils_slr` it ever imported in
`sys.modules`, so every later load unpickled against that stale class and
ignored the freshly downloaded `code/`. Courier Stratified retrain kept
failing on an old `StratifiedTransformerWrapper.fit` from a months-old
`/tmp/tmpvutfjrti` download.

`prepend_artifact_code_dir` now evicts cached modules shipped by the
artifact's `code/` that were imported from an artifact code directory,
and moves the directory to the front of `sys.path` when already present.
Modules installed in the image are never touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:08:09 -03:00
8ef7ed800a SIENTIAPDE-2071: chore: remove dead Makefile
Some checks failed
Quality gate / quality-gate (push) Has been cancelled
Card 16 of the SIENTIAPDE-2052 epic. This repo is a Flow B dataops
worker, it does not build its own image at all anymore, it runs off
a shared runtime image with code cloned from Gitea at deploy time. A
per repo image build Makefile pointing at aignosi.azurecr.io is dead
weight, no workflow calls make. Safe to remove.
2026-09-15 00:08:37 -03:00
9 changed files with 215 additions and 22 deletions

View File

@@ -18,15 +18,18 @@ jobs:
uses: Aignosi/github_workflow_templates/.github/workflows/reusable-deploy-dataops.yml@main uses: Aignosi/github_workflow_templates/.github/workflows/reusable-deploy-dataops.yml@main
with: with:
branch_name: ${{ github.event.pull_request.head.ref }} branch_name: ${{ github.event.pull_request.head.ref }}
project_name: 'laborious' project_name: 'laborious-legacy'
chart_name: 'laborious-worker' # This repo deploys its own opt-in release, laborious-legacy-worker: a single Deployment
# Only the "legacy" runtime, for now (card 13 correction) — laborious-worker has two # (no runtimes map, like scouter-worker/orchestrator-worker) with RUNTIME=legacy as the
# Deployments (basic, legacy). basic's GITHUB_BRANCH lives in the chart base (.global.env) # queue suffix. The new code lives in sientia-dataops-laborious (release laborious-worker).
# and is deliberately left untouched here. legacy's own override exists ONLY at the profile # The Gitea mirror target is derived from this repo's name:
# level — profiles/gcp.yaml's laborious-worker.runtimes.legacy.env, not the chart base at # gitea_admin/sientia-dataops-laborious-legacy. GITHUB_BRANCH is bumped in the chart base
# all — hence profile/profile_env_list_path below instead of env_list_path. # .env (release is opt-in; air-gapped profiles pin their own version) and in the gcp
# profile's laborious-legacy-worker.env.
chart_name: 'laborious-legacy-worker'
env_list_path: '[".env"]'
profile: 'gcp' profile: 'gcp'
profile_env_list_path: '[".runtimes.legacy.env"]' profile_env_list_path: '[".env"]'
update_version_in: '["pyproject"]' update_version_in: '["pyproject"]'
create_tarball: ${{ github.event_name == 'pull_request' }} create_tarball: ${{ github.event_name == 'pull_request' }}
mirror: true mirror: true

View File

@@ -1,7 +0,0 @@
VERSION = 1.0.8
name = sientia-laborious
# ENVIRONMENT = production
docker-hub:
@docker build --no-cache -t aignosi.azurecr.io/$(name):$(VERSION) .
@docker push aignosi.azurecr.io/$(name):$(VERSION)

View File

@@ -16,6 +16,7 @@ Capabilities:
import ctypes import ctypes
import gc import gc
import importlib
import sys import sys
import tempfile import tempfile
import threading import threading
@@ -51,6 +52,10 @@ MODEL_PICKLE_NAME = 'model.pkl'
INVALID_FLAVOR_MESSAGE = "Invalid flavor. Use 'sklearn' or 'pyfunc' or 'joblib'." INVALID_FLAVOR_MESSAGE = "Invalid flavor. Use 'sklearn' or 'pyfunc' or 'joblib'."
# Every artifact `code` directory this process has put on `sys.path`. A cached module is evicted
# only when it was imported from one of these, so modules installed in the image are never touched.
ARTIFACT_CODE_DIRS: set[str] = set()
def force_memory_release(logger: Logger, metadata: dict[str, Any] | None = None): def force_memory_release(logger: Logger, metadata: dict[str, Any] | None = None):
"""Attempt to release memory from the Python process. """Attempt to release memory from the Python process.
@@ -451,6 +456,15 @@ class MLFlowRepository(SientiaMonitoring):
The entry is never removed. A loaded model may import lazily, long after The entry is never removed. A loaded model may import lazily, long after
the load returned. the load returned.
`sys.path` only matters on the first import of a module: once
`utils_slr.mlflow.pyfunc_wrappers` sits in `sys.modules`, unpickling
resolves the class from it and the freshly downloaded `code` is never
read. So before prepending, the modules a previous artifact's `code`
left in `sys.modules` are evicted (see `evict_artifact_modules`), and
the directory is moved to the front even when it is already on
`sys.path`. Models already loaded keep the classes they were built
with: eviction only drops the cache entry, not the module objects.
Args: Args:
local_dir (str | None): Local directory of the downloaded model local_dir (str | None): Local directory of the downloaded model
artifact, e.g. the download of `<run>/artifacts/prediction_model`. artifact, e.g. the download of `<run>/artifacts/prediction_model`.
@@ -467,8 +481,15 @@ class MLFlowRepository(SientiaMonitoring):
if not code_dir: if not code_dir:
return None return None
code_dir = path.abspath(code_dir)
ARTIFACT_CODE_DIRS.add(code_dir)
self.evict_artifact_modules(code_dir, flavor, metadata)
if code_dir in sys.path: if code_dir in sys.path:
self.debug(f'{flavor} flavor: {code_dir} already on sys.path', metadata) sys.path.remove(code_dir)
sys.path.insert(0, code_dir)
self.debug(f'{flavor} flavor: {code_dir} already on sys.path, moved to front', metadata)
return None return None
sys.path.insert(0, code_dir) sys.path.insert(0, code_dir)
@@ -476,6 +497,70 @@ class MLFlowRepository(SientiaMonitoring):
return code_dir return code_dir
def evict_artifact_modules(
self,
code_dir: str,
flavor: str,
metadata: dict[str, Any] | None = None,
) -> list[str]:
"""
Drop from `sys.modules` what an artifact `code` directory imported before.
A long-lived worker loads many artifacts that ship the same top-level
package (`utils_slr`, `utils`) at different versions. Without eviction,
the first one imported wins for the life of the process, and every later
load unpickles against that stale class.
Only modules whose top-level name is shipped by `code_dir` *and* whose
file lives under an artifact `code` directory (a previous one, or this
very one re-downloaded in place) are evicted. A module of the same name
installed in the image is left alone.
Args:
code_dir (str): Absolute `code` directory about to be prepended,
already registered in `ARTIFACT_CODE_DIRS`.
flavor (str): Flavor doing the load, for the log line only.
metadata (dict[str, Any] | None): Metadata used for structured
logging.
Returns:
list[str]: Names of the evicted modules.
"""
shipped = {
path.splitext(entry)[0]
for entry in listdir(code_dir)
if entry != '__pycache__'
and (path.isdir(path.join(code_dir, entry)) or entry.endswith('.py'))
}
artifact_prefixes = tuple(path.join(directory, '') for directory in ARTIFACT_CODE_DIRS)
# Snapshot: another thread, or a lazy module's `__getattr__`, may import mid-scan.
evicted = [
name
for name, module in sys.modules.copy().items()
if name.partition('.')[0] in shipped
# `__path__` covers namespace packages, which have no `__file__`.
and any(
path.abspath(origin).startswith(artifact_prefixes)
for origin in [getattr(module, '__file__', None), *getattr(module, '__path__', [])]
if origin
)
]
for name in evicted:
sys.modules.pop(name, None)
if evicted:
importlib.invalidate_caches()
self.info(
f'{flavor} flavor: evicted {len(evicted)} cached artifact module(s) '
f'before loading from {code_dir}: {", ".join(sorted(evicted))}',
metadata,
)
return evicted
async def download_artifact_code_dir( async def download_artifact_code_dir(
self, self,
model_name: str, model_name: str,
@@ -1257,7 +1342,10 @@ class MLFlowRepository(SientiaMonitoring):
if not isinstance(treated_data_candidate, pd.DataFrame): if not isinstance(treated_data_candidate, pd.DataFrame):
data_model = treated_data_candidate data_model = treated_data_candidate
treated_data = data_model.predict(data) if transform_flavor == 'pyfunc':
treated_data = data_model.predict({}, data)
else:
treated_data = data_model.predict(data)
else: else:
treated_data = treated_data_candidate treated_data = treated_data_candidate

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "laborious" name = "laborious"
version = "0.7.2" version = "0.9.2"
description = "Sientia DataOps Laborious - ML Model Orchestration System" description = "Sientia DataOps Laborious - ML Model Orchestration System"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@@ -1,6 +1,6 @@
temporalio temporalio
psycopg2-binary psycopg2-binary
sqlalchemy sqlalchemy<2.1
asyncua==1.0.6 asyncua==1.0.6
redis redis
sientia_do>=1.12.2 sientia_do>=1.12.2

View File

@@ -1,6 +1,6 @@
temporalio temporalio
psycopg2-binary psycopg2-binary
sqlalchemy sqlalchemy<2.1
asyncua==1.0.6 asyncua==1.0.6
redis redis
git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.12.2 git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.12.2

View File

@@ -1,6 +1,6 @@
temporalio temporalio
psycopg2-binary psycopg2-binary
sqlalchemy sqlalchemy<2.1
asyncua==1.0.6 asyncua==1.0.6
redis redis
sientia_do>=1.12.2 sientia_do>=1.12.2

View File

@@ -1,5 +1,5 @@
sonar.projectKey=Aignosi_sientia-dataops-laborious_temporal_ca1a7039-6db9-49e5-be78-54d29bc93e4f sonar.projectKey=Aignosi_sientia-dataops-laborious_temporal_ca1a7039-6db9-49e5-be78-54d29bc93e4f
sonar.projectName=sientia-dataops-laborious_temporal sonar.projectName=sientia-dataops-laborious-legacy
sonar.sources=laborious sonar.sources=laborious
sonar.tests=tests sonar.tests=tests
sonar.projectVersion=1.0.0 sonar.projectVersion=1.0.0

View File

@@ -1,6 +1,7 @@
import ast import ast
import importlib import importlib
import sys import sys
import types
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from unittest.mock import ANY, AsyncMock, MagicMock, call, patch from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
@@ -1042,6 +1043,38 @@ async def test_fit_models_not_df_target_name_none_and_not_in_model(
} }
@patch('laborious.utils.repository.model_repository.pd.merge')
@patch('laborious.utils.repository.model_repository.isinstance')
@pytest.mark.asyncio
async def test_fit_models_pyfunc_transform_predicts_with_wrapper_signature(
isinstance_mock, pd_merge, mlflow_repository
):
"""A pyfunc data model refit returns the wrapper, whose `predict` takes `(context, model_input)`."""
isinstance_mock.return_value = False
data_model = MagicMock()
mlflow_repository.download_model = AsyncMock(
side_effect=[(data_model, 'artifact_path'), (MagicMock(), 'artifact_path')],
)
mlflow_repository.detect_and_parse_datetime_index = MagicMock()
mlflow_repository.get_prediction_data = MagicMock(return_value=DataFrame())
data = MagicMock()
await mlflow_repository.fit_models(
'model_name',
data,
'latest_production_id',
metadata['metadata'],
'pyfunc',
False,
'pyfunc',
'target',
)
data_model.fit.return_value.predict.assert_called_once_with({}, data)
@patch('laborious.utils.repository.model_repository.pd.merge') @patch('laborious.utils.repository.model_repository.pd.merge')
@patch('laborious.utils.repository.model_repository.isinstance') @patch('laborious.utils.repository.model_repository.isinstance')
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -2336,6 +2369,82 @@ def test_prepend_artifact_code_dir_does_not_duplicate(
assert sys.path.count(code_dir) == 1 assert sys.path.count(code_dir) == 1
def build_versioned_package_dir(artifact_dir, version):
"""Add a `code/` directory shipping a `probe_pkg` package at a given version."""
package_dir = artifact_dir / 'code' / 'probe_pkg'
package_dir.mkdir(parents=True)
(package_dir / '__init__.py').write_text('')
(package_dir / 'wrappers.py').write_text(f'VERSION = {version!r}\n')
return str(artifact_dir / 'code')
@pytest.fixture
def clean_probe_pkg():
"""Keep `probe_pkg` out of `sys.modules` before and after each test."""
def purge():
for name in [n for n in sys.modules if n.partition('.')[0] == 'probe_pkg']:
del sys.modules[name]
importlib.invalidate_caches()
purge()
yield
purge()
def test_prepend_artifact_code_dir_evicts_module_cached_from_previous_artifact(
mlflow_repository, tmp_path, preserved_sys_path, clean_probe_pkg
):
"""A later artifact's `code` wins over what an earlier artifact left in `sys.modules`."""
old_artifact, new_artifact = tmp_path / 'old', tmp_path / 'new'
build_versioned_package_dir(old_artifact, 'old')
build_versioned_package_dir(new_artifact, 'new')
mlflow_repository.prepend_artifact_code_dir(str(old_artifact), 'pyfunc')
stale = importlib.import_module('probe_pkg.wrappers')
assert stale.VERSION == 'old'
mlflow_repository.prepend_artifact_code_dir(str(new_artifact), 'pyfunc')
assert importlib.import_module('probe_pkg.wrappers').VERSION == 'new'
# Already-loaded objects keep the module they were built against.
assert stale.VERSION == 'old'
def test_prepend_artifact_code_dir_reloads_code_dir_re_downloaded_in_place(
mlflow_repository, tmp_path, preserved_sys_path, clean_probe_pkg
):
"""The fixed `joblib` download dir is reused: re-prepending it must re-read the files."""
code_dir = build_versioned_package_dir(tmp_path, 'old')
mlflow_repository.prepend_artifact_code_dir(str(tmp_path), 'joblib')
assert importlib.import_module('probe_pkg.wrappers').VERSION == 'old'
# A different size, so the `.pyc` written a moment ago is not taken as current.
(tmp_path / 'code' / 'probe_pkg' / 'wrappers.py').write_text("VERSION = 'renewed'\n")
sys.path.insert(0, str(tmp_path / 'elsewhere'))
mlflow_repository.prepend_artifact_code_dir(str(tmp_path), 'joblib')
assert sys.path[0] == code_dir
assert importlib.import_module('probe_pkg.wrappers').VERSION == 'renewed'
def test_evict_artifact_modules_leaves_modules_not_from_an_artifact_alone(
mlflow_repository, tmp_path, preserved_sys_path, clean_probe_pkg
):
"""A same-named module installed in the image is never evicted."""
code_dir = build_versioned_package_dir(tmp_path / 'artifact', 'new')
installed = types.ModuleType('probe_pkg')
installed.__file__ = str(tmp_path / 'site-packages' / 'probe_pkg' / '__init__.py')
sys.modules['probe_pkg'] = installed
evicted = mlflow_repository.evict_artifact_modules(code_dir, 'pyfunc')
assert evicted == []
assert sys.modules['probe_pkg'] is installed
def stub_remote_code_dir(mlflow_repository, tmp_path, artifact_name, exists=True): def stub_remote_code_dir(mlflow_repository, tmp_path, artifact_name, exists=True):
"""Point the client at a run whose artifact does — or does not — carry `code`. """Point the client at a run whose artifact does — or does not — carry `code`.