[spark] Add point-delete fast path for primary key DELETE to skip the target table scan - #8837
[spark] Add point-delete fast path for primary key DELETE to skip the target table scan#8837ZZZxDong wants to merge 1 commit into
Conversation
|
|
Good catch, all four points are valid — fixed. The fast path is now gated to DEDUPLICATE tables without NOT NULL non-pk columns (anything else falls back to the scan path), and NULL literals in the condition also fall back so SQL three-valued logic is preserved. Added tests for each case, and rebased onto master to resolve the conflict. |
de85cd1 to
0565f2d
Compare
b2ca4aa to
298ce26
Compare
|
CI caught another case of the same pattern: tables with primary-key indexes (btree/bitmap etc.) build index entries from real field values, so NULL-filled -D rows would leave the deletion invisible to index lookups. Added a third gate — tables with any pk index definition also fall back to the scan path. All tests green now. |
9a61e7c to
fa5662c
Compare
|
The remaining CI failure surfaced a real semantic difference: -D rows from the fast path carry NULL for non-key columns, so incremental / audit_log reads would not see the old field values of deleted rows (the TVF test asserts |
fa5662c to
e81b777
Compare
… target table scan
e81b777 to
db6df81
Compare
JingsongLi
left a comment
There was a problem hiding this comment.
Why not just introducing this by default just like Flink SQL?
Purpose
Closes #8836.
Deleting a set of primary keys from a large primary-key table currently costs a full table scan.
Concrete case that motivated this: a ~10-billion-row primary-key table and a ~10-million-row key table,
DELETE FROM big WHERE (pk) IN (SELECT pk FROM keys). Today theINsubquery is rewritten into a left-semi join, so the whole target table is read (all columns, because the-Drows are copied from the matched rows) just to find the 10M rows to delete. Runtime filtering does not help either:PaimonSupportsRuntimeFilteringonly pushes down partition-level predicates, and the keys are spread over every partition and bucket.Where this fits in the existing DELETE paths on a primary-key table:
WHERE dt = 'p1')OptimizeMetadataOnlyDeleteFromPaimonTable-> metadata-only truncate, no scanWhen the condition pins every primary key column, the matched keys are already fully described by the condition itself, so the
-Drecords can be built without reading the target table at all:pk = literal/pk IN (literals)covering all pk columns-Drows from the condition on the driver, capped bydelete.point-delete.max-rows(default 1M, falls back to scan beyond that)pk IN (subquery)covering all pk columnsKeys that do not exist in the table only add a
-Drecord that compaction removes later (note it can materialize a partition that did not exist before).Writing
-Drows that carry only the primary key is not a new semantics in Paimon: withrowkind.fielda user can alreadyINSERTrows holding just the primary keys plus'-D'and get exactly the same records, without touching the target table. This PR turns that into an automatic optimization forDELETE, instead of requiring a schema change and hand-written SQL.Safety
The
-Drows carry NULL for non-key columns, so the fast path is opt-in (delete.point-delete.enabled, defaultfalse) and additionally falls back to the scan path unless all of the following hold:merge-engine = deduplicate: the merge replaces the whole row and never reads field values of a delete row.partial-update(remove-record-on-sequence-group) andaggregationdo read them.sequence.field: the user sequence fields are part of the write buffer sort key (SortBufferWriteBuffer) and NULL sorts smallest, i.e. oldest, so a NULL-filled-Drow would lose the merge against the existing row and the delete would be silently dropped.TableWriteImpl.checkNullabilityrejects the write.-Drow would leave the deletion invisible to index lookups.-Drow would carry a NULL partition.Two more cases fall back inside the subquery path:
-Drow may be written (otherwise a NULL primary key would be stored).Known trade-off, documented on the option: until compaction merges them away, incremental /
audit_logreads see NULL instead of the old field values of the deleted rows. For the same reason the option should not be enabled on tables whose changelog is consumed by streaming jobs relying on the field values of retract records.Tests
DeletePointFastPathTest, 18 cases. Besides asserting the query results, the suite asserts which path ran: deleting a key that does not exist writes exactly one-Drecord on the fast path and nothing at all on the scan path, so a fallback case cannot pass by accident.IN, composite pk (partition + id), subquery with single and composite pk (including positionally renamed columns), delete-then-reinsert, "really skips the scan"id = NULL,id IN (1, NULL)), NULL keys from a subquery, correlated subquery,sequence.field, NOT NULL non-key column, cross-partition table,partial-updatewithsequence-group, overmax-rows, option disabled (default)The existing
DeleteFromTableTestsuite (29 tests) passes unchanged.API and Format
Two new Spark connector options, no format change:
delete.point-delete.enabled(defaultfalse)delete.point-delete.max-rows(default1000000)Documentation
docs/generated/spark_connector_configuration.htmlregenerated for the two options; theenableddescription spells out when the fast path applies and the incremental-read caveat.Follow-up (not in this PR)
The same idea generalizes one level further: for any DELETE condition, project only the primary key columns plus
sequence.fieldinstead of all columns before writing the-Drows. That keeps the scan but cuts the IO to a few columns, works forsequence.fieldtables, and covers conditions that do not pin the primary keys. Happy to send that as a separate PR if the direction looks good.