Skip to content

Commit 0ea4008

Browse files
mtabebeclaude
andcommitted
adapter: correct the comments around the read-only write-enable
Review of MaterializeInc#38399 found several claims that no longer match the code: - The `allow_writes_in_read_only` tripwire falls back to a no-op, which now blocks promotion rather than leaving a cold collection at cut-over, and the storage-side check it points at is a hard assert, not a soft panic with a fallback. Say both, and say why compute is deliberately softer. - Exclusive ownership of a replacement shard holds per (build version, deploy generation), not per process. Two read-only processes of one generation both write the shard, the same shape as a multi-replica MV, which the self-correcting persist sink is built to tolerate. - The replica-side handler enables persist compaction process-wide, which this path is the first thing to trigger inside a read-only deployment. - "Replacement" meant two unrelated things three lines apart in `bootstrap`: user `REPLACEMENT FOR` DDL and the builtin-migration mechanism. The sets are disjoint, so this is wording only. - Record why a *new* builtin MV cannot be write-enabled the same way: its shard allocation lives only in the read-only savepoint, so the promoted leader allocates a different shard. - Cite PR MaterializeInc#35402 for the v26.17 leader floor, and note that forcing the replacement mechanism across all builtins, as the 0dt tests do, leaves the caught-up gate reading its own frontiers. The design doc stated the force-write unconditionally; give the two conditions and the exclude-from-gate fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f73f431 commit 0ea4008

5 files changed

Lines changed: 56 additions & 11 deletions

File tree

doc/developer/design/20251015_builtin_schema_migration.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ Because this environment exclusively owns the replacement shard, the read-only p
8181
This lets a migrated builtin materialized view and its dependents hydrate before cut-over instead of all at once at cut-over.
8282
It is safe only for the self-owned replacement shard, never a shard the leader is still serving, which is why it applies to shard replacement and not schema evolution.
8383

84+
The force-write is conditional on two things.
85+
First, the leader must be at v26.17 or later, because every builtin materialized view reads the catalog shard and only leaders from that version on keep its frontier advancing with the current time; against an older leader the dataflow would sit at a stale frontier.
86+
Second, the `enable_0dt_hydrate_migrated_builtin_mvs` feature flag must be on; it exists as a break-glass revert.
87+
When either condition does not hold, the migrated materialized views and their dependents are instead excluded from the 0dt caught-up check, which is the older behaviour: promotion proceeds without them and they hydrate at cut-over.
88+
8489
A leader process performing shard replacement performs the same steps as in read-only mode.
8590
Additionally, it cleans up durable state written by earlier versions and/or deploy generations by:
8691
- arranging for the previous shards used by the migrated storage collections to be finalized

src/adapter/src/coord.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,10 @@ mod validity;
250250
///
251251
/// Every builtin materialized view reads `mz_internal.mz_catalog_raw`, so its dataflow only makes
252252
/// progress up to the catalog shard's frontier. Holding that frontier at the current time is the
253-
/// leader's job, and leaders only started doing it in v26.17. Write-enable such an MV against an
254-
/// older leader and it sits at a stale frontier and never reports caught up, which blocks
255-
/// promotion outright instead of merely leaving the collection cold at cut-over. We still support
256-
/// upgrading from before v26.17, so that leader is a real case, not a hypothetical.
253+
/// leader's job, and leaders only started doing it in v26.17 (PR #35402). Write-enable such an MV
254+
/// against an older leader and it sits at a stale frontier and never reports caught up, which
255+
/// blocks promotion outright instead of merely leaving the collection cold at cut-over. We still
256+
/// support upgrading from before v26.17, so that leader is a real case, not a hypothetical.
257257
const MIN_LEADER_VERSION_FOR_MIGRATED_MV_WRITES: Version = Version::new(26, 17, 0);
258258

259259
/// A pool of pre-allocated user IDs to avoid per-DDL persist writes.
@@ -2784,8 +2784,10 @@ impl Coordinator {
27842784
self.ship_dataflow(df_desc, mview.cluster_id, mview.target_replica)
27852785
.await;
27862786

2787-
// If this is a replacement MV, it must remain read-only until the replacement
2788-
// gets applied.
2787+
// If this MV is a pending `REPLACEMENT FOR` another MV, it must stay
2788+
// read-only until `ALTER ... APPLY REPLACEMENT` swaps it in. Unrelated to the
2789+
// builtin-migration `Replacement` mechanism below, and disjoint from it:
2790+
// builtin MVs never come from user DDL.
27892791
if mview.replacement_target.is_none() {
27902792
let gid = mview.global_id_writes();
27912793
if hydrate_migrated_mvs
@@ -2796,6 +2798,13 @@ impl Coordinator {
27962798
// writing it while read-only hydrates the MV and its dependents before
27972799
// cut-over. An `Evolution`-migrated MV reuses the leader's live shard
27982800
// and must never reach here.
2801+
//
2802+
// A *new* builtin MV gets no such treatment: its shard allocation
2803+
// lives only in this read-only savepoint, so the promoted leader
2804+
// allocates a different shard and anything written here would be
2805+
// discarded. A replacement shard instead survives promotion via its
2806+
// durable migration-shard entry, which is what makes the write worth
2807+
// doing.
27992808
self.controller
28002809
.compute
28012810
.allow_writes_in_read_only(mview.cluster_id, gid)

src/adapter/src/coord/caught_up.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,12 @@ impl Coordinator {
271271
// `snapshot_latest` requires that the collection consolidates to a
272272
// set. `mz_cluster_replica_frontiers` is a controller-managed builtin
273273
// written with ±1 diffs, so it satisfies that invariant.
274+
//
275+
// NOTE: these are the *leader's* frontiers only because this deployment reads the leader's
276+
// shard. A release that `Replacement`-migrates `mz_cluster_replica_frontiers` itself would
277+
// hand us a fresh shard that we write our own replica frontiers into, so the lag check
278+
// below would compare this deployment against itself and pass trivially. Forcing the
279+
// replacement mechanism across all builtins, as the 0dt tests do, has the same effect.
274280
let live_frontiers = self
275281
.controller
276282
.storage_collections

src/compute-client/src/controller.rs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,26 +1153,46 @@ impl ComputeController {
11531153

11541154
/// Like [`Self::allow_writes`], but takes effect even in read-only mode.
11551155
///
1156-
/// The caller must guarantee that no other environment writes the collection's output shard.
1156+
/// The caller must guarantee that no leader environment writes the collection's output shard.
11571157
/// In a 0dt deployment that means a shard this environment created for itself, the replacement
11581158
/// shard of a `Replacement`-migrated builtin collection, never one the leader is still serving
11591159
/// from. `Evolution` migrates in place and reuses the leader's shard, so it must not reach this
11601160
/// path. Violating the guarantee races two writers on one shard.
11611161
///
1162+
/// NOTE: exclusive ownership of a replacement shard holds per (build version, deploy
1163+
/// generation), not per process: the migration shard entry that names the shard is keyed by
1164+
/// that pair, and a read-only catalog open is a savepoint, so it fences nothing. Two read-only
1165+
/// processes of the same generation therefore both write it. That is the same shape as a
1166+
/// multi-replica materialized view, whose replicas also all write one shard, and the persist
1167+
/// sink is built to tolerate exactly that: it is self-correcting, so a conflicting write is
1168+
/// reconciled on the next append. See the `mz_compute::sink::materialized_view` module docs.
1169+
///
11621170
/// This is the compute-side counterpart to the storage controller's `force_writable` handling
11631171
/// of migrated storage collections. Migrated builtin tables are storage collections that
11641172
/// storage force-writes read-only; migrated builtin MVs are compute collections that only this
11651173
/// path can force-write. Both rest on the same guarantee (this environment exclusively owns the
11661174
/// replacement shard) but run on separate write paths, so each needs its own bypass.
1175+
///
1176+
/// NOTE: the replica-side handler enables persist compaction process-wide on the clusterd
1177+
/// (`ComputeState::handle_allow_writes`). This path is the first thing that triggers that
1178+
/// inside a read-only deployment.
11671179
pub fn allow_writes_in_read_only(
11681180
&mut self,
11691181
instance_id: ComputeInstanceId,
11701182
collection_id: GlobalId,
11711183
) -> Result<(), CollectionUpdateError> {
1172-
// Every builtin eligible for the read-only bypass has a system id. A non-system id means
1173-
// the caller's `Replacement`-only invariant broke, so degrade to the read-only no-op (cold
1174-
// collection at cut-over) rather than risk writing a shard the leader still serves. Mirrors
1175-
// the storage-side tripwire in `StorageController::register_introspection_collection`.
1184+
// Every builtin eligible for the read-only bypass has a system id, so a non-system id
1185+
// means the caller's `Replacement`-only invariant broke. No-op rather than risk writing a
1186+
// shard the leader still serves.
1187+
//
1188+
// NOTE: that no-op is not the harmless degradation it reads as. A collection left in the
1189+
// caught-up gate on a shard nobody writes never advances past the minimum frontier, so it
1190+
// blocks promotion rather than merely arriving cold at cut-over.
1191+
//
1192+
// The storage side asserts the same invariant hard, in
1193+
// `StorageController::register_introspection_collection`. The asymmetry is deliberate: the
1194+
// write is refused either way, and a soft panic keeps a caller bug loud in CI and Sentry
1195+
// without taking a production deployment down over it.
11761196
if self.read_only && !collection_id.is_system() {
11771197
soft_panic_or_log!(
11781198
"allow_writes_in_read_only called for non-system collection {collection_id}; \

test/0dt/mzcompose.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1819,6 +1819,11 @@ def get_persist_shard_id(item_id: str, service: str) -> str:
18191819
# caught-up gate they would hydrate at cut-over instead, spiking catalog-server CPU. Reading
18201820
# them here from the read-only generation asserts the write-enable-while-read-only path
18211821
# actually filled those shards.
1822+
#
1823+
# NOTE: this covers hydration, not the caught-up gate's lag comparison.
1824+
# `force_migrations="replacement"` replaces `mz_cluster_replica_frontiers` too, and the gate
1825+
# reads the leader's "live" frontiers out of that collection, so here it compares this
1826+
# generation against itself.
18221827
for relation in ["mz_databases", "mz_clusters"]:
18231828
count = c.sql_query(
18241829
f"SELECT count(*) FROM mz_catalog.{relation}",

0 commit comments

Comments
 (0)