Skip to content

Commit f73f431

Browse files
mtabebeclaude
andcommitted
environmentd: make the 0dt migrated-MV tests prove the ordering they claim
The existing test read the MVs only after observing `ReadyToPromote`. A peek against an unwritten replacement shard blocks, but so does a peek against an MV that is merely late, so a post-hoc read with a generous timeout passed whether or not the MVs were ever in the caught-up gate. Poll leader status and MV readability in one loop instead, and report whether the MVs were readable by the first `ReadyToPromote` tick. Bound each read by opening its own connection and dropping it on timeout. `statement_timeout` cannot do this: it bounds INSERT/UPDATE/DELETE, not a `SELECT` waiting on a frontier, so the poll loop hung on an unhydrated MV until the harness killed the test. Add the flag-off variant. It differs only in `enable_0dt_hydrate_migrated_builtin_mvs` and asserts the opposite, so a change that reaches `ReadyToPromote` without hydrating cannot pass both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent de7e8fe commit f73f431

1 file changed

Lines changed: 141 additions & 49 deletions

File tree

‎src/environmentd/tests/server.rs‎

Lines changed: 141 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
1212
#![recursion_limit = "256"]
1313

14+
use std::cell::Cell;
1415
use std::collections::{BTreeMap, BTreeSet, VecDeque};
1516
use std::fmt::Write;
1617
use std::io::Write as _;
@@ -2542,32 +2543,77 @@ async fn test_leader_promotion_mixed_code_version() {
25422543
client_this.simple_query("SELECT 1").await.unwrap();
25432544
}
25442545

2545-
/// Regression test for builtin MVs migrated by shard replacement: they must be hydrated by the
2546-
/// time the read-only deployment reports `ReadyToPromote`.
2546+
/// The migrated builtin MVs that the 0dt hydration tests read.
25472547
///
2548-
/// A replacement migration hands the new deployment a fresh shard that no other environment
2549-
/// writes. Leave the MV's dataflow read-only and that shard stays empty, so the MV and everything
2550-
/// downstream of it drop out of the 0dt caught-up gate and then all hydrate at once at cut-over.
2548+
/// `mz_clusters` is the interesting one: it joins the `mz_cluster_replica_size_internal` builtin
2549+
/// table, whose replacement shard is written once at bootstrap and thereafter only kept moving by
2550+
/// `read_only_mode_table_worker`.
2551+
const FORCED_BUILTIN_MIGRATION_MVS: &[&str] =
2552+
&["mz_catalog.mz_databases", "mz_catalog.mz_clusters"];
2553+
2554+
/// How long a single read of a migrated builtin MV may take before it counts as unreadable.
2555+
const MIGRATED_BUILTIN_MV_READ_TIMEOUT: Duration = Duration::from_secs(3);
2556+
2557+
/// Reads every MV in [`FORCED_BUILTIN_MIGRATION_MVS`], reporting whether all of them returned rows.
25512558
///
2552-
/// Reaching `ReadyToPromote` at all is the other half of the assertion. Write-enabling puts these
2553-
/// MVs back *into* the gate, so anything they cannot catch up to now blocks promotion instead of
2554-
/// being waved through. `mz_clusters` is the interesting case: it joins the
2555-
/// `mz_cluster_replica_size_internal` builtin table, whose replacement shard is written once at
2556-
/// bootstrap and thereafter only kept moving by `read_only_mode_table_worker`.
2557-
#[mz_ore::test(tokio::test(flavor = "multi_thread"))]
2558-
#[cfg_attr(miri, ignore)] // too slow
2559+
/// Each read gets its own connection, dropped on timeout so the session ends and takes the peek
2560+
/// with it. `statement_timeout` cannot do this job: it bounds INSERT/UPDATE/DELETE, not a `SELECT`
2561+
/// waiting on a frontier, so a peek against an unwritten replacement shard would hang the poll loop
2562+
/// rather than fail it.
25592563
#[allow(clippy::disallowed_methods)]
2560-
async fn test_0dt_migrated_builtin_mv_hydrates_before_promotion() {
2564+
async fn migrated_builtin_mvs_readable(server: &test_util::TestServer) -> bool {
2565+
for relation in FORCED_BUILTIN_MIGRATION_MVS {
2566+
let client = server.connect().await.unwrap();
2567+
let query = format!("SELECT count(*) FROM {relation}");
2568+
let read = tokio::time::timeout(
2569+
MIGRATED_BUILTIN_MV_READ_TIMEOUT,
2570+
client.query_one(&query, &[]),
2571+
);
2572+
match read.await {
2573+
Ok(Ok(row)) => {
2574+
let count: i64 = row.get(0);
2575+
if count == 0 {
2576+
tracing::info!("`{query}` returned 0 rows");
2577+
return false;
2578+
}
2579+
}
2580+
Ok(Err(err)) => {
2581+
tracing::info!("`{query}` errored: {err}");
2582+
return false;
2583+
}
2584+
Err(_) => {
2585+
tracing::info!(
2586+
"`{query}` did not return within {MIGRATED_BUILTIN_MV_READ_TIMEOUT:?}"
2587+
);
2588+
return false;
2589+
}
2590+
}
2591+
}
2592+
true
2593+
}
2594+
2595+
/// Boots a leader deployment plus a read-only deployment whose builtins were force-migrated by
2596+
/// shard replacement, and reports whether the migrated builtin MVs had become readable by the time
2597+
/// the read-only deployment first said `ReadyToPromote`.
2598+
///
2599+
/// Status and readability are polled in the same loop deliberately. Reading the MVs only *after*
2600+
/// observing `ReadyToPromote` proves nothing about ordering: a peek against an unwritten
2601+
/// replacement shard blocks, but so does a peek against an MV that is merely late, so a post-hoc
2602+
/// read with a generous timeout passes whether or not the MVs were ever in the gate.
2603+
#[allow(clippy::disallowed_methods)]
2604+
async fn migrated_builtin_mvs_readable_at_ready_to_promote(hydrate_migrated_mvs: bool) -> bool {
25612605
let tmpdir = TempDir::new().unwrap();
25622606
let harness = test_util::TestHarness::default()
25632607
.unsafe_mode()
25642608
.data_directory(tmpdir.path())
25652609
.with_deploy_generation(1)
25662610
// Tick often, and tolerate far less lag and require a far shorter healthy streak than
2567-
// production, so the test both finishes quickly and actually exercises the gate: a
2568-
// collection frozen at `boot_ts + 1` drifts outside a 5s tolerance long before a 10s
2569-
// streak can complete, where the 60s production tolerance would hide it for a whole
2570-
// minute.
2611+
// production, so the test both finishes quickly and actually exercises the gate.
2612+
//
2613+
// The allowed lag has to stay *below* the stability period: a collection frozen at
2614+
// `boot_ts + 1` looks caught up for as long as the tolerance lasts, so it must fall out of
2615+
// tolerance before an uninterrupted streak can complete. 5s against 10s does that, where
2616+
// the 60s production tolerance would hide it for a whole minute.
25712617
.with_system_parameter_default(
25722618
"0dt_deployment_hydration_check_interval".to_string(),
25732619
"1s".to_string(),
@@ -2579,14 +2625,18 @@ async fn test_0dt_migrated_builtin_mv_hydrates_before_promotion() {
25792625
.with_system_parameter_default(
25802626
"with_0dt_caught_up_check_stability_period".to_string(),
25812627
"10s".to_string(),
2628+
)
2629+
.with_system_parameter_default(
2630+
"enable_0dt_hydrate_migrated_builtin_mvs".to_string(),
2631+
hydrate_migrated_mvs.to_string(),
25822632
);
25832633

2584-
// The leader generation.
2634+
// The leader deployment.
25852635
let server_leader = harness.clone().start().await;
25862636
let client_leader = server_leader.connect().await.unwrap();
25872637
client_leader.simple_query("SELECT 1").await.unwrap();
25882638

2589-
// The new generation, booting read-only. Forcing the `replacement` mechanism gives every
2639+
// The new deployment, booting read-only. Forcing the `replacement` mechanism gives every
25902640
// builtin storage collection a fresh shard, which is what a release carrying a
25912641
// `MigrationStep::replacement` does for the collections it names.
25922642
let server_new = harness
@@ -2601,42 +2651,84 @@ async fn test_0dt_migrated_builtin_mv_hydrates_before_promotion() {
26012651
server_new.internal_http_local_addr()
26022652
))
26032653
.unwrap();
2654+
2655+
// Readability is monotonic: once an MV has hydrated it stays readable. So latching the first
2656+
// `true` can only understate how early the MVs hydrated, never overstate it, which keeps a
2657+
// readable-mid-tick race from failing the test spuriously.
2658+
//
2659+
// The budget stays well inside nextest's 240s kill for this package, so a gate that never opens
2660+
// fails the assertion instead of timing the test out.
2661+
let mvs_readable = Cell::new(false);
26042662
Retry::default()
2605-
.max_duration(Duration::from_secs(300))
2606-
.retry_async(|_state| async {
2607-
let res = reqwest::Client::new()
2608-
.get(status_url.clone())
2609-
.send()
2610-
.await
2611-
.unwrap();
2612-
assert_eq!(res.status(), StatusCode::OK);
2613-
let response = res.text().await.unwrap();
2614-
tracing::info!("leader status of the new generation: {response}");
2615-
assert_ne!(response, r#"{"status":"IsLeader"}"#);
2616-
if response == r#"{"status":"ReadyToPromote"}"# {
2617-
Ok(())
2618-
} else {
2619-
Err(())
2663+
.max_duration(Duration::from_secs(120))
2664+
.retry_async(|_state| {
2665+
let status_url = status_url.clone();
2666+
let server_new = &server_new;
2667+
let mvs_readable = &mvs_readable;
2668+
async move {
2669+
if !mvs_readable.get() {
2670+
mvs_readable.set(migrated_builtin_mvs_readable(server_new).await);
2671+
}
2672+
2673+
let res = reqwest::Client::new().get(status_url).send().await.unwrap();
2674+
assert_eq!(res.status(), StatusCode::OK);
2675+
let response = res.text().await.unwrap();
2676+
tracing::info!(
2677+
mvs_readable = mvs_readable.get(),
2678+
"leader status of the new deployment: {response}"
2679+
);
2680+
assert_ne!(response, r#"{"status":"IsLeader"}"#);
2681+
if response == r#"{"status":"ReadyToPromote"}"# {
2682+
Ok(())
2683+
} else {
2684+
Err(())
2685+
}
26202686
}
26212687
})
26222688
.await
2623-
.unwrap();
2689+
.expect("new deployment never reported ReadyToPromote");
26242690

2625-
// The new generation says it is ready to take over, so a migrated builtin MV has to be
2626-
// readable from it while it is still read-only. With no writer on the replacement shard this
2627-
// peek never returns, so bound it rather than hang the test.
2628-
//
26292691
// NOTE: we never promote. Cut-over `halt!`s the process, taking the test with it.
2630-
let client_new = server_new.connect().await.unwrap();
2631-
for relation in ["mz_databases", "mz_clusters"] {
2632-
let query = format!("SELECT count(*) FROM mz_catalog.{relation}");
2633-
let rows = tokio::time::timeout(Duration::from_secs(60), client_new.query(&query, &[]))
2634-
.await
2635-
.unwrap_or_else(|_| panic!("`{query}` never returned on the read-only generation"))
2636-
.unwrap();
2637-
let count: i64 = rows[0].get(0);
2638-
assert!(count > 0, "`{query}` returned {count}");
2639-
}
2692+
mvs_readable.get()
2693+
}
2694+
2695+
/// Regression test for builtin MVs migrated by shard replacement: they must be hydrated by the
2696+
/// time the read-only deployment reports `ReadyToPromote`.
2697+
///
2698+
/// A replacement migration hands the new deployment a fresh shard that no other environment
2699+
/// writes. Leave the MV's dataflow read-only and that shard stays empty, so the MV and everything
2700+
/// downstream of it drop out of the 0dt caught-up gate and then all hydrate at once at cut-over.
2701+
///
2702+
/// Reaching `ReadyToPromote` at all is the other half of the assertion. Write-enabling puts these
2703+
/// MVs back *into* the gate, so anything they cannot catch up to now blocks promotion instead of
2704+
/// being waved through.
2705+
///
2706+
/// NOTE: this covers the ordering, not the gate's lag comparison. Forcing the `replacement`
2707+
/// mechanism replaces `mz_cluster_replica_frontiers` too, and the gate reads its "live" frontiers
2708+
/// out of that collection, so here it compares this deployment against itself. Forcing a subset
2709+
/// would leave that comparison intact; see `Coordinator::maybe_check_caught_up`.
2710+
#[mz_ore::test(tokio::test(flavor = "multi_thread"))]
2711+
#[cfg_attr(miri, ignore)] // too slow
2712+
async fn test_0dt_migrated_builtin_mv_hydrates_before_promotion() {
2713+
assert!(
2714+
migrated_builtin_mvs_readable_at_ready_to_promote(true).await,
2715+
"migrated builtin MVs were not readable when the new deployment reported ReadyToPromote"
2716+
);
2717+
}
2718+
2719+
/// The break-glass half of [`test_0dt_migrated_builtin_mv_hydrates_before_promotion`]: with
2720+
/// `enable_0dt_hydrate_migrated_builtin_mvs` off, the migrated MVs are excluded from the caught-up
2721+
/// gate again, so the deployment reports `ReadyToPromote` with them still unhydrated.
2722+
///
2723+
/// Pinning the fallback this way is what makes the test above meaningful: the two differ only in
2724+
/// the flag, so a change that reaches `ReadyToPromote` without hydrating cannot pass both.
2725+
#[mz_ore::test(tokio::test(flavor = "multi_thread"))]
2726+
#[cfg_attr(miri, ignore)] // too slow
2727+
async fn test_0dt_migrated_builtin_mv_flag_off_promotes_unhydrated() {
2728+
assert!(
2729+
!migrated_builtin_mvs_readable_at_ready_to_promote(false).await,
2730+
"migrated builtin MVs hydrated even with enable_0dt_hydrate_migrated_builtin_mvs off"
2731+
);
26402732
}
26412733

26422734
// Test that websockets observe cancellation.

0 commit comments

Comments
 (0)