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
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from email.utils import parsedate_to_datetime
|
|
7
|
+
from typing import Any, Literal, Self
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from flowsense.config import get_airflow_config
|
|
13
|
+
from flowsense.domain import ConfigurationError, TaskRun
|
|
14
|
+
from flowsense.infrastructure.airflow.dto import (
|
|
15
|
+
AirflowDagRunDTO,
|
|
16
|
+
AirflowTaskDTO,
|
|
17
|
+
AirflowTaskInstanceDTO,
|
|
18
|
+
)
|
|
19
|
+
from flowsense.infrastructure.airflow.exceptions import (
|
|
20
|
+
AirflowApiError,
|
|
21
|
+
AirflowDagRunNotFoundError,
|
|
22
|
+
AirflowDataError,
|
|
23
|
+
)
|
|
24
|
+
from flowsense.infrastructure.airflow.mapper import (
|
|
25
|
+
map_dependencies,
|
|
26
|
+
map_task_instance,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
PAGE_SIZE = 100
|
|
30
|
+
AirflowApiVersion = Literal["v1", "v2"]
|
|
31
|
+
AirflowAuthMode = Literal["basic", "token"]
|
|
32
|
+
RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AirflowClient:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
base_url: str | None = None,
|
|
39
|
+
username: str | None = None,
|
|
40
|
+
password: str | None = None,
|
|
41
|
+
api_version: AirflowApiVersion | None = None,
|
|
42
|
+
auth_mode: AirflowAuthMode | None = None,
|
|
43
|
+
connect_timeout: float | None = None,
|
|
44
|
+
read_timeout: float | None = None,
|
|
45
|
+
max_retries: int | None = None,
|
|
46
|
+
retry_backoff: float | None = None,
|
|
47
|
+
history_run_limit: int | None = None,
|
|
48
|
+
target_dag_run_id: str | None = None,
|
|
49
|
+
http_client: httpx.Client | None = None,
|
|
50
|
+
sleep: Callable[[float], None] | None = None,
|
|
51
|
+
):
|
|
52
|
+
config = get_airflow_config()
|
|
53
|
+
|
|
54
|
+
self.base_url = (base_url or config.base_url).rstrip("/")
|
|
55
|
+
self.username = username or config.username
|
|
56
|
+
self.password = password or config.password
|
|
57
|
+
self.api_version = api_version or config.api_version
|
|
58
|
+
self.auth_mode = auth_mode or config.auth_mode
|
|
59
|
+
self.connect_timeout = (
|
|
60
|
+
connect_timeout if connect_timeout is not None else config.connect_timeout
|
|
61
|
+
)
|
|
62
|
+
self.read_timeout = (
|
|
63
|
+
read_timeout if read_timeout is not None else config.read_timeout
|
|
64
|
+
)
|
|
65
|
+
self.max_retries = (
|
|
66
|
+
max_retries if max_retries is not None else config.max_retries
|
|
67
|
+
)
|
|
68
|
+
self.retry_backoff = (
|
|
69
|
+
retry_backoff if retry_backoff is not None else config.retry_backoff
|
|
70
|
+
)
|
|
71
|
+
self.history_run_limit = (
|
|
72
|
+
history_run_limit
|
|
73
|
+
if history_run_limit is not None
|
|
74
|
+
else config.history_run_limit
|
|
75
|
+
)
|
|
76
|
+
self.target_dag_run_id = target_dag_run_id
|
|
77
|
+
|
|
78
|
+
if self.api_version not in {"v1", "v2"}:
|
|
79
|
+
raise ConfigurationError("api_version must be 'v1' or 'v2'")
|
|
80
|
+
|
|
81
|
+
if self.auth_mode not in {"basic", "token"}:
|
|
82
|
+
raise ConfigurationError("auth_mode must be 'basic' or 'token'")
|
|
83
|
+
if self.connect_timeout <= 0:
|
|
84
|
+
raise ConfigurationError("connect_timeout must be positive")
|
|
85
|
+
if self.read_timeout <= 0:
|
|
86
|
+
raise ConfigurationError("read_timeout must be positive")
|
|
87
|
+
if self.max_retries < 0:
|
|
88
|
+
raise ConfigurationError("max_retries must be non-negative")
|
|
89
|
+
if self.retry_backoff < 0:
|
|
90
|
+
raise ConfigurationError("retry_backoff must be non-negative")
|
|
91
|
+
if self.history_run_limit < 2:
|
|
92
|
+
raise ConfigurationError("history_run_limit must be at least 2")
|
|
93
|
+
if self.target_dag_run_id is not None and not self.target_dag_run_id.strip():
|
|
94
|
+
raise ConfigurationError("target_dag_run_id must not be empty")
|
|
95
|
+
|
|
96
|
+
self._owns_http_client = http_client is None
|
|
97
|
+
self._http_client = http_client or httpx.Client(
|
|
98
|
+
timeout=httpx.Timeout(
|
|
99
|
+
connect=self.connect_timeout,
|
|
100
|
+
read=self.read_timeout,
|
|
101
|
+
write=self.read_timeout,
|
|
102
|
+
pool=self.connect_timeout,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
self._sleep = sleep or time.sleep
|
|
106
|
+
self._token: str | None = None
|
|
107
|
+
|
|
108
|
+
def __enter__(self) -> Self:
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def __exit__(self, *args: object) -> None:
|
|
112
|
+
self.close()
|
|
113
|
+
|
|
114
|
+
def close(self) -> None:
|
|
115
|
+
if self._owns_http_client:
|
|
116
|
+
self._http_client.close()
|
|
117
|
+
|
|
118
|
+
def _request(
|
|
119
|
+
self,
|
|
120
|
+
method: str,
|
|
121
|
+
url: str,
|
|
122
|
+
**kwargs: object,
|
|
123
|
+
) -> httpx.Response:
|
|
124
|
+
request_kwargs: dict[str, Any] = dict(kwargs)
|
|
125
|
+
|
|
126
|
+
for attempt in range(self.max_retries + 1):
|
|
127
|
+
try:
|
|
128
|
+
response = self._http_client.request(
|
|
129
|
+
method=method,
|
|
130
|
+
url=url,
|
|
131
|
+
**request_kwargs,
|
|
132
|
+
)
|
|
133
|
+
except httpx.RequestError as exc:
|
|
134
|
+
if attempt < self.max_retries:
|
|
135
|
+
self._sleep(self._backoff_delay(attempt))
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
raise AirflowApiError(
|
|
139
|
+
method=method,
|
|
140
|
+
endpoint=exc.request.url.path,
|
|
141
|
+
) from exc
|
|
142
|
+
|
|
143
|
+
if (
|
|
144
|
+
response.status_code in RETRYABLE_STATUS_CODES
|
|
145
|
+
and attempt < self.max_retries
|
|
146
|
+
):
|
|
147
|
+
delay = self._retry_delay(response, attempt)
|
|
148
|
+
response.close()
|
|
149
|
+
self._sleep(delay)
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
response.raise_for_status()
|
|
154
|
+
except httpx.HTTPStatusError as exc:
|
|
155
|
+
raise AirflowApiError(
|
|
156
|
+
method=method,
|
|
157
|
+
endpoint=exc.request.url.path,
|
|
158
|
+
status_code=exc.response.status_code,
|
|
159
|
+
) from exc
|
|
160
|
+
|
|
161
|
+
return response
|
|
162
|
+
|
|
163
|
+
raise RuntimeError("Airflow request retry loop exited unexpectedly")
|
|
164
|
+
|
|
165
|
+
def _backoff_delay(self, attempt: int) -> float:
|
|
166
|
+
return self.retry_backoff * (2**attempt)
|
|
167
|
+
|
|
168
|
+
def _retry_delay(self, response: httpx.Response, attempt: int) -> float:
|
|
169
|
+
retry_after = response.headers.get("Retry-After")
|
|
170
|
+
if retry_after is None:
|
|
171
|
+
return self._backoff_delay(attempt)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
return max(0.0, float(retry_after))
|
|
175
|
+
except ValueError:
|
|
176
|
+
try:
|
|
177
|
+
retry_at = parsedate_to_datetime(retry_after)
|
|
178
|
+
if retry_at.tzinfo is None:
|
|
179
|
+
retry_at = retry_at.replace(tzinfo=UTC)
|
|
180
|
+
return max(0.0, (retry_at - datetime.now(UTC)).total_seconds())
|
|
181
|
+
except (TypeError, ValueError, OverflowError):
|
|
182
|
+
return self._backoff_delay(attempt)
|
|
183
|
+
|
|
184
|
+
def _get_token(self) -> str:
|
|
185
|
+
if self._token:
|
|
186
|
+
return self._token
|
|
187
|
+
|
|
188
|
+
response = self._request(
|
|
189
|
+
method="POST",
|
|
190
|
+
url=f"{self.base_url}/auth/token",
|
|
191
|
+
json={
|
|
192
|
+
"username": self.username,
|
|
193
|
+
"password": self.password,
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
payload = self._response_json(response, "authentication response")
|
|
198
|
+
token = payload.get("access_token")
|
|
199
|
+
if not isinstance(token, str) or not token:
|
|
200
|
+
raise AirflowDataError("authentication response")
|
|
201
|
+
self._token = token
|
|
202
|
+
return token
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _response_json(
|
|
206
|
+
response: httpx.Response,
|
|
207
|
+
resource: str,
|
|
208
|
+
) -> dict[str, Any]:
|
|
209
|
+
try:
|
|
210
|
+
payload = response.json()
|
|
211
|
+
except ValueError as exc:
|
|
212
|
+
raise AirflowDataError(resource) from exc
|
|
213
|
+
|
|
214
|
+
if not isinstance(payload, dict):
|
|
215
|
+
raise AirflowDataError(resource)
|
|
216
|
+
|
|
217
|
+
return payload
|
|
218
|
+
|
|
219
|
+
def _headers(self) -> dict[str, str]:
|
|
220
|
+
headers = {"Accept": "application/json"}
|
|
221
|
+
|
|
222
|
+
if self.auth_mode == "token":
|
|
223
|
+
headers["Authorization"] = f"Bearer {self._get_token()}"
|
|
224
|
+
|
|
225
|
+
return headers
|
|
226
|
+
|
|
227
|
+
def _authentication(self) -> httpx.BasicAuth | None:
|
|
228
|
+
if self.auth_mode == "basic":
|
|
229
|
+
return httpx.BasicAuth(self.username, self.password)
|
|
230
|
+
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def _api_url(self, path: str) -> str:
|
|
234
|
+
return f"{self.base_url}/api/{self.api_version}{path}"
|
|
235
|
+
|
|
236
|
+
def _get_paginated(
|
|
237
|
+
self,
|
|
238
|
+
url: str,
|
|
239
|
+
collection_key: str,
|
|
240
|
+
) -> dict:
|
|
241
|
+
items: list[dict] = []
|
|
242
|
+
offset = 0
|
|
243
|
+
last_page: dict = {}
|
|
244
|
+
|
|
245
|
+
while True:
|
|
246
|
+
request_kwargs: dict[str, object] = {
|
|
247
|
+
"headers": self._headers(),
|
|
248
|
+
"params": {"limit": PAGE_SIZE, "offset": offset},
|
|
249
|
+
}
|
|
250
|
+
authentication = self._authentication()
|
|
251
|
+
if authentication is not None:
|
|
252
|
+
request_kwargs["auth"] = authentication
|
|
253
|
+
|
|
254
|
+
response = self._request(
|
|
255
|
+
method="GET",
|
|
256
|
+
url=url,
|
|
257
|
+
**request_kwargs,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
last_page = self._response_json(response, collection_key)
|
|
261
|
+
page_items = last_page.get(collection_key)
|
|
262
|
+
if not isinstance(page_items, list):
|
|
263
|
+
raise AirflowDataError(collection_key)
|
|
264
|
+
items.extend(page_items)
|
|
265
|
+
total_entries = last_page.get("total_entries")
|
|
266
|
+
if total_entries is not None and not isinstance(total_entries, int):
|
|
267
|
+
raise AirflowDataError(collection_key)
|
|
268
|
+
|
|
269
|
+
if not page_items:
|
|
270
|
+
break
|
|
271
|
+
|
|
272
|
+
if total_entries is not None and len(items) >= total_entries:
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
if total_entries is None and len(page_items) < PAGE_SIZE:
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
offset += len(page_items)
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
**last_page,
|
|
282
|
+
collection_key: items,
|
|
283
|
+
"total_entries": last_page.get("total_entries", len(items)),
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
def get_dag_runs(self, dag_id: str) -> dict:
|
|
287
|
+
return self._get_paginated(
|
|
288
|
+
url=self._api_url(f"/dags/{dag_id}/dagRuns"),
|
|
289
|
+
collection_key="dag_runs",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def get_task_instances(
|
|
293
|
+
self,
|
|
294
|
+
dag_id: str,
|
|
295
|
+
dag_run_id: str,
|
|
296
|
+
) -> dict:
|
|
297
|
+
return self._get_paginated(
|
|
298
|
+
url=self._api_url(f"/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances"),
|
|
299
|
+
collection_key="task_instances",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def collect_task_runs(self, dag_id: str) -> list[TaskRun]:
|
|
303
|
+
response = self.get_dag_runs(dag_id)
|
|
304
|
+
try:
|
|
305
|
+
dag_runs = [
|
|
306
|
+
AirflowDagRunDTO.model_validate(item) for item in response["dag_runs"]
|
|
307
|
+
]
|
|
308
|
+
except ValidationError as exc:
|
|
309
|
+
raise AirflowDataError("DAG run") from exc
|
|
310
|
+
|
|
311
|
+
def run_timestamp(run: AirflowDagRunDTO) -> float:
|
|
312
|
+
timestamp = run.run_after or run.logical_date or run.queued_at
|
|
313
|
+
return timestamp.timestamp() if timestamp is not None else float("-inf")
|
|
314
|
+
|
|
315
|
+
successful_dag_runs = [run for run in dag_runs if run.state == "success"]
|
|
316
|
+
successful_dag_runs.sort(key=run_timestamp)
|
|
317
|
+
if self.target_dag_run_id is None:
|
|
318
|
+
selected_dag_runs = successful_dag_runs[-self.history_run_limit :]
|
|
319
|
+
else:
|
|
320
|
+
target_index = next(
|
|
321
|
+
(
|
|
322
|
+
index
|
|
323
|
+
for index, run in enumerate(successful_dag_runs)
|
|
324
|
+
if run.dag_run_id == self.target_dag_run_id
|
|
325
|
+
),
|
|
326
|
+
None,
|
|
327
|
+
)
|
|
328
|
+
if target_index is None:
|
|
329
|
+
raise AirflowDagRunNotFoundError(dag_id, self.target_dag_run_id)
|
|
330
|
+
|
|
331
|
+
window_start = max(0, target_index - self.history_run_limit + 1)
|
|
332
|
+
selected_dag_runs = successful_dag_runs[window_start : target_index + 1]
|
|
333
|
+
|
|
334
|
+
task_runs: list[TaskRun] = []
|
|
335
|
+
|
|
336
|
+
for dag_run in selected_dag_runs:
|
|
337
|
+
response = self.get_task_instances(
|
|
338
|
+
dag_id=dag_id,
|
|
339
|
+
dag_run_id=dag_run.dag_run_id,
|
|
340
|
+
)
|
|
341
|
+
try:
|
|
342
|
+
task_instances = [
|
|
343
|
+
AirflowTaskInstanceDTO.model_validate(item)
|
|
344
|
+
for item in response["task_instances"]
|
|
345
|
+
]
|
|
346
|
+
except ValidationError as exc:
|
|
347
|
+
raise AirflowDataError("task instance") from exc
|
|
348
|
+
|
|
349
|
+
task_runs.extend(
|
|
350
|
+
map_task_instance(
|
|
351
|
+
dag_id=dag_id,
|
|
352
|
+
dag_run_id=dag_run.dag_run_id,
|
|
353
|
+
task=task,
|
|
354
|
+
)
|
|
355
|
+
for task in task_instances
|
|
356
|
+
if task.state == "success"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return task_runs
|
|
360
|
+
|
|
361
|
+
def get_dag_tasks(self, dag_id: str) -> dict:
|
|
362
|
+
return self._get_paginated(
|
|
363
|
+
url=self._api_url(f"/dags/{dag_id}/tasks"),
|
|
364
|
+
collection_key="tasks",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def get_dag_dependencies(self, dag_id: str) -> dict[str, list[str]]:
|
|
368
|
+
response = self.get_dag_tasks(dag_id)
|
|
369
|
+
try:
|
|
370
|
+
tasks = [AirflowTaskDTO.model_validate(item) for item in response["tasks"]]
|
|
371
|
+
except ValidationError as exc:
|
|
372
|
+
raise AirflowDataError("DAG task") from exc
|
|
373
|
+
return map_dependencies(tasks)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AirflowDTO(BaseModel):
|
|
7
|
+
model_config = ConfigDict(extra="ignore")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AirflowDagRunDTO(AirflowDTO):
|
|
11
|
+
dag_run_id: str
|
|
12
|
+
state: str | None = None
|
|
13
|
+
logical_date: datetime | None = Field(
|
|
14
|
+
default=None,
|
|
15
|
+
validation_alias=AliasChoices("logical_date", "execution_date"),
|
|
16
|
+
)
|
|
17
|
+
run_after: datetime | None = None
|
|
18
|
+
queued_at: datetime | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AirflowTaskInstanceDTO(AirflowDTO):
|
|
22
|
+
task_id: str
|
|
23
|
+
state: str | None = None
|
|
24
|
+
start_date: datetime | None = None
|
|
25
|
+
end_date: datetime | None = None
|
|
26
|
+
duration: float | None = None
|
|
27
|
+
try_number: int = 0
|
|
28
|
+
map_index: int = -1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AirflowTaskDTO(AirflowDTO):
|
|
32
|
+
task_id: str
|
|
33
|
+
downstream_task_ids: list[str] = Field(default_factory=list)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from flowsense.domain import FlowSenseError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AirflowApiError(FlowSenseError):
|
|
5
|
+
def __init__(
|
|
6
|
+
self,
|
|
7
|
+
method: str,
|
|
8
|
+
endpoint: str,
|
|
9
|
+
status_code: int | None = None,
|
|
10
|
+
) -> None:
|
|
11
|
+
self.method = method
|
|
12
|
+
self.endpoint = endpoint
|
|
13
|
+
self.status_code = status_code
|
|
14
|
+
|
|
15
|
+
status = f" with status {status_code}" if status_code is not None else ""
|
|
16
|
+
super().__init__(f"Airflow API {method} {endpoint} failed{status}.")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AirflowDataError(FlowSenseError):
|
|
20
|
+
def __init__(self, resource: str) -> None:
|
|
21
|
+
self.resource = resource
|
|
22
|
+
super().__init__(f"Airflow returned invalid {resource} data.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AirflowDagRunNotFoundError(FlowSenseError):
|
|
26
|
+
def __init__(self, dag_id: str, dag_run_id: str) -> None:
|
|
27
|
+
self.dag_id = dag_id
|
|
28
|
+
self.dag_run_id = dag_run_id
|
|
29
|
+
super().__init__(
|
|
30
|
+
f"Successful Airflow DAG run {dag_run_id!r} was not found for {dag_id!r}."
|
|
31
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from flowsense.domain import TaskRun
|
|
2
|
+
from flowsense.infrastructure.airflow.dto import (
|
|
3
|
+
AirflowTaskDTO,
|
|
4
|
+
AirflowTaskInstanceDTO,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def map_task_instance(
|
|
9
|
+
dag_id: str,
|
|
10
|
+
dag_run_id: str,
|
|
11
|
+
task: AirflowTaskInstanceDTO,
|
|
12
|
+
) -> TaskRun:
|
|
13
|
+
return TaskRun(
|
|
14
|
+
dag_id=dag_id,
|
|
15
|
+
dag_run_id=dag_run_id,
|
|
16
|
+
task_id=task.task_id,
|
|
17
|
+
state=task.state,
|
|
18
|
+
start_date=task.start_date,
|
|
19
|
+
end_date=task.end_date,
|
|
20
|
+
duration=task.duration,
|
|
21
|
+
try_number=task.try_number,
|
|
22
|
+
map_index=task.map_index,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def map_dependencies(tasks: list[AirflowTaskDTO]) -> dict[str, list[str]]:
|
|
27
|
+
return {task.task_id: list(task.downstream_task_ids) for task in tasks}
|
|
File without changes
|
flowsense/mcp/server.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mcp.server import MCPServer
|
|
4
|
+
|
|
5
|
+
from flowsense.application import AnalysisRequest, analyze_dag, serialize_analysis
|
|
6
|
+
from flowsense.domain import AnalysisPolicy, FlowSenseError, MappedTaskAggregation
|
|
7
|
+
from flowsense.infrastructure.airflow import AirflowClient
|
|
8
|
+
|
|
9
|
+
mcp = MCPServer("FlowSense Engine")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@mcp.tool()
|
|
13
|
+
def analyze_airflow_dag(
|
|
14
|
+
dag_id: str,
|
|
15
|
+
minimum_history: int = 5,
|
|
16
|
+
baseline_window: int | None = None,
|
|
17
|
+
medium_threshold: float = 2.0,
|
|
18
|
+
high_threshold: float = 3.5,
|
|
19
|
+
critical_threshold: float = 5.0,
|
|
20
|
+
change_point_detection_enabled: bool = True,
|
|
21
|
+
change_point_minimum_segment_size: int = 3,
|
|
22
|
+
change_point_score_threshold: float = 3.5,
|
|
23
|
+
trend_detection_enabled: bool = True,
|
|
24
|
+
trend_minimum_observations: int = 5,
|
|
25
|
+
trend_score_threshold: float = 3.5,
|
|
26
|
+
trend_minimum_directional_consistency: float = 0.6,
|
|
27
|
+
mapped_task_aggregation: MappedTaskAggregation = MappedTaskAggregation.MAX,
|
|
28
|
+
history_run_limit: int | None = None,
|
|
29
|
+
dag_run_id: str | None = None,
|
|
30
|
+
) -> dict[str, object]:
|
|
31
|
+
"""Analyze an Apache Airflow DAG for temporal drift and propagation."""
|
|
32
|
+
try:
|
|
33
|
+
request = AnalysisRequest(
|
|
34
|
+
dag_id=dag_id,
|
|
35
|
+
policy=AnalysisPolicy(
|
|
36
|
+
minimum_history=minimum_history,
|
|
37
|
+
baseline_window=baseline_window,
|
|
38
|
+
medium_threshold=medium_threshold,
|
|
39
|
+
high_threshold=high_threshold,
|
|
40
|
+
critical_threshold=critical_threshold,
|
|
41
|
+
mapped_task_aggregation=mapped_task_aggregation,
|
|
42
|
+
change_point_detection_enabled=change_point_detection_enabled,
|
|
43
|
+
change_point_minimum_segment_size=change_point_minimum_segment_size,
|
|
44
|
+
change_point_score_threshold=change_point_score_threshold,
|
|
45
|
+
trend_detection_enabled=trend_detection_enabled,
|
|
46
|
+
trend_minimum_observations=trend_minimum_observations,
|
|
47
|
+
trend_score_threshold=trend_score_threshold,
|
|
48
|
+
trend_minimum_directional_consistency=(
|
|
49
|
+
trend_minimum_directional_consistency
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
history_run_limit=history_run_limit,
|
|
53
|
+
dag_run_id=dag_run_id,
|
|
54
|
+
)
|
|
55
|
+
with AirflowClient(
|
|
56
|
+
history_run_limit=history_run_limit,
|
|
57
|
+
target_dag_run_id=request.dag_run_id,
|
|
58
|
+
) as source:
|
|
59
|
+
analysis = analyze_dag(
|
|
60
|
+
dag_id=request.dag_id,
|
|
61
|
+
source=source,
|
|
62
|
+
policy=request.policy,
|
|
63
|
+
)
|
|
64
|
+
except FlowSenseError as exc:
|
|
65
|
+
raise RuntimeError(str(exc)) from exc
|
|
66
|
+
|
|
67
|
+
return serialize_analysis(analysis)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> None:
|
|
71
|
+
mcp.run()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
flowsense/py.typed
ADDED
|
File without changes
|