Skip to content

Commit cf8e804

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 parameter also gets a domain constraint of at least 1, since zero permits would leave every read-then-write waiting out its `statement_timeout`.
1 parent 67ece8e commit cf8e804

6 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
));
@@ -497,10 +502,36 @@ impl PeekClient {
497502
let response = match result {
498503
Ok(OccOutcome::Committed { response, write_ts }) => {
499504
if let Some(write_ts) = write_ts {
505+
// A committed write timestamp inside a transaction means the
506+
// two predicates disagreed: the syntactic one let us defer,
507+
// the subscribe then read persisted state. The write is
508+
// already durable, so there is nothing to refuse, and
509+
// `apply_write` still has to run to keep the session's read
510+
// timestamps ahead of it.
511+
soft_assert_or_log!(
512+
!defer_write,
513+
"read-then-write committed a write inside a transaction"
514+
);
500515
session.apply_write(write_ts);
501516
}
502517
Ok(response)
503518
}
519+
Ok(OccOutcome::Blind { response, diffs }) if defer_write => {
520+
// NOTE: A buffered session write carries no target-generation
521+
// guard. The immediate path pins `target_global_id` and group
522+
// commit re-validates it, but a `WriteOp` only names the
523+
// `CatalogItemId` and commit staging resolves whatever global
524+
// id is current then. So an `ALTER TABLE ... ADD COLUMN` that
525+
// lands between here and COMMIT appends rows of the old arity
526+
// under the new schema. This holds for every buffered write,
527+
// not just ours.
528+
session
529+
.add_transaction_ops(TransactionOps::Writes(vec![WriteOp {
530+
id: target_id,
531+
rows: TableData::Rows(diffs),
532+
}]))
533+
.map(|()| response)
534+
}
504535
Ok(OccOutcome::Blind { response, diffs }) => {
505536
match self
506537
.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)