Forum Discussion

TomMa's avatar
TomMa
Frequent Visitor
6 months ago
Solved

Merge statement creates duplicates - Low shuffle merge as cause?

In our notebook pipeline we retrieve data from our bronze layer and upsert them to the silver layer through a MERGE statement. Every week the merge statement results in around 10 duplicates spread over different tables. See the merge statement below:

 

def merge_data(table_name, bronze_filtered, target_table, id_col='Id'):

    bronze_count = bronze_filtered.count()
    # Upsert new records to the silver table based on the id of the record if there are any records
    if bronze_count > 0:
        print(f"🔄️ Processing {bronze_count} records for {table_name}.")
        merge_condition = f"""
        target.{id_col} = source.{id_col}
        """

        target_table.alias("target").merge(
            bronze_filtered.alias("source"),
            merge_condition
        ).whenMatchedUpdateAll() \
         .whenNotMatchedInsertAll() \
         .execute()
        print(f"✅ Upsert for {table_name} complete.")
    else:
        print(f"❌ No records to upsert for {table_name}.")

 

Id's are always unique in the bronze layer and runs are scheduled far enough apart to exclude concurrency as the root cause. After discussing with Claude about the number of source row and the number of target rows that were inserted in duplicate runs it suggested to disable the Low Shuffle Merge Optimization (see below). To me this seems to be a too low level setting to play with for this issue. 

Are there any other people having similar issues, or that have tried similar solutions? Thanks in advance!  

===

The critical clue is numTargetRowsInserted: 5 with numSourceRows: 1. This should be impossible with standard merge semantics. This looks like it could be a bug specific to Microsoft Fabric's custom Low Shuffle Merge optimization. From the docs you found earlier:

The Microsoft Spark Delta team implemented a custom Low Shuffle Merge optimization where unmodified rows are excluded from an expensive shuffling operation. It is controlled by spark.microsoft.delta.merge.lowShuffle.enabled, enabled by default.

The numTargetRowsCopied: 0 is suspicious here — in normal Low Shuffle Merge, unmodified rows that need to be rewritten to new files are counted as "copied". Getting 0 copied but 5 inserted from 1 source row suggests the Low Shuffle implementation may be misclassifying existing target rows as unmatched inserts under certain conditions.

I'd try disabling it for your pipeline and re-running to see if the behaviour changes:

 

spark.conf.set("spark.microsoft.delta.merge.lowShuffle.enabled", "false")

 

  • Hi TomMa ,

     

    In this context, "cache the bronze DataFrame" means calling .cache() (or .persist()) on your bronze_filtered DataFrame before passing it into the merge. Something like:

     

    def merge_data(table_name, bronze_filtered, target_table, id_col='Id'): bronze_filtered = bronze_filtered.cache() # Add this bronze_count = bronze_filtered.count()

     

    he reason this matters: Spark DataFrames are lazily evaluated, meaning the computation that produces bronze_filtered is re-executed each time Spark needs the data. If your upstream pipeline has any non-determinism (e.g., reading from a source that changed, a non-deterministic UDF, unstable sort), the DataFrame could theoretically produce different rows when Spark materializes it during the merge vs. when you called .count() earlier.

    By calling .cache(), you force Spark to materialize the DataFrame once and hold it in memory/disk, so the exact same rows are used for the count check and for the merge source. This rules out non-deterministic re-evaluation as a cause of the duplicates.This is a diagnostic step more than a fix — true upstream non-determinism would be the real bug to fix.

     

    If this post helps, then please appreciate giving a Kudos or accepting as a Solution to help the other members find it more quickly.
    If I misunderstand your needs or you still have problems on it, please feel free to let us know. Thanks a lot!

     

     

     

     

7 Replies

  • Hi TomMa ,

     

    In this context, "cache the bronze DataFrame" means calling .cache() (or .persist()) on your bronze_filtered DataFrame before passing it into the merge. Something like:

     

    def merge_data(table_name, bronze_filtered, target_table, id_col='Id'): bronze_filtered = bronze_filtered.cache() # Add this bronze_count = bronze_filtered.count()

     

    he reason this matters: Spark DataFrames are lazily evaluated, meaning the computation that produces bronze_filtered is re-executed each time Spark needs the data. If your upstream pipeline has any non-determinism (e.g., reading from a source that changed, a non-deterministic UDF, unstable sort), the DataFrame could theoretically produce different rows when Spark materializes it during the merge vs. when you called .count() earlier.

    By calling .cache(), you force Spark to materialize the DataFrame once and hold it in memory/disk, so the exact same rows are used for the count check and for the merge source. This rules out non-deterministic re-evaluation as a cause of the duplicates.This is a diagnostic step more than a fix — true upstream non-determinism would be the real bug to fix.

     

    If this post helps, then please appreciate giving a Kudos or accepting as a Solution to help the other members find it more quickly.
    If I misunderstand your needs or you still have problems on it, please feel free to let us know. Thanks a lot!

     

     

     

     

    • TomMa's avatar
      TomMa
      Frequent Visitor

      Thanks ssrithar , I will try it out for the upcoming week and see if any duplicates persist.

  • Hi TomMa ,

     

    The metric combination you described — numSourceRows: 1 producing numTargetRowsInserted: 5 with numTargetRowsCopied: 0 — genuinely shouldn't be possible under standard merge semantics. A single unmatched source row can only ever produce one insert. The only way you get 5 inserts from 1 source row is if the merge engine is misidentifying already-existing target rows as "not matched" and re-inserting them. That's exactly the class of bug Low Shuffle Merge could introduce, because its core optimization involves partitioning which rows need full rewrite processing vs. which don't — and a misclassification there could cause rows to fall into the wrong bucket.

     

    Before touching Spark configs, I'd verify these since they're lower risk to investigate:

    Duplicate IDs emerging during the bronze filter

    Schema evolution or partition mismatches

    The ID column having leading/trailing whitespace or case sensitivity issues.

     

    The most productive path is probably: cache the bronze DataFrame, rerun for a week, and see if duplicates persist before touching the Spark config. If they do, then disabling Low Shuffle is a reasonable next step to isolate the cause.

     

    If this post helps, then please appreciate giving a Kudos or accepting as a Solution to help the other members find it more quickly.
    If I misunderstand your needs or you still have problems on it, please feel free to let us know. Thanks a lot!

    • TomMa's avatar
      TomMa
      Frequent Visitor

      Hi ssrithar

       

      Thanks for your reply, this sounds like a sound approach. Could you maybe elaborate on what you intend with "cache the bronze dataframe" 

       

      Thanks in advance 🙂 

  • v-karpurapud's avatar
    v-karpurapud
    Community Support

    Hi TomMa 

    Thank you for submitting your question to the Microsoft Fabric Community Forum, and thanks to ssrithar for  helpful suggestions.

    Could you let us know if the suggested solution resolved your issue? If not, please share any additional details so we can assist further.

    Best regards,
    Community Support Team.

    • v-sshirivolu's avatar
      v-sshirivolu
      Community Support

      Hi TomMa ,
      I wanted to check if you had the opportunity to review the information provided. Please feel free to contact us if you have any further questions.

  • Hi TomMa ,

     

    Please let me know once you try if you still have any issues we can resolve it