batch-analytics 0.3.34__tar.gz → 0.3.36__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.
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/PKG-INFO +1 -1
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/pyproject.toml +1 -1
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/config.py +42 -6
- batch_analytics-0.3.36/src/batch_analytics/extract.py +375 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/job_runner.py +95 -13
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/transform.py +188 -23
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/PKG-INFO +1 -1
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/SOURCES.txt +3 -1
- batch_analytics-0.3.36/tests/test_iceberg_staging.py +85 -0
- batch_analytics-0.3.36/tests/test_yield_quality_pivot.py +218 -0
- batch_analytics-0.3.34/src/batch_analytics/extract.py +0 -214
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/README.md +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/setup.cfg +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/__init__.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/__main__.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/__init__.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/correlation.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/equipment_oee.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/gluon_autogluon_infer.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/gluon_autogluon_train.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/linear_regression.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_clustering.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_core.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_mvda.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/t_test.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/log.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/modules.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/output/__init__.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/output/base.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/output/clickhouse.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/output/local.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/output/s3.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/utils/__init__.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/utils/gluon_autogluon_common.py +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/dependency_links.txt +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/entry_points.txt +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/requires.txt +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/top_level.txt +0 -0
- {batch_analytics-0.3.34 → batch_analytics-0.3.36}/tests/test_pca_mvda.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: batch-analytics
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.36
|
|
4
4
|
Summary: PySpark batch analytics: Extract, Transform, Stage, and analytical modules (linear regression, correlation, PCA, t-test, LLM classification).
|
|
5
5
|
Author: Litewave Analytics Team
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "batch-analytics"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.36"
|
|
8
8
|
description = "PySpark batch analytics: Extract, Transform, Stage, and analytical modules (linear regression, correlation, PCA, t-test, LLM classification)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -66,6 +66,27 @@ class ExtractConfig:
|
|
|
66
66
|
# filter_values: comma-separated list, or JSON array e.g. ["a","b"] for values containing commas.
|
|
67
67
|
filter_column: str = os.environ.get("BATCH_EXTRACT_FILTER_COLUMN", "").strip()
|
|
68
68
|
filter_values: str = os.environ.get("BATCH_EXTRACT_FILTER_VALUES", "").strip()
|
|
69
|
+
# clickhouse (default) or iceberg. Iceberg reads BATCH_SOURCE_TABLES via spark.table.
|
|
70
|
+
source_format: str = field(
|
|
71
|
+
default_factory=lambda: (
|
|
72
|
+
os.environ.get("BATCH_SOURCE_FORMAT") or "clickhouse"
|
|
73
|
+
).strip().lower()
|
|
74
|
+
or "clickhouse"
|
|
75
|
+
)
|
|
76
|
+
# Comma-separated left-join keys when more than one source table is set.
|
|
77
|
+
join_keys: str = field(
|
|
78
|
+
default_factory=lambda: os.environ.get("BATCH_EXTRACT_JOIN_KEYS", "").strip()
|
|
79
|
+
)
|
|
80
|
+
# JSON object: table name or short name -> comma-separated columns (or a list).
|
|
81
|
+
columns_json: str = field(
|
|
82
|
+
default_factory=lambda: os.environ.get("BATCH_EXTRACT_COLUMNS_JSON", "").strip()
|
|
83
|
+
)
|
|
84
|
+
# JSON object: table -> {"group_by": "batch_no", "aggs": {"actual_yield_qty": "max"}}.
|
|
85
|
+
aggregate_before_join_json: str = field(
|
|
86
|
+
default_factory=lambda: os.environ.get(
|
|
87
|
+
"BATCH_EXTRACT_AGG_BEFORE_JOIN_JSON", ""
|
|
88
|
+
).strip()
|
|
89
|
+
)
|
|
69
90
|
|
|
70
91
|
|
|
71
92
|
@dataclass
|
|
@@ -85,15 +106,23 @@ class TransformConfig:
|
|
|
85
106
|
"/tmp/analytics_stage",
|
|
86
107
|
)
|
|
87
108
|
)
|
|
88
|
-
#
|
|
89
|
-
#
|
|
109
|
+
# Staging format for write (ETL) and load_staged (analytics): parquet, delta, clickhouse, iceberg.
|
|
110
|
+
# iceberg writes BATCH_STAGING_TABLE with DataFrame.writeTo and reads it back via spark.sql.
|
|
90
111
|
staging_format: str = field(
|
|
91
112
|
default_factory=lambda: os.environ.get("BATCH_STAGING_FORMAT", "clickhouse")
|
|
92
113
|
)
|
|
93
|
-
# Staging table name
|
|
114
|
+
# Staging table: ClickHouse table name, or Iceberg identifier e.g. nessie.gold.batch_yield_features
|
|
94
115
|
staging_table: str = field(
|
|
95
116
|
default_factory=lambda: os.environ.get("BATCH_STAGING_TABLE", "analytics_staging")
|
|
96
117
|
)
|
|
118
|
+
# Optional SQL WHERE fragment when reading staging (no leading WHERE).
|
|
119
|
+
staging_filter: str = field(
|
|
120
|
+
default_factory=lambda: os.environ.get("BATCH_STAGING_FILTER", "").strip()
|
|
121
|
+
)
|
|
122
|
+
# Optional comma-separated columns when reading staging (empty = SELECT *).
|
|
123
|
+
staging_columns: str = field(
|
|
124
|
+
default_factory=lambda: os.environ.get("BATCH_STAGING_COLUMNS", "").strip()
|
|
125
|
+
)
|
|
97
126
|
# Spark save mode for ClickHouse staging (and path staging): overwrite | append
|
|
98
127
|
staging_write_mode: str = field(
|
|
99
128
|
default_factory=lambda: os.environ.get("BATCH_STAGING_WRITE_MODE", "overwrite")
|
|
@@ -211,11 +240,18 @@ class SparkK8sConfig:
|
|
|
211
240
|
executor_cores: int = int(os.environ.get("SPARK_EXECUTOR_CORES", "1"))
|
|
212
241
|
executor_memory: str = os.environ.get("SPARK_EXECUTOR_MEMORY", "512m")
|
|
213
242
|
executor_memory_overhead: str = os.environ.get("SPARK_EXECUTOR_MEMORY_OVERHEAD", "128m")
|
|
214
|
-
# S3 (optional; set for s3a:// paths)
|
|
243
|
+
# S3 (optional; set for s3a:// paths / Iceberg warehouse)
|
|
215
244
|
s3_access_key: str = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
|
216
245
|
s3_secret_key: str = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
|
217
|
-
|
|
218
|
-
|
|
246
|
+
# Prefer S3_ENDPOINT (lakehouse MinIO); fall back to AWS_ENDPOINT for legacy.
|
|
247
|
+
s3_endpoint: str = os.environ.get(
|
|
248
|
+
"S3_ENDPOINT",
|
|
249
|
+
os.environ.get("AWS_ENDPOINT", ""),
|
|
250
|
+
)
|
|
251
|
+
s3_region: str = os.environ.get(
|
|
252
|
+
"AWS_DEFAULT_REGION",
|
|
253
|
+
os.environ.get("AWS_REGION", "us-east-2"),
|
|
254
|
+
)
|
|
219
255
|
|
|
220
256
|
|
|
221
257
|
@dataclass
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extract stage: Load data from ClickHouse using Spark ClickHouse connector or JDBC.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from pyspark.sql import DataFrame, SparkSession
|
|
11
|
+
from pyspark.sql import functions as F
|
|
12
|
+
from pyspark.sql.functions import col
|
|
13
|
+
|
|
14
|
+
from .config import BatchAnalyticsConfig
|
|
15
|
+
from .transform import quote_iceberg_identifier
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_extract_filter_values(raw: str) -> List[str]:
|
|
21
|
+
"""
|
|
22
|
+
Parse BATCH_EXTRACT_FILTER_VALUES: comma-separated tokens, or JSON array string.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
a,b,c -> ["a","b","c"]
|
|
26
|
+
["GP/A","GP/B"] -> JSON list (values may contain commas)
|
|
27
|
+
"""
|
|
28
|
+
text = (raw or "").strip()
|
|
29
|
+
if not text:
|
|
30
|
+
return []
|
|
31
|
+
if text.startswith("["):
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(text)
|
|
34
|
+
if isinstance(data, list):
|
|
35
|
+
out = [str(x).strip() for x in data if str(x).strip()]
|
|
36
|
+
return out
|
|
37
|
+
except json.JSONDecodeError:
|
|
38
|
+
logger.warning("BATCH_EXTRACT_FILTER_VALUES looks like JSON but failed to parse; using comma split")
|
|
39
|
+
return [p.strip() for p in text.split(",") if p.strip()]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _apply_extract_filter(df: DataFrame, config: BatchAnalyticsConfig) -> DataFrame:
|
|
43
|
+
"""Apply col IN (values) when filter_column is set; empty column = no filter."""
|
|
44
|
+
col_name = (config.extract.filter_column or "").strip()
|
|
45
|
+
if not col_name:
|
|
46
|
+
return df
|
|
47
|
+
if col_name not in df.columns:
|
|
48
|
+
logger.warning(
|
|
49
|
+
"BATCH_EXTRACT_FILTER_COLUMN=%r not in extracted columns %s; skipping filter",
|
|
50
|
+
col_name,
|
|
51
|
+
df.columns,
|
|
52
|
+
)
|
|
53
|
+
return df
|
|
54
|
+
values = parse_extract_filter_values(config.extract.filter_values)
|
|
55
|
+
if not values:
|
|
56
|
+
logger.warning(
|
|
57
|
+
"BATCH_EXTRACT_FILTER_COLUMN=%r set but BATCH_EXTRACT_FILTER_VALUES is empty; skipping IN filter",
|
|
58
|
+
col_name,
|
|
59
|
+
)
|
|
60
|
+
return df
|
|
61
|
+
filtered = df.filter(col(col_name).isin(values))
|
|
62
|
+
return filtered
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_ch_database_table(table: str, default_database: str) -> Tuple[str, str]:
|
|
66
|
+
"""
|
|
67
|
+
Resolve ``table`` reference to (database, table_name).
|
|
68
|
+
|
|
69
|
+
``batch_metric_facts`` → (default_database, batch_metric_facts)
|
|
70
|
+
``analytics.batch_metric_facts`` → (analytics, batch_metric_facts)
|
|
71
|
+
"""
|
|
72
|
+
t = (table or "").strip()
|
|
73
|
+
if not t:
|
|
74
|
+
return default_database, t
|
|
75
|
+
if "." in t and not t.startswith("("):
|
|
76
|
+
db, tbl = t.split(".", 1)
|
|
77
|
+
db, tbl = db.strip(), tbl.strip()
|
|
78
|
+
if db and tbl:
|
|
79
|
+
return db, tbl
|
|
80
|
+
return default_database, t
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _read_via_catalog(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> Optional[DataFrame]:
|
|
84
|
+
"""
|
|
85
|
+
Read via Spark SQL catalog (ClickHouseCatalog), registered in job_runner.create_spark_session.
|
|
86
|
+
|
|
87
|
+
clickhouse-spark-runtime does **not** register legacy short name ``format(\"clickhouse\")`` /
|
|
88
|
+
``clickhouse.DefaultSource``; catalog + ``spark.table(catalog.db.table)`` is the supported path.
|
|
89
|
+
"""
|
|
90
|
+
cat = os.environ.get("BATCH_CLICKHOUSE_CATALOG", "batch_ch").strip()
|
|
91
|
+
if not cat:
|
|
92
|
+
return None
|
|
93
|
+
db, tbl = _parse_ch_database_table(table, cfg.clickhouse.database)
|
|
94
|
+
ident = f"{cat}.{db}.{tbl}"
|
|
95
|
+
try:
|
|
96
|
+
return spark.table(ident)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.warning(
|
|
99
|
+
"Catalog read failed for %s (%s): %s. Trying other readers.",
|
|
100
|
+
ident,
|
|
101
|
+
table,
|
|
102
|
+
e,
|
|
103
|
+
)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _read_via_format(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> Optional[DataFrame]:
|
|
108
|
+
"""
|
|
109
|
+
Read from ClickHouse using the native format API (clickhouse-spark-runtime).
|
|
110
|
+
Requires: com.clickhouse.spark:clickhouse-spark-runtime in spark.jars.packages
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
rd = (
|
|
114
|
+
spark.read.format("clickhouse")
|
|
115
|
+
.option("host", cfg.clickhouse.host)
|
|
116
|
+
.option("protocol", cfg.clickhouse.protocol)
|
|
117
|
+
.option("http_port", str(cfg.clickhouse.port))
|
|
118
|
+
.option("database", cfg.clickhouse.database)
|
|
119
|
+
.option("table", table)
|
|
120
|
+
.option("user", cfg.clickhouse.user)
|
|
121
|
+
)
|
|
122
|
+
if cfg.clickhouse.password:
|
|
123
|
+
rd = rd.option("password", cfg.clickhouse.password)
|
|
124
|
+
df = rd.load()
|
|
125
|
+
return df
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.warning(
|
|
128
|
+
"Native ClickHouse connector failed for table %s: %s. Falling back to JDBC.",
|
|
129
|
+
table,
|
|
130
|
+
e,
|
|
131
|
+
)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _read_via_jdbc(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> DataFrame:
|
|
136
|
+
"""Read from ClickHouse via JDBC."""
|
|
137
|
+
return spark.read.jdbc(
|
|
138
|
+
cfg.clickhouse.jdbc_url,
|
|
139
|
+
table,
|
|
140
|
+
properties=cfg.clickhouse.jdbc_properties,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def extract_table(
|
|
145
|
+
spark: SparkSession,
|
|
146
|
+
table: str,
|
|
147
|
+
config: BatchAnalyticsConfig,
|
|
148
|
+
) -> DataFrame:
|
|
149
|
+
"""
|
|
150
|
+
Extract a single source table.
|
|
151
|
+
|
|
152
|
+
``BATCH_SOURCE_FORMAT=iceberg`` reads a Nessie Iceberg identifier via ``spark.table``.
|
|
153
|
+
The default ``clickhouse`` path uses the native connector when configured, otherwise JDBC.
|
|
154
|
+
"""
|
|
155
|
+
fmt = (config.extract.source_format or "clickhouse").strip().lower()
|
|
156
|
+
if fmt == "iceberg":
|
|
157
|
+
ident = quote_iceberg_identifier(table)
|
|
158
|
+
logger.info("Extracting Iceberg table %s", ident)
|
|
159
|
+
df = spark.table(ident)
|
|
160
|
+
elif fmt == "clickhouse":
|
|
161
|
+
if config.extract.use_native_connector:
|
|
162
|
+
# Prefer catalog (matches clickhouse-spark-runtime); avoid legacy DefaultSource path.
|
|
163
|
+
df = _read_via_catalog(spark, config, table)
|
|
164
|
+
if df is None:
|
|
165
|
+
df = _read_via_format(spark, config, table)
|
|
166
|
+
if df is None:
|
|
167
|
+
df = _read_via_jdbc(spark, config, table)
|
|
168
|
+
else:
|
|
169
|
+
df = _read_via_jdbc(spark, config, table)
|
|
170
|
+
else:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"BATCH_SOURCE_FORMAT must be clickhouse or iceberg; got {fmt!r}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
df = _apply_extract_filter(df, config)
|
|
176
|
+
logger.info("Extracted table %s: %d rows", table, df.count())
|
|
177
|
+
return df
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def extract_all(
|
|
181
|
+
spark: SparkSession,
|
|
182
|
+
config: BatchAnalyticsConfig,
|
|
183
|
+
) -> Dict[str, DataFrame]:
|
|
184
|
+
"""
|
|
185
|
+
Extract all configured source tables (ClickHouse or Iceberg).
|
|
186
|
+
Returns a dict mapping table name to DataFrame, in source_tables order.
|
|
187
|
+
"""
|
|
188
|
+
tables = [t.strip() for t in config.extract.source_tables.split(",") if t.strip()]
|
|
189
|
+
if not tables:
|
|
190
|
+
raise ValueError("No source tables configured in BATCH_SOURCE_TABLES")
|
|
191
|
+
|
|
192
|
+
result: Dict[str, DataFrame] = {}
|
|
193
|
+
for table in tables:
|
|
194
|
+
df = extract_table(spark, table, config)
|
|
195
|
+
result[table] = df
|
|
196
|
+
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_AGG_FUNCS = {
|
|
201
|
+
"max": F.max,
|
|
202
|
+
"min": F.min,
|
|
203
|
+
"sum": F.sum,
|
|
204
|
+
"avg": F.avg,
|
|
205
|
+
"mean": F.avg,
|
|
206
|
+
"first": F.first,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parse_json_object(raw: str, env_name: str) -> Dict:
|
|
211
|
+
text = (raw or "").strip()
|
|
212
|
+
if not text:
|
|
213
|
+
return {}
|
|
214
|
+
try:
|
|
215
|
+
data = json.loads(text)
|
|
216
|
+
except json.JSONDecodeError as e:
|
|
217
|
+
raise ValueError(f"{env_name} must be valid JSON: {e}") from e
|
|
218
|
+
if not isinstance(data, dict):
|
|
219
|
+
raise ValueError(f"{env_name} must be a JSON object")
|
|
220
|
+
return data
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _table_short_name(table_name: str) -> str:
|
|
224
|
+
text = (table_name or "").strip().rstrip("`")
|
|
225
|
+
if not text:
|
|
226
|
+
return ""
|
|
227
|
+
return text.split(".")[-1].strip("`")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _spec_for_table(table_name: str, spec: Dict) -> Optional[object]:
|
|
231
|
+
if not spec:
|
|
232
|
+
return None
|
|
233
|
+
if table_name in spec:
|
|
234
|
+
return spec[table_name]
|
|
235
|
+
short = _table_short_name(table_name)
|
|
236
|
+
if short and short in spec:
|
|
237
|
+
return spec[short]
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _column_list(raw: object, table_name: str) -> List[str]:
|
|
242
|
+
if isinstance(raw, str):
|
|
243
|
+
names = [part.strip() for part in raw.split(",") if part.strip()]
|
|
244
|
+
elif isinstance(raw, list):
|
|
245
|
+
names = [str(part).strip() for part in raw if str(part).strip()]
|
|
246
|
+
else:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"extract.columns for {table_name} must be a comma-separated string or a list"
|
|
249
|
+
)
|
|
250
|
+
if not names:
|
|
251
|
+
raise ValueError(f"extract.columns for {table_name} is empty")
|
|
252
|
+
return names
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def prepare_source_frame(
|
|
256
|
+
df: DataFrame,
|
|
257
|
+
table_name: str,
|
|
258
|
+
config: BatchAnalyticsConfig,
|
|
259
|
+
) -> DataFrame:
|
|
260
|
+
"""Project columns and optionally aggregate one source before a join."""
|
|
261
|
+
columns_spec = _parse_json_object(
|
|
262
|
+
config.extract.columns_json, "BATCH_EXTRACT_COLUMNS_JSON"
|
|
263
|
+
)
|
|
264
|
+
selected = _spec_for_table(table_name, columns_spec)
|
|
265
|
+
if selected is not None:
|
|
266
|
+
names = _column_list(selected, table_name)
|
|
267
|
+
missing = [name for name in names if name not in df.columns]
|
|
268
|
+
if missing:
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"extract columns {missing} not in {table_name}; have {df.columns}"
|
|
271
|
+
)
|
|
272
|
+
df = df.select(*names)
|
|
273
|
+
|
|
274
|
+
agg_spec_all = _parse_json_object(
|
|
275
|
+
config.extract.aggregate_before_join_json,
|
|
276
|
+
"BATCH_EXTRACT_AGG_BEFORE_JOIN_JSON",
|
|
277
|
+
)
|
|
278
|
+
agg_spec = _spec_for_table(table_name, agg_spec_all)
|
|
279
|
+
if not agg_spec:
|
|
280
|
+
return df
|
|
281
|
+
if not isinstance(agg_spec, dict):
|
|
282
|
+
raise ValueError(f"aggregate_before_join for {table_name} must be an object")
|
|
283
|
+
group_by = agg_spec.get("group_by") or agg_spec.get("groupBy")
|
|
284
|
+
gcols = [part.strip() for part in str(group_by or "").split(",") if part.strip()]
|
|
285
|
+
aggs = agg_spec.get("aggs") or {}
|
|
286
|
+
if not gcols or not isinstance(aggs, dict) or not aggs:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f"aggregate_before_join for {table_name} requires group_by and aggs"
|
|
289
|
+
)
|
|
290
|
+
for name in gcols:
|
|
291
|
+
if name not in df.columns:
|
|
292
|
+
raise ValueError(
|
|
293
|
+
f"aggregate group_by column {name!r} not in {table_name}; have {df.columns}"
|
|
294
|
+
)
|
|
295
|
+
exprs = []
|
|
296
|
+
for col_name, agg_name in aggs.items():
|
|
297
|
+
key = str(col_name).strip()
|
|
298
|
+
fn_name = str(agg_name).strip().lower()
|
|
299
|
+
if key not in df.columns:
|
|
300
|
+
raise ValueError(
|
|
301
|
+
f"aggregate column {key!r} not in {table_name}; have {df.columns}"
|
|
302
|
+
)
|
|
303
|
+
fn = _AGG_FUNCS.get(fn_name)
|
|
304
|
+
if fn is None:
|
|
305
|
+
raise ValueError(
|
|
306
|
+
f"aggregate {fn_name!r} must be one of {sorted(_AGG_FUNCS)}; table {table_name}"
|
|
307
|
+
)
|
|
308
|
+
exprs.append(fn(F.col(key)).alias(key))
|
|
309
|
+
return df.groupBy(*gcols).agg(*exprs)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _join_keys(config: BatchAnalyticsConfig, join_keys: Optional[List[str]]) -> Optional[List[str]]:
|
|
313
|
+
if join_keys:
|
|
314
|
+
return [key.strip() for key in join_keys if key and str(key).strip()]
|
|
315
|
+
raw = (config.extract.join_keys or "").strip()
|
|
316
|
+
if not raw:
|
|
317
|
+
return None
|
|
318
|
+
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def unify_dataframes(
|
|
322
|
+
all_dfs: Dict[str, DataFrame],
|
|
323
|
+
config: BatchAnalyticsConfig,
|
|
324
|
+
join_keys: Optional[List[str]] = None,
|
|
325
|
+
primary_table: Optional[str] = None,
|
|
326
|
+
) -> DataFrame:
|
|
327
|
+
"""
|
|
328
|
+
Project, aggregate, and left-join already loaded source frames.
|
|
329
|
+
|
|
330
|
+
First table is the join base. Non-key columns that exist on both sides are
|
|
331
|
+
rejected so a wide pivot is not built on ambiguous names.
|
|
332
|
+
"""
|
|
333
|
+
if not all_dfs:
|
|
334
|
+
raise ValueError("No source tables to unify")
|
|
335
|
+
prepared: Dict[str, DataFrame] = {
|
|
336
|
+
name: prepare_source_frame(df, name, config) for name, df in all_dfs.items()
|
|
337
|
+
}
|
|
338
|
+
keys = _join_keys(config, join_keys)
|
|
339
|
+
frames = list(prepared.values())
|
|
340
|
+
if len(frames) == 1:
|
|
341
|
+
return frames[0]
|
|
342
|
+
if keys:
|
|
343
|
+
base = frames[0]
|
|
344
|
+
for other in frames[1:]:
|
|
345
|
+
missing = [key for key in keys if key not in base.columns or key not in other.columns]
|
|
346
|
+
if missing:
|
|
347
|
+
raise ValueError(f"join keys {missing} missing after extract projection")
|
|
348
|
+
overlap = (set(base.columns) & set(other.columns)) - set(keys)
|
|
349
|
+
if overlap:
|
|
350
|
+
raise ValueError(
|
|
351
|
+
"join would duplicate columns "
|
|
352
|
+
f"{sorted(overlap)}; set extract.columns to drop them"
|
|
353
|
+
)
|
|
354
|
+
base = base.join(other, on=keys, how="left")
|
|
355
|
+
return base
|
|
356
|
+
if primary_table and primary_table in prepared:
|
|
357
|
+
return prepared[primary_table]
|
|
358
|
+
return frames[0]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def extract_unified(
|
|
362
|
+
spark: SparkSession,
|
|
363
|
+
config: BatchAnalyticsConfig,
|
|
364
|
+
join_keys: Optional[List[str]] = None,
|
|
365
|
+
primary_table: Optional[str] = None,
|
|
366
|
+
) -> DataFrame:
|
|
367
|
+
"""
|
|
368
|
+
Extract and unify source tables into one DataFrame.
|
|
369
|
+
- Single table: returns it directly (after optional projection and aggregate).
|
|
370
|
+
- Multiple tables + join_keys: joins on those keys (left join, first table base).
|
|
371
|
+
- Multiple tables, no join_keys: returns the primary (or first) table.
|
|
372
|
+
Use primary_table to choose which table to use for analytics.
|
|
373
|
+
"""
|
|
374
|
+
all_dfs = extract_all(spark, config)
|
|
375
|
+
return unify_dataframes(all_dfs, config, join_keys=join_keys, primary_table=primary_table)
|
|
@@ -19,7 +19,7 @@ from .extract import extract_unified
|
|
|
19
19
|
from .log import log_dataframe_summary, log_run
|
|
20
20
|
from .modules import DEFAULT_MODULES, MODULE_REGISTRY, VALID_MODULES
|
|
21
21
|
from .output import write_analytics_output
|
|
22
|
-
from .transform import load_staged, stage_to_clickhouse, transform
|
|
22
|
+
from .transform import load_staged, stage_to_clickhouse, stage_to_iceberg, transform
|
|
23
23
|
|
|
24
24
|
logging.basicConfig(
|
|
25
25
|
level=logging.INFO,
|
|
@@ -161,6 +161,9 @@ def create_spark_session(
|
|
|
161
161
|
read_codec,
|
|
162
162
|
)
|
|
163
163
|
|
|
164
|
+
builder = _apply_iceberg_nessie_catalog(builder, config)
|
|
165
|
+
builder = _apply_s3a_lakehouse(builder, config)
|
|
166
|
+
|
|
164
167
|
if cfg.master.startswith("k8s://"):
|
|
165
168
|
driver_host = socket.gethostbyname(socket.gethostname())
|
|
166
169
|
builder = (
|
|
@@ -188,19 +191,91 @@ def create_spark_session(
|
|
|
188
191
|
.config("spark.kubernetes.executor.serviceAccountName", cfg.service_account)
|
|
189
192
|
.config("spark.kubernetes.container.image.pullPolicy", "IfNotPresent")
|
|
190
193
|
)
|
|
191
|
-
if cfg.s3_access_key and cfg.s3_secret_key:
|
|
192
|
-
builder = (
|
|
193
|
-
builder.config("spark.hadoop.fs.s3a.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem")
|
|
194
|
-
.config("spark.hadoop.fs.s3a.access.key", cfg.s3_access_key)
|
|
195
|
-
.config("spark.hadoop.fs.s3a.secret.key", cfg.s3_secret_key)
|
|
196
|
-
.config("spark.hadoop.fs.s3a.endpoint", cfg.s3_endpoint)
|
|
197
|
-
.config("spark.hadoop.fs.s3a.endpoint.region", cfg.s3_region)
|
|
198
|
-
)
|
|
199
194
|
logger.info("Spark on Kubernetes: master=%s", cfg.master)
|
|
200
195
|
|
|
201
196
|
return builder.getOrCreate()
|
|
202
197
|
|
|
203
198
|
|
|
199
|
+
def _apply_iceberg_nessie_catalog(builder, config: BatchAnalyticsConfig):
|
|
200
|
+
"""Register Nessie Iceberg catalog when lakehouse env is present."""
|
|
201
|
+
nessie_uri = (os.environ.get("NESSIE_URI") or "").strip()
|
|
202
|
+
warehouse = (os.environ.get("ICEBERG_WAREHOUSE") or "").strip()
|
|
203
|
+
if not nessie_uri and not warehouse:
|
|
204
|
+
return builder
|
|
205
|
+
catalog = (os.environ.get("ICEBERG_CATALOG") or "nessie").strip() or "nessie"
|
|
206
|
+
ref = (os.environ.get("NESSIE_REF") or "main").strip() or "main"
|
|
207
|
+
if not nessie_uri:
|
|
208
|
+
nessie_uri = "http://nessie:19120/api/v2"
|
|
209
|
+
if not warehouse:
|
|
210
|
+
warehouse = "s3a://lake/"
|
|
211
|
+
logger.info(
|
|
212
|
+
"Configuring Iceberg catalog %s uri=%s ref=%s warehouse=%s",
|
|
213
|
+
catalog,
|
|
214
|
+
nessie_uri,
|
|
215
|
+
ref,
|
|
216
|
+
warehouse,
|
|
217
|
+
)
|
|
218
|
+
return (
|
|
219
|
+
builder.config(
|
|
220
|
+
"spark.sql.extensions",
|
|
221
|
+
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions",
|
|
222
|
+
)
|
|
223
|
+
.config(f"spark.sql.catalog.{catalog}", "org.apache.iceberg.spark.SparkCatalog")
|
|
224
|
+
.config(
|
|
225
|
+
f"spark.sql.catalog.{catalog}.catalog-impl",
|
|
226
|
+
"org.apache.iceberg.nessie.NessieCatalog",
|
|
227
|
+
)
|
|
228
|
+
.config(f"spark.sql.catalog.{catalog}.uri", nessie_uri)
|
|
229
|
+
.config(f"spark.sql.catalog.{catalog}.ref", ref)
|
|
230
|
+
.config(f"spark.sql.catalog.{catalog}.warehouse", warehouse)
|
|
231
|
+
.config(
|
|
232
|
+
f"spark.sql.catalog.{catalog}.io-impl",
|
|
233
|
+
"org.apache.iceberg.hadoop.HadoopFileIO",
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _apply_s3a_lakehouse(builder, config: BatchAnalyticsConfig):
|
|
239
|
+
"""S3A settings for Iceberg warehouse / s3a staging (AWS or MinIO via S3_ENDPOINT)."""
|
|
240
|
+
cfg = config.spark_k8s
|
|
241
|
+
if not (cfg.s3_access_key or cfg.s3_secret_key or os.environ.get("ICEBERG_WAREHOUSE")):
|
|
242
|
+
return builder
|
|
243
|
+
|
|
244
|
+
builder = builder.config(
|
|
245
|
+
"spark.hadoop.fs.s3a.impl",
|
|
246
|
+
"org.apache.hadoop.fs.s3a.S3AFileSystem",
|
|
247
|
+
)
|
|
248
|
+
if cfg.s3_access_key:
|
|
249
|
+
builder = builder.config("spark.hadoop.fs.s3a.access.key", cfg.s3_access_key)
|
|
250
|
+
if cfg.s3_secret_key:
|
|
251
|
+
builder = builder.config("spark.hadoop.fs.s3a.secret.key", cfg.s3_secret_key)
|
|
252
|
+
|
|
253
|
+
endpoint = (cfg.s3_endpoint or "").strip()
|
|
254
|
+
region = (cfg.s3_region or "us-east-2").strip()
|
|
255
|
+
if endpoint:
|
|
256
|
+
# Custom/MinIO endpoint
|
|
257
|
+
ssl = not endpoint.startswith("http://")
|
|
258
|
+
builder = (
|
|
259
|
+
builder.config("spark.hadoop.fs.s3a.endpoint", endpoint)
|
|
260
|
+
.config("spark.hadoop.fs.s3a.path.style.access", "true")
|
|
261
|
+
.config(
|
|
262
|
+
"spark.hadoop.fs.s3a.connection.ssl.enabled",
|
|
263
|
+
"true" if ssl else "false",
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
builder = (
|
|
268
|
+
builder.config(
|
|
269
|
+
"spark.hadoop.fs.s3a.endpoint",
|
|
270
|
+
f"https://s3.{region}.amazonaws.com",
|
|
271
|
+
)
|
|
272
|
+
.config("spark.hadoop.fs.s3a.endpoint.region", region)
|
|
273
|
+
.config("spark.hadoop.fs.s3a.path.style.access", "false")
|
|
274
|
+
.config("spark.hadoop.fs.s3a.connection.ssl.enabled", "true")
|
|
275
|
+
)
|
|
276
|
+
return builder
|
|
277
|
+
|
|
278
|
+
|
|
204
279
|
def run_pipeline(
|
|
205
280
|
config: Optional[BatchAnalyticsConfig] = None,
|
|
206
281
|
spark: Optional[SparkSession] = None,
|
|
@@ -281,12 +356,19 @@ def run_pipeline(
|
|
|
281
356
|
df_transformed = remove_duplicates(df_transformed, key_columns=dedup_cols)
|
|
282
357
|
result["stages"]["transform"] = {"skipped": True}
|
|
283
358
|
|
|
284
|
-
# ----- Stage (
|
|
359
|
+
# ----- Stage (ClickHouse or Iceberg) - separate job before analytics -----
|
|
285
360
|
if run_stage:
|
|
286
|
-
|
|
287
|
-
|
|
361
|
+
staging_format = (config.transform.staging_format or "").strip().lower()
|
|
362
|
+
if staging_format == "iceberg":
|
|
363
|
+
logger.info("Stage: Stage to Iceberg")
|
|
364
|
+
stage_to_iceberg(spark, df_transformed, config)
|
|
365
|
+
destination = config.transform.staging_table
|
|
366
|
+
else:
|
|
367
|
+
logger.info("Stage: Stage to ClickHouse")
|
|
368
|
+
stage_to_clickhouse(spark, df_transformed, config)
|
|
369
|
+
destination = f"{config.clickhouse.database}.{config.transform.staging_table}"
|
|
288
370
|
result["stages"]["stage"] = {
|
|
289
|
-
"destination":
|
|
371
|
+
"destination": destination,
|
|
290
372
|
"row_count": df_transformed.count(),
|
|
291
373
|
}
|
|
292
374
|
else:
|