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.
Files changed (22) hide show
  1. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/PKG-INFO +1 -1
  2. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/Operations.py +165 -7
  3. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/SilverMerge.py +177 -40
  4. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/WorkflowBuilder.py +15 -3
  5. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/setup.py +1 -1
  6. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/silverstreamingapplication.egg-info/PKG-INFO +1 -1
  7. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/Maintenance.py +0 -0
  8. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/PartitionAnalyzer.py +0 -0
  9. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/PerfAnalyzer.py +0 -0
  10. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/__init__.py +0 -0
  11. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/logging.py +0 -0
  12. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/sparksession.py +0 -0
  13. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/utils.py +0 -0
  14. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/main/utilsDatabricksAPI.py +0 -0
  15. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/tests/OperationsTest.py +0 -0
  16. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/tests/__init__.py +0 -0
  17. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/tests/main_test.py +0 -0
  18. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/com/phida/tests/utils_test.py +0 -0
  19. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/setup.cfg +0 -0
  20. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/silverstreamingapplication.egg-info/SOURCES.txt +0 -0
  21. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/silverstreamingapplication.egg-info/dependency_links.txt +0 -0
  22. {silverstreamingapplication-1.5 → silverstreamingapplication-1.7}/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.5
3
+ Version: 1.7
4
4
  Summary: initial version
5
5
  Home-page:
6
6
  Author:
@@ -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 "src_commit_time"
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
- keyCols = (spark.read.table(dbName + "." + tblName)
56
- .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")
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 df2.columns:
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")
@@ -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() == 'Y' else 'N'
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
- 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.")
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
- Filters out records from microBatchOutputDF that have been marked as deleted in the Bronze table
221
- within the last 4 hours based on hvr_integ_key.
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
- if microBatchOutputDF.isEmpty():
225
- return microBatchOutputDF
302
+ if microBatchOutputDF.isEmpty():
303
+ return microBatchOutputDF
226
304
 
227
- filtered_table_name = f"{self.srcDatabaseName}.{self.srcTableName}_filtered"
305
+ filtered_table_name = f"{self.srcDatabaseName}.{self.srcTableName}_filtered"
228
306
 
229
- if not tableExists(self.srcDatabaseName, f"{self.srcTableName}_filtered"):
230
- return microBatchOutputDF
307
+ if not tableExists(self.srcDatabaseName, f"{self.srcTableName}_filtered"):
308
+ return microBatchOutputDF
231
309
 
232
- 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)
233
311
 
234
- if delete_records.isEmpty():
235
- return microBatchOutputDF
312
+ if delete_records.isEmpty():
313
+ return microBatchOutputDF
236
314
 
237
- 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")))
238
316
 
239
- 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")
240
318
 
241
- filtered_df = (joined_df.filter(
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
- return filtered_df
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
- op_8_df = microBatchOutputDF.filter("source_operation = 8")
295
- microBatchOutputDF = microBatchOutputDF.filter("source_operation in (0,1,2,3)")
296
- 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
+
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
- microBatchOutputDF = self.preprocess_dataframe(microBatchOutputDF_8, parent_keys)
310
-
311
- 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
+ # 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("s.source_operation in (0,3)") \
321
- .whenMatchedUpdate(condition="s.source_operation not in (0,3)", set=self.columnsDict) \
322
- .whenNotMatchedInsert(condition="s.source_operation not in (0,3)", values=self.columnsDict) \
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
@@ -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:
@@ -2,7 +2,7 @@ from setuptools import setup
2
2
 
3
3
  setup(
4
4
  name='silverstreamingapplication',
5
- version='1.5',
5
+ version='1.7',
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.5
3
+ Version: 1.7
4
4
  Summary: initial version
5
5
  Home-page:
6
6
  Author: