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.
Files changed (22) hide show
  1. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/PKG-INFO +1 -1
  2. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/Operations.py +79 -6
  3. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/SilverMerge.py +215 -38
  4. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/WorkflowBuilder.py +13 -3
  5. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/setup.py +1 -1
  6. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/silverstreamingapplication.egg-info/PKG-INFO +1 -1
  7. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/Maintenance.py +0 -0
  8. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/PartitionAnalyzer.py +0 -0
  9. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/PerfAnalyzer.py +0 -0
  10. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/__init__.py +0 -0
  11. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/logging.py +0 -0
  12. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/sparksession.py +0 -0
  13. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/utils.py +0 -0
  14. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/main/utilsDatabricksAPI.py +0 -0
  15. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/tests/OperationsTest.py +0 -0
  16. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/tests/__init__.py +0 -0
  17. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/tests/main_test.py +0 -0
  18. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/com/phida/tests/utils_test.py +0 -0
  19. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/setup.cfg +0 -0
  20. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/silverstreamingapplication.egg-info/SOURCES.txt +0 -0
  21. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/silverstreamingapplication.egg-info/dependency_links.txt +0 -0
  22. {silverstreamingapplication-1.6 → silverstreamingapplication-1.8}/silverstreamingapplication.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: silverstreamingapplication
3
- Version: 1.6
3
+ Version: 1.8
4
4
  Summary: initial version
5
5
  Home-page:
6
6
  Author:
@@ -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 "src_commit_time"
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
- keyCols = (spark.read.table(dbName + "." + tblName)
57
- .select("src_key_cols", "src_commit_time")
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")
@@ -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
- self.columnsDict = buildColumnsDict(silverDerivedColumns, self.dropColumnList)
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
- Filters out records from microBatchOutputDF that have been marked as deleted in the Bronze table
226
- within the last 4 hours based on hvr_integ_key.
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
- if microBatchOutputDF.isEmpty():
230
- return microBatchOutputDF
302
+ if microBatchOutputDF.isEmpty():
303
+ return microBatchOutputDF
231
304
 
232
- filtered_table_name = f"{self.srcDatabaseName}.{self.srcTableName}_filtered"
305
+ filtered_table_name = f"{self.srcDatabaseName}.{self.srcTableName}_filtered"
233
306
 
234
- if not tableExists(self.srcDatabaseName, f"{self.srcTableName}_filtered"):
235
- return microBatchOutputDF
307
+ if not tableExists(self.srcDatabaseName, f"{self.srcTableName}_filtered"):
308
+ return microBatchOutputDF
236
309
 
237
- delete_records = spark.read.table(filtered_table_name).filter(col("source_operation") == 0)
310
+ delete_records = spark.read.table(filtered_table_name).filter(col("source_operation") == 0)
238
311
 
239
- if delete_records.isEmpty():
240
- return microBatchOutputDF
312
+ if delete_records.isEmpty():
313
+ return microBatchOutputDF
241
314
 
242
- delete_keys_df = (delete_records.groupBy(self.keyColsList).agg(spark_max(col("hvr_integ_key")).alias("delete_hvr_integ_key")))
315
+ delete_keys_df = (delete_records.groupBy(self.keyColsList).agg(spark_max(col("hvr_integ_key")).alias("delete_hvr_integ_key")))
243
316
 
244
- joined_df = microBatchOutputDF.join(broadcast(delete_keys_df), on=self.keyColsList, how="left")
317
+ joined_df = microBatchOutputDF.join(broadcast(delete_keys_df), on=self.keyColsList, how="left")
245
318
 
246
- filtered_df = (joined_df.filter(
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
- return filtered_df
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
- op_8_df = microBatchOutputDF.filter("source_operation = 8")
300
- microBatchOutputDF = microBatchOutputDF.filter("source_operation in (0,1,2,3)")
301
- microBatchOutputDF_8 = microBatchOutputDF.filter("source_operation in (0,1,2,3,8)")
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
- microBatchOutputDF = self.preprocess_dataframe(microBatchOutputDF_8, parent_keys)
315
-
316
- windowSpec = Window.partitionBy(self.keyColsList).orderBy(col("hvr_integ_key").desc())
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("s.source_operation in (0,3)") \
326
- .whenMatchedUpdate(condition="s.source_operation not in (0,3)", set=self.columnsDict) \
327
- .whenNotMatchedInsert(condition="s.source_operation not in (0,3)", values=self.columnsDict) \
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
@@ -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:
@@ -2,7 +2,7 @@ from setuptools import setup
2
2
 
3
3
  setup(
4
4
  name='silverstreamingapplication',
5
- version='1.6',
5
+ version='1.8',
6
6
  packages=['com.phida.main', 'com.phida.tests'],
7
7
  url='',
8
8
  license='',
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: silverstreamingapplication
3
- Version: 1.6
3
+ Version: 1.8
4
4
  Summary: initial version
5
5
  Home-page:
6
6
  Author: