1use 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#[derive(clap::ValueEnum, Default, Debug, Copy, Clone, PartialEq, Eq)]
28pub enum Level {
29 #[default]
31 File,
32 Statement,
34 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
51pub 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
67pub async fn run_consistency_checks(state: &State) -> Result<ControlFlow, anyhow::Error> {
70 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 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
106pub 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
119async fn check_coordinator(state: &State) -> Result<(), anyhow::Error> {
121 let response = reqwest::get(&format!(
123 "http://{}/api/coordinator/dump",
124 state.materialize.internal_http_addr
125 ))
126 .await?;
127 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 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
158async 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 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 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 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 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 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 .dump(unfinalized_shards)
225 .expect("state must be dumpable")
226 });
227 let Some(disk_catalog) = maybe_disk_catalog else {
228 tracing::warn!("No Catalog state on disk, skipping consistency check");
231 return Ok(());
232 };
233
234 if disk_catalog != memory_catalog {
235 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
252async fn check_statement_logging(orig_state: &State) -> Result<(), anyhow::Error> {
266 use crate::util::postgres::postgres_client;
267
268 let (mut state, state_cleanup) = action::create_state(&orig_state.config).await?;
271
272 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 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
325async 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 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 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#[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 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}