mlrun 1.10.0rc40__py3-none-any.whl → 1.11.0rc16__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.

Potentially problematic release.


This version of mlrun might be problematic. Click here for more details.

Files changed (150) hide show
  1. mlrun/__init__.py +3 -2
  2. mlrun/__main__.py +0 -4
  3. mlrun/artifacts/dataset.py +2 -2
  4. mlrun/artifacts/plots.py +1 -1
  5. mlrun/{model_monitoring/db/tsdb/tdengine → auth}/__init__.py +2 -3
  6. mlrun/auth/nuclio.py +89 -0
  7. mlrun/auth/providers.py +429 -0
  8. mlrun/auth/utils.py +415 -0
  9. mlrun/common/constants.py +7 -0
  10. mlrun/common/model_monitoring/helpers.py +41 -4
  11. mlrun/common/runtimes/constants.py +28 -0
  12. mlrun/common/schemas/__init__.py +13 -3
  13. mlrun/common/schemas/alert.py +2 -2
  14. mlrun/common/schemas/api_gateway.py +3 -0
  15. mlrun/common/schemas/auth.py +10 -10
  16. mlrun/common/schemas/client_spec.py +4 -0
  17. mlrun/common/schemas/constants.py +25 -0
  18. mlrun/common/schemas/frontend_spec.py +1 -8
  19. mlrun/common/schemas/function.py +24 -0
  20. mlrun/common/schemas/hub.py +3 -2
  21. mlrun/common/schemas/model_monitoring/__init__.py +1 -1
  22. mlrun/common/schemas/model_monitoring/constants.py +2 -2
  23. mlrun/common/schemas/secret.py +17 -2
  24. mlrun/common/secrets.py +95 -1
  25. mlrun/common/types.py +10 -10
  26. mlrun/config.py +53 -15
  27. mlrun/data_types/infer.py +2 -2
  28. mlrun/datastore/__init__.py +2 -3
  29. mlrun/datastore/base.py +274 -10
  30. mlrun/datastore/datastore.py +1 -1
  31. mlrun/datastore/datastore_profile.py +49 -17
  32. mlrun/datastore/model_provider/huggingface_provider.py +6 -2
  33. mlrun/datastore/model_provider/model_provider.py +2 -2
  34. mlrun/datastore/model_provider/openai_provider.py +2 -2
  35. mlrun/datastore/s3.py +15 -16
  36. mlrun/datastore/sources.py +1 -1
  37. mlrun/datastore/store_resources.py +4 -4
  38. mlrun/datastore/storeytargets.py +16 -10
  39. mlrun/datastore/targets.py +1 -1
  40. mlrun/datastore/utils.py +16 -3
  41. mlrun/datastore/v3io.py +1 -1
  42. mlrun/db/base.py +36 -12
  43. mlrun/db/httpdb.py +316 -101
  44. mlrun/db/nopdb.py +29 -11
  45. mlrun/errors.py +4 -2
  46. mlrun/execution.py +11 -12
  47. mlrun/feature_store/api.py +1 -1
  48. mlrun/feature_store/common.py +1 -1
  49. mlrun/feature_store/feature_vector_utils.py +1 -1
  50. mlrun/feature_store/steps.py +8 -6
  51. mlrun/frameworks/_common/utils.py +3 -3
  52. mlrun/frameworks/_dl_common/loggers/logger.py +1 -1
  53. mlrun/frameworks/_dl_common/loggers/tensorboard_logger.py +2 -1
  54. mlrun/frameworks/_ml_common/loggers/mlrun_logger.py +1 -1
  55. mlrun/frameworks/_ml_common/utils.py +2 -1
  56. mlrun/frameworks/auto_mlrun/auto_mlrun.py +4 -3
  57. mlrun/frameworks/lgbm/mlrun_interfaces/mlrun_interface.py +2 -1
  58. mlrun/frameworks/onnx/dataset.py +2 -1
  59. mlrun/frameworks/onnx/mlrun_interface.py +2 -1
  60. mlrun/frameworks/pytorch/callbacks/logging_callback.py +5 -4
  61. mlrun/frameworks/pytorch/callbacks/mlrun_logging_callback.py +2 -1
  62. mlrun/frameworks/pytorch/callbacks/tensorboard_logging_callback.py +2 -1
  63. mlrun/frameworks/pytorch/utils.py +2 -1
  64. mlrun/frameworks/sklearn/metric.py +2 -1
  65. mlrun/frameworks/tf_keras/callbacks/logging_callback.py +5 -4
  66. mlrun/frameworks/tf_keras/callbacks/mlrun_logging_callback.py +2 -1
  67. mlrun/frameworks/tf_keras/callbacks/tensorboard_logging_callback.py +2 -1
  68. mlrun/hub/__init__.py +37 -0
  69. mlrun/hub/base.py +142 -0
  70. mlrun/hub/module.py +67 -76
  71. mlrun/hub/step.py +113 -0
  72. mlrun/launcher/base.py +2 -1
  73. mlrun/launcher/local.py +2 -1
  74. mlrun/model.py +12 -2
  75. mlrun/model_monitoring/__init__.py +0 -1
  76. mlrun/model_monitoring/api.py +2 -2
  77. mlrun/model_monitoring/applications/base.py +20 -6
  78. mlrun/model_monitoring/applications/context.py +1 -0
  79. mlrun/model_monitoring/controller.py +7 -17
  80. mlrun/model_monitoring/db/_schedules.py +2 -16
  81. mlrun/model_monitoring/db/_stats.py +2 -13
  82. mlrun/model_monitoring/db/tsdb/__init__.py +9 -7
  83. mlrun/model_monitoring/db/tsdb/base.py +2 -4
  84. mlrun/model_monitoring/db/tsdb/preaggregate.py +234 -0
  85. mlrun/model_monitoring/db/tsdb/stream_graph_steps.py +63 -0
  86. mlrun/model_monitoring/db/tsdb/timescaledb/queries/timescaledb_metrics_queries.py +414 -0
  87. mlrun/model_monitoring/db/tsdb/timescaledb/queries/timescaledb_predictions_queries.py +376 -0
  88. mlrun/model_monitoring/db/tsdb/timescaledb/queries/timescaledb_results_queries.py +590 -0
  89. mlrun/model_monitoring/db/tsdb/timescaledb/timescaledb_connection.py +434 -0
  90. mlrun/model_monitoring/db/tsdb/timescaledb/timescaledb_connector.py +541 -0
  91. mlrun/model_monitoring/db/tsdb/timescaledb/timescaledb_operations.py +808 -0
  92. mlrun/model_monitoring/db/tsdb/timescaledb/timescaledb_schema.py +502 -0
  93. mlrun/model_monitoring/db/tsdb/timescaledb/timescaledb_stream.py +163 -0
  94. mlrun/model_monitoring/db/tsdb/timescaledb/timescaledb_stream_graph_steps.py +60 -0
  95. mlrun/model_monitoring/db/tsdb/timescaledb/utils/timescaledb_dataframe_processor.py +141 -0
  96. mlrun/model_monitoring/db/tsdb/timescaledb/utils/timescaledb_query_builder.py +585 -0
  97. mlrun/model_monitoring/db/tsdb/timescaledb/writer_graph_steps.py +73 -0
  98. mlrun/model_monitoring/db/tsdb/v3io/stream_graph_steps.py +4 -6
  99. mlrun/model_monitoring/db/tsdb/v3io/v3io_connector.py +147 -79
  100. mlrun/model_monitoring/features_drift_table.py +2 -1
  101. mlrun/model_monitoring/helpers.py +2 -1
  102. mlrun/model_monitoring/stream_processing.py +18 -16
  103. mlrun/model_monitoring/writer.py +4 -3
  104. mlrun/package/__init__.py +2 -1
  105. mlrun/platforms/__init__.py +0 -44
  106. mlrun/platforms/iguazio.py +1 -1
  107. mlrun/projects/operations.py +11 -10
  108. mlrun/projects/project.py +81 -82
  109. mlrun/run.py +4 -7
  110. mlrun/runtimes/__init__.py +2 -204
  111. mlrun/runtimes/base.py +89 -21
  112. mlrun/runtimes/constants.py +225 -0
  113. mlrun/runtimes/daskjob.py +4 -2
  114. mlrun/runtimes/databricks_job/databricks_runtime.py +2 -1
  115. mlrun/runtimes/mounts.py +5 -0
  116. mlrun/runtimes/nuclio/__init__.py +12 -8
  117. mlrun/runtimes/nuclio/api_gateway.py +36 -6
  118. mlrun/runtimes/nuclio/application/application.py +200 -32
  119. mlrun/runtimes/nuclio/function.py +154 -49
  120. mlrun/runtimes/nuclio/serving.py +55 -42
  121. mlrun/runtimes/pod.py +59 -10
  122. mlrun/secrets.py +46 -2
  123. mlrun/serving/__init__.py +2 -0
  124. mlrun/serving/remote.py +5 -5
  125. mlrun/serving/routers.py +3 -3
  126. mlrun/serving/server.py +46 -43
  127. mlrun/serving/serving_wrapper.py +6 -2
  128. mlrun/serving/states.py +554 -207
  129. mlrun/serving/steps.py +1 -1
  130. mlrun/serving/system_steps.py +42 -33
  131. mlrun/track/trackers/mlflow_tracker.py +29 -31
  132. mlrun/utils/helpers.py +89 -16
  133. mlrun/utils/http.py +9 -2
  134. mlrun/utils/notifications/notification/git.py +1 -1
  135. mlrun/utils/notifications/notification/mail.py +39 -16
  136. mlrun/utils/notifications/notification_pusher.py +2 -2
  137. mlrun/utils/version/version.json +2 -2
  138. mlrun/utils/version/version.py +3 -4
  139. {mlrun-1.10.0rc40.dist-info → mlrun-1.11.0rc16.dist-info}/METADATA +39 -49
  140. {mlrun-1.10.0rc40.dist-info → mlrun-1.11.0rc16.dist-info}/RECORD +144 -130
  141. mlrun/db/auth_utils.py +0 -152
  142. mlrun/model_monitoring/db/tsdb/tdengine/schemas.py +0 -343
  143. mlrun/model_monitoring/db/tsdb/tdengine/stream_graph_steps.py +0 -75
  144. mlrun/model_monitoring/db/tsdb/tdengine/tdengine_connection.py +0 -281
  145. mlrun/model_monitoring/db/tsdb/tdengine/tdengine_connector.py +0 -1368
  146. mlrun/model_monitoring/db/tsdb/tdengine/writer_graph_steps.py +0 -51
  147. {mlrun-1.10.0rc40.dist-info → mlrun-1.11.0rc16.dist-info}/WHEEL +0 -0
  148. {mlrun-1.10.0rc40.dist-info → mlrun-1.11.0rc16.dist-info}/entry_points.txt +0 -0
  149. {mlrun-1.10.0rc40.dist-info → mlrun-1.11.0rc16.dist-info}/licenses/LICENSE +0 -0
  150. {mlrun-1.10.0rc40.dist-info → mlrun-1.11.0rc16.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,808 @@
1
+ # Copyright 2025 Iguazio
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from typing import Optional
16
+
17
+ import psycopg
18
+
19
+ import mlrun.common.schemas.model_monitoring as mm_schemas
20
+ import mlrun.errors
21
+ import mlrun.model_monitoring.db.tsdb.timescaledb.timescaledb_schema as timescaledb_schema
22
+ from mlrun.datastore.datastore_profile import DatastoreProfilePostgreSQL
23
+ from mlrun.model_monitoring.db.tsdb.preaggregate import PreAggregateConfig
24
+ from mlrun.model_monitoring.db.tsdb.timescaledb.timescaledb_connection import (
25
+ Statement,
26
+ TimescaleDBConnection,
27
+ )
28
+ from mlrun.model_monitoring.db.tsdb.timescaledb.utils.timescaledb_query_builder import (
29
+ TimescaleDBNaming,
30
+ )
31
+ from mlrun.utils import datetime_from_iso, logger
32
+
33
+
34
+ class TimescaleDBOperationsManager:
35
+ """
36
+ Handles all CRUD operations for TimescaleDB TSDB connector.
37
+
38
+ This class implements all create/update/delete operations for model monitoring data:
39
+ - Table and schema creation with optional pre-aggregates and continuous aggregates
40
+ - Event writing with parameterized queries
41
+ - Record deletion with support for both raw and aggregate data cleanup
42
+ - Resource deletion with automatic discovery of project-related tables and views
43
+ - Schema management with automatic cleanup of empty schemas
44
+
45
+
46
+ Key Features:
47
+ - Parameterized queries for all write/delete operations
48
+ - Automatic discovery of aggregate tables for comprehensive cleanup
49
+ - Transaction-based operations for data consistency
50
+ - Configurable pre-aggregation with retention policies
51
+ - Thread-safe operations through shared connection pooling
52
+
53
+ :param project: Project name used for table naming and schema organization
54
+ :param connection: Shared TimescaleDBConnection instance
55
+ :param pre_aggregate_config: Optional configuration for pre-aggregated tables
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ project: str,
61
+ connection: TimescaleDBConnection,
62
+ pre_aggregate_config: Optional[PreAggregateConfig] = None,
63
+ profile: Optional[DatastoreProfilePostgreSQL] = None,
64
+ ):
65
+ """
66
+ Initialize operations handler with a shared connection.
67
+
68
+ :param project: The project name
69
+ :param connection: Shared TimescaleDBConnection instance
70
+ :param pre_aggregate_config: Optional pre-aggregation configuration
71
+ :param profile: Optional datastore profile for admin operations (database creation)
72
+ """
73
+ self.project = project
74
+ self._pre_aggregate_config = pre_aggregate_config
75
+ self._profile = profile
76
+
77
+ # Use the injected shared connection
78
+ self._connection = connection
79
+
80
+ # Initialize table schemas
81
+ self._init_tables()
82
+
83
+ def _init_tables(self) -> None:
84
+ self.tables = timescaledb_schema.create_table_schemas(self.project)
85
+
86
+ def _create_db_if_not_exists(self) -> None:
87
+ """
88
+ Create the database if it does not exist.
89
+
90
+ This method connects to the default 'postgres' database to create
91
+ the monitoring database if it doesn't already exist. It also ensures the
92
+ TimescaleDB extension is enabled in the monitoring database.
93
+
94
+ Note: Requires a profile to be set during initialization.
95
+ """
96
+ if not self._profile:
97
+ logger.debug(
98
+ "No profile provided, skipping database creation",
99
+ project=self.project,
100
+ )
101
+ return
102
+
103
+ database_name = self._profile.database
104
+
105
+ logger.debug(
106
+ "Checking/creating TimescaleDB database",
107
+ project=self.project,
108
+ database=database_name,
109
+ )
110
+
111
+ # Connect to default postgres database to create the monitoring database
112
+ admin_connection = TimescaleDBConnection(
113
+ dsn=self._profile.admin_dsn(),
114
+ min_connections=1,
115
+ max_connections=1,
116
+ autocommit=True, # DDL requires autocommit
117
+ )
118
+
119
+ try:
120
+ # Check if database exists using parameterized Statement
121
+ check_stmt = Statement(
122
+ sql="SELECT 1 FROM pg_database WHERE datname = %s",
123
+ parameters=(database_name,),
124
+ )
125
+ result = admin_connection.run(query=check_stmt)
126
+
127
+ if not result or not result.data:
128
+ # Database doesn't exist, create it
129
+ # Note: CREATE DATABASE cannot be parameterized, but database_name
130
+ # comes from our own profile, not user input
131
+ admin_connection.run(statements=[f'CREATE DATABASE "{database_name}"'])
132
+ logger.info(
133
+ "Created TimescaleDB database",
134
+ project=self.project,
135
+ database=database_name,
136
+ )
137
+ else:
138
+ logger.debug(
139
+ "TimescaleDB database already exists",
140
+ project=self.project,
141
+ database=database_name,
142
+ )
143
+ finally:
144
+ # Close the admin connection pool to avoid resource leak
145
+ admin_connection.close()
146
+
147
+ def create_tables(
148
+ self, pre_aggregate_config: Optional[PreAggregateConfig] = None
149
+ ) -> None:
150
+ config = pre_aggregate_config or self._pre_aggregate_config
151
+
152
+ logger.debug(
153
+ "Creating TimescaleDB tables for model monitoring",
154
+ project=self.project,
155
+ with_pre_aggregates=config is not None,
156
+ )
157
+
158
+ # Create database if it doesn't exist
159
+ self._create_db_if_not_exists()
160
+
161
+ # Try to create extension, ignore if already exists
162
+ try:
163
+ self._connection.run(
164
+ statements=["CREATE EXTENSION IF NOT EXISTS timescaledb"]
165
+ )
166
+ except psycopg.errors.DuplicateObject:
167
+ # Extension already loaded - this is fine
168
+ pass
169
+
170
+ # Create schema if it doesn't exist
171
+ schema_name = self.tables[mm_schemas.TimescaleDBTables.PREDICTIONS].schema
172
+ self._connection.run(statements=[f"CREATE SCHEMA IF NOT EXISTS {schema_name}"])
173
+
174
+ # Create main tables and convert to hypertables
175
+ for table_type, table in self.tables.items():
176
+ statements = [table._create_table_query()]
177
+
178
+ # Convert to hypertable
179
+ statements.append(table._create_hypertable_query())
180
+
181
+ # Create indexes
182
+ statements.extend(table._create_indexes_query())
183
+
184
+ # Create pre-aggregate tables if config provided
185
+ if config:
186
+ statements.extend(table._create_continuous_aggregates_query(config))
187
+ statements.extend(table._create_retention_policies_query(config))
188
+
189
+ # Execute all statements for this table
190
+ self._connection.run(statements=statements)
191
+
192
+ logger.debug(
193
+ "Successfully created TimescaleDB tables",
194
+ project=self.project,
195
+ table_count=len(self.tables),
196
+ )
197
+
198
+ def write_application_event(
199
+ self,
200
+ event: dict,
201
+ kind: mm_schemas.WriterEventKind = mm_schemas.WriterEventKind.RESULT,
202
+ ) -> None:
203
+ """
204
+ Write a single result or metric to TimescaleDB using parameterized queries.
205
+
206
+ Uses PostgreSQL's parameterized queries for safety and performance.
207
+
208
+ :param event: Event data to write
209
+ :param kind: Type of event (RESULT or METRIC)
210
+ """
211
+ if kind == mm_schemas.WriterEventKind.RESULT:
212
+ table = self.tables[mm_schemas.TimescaleDBTables.APP_RESULTS]
213
+ else:
214
+ table = self.tables[mm_schemas.TimescaleDBTables.METRICS]
215
+
216
+ # Convert datetime strings to datetime objects if needed
217
+ for time_field in [
218
+ mm_schemas.WriterEvent.END_INFER_TIME,
219
+ mm_schemas.WriterEvent.START_INFER_TIME,
220
+ ]:
221
+ if time_field in event:
222
+ if isinstance(event[time_field], str):
223
+ event[time_field] = datetime_from_iso(event[time_field])
224
+ # datetime objects can stay as-is
225
+
226
+ # Prepare the INSERT statement with parameterized query
227
+ columns = list(table.columns.keys())
228
+ placeholders = ", ".join(["%s"] * len(columns))
229
+
230
+ insert_sql = f"""
231
+ INSERT INTO {table.full_name()} ({', '.join(columns)})
232
+ VALUES ({placeholders})
233
+ """
234
+
235
+ # Prepare values in the correct order
236
+ values = tuple(event.get(col) for col in columns)
237
+
238
+ # Create parameterized statement
239
+ stmt = Statement(insert_sql, values)
240
+
241
+ try:
242
+ # Execute parameterized query
243
+ self._connection.run(statements=[stmt])
244
+ except Exception as e:
245
+ logger.error(
246
+ "Failed to write application event to TimescaleDB",
247
+ project=self.project,
248
+ table=table.table_name,
249
+ error=mlrun.errors.err_to_str(e),
250
+ )
251
+ raise mlrun.errors.MLRunRuntimeError(
252
+ f"Failed to write event to TimescaleDB: {e}"
253
+ ) from e
254
+
255
+ def delete_tsdb_records(
256
+ self,
257
+ endpoint_ids: list[str],
258
+ include_aggregates: bool = True,
259
+ ) -> None:
260
+ """
261
+ Delete model endpoint records from TimescaleDB using parameterized queries.
262
+
263
+ :param endpoint_ids: List of endpoint IDs to delete
264
+ :param include_aggregates: Whether to delete from pre-aggregate tables as well
265
+ """
266
+ if not endpoint_ids:
267
+ logger.debug("No endpoint IDs provided for deletion", project=self.project)
268
+ return
269
+
270
+ logger.debug(
271
+ "Deleting model endpoint records from TimescaleDB",
272
+ project=self.project,
273
+ number_of_endpoints_to_delete=len(endpoint_ids),
274
+ include_aggregates=include_aggregates,
275
+ )
276
+
277
+ try:
278
+ # Execute all deletions in a single transaction to prevent race conditions
279
+ # Raw data must be deleted first to prevent continuous aggregates from repopulating
280
+ all_deletion_statements = []
281
+
282
+ # 1. Delete raw data first (removes source for continuous aggregates)
283
+ all_deletion_statements.extend(
284
+ self._get_raw_delete_statements(endpoint_ids)
285
+ )
286
+
287
+ # 2. Delete aggregate data second (cleanup existing aggregated data)
288
+ if include_aggregates:
289
+ # Always try to discover and delete aggregates, regardless of config
290
+ all_deletion_statements.extend(
291
+ self._get_aggregate_delete_statements_by_endpoints(endpoint_ids)
292
+ )
293
+
294
+ # Execute all deletions in a single transaction
295
+ self._connection.run(statements=all_deletion_statements)
296
+
297
+ logger.debug(
298
+ "Successfully deleted model endpoint records from TimescaleDB",
299
+ project=self.project,
300
+ number_of_endpoints_deleted=len(endpoint_ids),
301
+ )
302
+
303
+ except Exception as e:
304
+ logger.error(
305
+ "Failed to delete model endpoint records from TimescaleDB",
306
+ project=self.project,
307
+ endpoint_count=len(endpoint_ids),
308
+ error=mlrun.errors.err_to_str(e),
309
+ )
310
+ raise
311
+
312
+ def _get_raw_delete_statements(self, endpoint_ids: list[str]) -> list[Statement]:
313
+ """
314
+ Get parameterized DELETE statements for raw data tables.
315
+
316
+ :param endpoint_ids: List of endpoint IDs to delete
317
+ :return: List of Statement objects for raw data deletion
318
+ """
319
+ statements = []
320
+
321
+ for table_schema in self.tables.values():
322
+ if len(endpoint_ids) == 1:
323
+ delete_sql = (
324
+ f"DELETE FROM {table_schema.full_name()} "
325
+ f"WHERE {mm_schemas.WriterEvent.ENDPOINT_ID} = %s"
326
+ )
327
+ stmt = Statement(delete_sql, (endpoint_ids[0],))
328
+ else:
329
+ delete_sql = (
330
+ f"DELETE FROM {table_schema.full_name()} "
331
+ f"WHERE {mm_schemas.WriterEvent.ENDPOINT_ID} = ANY(%s)"
332
+ )
333
+ stmt = Statement(delete_sql, (endpoint_ids,))
334
+
335
+ statements.append(stmt)
336
+
337
+ return statements
338
+
339
+ def _get_aggregate_delete_statements_by_endpoints(
340
+ self, endpoint_ids: list[str]
341
+ ) -> list[Statement]:
342
+ """
343
+ Get parameterized DELETE statements for aggregate data tables by discovering existing tables.
344
+
345
+ This approach discovers all existing aggregate tables rather than relying on configuration,
346
+ ensuring we don't miss any aggregate data.
347
+
348
+ :param endpoint_ids: List of endpoint IDs to delete (must be non-empty)
349
+ :return: List of Statement objects for aggregate data deletion
350
+ """
351
+ statements = []
352
+
353
+ # Early return for empty endpoint list - nothing to delete
354
+ if not endpoint_ids:
355
+ return statements
356
+
357
+ try:
358
+ schema_name = self.tables[mm_schemas.TimescaleDBTables.PREDICTIONS].schema
359
+
360
+ # Get base table patterns for tables that have endpoint_id
361
+ base_patterns = []
362
+ base_patterns.extend(
363
+ self.tables[table_type].table_name
364
+ for table_type in [
365
+ mm_schemas.TimescaleDBTables.PREDICTIONS,
366
+ mm_schemas.TimescaleDBTables.METRICS,
367
+ mm_schemas.TimescaleDBTables.APP_RESULTS,
368
+ ]
369
+ if table_type in self.tables
370
+ )
371
+ if not base_patterns:
372
+ return statements
373
+
374
+ # Build query to find all aggregate tables and continuous aggregate views
375
+ # TimescaleDB continuous aggregates appear as VIEWs in information_schema
376
+ pattern_conditions = []
377
+ parameters = [schema_name]
378
+
379
+ for pattern in base_patterns:
380
+ pattern_conditions.extend(
381
+ [
382
+ "table_name LIKE %s", # _agg_ tables
383
+ "table_name LIKE %s", # _cagg_ views
384
+ ]
385
+ )
386
+ parameters.extend(TimescaleDBNaming.get_all_aggregate_patterns(pattern))
387
+
388
+ discovery_stmt = Statement(
389
+ f"""
390
+ SELECT table_name
391
+ FROM information_schema.tables
392
+ WHERE table_schema = %s
393
+ AND table_type IN ('BASE TABLE', 'VIEW')
394
+ AND ({' OR '.join(pattern_conditions)})
395
+ ORDER BY table_name
396
+ """,
397
+ tuple(parameters),
398
+ )
399
+
400
+ result = self._connection.run(query=discovery_stmt)
401
+ discovered_objects = (
402
+ [row[0] for row in result.data] if result and result.data else []
403
+ )
404
+
405
+ if not discovered_objects:
406
+ logger.debug(
407
+ "No aggregate objects found for deletion",
408
+ project=self.project,
409
+ schema=schema_name,
410
+ )
411
+ return statements
412
+
413
+ logger.debug(
414
+ "Discovered aggregate objects for endpoint deletion",
415
+ project=self.project,
416
+ aggregate_objects=len(discovered_objects),
417
+ endpoint_count=len(endpoint_ids),
418
+ )
419
+
420
+ # Create delete statements for all discovered aggregate objects
421
+ for object_name in discovered_objects:
422
+ delete_sql = f"DELETE FROM {schema_name}.{object_name} WHERE"
423
+ if len(endpoint_ids) == 1:
424
+ delete_sql += f" {mm_schemas.WriterEvent.ENDPOINT_ID} = %s"
425
+ stmt = Statement(delete_sql, (endpoint_ids[0],))
426
+ else:
427
+ delete_sql += f" {mm_schemas.WriterEvent.ENDPOINT_ID} = ANY(%s)"
428
+ stmt = Statement(delete_sql, (endpoint_ids,))
429
+
430
+ statements.append(stmt)
431
+
432
+ except Exception as e:
433
+ logger.debug(
434
+ "Failed to discover aggregate objects for deletion",
435
+ project=self.project,
436
+ error=mlrun.errors.err_to_str(e),
437
+ )
438
+ # Continue with empty statements list rather than failing completely
439
+
440
+ return statements
441
+
442
+ def delete_tsdb_resources(self) -> None:
443
+ """
444
+ Delete all project resources in TimescaleDB by discovering existing tables that match our patterns.
445
+
446
+ This approach ensures we don't miss any tables, even if configurations are out of sync.
447
+ """
448
+ logger.debug(
449
+ "Deleting all project resources from TimescaleDB",
450
+ project=self.project,
451
+ )
452
+
453
+ try:
454
+ schema_name = self.tables[mm_schemas.TimescaleDBTables.PREDICTIONS].schema
455
+
456
+ # Get the base table patterns for this project
457
+ base_patterns = []
458
+ base_patterns.extend(
459
+ table_schema.table_name for table_schema in self.tables.values()
460
+ )
461
+ # Build discovery query for all project objects
462
+ pattern_conditions = []
463
+ parameters = [schema_name]
464
+
465
+ for pattern in base_patterns:
466
+ # Match exact table name OR table name with _agg_/_cagg_ suffix
467
+ pattern_conditions.extend(
468
+ [
469
+ "table_name = %s",
470
+ "table_name LIKE %s", # _agg_ tables
471
+ "table_name LIKE %s", # _cagg_ views
472
+ ]
473
+ )
474
+ parameters.extend(TimescaleDBNaming.get_deletion_patterns(pattern))
475
+
476
+ # Discover tables
477
+ tables_stmt = Statement(
478
+ f"""
479
+ SELECT table_name
480
+ FROM information_schema.tables
481
+ WHERE table_schema = %s
482
+ AND table_type = 'BASE TABLE'
483
+ AND ({' OR '.join(pattern_conditions)})
484
+ ORDER BY table_name
485
+ """,
486
+ tuple([schema_name] + parameters[1:]),
487
+ )
488
+
489
+ # Build separate pattern conditions for TimescaleDB continuous aggregates
490
+ view_pattern_conditions = []
491
+ view_parameters = [schema_name]
492
+
493
+ for pattern in base_patterns:
494
+ # For continuous aggregates, look for _cagg_ pattern
495
+ view_pattern_conditions.append("view_name LIKE %s")
496
+ view_parameters.append(TimescaleDBNaming.get_cagg_pattern(pattern))
497
+
498
+ # Discover TimescaleDB continuous aggregates (use TimescaleDB catalog, not pg_matviews)
499
+ views_stmt = Statement(
500
+ f"""
501
+ SELECT view_name as table_name
502
+ FROM timescaledb_information.continuous_aggregates
503
+ WHERE view_schema = %s
504
+ AND ({' OR '.join(view_pattern_conditions)})
505
+ ORDER BY view_name
506
+ """,
507
+ tuple(view_parameters),
508
+ )
509
+
510
+ tables_result = self._connection.run(query=tables_stmt)
511
+ views_result = self._connection.run(query=views_stmt)
512
+
513
+ discovered_tables = (
514
+ [row[0] for row in tables_result.data]
515
+ if tables_result and tables_result.data
516
+ else []
517
+ )
518
+ discovered_views = (
519
+ [row[0] for row in views_result.data]
520
+ if views_result and views_result.data
521
+ else []
522
+ )
523
+
524
+ if not discovered_tables and not discovered_views:
525
+ logger.debug(
526
+ "No project resources found to delete",
527
+ project=self.project,
528
+ schema=schema_name,
529
+ )
530
+ return
531
+
532
+ logger.debug(
533
+ "Discovered project resources for deletion",
534
+ project=self.project,
535
+ tables=len(discovered_tables),
536
+ views=len(discovered_views),
537
+ schema=schema_name,
538
+ )
539
+
540
+ drop_statements = []
541
+
542
+ # Drop materialized views first (they depend on tables)
543
+ if discovered_views:
544
+ view_list = ", ".join(
545
+ f"{schema_name}.{view_name}" for view_name in discovered_views
546
+ )
547
+ drop_statements.append(
548
+ f"DROP MATERIALIZED VIEW IF EXISTS {view_list} CASCADE"
549
+ )
550
+
551
+ # Drop tables second (one by one due to TimescaleDB hypertable limitations)
552
+ drop_statements.extend(
553
+ f"DROP TABLE IF EXISTS {schema_name}.{table_name} CASCADE"
554
+ for table_name in discovered_tables
555
+ )
556
+ # Execute all drops
557
+ if drop_statements:
558
+ self._connection.run(statements=drop_statements)
559
+
560
+ logger.debug(
561
+ "Successfully dropped project resources from TimescaleDB",
562
+ project=self.project,
563
+ )
564
+
565
+ # Optional cleanup: drop schema if empty (errors are logged but don't fail the operation)
566
+ self._drop_schema_if_empty()
567
+
568
+ except Exception as e:
569
+ logger.error(
570
+ "Failed to delete all project resources from TimescaleDB",
571
+ project=self.project,
572
+ error=mlrun.errors.err_to_str(e),
573
+ )
574
+ raise
575
+
576
+ logger.debug(
577
+ "Successfully deleted all project resources from TimescaleDB",
578
+ project=self.project,
579
+ )
580
+
581
+ def _drop_schema_if_empty(self) -> None:
582
+ """
583
+ Drop the schema if it contains no more tables using parameterized query.
584
+
585
+ This is a best-effort cleanup operation that should not fail the main resource deletion.
586
+ Schema dropping may fail due to permissions, remaining objects, or concurrent operations,
587
+ but the primary table deletion operation has already succeeded.
588
+ """
589
+ try:
590
+ schema_name = self.tables[mm_schemas.TimescaleDBTables.PREDICTIONS].schema
591
+
592
+ # Check if schema has any tables using parameterized query
593
+ check_stmt = Statement(
594
+ """
595
+ SELECT COUNT(*) AS table_count
596
+ FROM information_schema.tables
597
+ WHERE table_schema = %s
598
+ """,
599
+ (schema_name,),
600
+ )
601
+
602
+ result = self._connection.run(query=check_stmt)
603
+
604
+ if result and result.data and result.data[0][0] == 0:
605
+ # Schema is empty, drop it
606
+ drop_schema_query = f"DROP SCHEMA IF EXISTS {schema_name} CASCADE"
607
+ self._connection.run(statements=[drop_schema_query])
608
+
609
+ logger.debug(
610
+ "Dropped empty schema",
611
+ project=self.project,
612
+ schema=schema_name,
613
+ )
614
+ except Exception as e:
615
+ # Schema dropping is optional cleanup - don't fail the main operation
616
+ # This may happen due to permissions, remaining objects, or concurrent operations
617
+ logger.warning(
618
+ "Failed to check/drop empty schema (non-critical cleanup operation)",
619
+ project=self.project,
620
+ error=mlrun.errors.err_to_str(e),
621
+ )
622
+
623
+ def delete_application_records(
624
+ self, application_name: str, endpoint_ids: Optional[list[str]] = None
625
+ ) -> None:
626
+ """
627
+ Delete application records from TimescaleDB for the given model endpoints or all if endpoint_ids is None.
628
+
629
+ This method deletes records from both app_results and metrics tables that match the specified
630
+ application name and optionally filter by endpoint IDs.
631
+
632
+ :param application_name: Name of the application whose records should be deleted
633
+ :param endpoint_ids: Optional list of endpoint IDs to filter deletion. If None, deletes all records
634
+ for the application across all endpoints.
635
+ """
636
+ logger.debug(
637
+ "Deleting application records from TimescaleDB",
638
+ project=self.project,
639
+ application_name=application_name,
640
+ endpoint_ids=endpoint_ids,
641
+ )
642
+
643
+ if not application_name:
644
+ logger.warning(
645
+ "No application name provided for deletion", project=self.project
646
+ )
647
+ return
648
+
649
+ try:
650
+ self._delete_application_records(application_name, endpoint_ids)
651
+ except Exception as e:
652
+ logger.error(
653
+ "Failed to delete application records from TimescaleDB",
654
+ project=self.project,
655
+ application_name=application_name,
656
+ endpoint_ids=endpoint_ids,
657
+ error=mlrun.errors.err_to_str(e),
658
+ )
659
+ raise mlrun.errors.MLRunRuntimeError(
660
+ f"Failed to delete application records for {application_name}: {e}"
661
+ ) from e
662
+
663
+ def _delete_application_records(self, application_name, endpoint_ids):
664
+ base_parameters = [application_name]
665
+
666
+ # Add endpoint filter if provided
667
+ if endpoint_ids:
668
+ if len(endpoint_ids) == 1:
669
+ endpoint_filter = f" AND {mm_schemas.WriterEvent.ENDPOINT_ID} = %s"
670
+ parameters = base_parameters + [endpoint_ids[0]]
671
+ else:
672
+ endpoint_filter = f" AND {mm_schemas.WriterEvent.ENDPOINT_ID} = ANY(%s)"
673
+ parameters = base_parameters + [endpoint_ids]
674
+ else:
675
+ endpoint_filter = ""
676
+ parameters = base_parameters
677
+
678
+ # Delete from app_results table
679
+ app_results_table = self.tables[mm_schemas.TimescaleDBTables.APP_RESULTS]
680
+ app_filter = f"{mm_schemas.WriterEvent.APPLICATION_NAME} = %s"
681
+ app_results_sql = (
682
+ f"DELETE FROM {app_results_table.full_name()} "
683
+ f"WHERE {app_filter}{endpoint_filter}"
684
+ )
685
+ deletion_statements = [Statement(app_results_sql, tuple(parameters))]
686
+ # Delete from metrics table
687
+ metrics_table = self.tables[mm_schemas.TimescaleDBTables.METRICS]
688
+ metrics_sql = (
689
+ f"DELETE FROM {metrics_table.full_name()} "
690
+ f"WHERE {app_filter}{endpoint_filter}"
691
+ )
692
+ deletion_statements.append(Statement(metrics_sql, tuple(parameters)))
693
+
694
+ # Also delete from aggregate tables if they exist
695
+ aggregate_statements = self._get_aggregate_delete_statements_by_application(
696
+ application_name, endpoint_ids
697
+ )
698
+ deletion_statements.extend(aggregate_statements)
699
+
700
+ # Execute all deletions in a single transaction
701
+ self._connection.run(statements=deletion_statements)
702
+
703
+ logger.debug(
704
+ "Successfully deleted application records from TimescaleDB",
705
+ project=self.project,
706
+ application_name=application_name,
707
+ endpoint_count=len(endpoint_ids) if endpoint_ids else "all",
708
+ )
709
+
710
+ def _get_aggregate_delete_statements_by_application(
711
+ self, application_name: str, endpoint_ids: Optional[list[str]] = None
712
+ ) -> list[Statement]:
713
+ """
714
+ Get parameterized DELETE statements for aggregate tables filtered by application name.
715
+
716
+ This discovers existing aggregate tables and creates deletion statements that filter
717
+ by both application name and optionally endpoint IDs.
718
+
719
+ :param application_name: Application name to filter by
720
+ :param endpoint_ids: Optional endpoint IDs to filter by
721
+ :return: List of Statement objects for aggregate data deletion
722
+ """
723
+ statements = []
724
+
725
+ try:
726
+ schema_name = self.tables[mm_schemas.TimescaleDBTables.PREDICTIONS].schema
727
+
728
+ # Discover all continuous aggregates and materialized views for this project
729
+ discovery_stmt = Statement(
730
+ """
731
+ SELECT table_name
732
+ FROM (
733
+ SELECT matviewname as table_name
734
+ FROM pg_matviews
735
+ WHERE schemaname = %s
736
+ AND matviewname LIKE %s
737
+
738
+ UNION ALL
739
+
740
+ SELECT view_name as table_name
741
+ FROM timescaledb_information.continuous_aggregates
742
+ WHERE view_schema = %s
743
+ AND view_name LIKE %s
744
+ ) AS combined_objects
745
+ ORDER BY table_name
746
+ """,
747
+ (schema_name, f"%{self.project}%", schema_name, f"%{self.project}%"),
748
+ )
749
+
750
+ result = self._connection.run(query=discovery_stmt)
751
+ discovered_objects = (
752
+ [row[0] for row in result.data] if result and result.data else []
753
+ )
754
+
755
+ if not discovered_objects:
756
+ logger.debug(
757
+ "No aggregate objects found for application deletion",
758
+ project=self.project,
759
+ application_name=application_name,
760
+ schema=schema_name,
761
+ )
762
+ return statements
763
+
764
+ logger.debug(
765
+ "Discovered aggregate objects for application deletion",
766
+ project=self.project,
767
+ application_name=application_name,
768
+ aggregate_objects=len(discovered_objects),
769
+ )
770
+
771
+ # Build filter conditions
772
+ app_filter = f"{mm_schemas.WriterEvent.APPLICATION_NAME} = %s"
773
+ base_parameters = [application_name]
774
+
775
+ # Note: None means "delete all for this application" (no endpoint filter)
776
+ # Empty list [] would delete nothing, so we treat it same as None
777
+ if endpoint_ids: # Non-empty list
778
+ if len(endpoint_ids) == 1:
779
+ endpoint_filter = f" AND {mm_schemas.WriterEvent.ENDPOINT_ID} = %s"
780
+ parameters = base_parameters + [endpoint_ids[0]]
781
+ else:
782
+ endpoint_filter = (
783
+ f" AND {mm_schemas.WriterEvent.ENDPOINT_ID} = ANY(%s)"
784
+ )
785
+ parameters = base_parameters + [endpoint_ids]
786
+ else:
787
+ endpoint_filter = ""
788
+ parameters = base_parameters
789
+
790
+ # Create delete statements for all discovered aggregate objects
791
+ for object_name in discovered_objects:
792
+ delete_sql = (
793
+ f"DELETE FROM {schema_name}.{object_name} "
794
+ f"WHERE {app_filter}{endpoint_filter}"
795
+ )
796
+ stmt = Statement(delete_sql, tuple(parameters))
797
+ statements.append(stmt)
798
+
799
+ except Exception as e:
800
+ logger.warning(
801
+ "Failed to discover aggregate objects for application deletion",
802
+ project=self.project,
803
+ application_name=application_name,
804
+ error=mlrun.errors.err_to_str(e),
805
+ )
806
+ # Continue with empty statements list rather than failing completely
807
+
808
+ return statements