1use 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#[derive(clap::ValueEnum, Default, Debug, Copy, Clone, PartialEq, Eq)]
29pub enum Level {
30 #[default]
32 File,
33 Statement,
35 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
52pub 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
68async 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
91pub async fn run_consistency_checks(state: &State) -> Result<ControlFlow, anyhow::Error> {
94 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 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
134pub 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
147async fn check_coordinator(state: &State) -> Result<(), anyhow::Error> {
149 let response = reqwest::get(&format!(
151 "http://{}/api/coordinator/dump",
152 state.materialize.internal_http_addr
153 ))
154 .await?;
155 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 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
186async 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 if state.materialize.catalog_config.is_none() {
205 return Ok(());
206 }
207
208 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 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 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 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 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 .dump(unfinalized_shards)
262 .expect("state must be dumpable")
263 });
264 let Some(disk_catalog) = maybe_disk_catalog else {
265 tracing::warn!("No Catalog state on disk, skipping consistency check");
268 return Ok(());
269 };
270
271 if disk_catalog != memory_catalog {
272 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
289async fn check_statement_logging(orig_state: &State) -> Result<(), anyhow::Error> {
303 use crate::util::postgres::postgres_client;
304
305 let (mut state, state_cleanup) = action::create_state(&orig_state.config).await?;
308
309 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 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
362async 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 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 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#[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 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}