SilverStreamingApplication 1.5__tar.gz → 1.7__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.5 → silverstreamingapplication-1.7}/PKG-INFO +1 -1
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/Operations.py +165 -7
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/SilverMerge.py +177 -40
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/WorkflowBuilder.py +15 -3
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/setup.py +1 -1
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/silverstreamingapplication.egg-info/PKG-INFO +1 -1
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/Maintenance.py +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/PartitionAnalyzer.py +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/PerfAnalyzer.py +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/__init__.py +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/logging.py +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/sparksession.py +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/utils.py +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/utilsDatabricksAPI.py +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/tests/OperationsTest.py +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/tests/__init__.py +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/tests/main_test.py +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/tests/utils_test.py +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/setup.cfg +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/silverstreamingapplication.egg-info/SOURCES.txt +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/silverstreamingapplication.egg-info/dependency_links.txt +0 -0
- {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/silverstreamingapplication.egg-info/top_level.txt +0 -0
{silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/Operations.py
RENAMED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
from pyspark.sql.utils import AnalysisException
|
|
2
2
|
from com.phida.main.sparksession import spark
|
|
3
3
|
from pyspark.sql.functions import col
|
|
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)
|
|
4
9
|
|
|
5
10
|
|
|
6
11
|
def tableExists(dbName, tblName):
|
|
@@ -48,17 +53,34 @@ def getKeyCols(dbName, tblName):
|
|
|
48
53
|
getKeyCols("<database name>", "vbuk")
|
|
49
54
|
|
|
50
55
|
tip:
|
|
51
|
-
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"
|
|
52
57
|
"""
|
|
58
|
+
|
|
53
59
|
try:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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")
|
|
57
80
|
.limit(1)
|
|
58
81
|
.collect()[0][0]
|
|
59
|
-
|
|
82
|
+
)
|
|
60
83
|
return keyCols
|
|
61
|
-
|
|
62
84
|
except Exception:
|
|
63
85
|
raise Exception(
|
|
64
86
|
f"the column src_key_cols is not present in the given table {dbName}.{tblName}")
|
|
@@ -92,6 +114,89 @@ def hiveDDL(df):
|
|
|
92
114
|
|
|
93
115
|
return tblDDL
|
|
94
116
|
|
|
117
|
+
def describe_table_to_schema(desc_rows):
|
|
118
|
+
"""
|
|
119
|
+
Convert table description rows into a Spark StructType schema.
|
|
120
|
+
This function parses the output from a DESCRIBE TABLE command and organizes
|
|
121
|
+
the columns into a StructType schema, separating regular data fields from
|
|
122
|
+
partition fields.
|
|
123
|
+
Args:
|
|
124
|
+
desc_rows: An iterable of row objects containing table metadata.
|
|
125
|
+
Each row should have 'col_name' and 'data_type' attributes.
|
|
126
|
+
Returns:
|
|
127
|
+
StructType: A Spark StructType object representing the table schema,
|
|
128
|
+
with data fields listed first, followed by partition fields.
|
|
129
|
+
Example:
|
|
130
|
+
>>> desc_rows = [
|
|
131
|
+
... Row(col_name='id', data_type='bigint'),
|
|
132
|
+
... Row(col_name='name', data_type='string'),
|
|
133
|
+
... Row(col_name='# Partition Information', data_type=''),
|
|
134
|
+
... Row(col_name='date', data_type='date')
|
|
135
|
+
... ]
|
|
136
|
+
>>> schema = describe_table_to_schema(desc_rows)
|
|
137
|
+
"""
|
|
138
|
+
data_fields = []
|
|
139
|
+
partition_fields = []
|
|
140
|
+
|
|
141
|
+
in_partition_section = False
|
|
142
|
+
|
|
143
|
+
for r in desc_rows:
|
|
144
|
+
col = r.col_name
|
|
145
|
+
dtype = r.data_type
|
|
146
|
+
|
|
147
|
+
# Enter partition section
|
|
148
|
+
if col.startswith("# Partition Information"):
|
|
149
|
+
in_partition_section = True
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
# Skip header rows
|
|
153
|
+
if col.startswith("#"):
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
# Skip empty rows (defensive)
|
|
157
|
+
if not col or not dtype:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
field = StructField(
|
|
161
|
+
col,
|
|
162
|
+
_parse_datatype(dtype),
|
|
163
|
+
True
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if in_partition_section:
|
|
167
|
+
partition_fields.append(field)
|
|
168
|
+
else:
|
|
169
|
+
data_fields.append(field)
|
|
170
|
+
|
|
171
|
+
return StructType(data_fields + partition_fields)
|
|
172
|
+
|
|
173
|
+
def _parse_datatype(dt):
|
|
174
|
+
"""
|
|
175
|
+
Parse a data type string and return the corresponding Spark data type.
|
|
176
|
+
|
|
177
|
+
This function serves as a wrapper that leverages Spark's internal parsing
|
|
178
|
+
mechanism to correctly handle complex data types including nested structures,
|
|
179
|
+
arrays, maps, and other composite types.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
dt (str): A string representation of a Spark data type
|
|
183
|
+
(e.g., 'IntegerType', 'StructType', 'ArrayType(StringType)')
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
pyspark.sql.types.DataType: The parsed Spark data type object corresponding
|
|
187
|
+
to the input string.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
ParseException: If the data type string is invalid or cannot be parsed.
|
|
191
|
+
|
|
192
|
+
Examples:
|
|
193
|
+
>>> _parse_datatype('IntegerType')
|
|
194
|
+
IntegerType
|
|
195
|
+
>>> _parse_datatype('ArrayType(StringType)')
|
|
196
|
+
ArrayType(StringType, containsNull=true)
|
|
197
|
+
"""
|
|
198
|
+
# Let Spark parse complex types correctly
|
|
199
|
+
return _parse_datatype_string(dt)
|
|
95
200
|
|
|
96
201
|
def hasColumn(df, columnName):
|
|
97
202
|
"""
|
|
@@ -186,6 +291,13 @@ def addDerivedColumns(df, colExprList):
|
|
|
186
291
|
except Exception:
|
|
187
292
|
raise Exception("The given column expression is invalid")
|
|
188
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
|
+
|
|
189
301
|
columnList.append(colExpr)
|
|
190
302
|
|
|
191
303
|
dfOut = df.selectExpr(columnList)
|
|
@@ -299,9 +411,10 @@ def schemaDiff(df1, df2):
|
|
|
299
411
|
1. Make sure that the first dataframe has got more columns than the second
|
|
300
412
|
"""
|
|
301
413
|
df1_columns = df1.columns
|
|
414
|
+
df2_columns_lower = [col.lower() for col in df2.columns]
|
|
302
415
|
|
|
303
416
|
for columnName in df1.columns:
|
|
304
|
-
if columnName in
|
|
417
|
+
if columnName.lower() in df2_columns_lower:
|
|
305
418
|
df1_columns.remove(columnName)
|
|
306
419
|
|
|
307
420
|
df = df1.select(df1_columns)
|
|
@@ -418,3 +531,48 @@ def buildJoinCondition(keyColsList):
|
|
|
418
531
|
condition = condition + "t.`" + column + "` <=> " + "s.`" + column + "` AND "
|
|
419
532
|
|
|
420
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.5 → silverstreamingapplication-1.7}/com/phida/main/SilverMerge.py
RENAMED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
from datetime import date, timedelta
|
|
1
2
|
from com.phida.main.sparksession import spark, logger
|
|
2
|
-
from com.phida.main.Operations import tableExists, addDerivedColumns, createDeltaTable, alterDeltaTable, \
|
|
3
|
-
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
|
|
4
5
|
from com.phida.main.utils import pathExists, convertStrToList
|
|
5
|
-
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
|
|
6
7
|
from pyspark.sql.window import Window
|
|
7
8
|
from delta.tables import DeltaTable
|
|
8
9
|
from pyspark.sql import DataFrame
|
|
@@ -27,8 +28,11 @@ class SilverMerge:
|
|
|
27
28
|
cluster_table_path: String - Path to the cluster table
|
|
28
29
|
enable_delete_filter: String - Flag to enable/disable filtering of deleted records (default: 'N')
|
|
29
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')
|
|
30
33
|
|
|
31
34
|
methods:
|
|
35
|
+
move_dsp_to_bronze
|
|
32
36
|
silverCleansing
|
|
33
37
|
prepareTarget
|
|
34
38
|
upsertToDelta
|
|
@@ -38,12 +42,12 @@ class SilverMerge:
|
|
|
38
42
|
from com.phida.SilverMerge import SilverMerge
|
|
39
43
|
silverMergeObj = SilverMerge(srcDatabaseName, srcTableName, tgtDatabaseName, tgtTableName,
|
|
40
44
|
tgtCheckpoint, tgtTablePath, tgtPartitionColumns, derivedColExpr,
|
|
41
|
-
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)
|
|
42
46
|
|
|
43
47
|
"""
|
|
44
48
|
def __init__(self, srcDatabaseName, srcTableName, tgtDatabaseName, tgtTableName,
|
|
45
49
|
tgtCheckpoint, tgtTablePath, tgtPartitionColumns, derivedColExpr,
|
|
46
|
-
triggerOnce, dropColumnStr, pruneColumn, cluster_table_path, enable_delete_filter, use_auto_loader):
|
|
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):
|
|
47
51
|
"""
|
|
48
52
|
desc:
|
|
49
53
|
Initialize the required class variables
|
|
@@ -63,9 +67,13 @@ class SilverMerge:
|
|
|
63
67
|
cluster_table_path: String - Path to the cluster table
|
|
64
68
|
enable_delete_filter: String - Flag to enable/disable filtering of deleted records (default: 'N')
|
|
65
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')
|
|
66
72
|
"""
|
|
67
73
|
|
|
68
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}")
|
|
69
77
|
|
|
70
78
|
self.srcDatabaseName = srcDatabaseName
|
|
71
79
|
self.srcTableName = srcTableName
|
|
@@ -80,34 +88,65 @@ class SilverMerge:
|
|
|
80
88
|
self.pruneColumn = pruneColumn
|
|
81
89
|
self.cluster_table_path = cluster_table_path
|
|
82
90
|
self.enable_delete_filter = enable_delete_filter.strip().upper() if enable_delete_filter else 'N'
|
|
83
|
-
self.use_auto_loader = use_auto_loader.strip().upper() if use_auto_loader and use_auto_loader.strip().upper()
|
|
91
|
+
self.use_auto_loader = use_auto_loader.strip().upper() if use_auto_loader and use_auto_loader.strip().upper() in ('Y') else 'N'
|
|
92
|
+
self.modified_after = modified_after.strip() if modified_after else (date.today() - timedelta(days=15)).strftime('%Y-%m-%d')
|
|
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)
|
|
84
103
|
|
|
85
104
|
logger.info(f"phida_log: Check if source table {self.srcDatabaseName}.{self.srcTableName} exists")
|
|
86
105
|
|
|
87
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.")
|
|
88
109
|
|
|
89
110
|
logger.info(f"phida_log: source table exists")
|
|
90
111
|
|
|
91
112
|
logger.info(f"phida_log: initialising derived class variables")
|
|
92
113
|
|
|
93
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...")
|
|
94
117
|
self.srcDF = spark.readStream.table(self.srcDatabaseName + "." + self.srcTableName)
|
|
118
|
+
logger.info("Bronze Structure extracted.")
|
|
119
|
+
print("[SilverMerge] Bronze structure extracted.")
|
|
95
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...")
|
|
123
|
+
desc = spark.sql(f"DESCRIBE TABLE {self.srcDatabaseName}.{self.srcTableName}").collect()
|
|
124
|
+
schema = describe_table_to_schema(desc)
|
|
96
125
|
src_table_path = spark.sql(f"DESCRIBE DETAIL {self.srcDatabaseName}.{self.srcTableName}").collect()[0]['location']
|
|
97
|
-
self.srcDF = spark.readStream.format("cloudFiles").option("cloudFiles.format", "parquet").option("cloudFiles.schemaLocation", src_table_path + "/_cloudfiles/_schemas").load(src_table_path).drop("_rescued_data")
|
|
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.")
|
|
98
129
|
|
|
130
|
+
logger.info("Determining key columns for merge...")
|
|
131
|
+
print("[SilverMerge] Determining key columns for merge...")
|
|
99
132
|
self.keyCols = getKeyCols(self.srcDatabaseName, self.srcTableName)
|
|
100
133
|
|
|
101
134
|
self.keyColsList = convertStrToList(self.keyCols, ",")
|
|
102
|
-
|
|
135
|
+
logger.info(f"Key columns for merge: {self.keyColsList}")
|
|
136
|
+
print(f"[SilverMerge] Key columns: {self.keyColsList}")
|
|
137
|
+
|
|
103
138
|
self.joinCondition = buildJoinCondition(self.keyColsList)
|
|
139
|
+
logger.info(f"Join condition for merge: {self.joinCondition}")
|
|
104
140
|
|
|
105
141
|
self.condition = f"{self.pruneColumn} <> '' AND {self.joinCondition}" if self.pruneColumn.strip() \
|
|
106
142
|
else self.joinCondition
|
|
143
|
+
logger.info(f"Final merge condition: {self.condition}")
|
|
107
144
|
|
|
108
145
|
self.dropColumnList = convertStrToList(self.dropColumnStr, ",")
|
|
109
146
|
|
|
110
147
|
self.columnsDict = buildColumnsDict(self.srcDF, self.dropColumnList)
|
|
148
|
+
logger.info("Initialization of SilverMerge complete.")
|
|
149
|
+
print("[SilverMerge] Initialization complete.")
|
|
111
150
|
|
|
112
151
|
def silverCleansing(self):
|
|
113
152
|
"""
|
|
@@ -127,21 +166,56 @@ class SilverMerge:
|
|
|
127
166
|
N/A
|
|
128
167
|
"""
|
|
129
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
|
+
|
|
130
172
|
|
|
131
173
|
if tableExists(self.srcDatabaseName, self.srcTableName):
|
|
132
174
|
|
|
133
175
|
silverRawDF = self.srcDF
|
|
176
|
+
logger.info("Bronze DataFrame loaded for cleansing.")
|
|
134
177
|
|
|
135
178
|
if self.derivedColExpr:
|
|
179
|
+
logger.info("Adding derived columns to DataFrame...")
|
|
136
180
|
derivedColExprList = convertStrToList(self.derivedColExpr, "§")
|
|
137
|
-
|
|
138
181
|
silverDerivedColumns = addDerivedColumns(silverRawDF, derivedColExprList)
|
|
139
|
-
|
|
182
|
+
logger.info("Derived columns added.")
|
|
140
183
|
else:
|
|
141
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")
|
|
142
209
|
|
|
143
|
-
|
|
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.")
|
|
144
215
|
|
|
216
|
+
self.columnsDict = buildColumnsDict(silverDerivedColumns, self.dropColumnList)
|
|
217
|
+
logger.info("Cleansing transformations complete.")
|
|
218
|
+
print("[SilverMerge] Cleansing transformations complete.")
|
|
145
219
|
return silverDerivedColumns
|
|
146
220
|
|
|
147
221
|
def prepareTarget(self, inDF):
|
|
@@ -166,6 +240,7 @@ class SilverMerge:
|
|
|
166
240
|
N/A
|
|
167
241
|
"""
|
|
168
242
|
logger.info(f"phida_log: preparing the target delta table ")
|
|
243
|
+
print(f"[SilverMerge] Preparing target table {self.tgtDatabaseName}.{self.tgtTableName}...")
|
|
169
244
|
|
|
170
245
|
targetTableExists = tableExists(self.tgtDatabaseName, self.tgtTableName)
|
|
171
246
|
|
|
@@ -179,6 +254,7 @@ class SilverMerge:
|
|
|
179
254
|
|
|
180
255
|
logger.info(f"phida_log: This seems to be the first run")
|
|
181
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}...")
|
|
182
258
|
|
|
183
259
|
createDeltaTable(inDF,
|
|
184
260
|
self.tgtTablePath,
|
|
@@ -207,6 +283,7 @@ class SilverMerge:
|
|
|
207
283
|
if addColumns:
|
|
208
284
|
logger.info(f"phida_log: There seems to be a schema change in silver")
|
|
209
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}")
|
|
210
287
|
|
|
211
288
|
alterDeltaTable(self.tgtDatabaseName, self.tgtTableName, addColumns)
|
|
212
289
|
|
|
@@ -214,37 +291,38 @@ class SilverMerge:
|
|
|
214
291
|
|
|
215
292
|
else:
|
|
216
293
|
logger.info(f"phida_log: There is no change in schema in silver")
|
|
294
|
+
print("[SilverMerge] No schema changes detected.")
|
|
217
295
|
|
|
218
296
|
def filter_deleted_records(self, microBatchOutputDF):
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
+
"""
|
|
223
301
|
|
|
224
|
-
|
|
225
|
-
|
|
302
|
+
if microBatchOutputDF.isEmpty():
|
|
303
|
+
return microBatchOutputDF
|
|
226
304
|
|
|
227
|
-
|
|
305
|
+
filtered_table_name = f"{self.srcDatabaseName}.{self.srcTableName}_filtered"
|
|
228
306
|
|
|
229
|
-
|
|
230
|
-
|
|
307
|
+
if not tableExists(self.srcDatabaseName, f"{self.srcTableName}_filtered"):
|
|
308
|
+
return microBatchOutputDF
|
|
231
309
|
|
|
232
|
-
|
|
310
|
+
delete_records = spark.read.table(filtered_table_name).filter(col("source_operation") == 0)
|
|
233
311
|
|
|
234
|
-
|
|
235
|
-
|
|
312
|
+
if delete_records.isEmpty():
|
|
313
|
+
return microBatchOutputDF
|
|
236
314
|
|
|
237
|
-
|
|
315
|
+
delete_keys_df = (delete_records.groupBy(self.keyColsList).agg(spark_max(col("hvr_integ_key")).alias("delete_hvr_integ_key")))
|
|
238
316
|
|
|
239
|
-
|
|
317
|
+
joined_df = microBatchOutputDF.join(broadcast(delete_keys_df), on=self.keyColsList, how="left")
|
|
240
318
|
|
|
241
|
-
|
|
319
|
+
filtered_df = (joined_df.filter(
|
|
242
320
|
(col("delete_hvr_integ_key").isNull()) |
|
|
243
321
|
(col("hvr_integ_key") >= col("delete_hvr_integ_key"))
|
|
244
322
|
)
|
|
245
323
|
.drop("delete_hvr_integ_key"))
|
|
246
324
|
|
|
247
|
-
|
|
325
|
+
return filtered_df
|
|
248
326
|
|
|
249
327
|
def preprocess_dataframe(self, df: DataFrame, parent_keys: list) -> DataFrame:
|
|
250
328
|
|
|
@@ -270,8 +348,6 @@ class SilverMerge:
|
|
|
270
348
|
unpack_keys = set(row["key_columns_for_unpack_table"].split(',')) if row["key_columns_for_unpack_table"] else set()
|
|
271
349
|
parent_keys = list(pack_keys & unpack_keys)
|
|
272
350
|
return parent_keys
|
|
273
|
-
|
|
274
|
-
|
|
275
351
|
|
|
276
352
|
def upsertToDelta(self, microBatchOutputDF, batchId):
|
|
277
353
|
"""
|
|
@@ -291,11 +367,28 @@ class SilverMerge:
|
|
|
291
367
|
tip:
|
|
292
368
|
N/A
|
|
293
369
|
"""
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
+
|
|
297
390
|
tgtDeltaTable = DeltaTable.forName(spark, self.tgtDatabaseName + "." + self.tgtTableName)
|
|
298
|
-
|
|
391
|
+
|
|
299
392
|
if self.cluster_table_path:
|
|
300
393
|
if not op_8_df.isEmpty():
|
|
301
394
|
table_details = DeltaTable.forName(spark, self.cluster_table_path)
|
|
@@ -306,9 +399,36 @@ class SilverMerge:
|
|
|
306
399
|
tgtDeltaTable.alias("t").merge(microBatchOutputDF_8.alias("s"), parent_key_conditions) \
|
|
307
400
|
.whenMatchedDelete() \
|
|
308
401
|
.execute()
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
+
# 1) HVR-only (prod behavior): latest by hvr_integ_key
|
|
412
|
+
# 2) DSP-only: latest by src_commit_time
|
|
413
|
+
# 3) Mixed HVR + DSP: DSP rows win for same PK, then latest timestamp
|
|
414
|
+
batch_cols = set(microBatchOutputDF.columns)
|
|
415
|
+
has_hvr_key = "hvr_integ_key" in batch_cols
|
|
416
|
+
has_dsp_op = "__operation_type" in batch_cols
|
|
417
|
+
|
|
418
|
+
if has_hvr_key and not has_dsp_op:
|
|
419
|
+
# HVR-only: keep existing prod behavior
|
|
420
|
+
windowSpec = Window.partitionBy(self.keyColsList).orderBy(col("hvr_integ_key").desc())
|
|
421
|
+
elif has_hvr_key and has_dsp_op:
|
|
422
|
+
# Mixed mode: prioritize DSP records, then newest timestamp
|
|
423
|
+
is_dsp_record = col("hvr_integ_key").isNull()
|
|
424
|
+
windowSpec = Window.partitionBy(self.keyColsList).orderBy(
|
|
425
|
+
is_dsp_record.desc(),
|
|
426
|
+
col("src_commit_time").desc(), #__timestamp is casted to src_commit_time in silverCleansing()
|
|
427
|
+
col("hvr_integ_key").desc()
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
# DSP-only (or no hvr_integ_key): latest timestamp wins
|
|
431
|
+
windowSpec = Window.partitionBy(self.keyColsList).orderBy(col("src_commit_time").desc()) #__timestamp is casted to src_commit_time in silverCleansing()
|
|
312
432
|
|
|
313
433
|
microBatchOutputDF = microBatchOutputDF.withColumn("latest_record", row_number().over(windowSpec)).filter(
|
|
314
434
|
"latest_record == 1").drop("latest_record")
|
|
@@ -316,11 +436,27 @@ class SilverMerge:
|
|
|
316
436
|
if self.enable_delete_filter == 'Y':
|
|
317
437
|
microBatchOutputDF = self.filter_deleted_records(microBatchOutputDF)
|
|
318
438
|
|
|
439
|
+
# DSP_Change: Build merge conditions only from columns that actually exist in the batch.
|
|
440
|
+
# Referencing s.__operation_type when the source is HVR-only causes DELTA_MERGE_UNRESOLVED_EXPRESSION.
|
|
441
|
+
delete_parts = []
|
|
442
|
+
if has_source_operation:
|
|
443
|
+
delete_parts.append("s.source_operation in (0,3)")
|
|
444
|
+
if has_operation_type:
|
|
445
|
+
delete_parts.append("s.__operation_type in ('X')")
|
|
446
|
+
|
|
447
|
+
if not delete_parts:
|
|
448
|
+
raise Exception("No operation column found in source batch")
|
|
449
|
+
|
|
450
|
+
delete_condition = " OR ".join([f"({p})" for p in delete_parts])
|
|
451
|
+
update_insert_condition = f"NOT ({delete_condition})"
|
|
452
|
+
|
|
453
|
+
print(f"[SilverMerge] Executing merge for batch {batchId} into {self.tgtDatabaseName}.{self.tgtTableName}...")
|
|
319
454
|
tgtDeltaTable.alias("t").merge(microBatchOutputDF.alias("s"), self.condition) \
|
|
320
|
-
.whenMatchedDelete(
|
|
321
|
-
.whenMatchedUpdate(condition=
|
|
322
|
-
.whenNotMatchedInsert(condition=
|
|
455
|
+
.whenMatchedDelete(delete_condition) \
|
|
456
|
+
.whenMatchedUpdate(condition=update_insert_condition, set=self.columnsDict) \
|
|
457
|
+
.whenNotMatchedInsert(condition=update_insert_condition, values=self.columnsDict) \
|
|
323
458
|
.execute()
|
|
459
|
+
print(f"[SilverMerge] Merge for batch {batchId} complete.")
|
|
324
460
|
|
|
325
461
|
def streamIntoDeltaTarget(self):
|
|
326
462
|
"""
|
|
@@ -345,6 +481,7 @@ class SilverMerge:
|
|
|
345
481
|
self.prepareTarget(silverCleansedDF)
|
|
346
482
|
|
|
347
483
|
logger.info(f"phida_log: performing streaming merge on target {self.tgtDatabaseName}.{self.tgtTableName}")
|
|
484
|
+
print(f"[SilverMerge] Starting streaming merge: {self.srcDatabaseName}.{self.srcTableName} -> {self.tgtDatabaseName}.{self.tgtTableName}")
|
|
348
485
|
|
|
349
486
|
if self.triggerOnce == "Y":
|
|
350
487
|
outputDF = (silverCleansedDF.writeStream
|
|
@@ -368,4 +505,4 @@ class SilverMerge:
|
|
|
368
505
|
.start(self.tgtTablePath)
|
|
369
506
|
)
|
|
370
507
|
|
|
371
|
-
return outputDF
|
|
508
|
+
return outputDF
|
{silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/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, library):
|
|
75
|
+
pruneColumn, cluster_table_path, enable_delete_filter, enable_autoloader, modified_after, backfill_interval, library):
|
|
76
76
|
"""
|
|
77
77
|
desc:
|
|
78
78
|
A method for creating the dict/json for a single task
|
|
@@ -96,6 +96,8 @@ class WorkflowBuilder:
|
|
|
96
96
|
cluster_table_path: String - The path of the table in the cluster
|
|
97
97
|
enable_delete_filter: String - Flag to enable/disable filtering of deleted records (default: 'N')
|
|
98
98
|
enable_autoloader: String - Flag to enable/disable autoloader (default: 'N')
|
|
99
|
+
modified_after: String - The modified after parameter for autoloader to read only the files modified after the specified timestamp (optional, format: "2026-01-01", "2025-06-01" etc.)
|
|
100
|
+
backfill_interval: String - The backfill interval for autoloader to read the historical data by looking back a certain interval from the current timestamp (optional, format: "1 month", "10 months", "15 days", "12 hours" etc.)
|
|
99
101
|
pythonWhlPath: String - Path of the python wheel file
|
|
100
102
|
library: String - Library to be installed for the job (with version number)
|
|
101
103
|
|
|
@@ -137,7 +139,9 @@ class WorkflowBuilder:
|
|
|
137
139
|
"drop_column_list": dropColumnStr,
|
|
138
140
|
"cluster_table_path": cluster_table_path,
|
|
139
141
|
"enable_delete_filter": str(enable_delete_filter),
|
|
140
|
-
"enable_autoloader": enable_autoloader
|
|
142
|
+
"enable_autoloader": enable_autoloader,
|
|
143
|
+
"modified_after": modified_after,
|
|
144
|
+
"backfill_interval": backfill_interval
|
|
141
145
|
}
|
|
142
146
|
},
|
|
143
147
|
"timeout_seconds": timeout_seconds,
|
|
@@ -348,11 +352,19 @@ class WorkflowBuilder:
|
|
|
348
352
|
enable_autoloader = task['enable_autoloader']
|
|
349
353
|
except:
|
|
350
354
|
enable_autoloader = ""
|
|
355
|
+
try:
|
|
356
|
+
modified_after = task['modified_after']
|
|
357
|
+
except:
|
|
358
|
+
modified_after = ""
|
|
359
|
+
try:
|
|
360
|
+
backfill_interval = task['backfill_interval']
|
|
361
|
+
except:
|
|
362
|
+
backfill_interval = ""
|
|
351
363
|
|
|
352
364
|
taskTemp = self.createTask(taskKey, jobClusterKey, notebookPath, srcDatabaseName, srcTableName,
|
|
353
365
|
tgtDatabaseName, tgtTableName, tgtCheckpoint, tgtTablePath,
|
|
354
366
|
tgtPartitionColumns, derivedColExpr, triggerOnce, dropColumnStr,
|
|
355
|
-
pruneColumn, cluster_table_path, enable_delete_filter, enable_autoloader, self.library)
|
|
367
|
+
pruneColumn, cluster_table_path, enable_delete_filter, enable_autoloader, modified_after, backfill_interval, self.library)
|
|
356
368
|
|
|
357
369
|
tasks.append(taskTemp)
|
|
358
370
|
if jobId is None:
|
{silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/Maintenance.py
RENAMED
|
File without changes
|
|
File without changes
|
{silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/PerfAnalyzer.py
RENAMED
|
File without changes
|
{silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/sparksession.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/tests/OperationsTest.py
RENAMED
|
File without changes
|
{silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/tests/__init__.py
RENAMED
|
File without changes
|
{silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/tests/main_test.py
RENAMED
|
File without changes
|
{silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/tests/utils_test.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|