Repository navigation
[RLC] Case 1 follow-up — MERGE source-key read predicate as an exact semi-join (MERGE ‖ MERGE on disjoint keys) - #20
Draft
sezruby wants to merge 2 commits into
Conversation
…GE on disjoint keys) With a key-only ON condition (e.g. `t.id = s.id`) MERGE registers a read predicate of `true` on unpartitioned / liquid-clustered tables, so any concurrent MERGE that adds a file conflicts, even when the two MERGEs touch disjoint keys. DBR resolves this per key. This change, behind the internal flag `spark.databricks.delta.merge.sourceKeyReadPredicate.enabled` (default off), collects the distinct source values of each `target = source` equi-join conjunct (bounded by `merge.sourceKeyReadPredicate.maxKeys`, default 100000, provisional) and adds `target_key IN (source keys)` (InSet) to the predicates passed to `filterFiles`. The predicate: - is recorded as a read predicate, so concurrent appends are checked against it with min/max and value-exact conflict data skipping; - prunes the MERGE's own target scan and read-file set. Skipped for NOT MATCHED BY SOURCE (whole-table read), null-safe equality, non-atomic or mismatched key types, and when maxKeys is exceeded. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…no key cap Updated DBR study (wiki, DBR 18.2 source-size addendum): DBR never decides MERGE conflicts with a literal IN list. Its read predicate is a join against the MERGE source (DeltaTableMergeSourceReadPredicate), with no source-size cap (tested to 4M rows), and rowLevelConflictDetection.inSetThreshold is only a pre-filter. Match that: - Replace merge.sourceKeyReadPredicate.maxKeys (and its fallback) with merge.sourceKeyReadPredicate.inSetThreshold (default 10000). It only chooses the pre-filter: InSet up to the threshold, min/max range above it. - Add MergeSourceKeyMatch(targetKeys, sourceKeys), recorded with the pre-filters. It is `true` in generic paths; the value-exact and delete/read conflict tiers evaluate it as a left-semi join of the winner's rows against the (materialized or deterministic) MERGE source on the key tuple. - Exclude FLOAT/DOUBLE and non-binary-collation string keys, where a value pre-filter could disagree with the join's `=`. - Tests: 20000-key source (range pre-filter) commits on disjoint keys and aborts on a shared key; inSetThreshold 1/0 never changes the outcome; multi-column keys are matched as tuples. A mutation check (join removed) fails exactly these three tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This was referenced Oct 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #15 (
conflict-data-skipping-delete-read→ value-exact delta-io#7466 → min/max delta-io#7358). Implements the Case 1 follow-up from #4 / delta-io#7360: MERGE ‖ MERGE on disjoint keys.1. Problem
OSS MERGE records only the target-only conjuncts of
ONas its read predicate(
getTargetOnlyPredicates). A key-only upsertON t.id = s.idtherefore readstrue. On anunpartitioned or liquid-clustered table, every concurrently added file conflicts, so two MERGEs on
disjoint keys always abort the loser with
ConcurrentAppendException. Conflict-time data skipping(Case 1) cannot help, because there is no predicate to skip with.
2. Reference behavior
OSS docs (docs.delta.io, Concurrency control): MERGE ‖ MERGE "can conflict", depending on
whether the operations touch the same files. The only recommended mitigation is partitioning plus an
explicit
ONpredicate.Databricks Runtime (our black-box tests, DBR 16.4 / 18.2, unpartitioned DV table, WriteSerializable):
ConcurrentAppendException)The decision is per key, not per file or min/max range: a loser key inside the winner's new file's
range still commits. It does not degrade with the source size, so the key check is exact and has no
key-count cap. A key-set pre-filter can only affect how much of the winner's change is scanned.
3. Design
MergeIntoCommandBase.getSourceKeyReadPredicatesreturns, for a MERGE without NMBS whoseONhasusable equi-join keys, a set of predicates that are all ANDed in one read:
inSetThresholddistinct key tuples (collected withdistinct().limit(threshold+1)):InSet(t_i, non-null source values).t_i >= min(s_i) AND t_i <= max(s_i), from one aggregate.false.MergeSourceKeyMatch(targetKeys, sourceKeys). A marker predicate whose children are thetarget keys and which carries the source DataFrame projected to the source keys. It evaluates to
truein every generic path (file pruning, partition filters, stats skipping), where thepre-filters do the work. The conflict checker's value-exact tiers evaluate it exactly, as a
left-semi join of the winner's rows against
sourceKeyson the key tuple.These are appended to the existing
deltaTxn.filterFiles(...)call inClassicMergeExecutor.findTouchedFilesand in theInsertOnlyMergeExecutorpath. The pre-filtersalso prune the MERGE's own target scan and
readFiles.Key extraction. Each
EqualTo(t, s)conjunct ofONqualifies when:treferences only target columns andsonly source columns, in either operand order;Excluded:
<=>, because NULL would match NULL;FLOAT/DOUBLE, because of-0.0 = 0.0and NaN normalization;For these exclusions, a pre-filter on the raw collected values could disagree with the join's
=.Conflict checker (
ConflictDataSkippingReader.rowsMatchingAnyRead, used by bothvalue-exact tiers):
MergeSourceKeyMatch, the code path is unchanged (a singlewhere).where(plain filters), thenleft_semijoin with eachmarker's source keys. The reads are unioned and the result is checked for existence.
!rows.isEmpty.several reads is counted once per read, and that multiplicity depends only on the row's values,
so pre − post stays exact.
dropped, which over-approximates.
conflict.
Source lifetime. The commit runs inside
runWithMaterializedSourceLostRetries, before thematerialized RDD is unpersisted, so the conflict check reuses it. A non-materialized (deterministic
Delta) source is re-read, exactly as MERGE itself reads its source twice.
Observability. Event
delta.dml.merge.sourceKeyReadPredicate{numKeyColumns, inSetThreshold, preFilter: inSet|range, numDistinctKeys (-1 above threshold)}.4. Correctness
Sound. A target row can satisfy
ONonly if its key tuple equals some source row's keytuple. So "key tuple ∈ source key tuples" ⊇ the rows the MERGE reads. This covers:
matched has the same key tuple.
Each pre-filter is implied by the tuple match, so
pre-filters ∧ matchequals the tuple match.Exact on tuples. The semi-join matches whole key tuples. A winner row
(60, 5)against loserkeys
{(60, 0), (61, 5)}passes the per-column pre-filters but is not a conflict. This is tested.Same new key. The winner's inserted
k ∈ Sis found by the semi-join, so the loser aborts andthere is no duplicate.
Key-changing UPDATE. If the winner moves a key into
S, the new image is an added file, so itis checked.
Fail-safe. Flag off, NMBS, or no usable key gives today's predicate. Any scan or join error is
treated as a conflict.
5. Size / threshold
There is no correctness threshold.
inSetThresholdonly selects the pre-filter. Changing it neverchanges an outcome (tested with 1 and 0), only how much of the winner's change survives to the join.
Costs:
distinct().limit(threshold+1)job, plus one min/max aggregate above thethreshold.
valueExact.maxBytes) against the source keys. Spark picks broadcast or shuffle. The cost scaleswith the winner's change and the source size, not with the table size.
6. Scope & interactions
DELETE_DELETEuntil stacked with Case 2 (#2)NOT MATCHED BY SOURCE7. Validation
The
MergeSourceKeyReadPredicateSuite(13 tests) runs real interleaved MERGE races. The loser stopsat pre-commit via
TransactionExecutionTestMixin, then the winner commits:ConcurrentAppendException;commits, and a winner touching one source key aborts;
inSetThreshold1 and 0: disjoint keys still commit;INwould conflict;Mutation check. With the semi-join removed (pre-filter only), exactly the 3 join-dependent tests
fail: the 20000-key, tiny-threshold and multi-column tests. So they really exercise the join.
Results (JDK 17, Spark master build, base
conflict-data-skipping-delete-readf3b6a00)MergeSourceKeyReadPredicateSuiteConflictDataSkippingSuite+DeleteReadConflictDataSkippingSuite(default config)spark/scalastyle,spark/Test/scalastyleWith the flag on, the only failures are
insert only merge - target data skipping(path-based andDV). The test asserts exactly 2 source and target scans, and collecting the source keys adds one
more. MERGE results are identical. With the flag off (the default), the test is unaffected.