1111
1212#![ recursion_limit = "256" ]
1313
14+ use std:: cell:: Cell ;
1415use std:: collections:: { BTreeMap , BTreeSet , VecDeque } ;
1516use std:: fmt:: Write ;
1617use 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