QTZPOC-20

QTZPOC-20: Improve structured logging and reporting for all model import pipeline failures by introducing a dedicated activity and centralizing error classification. This ensures consistent observability, with all workflow failures now reported before cleanup. Also, adjusted import step timeouts and refined manual run script cleanup logic.
This commit is contained in:
2026-08-27 14:44:57 -03:00
parent e95e65b72b
commit 853c83e662
18 changed files with 857 additions and 71 deletions

View File

@@ -24,8 +24,8 @@ from pytest import fixture, mark, raises
from laborious import metrics
from laborious.activities.model_import import ModelImport
from laborious.activities.model_import_errors import ImportGateRejectedError
from laborious.utils.bundle.steps import ImportStep
from laborious.utils.models.import_errors import ImportGateRejectedError
from tests.helpers import bundle_factory as factory
from tests.helpers.status_engine import FakeStatusEngine

View File

@@ -32,12 +32,12 @@ from laborious.activities.model_import import (
derive_run_name,
map_state_to_status,
)
from laborious.activities.model_import_errors import (
from laborious.utils.bundle.steps import ImportStep
from laborious.utils.models.import_errors import (
ImportErrorCode,
ImportLogRowNotClaimableError,
ImportStatusRejectedError,
)
from laborious.utils.bundle.steps import ImportStep
from tests.helpers.status_engine import FakeStatusEngine
STATUS_CONFIG = {
@@ -747,6 +747,78 @@ async def test_a_verdict_that_cannot_be_written_is_reported_with_the_verdict():
assert 'connection refused' in logged
@mark.asyncio
async def test_the_failure_that_ended_the_import_is_reported_with_its_step_and_its_code():
"""Before this activity existed, an MLflow that refused the connection reached the logs only as
whatever the Temporal SDK printed for the attempt: a traceback with no id, no step and no code."""
engine = FakeStatusEngine()
activity = build_activity(engine)
result = await activity.report_import_pipeline_failure(
{
**claim_input(),
'step': ImportStep.EXPERIMENT_CREATION.value,
'gate': None,
'detail': 'MlflowException: API request to http://mlflow:5080 failed: refused',
'error_type': 'MlflowException',
}
)
assert result == {'reported': True}
# The row is written by the terminal write, not by the reporting of a failure.
assert engine.statements == []
message, fields = activity.logger.custom_error.call_args_list[0].args
assert 'model import failed' in message
assert 'API request to http://mlflow:5080' in message
assert fields['import_step'] == ImportStep.EXPERIMENT_CREATION.value
assert fields['import_code'] == ImportErrorCode.STORAGE_PREPARATION_FAILED.value
assert fields['error_type'] == 'MlflowException'
assert fields['import_run_id'] == 77
assert fields['import_workflow_id'] == 'import_model-77-abc'
activity.emit_metric.assert_awaited_once()
assert (
activity.send_notification_async.await_args.kwargs['notification_id']
== ImportErrorCode.STORAGE_PREPARATION_FAILED.value
)
@mark.asyncio
async def test_a_gate_that_rejected_the_bundle_is_a_field_on_the_report():
engine = FakeStatusEngine()
activity = build_activity(engine)
await activity.report_import_pipeline_failure(
{
**claim_input(),
'step': ImportStep.DECRYPTION.value,
'gate': 3,
'detail': 'ImportGateRejectedError: gate 3 rejected the bundle',
'error_type': 'ImportGateRejectedError',
}
)
_, fields = activity.logger.custom_error.call_args_list[0].args
assert fields['import_gate'] == 3
assert fields['import_code'] == ImportErrorCode.BUNDLE_CANNOT_BE_OPENED.value
@mark.asyncio
async def test_a_step_this_module_does_not_know_is_reported_rather_than_raised():
"""Reporting a failure is never a reason to fail: the raw step still reaches the log line."""
engine = FakeStatusEngine()
activity = build_activity(engine)
result = await activity.report_import_pipeline_failure(
{**claim_input(), 'step': 'provisioning', 'detail': 'RuntimeError: boom'}
)
assert result == {'reported': True}
_, fields = activity.logger.custom_error.call_args_list[0].args
assert fields['import_step'] == 'provisioning'
assert fields['import_code'] == ImportErrorCode.UNEXPECTED_ERROR.value
assert fields['error_type'] == 'unavailable'
@mark.asyncio
async def test_the_engine_is_disposed_on_shutdown(import_activity: ModelImport, engine):
import_activity.close()

View File

@@ -19,7 +19,7 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
WORKFLOW_MODULE = REPOSITORY_ROOT / 'laborious/workflows/import_model.py'
ACTIVITIES_MODULE = REPOSITORY_ROOT / 'laborious/activities/model_import.py'
ERRORS_MODULE = REPOSITORY_ROOT / 'laborious/activities/model_import_errors.py'
ERRORS_MODULE = REPOSITORY_ROOT / 'laborious/utils/models/import_errors.py'
READER_MODULE = REPOSITORY_ROOT / 'laborious/utils/bundle/reader.py'
FORMAT_MODULE = REPOSITORY_ROOT / 'laborious/utils/bundle/format.py'

View File

@@ -16,9 +16,11 @@ from pathlib import Path
from typing import Any
from pytest import mark, raises
from temporalio.exceptions import ApplicationError
from laborious import metrics
from laborious.activities.model_import_errors import (
from laborious.utils.bundle.steps import GATE_TO_STEP, ImportStep
from laborious.utils.models.import_errors import (
IMPORT_FAILURE_CATALOG,
STEPS_WITHOUT_A_USER_FACING_REASON,
UNCLASSIFIED_FAILURE,
@@ -28,12 +30,13 @@ from laborious.activities.model_import_errors import (
ImportLogRowNotClaimableError,
ImportStatusRejectedError,
classify_import_failure,
describe_failure_chain,
failure_location,
report_import_failure,
summarize_failure,
)
from laborious.utils.bundle.steps import GATE_TO_STEP, ImportStep
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
REPOSITORY_ROOT = Path(__file__).resolve().parents[4]
# The modules that make up the import path. The channel lives in the third one; the other two may
# not report a failure of their own.
@@ -359,6 +362,180 @@ async def test_an_explicit_code_keeps_the_step_s_sentence():
assert sinks.notifications[0]['level'] == 'ERROR'
# --------------------------------------------- the fields the two log lines carry
#
# The message text is for a person reading a log; these fields are for the query that finds it. Both
# lines the channel produces — the technical one and the user-facing sentence the notification logs —
# carry the same ones, which is what makes `import_run_id=30` return the pair.
def _wrapped_activity_failure() -> BaseException:
"""What an activity failure looks like by the time the channel sees it.
A `MlflowException` raised inside an activity reaches the workflow as an `ApplicationError` whose
`type` still names it, wrapped in the error Temporal raises for the failed activity. Built by hand
rather than through a real activity because what is under test is the unwrapping.
"""
root = ApplicationError(
'API request to http://localhost:5080/api/2.0/mlflow/experiments/get-by-name failed with '
'exception HTTPConnectionPool(host=localhost, port=5080): Connection refused',
type='MlflowException',
)
wrapper = RuntimeError('Activity task failed')
wrapper.__cause__ = root
return wrapper
@mark.asyncio
async def test_the_log_line_carries_the_ids_the_step_and_the_code_as_fields():
sinks = FakeSinks()
await report_import_failure(
sinks,
ImportStep.EXPERIMENT_CREATION,
_wrapped_activity_failure(),
import_run_id=30,
workflow_id='manual-import-30',
metadata={'model_name': '-', 'model_id': '30'},
)
_, fields = sinks.logged[0]
assert fields['import_run_id'] == 30
assert fields['import_workflow_id'] == 'manual-import-30'
assert fields['import_step'] == ImportStep.EXPERIMENT_CREATION.value
assert fields['import_code'] == ImportErrorCode.STORAGE_PREPARATION_FAILED.value
# The workflow metadata every other line already carries is kept, not replaced.
assert fields['model_id'] == '30'
@mark.asyncio
async def test_the_fields_name_the_root_cause_not_the_wrapper():
"""`error_type=ApplicationError` would be true of every activity failure there is."""
sinks = FakeSinks()
await report_import_failure(
sinks,
ImportStep.EXPERIMENT_CREATION,
_wrapped_activity_failure(),
import_run_id=30,
workflow_id='manual-import-30',
)
_, fields = sinks.logged[0]
assert fields['error_type'] == 'MlflowException'
assert 'Connection refused' in fields['error_summary']
assert '\n' not in fields['error_summary']
@mark.asyncio
async def test_the_user_facing_line_is_greppable_by_the_same_fields():
"""The sentence a person reads said nothing technical, and nothing said where to look."""
sinks = FakeSinks()
await report_import_failure(
sinks,
None,
ImportLogRowNotClaimableError('row 29 is in status IMPORT_ERROR', import_run_id=29),
import_run_id=29,
workflow_id='manual-import-29',
code=ImportErrorCode.LOG_ROW_NOT_CLAIMABLE,
marker='not_claimable',
)
notified = sinks.notifications[0]
assert notified['message'] == UNCLASSIFIED_FAILURE[1]
assert notified['metadata']['import_code'] == ImportErrorCode.LOG_ROW_NOT_CLAIMABLE.value
assert notified['metadata']['import_step'] == 'not_claimable'
assert notified['metadata']['import_run_id'] == 29
assert notified['metadata']['error_type'] == 'ImportLogRowNotClaimableError'
assert notified['metadata'] == sinks.logged[0][1]
@mark.asyncio
async def test_the_gate_and_the_unrecorded_verdict_are_fields_when_there_are_any():
sinks = FakeSinks()
await report_import_failure(
sinks,
ImportStep.DECRYPTION,
ImportGateRejectedError('gate 3 rejected the bundle', step=ImportStep.DECRYPTION, gate=3),
import_run_id=77,
workflow_id='wf',
gate=3,
verdict='succeeded=False step=decryption gate=3',
)
_, fields = sinks.logged[0]
assert fields['import_gate'] == 3
assert fields['import_verdict_not_recorded'] == 'succeeded=False step=decryption gate=3'
@mark.asyncio
async def test_a_failure_with_neither_gate_nor_verdict_carries_neither_field():
"""An empty field is noise in every line that has nothing to say in it."""
sinks = FakeSinks()
await report_import_failure(
sinks, ImportStep.DOWNLOAD, RuntimeError('boom'), import_run_id=77, workflow_id='wf'
)
_, fields = sinks.logged[0]
assert 'import_gate' not in fields
assert 'import_verdict_not_recorded' not in fields
@mark.asyncio
async def test_no_field_can_shadow_the_formatter_s_own_keys():
sinks = FakeSinks()
await report_import_failure(
sinks, ImportStep.DOWNLOAD, RuntimeError('boom'), import_run_id=77, workflow_id='wf'
)
_, fields = sinks.logged[0]
added = set(fields) - {'model_name', 'model_id', 'workflow_name', 'schedule_name'}
assert all(key.startswith(('import_', 'error_')) for key in added)
assert not {'at', 'msg', 'timestamp'} & set(fields)
def test_the_chain_is_flattened_root_cause_first():
"""Because the first line is what becomes `error_summary`, and it has to be the cause."""
kind, detail = describe_failure_chain(_wrapped_activity_failure())
assert kind == 'MlflowException'
assert detail.splitlines()[0].startswith('MlflowException: API request to')
assert detail.splitlines()[-1] == 'RuntimeError: Activity task failed'
def test_a_cycle_in_the_chain_cannot_hang_the_reporting_of_a_failure():
first = RuntimeError('first')
second = RuntimeError('second')
first.__cause__ = second
second.__cause__ = first
_, detail = describe_failure_chain(first)
assert len(detail.splitlines()) <= 10
def test_a_failure_that_arrives_as_text_keeps_the_type_that_travelled_with_it():
kind, summary = summarize_failure(
None,
'MlflowException: connection refused\nRuntimeError: Activity task failed',
'MlflowException',
)
assert kind == 'MlflowException'
assert summary == 'MlflowException: connection refused'
def test_a_failure_that_arrives_as_nothing_says_so_rather_than_guessing():
kind, summary = summarize_failure(None, None, None)
assert kind == 'unavailable'
assert summary == ''
# ------------------------------------------------- the channel is the only reporter
@@ -392,7 +569,7 @@ def test_no_import_module_reports_a_failure_of_its_own(module_path: Path):
def test_the_channel_itself_is_where_the_reporting_lives():
channel = REPOSITORY_ROOT / 'laborious/activities/model_import_errors.py'
channel = REPOSITORY_ROOT / 'laborious/utils/models/import_errors.py'
calls = _reporting_calls(channel)

View File

@@ -109,6 +109,6 @@ def test_the_import_worker_registers_only_the_import_activities():
if isinstance(inner, ast.keyword) and inner.arg == 'other_workflows':
assert ast.unparse(inner.value) == '[]'
assert len(activities) == 13
assert len(activities) == 14
assert all(name.startswith('activities.') for name in activities)
assert all('import' in name for name in activities)

View File

@@ -15,18 +15,19 @@ from unittest.mock import MagicMock, patch
from pytest import fixture, mark, raises
from sientia_do.temporal.policies import retry_policy
from temporalio.exceptions import ApplicationError
from laborious.activities.activities import Activities
from laborious.activities.model_import import (
IMPORT_STATUS_HINT_RETRY_POLICY,
IMPORT_STATUS_RETRY_POLICY,
)
from laborious.activities.model_import_errors import (
from laborious.utils.bundle.steps import ImportStep
from laborious.utils.models.import_errors import (
ImportGateRejectedError,
ImportInputError,
ImportLogRowNotClaimableError,
)
from laborious.utils.bundle.steps import ImportStep
from laborious.workflows.import_model import (
REJECTED_INPUT_FIELDS,
STATUS_WRITE_ENVELOPE,
@@ -224,6 +225,24 @@ async def test_the_names_are_recorded_before_mlflow_is_touched(
assert names['run_name'] == 'import-factory_model-v2'
@mark.asyncio
async def test_the_model_is_named_from_the_bundles_model_name(
import_workflow: ImportModel, workflow_env
):
"""`metadata.model_name` names the model, verbatim, in both destinations.
The registered model and the model document take the bundle's own `model_name` and nothing else:
no other metadata field substitutes for it, and nothing is appended to it.
"""
await import_workflow.run(valid_input())
registration = workflow_env.recorder.payload_of(Activities.register_import_model_version)
document = workflow_env.recorder.payload_of(Activities.write_import_model_document)
assert registration['model_name'] == BUNDLE_METADATA['model_name']
assert document['model_name'] == BUNDLE_METADATA['model_name']
assert document['model_name'] == registration['model_name']
@mark.asyncio
async def test_the_run_is_created_with_exactly_the_recorded_name(
import_workflow: ImportModel, workflow_env
@@ -660,6 +679,168 @@ async def test_reporting_the_report_is_where_it_stops(import_workflow: ImportMod
assert result['run_id'] == RUN_ID
# --------------------------------------------------------------- the failure report
def activity_failure(message: str, kind: str) -> BaseException:
"""What a failed activity looks like from inside the workflow.
The activity's own exception type survives the boundary in `ApplicationError.type` and nowhere
else; the outer error is the one Temporal raises for the failed activity.
"""
wrapper = RuntimeError('Activity task failed')
wrapper.__cause__ = ApplicationError(message, type=kind)
return wrapper
@mark.asyncio
async def test_the_failure_that_ends_an_import_is_reported_through_the_channel(
import_workflow: ImportModel,
):
"""The gap this closes: a failure at any pipeline step used to reach the structured logs as
nothing at all — only the SDK's own traceback for the attempt, with no id and no step."""
recorder = Recorder(
failures={
Activities.create_import_experiment: activity_failure(
'API request to http://localhost:5080/api/2.0/mlflow/experiments/get-by-name '
'failed with exception Connection refused',
'MlflowException',
)
}
)
patcher, _ = run_with(recorder)
try:
with raises(RuntimeError, match='Activity task failed'):
await import_workflow.run(valid_input())
finally:
patcher.stop()
reported = recorder.payload_of(Activities.report_import_pipeline_failure)
assert reported['step'] == ImportStep.EXPERIMENT_CREATION.value
assert reported['error_type'] == 'MlflowException'
assert reported['detail'].splitlines()[0].startswith('MlflowException: API request to')
assert reported['import_run_id'] == 77
assert reported['workflow_id'] == WORKFLOW_ID
@mark.asyncio
async def test_the_report_comes_before_the_cleanup_and_the_terminal_write(
import_workflow: ImportModel,
):
"""So the log reads in the order things happened, and the row's step matches the line's."""
recorder = Recorder(
failures={Activities.upload_import_artifacts: activity_failure('bucket gone', 'S3Error')}
)
patcher, _ = run_with(recorder)
try:
with raises(RuntimeError):
await import_workflow.run(valid_input())
finally:
patcher.stop()
order = recorder.order
assert (
order.index(Activities.report_import_pipeline_failure)
< order.index(Activities.cleanup_import_files)
< order.index(Activities.record_import_terminal_status)
)
reported = recorder.payload_of(Activities.report_import_pipeline_failure)
terminal = recorder.payload_of(Activities.record_import_terminal_status)
assert reported['step'] == terminal['step'] == ImportStep.ARTIFACT_UPLOAD.value
@mark.asyncio
async def test_the_gate_that_rejected_the_bundle_travels_into_the_report(
import_workflow: ImportModel,
):
recorder = Recorder(
failures={
Activities.open_import_bundle: ImportGateRejectedError(
'gate 3 rejected the bundle', step=ImportStep.DECRYPTION, gate=3
)
}
)
patcher, _ = run_with(recorder)
try:
with raises(ImportGateRejectedError):
await import_workflow.run(valid_input())
finally:
patcher.stop()
reported = recorder.payload_of(Activities.report_import_pipeline_failure)
assert reported['step'] == ImportStep.DECRYPTION.value
assert reported['gate'] == 3
@mark.asyncio
async def test_a_claim_that_matches_nothing_is_not_reported_twice(import_workflow: ImportModel):
"""The claim reports itself, and it runs outside the try — so the channel is hit once."""
recorder = Recorder(
failures={
Activities.claim_import_status: ImportLogRowNotClaimableError(
'row 29 could not be claimed', import_run_id=29
)
}
)
patcher, _ = run_with(recorder)
try:
with raises(ImportLogRowNotClaimableError):
await import_workflow.run(valid_input())
finally:
patcher.stop()
assert Activities.report_import_pipeline_failure not in recorder.order
@mark.asyncio
async def test_a_report_that_cannot_be_made_does_not_mask_the_failure(
import_workflow: ImportModel,
):
recorder = Recorder(
failures={
Activities.write_import_model_document: activity_failure('mongo down', 'PyMongoError'),
Activities.report_import_pipeline_failure: RuntimeError('no channel'),
}
)
patcher, _ = run_with(recorder)
try:
with raises(RuntimeError, match='Activity task failed'):
await import_workflow.run(valid_input())
finally:
patcher.stop()
terminal = recorder.payload_of(Activities.record_import_terminal_status)
assert terminal['succeeded'] is False
assert terminal['step'] == ImportStep.MODEL_DOCUMENT.value
@mark.asyncio
async def test_a_successful_import_reports_no_failure(import_workflow: ImportModel, workflow_env):
await import_workflow.run(valid_input())
assert Activities.report_import_pipeline_failure not in workflow_env.recorder.order
@mark.asyncio
async def test_the_report_gives_up_inside_the_short_bound(import_workflow: ImportModel):
"""A failure already happened; spending 151 seconds telling the log about it would be worse."""
recorder = Recorder(
failures={
Activities.create_import_experiment: activity_failure('refused', 'MlflowException')
}
)
patcher, _ = run_with(recorder)
try:
with raises(RuntimeError):
await import_workflow.run(valid_input())
finally:
patcher.stop()
call = recorder.calls_to(Activities.report_import_pipeline_failure)[0]
assert call.kwargs['retry_policy'] is IMPORT_STATUS_HINT_RETRY_POLICY
assert call.kwargs['start_to_close_timeout'] == STATUS_WRITE_TIMEOUT
# ------------------------------------------------------------------- the retry split