Skip to content

Commit a451bc5

Browse files
committed
feat(pgwire): implement transactional DDL with AST-based dispatch
Wire transactional DDL semantics through the pgwire session layer: - `ddl_buffer`: thread-local buffer that intercepts `propose_catalog_entry` calls while a BEGIN block is active, accumulating encoded payloads instead of proposing them immediately. - `transaction_cmds`: COMMIT flushes the buffer as a single `MetadataEntry::Batch` proposal; ROLLBACK discards without proposing. - `metadata_proposer`: checks the thread-local buffer before proposing, returning early when a transaction is active. - `ast` + router fast path: parse DDL once into `NodedbStatement`, handle `IF [NOT] EXISTS` at dispatch before falling through to legacy string-prefix handlers.
1 parent 72eaecb commit a451bc5

6 files changed

Lines changed: 404 additions & 0 deletions

File tree

nodedb/src/control/metadata_proposer.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,15 @@ pub fn propose_catalog_entry_with_timeout(
176176
}
177177

178178
let payload = catalog_entry::encode(entry)?;
179+
180+
// DDL transaction buffer: if a transactional DDL session is
181+
// active on this thread (BEGIN ... COMMIT), buffer the payload
182+
// instead of proposing immediately. The buffered entries will
183+
// be proposed as a single MetadataEntry::Batch at COMMIT time.
184+
if crate::control::server::pgwire::session::ddl_buffer::try_buffer(payload.clone()) {
185+
return Ok(0);
186+
}
187+
179188
let metadata_entry = MetadataEntry::CatalogDdl { payload };
180189
let raw = encode_entry(&metadata_entry).map_err(|e| Error::Config {
181190
detail: format!("metadata entry encode: {e}"),
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
//! AST-based DDL dispatch — typed fast path.
2+
//!
3+
//! Runs before the legacy string-prefix routers. Handles
4+
//! `IF [NOT] EXISTS` at the dispatch level so individual handlers
5+
//! don't need to check. Falls through to legacy dispatch for
6+
//! `Other` variants and for statements where the typed path
7+
//! delegates to the existing handler (via `raw_sql`).
8+
9+
use pgwire::api::results::{Response, Tag};
10+
use pgwire::error::PgWireResult;
11+
12+
use nodedb_sql::ddl_ast::NodedbStatement;
13+
14+
use crate::control::security::identity::AuthenticatedIdentity;
15+
use crate::control::state::SharedState;
16+
17+
/// Try to dispatch a parsed `NodedbStatement`. Returns `Some` if
18+
/// fully handled, `None` if the statement should fall through to
19+
/// the legacy dispatch.
20+
pub(super) fn try_dispatch(
21+
state: &SharedState,
22+
identity: &AuthenticatedIdentity,
23+
stmt: &NodedbStatement,
24+
) -> Option<PgWireResult<Vec<Response>>> {
25+
match stmt {
26+
// ── IF NOT EXISTS: swallow duplicate-creation errors ──────
27+
NodedbStatement::CreateCollection {
28+
name,
29+
if_not_exists: true,
30+
..
31+
} => {
32+
if collection_exists(state, identity, name) {
33+
return Some(Ok(vec![Response::Execution(Tag::new("CREATE COLLECTION"))]));
34+
}
35+
None // fall through to legacy CREATE handler
36+
}
37+
38+
NodedbStatement::CreateSequence {
39+
name,
40+
if_not_exists: true,
41+
..
42+
} => {
43+
if sequence_exists(state, identity, name) {
44+
return Some(Ok(vec![Response::Execution(Tag::new("CREATE SEQUENCE"))]));
45+
}
46+
None
47+
}
48+
49+
// ── IF EXISTS: swallow not-found errors on DROP ──────────
50+
NodedbStatement::DropCollection {
51+
name,
52+
if_exists: true,
53+
} => {
54+
if !collection_exists(state, identity, name) {
55+
return Some(Ok(vec![Response::Execution(Tag::new("DROP COLLECTION"))]));
56+
}
57+
None
58+
}
59+
60+
NodedbStatement::DropIndex {
61+
if_exists: true, ..
62+
} => None, // legacy handler has its own check
63+
64+
NodedbStatement::DropTrigger {
65+
name,
66+
if_exists: true,
67+
..
68+
} => {
69+
if !trigger_exists(state, identity, name) {
70+
return Some(Ok(vec![Response::Execution(Tag::new("DROP TRIGGER"))]));
71+
}
72+
None
73+
}
74+
75+
NodedbStatement::DropSchedule {
76+
name,
77+
if_exists: true,
78+
} => {
79+
if !schedule_exists(state, identity, name) {
80+
return Some(Ok(vec![Response::Execution(Tag::new("DROP SCHEDULE"))]));
81+
}
82+
None
83+
}
84+
85+
NodedbStatement::DropSequence {
86+
name,
87+
if_exists: true,
88+
} => {
89+
if !sequence_exists(state, identity, name) {
90+
return Some(Ok(vec![Response::Execution(Tag::new("DROP SEQUENCE"))]));
91+
}
92+
None
93+
}
94+
95+
NodedbStatement::DropAlert {
96+
name,
97+
if_exists: true,
98+
} => {
99+
if !alert_exists(state, identity, name) {
100+
return Some(Ok(vec![Response::Execution(Tag::new("DROP ALERT"))]));
101+
}
102+
None
103+
}
104+
105+
NodedbStatement::DropRetentionPolicy {
106+
name,
107+
if_exists: true,
108+
} => {
109+
if !retention_policy_exists(state, identity, name) {
110+
return Some(Ok(vec![Response::Execution(Tag::new(
111+
"DROP RETENTION POLICY",
112+
))]));
113+
}
114+
None
115+
}
116+
117+
NodedbStatement::DropChangeStream {
118+
name,
119+
if_exists: true,
120+
} => {
121+
if !change_stream_exists(state, identity, name) {
122+
return Some(Ok(vec![Response::Execution(Tag::new(
123+
"DROP CHANGE STREAM",
124+
))]));
125+
}
126+
None
127+
}
128+
129+
NodedbStatement::DropMaterializedView {
130+
name,
131+
if_exists: true,
132+
} => {
133+
if !materialized_view_exists(state, identity, name) {
134+
return Some(Ok(vec![Response::Execution(Tag::new(
135+
"DROP MATERIALIZED VIEW",
136+
))]));
137+
}
138+
None
139+
}
140+
141+
NodedbStatement::DropContinuousAggregate {
142+
name,
143+
if_exists: true,
144+
} => {
145+
if !continuous_aggregate_exists(state, identity, name) {
146+
return Some(Ok(vec![Response::Execution(Tag::new(
147+
"DROP CONTINUOUS AGGREGATE",
148+
))]));
149+
}
150+
None
151+
}
152+
153+
NodedbStatement::DropRlsPolicy {
154+
if_exists: true, ..
155+
} => {
156+
// RLS policy existence check would need collection context;
157+
// fall through to legacy handler which already handles this.
158+
None
159+
}
160+
161+
NodedbStatement::DropConsumerGroup {
162+
if_exists: true, ..
163+
} => None, // legacy handler
164+
165+
// All other variants fall through to legacy dispatch.
166+
_ => None,
167+
}
168+
}
169+
170+
fn collection_exists(state: &SharedState, identity: &AuthenticatedIdentity, name: &str) -> bool {
171+
let Some(catalog) = state.credentials.catalog() else {
172+
return false;
173+
};
174+
let tid = identity.tenant_id.as_u32();
175+
matches!(catalog.get_collection(tid, name), Ok(Some(_)))
176+
}
177+
178+
fn trigger_exists(state: &SharedState, identity: &AuthenticatedIdentity, name: &str) -> bool {
179+
let Some(catalog) = state.credentials.catalog() else {
180+
return false;
181+
};
182+
let tid = identity.tenant_id.as_u32();
183+
matches!(catalog.get_trigger(tid, name), Ok(Some(_)))
184+
}
185+
186+
fn schedule_exists(state: &SharedState, identity: &AuthenticatedIdentity, name: &str) -> bool {
187+
let tid = identity.tenant_id.as_u32();
188+
state.schedule_registry.get(tid, name).is_some()
189+
}
190+
191+
fn sequence_exists(state: &SharedState, identity: &AuthenticatedIdentity, name: &str) -> bool {
192+
let tid = identity.tenant_id.as_u32();
193+
state.sequence_registry.exists(tid, name)
194+
}
195+
196+
fn alert_exists(state: &SharedState, identity: &AuthenticatedIdentity, name: &str) -> bool {
197+
let tid = identity.tenant_id.as_u32();
198+
state.alert_registry.get(tid, name).is_some()
199+
}
200+
201+
fn retention_policy_exists(
202+
state: &SharedState,
203+
identity: &AuthenticatedIdentity,
204+
name: &str,
205+
) -> bool {
206+
let tid = identity.tenant_id.as_u32();
207+
state.retention_policy_registry.get(tid, name).is_some()
208+
}
209+
210+
fn change_stream_exists(state: &SharedState, identity: &AuthenticatedIdentity, name: &str) -> bool {
211+
let tid = identity.tenant_id.as_u32();
212+
state.stream_registry.get(tid, name).is_some()
213+
}
214+
215+
fn materialized_view_exists(
216+
state: &SharedState,
217+
identity: &AuthenticatedIdentity,
218+
name: &str,
219+
) -> bool {
220+
let tid = identity.tenant_id.as_u32();
221+
state.mv_registry.get_def(tid, name).is_some()
222+
}
223+
224+
fn continuous_aggregate_exists(
225+
state: &SharedState,
226+
identity: &AuthenticatedIdentity,
227+
name: &str,
228+
) -> bool {
229+
let tid = identity.tenant_id.as_u32();
230+
state.mv_registry.get_def(tid, name).is_some()
231+
}

nodedb/src/control/server/pgwire/ddl/router/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod admin;
2+
mod ast;
23
mod auth;
34
mod collaborative;
45
mod dsl;
@@ -26,6 +27,18 @@ pub async fn dispatch(
2627
identity: &AuthenticatedIdentity,
2728
sql: &str,
2829
) -> Option<PgWireResult<Vec<Response>>> {
30+
// AST-typed fast path: parse once, handle IF [NOT] EXISTS at the
31+
// dispatch level, then fall through to legacy handlers for the
32+
// actual execution. This is the incremental migration path —
33+
// once every legacy handler has been ported to accept a typed
34+
// NodedbStatement, the string-prefix routers below can be
35+
// removed entirely.
36+
if let Some(stmt) = nodedb_sql::ddl_ast::parse(sql)
37+
&& let Some(r) = ast::try_dispatch(state, identity, &stmt)
38+
{
39+
return Some(r);
40+
}
41+
2942
let upper = sql.to_uppercase();
3043
let parts: Vec<&str> = sql.split_whitespace().collect();
3144

nodedb/src/control/server/pgwire/handler/transaction_cmds.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ impl NodeDbPgHandler {
1818
let next = self.state.wal.next_lsn();
1919
crate::types::Lsn::new(next.as_u64().saturating_sub(1))
2020
};
21+
crate::control::server::pgwire::session::ddl_buffer::activate();
2122
self.sessions.begin(addr, snapshot_lsn).map_err(|msg| {
2223
PgWireError::UserError(Box::new(ErrorInfo::new(
2324
"ERROR".to_owned(),
@@ -171,6 +172,35 @@ impl NodeDbPgHandler {
171172
}
172173
}
173174

175+
// Flush any buffered DDL entries as a single atomic batch.
176+
if let Some(payloads) = crate::control::server::pgwire::session::ddl_buffer::take()
177+
&& !payloads.is_empty()
178+
{
179+
use nodedb_cluster::{MetadataEntry, encode_entry};
180+
let sub_entries: Vec<MetadataEntry> = payloads
181+
.into_iter()
182+
.map(|p| MetadataEntry::CatalogDdl { payload: p })
183+
.collect();
184+
let batch = MetadataEntry::Batch {
185+
entries: sub_entries,
186+
};
187+
if let Some(handle) = self.state.metadata_raft.get() {
188+
let raw = encode_entry(&batch).map_err(|e| {
189+
PgWireError::UserError(Box::new(ErrorInfo::new(
190+
"ERROR".to_owned(),
191+
"XX000".to_owned(),
192+
format!("DDL batch encode: {e}"),
193+
)))
194+
})?;
195+
handle.propose(raw).map_err(|e| {
196+
PgWireError::UserError(Box::new(ErrorInfo::new(
197+
"ERROR".to_owned(),
198+
"XX000".to_owned(),
199+
format!("DDL batch propose: {e}"),
200+
)))
201+
})?;
202+
}
203+
}
174204
// Close non-WITH-HOLD cursors on transaction end.
175205
self.sessions.close_non_hold_cursors(addr);
176206
Ok(vec![Response::Execution(Tag::new("COMMIT"))])
@@ -182,6 +212,7 @@ impl NodeDbPgHandler {
182212
identity: &AuthenticatedIdentity,
183213
addr: &std::net::SocketAddr,
184214
) -> PgWireResult<Vec<Response>> {
215+
crate::control::server::pgwire::session::ddl_buffer::discard();
185216
let reservations = self.sessions.rollback(addr).unwrap_or_default();
186217
for handle in &reservations {
187218
let key = &handle.sequence_key;

0 commit comments

Comments
 (0)