Skip to content

Commit fa3b9b9

Browse files
committed
Capture resolved conflict outcomes
1 parent 244e7c9 commit fa3b9b9

5 files changed

Lines changed: 878 additions & 123 deletions

File tree

docs/codebase-audit-2026-07-22.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ maintenance path. A bug must not be hidden by weakening a test or silently chang
1515
appends through the caller's `ClientContext`, so base DML and delta rows share one commit or rollback. A resolved
1616
catalog-level lock prevents refresh from advancing its watermark past an uncommitted delta. Regression coverage includes
1717
rollback, constraint failure, defaults, generated columns, `RETURNING`, and an uncommitted DML/refresh race.
18-
- [ ] **Capture actual `ON CONFLICT` outcomes.** `INSERT ... ON CONFLICT DO UPDATE` and `INSERT OR REPLACE` do not emit the
19-
required delete-old/insert-new delta pair. Derive deltas from rows actually inserted or updated, not merely the input to
20-
`LogicalInsert`.
18+
- [x] **Capture actual `ON CONFLICT` outcomes.** Conflict clauses lower to `LogicalMergeInto`; a transparent physical action
19+
decorator now captures only the INSERT, UPDATE, and DELETE rows selected by DuckDB after match and action predicates are
20+
resolved. Updates emit the required delete-old/insert-new pair, volatile defaults are evaluated once, `RETURNING` is
21+
preserved, and all writes remain in the base DML transaction. The same boundary also covers native `MERGE` actions.
2122
- [ ] **Make MV lifecycle and native refresh transactional.** CREATE, REPLACE, DROP, ALTER, and `PRAGMA refresh` currently
2223
execute material work through helper autocommit connections. Caller rollback cannot restore a consistent collection of
2324
user view, backing table, metadata, and deltas; failed replacement can destroy the old MV. Use the caller transaction for

src/include/rules/transactional_delta_capture.hpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,27 @@ class LogicalTransactionalDeltaCapture : public LogicalExtensionOperator {
3535
void ResolveTypes() override;
3636
};
3737

38+
// Transparent wrapper around a LogicalMergeInto. At physical-plan creation it decorates
39+
// DuckDB's resolved action sinks, so only rows that actually INSERT/UPDATE/DELETE are captured.
40+
class LogicalTransactionalMergeDeltaCapture : public LogicalExtensionOperator {
41+
public:
42+
LogicalTransactionalMergeDeltaCapture(TableCatalogEntry &base_table, TableCatalogEntry &delta_table);
43+
44+
TableCatalogEntry &base_table;
45+
TableCatalogEntry &delta_table;
46+
47+
PhysicalOperator &CreatePlan(ClientContext &context, PhysicalPlanGenerator &planner) override;
48+
vector<ColumnBinding> GetColumnBindings() override;
49+
string GetName() const override;
50+
string GetExtensionName() const override;
51+
bool SupportSerialization() const override {
52+
return false;
53+
}
54+
55+
protected:
56+
void ResolveTypes() override;
57+
};
58+
3859
} // namespace duckdb
3960

4061
#endif // TRANSACTIONAL_DELTA_CAPTURE_HPP

src/rules/refresh_insert_rule.cpp

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include "duckdb/planner/expression/bound_columnref_expression.hpp"
1919
#include "duckdb/planner/operator/logical_delete.hpp"
2020
#include "duckdb/planner/operator/logical_insert.hpp"
21+
#include "duckdb/planner/operator/logical_merge_into.hpp"
2122
#include "duckdb/planner/operator/logical_projection.hpp"
2223
#include "duckdb/planner/operator/logical_simple.hpp"
2324
#include "duckdb/planner/operator/logical_update.hpp"
@@ -297,13 +298,15 @@ void RefreshInsertRule::RefreshInsertRuleFunction(OptimizerExtensionInput &input
297298
return;
298299
}
299300

300-
auto dml = root;
301+
auto dml_owner = &plan;
302+
auto dml = dml_owner->get();
301303
while (dml->type != LogicalOperatorType::LOGICAL_INSERT && dml->type != LogicalOperatorType::LOGICAL_DELETE &&
302-
dml->type != LogicalOperatorType::LOGICAL_UPDATE) {
304+
dml->type != LogicalOperatorType::LOGICAL_UPDATE && dml->type != LogicalOperatorType::LOGICAL_MERGE_INTO) {
303305
if (dml->children.size() != 1) {
304306
return;
305307
}
306-
dml = dml->children[0].get();
308+
dml_owner = &dml->children[0];
309+
dml = dml_owner->get();
307310
}
308311

309312
switch (dml->type) {
@@ -361,6 +364,19 @@ void RefreshInsertRule::RefreshInsertRuleFunction(OptimizerExtensionInput &input
361364
OPENIVM_DEBUG_PRINT("[INSERT RULE] transactional UPDATE delta capture for '%s'\n", table_name.c_str());
362365
break;
363366
}
367+
case LogicalOperatorType::LOGICAL_MERGE_INTO: {
368+
auto &merge = dml->Cast<LogicalMergeInto>();
369+
const auto &table_name = merge.table.name;
370+
auto delta_table = TryGetTrackedDeltaTable(input.context, merge.table);
371+
if (!delta_table) {
372+
return;
373+
}
374+
auto capture = make_uniq<LogicalTransactionalMergeDeltaCapture>(merge.table, *delta_table);
375+
capture->children.push_back(std::move(*dml_owner));
376+
*dml_owner = std::move(capture);
377+
OPENIVM_DEBUG_PRINT("[INSERT RULE] transactional MERGE action delta capture for '%s'\n", table_name.c_str());
378+
break;
379+
}
364380
default:
365381
return;
366382
}

0 commit comments

Comments
 (0)