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 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 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 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 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 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 .dump(unfinalized_shards)
253 .expect("state must be dumpable")
254 });
255 let Some(disk_catalog) = maybe_disk_catalog else {
256 tracing::warn!("No Catalog state on disk, skipping consistency check");
259 return Ok(());
260 };
261
262 if disk_catalog != memory_catalog {
263 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
280async fn check_statement_logging(orig_state: &State) -> Result<(), anyhow::Error> {
294 use crate::util::postgres::postgres_client;
295
296 let (mut state, state_cleanup) = action::create_state(&orig_state.config).await?;
299
300 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 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
353async 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 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 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#[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 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}