7 Commits
0.7.2 ... 0.9.1

Author SHA1 Message Date
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
7 changed files with 203 additions and 13 deletions

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,6 +1342,9 @@ 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
if transform_flavor == 'pyfunc':
treated_data = data_model.predict({}, data)
else:
treated_data = data_model.predict(data) 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.1"
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,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`.