Skip to content

Commit 27c3b32

Browse files
aljoschaclaude
andauthored
adapter: route SQL EXECUTE through frontend sequencing (#36453)
A prepared statement invoked via SQL `EXECUTE foo` previously skipped frontend sequencing in the session task and was re-dispatched via `Command::Execute` from the coordinator's `Plan::Execute` handler. That made `EXECUTE foo` and the same statement issued directly take divergent routes through the adapter. Unroll `Statement::Execute` in the session client: resolve the prepared statement, install a fresh portal for the inner statement, then run `try_frontend_peek` against that portal. Statement logging still begins on the outer `EXECUTE foo (...)`, so `mz_statement_execution_history` records the user-visible SQL text and pgwire parameters rather than the inner statement. Needed for the upcoming OCC-based read-then-write path: without this, a read-then-write issued via SQL EXECUTE would bypass frontend sequencing and reach the coordinator's legacy lock-based implementation, and only one of the two can safely run at a time. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ad4aaae commit 27c3b32

2 files changed

Lines changed: 259 additions & 5 deletions

File tree

src/adapter/src/client.rs

Lines changed: 254 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ use mz_ore::collections::CollectionExt;
3030
use mz_ore::id_gen::{IdAllocator, IdAllocatorInnerBitSet, MAX_ORG_ID, org_id_conn_bits};
3131
use mz_ore::instrument;
3232
use mz_ore::now::{EpochMillis, NowFn, to_datetime};
33+
use mz_ore::str::StrExt;
3334
use mz_ore::task::AbortOnDropHandle;
3435
use mz_ore::thread::JoinOnDropHandle;
3536
use mz_ore::tracing::OpenTelemetryContext;
@@ -57,7 +58,7 @@ use crate::command::{
5758
};
5859
use crate::coord::{Coordinator, ExecuteContextGuard};
5960
use crate::error::AdapterError;
60-
use crate::metrics::Metrics;
61+
use crate::metrics::{self, Metrics};
6162
use crate::session::{
6263
EndTransactionAction, PreparedStatement, Session, SessionConfig, StateRevision, TransactionId,
6364
};
@@ -735,12 +736,30 @@ impl SessionClient {
735736
) -> Result<(ExecuteResponse, Instant), AdapterError> {
736737
let execute_started = Instant::now();
737738

739+
let mut outer_ctx_extra = outer_ctx_extra;
740+
741+
// Unroll SQL `EXECUTE <prepared> (...)` so the inner statement
742+
// flows through `try_frontend_peek` below, rather than being
743+
// re-dispatched via `Command::Execute` from the coordinator's
744+
// `Plan::Execute` handler. Without this, a prepared statement
745+
// would route differently from the same statement issued
746+
// directly.
747+
//
748+
// On a successful unroll, `unroll_sql_execute` also returns a
749+
// catalog snapshot (threaded through to avoid taking a second
750+
// one) and begins EXECUTE-level statement logging on the outer
751+
// portal — so `mz_statement_execution_history` records
752+
// `EXECUTE foo (...)`, not the inner SQL — installing the
753+
// resulting `ExecuteContextGuard` into `outer_ctx_extra`.
754+
let (portal_name, catalog) = self
755+
.unroll_sql_execute(portal_name, &mut outer_ctx_extra)
756+
.await?;
757+
738758
// Attempt peek sequencing in the session task.
739759
// If unsupported, fall back to the Coordinator path.
740760
// TODO(peek-seq): wire up cancel_future
741-
let mut outer_ctx_extra = outer_ctx_extra;
742761
let peek_result = self
743-
.try_frontend_peek(&portal_name, &mut outer_ctx_extra)
762+
.try_frontend_peek(&portal_name, catalog, &mut outer_ctx_extra)
744763
.await?;
745764
if let Some(resp) = peek_result {
746765
debug!("frontend peek succeeded");
@@ -769,6 +788,236 @@ impl SessionClient {
769788
Ok((response, execute_started))
770789
}
771790

791+
/// If the named portal binds a SQL `EXECUTE <prepared>`, resolve the
792+
/// prepared statement, install a fresh portal for the inner statement
793+
/// (carrying the EXECUTE's actual parameter values), and return that
794+
/// portal's name so the caller can run `try_frontend_peek` against it.
795+
///
796+
/// Only ever unrolls one level: the parser rejects
797+
/// `PREPARE foo AS EXECUTE bar` (matching Postgres), so the inner
798+
/// statement is guaranteed not to be another `EXECUTE`. A failsafe below
799+
/// surfaces an internal error if that invariant is ever violated.
800+
///
801+
/// When the portal does not bind an `EXECUTE` — the common case —
802+
/// returns the original portal name and `None`, costing only a portal
803+
/// lookup. On unroll, also returns the catalog snapshot taken here so
804+
/// the caller can thread it into `try_frontend_peek`, which reuses it
805+
/// instead of taking its own.
806+
async fn unroll_sql_execute(
807+
&mut self,
808+
portal_name: String,
809+
outer_ctx_extra: &mut Option<ExecuteContextGuard>,
810+
) -> Result<(String, Option<Arc<Catalog>>), AdapterError> {
811+
let (stmt, params, outer_logging, outer_lifecycle_timestamps) = {
812+
let session = self.session.as_ref().expect("SessionClient invariant");
813+
let portal = match session.get_portal_unverified(&portal_name) {
814+
Some(p) => p,
815+
// No portal: let `try_frontend_peek` surface the
816+
// standard "missing portal" error.
817+
None => return Ok((portal_name, None)),
818+
};
819+
match &portal.stmt {
820+
Some(stmt) => (
821+
Arc::clone(stmt),
822+
portal.parameters.clone(),
823+
Arc::clone(&portal.logging),
824+
portal.lifecycle_timestamps.clone(),
825+
),
826+
None => return Ok((portal_name, None)),
827+
}
828+
};
829+
830+
// Only EXECUTE statements need unrolling. Bail out before taking a
831+
// catalog snapshot in the (overwhelmingly common) non-EXECUTE case.
832+
if !matches!(&*stmt, Statement::Execute(_)) {
833+
return Ok((portal_name, None));
834+
}
835+
836+
let catalog = self.catalog_snapshot("unroll_sql_execute").await;
837+
838+
// Validate the outer EXECUTE portal against the (possibly newer)
839+
// catalog: ensures the recorded portal description still matches
840+
// what describing the EXECUTE would produce now.
841+
{
842+
let session = self.session.as_mut().expect("SessionClient invariant");
843+
Coordinator::verify_portal(&catalog, session, &portal_name)?;
844+
}
845+
846+
// Bump query_total for the outer EXECUTE itself. The inner
847+
// statement gets its own increment inside `try_frontend_peek_inner`
848+
// (or, on bailout, in the coordinator's `handle_execute`).
849+
{
850+
let session = self.session.as_ref().expect("SessionClient invariant");
851+
session
852+
.metrics()
853+
.query_total(&[
854+
metrics::session_type_label_value(session.user()),
855+
metrics::statement_type_label_value(&stmt),
856+
])
857+
.inc();
858+
}
859+
860+
// Begin EXECUTE-level statement logging up front, so that planning
861+
// errors below produce an `Errored` end-event in
862+
// `mz_statement_execution_history` rather than no entry at all.
863+
//
864+
// We pass the *outer* portal's `logging` and pgwire-bound `params`
865+
// so the recorded entry shows the user-visible `EXECUTE foo (...)`,
866+
// not the inner SQL. The id (if any) moves into `outer_ctx_extra`
867+
// below for `try_frontend_peek` to retire; on planning error we
868+
// explicitly emit an `Errored` end-event below.
869+
let began_outer_logging = outer_ctx_extra.is_none();
870+
let logging_id: Option<crate::statement_logging::StatementLoggingId> =
871+
if began_outer_logging {
872+
let session = self.session.as_mut().expect("SessionClient invariant");
873+
let result = self
874+
.peek_client
875+
.statement_logging_frontend
876+
.begin_statement_execution(
877+
session,
878+
&params,
879+
&outer_logging,
880+
catalog.system_config(),
881+
outer_lifecycle_timestamps,
882+
);
883+
if let Some((id, began_execution, mseh_update, prepared_statement)) = result {
884+
self.peek_client.log_began_execution(
885+
began_execution,
886+
mseh_update,
887+
prepared_statement,
888+
);
889+
Some(id)
890+
} else {
891+
None
892+
}
893+
} else {
894+
None
895+
};
896+
897+
let new_portal_name = match self.install_inner_portal_for_execute(&catalog, &stmt, &params)
898+
{
899+
Ok(name) => name,
900+
Err(err) => {
901+
if let Some(id) = logging_id {
902+
self.peek_client.log_ended_execution(
903+
id,
904+
StatementEndedExecutionReason::Errored {
905+
error: err.to_string(),
906+
},
907+
);
908+
}
909+
return Err(err);
910+
}
911+
};
912+
913+
// Hand off to `outer_ctx_extra` whenever we entered the begin path
914+
// for the outer EXECUTE — even if `begin_statement_execution`
915+
// returned `None` (sampling decided not to sample, or logging is
916+
// disabled for the user). This mirrors the original coord path,
917+
// which always installs a guard via
918+
// `ExecuteContextGuard::new(maybe_uuid, ...)`. Without this, the
919+
// inner portal would be treated as a fresh statement by
920+
// `try_frontend_peek` (or the fallback `Command::Execute` path)
921+
// and re-account its bytes against
922+
// `mz_statement_logging_unsampled_bytes`, double-counting the
923+
// inner SQL.
924+
if began_outer_logging {
925+
// Soft invariant: `try_frontend_peek` takes ownership of
926+
// `outer_ctx_extra` immediately, so this guard's `Drop` is
927+
// unreachable on the normal flow and the dummy channel is
928+
// never used. If a panic does fire `Drop` between here and
929+
// that takeover, the `Aborted` end-event is silently lost
930+
// — an acceptable trade given the panic implies the
931+
// connection is going down anyway.
932+
let (dummy_tx, _dummy_rx) = mpsc::unbounded_channel();
933+
*outer_ctx_extra = Some(ExecuteContextGuard::new(logging_id, dummy_tx));
934+
}
935+
936+
Ok((new_portal_name, Some(catalog)))
937+
}
938+
939+
/// Helper for [`Self::unroll_sql_execute`]: plans the outer
940+
/// `Statement::Execute`, verifies the referenced prepared statement, and
941+
/// installs a fresh portal carrying the inner statement plus the
942+
/// EXECUTE's bound parameter values. Returns the new portal's name.
943+
///
944+
/// Split out so [`Self::unroll_sql_execute`] can wrap the fallible work
945+
/// in a single error-handling site that emits an `Errored` end-event
946+
/// for the EXECUTE-level statement-logging entry.
947+
fn install_inner_portal_for_execute(
948+
&mut self,
949+
catalog: &Arc<Catalog>,
950+
stmt: &Arc<Statement<Raw>>,
951+
params: &mz_sql::plan::Params,
952+
) -> Result<String, AdapterError> {
953+
use mz_sql::plan::Plan;
954+
955+
let execute_plan = {
956+
let session = self.session.as_mut().expect("SessionClient invariant");
957+
let conn_catalog = catalog.for_session(session);
958+
let (resolved_stmt, resolved_ids) =
959+
mz_sql::names::resolve(&conn_catalog, (**stmt).clone())?;
960+
let pcx = session.pcx();
961+
let plan = mz_sql::plan::plan(
962+
Some(pcx),
963+
&conn_catalog,
964+
resolved_stmt,
965+
params,
966+
&resolved_ids,
967+
)?;
968+
match plan {
969+
Plan::Execute(plan) => plan,
970+
other => {
971+
// Planning a `Statement::Execute` must yield
972+
// `Plan::Execute`. If it doesn't, the planner
973+
// contract is broken.
974+
return Err(AdapterError::Internal(format!(
975+
"planning Statement::Execute yielded unexpected plan: {:?}",
976+
mz_sql::plan::PlanKind::from(&other),
977+
)));
978+
}
979+
}
980+
};
981+
982+
// Verify and install the inner portal. Mirrors
983+
// `Coordinator::sequence_execute`. The new portal carries the inner
984+
// prepared statement's `logging`, but `try_frontend_peek` will see
985+
// `outer_ctx_extra=Some(...)` and inherit the EXECUTE-level logging
986+
// instead of starting fresh from this portal.
987+
let session = self.session.as_mut().expect("SessionClient invariant");
988+
Coordinator::verify_prepared_statement(catalog, session, &execute_plan.name)?;
989+
let ps = session
990+
.get_prepared_statement_unverified(&execute_plan.name)
991+
.expect("verified above");
992+
let inner_stmt = ps.stmt().cloned();
993+
let inner_desc = ps.desc().clone();
994+
let state_revision = ps.state_revision;
995+
let inner_logging = Arc::clone(ps.logging());
996+
997+
// Failsafe: `PREPARE foo AS EXECUTE bar` is rejected by the parser,
998+
// so the resolved inner statement must not be another `EXECUTE`. If
999+
// that ever changes, we'd silently skip frontend sequencing for the
1000+
// deeper EXECUTEs — surface it as an internal error instead.
1001+
if let Some(inner) = inner_stmt.as_ref() {
1002+
if matches!(inner, Statement::Execute(_)) {
1003+
return Err(AdapterError::Internal(format!(
1004+
"nested EXECUTE: prepared statement {} resolves to another EXECUTE; \
1005+
parser should reject `PREPARE ... AS EXECUTE ...`",
1006+
execute_plan.name.quoted(),
1007+
)));
1008+
}
1009+
}
1010+
1011+
session.create_new_portal(
1012+
inner_stmt,
1013+
inner_logging,
1014+
inner_desc,
1015+
execute_plan.params,
1016+
Vec::new(),
1017+
state_revision,
1018+
)
1019+
}
1020+
7721021
fn now(&self) -> EpochMillis {
7731022
(self.inner().now)()
7741023
}
@@ -1172,12 +1421,13 @@ impl SessionClient {
11721421
pub(crate) async fn try_frontend_peek(
11731422
&mut self,
11741423
portal_name: &str,
1424+
catalog: Option<Arc<Catalog>>,
11751425
outer_ctx_extra: &mut Option<ExecuteContextGuard>,
11761426
) -> Result<Option<ExecuteResponse>, AdapterError> {
11771427
if self.enable_frontend_peek_sequencing {
11781428
let session = self.session.as_mut().expect("SessionClient invariant");
11791429
self.peek_client
1180-
.try_frontend_peek(portal_name, session, outer_ctx_extra)
1430+
.try_frontend_peek(portal_name, catalog, session, outer_ctx_extra)
11811431
.await
11821432
} else {
11831433
Ok(None)

src/adapter/src/frontend_peek.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ impl PeekClient {
7777
pub(crate) async fn try_frontend_peek(
7878
&mut self,
7979
portal_name: &str,
80+
catalog: Option<Arc<Catalog>>,
8081
session: &mut Session,
8182
outer_ctx_extra: &mut Option<ExecuteContextGuard>,
8283
) -> Result<Option<ExecuteResponse>, AdapterError> {
@@ -101,7 +102,10 @@ impl PeekClient {
101102
// catalog revision has changed, which we could see with an atomic read.
102103
// But anyhow, this problem will just go away when we reach the point that we never fall
103104
// back to the old sequencing.
104-
let catalog = self.catalog_snapshot("try_frontend_peek").await;
105+
let catalog = match catalog {
106+
Some(c) => c,
107+
None => self.catalog_snapshot("try_frontend_peek").await,
108+
};
105109

106110
// Extract things from the portal.
107111
let (stmt, params, logging, lifecycle_timestamps) = {

0 commit comments

Comments
 (0)