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    // Dump the in-memory catalog state of the Materialize environment that we're
200    // connected to.
201    let memory_catalog = reqwest::get(&format!(
202        "http://{}/api/catalog/dump",
203        state.materialize.internal_http_addr,
204    ))
205    .await
206    .context("GET catalog")?
207    .text()
208    .await
209    .context("deserialize catalog")?;
210
211    // Pull out the system parameter defaults from the in-memory catalog, as we
212    // need to load the disk catalog with the same defaults.
213    let dump: CatalogDump = serde_json::from_str(&memory_catalog).context("decoding catalog")?;
214
215    let Some(system_parameter_defaults) = dump.system_parameter_defaults else {
216        // TODO(parkmycar, def-): Ideally this could be an error, but a lot of test suites fail. We
217        // should explicitly disable consistency check in these test suites.
218        tracing::warn!(
219            "Missing system_parameter_defaults in memory catalog state, skipping consistency check"
220        );
221        return Ok(());
222    };
223
224    let unfinalized_shards = dump
225        .storage_metadata
226        .and_then(|storage_metadata| storage_metadata.unfinalized_shards);
227
228    // Load the on-disk catalog and dump its state.
229
230    // Make sure the version is parseable.
231    let _: semver::Version = state.build_info.version.parse().expect("invalid version");
232
233    let maybe_disk_catalog = state
234        .with_catalog_copy(
235            system_parameter_defaults,
236            state.build_info,
237            &state.materialize.bootstrap_args,
238            // The expression cache can be taxing on the CPU and is unnecessary for consistency checks.
239            Some(false),
240            |catalog| catalog.state().clone(),
241        )
242        .await
243        .map_err(|e| anyhow!("failed to read on-disk catalog state: {e}"))?
244        .map(|catalog| {
245            catalog
246                // The set of unfinalized shards in the catalog are updated asynchronously by
247                // background processes. As a result, the value may legitimately change after
248                // fetching the memory catalog but before fetching the disk catalog, causing the
249                // comparison to fail. This is a gross hack that always sets the disk catalog's
250                // unfinalized shards equal to the memory catalog's unfinalized shards to ignore
251                // false negatives. Unfortunately, we also end up ignoring true negatives.
252                .dump(unfinalized_shards)
253                .expect("state must be dumpable")
254        });
255    let Some(disk_catalog) = maybe_disk_catalog else {
256        // TODO(parkmycar, def-): Ideally this could be an error, but a lot of test suites fail. We
257        // should explicitly disable consistency check in these test suites.
258        tracing::warn!("No Catalog state on disk, skipping consistency check");
259        return Ok(());
260    };
261
262    if disk_catalog != memory_catalog {
263        // The state objects here are around 100k lines pretty printed, so find the
264        // first lines that differs and show context around it.
265        let diff = similar::TextDiff::from_lines(&memory_catalog, &disk_catalog)
266            .unified_diff()
267            .context_radius(50)
268            .to_string()
269            .lines()
270            .take(200)
271            .collect::<Vec<_>>()
272            .join("\n");
273
274        bail!("the in-memory state of the catalog does not match its on-disk state:\n{diff}");
275    }
276
277    Ok(())
278}
279
280/// This currently checks only whether the statement log reports all statements to be in a finished
281/// state. (We used to have an assertion for roughly this in `ExecuteContextExtra`'s Drop, but that
282/// had to be removed due to <https://github.com/MaterializeInc/database-issues/issues/7304>)
283///
284/// Note that this check should succeed regardless of the statement logging sampling rate.
285///
286/// Ideally, we could run this at any moment successfully, but currently system restarts can mess
287/// this up: there is a buffering of statement log writes, with the buffers flushed every 5 seconds.
288/// So, if a system kill/restart comes at a bad moment, then some statements might get permanently
289/// stuck in an unfinished state in the statement log. Therefore, we currently run this only after
290/// normal `.td`s, but not after cluster tests and whatnot that kill/restart the system.
291/// (Also, this can take several seconds due to the 5 sec buffering, so we run this only in Nightly
292/// by default.)
293async fn check_statement_logging(orig_state: &State) -> Result<(), anyhow::Error> {
294    use crate::util::postgres::postgres_client;
295
296    // Create new Testdrive state, so that we create a new session to Materialize, and we forget any
297    // weird Testdrive setting that the `.td` file before us might have set.
298    let (mut state, state_cleanup) = action::create_state(&orig_state.config).await?;
299
300    // First, query the current value of enable_rbac_checks so we can restore it later
301    let mz_system_url = format!(
302        "postgres://mz_system:materialize@{}",
303        state.materialize.internal_sql_addr
304    );
305
306    let (client, _handle) = postgres_client(&mz_system_url, state.default_timeout)
307        .await
308        .context("connecting as mz_system to query enable_rbac_checks")?;
309
310    let row = query_one(&client, sql!("SHOW enable_rbac_checks"), &[])
311        .await
312        .context("querying enable_rbac_checks")?;
313
314    let original_value: String = row.get(0);
315
316    // Create a testdrive script to check that all statements have finished executing.
317    // We disable RBAC checks so we can query mz_internal tables, similar to statement-logging.td.
318    // We restore the setting to its original value at the end.
319    let check_script = format!(
320        r#"
321$ postgres-execute connection=postgres://mz_system:materialize@{0}
322ALTER SYSTEM SET enable_rbac_checks = false
323
324> SELECT count(*)
325  FROM mz_internal.mz_recent_activity_log
326  WHERE
327    (finished_at IS NULL OR finished_status IS NULL)
328    AND sql NOT LIKE '%__FILTER-OUT-THIS-QUERY__%'
329    AND finished_status IS DISTINCT FROM 'aborted';
3300
331
332$ postgres-execute connection=postgres://mz_system:materialize@{0}
333ALTER SYSTEM SET enable_rbac_checks = {1}
334"#,
335        state.materialize.internal_sql_addr, original_value
336    );
337
338    let mut line_reader = LineReader::new(&check_script);
339    let cmds = parse(&mut line_reader).map_err(|e| anyhow!("{}", e.source))?;
340
341    for cmd in cmds {
342        cmd.run(&mut state)
343            .await
344            .map_err(|e| anyhow!("{}", e.source))?;
345    }
346
347    drop(state);
348    state_cleanup.await?;
349
350    Ok(())
351}
352
353/// Checks if the provided `shard_id` is a tombstone, returning an error if it's not.
354async fn check_shard_tombstone(state: &State, shard_id: &str) -> Result<(), anyhow::Error> {
355    println!("$ check-shard-tombstone {shard_id}");
356
357    let (Some(consensus_uri), Some(blob_uri)) =
358        (&state.persist_consensus_url, &state.persist_blob_url)
359    else {
360        // TODO(parkmycar): Testdrive on Cloud Test doesn't currently supply the Persist URLs.
361        tracing::warn!("Persist consensus or blob URL not known");
362        return Ok(());
363    };
364
365    let location = PersistLocation {
366        blob_uri: blob_uri.clone(),
367        consensus_uri: consensus_uri.clone(),
368    };
369    let client = state
370        .persist_clients
371        .open(location)
372        .await
373        .context("openning persist client")?;
374    let shard_id = ShardId::from_str(shard_id).map_err(|s| anyhow!("invalid ShardId: {s}"))?;
375
376    // It might take the storage-controller a moment to drop it's handles, so do a couple retries.
377    let (_client, result) = Retry::default()
378        .max_duration(state.timeout)
379        .retry_async_with_state(client, |retry_state, client| async move {
380            let inspect_state = client
381                .inspect_shard::<mz_repr::Timestamp>(&shard_id)
382                .await
383                .context("inspecting shard")
384                .and_then(|state| serde_json::to_value(state).context("to json"))
385                .and_then(|state| {
386                    serde_json::from_value::<ShardState>(state).context("to shard state")
387                });
388
389            let result = match inspect_state {
390                Ok(state) if state.is_tombstone() => RetryResult::Ok(()),
391                Ok(state) => {
392                    if retry_state.i == 0 {
393                        print!("shard isn't tombstoned; sleeping to see if it gets cleaned up.");
394                    }
395                    if let Some(backoff) = retry_state.next_backoff {
396                        if !backoff.is_zero() {
397                            print!(" {:.0?}", backoff);
398                        }
399                    }
400                    std::io::stdout().flush().expect("flushing stdout");
401
402                    RetryResult::RetryableErr(anyhow!("non-tombstone state: {state:?}"))
403                }
404                Result::Err(e) => RetryResult::FatalErr(e),
405            };
406
407            (client, result)
408        })
409        .await;
410
411    result
412}
413
414/// Parts of a shard's state that we read to determine if it's a tombstone.
415#[derive(Debug, Serialize, Deserialize)]
416struct ShardState {
417    leased_readers: BTreeMap<String, serde_json::Value>,
418    critical_readers: BTreeMap<String, serde_json::Value>,
419    writers: BTreeMap<String, serde_json::Value>,
420    since: Vec<mz_repr::Timestamp>,
421    upper: Vec<mz_repr::Timestamp>,
422}
423
424impl ShardState {
425    /// Returns if this shard is currently a tombstsone.
426    fn is_tombstone(&self) -> bool {
427        self.upper.is_empty()
428            && self.since.is_empty()
429            && self.writers.is_empty()
430            && self.leased_readers.is_empty()
431            && self.critical_readers.is_empty()
432    }
433}