Skip to content

Commit c87733c

Browse files
authored
Optimize DELETE by scanning indexed columns at bind time (duckdb#20682)
2 parents 6913465 + 7bc3bf6 commit c87733c

7 files changed

Lines changed: 367 additions & 87 deletions

File tree

src/execution/operator/persistent/physical_delete.cpp

Lines changed: 11 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -77,68 +77,21 @@ SinkResultType PhysicalDelete::Sink(ExecutionContext &context, DataChunk &chunk,
7777
l_state.delete_chunk.Reset();
7878
row_ids.Flatten(chunk.size());
7979

80-
// Check if we can use columns from the input chunk (passed through from the scan)
81-
// instead of fetching them by row ID
82-
bool use_input_columns = !return_columns.empty();
83-
84-
if (use_input_columns) {
85-
// Use columns from the input chunk - they were passed through from the scan
86-
// Only physical columns are passed; generated columns are computed in the RETURNING projection
87-
for (idx_t i = 0; i < table.ColumnCount(); i++) {
88-
D_ASSERT(return_columns[i] != DConstants::INVALID_INDEX);
80+
// Use columns from the input chunk - they were passed through from the scan
81+
// return_columns maps storage_idx -> chunk_idx
82+
// For RETURNING: all columns have valid indices
83+
// For index-only: only indexed columns have valid indices (sparse mapping)
84+
D_ASSERT(!return_columns.empty() && "return_columns should always be populated for RETURNING or unique indexes");
85+
for (idx_t i = 0; i < table.ColumnCount(); i++) {
86+
if (return_columns[i] != DConstants::INVALID_INDEX) {
87+
// Column was passed through from the scan
8988
l_state.delete_chunk.data[i].Reference(chunk.data[return_columns[i]]);
90-
}
91-
l_state.delete_chunk.SetCardinality(chunk.size());
92-
} else {
93-
// Fall back to fetching columns by row ID
94-
// This path is only used when unique indexes exist but no RETURNING
95-
// (need indexed columns for delete tracking)
96-
D_ASSERT(!return_chunk && "RETURNING should always use the optimized path with return_columns");
97-
98-
auto &transaction = DuckTransaction::Get(context.client, table.db);
99-
auto to_be_fetched = vector<bool>(types.size(), false);
100-
vector<StorageIndex> column_ids;
101-
vector<LogicalType> column_types;
102-
103-
// Fetch only the required columns for updating the delete indexes
104-
auto &local_storage = LocalStorage::Get(context.client, table.db);
105-
auto storage = local_storage.GetStorage(table);
106-
unordered_set<column_t> indexed_column_id_set;
107-
storage->delete_indexes.Scan([&](Index &index) {
108-
if (!index.IsBound() || !index.IsUnique()) {
109-
return false;
110-
}
111-
auto &set = index.GetColumnIdSet();
112-
indexed_column_id_set.insert(set.begin(), set.end());
113-
return false;
114-
});
115-
for (auto &col : indexed_column_id_set) {
116-
column_ids.emplace_back(col);
117-
}
118-
sort(column_ids.begin(), column_ids.end());
119-
for (auto &col : column_ids) {
120-
auto i = col.GetPrimaryIndex();
121-
to_be_fetched[i] = true;
122-
column_types.push_back(types[i]);
123-
}
124-
125-
// Fetch the to-be-deleted chunk.
126-
DataChunk fetch_chunk;
127-
fetch_chunk.Initialize(Allocator::Get(context.client), column_types, chunk.size());
128-
auto fetch_state = ColumnFetchState();
129-
table.Fetch(transaction, fetch_chunk, column_ids, row_ids, chunk.size(), fetch_state);
130-
131-
// Reference the necessary columns of the fetch_chunk.
132-
idx_t fetch_idx = 0;
133-
for (idx_t i = 0; i < table.ColumnCount(); i++) {
134-
if (to_be_fetched[i]) {
135-
l_state.delete_chunk.data[i].Reference(fetch_chunk.data[fetch_idx++]);
136-
continue;
137-
}
89+
} else {
90+
// Column not in scan (sparse mapping for index-only case) - use NULL placeholder
13891
l_state.delete_chunk.data[i].Reference(Value(types[i]));
13992
}
140-
l_state.delete_chunk.SetCardinality(fetch_chunk);
14193
}
94+
l_state.delete_chunk.SetCardinality(chunk.size());
14295

14396
// Append the deleted row IDs to the delete indexes.
14497
// If we only delete local row IDs, then the delete_chunk is empty.

src/include/duckdb/planner/binder.hpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,13 @@ class Binder : public enable_shared_from_this<Binder> {
408408
void BindDeleteReturningColumns(TableCatalogEntry &table, LogicalGet &get, vector<idx_t> &return_columns,
409409
vector<unique_ptr<Expression>> &projection_expressions,
410410
LogicalOperator &target_binding);
411+
//! Build a sparse mapping for unique index columns only (for DELETE without RETURNING)
412+
//! return_columns[storage_idx] = scan_chunk_idx (only for indexed columns)
413+
void BindDeleteIndexColumns(TableCatalogEntry &table, LogicalGet &get, vector<idx_t> &return_columns);
414+
//! Overload for MERGE INTO: builds projection expressions and maps storage_idx -> projection_expr_idx
415+
void BindDeleteIndexColumns(TableCatalogEntry &table, LogicalGet &get, vector<idx_t> &return_columns,
416+
vector<unique_ptr<Expression>> &projection_expressions,
417+
LogicalOperator &target_binding);
411418
BoundStatement BindReturning(vector<unique_ptr<ParsedExpression>> returning_list, TableCatalogEntry &table,
412419
const string &alias, idx_t update_table_index,
413420
unique_ptr<LogicalOperator> child_operator,

src/planner/binder.cpp

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -403,33 +403,108 @@ void Binder::BindDeleteReturningColumns(TableCatalogEntry &table, LogicalGet &ge
403403
}
404404
}
405405

406-
void Binder::BindDeleteReturningColumns(TableCatalogEntry &table, LogicalGet &get, vector<idx_t> &return_columns,
407-
vector<unique_ptr<Expression>> &projection_expressions,
408-
LogicalOperator &target_binding) {
409-
// First, use the base helper to ensure all physical columns are in the scan
410-
vector<idx_t> scan_return_columns;
411-
BindDeleteReturningColumns(table, get, scan_return_columns);
412-
413-
// Resolve types after adding columns to the scan
406+
//! Helper: convert scan column mapping to projection expression mapping for MERGE INTO
407+
static void ConvertScanToProjectionMapping(TableCatalogEntry &table, const vector<idx_t> &scan_return_columns,
408+
vector<idx_t> &return_columns,
409+
vector<unique_ptr<Expression>> &projection_expressions,
410+
LogicalOperator &target_binding) {
414411
target_binding.ResolveOperatorTypes();
415412
auto target_bindings = target_binding.GetColumnBindings();
416413
auto &target_types = target_binding.types;
417414

418-
// Convert scan mapping to projection expression mapping
419-
// return_columns[storage_idx] = projection_expr_idx
420415
auto physical_count = table.GetColumns().PhysicalColumnCount();
421416
return_columns.resize(physical_count, DConstants::INVALID_INDEX);
422417

423418
for (idx_t storage_idx = 0; storage_idx < scan_return_columns.size(); storage_idx++) {
424419
auto scan_idx = scan_return_columns[storage_idx];
425420
if (scan_idx != DConstants::INVALID_INDEX && scan_idx < target_bindings.size()) {
426421
return_columns[storage_idx] = projection_expressions.size();
427-
auto col_ref = make_uniq<BoundColumnRefExpression>(target_types[scan_idx], target_bindings[scan_idx]);
428-
projection_expressions.push_back(std::move(col_ref));
422+
projection_expressions.push_back(
423+
make_uniq<BoundColumnRefExpression>(target_types[scan_idx], target_bindings[scan_idx]));
429424
}
430425
}
431426
}
432427

428+
void Binder::BindDeleteReturningColumns(TableCatalogEntry &table, LogicalGet &get, vector<idx_t> &return_columns,
429+
vector<unique_ptr<Expression>> &projection_expressions,
430+
LogicalOperator &target_binding) {
431+
vector<idx_t> scan_return_columns;
432+
BindDeleteReturningColumns(table, get, scan_return_columns);
433+
ConvertScanToProjectionMapping(table, scan_return_columns, return_columns, projection_expressions, target_binding);
434+
}
435+
436+
void Binder::BindDeleteIndexColumns(TableCatalogEntry &table, LogicalGet &get, vector<idx_t> &return_columns) {
437+
// Build a mapping from storage column index to scan chunk index for unique index tracking.
438+
// This is a sparse mapping - only indexed columns have valid indices.
439+
// Used when DELETE has no RETURNING but table has unique indexes.
440+
auto &storage = table.GetStorage();
441+
auto &info = storage.GetDataTableInfo();
442+
auto &indexes = info->GetIndexes();
443+
444+
// Collect column IDs from unique indexes
445+
unordered_set<column_t> indexed_column_ids;
446+
indexes.Scan([&](Index &index) {
447+
if (index.IsUnique()) {
448+
auto &col_ids = index.GetColumnIdSet();
449+
indexed_column_ids.insert(col_ids.begin(), col_ids.end());
450+
}
451+
return false;
452+
});
453+
454+
if (indexed_column_ids.empty()) {
455+
return;
456+
}
457+
458+
auto &column_ids = get.GetColumnIds();
459+
auto &columns = table.GetColumns();
460+
auto physical_count = columns.PhysicalColumnCount();
461+
462+
// Initialize the mapping with INVALID_INDEX
463+
return_columns.resize(physical_count, DConstants::INVALID_INDEX);
464+
465+
// First, map columns already in the scan to their storage indices
466+
for (idx_t chunk_idx = 0; chunk_idx < column_ids.size(); chunk_idx++) {
467+
auto &col_id = column_ids[chunk_idx];
468+
if (col_id.IsVirtualColumn()) {
469+
continue;
470+
}
471+
auto logical_idx = col_id.GetPrimaryIndex();
472+
auto &col = columns.GetColumn(LogicalIndex(logical_idx));
473+
if (!col.Generated()) {
474+
auto storage_idx = col.StorageOid();
475+
// Only map if this column is in a unique index
476+
if (indexed_column_ids.count(storage_idx)) {
477+
return_columns[storage_idx] = chunk_idx;
478+
}
479+
}
480+
}
481+
482+
// Add any missing indexed columns to the scan
483+
for (auto col_idx : indexed_column_ids) {
484+
if (return_columns[col_idx] == DConstants::INVALID_INDEX) {
485+
return_columns[col_idx] = column_ids.size();
486+
// Find the logical index for this storage index
487+
for (auto &col : columns.Physical()) {
488+
if (col.StorageOid() == col_idx) {
489+
get.AddColumnId(col.Logical().index);
490+
break;
491+
}
492+
}
493+
}
494+
}
495+
}
496+
497+
void Binder::BindDeleteIndexColumns(TableCatalogEntry &table, LogicalGet &get, vector<idx_t> &return_columns,
498+
vector<unique_ptr<Expression>> &projection_expressions,
499+
LogicalOperator &target_binding) {
500+
vector<idx_t> scan_return_columns;
501+
BindDeleteIndexColumns(table, get, scan_return_columns);
502+
if (!scan_return_columns.empty()) {
503+
ConvertScanToProjectionMapping(table, scan_return_columns, return_columns, projection_expressions,
504+
target_binding);
505+
}
506+
}
507+
433508
BoundStatement Binder::BindReturning(vector<unique_ptr<ParsedExpression>> returning_list, TableCatalogEntry &table,
434509
const string &alias, idx_t update_table_index,
435510
unique_ptr<LogicalOperator> child_operator, virtual_column_map_t virtual_columns) {

src/planner/binder/statement/bind_delete.cpp

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include "duckdb/planner/operator/logical_get.hpp"
88
#include "duckdb/planner/operator/logical_cross_product.hpp"
99
#include "duckdb/catalog/catalog_entry/table_catalog_entry.hpp"
10+
#include "duckdb/storage/data_table.hpp"
1011

1112
namespace duckdb {
1213

@@ -64,11 +65,18 @@ BoundStatement Binder::Bind(DeleteStatement &stmt) {
6465
auto del = make_uniq<LogicalDelete>(table, GenerateTableIndex());
6566
del->bound_constraints = BindConstraints(table);
6667

67-
// If RETURNING is present, add all physical table columns to the scan so we can pass them through
68-
// instead of having to fetch them by row ID in PhysicalDelete.
69-
// Generated columns will be computed in the RETURNING projection by the binder.
68+
// Add columns to the scan to avoid fetching by row ID in PhysicalDelete:
69+
// - If RETURNING: add all physical columns (for RETURNING projection)
70+
// - Else if unique indexes exist: add only indexed columns (for delete index tracking)
7071
if (!stmt.returning_list.empty()) {
72+
// Add all physical columns for RETURNING
7173
BindDeleteReturningColumns(table, get, del->return_columns);
74+
} else if (table.IsDuckTable()) {
75+
// Only optimize for DuckDB tables (not attached external tables like SQLite)
76+
auto &storage = table.GetStorage();
77+
if (storage.HasUniqueIndexes()) {
78+
BindDeleteIndexColumns(table, get, del->return_columns);
79+
}
7280
}
7381

7482
del->AddChild(std::move(root));

src/planner/binder/statement/bind_merge_into.cpp

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -368,28 +368,37 @@ BoundStatement Binder::Bind(MergeIntoStatement &stmt) {
368368
projection_expressions.push_back(std::move(marker_ref));
369369
}
370370

371-
// If RETURNING is present and we have a DELETE action, add all physical columns to the scan
372-
// so we can pass them through instead of fetching by row ID in PhysicalDelete.
373-
// Generated columns will be computed in the RETURNING projection by the binder.
374-
if (!stmt.returning_list.empty()) {
375-
bool has_delete_action = false;
376-
for (auto &entry : merge_into->actions) {
377-
for (auto &action : entry.second) {
378-
if (action->action_type == MergeActionType::MERGE_DELETE) {
379-
has_delete_action = true;
380-
break;
381-
}
382-
}
383-
if (has_delete_action) {
371+
// Check if we have a DELETE action
372+
bool has_delete_action = false;
373+
for (auto &entry : merge_into->actions) {
374+
for (auto &action : entry.second) {
375+
if (action->action_type == MergeActionType::MERGE_DELETE) {
376+
has_delete_action = true;
384377
break;
385378
}
386379
}
387-
388380
if (has_delete_action) {
381+
break;
382+
}
383+
}
384+
385+
// If RETURNING is present and we have a DELETE action, add all physical columns to the scan
386+
// so we can pass them through instead of fetching by row ID in PhysicalDelete.
387+
// Generated columns will be computed in the RETURNING projection by the binder.
388+
if (has_delete_action) {
389+
if (!stmt.returning_list.empty()) {
389390
// Use the overloaded helper to add physical columns to the scan and build projection expressions
390391
auto &target_binding = join_ref.get().children[inverted ? 0 : 1];
391392
BindDeleteReturningColumns(table, get, merge_into->delete_return_columns, projection_expressions,
392393
*target_binding);
394+
} else if (table.IsDuckTable()) {
395+
// Only optimize for DuckDB tables (not attached external tables like SQLite)
396+
auto &storage = table.GetStorage();
397+
if (storage.HasUniqueIndexes()) {
398+
auto &target_binding = join_ref.get().children[inverted ? 0 : 1];
399+
BindDeleteIndexColumns(table, get, merge_into->delete_return_columns, projection_expressions,
400+
*target_binding);
401+
}
393402
}
394403
}
395404

0 commit comments

Comments
 (0)