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