SilverStreamingApplication 1.7__tar.gz → 1.9__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.7 → silverstreamingapplication-1.9}/PKG-INFO +1 -1
  2. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/main/Operations.py +4 -69
  3. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/main/SilverMerge.py +81 -47
  4. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/setup.py +1 -1
  5. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/silverstreamingapplication.egg-info/PKG-INFO +1 -1
  6. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/main/Maintenance.py +0 -0
  7. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/main/PartitionAnalyzer.py +0 -0
  8. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/main/PerfAnalyzer.py +0 -0
  9. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/main/WorkflowBuilder.py +0 -0
  10. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/main/__init__.py +0 -0
  11. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/main/logging.py +0 -0
  12. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/main/sparksession.py +0 -0
  13. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/main/utils.py +0 -0
  14. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/main/utilsDatabricksAPI.py +0 -0
  15. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/tests/OperationsTest.py +0 -0
  16. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/tests/__init__.py +0 -0
  17. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/tests/main_test.py +0 -0
  18. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/com/phida/tests/utils_test.py +0 -0
  19. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/setup.cfg +0 -0
  20. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/silverstreamingapplication.egg-info/SOURCES.txt +0 -0
  21. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/silverstreamingapplication.egg-info/dependency_links.txt +0 -0
  22. {silverstreamingapplication-1.7 → silverstreamingapplication-1.9}/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.7
3
+ Version: 1.9
4
4
  Summary: initial version
5
5
  Home-page:
6
6
  Author:
@@ -2,10 +2,6 @@ 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)
9
5
 
10
6
 
11
7
  def tableExists(dbName, tblName):
@@ -57,30 +53,14 @@ def getKeyCols(dbName, tblName):
57
53
  """
58
54
 
59
55
  try:
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
56
+ keyCols = (spark.read.table(dbName + "." + tblName)
79
57
  .select("src_key_cols")
80
58
  .limit(1)
81
59
  .collect()[0][0]
82
60
  )
83
61
  return keyCols
62
+
63
+
84
64
  except Exception:
85
65
  raise Exception(
86
66
  f"the column src_key_cols is not present in the given table {dbName}.{tblName}")
@@ -530,49 +510,4 @@ def buildJoinCondition(keyColsList):
530
510
  for column in keyColsList:
531
511
  condition = condition + "t.`" + column + "` <=> " + "s.`" + column + "` AND "
532
512
 
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")
513
+ return condition[:-5]
@@ -1,6 +1,6 @@
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, move_dsp_to_bronze, tableExists, addDerivedColumns, createDeltaTable, alterDeltaTable, \
3
+ from com.phida.main.Operations import describe_table_to_schema, tableExists, addDerivedColumns, createDeltaTable, alterDeltaTable, \
4
4
  dropColumns, getDerivedColumnsList, schemaDiff, getKeyCols, buildJoinCondition, buildColumnsDict, hiveDDL, schemaDataTypeDiff, hasColumn
5
5
  from com.phida.main.utils import pathExists, convertStrToList
6
6
  from pyspark.sql.functions import row_number, col, broadcast,expr, max as spark_max, lit, when, coalesce
@@ -28,11 +28,8 @@ 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')
33
31
 
34
32
  methods:
35
- move_dsp_to_bronze
36
33
  silverCleansing
37
34
  prepareTarget
38
35
  upsertToDelta
@@ -42,12 +39,12 @@ class SilverMerge:
42
39
  from com.phida.SilverMerge import SilverMerge
43
40
  silverMergeObj = SilverMerge(srcDatabaseName, srcTableName, tgtDatabaseName, tgtTableName,
44
41
  tgtCheckpoint, tgtTablePath, tgtPartitionColumns, derivedColExpr,
45
- triggerOnce, dropColumnStr, pruneColumn, cluster_table_path, enable_delete_filter, use_auto_loader, is_dsp_kernel, src_dsp_path)
42
+ triggerOnce, dropColumnStr, pruneColumn, cluster_table_path, enable_delete_filter, use_auto_loader)
46
43
 
47
44
  """
48
45
  def __init__(self, srcDatabaseName, srcTableName, tgtDatabaseName, tgtTableName,
49
46
  tgtCheckpoint, tgtTablePath, tgtPartitionColumns, derivedColExpr,
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
+ triggerOnce, dropColumnStr, pruneColumn, cluster_table_path, enable_delete_filter, use_auto_loader, modified_after=None, backfill_interval=None):
51
48
  """
52
49
  desc:
53
50
  Initialize the required class variables
@@ -67,8 +64,6 @@ class SilverMerge:
67
64
  cluster_table_path: String - Path to the cluster table
68
65
  enable_delete_filter: String - Flag to enable/disable filtering of deleted records (default: 'N')
69
66
  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')
72
67
  """
73
68
 
74
69
  logger.info("phida_log: Initialising class variables")
@@ -90,16 +85,7 @@ class SilverMerge:
90
85
  self.enable_delete_filter = enable_delete_filter.strip().upper() if enable_delete_filter else 'N'
91
86
  self.use_auto_loader = use_auto_loader.strip().upper() if use_auto_loader and use_auto_loader.strip().upper() in ('Y') else 'N'
92
87
  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)
88
+ self.backfill_interval = backfill_interval if backfill_interval else "1 week"
103
89
 
104
90
  logger.info(f"phida_log: Check if source table {self.srcDatabaseName}.{self.srcTableName} exists")
105
91
 
@@ -204,8 +190,10 @@ class SilverMerge:
204
190
 
205
191
  silverDerivedColumns = silverDerivedColumns.drop("__timestamp")
206
192
  logger.info("phida_log: Mapped DSP __timestamp to src_commit_time")
193
+ print("[SilverMerge] Mapped DSP __timestamp to src_commit_time")
207
194
  else:
208
195
  logger.info("phida_log: HVR data; src_commit_time already present")
196
+ print("[SilverMerge] HVR data; src_commit_time already present")
209
197
 
210
198
  # Convert all column names to lower case to ensure
211
199
  # the target Delta table is created and maintained with lower-case column names.
@@ -404,31 +392,41 @@ class SilverMerge:
404
392
  microBatchOutputDF = hvr_preprocessed_df.unionByName(dsp_df, allowMissingColumns=True)
405
393
 
406
394
  # 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
395
+ #lower_cols = [c.lower() for c in microBatchOutputDF.columns]
396
+ #microBatchOutputDF = microBatchOutputDF.toDF(*lower_cols)
397
+ "Commenting above column lowering as it is already done in silverCleansing() method"
398
+
399
+ # Compatible ordering across all modes.
400
+ # Do not infer mode from column presence because unionByName(allowMissingColumns=True)
401
+ # can materialize placeholder columns with null values.
402
+ # Example: HVR-only batch → after union, __operation_type column exists but is all NULL.
403
+ # Checking column presence alone would incorrectly route HVR data to mixed-mode ordering.
414
404
  batch_cols = set(microBatchOutputDF.columns)
415
- has_hvr_key = "hvr_integ_key" in batch_cols
416
- has_dsp_op = "__operation_type" in batch_cols
405
+ has_hvr_key = "hvr_integ_key" in batch_cols # True for HVR and mixed tables
406
+ has_dsp_op = "__operation_type" in batch_cols # True for DSP and mixed tables (may be all-null after union)
407
+ has_src_commit_time = "src_commit_time" in batch_cols # Common timestamp column for both sources
417
408
 
418
409
  if has_hvr_key and not has_dsp_op:
419
- # HVR-only: keep existing prod behavior
410
+ # HVR-only table (no DSP column at all): deduplicate by integ_key descending — highest key = latest change.
420
411
  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
412
  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()
413
+ # Mixed or DSP path: build ordering dynamically based on which recency columns actually exist.
414
+ order_exprs = []
415
+ if has_dsp_op:
416
+ # Flag actual DSP rows (op in A/I/L/X = 1) vs HVR rows or union-placeholder nulls (= 0).
417
+ # DSP rows sort first (desc), so DSP always wins over HVR for the same key in a mixed batch.
418
+ is_dsp_record = when(col("__operation_type").isin("A", "I", "L", "X"), lit(1)).otherwise(lit(0))
419
+ order_exprs.append(is_dsp_record.desc())
420
+ if has_src_commit_time:
421
+ # Within the same source type, pick the most recent record by commit timestamp.
422
+ order_exprs.append(col("src_commit_time").desc_nulls_last())
423
+ if has_hvr_key:
424
+ # Final tiebreaker for HVR rows: higher integ_key = later change from source.
425
+ order_exprs.append(col("hvr_integ_key").desc_nulls_last())
426
+ if not order_exprs:
427
+ # Defensive fallback: no recency column available, treat all rows as equal.
428
+ order_exprs.append(lit(1))
429
+ windowSpec = Window.partitionBy(self.keyColsList).orderBy(*order_exprs)
432
430
 
433
431
  microBatchOutputDF = microBatchOutputDF.withColumn("latest_record", row_number().over(windowSpec)).filter(
434
432
  "latest_record == 1").drop("latest_record")
@@ -436,24 +434,60 @@ class SilverMerge:
436
434
  if self.enable_delete_filter == 'Y':
437
435
  microBatchOutputDF = self.filter_deleted_records(microBatchOutputDF)
438
436
 
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.
437
+ # Build delete/update conditions only from operation columns present in the batch to avoid UNRESOLVED_EXPRESSION errors.
441
438
  delete_parts = []
439
+ update_insert_parts = []
440
+
441
+ # HVR: source_operation 0=delete, 3=before-image; all others are insert/update.
442
442
  if has_source_operation:
443
- delete_parts.append("s.source_operation in (0,3)")
443
+ delete_parts.append(
444
+ "(s.source_operation IS NOT NULL AND s.source_operation in (0,3))"
445
+ )
446
+ update_insert_parts.append(
447
+ "(s.source_operation IS NOT NULL AND s.source_operation not in (0,3))"
448
+ )
449
+
450
+ # DSP: __operation_type X=delete; A/I/L are insert/update variants.
444
451
  if has_operation_type:
445
- delete_parts.append("s.__operation_type in ('X')")
452
+ delete_parts.append(
453
+ "(s.__operation_type IS NOT NULL AND s.__operation_type in ('X'))"
454
+ )
455
+ update_insert_parts.append(
456
+ "(s.__operation_type IS NOT NULL AND s.__operation_type in ('A','I','L'))"
457
+ )
446
458
 
447
- if not delete_parts:
459
+ # At least one operation column must exist; otherwise the batch is malformed.
460
+ if not delete_parts or not update_insert_parts:
448
461
  raise Exception("No operation column found in source batch")
449
462
 
450
- delete_condition = " OR ".join([f"({p})" for p in delete_parts])
451
- update_insert_condition = f"NOT ({delete_condition})"
463
+ # OR joins HVR and DSP branches because a row belongs to exactly one source system.
464
+ delete_condition = " OR ".join(delete_parts)
465
+ update_insert_condition = " OR ".join(update_insert_parts)
466
+
467
+ # Freshness guard: prevent a late/older microbatch from overwriting a newer target row.
468
+ # src_commit_time is the common recency column populated for both HVR and DSP flows.
469
+ # Example:
470
+ # Batch 1 (arrives first): src_commit_time = '2024-01-01 10:00' → written to Silver (t.src_commit_time = '2024-01-01 10:00')
471
+ # Batch 2 (arrives late): src_commit_time = '2024-01-01 08:00' → BLOCKED because s.src_commit_time < t.src_commit_time
472
+ # Batch 3 (arrives later): src_commit_time = '2024-01-01 12:00' → ALLOWED because s.src_commit_time >= t.src_commit_time
473
+ # New key (no target row): t.src_commit_time IS NULL → ALLOWED (first-time insert)
474
+ freshness_condition = """
475
+ (
476
+ t.src_commit_time IS NULL
477
+ OR (s.src_commit_time IS NOT NULL AND s.src_commit_time >= t.src_commit_time)
478
+ )
479
+ """
480
+
481
+ # Apply freshness to matched delete so stale deletes cannot remove newer target rows.
482
+ matched_delete_condition = f"({delete_condition}) AND ({freshness_condition})"
483
+
484
+ # Apply freshness to matched update so older records do not overwrite newer silver state.
485
+ matched_update_condition = f"({update_insert_condition}) AND ({freshness_condition})"
452
486
 
453
487
  print(f"[SilverMerge] Executing merge for batch {batchId} into {self.tgtDatabaseName}.{self.tgtTableName}...")
454
488
  tgtDeltaTable.alias("t").merge(microBatchOutputDF.alias("s"), self.condition) \
455
- .whenMatchedDelete(delete_condition) \
456
- .whenMatchedUpdate(condition=update_insert_condition, set=self.columnsDict) \
489
+ .whenMatchedDelete(matched_delete_condition) \
490
+ .whenMatchedUpdate(condition=matched_update_condition, set=self.columnsDict) \
457
491
  .whenNotMatchedInsert(condition=update_insert_condition, values=self.columnsDict) \
458
492
  .execute()
459
493
  print(f"[SilverMerge] Merge for batch {batchId} complete.")
@@ -2,7 +2,7 @@ from setuptools import setup
2
2
 
3
3
  setup(
4
4
  name='silverstreamingapplication',
5
- version='1.7',
5
+ version='1.9',
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.7
3
+ Version: 1.9
4
4
  Summary: initial version
5
5
  Home-page:
6
6
  Author: