Skip to main content

mz_testdrive/action/
consistency.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::fmt::Write;
12use std::future::Future;
13use std::io::Write as _;
14use std::str::FromStr;
15use std::time::Duration;
16
17use crate::action;
18use crate::action::{ControlFlow, Run, State};
19use crate::parser::{BuiltinCommand, LineReader, parse};
20use anyhow::{Context, anyhow, bail};
21use mz_ore::retry::{Retry, RetryResult};
22use mz_persist_client::{PersistLocation, ShardId};
23use mz_postgres_util::{query_one, sql};
24use reqwest::StatusCode;
25use serde::{Deserialize, Serialize};
26
27/// Level of consistency checks we should enable on a testdrive run.
28#[derive(clap::ValueEnum, Default, Debug, Copy, Clone, PartialEq, Eq)]
29pub enum Level {
30    /// Run the consistency checks after the completion of a test file.
31    #[default]
32    File,
33    /// Run the consistency checks after each statement, good for debugging.
34    Statement,
35    /// Disable consistency checks entirely.
36    Disable,
37}
38
39impl FromStr for Level {
40    type Err = String;
41
42    fn from_str(s: &str) -> Result<Self, Self::Err> {
43        match s {
44            "file" => Ok(Level::File),
45            "statement" => Ok(Level::Statement),
46            "disable" => Ok(Level::Disable),
47            s => Err(format!("Unknown consistency check level: {s}")),
48        }
49    }
50}
51
52/// Skips consistency checks for the current file.
53pub fn skip_consistency_checks(
54    mut cmd: BuiltinCommand,
55    state: &mut State,
56) -> Result<ControlFlow, anyhow::Error> {
57    let reason = cmd
58        .args
59        .string("reason")
60        .context("must provide reason for skipping")?;
61    cmd.args.done()?;
62    tracing::info!(reason, "Skipping consistency checks as requested.");
63
64    state.consistency_checks_adhoc_skip = true;
65    Ok(ControlFlow::Continue)
66}
67
68/// Runs `check` under `--consistency-check-timeout`.
69///
70/// Every check here talks to a Materialize that may have died mid-file, and
71/// both the HTTP client and persist retry internally with no deadline of their
72/// own. Without a bound the checks wait out the whole CI step, hours later,
73/// having printed nothing about which one was stuck.
74///
75/// The deadline is deliberately its own knob rather than a multiple of
76/// `--default-timeout`. A check opens the durable catalog and diffs two full
77/// dumps of it, so it costs far more than a query and scales with the size of
78/// the catalog, not with how long a single query may take. It bounds a hang,
79/// so it sits far above what a healthy check costs on a loaded agent.
80async fn with_deadline<F>(state: &State, check: F) -> Result<(), anyhow::Error>
81where
82    F: Future<Output = Result<(), anyhow::Error>>,
83{
84    let deadline = state.consistency_check_timeout;
85    match tokio::time::timeout(deadline, check).await {
86        Ok(result) => result,
87        Err(_) => bail!("did not finish within {deadline:?}"),
88    }
89}
90
91/// Runs consistency checks against multiple parts of Materialize to make sure we haven't violated
92/// our invariants or leaked resources.
93pub async fn run_consistency_checks(state: &State) -> Result<ControlFlow, anyhow::Error> {
94    // Return early if the user adhoc disabled consistency checks for the current file.
95    if state.consistency_checks_adhoc_skip {
96        return Ok(ControlFlow::Continue);
97    }
98
99    let coordinator = with_deadline(state, check_coordinator(state))
100        .await
101        .context("coordinator");
102    let catalog_state = with_deadline(state, check_catalog_state(state))
103        .await
104        .context("catalog state");
105    let statement_logging_state = if state.check_statement_logging {
106        with_deadline(state, check_statement_logging(state))
107            .await
108            .context("statement logging state")
109    } else {
110        Ok(())
111    };
112    // TODO(parkmycar): Fix subsources so they don't leak their shards and then add a leaked shards
113    // consistency check.
114
115    // Make sure to report all inconsistencies, not just the first.
116    let mut msg = String::new();
117    if let Err(e) = coordinator {
118        writeln!(&mut msg, "coordinator inconsistency: {e:?}")?;
119    }
120    if let Err(e) = catalog_state {
121        writeln!(&mut msg, "catalog inconsistency: {e:?}")?;
122    }
123    if let Err(e) = statement_logging_state {
124        writeln!(&mut msg, "statement logging inconsistency: {e:?}")?;
125    }
126
127    if msg.is_empty() {
128        Ok(ControlFlow::Continue)
129    } else {
130        Err(anyhow!("{msg}"))
131    }
132}
133
134/// Checks if a shard in Persist has been tombstoned.
135///
136/// TODO(parkmycar): Run this as part of the consistency checks, instead of as a specific command.
137pub async fn run_check_shard_tombstone(
138    mut cmd: BuiltinCommand,
139    state: &State,
140) -> Result<ControlFlow, anyhow::Error> {
141    let shard_id = cmd.args.string("shard-id")?;
142    cmd.args.done()?;
143    check_shard_tombstone(state, &shard_id).await?;
144    Ok(ControlFlow::Continue)
145}
146
147/// Asks the Coordinator to run it's own internal consistency checks.
148async fn check_coordinator(state: &State) -> Result<(), anyhow::Error> {
149    // Make sure we can dump the Coordinator state.
150    let response = reqwest::get(&format!(
151        "http://{}/api/coordinator/dump",
152        state.materialize.internal_http_addr
153    ))
154    .await?;
155    // We allow NOT_FOUND to support upgrade tests where this endpoint doesn't yet exist.
156    if !response.status().is_success() && response.status() != StatusCode::NOT_FOUND {
157        let response: Result<serde_json::Value, _> = response.json().await;
158        bail!("Coordinator failed to dump state: {:?}", response);
159    }
160
161    // Run the consistency checks.
162    let response = Retry::default()
163        .max_duration(Duration::from_secs(2))
164        .retry_async(|_| async {
165            reqwest::get(&format!(
166                "http://{}/api/coordinator/check",
167                state.materialize.internal_http_addr,
168            ))
169            .await
170        })
171        .await
172        .context("querying coordinator")?;
173    if response.status() == StatusCode::NOT_FOUND {
174        bail!("Coordinator consistency check not available");
175    }
176
177    let inconsistencies: serde_json::Value =
178        response.json().await.context("deserialize response")?;
179
180    match inconsistencies {
181        serde_json::Value::String(x) if x.is_empty() => Ok(()),
182        other => Err(anyhow!("coordinator inconsistencies! {other:?}")),
183    }
184}
185
186/// Checks that the in-memory catalog matches what we have persisted on disk.
187async fn check_catalog_state(state: &State) -> Result<(), anyhow::Error> {
188    #[derive(Debug, Deserialize)]
189    struct StorageMetadata {
190        unfinalized_shards: Option<BTreeSet<String>>,
191    }
192
193    #[derive(Debug, Deserialize)]
194    struct CatalogDump {
195        system_parameter_defaults: Option<BTreeMap<String, String>>,
196        storage_metadata: Option<StorageMetadata>,
197    }
198
199    // The comparison below needs the on-disk catalog, which `with_catalog_copy`
200    // can only open when a catalog config was supplied
201    // (`--validate-catalog-store`). Without one it returns `None` and the
202    // check is skipped, so do not fetch and parse the dump (100+ MB) for
203    // nothing.
204    if state.materialize.catalog_config.is_none() {
205        return Ok(());
206    }
207
208    // Dump the in-memory catalog state of the Materialize environment that we're
209    // connected to.
210    let memory_catalog = reqwest::get(&format!(
211        "http://{}/api/catalog/dump",
212        state.materialize.internal_http_addr,
213    ))
214    .await
215    .context("GET catalog")?
216    .text()
217    .await
218    .context("deserialize catalog")?;
219
220    // Pull out the system parameter defaults from the in-memory catalog, as we
221    // need to load the disk catalog with the same defaults.
222    let dump: CatalogDump = serde_json::from_str(&memory_catalog).context("decoding catalog")?;
223
224    let Some(system_parameter_defaults) = dump.system_parameter_defaults else {
225        // TODO(parkmycar, def-): Ideally this could be an error, but a lot of test suites fail. We
226        // should explicitly disable consistency check in these test suites.
227        tracing::warn!(
228            "Missing system_parameter_defaults in memory catalog state, skipping consistency check"
229        );
230        return Ok(());
231    };
232
233    let unfinalized_shards = dump
234        .storage_metadata
235        .and_then(|storage_metadata| storage_metadata.unfinalized_shards);
236
237    // Load the on-disk catalog and dump its state.
238
239    // Make sure the version is parseable.
240    let _: semver::Version = state.build_info.version.parse().expect("invalid version");
241
242    let maybe_disk_catalog = state
243        .with_catalog_copy(
244            system_parameter_defaults,
245            state.build_info,
246            &state.materialize.bootstrap_args,
247            // The expression cache can be taxing on the CPU and is unnecessary for consistency checks.
248            Some(false),
249            |catalog| catalog.state().clone(),
250        )
251        .await
252        .map_err(|e| anyhow!("failed to read on-disk catalog state: {e}"))?
253        .map(|catalog| {
254            catalog
255                // The set of unfinalized shards in the catalog are updated asynchronously by
256                // background processes. As a result, the value may legitimately change after
257                // fetching the memory catalog but before fetching the disk catalog, causing the
258                // comparison to fail. This is a gross hack that always sets the disk catalog's
259                // unfinalized shards equal to the memory catalog's unfinalized shards to ignore
260                // false negatives. Unfortunately, we also end up ignoring true negatives.
261                .dump(unfinalized_shards)
262                .expect("state must be dumpable")
263        });
264    let Some(disk_catalog) = maybe_disk_catalog else {
265        // TODO(parkmycar, def-): Ideally this could be an error, but a lot of test suites fail. We
266        // should explicitly disable consistency check in these test suites.
267        tracing::warn!("No Catalog state on disk, skipping consistency check");
268        return Ok(());
269    };
270
271    if disk_catalog != memory_catalog {
272        // The state objects here are around 100k lines pretty printed, so find the
273        // first lines that differs and show context around it.
274        let diff = similar::TextDiff::from_lines(&memory_catalog, &disk_catalog)
275            .unified_diff()
276            .context_radius(50)
277            .to_string()
278            .lines()
279            .take(200)
280            .collect::<Vec<_>>()
281            .join("\n");
282
283        bail!("the in-memory state of the catalog does not match its on-disk state:\n{diff}");
284    }
285
286    Ok(())
287}
288
289/// This currently checks only whether the statement log reports all statements to be in a finished
290/// state. (We used to have an assertion for roughly this in `ExecuteContextExtra`'s Drop, but that
291/// had to be removed due to <https://github.com/MaterializeInc/database-issues/issues/7304>)
292///
293/// Note that this check should succeed regardless of the statement logging sampling rate.
294///
295/// Ideally, we could run this at any moment successfully, but currently system restarts can mess
296/// this up: there is a buffering of statement log writes, with the buffers flushed every 5 seconds.
297/// So, if a system kill/restart comes at a bad moment, then some statements might get permanently
298/// stuck in an unfinished state in the statement log. Therefore, we currently run this only after
299/// normal `.td`s, but not after cluster tests and whatnot that kill/restart the system.
300/// (Also, this can take several seconds due to the 5 sec buffering, so we run this only in Nightly
301/// by default.)
302async fn check_statement_logging(orig_state: &State) -> Result<(), anyhow::Error> {
303    use crate::util::postgres::postgres_client;
304
305    // Create new Testdrive state, so that we create a new session to Materialize, and we forget any
306    // weird Testdrive setting that the `.td` file before us might have set.
307    let (mut state, state_cleanup) = action::create_state(&orig_state.config).await?;
308
309    // First, query the current value of enable_rbac_checks so we can restore it later
310    let mz_system_url = format!(
311        "postgres://mz_system:materialize@{}",
312        state.materialize.internal_sql_addr
313    );
314
315    let (client, _handle) = postgres_client(&mz_system_url, state.default_timeout)
316        .await
317        .context("connecting as mz_system to query enable_rbac_checks")?;
318
319    let row = query_one(&client, sql!("SHOW enable_rbac_checks"), &[])
320        .await
321        .context("querying enable_rbac_checks")?;
322
323    let original_value: String = row.get(0);
324
325    // Create a testdrive script to check that all statements have finished executing.
326    // We disable RBAC checks so we can query mz_internal tables, similar to statement-logging.td.
327    // We restore the setting to its original value at the end.
328    let check_script = format!(
329        r#"
330$ postgres-execute connection=postgres://mz_system:materialize@{0}
331ALTER SYSTEM SET enable_rbac_checks = false
332
333> SELECT count(*)
334  FROM mz_internal.mz_recent_activity_log
335  WHERE
336    (finished_at IS NULL OR finished_status IS NULL)
337    AND sql NOT LIKE '%__FILTER-OUT-THIS-QUERY__%'
338    AND finished_status IS DISTINCT FROM 'aborted';
3390
340
341$ postgres-execute connection=postgres://mz_system:materialize@{0}
342ALTER SYSTEM SET enable_rbac_checks = {1}
343"#,
344        state.materialize.internal_sql_addr, original_value
345    );
346
347    let mut line_reader = LineReader::new(&check_script);
348    let cmds = parse(&mut line_reader).map_err(|e| anyhow!("{}", e.source))?;
349
350    for cmd in cmds {
351        cmd.run(&mut state)
352            .await
353            .map_err(|e| anyhow!("{}", e.source))?;
354    }
355
356    drop(state);
357    state_cleanup.await?;
358
359    Ok(())
360}
361
362/// Checks if the provided `shard_id` is a tombstone, returning an error if it's not.
363async fn check_shard_tombstone(state: &State, shard_id: &str) -> Result<(), anyhow::Error> {
364    println!("$ check-shard-tombstone {shard_id}");
365
366    let (Some(consensus_uri), Some(blob_uri)) =
367        (&state.persist_consensus_url, &state.persist_blob_url)
368    else {
369        // TODO(parkmycar): Testdrive on Cloud Test doesn't currently supply the Persist URLs.
370        tracing::warn!("Persist consensus or blob URL not known");
371        return Ok(());
372    };
373
374    let location = PersistLocation {
375        blob_uri: blob_uri.clone(),
376        consensus_uri: consensus_uri.clone(),
377    };
378    let client = state
379        .persist_clients
380        .open(location)
381        .await
382        .context("openning persist client")?;
383    let shard_id = ShardId::from_str(shard_id).map_err(|s| anyhow!("invalid ShardId: {s}"))?;
384
385    // It might take the storage-controller a moment to drop it's handles, so do a couple retries.
386    let (_client, result) = Retry::default()
387        .max_duration(state.timeout)
388        .retry_async_with_state(client, |retry_state, client| async move {
389            let inspect_state = client
390                .inspect_shard::<mz_repr::Timestamp>(&shard_id)
391                .await
392                .context("inspecting shard")
393                .and_then(|state| serde_json::to_value(state).context("to json"))
394                .and_then(|state| {
395                    serde_json::from_value::<ShardState>(state).context("to shard state")
396                });
397
398            let result = match inspect_state {
399                Ok(state) if state.is_tombstone() => RetryResult::Ok(()),
400                Ok(state) => {
401                    if retry_state.i == 0 {
402                        print!("shard isn't tombstoned; sleeping to see if it gets cleaned up.");
403                    }
404                    if let Some(backoff) = retry_state.next_backoff {
405                        if !backoff.is_zero() {
406                            print!(" {:.0?}", backoff);
407                        }
408                    }
409                    std::io::stdout().flush().expect("flushing stdout");
410
411                    RetryResult::RetryableErr(anyhow!("non-tombstone state: {state:?}"))
412                }
413                Result::Err(e) => RetryResult::FatalErr(e),
414            };
415
416            (client, result)
417        })
418        .await;
419
420    result
421}
422
423/// Parts of a shard's state that we read to determine if it's a tombstone.
424#[derive(Debug, Serialize, Deserialize)]
425struct ShardState {
426    leased_readers: BTreeMap<String, serde_json::Value>,
427    critical_readers: BTreeMap<String, serde_json::Value>,
428    writers: BTreeMap<String, serde_json::Value>,
429    since: Vec<mz_repr::Timestamp>,
430    upper: Vec<mz_repr::Timestamp>,
431}
432
433impl ShardState {
434    /// Returns if this shard is currently a tombstsone.
435    fn is_tombstone(&self) -> bool {
436        self.upper.is_empty()
437            && self.since.is_empty()
438            && self.writers.is_empty()
439            && self.leased_readers.is_empty()
440            && self.critical_readers.is_empty()
441    }
442}