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.
Files changed (51) hide show
  1. detectkit/__init__.py +17 -0
  2. detectkit/alerting/__init__.py +13 -0
  3. detectkit/alerting/channels/__init__.py +21 -0
  4. detectkit/alerting/channels/base.py +193 -0
  5. detectkit/alerting/channels/email.py +146 -0
  6. detectkit/alerting/channels/factory.py +193 -0
  7. detectkit/alerting/channels/mattermost.py +53 -0
  8. detectkit/alerting/channels/slack.py +55 -0
  9. detectkit/alerting/channels/telegram.py +110 -0
  10. detectkit/alerting/channels/webhook.py +139 -0
  11. detectkit/alerting/orchestrator.py +369 -0
  12. detectkit/cli/__init__.py +1 -0
  13. detectkit/cli/commands/__init__.py +1 -0
  14. detectkit/cli/commands/init.py +282 -0
  15. detectkit/cli/commands/run.py +486 -0
  16. detectkit/cli/commands/test_alert.py +184 -0
  17. detectkit/cli/main.py +186 -0
  18. detectkit/config/__init__.py +30 -0
  19. detectkit/config/metric_config.py +499 -0
  20. detectkit/config/profile.py +285 -0
  21. detectkit/config/project_config.py +164 -0
  22. detectkit/config/validator.py +124 -0
  23. detectkit/core/__init__.py +6 -0
  24. detectkit/core/interval.py +132 -0
  25. detectkit/core/models.py +106 -0
  26. detectkit/database/__init__.py +27 -0
  27. detectkit/database/clickhouse_manager.py +393 -0
  28. detectkit/database/internal_tables.py +724 -0
  29. detectkit/database/manager.py +324 -0
  30. detectkit/database/tables.py +138 -0
  31. detectkit/detectors/__init__.py +6 -0
  32. detectkit/detectors/base.py +441 -0
  33. detectkit/detectors/factory.py +138 -0
  34. detectkit/detectors/statistical/__init__.py +8 -0
  35. detectkit/detectors/statistical/iqr.py +508 -0
  36. detectkit/detectors/statistical/mad.py +478 -0
  37. detectkit/detectors/statistical/manual_bounds.py +206 -0
  38. detectkit/detectors/statistical/zscore.py +491 -0
  39. detectkit/loaders/__init__.py +6 -0
  40. detectkit/loaders/metric_loader.py +470 -0
  41. detectkit/loaders/query_template.py +164 -0
  42. detectkit/orchestration/__init__.py +9 -0
  43. detectkit/orchestration/task_manager.py +746 -0
  44. detectkit/utils/__init__.py +17 -0
  45. detectkit/utils/stats.py +196 -0
  46. detectkit-0.2.4.dist-info/METADATA +237 -0
  47. detectkit-0.2.4.dist-info/RECORD +51 -0
  48. detectkit-0.2.4.dist-info/WHEEL +5 -0
  49. detectkit-0.2.4.dist-info/entry_points.txt +2 -0
  50. detectkit-0.2.4.dist-info/licenses/LICENSE +21 -0
  51. detectkit-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,724 @@
1
+ """
2
+ Internal tables manager for detectk.
3
+
4
+ High-level wrapper over BaseDatabaseManager for working with internal tables
5
+ (_dtk_datapoints, _dtk_detections, _dtk_tasks).
6
+
7
+ This class provides convenient methods that use the UNIVERSAL BaseDatabaseManager
8
+ methods underneath. It does NOT duplicate logic - just provides semantic wrappers.
9
+ """
10
+
11
+ from datetime import datetime, timezone
12
+ from typing import Dict, List, Optional
13
+
14
+ import numpy as np
15
+
16
+ from detectkit.database.manager import BaseDatabaseManager
17
+ from detectkit.database.tables import (
18
+ INTERNAL_TABLES,
19
+ TABLE_DATAPOINTS,
20
+ TABLE_DETECTIONS,
21
+ TABLE_TASKS,
22
+ )
23
+
24
+
25
+ class InternalTablesManager:
26
+ """
27
+ Manager for internal detectk tables.
28
+
29
+ Provides high-level methods for working with _dtk_* tables:
30
+ - Ensure tables exist
31
+ - Save datapoints and detections
32
+ - Task locking and status management
33
+ - Query last timestamps
34
+
35
+ This is a WRAPPER over BaseDatabaseManager - uses its universal methods.
36
+
37
+ Example:
38
+ >>> manager = ClickHouseDatabaseManager(...)
39
+ >>> internal = InternalTablesManager(manager)
40
+ >>> internal.ensure_tables()
41
+ >>> internal.save_datapoints("cpu_usage", data)
42
+ """
43
+
44
+ def __init__(self, manager: BaseDatabaseManager):
45
+ """
46
+ Initialize internal tables manager.
47
+
48
+ Args:
49
+ manager: Database manager instance (ClickHouse, PostgreSQL, etc.)
50
+ """
51
+ self._manager = manager
52
+
53
+ def ensure_tables(self) -> None:
54
+ """
55
+ Create all internal tables if they don't exist.
56
+
57
+ Tables created:
58
+ - _dtk_datapoints
59
+ - _dtk_detections
60
+ - _dtk_tasks
61
+
62
+ This is idempotent - safe to call multiple times.
63
+
64
+ Example:
65
+ >>> internal.ensure_tables()
66
+ """
67
+ for table_name, model_factory in INTERNAL_TABLES.items():
68
+ # Get fully qualified table name in internal location
69
+ full_table_name = self._manager.get_full_table_name(
70
+ table_name, use_internal=True
71
+ )
72
+
73
+ # Check if table exists
74
+ if not self._manager.table_exists(
75
+ table_name, schema=self._manager.internal_location
76
+ ):
77
+ # Create table from model
78
+ table_model = model_factory()
79
+ self._manager.create_table(
80
+ full_table_name, table_model, if_not_exists=True
81
+ )
82
+
83
+ def save_datapoints(
84
+ self,
85
+ metric_name: str,
86
+ data: Dict[str, np.ndarray],
87
+ interval_seconds: int,
88
+ seasonality_columns: list[str],
89
+ ) -> int:
90
+ """
91
+ Save metric datapoints to _dtk_datapoints table.
92
+
93
+ Args:
94
+ metric_name: Metric identifier
95
+ data: Dictionary with keys:
96
+ - timestamp: np.array of datetime64
97
+ - value: np.array of float64 (nullable)
98
+ - seasonality_data: np.array of JSON strings
99
+ interval_seconds: Interval in seconds
100
+ seasonality_columns: List of seasonality column names
101
+
102
+ Returns:
103
+ Number of rows inserted
104
+
105
+ Example:
106
+ >>> data = {
107
+ ... "timestamp": np.array([dt1, dt2], dtype="datetime64[ms]"),
108
+ ... "value": np.array([0.5, 0.6]),
109
+ ... "seasonality_data": np.array(['{"hour": 10}', '{"hour": 11}']),
110
+ ... }
111
+ >>> rows = internal.save_datapoints(
112
+ ... "cpu_usage", data, 600, ["hour", "day_of_week"]
113
+ ... )
114
+ """
115
+ num_rows = len(data["timestamp"])
116
+
117
+ # Prepare data for insert_batch
118
+ insert_data = {
119
+ "metric_name": np.full(num_rows, metric_name, dtype=object),
120
+ "timestamp": data["timestamp"],
121
+ "value": data["value"],
122
+ "seasonality_data": data["seasonality_data"],
123
+ "interval_seconds": np.full(num_rows, interval_seconds, dtype=np.int32),
124
+ "seasonality_columns": np.full(
125
+ num_rows, ",".join(seasonality_columns), dtype=object
126
+ ),
127
+ "created_at": np.full(
128
+ num_rows, datetime.now(timezone.utc).replace(tzinfo=None), dtype="datetime64[ms]"
129
+ ),
130
+ }
131
+
132
+ # Use universal insert_batch method
133
+ full_table_name = self._manager.get_full_table_name(
134
+ TABLE_DATAPOINTS, use_internal=True
135
+ )
136
+
137
+ return self._manager.insert_batch(
138
+ full_table_name, insert_data, conflict_strategy="ignore"
139
+ )
140
+
141
+ def save_detections(
142
+ self,
143
+ metric_name: str,
144
+ detector_id: str,
145
+ detector_name: str,
146
+ data: Dict[str, np.ndarray],
147
+ detector_params: str,
148
+ ) -> int:
149
+ """
150
+ Save detection results to _dtk_detections table.
151
+
152
+ Args:
153
+ metric_name: Metric identifier
154
+ detector_id: Detector identifier (hash)
155
+ detector_name: Detector class name (e.g., "MADDetector")
156
+ data: Dictionary with keys:
157
+ - timestamp: np.array of datetime64
158
+ - is_anomaly: np.array of bool
159
+ - confidence_lower: np.array of float64 (nullable)
160
+ - confidence_upper: np.array of float64 (nullable)
161
+ - value: np.array of float64 (nullable)
162
+ - processed_value: np.array of float64 (nullable)
163
+ - detection_metadata: np.array of JSON strings
164
+ detector_params: JSON string with sorted detector parameters
165
+
166
+ Returns:
167
+ Number of rows inserted
168
+
169
+ Example:
170
+ >>> data = {
171
+ ... "timestamp": np.array([dt1, dt2]),
172
+ ... "is_anomaly": np.array([False, True]),
173
+ ... "confidence_lower": np.array([0.4, 0.5]),
174
+ ... "confidence_upper": np.array([0.6, 0.7]),
175
+ ... "value": np.array([0.5, 0.9]),
176
+ ... "processed_value": np.array([0.5, 0.9]),
177
+ ... "detection_metadata": np.array(['{"severity": 0.0}', '{"severity": 0.8}']),
178
+ ... }
179
+ >>> rows = internal.save_detections(
180
+ ... "cpu_usage", "mad_abc123", "MADDetector", data, '{"threshold": 3.0}'
181
+ ... )
182
+ """
183
+ num_rows = len(data["timestamp"])
184
+
185
+ # Prepare data for insert_batch
186
+ insert_data = {
187
+ "metric_name": np.full(num_rows, metric_name, dtype=object),
188
+ "detector_id": np.full(num_rows, detector_id, dtype=object),
189
+ "detector_name": np.full(num_rows, detector_name, dtype=object),
190
+ "timestamp": data["timestamp"],
191
+ "is_anomaly": data["is_anomaly"],
192
+ "confidence_lower": data["confidence_lower"],
193
+ "confidence_upper": data["confidence_upper"],
194
+ "value": data["value"],
195
+ "processed_value": data["processed_value"],
196
+ "detector_params": np.full(num_rows, detector_params, dtype=object),
197
+ "detection_metadata": data["detection_metadata"],
198
+ "created_at": np.full(
199
+ num_rows, datetime.now(timezone.utc).replace(tzinfo=None), dtype="datetime64[ms]"
200
+ ),
201
+ }
202
+
203
+ # Use universal insert_batch method
204
+ full_table_name = self._manager.get_full_table_name(
205
+ TABLE_DETECTIONS, use_internal=True
206
+ )
207
+
208
+ return self._manager.insert_batch(
209
+ full_table_name, insert_data, conflict_strategy="ignore"
210
+ )
211
+
212
+ def get_last_datapoint_timestamp(self, metric_name: str) -> Optional[datetime]:
213
+ """
214
+ Get last saved timestamp for a metric in _dtk_datapoints.
215
+
216
+ Args:
217
+ metric_name: Metric identifier
218
+
219
+ Returns:
220
+ Last timestamp or None if no data
221
+
222
+ Example:
223
+ >>> last_ts = internal.get_last_datapoint_timestamp("cpu_usage")
224
+ >>> if last_ts:
225
+ ... print(f"Last data at {last_ts}")
226
+ """
227
+ full_table_name = self._manager.get_full_table_name(
228
+ TABLE_DATAPOINTS, use_internal=True
229
+ )
230
+
231
+ return self._manager.get_last_timestamp(full_table_name, metric_name)
232
+
233
+ def get_last_detection_timestamp(
234
+ self, metric_name: str, detector_id: str
235
+ ) -> Optional[datetime]:
236
+ """
237
+ Get last saved timestamp for a detector in _dtk_detections.
238
+
239
+ Args:
240
+ metric_name: Metric identifier
241
+ detector_id: Detector identifier
242
+
243
+ Returns:
244
+ Last timestamp or None if no data
245
+
246
+ Example:
247
+ >>> last_ts = internal.get_last_detection_timestamp("cpu_usage", "mad_abc123")
248
+ >>> if last_ts:
249
+ ... print(f"Last detection at {last_ts}")
250
+ """
251
+ full_table_name = self._manager.get_full_table_name(
252
+ TABLE_DETECTIONS, use_internal=True
253
+ )
254
+
255
+ # Need to filter by both metric_name AND detector_id
256
+ query = f"""
257
+ SELECT max(timestamp) as last_ts
258
+ FROM {full_table_name}
259
+ WHERE metric_name = %(metric_name)s
260
+ AND detector_id = %(detector_id)s
261
+ """
262
+
263
+ result = self._manager.execute_query(
264
+ query, {"metric_name": metric_name, "detector_id": detector_id}
265
+ )
266
+
267
+ if result and result[0]["last_ts"]:
268
+ return result[0]["last_ts"]
269
+
270
+ return None
271
+
272
+ def load_datapoints(
273
+ self,
274
+ metric_name: str,
275
+ from_timestamp: Optional[datetime] = None,
276
+ to_timestamp: Optional[datetime] = None,
277
+ ) -> Dict[str, np.ndarray]:
278
+ """
279
+ Load datapoints from _dtk_datapoints table.
280
+
281
+ Args:
282
+ metric_name: Metric identifier
283
+ from_timestamp: Start timestamp (inclusive, optional)
284
+ to_timestamp: End timestamp (exclusive, optional)
285
+
286
+ Returns:
287
+ Dict with numpy arrays: timestamp, value, seasonality_data
288
+
289
+ Example:
290
+ >>> data = internal.load_datapoints("cpu_usage", from_timestamp=start, to_timestamp=end)
291
+ >>> print(f"Loaded {len(data['timestamp'])} points")
292
+ """
293
+ full_table_name = self._manager.get_full_table_name(
294
+ TABLE_DATAPOINTS, use_internal=True
295
+ )
296
+
297
+ # Build WHERE clause
298
+ where_parts = [f"metric_name = '{metric_name}'"]
299
+ if from_timestamp:
300
+ where_parts.append(f"timestamp >= '{from_timestamp.strftime('%Y-%m-%d %H:%M:%S')}'")
301
+ if to_timestamp:
302
+ where_parts.append(f"timestamp < '{to_timestamp.strftime('%Y-%m-%d %H:%M:%S')}'")
303
+
304
+ where_clause = " AND ".join(where_parts)
305
+
306
+ # Query data
307
+ query = f"""
308
+ SELECT
309
+ timestamp,
310
+ value,
311
+ seasonality_data,
312
+ seasonality_columns
313
+ FROM {full_table_name}
314
+ WHERE {where_clause}
315
+ ORDER BY timestamp
316
+ """
317
+
318
+ results = self._manager.execute_query(query)
319
+
320
+ # Convert to numpy arrays
321
+ if not results:
322
+ return {
323
+ "timestamp": np.array([], dtype="datetime64[ms]"),
324
+ "value": np.array([], dtype=np.float64),
325
+ "seasonality_data": np.array([], dtype=object),
326
+ "seasonality_columns": [],
327
+ }
328
+
329
+ timestamps = [row["timestamp"] for row in results]
330
+ values = [row["value"] for row in results]
331
+ seasonality = [row["seasonality_data"] for row in results]
332
+
333
+ # Get seasonality_columns from first row (comma-separated string)
334
+ seasonality_columns_str = results[0].get("seasonality_columns", "")
335
+ seasonality_columns = [c.strip() for c in seasonality_columns_str.split(",") if c.strip()] if seasonality_columns_str else []
336
+
337
+ return {
338
+ "timestamp": np.array(timestamps, dtype="datetime64[ms]"),
339
+ "value": np.array(values, dtype=np.float64),
340
+ "seasonality_data": np.array(seasonality, dtype=object),
341
+ "seasonality_columns": seasonality_columns,
342
+ }
343
+
344
+ def delete_datapoints(
345
+ self,
346
+ metric_name: str,
347
+ from_timestamp: Optional[datetime] = None,
348
+ to_timestamp: Optional[datetime] = None,
349
+ ) -> int:
350
+ """
351
+ Delete datapoints for a metric.
352
+
353
+ Args:
354
+ metric_name: Metric name
355
+ from_timestamp: Optional start timestamp (inclusive)
356
+ to_timestamp: Optional end timestamp (exclusive)
357
+
358
+ Returns:
359
+ Number of rows deleted (if supported by database)
360
+ """
361
+ full_table_name = self._manager.get_full_table_name(
362
+ TABLE_DATAPOINTS, use_internal=True
363
+ )
364
+
365
+ # Build WHERE clause
366
+ where_parts = [f"metric_name = '{metric_name}'"]
367
+ if from_timestamp:
368
+ where_parts.append(f"timestamp >= '{from_timestamp.strftime('%Y-%m-%d %H:%M:%S')}'")
369
+ if to_timestamp:
370
+ where_parts.append(f"timestamp < '{to_timestamp.strftime('%Y-%m-%d %H:%M:%S')}'")
371
+
372
+ where_clause = " AND ".join(where_parts)
373
+
374
+ # Delete data
375
+ query = f"ALTER TABLE {full_table_name} DELETE WHERE {where_clause}"
376
+ self._manager.execute_query(query)
377
+
378
+ # ClickHouse ALTER TABLE DELETE is async, return 0
379
+ # Other databases might return affected rows
380
+ return 0
381
+
382
+ def delete_detections(
383
+ self,
384
+ metric_name: str,
385
+ detector_id: Optional[str] = None,
386
+ from_timestamp: Optional[datetime] = None,
387
+ to_timestamp: Optional[datetime] = None,
388
+ ) -> int:
389
+ """
390
+ Delete detections for a metric.
391
+
392
+ Args:
393
+ metric_name: Metric name
394
+ detector_id: Optional detector ID filter
395
+ from_timestamp: Optional start timestamp (inclusive)
396
+ to_timestamp: Optional end timestamp (exclusive)
397
+
398
+ Returns:
399
+ Number of rows deleted (if supported by database)
400
+ """
401
+ full_table_name = self._manager.get_full_table_name(
402
+ TABLE_DETECTIONS, use_internal=True
403
+ )
404
+
405
+ # Build WHERE clause
406
+ where_parts = [f"metric_name = '{metric_name}'"]
407
+ if detector_id:
408
+ where_parts.append(f"detector_id = '{detector_id}'")
409
+ if from_timestamp:
410
+ where_parts.append(f"timestamp >= '{from_timestamp.strftime('%Y-%m-%d %H:%M:%S')}'")
411
+ if to_timestamp:
412
+ where_parts.append(f"timestamp < '{to_timestamp.strftime('%Y-%m-%d %H:%M:%S')}'")
413
+
414
+ where_clause = " AND ".join(where_parts)
415
+
416
+ # Delete data
417
+ query = f"ALTER TABLE {full_table_name} DELETE WHERE {where_clause}"
418
+ self._manager.execute_query(query)
419
+
420
+ # ClickHouse ALTER TABLE DELETE is async, return 0
421
+ return 0
422
+
423
+ def get_recent_detections(
424
+ self,
425
+ metric_name: str,
426
+ last_point: datetime,
427
+ num_points: int,
428
+ ) -> List[Dict]:
429
+ """
430
+ Get recent detection results grouped by timestamp.
431
+
432
+ This method is fully database-agnostic - uses simple SELECT
433
+ and groups data in Python (no GROUP BY, no database-specific functions).
434
+
435
+ Args:
436
+ metric_name: Metric identifier
437
+ last_point: Last complete timestamp to query up to
438
+ num_points: Number of recent timestamps to retrieve
439
+
440
+ Returns:
441
+ List of dicts, each containing:
442
+ - timestamp: Detection timestamp
443
+ - detector_ids: List of detector IDs for this timestamp
444
+ - detector_names: List of detector names
445
+ - detector_params_list: List of detector params (JSON strings)
446
+ - is_anomaly_flags: List of is_anomaly bools
447
+ - confidence_lowers: List of lower confidence bounds
448
+ - confidence_uppers: List of upper confidence bounds
449
+ - value: Metric value (same for all detectors at this timestamp)
450
+
451
+ Example:
452
+ >>> detections = internal.get_recent_detections(
453
+ ... "cpu_usage",
454
+ ... datetime(2024, 1, 1, 12, 0, 0),
455
+ ... 5
456
+ ... )
457
+ >>> for det in detections:
458
+ ... print(f"{det['timestamp']}: {len(det['detector_ids'])} detectors")
459
+ """
460
+ full_table_name = self._manager.get_full_table_name(
461
+ TABLE_DETECTIONS, use_internal=True
462
+ )
463
+
464
+ # Step 1: Get distinct timestamps (database-agnostic)
465
+ # Find last N timestamps with detections
466
+ timestamps_query = f"""
467
+ SELECT DISTINCT timestamp
468
+ FROM {full_table_name}
469
+ WHERE metric_name = %(metric_name)s
470
+ AND timestamp <= %(last_point)s
471
+ ORDER BY timestamp DESC
472
+ LIMIT %(num_points)s
473
+ """
474
+
475
+ timestamp_results = self._manager.execute_query(
476
+ timestamps_query,
477
+ params={
478
+ "metric_name": metric_name,
479
+ "last_point": last_point,
480
+ "num_points": num_points,
481
+ },
482
+ )
483
+
484
+ if not timestamp_results:
485
+ return []
486
+
487
+ # Extract timestamps
488
+ timestamps = [row["timestamp"] for row in timestamp_results]
489
+
490
+ # Step 2: Get all detections for these timestamps (simple SELECT)
491
+ # Build IN clause with timestamps
492
+ timestamps_str = ", ".join([
493
+ f"'{ts.strftime('%Y-%m-%d %H:%M:%S')}'" for ts in timestamps
494
+ ])
495
+
496
+ detections_query = f"""
497
+ SELECT
498
+ timestamp,
499
+ detector_id,
500
+ detector_name,
501
+ detector_params,
502
+ is_anomaly,
503
+ confidence_lower,
504
+ confidence_upper,
505
+ value
506
+ FROM {full_table_name}
507
+ WHERE metric_name = %(metric_name)s
508
+ AND timestamp IN ({timestamps_str})
509
+ ORDER BY timestamp DESC, detector_id
510
+ """
511
+
512
+ detection_results = self._manager.execute_query(
513
+ detections_query,
514
+ params={"metric_name": metric_name},
515
+ )
516
+
517
+ if not detection_results:
518
+ return []
519
+
520
+ # Step 3: Group by timestamp in Python (no pandas, pure Python)
521
+ # Use timestamp string as key to avoid datetime comparison issues
522
+ grouped = {}
523
+ for row in detection_results:
524
+ ts = row["timestamp"]
525
+ # Convert timestamp to string key for grouping
526
+ if isinstance(ts, str):
527
+ ts_key = ts
528
+ ts_value = ts
529
+ else:
530
+ # datetime object - normalize and convert to string
531
+ if hasattr(ts, 'tzinfo') and ts.tzinfo is not None:
532
+ ts = ts.replace(tzinfo=None)
533
+ ts_key = ts.isoformat()
534
+ ts_value = ts
535
+
536
+ if ts_key not in grouped:
537
+ grouped[ts_key] = {
538
+ "timestamp": ts_value,
539
+ "detector_ids": [],
540
+ "detector_names": [],
541
+ "detector_params_list": [],
542
+ "is_anomaly_flags": [],
543
+ "confidence_lowers": [],
544
+ "confidence_uppers": [],
545
+ "value": row["value"], # Same for all detectors at this timestamp
546
+ }
547
+
548
+ grouped[ts_key]["detector_ids"].append(row["detector_id"])
549
+ grouped[ts_key]["detector_names"].append(row["detector_name"])
550
+ grouped[ts_key]["detector_params_list"].append(row["detector_params"])
551
+ grouped[ts_key]["is_anomaly_flags"].append(row["is_anomaly"])
552
+ grouped[ts_key]["confidence_lowers"].append(row["confidence_lower"])
553
+ grouped[ts_key]["confidence_uppers"].append(row["confidence_upper"])
554
+
555
+ # Step 4: Convert to list, sorted by timestamp key (desc)
556
+ result = [grouped[ts_key] for ts_key in sorted(grouped.keys(), reverse=True)]
557
+
558
+ return result
559
+
560
+ def acquire_lock(
561
+ self,
562
+ metric_name: str,
563
+ detector_id: str,
564
+ process_type: str,
565
+ timeout_seconds: int = 3600,
566
+ ) -> bool:
567
+ """
568
+ Acquire task lock by creating task record with status='running'.
569
+
570
+ This implements task locking to prevent concurrent execution.
571
+
572
+ Args:
573
+ metric_name: Metric identifier
574
+ detector_id: Detector identifier (or "load" for loading tasks)
575
+ process_type: Process type ("load" or "detect")
576
+ timeout_seconds: Task timeout in seconds
577
+
578
+ Returns:
579
+ True if lock acquired, False if already locked
580
+
581
+ Raises:
582
+ Exception: If lock is held by another process (check timeout)
583
+
584
+ Example:
585
+ >>> if internal.acquire_lock("cpu_usage", "load", "load"):
586
+ ... try:
587
+ ... # Do work
588
+ ... pass
589
+ ... finally:
590
+ ... internal.release_lock("cpu_usage", "load", "load", "completed")
591
+ """
592
+ # Check if task is already running
593
+ existing_status = self.check_lock(metric_name, detector_id, process_type)
594
+
595
+ if existing_status:
596
+ # Task is locked
597
+ # TODO: Check if lock expired based on timeout
598
+ return False
599
+
600
+ # Acquire lock by creating task record
601
+ self._manager.upsert_task_status(
602
+ metric_name=metric_name,
603
+ detector_id=detector_id,
604
+ process_type=process_type,
605
+ status="running",
606
+ timeout_seconds=timeout_seconds,
607
+ )
608
+
609
+ return True
610
+
611
+ def release_lock(
612
+ self,
613
+ metric_name: str,
614
+ detector_id: str,
615
+ process_type: str,
616
+ status: str,
617
+ last_processed_timestamp: Optional[datetime] = None,
618
+ error_message: Optional[str] = None,
619
+ ) -> None:
620
+ """
621
+ Release task lock by updating status to 'completed' or 'failed'.
622
+
623
+ Args:
624
+ metric_name: Metric identifier
625
+ detector_id: Detector identifier
626
+ process_type: Process type
627
+ status: Final status ("completed" or "failed")
628
+ last_processed_timestamp: Last successfully processed timestamp
629
+ error_message: Error message if status is "failed"
630
+
631
+ Example:
632
+ >>> internal.release_lock(
633
+ ... "cpu_usage", "load", "load",
634
+ ... status="completed",
635
+ ... last_processed_timestamp=datetime(2024, 1, 1, 23, 59)
636
+ ... )
637
+ """
638
+ self._manager.upsert_task_status(
639
+ metric_name=metric_name,
640
+ detector_id=detector_id,
641
+ process_type=process_type,
642
+ status=status,
643
+ last_processed_timestamp=last_processed_timestamp,
644
+ error_message=error_message,
645
+ )
646
+
647
+ def check_lock(
648
+ self, metric_name: str, detector_id: str, process_type: str
649
+ ) -> Optional[Dict]:
650
+ """
651
+ Check if task is locked (running).
652
+
653
+ Args:
654
+ metric_name: Metric identifier
655
+ detector_id: Detector identifier
656
+ process_type: Process type
657
+
658
+ Returns:
659
+ Task status dict if locked, None if not locked
660
+
661
+ Example:
662
+ >>> status = internal.check_lock("cpu_usage", "load", "load")
663
+ >>> if status and status["status"] == "running":
664
+ ... print("Task is locked")
665
+ """
666
+ full_table_name = self._manager.get_full_table_name(
667
+ TABLE_TASKS, use_internal=True
668
+ )
669
+
670
+ query = f"""
671
+ SELECT *
672
+ FROM {full_table_name}
673
+ WHERE metric_name = %(metric_name)s
674
+ AND detector_id = %(detector_id)s
675
+ AND process_type = %(process_type)s
676
+ AND status = 'running'
677
+ """
678
+
679
+ results = self._manager.execute_query(
680
+ query,
681
+ {
682
+ "metric_name": metric_name,
683
+ "detector_id": detector_id,
684
+ "process_type": process_type,
685
+ },
686
+ )
687
+
688
+ if results:
689
+ return results[0]
690
+ return None
691
+
692
+ def update_task_progress(
693
+ self,
694
+ metric_name: str,
695
+ detector_id: str,
696
+ process_type: str,
697
+ last_processed_timestamp: datetime,
698
+ ) -> None:
699
+ """
700
+ Update task progress (last_processed_timestamp) while task is running.
701
+
702
+ This enables idempotency - if process crashes, it can resume from
703
+ last_processed_timestamp.
704
+
705
+ Args:
706
+ metric_name: Metric identifier
707
+ detector_id: Detector identifier
708
+ process_type: Process type
709
+ last_processed_timestamp: Last successfully processed timestamp
710
+
711
+ Example:
712
+ >>> # Update progress every 1000 rows
713
+ >>> internal.update_task_progress(
714
+ ... "cpu_usage", "load", "load",
715
+ ... datetime(2024, 1, 1, 12, 0)
716
+ ... )
717
+ """
718
+ self._manager.upsert_task_status(
719
+ metric_name=metric_name,
720
+ detector_id=detector_id,
721
+ process_type=process_type,
722
+ status="running",
723
+ last_processed_timestamp=last_processed_timestamp,
724
+ )