@1aboveio/skills 0.10.0 → 0.11.0
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.
- package/README.md +6 -2
- package/package.json +1 -1
- package/runtime/skills/distribution/generated/recipes.json +29 -19
- package/runtime/skills/distribution/scripts/bundles.mjs +86 -18
- package/skills/backend/pyspark/LICENSE +3 -0
- package/skills/backend/pyspark/SKILL.md +116 -0
- package/skills/backend/pyspark/assets/templates/etl.py +696 -0
- package/skills/backend/pyspark/assets/templates/utils/__init__.py +1 -0
- package/skills/backend/pyspark/assets/templates/utils/hudi_metadata.py +14 -0
- package/skills/backend/pyspark/references/diagnosis-and-profiling.md +145 -0
- package/skills/backend/pyspark/references/etl-contract.md +107 -0
- package/skills/backend/pyspark/references/parity-testing.md +83 -0
- package/skills/backend/pyspark/references/production-validation.md +166 -0
- package/skills/backend/pyspark/references/transformation-design.md +162 -0
- package/skills/backend/pyspark/references/velocity-feature-calculation.md +193 -0
- package/skills/backend/pyspark/scripts/spark_eventlog_summary.py +223 -0
- package/skills/engineering/engineering-runtime/coherence/workflow.json +14 -14
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/engineering/resolve-issues/generated/workflow-repair-policy.json +11 -11
- package/skills/engineering/resolve-issues/scripts/run-state.mjs +1 -1
- package/skills/engineering/ensure-coverage/scripts/package-lock.json +0 -76
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from argparse import ArgumentParser
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import enum
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from typing import List, Dict, Union, Literal, Optional
|
|
10
|
+
|
|
11
|
+
from dateutil.relativedelta import relativedelta
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from pyspark.sql import SparkSession, DataFrame
|
|
14
|
+
from pyspark.sql.functions import *
|
|
15
|
+
from pyspark.sql.types import *
|
|
16
|
+
from pyspark.sql.utils import AnalysisException
|
|
17
|
+
import typer
|
|
18
|
+
from typing_extensions import Annotated
|
|
19
|
+
|
|
20
|
+
from utils.hudi_metadata import hudi_metadata_write_options
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_SPARK_LEASE_LOCK = threading.RLock()
|
|
26
|
+
_SPARK_LEASES = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _classic_spark_runtime_exists() -> bool:
|
|
30
|
+
from pyspark import SparkContext
|
|
31
|
+
|
|
32
|
+
session = (
|
|
33
|
+
SparkSession.getActiveSession()
|
|
34
|
+
or getattr(SparkSession, "_instantiatedSession", None)
|
|
35
|
+
)
|
|
36
|
+
if (
|
|
37
|
+
session is not None
|
|
38
|
+
and getattr(getattr(session, "_sc", None), "_jsc", None) is not None
|
|
39
|
+
):
|
|
40
|
+
return True
|
|
41
|
+
context = getattr(SparkContext, "_active_spark_context", None)
|
|
42
|
+
return context is not None and getattr(context, "_jsc", None) is not None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _connect_spark_runtime_exists() -> bool:
|
|
46
|
+
module = sys.modules.get("pyspark.sql.connect.session")
|
|
47
|
+
connect_session = getattr(module, "SparkSession", None) if module else None
|
|
48
|
+
if connect_session is None:
|
|
49
|
+
return False
|
|
50
|
+
return (
|
|
51
|
+
connect_session.getActiveSession() is not None
|
|
52
|
+
or getattr(connect_session, "_default_session", None) is not None
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _spark_runtime_exists(spark_remote: Optional[str]) -> bool:
|
|
57
|
+
connect_mode = bool(
|
|
58
|
+
spark_remote
|
|
59
|
+
or os.environ.get("SPARK_REMOTE")
|
|
60
|
+
or os.environ.get("SPARK_CONNECT_MODE_ENABLED")
|
|
61
|
+
)
|
|
62
|
+
return (
|
|
63
|
+
_connect_spark_runtime_exists()
|
|
64
|
+
if connect_mode
|
|
65
|
+
else _classic_spark_runtime_exists()
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def create_spark_session(spark_remote: Optional[str] = None) -> SparkSession:
|
|
70
|
+
"""Create a Spark session in Spark Connect or classic spark-submit mode."""
|
|
71
|
+
builder = SparkSession.builder
|
|
72
|
+
if spark_remote:
|
|
73
|
+
builder = builder.remote(spark_remote)
|
|
74
|
+
return builder.getOrCreate()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@contextmanager
|
|
78
|
+
def managed_spark_session(
|
|
79
|
+
spark_remote: Optional[str] = None,
|
|
80
|
+
*,
|
|
81
|
+
builder=None,
|
|
82
|
+
):
|
|
83
|
+
"""Lease a Spark session and stop only a runtime created by this helper."""
|
|
84
|
+
with _SPARK_LEASE_LOCK:
|
|
85
|
+
runtime_existed = _spark_runtime_exists(spark_remote)
|
|
86
|
+
spark = (
|
|
87
|
+
builder.getOrCreate()
|
|
88
|
+
if builder is not None
|
|
89
|
+
else create_spark_session(spark_remote)
|
|
90
|
+
)
|
|
91
|
+
key = id(spark)
|
|
92
|
+
state = _SPARK_LEASES.get(key)
|
|
93
|
+
if state is None:
|
|
94
|
+
state = {
|
|
95
|
+
"leases": 0,
|
|
96
|
+
"helper_owned": not runtime_existed,
|
|
97
|
+
}
|
|
98
|
+
_SPARK_LEASES[key] = state
|
|
99
|
+
state["leases"] += 1
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
yield spark
|
|
103
|
+
finally:
|
|
104
|
+
with _SPARK_LEASE_LOCK:
|
|
105
|
+
state = _SPARK_LEASES.get(key)
|
|
106
|
+
if state is None:
|
|
107
|
+
return
|
|
108
|
+
state["leases"] -= 1
|
|
109
|
+
if state["leases"] == 0:
|
|
110
|
+
del _SPARK_LEASES[key]
|
|
111
|
+
if state["helper_owned"]:
|
|
112
|
+
spark.stop()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Etl(ABC):
|
|
116
|
+
"""
|
|
117
|
+
The base class for ETL jobs.
|
|
118
|
+
|
|
119
|
+
Attributes:
|
|
120
|
+
----------
|
|
121
|
+
id: str
|
|
122
|
+
field name of the unique key, required in Hudi table
|
|
123
|
+
ts: str
|
|
124
|
+
field name of the record unique timestamp, used as Hudi `preCombine` field
|
|
125
|
+
filter_by: str
|
|
126
|
+
field name filtering in `extract` function
|
|
127
|
+
|
|
128
|
+
Methods:
|
|
129
|
+
-------
|
|
130
|
+
extract(start_date: str, end_date: str):
|
|
131
|
+
extract table from <`src_db`.`src_tbl`>
|
|
132
|
+
|
|
133
|
+
transform(df: DataFrame):
|
|
134
|
+
performs transformation to df
|
|
135
|
+
|
|
136
|
+
load(df):
|
|
137
|
+
load into <`dst_db`.`dst_tbl`> as `spark_table` or `hudi_table` based on the `table_type` setting
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
table_type: Literal["hudi_table", "spark_table"] = "hudi_table"
|
|
141
|
+
hudi_mode: Literal["upsert", "insert_overwrite", "insert_overwrite_table"] = (
|
|
142
|
+
"upsert"
|
|
143
|
+
)
|
|
144
|
+
src_db = None
|
|
145
|
+
src_tbl = None
|
|
146
|
+
dst_db = None
|
|
147
|
+
dst_tbl = None
|
|
148
|
+
id = None
|
|
149
|
+
ts = None
|
|
150
|
+
filter_by = None
|
|
151
|
+
offset = None
|
|
152
|
+
par_cols = []
|
|
153
|
+
path = None
|
|
154
|
+
repartition = []
|
|
155
|
+
url: str = None
|
|
156
|
+
concurrency_mode: Literal["SINGLE_WRITER", "OPTIMISTIC_CONCURRENCY_CONTROL"] = (
|
|
157
|
+
"SINGLE_WRITER"
|
|
158
|
+
)
|
|
159
|
+
extra_hudi_options: Dict[str, Union[str, bool, int]] = {}
|
|
160
|
+
zookeeper: str = None
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
start_date: str = None,
|
|
165
|
+
end_date: str = None,
|
|
166
|
+
zookeeper: str = None,
|
|
167
|
+
url: str = None,
|
|
168
|
+
bulk: bool = True,
|
|
169
|
+
hudi_mode_override: str = None,
|
|
170
|
+
**kwargs,
|
|
171
|
+
):
|
|
172
|
+
fields = ["dst_db", "dst_tbl", "path"]
|
|
173
|
+
if self.table_type == "hudi_table":
|
|
174
|
+
fields += ["id", "ts"]
|
|
175
|
+
for field in fields:
|
|
176
|
+
if getattr(self, field) is None:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"Field {field} cannot be None. Please set {field} before proceed"
|
|
179
|
+
)
|
|
180
|
+
if len(self.par_cols) == 0:
|
|
181
|
+
logger.warning("`par_cols` is empty; output will not be partitioned")
|
|
182
|
+
if type(self.repartition) != list:
|
|
183
|
+
self.repartition = [self.repartition]
|
|
184
|
+
if self.filter_by is None:
|
|
185
|
+
logger.warning(
|
|
186
|
+
"`filter_by` is not set, will extract full table, please be aware!"
|
|
187
|
+
)
|
|
188
|
+
self.start_date: str = start_date if start_date else None
|
|
189
|
+
self.end_date: str = end_date if end_date else None
|
|
190
|
+
self.zookeeper = zookeeper if zookeeper else self.zookeeper
|
|
191
|
+
self.url = url if url else self.url
|
|
192
|
+
self.bulk = bulk
|
|
193
|
+
# Allow CLI override of hudi_mode (e.g. --hudi-mode bulk_insert)
|
|
194
|
+
if hudi_mode_override:
|
|
195
|
+
self.hudi_mode = hudi_mode_override
|
|
196
|
+
for k, v in kwargs.items():
|
|
197
|
+
setattr(self, k, v)
|
|
198
|
+
|
|
199
|
+
if (
|
|
200
|
+
self.concurrency_mode == "OPTIMISTIC_CONCURRENCY_CONTROL"
|
|
201
|
+
and self.zookeeper is None
|
|
202
|
+
):
|
|
203
|
+
raise ValueError("concurrency control enabled but zookeeper not set")
|
|
204
|
+
self.spark = SparkSession.getActiveSession()
|
|
205
|
+
|
|
206
|
+
def __call__(self):
|
|
207
|
+
self.process()
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def run_from_cli(
|
|
211
|
+
cls,
|
|
212
|
+
start_date: Annotated[str, typer.Option("--start-date")] = None,
|
|
213
|
+
end_date: Annotated[str, typer.Option("--end-date")] = None,
|
|
214
|
+
zookeeper: Annotated[str, typer.Option("--zookeeper")] = None,
|
|
215
|
+
url: Annotated[str, typer.Option("--url")] = None,
|
|
216
|
+
bulk: Annotated[bool, typer.Option("--bulk/--per-day")] = True,
|
|
217
|
+
):
|
|
218
|
+
obj = cls.__new__(cls)
|
|
219
|
+
obj.__init__(start_date, end_date, zookeeper, url, bulk)
|
|
220
|
+
obj()
|
|
221
|
+
return obj
|
|
222
|
+
|
|
223
|
+
def extract(self, start_date: str = None, end_date: str = None):
|
|
224
|
+
start_date = start_date or self.start_date
|
|
225
|
+
end_date = end_date or self.end_date
|
|
226
|
+
df = self.spark.table(f"{self.src_db}.{self.src_tbl}")
|
|
227
|
+
|
|
228
|
+
if self.offset and start_date:
|
|
229
|
+
from dateutil.relativedelta import relativedelta
|
|
230
|
+
from dateutil.parser import parse as date_parse
|
|
231
|
+
|
|
232
|
+
start_date = date_parse(start_date) - relativedelta(days=self.offset)
|
|
233
|
+
start_date = start_date.strftime("%Y-%m-%d")
|
|
234
|
+
|
|
235
|
+
if self.filter_by and start_date:
|
|
236
|
+
df = df.filter(col(self.filter_by) >= start_date)
|
|
237
|
+
if self.filter_by and end_date:
|
|
238
|
+
df = df.filter(col(self.filter_by) < end_date)
|
|
239
|
+
return df
|
|
240
|
+
|
|
241
|
+
def select_or_none(
|
|
242
|
+
self, df: DataFrame, cols: Union[List, Dict[str, Union[str, DataType]]]
|
|
243
|
+
) -> DataFrame:
|
|
244
|
+
if type(cols) == list:
|
|
245
|
+
available_cols = [i for i in cols if i in df.columns]
|
|
246
|
+
missing_cols = [i for i in cols if i not in df.columns]
|
|
247
|
+
df = df.select(*available_cols)
|
|
248
|
+
|
|
249
|
+
logger.warning(
|
|
250
|
+
f"Columns {missing_cols} are missing, filling with None and cast as default StringType"
|
|
251
|
+
)
|
|
252
|
+
for col in missing_cols:
|
|
253
|
+
df = df.withColumn(col, lit(None).cast(StringType()))
|
|
254
|
+
elif type(cols) == dict:
|
|
255
|
+
available_cols = [i for i in cols.keys() if i in df.columns]
|
|
256
|
+
missing_cols = [i for i in cols.keys() if i not in df.columns]
|
|
257
|
+
df = df.select(*available_cols)
|
|
258
|
+
for col in missing_cols:
|
|
259
|
+
col_type = cols[col]
|
|
260
|
+
df = df.withColumn(col, lit(None).cast(col_type))
|
|
261
|
+
else:
|
|
262
|
+
raise TypeError(f"cols {cols} should be List or Dict")
|
|
263
|
+
df = df.select(*cols)
|
|
264
|
+
return df
|
|
265
|
+
|
|
266
|
+
@abstractmethod
|
|
267
|
+
def transform(self, df: DataFrame) -> DataFrame:
|
|
268
|
+
return df
|
|
269
|
+
|
|
270
|
+
def _prepare_partition(self, df: DataFrame) -> DataFrame:
|
|
271
|
+
self.spark.conf.set("spark.sql.sources.partitionOverwriteMode", "DYNAMIC")
|
|
272
|
+
new_cols = [i for i in df.columns if i not in self.par_cols] + self.par_cols
|
|
273
|
+
df = df.select(*new_cols)
|
|
274
|
+
if self.start_date and self.filter_by:
|
|
275
|
+
df = df.filter(col(self.filter_by) >= self.start_date)
|
|
276
|
+
return df
|
|
277
|
+
|
|
278
|
+
def _is_existing_hudi_table(self) -> bool:
|
|
279
|
+
"""Check if a Hudi table already exists at `self.path`.
|
|
280
|
+
|
|
281
|
+
Used to determine whether we can perform an `upsert` (requires existing
|
|
282
|
+
table metadata at `.hoodie/hoodie.properties`) or need to fall back to
|
|
283
|
+
`bulk_insert` for the initial load.
|
|
284
|
+
"""
|
|
285
|
+
try:
|
|
286
|
+
self.spark.read.text(f"{self.path}/.hoodie/hoodie.properties").limit(1).collect()
|
|
287
|
+
return True
|
|
288
|
+
except Exception:
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
def load_hudi(self, df: DataFrame) -> DataFrame:
|
|
292
|
+
# -----------------------------------------------------------------
|
|
293
|
+
# Hudi write operation auto-detection (added 2026-03-02)
|
|
294
|
+
# -----------------------------------------------------------------
|
|
295
|
+
# In Hudi 0.14+, the `upsert` operation expects an existing Hudi table
|
|
296
|
+
# with `.hoodie/hoodie.properties` on HDFS. Writing to a *new* path
|
|
297
|
+
# with `upsert` fails with `HoodieUpsertException`.
|
|
298
|
+
#
|
|
299
|
+
# Previously (Hudi 0.12), `upsert` could auto-create new tables.
|
|
300
|
+
#
|
|
301
|
+
# Decision: auto-detect whether the table exists. If not, override
|
|
302
|
+
# the operation to `bulk_insert` for the initial load. Subsequent
|
|
303
|
+
# writes use the configured `hudi_mode` (typically `upsert`).
|
|
304
|
+
# -----------------------------------------------------------------
|
|
305
|
+
operation = self.hudi_mode
|
|
306
|
+
needs_existing_table = operation in ("upsert", "insert_overwrite", "insert_overwrite_table")
|
|
307
|
+
if needs_existing_table and not self._is_existing_hudi_table():
|
|
308
|
+
logger.info(
|
|
309
|
+
f"Hudi table not found at {self.path}, "
|
|
310
|
+
f"falling back to 'bulk_insert' for initial load "
|
|
311
|
+
f"(configured mode: {operation})"
|
|
312
|
+
)
|
|
313
|
+
operation = "bulk_insert"
|
|
314
|
+
|
|
315
|
+
hudi_options = {
|
|
316
|
+
"hoodie.table.name": f"{self.dst_db}.{self.dst_tbl}",
|
|
317
|
+
"hoodie.datasource.write.table.type": "COPY_ON_WRITE",
|
|
318
|
+
"hoodie.datasource.write.recordkey.field": self.id,
|
|
319
|
+
"hoodie.datasource.write.partitionpath.field": ",".join(self.par_cols),
|
|
320
|
+
"hoodie.datasource.write.keygenerator.class": "org.apache.hudi.keygen.ComplexKeyGenerator",
|
|
321
|
+
"hoodie.datasource.write.precombine.field": self.ts,
|
|
322
|
+
"hoodie.datasource.write.operation": f"{operation}",
|
|
323
|
+
"hoodie.datasource.write.reconcile.schema": True,
|
|
324
|
+
"hoodie.schema.on.read.enable": True,
|
|
325
|
+
# ---------------------------------------------------------------
|
|
326
|
+
# Hudi 0.15 ComplexKeyGenerator regression fix
|
|
327
|
+
# ---------------------------------------------------------------
|
|
328
|
+
# Hudi 0.14.1, 0.15.0, 1.0.x introduced a regression in
|
|
329
|
+
# ComplexKeyGenerator with a single record key field - the record
|
|
330
|
+
# key encoding changed, which can cause duplicate records during
|
|
331
|
+
# upserts. Setting this to `true` ensures consistent encoding
|
|
332
|
+
# for tables created on these versions.
|
|
333
|
+
#
|
|
334
|
+
# Ref: https://hudi.apache.org/releases/release-1.1.1
|
|
335
|
+
# ---------------------------------------------------------------
|
|
336
|
+
"hoodie.write.complex.keygen.new.encoding": True,
|
|
337
|
+
"hoodie.datasource.hive_sync.enable": True,
|
|
338
|
+
"hoodie.datasource.hive_sync.create_managed_table": True,
|
|
339
|
+
"hoodie.datasource.hive_sync.database": self.dst_db,
|
|
340
|
+
"hoodie.datasource.hive_sync.table": self.dst_tbl,
|
|
341
|
+
"hoodie.datasource.hive_sync.skip_ro_suffix": False,
|
|
342
|
+
"hoodie.datasource.hive_sync.support_timestamp": True,
|
|
343
|
+
"hoodie.datasource.write.hive_style_partitioning": True,
|
|
344
|
+
"hoodie.metadata.enable": False,
|
|
345
|
+
"hoodie.write.concurrency.mode": f"{self.concurrency_mode}",
|
|
346
|
+
"hoodie.write.lock.zookeeper.base_path": "/hudi",
|
|
347
|
+
"hoodie.write.lock.zookeeper.lock_key": f"{self.dst_db}.{self.dst_tbl}",
|
|
348
|
+
"hoodie.write.lock.zookeeper.port": "2181",
|
|
349
|
+
"hoodie.write.lock.zookeeper.url": f"{self.zookeeper}",
|
|
350
|
+
"path": self.path,
|
|
351
|
+
}
|
|
352
|
+
hudi_options.update(self.extra_hudi_options)
|
|
353
|
+
# Applied last, deliberately. A table inside the daily v3 metadata
|
|
354
|
+
# listing scope must have its Hudi metadata index maintained by every
|
|
355
|
+
# writer; an `extra_hudi_options` opt-out would leave the index stale
|
|
356
|
+
# and make readers see the wrong file list. Tables outside the scope
|
|
357
|
+
# keep `hoodie.metadata.enable=false` exactly as before.
|
|
358
|
+
hudi_options.update(
|
|
359
|
+
hudi_metadata_write_options(f"{self.dst_db}.{self.dst_tbl}")
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
df.write.format("hudi").options(**hudi_options).mode("append").save()
|
|
363
|
+
|
|
364
|
+
def load_spark(self, df: DataFrame):
|
|
365
|
+
output = df.repartition(*self.repartition) if self.repartition else df
|
|
366
|
+
try:
|
|
367
|
+
output.write.mode("overwrite").insertInto(f"{self.dst_db}.{self.dst_tbl}")
|
|
368
|
+
except AnalysisException:
|
|
369
|
+
writer = output.write.option("path", self.path).mode("overwrite")
|
|
370
|
+
if len(self.par_cols) == 0:
|
|
371
|
+
writer.saveAsTable(f"{self.dst_db}.{self.dst_tbl}")
|
|
372
|
+
else:
|
|
373
|
+
writer.partitionBy(*self.par_cols).saveAsTable(
|
|
374
|
+
f"{self.dst_db}.{self.dst_tbl}"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def load(self, df: DataFrame):
|
|
378
|
+
if len(self.par_cols) > 0:
|
|
379
|
+
df = self._prepare_partition(df)
|
|
380
|
+
if self.table_type == "hudi_table":
|
|
381
|
+
self.load_hudi(df)
|
|
382
|
+
elif self.table_type == "spark_table":
|
|
383
|
+
self.load_spark(df)
|
|
384
|
+
else:
|
|
385
|
+
raise ValueError(f'table_type "{self.table_type}" is incorrect')
|
|
386
|
+
|
|
387
|
+
def etl(self, start_date: str = None, end_date: str = None):
|
|
388
|
+
df = self.extract(start_date=start_date, end_date=end_date)
|
|
389
|
+
res = self.transform(df)
|
|
390
|
+
self.load(res)
|
|
391
|
+
|
|
392
|
+
def process(self):
|
|
393
|
+
if self.bulk:
|
|
394
|
+
self.etl(self.start_date, self.end_date)
|
|
395
|
+
else:
|
|
396
|
+
# 1 Batch per day
|
|
397
|
+
logger.info("Processing ETL task on a per day basis")
|
|
398
|
+
date_rng = pd.date_range(self.start_date, self.end_date)
|
|
399
|
+
for dt in date_rng:
|
|
400
|
+
start_dt = dt.strftime("%Y-%m-%d")
|
|
401
|
+
end_dt = dt + relativedelta(days=1)
|
|
402
|
+
end_dt = end_dt.strftime("%Y-%m-%d")
|
|
403
|
+
logger.info(f"Processing dt {start_dt}")
|
|
404
|
+
self.etl(start_dt, end_dt)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class MongoDbEtl(Etl):
|
|
408
|
+
schema = None
|
|
409
|
+
|
|
410
|
+
def extract(
|
|
411
|
+
self, start_date: str = None, end_date: str = None, schema: StructField = None
|
|
412
|
+
):
|
|
413
|
+
start_date = start_date or self.start_date
|
|
414
|
+
end_date = end_date or self.end_date
|
|
415
|
+
schema = schema or self.schema
|
|
416
|
+
df = (
|
|
417
|
+
self.spark.read.format("mongodb")
|
|
418
|
+
.option("connection.uri", self.url)
|
|
419
|
+
.option("database", self.src_db)
|
|
420
|
+
.option("collection", self.src_tbl)
|
|
421
|
+
.option("sql.inferSchema.mapTypes.enabled", "true")
|
|
422
|
+
.load(schema=schema)
|
|
423
|
+
)
|
|
424
|
+
if start_date:
|
|
425
|
+
df = df.filter(col(self.filter_by) >= start_date)
|
|
426
|
+
if end_date:
|
|
427
|
+
df = df.filter(col(self.filter_by) < end_date)
|
|
428
|
+
return df
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class JdbcEtl(Etl):
|
|
432
|
+
driver = None
|
|
433
|
+
|
|
434
|
+
def __init__(
|
|
435
|
+
self,
|
|
436
|
+
url: str,
|
|
437
|
+
start_date: str = None,
|
|
438
|
+
end_date: str = None,
|
|
439
|
+
bulk=True,
|
|
440
|
+
hudi_mode_override: str = None,
|
|
441
|
+
**kwargs,
|
|
442
|
+
):
|
|
443
|
+
super().__init__(start_date, end_date, url=url, bulk=bulk, hudi_mode_override=hudi_mode_override, **kwargs)
|
|
444
|
+
|
|
445
|
+
@classmethod
|
|
446
|
+
def run_from_cli(
|
|
447
|
+
cls,
|
|
448
|
+
url: Annotated[str, typer.Option("--url")],
|
|
449
|
+
start_date: Annotated[str, typer.Option("--start-date")] = None,
|
|
450
|
+
end_date: Annotated[str, typer.Option("--end-date")] = None,
|
|
451
|
+
bulk: Annotated[bool, typer.Option("--bulk/--per-day")] = True,
|
|
452
|
+
hudi_mode: Annotated[str, typer.Option("--hudi-mode")] = None,
|
|
453
|
+
):
|
|
454
|
+
obj = cls.__new__(cls)
|
|
455
|
+
obj.__init__(url, start_date, end_date, bulk, hudi_mode_override=hudi_mode)
|
|
456
|
+
obj()
|
|
457
|
+
return obj
|
|
458
|
+
|
|
459
|
+
def _prepare_query(self, start_date: str = None, end_date: str = None):
|
|
460
|
+
query = f"select * from {self.src_db}.{self.src_tbl} where 1 = 1 "
|
|
461
|
+
if self.filter_by and start_date:
|
|
462
|
+
query += f'and {self.filter_by} >= "{start_date}" '
|
|
463
|
+
if self.filter_by and end_date:
|
|
464
|
+
query += f'and {self.filter_by} < "{end_date}" '
|
|
465
|
+
self.query = f"({query}) as tmp"
|
|
466
|
+
return self.query
|
|
467
|
+
|
|
468
|
+
def extract(self, start_date: str = None, end_date: str = None):
|
|
469
|
+
start_date = start_date or self.start_date
|
|
470
|
+
end_date = end_date or self.end_date
|
|
471
|
+
self._prepare_query(start_date, end_date)
|
|
472
|
+
logger.info(self.query)
|
|
473
|
+
if self.filter_by and start_date and end_date:
|
|
474
|
+
df = (
|
|
475
|
+
self.spark.read.format("jdbc")
|
|
476
|
+
.option("driver", self.driver)
|
|
477
|
+
.option("url", self.url)
|
|
478
|
+
.option("dbtable", self.query)
|
|
479
|
+
.option("partitionColumn", self.ts)
|
|
480
|
+
.option("lowerBound", start_date)
|
|
481
|
+
.option("upperBound", end_date)
|
|
482
|
+
.option("numPartitions", "8")
|
|
483
|
+
.option("fetchsize", "1000")
|
|
484
|
+
.option("pushDownPredicate", "true")
|
|
485
|
+
.option("pushDownAggregate", "true")
|
|
486
|
+
.option("pushDownLimit", "true")
|
|
487
|
+
.option("pushDownOffset", "true")
|
|
488
|
+
.load()
|
|
489
|
+
)
|
|
490
|
+
else:
|
|
491
|
+
df = (
|
|
492
|
+
self.spark.read.format("jdbc")
|
|
493
|
+
.option("driver", self.driver)
|
|
494
|
+
.option("url", self.url)
|
|
495
|
+
.option("dbtable", self.query)
|
|
496
|
+
.option("fetchsize", "1000")
|
|
497
|
+
.option("pushDownPredicate", "true")
|
|
498
|
+
.option("pushDownAggregate", "true")
|
|
499
|
+
.option("pushDownLimit", "true")
|
|
500
|
+
.option("pushDownOffset", "true")
|
|
501
|
+
.load()
|
|
502
|
+
)
|
|
503
|
+
return df
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class MySqlEtl(JdbcEtl):
|
|
507
|
+
driver = "com.mysql.cj.jdbc.Driver"
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
class MsSqlEtl(JdbcEtl):
|
|
511
|
+
"""
|
|
512
|
+
ETL base class for Microsoft SQL Server sources via JDBC.
|
|
513
|
+
|
|
514
|
+
JDBC URL format:
|
|
515
|
+
jdbc:sqlserver://host:port;databaseName=db;encrypt=false
|
|
516
|
+
|
|
517
|
+
Differences from MySqlEtl:
|
|
518
|
+
- Driver: com.microsoft.sqlserver.jdbc.SQLServerDriver
|
|
519
|
+
- Date literals use single quotes (ANSI SQL)
|
|
520
|
+
- Identifiers use bracket quoting: [schema].[table]
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
driver = "com.microsoft.sqlserver.jdbc.SQLServerDriver"
|
|
524
|
+
|
|
525
|
+
def _prepare_query(self, start_date: str = None, end_date: str = None):
|
|
526
|
+
# Use bracket-quoted identifiers for MSSQL
|
|
527
|
+
if self.src_db:
|
|
528
|
+
table_ref = f"[{self.src_db}].[{self.src_tbl}]"
|
|
529
|
+
else:
|
|
530
|
+
table_ref = f"[{self.src_tbl}]"
|
|
531
|
+
|
|
532
|
+
query = f"select * from {table_ref} where 1 = 1 "
|
|
533
|
+
if self.filter_by and start_date:
|
|
534
|
+
query += f"and [{self.filter_by}] >= '{start_date}' "
|
|
535
|
+
if self.filter_by and end_date:
|
|
536
|
+
query += f"and [{self.filter_by}] < '{end_date}' "
|
|
537
|
+
self.query = f"({query}) as tmp"
|
|
538
|
+
return self.query
|
|
539
|
+
|
|
540
|
+
def extract(self, start_date: str = None, end_date: str = None):
|
|
541
|
+
"""
|
|
542
|
+
MSSQL-specific extract -- always uses single-partition JDBC read.
|
|
543
|
+
|
|
544
|
+
MSSQL servers often redirect partitioned connections to internal
|
|
545
|
+
hostnames (e.g. 'DB197') that Spark workers cannot resolve.
|
|
546
|
+
Avoid `partitionColumn` / `numPartitions` to prevent this.
|
|
547
|
+
|
|
548
|
+
All semicolon-delimited URL properties (user, password, encrypt, etc.)
|
|
549
|
+
are extracted and passed as explicit Spark JDBC `.option()` calls.
|
|
550
|
+
This is required because:
|
|
551
|
+
- MSSQL JDBC driver v10.2+ defaults `encrypt=true`
|
|
552
|
+
- Spark's JDBC Properties object does NOT merge URL-embedded properties
|
|
553
|
+
- All properties must be in the Properties object for the driver
|
|
554
|
+
"""
|
|
555
|
+
start_date = start_date or self.start_date
|
|
556
|
+
end_date = end_date or self.end_date
|
|
557
|
+
self._prepare_query(start_date, end_date)
|
|
558
|
+
logger.info(self.query)
|
|
559
|
+
|
|
560
|
+
# Parse MSSQL JDBC URL: jdbc:sqlserver://host:port;key=val;key=val;...
|
|
561
|
+
parts = self.url.split(";")
|
|
562
|
+
base_url = parts[0] # jdbc:sqlserver://host:port
|
|
563
|
+
jdbc_props = {}
|
|
564
|
+
remaining_url_parts = []
|
|
565
|
+
for part in parts[1:]:
|
|
566
|
+
if "=" in part:
|
|
567
|
+
k, v = part.split("=", 1)
|
|
568
|
+
# databaseName must stay in the URL (required by the driver)
|
|
569
|
+
# redirect must stay in the URL (connection-level property;
|
|
570
|
+
# the driver ignores it when passed as a JDBC Properties entry)
|
|
571
|
+
if k.lower() in ("databasename", "redirect"):
|
|
572
|
+
remaining_url_parts.append(part)
|
|
573
|
+
else:
|
|
574
|
+
jdbc_props[k] = v
|
|
575
|
+
else:
|
|
576
|
+
remaining_url_parts.append(part)
|
|
577
|
+
|
|
578
|
+
clean_url = ";".join([base_url] + remaining_url_parts)
|
|
579
|
+
|
|
580
|
+
# Ensure SSL properties are set (driver v10.2+ defaults encrypt=true)
|
|
581
|
+
jdbc_props.setdefault("encrypt", "false")
|
|
582
|
+
jdbc_props.setdefault("trustServerCertificate", "true")
|
|
583
|
+
|
|
584
|
+
logger.info(f"MSSQL clean URL: {clean_url}")
|
|
585
|
+
logger.info(f"MSSQL props keys: {list(jdbc_props.keys())}")
|
|
586
|
+
|
|
587
|
+
df = (
|
|
588
|
+
self.spark.read.format("jdbc")
|
|
589
|
+
.option("driver", self.driver)
|
|
590
|
+
.option("url", clean_url)
|
|
591
|
+
.option("dbtable", self.query)
|
|
592
|
+
.option("fetchsize", "1000")
|
|
593
|
+
.options(**jdbc_props)
|
|
594
|
+
.load()
|
|
595
|
+
)
|
|
596
|
+
return df
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class JsonEtl(Etl):
|
|
600
|
+
src_path = None
|
|
601
|
+
schema = None
|
|
602
|
+
|
|
603
|
+
def __init__(
|
|
604
|
+
self,
|
|
605
|
+
src_path: str,
|
|
606
|
+
start_date: str = None,
|
|
607
|
+
end_date: str = None,
|
|
608
|
+
bulk=True,
|
|
609
|
+
recursive: bool = True,
|
|
610
|
+
**kwargs,
|
|
611
|
+
):
|
|
612
|
+
super().__init__(start_date, end_date, bulk, **kwargs)
|
|
613
|
+
self.src_path = src_path
|
|
614
|
+
self.recursive = recursive
|
|
615
|
+
|
|
616
|
+
@classmethod
|
|
617
|
+
def run_from_cli(
|
|
618
|
+
cls,
|
|
619
|
+
src_path: Annotated[str, typer.Option("--src-path")] = None,
|
|
620
|
+
start_date: Annotated[str, typer.Option("--start-date")] = None,
|
|
621
|
+
end_date: Annotated[str, typer.Option("--end-date")] = None,
|
|
622
|
+
bulk: Annotated[bool, typer.Option("--bulk/--per-day")] = True,
|
|
623
|
+
recursive: Annotated[
|
|
624
|
+
bool, typer.Option("--recursive/--no-recursive")
|
|
625
|
+
] = True,
|
|
626
|
+
):
|
|
627
|
+
obj = cls.__new__(cls)
|
|
628
|
+
obj.__init__(src_path, start_date, end_date, bulk, recursive)
|
|
629
|
+
obj()
|
|
630
|
+
return obj
|
|
631
|
+
|
|
632
|
+
def extract(self, start_date: str = None, end_date: str = None):
|
|
633
|
+
start_date = start_date or self.start_date
|
|
634
|
+
end_date = end_date or self.end_date
|
|
635
|
+
recursive = str(self.recursive).lower()
|
|
636
|
+
if self.schema:
|
|
637
|
+
return (
|
|
638
|
+
self.spark.read.option("recursiveFileLookup", recursive)
|
|
639
|
+
.schema(self.schema)
|
|
640
|
+
.json(self.src_path)
|
|
641
|
+
)
|
|
642
|
+
else:
|
|
643
|
+
return self.spark.read.option("recursiveFileLookup", recursive).json(
|
|
644
|
+
self.src_path
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
class CsvEtl(Etl):
|
|
649
|
+
src_path = None
|
|
650
|
+
schema = None
|
|
651
|
+
|
|
652
|
+
def __init__(
|
|
653
|
+
self,
|
|
654
|
+
src_path: str,
|
|
655
|
+
start_date: str = None,
|
|
656
|
+
end_date: str = None,
|
|
657
|
+
bulk=True,
|
|
658
|
+
recursive: bool = True,
|
|
659
|
+
**kwargs,
|
|
660
|
+
):
|
|
661
|
+
super().__init__(start_date, end_date, bulk=bulk, **kwargs)
|
|
662
|
+
self.src_path = src_path
|
|
663
|
+
self.recursive = recursive
|
|
664
|
+
|
|
665
|
+
@classmethod
|
|
666
|
+
def run_from_cli(
|
|
667
|
+
cls,
|
|
668
|
+
src_path: Annotated[str, typer.Option("--src-path")] = None,
|
|
669
|
+
start_date: Annotated[str, typer.Option("--start-date")] = None,
|
|
670
|
+
end_date: Annotated[str, typer.Option("--end-date")] = None,
|
|
671
|
+
bulk: Annotated[bool, typer.Option("--bulk/--per-day")] = True,
|
|
672
|
+
recursive: Annotated[
|
|
673
|
+
bool, typer.Option("--recursive/--no-recursive")
|
|
674
|
+
] = True,
|
|
675
|
+
):
|
|
676
|
+
obj = cls.__new__(cls)
|
|
677
|
+
obj.__init__(src_path, start_date, end_date, bulk, recursive)
|
|
678
|
+
obj()
|
|
679
|
+
return obj
|
|
680
|
+
|
|
681
|
+
def extract(self, start_date: str = None, end_date: str = None):
|
|
682
|
+
if self.schema:
|
|
683
|
+
return (
|
|
684
|
+
self.spark.read.option("recursiveFileLookup", self.recursive)
|
|
685
|
+
.option("header", True)
|
|
686
|
+
.option("mode", "DROPMALFORMED")
|
|
687
|
+
.schema(self.schema)
|
|
688
|
+
.csv(self.src_path)
|
|
689
|
+
)
|
|
690
|
+
else:
|
|
691
|
+
return (
|
|
692
|
+
self.spark.read.option("recursiveFileLookup", self.recursive)
|
|
693
|
+
.option("header", True)
|
|
694
|
+
.option("mode", "DROPMALFORMED")
|
|
695
|
+
.csv(self.src_path)
|
|
696
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Companion utilities for the bundled ETL template."""
|