truthound-dashboard 1.4.3__py3-none-any.whl → 1.5.0__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.
- truthound_dashboard/api/alerts.py +75 -86
- truthound_dashboard/api/anomaly.py +7 -13
- truthound_dashboard/api/cross_alerts.py +38 -52
- truthound_dashboard/api/drift.py +49 -59
- truthound_dashboard/api/drift_monitor.py +234 -79
- truthound_dashboard/api/enterprise_sampling.py +498 -0
- truthound_dashboard/api/history.py +57 -5
- truthound_dashboard/api/lineage.py +3 -48
- truthound_dashboard/api/maintenance.py +104 -49
- truthound_dashboard/api/mask.py +1 -2
- truthound_dashboard/api/middleware.py +2 -1
- truthound_dashboard/api/model_monitoring.py +435 -311
- truthound_dashboard/api/notifications.py +227 -191
- truthound_dashboard/api/notifications_advanced.py +21 -20
- truthound_dashboard/api/observability.py +586 -0
- truthound_dashboard/api/plugins.py +2 -433
- truthound_dashboard/api/profile.py +199 -37
- truthound_dashboard/api/quality_reporter.py +701 -0
- truthound_dashboard/api/reports.py +7 -16
- truthound_dashboard/api/router.py +66 -0
- truthound_dashboard/api/rule_suggestions.py +5 -5
- truthound_dashboard/api/scan.py +17 -19
- truthound_dashboard/api/schedules.py +85 -50
- truthound_dashboard/api/schema_evolution.py +6 -6
- truthound_dashboard/api/schema_watcher.py +667 -0
- truthound_dashboard/api/sources.py +98 -27
- truthound_dashboard/api/tiering.py +1323 -0
- truthound_dashboard/api/triggers.py +14 -11
- truthound_dashboard/api/validations.py +12 -11
- truthound_dashboard/api/versioning.py +1 -6
- truthound_dashboard/core/__init__.py +129 -3
- truthound_dashboard/core/actions/__init__.py +62 -0
- truthound_dashboard/core/actions/custom.py +426 -0
- truthound_dashboard/core/actions/notifications.py +910 -0
- truthound_dashboard/core/actions/storage.py +472 -0
- truthound_dashboard/core/actions/webhook.py +281 -0
- truthound_dashboard/core/anomaly.py +262 -67
- truthound_dashboard/core/anomaly_explainer.py +4 -3
- truthound_dashboard/core/backends/__init__.py +67 -0
- truthound_dashboard/core/backends/base.py +299 -0
- truthound_dashboard/core/backends/errors.py +191 -0
- truthound_dashboard/core/backends/factory.py +423 -0
- truthound_dashboard/core/backends/mock_backend.py +451 -0
- truthound_dashboard/core/backends/truthound_backend.py +718 -0
- truthound_dashboard/core/checkpoint/__init__.py +87 -0
- truthound_dashboard/core/checkpoint/adapters.py +814 -0
- truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
- truthound_dashboard/core/checkpoint/runner.py +270 -0
- truthound_dashboard/core/connections.py +437 -10
- truthound_dashboard/core/converters/__init__.py +14 -0
- truthound_dashboard/core/converters/truthound.py +620 -0
- truthound_dashboard/core/cross_alerts.py +540 -320
- truthound_dashboard/core/datasource_factory.py +1672 -0
- truthound_dashboard/core/drift_monitor.py +216 -20
- truthound_dashboard/core/enterprise_sampling.py +1291 -0
- truthound_dashboard/core/interfaces/__init__.py +225 -0
- truthound_dashboard/core/interfaces/actions.py +652 -0
- truthound_dashboard/core/interfaces/base.py +247 -0
- truthound_dashboard/core/interfaces/checkpoint.py +676 -0
- truthound_dashboard/core/interfaces/protocols.py +664 -0
- truthound_dashboard/core/interfaces/reporters.py +650 -0
- truthound_dashboard/core/interfaces/routing.py +646 -0
- truthound_dashboard/core/interfaces/triggers.py +619 -0
- truthound_dashboard/core/lineage.py +407 -71
- truthound_dashboard/core/model_monitoring.py +431 -3
- truthound_dashboard/core/notifications/base.py +4 -0
- truthound_dashboard/core/notifications/channels.py +501 -1203
- truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
- truthound_dashboard/core/notifications/deduplication/service.py +131 -348
- truthound_dashboard/core/notifications/dispatcher.py +202 -11
- truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
- truthound_dashboard/core/notifications/escalation/engine.py +168 -358
- truthound_dashboard/core/notifications/routing/__init__.py +88 -128
- truthound_dashboard/core/notifications/routing/engine.py +90 -317
- truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
- truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
- truthound_dashboard/core/notifications/throttling/builder.py +117 -255
- truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
- truthound_dashboard/core/phase5/collaboration.py +1 -1
- truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
- truthound_dashboard/core/quality_reporter.py +1359 -0
- truthound_dashboard/core/report_history.py +0 -6
- truthound_dashboard/core/reporters/__init__.py +175 -14
- truthound_dashboard/core/reporters/adapters.py +943 -0
- truthound_dashboard/core/reporters/base.py +0 -3
- truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
- truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
- truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
- truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
- truthound_dashboard/core/reporters/compat.py +266 -0
- truthound_dashboard/core/reporters/csv_reporter.py +2 -35
- truthound_dashboard/core/reporters/factory.py +526 -0
- truthound_dashboard/core/reporters/interfaces.py +745 -0
- truthound_dashboard/core/reporters/registry.py +1 -10
- truthound_dashboard/core/scheduler.py +165 -0
- truthound_dashboard/core/schema_evolution.py +3 -3
- truthound_dashboard/core/schema_watcher.py +1528 -0
- truthound_dashboard/core/services.py +595 -76
- truthound_dashboard/core/store_manager.py +810 -0
- truthound_dashboard/core/streaming_anomaly.py +169 -4
- truthound_dashboard/core/tiering.py +1309 -0
- truthound_dashboard/core/triggers/evaluators.py +178 -8
- truthound_dashboard/core/truthound_adapter.py +2620 -197
- truthound_dashboard/core/unified_alerts.py +23 -20
- truthound_dashboard/db/__init__.py +8 -0
- truthound_dashboard/db/database.py +8 -2
- truthound_dashboard/db/models.py +944 -25
- truthound_dashboard/db/repository.py +2 -0
- truthound_dashboard/main.py +11 -0
- truthound_dashboard/schemas/__init__.py +177 -16
- truthound_dashboard/schemas/base.py +44 -23
- truthound_dashboard/schemas/collaboration.py +19 -6
- truthound_dashboard/schemas/cross_alerts.py +19 -3
- truthound_dashboard/schemas/drift.py +61 -55
- truthound_dashboard/schemas/drift_monitor.py +67 -23
- truthound_dashboard/schemas/enterprise_sampling.py +653 -0
- truthound_dashboard/schemas/lineage.py +0 -33
- truthound_dashboard/schemas/mask.py +10 -8
- truthound_dashboard/schemas/model_monitoring.py +89 -10
- truthound_dashboard/schemas/notifications_advanced.py +13 -0
- truthound_dashboard/schemas/observability.py +453 -0
- truthound_dashboard/schemas/plugins.py +0 -280
- truthound_dashboard/schemas/profile.py +154 -247
- truthound_dashboard/schemas/quality_reporter.py +403 -0
- truthound_dashboard/schemas/reports.py +2 -2
- truthound_dashboard/schemas/rule_suggestion.py +8 -1
- truthound_dashboard/schemas/scan.py +4 -24
- truthound_dashboard/schemas/schedule.py +11 -3
- truthound_dashboard/schemas/schema_watcher.py +727 -0
- truthound_dashboard/schemas/source.py +17 -2
- truthound_dashboard/schemas/tiering.py +822 -0
- truthound_dashboard/schemas/triggers.py +16 -0
- truthound_dashboard/schemas/unified_alerts.py +7 -0
- truthound_dashboard/schemas/validation.py +0 -13
- truthound_dashboard/schemas/validators/base.py +41 -21
- truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
- truthound_dashboard/schemas/validators/localization_validators.py +273 -0
- truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
- truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
- truthound_dashboard/schemas/validators/referential_validators.py +312 -0
- truthound_dashboard/schemas/validators/registry.py +93 -8
- truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
- truthound_dashboard/schemas/versioning.py +1 -6
- truthound_dashboard/static/index.html +2 -2
- truthound_dashboard-1.5.0.dist-info/METADATA +309 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/RECORD +149 -148
- truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
- truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
- truthound_dashboard/core/plugins/hooks/manager.py +0 -403
- truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
- truthound_dashboard/core/reporters/junit_reporter.py +0 -233
- truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
- truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
- truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
- truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
- truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
- truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
- truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
- truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
- truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
- truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
- truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
- truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
- truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
- truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
- truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
- truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
- truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
- truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
- truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
- truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
- truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
- truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
- truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
- truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
- truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
- truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
- truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
- truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
- truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
- truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
- truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
- truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
- truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
- truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
- truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
- truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
- truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
- truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
- truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
- truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
- truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
- truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
- truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
- truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
- truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
- truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
- truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
- truthound_dashboard-1.4.3.dist-info/METADATA +0 -505
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1528 @@
|
|
|
1
|
+
"""Schema Watcher Service.
|
|
2
|
+
|
|
3
|
+
This module provides schema monitoring functionality using truthound's
|
|
4
|
+
schema evolution module (truthound.profiler.evolution).
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
API Endpoints
|
|
8
|
+
↓
|
|
9
|
+
SchemaWatcherService
|
|
10
|
+
↓
|
|
11
|
+
SchemaEvolutionAdapter (truthound_adapter.py)
|
|
12
|
+
↓
|
|
13
|
+
truthound.profiler.evolution
|
|
14
|
+
- SchemaEvolutionDetector
|
|
15
|
+
- SchemaHistory
|
|
16
|
+
- SchemaWatcher
|
|
17
|
+
- ColumnRenameDetector
|
|
18
|
+
- BreakingChangeAlertManager
|
|
19
|
+
- ImpactAnalyzer
|
|
20
|
+
|
|
21
|
+
Features:
|
|
22
|
+
- Schema change detection with breaking change identification
|
|
23
|
+
- Column rename detection using multiple similarity algorithms
|
|
24
|
+
- Version history with semantic/incremental/timestamp/git strategies
|
|
25
|
+
- Continuous monitoring with configurable polling
|
|
26
|
+
- Impact analysis for affected consumers
|
|
27
|
+
- Alert management with acknowledgment and resolution
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
from datetime import datetime, timedelta
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
from uuid import uuid4
|
|
37
|
+
|
|
38
|
+
from sqlalchemy import func, select
|
|
39
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
40
|
+
from sqlalchemy.orm import selectinload
|
|
41
|
+
|
|
42
|
+
from truthound_dashboard.db.models import (
|
|
43
|
+
SchemaWatcherAlertModel,
|
|
44
|
+
SchemaWatcherAlertSeverity as DBAlertSeverity,
|
|
45
|
+
SchemaWatcherAlertStatus as DBAlertStatus,
|
|
46
|
+
SchemaWatcherModel,
|
|
47
|
+
SchemaWatcherRunModel,
|
|
48
|
+
SchemaWatcherRunStatus as DBRunStatus,
|
|
49
|
+
SchemaWatcherStatus as DBStatus,
|
|
50
|
+
Source as SourceModel,
|
|
51
|
+
)
|
|
52
|
+
from truthound_dashboard.schemas.schema_watcher import (
|
|
53
|
+
CompatibilityLevel,
|
|
54
|
+
ImpactScope,
|
|
55
|
+
RenameConfidence,
|
|
56
|
+
SchemaChangeDetail,
|
|
57
|
+
SchemaChangeSeverity,
|
|
58
|
+
SchemaChangeType,
|
|
59
|
+
SchemaDetectionResponse,
|
|
60
|
+
SchemaDiffResponse,
|
|
61
|
+
SchemaHistoryResponse,
|
|
62
|
+
SchemaVersionResponse,
|
|
63
|
+
SchemaVersionSummary,
|
|
64
|
+
SchemaWatcherAlertResponse,
|
|
65
|
+
SchemaWatcherAlertSeverity,
|
|
66
|
+
SchemaWatcherAlertStatus,
|
|
67
|
+
SchemaWatcherAlertSummary,
|
|
68
|
+
SchemaWatcherCheckNowResponse,
|
|
69
|
+
SchemaWatcherCreate,
|
|
70
|
+
SchemaWatcherResponse,
|
|
71
|
+
SchemaWatcherRunResponse,
|
|
72
|
+
SchemaWatcherRunStatus,
|
|
73
|
+
SchemaWatcherRunSummary,
|
|
74
|
+
SchemaWatcherSchedulerStatus,
|
|
75
|
+
SchemaWatcherStatistics,
|
|
76
|
+
SchemaWatcherStatus,
|
|
77
|
+
SchemaWatcherSummary,
|
|
78
|
+
SchemaWatcherUpdate,
|
|
79
|
+
RenameDetectionDetail,
|
|
80
|
+
RenameDetectionResponse,
|
|
81
|
+
VersionStrategy,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
logger = logging.getLogger(__name__)
|
|
85
|
+
|
|
86
|
+
# Storage paths
|
|
87
|
+
SCHEMA_HISTORY_BASE_PATH = Path("./data/schema_history")
|
|
88
|
+
ALERT_STORAGE_PATH = Path("./data/schema_alerts.json")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _generate_id() -> str:
|
|
92
|
+
"""Generate a unique ID."""
|
|
93
|
+
return str(uuid4())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _map_db_status(status: DBStatus | str) -> SchemaWatcherStatus:
|
|
97
|
+
"""Map DB status enum to schema enum."""
|
|
98
|
+
value = status.value if hasattr(status, "value") else status
|
|
99
|
+
return SchemaWatcherStatus(value)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _map_schema_status(status: SchemaWatcherStatus | str) -> DBStatus:
|
|
103
|
+
"""Map schema status enum to DB enum."""
|
|
104
|
+
value = status.value if hasattr(status, "value") else status
|
|
105
|
+
return DBStatus(value)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _map_db_alert_status(status: DBAlertStatus | str) -> SchemaWatcherAlertStatus:
|
|
109
|
+
"""Map DB alert status enum to schema enum."""
|
|
110
|
+
value = status.value if hasattr(status, "value") else status
|
|
111
|
+
return SchemaWatcherAlertStatus(value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _map_db_alert_severity(severity: DBAlertSeverity | str) -> SchemaWatcherAlertSeverity:
|
|
115
|
+
"""Map DB alert severity enum to schema enum."""
|
|
116
|
+
value = severity.value if hasattr(severity, "value") else severity
|
|
117
|
+
return SchemaWatcherAlertSeverity(value)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _map_db_run_status(status: DBRunStatus | str) -> SchemaWatcherRunStatus:
|
|
121
|
+
"""Map DB run status enum to schema enum."""
|
|
122
|
+
value = status.value if hasattr(status, "value") else status
|
|
123
|
+
return SchemaWatcherRunStatus(value)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SchemaWatcherService:
|
|
127
|
+
"""Service for schema watcher operations.
|
|
128
|
+
|
|
129
|
+
This service provides comprehensive schema monitoring using truthound's
|
|
130
|
+
schema evolution module. It manages:
|
|
131
|
+
- Watcher configuration and lifecycle
|
|
132
|
+
- Schema change detection
|
|
133
|
+
- Version history
|
|
134
|
+
- Alerts and impact analysis
|
|
135
|
+
- Background polling
|
|
136
|
+
|
|
137
|
+
The service uses SchemaEvolutionAdapter to interact with truthound,
|
|
138
|
+
maintaining loose coupling with the underlying library.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
142
|
+
"""Initialize service.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
session: Async database session.
|
|
146
|
+
"""
|
|
147
|
+
self._session = session
|
|
148
|
+
self._adapter = None # Lazy initialization
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def adapter(self):
|
|
152
|
+
"""Get schema evolution adapter (lazy initialization)."""
|
|
153
|
+
if self._adapter is None:
|
|
154
|
+
from truthound_dashboard.core.truthound_adapter import (
|
|
155
|
+
get_schema_evolution_adapter,
|
|
156
|
+
)
|
|
157
|
+
self._adapter = get_schema_evolution_adapter()
|
|
158
|
+
return self._adapter
|
|
159
|
+
|
|
160
|
+
# =========================================================================
|
|
161
|
+
# Watcher CRUD
|
|
162
|
+
# =========================================================================
|
|
163
|
+
|
|
164
|
+
async def create_watcher(
|
|
165
|
+
self,
|
|
166
|
+
data: SchemaWatcherCreate,
|
|
167
|
+
) -> SchemaWatcherResponse:
|
|
168
|
+
"""Create a new schema watcher.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
data: Watcher creation data.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Created watcher response.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
ValueError: If source not found.
|
|
178
|
+
"""
|
|
179
|
+
# Verify source exists
|
|
180
|
+
source = await self._session.get(SourceModel, data.source_id)
|
|
181
|
+
if not source:
|
|
182
|
+
raise ValueError(f"Source '{data.source_id}' not found")
|
|
183
|
+
|
|
184
|
+
now = datetime.utcnow()
|
|
185
|
+
next_check = now + timedelta(seconds=data.poll_interval_seconds)
|
|
186
|
+
|
|
187
|
+
watcher = SchemaWatcherModel(
|
|
188
|
+
id=_generate_id(),
|
|
189
|
+
name=data.name,
|
|
190
|
+
source_id=data.source_id,
|
|
191
|
+
status=DBStatus.ACTIVE,
|
|
192
|
+
poll_interval_seconds=data.poll_interval_seconds,
|
|
193
|
+
only_breaking=data.only_breaking,
|
|
194
|
+
enable_rename_detection=data.enable_rename_detection,
|
|
195
|
+
rename_similarity_threshold=data.rename_similarity_threshold,
|
|
196
|
+
version_strategy=data.version_strategy.value,
|
|
197
|
+
notify_on_change=data.notify_on_change,
|
|
198
|
+
track_history=data.track_history,
|
|
199
|
+
next_check_at=next_check,
|
|
200
|
+
watcher_config=data.config,
|
|
201
|
+
created_at=now,
|
|
202
|
+
updated_at=now,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
self._session.add(watcher)
|
|
206
|
+
await self._session.commit()
|
|
207
|
+
await self._session.refresh(watcher)
|
|
208
|
+
|
|
209
|
+
# Initialize schema history for this watcher
|
|
210
|
+
if data.track_history:
|
|
211
|
+
history_path = SCHEMA_HISTORY_BASE_PATH / watcher.id
|
|
212
|
+
history_path.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
await self.adapter.create_history(
|
|
214
|
+
history_id=watcher.id,
|
|
215
|
+
storage_path=str(history_path),
|
|
216
|
+
version_strategy=data.version_strategy.value,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return self._to_watcher_response(watcher, source)
|
|
220
|
+
|
|
221
|
+
async def get_watcher(self, watcher_id: str) -> SchemaWatcherResponse | None:
|
|
222
|
+
"""Get a watcher by ID.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
watcher_id: Watcher ID.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Watcher response or None if not found.
|
|
229
|
+
"""
|
|
230
|
+
stmt = (
|
|
231
|
+
select(SchemaWatcherModel)
|
|
232
|
+
.options(selectinload(SchemaWatcherModel.source))
|
|
233
|
+
.where(SchemaWatcherModel.id == watcher_id)
|
|
234
|
+
)
|
|
235
|
+
result = await self._session.execute(stmt)
|
|
236
|
+
watcher = result.scalar_one_or_none()
|
|
237
|
+
|
|
238
|
+
if not watcher:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
return self._to_watcher_response(watcher, watcher.source)
|
|
242
|
+
|
|
243
|
+
async def list_watchers(
|
|
244
|
+
self,
|
|
245
|
+
*,
|
|
246
|
+
status: SchemaWatcherStatus | None = None,
|
|
247
|
+
source_id: str | None = None,
|
|
248
|
+
limit: int = 50,
|
|
249
|
+
offset: int = 0,
|
|
250
|
+
) -> tuple[list[SchemaWatcherSummary], int]:
|
|
251
|
+
"""List watchers with optional filters.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
status: Filter by status.
|
|
255
|
+
source_id: Filter by source.
|
|
256
|
+
limit: Maximum results.
|
|
257
|
+
offset: Skip first N results.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Tuple of (watchers, total_count).
|
|
261
|
+
"""
|
|
262
|
+
stmt = select(SchemaWatcherModel).options(
|
|
263
|
+
selectinload(SchemaWatcherModel.source)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if status:
|
|
267
|
+
stmt = stmt.where(
|
|
268
|
+
SchemaWatcherModel.status == _map_schema_status(status)
|
|
269
|
+
)
|
|
270
|
+
if source_id:
|
|
271
|
+
stmt = stmt.where(SchemaWatcherModel.source_id == source_id)
|
|
272
|
+
|
|
273
|
+
# Count total
|
|
274
|
+
count_stmt = select(func.count()).select_from(stmt.subquery())
|
|
275
|
+
count_result = await self._session.execute(count_stmt)
|
|
276
|
+
total = count_result.scalar() or 0
|
|
277
|
+
|
|
278
|
+
# Apply pagination
|
|
279
|
+
stmt = (
|
|
280
|
+
stmt.order_by(SchemaWatcherModel.created_at.desc())
|
|
281
|
+
.limit(limit)
|
|
282
|
+
.offset(offset)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
result = await self._session.execute(stmt)
|
|
286
|
+
watchers = result.scalars().all()
|
|
287
|
+
|
|
288
|
+
summaries = [self._to_watcher_summary(w, w.source) for w in watchers]
|
|
289
|
+
return summaries, total
|
|
290
|
+
|
|
291
|
+
async def update_watcher(
|
|
292
|
+
self,
|
|
293
|
+
watcher_id: str,
|
|
294
|
+
data: SchemaWatcherUpdate,
|
|
295
|
+
) -> SchemaWatcherResponse | None:
|
|
296
|
+
"""Update a watcher.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
watcher_id: Watcher ID.
|
|
300
|
+
data: Update data.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Updated watcher response or None if not found.
|
|
304
|
+
"""
|
|
305
|
+
stmt = (
|
|
306
|
+
select(SchemaWatcherModel)
|
|
307
|
+
.options(selectinload(SchemaWatcherModel.source))
|
|
308
|
+
.where(SchemaWatcherModel.id == watcher_id)
|
|
309
|
+
)
|
|
310
|
+
result = await self._session.execute(stmt)
|
|
311
|
+
watcher = result.scalar_one_or_none()
|
|
312
|
+
|
|
313
|
+
if not watcher:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
# Update fields
|
|
317
|
+
update_data = data.model_dump(exclude_unset=True)
|
|
318
|
+
for key, value in update_data.items():
|
|
319
|
+
if key == "version_strategy" and value:
|
|
320
|
+
setattr(watcher, key, value.value)
|
|
321
|
+
elif hasattr(watcher, key):
|
|
322
|
+
setattr(watcher, key, value)
|
|
323
|
+
|
|
324
|
+
watcher.updated_at = datetime.utcnow()
|
|
325
|
+
|
|
326
|
+
# Recalculate next_check_at if poll_interval changed
|
|
327
|
+
if data.poll_interval_seconds and watcher.status == DBStatus.ACTIVE:
|
|
328
|
+
watcher.next_check_at = datetime.utcnow() + timedelta(
|
|
329
|
+
seconds=data.poll_interval_seconds
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
await self._session.commit()
|
|
333
|
+
await self._session.refresh(watcher)
|
|
334
|
+
|
|
335
|
+
return self._to_watcher_response(watcher, watcher.source)
|
|
336
|
+
|
|
337
|
+
async def delete_watcher(self, watcher_id: str) -> bool:
|
|
338
|
+
"""Delete a watcher and all related data.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
watcher_id: Watcher ID.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
True if deleted, False if not found.
|
|
345
|
+
"""
|
|
346
|
+
watcher = await self._session.get(SchemaWatcherModel, watcher_id)
|
|
347
|
+
if not watcher:
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
# Delete related alerts and runs (cascade should handle this)
|
|
351
|
+
await self._session.delete(watcher)
|
|
352
|
+
await self._session.commit()
|
|
353
|
+
|
|
354
|
+
# Clean up adapter resources
|
|
355
|
+
try:
|
|
356
|
+
await self.adapter.delete_watcher(watcher_id)
|
|
357
|
+
except ValueError:
|
|
358
|
+
pass # Watcher not in adapter (not started)
|
|
359
|
+
|
|
360
|
+
return True
|
|
361
|
+
|
|
362
|
+
async def set_watcher_status(
|
|
363
|
+
self,
|
|
364
|
+
watcher_id: str,
|
|
365
|
+
status: SchemaWatcherStatus,
|
|
366
|
+
) -> SchemaWatcherResponse | None:
|
|
367
|
+
"""Change watcher status.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
watcher_id: Watcher ID.
|
|
371
|
+
status: New status.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Updated watcher response or None if not found.
|
|
375
|
+
"""
|
|
376
|
+
stmt = (
|
|
377
|
+
select(SchemaWatcherModel)
|
|
378
|
+
.options(selectinload(SchemaWatcherModel.source))
|
|
379
|
+
.where(SchemaWatcherModel.id == watcher_id)
|
|
380
|
+
)
|
|
381
|
+
result = await self._session.execute(stmt)
|
|
382
|
+
watcher = result.scalar_one_or_none()
|
|
383
|
+
|
|
384
|
+
if not watcher:
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
old_status = watcher.status
|
|
388
|
+
watcher.status = _map_schema_status(status)
|
|
389
|
+
watcher.updated_at = datetime.utcnow()
|
|
390
|
+
|
|
391
|
+
# Update next_check_at based on status change
|
|
392
|
+
if status == SchemaWatcherStatus.ACTIVE:
|
|
393
|
+
watcher.next_check_at = datetime.utcnow() + timedelta(
|
|
394
|
+
seconds=watcher.poll_interval_seconds
|
|
395
|
+
)
|
|
396
|
+
watcher.error_count = 0
|
|
397
|
+
elif status in (SchemaWatcherStatus.PAUSED, SchemaWatcherStatus.STOPPED):
|
|
398
|
+
watcher.next_check_at = None
|
|
399
|
+
|
|
400
|
+
await self._session.commit()
|
|
401
|
+
await self._session.refresh(watcher)
|
|
402
|
+
|
|
403
|
+
# Update adapter state
|
|
404
|
+
try:
|
|
405
|
+
if status == SchemaWatcherStatus.ACTIVE and old_status != DBStatus.ACTIVE:
|
|
406
|
+
await self.adapter.resume_watcher(watcher_id)
|
|
407
|
+
elif status == SchemaWatcherStatus.PAUSED:
|
|
408
|
+
await self.adapter.pause_watcher(watcher_id)
|
|
409
|
+
elif status == SchemaWatcherStatus.STOPPED:
|
|
410
|
+
await self.adapter.stop_watcher(watcher_id)
|
|
411
|
+
except ValueError:
|
|
412
|
+
pass # Watcher not in adapter
|
|
413
|
+
|
|
414
|
+
return self._to_watcher_response(watcher, watcher.source)
|
|
415
|
+
|
|
416
|
+
# =========================================================================
|
|
417
|
+
# Schema Detection
|
|
418
|
+
# =========================================================================
|
|
419
|
+
|
|
420
|
+
async def detect_changes(
|
|
421
|
+
self,
|
|
422
|
+
current_schema: dict[str, Any],
|
|
423
|
+
baseline_schema: dict[str, Any],
|
|
424
|
+
*,
|
|
425
|
+
detect_renames: bool = True,
|
|
426
|
+
rename_similarity_threshold: float = 0.8,
|
|
427
|
+
) -> SchemaDetectionResponse:
|
|
428
|
+
"""Detect schema changes between two schemas.
|
|
429
|
+
|
|
430
|
+
Uses truthound's SchemaEvolutionDetector for comprehensive change
|
|
431
|
+
detection including additions, removals, type changes, and renames.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
current_schema: Current schema {column: type}.
|
|
435
|
+
baseline_schema: Baseline schema {column: type}.
|
|
436
|
+
detect_renames: Enable rename detection.
|
|
437
|
+
rename_similarity_threshold: Similarity threshold for renames.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
SchemaDetectionResponse with all detected changes.
|
|
441
|
+
"""
|
|
442
|
+
result = await self.adapter.detect_changes(
|
|
443
|
+
current_schema,
|
|
444
|
+
baseline_schema,
|
|
445
|
+
detect_renames=detect_renames,
|
|
446
|
+
rename_similarity_threshold=rename_similarity_threshold,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
changes = [
|
|
450
|
+
SchemaChangeDetail(
|
|
451
|
+
change_type=SchemaChangeType(c.change_type),
|
|
452
|
+
column_name=c.column_name,
|
|
453
|
+
old_value=c.old_value,
|
|
454
|
+
new_value=c.new_value,
|
|
455
|
+
severity=SchemaChangeSeverity(c.severity),
|
|
456
|
+
breaking=c.breaking,
|
|
457
|
+
description=c.description,
|
|
458
|
+
migration_hint=c.migration_hint,
|
|
459
|
+
)
|
|
460
|
+
for c in result.changes
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
return SchemaDetectionResponse(
|
|
464
|
+
total_changes=result.total_changes,
|
|
465
|
+
breaking_changes=result.breaking_changes,
|
|
466
|
+
compatibility_level=CompatibilityLevel(result.compatibility_level),
|
|
467
|
+
changes=changes,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
async def detect_renames(
|
|
471
|
+
self,
|
|
472
|
+
added_columns: dict[str, str],
|
|
473
|
+
removed_columns: dict[str, str],
|
|
474
|
+
*,
|
|
475
|
+
similarity_threshold: float = 0.8,
|
|
476
|
+
require_type_match: bool = True,
|
|
477
|
+
allow_compatible_types: bool = True,
|
|
478
|
+
algorithm: str = "composite",
|
|
479
|
+
) -> RenameDetectionResponse:
|
|
480
|
+
"""Detect column renames.
|
|
481
|
+
|
|
482
|
+
Uses truthound's ColumnRenameDetector with configurable similarity
|
|
483
|
+
algorithms.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
added_columns: Added columns {name: type}.
|
|
487
|
+
removed_columns: Removed columns {name: type}.
|
|
488
|
+
similarity_threshold: Similarity threshold (0.5-1.0).
|
|
489
|
+
require_type_match: Require matching types.
|
|
490
|
+
allow_compatible_types: Allow compatible types.
|
|
491
|
+
algorithm: Similarity algorithm.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
RenameDetectionResponse with detected renames.
|
|
495
|
+
"""
|
|
496
|
+
result = await self.adapter.detect_renames(
|
|
497
|
+
added_columns,
|
|
498
|
+
removed_columns,
|
|
499
|
+
similarity_threshold=similarity_threshold,
|
|
500
|
+
require_type_match=require_type_match,
|
|
501
|
+
allow_compatible_types=allow_compatible_types,
|
|
502
|
+
algorithm=algorithm,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
confirmed = [
|
|
506
|
+
RenameDetectionDetail(
|
|
507
|
+
old_name=r.old_name,
|
|
508
|
+
new_name=r.new_name,
|
|
509
|
+
similarity=r.similarity,
|
|
510
|
+
confidence=RenameConfidence(r.confidence),
|
|
511
|
+
reasons=r.reasons,
|
|
512
|
+
)
|
|
513
|
+
for r in result.confirmed_renames
|
|
514
|
+
]
|
|
515
|
+
|
|
516
|
+
possible = [
|
|
517
|
+
RenameDetectionDetail(
|
|
518
|
+
old_name=r.old_name,
|
|
519
|
+
new_name=r.new_name,
|
|
520
|
+
similarity=r.similarity,
|
|
521
|
+
confidence=RenameConfidence(r.confidence),
|
|
522
|
+
reasons=r.reasons,
|
|
523
|
+
)
|
|
524
|
+
for r in result.possible_renames
|
|
525
|
+
]
|
|
526
|
+
|
|
527
|
+
return RenameDetectionResponse(
|
|
528
|
+
confirmed_renames=confirmed,
|
|
529
|
+
possible_renames=possible,
|
|
530
|
+
unmatched_added=result.unmatched_added,
|
|
531
|
+
unmatched_removed=result.unmatched_removed,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# =========================================================================
|
|
535
|
+
# Version History
|
|
536
|
+
# =========================================================================
|
|
537
|
+
|
|
538
|
+
async def save_schema_version(
|
|
539
|
+
self,
|
|
540
|
+
watcher_id: str,
|
|
541
|
+
schema: dict[str, Any],
|
|
542
|
+
*,
|
|
543
|
+
version: str | None = None,
|
|
544
|
+
metadata: dict[str, Any] | None = None,
|
|
545
|
+
) -> SchemaVersionResponse:
|
|
546
|
+
"""Save a schema version to history.
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
watcher_id: Watcher ID (used as history ID).
|
|
550
|
+
schema: Schema dictionary.
|
|
551
|
+
version: Optional explicit version.
|
|
552
|
+
metadata: Optional metadata.
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
SchemaVersionResponse with version info.
|
|
556
|
+
"""
|
|
557
|
+
result = await self.adapter.save_schema_version(
|
|
558
|
+
history_id=watcher_id,
|
|
559
|
+
schema=schema,
|
|
560
|
+
version=version,
|
|
561
|
+
metadata=metadata,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
changes = None
|
|
565
|
+
if result.changes_from_parent:
|
|
566
|
+
changes = [
|
|
567
|
+
SchemaChangeDetail(
|
|
568
|
+
change_type=SchemaChangeType(c.change_type),
|
|
569
|
+
column_name=c.column_name,
|
|
570
|
+
old_value=c.old_value,
|
|
571
|
+
new_value=c.new_value,
|
|
572
|
+
severity=SchemaChangeSeverity(c.severity),
|
|
573
|
+
breaking=c.breaking,
|
|
574
|
+
description=c.description,
|
|
575
|
+
migration_hint=c.migration_hint,
|
|
576
|
+
)
|
|
577
|
+
for c in result.changes_from_parent
|
|
578
|
+
]
|
|
579
|
+
|
|
580
|
+
return SchemaVersionResponse(
|
|
581
|
+
id=result.id,
|
|
582
|
+
version=result.version,
|
|
583
|
+
schema=result.schema,
|
|
584
|
+
metadata=result.metadata,
|
|
585
|
+
created_at=datetime.fromisoformat(result.created_at) if result.created_at else None,
|
|
586
|
+
has_breaking_changes=result.has_breaking_changes,
|
|
587
|
+
changes_from_parent=changes,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
async def get_schema_version(
|
|
591
|
+
self,
|
|
592
|
+
watcher_id: str,
|
|
593
|
+
version: str,
|
|
594
|
+
) -> SchemaVersionResponse | None:
|
|
595
|
+
"""Get a specific schema version.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
watcher_id: Watcher ID.
|
|
599
|
+
version: Version string or ID.
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
SchemaVersionResponse or None.
|
|
603
|
+
"""
|
|
604
|
+
result = await self.adapter.get_schema_version(watcher_id, version)
|
|
605
|
+
if not result:
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
return SchemaVersionResponse(
|
|
609
|
+
id=result.id,
|
|
610
|
+
version=result.version,
|
|
611
|
+
schema=result.schema,
|
|
612
|
+
metadata=result.metadata,
|
|
613
|
+
created_at=datetime.fromisoformat(result.created_at) if result.created_at else None,
|
|
614
|
+
has_breaking_changes=result.has_breaking_changes,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
async def list_schema_versions(
|
|
618
|
+
self,
|
|
619
|
+
watcher_id: str,
|
|
620
|
+
*,
|
|
621
|
+
limit: int = 50,
|
|
622
|
+
) -> list[SchemaVersionSummary]:
|
|
623
|
+
"""List schema versions.
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
watcher_id: Watcher ID.
|
|
627
|
+
limit: Maximum versions.
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
List of SchemaVersionSummary.
|
|
631
|
+
"""
|
|
632
|
+
versions = await self.adapter.list_schema_versions(
|
|
633
|
+
history_id=watcher_id,
|
|
634
|
+
limit=limit,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
return [
|
|
638
|
+
SchemaVersionSummary(
|
|
639
|
+
id=v.id,
|
|
640
|
+
version=v.version,
|
|
641
|
+
column_count=len(v.schema) if v.schema else 0,
|
|
642
|
+
created_at=datetime.fromisoformat(v.created_at) if v.created_at else None,
|
|
643
|
+
has_breaking_changes=v.has_breaking_changes,
|
|
644
|
+
)
|
|
645
|
+
for v in versions
|
|
646
|
+
]
|
|
647
|
+
|
|
648
|
+
async def diff_versions(
|
|
649
|
+
self,
|
|
650
|
+
watcher_id: str,
|
|
651
|
+
from_version: str,
|
|
652
|
+
to_version: str | None = None,
|
|
653
|
+
) -> SchemaDiffResponse:
|
|
654
|
+
"""Get diff between schema versions.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
watcher_id: Watcher ID.
|
|
658
|
+
from_version: Source version.
|
|
659
|
+
to_version: Target version (None = latest).
|
|
660
|
+
|
|
661
|
+
Returns:
|
|
662
|
+
SchemaDiffResponse with changes.
|
|
663
|
+
"""
|
|
664
|
+
result = await self.adapter.diff_versions(
|
|
665
|
+
history_id=watcher_id,
|
|
666
|
+
from_version=from_version,
|
|
667
|
+
to_version=to_version,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
changes = [
|
|
671
|
+
SchemaChangeDetail(
|
|
672
|
+
change_type=SchemaChangeType(c.change_type),
|
|
673
|
+
column_name=c.column_name,
|
|
674
|
+
old_value=c.old_value,
|
|
675
|
+
new_value=c.new_value,
|
|
676
|
+
severity=SchemaChangeSeverity(c.severity),
|
|
677
|
+
breaking=c.breaking,
|
|
678
|
+
description=c.description,
|
|
679
|
+
migration_hint=c.migration_hint,
|
|
680
|
+
)
|
|
681
|
+
for c in result.changes
|
|
682
|
+
]
|
|
683
|
+
|
|
684
|
+
return SchemaDiffResponse(
|
|
685
|
+
from_version=result.from_version,
|
|
686
|
+
to_version=result.to_version,
|
|
687
|
+
changes=changes,
|
|
688
|
+
text_diff=result.text_diff,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
async def rollback_version(
|
|
692
|
+
self,
|
|
693
|
+
watcher_id: str,
|
|
694
|
+
to_version: str,
|
|
695
|
+
*,
|
|
696
|
+
reason: str | None = None,
|
|
697
|
+
) -> SchemaVersionResponse:
|
|
698
|
+
"""Rollback to a previous version.
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
watcher_id: Watcher ID.
|
|
702
|
+
to_version: Version to rollback to.
|
|
703
|
+
reason: Reason for rollback.
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
New SchemaVersionResponse after rollback.
|
|
707
|
+
"""
|
|
708
|
+
result = await self.adapter.rollback_version(
|
|
709
|
+
history_id=watcher_id,
|
|
710
|
+
to_version=to_version,
|
|
711
|
+
reason=reason,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
return SchemaVersionResponse(
|
|
715
|
+
id=result.id,
|
|
716
|
+
version=result.version,
|
|
717
|
+
schema=result.schema,
|
|
718
|
+
metadata=result.metadata,
|
|
719
|
+
created_at=datetime.fromisoformat(result.created_at) if result.created_at else None,
|
|
720
|
+
has_breaking_changes=result.has_breaking_changes,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
# =========================================================================
|
|
724
|
+
# Check Now (Immediate Execution)
|
|
725
|
+
# =========================================================================
|
|
726
|
+
|
|
727
|
+
async def check_now(
|
|
728
|
+
self,
|
|
729
|
+
watcher_id: str,
|
|
730
|
+
) -> SchemaWatcherCheckNowResponse:
|
|
731
|
+
"""Execute immediate check for a watcher.
|
|
732
|
+
|
|
733
|
+
This performs a full schema check:
|
|
734
|
+
1. Get current schema from source
|
|
735
|
+
2. Compare with previous version
|
|
736
|
+
3. Save new version if changes detected
|
|
737
|
+
4. Create alert if breaking changes
|
|
738
|
+
5. Update watcher state
|
|
739
|
+
|
|
740
|
+
Args:
|
|
741
|
+
watcher_id: Watcher ID.
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
SchemaWatcherCheckNowResponse with results.
|
|
745
|
+
|
|
746
|
+
Raises:
|
|
747
|
+
ValueError: If watcher not found.
|
|
748
|
+
"""
|
|
749
|
+
from truthound_dashboard.core.truthound_adapter import get_adapter
|
|
750
|
+
|
|
751
|
+
# Get watcher
|
|
752
|
+
stmt = (
|
|
753
|
+
select(SchemaWatcherModel)
|
|
754
|
+
.options(selectinload(SchemaWatcherModel.source))
|
|
755
|
+
.where(SchemaWatcherModel.id == watcher_id)
|
|
756
|
+
)
|
|
757
|
+
result = await self._session.execute(stmt)
|
|
758
|
+
watcher = result.scalar_one_or_none()
|
|
759
|
+
|
|
760
|
+
if not watcher:
|
|
761
|
+
raise ValueError(f"Watcher '{watcher_id}' not found")
|
|
762
|
+
|
|
763
|
+
source = watcher.source
|
|
764
|
+
if not source:
|
|
765
|
+
raise ValueError(f"Source for watcher '{watcher_id}' not found")
|
|
766
|
+
|
|
767
|
+
# Create run record
|
|
768
|
+
run = SchemaWatcherRunModel(
|
|
769
|
+
id=_generate_id(),
|
|
770
|
+
watcher_id=watcher_id,
|
|
771
|
+
source_id=source.id,
|
|
772
|
+
started_at=datetime.utcnow(),
|
|
773
|
+
status=DBRunStatus.RUNNING,
|
|
774
|
+
)
|
|
775
|
+
self._session.add(run)
|
|
776
|
+
await self._session.flush()
|
|
777
|
+
|
|
778
|
+
try:
|
|
779
|
+
# Learn current schema from source
|
|
780
|
+
adapter = get_adapter()
|
|
781
|
+
learn_result = await adapter.learn(
|
|
782
|
+
source.path,
|
|
783
|
+
infer_constraints=True,
|
|
784
|
+
categorical_threshold=watcher.watcher_config.get(
|
|
785
|
+
"categorical_threshold", 20
|
|
786
|
+
) if watcher.watcher_config else 20,
|
|
787
|
+
)
|
|
788
|
+
current_schema = learn_result.schema.get("columns", {})
|
|
789
|
+
|
|
790
|
+
# Get previous version
|
|
791
|
+
previous_version = await self.adapter.get_latest_version(watcher_id)
|
|
792
|
+
|
|
793
|
+
changes_detected = 0
|
|
794
|
+
breaking_detected = 0
|
|
795
|
+
alert_id = None
|
|
796
|
+
version_id = None
|
|
797
|
+
|
|
798
|
+
if previous_version:
|
|
799
|
+
# Detect changes
|
|
800
|
+
detection = await self.detect_changes(
|
|
801
|
+
current_schema,
|
|
802
|
+
previous_version.schema,
|
|
803
|
+
detect_renames=watcher.enable_rename_detection,
|
|
804
|
+
rename_similarity_threshold=watcher.rename_similarity_threshold,
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
changes_detected = detection.total_changes
|
|
808
|
+
breaking_detected = detection.breaking_changes
|
|
809
|
+
|
|
810
|
+
# Save new version if changes
|
|
811
|
+
if changes_detected > 0:
|
|
812
|
+
new_version = await self.save_schema_version(
|
|
813
|
+
watcher_id,
|
|
814
|
+
current_schema,
|
|
815
|
+
metadata={"source_id": source.id, "run_id": run.id},
|
|
816
|
+
)
|
|
817
|
+
version_id = new_version.id
|
|
818
|
+
|
|
819
|
+
# Create alert if needed
|
|
820
|
+
should_alert = (
|
|
821
|
+
not watcher.only_breaking or breaking_detected > 0
|
|
822
|
+
)
|
|
823
|
+
if should_alert:
|
|
824
|
+
alert = await self._create_alert(
|
|
825
|
+
watcher=watcher,
|
|
826
|
+
source=source,
|
|
827
|
+
from_version_id=previous_version.id,
|
|
828
|
+
to_version_id=new_version.id,
|
|
829
|
+
detection=detection,
|
|
830
|
+
)
|
|
831
|
+
alert_id = alert.id
|
|
832
|
+
|
|
833
|
+
watcher.last_change_at = datetime.utcnow()
|
|
834
|
+
watcher.change_count += 1
|
|
835
|
+
else:
|
|
836
|
+
# First version
|
|
837
|
+
new_version = await self.save_schema_version(
|
|
838
|
+
watcher_id,
|
|
839
|
+
current_schema,
|
|
840
|
+
metadata={"source_id": source.id, "run_id": run.id, "initial": True},
|
|
841
|
+
)
|
|
842
|
+
version_id = new_version.id
|
|
843
|
+
|
|
844
|
+
# Update run
|
|
845
|
+
run.status = DBRunStatus.COMPLETED
|
|
846
|
+
run.completed_at = datetime.utcnow()
|
|
847
|
+
run.changes_detected = changes_detected
|
|
848
|
+
run.breaking_detected = breaking_detected
|
|
849
|
+
run.version_created_id = version_id
|
|
850
|
+
run.alert_created_id = alert_id
|
|
851
|
+
run.duration_ms = (
|
|
852
|
+
run.completed_at - run.started_at
|
|
853
|
+
).total_seconds() * 1000
|
|
854
|
+
|
|
855
|
+
# Update watcher
|
|
856
|
+
watcher.last_check_at = datetime.utcnow()
|
|
857
|
+
watcher.check_count += 1
|
|
858
|
+
watcher.next_check_at = datetime.utcnow() + timedelta(
|
|
859
|
+
seconds=watcher.poll_interval_seconds
|
|
860
|
+
)
|
|
861
|
+
watcher.error_count = 0
|
|
862
|
+
watcher.last_error = None
|
|
863
|
+
watcher.updated_at = datetime.utcnow()
|
|
864
|
+
|
|
865
|
+
await self._session.commit()
|
|
866
|
+
|
|
867
|
+
return SchemaWatcherCheckNowResponse(
|
|
868
|
+
watcher_id=watcher_id,
|
|
869
|
+
run_id=run.id,
|
|
870
|
+
status=SchemaWatcherRunStatus.COMPLETED,
|
|
871
|
+
changes_detected=changes_detected,
|
|
872
|
+
breaking_detected=breaking_detected,
|
|
873
|
+
alert_created_id=alert_id,
|
|
874
|
+
version_created_id=version_id,
|
|
875
|
+
duration_ms=run.duration_ms,
|
|
876
|
+
message=f"Check completed: {changes_detected} changes, {breaking_detected} breaking",
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
except Exception as e:
|
|
880
|
+
# Update run as failed
|
|
881
|
+
run.status = DBRunStatus.FAILED
|
|
882
|
+
run.completed_at = datetime.utcnow()
|
|
883
|
+
run.error_message = str(e)
|
|
884
|
+
run.duration_ms = (
|
|
885
|
+
run.completed_at - run.started_at
|
|
886
|
+
).total_seconds() * 1000
|
|
887
|
+
|
|
888
|
+
# Update watcher error state
|
|
889
|
+
watcher.error_count += 1
|
|
890
|
+
watcher.last_error = str(e)
|
|
891
|
+
watcher.updated_at = datetime.utcnow()
|
|
892
|
+
|
|
893
|
+
# Set to error status after 3 consecutive failures
|
|
894
|
+
if watcher.error_count >= 3:
|
|
895
|
+
watcher.status = DBStatus.ERROR
|
|
896
|
+
|
|
897
|
+
await self._session.commit()
|
|
898
|
+
|
|
899
|
+
return SchemaWatcherCheckNowResponse(
|
|
900
|
+
watcher_id=watcher_id,
|
|
901
|
+
run_id=run.id,
|
|
902
|
+
status=SchemaWatcherRunStatus.FAILED,
|
|
903
|
+
changes_detected=0,
|
|
904
|
+
breaking_detected=0,
|
|
905
|
+
duration_ms=run.duration_ms,
|
|
906
|
+
message=f"Check failed: {str(e)}",
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
async def _create_alert(
|
|
910
|
+
self,
|
|
911
|
+
watcher: SchemaWatcherModel,
|
|
912
|
+
source: SourceModel,
|
|
913
|
+
from_version_id: str | None,
|
|
914
|
+
to_version_id: str,
|
|
915
|
+
detection: SchemaDetectionResponse,
|
|
916
|
+
) -> SchemaWatcherAlertModel:
|
|
917
|
+
"""Create an alert for detected changes.
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
watcher: Watcher model.
|
|
921
|
+
source: Source model.
|
|
922
|
+
from_version_id: Previous version ID.
|
|
923
|
+
to_version_id: New version ID.
|
|
924
|
+
detection: Detection result.
|
|
925
|
+
|
|
926
|
+
Returns:
|
|
927
|
+
Created alert model.
|
|
928
|
+
"""
|
|
929
|
+
# Determine severity
|
|
930
|
+
if detection.breaking_changes >= 3:
|
|
931
|
+
severity = DBAlertSeverity.CRITICAL
|
|
932
|
+
elif detection.breaking_changes >= 1:
|
|
933
|
+
severity = DBAlertSeverity.HIGH
|
|
934
|
+
elif detection.total_changes >= 5:
|
|
935
|
+
severity = DBAlertSeverity.MEDIUM
|
|
936
|
+
else:
|
|
937
|
+
severity = DBAlertSeverity.LOW
|
|
938
|
+
|
|
939
|
+
# Generate title
|
|
940
|
+
if detection.breaking_changes > 0:
|
|
941
|
+
title = f"🚨 {detection.breaking_changes} breaking changes in {source.name}"
|
|
942
|
+
else:
|
|
943
|
+
title = f"Schema changed: {detection.total_changes} changes in {source.name}"
|
|
944
|
+
|
|
945
|
+
# Generate recommendations
|
|
946
|
+
recommendations = []
|
|
947
|
+
for change in detection.changes[:5]:
|
|
948
|
+
if change.breaking:
|
|
949
|
+
recommendations.append(f"Review: {change.description}")
|
|
950
|
+
if change.migration_hint:
|
|
951
|
+
recommendations.append(f"Hint: {change.migration_hint}")
|
|
952
|
+
|
|
953
|
+
# Serialize changes
|
|
954
|
+
changes_summary = {
|
|
955
|
+
"total_changes": detection.total_changes,
|
|
956
|
+
"breaking_changes": detection.breaking_changes,
|
|
957
|
+
"compatibility_level": detection.compatibility_level.value,
|
|
958
|
+
"changes": [
|
|
959
|
+
{
|
|
960
|
+
"change_type": c.change_type.value,
|
|
961
|
+
"column_name": c.column_name,
|
|
962
|
+
"old_value": c.old_value,
|
|
963
|
+
"new_value": c.new_value,
|
|
964
|
+
"severity": c.severity.value,
|
|
965
|
+
"breaking": c.breaking,
|
|
966
|
+
"description": c.description,
|
|
967
|
+
}
|
|
968
|
+
for c in detection.changes
|
|
969
|
+
],
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
alert = SchemaWatcherAlertModel(
|
|
973
|
+
id=_generate_id(),
|
|
974
|
+
watcher_id=watcher.id,
|
|
975
|
+
source_id=source.id,
|
|
976
|
+
from_version_id=from_version_id,
|
|
977
|
+
to_version_id=to_version_id,
|
|
978
|
+
title=title,
|
|
979
|
+
severity=severity,
|
|
980
|
+
status=DBAlertStatus.OPEN,
|
|
981
|
+
total_changes=detection.total_changes,
|
|
982
|
+
breaking_changes=detection.breaking_changes,
|
|
983
|
+
changes_summary=changes_summary,
|
|
984
|
+
impact_scope="local", # Can be enhanced with ImpactAnalyzer
|
|
985
|
+
recommendations=recommendations,
|
|
986
|
+
created_at=datetime.utcnow(),
|
|
987
|
+
updated_at=datetime.utcnow(),
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
self._session.add(alert)
|
|
991
|
+
await self._session.flush()
|
|
992
|
+
|
|
993
|
+
return alert
|
|
994
|
+
|
|
995
|
+
# =========================================================================
|
|
996
|
+
# Alerts
|
|
997
|
+
# =========================================================================
|
|
998
|
+
|
|
999
|
+
async def get_alert(self, alert_id: str) -> SchemaWatcherAlertResponse | None:
|
|
1000
|
+
"""Get an alert by ID.
|
|
1001
|
+
|
|
1002
|
+
Args:
|
|
1003
|
+
alert_id: Alert ID.
|
|
1004
|
+
|
|
1005
|
+
Returns:
|
|
1006
|
+
Alert response or None.
|
|
1007
|
+
"""
|
|
1008
|
+
alert = await self._session.get(SchemaWatcherAlertModel, alert_id)
|
|
1009
|
+
if not alert:
|
|
1010
|
+
return None
|
|
1011
|
+
|
|
1012
|
+
# Load related data
|
|
1013
|
+
source = await self._session.get(SourceModel, alert.source_id)
|
|
1014
|
+
watcher = await self._session.get(SchemaWatcherModel, alert.watcher_id)
|
|
1015
|
+
|
|
1016
|
+
return self._to_alert_response(alert, source, watcher)
|
|
1017
|
+
|
|
1018
|
+
async def list_alerts(
|
|
1019
|
+
self,
|
|
1020
|
+
*,
|
|
1021
|
+
watcher_id: str | None = None,
|
|
1022
|
+
status: SchemaWatcherAlertStatus | None = None,
|
|
1023
|
+
severity: SchemaWatcherAlertSeverity | None = None,
|
|
1024
|
+
limit: int = 50,
|
|
1025
|
+
offset: int = 0,
|
|
1026
|
+
) -> tuple[list[SchemaWatcherAlertSummary], int]:
|
|
1027
|
+
"""List alerts with filters.
|
|
1028
|
+
|
|
1029
|
+
Args:
|
|
1030
|
+
watcher_id: Filter by watcher.
|
|
1031
|
+
status: Filter by status.
|
|
1032
|
+
severity: Filter by severity.
|
|
1033
|
+
limit: Maximum results.
|
|
1034
|
+
offset: Skip first N results.
|
|
1035
|
+
|
|
1036
|
+
Returns:
|
|
1037
|
+
Tuple of (alerts, total_count).
|
|
1038
|
+
"""
|
|
1039
|
+
stmt = select(SchemaWatcherAlertModel)
|
|
1040
|
+
|
|
1041
|
+
if watcher_id:
|
|
1042
|
+
stmt = stmt.where(SchemaWatcherAlertModel.watcher_id == watcher_id)
|
|
1043
|
+
if status:
|
|
1044
|
+
stmt = stmt.where(
|
|
1045
|
+
SchemaWatcherAlertModel.status == DBAlertStatus(status.value)
|
|
1046
|
+
)
|
|
1047
|
+
if severity:
|
|
1048
|
+
stmt = stmt.where(
|
|
1049
|
+
SchemaWatcherAlertModel.severity == DBAlertSeverity(severity.value)
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
# Count total
|
|
1053
|
+
count_stmt = select(func.count()).select_from(stmt.subquery())
|
|
1054
|
+
count_result = await self._session.execute(count_stmt)
|
|
1055
|
+
total = count_result.scalar() or 0
|
|
1056
|
+
|
|
1057
|
+
# Apply pagination
|
|
1058
|
+
stmt = (
|
|
1059
|
+
stmt.order_by(SchemaWatcherAlertModel.created_at.desc())
|
|
1060
|
+
.limit(limit)
|
|
1061
|
+
.offset(offset)
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
result = await self._session.execute(stmt)
|
|
1065
|
+
alerts = result.scalars().all()
|
|
1066
|
+
|
|
1067
|
+
# Load source names
|
|
1068
|
+
summaries = []
|
|
1069
|
+
for alert in alerts:
|
|
1070
|
+
source = await self._session.get(SourceModel, alert.source_id)
|
|
1071
|
+
summaries.append(self._to_alert_summary(alert, source))
|
|
1072
|
+
|
|
1073
|
+
return summaries, total
|
|
1074
|
+
|
|
1075
|
+
async def acknowledge_alert(
|
|
1076
|
+
self,
|
|
1077
|
+
alert_id: str,
|
|
1078
|
+
*,
|
|
1079
|
+
acknowledged_by: str | None = None,
|
|
1080
|
+
) -> SchemaWatcherAlertResponse | None:
|
|
1081
|
+
"""Acknowledge an alert.
|
|
1082
|
+
|
|
1083
|
+
Args:
|
|
1084
|
+
alert_id: Alert ID.
|
|
1085
|
+
acknowledged_by: Who acknowledged.
|
|
1086
|
+
|
|
1087
|
+
Returns:
|
|
1088
|
+
Updated alert or None.
|
|
1089
|
+
"""
|
|
1090
|
+
alert = await self._session.get(SchemaWatcherAlertModel, alert_id)
|
|
1091
|
+
if not alert:
|
|
1092
|
+
return None
|
|
1093
|
+
|
|
1094
|
+
alert.status = DBAlertStatus.ACKNOWLEDGED
|
|
1095
|
+
alert.acknowledged_at = datetime.utcnow()
|
|
1096
|
+
alert.acknowledged_by = acknowledged_by
|
|
1097
|
+
alert.updated_at = datetime.utcnow()
|
|
1098
|
+
|
|
1099
|
+
await self._session.commit()
|
|
1100
|
+
await self._session.refresh(alert)
|
|
1101
|
+
|
|
1102
|
+
source = await self._session.get(SourceModel, alert.source_id)
|
|
1103
|
+
watcher = await self._session.get(SchemaWatcherModel, alert.watcher_id)
|
|
1104
|
+
|
|
1105
|
+
return self._to_alert_response(alert, source, watcher)
|
|
1106
|
+
|
|
1107
|
+
async def resolve_alert(
|
|
1108
|
+
self,
|
|
1109
|
+
alert_id: str,
|
|
1110
|
+
*,
|
|
1111
|
+
resolved_by: str | None = None,
|
|
1112
|
+
resolution_notes: str | None = None,
|
|
1113
|
+
) -> SchemaWatcherAlertResponse | None:
|
|
1114
|
+
"""Resolve an alert.
|
|
1115
|
+
|
|
1116
|
+
Args:
|
|
1117
|
+
alert_id: Alert ID.
|
|
1118
|
+
resolved_by: Who resolved.
|
|
1119
|
+
resolution_notes: Notes about resolution.
|
|
1120
|
+
|
|
1121
|
+
Returns:
|
|
1122
|
+
Updated alert or None.
|
|
1123
|
+
"""
|
|
1124
|
+
alert = await self._session.get(SchemaWatcherAlertModel, alert_id)
|
|
1125
|
+
if not alert:
|
|
1126
|
+
return None
|
|
1127
|
+
|
|
1128
|
+
alert.status = DBAlertStatus.RESOLVED
|
|
1129
|
+
alert.resolved_at = datetime.utcnow()
|
|
1130
|
+
alert.resolved_by = resolved_by
|
|
1131
|
+
alert.resolution_notes = resolution_notes
|
|
1132
|
+
alert.updated_at = datetime.utcnow()
|
|
1133
|
+
|
|
1134
|
+
await self._session.commit()
|
|
1135
|
+
await self._session.refresh(alert)
|
|
1136
|
+
|
|
1137
|
+
source = await self._session.get(SourceModel, alert.source_id)
|
|
1138
|
+
watcher = await self._session.get(SchemaWatcherModel, alert.watcher_id)
|
|
1139
|
+
|
|
1140
|
+
return self._to_alert_response(alert, source, watcher)
|
|
1141
|
+
|
|
1142
|
+
# =========================================================================
|
|
1143
|
+
# Runs
|
|
1144
|
+
# =========================================================================
|
|
1145
|
+
|
|
1146
|
+
async def get_run(self, run_id: str) -> SchemaWatcherRunResponse | None:
|
|
1147
|
+
"""Get a run by ID.
|
|
1148
|
+
|
|
1149
|
+
Args:
|
|
1150
|
+
run_id: Run ID.
|
|
1151
|
+
|
|
1152
|
+
Returns:
|
|
1153
|
+
Run response or None.
|
|
1154
|
+
"""
|
|
1155
|
+
run = await self._session.get(SchemaWatcherRunModel, run_id)
|
|
1156
|
+
if not run:
|
|
1157
|
+
return None
|
|
1158
|
+
|
|
1159
|
+
source = await self._session.get(SourceModel, run.source_id)
|
|
1160
|
+
watcher = await self._session.get(SchemaWatcherModel, run.watcher_id)
|
|
1161
|
+
|
|
1162
|
+
return self._to_run_response(run, source, watcher)
|
|
1163
|
+
|
|
1164
|
+
async def list_runs(
|
|
1165
|
+
self,
|
|
1166
|
+
*,
|
|
1167
|
+
watcher_id: str | None = None,
|
|
1168
|
+
status: SchemaWatcherRunStatus | None = None,
|
|
1169
|
+
limit: int = 50,
|
|
1170
|
+
offset: int = 0,
|
|
1171
|
+
) -> tuple[list[SchemaWatcherRunSummary], int]:
|
|
1172
|
+
"""List runs with filters.
|
|
1173
|
+
|
|
1174
|
+
Args:
|
|
1175
|
+
watcher_id: Filter by watcher.
|
|
1176
|
+
status: Filter by status.
|
|
1177
|
+
limit: Maximum results.
|
|
1178
|
+
offset: Skip first N results.
|
|
1179
|
+
|
|
1180
|
+
Returns:
|
|
1181
|
+
Tuple of (runs, total_count).
|
|
1182
|
+
"""
|
|
1183
|
+
stmt = select(SchemaWatcherRunModel)
|
|
1184
|
+
|
|
1185
|
+
if watcher_id:
|
|
1186
|
+
stmt = stmt.where(SchemaWatcherRunModel.watcher_id == watcher_id)
|
|
1187
|
+
if status:
|
|
1188
|
+
stmt = stmt.where(
|
|
1189
|
+
SchemaWatcherRunModel.status == DBRunStatus(status.value)
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
# Count total
|
|
1193
|
+
count_stmt = select(func.count()).select_from(stmt.subquery())
|
|
1194
|
+
count_result = await self._session.execute(count_stmt)
|
|
1195
|
+
total = count_result.scalar() or 0
|
|
1196
|
+
|
|
1197
|
+
# Apply pagination
|
|
1198
|
+
stmt = (
|
|
1199
|
+
stmt.order_by(SchemaWatcherRunModel.started_at.desc())
|
|
1200
|
+
.limit(limit)
|
|
1201
|
+
.offset(offset)
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
result = await self._session.execute(stmt)
|
|
1205
|
+
runs = result.scalars().all()
|
|
1206
|
+
|
|
1207
|
+
summaries = [self._to_run_summary(r) for r in runs]
|
|
1208
|
+
return summaries, total
|
|
1209
|
+
|
|
1210
|
+
# =========================================================================
|
|
1211
|
+
# Statistics
|
|
1212
|
+
# =========================================================================
|
|
1213
|
+
|
|
1214
|
+
async def get_statistics(self) -> SchemaWatcherStatistics:
|
|
1215
|
+
"""Get overall statistics.
|
|
1216
|
+
|
|
1217
|
+
Returns:
|
|
1218
|
+
SchemaWatcherStatistics with aggregate metrics.
|
|
1219
|
+
"""
|
|
1220
|
+
# Watcher counts
|
|
1221
|
+
watcher_counts = {}
|
|
1222
|
+
for status in DBStatus:
|
|
1223
|
+
stmt = select(func.count()).where(
|
|
1224
|
+
SchemaWatcherModel.status == status
|
|
1225
|
+
)
|
|
1226
|
+
result = await self._session.execute(stmt)
|
|
1227
|
+
watcher_counts[status.value] = result.scalar() or 0
|
|
1228
|
+
|
|
1229
|
+
# Alert counts
|
|
1230
|
+
alert_counts = {}
|
|
1231
|
+
for status in DBAlertStatus:
|
|
1232
|
+
stmt = select(func.count()).where(
|
|
1233
|
+
SchemaWatcherAlertModel.status == status
|
|
1234
|
+
)
|
|
1235
|
+
result = await self._session.execute(stmt)
|
|
1236
|
+
alert_counts[status.value] = result.scalar() or 0
|
|
1237
|
+
|
|
1238
|
+
# Run counts
|
|
1239
|
+
run_counts = {}
|
|
1240
|
+
for status in DBRunStatus:
|
|
1241
|
+
stmt = select(func.count()).where(
|
|
1242
|
+
SchemaWatcherRunModel.status == status
|
|
1243
|
+
)
|
|
1244
|
+
result = await self._session.execute(stmt)
|
|
1245
|
+
run_counts[status.value] = result.scalar() or 0
|
|
1246
|
+
|
|
1247
|
+
# Total changes
|
|
1248
|
+
stmt = select(func.sum(SchemaWatcherRunModel.changes_detected))
|
|
1249
|
+
result = await self._session.execute(stmt)
|
|
1250
|
+
total_changes = result.scalar() or 0
|
|
1251
|
+
|
|
1252
|
+
stmt = select(func.sum(SchemaWatcherRunModel.breaking_detected))
|
|
1253
|
+
result = await self._session.execute(stmt)
|
|
1254
|
+
total_breaking = result.scalar() or 0
|
|
1255
|
+
|
|
1256
|
+
# Calculate detection rate
|
|
1257
|
+
total_checks = sum(watcher_counts.values())
|
|
1258
|
+
total_with_changes = run_counts.get("completed", 0)
|
|
1259
|
+
detection_rate = (
|
|
1260
|
+
total_with_changes / total_checks if total_checks > 0 else 0.0
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
return SchemaWatcherStatistics(
|
|
1264
|
+
total_watchers=sum(watcher_counts.values()),
|
|
1265
|
+
active_watchers=watcher_counts.get("active", 0),
|
|
1266
|
+
paused_watchers=watcher_counts.get("paused", 0),
|
|
1267
|
+
error_watchers=watcher_counts.get("error", 0),
|
|
1268
|
+
total_alerts=sum(alert_counts.values()),
|
|
1269
|
+
open_alerts=alert_counts.get("open", 0),
|
|
1270
|
+
acknowledged_alerts=alert_counts.get("acknowledged", 0),
|
|
1271
|
+
resolved_alerts=alert_counts.get("resolved", 0),
|
|
1272
|
+
total_runs=sum(run_counts.values()),
|
|
1273
|
+
successful_runs=run_counts.get("completed", 0),
|
|
1274
|
+
failed_runs=run_counts.get("failed", 0),
|
|
1275
|
+
total_changes_detected=total_changes,
|
|
1276
|
+
total_breaking_changes=total_breaking,
|
|
1277
|
+
avg_detection_rate=detection_rate,
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
async def get_scheduler_status(self) -> SchemaWatcherSchedulerStatus:
|
|
1281
|
+
"""Get scheduler status.
|
|
1282
|
+
|
|
1283
|
+
Returns:
|
|
1284
|
+
SchemaWatcherSchedulerStatus.
|
|
1285
|
+
"""
|
|
1286
|
+
# Count active watchers
|
|
1287
|
+
stmt = select(func.count()).where(
|
|
1288
|
+
SchemaWatcherModel.status == DBStatus.ACTIVE
|
|
1289
|
+
)
|
|
1290
|
+
result = await self._session.execute(stmt)
|
|
1291
|
+
active_count = result.scalar() or 0
|
|
1292
|
+
|
|
1293
|
+
# Get next scheduled check
|
|
1294
|
+
stmt = (
|
|
1295
|
+
select(SchemaWatcherModel.next_check_at)
|
|
1296
|
+
.where(SchemaWatcherModel.status == DBStatus.ACTIVE)
|
|
1297
|
+
.where(SchemaWatcherModel.next_check_at.isnot(None))
|
|
1298
|
+
.order_by(SchemaWatcherModel.next_check_at.asc())
|
|
1299
|
+
.limit(1)
|
|
1300
|
+
)
|
|
1301
|
+
result = await self._session.execute(stmt)
|
|
1302
|
+
next_check = result.scalar_one_or_none()
|
|
1303
|
+
|
|
1304
|
+
# Count pending checks (next_check_at <= now)
|
|
1305
|
+
stmt = select(func.count()).where(
|
|
1306
|
+
SchemaWatcherModel.status == DBStatus.ACTIVE,
|
|
1307
|
+
SchemaWatcherModel.next_check_at <= datetime.utcnow(),
|
|
1308
|
+
)
|
|
1309
|
+
result = await self._session.execute(stmt)
|
|
1310
|
+
pending = result.scalar() or 0
|
|
1311
|
+
|
|
1312
|
+
return SchemaWatcherSchedulerStatus(
|
|
1313
|
+
is_running=active_count > 0,
|
|
1314
|
+
active_watchers=active_count,
|
|
1315
|
+
next_check_at=next_check,
|
|
1316
|
+
pending_checks=pending,
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
# =========================================================================
|
|
1320
|
+
# Response Converters
|
|
1321
|
+
# =========================================================================
|
|
1322
|
+
|
|
1323
|
+
def _to_watcher_response(
|
|
1324
|
+
self,
|
|
1325
|
+
watcher: SchemaWatcherModel,
|
|
1326
|
+
source: SourceModel | None,
|
|
1327
|
+
) -> SchemaWatcherResponse:
|
|
1328
|
+
"""Convert watcher model to response."""
|
|
1329
|
+
return SchemaWatcherResponse(
|
|
1330
|
+
id=watcher.id,
|
|
1331
|
+
name=watcher.name,
|
|
1332
|
+
source_id=watcher.source_id,
|
|
1333
|
+
status=_map_db_status(watcher.status),
|
|
1334
|
+
poll_interval_seconds=watcher.poll_interval_seconds,
|
|
1335
|
+
only_breaking=watcher.only_breaking,
|
|
1336
|
+
enable_rename_detection=watcher.enable_rename_detection,
|
|
1337
|
+
rename_similarity_threshold=watcher.rename_similarity_threshold,
|
|
1338
|
+
version_strategy=VersionStrategy(watcher.version_strategy),
|
|
1339
|
+
notify_on_change=watcher.notify_on_change,
|
|
1340
|
+
track_history=watcher.track_history,
|
|
1341
|
+
last_check_at=watcher.last_check_at,
|
|
1342
|
+
last_change_at=watcher.last_change_at,
|
|
1343
|
+
next_check_at=watcher.next_check_at,
|
|
1344
|
+
check_count=watcher.check_count,
|
|
1345
|
+
change_count=watcher.change_count,
|
|
1346
|
+
error_count=watcher.error_count,
|
|
1347
|
+
last_error=watcher.last_error,
|
|
1348
|
+
config=watcher.watcher_config,
|
|
1349
|
+
is_active=watcher.is_active,
|
|
1350
|
+
is_healthy=watcher.is_healthy,
|
|
1351
|
+
detection_rate=watcher.detection_rate,
|
|
1352
|
+
source_name=source.name if source else None,
|
|
1353
|
+
created_at=watcher.created_at,
|
|
1354
|
+
updated_at=watcher.updated_at,
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
def _to_watcher_summary(
|
|
1358
|
+
self,
|
|
1359
|
+
watcher: SchemaWatcherModel,
|
|
1360
|
+
source: SourceModel | None,
|
|
1361
|
+
) -> SchemaWatcherSummary:
|
|
1362
|
+
"""Convert watcher model to summary."""
|
|
1363
|
+
return SchemaWatcherSummary(
|
|
1364
|
+
id=watcher.id,
|
|
1365
|
+
name=watcher.name,
|
|
1366
|
+
source_id=watcher.source_id,
|
|
1367
|
+
source_name=source.name if source else None,
|
|
1368
|
+
status=_map_db_status(watcher.status),
|
|
1369
|
+
poll_interval_seconds=watcher.poll_interval_seconds,
|
|
1370
|
+
check_count=watcher.check_count,
|
|
1371
|
+
change_count=watcher.change_count,
|
|
1372
|
+
last_check_at=watcher.last_check_at,
|
|
1373
|
+
next_check_at=watcher.next_check_at,
|
|
1374
|
+
created_at=watcher.created_at,
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
def _to_alert_response(
|
|
1378
|
+
self,
|
|
1379
|
+
alert: SchemaWatcherAlertModel,
|
|
1380
|
+
source: SourceModel | None,
|
|
1381
|
+
watcher: SchemaWatcherModel | None,
|
|
1382
|
+
) -> SchemaWatcherAlertResponse:
|
|
1383
|
+
"""Convert alert model to response."""
|
|
1384
|
+
# Calculate time metrics
|
|
1385
|
+
time_to_acknowledge = None
|
|
1386
|
+
time_to_resolve = None
|
|
1387
|
+
if alert.acknowledged_at and alert.created_at:
|
|
1388
|
+
time_to_acknowledge = (
|
|
1389
|
+
alert.acknowledged_at - alert.created_at
|
|
1390
|
+
).total_seconds()
|
|
1391
|
+
if alert.resolved_at and alert.created_at:
|
|
1392
|
+
time_to_resolve = (
|
|
1393
|
+
alert.resolved_at - alert.created_at
|
|
1394
|
+
).total_seconds()
|
|
1395
|
+
|
|
1396
|
+
return SchemaWatcherAlertResponse(
|
|
1397
|
+
id=alert.id,
|
|
1398
|
+
watcher_id=alert.watcher_id,
|
|
1399
|
+
source_id=alert.source_id,
|
|
1400
|
+
from_version_id=alert.from_version_id,
|
|
1401
|
+
to_version_id=alert.to_version_id,
|
|
1402
|
+
title=alert.title,
|
|
1403
|
+
severity=_map_db_alert_severity(alert.severity),
|
|
1404
|
+
status=_map_db_alert_status(alert.status),
|
|
1405
|
+
total_changes=alert.total_changes,
|
|
1406
|
+
breaking_changes=alert.breaking_changes,
|
|
1407
|
+
changes_summary=alert.changes_summary,
|
|
1408
|
+
impact_scope=ImpactScope(alert.impact_scope) if alert.impact_scope else None,
|
|
1409
|
+
affected_consumers=alert.affected_consumers,
|
|
1410
|
+
recommendations=alert.recommendations,
|
|
1411
|
+
acknowledged_at=alert.acknowledged_at,
|
|
1412
|
+
acknowledged_by=alert.acknowledged_by,
|
|
1413
|
+
resolved_at=alert.resolved_at,
|
|
1414
|
+
resolved_by=alert.resolved_by,
|
|
1415
|
+
resolution_notes=alert.resolution_notes,
|
|
1416
|
+
is_open=alert.is_open,
|
|
1417
|
+
has_breaking_changes=alert.has_breaking_changes,
|
|
1418
|
+
time_to_acknowledge=time_to_acknowledge,
|
|
1419
|
+
time_to_resolve=time_to_resolve,
|
|
1420
|
+
source_name=source.name if source else None,
|
|
1421
|
+
watcher_name=watcher.name if watcher else None,
|
|
1422
|
+
created_at=alert.created_at,
|
|
1423
|
+
updated_at=alert.updated_at,
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
def _to_alert_summary(
|
|
1427
|
+
self,
|
|
1428
|
+
alert: SchemaWatcherAlertModel,
|
|
1429
|
+
source: SourceModel | None,
|
|
1430
|
+
) -> SchemaWatcherAlertSummary:
|
|
1431
|
+
"""Convert alert model to summary."""
|
|
1432
|
+
return SchemaWatcherAlertSummary(
|
|
1433
|
+
id=alert.id,
|
|
1434
|
+
watcher_id=alert.watcher_id,
|
|
1435
|
+
source_id=alert.source_id,
|
|
1436
|
+
title=alert.title,
|
|
1437
|
+
severity=_map_db_alert_severity(alert.severity),
|
|
1438
|
+
status=_map_db_alert_status(alert.status),
|
|
1439
|
+
total_changes=alert.total_changes,
|
|
1440
|
+
breaking_changes=alert.breaking_changes,
|
|
1441
|
+
created_at=alert.created_at,
|
|
1442
|
+
source_name=source.name if source else None,
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
def _to_run_response(
|
|
1446
|
+
self,
|
|
1447
|
+
run: SchemaWatcherRunModel,
|
|
1448
|
+
source: SourceModel | None,
|
|
1449
|
+
watcher: SchemaWatcherModel | None,
|
|
1450
|
+
) -> SchemaWatcherRunResponse:
|
|
1451
|
+
"""Convert run model to response."""
|
|
1452
|
+
return SchemaWatcherRunResponse(
|
|
1453
|
+
id=run.id,
|
|
1454
|
+
watcher_id=run.watcher_id,
|
|
1455
|
+
source_id=run.source_id,
|
|
1456
|
+
started_at=run.started_at,
|
|
1457
|
+
completed_at=run.completed_at,
|
|
1458
|
+
status=_map_db_run_status(run.status),
|
|
1459
|
+
changes_detected=run.changes_detected,
|
|
1460
|
+
breaking_detected=run.breaking_detected,
|
|
1461
|
+
version_created_id=run.version_created_id,
|
|
1462
|
+
alert_created_id=run.alert_created_id,
|
|
1463
|
+
duration_ms=run.duration_ms,
|
|
1464
|
+
error_message=run.error_message,
|
|
1465
|
+
metadata=run.run_metadata,
|
|
1466
|
+
is_successful=run.is_successful,
|
|
1467
|
+
has_changes=run.has_changes,
|
|
1468
|
+
source_name=source.name if source else None,
|
|
1469
|
+
watcher_name=watcher.name if watcher else None,
|
|
1470
|
+
)
|
|
1471
|
+
|
|
1472
|
+
def _to_run_summary(
|
|
1473
|
+
self,
|
|
1474
|
+
run: SchemaWatcherRunModel,
|
|
1475
|
+
) -> SchemaWatcherRunSummary:
|
|
1476
|
+
"""Convert run model to summary."""
|
|
1477
|
+
return SchemaWatcherRunSummary(
|
|
1478
|
+
id=run.id,
|
|
1479
|
+
watcher_id=run.watcher_id,
|
|
1480
|
+
source_id=run.source_id,
|
|
1481
|
+
started_at=run.started_at,
|
|
1482
|
+
status=_map_db_run_status(run.status),
|
|
1483
|
+
changes_detected=run.changes_detected,
|
|
1484
|
+
breaking_detected=run.breaking_detected,
|
|
1485
|
+
duration_ms=run.duration_ms,
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
# =============================================================================
|
|
1490
|
+
# Background Processing
|
|
1491
|
+
# =============================================================================
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
async def process_due_watchers(session: AsyncSession) -> int:
|
|
1495
|
+
"""Process all watchers due for checking.
|
|
1496
|
+
|
|
1497
|
+
This function is called by the scheduler to run periodic checks.
|
|
1498
|
+
|
|
1499
|
+
Args:
|
|
1500
|
+
session: Database session.
|
|
1501
|
+
|
|
1502
|
+
Returns:
|
|
1503
|
+
Number of watchers processed.
|
|
1504
|
+
"""
|
|
1505
|
+
service = SchemaWatcherService(session)
|
|
1506
|
+
|
|
1507
|
+
# Get due watchers
|
|
1508
|
+
stmt = (
|
|
1509
|
+
select(SchemaWatcherModel)
|
|
1510
|
+
.where(
|
|
1511
|
+
SchemaWatcherModel.status == DBStatus.ACTIVE,
|
|
1512
|
+
SchemaWatcherModel.next_check_at <= datetime.utcnow(),
|
|
1513
|
+
)
|
|
1514
|
+
.order_by(SchemaWatcherModel.next_check_at.asc())
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
result = await session.execute(stmt)
|
|
1518
|
+
watchers = result.scalars().all()
|
|
1519
|
+
|
|
1520
|
+
processed = 0
|
|
1521
|
+
for watcher in watchers:
|
|
1522
|
+
try:
|
|
1523
|
+
await service.check_now(watcher.id)
|
|
1524
|
+
processed += 1
|
|
1525
|
+
except Exception as e:
|
|
1526
|
+
logger.error(f"Error processing watcher {watcher.id}: {e}")
|
|
1527
|
+
|
|
1528
|
+
return processed
|