Skip to content

Commit af343a1

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.
1 parent 87ed17c commit af343a1

7 files changed

Lines changed: 407 additions & 33 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
@@ -41,9 +41,13 @@ a subscribe that continually tracks the current state of the data.
4141
- Removing the in-process locks immediately. During rollout, the old lock-based
4242
path and the new OCC path coexist behind a feature flag. The locks can be
4343
removed once the OCC path is fully rolled out.
44-
- Mixed read/write transactions. A write on this path commits at the frontier it
45-
observed, which it cannot postpone until COMMIT, so it runs only as a single
46-
statement.
44+
- Mixed read/write transactions. A write that reads persisted state commits at
45+
the frontier it observed, which it cannot postpone until COMMIT, so it runs
46+
only as a single statement. A write that reads nothing does compose with
47+
transactions: its diffs are frontier-independent, so they are buffered as
48+
session write ops and land when the transaction commits. That covers, for
49+
example, `INSERT INTO t SELECT generate_series(1, 20000)`, whose values are
50+
constant but too large to fold into a literal.
4751

4852
## Overview
4953

src/adapter/src/client.rs

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1909,27 +1909,21 @@ impl SessionClient {
19091909
}
19101910
};
19111911

1912-
// Only single-statement (`Started`) transactions may enter the OCC
1913-
// loop, its writes commit immediately and cannot be rolled back at
1914-
// transaction end. Multi-statement transactions reach this point only
1915-
// for AST-constant INSERTs whose planned expression turned out
1916-
// non-constant. Match the coordinator's error precedence: `mz_now()`
1917-
// gets its dedicated error, everything else is prohibited in a
1918-
// transaction block. The coordinator's lock-based path additionally
1919-
// supports INSERTs of volatile constants (for example `random()`) in
1920-
// transaction blocks by buffering the diffs until commit, which the
1921-
// OCC path cannot do.
1912+
// The syntactic predicate for "reads persisted state", see the module
1913+
// docs on `frontend_read_then_write`. Inside a transaction, only a write
1914+
// that reads nothing can run on this path.
1915+
//
1916+
// The AST gate above is not enough to establish this. It admits
1917+
// INSERTs whose source is constant in the AST, and such a statement can
1918+
// still plan to a selection with `Get` nodes, because SQL-implemented
1919+
// builtins (`pg_get_viewdef`, `text` to `reg*` casts, ...) read system
1920+
// relations. So decide on the planned selection, and do it before we
1921+
// execute a dataflow for a statement we would then refuse.
19221922
{
19231923
let session = self.session.as_ref().expect("SessionClient invariant");
1924-
if !matches!(session.transaction(), TransactionStatus::Started(_)) {
1925-
let contains_temporal = rtw_plan.selection.contains_temporal()
1926-
|| rtw_plan.assignments.values().any(|e| e.contains_temporal())
1927-
|| rtw_plan.returning.iter().any(|e| e.contains_temporal());
1928-
if contains_temporal {
1929-
return Err(AdapterError::Unsupported(
1930-
"calls to mz_now in write statements",
1931-
));
1932-
}
1924+
if session.transaction().is_in_multi_statement_transaction()
1925+
&& !rtw_plan.selection.depends_on().is_empty()
1926+
{
19331927
return Err(prohibited_in_transaction(&stmt));
19341928
}
19351929
}

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};
@@ -4208,6 +4210,7 @@ impl Coordinator {
42084210
};
42094211
self.catalog_transact(Some(session), vec![op]).await?;
42104212

4213+
Self::notice_if_startup_only(session, &name);
42114214
session.add_notice(AdapterNotice::VarDefaultUpdated {
42124215
role: None,
42134216
var_name: Some(name),
@@ -4224,6 +4227,7 @@ impl Coordinator {
42244227
self.is_user_allowed_to_alter_system(session, Some(&name))?;
42254228
let op = catalog::Op::ResetSystemConfiguration { name: name.clone() };
42264229
self.catalog_transact(Some(session), vec![op]).await?;
4230+
Self::notice_if_startup_only(session, &name);
42274231
session.add_notice(AdapterNotice::VarDefaultUpdated {
42284232
role: None,
42294233
var_name: Some(name),
@@ -4238,15 +4242,79 @@ impl Coordinator {
42384242
_: plan::AlterSystemResetAllPlan,
42394243
) -> Result<ExecuteResponse, AdapterError> {
42404244
self.is_user_allowed_to_alter_system(session, None)?;
4245+
// Which parameters `RESET ALL` changes has to be read before the
4246+
// transaction applies it, afterwards they all read as their default.
4247+
let startup_only_changed = self.startup_only_vars_changed_by_reset_all();
42414248
let op = catalog::Op::ResetAllSystemConfiguration;
42424249
self.catalog_transact(Some(session), vec![op]).await?;
4250+
for name in startup_only_changed {
4251+
session.add_notice(AdapterNotice::StartupOnlyVarUpdated {
4252+
var_name: name.to_string(),
4253+
});
4254+
}
42434255
session.add_notice(AdapterNotice::VarDefaultUpdated {
42444256
role: None,
42454257
var_name: None,
42464258
});
42474259
Ok(ExecuteResponse::AlteredSystemConfiguration)
42484260
}
42494261

4262+
/// System parameters whose value `environmentd` samples once at startup.
4263+
///
4264+
/// `enable_adapter_frontend_occ_read_then_write` selects between the
4265+
/// lock-based and the OCC read-then-write path. Both are never live in one
4266+
/// process, so the choice is fixed at boot and every session inherits it.
4267+
/// `max_concurrent_occ_writes` sizes the OCC semaphore at boot.
4268+
///
4269+
/// `ALTER SYSTEM` on one of these is allowed to go through. The catalog
4270+
/// value is what the next process start reads, and the running process
4271+
/// cannot observe it, so there is no window where two code paths are live at
4272+
/// once.
4273+
fn startup_only_vars() -> [&'static str; 2] {
4274+
[
4275+
FRONTEND_READ_THEN_WRITE.name(),
4276+
MAX_CONCURRENT_OCC_WRITES.name(),
4277+
]
4278+
}
4279+
4280+
/// Warns that `name` is only read at startup, so the running process keeps
4281+
/// the value it sampled at boot.
4282+
fn notice_if_startup_only(session: &Session, name: &str) {
4283+
if Self::startup_only_vars()
4284+
.iter()
4285+
.any(|n| n.eq_ignore_ascii_case(name))
4286+
{
4287+
session.add_notice(AdapterNotice::StartupOnlyVarUpdated {
4288+
var_name: name.to_string(),
4289+
});
4290+
}
4291+
}
4292+
4293+
/// The startup-only parameters whose value `ALTER SYSTEM RESET ALL` would
4294+
/// change. Parameters already at their effective default are untouched, so
4295+
/// they are not reported.
4296+
fn startup_only_vars_changed_by_reset_all(&self) -> Vec<&'static str> {
4297+
// Value-based, unlike `notice_if_startup_only`, which warns whenever an
4298+
// operator names one of these parameters. `RESET ALL` names every
4299+
// parameter, so only a value that actually moves is worth a warning.
4300+
let config = self.catalog().system_config();
4301+
let defaults = config.defaults();
4302+
Self::startup_only_vars()
4303+
.into_iter()
4304+
.filter(|name| {
4305+
// Both names are registered system vars, a lookup failure
4306+
// would mean the definitions and this list have drifted apart.
4307+
let current = config
4308+
.get(name)
4309+
.expect("startup-only parameter is a registered system var")
4310+
.value();
4311+
defaults
4312+
.get(*name)
4313+
.is_some_and(|default| default != &current)
4314+
})
4315+
.collect()
4316+
}
4317+
42504318
// TODO(jkosh44) Move this into rbac.rs once RBAC is always on.
42514319
fn is_user_allowed_to_alter_system(
42524320
&self,

src/adapter/src/frontend_read_then_write.rs

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,13 @@ use mz_expr::Eval;
6767
use mz_expr::row::RowCollection;
6868
use mz_expr::{CollectionPlan, Id, LocalId, MirRelationExpr, MirScalarExpr, RowSetFinishing};
6969
use mz_ore::cast::CastFrom;
70-
use mz_ore::soft_panic_or_log;
70+
use mz_ore::{soft_assert_or_log, soft_panic_or_log};
7171
use mz_repr::optimize::OverrideFrom;
7272
use mz_repr::{CatalogItemId, Diff, GlobalId, RelationDesc, Row, RowArena, Timestamp};
7373
use mz_sql::catalog::CatalogError;
7474
use mz_sql::plan::{self, MutationKind, QueryWhen};
7575
use mz_sql::session::metadata::SessionMetadata;
76+
use mz_storage_client::client::TableData;
7677
use prometheus::Histogram;
7778
use timely::progress::Antichain;
7879
use tokio::sync::mpsc;
@@ -87,7 +88,7 @@ use crate::coord::{Coordinator, TargetCluster};
8788
use crate::error::AdapterError;
8889
use crate::optimize::Optimize;
8990
use crate::optimize::dataflows::{ComputeInstanceSnapshot, EvalTime, ExprPrep, ExprPrepOneShot};
90-
use crate::session::{Session, TransactionOps};
91+
use crate::session::{Session, TransactionOps, WriteOp};
9192
use crate::statement_logging::{StatementLifecycleEvent, StatementLoggingId};
9293
use crate::{PeekClient, PeekResponseUnary, TimelineContext, optimize};
9394

@@ -318,13 +319,17 @@ impl PeekClient {
318319
// write ops once we know them.
319320
session.add_transaction_ops(TransactionOps::Writes(vec![]))?;
320321

321-
// A write on this path commits immediately and cannot be rolled back at
322-
// transaction end, so only a single-statement transaction may reach it.
323-
// The check lives in `SessionClient::try_frontend_read_then_write`, and
324-
// this is defense in depth for it: rejecting here, before we run a
325-
// dataflow, is the last point where refusing is still possible.
326-
if session.transaction().is_in_multi_statement_transaction() {
327-
soft_panic_or_log!("read-then-write reached the OCC path inside a transaction");
322+
// Inside a transaction only a write that reads nothing gets here, see
323+
// the module docs. The syntactic check lives in
324+
// `SessionClient::try_frontend_read_then_write`.
325+
let defer_write = session.transaction().is_in_multi_statement_transaction();
326+
if defer_write && !depends_on.is_empty() {
327+
// Defense in depth for the check named above. Rejecting here, before
328+
// we run a dataflow, is the only place left where refusing is still
329+
// possible: past the OCC loop the write may already be durable.
330+
soft_panic_or_log!(
331+
"read-dependent read-then-write reached the OCC path inside a transaction"
332+
);
328333
return Err(AdapterError::Internal(
329334
"read-then-write cannot be run inside a transaction block".into(),
330335
));
@@ -506,10 +511,36 @@ impl PeekClient {
506511
let response = match result {
507512
Ok(OccOutcome::Committed { response, write_ts }) => {
508513
if let Some(write_ts) = write_ts {
514+
// A committed write timestamp inside a transaction means the
515+
// two predicates disagreed: the syntactic one let us defer,
516+
// the subscribe then read persisted state. The write is
517+
// already durable, so there is nothing to refuse, and
518+
// `apply_write` still has to run to keep the session's read
519+
// timestamps ahead of it.
520+
soft_assert_or_log!(
521+
!defer_write,
522+
"read-then-write committed a write inside a transaction"
523+
);
509524
session.apply_write(write_ts);
510525
}
511526
Ok(response)
512527
}
528+
Ok(OccOutcome::Blind { response, diffs }) if defer_write => {
529+
// NOTE: A buffered session write carries no target-generation
530+
// guard. The immediate path pins `target_global_id` and group
531+
// commit re-validates it, but a `WriteOp` only names the
532+
// `CatalogItemId` and commit staging resolves whatever global
533+
// id is current then. So an `ALTER TABLE ... ADD COLUMN` that
534+
// lands between here and COMMIT appends rows of the old arity
535+
// under the new schema. This holds for every buffered write,
536+
// not just ours.
537+
session
538+
.add_transaction_ops(TransactionOps::Writes(vec![WriteOp {
539+
id: target_id,
540+
rows: TableData::Rows(diffs),
541+
}]))
542+
.map(|()| response)
543+
}
513544
Ok(OccOutcome::Blind { response, diffs }) => {
514545
match self
515546
.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,
@@ -211,6 +217,7 @@ impl AdapterNotice {
211217
AdapterNotice::DroppedInUseIndex { .. } => Severity::Notice,
212218
AdapterNotice::PerReplicaLogRead { .. } => Severity::Notice,
213219
AdapterNotice::VarDefaultUpdated { .. } => Severity::Notice,
220+
AdapterNotice::StartupOnlyVarUpdated { .. } => Severity::Warning,
214221
AdapterNotice::Welcome(_) => Severity::Notice,
215222
AdapterNotice::PlanInsights(_) => Severity::Notice,
216223
AdapterNotice::IntrospectionClusterUsage => Severity::Warning,
@@ -323,6 +330,7 @@ impl AdapterNotice {
323330
AdapterNotice::WebhookSourceCreated { .. } => SqlState::SUCCESSFUL_COMPLETION,
324331
AdapterNotice::PerReplicaLogRead { .. } => SqlState::SUCCESSFUL_COMPLETION,
325332
AdapterNotice::VarDefaultUpdated { .. } => SqlState::SUCCESSFUL_COMPLETION,
333+
AdapterNotice::StartupOnlyVarUpdated { .. } => SqlState::WARNING,
326334
AdapterNotice::Welcome(_) => SqlState::SUCCESSFUL_COMPLETION,
327335
AdapterNotice::PlanInsights(_) => SqlState::from_code("MZ001"),
328336
AdapterNotice::IntrospectionClusterUsage => SqlState::WARNING,
@@ -508,6 +516,11 @@ impl fmt::Display for AdapterNotice {
508516
"{vars} updated for {target}, this will have no effect on the current session"
509517
)
510518
}
519+
AdapterNotice::StartupOnlyVarUpdated { var_name } => write!(
520+
f,
521+
"changes to {} only take effect when environmentd restarts",
522+
var_name.quoted()
523+
),
511524
AdapterNotice::Welcome(message) => message.fmt(f),
512525
AdapterNotice::PlanInsights(message) => message.fmt(f),
513526
AdapterNotice::IntrospectionClusterUsage => write!(

0 commit comments

Comments
 (0)