Skip to content

Commit a75be88

Browse files
author
ci bot
committed
Merge branch 'feat/TG-1094-mcp-monitors-lifecycle-and-settings' into 'enterprise'
feat(mcp): monitor lifecycle & settings tools (TG-1094) See merge request dkinternal/testgen/dataops-testgen!564
2 parents 2ee9632 + 1fe6947 commit a75be88

13 files changed

Lines changed: 999 additions & 106 deletions

testgen/common/freshness_service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
import numpy as np
99
import pandas as pd
1010

11-
from testgen.common.time_series_service import NotEnoughData, get_holiday_dates
11+
from testgen.common.holiday_service import get_holiday_dates
12+
from testgen.common.time_series_service import NotEnoughData
1213

1314
LOG = logging.getLogger("testgen")
1415

testgen/common/holiday_service.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Holiday calendar utilities: resolve and validate country / financial-market holiday codes.
2+
3+
Holiday codes name the calendars excluded from prediction baselines — used as SARIMAX
4+
exogenous regressors and in freshness business-time gap calculations. Each code resolves
5+
via the ``holidays`` package as either a country (ISO code, e.g. ``US``) or a financial
6+
market (e.g. ``NYSE``).
7+
"""
8+
9+
import logging
10+
from datetime import datetime
11+
12+
import holidays
13+
import pandas as pd
14+
15+
LOG = logging.getLogger("testgen")
16+
17+
18+
def get_holiday_dates(holiday_codes: list[str], datetime_index: pd.DatetimeIndex) -> set[datetime]:
19+
years = list(range(datetime_index.year.min(), datetime_index.year.max() + 1))
20+
21+
holiday_dates = set()
22+
if holiday_codes:
23+
for code in holiday_codes:
24+
code = code.strip().upper()
25+
found = False
26+
27+
try:
28+
country_holidays = holidays.country_holidays(code, years=years)
29+
holiday_dates.update(country_holidays.keys())
30+
found = True
31+
except NotImplementedError:
32+
pass # Not a valid country code
33+
34+
if not found:
35+
try:
36+
financial_holidays = holidays.financial_holidays(code, years=years)
37+
holiday_dates.update(financial_holidays.keys())
38+
found = True
39+
except NotImplementedError:
40+
pass # Not a valid financial code
41+
42+
if not found:
43+
LOG.warning(f"Holiday code '{code}' could not be resolved as a country or financial market")
44+
45+
return holiday_dates
46+
47+
48+
def is_supported_holiday_code(code: str) -> bool:
49+
"""Whether a holiday code resolves to a country or financial-market calendar.
50+
51+
Applies the same normalization as :func:`get_holiday_dates` (strip + upper), so a code
52+
that passes here is one that resolver will actually honor.
53+
"""
54+
normalized = code.strip().upper()
55+
if not normalized:
56+
return False
57+
for resolver in (holidays.country_holidays, holidays.financial_holidays):
58+
try:
59+
resolver(normalized)
60+
except NotImplementedError:
61+
continue
62+
else:
63+
return True
64+
return False

testgen/common/models/scheduler.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,14 @@ def select_active_by_kwargs(
135135
query = query.where(cls.kwargs[k].astext == str(v))
136136
return list(get_current_session().scalars(query).all())
137137

138+
@classmethod
139+
def get_for_monitor_suite(cls, monitor_suite_id: str | UUID) -> Self | None:
140+
"""The run-monitors schedule for a monitor suite, active or paused."""
141+
return cls.get(
142+
cls.key == RUN_MONITORS_JOB_KEY,
143+
cls.kwargs["test_suite_id"].astext == str(monitor_suite_id),
144+
)
145+
138146
@classmethod
139147
def upsert_for_retention(
140148
cls,

testgen/common/monitor_service.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Monitor lifecycle: enable, update, and disable monitoring for a table group.
2+
3+
Shared by the MCP tools, the monitors dashboard, and the table-group creation
4+
wizard so all three drive monitoring through one path. Functions mutate the
5+
monitor ``TestSuite``, its ``JobSchedule``, and the table-group link, and raise
6+
stdlib exceptions — the MCP and UI layers translate those into their own
7+
user-facing errors.
8+
"""
9+
10+
import logging
11+
from typing import Any
12+
13+
from sqlalchemy import func, select
14+
15+
from testgen.commands.test_generation import run_monitor_generation
16+
from testgen.common.models import get_current_session
17+
from testgen.common.models.scheduler import RUN_MONITORS_JOB_KEY, JobSchedule
18+
from testgen.common.models.table_group import TableGroup
19+
from testgen.common.models.test_definition import TestDefinition
20+
from testgen.common.models.test_result import TestResult
21+
from testgen.common.models.test_run import TestRun
22+
from testgen.common.models.test_suite import TestSuite
23+
24+
LOG = logging.getLogger("testgen")
25+
26+
# Monitors generated when monitoring is first enabled. Freshness_Trend depends on
27+
# per-table freshness fingerprinting and is generated separately, so it is not part
28+
# of the initial bootstrap set.
29+
INITIAL_MONITOR_TYPES = ["Volume_Trend", "Schema_Drift"]
30+
31+
# Default monitor configuration applied at bootstrap, mirroring the UI's setup form.
32+
_DEFAULT_SUITE_ATTRS: dict[str, Any] = {
33+
"monitor_lookback": 14,
34+
"monitor_regenerate_freshness": True,
35+
"predict_min_lookback": 30,
36+
"predict_sensitivity": "medium",
37+
"predict_exclude_weekends": False,
38+
"predict_holiday_codes": None,
39+
}
40+
41+
# The monitor-configuration columns callers may set via ``suite_attrs``. Used as a
42+
# whitelist so a caller's dict (e.g. a UI form payload) can't write arbitrary suite columns.
43+
_MONITOR_SETTING_COLUMNS = tuple(_DEFAULT_SUITE_ATTRS)
44+
45+
46+
def enable_monitoring(
47+
table_group: TableGroup,
48+
cron_expr: str,
49+
cron_tz: str = "UTC",
50+
*,
51+
suite_attrs: dict[str, Any] | None = None,
52+
active: bool = True,
53+
) -> tuple[TestSuite, int]:
54+
"""Bootstrap monitoring for a table group.
55+
56+
Creates the monitor test suite, generates the initial monitors, creates the
57+
run-monitors schedule, and links the suite to the table group.
58+
59+
``suite_attrs`` overrides the default monitor configuration.
60+
61+
Returns ``(monitor_suite, monitor_count)``. Raises ``ValueError`` if the table
62+
group already has monitoring enabled.
63+
"""
64+
if table_group.monitor_test_suite_id:
65+
raise ValueError("Monitoring is already enabled for this table group.")
66+
67+
provided = suite_attrs or {}
68+
attrs = dict(_DEFAULT_SUITE_ATTRS)
69+
for key in _MONITOR_SETTING_COLUMNS:
70+
if (value := provided.get(key)) is not None:
71+
attrs[key] = value
72+
73+
monitor_suite = TestSuite(
74+
project_code=table_group.project_code,
75+
test_suite=f"{table_group.table_groups_name} Monitors",
76+
connection_id=table_group.connection_id,
77+
table_groups_id=table_group.id,
78+
export_to_observability=False,
79+
dq_score_exclude=True,
80+
is_monitor=True,
81+
**attrs,
82+
)
83+
monitor_suite.save()
84+
85+
JobSchedule(
86+
project_code=table_group.project_code,
87+
key=RUN_MONITORS_JOB_KEY,
88+
kwargs={"test_suite_id": str(monitor_suite.id)},
89+
cron_expr=cron_expr,
90+
cron_tz=cron_tz,
91+
active=active,
92+
).save()
93+
94+
table_group.monitor_test_suite_id = monitor_suite.id
95+
table_group.save()
96+
97+
# Commit needed to make the test suite visible to run_monitor_generation's separate DB connection.
98+
get_current_session().commit()
99+
run_monitor_generation(monitor_suite.id, INITIAL_MONITOR_TYPES)
100+
101+
count = get_current_session().scalar(
102+
select(func.count()).select_from(TestDefinition).where(TestDefinition.test_suite_id == monitor_suite.id)
103+
)
104+
return monitor_suite, count or 0
105+
106+
107+
def update_monitoring(
108+
monitor_suite: TestSuite,
109+
schedule: JobSchedule,
110+
*,
111+
suite_attrs: dict[str, Any] | None = None,
112+
cron_expr: str | None = None,
113+
cron_tz: str | None = None,
114+
active: bool | None = None,
115+
) -> None:
116+
"""Apply a partial update to monitor settings and/or schedule.
117+
118+
``suite_attrs`` maps monitor ``TestSuite`` columns to new values; only the keys in
119+
``_MONITOR_SETTING_COLUMNS`` that are present are applied (a present ``None`` clears
120+
the column). Supplied schedule fields are updated in place. Does not commit — the
121+
caller's session lifecycle does.
122+
"""
123+
provided = suite_attrs or {}
124+
for key in _MONITOR_SETTING_COLUMNS:
125+
if key in provided:
126+
setattr(monitor_suite, key, provided[key])
127+
monitor_suite.save()
128+
129+
if cron_expr is not None:
130+
schedule.cron_expr = cron_expr
131+
if cron_tz is not None:
132+
schedule.cron_tz = cron_tz
133+
if active is not None:
134+
schedule.active = active
135+
schedule.save()
136+
137+
138+
def disable_monitoring(monitor_suite: TestSuite) -> dict[str, int]:
139+
"""Remove a monitor suite and all its monitors, runs, and history.
140+
141+
Counts what will be removed before cascading the delete. Returns counts keyed
142+
``monitors`` (test definitions), ``events`` (test results), and ``runs``.
143+
"""
144+
session = get_current_session()
145+
suite_id = monitor_suite.id
146+
counts = {
147+
"monitors": session.scalar(
148+
select(func.count()).select_from(TestDefinition).where(TestDefinition.test_suite_id == suite_id)
149+
)
150+
or 0,
151+
"events": session.scalar(
152+
select(func.count()).select_from(TestResult).where(TestResult.test_suite_id == suite_id)
153+
)
154+
or 0,
155+
"runs": session.scalar(
156+
select(func.count()).select_from(TestRun).where(TestRun.test_suite_id == suite_id)
157+
)
158+
or 0,
159+
}
160+
TestSuite.cascade_delete([suite_id])
161+
return counts

testgen/common/time_series_service.py

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import logging
2-
from datetime import datetime
32

4-
import holidays
53
import numpy as np
64
import pandas as pd
75
from statsmodels.tsa.statespace.sarimax import SARIMAX
86

7+
from testgen.common.holiday_service import get_holiday_dates
8+
99
LOG = logging.getLogger("testgen")
1010

1111
# This is a heuristic minimum to get a reasonable prediction
@@ -137,31 +137,3 @@ def infer_frequency(datetime_series: pd.Series) -> str:
137137
return frequency if frequency != "0min" else f"{int(total_seconds)}S"
138138

139139

140-
def get_holiday_dates(holiday_codes: list[str], datetime_index: pd.DatetimeIndex) -> set[datetime]:
141-
years = list(range(datetime_index.year.min(), datetime_index.year.max() + 1))
142-
143-
holiday_dates = set()
144-
if holiday_codes:
145-
for code in holiday_codes:
146-
code = code.strip().upper()
147-
found = False
148-
149-
try:
150-
country_holidays = holidays.country_holidays(code, years=years)
151-
holiday_dates.update(country_holidays.keys())
152-
found = True
153-
except NotImplementedError:
154-
pass # Not a valid country code
155-
156-
if not found:
157-
try:
158-
financial_holidays = holidays.financial_holidays(code, years=years)
159-
holiday_dates.update(financial_holidays.keys())
160-
found = True
161-
except NotImplementedError:
162-
pass # Not a valid financial code
163-
164-
if not found:
165-
LOG.warning(f"Holiday code '{code}' could not be resolved as a country or financial market")
166-
167-
return holiday_dates

testgen/mcp/server.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,14 @@ def build_mcp_server(
172172
search_hygiene_issues,
173173
update_hygiene_issue,
174174
)
175-
from testgen.mcp.tools.monitors import get_monitor_summary, list_monitored_tables
175+
from testgen.mcp.tools.monitors import (
176+
disable_monitors,
177+
enable_monitors,
178+
get_monitor_settings,
179+
get_monitor_summary,
180+
list_monitored_tables,
181+
update_monitor_settings,
182+
)
176183
from testgen.mcp.tools.notifications import (
177184
create_notification,
178185
delete_notification,
@@ -321,6 +328,10 @@ def safe_prompt(fn):
321328
safe_tool(get_schema_history)
322329
safe_tool(get_monitor_summary)
323330
safe_tool(list_monitored_tables)
331+
safe_tool(enable_monitors)
332+
safe_tool(get_monitor_settings)
333+
safe_tool(update_monitor_settings)
334+
safe_tool(disable_monitors)
324335
safe_tool(run_tests)
325336
safe_tool(run_profiling)
326337
safe_tool(cancel_test_run)

0 commit comments

Comments
 (0)