flowsense-engine 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowsense/__init__.py +77 -0
- flowsense/application/__init__.py +20 -0
- flowsense/application/analyzer.py +68 -0
- flowsense/application/output.py +128 -0
- flowsense/application/pipeline.py +210 -0
- flowsense/application/ports.py +11 -0
- flowsense/application/request.py +32 -0
- flowsense/application/serialization.py +177 -0
- flowsense/cli/__init__.py +0 -0
- flowsense/cli/main.py +195 -0
- flowsense/cli/report.py +233 -0
- flowsense/collector/__init__.py +0 -0
- flowsense/collector/airflow_client.py +5 -0
- flowsense/config.py +72 -0
- flowsense/domain/__init__.py +52 -0
- flowsense/domain/enums.py +47 -0
- flowsense/domain/exceptions.py +25 -0
- flowsense/domain/models.py +19 -0
- flowsense/domain/policy.py +55 -0
- flowsense/domain/results.py +187 -0
- flowsense/engine/__init__.py +0 -0
- flowsense/engine/analyzer.py +17 -0
- flowsense/engine/change_point.py +87 -0
- flowsense/engine/drift.py +80 -0
- flowsense/engine/history.py +34 -0
- flowsense/engine/impact.py +51 -0
- flowsense/engine/propagation.py +173 -0
- flowsense/engine/root_cause.py +148 -0
- flowsense/engine/timing.py +221 -0
- flowsense/engine/trend.py +82 -0
- flowsense/infrastructure/__init__.py +1 -0
- flowsense/infrastructure/airflow/__init__.py +13 -0
- flowsense/infrastructure/airflow/client.py +373 -0
- flowsense/infrastructure/airflow/dto.py +33 -0
- flowsense/infrastructure/airflow/exceptions.py +31 -0
- flowsense/infrastructure/airflow/mapper.py +27 -0
- flowsense/mcp/__init__.py +0 -0
- flowsense/mcp/server.py +75 -0
- flowsense/models/__init__.py +7 -0
- flowsense/models/dag_analysis.py +3 -0
- flowsense/models/task_run.py +3 -0
- flowsense/py.typed +0 -0
- flowsense/version.py +6 -0
- flowsense_engine-0.2.1.dist-info/METADATA +447 -0
- flowsense_engine-0.2.1.dist-info/RECORD +49 -0
- flowsense_engine-0.2.1.dist-info/WHEEL +5 -0
- flowsense_engine-0.2.1.dist-info/entry_points.txt +3 -0
- flowsense_engine-0.2.1.dist-info/licenses/LICENSE +17 -0
- flowsense_engine-0.2.1.dist-info/top_level.txt +1 -0
flowsense/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from flowsense.application import (
|
|
2
|
+
ANALYSIS_SCHEMA_VERSION,
|
|
3
|
+
AnalysisDocument,
|
|
4
|
+
AnalysisRequest,
|
|
5
|
+
DAGDataSource,
|
|
6
|
+
analysis_json_schema,
|
|
7
|
+
analyze_dag,
|
|
8
|
+
build_analysis_document,
|
|
9
|
+
serialize_analysis,
|
|
10
|
+
)
|
|
11
|
+
from flowsense.domain import (
|
|
12
|
+
DEFAULT_ANALYSIS_POLICY,
|
|
13
|
+
AnalysisDiagnostic,
|
|
14
|
+
AnalysisPolicy,
|
|
15
|
+
ChangeDirection,
|
|
16
|
+
ChangePointResult,
|
|
17
|
+
ConfigurationError,
|
|
18
|
+
DAGAnalysis,
|
|
19
|
+
DAGAnalysisSummary,
|
|
20
|
+
DriftResult,
|
|
21
|
+
FlowSenseError,
|
|
22
|
+
ImpactClassification,
|
|
23
|
+
InsufficientHistoryError,
|
|
24
|
+
InvalidTaskTimingError,
|
|
25
|
+
MappedTaskAggregation,
|
|
26
|
+
PropagationResult,
|
|
27
|
+
RootCauseResult,
|
|
28
|
+
Severity,
|
|
29
|
+
TaskImpact,
|
|
30
|
+
TaskRun,
|
|
31
|
+
TrendDirection,
|
|
32
|
+
TrendResult,
|
|
33
|
+
)
|
|
34
|
+
from flowsense.infrastructure.airflow import (
|
|
35
|
+
AirflowApiError,
|
|
36
|
+
AirflowClient,
|
|
37
|
+
AirflowDagRunNotFoundError,
|
|
38
|
+
AirflowDataError,
|
|
39
|
+
)
|
|
40
|
+
from flowsense.version import __version__
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"ANALYSIS_SCHEMA_VERSION",
|
|
44
|
+
"DEFAULT_ANALYSIS_POLICY",
|
|
45
|
+
"AirflowApiError",
|
|
46
|
+
"AirflowClient",
|
|
47
|
+
"AirflowDagRunNotFoundError",
|
|
48
|
+
"AirflowDataError",
|
|
49
|
+
"AnalysisDiagnostic",
|
|
50
|
+
"AnalysisDocument",
|
|
51
|
+
"AnalysisPolicy",
|
|
52
|
+
"AnalysisRequest",
|
|
53
|
+
"ChangeDirection",
|
|
54
|
+
"ChangePointResult",
|
|
55
|
+
"ConfigurationError",
|
|
56
|
+
"DAGAnalysis",
|
|
57
|
+
"DAGAnalysisSummary",
|
|
58
|
+
"DAGDataSource",
|
|
59
|
+
"DriftResult",
|
|
60
|
+
"FlowSenseError",
|
|
61
|
+
"ImpactClassification",
|
|
62
|
+
"InsufficientHistoryError",
|
|
63
|
+
"InvalidTaskTimingError",
|
|
64
|
+
"MappedTaskAggregation",
|
|
65
|
+
"PropagationResult",
|
|
66
|
+
"RootCauseResult",
|
|
67
|
+
"Severity",
|
|
68
|
+
"TaskImpact",
|
|
69
|
+
"TaskRun",
|
|
70
|
+
"TrendDirection",
|
|
71
|
+
"TrendResult",
|
|
72
|
+
"__version__",
|
|
73
|
+
"analysis_json_schema",
|
|
74
|
+
"analyze_dag",
|
|
75
|
+
"build_analysis_document",
|
|
76
|
+
"serialize_analysis",
|
|
77
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from flowsense.application.analyzer import analyze_dag
|
|
2
|
+
from flowsense.application.output import ANALYSIS_SCHEMA_VERSION, AnalysisDocument
|
|
3
|
+
from flowsense.application.ports import DAGDataSource
|
|
4
|
+
from flowsense.application.request import AnalysisRequest
|
|
5
|
+
from flowsense.application.serialization import (
|
|
6
|
+
analysis_json_schema,
|
|
7
|
+
build_analysis_document,
|
|
8
|
+
serialize_analysis,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ANALYSIS_SCHEMA_VERSION",
|
|
13
|
+
"AnalysisDocument",
|
|
14
|
+
"AnalysisRequest",
|
|
15
|
+
"DAGDataSource",
|
|
16
|
+
"analysis_json_schema",
|
|
17
|
+
"analyze_dag",
|
|
18
|
+
"build_analysis_document",
|
|
19
|
+
"serialize_analysis",
|
|
20
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from flowsense.application.pipeline import (
|
|
4
|
+
analyze_handoff_histories,
|
|
5
|
+
analyze_task_histories,
|
|
6
|
+
classify_task_impacts,
|
|
7
|
+
determine_overall_severity,
|
|
8
|
+
)
|
|
9
|
+
from flowsense.application.ports import DAGDataSource
|
|
10
|
+
from flowsense.domain import DEFAULT_ANALYSIS_POLICY, AnalysisPolicy, DAGAnalysis
|
|
11
|
+
from flowsense.engine.history import build_duration_history
|
|
12
|
+
from flowsense.engine.propagation import analyze_propagation
|
|
13
|
+
from flowsense.engine.root_cause import select_primary_origin
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def analyze_dag(
|
|
17
|
+
dag_id: str,
|
|
18
|
+
source: DAGDataSource,
|
|
19
|
+
policy: AnalysisPolicy = DEFAULT_ANALYSIS_POLICY,
|
|
20
|
+
) -> DAGAnalysis:
|
|
21
|
+
task_runs = source.collect_task_runs(dag_id)
|
|
22
|
+
dependencies = source.get_dag_dependencies(dag_id)
|
|
23
|
+
duration_history = build_duration_history(
|
|
24
|
+
task_runs,
|
|
25
|
+
aggregation=policy.mapped_task_aggregation,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
task_analysis = analyze_task_histories(duration_history, policy)
|
|
29
|
+
handoff_analysis = analyze_handoff_histories(task_runs, dependencies, policy)
|
|
30
|
+
task_impacts = classify_task_impacts(
|
|
31
|
+
task_analysis.drift_results,
|
|
32
|
+
handoff_analysis.drift_results,
|
|
33
|
+
)
|
|
34
|
+
propagation_results = analyze_propagation(
|
|
35
|
+
drift_results=task_analysis.drift_results,
|
|
36
|
+
dependencies=dependencies,
|
|
37
|
+
)
|
|
38
|
+
primary_origin = select_primary_origin(
|
|
39
|
+
drift_results=task_analysis.drift_results,
|
|
40
|
+
task_impacts=task_impacts,
|
|
41
|
+
dependencies=dependencies,
|
|
42
|
+
propagation_results=propagation_results,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return DAGAnalysis(
|
|
46
|
+
dag_id=dag_id,
|
|
47
|
+
runs_analyzed=len({run.dag_run_id for run in task_runs}),
|
|
48
|
+
current_dag_run_id=(task_runs[-1].dag_run_id if task_runs else None),
|
|
49
|
+
overall_severity=determine_overall_severity(
|
|
50
|
+
task_analysis.drift_results,
|
|
51
|
+
handoff_analysis.drift_results,
|
|
52
|
+
),
|
|
53
|
+
primary_origin=primary_origin,
|
|
54
|
+
drift_results=task_analysis.drift_results,
|
|
55
|
+
handoff_drift_results=handoff_analysis.drift_results,
|
|
56
|
+
task_impacts=task_impacts,
|
|
57
|
+
propagation_results=propagation_results,
|
|
58
|
+
dependencies=dependencies,
|
|
59
|
+
diagnostics=[
|
|
60
|
+
*task_analysis.diagnostics,
|
|
61
|
+
*handoff_analysis.diagnostics,
|
|
62
|
+
],
|
|
63
|
+
policy=policy,
|
|
64
|
+
change_point_results=task_analysis.change_point_results,
|
|
65
|
+
handoff_change_point_results=handoff_analysis.change_point_results,
|
|
66
|
+
trend_results=task_analysis.trend_results,
|
|
67
|
+
handoff_trend_results=handoff_analysis.trend_results,
|
|
68
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
from flowsense.domain import (
|
|
6
|
+
ChangeDirection,
|
|
7
|
+
ImpactClassification,
|
|
8
|
+
MappedTaskAggregation,
|
|
9
|
+
Severity,
|
|
10
|
+
TrendDirection,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
ANALYSIS_SCHEMA_VERSION = "1.1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OutputModel(BaseModel):
|
|
17
|
+
"""Base model for the versioned public analysis contract."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AnalysisSummaryOutput(OutputModel):
|
|
23
|
+
total_tasks: int
|
|
24
|
+
analyzed_tasks: int
|
|
25
|
+
analysis_coverage_percent: float
|
|
26
|
+
normal_tasks: int
|
|
27
|
+
medium_tasks: int
|
|
28
|
+
high_tasks: int
|
|
29
|
+
critical_tasks: int
|
|
30
|
+
anomalous_tasks: int
|
|
31
|
+
anomalous_handoffs: int
|
|
32
|
+
affected_tasks: int
|
|
33
|
+
change_points: int
|
|
34
|
+
trends: int
|
|
35
|
+
diagnostics: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AnalysisPolicyOutput(OutputModel):
|
|
39
|
+
minimum_history: int
|
|
40
|
+
baseline_window: int | None
|
|
41
|
+
medium_threshold: float
|
|
42
|
+
high_threshold: float
|
|
43
|
+
critical_threshold: float
|
|
44
|
+
mapped_task_aggregation: MappedTaskAggregation
|
|
45
|
+
change_point_detection_enabled: bool
|
|
46
|
+
change_point_minimum_segment_size: int
|
|
47
|
+
change_point_score_threshold: float
|
|
48
|
+
trend_detection_enabled: bool
|
|
49
|
+
trend_minimum_observations: int
|
|
50
|
+
trend_score_threshold: float
|
|
51
|
+
trend_minimum_directional_consistency: float
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RootCauseOutput(OutputModel):
|
|
55
|
+
task_id: str
|
|
56
|
+
classification: ImpactClassification
|
|
57
|
+
severity: Severity
|
|
58
|
+
propagation_score: float
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DriftOutput(OutputModel):
|
|
62
|
+
baseline: float
|
|
63
|
+
current: float
|
|
64
|
+
mad: float
|
|
65
|
+
robust_z_score: float
|
|
66
|
+
deviation_percent: float
|
|
67
|
+
severity: Severity
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ChangePointOutput(OutputModel):
|
|
71
|
+
change_index: int
|
|
72
|
+
before_median: float
|
|
73
|
+
after_median: float
|
|
74
|
+
change_percent: float | None
|
|
75
|
+
score: float
|
|
76
|
+
direction: ChangeDirection
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TrendOutput(OutputModel):
|
|
80
|
+
direction: TrendDirection
|
|
81
|
+
slope_per_observation: float
|
|
82
|
+
estimated_change: float
|
|
83
|
+
change_percent: float | None
|
|
84
|
+
score: float
|
|
85
|
+
directional_consistency: float
|
|
86
|
+
observations: int
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TaskImpactOutput(OutputModel):
|
|
90
|
+
classification: ImpactClassification
|
|
91
|
+
task_severity: Severity
|
|
92
|
+
upstream_handoff_severity: Severity | None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class PropagationOutput(OutputModel):
|
|
96
|
+
origin_task: str
|
|
97
|
+
affected_tasks: list[str]
|
|
98
|
+
path: list[str]
|
|
99
|
+
propagation_score: float
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DiagnosticOutput(OutputModel):
|
|
103
|
+
code: str
|
|
104
|
+
subject_id: str
|
|
105
|
+
message: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AnalysisDocument(OutputModel):
|
|
109
|
+
"""Typed representation of the FlowSense analysis output schema."""
|
|
110
|
+
|
|
111
|
+
schema_version: Literal["1.1"] = ANALYSIS_SCHEMA_VERSION
|
|
112
|
+
dag_id: str
|
|
113
|
+
current_dag_run_id: str | None
|
|
114
|
+
runs_analyzed: int
|
|
115
|
+
overall_severity: Severity
|
|
116
|
+
summary: AnalysisSummaryOutput
|
|
117
|
+
policy: AnalysisPolicyOutput
|
|
118
|
+
primary_origin: RootCauseOutput | None
|
|
119
|
+
drift_results: dict[str, DriftOutput]
|
|
120
|
+
change_point_results: dict[str, ChangePointOutput]
|
|
121
|
+
trend_results: dict[str, TrendOutput]
|
|
122
|
+
handoff_drift_results: dict[str, DriftOutput]
|
|
123
|
+
handoff_change_point_results: dict[str, ChangePointOutput]
|
|
124
|
+
handoff_trend_results: dict[str, TrendOutput]
|
|
125
|
+
task_impacts: dict[str, TaskImpactOutput]
|
|
126
|
+
propagation_results: list[PropagationOutput]
|
|
127
|
+
dependencies: dict[str, list[str]]
|
|
128
|
+
diagnostics: list[DiagnosticOutput]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from flowsense.domain import (
|
|
6
|
+
AnalysisDiagnostic,
|
|
7
|
+
AnalysisPolicy,
|
|
8
|
+
ChangePointResult,
|
|
9
|
+
DriftResult,
|
|
10
|
+
InsufficientHistoryError,
|
|
11
|
+
Severity,
|
|
12
|
+
TaskImpact,
|
|
13
|
+
TaskRun,
|
|
14
|
+
TrendResult,
|
|
15
|
+
)
|
|
16
|
+
from flowsense.domain.enums import SEVERITY_SCORE
|
|
17
|
+
from flowsense.engine.change_point import detect_change_point
|
|
18
|
+
from flowsense.engine.drift import calculate_drift
|
|
19
|
+
from flowsense.engine.impact import classify_task_impact
|
|
20
|
+
from flowsense.engine.timing import (
|
|
21
|
+
build_handoff_history_with_diagnostics,
|
|
22
|
+
calculate_handoff_drift,
|
|
23
|
+
)
|
|
24
|
+
from flowsense.engine.trend import detect_trend
|
|
25
|
+
|
|
26
|
+
TaskId = str
|
|
27
|
+
Edge = tuple[str, str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TaskAnalysisStageResult:
|
|
32
|
+
drift_results: dict[TaskId, DriftResult]
|
|
33
|
+
change_point_results: dict[TaskId, ChangePointResult]
|
|
34
|
+
trend_results: dict[TaskId, TrendResult]
|
|
35
|
+
diagnostics: list[AnalysisDiagnostic]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class HandoffAnalysisStageResult:
|
|
40
|
+
drift_results: dict[Edge, DriftResult]
|
|
41
|
+
change_point_results: dict[Edge, ChangePointResult]
|
|
42
|
+
trend_results: dict[Edge, TrendResult]
|
|
43
|
+
diagnostics: list[AnalysisDiagnostic]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _detect_change_point(
|
|
47
|
+
subject_id: str,
|
|
48
|
+
values: list[float],
|
|
49
|
+
policy: AnalysisPolicy,
|
|
50
|
+
) -> ChangePointResult | None:
|
|
51
|
+
if not policy.change_point_detection_enabled:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
return detect_change_point(
|
|
55
|
+
subject_id,
|
|
56
|
+
values,
|
|
57
|
+
minimum_segment_size=policy.change_point_minimum_segment_size,
|
|
58
|
+
score_threshold=policy.change_point_score_threshold,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _detect_trend(
|
|
63
|
+
subject_id: str,
|
|
64
|
+
values: list[float],
|
|
65
|
+
policy: AnalysisPolicy,
|
|
66
|
+
) -> TrendResult | None:
|
|
67
|
+
if not policy.trend_detection_enabled:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
return detect_trend(
|
|
71
|
+
subject_id,
|
|
72
|
+
values,
|
|
73
|
+
minimum_observations=policy.trend_minimum_observations,
|
|
74
|
+
score_threshold=policy.trend_score_threshold,
|
|
75
|
+
minimum_directional_consistency=(policy.trend_minimum_directional_consistency),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def analyze_task_histories(
|
|
80
|
+
duration_history: dict[TaskId, list[float]],
|
|
81
|
+
policy: AnalysisPolicy,
|
|
82
|
+
) -> TaskAnalysisStageResult:
|
|
83
|
+
drift_results: dict[TaskId, DriftResult] = {}
|
|
84
|
+
change_point_results: dict[TaskId, ChangePointResult] = {}
|
|
85
|
+
trend_results: dict[TaskId, TrendResult] = {}
|
|
86
|
+
diagnostics: list[AnalysisDiagnostic] = []
|
|
87
|
+
|
|
88
|
+
for task_id, durations in duration_history.items():
|
|
89
|
+
change_point = _detect_change_point(task_id, durations, policy)
|
|
90
|
+
if change_point is not None:
|
|
91
|
+
change_point_results[task_id] = change_point
|
|
92
|
+
|
|
93
|
+
trend = _detect_trend(task_id, durations, policy)
|
|
94
|
+
if trend is not None:
|
|
95
|
+
trend_results[task_id] = trend
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
drift_results[task_id] = calculate_drift(
|
|
99
|
+
task_id=task_id,
|
|
100
|
+
durations=durations,
|
|
101
|
+
policy=policy,
|
|
102
|
+
)
|
|
103
|
+
except InsufficientHistoryError as exc:
|
|
104
|
+
diagnostics.append(
|
|
105
|
+
AnalysisDiagnostic(
|
|
106
|
+
code="INSUFFICIENT_TASK_HISTORY",
|
|
107
|
+
subject_id=task_id,
|
|
108
|
+
message=str(exc),
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return TaskAnalysisStageResult(
|
|
113
|
+
drift_results=drift_results,
|
|
114
|
+
change_point_results=change_point_results,
|
|
115
|
+
trend_results=trend_results,
|
|
116
|
+
diagnostics=diagnostics,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def analyze_handoff_histories(
|
|
121
|
+
task_runs: list[TaskRun],
|
|
122
|
+
dependencies: dict[TaskId, list[TaskId]],
|
|
123
|
+
policy: AnalysisPolicy,
|
|
124
|
+
) -> HandoffAnalysisStageResult:
|
|
125
|
+
history_result = build_handoff_history_with_diagnostics(
|
|
126
|
+
task_runs=task_runs,
|
|
127
|
+
dependencies=dependencies,
|
|
128
|
+
)
|
|
129
|
+
drift_results: dict[Edge, DriftResult] = {}
|
|
130
|
+
change_point_results: dict[Edge, ChangePointResult] = {}
|
|
131
|
+
trend_results: dict[Edge, TrendResult] = {}
|
|
132
|
+
diagnostics = [
|
|
133
|
+
AnalysisDiagnostic(
|
|
134
|
+
code=diagnostic.code,
|
|
135
|
+
subject_id=f"{diagnostic.upstream_task}->{diagnostic.downstream_task}",
|
|
136
|
+
message=diagnostic.message,
|
|
137
|
+
)
|
|
138
|
+
for diagnostic in history_result.diagnostics
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
for edge, delays in history_result.history.items():
|
|
142
|
+
upstream_task, downstream_task = edge
|
|
143
|
+
subject_id = f"{upstream_task}->{downstream_task}"
|
|
144
|
+
|
|
145
|
+
change_point = _detect_change_point(subject_id, delays, policy)
|
|
146
|
+
if change_point is not None:
|
|
147
|
+
change_point_results[edge] = change_point
|
|
148
|
+
|
|
149
|
+
trend = _detect_trend(subject_id, delays, policy)
|
|
150
|
+
if trend is not None:
|
|
151
|
+
trend_results[edge] = trend
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
drift_results[edge] = calculate_handoff_drift(
|
|
155
|
+
upstream_task=upstream_task,
|
|
156
|
+
downstream_task=downstream_task,
|
|
157
|
+
handoff_delays=delays,
|
|
158
|
+
policy=policy,
|
|
159
|
+
)
|
|
160
|
+
except InsufficientHistoryError as exc:
|
|
161
|
+
diagnostics.append(
|
|
162
|
+
AnalysisDiagnostic(
|
|
163
|
+
code="INSUFFICIENT_HANDOFF_HISTORY",
|
|
164
|
+
subject_id=subject_id,
|
|
165
|
+
message=str(exc),
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return HandoffAnalysisStageResult(
|
|
170
|
+
drift_results=drift_results,
|
|
171
|
+
change_point_results=change_point_results,
|
|
172
|
+
trend_results=trend_results,
|
|
173
|
+
diagnostics=diagnostics,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def classify_task_impacts(
|
|
178
|
+
task_drift_results: dict[TaskId, DriftResult],
|
|
179
|
+
handoff_drift_results: dict[Edge, DriftResult],
|
|
180
|
+
) -> dict[TaskId, TaskImpact]:
|
|
181
|
+
return {
|
|
182
|
+
task_id: classify_task_impact(
|
|
183
|
+
task_id=task_id,
|
|
184
|
+
task_drift=task_drift,
|
|
185
|
+
upstream_handoff_drifts=[
|
|
186
|
+
drift
|
|
187
|
+
for (_upstream, downstream), drift in handoff_drift_results.items()
|
|
188
|
+
if downstream == task_id
|
|
189
|
+
],
|
|
190
|
+
)
|
|
191
|
+
for task_id, task_drift in task_drift_results.items()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def determine_overall_severity(
|
|
196
|
+
task_drift_results: dict[TaskId, DriftResult],
|
|
197
|
+
handoff_drift_results: dict[Edge, DriftResult],
|
|
198
|
+
) -> Severity:
|
|
199
|
+
drift_results = [
|
|
200
|
+
*task_drift_results.values(),
|
|
201
|
+
*handoff_drift_results.values(),
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
if not drift_results:
|
|
205
|
+
return Severity.NORMAL
|
|
206
|
+
|
|
207
|
+
return max(
|
|
208
|
+
drift_results,
|
|
209
|
+
key=lambda result: SEVERITY_SCORE[result.severity],
|
|
210
|
+
).severity
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
from flowsense.domain import TaskRun
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DAGDataSource(Protocol):
|
|
7
|
+
def collect_task_runs(self, dag_id: str) -> list[TaskRun]:
|
|
8
|
+
"""Return task runs ordered from oldest DAG run to newest."""
|
|
9
|
+
...
|
|
10
|
+
|
|
11
|
+
def get_dag_dependencies(self, dag_id: str) -> dict[str, list[str]]: ...
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from flowsense.domain import (
|
|
4
|
+
DEFAULT_ANALYSIS_POLICY,
|
|
5
|
+
AnalysisPolicy,
|
|
6
|
+
ConfigurationError,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class AnalysisRequest:
|
|
12
|
+
"""Validated input contract shared by FlowSense delivery adapters."""
|
|
13
|
+
|
|
14
|
+
dag_id: str
|
|
15
|
+
policy: AnalysisPolicy = DEFAULT_ANALYSIS_POLICY
|
|
16
|
+
history_run_limit: int | None = None
|
|
17
|
+
dag_run_id: str | None = None
|
|
18
|
+
|
|
19
|
+
def __post_init__(self) -> None:
|
|
20
|
+
if not self.dag_id.strip():
|
|
21
|
+
raise ConfigurationError("dag_id must not be empty.")
|
|
22
|
+
|
|
23
|
+
if self.dag_run_id is not None and not self.dag_run_id.strip():
|
|
24
|
+
raise ConfigurationError("dag_run_id must not be empty.")
|
|
25
|
+
|
|
26
|
+
if (
|
|
27
|
+
self.history_run_limit is not None
|
|
28
|
+
and self.history_run_limit < self.policy.minimum_history
|
|
29
|
+
):
|
|
30
|
+
raise ConfigurationError(
|
|
31
|
+
"history_run_limit must be at least minimum_history."
|
|
32
|
+
)
|