feat(merge_insert): probe scalar indices on the DataFusion plan path - #8055
feat(merge_insert): probe scalar indices on the DataFusion plan path#8055wjones127 wants to merge 3 commits into
Conversation
The ci_benchmarks suite had no merge_insert coverage, so there was no way to see how the legacy indexed path compares to the DataFusion path, or to catch a regression in either. Adds 111 parametrized benchmarks covering the source/target ratio sweep, key distribution, cold vs warm index cache, partial-column write amplification, clause shapes, target layouts, and streaming sources. Every benchmark that can run on both paths is parametrized on `use_index`, which is the only knob that selects between them today. Also adds a `setup=` hook to the `io_mem_benchmark` fixture, which previously had no way to reset state between runs -- required for any benchmark that mutates its dataset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The DataFusion merge path reached matched target rows by scanning the whole table and hash-joining against the source. Only the legacy path could probe a scalar index on the merge keys, so any merge that needed the DataFusion path scanned the full target even when an index would have found the same rows. Adds an index-probe target provider: matched rows come from an `IsIn` probe of every indexed merge key, taken by row address, unioned with a scan of the fragments no chosen index covers. It reports the same schema as the full-scan provider, so the join, action expression, and write sink are unchanged. Routing is unchanged: this only affects merges that already used the DataFusion path and have an indexed key, chiefly a composite key with some columns indexed. `explain_plan` now uses a re-scannable source so the probe is visible, and `analyze_plan` builds its provider the way execution does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…once Benchmarking the probe surfaced two ways a partially-indexed composite key merge produced wrong results. A materialized source is spread across partitions and `MapIndexExec` probes only the partition it is asked for, so the keys in every other partition were never probed and their target rows were inserted as duplicates instead of updated. `MapIndexExec` now requires a single input partition, which is what keeps the optimizer from removing the probe's `CoalescePartitionsExec`. The probe also evaluates one query per source batch, and an over-matching probe can reach the same target row from two batches. The target row was then read twice, giving one source row two candidate matches, and the merge failed with "Ambiguous merge inserts are prohibited". Candidate row addresses are now de-duplicated before the take. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Benchmark results, before and afterRan the Only the narrow-target cases ran. The The run found two correctness bugsBoth in The probe missed most of the source. A materialized source is spread round-robin across partitions, and Over-matching probes named the same target row twice. Probes are evaluated per source batch, so two batches can reach the same target row. That row was then read twice, which looks like two candidate matches for one source row, and the merge failed with Fixed in The second bug is pre-existing in the legacy indexed path, which has the same per-batch probe with no cross-batch de-duplication. Filed as #8057; fixing it there changes legacy behavior and is out of scope here. Affected case
Same merge measured for IO, 10K-row source, warm cache:
Everything else is unchanged46 timing cases, none of which reach the probe: median absolute delta 1.7%, 44 within ±10%, worst +18.1% on a 5-second case. All 14 IO cases report byte-identical read and write volumes on both sides, which is the reliable signal here — the timing spread is laptop variance, not a code difference. The probe is not always fasterThis is the result worth acting on. Same dataset, same 10K-row source, same merge shape — the only difference is which column carries the index:
Both of those are partially-indexed composite keys, so both take the probe as this PR stands. Context: the same merge across paths10K-row source against the 10M-row target, warm cache.
Two things fall out of this. For a selective key the probe is roughly 10x faster than the scan, which is the case for routing indexed merges onto this path. But v1's 5.99 s on the fully-indexed composite is the low-cardinality effect again — it ANDs |
Stacked on #8052 — its benchmark commit shows up in this diff until that one merges. Review only
feat(merge_insert): probe scalar indices on the DataFusion plan path.merge_inserthas two execution paths. The DataFusion path reaches matched target rows by scanning the whole table and hash-joining it against the source. The legacy path can instead probe a scalar index on the merge keys, reading only the pages that could contain a source key — but it is the only path that can, so any merge that needs the DataFusion path scans the full target even when an index would have found the same rows for a fraction of the IO.This PR teaches the DataFusion path to probe. The target scan becomes:
It is a new
TableProviderfor the target rather than a change to the plan builder, and it reports the same schema as the full-scan provider it replaces, so the join, the action expression, and the write sink are untouched.Correctness rests on the probe being allowed to over-match but never under-match, since the join is what applies the real key predicate. Two consequences: only the indexed merge keys need probing, so a composite key with one indexed column still prunes by that column; and rows in fragments no chosen index covers would be invisible to the probe, so those fragments are scanned and unioned in.
The probe replaces the scan when every one of these holds:
when_not_matched_by_sourceisKeep— the other modes need target rows the source never mentionsuse_indexwas not turned offWhich merge configurations that adds up to is deliberately narrow: this PR does not change which path a merge takes, so it only affects merges that already used the DataFusion path and have an indexed key. The main one is a composite key where only some columns are indexed. Routing indexed keys away from the legacy path is a separate change, and it needs the benchmark comparison to justify it.
Example
A merge on
["a", "b"]where onlyais indexed, previously a full scan of the target:Note
projection=[a, b, _rowaddr]: a full-schema upsert builds its output rows entirely from the source, so the target read fetches only the join keys and the row address needed to supersede the old rows.valueis never read. There is a test pinning this, because widening it would quietly turn a cheap keyed read into a full-row one.Behavior changes
explain_plannow stands the source up as an in-memory table instead of a single-use stream. Without that it could never show the probe, since a single-use source rules it out — and it also means the plan shown is the one the common entry points build. Because the source now reports exact statistics, DataFusion collects it as the join's build side, so the source appears above the target in the plan and the join type is the swapped form of what it was (Leftwhere it readRight). Same join, sides exchanged.analyze_plannow builds its source provider the same way execution does, so the plan it analyzes is the plan that would have run. For a stream source with retries enabled that means the source is buffered (in memory, then spilled) rather than consumed once.Not included
(key, row_addr)pairs from the probe, which would let a plain upsert skip the target read entirely. The index API returns a row-address mask today, so the key that matched is not recoverable without reading it back.