databricks4py 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {databricks4py-0.2.0/src/databricks4py.egg-info → databricks4py-0.3.0}/PKG-INFO +1 -1
- {databricks4py-0.2.0 → databricks4py-0.3.0}/pyproject.toml +1 -1
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/__init__.py +1 -1
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/io/__init__.py +11 -1
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/io/delta.py +57 -2
- databricks4py-0.3.0/src/databricks4py/io/maintenance.py +151 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/io/merge.py +52 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/io/streaming.py +35 -1
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/quality/__init__.py +7 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/quality/expectations.py +90 -1
- databricks4py-0.3.0/src/databricks4py/quality/profiler.py +124 -0
- databricks4py-0.3.0/src/databricks4py/transforms.py +109 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0/src/databricks4py.egg-info}/PKG-INFO +1 -1
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py.egg-info/SOURCES.txt +10 -0
- databricks4py-0.3.0/tests/test_circuit_breaker.py +240 -0
- databricks4py-0.3.0/tests/test_delta_restore.py +134 -0
- databricks4py-0.3.0/tests/test_freshness_expectation.py +111 -0
- databricks4py-0.3.0/tests/test_maintenance.py +187 -0
- databricks4py-0.3.0/tests/test_profiler.py +204 -0
- databricks4py-0.3.0/tests/test_soft_delete.py +155 -0
- databricks4py-0.3.0/tests/test_transforms.py +269 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/LICENSE +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/README.md +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/setup.cfg +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/catalog.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/config/__init__.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/config/base.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/config/unity.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/filters/__init__.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/filters/base.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/io/checkpoint.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/io/dbfs.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/logging.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/metrics/__init__.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/metrics/base.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/metrics/delta_sink.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/metrics/logging_sink.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/migrations/__init__.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/migrations/alter.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/migrations/runner.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/migrations/schema_diff.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/migrations/validators.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/observability/__init__.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/observability/_utils.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/observability/batch_context.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/observability/health.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/observability/query_listener.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/py.typed +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/quality/base.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/quality/gate.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/retry.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/secrets.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/spark_session.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/testing/__init__.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/testing/assertions.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/testing/builders.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/testing/fixtures.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/testing/mocks.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/testing/temp_table.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/workflow.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py.egg-info/dependency_links.txt +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py.egg-info/requires.txt +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py.egg-info/top_level.txt +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_assertions.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_batch_context.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_builders.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_catalog.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_checkpoint.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_config.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_dbfs.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_delta.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_delta_advanced_integration.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_delta_metrics_sink_integration.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_filters.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_health.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_logging.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_merge.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_metrics.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_migration_runner.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_migration_runner_integration.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_migrations.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_mocks.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_observability_integration.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_quality.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_quality_integration.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_query_listener.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_retry.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_schema_diff.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_secrets.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_spark_session.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_streaming.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_streaming_dlq.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_streaming_integration.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_table_alter.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_table_alter_integration.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_temp_table.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_workflow.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_workflow_e2e_integration.py +0 -0
- {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_workflow_v2.py +0 -0
|
@@ -4,7 +4,7 @@ A collection of reusable abstractions for building PySpark applications
|
|
|
4
4
|
on Databricks and locally.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "0.
|
|
7
|
+
__version__ = "0.3.0"
|
|
8
8
|
|
|
9
9
|
from databricks4py.catalog import CatalogSchema
|
|
10
10
|
from databricks4py.config import Environment, JobConfig, UnityConfig
|
|
@@ -10,8 +10,13 @@ from databricks4py.io.delta import (
|
|
|
10
10
|
optimize_table,
|
|
11
11
|
vacuum_table,
|
|
12
12
|
)
|
|
13
|
+
from databricks4py.io.maintenance import MaintenanceResult, MaintenanceRunner, analyze_table
|
|
13
14
|
from databricks4py.io.merge import MergeBuilder, MergeResult
|
|
14
|
-
from databricks4py.io.streaming import
|
|
15
|
+
from databricks4py.io.streaming import (
|
|
16
|
+
CircuitBreakerError,
|
|
17
|
+
StreamingTableReader,
|
|
18
|
+
StreamingTriggerOptions,
|
|
19
|
+
)
|
|
15
20
|
|
|
16
21
|
__all__ = [
|
|
17
22
|
# Checkpoint
|
|
@@ -31,10 +36,15 @@ __all__ = [
|
|
|
31
36
|
"GeneratedColumn",
|
|
32
37
|
"optimize_table",
|
|
33
38
|
"vacuum_table",
|
|
39
|
+
# Maintenance
|
|
40
|
+
"MaintenanceResult",
|
|
41
|
+
"MaintenanceRunner",
|
|
42
|
+
"analyze_table",
|
|
34
43
|
# Merge
|
|
35
44
|
"MergeBuilder",
|
|
36
45
|
"MergeResult",
|
|
37
46
|
# Streaming
|
|
47
|
+
"CircuitBreakerError",
|
|
38
48
|
"StreamingTableReader",
|
|
39
49
|
"StreamingTriggerOptions",
|
|
40
50
|
]
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import logging
|
|
6
6
|
from collections.abc import Sequence
|
|
7
7
|
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
8
9
|
from typing import TYPE_CHECKING, Any
|
|
9
10
|
|
|
10
11
|
from pyspark.sql import DataFrame, SparkSession
|
|
@@ -95,7 +96,7 @@ class DeltaTable:
|
|
|
95
96
|
spark: SparkSession | None = None,
|
|
96
97
|
) -> None:
|
|
97
98
|
self._spark = active_fallback(spark)
|
|
98
|
-
self._table_name = table_name
|
|
99
|
+
self._table_name = self._validated_table_name(table_name)
|
|
99
100
|
self._schema = self._resolve_schema(schema)
|
|
100
101
|
self._location = location
|
|
101
102
|
self._generated_columns = list(generated_columns or [])
|
|
@@ -107,6 +108,20 @@ class DeltaTable:
|
|
|
107
108
|
|
|
108
109
|
self._ensure_table_exists()
|
|
109
110
|
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _validated_table_name(name: str) -> str:
|
|
113
|
+
"""Validate that a table name doesn't contain SQL injection characters.
|
|
114
|
+
|
|
115
|
+
Allows word characters, dots, backticks, hyphens, spaces, and quotes
|
|
116
|
+
(all valid in Spark/Delta table identifiers). Rejects semicolons and
|
|
117
|
+
SQL comment sequences that are the primary injection vectors.
|
|
118
|
+
"""
|
|
119
|
+
import re
|
|
120
|
+
|
|
121
|
+
if re.search(r"--|;|/\*|\*/", name):
|
|
122
|
+
raise ValueError(f"Unsafe characters in table name: {name!r}")
|
|
123
|
+
return name
|
|
124
|
+
|
|
110
125
|
@staticmethod
|
|
111
126
|
def _resolve_schema(schema: StructType | dict[str, str]) -> StructType:
|
|
112
127
|
if isinstance(schema, dict):
|
|
@@ -251,6 +266,46 @@ class DeltaTable:
|
|
|
251
266
|
row = self.detail().select("partitionColumns").first()
|
|
252
267
|
return list(row["partitionColumns"]) if row else []
|
|
253
268
|
|
|
269
|
+
def history(self, limit: int = 20) -> DataFrame:
|
|
270
|
+
"""Retrieve the Delta transaction history for this table.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
limit: Maximum number of history entries to return (default 20).
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
DataFrame with columns including ``version``, ``timestamp``,
|
|
277
|
+
``operation``, and ``operationMetrics``.
|
|
278
|
+
"""
|
|
279
|
+
return self._spark.sql(f"DESCRIBE HISTORY {self._table_name} LIMIT {limit}")
|
|
280
|
+
|
|
281
|
+
def restore(self, version: int) -> DataFrame:
|
|
282
|
+
"""Restore this table to a prior Delta version.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
version: The Delta version number to restore to (from :meth:`history`).
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
DataFrame summarising the restore operation.
|
|
289
|
+
"""
|
|
290
|
+
logger.info("Restoring %s to version %d", self._table_name, version)
|
|
291
|
+
return self._spark.sql(f"RESTORE TABLE {self._table_name} TO VERSION AS OF {version}")
|
|
292
|
+
|
|
293
|
+
def restore_to_timestamp(self, timestamp: datetime) -> DataFrame:
|
|
294
|
+
"""Restore this table to the state at a given timestamp.
|
|
295
|
+
|
|
296
|
+
The ``timestamp`` is formatted as ``YYYY-MM-DD HH:MM:SS`` for the SQL
|
|
297
|
+
statement. Pass a timezone-aware datetime for predictable behaviour.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
timestamp: The point in time to restore to.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
DataFrame summarising the restore operation.
|
|
304
|
+
"""
|
|
305
|
+
ts_str = timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
|
306
|
+
logger.info("Restoring %s to timestamp %s", self._table_name, ts_str)
|
|
307
|
+
return self._spark.sql(f"RESTORE TABLE {self._table_name} TO TIMESTAMP AS OF '{ts_str}'")
|
|
308
|
+
|
|
254
309
|
def merge(
|
|
255
310
|
self,
|
|
256
311
|
source: DataFrame,
|
|
@@ -359,7 +414,7 @@ class DeltaTable:
|
|
|
359
414
|
history = self._spark.sql(f"DESCRIBE HISTORY {self._table_name} LIMIT 1")
|
|
360
415
|
rows = history.collect()
|
|
361
416
|
if not rows:
|
|
362
|
-
return
|
|
417
|
+
return _MergeResult(rows_inserted=0, rows_updated=0, rows_deleted=0)
|
|
363
418
|
metrics: dict[str, str] = rows[0]["operationMetrics"] or {}
|
|
364
419
|
|
|
365
420
|
result = _MergeResult(
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Table maintenance utilities for Delta Lake."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from pyspark.sql import SparkSession
|
|
12
|
+
|
|
13
|
+
from databricks4py.io.delta import optimize_table, vacuum_table
|
|
14
|
+
from databricks4py.spark_session import active_fallback
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from databricks4py.metrics.base import MetricsSink
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"MaintenanceResult",
|
|
21
|
+
"MaintenanceRunner",
|
|
22
|
+
"analyze_table",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def analyze_table(
|
|
29
|
+
table_name: str,
|
|
30
|
+
*,
|
|
31
|
+
columns: Sequence[str] | None = None,
|
|
32
|
+
spark: SparkSession | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Run ANALYZE TABLE to compute statistics.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
table_name: The table to analyze.
|
|
38
|
+
columns: Optional columns to compute statistics for.
|
|
39
|
+
spark: Optional SparkSession.
|
|
40
|
+
"""
|
|
41
|
+
_spark = active_fallback(spark)
|
|
42
|
+
|
|
43
|
+
if columns:
|
|
44
|
+
col_list = ", ".join(columns)
|
|
45
|
+
sql = f"ANALYZE TABLE {table_name} COMPUTE STATISTICS FOR COLUMNS {col_list}"
|
|
46
|
+
else:
|
|
47
|
+
sql = f"ANALYZE TABLE {table_name} COMPUTE STATISTICS"
|
|
48
|
+
|
|
49
|
+
logger.info("Running: %s", sql)
|
|
50
|
+
_spark.sql(sql)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class MaintenanceResult:
|
|
55
|
+
"""Result of a maintenance run."""
|
|
56
|
+
|
|
57
|
+
table_name: str
|
|
58
|
+
optimized: bool
|
|
59
|
+
vacuumed: bool
|
|
60
|
+
analyzed: bool
|
|
61
|
+
duration_ms: float
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MaintenanceRunner:
|
|
65
|
+
"""Composes optimize, vacuum, and analyze into a single maintenance run."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
table_name: str,
|
|
70
|
+
*,
|
|
71
|
+
optimize: bool = True,
|
|
72
|
+
vacuum: bool = True,
|
|
73
|
+
analyze: bool = True,
|
|
74
|
+
zorder_by: str | Sequence[str] | None = None,
|
|
75
|
+
retention_hours: int = 168,
|
|
76
|
+
analyze_columns: Sequence[str] | None = None,
|
|
77
|
+
metrics_sink: MetricsSink | None = None,
|
|
78
|
+
spark: SparkSession | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
self._table_name = table_name
|
|
81
|
+
self._optimize = optimize
|
|
82
|
+
self._vacuum = vacuum
|
|
83
|
+
self._analyze = analyze
|
|
84
|
+
self._zorder_by = zorder_by
|
|
85
|
+
self._retention_hours = retention_hours
|
|
86
|
+
self._analyze_columns = analyze_columns
|
|
87
|
+
self._metrics_sink = metrics_sink
|
|
88
|
+
self._spark = active_fallback(spark)
|
|
89
|
+
|
|
90
|
+
def run(self) -> MaintenanceResult:
|
|
91
|
+
start = time.monotonic()
|
|
92
|
+
|
|
93
|
+
if self._optimize:
|
|
94
|
+
logger.info("Optimizing %s", self._table_name)
|
|
95
|
+
optimize_table(
|
|
96
|
+
self._table_name,
|
|
97
|
+
zorder_by=self._zorder_by,
|
|
98
|
+
spark=self._spark,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if self._vacuum:
|
|
102
|
+
logger.info("Vacuuming %s", self._table_name)
|
|
103
|
+
vacuum_table(
|
|
104
|
+
self._table_name,
|
|
105
|
+
retention_hours=self._retention_hours,
|
|
106
|
+
spark=self._spark,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if self._analyze:
|
|
110
|
+
logger.info("Analyzing %s", self._table_name)
|
|
111
|
+
analyze_table(
|
|
112
|
+
self._table_name,
|
|
113
|
+
columns=self._analyze_columns,
|
|
114
|
+
spark=self._spark,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
118
|
+
|
|
119
|
+
result = MaintenanceResult(
|
|
120
|
+
table_name=self._table_name,
|
|
121
|
+
optimized=self._optimize,
|
|
122
|
+
vacuumed=self._vacuum,
|
|
123
|
+
analyzed=self._analyze,
|
|
124
|
+
duration_ms=duration_ms,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if self._metrics_sink:
|
|
128
|
+
from datetime import datetime, timezone
|
|
129
|
+
|
|
130
|
+
from databricks4py.metrics.base import MetricEvent
|
|
131
|
+
|
|
132
|
+
event = MetricEvent(
|
|
133
|
+
job_name="maintenance",
|
|
134
|
+
event_type="maintenance_complete",
|
|
135
|
+
timestamp=datetime.now(tz=timezone.utc),
|
|
136
|
+
duration_ms=int(duration_ms),
|
|
137
|
+
table_name=self._table_name,
|
|
138
|
+
metadata={
|
|
139
|
+
"optimized": result.optimized,
|
|
140
|
+
"vacuumed": result.vacuumed,
|
|
141
|
+
"analyzed": result.analyzed,
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
self._metrics_sink.emit(event)
|
|
145
|
+
|
|
146
|
+
logger.info(
|
|
147
|
+
"Maintenance complete for %s in %.0fms",
|
|
148
|
+
self._table_name,
|
|
149
|
+
duration_ms,
|
|
150
|
+
)
|
|
151
|
+
return result
|
|
@@ -92,6 +92,45 @@ class MergeBuilder:
|
|
|
92
92
|
self._actions.append({"type": "not_matched_by_source_delete", "condition": condition})
|
|
93
93
|
return self
|
|
94
94
|
|
|
95
|
+
def when_matched_soft_delete(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
deleted_col: str = "is_deleted",
|
|
99
|
+
deleted_at_col: str | None = "deleted_at",
|
|
100
|
+
condition: str | None = None,
|
|
101
|
+
) -> MergeBuilder:
|
|
102
|
+
"""Mark matched rows as logically deleted without physically removing them.
|
|
103
|
+
|
|
104
|
+
Sets ``deleted_col`` to ``True`` and optionally ``deleted_at_col`` to
|
|
105
|
+
``current_timestamp()``. Useful for audit-friendly delete patterns in
|
|
106
|
+
regulated environments.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
deleted_col: Boolean column to set ``True`` on the matched row.
|
|
110
|
+
deleted_at_col: Timestamp column to set to ``current_timestamp()``.
|
|
111
|
+
Pass ``None`` to skip updating a timestamp column.
|
|
112
|
+
condition: Optional SQL condition restricting which matched rows to affect.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
This builder for chaining.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: If ``deleted_col`` or ``deleted_at_col`` is an empty string.
|
|
119
|
+
"""
|
|
120
|
+
if not deleted_col:
|
|
121
|
+
raise ValueError("deleted_col must be a non-empty column name")
|
|
122
|
+
if deleted_at_col is not None and not deleted_at_col:
|
|
123
|
+
raise ValueError("deleted_at_col must be a non-empty column name or None")
|
|
124
|
+
self._actions.append(
|
|
125
|
+
{
|
|
126
|
+
"type": "matched_soft_delete",
|
|
127
|
+
"deleted_col": deleted_col,
|
|
128
|
+
"deleted_at_col": deleted_at_col,
|
|
129
|
+
"condition": condition,
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
return self
|
|
133
|
+
|
|
95
134
|
def _build_condition(self) -> str:
|
|
96
135
|
if self._join_condition:
|
|
97
136
|
return self._join_condition
|
|
@@ -144,6 +183,19 @@ class MergeBuilder:
|
|
|
144
183
|
return merger.whenNotMatchedBySourceDelete(condition=cond)
|
|
145
184
|
return merger.whenNotMatchedBySourceDelete()
|
|
146
185
|
|
|
186
|
+
if action_type == "matched_soft_delete":
|
|
187
|
+
deleted_col = action["deleted_col"]
|
|
188
|
+
deleted_at_col = action.get("deleted_at_col")
|
|
189
|
+
cond = action.get("condition")
|
|
190
|
+
|
|
191
|
+
update_map: dict[str, str] = {deleted_col: "true"}
|
|
192
|
+
if deleted_at_col:
|
|
193
|
+
update_map[deleted_at_col] = "current_timestamp()"
|
|
194
|
+
|
|
195
|
+
if cond:
|
|
196
|
+
return merger.whenMatchedUpdate(condition=cond, set=update_map)
|
|
197
|
+
return merger.whenMatchedUpdate(set=update_map)
|
|
198
|
+
|
|
147
199
|
msg = f"Unknown merge action: {action_type}"
|
|
148
200
|
raise ValueError(msg)
|
|
149
201
|
|
|
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
|
|
|
19
19
|
from databricks4py.metrics.base import MetricsSink
|
|
20
20
|
|
|
21
21
|
__all__ = [
|
|
22
|
+
"CircuitBreakerError",
|
|
22
23
|
"StreamingTableReader",
|
|
23
24
|
"StreamingTriggerOptions",
|
|
24
25
|
]
|
|
@@ -26,6 +27,14 @@ __all__ = [
|
|
|
26
27
|
logger = logging.getLogger(__name__)
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
class CircuitBreakerError(RuntimeError):
|
|
31
|
+
"""Raised when the streaming circuit breaker trips due to consecutive failures.
|
|
32
|
+
|
|
33
|
+
Stops the query so the issue can be investigated rather than silently
|
|
34
|
+
accumulating data in the DLQ.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
29
38
|
class StreamingTriggerOptions(Enum):
|
|
30
39
|
"""Common streaming trigger configurations.
|
|
31
40
|
|
|
@@ -83,6 +92,10 @@ class StreamingTableReader(ABC):
|
|
|
83
92
|
is written here with ``_dlq_error_message``, ``_dlq_error_timestamp``,
|
|
84
93
|
and ``_dlq_batch_id`` columns appended. Uses ``mergeSchema=true`` so
|
|
85
94
|
the table is auto-created on first failure.
|
|
95
|
+
max_consecutive_failures: If set, raises CircuitBreakerError on the Nth
|
|
96
|
+
consecutive batch failure (i.e. ``max_consecutive_failures=1`` means the
|
|
97
|
+
very first failure trips the breaker). Applies whether or not a DLQ is
|
|
98
|
+
configured. Resets to zero after the first subsequent successful batch.
|
|
86
99
|
spark: Optional SparkSession.
|
|
87
100
|
"""
|
|
88
101
|
|
|
@@ -101,12 +114,15 @@ class StreamingTableReader(ABC):
|
|
|
101
114
|
checkpoint_manager: CheckpointManager | None = None,
|
|
102
115
|
metrics_sink: MetricsSink | None = None,
|
|
103
116
|
dead_letter_table: str | None = None,
|
|
117
|
+
max_consecutive_failures: int | None = None,
|
|
104
118
|
spark: SparkSession | None = None,
|
|
105
119
|
) -> None:
|
|
106
120
|
self._spark = active_fallback(spark)
|
|
107
121
|
self._source_table = source_table
|
|
108
122
|
self._metrics_sink = metrics_sink
|
|
109
123
|
self._dead_letter_table = dead_letter_table
|
|
124
|
+
self._max_consecutive_failures = max_consecutive_failures
|
|
125
|
+
self._consecutive_failures: int = 0
|
|
110
126
|
self._query: StreamingQuery | None = None
|
|
111
127
|
|
|
112
128
|
# Resolve trigger to a plain dict
|
|
@@ -169,6 +185,17 @@ class StreamingTableReader(ABC):
|
|
|
169
185
|
error_msg[:200],
|
|
170
186
|
)
|
|
171
187
|
|
|
188
|
+
def _check_circuit_breaker(self, cause: BaseException) -> None:
|
|
189
|
+
"""Raise CircuitBreakerError if the failure threshold has been exceeded."""
|
|
190
|
+
if (
|
|
191
|
+
self._max_consecutive_failures is not None
|
|
192
|
+
and self._consecutive_failures >= self._max_consecutive_failures
|
|
193
|
+
):
|
|
194
|
+
raise CircuitBreakerError(
|
|
195
|
+
f"Circuit breaker tripped after {self._consecutive_failures} consecutive "
|
|
196
|
+
f"failures on {self._source_table}"
|
|
197
|
+
) from cause
|
|
198
|
+
|
|
172
199
|
def _foreach_batch_wrapper(self, df: DataFrame, batch_id: int) -> None:
|
|
173
200
|
"""Internal wrapper handling empty batch detection, filtering, and metrics."""
|
|
174
201
|
if self._skip_empty_batches and df.isEmpty():
|
|
@@ -187,7 +214,9 @@ class StreamingTableReader(ABC):
|
|
|
187
214
|
start = time.monotonic()
|
|
188
215
|
try:
|
|
189
216
|
self.process_batch(df, batch_id)
|
|
190
|
-
except Exception: # noqa: BLE001 — broad catch intentional: DLQ must capture all failures
|
|
217
|
+
except Exception as exc: # noqa: BLE001 — broad catch intentional: DLQ must capture all failures
|
|
218
|
+
self._consecutive_failures += 1
|
|
219
|
+
|
|
191
220
|
if self._dead_letter_table is not None:
|
|
192
221
|
import traceback
|
|
193
222
|
|
|
@@ -202,8 +231,13 @@ class StreamingTableReader(ABC):
|
|
|
202
231
|
exc_info=True,
|
|
203
232
|
)
|
|
204
233
|
raise
|
|
234
|
+
self._check_circuit_breaker(exc)
|
|
205
235
|
return
|
|
236
|
+
|
|
237
|
+
self._check_circuit_breaker(exc)
|
|
206
238
|
raise
|
|
239
|
+
else:
|
|
240
|
+
self._consecutive_failures = 0
|
|
207
241
|
duration_ms = (time.monotonic() - start) * 1000
|
|
208
242
|
|
|
209
243
|
if self._metrics_sink is not None:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from databricks4py.quality.base import Expectation, ExpectationResult, QualityReport
|
|
4
4
|
from databricks4py.quality.expectations import (
|
|
5
5
|
ColumnExists,
|
|
6
|
+
FreshnessExpectation,
|
|
6
7
|
InRange,
|
|
7
8
|
MatchesRegex,
|
|
8
9
|
NotNull,
|
|
@@ -10,11 +11,13 @@ from databricks4py.quality.expectations import (
|
|
|
10
11
|
Unique,
|
|
11
12
|
)
|
|
12
13
|
from databricks4py.quality.gate import QualityError, QualityGate
|
|
14
|
+
from databricks4py.quality.profiler import ColumnProfile, DataProfile, profile
|
|
13
15
|
|
|
14
16
|
__all__ = [
|
|
15
17
|
"ColumnExists",
|
|
16
18
|
"Expectation",
|
|
17
19
|
"ExpectationResult",
|
|
20
|
+
"FreshnessExpectation",
|
|
18
21
|
"InRange",
|
|
19
22
|
"MatchesRegex",
|
|
20
23
|
"NotNull",
|
|
@@ -23,4 +26,8 @@ __all__ = [
|
|
|
23
26
|
"QualityReport",
|
|
24
27
|
"RowCount",
|
|
25
28
|
"Unique",
|
|
29
|
+
# Profiler
|
|
30
|
+
"ColumnProfile",
|
|
31
|
+
"DataProfile",
|
|
32
|
+
"profile",
|
|
26
33
|
]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from datetime import timedelta
|
|
5
6
|
from functools import reduce
|
|
6
7
|
from typing import TYPE_CHECKING
|
|
7
8
|
|
|
@@ -10,7 +11,15 @@ from databricks4py.quality.base import Expectation, ExpectationResult
|
|
|
10
11
|
if TYPE_CHECKING:
|
|
11
12
|
from pyspark.sql import Column, DataFrame
|
|
12
13
|
|
|
13
|
-
__all__ = [
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ColumnExists",
|
|
16
|
+
"FreshnessExpectation",
|
|
17
|
+
"InRange",
|
|
18
|
+
"MatchesRegex",
|
|
19
|
+
"NotNull",
|
|
20
|
+
"RowCount",
|
|
21
|
+
"Unique",
|
|
22
|
+
]
|
|
14
23
|
|
|
15
24
|
|
|
16
25
|
class NotNull(Expectation):
|
|
@@ -182,3 +191,83 @@ class ColumnExists(Expectation):
|
|
|
182
191
|
if self._dtype:
|
|
183
192
|
return f"ColumnExists({cols}, dtype={self._dtype!r})"
|
|
184
193
|
return f"ColumnExists({cols})"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class FreshnessExpectation(Expectation):
|
|
197
|
+
"""Validates that a timestamp column contains sufficiently recent data.
|
|
198
|
+
|
|
199
|
+
The table passes when ``max(column) >= now - max_age``. The failing condition
|
|
200
|
+
identifies individual rows older than the cutoff, which is useful for quarantine
|
|
201
|
+
routing via :class:`~databricks4py.quality.gate.QualityGate`.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
column: Timestamp column to check.
|
|
205
|
+
max_age: Maximum allowed age of the most-recent row. A ``timedelta(hours=1)``
|
|
206
|
+
means the newest row must be within the last hour.
|
|
207
|
+
|
|
208
|
+
Example::
|
|
209
|
+
|
|
210
|
+
from datetime import timedelta
|
|
211
|
+
from databricks4py.quality.expectations import FreshnessExpectation
|
|
212
|
+
|
|
213
|
+
expectation = FreshnessExpectation("event_time", max_age=timedelta(hours=6))
|
|
214
|
+
result = expectation.validate(df)
|
|
215
|
+
if not result.passed:
|
|
216
|
+
print(result)
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(self, column: str, max_age: timedelta) -> None:
|
|
220
|
+
self._column = column
|
|
221
|
+
self._max_age = max_age
|
|
222
|
+
|
|
223
|
+
def validate(self, df: DataFrame) -> ExpectationResult:
|
|
224
|
+
from datetime import datetime, timezone
|
|
225
|
+
|
|
226
|
+
from pyspark.sql import functions as F
|
|
227
|
+
|
|
228
|
+
total = df.count()
|
|
229
|
+
if total == 0:
|
|
230
|
+
return ExpectationResult(
|
|
231
|
+
expectation=repr(self),
|
|
232
|
+
passed=False,
|
|
233
|
+
total_rows=0,
|
|
234
|
+
failing_rows=0,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
cutoff = datetime.now(tz=timezone.utc) - self._max_age
|
|
238
|
+
|
|
239
|
+
max_row = df.agg(F.max(F.col(self._column)).alias("max_ts")).first()
|
|
240
|
+
max_ts = max_row["max_ts"] if max_row else None
|
|
241
|
+
|
|
242
|
+
if max_ts is None:
|
|
243
|
+
return ExpectationResult(
|
|
244
|
+
expectation=repr(self),
|
|
245
|
+
passed=False,
|
|
246
|
+
total_rows=total,
|
|
247
|
+
failing_rows=0,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
max_ts_utc = max_ts.replace(tzinfo=timezone.utc) if max_ts.tzinfo is None else max_ts
|
|
251
|
+
passed = max_ts_utc >= cutoff
|
|
252
|
+
|
|
253
|
+
# Use the same cutoff computed above so row-count and table-level verdict are consistent
|
|
254
|
+
stale_condition = F.col(self._column) < F.lit(cutoff)
|
|
255
|
+
failing = df.where(stale_condition).count()
|
|
256
|
+
|
|
257
|
+
return ExpectationResult(
|
|
258
|
+
expectation=repr(self),
|
|
259
|
+
passed=passed,
|
|
260
|
+
total_rows=total,
|
|
261
|
+
failing_rows=failing,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def failing_condition(self) -> Column | None:
|
|
265
|
+
from datetime import datetime, timezone
|
|
266
|
+
|
|
267
|
+
from pyspark.sql import functions as F
|
|
268
|
+
|
|
269
|
+
cutoff = datetime.now(tz=timezone.utc) - self._max_age
|
|
270
|
+
return F.col(self._column) < F.lit(cutoff)
|
|
271
|
+
|
|
272
|
+
def __repr__(self) -> str:
|
|
273
|
+
return f"FreshnessExpectation({self._column!r}, max_age={self._max_age!r})"
|