Files
vitor-aignosi 4c7cae502f QTZPOC-20
QTZPOC-20: Refactor ModelImport status engine for lazy initialization, add byte-counted size validation to bundle extraction, optimize MLflow run ID lookups, and ensure workflow cancellation propagates correctly.
2026-09-10 08:59:31 -03:00

1251 lines
58 KiB
Python

"""Model import activities: the import log row, the bundle, MLflow, the model document, cleanup.
The import log row is **not created here**. It is `public.experiment_run` in the Postgres database
`sientia-core-mlops-bff`; the frontend inserts it through the BFF with `run_type='IMPORT'` and
`status='PENDING'`, and passes its `id` in the workflow input. Every write in this module is an
`UPDATE ... WHERE id = :import_run_id`, on an engine of its own against that database — never the
`sientia` database Laborious itself uses, and never in a transaction shared with a provisioning
write.
Three classes of status write, and only the first one is strict:
- **A, the claim** — `WHERE id AND run_type='IMPORT' AND (PENDING OR my own workflow id)`. This is
the ownership decision, and a claim matching nothing fails the import explicitly.
- **B, progress** — `WHERE id`, nothing else. A hint. Its failure never fails the import.
- **C, terminal** — `WHERE id`, nothing else, in the workflow's `finally`, after cleanup. The
authority: it rewrites status, code, reason and step from one classification of the outcome.
"""
from temporalio import activity, workflow
with workflow.unsafe.imports_passed_through():
import json
import shutil
from datetime import timedelta
from enum import StrEnum
from pathlib import Path
from typing import Any, Final
from minio.commonconfig import ENABLED, Filter
from minio.lifecycleconfig import Expiration, LifecycleConfig, Rule
from mlflow.entities import Param
from mlflow.exceptions import MlflowException
from mlflow.protos.databricks_pb2 import RESOURCE_DOES_NOT_EXIST, ErrorCode
from pymongo import ReturnDocument
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.observability.logger import Logger
from sientia_do.observability.metrics_controller import MetricsController
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
from sientia_do.repository.minio_repository import MinioRepository
from sientia_do.repository.mongodb_repository import MongoDBRepository
from sientia_do.temporal.constants import now
from sqlalchemy import create_engine, text
from sqlalchemy.engine import URL, Engine
from sqlalchemy.exc import DataError, IntegrityError, ProgrammingError
from sqlalchemy.sql import Executable
from temporalio.common import RetryPolicy
from temporalio.exceptions import ApplicationError
from laborious import metrics
from laborious.utils.bundle import format as fmt
from laborious.utils.bundle.reader import BundleGateError, BundleLimits, BundleReader
from laborious.utils.bundle.steps import ImportStep
from laborious.utils.import_password import (
EnvelopeError,
decrypt_password_envelope,
load_envelope_key,
)
from laborious.utils.messages import MISSING_MINIO_REPOSITORY as _MISSING_MINIO
from laborious.utils.models.import_errors import (
ImportErrorCode,
ImportGateRejectedError,
ImportLogRowNotClaimableError,
ImportMisconfiguredError,
ImportResponsibility,
ImportStatusRejectedError,
classify_import_failure,
report_import_failure,
)
from laborious.utils.repository.model_repository import MLFlowRepository
from laborious.utils.time import to_naive
# The table is a constant of the code, never an input: there is exactly one import log on this
# platform, and letting a caller name it would be a SQL-injection surface bought for nothing. The
# statements below write the name out literally rather than interpolating this constant — an
# interpolation is a shape a reader (and bandit) has to reason about, and there is nothing to
# parameterise. The constant is what the tests assert every statement against.
IMPORT_LOG_TABLE: Final[str] = 'public.experiment_run'
RUN_NAME_MAX_LENGTH: Final[int] = 50
RUN_NAME_PREFIX: Final[str] = 'import-'
_MISSING_MLFLOW = 'MLflow repository not initialized'
_MISSING_MONGO = 'MongoDB repository not initialized'
# Where a digest may sit inside the BFF-owned `request_data`. Checked in this order; absence is not
# a failure, only a disagreement is.
REQUEST_DIGEST_KEYS: Final[tuple[str, ...]] = (
'expected_digest',
'digest',
'sha256',
'file_digest',
)
class ImportState(StrEnum):
"""The workflow's internal vocabulary, mapped onto the shared status column by D17."""
RUNNING = 'RUNNING'
COMPLETED = 'COMPLETED'
ERROR = 'ERROR'
class ExperimentRunStatus(StrEnum):
"""The values this workflow writes into `experiment_run.status`.
Two are **reused** from the training flow because they describe a stage, not a flow — the row's
flow is told by `run_type`, not by the status. Two are **new in V12**, because
`TRAINING_SUCCESS` / `TRAINING_ERROR` would lie about where the row came from. Nobody should
"fix" the reuse into an `IMPORT_RUNNING` twin: it would carry no information `run_type` does not
already carry, and it would widen the pairing CHECK and the frontend's switch for nothing.
"""
ORCHESTRATOR_WAITING_PROC = 'ORCHESTRATOR_WAITING_PROC' # reused
ORCHESTRATOR_VALIDATION_ERROR = 'ORCHESTRATOR_VALIDATION_ERROR' # reused
IMPORT_SUCCESS = 'IMPORT_SUCCESS' # new in V12
IMPORT_ERROR = 'IMPORT_ERROR' # new in V12
# Only the status writes retry, and only because they are idempotent: `UPDATE ... SET <fixed values>
# WHERE id` leaves the row the same however many times it runs. A provisioning call has no such
# shape, which is why it keeps `maximum_attempts=1`. Defined here rather than in `sientia_do`, which
# other repositories share.
IMPORT_STATUS_RETRY_POLICY: Final[RetryPolicy] = RetryPolicy(
maximum_attempts=10,
initial_interval=timedelta(seconds=1),
backoff_coefficient=2.0,
maximum_interval=timedelta(seconds=30),
non_retryable_error_types=['ImportLogRowNotClaimableError', 'ImportStatusRejectedError'],
)
# Progress writes are hints nobody reads — the terminal write rewrites them — so a 151-second pause
# before each of a dozen stage updates would stall a working import for twenty minutes because of a
# database the import itself does not need.
IMPORT_STATUS_HINT_RETRY_POLICY: Final[RetryPolicy] = RetryPolicy(
maximum_attempts=3,
initial_interval=timedelta(seconds=1),
backoff_coefficient=2.0,
maximum_interval=timedelta(seconds=2),
non_retryable_error_types=['ImportLogRowNotClaimableError', 'ImportStatusRejectedError'],
)
_READ_ROW_SQL = text(
"""
SELECT id,
run_type,
status,
bucket_name,
file_name,
request_data,
orchestrator_response_data -> 'import' ->> 'workflow_id' AS claimed_by
FROM public.experiment_run
WHERE id = :import_run_id
"""
)
def _build_update(
columns: dict[str, str], *, casts: dict[str, str] | None = None, extra_where: str = ''
) -> Executable:
"""`UPDATE public.experiment_run SET <columns> WHERE id = :import_run_id [AND <extra_where>]`.
`columns` maps each SET target to its bind parameter name, e.g. `{'status': 'status'}` renders
`status = :status`. `casts` names which of those columns need `CAST(:name AS <cast>)` instead of
a bare `:name`. Order is preserved (Python 3.7+ dict order), so the rendered SQL is deterministic
and diff-friendly.
"""
casts = casts or {}
assignments = ',\n '.join(
f'{column} = CAST(:{param} AS {casts[column]})'
if column in casts
else f'{column} = :{param}'
for column, param in columns.items()
)
where = 'WHERE id = :import_run_id'
if extra_where:
where = f'{where}\n AND {extra_where}'
return text(
f'UPDATE public.experiment_run\n SET {assignments}\n {where}' # nosec B608 - columns/extra_where come only from literal constants in this module, never user input
)
_CLAIM_SQL = _build_update(
{'status': 'status', 'updated_at': 'updated_at', 'orchestrator_response_data': 'detail'},
casts={'orchestrator_response_data': 'JSONB'},
extra_where=(
"run_type = 'IMPORT'\n"
" AND (status = 'PENDING'\n"
" OR (status = 'ORCHESTRATOR_WAITING_PROC'\n"
" AND orchestrator_response_data -> 'import' ->> 'workflow_id' "
'= :workflow_id))'
),
)
# Class B and C key on the id alone: ownership was decided once, by the claim. Re-checking it on
# every later write buys nothing and costs the thing that matters — a predicate miss turning a
# pipeline that already imported the model into a reported failure.
_PROGRESS_SQL = _build_update(
{'status': 'status', 'updated_at': 'updated_at', 'orchestrator_response_data': 'detail'},
casts={'orchestrator_response_data': 'JSONB'},
)
_NAMES_SQL = _build_update(
{
'experiment_name': 'experiment_name',
'run_name': 'run_name',
'updated_at': 'updated_at',
'orchestrator_response_data': 'detail',
},
casts={'orchestrator_response_data': 'JSONB'},
)
_TERMINAL_SQL = _build_update(
{
'status': 'status',
'error_message': 'error_message',
'updated_at': 'updated_at',
'orchestrator_response_data': 'detail',
},
casts={'orchestrator_response_data': 'JSONB'},
)
def map_state_to_status(
state: ImportState, step: ImportStep | str | None = None
) -> ExperimentRunStatus:
"""Map the workflow's internal state onto the shared status column (design D17).
`RUNNING` is the orchestrator holding the row. `COMPLETED` is `IMPORT_SUCCESS`. An `ERROR` is
split in two by *who can act on it*: the user, for everything up to the content policy, or only
support, for everything after it — the same split the table already draws between
`ORCHESTRATOR_VALIDATION_ERROR` and `TRAINING_ERROR`.
"""
if state is ImportState.RUNNING:
return ExperimentRunStatus.ORCHESTRATOR_WAITING_PROC
if state is ImportState.COMPLETED:
return ExperimentRunStatus.IMPORT_SUCCESS
failure = classify_import_failure(ImportStep(step) if step is not None else None)
if failure.responsibility is ImportResponsibility.USER:
return ExperimentRunStatus.ORCHESTRATOR_VALIDATION_ERROR
return ExperimentRunStatus.IMPORT_ERROR
def derive_run_name(model_name: str, model_version: str) -> str:
"""`import-{model_name}-v{model_version}`, truncated inside the model-name segment.
Deterministic, because the recorded name and the created run's name must not diverge: the run is
created with exactly this value, passed in as an argument rather than recomputed. The `import-`
prefix guarantees the column's length-3 floor; the truncation is what keeps it under 50.
"""
suffix = f'-v{model_version}'
room = RUN_NAME_MAX_LENGTH - len(RUN_NAME_PREFIX) - len(suffix)
if room < 1:
# A version string long enough to leave no room for a name: keep the prefix and as much of
# the version as fits, so the value is still a legible, deterministic 50 characters.
return f'{RUN_NAME_PREFIX}{suffix.lstrip("-")}'[:RUN_NAME_MAX_LENGTH]
return f'{RUN_NAME_PREFIX}{model_name[:room]}{suffix}'
def build_import_detail(
*,
workflow_id: str,
step: ImportStep | str | None = None,
code: ImportErrorCode | str | None = None,
gate: int | None = None,
source: dict[str, Any] | None = None,
cleanup_failed: bool = False,
) -> str:
"""Build `orchestrator_response_data` as JSON text.
The whole `import` object is rewritten on every write, which is what keeps the workflow
identifier the claim wrote from ever being dropped. There is no column for any of this (design
D21): the code the frontend switches on and the identifier support quotes live at fixed keys in
the JSON column the workflow already owns.
"""
detail: dict[str, Any] = {'workflow_id': workflow_id, 'gate': gate}
if step is not None:
detail['step'] = str(step)
if code is not None:
detail['code'] = str(code)
if source:
detail['source'] = source
if cleanup_failed:
detail['cleanup_failed'] = True
return json.dumps({'import': detail})
class ModelImport(SientiaMonitoring):
"""Activities turning a `.sientia` object into a registered model, or into a durable ERROR."""
def __init__(
self,
status_config: dict[str, Any],
import_config: dict[str, Any],
minio_repository: MinioRepository | None = None,
mlflow_repository: MLFlowRepository | None = None,
mongo_repository: MongoDBRepository | None = None,
logger: Logger | None = None,
notification_handler: NotificationHandler | None = None,
metrics_controller: MetricsController | None = None,
):
"""
Args:
status_config: `build_import_status_config()` — the BFF database holding the import log.
This is a **second** Postgres connection — another database, on the `POSTGRES_*`
server unless the IMPORT_STATUS_DB_* overrides move it — and its separateness from
the one Laborious already holds is what makes design D7's transactional
independence literal.
import_config: `build_import_config()` — bucket, prefix, ceilings, work directory,
envelope key and the models collection name.
minio_repository: the shared MinIO repository, for the uploaded bundle.
mlflow_repository: the shared MLflow repository — the same instance the rest of the
worker uses, because setting the tracking URI is a process-wide side effect.
mongo_repository: MongoDB, where the model listing lives.
logger: logger instance.
notification_handler: the notification path the rest of the repository uses.
metrics_controller: metrics controller.
"""
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
self.import_config = import_config
self.status_config = status_config
self.import_minio_repository = minio_repository
self.import_mlflow_repository = mlflow_repository
self.mongo_repository = mongo_repository
self._status_engine: Engine | None = None
@property
def status_engine(self) -> Engine:
"""The BFF status database's engine, created on first access and cached from then on.
A worker pod where the import feature is never exercised never opens a connection pool
against that Postgres: nothing in this module reaches `status_engine` unless an import
activity actually runs.
"""
if self._status_engine is None:
self._status_engine = create_engine(
URL.create(
'postgresql',
username=self.status_config['user'],
password=self.status_config['password'],
host=self.status_config['host'],
port=self.status_config['port'],
database=self.status_config['dbname'],
),
pool_size=2,
max_overflow=3,
pool_pre_ping=True,
)
return self._status_engine
def close(self) -> None:
"""Dispose the status engine, when one was ever created. The import holds no other
connection."""
if self._status_engine is not None:
self._status_engine.dispose()
# ------------------------------------------------------------------ helpers
@staticmethod
def _timestamp() -> Any:
"""`updated_at` is `TIMESTAMP WITHOUT TIME ZONE`, so the value handed over is naive."""
return to_naive(now())
@staticmethod
def _digest_from_request_data(request_data: Any) -> str | None:
"""Find the uploader's digest inside the BFF-owned request payload, if it recorded one."""
if not isinstance(request_data, dict):
return None
for key in REQUEST_DIGEST_KEYS:
value = request_data.get(key)
if isinstance(value, str) and value:
return value.lower()
return None
def _execute_status_write(self, statement: Any, parameters: dict[str, Any]) -> int:
"""Run one status statement in its own transaction and return the rows it affected.
Raises:
ImportStatusRejectedError: the database accepted the connection and refused the
statement — a CHECK violation, an undefined column, a value too long. Non-retryable,
so a missing `V12` fails on the first attempt instead of after the full envelope.
Exception: anything else (connection refused, reset, timeout, a pod being replaced) is
left to propagate, because repeating it is exactly what fixes it.
"""
try:
with self.status_engine.begin() as connection:
return connection.execute(statement, parameters).rowcount
except (IntegrityError, ProgrammingError, DataError) as error:
raise ImportStatusRejectedError(
'the import log database refused the status statement'
) from error
def _read_row(self, import_run_id: Any) -> dict[str, Any] | None:
"""Read the record. A read, not a write: nothing of somebody else's is overwritten."""
with self.status_engine.connect() as connection:
row = connection.execute(_READ_ROW_SQL, {'import_run_id': import_run_id}).mappings()
found = row.first()
return dict(found) if found is not None else None
@staticmethod
def _describe_unclaimable(row: dict[str, Any] | None, workflow_id: str) -> str:
"""Name the cause of a zero-row claim: missing row, training row, or someone else's."""
if row is None:
return 'no such import log row'
if row.get('run_type') != 'IMPORT':
return f'row is a {row.get("run_type")} run, not an import'
claimed_by = row.get('claimed_by')
if claimed_by and claimed_by != workflow_id:
return f'row is already claimed by workflow {claimed_by}'
return f'row is in status {row.get("status")} and cannot be claimed'
# -------------------------------------------------------------- class A: claim
@activity.defn(name='claim_import_status')
async def claim_import_status(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Claim the import log row the frontend inserted. The workflow's first write.
One conditional `UPDATE`, whose second disjunct makes a re-execution of the same workflow
idempotent instead of a conflict — a lost response on attempt 1 must not become a false
"not mine" on attempt 2. Zero rows affected covers the id typo, the duplicated start and the
wrong `run_type` in one condition, with no read-then-write race, and it is what stops a
duplicated workflow start from provisioning twice.
Args (input_data):
metadata (dict): workflow metadata for logging and metrics.
import_run_id (int): the record's primary key, from the workflow input.
workflow_id (str): this workflow's identifier, recorded in the JSON detail.
source (dict, optional): the bundle's declared origin, when already known.
Returns:
dict: `claimed`, plus the row values the input guards are checked against —
`bucket_name`, `file_name` and `request_digest`.
Raises:
ImportLogRowNotClaimableError: the row is not this workflow's. Non-retryable, reported
through the channel, and **written to no row** — the loudness has to go somewhere
that is not somebody else's audit record.
"""
metadata = input_data.get('metadata', {})
import_run_id = input_data['import_run_id']
workflow_id = input_data['workflow_id']
try:
affected = self._execute_status_write(
_CLAIM_SQL,
{
'status': ExperimentRunStatus.ORCHESTRATOR_WAITING_PROC.value,
'updated_at': self._timestamp(),
'detail': build_import_detail(
workflow_id=workflow_id,
step=ImportStep.RECEIVED,
source=input_data.get('source'),
),
'import_run_id': import_run_id,
'workflow_id': workflow_id,
},
)
except ImportStatusRejectedError as error:
await report_import_failure(
self,
None,
error,
import_run_id=import_run_id,
workflow_id=workflow_id,
metadata=metadata,
code=ImportErrorCode.STATUS_NOT_RECORDED,
marker='status_write',
)
raise
row = self._read_row(import_run_id)
if affected == 0:
observed = self._describe_unclaimable(row, workflow_id)
not_claimable = ImportLogRowNotClaimableError(
f'import log row {import_run_id} could not be claimed: {observed}',
import_run_id=import_run_id,
observed=observed,
)
await report_import_failure(
self,
None,
not_claimable,
import_run_id=import_run_id,
workflow_id=workflow_id,
metadata=metadata,
code=ImportErrorCode.LOG_ROW_NOT_CLAIMABLE,
marker='not_claimable',
)
raise not_claimable
await self.emit_metric(
metric_object=metrics.MODEL_IMPORT_STARTED_COUNT,
tags=self.get_core_labels(metadata, operation_type=ImportStep.RECEIVED.value),
)
self.info(f'import log row {import_run_id} claimed by {workflow_id}', metadata)
row = row or {}
return {
'claimed': True,
'bucket_name': row.get('bucket_name'),
'file_name': row.get('file_name'),
'request_digest': self._digest_from_request_data(row.get('request_data')),
}
# ----------------------------------------------------------- class B: progress
@activity.defn(name='record_import_status')
async def record_import_status(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Record the step the workflow is about to attempt. A hint, keyed on the id alone.
Its failure is **not** an import failure: the workflow reports it through the channel and
carries on, because losing a hint costs a record pointing one step behind and nothing else —
the terminal write rewrites status, code, reason and step from one classification.
Args (input_data):
metadata (dict): workflow metadata.
import_run_id (int): the record's primary key.
workflow_id (str): recorded in the detail, so it is never dropped.
step (str): the step about to be attempted.
source (dict, optional): the bundle's declared origin.
"""
affected = self._execute_status_write(
_PROGRESS_SQL,
{
'status': ExperimentRunStatus.ORCHESTRATOR_WAITING_PROC.value,
'updated_at': self._timestamp(),
'detail': build_import_detail(
workflow_id=input_data['workflow_id'],
step=input_data['step'],
source=input_data.get('source'),
),
'import_run_id': input_data['import_run_id'],
},
)
return {'recorded': affected > 0, 'rows': affected}
@activity.defn(name='record_import_names')
async def record_import_names(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Write the experiment and run names, before anything exists in MLflow.
Both columns exist so a person watching the import list sees *which* model is being
imported. The earliest moment both values are known is when the bundle is open, so that is
when they are written — before the experiment exists, before the run exists.
Args (input_data):
metadata (dict): workflow metadata.
import_run_id (int): the record's primary key.
workflow_id (str): recorded in the detail.
experiment_name (str): the bundle's own experiment name, verbatim.
run_name (str): the derived run name, which the run is then created with.
source (dict, optional): the bundle's declared origin.
"""
affected = self._execute_status_write(
_NAMES_SQL,
{
'experiment_name': input_data['experiment_name'],
'run_name': input_data['run_name'],
'updated_at': self._timestamp(),
'detail': build_import_detail(
workflow_id=input_data['workflow_id'],
step=input_data.get('step', ImportStep.EXPERIMENT_CREATION),
source=input_data.get('source'),
),
'import_run_id': input_data['import_run_id'],
},
)
return {'recorded': affected > 0, 'rows': affected}
# ----------------------------------------------------------- class C: terminal
@activity.defn(name='record_import_terminal_status')
async def record_import_terminal_status(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""The single authoritative write, run from the workflow's `finally` after cleanup.
Every failure is classified **once**, here, from the step that failed. On success the reason
column is written as `NULL` — never `''`, which its length-3 CHECK would refuse — and the
detail carries no code. A cleanup failure is noted in the detail without changing a
successful status: the user's model was imported, and reporting ERROR would be false.
Args (input_data):
metadata (dict): workflow metadata.
import_run_id (int): the record's primary key.
workflow_id (str): recorded in the detail.
succeeded (bool): whether every step succeeded.
step (str, optional): the failing step, or the last step reached on success.
gate (int, optional): the gate that rejected the bundle, when one did.
source (dict, optional): the bundle's declared origin.
cleanup_failed (bool, optional): whether the temporary-file cleanup failed.
Returns:
dict: the verdict that was written — status, code and reason — so a caller that has to
report a failure to record it still holds the verdict it could not write.
"""
metadata = input_data.get('metadata', {})
succeeded = bool(input_data.get('succeeded'))
step = input_data.get('step')
cleanup_failed = bool(input_data.get('cleanup_failed'))
if succeeded:
status = map_state_to_status(ImportState.COMPLETED)
code: ImportErrorCode | None = None
reason: str | None = None
else:
try:
step_enum = ImportStep(step) if step else None
except ValueError:
step_enum = None
failure = classify_import_failure(step_enum)
status = map_state_to_status(ImportState.ERROR, step_enum)
code, reason = failure.code, failure.reason
verdict = {
'status': status.value,
'code': code.value if code else None,
'reason': reason,
'step': str(step) if step else None,
}
self._execute_status_write(
_TERMINAL_SQL,
{
'status': status.value,
'error_message': reason,
'updated_at': self._timestamp(),
'detail': build_import_detail(
workflow_id=input_data['workflow_id'],
step=step,
code=code,
gate=input_data.get('gate'),
source=input_data.get('source'),
cleanup_failed=cleanup_failed,
),
'import_run_id': input_data['import_run_id'],
},
)
if succeeded:
await self.emit_metric(
metric_object=metrics.MODEL_IMPORT_COMPLETED_COUNT,
tags=self.get_core_labels(metadata, operation_type=status.value),
)
self.info(f'import {input_data["import_run_id"]} recorded as {status.value}', metadata)
return verdict
@activity.defn(name='report_import_status_write_failure')
async def report_import_status_write_failure(
self, input_data: dict[str, Any]
) -> dict[str, Any]:
"""Report a status write that could not be made — the one failure with no row to land on.
Called by the workflow when a progress or terminal write exhausted its attempts or was
refused. It carries the verdict it could not write, so the outcome survives in text even
when it cannot survive in the row, and it never turns a completed import into a failed one.
Args (input_data):
metadata (dict): workflow metadata.
import_run_id (int): the record's primary key.
workflow_id (str): the identifier that correlates this line with the record.
detail (str): the underlying failure, as text — it crossed an activity boundary, so the
exception object itself is gone by now.
verdict (str, optional): the verdict the terminal write could not record.
"""
await report_import_failure(
self,
None,
None,
import_run_id=input_data['import_run_id'],
workflow_id=input_data['workflow_id'],
metadata=input_data.get('metadata', {}),
code=ImportErrorCode.STATUS_NOT_RECORDED,
marker='status_write',
verdict=input_data.get('verdict'),
detail_text=input_data.get('detail'),
)
return {'reported': True}
@activity.defn(name='report_import_pipeline_failure')
async def report_import_pipeline_failure(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Report the failure that ended the import — the one every step shares.
Until this existed, a failure at `download` … `model_document` reached the observability
surface only as whatever the Temporal SDK printed for the failing attempt: a bare traceback,
with no `import_run_id`, no step, no code, and no pod or runtime label, so it could not be
joined to the record it failed. It also never incremented the import error metric and never
notified, which the two failures that *are* reported (a row that cannot be claimed, a verdict
that cannot be written) always did.
The exception object is gone by the time the workflow gets here — it crossed an activity
boundary — so the failure travels as text and its type as a name, and the classification comes
from the step, exactly as the record's does. Reporting is never a reason to fail: an
unrecognised step is reported as unclassified rather than raising over a spelling.
Args (input_data):
metadata (dict): workflow metadata.
import_run_id (int): the record's primary key.
workflow_id (str): the identifier a user quotes to support.
step (str, optional): the step that failed. Absent means unclassified.
gate (int, optional): the gate that rejected the bundle, when one did.
detail (str): the failure as text, root cause first.
error_type (str, optional): the exception type name that travelled with it.
"""
raw_step = input_data.get('step')
try:
step = ImportStep(raw_step) if raw_step else None
except ValueError:
step = None
await report_import_failure(
self,
step,
None,
import_run_id=input_data.get('import_run_id'),
workflow_id=input_data['workflow_id'],
metadata=input_data.get('metadata', {}),
marker=str(raw_step) if raw_step else None,
detail_text=input_data.get('detail'),
error_type=input_data.get('error_type'),
gate=input_data.get('gate'),
)
return {'reported': True}
# ------------------------------------------------------------- bundle helpers
def _bundle_reader(self) -> BundleReader:
"""A reader carrying the configured ceilings."""
return BundleReader(
BundleLimits(
max_object_bytes=self.import_config['max_object_bytes'],
max_entries=self.import_config['max_entries'],
max_uncompressed_bytes=self.import_config['max_uncompressed_bytes'],
max_compression_ratio=self.import_config['max_compression_ratio'],
object_prefix=self.import_config['prefix'],
)
)
def _work_dir(self, workflow_id: str) -> Path:
"""This import's own scratch directory. One per workflow, never shared."""
return Path(self.import_config['work_dir']) / workflow_id
def _effective_bucket(self, requested: str | None) -> str:
return requested or self.import_config['bucket']
# --------------------------------------------------------- download and open
async def _download_bundle(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Gate 1, then the download, then the mandatory digest comparison.
The object's size and key shape are checked from `stat_object`, before its body is fetched.
The SHA-256 of what was downloaded is then compared against `expected_digest` — the value the
uploader computed over the bytes it uploaded — with **no path that skips the comparison**,
because a missing value was already rejected at `received`. A mismatch fails here, before the
header is parsed: it means the object behind that key is not the file the user chose.
Args (input_data):
metadata (dict): workflow metadata.
workflow_id (str): names this import's scratch directory.
bucket (str, optional): the effective bucket; the configured one when absent.
object_key (str): the uploaded object's key.
expected_digest (str): the uploader's SHA-256, lowercase hex.
Returns:
dict: `path`, `size` and `digest` of the downloaded file.
Raises:
ImportGateRejectedError: gate 1 rejected the object, or the digests disagree.
"""
if self.import_minio_repository is None:
raise ImportMisconfiguredError(_MISSING_MINIO)
metadata = input_data.get('metadata', {})
bucket = self._effective_bucket(input_data.get('bucket'))
object_key = input_data['object_key']
expected_digest = input_data['expected_digest']
reader = self._bundle_reader()
client = self.import_minio_repository.minio_client
try:
stat = client.stat_object(bucket, object_key)
except Exception as error:
raise ImportGateRejectedError(
'the uploaded object could not be read', step=ImportStep.DOWNLOAD, gate=1
) from error
try:
reader.check_object_limits(size=stat.size or 0, key=object_key)
except BundleGateError as error:
raise ImportGateRejectedError(str(error), step=error.step, gate=error.gate) from error
work_dir = self._work_dir(input_data['workflow_id'])
work_dir.mkdir(parents=True, exist_ok=True)
target = work_dir / 'bundle.sientia'
try:
client.fget_object(bucket, object_key, str(target))
except Exception as error:
raise ImportGateRejectedError(
'the uploaded object could not be downloaded', step=ImportStep.DOWNLOAD, gate=1
) from error
digest = fmt.file_digest(target)
if digest != str(expected_digest).lower():
target.unlink(missing_ok=True)
raise ImportGateRejectedError(
'the downloaded object does not match the digest the uploader computed',
step=ImportStep.DOWNLOAD,
gate=1,
)
self.info(f'import bundle downloaded from {bucket} ({stat.size} bytes)', metadata)
return {'path': str(target), 'size': stat.size, 'digest': digest}
async def _open_bundle(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Open the envelope, then the bundle: gates 2 to 7, in the reader's own order.
The password envelope is decrypted **here**, inside the activity that uses the password, so
no plaintext password is ever an activity input, an activity result or a workflow variable —
and therefore never lands in the Temporal event history. The recovered password is a local
value and nothing else.
Args (input_data):
metadata (dict): workflow metadata.
workflow_id (str): names this import's scratch directory.
path (str): the downloaded bundle.
password_envelope (str): base64 `nonce || ciphertext || tag`, AES-256-GCM.
Returns:
dict: `extracted_dir`, `metadata`, `parameters`, `digest` — nothing secret.
Raises:
ImportGateRejectedError: with the step and the gate that rejected the bundle.
"""
metadata = input_data.get('metadata', {})
work_dir = self._work_dir(input_data['workflow_id'])
extract_root = work_dir / 'extract'
try:
key = load_envelope_key(self.import_config.get('password_key'))
password = decrypt_password_envelope(input_data['password_envelope'], key)
except EnvelopeError as error:
# A distinct failure from a wrong *bundle* password, and never confused with it: the
# envelope is the platform's own transport, and its message says so. Same step, because
# the step is where the import stopped, not who is at fault.
raise ImportGateRejectedError(
'the import password envelope could not be opened',
step=ImportStep.DECRYPTION,
gate=3,
cause='envelope',
) from error
reader = self._bundle_reader()
try:
opened = reader.open(input_data['path'], password, extract_root, work_dir)
except BundleGateError as error:
raise ImportGateRejectedError(str(error), step=error.step, gate=error.gate) from error
finally:
del password
await self.emit_metric(
metric_object=metrics.MODEL_IMPORT_KDF_DURATION,
tags=self.get_core_labels(metadata, operation_type=ImportStep.DECRYPTION.value),
method='observe',
value=opened.kdf_seconds,
)
self.info(f'import bundle opened into {opened.extracted_dir}', metadata)
return {
'extracted_dir': str(opened.extracted_dir),
'metadata': opened.metadata,
'parameters': opened.parameters,
'digest': opened.digest,
}
# ------------------------------------------------------------ MLflow provisioning
async def _create_experiment(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Get or create the experiment named by the bundle, keeping the origin's name.
Args (input_data):
metadata (dict): workflow metadata.
experiment_name (str): the bundle's `metadata.experiment_name`.
Returns:
dict: `experiment_id` and `experiment_name`.
"""
if self.import_mlflow_repository is None:
raise ImportMisconfiguredError(_MISSING_MLFLOW)
experiment_name = input_data['experiment_name']
experiment = self.import_mlflow_repository.get_experiment(
experiment_name, create_if_not_exists=True
)
self.info(
f'import experiment ready: {experiment_name} ({experiment.experiment_id})',
input_data.get('metadata', {}),
)
return {'experiment_id': experiment.experiment_id, 'experiment_name': experiment_name}
async def _upload_artifacts(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Create a new run with the recorded name and log the bundle's tree and parameters.
Always a new run — the importer never appends to an existing one. The run is created with
exactly the `run_name` the fused activity computed, and the workflow records that same value
onto the row right after the fused activity returns, so the two cannot diverge. The artifact
tree is logged verbatim, keeping `prediction_model` and `data_model` at the run's artifact
root, which is what the legacy loader resolves. The bundle's `parameters` are logged
unchanged, and the origin's own identity is logged alongside them under `source.*` — records
of where the model came from, not links.
Args (input_data):
metadata (dict): workflow metadata.
experiment_id (str): the experiment to create the run in.
run_name (str): the name to create the run with.
extracted_dir (str): the opened bundle's directory.
parameters (dict): the bundle's `parameters` block, verbatim.
source (dict): the bundle's `metadata` block, logged as `source.*`.
Returns:
dict: `run_id` and `run_name`.
"""
if self.import_mlflow_repository is None:
raise ImportMisconfiguredError(_MISSING_MLFLOW)
client = self.import_mlflow_repository.client
run = client.create_run(
experiment_id=input_data['experiment_id'], run_name=input_data['run_name']
)
run_id = run.info.run_id
artifacts_dir = Path(input_data['extracted_dir']) / 'artifacts'
client.log_artifacts(run_id, str(artifacts_dir))
params = [
Param(name, str(value)) for name, value in (input_data.get('parameters') or {}).items()
]
params += [
Param(f'source.{name}', str(value))
for name, value in (input_data.get('source') or {}).items()
]
for i in range(0, len(params), 100): # MLflow's log_batch limit is 100 params per call
client.log_batch(run_id, params=params[i : i + 100])
client.set_terminated(run_id, 'FINISHED')
self.info(f'import artifacts uploaded to run {run_id}', input_data.get('metadata', {}))
return {'run_id': run_id, 'run_name': input_data['run_name']}
@activity.defn(name='prepare_and_upload_import_bundle')
async def prepare_and_upload_import_bundle(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Download, decrypt, open, create the experiment, and upload artifacts — one disk, one try.
`_download_bundle`, `_open_bundle`, `_create_experiment` and `_upload_artifacts` used to be
four separately-schedulable Temporal activities sharing one worker-local scratch directory,
with nothing guaranteeing they landed on the same worker process — a real hazard once the
import worker runs more than one replica, since a later phase reading a file an earlier
phase wrote on a different pod fails with `FileNotFoundError`. Fusing them into one activity
means every disk write and every disk read this import makes happens inside one attempt, on
one process.
The run name is computed here, from the bundle's own `model_name`/`model_version`, because it
is needed to create the run — the workflow cannot compute it beforehand, since it does not
learn the bundle's metadata until this activity returns. The workflow records that same
`run_name` onto the row immediately after this activity returns (before the registered
version and the model document are written), rather than before the run is created, which is
the narrowed guarantee `model-import-status-record`'s names requirement now makes.
A retry of this activity redoes all four phases from the start. Re-uploading the same
artifacts to the same run is safe — MLflow overwrites identical content rather than
duplicating it — so the cost of a retry is bandwidth and time, not corruption.
Args (input_data):
metadata (dict): workflow metadata.
workflow_id (str): names this import's scratch directory.
bucket (str, optional): the effective bucket; the configured one when absent.
object_key (str): the uploaded object's key.
expected_digest (str): the uploader's SHA-256, lowercase hex.
password_envelope (str): base64 `nonce || ciphertext || tag`, AES-256-GCM.
Returns:
dict: `extracted_dir`, `metadata` (the bundle's own, i.e. `source`), `parameters`,
`digest`, `experiment_id`, `experiment_name`, `run_id`, `run_name`.
"""
downloaded = await self._download_bundle(input_data)
opened = await self._open_bundle({**input_data, **downloaded})
source = opened['metadata']
run_name = derive_run_name(source['model_name'], source['model_version'])
try:
experiment = await self._create_experiment(
{**input_data, 'experiment_name': source['experiment_name']}
)
except ImportMisconfiguredError:
raise
except Exception as error:
# Raw MLflow/network failures carry no location of their own (unlike the gates, which
# raise `ImportGateRejectedError`), so without this the workflow's fallback step —
# whatever it was attempting before this fused activity started — misclassifies the
# failure. Retryable: this is a broken dependency, not a rejected bundle.
raise ApplicationError(
str(error), {'step': ImportStep.EXPERIMENT_CREATION.value}
) from error
try:
run = await self._upload_artifacts(
{
**input_data,
**opened,
**experiment,
'run_name': run_name,
'source': source,
}
)
except ImportMisconfiguredError:
raise
except Exception as error:
raise ApplicationError(
str(error), {'step': ImportStep.ARTIFACT_UPLOAD.value}
) from error
return {**opened, **experiment, **run}
@activity.defn(name='register_import_model_version')
async def register_import_model_version(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Register a version whose source the legacy loader can parse. No stage change.
The source is the run's **resolved artifact URI** with `prediction_model` appended —
`mlflow-artifacts:/<experiment_id>/<run_id>/artifacts/prediction_model` on this deployment —
because `get_model_run_id` extracts the run id as `source.split('/')[2]`. That index is the
run id only in the resolved form: a literal `runs:/<run_id>/prediction_model` has just two
slashes, so index 2 is the string `prediction_model`, and a `models:/m-*` source (what the
new stack emits) has none of the parts at all. This is the same value the training flow
stores, because `mlflow.register_model` resolves the `runs:/` URI before writing it
(`model_repository.py`, `register_model`) — `create_model_version` does not.
The stage is left untouched: a `COMPLETED` import is a registered model, not a servable one,
and promoting it to `Production` is a manual operator step outside this workflow.
Args (input_data):
metadata (dict): workflow metadata.
model_name (str): the bundle's `metadata.model_name`, verbatim.
run_id (str): the run this import created.
Returns:
dict: `model_name`, `version` and `source`.
"""
if self.import_mlflow_repository is None:
raise ImportMisconfiguredError(_MISSING_MLFLOW)
client = self.import_mlflow_repository.client
model_name = input_data['model_name']
run_id = input_data['run_id']
try:
client.get_registered_model(model_name)
except MlflowException as error:
if error.error_code != ErrorCode.Name(RESOURCE_DOES_NOT_EXIST):
raise
# First import of this name: the registered model is created on demand. A second import
# lands as a new version of the same registered model, never a differently named one.
client.create_registered_model(model_name)
source = f'{str(client.get_run(run_id).info.artifact_uri).rstrip("/")}/prediction_model'
version = client.create_model_version(name=model_name, source=source, run_id=run_id)
self.info(
f'import registered {model_name} version {version.version}',
input_data.get('metadata', {}),
)
return {'model_name': model_name, 'version': str(version.version), 'source': source}
# --------------------------------------------------- the MongoDB model document
@activity.defn(name='write_import_model_document')
async def write_import_model_document(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Write the document that *is* the model listing, inert, last.
This is what the runtime reads to know how to load a model — `model_config.transform_flavor`,
`model_config.predict_flavor` and `model_config.target` — so without it an imported model is
invisible to every workflow. `id` continues the collection's own numbering, assigned by an
atomic counter (`_next_model_document_id`) rather than a scan of existing ids, so two
concurrent imports can never collide on the same id. A name already in the collection is left
exactly as it is: `active`, `target` and the flavors of a model already on the platform are
operator-owned.
Args (input_data):
metadata (dict): workflow metadata.
model_name (str): the bundle's `metadata.model_name`, verbatim.
target (str): the bundle's `parameters.target_variable`.
Returns:
dict: `written`, `id`, `skipped` and `ignored_ids` (always `0`, kept for shape
compatibility — no id is skipped under the counter).
"""
if self.mongo_repository is None:
raise ImportMisconfiguredError(_MISSING_MONGO)
metadata = input_data.get('metadata', {})
collection = self.import_config['models_collection']
model_name = input_data['model_name']
existing = await self.mongo_repository.find_one(collection, {'name': model_name}, metadata)
if existing is not None:
self.info(
f'model document for {model_name} already exists; leaving it untouched', metadata
)
return {'written': False, 'skipped': True, 'id': existing.get('id')}
next_id, ignored = await self._next_model_document_id(collection, metadata)
document = {
'id': next_id,
'name': model_name,
'active': False,
'model_config': {
'transform_flavor': 'sklearn',
'predict_flavor': 'joblib',
'retention_minutes': 60,
'target': input_data['target'],
'skip_predict_timestamp': True,
},
}
await self.mongo_repository.insert(collection, document, metadata)
self.info(f'model document written for {model_name} with id {next_id}', metadata)
return {'written': True, 'skipped': False, 'id': next_id, 'ignored_ids': ignored}
async def _next_model_document_id(
self, collection: str, metadata: dict[str, Any]
) -> tuple[str, int]:
"""Atomically increment a counter dedicated to `collection`, and return its new value.
Reaches MongoDB through the repository's own public `database` handle — the same access
pattern `find_one`/`distinct` already use internally — with pymongo's native
`find_one_and_update`, rather than the max-scan this replaces. That scan raced: two imports
writing at the same time could both read the same maximum and collide on the same id. An
atomic `$inc` cannot.
The counter's backing collection (`import_config['counters_collection']`) is an external
dependency: it must be created and seeded by a migration before this runs against a given
environment (see `openspec/changes/model-import-fix-bugs/design.md`, Decision 3). This code
does not create or seed it.
Assumes `self.mongo_repository` is not `None` — the only caller, `write_import_model_document`,
already raises before calling this when it is.
"""
counters = self.mongo_repository.database[ # type: ignore[union-attr]
self.import_config['counters_collection']
]
doc = counters.find_one_and_update(
{'_id': collection},
{'$inc': {'seq': 1}},
upsert=True,
return_document=ReturnDocument.AFTER,
)
return str(doc['seq']), 0 # type: ignore[index]
# ----------------------------------------------------------- cleanup and retention
@activity.defn(name='cleanup_import_files')
async def cleanup_import_files(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Remove this import's scratch directory. Never the uploaded object.
Runs on every path, success or failure, and **never raises**: a cleanup failure is reported
through the channel and noted in the record's detail, but the user's model was imported and
reporting an error would be false.
Args (input_data):
metadata (dict): workflow metadata.
workflow_id (str): names the directory to remove.
import_run_id (int): for correlation when the removal fails.
Returns:
dict: `cleaned` and, when it failed, the reason as text.
"""
metadata = input_data.get('metadata', {})
work_dir = self._work_dir(input_data['workflow_id'])
try:
shutil.rmtree(work_dir, ignore_errors=False)
except FileNotFoundError:
# Nothing was downloaded, or a gate removed its own partial tree. Not a failure.
return {'cleaned': True, 'existed': False}
except OSError as error:
await report_import_failure(
self,
ImportStep.CLEANUP,
error,
import_run_id=input_data.get('import_run_id'),
workflow_id=input_data['workflow_id'],
metadata=metadata,
code=ImportErrorCode.CLEANUP_INCOMPLETE,
marker=ImportStep.CLEANUP.value,
)
return {'cleaned': False, 'existed': True, 'reason': type(error).__name__}
return {'cleaned': True, 'existed': True}
@activity.defn(name='ensure_import_bundle_retention')
async def ensure_import_bundle_retention(self, input_data: dict[str, Any]) -> dict[str, Any]:
"""Ensure the 7-day expiry rule on the import prefix, idempotently.
Applied by the importer rather than assumed from a Helm value, so the acceptance criterion
("lifecycle active") is verifiable from the code that depends on it. The uploaded object is
never deleted by the import itself — it is left to this rule, as the record of an attempt.
See also: `Storage.cleanup_minio_objects_expired` (laborious/activities/storage.py) — an
older, unrelated scan-and-delete expiry mechanism for a different prefix family. The two are
independent by design for now; see docs/minio-object-expiry.md.
Args (input_data):
metadata (dict): workflow metadata.
bucket (str, optional): the effective bucket; the configured one when absent.
Returns:
dict: whether the rule was already there and what it expires.
"""
if self.import_minio_repository is None:
raise ImportMisconfiguredError(_MISSING_MINIO)
metadata = input_data.get('metadata', {})
bucket = self._effective_bucket(input_data.get('bucket'))
prefix = self.import_config['prefix']
days = self.import_config['retention_days']
rule_id = 'sientia-import-bundle-expiry'
client = self.import_minio_repository.minio_client
try:
existing = client.get_bucket_lifecycle(bucket)
except Exception:
# A bucket with no lifecycle configuration at all answers with an error on some
# deployments and with `None` on others; both mean "there is no rule yet".
existing = None
existing_rule = next(
(rule for rule in (existing.rules if existing else []) if rule.rule_id == rule_id), None
)
if (
existing_rule is not None
and existing_rule.status == ENABLED
and getattr(existing_rule.expiration, 'days', None) == days
and getattr(existing_rule.rule_filter, 'prefix', None) == prefix
):
# The read still runs every time (it is how drift — someone editing the rule outside
# this code — gets detected); only the write is skipped when nothing has drifted.
self.info(f'import retention rule already correct on {bucket} ({days} days)', metadata)
return {
'bucket': bucket,
'prefix': prefix,
'days': days,
'rules': len(existing.rules), # type: ignore[union-attr]
}
rules = [rule for rule in (existing.rules if existing else []) if rule.rule_id != rule_id]
rules.append(
Rule(
ENABLED,
rule_filter=Filter(prefix=prefix),
rule_id=rule_id,
expiration=Expiration(days=days),
)
)
client.set_bucket_lifecycle(bucket, LifecycleConfig(rules))
self.info(f'import retention rule ensured on {bucket} ({days} days)', metadata)
return {'bucket': bucket, 'prefix': prefix, 'days': days, 'rules': len(rules)}