SilverStreamingApplication 1.6__tar.gz → 1.8__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.
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/PKG-INFO +1 -1
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/Operations.py +79 -6
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/SilverMerge.py +215 -38
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/WorkflowBuilder.py +13 -3
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/setup.py +1 -1
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/silverstreamingapplication.egg-info/PKG-INFO +1 -1
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/Maintenance.py +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/PartitionAnalyzer.py +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/PerfAnalyzer.py +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/__init__.py +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/logging.py +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/sparksession.py +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/utils.py +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/utilsDatabricksAPI.py +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/tests/OperationsTest.py +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/tests/__init__.py +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/tests/main_test.py +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/tests/utils_test.py +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/setup.cfg +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/silverstreamingapplication.egg-info/SOURCES.txt +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/silverstreamingapplication.egg-info/dependency_links.txt +0 -0
- {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/silverstreamingapplication.egg-info/top_level.txt +0 -0
{silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/Operations.py
RENAMED
|
@@ -2,6 +2,10 @@ from pyspark.sql.utils import AnalysisException
|
|
|
2
2
|
from com.phida.main.sparksession import spark
|
|
3
3
|
from pyspark.sql.functions import col
|
|
4
4
|
from pyspark.sql.types import _parse_datatype_string, StructType, StructField
|
|
5
|
+
#Importing dbutils as it is used in file movement.
|
|
6
|
+
from pyspark.dbutils import DBUtils
|
|
7
|
+
|
|
8
|
+
dbutils = DBUtils(spark.sparkContext)
|
|
5
9
|
|
|
6
10
|
|
|
7
11
|
def tableExists(dbName, tblName):
|
|
@@ -49,17 +53,34 @@ def getKeyCols(dbName, tblName):
|
|
|
49
53
|
getKeyCols("<database name>", "vbuk")
|
|
50
54
|
|
|
51
55
|
tip:
|
|
52
|
-
Make sure the table is a bronze table and has the columns "src_key_cols" and "
|
|
56
|
+
Make sure the table is a bronze table and has the columns "src_key_cols", "src_commit_time", and "__timestamp"
|
|
53
57
|
"""
|
|
58
|
+
|
|
54
59
|
try:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
# To accomodate both the hvr and dsp data together and seperately
|
|
61
|
+
# To achive Below Logic:
|
|
62
|
+
#If Both HVR and DSP -> DSP Wins -> __timestamp
|
|
63
|
+
#IF Only HVR Or DSP -> Particluar wins
|
|
64
|
+
table = spark.read.table(f"{dbName}.{tblName}")
|
|
65
|
+
existing_cols = table.columns
|
|
66
|
+
|
|
67
|
+
# Determine the best column to order by
|
|
68
|
+
order_cols = [c for c in ["src_commit_time", "__timestamp"] if c in existing_cols]
|
|
69
|
+
|
|
70
|
+
if order_cols:
|
|
71
|
+
keyCols = (table
|
|
72
|
+
.orderBy(*order_cols, ascending=False)
|
|
73
|
+
.select("src_key_cols")
|
|
74
|
+
.limit(1)
|
|
75
|
+
.collect()[0][0]
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
keyCols = (table
|
|
79
|
+
.select("src_key_cols")
|
|
58
80
|
.limit(1)
|
|
59
81
|
.collect()[0][0]
|
|
60
|
-
|
|
82
|
+
)
|
|
61
83
|
return keyCols
|
|
62
|
-
|
|
63
84
|
except Exception:
|
|
64
85
|
raise Exception(
|
|
65
86
|
f"the column src_key_cols is not present in the given table {dbName}.{tblName}")
|
|
@@ -270,6 +291,13 @@ def addDerivedColumns(df, colExprList):
|
|
|
270
291
|
except Exception:
|
|
271
292
|
raise Exception("The given column expression is invalid")
|
|
272
293
|
|
|
294
|
+
# TO Handel DataTypes issues (DecFloat issues in DSP)
|
|
295
|
+
# Value: cast(CFB_ADTDAYS as decimal(4,0)) as cfb_adtdays§ cast(SICHZ_TRM as decimal(6,3))
|
|
296
|
+
# If the alias matches an existing column name (case-insensitive), drop the
|
|
297
|
+
# original first so the derived expression replaces it instead of duplicating it.
|
|
298
|
+
alias = colExpr.split(" as ")[-1].strip().lower()
|
|
299
|
+
columnList = [c for c in columnList if c.strip("`").lower() != alias]
|
|
300
|
+
|
|
273
301
|
columnList.append(colExpr)
|
|
274
302
|
|
|
275
303
|
dfOut = df.selectExpr(columnList)
|
|
@@ -503,3 +531,48 @@ def buildJoinCondition(keyColsList):
|
|
|
503
531
|
condition = condition + "t.`" + column + "` <=> " + "s.`" + column + "` AND "
|
|
504
532
|
|
|
505
533
|
return condition[:-5]
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# A Function for moving files from DSP to Bronze for the given source table.
|
|
537
|
+
# Since, DSP Writes into two Folders "initial" and "delta", .parquet files from these two folders are recursively
|
|
538
|
+
# moved into Bronze Table Location.
|
|
539
|
+
def move_dsp_to_bronze(srcDatabaseName, srcTableName, src_dsp_path, src_table_path):
|
|
540
|
+
dsp_path = src_dsp_path
|
|
541
|
+
bronze_path = src_table_path
|
|
542
|
+
num_files = 0
|
|
543
|
+
|
|
544
|
+
print(f"Recursively copying all files from {dsp_path} to {bronze_path} (flattened, no hierarchy, source not deleted)")
|
|
545
|
+
|
|
546
|
+
def list_all_files(path):
|
|
547
|
+
files = []
|
|
548
|
+
for item in dbutils.fs.ls(path):
|
|
549
|
+
if item.isDir():
|
|
550
|
+
files.extend(list_all_files(item.path))
|
|
551
|
+
else:
|
|
552
|
+
files.append(item.path)
|
|
553
|
+
return files
|
|
554
|
+
|
|
555
|
+
try:
|
|
556
|
+
all_files = list_all_files(dsp_path)
|
|
557
|
+
for src_file in all_files:
|
|
558
|
+
file_name = src_file.split("/")[-1]
|
|
559
|
+
if ".sap." in file_name:
|
|
560
|
+
dest_file = f"{bronze_path}/{file_name}"
|
|
561
|
+
dbutils.fs.cp(src_file, dest_file)
|
|
562
|
+
#num_files += 1
|
|
563
|
+
else:
|
|
564
|
+
dest_file = f"{bronze_path}/{file_name}"
|
|
565
|
+
dbutils.fs.mv(src_file, dest_file)
|
|
566
|
+
num_files += 1
|
|
567
|
+
except Exception as e:
|
|
568
|
+
print(f"Failed to copy files: {e}")
|
|
569
|
+
|
|
570
|
+
if num_files > 0:
|
|
571
|
+
print(f"Copied {num_files} files from {dsp_path} to {bronze_path}, refreshing table {srcDatabaseName}.{srcTableName}")
|
|
572
|
+
spark.sql(f"REFRESH TABLE {srcDatabaseName}.{srcTableName}")
|
|
573
|
+
print(f"Table {srcDatabaseName}.{srcTableName} is Refreshed")
|
|
574
|
+
else:
|
|
575
|
+
print(f"No files were copied from {dsp_path} to {bronze_path}, so skipping refresh of {srcDatabaseName}.{srcTableName}")
|
|
576
|
+
|
|
577
|
+
# spark.sql(f"REFRESH TABLE {srcDatabaseName}.{srcTableName}")
|
|
578
|
+
# print(f"Table {srcDatabaseName}.{srcTableName} is Refreshed")
|
{silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/SilverMerge.py
RENAMED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
from datetime import date, timedelta
|
|
2
2
|
from com.phida.main.sparksession import spark, logger
|
|
3
|
-
from com.phida.main.Operations import describe_table_to_schema, tableExists, addDerivedColumns, createDeltaTable, alterDeltaTable, \
|
|
4
|
-
dropColumns, getDerivedColumnsList, schemaDiff, getKeyCols, buildJoinCondition, buildColumnsDict, hiveDDL, schemaDataTypeDiff
|
|
3
|
+
from com.phida.main.Operations import describe_table_to_schema, move_dsp_to_bronze, tableExists, addDerivedColumns, createDeltaTable, alterDeltaTable, \
|
|
4
|
+
dropColumns, getDerivedColumnsList, schemaDiff, getKeyCols, buildJoinCondition, buildColumnsDict, hiveDDL, schemaDataTypeDiff, hasColumn
|
|
5
5
|
from com.phida.main.utils import pathExists, convertStrToList
|
|
6
|
-
from pyspark.sql.functions import row_number, col, broadcast,expr, max as spark_max
|
|
6
|
+
from pyspark.sql.functions import row_number, col, broadcast,expr, max as spark_max, lit, when, coalesce
|
|
7
7
|
from pyspark.sql.window import Window
|
|
8
8
|
from delta.tables import DeltaTable
|
|
9
9
|
from pyspark.sql import DataFrame
|
|
@@ -28,8 +28,11 @@ class SilverMerge:
|
|
|
28
28
|
cluster_table_path: String - Path to the cluster table
|
|
29
29
|
enable_delete_filter: String - Flag to enable/disable filtering of deleted records (default: 'N')
|
|
30
30
|
use_auto_loader: String - Flag to enable/disable use of autoloader for reading source table (default: 'N')
|
|
31
|
+
is_dsp_kernel: String - Flag to indicate if the kernel is a DSP kernel, which requires file movement from DSP to Bronze (default: 'N')
|
|
32
|
+
src_dsp_path: String - The DSP path to read from if it's a DSP kernel (optional, only needed if is_dsp_kernel is 'Y')
|
|
31
33
|
|
|
32
34
|
methods:
|
|
35
|
+
move_dsp_to_bronze
|
|
33
36
|
silverCleansing
|
|
34
37
|
prepareTarget
|
|
35
38
|
upsertToDelta
|
|
@@ -39,12 +42,12 @@ class SilverMerge:
|
|
|
39
42
|
from com.phida.SilverMerge import SilverMerge
|
|
40
43
|
silverMergeObj = SilverMerge(srcDatabaseName, srcTableName, tgtDatabaseName, tgtTableName,
|
|
41
44
|
tgtCheckpoint, tgtTablePath, tgtPartitionColumns, derivedColExpr,
|
|
42
|
-
triggerOnce, dropColumnStr, pruneColumn, cluster_table_path, enable_delete_filter, use_auto_loader)
|
|
45
|
+
triggerOnce, dropColumnStr, pruneColumn, cluster_table_path, enable_delete_filter, use_auto_loader, is_dsp_kernel, src_dsp_path)
|
|
43
46
|
|
|
44
47
|
"""
|
|
45
48
|
def __init__(self, srcDatabaseName, srcTableName, tgtDatabaseName, tgtTableName,
|
|
46
49
|
tgtCheckpoint, tgtTablePath, tgtPartitionColumns, derivedColExpr,
|
|
47
|
-
triggerOnce, dropColumnStr, pruneColumn, cluster_table_path, enable_delete_filter, use_auto_loader, modified_after=None, backfill_interval=None):
|
|
50
|
+
triggerOnce, dropColumnStr, pruneColumn, cluster_table_path, enable_delete_filter, use_auto_loader, modified_after=None, backfill_interval=None, is_dsp_kernel='N', src_dsp_path=None):
|
|
48
51
|
"""
|
|
49
52
|
desc:
|
|
50
53
|
Initialize the required class variables
|
|
@@ -64,9 +67,13 @@ class SilverMerge:
|
|
|
64
67
|
cluster_table_path: String - Path to the cluster table
|
|
65
68
|
enable_delete_filter: String - Flag to enable/disable filtering of deleted records (default: 'N')
|
|
66
69
|
use_auto_loader: String - Flag to enable/disable use of autoloader for reading source table (default: 'N')
|
|
70
|
+
is_dsp_kernel: String - Flag to indicate if the kernel is a DSP kernel, which requires file movement from DSP to Bronze (default: 'N')
|
|
71
|
+
src_dsp_path: String - The DSP path to read from if it's a DSP kernel (optional, only needed if is_dsp_kernel is 'Y')
|
|
67
72
|
"""
|
|
68
73
|
|
|
69
74
|
logger.info("phida_log: Initialising class variables")
|
|
75
|
+
logger.info("Reading Bronze Table...")
|
|
76
|
+
print(f"[SilverMerge] Initialising pipeline for {srcDatabaseName}.{srcTableName} -> {tgtDatabaseName}.{tgtTableName}")
|
|
70
77
|
|
|
71
78
|
self.srcDatabaseName = srcDatabaseName
|
|
72
79
|
self.srcTableName = srcTableName
|
|
@@ -84,35 +91,62 @@ class SilverMerge:
|
|
|
84
91
|
self.use_auto_loader = use_auto_loader.strip().upper() if use_auto_loader and use_auto_loader.strip().upper() in ('Y') else 'N'
|
|
85
92
|
self.modified_after = modified_after.strip() if modified_after else (date.today() - timedelta(days=15)).strftime('%Y-%m-%d')
|
|
86
93
|
self.backfill_interval = backfill_interval if backfill_interval else "1 month"
|
|
94
|
+
self.is_dsp_kernel = is_dsp_kernel.strip().upper() if is_dsp_kernel else 'N' #To determine if Kernel is loaded via Datasphere
|
|
95
|
+
self.src_dsp_path = src_dsp_path if src_dsp_path else None
|
|
96
|
+
|
|
97
|
+
#Run file movement between DSP and Bronze only if it's a DSP kernel, to ensure we don't have unnecessary file movements for non-DSP kernels
|
|
98
|
+
if self.is_dsp_kernel == 'Y':
|
|
99
|
+
src_table_path = spark.sql(f"DESCRIBE DETAIL {self.srcDatabaseName}.{self.srcTableName}").collect()[0]['location']
|
|
100
|
+
logger.info(f"phida_log: File movement between DSP and Bronze")
|
|
101
|
+
print(f"[SilverMerge] File movement between DSP and Bronze")
|
|
102
|
+
move_dsp_to_bronze(self.srcDatabaseName ,self.srcTableName, self.src_dsp_path, src_table_path)
|
|
87
103
|
|
|
88
104
|
logger.info(f"phida_log: Check if source table {self.srcDatabaseName}.{self.srcTableName} exists")
|
|
89
105
|
|
|
90
106
|
if tableExists(self.srcDatabaseName, self.srcTableName):
|
|
107
|
+
logger.info(f"Bronze table {self.srcDatabaseName}.{self.srcTableName} exists.")
|
|
108
|
+
print(f"[SilverMerge] Bronze table {self.srcDatabaseName}.{self.srcTableName} found.")
|
|
91
109
|
|
|
92
110
|
logger.info(f"phida_log: source table exists")
|
|
93
111
|
|
|
94
112
|
logger.info(f"phida_log: initialising derived class variables")
|
|
95
113
|
|
|
96
114
|
if self.use_auto_loader == 'N':
|
|
115
|
+
logger.info("Extracting Bronze Structure using Spark readStream.table...")
|
|
116
|
+
print("[SilverMerge] Reading Bronze table using Spark readStream...")
|
|
97
117
|
self.srcDF = spark.readStream.table(self.srcDatabaseName + "." + self.srcTableName)
|
|
118
|
+
logger.info("Bronze Structure extracted.")
|
|
119
|
+
print("[SilverMerge] Bronze structure extracted.")
|
|
98
120
|
elif self.use_auto_loader == 'Y':
|
|
121
|
+
logger.info("Extracting Bronze Structure using Auto Loader...")
|
|
122
|
+
print("[SilverMerge] Reading Bronze table using Auto Loader...")
|
|
99
123
|
desc = spark.sql(f"DESCRIBE TABLE {self.srcDatabaseName}.{self.srcTableName}").collect()
|
|
100
124
|
schema = describe_table_to_schema(desc)
|
|
101
125
|
src_table_path = spark.sql(f"DESCRIBE DETAIL {self.srcDatabaseName}.{self.srcTableName}").collect()[0]['location']
|
|
102
126
|
self.srcDF = spark.readStream.format("cloudFiles").option("cloudFiles.format", "parquet").option("cloudFiles.schemaLocation", src_table_path + "/_cloudfiles/_schemas").option("modifiedAfter", f"{self.modified_after} 00:00:00.000000 UTC+0").option("cloudFiles.backfillInterval", self.backfill_interval).schema(schema).load(src_table_path).drop("_rescued_data")
|
|
127
|
+
logger.info("Bronze Structure extracted.")
|
|
128
|
+
print("[SilverMerge] Bronze structure extracted via Auto Loader.")
|
|
103
129
|
|
|
130
|
+
logger.info("Determining key columns for merge...")
|
|
131
|
+
print("[SilverMerge] Determining key columns for merge...")
|
|
104
132
|
self.keyCols = getKeyCols(self.srcDatabaseName, self.srcTableName)
|
|
105
133
|
|
|
106
134
|
self.keyColsList = convertStrToList(self.keyCols, ",")
|
|
107
|
-
|
|
135
|
+
logger.info(f"Key columns for merge: {self.keyColsList}")
|
|
136
|
+
print(f"[SilverMerge] Key columns: {self.keyColsList}")
|
|
137
|
+
|
|
108
138
|
self.joinCondition = buildJoinCondition(self.keyColsList)
|
|
139
|
+
logger.info(f"Join condition for merge: {self.joinCondition}")
|
|
109
140
|
|
|
110
141
|
self.condition = f"{self.pruneColumn} <> '' AND {self.joinCondition}" if self.pruneColumn.strip() \
|
|
111
142
|
else self.joinCondition
|
|
143
|
+
logger.info(f"Final merge condition: {self.condition}")
|
|
112
144
|
|
|
113
145
|
self.dropColumnList = convertStrToList(self.dropColumnStr, ",")
|
|
114
146
|
|
|
115
147
|
self.columnsDict = buildColumnsDict(self.srcDF, self.dropColumnList)
|
|
148
|
+
logger.info("Initialization of SilverMerge complete.")
|
|
149
|
+
print("[SilverMerge] Initialization complete.")
|
|
116
150
|
|
|
117
151
|
def silverCleansing(self):
|
|
118
152
|
"""
|
|
@@ -132,21 +166,56 @@ class SilverMerge:
|
|
|
132
166
|
N/A
|
|
133
167
|
"""
|
|
134
168
|
logger.info(f"phida_log: applying cleansing rules on source dataframe ")
|
|
169
|
+
logger.info("Starting cleansing transformations on Bronze DataFrame...")
|
|
170
|
+
print("[SilverMerge] Starting Silver cleansing transformations...")
|
|
171
|
+
|
|
135
172
|
|
|
136
173
|
if tableExists(self.srcDatabaseName, self.srcTableName):
|
|
137
174
|
|
|
138
175
|
silverRawDF = self.srcDF
|
|
176
|
+
logger.info("Bronze DataFrame loaded for cleansing.")
|
|
139
177
|
|
|
140
178
|
if self.derivedColExpr:
|
|
179
|
+
logger.info("Adding derived columns to DataFrame...")
|
|
141
180
|
derivedColExprList = convertStrToList(self.derivedColExpr, "§")
|
|
142
|
-
|
|
143
181
|
silverDerivedColumns = addDerivedColumns(silverRawDF, derivedColExprList)
|
|
144
|
-
|
|
182
|
+
logger.info("Derived columns added.")
|
|
145
183
|
else:
|
|
146
184
|
silverDerivedColumns = silverRawDF
|
|
185
|
+
logger.info("No derived columns specified.")
|
|
186
|
+
|
|
187
|
+
# Map DSP __timestamp (timestamp_ntz) to src_commit_time for unified timestamp handling.
|
|
188
|
+
# And drop __timestamp from the DF
|
|
189
|
+
# Handle both mixed HVR+DSP and DSP-only inputs safely.
|
|
190
|
+
has_src_commit_time = "src_commit_time" in silverDerivedColumns.columns
|
|
191
|
+
has_dsp_timestamp = "__timestamp" in silverDerivedColumns.columns
|
|
192
|
+
|
|
193
|
+
if has_dsp_timestamp:
|
|
194
|
+
if has_src_commit_time:
|
|
195
|
+
silverDerivedColumns = silverDerivedColumns.withColumn(
|
|
196
|
+
"src_commit_time",
|
|
197
|
+
coalesce(col("src_commit_time"), col("__timestamp").cast("timestamp"))
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
silverDerivedColumns = silverDerivedColumns.withColumn(
|
|
201
|
+
"src_commit_time",
|
|
202
|
+
col("__timestamp").cast("timestamp")
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
silverDerivedColumns = silverDerivedColumns.drop("__timestamp")
|
|
206
|
+
logger.info("phida_log: Mapped DSP __timestamp to src_commit_time")
|
|
207
|
+
else:
|
|
208
|
+
logger.info("phida_log: HVR data; src_commit_time already present")
|
|
147
209
|
|
|
148
|
-
|
|
210
|
+
# Convert all column names to lower case to ensure
|
|
211
|
+
# the target Delta table is created and maintained with lower-case column names.
|
|
212
|
+
silverDerivedColumns = silverDerivedColumns.toDF(*[c.lower() for c in silverDerivedColumns.columns])
|
|
213
|
+
logger.info("Column names converted to lower case.")
|
|
214
|
+
print("[SilverMerge] Column names normalised to lower case.")
|
|
149
215
|
|
|
216
|
+
self.columnsDict = buildColumnsDict(silverDerivedColumns, self.dropColumnList)
|
|
217
|
+
logger.info("Cleansing transformations complete.")
|
|
218
|
+
print("[SilverMerge] Cleansing transformations complete.")
|
|
150
219
|
return silverDerivedColumns
|
|
151
220
|
|
|
152
221
|
def prepareTarget(self, inDF):
|
|
@@ -171,6 +240,7 @@ class SilverMerge:
|
|
|
171
240
|
N/A
|
|
172
241
|
"""
|
|
173
242
|
logger.info(f"phida_log: preparing the target delta table ")
|
|
243
|
+
print(f"[SilverMerge] Preparing target table {self.tgtDatabaseName}.{self.tgtTableName}...")
|
|
174
244
|
|
|
175
245
|
targetTableExists = tableExists(self.tgtDatabaseName, self.tgtTableName)
|
|
176
246
|
|
|
@@ -184,6 +254,7 @@ class SilverMerge:
|
|
|
184
254
|
|
|
185
255
|
logger.info(f"phida_log: This seems to be the first run")
|
|
186
256
|
logger.info(f"phida_log: creating the target table {self.tgtDatabaseName}.{self.tgtTableName}")
|
|
257
|
+
print(f"[SilverMerge] First run detected. Creating target table {self.tgtDatabaseName}.{self.tgtTableName}...")
|
|
187
258
|
|
|
188
259
|
createDeltaTable(inDF,
|
|
189
260
|
self.tgtTablePath,
|
|
@@ -212,6 +283,7 @@ class SilverMerge:
|
|
|
212
283
|
if addColumns:
|
|
213
284
|
logger.info(f"phida_log: There seems to be a schema change in silver")
|
|
214
285
|
logger.info(f"phida_log: Altering the target table {self.tgtDatabaseName}.{self.tgtTableName}")
|
|
286
|
+
print(f"[SilverMerge] Schema change detected. Altering target table with new columns: {addColumns}")
|
|
215
287
|
|
|
216
288
|
alterDeltaTable(self.tgtDatabaseName, self.tgtTableName, addColumns)
|
|
217
289
|
|
|
@@ -219,37 +291,38 @@ class SilverMerge:
|
|
|
219
291
|
|
|
220
292
|
else:
|
|
221
293
|
logger.info(f"phida_log: There is no change in schema in silver")
|
|
294
|
+
print("[SilverMerge] No schema changes detected.")
|
|
222
295
|
|
|
223
296
|
def filter_deleted_records(self, microBatchOutputDF):
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
297
|
+
"""
|
|
298
|
+
Filters out records from microBatchOutputDF that have been marked as deleted in the Bronze table
|
|
299
|
+
within the last 4 hours based on hvr_integ_key.
|
|
300
|
+
"""
|
|
228
301
|
|
|
229
|
-
|
|
230
|
-
|
|
302
|
+
if microBatchOutputDF.isEmpty():
|
|
303
|
+
return microBatchOutputDF
|
|
231
304
|
|
|
232
|
-
|
|
305
|
+
filtered_table_name = f"{self.srcDatabaseName}.{self.srcTableName}_filtered"
|
|
233
306
|
|
|
234
|
-
|
|
235
|
-
|
|
307
|
+
if not tableExists(self.srcDatabaseName, f"{self.srcTableName}_filtered"):
|
|
308
|
+
return microBatchOutputDF
|
|
236
309
|
|
|
237
|
-
|
|
310
|
+
delete_records = spark.read.table(filtered_table_name).filter(col("source_operation") == 0)
|
|
238
311
|
|
|
239
|
-
|
|
240
|
-
|
|
312
|
+
if delete_records.isEmpty():
|
|
313
|
+
return microBatchOutputDF
|
|
241
314
|
|
|
242
|
-
|
|
315
|
+
delete_keys_df = (delete_records.groupBy(self.keyColsList).agg(spark_max(col("hvr_integ_key")).alias("delete_hvr_integ_key")))
|
|
243
316
|
|
|
244
|
-
|
|
317
|
+
joined_df = microBatchOutputDF.join(broadcast(delete_keys_df), on=self.keyColsList, how="left")
|
|
245
318
|
|
|
246
|
-
|
|
319
|
+
filtered_df = (joined_df.filter(
|
|
247
320
|
(col("delete_hvr_integ_key").isNull()) |
|
|
248
321
|
(col("hvr_integ_key") >= col("delete_hvr_integ_key"))
|
|
249
322
|
)
|
|
250
323
|
.drop("delete_hvr_integ_key"))
|
|
251
324
|
|
|
252
|
-
|
|
325
|
+
return filtered_df
|
|
253
326
|
|
|
254
327
|
def preprocess_dataframe(self, df: DataFrame, parent_keys: list) -> DataFrame:
|
|
255
328
|
|
|
@@ -275,8 +348,6 @@ class SilverMerge:
|
|
|
275
348
|
unpack_keys = set(row["key_columns_for_unpack_table"].split(',')) if row["key_columns_for_unpack_table"] else set()
|
|
276
349
|
parent_keys = list(pack_keys & unpack_keys)
|
|
277
350
|
return parent_keys
|
|
278
|
-
|
|
279
|
-
|
|
280
351
|
|
|
281
352
|
def upsertToDelta(self, microBatchOutputDF, batchId):
|
|
282
353
|
"""
|
|
@@ -296,11 +367,28 @@ class SilverMerge:
|
|
|
296
367
|
tip:
|
|
297
368
|
N/A
|
|
298
369
|
"""
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
370
|
+
|
|
371
|
+
# DSP_Change: Support both HVR (source_operation) and DSP (__operation_type) operation types
|
|
372
|
+
# HVR: source_operation in (0,1,2,3) where 0=delete, 1=insert, 2=update, 3=before-image
|
|
373
|
+
# DSP: __operation_type in ('A','I','L','X') where A=Append, I=Delta-Insert, L=Initial Load, X=delete
|
|
374
|
+
cols = set(microBatchOutputDF.columns)
|
|
375
|
+
has_source_operation = "source_operation" in cols
|
|
376
|
+
has_operation_type = "__operation_type" in cols
|
|
377
|
+
|
|
378
|
+
empty_df = microBatchOutputDF.limit(0)
|
|
379
|
+
|
|
380
|
+
# Build filtered views once
|
|
381
|
+
hvr_df_8 = (microBatchOutputDF.filter("source_operation in (0,1,2,3,8)") if has_source_operation else empty_df)
|
|
382
|
+
op_8_df = (hvr_df_8.filter("source_operation = 8") if has_source_operation else empty_df)
|
|
383
|
+
hvr_df = (hvr_df_8.filter("source_operation in (0,1,2,3)") if has_source_operation else empty_df)
|
|
384
|
+
dsp_df = (microBatchOutputDF.filter("__operation_type in ('A','I','L','X')") if has_operation_type else empty_df)
|
|
385
|
+
|
|
386
|
+
# Keep op-8/preprocess flow strictly HVR-only. DSP records should not go through op-8 path.
|
|
387
|
+
microBatchOutputDF_8 = hvr_df_8
|
|
388
|
+
microBatchOutputDF = hvr_df.unionByName(dsp_df, allowMissingColumns=True)
|
|
389
|
+
|
|
302
390
|
tgtDeltaTable = DeltaTable.forName(spark, self.tgtDatabaseName + "." + self.tgtTableName)
|
|
303
|
-
|
|
391
|
+
|
|
304
392
|
if self.cluster_table_path:
|
|
305
393
|
if not op_8_df.isEmpty():
|
|
306
394
|
table_details = DeltaTable.forName(spark, self.cluster_table_path)
|
|
@@ -311,9 +399,45 @@ class SilverMerge:
|
|
|
311
399
|
tgtDeltaTable.alias("t").merge(microBatchOutputDF_8.alias("s"), parent_key_conditions) \
|
|
312
400
|
.whenMatchedDelete() \
|
|
313
401
|
.execute()
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
402
|
+
# Apply op-8 preprocess only to HVR rows, then add DSP rows back.
|
|
403
|
+
hvr_preprocessed_df = self.preprocess_dataframe(microBatchOutputDF_8, parent_keys)
|
|
404
|
+
microBatchOutputDF = hvr_preprocessed_df.unionByName(dsp_df, allowMissingColumns=True)
|
|
405
|
+
|
|
406
|
+
# Convert all column names to lower case just before writing to Silver
|
|
407
|
+
lower_cols = [c.lower() for c in microBatchOutputDF.columns]
|
|
408
|
+
microBatchOutputDF = microBatchOutputDF.toDF(*lower_cols)
|
|
409
|
+
|
|
410
|
+
# Compatible ordering across all modes.
|
|
411
|
+
# Do not infer mode from column presence because unionByName(allowMissingColumns=True)
|
|
412
|
+
# can materialize placeholder columns with null values.
|
|
413
|
+
# Example: HVR-only batch → after union, __operation_type column exists but is all NULL.
|
|
414
|
+
# Checking column presence alone would incorrectly route HVR data to mixed-mode ordering.
|
|
415
|
+
batch_cols = set(microBatchOutputDF.columns)
|
|
416
|
+
has_hvr_key = "hvr_integ_key" in batch_cols # True for HVR and mixed tables
|
|
417
|
+
has_dsp_op = "__operation_type" in batch_cols # True for DSP and mixed tables (may be all-null after union)
|
|
418
|
+
has_src_commit_time = "src_commit_time" in batch_cols # Common timestamp column for both sources
|
|
419
|
+
|
|
420
|
+
if has_hvr_key and not has_dsp_op:
|
|
421
|
+
# HVR-only table (no DSP column at all): deduplicate by integ_key descending — highest key = latest change.
|
|
422
|
+
windowSpec = Window.partitionBy(self.keyColsList).orderBy(col("hvr_integ_key").desc_nulls_last())
|
|
423
|
+
else:
|
|
424
|
+
# Mixed or DSP path: build ordering dynamically based on which recency columns actually exist.
|
|
425
|
+
order_exprs = []
|
|
426
|
+
if has_dsp_op:
|
|
427
|
+
# Flag actual DSP rows (op in A/I/L/X = 1) vs HVR rows or union-placeholder nulls (= 0).
|
|
428
|
+
# DSP rows sort first (desc), so DSP always wins over HVR for the same key in a mixed batch.
|
|
429
|
+
is_dsp_record = when(col("__operation_type").isin("A", "I", "L", "X"), lit(1)).otherwise(lit(0))
|
|
430
|
+
order_exprs.append(is_dsp_record.desc())
|
|
431
|
+
if has_src_commit_time:
|
|
432
|
+
# Within the same source type, pick the most recent record by commit timestamp.
|
|
433
|
+
order_exprs.append(col("src_commit_time").desc_nulls_last())
|
|
434
|
+
if has_hvr_key:
|
|
435
|
+
# Final tiebreaker for HVR rows: higher integ_key = later change from source.
|
|
436
|
+
order_exprs.append(col("hvr_integ_key").desc_nulls_last())
|
|
437
|
+
if not order_exprs:
|
|
438
|
+
# Defensive fallback: no recency column available, treat all rows as equal.
|
|
439
|
+
order_exprs.append(lit(1))
|
|
440
|
+
windowSpec = Window.partitionBy(self.keyColsList).orderBy(*order_exprs)
|
|
317
441
|
|
|
318
442
|
microBatchOutputDF = microBatchOutputDF.withColumn("latest_record", row_number().over(windowSpec)).filter(
|
|
319
443
|
"latest_record == 1").drop("latest_record")
|
|
@@ -321,11 +445,63 @@ class SilverMerge:
|
|
|
321
445
|
if self.enable_delete_filter == 'Y':
|
|
322
446
|
microBatchOutputDF = self.filter_deleted_records(microBatchOutputDF)
|
|
323
447
|
|
|
448
|
+
# Build delete/update conditions only from operation columns present in the batch to avoid UNRESOLVED_EXPRESSION errors.
|
|
449
|
+
delete_parts = []
|
|
450
|
+
update_insert_parts = []
|
|
451
|
+
|
|
452
|
+
# HVR: source_operation 0=delete, 3=before-image; all others are insert/update.
|
|
453
|
+
if has_source_operation:
|
|
454
|
+
delete_parts.append(
|
|
455
|
+
"(s.source_operation IS NOT NULL AND s.source_operation in (0,3))"
|
|
456
|
+
)
|
|
457
|
+
update_insert_parts.append(
|
|
458
|
+
"(s.source_operation IS NOT NULL AND s.source_operation not in (0,3))"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# DSP: __operation_type X=delete; A/I/L are insert/update variants.
|
|
462
|
+
if has_operation_type:
|
|
463
|
+
delete_parts.append(
|
|
464
|
+
"(s.__operation_type IS NOT NULL AND s.__operation_type in ('X'))"
|
|
465
|
+
)
|
|
466
|
+
update_insert_parts.append(
|
|
467
|
+
"(s.__operation_type IS NOT NULL AND s.__operation_type in ('A','I','L'))"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# At least one operation column must exist; otherwise the batch is malformed.
|
|
471
|
+
if not delete_parts or not update_insert_parts:
|
|
472
|
+
raise Exception("No operation column found in source batch")
|
|
473
|
+
|
|
474
|
+
# OR joins HVR and DSP branches because a row belongs to exactly one source system.
|
|
475
|
+
delete_condition = " OR ".join(delete_parts)
|
|
476
|
+
update_insert_condition = " OR ".join(update_insert_parts)
|
|
477
|
+
|
|
478
|
+
# Freshness guard: prevent a late/older microbatch from overwriting a newer target row.
|
|
479
|
+
# src_commit_time is the common recency column populated for both HVR and DSP flows.
|
|
480
|
+
# Example:
|
|
481
|
+
# Batch 1 (arrives first): src_commit_time = '2024-01-01 10:00' → written to Silver (t.src_commit_time = '2024-01-01 10:00')
|
|
482
|
+
# Batch 2 (arrives late): src_commit_time = '2024-01-01 08:00' → BLOCKED because s.src_commit_time < t.src_commit_time
|
|
483
|
+
# Batch 3 (arrives later): src_commit_time = '2024-01-01 12:00' → ALLOWED because s.src_commit_time >= t.src_commit_time
|
|
484
|
+
# New key (no target row): t.src_commit_time IS NULL → ALLOWED (first-time insert)
|
|
485
|
+
freshness_condition = """
|
|
486
|
+
(
|
|
487
|
+
t.src_commit_time IS NULL
|
|
488
|
+
OR (s.src_commit_time IS NOT NULL AND s.src_commit_time >= t.src_commit_time)
|
|
489
|
+
)
|
|
490
|
+
"""
|
|
491
|
+
|
|
492
|
+
# Apply freshness to matched delete so stale deletes cannot remove newer target rows.
|
|
493
|
+
matched_delete_condition = f"({delete_condition}) AND ({freshness_condition})"
|
|
494
|
+
|
|
495
|
+
# Apply freshness to matched update so older records do not overwrite newer silver state.
|
|
496
|
+
matched_update_condition = f"({update_insert_condition}) AND ({freshness_condition})"
|
|
497
|
+
|
|
498
|
+
print(f"[SilverMerge] Executing merge for batch {batchId} into {self.tgtDatabaseName}.{self.tgtTableName}...")
|
|
324
499
|
tgtDeltaTable.alias("t").merge(microBatchOutputDF.alias("s"), self.condition) \
|
|
325
|
-
.whenMatchedDelete(
|
|
326
|
-
.whenMatchedUpdate(condition=
|
|
327
|
-
.whenNotMatchedInsert(condition=
|
|
500
|
+
.whenMatchedDelete(matched_delete_condition) \
|
|
501
|
+
.whenMatchedUpdate(condition=matched_update_condition, set=self.columnsDict) \
|
|
502
|
+
.whenNotMatchedInsert(condition=update_insert_condition, values=self.columnsDict) \
|
|
328
503
|
.execute()
|
|
504
|
+
print(f"[SilverMerge] Merge for batch {batchId} complete.")
|
|
329
505
|
|
|
330
506
|
def streamIntoDeltaTarget(self):
|
|
331
507
|
"""
|
|
@@ -350,6 +526,7 @@ class SilverMerge:
|
|
|
350
526
|
self.prepareTarget(silverCleansedDF)
|
|
351
527
|
|
|
352
528
|
logger.info(f"phida_log: performing streaming merge on target {self.tgtDatabaseName}.{self.tgtTableName}")
|
|
529
|
+
print(f"[SilverMerge] Starting streaming merge: {self.srcDatabaseName}.{self.srcTableName} -> {self.tgtDatabaseName}.{self.tgtTableName}")
|
|
353
530
|
|
|
354
531
|
if self.triggerOnce == "Y":
|
|
355
532
|
outputDF = (silverCleansedDF.writeStream
|
|
@@ -373,4 +550,4 @@ class SilverMerge:
|
|
|
373
550
|
.start(self.tgtTablePath)
|
|
374
551
|
)
|
|
375
552
|
|
|
376
|
-
return outputDF
|
|
553
|
+
return outputDF
|
{silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/WorkflowBuilder.py
RENAMED
|
@@ -72,7 +72,7 @@ class WorkflowBuilder:
|
|
|
72
72
|
@staticmethod
|
|
73
73
|
def createTask(taskKey, jobClusterKey, notebookPath, srcDatabaseName, srcTableName, tgtDatabaseName, tgtTableName,
|
|
74
74
|
tgtCheckpoint, tgtTablePath, tgtPartitionColumns, derivedColExpr, triggerOnce, dropColumnStr,
|
|
75
|
-
pruneColumn, cluster_table_path, enable_delete_filter, enable_autoloader, modified_after, backfill_interval, library):
|
|
75
|
+
pruneColumn, cluster_table_path, enable_delete_filter, enable_autoloader, modified_after, backfill_interval, is_dsp_kernel, src_dsp_path, library):
|
|
76
76
|
"""
|
|
77
77
|
desc:
|
|
78
78
|
A method for creating the dict/json for a single task
|
|
@@ -141,7 +141,9 @@ class WorkflowBuilder:
|
|
|
141
141
|
"enable_delete_filter": str(enable_delete_filter),
|
|
142
142
|
"enable_autoloader": enable_autoloader,
|
|
143
143
|
"modified_after": modified_after,
|
|
144
|
-
"backfill_interval": backfill_interval
|
|
144
|
+
"backfill_interval": backfill_interval,
|
|
145
|
+
"is_dsp_kernel": is_dsp_kernel,
|
|
146
|
+
"src_dsp_path": src_dsp_path
|
|
145
147
|
}
|
|
146
148
|
},
|
|
147
149
|
"timeout_seconds": timeout_seconds,
|
|
@@ -360,11 +362,19 @@ class WorkflowBuilder:
|
|
|
360
362
|
backfill_interval = task['backfill_interval']
|
|
361
363
|
except:
|
|
362
364
|
backfill_interval = ""
|
|
365
|
+
try:
|
|
366
|
+
is_dsp_kernel = task['is_dsp_kernel']
|
|
367
|
+
except:
|
|
368
|
+
is_dsp_kernel = ""
|
|
369
|
+
try:
|
|
370
|
+
src_dsp_path = task['src_dsp_path']
|
|
371
|
+
except:
|
|
372
|
+
src_dsp_path = ""
|
|
363
373
|
|
|
364
374
|
taskTemp = self.createTask(taskKey, jobClusterKey, notebookPath, srcDatabaseName, srcTableName,
|
|
365
375
|
tgtDatabaseName, tgtTableName, tgtCheckpoint, tgtTablePath,
|
|
366
376
|
tgtPartitionColumns, derivedColExpr, triggerOnce, dropColumnStr,
|
|
367
|
-
pruneColumn, cluster_table_path, enable_delete_filter, enable_autoloader, modified_after, backfill_interval, self.library)
|
|
377
|
+
pruneColumn, cluster_table_path, enable_delete_filter, enable_autoloader, modified_after, backfill_interval, is_dsp_kernel, src_dsp_path, self.library)
|
|
368
378
|
|
|
369
379
|
tasks.append(taskTemp)
|
|
370
380
|
if jobId is None:
|
{silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/Maintenance.py
RENAMED
|
File without changes
|
|
File without changes
|
{silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/PerfAnalyzer.py
RENAMED
|
File without changes
|
{silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/sparksession.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/tests/OperationsTest.py
RENAMED
|
File without changes
|
{silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/tests/__init__.py
RENAMED
|
File without changes
|
{silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/tests/main_test.py
RENAMED
|
File without changes
|
{silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/tests/utils_test.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|