Skip to content

Commit ffb186d

Browse files
committed
Finish transactional MV lifecycle
1 parent 6d8d6a5 commit ffb186d

8 files changed

Lines changed: 242 additions & 70 deletions

File tree

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ maintenance path. A bug must not be hidden by weakening a test or silently chang
1919
decorator now captures only the INSERT, UPDATE, and DELETE rows selected by DuckDB after match and action predicates are
2020
resolved. Updates emit the required delete-old/insert-new pair, volatile defaults are evaluated once, `RETURNING` is
2121
preserved, and all writes remain in the base DML transaction. The same boundary also covers native `MERGE` actions.
22-
- [ ] **Make MV lifecycle and native refresh transactional.** CREATE, REPLACE, DROP, ALTER, and `PRAGMA refresh` currently
23-
execute material work through helper autocommit connections. Caller rollback cannot restore a consistent collection of
24-
user view, backing table, metadata, and deltas; failed replacement can destroy the old MV. Use the caller transaction for
25-
native-catalog work and stage cross-catalog replacement before publication.
22+
- [x] **Make MV lifecycle and native refresh transactional.** Native CREATE, REPLACE, DROP, ALTER, and refresh inside an
23+
explicit caller transaction now mutate the user view, backing state, metadata, and deltas atomically and retain their
24+
OpenIVM locks through commit or rollback. Autocommit refresh temporarily retains the established locked executor because
25+
DuckDB query-pragma preprocessing ends a transaction before its returned program completes; `refresh.cpp` records the
26+
native-operator follow-up. DuckLake replacement materializes data and auxiliary state under unpublished staging names
27+
before publishing them.
2628
- [ ] **Continue cascade traversal when the current node has no source deltas.** The root empty-delta fast path returns
2729
before visiting downstream nodes. A child can have independent source deltas or pending parent-delta rows after an earlier
2830
failed cascade. Model `node skipped` separately from `graph traversal complete` and checkpoint each node independently.

src/core/parser.cpp

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,12 @@ MaterializedViewParserExtension::PlanFunction(ParserExtensionInfo *info, ClientC
378378
select_planner.CreatePlan(std::move(select_parser.statements[0]));
379379
auto select_plan = std::move(select_planner.plan);
380380
visible_output_count = select_planner.names.size();
381+
for (auto &name : select_planner.names) {
382+
if (StringUtil::CIEquals(name, openivm::MULTIPLICITY_COL) ||
383+
StringUtil::CIEquals(name, openivm::TIMESTAMP_COL)) {
384+
throw BinderException("Materialized-view output uses reserved OpenIVM column '%s'", name);
385+
}
386+
}
381387
add_create_profile_step("create_compile_select_plan", select_parse_plan_start);
382388

383389
// Inline CTEs without running the full optimizer, which can reshape plans
@@ -1519,9 +1525,11 @@ string MaterializedViewLifecycleQuery(ClientContext &context, const FunctionPara
15191525
MaterializedViewParserExtension::PlanFunction(nullptr, context, std::move(parse_result.parse_data));
15201526
if (plan_result.function.name == OPENIVM_TRANSACTIONAL_DDL_FUNCTION) {
15211527
if (!lock_view_name.empty()) {
1522-
TransactionalMVLockState::Get(context).Acquire({lock_view_name}, {});
1528+
auto &default_entry = ClientData::Get(context).catalog_search_path->GetDefault();
1529+
auto &catalog = Catalog::GetCatalog(context, default_entry.catalog);
1530+
TransactionalMVLockState::Get(context).Acquire({lock_view_name}, {}, {&catalog});
15231531
}
1524-
return RenderTransactionalDDL(plan_result.parameters);
1532+
return RenderTransactionalDDL(context, plan_result.parameters);
15251533
}
15261534
if (!lock_view_name.empty()) {
15271535
ViewLockGuard view_guard(lock_view_name);
@@ -1606,7 +1614,8 @@ string MaterializedViewDropQuery(ClientContext &context, const FunctionParameter
16061614
}
16071615
auto view_delta_table = SqlUtils::DeltaName(drop.info->name);
16081616
delta_tables.push_back(view_delta_table);
1609-
TransactionalMVLockState::Get(context).Acquire({drop.info->name}, delta_tables);
1617+
auto &catalog = Catalog::GetCatalog(context, catalog_name);
1618+
TransactionalMVLockState::Get(context).Acquire({drop.info->name}, delta_tables, {&catalog});
16101619
return program;
16111620
}
16121621
} // namespace duckdb

src/core/parser_ddl.cpp

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -461,9 +461,23 @@ void ExecuteDropView(ClientContext &context, TableFunctionInput &input, DataChun
461461
state.finished = true;
462462
}
463463

464-
string RenderTransactionalDDL(const vector<Value> &parameters) {
464+
string RenderTransactionalDDL(ClientContext &context, const vector<Value> &parameters) {
465+
struct ProfileRow {
466+
string view_name;
467+
string step_name;
468+
int64_t duration_ms;
469+
string detail;
470+
};
471+
struct PendingProfileMarker {
472+
string view_name;
473+
string step_name;
474+
string detail;
475+
idx_t statement_start;
476+
};
477+
465478
string sql;
466-
auto append_statement = [&](const string &statement) {
479+
idx_t logical_statement_count = 0;
480+
auto append_statement = [&](const string &statement, bool logical_statement = true) {
467481
if (statement.empty()) {
468482
return;
469483
}
@@ -472,12 +486,57 @@ string RenderTransactionalDDL(const vector<Value> &parameters) {
472486
sql += ";";
473487
}
474488
sql += "\n";
489+
if (logical_statement) {
490+
logical_statement_count++;
491+
}
492+
};
493+
Value profile_value;
494+
bool profile_enabled = context.TryGetCurrentSetting("openivm_profile_refresh", profile_value) &&
495+
!profile_value.IsNull() && BooleanValue::Get(profile_value);
496+
vector<ProfileRow> profile_rows;
497+
unique_ptr<PendingProfileMarker> pending_profile;
498+
auto finish_profile_marker = [&]() {
499+
if (!pending_profile) {
500+
return;
501+
}
502+
auto statement_count = logical_statement_count - pending_profile->statement_start;
503+
auto detail = pending_profile->detail;
504+
if (!detail.empty()) {
505+
detail += "; ";
506+
}
507+
detail +=
508+
"statements=" + to_string(statement_count) + "; transactional_program=true; duration_not_measured=true";
509+
profile_rows.push_back({pending_profile->view_name, pending_profile->step_name, 0, std::move(detail)});
510+
pending_profile.reset();
475511
};
476512
for (auto &parameter : parameters) {
477513
auto statement = parameter.GetValue<string>();
478-
if (statement.empty() || StringUtil::StartsWith(statement, OPENIVM_DDL_CLEANUP_PREFIX) ||
479-
StringUtil::StartsWith(statement, OPENIVM_DDL_PROFILE_PREFIX) ||
480-
StringUtil::StartsWith(statement, OPENIVM_DDL_PROFILE_RECORD_PREFIX)) {
514+
if (statement.empty() || StringUtil::StartsWith(statement, OPENIVM_DDL_CLEANUP_PREFIX)) {
515+
continue;
516+
}
517+
if (StringUtil::StartsWith(statement, OPENIVM_DDL_PROFILE_RECORD_PREFIX)) {
518+
if (profile_enabled) {
519+
string view_name;
520+
string step_name;
521+
string detail;
522+
int64_t duration_ms = 0;
523+
ParseCreateMVProfileRecord(statement.substr(strlen(OPENIVM_DDL_PROFILE_RECORD_PREFIX)), view_name,
524+
step_name, duration_ms, detail);
525+
profile_rows.push_back({std::move(view_name), std::move(step_name), duration_ms, std::move(detail)});
526+
}
527+
continue;
528+
}
529+
if (StringUtil::StartsWith(statement, OPENIVM_DDL_PROFILE_PREFIX)) {
530+
if (profile_enabled) {
531+
finish_profile_marker();
532+
string view_name;
533+
string step_name;
534+
string detail;
535+
ParseCreateMVProfileMarker(statement.substr(strlen(OPENIVM_DDL_PROFILE_PREFIX)), view_name, step_name,
536+
detail);
537+
pending_profile = make_uniq<PendingProfileMarker>(PendingProfileMarker {
538+
std::move(view_name), std::move(step_name), std::move(detail), logical_statement_count});
539+
}
481540
continue;
482541
}
483542
if (StringUtil::StartsWith(statement, OPENIVM_DDL_CREATE_DELTA_FROM_DATA_PREFIX)) {
@@ -493,10 +552,11 @@ string RenderTransactionalDDL(const vector<Value> &parameters) {
493552
" AS SELECT *, 1::INTEGER AS " + string(openivm::MULTIPLICITY_COL) +
494553
", now()::TIMESTAMP AS " + string(openivm::TIMESTAMP_COL) + " FROM " + data_table +
495554
" LIMIT 0");
496-
append_statement("ALTER TABLE " + delta_table + " ALTER " + string(openivm::MULTIPLICITY_COL) +
497-
" SET DEFAULT 1");
555+
append_statement(
556+
"ALTER TABLE " + delta_table + " ALTER " + string(openivm::MULTIPLICITY_COL) + " SET DEFAULT 1", false);
498557
append_statement("ALTER TABLE " + delta_table + " ALTER " + string(openivm::TIMESTAMP_COL) +
499-
" SET DEFAULT now()");
558+
" SET DEFAULT now()",
559+
false);
500560
continue;
501561
}
502562

@@ -516,6 +576,27 @@ string RenderTransactionalDDL(const vector<Value> &parameters) {
516576
}
517577
append_statement(statement);
518578
}
579+
if (profile_enabled) {
580+
finish_profile_marker();
581+
if (!profile_rows.empty()) {
582+
auto view_name = profile_rows.front().view_name;
583+
profile_rows.push_back(
584+
{view_name, "create_mv_total", 0, "transactional_program=true; duration_not_measured=true"});
585+
auto now = std::chrono::steady_clock::now().time_since_epoch();
586+
auto refresh_id = view_name + "_create_tx_" +
587+
to_string(std::chrono::duration_cast<std::chrono::nanoseconds>(now).count());
588+
for (idx_t step_order = 0; step_order < profile_rows.size(); step_order++) {
589+
auto &row = profile_rows[step_order];
590+
append_statement("INSERT OR REPLACE INTO " + string(openivm::PROFILE_TABLE) +
591+
" (refresh_id, view_name, step_order, step_name, duration_ms, detail) VALUES ('" +
592+
SqlUtils::EscapeValue(refresh_id) + "', '" + SqlUtils::EscapeValue(row.view_name) +
593+
"', " + to_string(step_order) + ", '" + SqlUtils::EscapeValue(row.step_name) +
594+
"', " + to_string(row.duration_ms) + ", '" + SqlUtils::EscapeValue(row.detail) +
595+
"')",
596+
false);
597+
}
598+
}
599+
}
519600
append_statement("SELECT true AS \"MATERIALIZED VIEW CREATION\"");
520601
return sql;
521602
}

src/core/refresh_locks.cpp

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,38 +7,49 @@ std::unordered_map<string, unique_ptr<std::mutex>> RefreshLocks::view_mutexes_;
77
std::unordered_map<string, unique_ptr<std::mutex>> RefreshLocks::delta_mutexes_;
88
std::unordered_map<const Catalog *, unique_ptr<DeltaCatalogPhaseGate>> RefreshLocks::delta_catalog_gates_;
99

10-
void DeltaCatalogPhaseGate::EnterWrite() {
10+
static bool HasOtherOwner(const unordered_map<ClientContext *, idx_t> &owners, ClientContext &owner) {
11+
return !owners.empty() && (owners.size() > 1 || owners.find(&owner) == owners.end());
12+
}
13+
14+
void DeltaCatalogPhaseGate::EnterWrite(ClientContext &owner) {
1115
std::unique_lock<mutex> guard(lock);
1216
// Once a refresh is waiting, stop admitting new writers so the finite set of
13-
// active transactions can drain. Writers otherwise run concurrently.
14-
condition.wait(guard, [&]() { return active_refreshes == 0 && waiting_refreshes == 0; });
15-
active_writers++;
17+
// active transactions can drain. The refresh owner's own later DML remains
18+
// reentrant, which lets transaction-local DML and refresh compose safely.
19+
condition.wait(guard, [&]() {
20+
bool owner_is_active = active_writers.find(&owner) != active_writers.end() ||
21+
active_refreshes.find(&owner) != active_refreshes.end();
22+
return !HasOtherOwner(active_refreshes, owner) && (waiting_refreshes == 0 || owner_is_active);
23+
});
24+
active_writers[&owner]++;
1625
}
1726

18-
void DeltaCatalogPhaseGate::ExitWrite() {
27+
void DeltaCatalogPhaseGate::ExitWrite(ClientContext &owner) {
1928
lock_guard<mutex> guard(lock);
20-
D_ASSERT(active_writers > 0);
21-
active_writers--;
22-
if (active_writers == 0) {
23-
condition.notify_all();
29+
auto entry = active_writers.find(&owner);
30+
D_ASSERT(entry != active_writers.end() && entry->second > 0);
31+
if (--entry->second == 0) {
32+
active_writers.erase(entry);
2433
}
34+
condition.notify_all();
2535
}
2636

27-
void DeltaCatalogPhaseGate::EnterRefresh() {
37+
void DeltaCatalogPhaseGate::EnterRefresh(ClientContext &owner) {
2838
std::unique_lock<mutex> guard(lock);
2939
waiting_refreshes++;
30-
condition.wait(guard, [&]() { return active_writers == 0; });
40+
condition.wait(guard, [&]() { return !HasOtherOwner(active_writers, owner); });
3141
waiting_refreshes--;
32-
active_refreshes++;
42+
active_refreshes[&owner]++;
3343
}
3444

35-
void DeltaCatalogPhaseGate::ExitRefresh() {
45+
void DeltaCatalogPhaseGate::ExitRefresh(ClientContext &owner) {
3646
lock_guard<mutex> guard(lock);
37-
D_ASSERT(active_refreshes > 0);
38-
active_refreshes--;
39-
if (active_refreshes == 0) {
40-
condition.notify_all();
47+
auto entry = active_refreshes.find(&owner);
48+
D_ASSERT(entry != active_refreshes.end() && entry->second > 0);
49+
if (--entry->second == 0) {
50+
active_refreshes.erase(entry);
4151
}
52+
condition.notify_all();
4253
}
4354

4455
std::mutex &RefreshLocks::GetViewMutex(const string &view_name) {
@@ -88,27 +99,52 @@ void RefreshLocks::UnlockDelta(const string &delta_table_name) {
8899
GetDeltaMutex(delta_table_name).unlock();
89100
}
90101

91-
void RefreshLocks::EnterDeltaWrite(Catalog &catalog) {
92-
GetDeltaCatalogGate(catalog).EnterWrite();
102+
void RefreshLocks::EnterDeltaWrite(ClientContext &owner, Catalog &catalog) {
103+
GetDeltaCatalogGate(catalog).EnterWrite(owner);
93104
}
94105

95-
void RefreshLocks::ExitDeltaWrite(Catalog &catalog) {
96-
GetDeltaCatalogGate(catalog).ExitWrite();
106+
void RefreshLocks::ExitDeltaWrite(ClientContext &owner, Catalog &catalog) {
107+
GetDeltaCatalogGate(catalog).ExitWrite(owner);
97108
}
98109

99-
void RefreshLocks::EnterDeltaRefresh(Catalog &catalog) {
100-
GetDeltaCatalogGate(catalog).EnterRefresh();
110+
void RefreshLocks::EnterDeltaRefresh(ClientContext &owner, Catalog &catalog) {
111+
GetDeltaCatalogGate(catalog).EnterRefresh(owner);
101112
}
102113

103-
void RefreshLocks::ExitDeltaRefresh(Catalog &catalog) {
104-
GetDeltaCatalogGate(catalog).ExitRefresh();
114+
void RefreshLocks::ExitDeltaRefresh(ClientContext &owner, Catalog &catalog) {
115+
GetDeltaCatalogGate(catalog).ExitRefresh(owner);
105116
}
106117

107118
TransactionalMVLockState &TransactionalMVLockState::Get(ClientContext &context) {
108-
return *context.registered_state->GetOrCreate<TransactionalMVLockState>("openivm_transactional_mv_locks");
119+
auto state = context.registered_state->GetOrCreate<TransactionalMVLockState>("openivm_transactional_mv_locks");
120+
state->owner = &context;
121+
return *state;
122+
}
123+
124+
optional_ptr<TransactionalMVLockState> TransactionalMVLockState::TryGet(ClientContext &context) {
125+
return context.registered_state->Get<TransactionalMVLockState>("openivm_transactional_mv_locks");
126+
}
127+
128+
bool TransactionalMVLockState::OwnsRefreshDelta(Catalog &catalog, const string &delta_table_name) const {
129+
return locked_catalogs.find(&catalog) != locked_catalogs.end() &&
130+
locked_delta_tables.find(delta_table_name) != locked_delta_tables.end();
109131
}
110132

111-
void TransactionalMVLockState::Acquire(const vector<string> &view_names, const vector<string> &delta_table_names) {
133+
void TransactionalMVLockState::Acquire(const vector<string> &view_names, const vector<string> &delta_table_names,
134+
const vector<Catalog *> &source_catalogs) {
135+
if (!owner) {
136+
throw InternalException("OpenIVM transactional lock state has no owning client context");
137+
}
138+
auto sorted_catalogs = source_catalogs;
139+
std::sort(sorted_catalogs.begin(), sorted_catalogs.end(),
140+
[](Catalog *left, Catalog *right) { return left->GetName() < right->GetName(); });
141+
sorted_catalogs.erase(std::unique(sorted_catalogs.begin(), sorted_catalogs.end()), sorted_catalogs.end());
142+
for (auto catalog : sorted_catalogs) {
143+
if (locked_catalogs.insert(catalog).second) {
144+
catalog_guards.push_back(make_uniq<DeltaCatalogRefreshGuard>(*owner, *catalog));
145+
}
146+
}
147+
112148
auto sorted_views = view_names;
113149
std::sort(sorted_views.begin(), sorted_views.end());
114150
sorted_views.erase(std::unique(sorted_views.begin(), sorted_views.end()), sorted_views.end());
@@ -139,8 +175,10 @@ void TransactionalMVLockState::TransactionRollback(MetaTransaction &transaction,
139175
void TransactionalMVLockState::Release() {
140176
delta_guards.clear();
141177
view_guards.clear();
178+
catalog_guards.clear();
142179
locked_delta_tables.clear();
143180
locked_views.clear();
181+
locked_catalogs.clear();
144182
}
145183

146184
} // namespace duckdb

src/include/core/parser_ddl.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ enum class DDLExecutionMode : uint8_t { CALLER_TRANSACTION, STAGED_CROSS_CATALOG
1919

2020
void ConfigureDDLExecutorResult(ParserExtensionPlanResult &result,
2121
DDLExecutionMode mode = DDLExecutionMode::STAGED_CROSS_CATALOG);
22-
string RenderTransactionalDDL(const vector<Value> &parameters);
22+
string RenderTransactionalDDL(ClientContext &context, const vector<Value> &parameters);
2323
void ExecuteStagedDDL(ClientContext &context, const vector<Value> &parameters);
2424
string BuildCreateDeltaFromDataOperation(const string &delta_table, const string &data_table, bool replace);
2525
string BuildDropViewStatement(const DropInfo &drop_info);

0 commit comments

Comments
 (0)