detectkit 0.2.4__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.
- detectkit/__init__.py +17 -0
- detectkit/alerting/__init__.py +13 -0
- detectkit/alerting/channels/__init__.py +21 -0
- detectkit/alerting/channels/base.py +193 -0
- detectkit/alerting/channels/email.py +146 -0
- detectkit/alerting/channels/factory.py +193 -0
- detectkit/alerting/channels/mattermost.py +53 -0
- detectkit/alerting/channels/slack.py +55 -0
- detectkit/alerting/channels/telegram.py +110 -0
- detectkit/alerting/channels/webhook.py +139 -0
- detectkit/alerting/orchestrator.py +369 -0
- detectkit/cli/__init__.py +1 -0
- detectkit/cli/commands/__init__.py +1 -0
- detectkit/cli/commands/init.py +282 -0
- detectkit/cli/commands/run.py +486 -0
- detectkit/cli/commands/test_alert.py +184 -0
- detectkit/cli/main.py +186 -0
- detectkit/config/__init__.py +30 -0
- detectkit/config/metric_config.py +499 -0
- detectkit/config/profile.py +285 -0
- detectkit/config/project_config.py +164 -0
- detectkit/config/validator.py +124 -0
- detectkit/core/__init__.py +6 -0
- detectkit/core/interval.py +132 -0
- detectkit/core/models.py +106 -0
- detectkit/database/__init__.py +27 -0
- detectkit/database/clickhouse_manager.py +393 -0
- detectkit/database/internal_tables.py +724 -0
- detectkit/database/manager.py +324 -0
- detectkit/database/tables.py +138 -0
- detectkit/detectors/__init__.py +6 -0
- detectkit/detectors/base.py +441 -0
- detectkit/detectors/factory.py +138 -0
- detectkit/detectors/statistical/__init__.py +8 -0
- detectkit/detectors/statistical/iqr.py +508 -0
- detectkit/detectors/statistical/mad.py +478 -0
- detectkit/detectors/statistical/manual_bounds.py +206 -0
- detectkit/detectors/statistical/zscore.py +491 -0
- detectkit/loaders/__init__.py +6 -0
- detectkit/loaders/metric_loader.py +470 -0
- detectkit/loaders/query_template.py +164 -0
- detectkit/orchestration/__init__.py +9 -0
- detectkit/orchestration/task_manager.py +746 -0
- detectkit/utils/__init__.py +17 -0
- detectkit/utils/stats.py +196 -0
- detectkit-0.2.4.dist-info/METADATA +237 -0
- detectkit-0.2.4.dist-info/RECORD +51 -0
- detectkit-0.2.4.dist-info/WHEEL +5 -0
- detectkit-0.2.4.dist-info/entry_points.txt +2 -0
- detectkit-0.2.4.dist-info/licenses/LICENSE +21 -0
- detectkit-0.2.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interval parsing and handling.
|
|
3
|
+
|
|
4
|
+
Supports:
|
|
5
|
+
- Integer seconds: 600
|
|
6
|
+
- String format: "10min", "1h", "1d"
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Union
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Interval:
|
|
14
|
+
"""
|
|
15
|
+
Represents a time interval in seconds.
|
|
16
|
+
|
|
17
|
+
Supports parsing from:
|
|
18
|
+
- Integer (seconds): 600
|
|
19
|
+
- String: "10min", "1h", "1d", "30s"
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
>>> interval = Interval("10min")
|
|
23
|
+
>>> interval.seconds
|
|
24
|
+
600
|
|
25
|
+
>>> interval = Interval(3600)
|
|
26
|
+
>>> interval.seconds
|
|
27
|
+
3600
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
UNITS = {
|
|
31
|
+
's': 1,
|
|
32
|
+
'sec': 1,
|
|
33
|
+
'second': 1,
|
|
34
|
+
'seconds': 1,
|
|
35
|
+
'm': 60,
|
|
36
|
+
'min': 60,
|
|
37
|
+
'minute': 60,
|
|
38
|
+
'minutes': 60,
|
|
39
|
+
'h': 3600,
|
|
40
|
+
'hour': 3600,
|
|
41
|
+
'hours': 3600,
|
|
42
|
+
'd': 86400,
|
|
43
|
+
'day': 86400,
|
|
44
|
+
'days': 86400,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def __init__(self, value: Union[int, str]):
|
|
48
|
+
"""
|
|
49
|
+
Initialize interval from integer or string.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
value: Interval as integer (seconds) or string ("10min")
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If string format is invalid
|
|
56
|
+
"""
|
|
57
|
+
if isinstance(value, int):
|
|
58
|
+
if value <= 0:
|
|
59
|
+
raise ValueError(f"Interval must be positive, got {value}")
|
|
60
|
+
self._seconds = value
|
|
61
|
+
elif isinstance(value, str):
|
|
62
|
+
self._seconds = self._parse_string(value)
|
|
63
|
+
else:
|
|
64
|
+
raise TypeError(f"Interval must be int or str, got {type(value)}")
|
|
65
|
+
|
|
66
|
+
def _parse_string(self, s: str) -> int:
|
|
67
|
+
"""
|
|
68
|
+
Parse interval string.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
s: String like "10min", "1h", "30s"
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Interval in seconds
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If format is invalid
|
|
78
|
+
"""
|
|
79
|
+
s = s.strip().lower()
|
|
80
|
+
|
|
81
|
+
# Match pattern: digits followed by unit
|
|
82
|
+
match = re.match(r'^(\d+)([a-z]+)$', s)
|
|
83
|
+
if not match:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Invalid interval format: '{s}'. "
|
|
86
|
+
f"Expected format: <number><unit> (e.g., '10min', '1h')"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
value_str, unit = match.groups()
|
|
90
|
+
value = int(value_str)
|
|
91
|
+
|
|
92
|
+
if value <= 0:
|
|
93
|
+
raise ValueError(f"Interval value must be positive, got {value}")
|
|
94
|
+
|
|
95
|
+
if unit not in self.UNITS:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Unknown time unit: '{unit}'. "
|
|
98
|
+
f"Supported units: {', '.join(sorted(set(self.UNITS.keys())))}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return value * self.UNITS[unit]
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def seconds(self) -> int:
|
|
105
|
+
"""Get interval in seconds."""
|
|
106
|
+
return self._seconds
|
|
107
|
+
|
|
108
|
+
def __eq__(self, other) -> bool:
|
|
109
|
+
"""Check equality based on seconds."""
|
|
110
|
+
if isinstance(other, Interval):
|
|
111
|
+
return self._seconds == other._seconds
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def __hash__(self) -> int:
|
|
115
|
+
"""Hash based on seconds."""
|
|
116
|
+
return hash(self._seconds)
|
|
117
|
+
|
|
118
|
+
def __repr__(self) -> str:
|
|
119
|
+
"""String representation."""
|
|
120
|
+
return f"Interval({self._seconds})"
|
|
121
|
+
|
|
122
|
+
def __str__(self) -> str:
|
|
123
|
+
"""User-friendly string representation."""
|
|
124
|
+
# Try to represent in human-readable format
|
|
125
|
+
if self._seconds % 86400 == 0:
|
|
126
|
+
return f"{self._seconds // 86400}d"
|
|
127
|
+
elif self._seconds % 3600 == 0:
|
|
128
|
+
return f"{self._seconds // 3600}h"
|
|
129
|
+
elif self._seconds % 60 == 0:
|
|
130
|
+
return f"{self._seconds // 60}min"
|
|
131
|
+
else:
|
|
132
|
+
return f"{self._seconds}s"
|
detectkit/core/models.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core data models for detectk.
|
|
3
|
+
|
|
4
|
+
Defines table schemas and column definitions for database abstraction.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ColumnDefinition:
|
|
13
|
+
"""
|
|
14
|
+
Definition of a table column.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
name: Column name
|
|
18
|
+
type: SQL type (database-specific)
|
|
19
|
+
nullable: Whether column can be NULL
|
|
20
|
+
default: Default value for column
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
type: str
|
|
25
|
+
nullable: bool = False
|
|
26
|
+
default: Optional[Any] = None
|
|
27
|
+
|
|
28
|
+
def __post_init__(self):
|
|
29
|
+
"""Validate column definition."""
|
|
30
|
+
if not self.name:
|
|
31
|
+
raise ValueError("Column name cannot be empty")
|
|
32
|
+
if not self.type:
|
|
33
|
+
raise ValueError("Column type cannot be empty")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class TableModel:
|
|
38
|
+
"""
|
|
39
|
+
Model for table schema definition.
|
|
40
|
+
|
|
41
|
+
This is used by BaseDatabaseManager.create_table() to create tables
|
|
42
|
+
in a database-agnostic way.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
columns: List of column definitions
|
|
46
|
+
primary_key: List of column names forming primary key
|
|
47
|
+
engine: Database engine (ClickHouse-specific, e.g., "MergeTree")
|
|
48
|
+
order_by: Columns for ORDER BY clause (ClickHouse-specific)
|
|
49
|
+
indexes: Additional indexes to create
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
>>> model = TableModel(
|
|
53
|
+
... columns=[
|
|
54
|
+
... ColumnDefinition("id", "Int32"),
|
|
55
|
+
... ColumnDefinition("name", "String"),
|
|
56
|
+
... ],
|
|
57
|
+
... primary_key=["id"],
|
|
58
|
+
... engine="MergeTree",
|
|
59
|
+
... order_by=["id"]
|
|
60
|
+
... )
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
columns: List[ColumnDefinition]
|
|
64
|
+
primary_key: List[str]
|
|
65
|
+
engine: Optional[str] = None
|
|
66
|
+
order_by: Optional[List[str]] = None
|
|
67
|
+
indexes: List[str] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
def __post_init__(self):
|
|
70
|
+
"""Validate table model."""
|
|
71
|
+
if not self.columns:
|
|
72
|
+
raise ValueError("Table must have at least one column")
|
|
73
|
+
|
|
74
|
+
if not self.primary_key:
|
|
75
|
+
raise ValueError("Table must have a primary key")
|
|
76
|
+
|
|
77
|
+
# Validate primary key columns exist
|
|
78
|
+
column_names = {col.name for col in self.columns}
|
|
79
|
+
for pk_col in self.primary_key:
|
|
80
|
+
if pk_col not in column_names:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Primary key column '{pk_col}' not found in table columns"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Validate order_by columns exist (if specified)
|
|
86
|
+
if self.order_by:
|
|
87
|
+
for order_col in self.order_by:
|
|
88
|
+
if order_col not in column_names:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"ORDER BY column '{order_col}' not found in table columns"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def get_column(self, name: str) -> Optional[ColumnDefinition]:
|
|
94
|
+
"""
|
|
95
|
+
Get column definition by name.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
name: Column name
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
ColumnDefinition or None if not found
|
|
102
|
+
"""
|
|
103
|
+
for col in self.columns:
|
|
104
|
+
if col.name == name:
|
|
105
|
+
return col
|
|
106
|
+
return None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Database managers for detectk."""
|
|
2
|
+
|
|
3
|
+
from detectkit.database.manager import BaseDatabaseManager
|
|
4
|
+
from detectkit.database.clickhouse_manager import ClickHouseDatabaseManager
|
|
5
|
+
from detectkit.database.internal_tables import InternalTablesManager
|
|
6
|
+
from detectkit.database.tables import (
|
|
7
|
+
TABLE_DATAPOINTS,
|
|
8
|
+
TABLE_DETECTIONS,
|
|
9
|
+
TABLE_TASKS,
|
|
10
|
+
INTERNAL_TABLES,
|
|
11
|
+
get_datapoints_table_model,
|
|
12
|
+
get_detections_table_model,
|
|
13
|
+
get_tasks_table_model,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BaseDatabaseManager",
|
|
18
|
+
"ClickHouseDatabaseManager",
|
|
19
|
+
"InternalTablesManager",
|
|
20
|
+
"TABLE_DATAPOINTS",
|
|
21
|
+
"TABLE_DETECTIONS",
|
|
22
|
+
"TABLE_TASKS",
|
|
23
|
+
"INTERNAL_TABLES",
|
|
24
|
+
"get_datapoints_table_model",
|
|
25
|
+
"get_detections_table_model",
|
|
26
|
+
"get_tasks_table_model",
|
|
27
|
+
]
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ClickHouse database manager implementation.
|
|
3
|
+
|
|
4
|
+
Implements BaseDatabaseManager for ClickHouse using universal methods.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from clickhouse_driver import Client
|
|
14
|
+
CLICKHOUSE_AVAILABLE = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
CLICKHOUSE_AVAILABLE = False
|
|
17
|
+
|
|
18
|
+
from detectkit.core.models import ColumnDefinition, TableModel
|
|
19
|
+
from detectkit.database.manager import BaseDatabaseManager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClickHouseDatabaseManager(BaseDatabaseManager):
|
|
23
|
+
"""
|
|
24
|
+
ClickHouse implementation of BaseDatabaseManager.
|
|
25
|
+
|
|
26
|
+
Uses universal methods - does NOT hardcode internal table logic.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
host: ClickHouse host
|
|
30
|
+
port: ClickHouse port (default: 9000 for native protocol)
|
|
31
|
+
user: Database user
|
|
32
|
+
password: Database password
|
|
33
|
+
internal_database: Database for internal tables (_dtk_*)
|
|
34
|
+
data_database: Database for user data tables
|
|
35
|
+
settings: Optional ClickHouse settings
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
host: str = "localhost",
|
|
41
|
+
port: int = 9000,
|
|
42
|
+
user: str = "default",
|
|
43
|
+
password: str = "",
|
|
44
|
+
internal_database: str = "detectk_internal",
|
|
45
|
+
data_database: str = "default",
|
|
46
|
+
settings: Optional[Dict[str, Any]] = None,
|
|
47
|
+
):
|
|
48
|
+
"""Initialize ClickHouse manager."""
|
|
49
|
+
if not CLICKHOUSE_AVAILABLE:
|
|
50
|
+
raise ImportError(
|
|
51
|
+
"clickhouse-driver is not installed. "
|
|
52
|
+
"Install with: pip install detectk[clickhouse]"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
self._internal_database = internal_database
|
|
56
|
+
self._data_database = data_database
|
|
57
|
+
|
|
58
|
+
# Create client
|
|
59
|
+
self._client = Client(
|
|
60
|
+
host=host,
|
|
61
|
+
port=port,
|
|
62
|
+
user=user,
|
|
63
|
+
password=password,
|
|
64
|
+
settings=settings or {},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Ensure databases exist
|
|
68
|
+
self._ensure_databases()
|
|
69
|
+
|
|
70
|
+
def _ensure_databases(self) -> None:
|
|
71
|
+
"""Create internal and data databases if they don't exist."""
|
|
72
|
+
for db in [self._internal_database, self._data_database]:
|
|
73
|
+
self._client.execute(f"CREATE DATABASE IF NOT EXISTS {db}")
|
|
74
|
+
|
|
75
|
+
def execute_query(
|
|
76
|
+
self,
|
|
77
|
+
query: str,
|
|
78
|
+
params: Optional[Dict[str, Any]] = None
|
|
79
|
+
) -> List[Dict[str, Any]]:
|
|
80
|
+
"""
|
|
81
|
+
Execute SQL query and return results as list of dictionaries.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
query: SQL query to execute
|
|
85
|
+
params: Optional query parameters
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of dictionaries where each dict represents a row
|
|
89
|
+
"""
|
|
90
|
+
# Execute query with or without parameters
|
|
91
|
+
if params:
|
|
92
|
+
result = self._client.execute(query, params, with_column_types=True)
|
|
93
|
+
else:
|
|
94
|
+
result = self._client.execute(query, with_column_types=True)
|
|
95
|
+
|
|
96
|
+
# result is tuple: (rows, columns_with_types)
|
|
97
|
+
# columns_with_types is list of tuples: (name, type)
|
|
98
|
+
rows, columns_with_types = result
|
|
99
|
+
column_names = [col[0] for col in columns_with_types]
|
|
100
|
+
|
|
101
|
+
# Convert to list of dicts
|
|
102
|
+
return [
|
|
103
|
+
dict(zip(column_names, row))
|
|
104
|
+
for row in rows
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
def create_table(
|
|
108
|
+
self,
|
|
109
|
+
table_name: str,
|
|
110
|
+
table_model: TableModel,
|
|
111
|
+
if_not_exists: bool = True
|
|
112
|
+
) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Create ClickHouse table from TableModel.
|
|
115
|
+
|
|
116
|
+
Converts generic TableModel to ClickHouse-specific DDL.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
table_name: Name of table to create
|
|
120
|
+
table_model: Table schema definition
|
|
121
|
+
if_not_exists: Add IF NOT EXISTS clause
|
|
122
|
+
"""
|
|
123
|
+
# Build column definitions
|
|
124
|
+
col_defs = []
|
|
125
|
+
for col in table_model.columns:
|
|
126
|
+
col_def = f"{col.name} {col.type}"
|
|
127
|
+
if col.default is not None:
|
|
128
|
+
col_def += f" DEFAULT {self._format_default(col.default)}"
|
|
129
|
+
col_defs.append(col_def)
|
|
130
|
+
|
|
131
|
+
columns_sql = ",\n ".join(col_defs)
|
|
132
|
+
|
|
133
|
+
# Build CREATE TABLE statement
|
|
134
|
+
if_not_exists_clause = "IF NOT EXISTS " if if_not_exists else ""
|
|
135
|
+
|
|
136
|
+
# For ClickHouse, use engine and order_by from table_model
|
|
137
|
+
engine = table_model.engine or "MergeTree"
|
|
138
|
+
order_by = table_model.order_by or table_model.primary_key
|
|
139
|
+
|
|
140
|
+
order_by_clause = ", ".join(order_by)
|
|
141
|
+
|
|
142
|
+
# Add parentheses only if engine doesn't already have them
|
|
143
|
+
if "(" in engine:
|
|
144
|
+
engine_clause = engine
|
|
145
|
+
else:
|
|
146
|
+
engine_clause = f"{engine}()"
|
|
147
|
+
|
|
148
|
+
ddl = f"""
|
|
149
|
+
CREATE TABLE {if_not_exists_clause}{table_name} (
|
|
150
|
+
{columns_sql}
|
|
151
|
+
)
|
|
152
|
+
ENGINE = {engine_clause}
|
|
153
|
+
ORDER BY ({order_by_clause})
|
|
154
|
+
""".strip()
|
|
155
|
+
|
|
156
|
+
self._client.execute(ddl)
|
|
157
|
+
|
|
158
|
+
def _format_default(self, value: Any) -> str:
|
|
159
|
+
"""Format default value for SQL."""
|
|
160
|
+
if isinstance(value, str):
|
|
161
|
+
return f"'{value}'"
|
|
162
|
+
elif isinstance(value, (int, float)):
|
|
163
|
+
return str(value)
|
|
164
|
+
elif value is None:
|
|
165
|
+
return "NULL"
|
|
166
|
+
else:
|
|
167
|
+
return str(value)
|
|
168
|
+
|
|
169
|
+
def table_exists(
|
|
170
|
+
self,
|
|
171
|
+
table_name: str,
|
|
172
|
+
schema: Optional[str] = None
|
|
173
|
+
) -> bool:
|
|
174
|
+
"""
|
|
175
|
+
Check if table exists in ClickHouse.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
table_name: Name of table to check
|
|
179
|
+
schema: Database name (if None, check both internal and data databases)
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
True if table exists
|
|
183
|
+
"""
|
|
184
|
+
if schema:
|
|
185
|
+
databases = [schema]
|
|
186
|
+
else:
|
|
187
|
+
databases = [self._internal_database, self._data_database]
|
|
188
|
+
|
|
189
|
+
for db in databases:
|
|
190
|
+
query = """
|
|
191
|
+
SELECT 1
|
|
192
|
+
FROM system.tables
|
|
193
|
+
WHERE database = %(database)s
|
|
194
|
+
AND name = %(table)s
|
|
195
|
+
"""
|
|
196
|
+
result = self.execute_query(
|
|
197
|
+
query,
|
|
198
|
+
{"database": db, "table": table_name}
|
|
199
|
+
)
|
|
200
|
+
if result:
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
def insert_batch(
|
|
206
|
+
self,
|
|
207
|
+
table_name: str,
|
|
208
|
+
data: Dict[str, np.ndarray],
|
|
209
|
+
conflict_strategy: str = "ignore"
|
|
210
|
+
) -> int:
|
|
211
|
+
"""
|
|
212
|
+
Insert batch of data into ClickHouse table.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
table_name: Table to insert into
|
|
216
|
+
data: Dictionary mapping column names to numpy arrays
|
|
217
|
+
conflict_strategy: "ignore" or "replace" (ClickHouse doesn't support REPLACE)
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Number of rows inserted
|
|
221
|
+
"""
|
|
222
|
+
if not data:
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
# Validate all arrays have same length
|
|
226
|
+
lengths = [len(arr) for arr in data.values()]
|
|
227
|
+
if len(set(lengths)) > 1:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"All arrays must have same length, got: {dict(zip(data.keys(), lengths))}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
num_rows = lengths[0]
|
|
233
|
+
if num_rows == 0:
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
# Convert numpy arrays to lists for ClickHouse driver
|
|
237
|
+
column_names = list(data.keys())
|
|
238
|
+
rows = []
|
|
239
|
+
|
|
240
|
+
for i in range(num_rows):
|
|
241
|
+
row = []
|
|
242
|
+
for col_name in column_names:
|
|
243
|
+
value = data[col_name][i]
|
|
244
|
+
|
|
245
|
+
# Convert numpy types to Python types
|
|
246
|
+
if isinstance(value, (np.datetime64, np.timedelta64)):
|
|
247
|
+
# Convert numpy datetime64 to Python datetime
|
|
248
|
+
value = self._convert_numpy_datetime(value)
|
|
249
|
+
elif isinstance(value, np.ndarray):
|
|
250
|
+
value = value.tolist()
|
|
251
|
+
elif isinstance(value, (np.integer, np.floating)):
|
|
252
|
+
value = value.item()
|
|
253
|
+
elif value is None or (isinstance(value, float) and np.isnan(value)):
|
|
254
|
+
value = None
|
|
255
|
+
|
|
256
|
+
row.append(value)
|
|
257
|
+
rows.append(row)
|
|
258
|
+
|
|
259
|
+
# For ClickHouse, conflict_strategy="ignore" is handled by PRIMARY KEY
|
|
260
|
+
# Duplicates are silently ignored by MergeTree
|
|
261
|
+
# Note: For ReplacingMergeTree, use conflict_strategy="replace"
|
|
262
|
+
|
|
263
|
+
# Insert data
|
|
264
|
+
self._client.execute(
|
|
265
|
+
f"INSERT INTO {table_name} ({', '.join(column_names)}) VALUES",
|
|
266
|
+
rows
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return num_rows
|
|
270
|
+
|
|
271
|
+
def _convert_numpy_datetime(self, dt: np.datetime64) -> datetime:
|
|
272
|
+
"""Convert numpy datetime64 to Python datetime with UTC timezone."""
|
|
273
|
+
# Convert to timestamp
|
|
274
|
+
timestamp = (dt - np.datetime64('1970-01-01T00:00:00')) / np.timedelta64(1, 's')
|
|
275
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
|
276
|
+
|
|
277
|
+
def get_last_timestamp(
|
|
278
|
+
self,
|
|
279
|
+
table_name: str,
|
|
280
|
+
metric_name: str,
|
|
281
|
+
timestamp_column: str = "timestamp"
|
|
282
|
+
) -> Optional[datetime]:
|
|
283
|
+
"""
|
|
284
|
+
Get last timestamp for a metric in a table.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
table_name: Table to query
|
|
288
|
+
metric_name: Metric name to filter by
|
|
289
|
+
timestamp_column: Name of timestamp column
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Last timestamp or None if no data
|
|
293
|
+
"""
|
|
294
|
+
query = f"""
|
|
295
|
+
SELECT max({timestamp_column}) as last_ts
|
|
296
|
+
FROM {table_name}
|
|
297
|
+
WHERE metric_name = %(metric_name)s
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
result = self.execute_query(query, {"metric_name": metric_name})
|
|
301
|
+
|
|
302
|
+
if result and result[0]["last_ts"]:
|
|
303
|
+
return result[0]["last_ts"]
|
|
304
|
+
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
def upsert_task_status(
|
|
308
|
+
self,
|
|
309
|
+
metric_name: str,
|
|
310
|
+
detector_id: str,
|
|
311
|
+
process_type: str,
|
|
312
|
+
status: str,
|
|
313
|
+
last_processed_timestamp: Optional[datetime] = None,
|
|
314
|
+
error_message: Optional[str] = None,
|
|
315
|
+
timeout_seconds: int = 3600
|
|
316
|
+
) -> None:
|
|
317
|
+
"""
|
|
318
|
+
Update or insert task status in ClickHouse.
|
|
319
|
+
|
|
320
|
+
ClickHouse doesn't have native UPSERT, so we use DELETE + INSERT pattern.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
metric_name: Metric identifier
|
|
324
|
+
detector_id: Detector identifier
|
|
325
|
+
process_type: Process type
|
|
326
|
+
status: Task status
|
|
327
|
+
last_processed_timestamp: Last processed timestamp
|
|
328
|
+
error_message: Error message if failed
|
|
329
|
+
timeout_seconds: Timeout in seconds
|
|
330
|
+
"""
|
|
331
|
+
from detectkit.database.tables import TABLE_TASKS
|
|
332
|
+
|
|
333
|
+
# Get current UTC time (convert to naive UTC for numpy compatibility)
|
|
334
|
+
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
335
|
+
|
|
336
|
+
# First, delete existing record (if any)
|
|
337
|
+
delete_query = f"""
|
|
338
|
+
ALTER TABLE {self.get_full_table_name(TABLE_TASKS, use_internal=True)}
|
|
339
|
+
DELETE WHERE metric_name = %(metric_name)s
|
|
340
|
+
AND detector_id = %(detector_id)s
|
|
341
|
+
AND process_type = %(process_type)s
|
|
342
|
+
"""
|
|
343
|
+
|
|
344
|
+
self._client.execute(
|
|
345
|
+
delete_query,
|
|
346
|
+
{
|
|
347
|
+
"metric_name": metric_name,
|
|
348
|
+
"detector_id": detector_id,
|
|
349
|
+
"process_type": process_type,
|
|
350
|
+
}
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Convert last_processed_timestamp to naive UTC if needed
|
|
354
|
+
last_ts_naive = None
|
|
355
|
+
if last_processed_timestamp:
|
|
356
|
+
if last_processed_timestamp.tzinfo is not None:
|
|
357
|
+
last_ts_naive = last_processed_timestamp.replace(tzinfo=None)
|
|
358
|
+
else:
|
|
359
|
+
last_ts_naive = last_processed_timestamp
|
|
360
|
+
|
|
361
|
+
# Then insert new record
|
|
362
|
+
insert_data = {
|
|
363
|
+
"metric_name": np.array([metric_name]),
|
|
364
|
+
"detector_id": np.array([detector_id]),
|
|
365
|
+
"process_type": np.array([process_type]),
|
|
366
|
+
"status": np.array([status]),
|
|
367
|
+
"started_at": np.array([now], dtype="datetime64[ms]"),
|
|
368
|
+
"updated_at": np.array([now], dtype="datetime64[ms]"),
|
|
369
|
+
"last_processed_timestamp": np.array([last_ts_naive], dtype="datetime64[ms]") if last_ts_naive else np.array([None]),
|
|
370
|
+
"error_message": np.array([error_message]),
|
|
371
|
+
"timeout_seconds": np.array([timeout_seconds], dtype=np.int32),
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
self.insert_batch(
|
|
375
|
+
self.get_full_table_name(TABLE_TASKS, use_internal=True),
|
|
376
|
+
insert_data,
|
|
377
|
+
conflict_strategy="ignore"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def internal_location(self) -> str:
|
|
382
|
+
"""Get internal database name."""
|
|
383
|
+
return self._internal_database
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def data_location(self) -> str:
|
|
387
|
+
"""Get data database name."""
|
|
388
|
+
return self._data_database
|
|
389
|
+
|
|
390
|
+
def close(self) -> None:
|
|
391
|
+
"""Close ClickHouse connection."""
|
|
392
|
+
if hasattr(self, "_client"):
|
|
393
|
+
self._client.disconnect()
|