1use std::collections::BTreeSet;
11use std::fmt::Debug;
12use std::time::{Duration, Instant};
13use std::{cmp, str};
14
15use anyhow::{Context, anyhow, bail, ensure};
16use mz_kafka_util::client::get_partitions;
17use mz_ore::task;
18use mz_postgres_util::{Sql, query_one, sql};
19use rdkafka::consumer::{BaseConsumer, CommitMode, Consumer, StreamConsumer};
20use rdkafka::error::KafkaError;
21use rdkafka::message::{Headers, Message};
22use rdkafka::types::RDKafkaErrorCode;
23use rdkafka::{Offset, TopicPartitionList};
24use regex::Regex;
25use tokio::pin;
26use tokio_stream::StreamExt;
27
28use crate::action::{ControlFlow, State};
29use crate::format::avro::{self, DebugValue};
30use crate::parser::BuiltinCommand;
31
32#[derive(Debug, Clone, Copy)]
33enum Format {
34 Avro,
35 Json,
36 Bytes,
37 Text,
38}
39
40impl TryFrom<&str> for Format {
41 type Error = anyhow::Error;
42
43 fn try_from(value: &str) -> Result<Self, Self::Error> {
44 match value {
45 "avro" => Ok(Format::Avro),
46 "json" => Ok(Format::Json),
47 "bytes" => Ok(Format::Bytes),
48 "text" => Ok(Format::Text),
49 f => bail!("unknown format: {}", f),
50 }
51 }
52}
53
54#[derive(Debug)]
55struct RecordFormat {
56 key: Format,
57 value: Format,
58 requires_key: bool,
59}
60
61#[allow(dead_code)]
62#[derive(Debug, Clone)]
63enum DecodedValue {
64 Avro(DebugValue),
65 Json(serde_json::Value),
66 Bytes(Vec<u8>),
67 Text(String),
68}
69
70enum Topic {
71 FromSink(String),
72 Named(String),
73}
74
75#[derive(Debug, Clone)]
76struct Record<A> {
77 headers: Vec<String>,
78 key: Option<A>,
79 value: Option<A>,
80 partition: Option<i32>,
81}
82
83async fn get_topic(sink: &str, topic_field: &str, state: &State) -> Result<String, anyhow::Error> {
84 let query = sql!(
85 "SELECT {} FROM mz_sinks JOIN mz_kafka_sinks \
86 ON mz_sinks.id = mz_kafka_sinks.id \
87 JOIN mz_schemas s ON s.id = mz_sinks.schema_id \
88 LEFT JOIN mz_databases d ON d.id = s.database_id \
89 WHERE d.name = $1 \
90 AND s.name = $2 \
91 AND mz_sinks.name = $3",
92 Sql::ident(topic_field)
93 );
94 let sink_fields: Vec<&str> = sink.split('.').collect();
95 let result = query_one(
96 &state.materialize.pgclient,
97 query,
98 &[&sink_fields[0], &sink_fields[1], &sink_fields[2]],
99 )
100 .await
101 .context("retrieving topic name")?
102 .get(topic_field);
103 Ok(result)
104}
105
106pub async fn run_verify_data(
107 mut cmd: BuiltinCommand,
108 state: &mut State,
109) -> Result<ControlFlow, anyhow::Error> {
110 let mut format = if let Some(format_str) = cmd.args.opt_string("format") {
111 let requires_key: bool = cmd.args.opt_bool("key")?.unwrap_or(false);
115 let format_type = format_str.as_str().try_into()?;
116 RecordFormat {
117 key: format_type,
118 value: format_type,
119 requires_key,
120 }
121 } else {
122 let key_format = cmd.args.string("key-format")?.as_str().try_into()?;
123 let value_format = cmd.args.string("value-format")?.as_str().try_into()?;
124 RecordFormat {
125 key: key_format,
126 value: value_format,
127 requires_key: true,
128 }
129 };
130
131 let source = match (cmd.args.opt_string("sink"), cmd.args.opt_string("topic")) {
132 (Some(sink), None) => Topic::FromSink(sink),
133 (None, Some(topic)) => Topic::Named(topic),
134 (Some(_), Some(_)) => bail!("Can't provide both `source` and `topic` to kafka-verify-data"),
135 (None, None) => bail!("kafka-verify-data expects either `source` or `topic`"),
136 };
137
138 let sort_messages = cmd.args.opt_bool("sort-messages")?.unwrap_or(false);
139
140 let header_keys: Vec<_> = cmd
141 .args
142 .opt_string("headers")
143 .map(|s| s.split(',').map(str::to_owned).collect())
144 .unwrap_or_default();
145
146 let expected_messages = cmd.input;
147 if expected_messages.len() == 0 {
148 bail!("kafka-verify-data requires a non-empty list of expected messages");
151 }
152 let partial_search = cmd.args.opt_parse("partial-search")?;
153 let exhaustive = cmd.args.opt_bool("exhaustive")?.unwrap_or(true);
158 let debug_print_only = cmd.args.opt_bool("debug-print-only")?.unwrap_or(false);
159 let glue = cmd.args.opt_bool("glue")?.unwrap_or(false);
162 cmd.args.done()?;
163
164 let topic: String = match &source {
165 Topic::FromSink(sink) => get_topic(sink, "topic", state).await?,
166 Topic::Named(name) => name.clone(),
167 };
168
169 println!("Verifying results in Kafka topic {}", topic);
170
171 let mut config = state.kafka_config.clone();
172 config.set("enable.auto.commit", "false");
173 config.set("enable.auto.offset.store", "false");
174
175 let consumer: StreamConsumer =
176 mz_kafka_util::client::create(&config).context("creating kafka consumer")?;
177 consumer
178 .subscribe(&[&topic])
179 .context("subscribing to kafka topic")?;
180
181 let (mut stream_messages_remaining, stream_timeout) = match partial_search {
187 Some(size) => (size, state.timeout),
188 None => (expected_messages.len(), Duration::from_secs(15)),
189 };
190
191 let timeout = cmp::max(state.timeout, stream_timeout);
192
193 let message_stream = consumer.stream().timeout(timeout);
194 pin!(message_stream);
195
196 let mut actual_bytes = vec![];
202
203 let start = std::time::Instant::now();
204 let mut topic_created = false;
205
206 while stream_messages_remaining > 0 {
207 match message_stream.next().await {
208 Some(Ok(message)) => {
209 let message = match message {
210 Err(KafkaError::MessageConsumption(
213 RDKafkaErrorCode::UnknownTopicOrPartition,
214 )) if start.elapsed() < timeout && !topic_created => {
215 println!("waiting for Kafka topic creation...");
216 continue;
217 }
218 e => e?,
219 };
220
221 stream_messages_remaining -= 1;
222 topic_created = true;
223
224 consumer
225 .store_offset_from_message(&message)
226 .context("storing message offset")?;
227
228 let mut headers = vec![];
229 for header_key in &header_keys {
230 let hs = message.headers().context("expected headers for message")?;
232 let mut hs = hs.iter().filter(|i| i.key == header_key);
233 let h = hs.next();
234 if hs.next().is_some() {
235 bail!("expected at most one header with key {header_key}");
236 }
237 match h {
238 None => headers.push("<missing>".into()),
239 Some(h) => {
240 let value = str::from_utf8(h.value.unwrap_or(b"<null>"))?;
241 headers.push(value.into());
242 }
243 }
244 }
245
246 actual_bytes.push(Record {
247 headers,
248 key: message.key().map(|b| b.to_owned()),
249 value: message.payload().map(|b| b.to_owned()),
250 partition: Some(message.partition()),
251 });
252 }
253 Some(Err(e)) => {
254 println!("Received error from Kafka stream consumer: {}", e);
255 break;
256 }
257 None => {
258 break;
259 }
260 }
261 }
262
263 let (key_schema, value_schema) = if glue {
264 resolve_glue_schemas(state, &actual_bytes, &mut format).await?
269 } else {
270 let key_schema = if let Format::Avro = format.key {
271 let schema = match state
275 .ccsr_client
276 .get_schema_by_subject(&format!("{}-key", topic))
277 .await
278 {
279 Ok(key_schema) => {
280 Some(avro::parse_schema(&key_schema.raw, &[]).context("parsing avro schema")?)
281 }
282 Err(
283 mz_ccsr::GetBySubjectError::SubjectNotFound
284 | mz_ccsr::GetBySubjectError::VersionNotFound(_),
285 ) => None,
286 Err(e) => return Err(anyhow::Error::from(e).context("fetching key schema")),
287 };
288 if schema.is_some() {
291 format.requires_key = true;
292 }
293 schema
294 } else {
295 None
296 };
297 let value_schema = if let Format::Avro = format.value {
298 let val_schema = state
299 .ccsr_client
300 .get_schema_by_subject(&format!("{}-value", topic))
301 .await
302 .context("fetching schema")?
303 .raw;
304 Some(avro::parse_schema(&val_schema, &[]).context("parsing avro schema")?)
305 } else {
306 None
307 };
308 (key_schema, value_schema)
309 };
310
311 if glue {
316 if matches!(format.value, Format::Avro) && value_schema.is_none() {
317 bail!(
318 "kafka-verify-data glue=true: no records received to resolve the \
319 value schema from Glue (did the sink produce no output?)"
320 );
321 }
322 if format.requires_key && matches!(format.key, Format::Avro) && key_schema.is_none() {
323 bail!(
324 "kafka-verify-data glue=true: no keyed records received to resolve \
325 the key schema from Glue (did the sink produce no output?)"
326 );
327 }
328 }
329
330 let mut actual_messages =
331 decode_messages(actual_bytes, &key_schema, &value_schema, &format, glue)?;
332
333 if sort_messages {
334 actual_messages.sort_by_key(|r| format!("{:?}", r));
335 }
336
337 if debug_print_only {
338 bail!(
339 "records in sink:\n{}",
340 actual_messages
341 .into_iter()
342 .map(|a| format!("{:#?}", a))
343 .collect::<Vec<_>>()
344 .join("\n")
345 );
346 }
347
348 let expected = parse_expected_messages(
349 expected_messages,
350 key_schema,
351 value_schema,
352 &format,
353 &header_keys,
354 )?;
355
356 verify_with_partial_search(
357 &expected,
358 &actual_messages,
359 &state.regex,
360 &state.regex_replacement,
361 partial_search.is_some(),
362 )?;
363
364 consumer
365 .commit_consumer_state(CommitMode::Sync)
366 .context("committing verified message offsets")?;
367
368 if partial_search.is_some() || !exhaustive {
369 state.kafka_verify_topics.remove(&topic);
370 } else {
371 state.kafka_verify_topics.insert(topic);
372 }
373
374 Ok(ControlFlow::Continue)
375}
376
377pub async fn verify_topics_exhausted(state: &State) -> Result<(), anyhow::Error> {
379 for topic in &state.kafka_verify_topics {
380 let mut config = state.kafka_config.clone();
381 config.set("enable.auto.commit", "false");
382 config.set("enable.auto.offset.store", "false");
383 config.set("enable.partition.eof", "true");
384
385 let topic = topic.clone();
386 let timeout = state.timeout;
387 task::spawn_blocking(
388 {
389 let topic = topic.clone();
390 move || format!("kafka_verify_topic_exhausted:{topic}")
391 },
392 move || verify_topic_exhausted(config, &topic, timeout),
393 )
394 .await?;
395 }
396 Ok(())
397}
398
399fn verify_topic_exhausted(
400 config: rdkafka::ClientConfig,
401 topic: &str,
402 timeout: Duration,
403) -> Result<(), anyhow::Error> {
404 let consumer: BaseConsumer =
405 mz_kafka_util::client::create(&config).context("creating kafka consumer")?;
406 let deadline = Instant::now() + timeout;
407 let partitions = get_partitions(consumer.client(), topic, remaining(deadline)?)?;
408
409 let mut requested_offsets = TopicPartitionList::with_capacity(partitions.len());
410 for partition in &partitions {
411 requested_offsets.add_partition(topic, *partition);
412 }
413 let committed_offsets = consumer
414 .committed_offsets(requested_offsets, remaining(deadline)?)
415 .context("fetching committed message offsets")?;
416
417 let mut assignment = TopicPartitionList::with_capacity(partitions.len());
422 let mut pending: BTreeSet<i32> = BTreeSet::new();
423 for partition in partitions {
424 let committed = committed_offsets
425 .find_partition(topic, partition)
426 .context("missing committed offset for topic partition")?
427 .offset();
428 let (low, high) = consumer
429 .fetch_watermarks(topic, partition, remaining(deadline)?)
430 .with_context(|| {
431 format!("fetching watermarks for Kafka topic {topic} partition {partition}")
432 })?;
433 let start = verification_start_offset(committed, low, high)?;
434 assignment.add_partition_offset(topic, partition, Offset::Offset(start))?;
435 pending.insert(partition);
436 }
437
438 consumer
439 .assign(&assignment)
440 .context("assigning Kafka topic partitions")?;
441
442 while !pending.is_empty() {
450 match consumer.poll(remaining(deadline)?) {
451 Some(Ok(message)) => {
452 bail!(
453 "extra record after final kafka-verify-data for topic {topic}, partition {}, offset {}",
454 message.partition(),
455 message.offset(),
456 );
457 }
458 Some(Err(KafkaError::PartitionEOF(partition))) => {
459 pending.remove(&partition);
460 }
461 Some(Err(e)) => {
462 return Err(e).context("reading final Kafka topic offsets");
463 }
464 None => {
465 bail!("timed out verifying the end of Kafka topic {topic}");
466 }
467 }
468 }
469
470 Ok(())
471}
472
473fn verification_start_offset(committed: Offset, low: i64, high: i64) -> Result<i64, anyhow::Error> {
474 match committed {
475 Offset::Offset(offset) if (low..=high).contains(&offset) => Ok(offset),
476 Offset::Offset(_) | Offset::Invalid => Ok(low),
477 offset => bail!("unexpected committed Kafka offset {offset:?}"),
478 }
479}
480
481fn remaining(deadline: Instant) -> Result<Duration, anyhow::Error> {
482 deadline
483 .checked_duration_since(Instant::now())
484 .filter(|remaining| !remaining.is_zero())
485 .context("timed out verifying final Kafka topic offsets")
486}
487
488async fn resolve_glue_schemas(
496 state: &State,
497 records: &[Record<Vec<u8>>],
498 format: &mut RecordFormat,
499) -> Result<(Option<mz_avro::Schema>, Option<mz_avro::Schema>), anyhow::Error> {
500 let client = aws_sdk_glue::Client::new(&state.aws_config);
501
502 async fn schema_for(
503 client: &aws_sdk_glue::Client,
504 payload: Option<&Vec<u8>>,
505 ) -> Result<Option<mz_avro::Schema>, anyhow::Error> {
506 let Some(payload) = payload else {
507 return Ok(None);
508 };
509 let (schema_version_id, _) = mz_interchange::glue::extract_avro_header(payload)?;
510 let resp = client
511 .get_schema_version()
512 .schema_version_id(schema_version_id.to_string())
513 .send()
514 .await
515 .with_context(|| format!("fetching Glue schema version {schema_version_id}"))?;
516 let definition = resp
517 .schema_definition()
518 .ok_or_else(|| anyhow!("Glue schema version {schema_version_id} has no definition"))?;
519 Ok(Some(
520 avro::parse_schema(definition, &[]).context("parsing Glue avro schema")?,
521 ))
522 }
523
524 let value_schema = if let Format::Avro = format.value {
525 schema_for(&client, records.iter().find_map(|r| r.value.as_ref())).await?
526 } else {
527 None
528 };
529 let key_schema = if let Format::Avro = format.key {
530 let schema = schema_for(&client, records.iter().find_map(|r| r.key.as_ref())).await?;
531 if schema.is_some() {
532 format.requires_key = true;
533 }
534 schema
535 } else {
536 None
537 };
538 Ok((key_schema, value_schema))
539}
540
541fn split_headers(input: &str, n_headers: usize) -> anyhow::Result<(Vec<String>, &str)> {
543 let whitespace = Regex::new("\\s+").expect("building known-valid regex");
544 let mut parts = whitespace.splitn(input, n_headers + 1);
545 let mut headers = Vec::with_capacity(n_headers);
546 for _ in 0..n_headers {
547 headers.push(
548 parts
549 .next()
550 .context("expected another header in the input")?
551 .to_string(),
552 )
553 }
554 let rest = parts
555 .next()
556 .context("expected some contents after any message headers")?;
557
558 ensure!(
559 parts.next().is_none(),
560 "more than n+1 elements from a call to splitn(_, n+1)"
561 );
562
563 Ok((headers, rest))
564}
565
566fn decode_avro(
569 schema: &mz_avro::Schema,
570 bytes: &[u8],
571 glue: bool,
572) -> Result<avro::Value, anyhow::Error> {
573 if glue {
574 avro::from_glue_bytes(schema, bytes)
575 } else {
576 avro::from_confluent_bytes(schema, bytes)
577 }
578}
579
580fn decode_messages(
581 actual_bytes: Vec<Record<Vec<u8>>>,
582 key_schema: &Option<mz_avro::Schema>,
583 value_schema: &Option<mz_avro::Schema>,
584 format: &RecordFormat,
585 glue: bool,
586) -> Result<Vec<Record<DecodedValue>>, anyhow::Error> {
587 let mut actual_messages = vec![];
588
589 for record in actual_bytes {
590 let Record { key, value, .. } = record;
591 let key = if format.requires_key {
592 match (key, format.key) {
593 (Some(bytes), Format::Avro) => Some(DecodedValue::Avro(DebugValue(decode_avro(
594 key_schema.as_ref().unwrap(),
595 &bytes,
596 glue,
597 )?))),
598 (Some(bytes), Format::Json) => Some(DecodedValue::Json(
599 serde_json::from_slice(&bytes).context("decoding json")?,
600 )),
601 (Some(bytes), Format::Bytes) => Some(DecodedValue::Bytes(bytes)),
602 (Some(bytes), Format::Text) => Some(DecodedValue::Text(String::from_utf8(bytes)?)),
603 (None, _) if format.requires_key => bail!("empty message key"),
604 (None, _) => None,
605 }
606 } else {
607 None
608 };
609
610 let value = match (value, format.value) {
611 (Some(bytes), Format::Avro) => Some(DecodedValue::Avro(DebugValue(decode_avro(
612 value_schema.as_ref().unwrap(),
613 &bytes,
614 glue,
615 )?))),
616 (Some(bytes), Format::Json) => Some(DecodedValue::Json(
617 serde_json::from_slice(&bytes).context("decoding json")?,
618 )),
619 (Some(bytes), Format::Bytes) => Some(DecodedValue::Bytes(bytes)),
620 (Some(bytes), Format::Text) => Some(DecodedValue::Text(String::from_utf8(bytes)?)),
621 (None, _) => None,
622 };
623
624 actual_messages.push(Record {
625 headers: record.headers.clone(),
626 key,
627 value,
628 partition: record.partition,
629 });
630 }
631
632 Ok(actual_messages)
633}
634
635fn parse_expected_messages(
636 expected_messages: Vec<String>,
637 key_schema: Option<mz_avro::Schema>,
638 value_schema: Option<mz_avro::Schema>,
639 format: &RecordFormat,
640 header_keys: &[String],
641) -> Result<Vec<Record<DecodedValue>>, anyhow::Error> {
642 let mut expected = vec![];
643
644 for msg in expected_messages {
645 let (headers, content) = split_headers(&msg, header_keys.len())?;
646 let mut content = content.as_bytes();
647 let mut deserializer = serde_json::Deserializer::from_reader(&mut content).into_iter();
648
649 let key = if format.requires_key {
650 let key: serde_json::Value = deserializer
651 .next()
652 .context("key missing in input line")?
653 .context("parsing json")?;
654
655 Some(match format.key {
656 Format::Avro => DecodedValue::Avro(DebugValue(avro::from_json(
657 &key,
658 key_schema.as_ref().unwrap().top_node(),
659 )?)),
660 Format::Json => DecodedValue::Json(key),
661 Format::Bytes => {
662 unimplemented!("bytes format not yet supported in tests")
663 }
664 Format::Text => DecodedValue::Text(
665 key.as_str()
666 .map(|s| s.to_string())
667 .unwrap_or_else(|| key.to_string()),
668 ),
669 })
670 } else {
671 None
672 };
673
674 let value = match deserializer.next().transpose().context("parsing json")? {
675 None => None,
676 Some(value) if value.as_str() == Some("<null>") => None,
677 Some(value) => match format.value {
678 Format::Avro => Some(DecodedValue::Avro(DebugValue(avro::from_json(
679 &value,
680 value_schema.as_ref().unwrap().top_node(),
681 )?))),
682 Format::Json => Some(DecodedValue::Json(value)),
683 Format::Bytes => {
684 unimplemented!("bytes format not yet supported in tests")
685 }
686 Format::Text => Some(DecodedValue::Text(
690 value
691 .as_str()
692 .map(|s| s.to_string())
693 .unwrap_or_else(|| value.to_string()),
694 )),
695 },
696 };
697
698 let content =
699 str::from_utf8(content).context("internal error: contents were previously a string")?;
700 let partition = match content.trim().split_once("=") {
701 None if content.trim() != "" => bail!("unexpected cruft at end of line: {content}"),
702 None => None,
703 Some((label, partition)) => {
704 if label != "partition" {
705 bail!("partition expectation has unexpected label: {label}")
706 }
707 Some(partition.parse().context("parsing expected partition")?)
708 }
709 };
710
711 expected.push(Record {
712 headers,
713 key,
714 value,
715 partition,
716 });
717 }
718
719 Ok(expected)
720}
721
722fn verify_with_partial_search<A>(
723 expected: &[Record<A>],
724 actual: &[Record<A>],
725 regex: &Option<Regex>,
726 regex_replacement: &String,
727 partial_search: bool,
728) -> Result<(), anyhow::Error>
729where
730 A: Debug + Clone,
731{
732 let mut expected = expected.iter();
733 let mut actual = actual.iter();
734 let mut index = 0..;
735
736 let mut found_beginning = !partial_search;
737 let mut expected_item = expected.next();
738 let mut actual_item = actual.next();
739 loop {
740 let i = index.next().expect("known to exist");
741 match (expected_item, actual_item) {
742 (Some(e), Some(a)) => {
743 let mut a = a.clone();
744 if e.partition.is_none() {
745 a.partition = None;
746 }
747 let e_str = format!("{:#?}", e);
748 let a_str = match ®ex {
749 Some(regex) => regex
750 .replace_all(&format!("{:#?}", a).to_string(), regex_replacement.as_str())
751 .to_string(),
752 _ => format!("{:#?}", a),
753 };
754
755 if e_str != a_str {
756 if found_beginning {
757 bail!(
758 "record {} did not match\nexpected:\n{}\n\nactual:\n{}",
759 i,
760 e_str,
761 a_str,
762 );
763 }
764 actual_item = actual.next();
765 } else {
766 found_beginning = true;
767 expected_item = expected.next();
768 actual_item = actual.next();
769 }
770 }
771 (Some(e), None) => bail!("missing record {}: {:#?}", i, e),
772 (None, Some(a)) => {
773 if !partial_search {
774 bail!("extra record {}: {:#?}", i, a);
775 }
776 break;
777 }
778 (None, None) => break,
779 }
780 }
781 let expected: Vec<_> = expected.map(|e| format!("{:#?}", e)).collect();
782 let actual: Vec<_> = actual.map(|a| format!("{:#?}", a)).collect();
783
784 if !expected.is_empty() {
785 bail!("missing records:\n{}", expected.join("\n"))
786 } else if !actual.is_empty() && !partial_search {
787 bail!("extra records:\n{}", actual.join("\n"))
788 } else {
789 Ok(())
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 #[mz_ore::test]
798 fn verification_start_offset_uses_committed_offset() {
799 assert_eq!(
800 verification_start_offset(Offset::Offset(5), 2, 8).unwrap(),
801 5
802 );
803 }
804
805 #[mz_ore::test]
806 fn verification_start_offset_resets_out_of_range_offsets() {
807 assert_eq!(
808 verification_start_offset(Offset::Offset(1), 2, 8).unwrap(),
809 2
810 );
811 assert_eq!(
812 verification_start_offset(Offset::Offset(9), 2, 8).unwrap(),
813 2
814 );
815 assert_eq!(verification_start_offset(Offset::Invalid, 2, 8).unwrap(), 2);
816 }
817}