Skip to content

Commit 572c1f0

Browse files
committed
adapter: buffer a transaction's blind read-then-write until commit
A read-then-write inside a multi-statement transaction was refused outright, because a write that commits immediately cannot be rolled back at transaction end. That is the right answer only for a write that reads persisted state. One whose selection reads nothing produces diffs that are valid at any timestamp, so it can be buffered as a session write op and land with the rest of the transaction, which is what a constant INSERT already does. Two predicates decide it and they have to agree. Before planning, the syntactic one on `depends_on()` refuses a read-dependent write while refusing is still possible. During execution, the subscribe answers the same question dynamically, and the loop's `Committed` arm asserts it has no write timestamp to apply inside a transaction, which is that disagreement made observable. It is a soft assertion because the write is durable by then. `max_concurrent_occ_writes` is sampled once at startup, so `ALTER SYSTEM SET` on it silently did nothing. The statement is accepted and now warns that the change takes effect when environmentd restarts. `RESET ALL` names every parameter rather than one, so it compares values instead of names. The test for the parameter's domain constraint lands here too, though the constraint itself arrives with the path that reads the parameter. This PR's gate replaces the single-statement check with one keyed on `is_in_multi_statement_transaction`, which reports false for `Started`. An extended-protocol pipeline stays `Started` while it accumulates write ops, so the gate also has to treat a `Started` transaction holding ops as multi-statement. Otherwise a read-dependent write pipelined behind another statement's ops would still reach the OCC loop.
1 parent 3b65ce3 commit 572c1f0

7 files changed

Lines changed: 408 additions & 39 deletions

File tree

doc/developer/design/20260210_incremental_occ_read_then_write.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,13 @@ a subscribe that continually tracks the current state of the data.
4949
- Removing the in-process locks immediately. During rollout, the old lock-based
5050
path and the new OCC path coexist behind a feature flag. The locks can be
5151
removed once the OCC path is fully rolled out.
52-
- Mixed read/write transactions. A write on this path commits at the frontier it
53-
observed, which it cannot postpone until COMMIT, so it runs only as a single
54-
statement.
52+
- Mixed read/write transactions. A write that reads persisted state commits at
53+
the frontier it observed, which it cannot postpone until COMMIT, so it runs
54+
only as a single statement. A write that reads nothing does compose with
55+
transactions: its diffs are frontier-independent, so they are buffered as
56+
session write ops and land when the transaction commits. That covers, for
57+
example, `INSERT INTO t SELECT generate_series(1, 20000)`, whose values are
58+
constant but too large to fold into a literal.
5559

5660
## Overview
5761

src/adapter/src/client.rs

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1943,26 +1943,24 @@ impl SessionClient {
19431943
}
19441944
};
19451945

1946-
// Only single-statement (`Started` without staged write ops)
1947-
// transactions may enter the OCC loop, its writes commit immediately
1948-
// and cannot be rolled back at transaction end. Multi-statement
1949-
// transactions reach this point only for AST-constant INSERTs whose
1950-
// planned expression turned out non-constant. Match the coordinator's
1951-
// error precedence: `mz_now()` gets its dedicated error, everything
1952-
// else is prohibited in a transaction block. The coordinator's
1953-
// lock-based path additionally supports INSERTs of volatile constants
1954-
// (for example `random()`) in transaction blocks by buffering the diffs
1955-
// until commit, which the OCC path cannot do.
1946+
// The syntactic predicate for "reads persisted state", see the module
1947+
// docs on `frontend_read_then_write`. Inside a transaction, only a write
1948+
// that reads nothing can run on this path.
1949+
//
1950+
// The AST gate above is not enough to establish this. It admits
1951+
// INSERTs whose source is constant in the AST, and such a statement can
1952+
// still plan to a selection with `Get` nodes, because SQL-implemented
1953+
// builtins (`pg_get_viewdef`, `text` to `reg*` casts, ...) read system
1954+
// relations. So decide on the planned selection, and do it before we
1955+
// execute a dataflow for a statement we would then refuse.
19561956
{
19571957
let session = self.session.as_ref().expect("SessionClient invariant");
1958-
let single_statement = matches!(session.transaction(), TransactionStatus::Started(_))
1959-
&& !session.transaction().contains_ops();
1960-
if !single_statement {
1961-
if crate::frontend_read_then_write::contains_mz_now(&rtw_plan) {
1962-
return Err(AdapterError::Unsupported(
1963-
"calls to mz_now in write statements",
1964-
));
1965-
}
1958+
// `is_in_multi_statement_transaction` reports false for `Started`,
1959+
// but an extended-protocol pipeline stays `Started` while it
1960+
// accumulates write ops, and this statement runs alongside them.
1961+
let in_transaction = session.transaction().is_in_multi_statement_transaction()
1962+
|| session.transaction().contains_ops();
1963+
if in_transaction && !rtw_plan.selection.depends_on().is_empty() {
19661964
return Err(prohibited_in_transaction(&stmt));
19671965
}
19681966
}

src/adapter/src/coord/sequencer/inner.rs

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ use futures::{Future, StreamExt, future};
2020
use itertools::Itertools;
2121
use mz_adapter_types::compaction::CompactionWindow;
2222
use mz_adapter_types::connection::ConnectionId;
23-
use mz_adapter_types::dyncfgs::{ENABLE_PASSWORD_AUTH, READ_THEN_WRITE_MAX_DEPENDENCIES};
23+
use mz_adapter_types::dyncfgs::{
24+
ENABLE_PASSWORD_AUTH, FRONTEND_READ_THEN_WRITE, READ_THEN_WRITE_MAX_DEPENDENCIES,
25+
};
2426
use mz_catalog::memory::error::ErrorKind;
2527
use mz_catalog::memory::objects::{
2628
CatalogItem, Connection, DataSourceDesc, Sink, Source, Table, TableDataSource, Type,
@@ -75,7 +77,7 @@ use mz_sql::plan::{
7577
use mz_sql::session::metadata::SessionMetadata;
7678
use mz_sql::session::user::UserKind;
7779
use mz_sql::session::vars::{
78-
self, IsolationLevel, NETWORK_POLICY, OwnedVarInput, SCHEMA_ALIAS,
80+
self, IsolationLevel, MAX_CONCURRENT_OCC_WRITES, NETWORK_POLICY, OwnedVarInput, SCHEMA_ALIAS,
7981
TRANSACTION_ISOLATION_VAR_NAME, Var, VarError, VarInput,
8082
};
8183
use mz_sql::{plan, rbac};
@@ -4234,6 +4236,7 @@ impl Coordinator {
42344236
};
42354237
self.catalog_transact(Some(session), vec![op]).await?;
42364238

4239+
Self::notice_if_startup_only(session, &name);
42374240
session.add_notice(AdapterNotice::VarDefaultUpdated {
42384241
role: None,
42394242
var_name: Some(name),
@@ -4250,6 +4253,7 @@ impl Coordinator {
42504253
self.is_user_allowed_to_alter_system(session, Some(&name))?;
42514254
let op = catalog::Op::ResetSystemConfiguration { name: name.clone() };
42524255
self.catalog_transact(Some(session), vec![op]).await?;
4256+
Self::notice_if_startup_only(session, &name);
42534257
session.add_notice(AdapterNotice::VarDefaultUpdated {
42544258
role: None,
42554259
var_name: Some(name),
@@ -4264,15 +4268,79 @@ impl Coordinator {
42644268
_: plan::AlterSystemResetAllPlan,
42654269
) -> Result<ExecuteResponse, AdapterError> {
42664270
self.is_user_allowed_to_alter_system(session, None)?;
4271+
// Which parameters `RESET ALL` changes has to be read before the
4272+
// transaction applies it, afterwards they all read as their default.
4273+
let startup_only_changed = self.startup_only_vars_changed_by_reset_all();
42674274
let op = catalog::Op::ResetAllSystemConfiguration;
42684275
self.catalog_transact(Some(session), vec![op]).await?;
4276+
for name in startup_only_changed {
4277+
session.add_notice(AdapterNotice::StartupOnlyVarUpdated {
4278+
var_name: name.to_string(),
4279+
});
4280+
}
42694281
session.add_notice(AdapterNotice::VarDefaultUpdated {
42704282
role: None,
42714283
var_name: None,
42724284
});
42734285
Ok(ExecuteResponse::AlteredSystemConfiguration)
42744286
}
42754287

4288+
/// System parameters whose value `environmentd` samples once at startup.
4289+
///
4290+
/// `enable_adapter_frontend_occ_read_then_write` selects between the
4291+
/// lock-based and the OCC read-then-write path. Both are never live in one
4292+
/// process, so the choice is fixed at boot and every session inherits it.
4293+
/// `max_concurrent_occ_writes` sizes the OCC semaphore at boot.
4294+
///
4295+
/// `ALTER SYSTEM` on one of these is allowed to go through. The catalog
4296+
/// value is what the next process start reads, and the running process
4297+
/// cannot observe it, so there is no window where two code paths are live at
4298+
/// once.
4299+
fn startup_only_vars() -> [&'static str; 2] {
4300+
[
4301+
FRONTEND_READ_THEN_WRITE.name(),
4302+
MAX_CONCURRENT_OCC_WRITES.name(),
4303+
]
4304+
}
4305+
4306+
/// Warns that `name` is only read at startup, so the running process keeps
4307+
/// the value it sampled at boot.
4308+
fn notice_if_startup_only(session: &Session, name: &str) {
4309+
if Self::startup_only_vars()
4310+
.iter()
4311+
.any(|n| n.eq_ignore_ascii_case(name))
4312+
{
4313+
session.add_notice(AdapterNotice::StartupOnlyVarUpdated {
4314+
var_name: name.to_string(),
4315+
});
4316+
}
4317+
}
4318+
4319+
/// The startup-only parameters whose value `ALTER SYSTEM RESET ALL` would
4320+
/// change. Parameters already at their effective default are untouched, so
4321+
/// they are not reported.
4322+
fn startup_only_vars_changed_by_reset_all(&self) -> Vec<&'static str> {
4323+
// Value-based, unlike `notice_if_startup_only`, which warns whenever an
4324+
// operator names one of these parameters. `RESET ALL` names every
4325+
// parameter, so only a value that actually moves is worth a warning.
4326+
let config = self.catalog().system_config();
4327+
let defaults = config.defaults();
4328+
Self::startup_only_vars()
4329+
.into_iter()
4330+
.filter(|name| {
4331+
// Both names are registered system vars, a lookup failure
4332+
// would mean the definitions and this list have drifted apart.
4333+
let current = config
4334+
.get(name)
4335+
.expect("startup-only parameter is a registered system var")
4336+
.value();
4337+
defaults
4338+
.get(*name)
4339+
.is_some_and(|default| default != &current)
4340+
})
4341+
.collect()
4342+
}
4343+
42764344
// TODO(jkosh44) Move this into rbac.rs once RBAC is always on.
42774345
fn is_user_allowed_to_alter_system(
42784346
&self,

src/adapter/src/frontend_read_then_write.rs

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,17 @@
4646
//! The answer decides where the diffs go. Diffs from a selection that reads
4747
//! persisted state are only correct at the frontier they were observed at, so
4848
//! they commit inside the OCC loop. Diffs from a selection that reads nothing
49-
//! are frontier-independent, so the caller of the loop submits them right after
50-
//! it.
49+
//! are frontier-independent, so the caller of the loop either submits them
50+
//! right after it or, inside a multi-statement transaction, buffers them as
51+
//! session write ops that land at COMMIT.
5152
//!
52-
//! A write on this path commits immediately and cannot be rolled back at
53-
//! transaction end, so `frontend_read_then_write` refuses to run a dataflow
54-
//! inside a multi-statement transaction at all. That refusal sits behind the
55-
//! gate in `SessionClient::try_frontend_read_then_write` as defense in depth,
56-
//! and it is the last point where refusing is still possible.
53+
//! Disagreement is caught on both sides, and only one side can still refuse.
54+
//! `frontend_read_then_write` re-checks the syntactic predicate before running a
55+
//! dataflow, which catches a caller that skipped the gate. If the syntactic
56+
//! predicate were laxer than the dynamic one, that check would pass and the
57+
//! write would commit mid-transaction, so the loop's `Committed` arm soft-panics
58+
//! when it has a write timestamp to apply inside a transaction. By then the
59+
//! write is durable, so all that arm can do is make the disagreement loud.
5760
//!
5861
//! ## Rollout note
5962
//!
@@ -83,13 +86,14 @@ use mz_expr::Eval;
8386
use mz_expr::row::RowCollection;
8487
use mz_expr::{CollectionPlan, Id, LocalId, MirRelationExpr, MirScalarExpr, RowSetFinishing};
8588
use mz_ore::cast::CastFrom;
86-
use mz_ore::soft_panic_or_log;
89+
use mz_ore::{soft_assert_or_log, soft_panic_or_log};
8790
use mz_repr::optimize::OverrideFrom;
8891
use mz_repr::{CatalogItemId, Diff, GlobalId, RelationDesc, Row, RowArena, Timestamp};
8992
use mz_sql::catalog::CatalogError;
9093
use mz_sql::plan::{self, MutationKind, QueryWhen};
9194
use mz_sql::session::metadata::SessionMetadata;
9295
use mz_sql::session::vars::IsolationLevel;
96+
use mz_storage_client::client::TableData;
9397
use mz_storage_types::sources::Timeline;
9498
use prometheus::Histogram;
9599
use timely::progress::Antichain;
@@ -105,7 +109,7 @@ use crate::coord::{Coordinator, TargetCluster};
105109
use crate::error::AdapterError;
106110
use crate::optimize::Optimize;
107111
use crate::optimize::dataflows::{ComputeInstanceSnapshot, EvalTime, ExprPrep, ExprPrepOneShot};
108-
use crate::session::{Session, TransactionOps};
112+
use crate::session::{Session, TransactionOps, WriteOp};
109113
use crate::statement_logging::{StatementLifecycleEvent, StatementLoggingId};
110114
use crate::{PeekClient, PeekResponseUnary, TimelineContext, optimize};
111115

@@ -583,13 +587,17 @@ impl PeekClient {
583587
// write ops once we know them.
584588
session.add_transaction_ops(TransactionOps::Writes(vec![]))?;
585589

586-
// A write on this path commits immediately and cannot be rolled back at
587-
// transaction end, so only a single-statement transaction may reach it.
588-
// The check lives in `SessionClient::try_frontend_read_then_write`, and
589-
// this is defense in depth for it: rejecting here, before we run a
590-
// dataflow, is the last point where refusing is still possible.
591-
if session.transaction().is_in_multi_statement_transaction() {
592-
soft_panic_or_log!("read-then-write reached the OCC path inside a transaction");
590+
// Inside a transaction only a write that reads nothing gets here, see
591+
// the module docs. The syntactic check lives in
592+
// `SessionClient::try_frontend_read_then_write`.
593+
let defer_write = session.transaction().is_in_multi_statement_transaction();
594+
if defer_write && !depends_on.is_empty() {
595+
// Defense in depth for the check named above. Rejecting here, before
596+
// we run a dataflow, is the only place left where refusing is still
597+
// possible: past the OCC loop the write may already be durable.
598+
soft_panic_or_log!(
599+
"read-dependent read-then-write reached the OCC path inside a transaction"
600+
);
593601
return Err(AdapterError::Internal(
594602
"read-then-write cannot be run inside a transaction block".into(),
595603
));
@@ -801,6 +809,16 @@ impl PeekClient {
801809
let mut permit = Some(permit);
802810
let response = match result {
803811
Ok(OccOutcome::Committed { response, write_ts }) => {
812+
// A committed write timestamp inside a transaction means the
813+
// two predicates disagreed: the syntactic one let us defer,
814+
// the subscribe then read persisted state. The write is
815+
// already durable, so there is nothing to refuse, and
816+
// `apply_write` still has to run to keep the session's read
817+
// timestamps ahead of it.
818+
soft_assert_or_log!(
819+
!defer_write,
820+
"read-then-write committed a write inside a transaction"
821+
);
804822
session.apply_write(write_ts);
805823
Ok(response)
806824
}
@@ -832,6 +850,22 @@ impl PeekClient {
832850
None => Ok(response),
833851
}
834852
}
853+
Ok(OccOutcome::Blind { response, diffs }) if defer_write => {
854+
// NOTE: A buffered session write carries no target-generation
855+
// guard. The immediate path pins `target_global_id` and group
856+
// commit re-validates it, but a `WriteOp` only names the
857+
// `CatalogItemId` and commit staging resolves whatever global
858+
// id is current then. So an `ALTER TABLE ... ADD COLUMN` that
859+
// lands between here and COMMIT appends rows of the old arity
860+
// under the new schema. This holds for every buffered write,
861+
// not just ours.
862+
session
863+
.add_transaction_ops(TransactionOps::Writes(vec![WriteOp {
864+
id: target_id,
865+
rows: TableData::Rows(diffs),
866+
}]))
867+
.map(|()| response)
868+
}
835869
Ok(OccOutcome::Blind { response, diffs }) => {
836870
match self
837871
.submit_blind_write(

src/adapter/src/notice.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ pub enum AdapterNotice {
132132
role: Option<String>,
133133
var_name: Option<String>,
134134
},
135+
/// An `ALTER SYSTEM` statement named a system parameter that
136+
/// `environmentd` only reads at startup, so the running process keeps the
137+
/// value it sampled at boot.
138+
StartupOnlyVarUpdated {
139+
var_name: String,
140+
},
135141
Welcome(String),
136142
PlanInsights(String),
137143
IntrospectionClusterUsage,
@@ -217,6 +223,7 @@ impl AdapterNotice {
217223
AdapterNotice::DroppedInUseIndex { .. } => Severity::Notice,
218224
AdapterNotice::PerReplicaLogRead { .. } => Severity::Notice,
219225
AdapterNotice::VarDefaultUpdated { .. } => Severity::Notice,
226+
AdapterNotice::StartupOnlyVarUpdated { .. } => Severity::Warning,
220227
AdapterNotice::Welcome(_) => Severity::Notice,
221228
AdapterNotice::PlanInsights(_) => Severity::Notice,
222229
AdapterNotice::IntrospectionClusterUsage => Severity::Warning,
@@ -340,6 +347,7 @@ impl AdapterNotice {
340347
AdapterNotice::WebhookSourceCreated { .. } => SqlState::SUCCESSFUL_COMPLETION,
341348
AdapterNotice::PerReplicaLogRead { .. } => SqlState::SUCCESSFUL_COMPLETION,
342349
AdapterNotice::VarDefaultUpdated { .. } => SqlState::SUCCESSFUL_COMPLETION,
350+
AdapterNotice::StartupOnlyVarUpdated { .. } => SqlState::WARNING,
343351
AdapterNotice::Welcome(_) => SqlState::SUCCESSFUL_COMPLETION,
344352
AdapterNotice::PlanInsights(_) => SqlState::from_code("MZ001"),
345353
AdapterNotice::IntrospectionClusterUsage => SqlState::WARNING,
@@ -526,6 +534,11 @@ impl fmt::Display for AdapterNotice {
526534
"{vars} updated for {target}, this will have no effect on the current session"
527535
)
528536
}
537+
AdapterNotice::StartupOnlyVarUpdated { var_name } => write!(
538+
f,
539+
"changes to {} only take effect when environmentd restarts",
540+
var_name.quoted()
541+
),
529542
AdapterNotice::Welcome(message) => message.fmt(f),
530543
AdapterNotice::PlanInsights(message) => message.fmt(f),
531544
AdapterNotice::IntrospectionClusterUsage => write!(

0 commit comments

Comments
 (0)