Skip to content

Commit 18b429a

Browse files
committed
adapter: let the controller own every cluster reshape
Route zero-timeout ALTER CLUSTER requests through the same durable reconfiguration record as every other shape change. Cluster catalog writes wake the controller immediately, and a forced cut-over waits only for the target set to materialize, not for it to hydrate. This removes the sequencer's replica reconciliation path and lets the complete strategy set decide the replica set. In particular, an active hydration-burst replica survives a forced cut-over without a cold drop-and-recreate cycle. Make create-time scoped configuration an invariant of the coordinator's catalog transaction machinery. It derives contexts from concrete cluster and replica create ops and folds the parameter update into the transaction, regardless of whether SQL or the controller produced those ops.
1 parent e456df6 commit 18b429a

22 files changed

Lines changed: 684 additions & 676 deletions

File tree

doc/developer/design/20260522_cluster_autoscaling.md

Lines changed: 8 additions & 8 deletions
Large diffs are not rendered by default.

doc/user/content/sql/alter-cluster.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ You can monitor a resize through the following:
221221

222222
##### Cancel a resize
223223
To **cancel** an in-flight resize, reissue `ALTER CLUSTER` with the cluster's
224-
current size. Materialize drops the pending replicas and keeps the current
224+
current size. Materialize drops the target replicas and keeps the current
225225
configuration.
226226

227227
#### Downtime considerations for v26.34 or before

src/adapter-types/src/cluster_state.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ pub enum BurstFinishCause {
175175
/// policy was removed or its hydration size changed, the cluster was
176176
/// turned off, or burst was disabled environment-wide.
177177
NoLongerWarranted,
178+
/// The controller could not provision the desired replica set within the
179+
/// resource budget and shed the burst.
180+
ResourceExhausted,
178181
}
179182

180183
/// The action a graceful reconfiguration applies once its `deadline` passes with

src/adapter/src/catalog/transact.rs

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,6 @@ pub enum Op {
242242
/// this write, alongside the record carried in `config`.
243243
burst_audit: Option<BurstAudit>,
244244
},
245-
UpdateClusterReplicaConfig {
246-
cluster_id: ClusterId,
247-
replica_id: ReplicaId,
248-
config: ReplicaConfig,
249-
},
250245
UpdateItem {
251246
id: CatalogItemId,
252247
name: QualifiedItemName,
@@ -635,6 +630,7 @@ impl Catalog {
635630
let cause = match cause {
636631
BurstFinishCause::LingerElapsed => BurstFinishCauseV1::LingerElapsed,
637632
BurstFinishCause::NoLongerWarranted => BurstFinishCauseV1::NoLongerWarranted,
633+
BurstFinishCause::ResourceExhausted => BurstFinishCauseV1::ResourceExhausted,
638634
};
639635
(
640636
HydrationBurstLifecycleV1::Finished,
@@ -2928,24 +2924,6 @@ impl Catalog {
29282924
)?;
29292925
}
29302926
}
2931-
Op::UpdateClusterReplicaConfig {
2932-
replica_id,
2933-
cluster_id,
2934-
config,
2935-
} => {
2936-
let replica = state.get_cluster_replica(cluster_id, replica_id).to_owned();
2937-
info!("update replica {}", replica.name);
2938-
tx.update_cluster_replica(
2939-
replica_id,
2940-
mz_catalog::durable::ClusterReplica {
2941-
cluster_id,
2942-
replica_id,
2943-
name: replica.name.clone(),
2944-
config: config.clone().into(),
2945-
owner_id: replica.owner_id,
2946-
},
2947-
)?;
2948-
}
29492927
Op::UpdateItem { id, name, to_item } => {
29502928
// A non-temporary item must not depend on a temporary one.
29512929
// Temporary objects are session-scoped and disappear with
@@ -4014,6 +3992,21 @@ mod tests {
40143992
);
40153993
assert_eq!(finished.burst_size, "large");
40163994

3995+
let resource_exhausted = Catalog::burst_audit_details(
3996+
&managed(Some(record())),
3997+
&managed(None),
3998+
cluster_id,
3999+
"c",
4000+
BurstAudit::Finished {
4001+
cause: BurstFinishCause::ResourceExhausted,
4002+
},
4003+
)
4004+
.expect("resource shedding carries the cleared record's details");
4005+
assert_eq!(
4006+
resource_exhausted.finish_cause,
4007+
Some(BurstFinishCauseV1::ResourceExhausted)
4008+
);
4009+
40174010
// An intent without a record on the corresponding side contradicts the
40184011
// write and must fail the transaction.
40194012
let incoherent = Catalog::burst_audit_details(

src/adapter/src/command.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,11 @@ pub enum Command {
189189
},
190190

191191
/// Install (or replace) the shared system-parameter frontend on the
192-
/// coordinator, so the create-cluster / create-replica paths can resolve a
193-
/// new object's scoped overrides synchronously, before the controller
194-
/// installs it or its first dataflow is planned, rather than waiting for
195-
/// the next sync tick. Sent by the sync loop whenever it (re)initializes the
196-
/// frontend. See the scoped feature flags design.
192+
/// coordinator, so catalog transactions can resolve a new object's scoped
193+
/// overrides before its replica is provisioned or its first dataflow is
194+
/// planned, rather than waiting for the next sync tick. Sent by the sync loop
195+
/// whenever it (re)initializes the frontend. See the scoped feature flags
196+
/// design.
197197
InstallScopedSystemParameterFrontend {
198198
frontend: Arc<SystemParameterFrontend>,
199199
},

src/adapter/src/coord.rs

Lines changed: 83 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,9 @@ use crate::catalog::{BuiltinTableUpdate, Catalog, OpenCatalogResult};
189189
use crate::client::{Client, Handle};
190190
use crate::command::{Command, ExecuteResponse};
191191
use crate::config::{
192-
ClusterEvalContext, ReplicaEvalContext, ScopedParameters, ScopedParametersScope,
193-
SynchronizedParameters, SystemParameterFrontend, SystemParameterSyncConfig,
192+
ClusterEvalContext, ClusterScopeContext, ReplicaEvalContext, ReplicaScopeContext,
193+
ScopedParameters, ScopedParametersScope, SynchronizedParameters, SystemParameterFrontend,
194+
SystemParameterSyncConfig,
194195
};
195196
use crate::coord::appends::{
196197
BuiltinTableAppendCompletion, BuiltinTableAppendNotify, DeferredOp, GroupCommitPermit,
@@ -2270,30 +2271,85 @@ impl Coordinator {
22702271
}
22712272
}
22722273

2273-
/// Evaluates the scoped overrides for freshly-created objects from explicit
2274-
/// eval contexts and returns an [`Op::UpdateScopedSystemParameters`] to fold
2275-
/// into the same transaction that creates them.
2274+
/// Evaluates scoped overrides for objects created by `ops` and returns an
2275+
/// [`Op::UpdateScopedSystemParameters`] to fold into the same transaction.
22762276
///
2277-
/// The objects are not yet in the catalog, so the contexts are built from
2278-
/// plan data and pre-allocated ids. Folding the op into the create
2279-
/// transaction makes its committed diff drive the replica-scoped controller
2280-
/// push, as a catalog implication, before `create_replica`. A new replica's
2281-
/// first configuration then carries its overrides rather than the env-wide
2282-
/// values. Render-frozen flags (e.g. the column-paged batcher, chosen at
2283-
/// arrangement-build time) make a later push too late, which is why this
2284-
/// happens in the create transaction rather than the next sync tick.
2277+
/// The objects are not yet in the catalog, so this derives their contexts
2278+
/// from the concrete create ops and pre-allocated ids. Centralizing the fold
2279+
/// here makes create-time configuration an invariant of coordinator-applied
2280+
/// catalog ops, independent of which component produced them. The committed
2281+
/// diff drives the replica-scoped controller push before `create_replica`.
2282+
/// Render-frozen flags make a later push too late.
22852283
///
2286-
/// Returns `None` when the shared frontend is not yet installed (e.g. before
2287-
/// LaunchDarkly connects), or when no override applies. The new objects then
2288-
/// resolve to the environment-wide value, and the periodic sync loop remains
2289-
/// the authoritative full-state reconciler.
2284+
/// Returns `None` when no scoped object is created or the shared frontend is
2285+
/// not yet installed. An installed frontend produces an op even when no
2286+
/// override applies, so a final DDL-transaction evaluation can clear a value
2287+
/// staged by an earlier statement. The periodic sync loop remains the
2288+
/// authoritative full-state reconciler.
22902289
///
22912290
/// [`Op::UpdateScopedSystemParameters`]: crate::catalog::Op::UpdateScopedSystemParameters
2292-
fn scoped_overrides_create_op(
2293-
&self,
2294-
clusters: &[ClusterEvalContext],
2295-
replicas: &[ReplicaEvalContext],
2296-
) -> Option<crate::catalog::Op> {
2291+
fn scoped_overrides_create_op(&self, ops: &[crate::catalog::Op]) -> Option<crate::catalog::Op> {
2292+
let mut created_clusters = BTreeMap::new();
2293+
let mut clusters = Vec::new();
2294+
for op in ops {
2295+
let crate::catalog::Op::CreateCluster { id, name, .. } = op else {
2296+
continue;
2297+
};
2298+
let cluster = ClusterScopeContext {
2299+
id: id.to_string(),
2300+
name: name.clone(),
2301+
is_builtin: id.is_system(),
2302+
};
2303+
created_clusters.insert(*id, cluster.clone());
2304+
clusters.push(ClusterEvalContext {
2305+
cluster_id: *id,
2306+
cluster,
2307+
});
2308+
}
2309+
2310+
let mut replicas = Vec::new();
2311+
for op in ops {
2312+
let crate::catalog::Op::CreateClusterReplica {
2313+
cluster_id,
2314+
replica_id,
2315+
name,
2316+
config,
2317+
..
2318+
} = op
2319+
else {
2320+
continue;
2321+
};
2322+
let ReplicaLocation::Managed(location) = &config.location else {
2323+
continue;
2324+
};
2325+
let cluster = created_clusters.get(cluster_id).cloned().or_else(|| {
2326+
self.catalog()
2327+
.try_get_cluster(*cluster_id)
2328+
.map(|cluster| ClusterScopeContext {
2329+
id: cluster_id.to_string(),
2330+
name: cluster.name.clone(),
2331+
is_builtin: cluster_id.is_system(),
2332+
})
2333+
})?;
2334+
replicas.push(ReplicaEvalContext {
2335+
cluster_id: *cluster_id,
2336+
replica_id: *replica_id,
2337+
replica: ReplicaScopeContext {
2338+
id: replica_id.to_string(),
2339+
name: name.clone(),
2340+
is_builtin: cluster_id.is_system(),
2341+
size: location.size.clone(),
2342+
size_family: location.allocation.family().to_string(),
2343+
cluster_id: cluster_id.to_string(),
2344+
cluster_name: cluster.name.clone(),
2345+
},
2346+
cluster,
2347+
});
2348+
}
2349+
2350+
if clusters.is_empty() && replicas.is_empty() {
2351+
return None;
2352+
}
22972353
let frontend = self.scoped_frontend.clone()?;
22982354
let catalog = self.catalog();
22992355
let system_config = catalog.system_config();
@@ -2315,19 +2371,15 @@ impl Coordinator {
23152371
let mut evaluated = ScopedParameters::default();
23162372
if !cluster_param_names.is_empty() && !clusters.is_empty() {
23172373
evaluated.cluster =
2318-
frontend.pull_cluster_overrides(&params, &cluster_param_names, clusters);
2374+
frontend.pull_cluster_overrides(&params, &cluster_param_names, &clusters);
23192375
}
23202376
if !replica_param_names.is_empty() && !replicas.is_empty() {
23212377
evaluated.replica =
2322-
frontend.pull_replica_overrides(&params, &replica_param_names, replicas);
2378+
frontend.pull_replica_overrides(&params, &replica_param_names, &replicas);
23232379
}
2324-
if evaluated.is_empty() {
2325-
return None;
2326-
}
2327-
2328-
// Prune only within the objects being created. They have no prior rows,
2329-
// so nothing is removed, and this op never touches another object whose
2330-
// override a concurrent reconcile may be writing.
2380+
// Prune only within the objects this transaction creates. A later
2381+
// statement in a DDL transaction can replace an earlier folded value,
2382+
// but this never touches an unrelated object's override.
23312383
let prune_scope = ScopedParametersScope {
23322384
clusters: clusters.iter().map(|cluster| cluster.cluster_id).collect(),
23332385
replicas: replicas.iter().map(|replica| replica.replica_id).collect(),

src/adapter/src/coord/cluster_controller.rs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -371,10 +371,6 @@ impl Coordinator {
371371

372372
/// Build the controller's view of one managed cluster from the catalog.
373373
/// Returns `None` for a missing or unmanaged cluster.
374-
///
375-
/// Also used by the ALTER sequencer's synchronous cut-over, which runs the
376-
/// controller's reconcile kernel against this same view so both paths
377-
/// converge on the same replica set.
378374
pub(crate) fn observe_cluster_state(&self, cluster_id: ClusterId) -> Option<ClusterState> {
379375
let cluster = self.catalog().try_get_cluster(cluster_id)?;
380376
let ClusterVariant::Managed(managed) = &cluster.config.variant else {
@@ -871,9 +867,8 @@ impl Coordinator {
871867
})
872868
}
873869

874-
/// Build an [`Op::CreateClusterReplica`] for a desired replica `shape` on
875-
/// `cluster_id` with the pre-allocated `replica_id`, attributed to `reason`.
876-
/// Returns `Ok(None)` if the cluster is gone or unmanaged.
870+
/// Builds a replica create op, or `Ok(None)` if the cluster is gone or
871+
/// unmanaged.
877872
fn build_create_replica_op(
878873
&self,
879874
cluster_id: ClusterId,
@@ -920,14 +915,16 @@ impl Coordinator {
920915
},
921916
};
922917

923-
Ok(Some(Op::CreateClusterReplica {
918+
let op = Op::CreateClusterReplica {
924919
cluster_id,
925920
replica_id,
926921
name,
927922
config,
928923
owner_id,
929924
reason,
930-
}))
925+
};
926+
927+
Ok(Some(op))
931928
}
932929
}
933930

src/adapter/src/coord/ddl.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ impl Coordinator {
254254
pub(crate) async fn catalog_transact_with_ddl_transaction<F>(
255255
&mut self,
256256
ctx: &mut ExecuteContext,
257-
ops: Vec<catalog::Op>,
257+
mut ops: Vec<catalog::Op>,
258258
side_effect: F,
259259
) -> Result<(), AdapterError>
260260
where
@@ -321,6 +321,21 @@ impl Coordinator {
321321
let prep_start = Instant::now();
322322
let mut combined_ops = txn_ops_clone;
323323
combined_ops.extend(ops.iter().cloned());
324+
let creates_scoped_object = ops.iter().any(|op| {
325+
matches!(
326+
op,
327+
catalog::Op::CreateCluster { .. } | catalog::Op::CreateClusterReplica { .. }
328+
)
329+
});
330+
if creates_scoped_object {
331+
// Include accumulated creates when deriving contexts. A replica can
332+
// be created in a later DDL statement than its still-uncommitted
333+
// cluster, which is absent from the coordinator's live catalog.
334+
if let Some(scoped_op) = self.scoped_overrides_create_op(&combined_ops) {
335+
ops.push(scoped_op.clone());
336+
combined_ops.push(scoped_op);
337+
}
338+
}
324339
let conn_id = ctx.session().conn_id().clone();
325340
let validate_res = self.validate_resource_limits(&combined_ops, &conn_id);
326341
phase_seconds
@@ -384,12 +399,16 @@ impl Coordinator {
384399
pub(crate) async fn catalog_transact_inner(
385400
&mut self,
386401
conn_id: Option<&ConnectionId>,
387-
ops: Vec<catalog::Op>,
402+
mut ops: Vec<catalog::Op>,
388403
) -> Result<(BuiltinTableAppendNotify, Vec<ParsedStateUpdate>), AdapterError> {
389404
if self.controller.read_only() {
390405
return Err(AdapterError::ReadOnly);
391406
}
392407

408+
if let Some(scoped_op) = self.scoped_overrides_create_op(&ops) {
409+
ops.push(scoped_op);
410+
}
411+
393412
event!(Level::TRACE, ops = format!("{:?}", ops));
394413

395414
let phase_seconds = self.metrics.catalog_transact_phase_seconds.clone();
@@ -1388,7 +1407,6 @@ impl Coordinator {
13881407
| Op::UpdateOwner { .. }
13891408
| Op::RevokeRole { .. }
13901409
| Op::UpdateClusterConfig { .. }
1391-
| Op::UpdateClusterReplicaConfig { .. }
13921410
| Op::UpdateSourceReferences { .. }
13931411
| Op::UpdateSystemConfiguration { .. }
13941412
| Op::ResetSystemConfiguration { .. }

0 commit comments

Comments
 (0)