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 = config.create().context("creating kafka consumer")?;
176 consumer
177 .subscribe(&[&topic])
178 .context("subscribing to kafka topic")?;
179
180 let (mut stream_messages_remaining, stream_timeout) = match partial_search {
186 Some(size) => (size, state.timeout),
187 None => (expected_messages.len(), Duration::from_secs(15)),
188 };
189
190 let timeout = cmp::max(state.timeout, stream_timeout);
191
192 let message_stream = consumer.stream().timeout(timeout);
193 pin!(message_stream);
194
195 let mut actual_bytes = vec![];
201
202 let start = std::time::Instant::now();
203 let mut topic_created = false;
204
205 while stream_messages_remaining > 0 {
206 match message_stream.next().await {
207 Some(Ok(message)) => {
208 let message = match message {
209 Err(KafkaError::MessageConsumption(
212 RDKafkaErrorCode::UnknownTopicOrPartition,
213 )) if start.elapsed() < timeout && !topic_created => {
214 println!("waiting for Kafka topic creation...");
215 continue;
216 }
217 e => e?,
218 };
219
220 stream_messages_remaining -= 1;
221 topic_created = true;
222
223 consumer
224 .store_offset_from_message(&message)
225 .context("storing message offset")?;
226
227 let mut headers = vec![];
228 for header_key in &header_keys {
229 let hs = message.headers().context("expected headers for message")?;
231 let mut hs = hs.iter().filter(|i| i.key == header_key);
232 let h = hs.next();
233 if hs.next().is_some() {
234 bail!("expected at most one header with key {header_key}");
235 }
236 match h {
237 None => headers.push("<missing>".into()),
238 Some(h) => {
239 let value = str::from_utf8(h.value.unwrap_or(b"<null>"))?;
240 headers.push(value.into());
241 }
242 }
243 }
244
245 actual_bytes.push(Record {
246 headers,
247 key: message.key().map(|b| b.to_owned()),
248 value: message.payload().map(|b| b.to_owned()),
249 partition: Some(message.partition()),
250 });
251 }
252 Some(Err(e)) => {
253 println!("Received error from Kafka stream consumer: {}", e);
254 break;
255 }
256 None => {
257 break;
258 }
259 }
260 }
261
262 let (key_schema, value_schema) = if glue {
263 resolve_glue_schemas(state, &actual_bytes, &mut format).await?
268 } else {
269 let key_schema = if let Format::Avro = format.key {
270 let schema = match state
274 .ccsr_client
275 .get_schema_by_subject(&format!("{}-key", topic))
276 .await
277 {
278 Ok(key_schema) => {
279 Some(avro::parse_schema(&key_schema.raw, &[]).context("parsing avro schema")?)
280 }
281 Err(
282 mz_ccsr::GetBySubjectError::SubjectNotFound
283 | mz_ccsr::GetBySubjectError::VersionNotFound(_),
284 ) => None,
285 Err(e) => return Err(anyhow::Error::from(e).context("fetching key schema")),
286 };
287 if schema.is_some() {
290 format.requires_key = true;
291 }
292 schema
293 } else {
294 None
295 };
296 let value_schema = if let Format::Avro = format.value {
297 let val_schema = state
298 .ccsr_client
299 .get_schema_by_subject(&format!("{}-value", topic))
300 .await
301 .context("fetching schema")?
302 .raw;
303 Some(avro::parse_schema(&val_schema, &[]).context("parsing avro schema")?)
304 } else {
305 None
306 };
307 (key_schema, value_schema)
308 };
309
310 if glue {
315 if matches!(format.value, Format::Avro) && value_schema.is_none() {
316 bail!(
317 "kafka-verify-data glue=true: no records received to resolve the \
318 value schema from Glue (did the sink produce no output?)"
319 );
320 }
321 if format.requires_key && matches!(format.key, Format::Avro) && key_schema.is_none() {
322 bail!(
323 "kafka-verify-data glue=true: no keyed records received to resolve \
324 the key schema from Glue (did the sink produce no output?)"
325 );
326 }
327 }
328
329 let mut actual_messages =
330 decode_messages(actual_bytes, &key_schema, &value_schema, &format, glue)?;
331
332 if sort_messages {
333 actual_messages.sort_by_key(|r| format!("{:?}", r));
334 }
335
336 if debug_print_only {
337 bail!(
338 "records in sink:\n{}",
339 actual_messages
340 .into_iter()
341 .map(|a| format!("{:#?}", a))
342 .collect::<Vec<_>>()
343 .join("\n")
344 );
345 }
346
347 let expected = parse_expected_messages(
348 expected_messages,
349 key_schema,
350 value_schema,
351 &format,
352 &header_keys,
353 )?;
354
355 verify_with_partial_search(
356 &expected,
357 &actual_messages,
358 &state.regex,
359 &state.regex_replacement,
360 partial_search.is_some(),
361 )?;
362
363 consumer
364 .commit_consumer_state(CommitMode::Sync)
365 .context("committing verified message offsets")?;
366
367 if partial_search.is_some() || !exhaustive {
368 state.kafka_verify_topics.remove(&topic);
369 } else {
370 state.kafka_verify_topics.insert(topic);
371 }
372
373 Ok(ControlFlow::Continue)
374}
375
376pub async fn verify_topics_exhausted(state: &State) -> Result<(), anyhow::Error> {
378 for topic in &state.kafka_verify_topics {
379 let mut config = state.kafka_config.clone();
380 config.set("enable.auto.commit", "false");
381 config.set("enable.auto.offset.store", "false");
382 config.set("enable.partition.eof", "true");
383
384 let topic = topic.clone();
385 let timeout = state.timeout;
386 task::spawn_blocking(
387 {
388 let topic = topic.clone();
389 move || format!("kafka_verify_topic_exhausted:{topic}")
390 },
391 move || verify_topic_exhausted(config, &topic, timeout),
392 )
393 .await?;
394 }
395 Ok(())
396}
397
398fn verify_topic_exhausted(
399 config: rdkafka::ClientConfig,
400 topic: &str,
401 timeout: Duration,
402) -> Result<(), anyhow::Error> {
403 let consumer: BaseConsumer = config.create().context("creating kafka consumer")?;
404 let deadline = Instant::now() + timeout;
405 let partitions = get_partitions(consumer.client(), topic, remaining(deadline)?)?;
406
407 let mut requested_offsets = TopicPartitionList::with_capacity(partitions.len());
408 for partition in &partitions {
409 requested_offsets.add_partition(topic, *partition);
410 }
411 let committed_offsets = consumer
412 .committed_offsets(requested_offsets, remaining(deadline)?)
413 .context("fetching committed message offsets")?;
414
415 let mut assignment = TopicPartitionList::with_capacity(partitions.len());
420 let mut pending: BTreeSet<i32> = BTreeSet::new();
421 for partition in partitions {
422 let committed = committed_offsets
423 .find_partition(topic, partition)
424 .context("missing committed offset for topic partition")?
425 .offset();
426 let (low, high) = consumer
427 .fetch_watermarks(topic, partition, remaining(deadline)?)
428 .with_context(|| {
429 format!("fetching watermarks for Kafka topic {topic} partition {partition}")
430 })?;
431 let start = verification_start_offset(committed, low, high)?;
432 assignment.add_partition_offset(topic, partition, Offset::Offset(start))?;
433 pending.insert(partition);
434 }
435
436 consumer
437 .assign(&assignment)
438 .context("assigning Kafka topic partitions")?;
439
440 while !pending.is_empty() {
448 match consumer.poll(remaining(deadline)?) {
449 Some(Ok(message)) => {
450 bail!(
451 "extra record after final kafka-verify-data for topic {topic}, partition {}, offset {}",
452 message.partition(),
453 message.offset(),
454 );
455 }
456 Some(Err(KafkaError::PartitionEOF(partition))) => {
457 pending.remove(&partition);
458 }
459 Some(Err(e)) => {
460 return Err(e).context("reading final Kafka topic offsets");
461 }
462 None => {
463 bail!("timed out verifying the end of Kafka topic {topic}");
464 }
465 }
466 }
467
468 Ok(())
469}
470
471fn verification_start_offset(committed: Offset, low: i64, high: i64) -> Result<i64, anyhow::Error> {
472 match committed {
473 Offset::Offset(offset) if (low..=high).contains(&offset) => Ok(offset),
474 Offset::Offset(_) | Offset::Invalid => Ok(low),
475 offset => bail!("unexpected committed Kafka offset {offset:?}"),
476 }
477}
478
479fn remaining(deadline: Instant) -> Result<Duration, anyhow::Error> {
480 deadline
481 .checked_duration_since(Instant::now())
482 .filter(|remaining| !remaining.is_zero())
483 .context("timed out verifying final Kafka topic offsets")
484}
485
486async fn resolve_glue_schemas(
494 state: &State,
495 records: &[Record<Vec<u8>>],
496 format: &mut RecordFormat,
497) -> Result<(Option<mz_avro::Schema>, Option<mz_avro::Schema>), anyhow::Error> {
498 let client = aws_sdk_glue::Client::new(&state.aws_config);
499
500 async fn schema_for(
501 client: &aws_sdk_glue::Client,
502 payload: Option<&Vec<u8>>,
503 ) -> Result<Option<mz_avro::Schema>, anyhow::Error> {
504 let Some(payload) = payload else {
505 return Ok(None);
506 };
507 let (schema_version_id, _) = mz_interchange::glue::extract_avro_header(payload)?;
508 let resp = client
509 .get_schema_version()
510 .schema_version_id(schema_version_id.to_string())
511 .send()
512 .await
513 .with_context(|| format!("fetching Glue schema version {schema_version_id}"))?;
514 let definition = resp
515 .schema_definition()
516 .ok_or_else(|| anyhow!("Glue schema version {schema_version_id} has no definition"))?;
517 Ok(Some(
518 avro::parse_schema(definition, &[]).context("parsing Glue avro schema")?,
519 ))
520 }
521
522 let value_schema = if let Format::Avro = format.value {
523 schema_for(&client, records.iter().find_map(|r| r.value.as_ref())).await?
524 } else {
525 None
526 };
527 let key_schema = if let Format::Avro = format.key {
528 let schema = schema_for(&client, records.iter().find_map(|r| r.key.as_ref())).await?;
529 if schema.is_some() {
530 format.requires_key = true;
531 }
532 schema
533 } else {
534 None
535 };
536 Ok((key_schema, value_schema))
537}
538
539fn split_headers(input: &str, n_headers: usize) -> anyhow::Result<(Vec<String>, &str)> {
541 let whitespace = Regex::new("\\s+").expect("building known-valid regex");
542 let mut parts = whitespace.splitn(input, n_headers + 1);
543 let mut headers = Vec::with_capacity(n_headers);
544 for _ in 0..n_headers {
545 headers.push(
546 parts
547 .next()
548 .context("expected another header in the input")?
549 .to_string(),
550 )
551 }
552 let rest = parts
553 .next()
554 .context("expected some contents after any message headers")?;
555
556 ensure!(
557 parts.next().is_none(),
558 "more than n+1 elements from a call to splitn(_, n+1)"
559 );
560
561 Ok((headers, rest))
562}
563
564fn decode_avro(
567 schema: &mz_avro::Schema,
568 bytes: &[u8],
569 glue: bool,
570) -> Result<avro::Value, anyhow::Error> {
571 if glue {
572 avro::from_glue_bytes(schema, bytes)
573 } else {
574 avro::from_confluent_bytes(schema, bytes)
575 }
576}
577
578fn decode_messages(
579 actual_bytes: Vec<Record<Vec<u8>>>,
580 key_schema: &Option<mz_avro::Schema>,
581 value_schema: &Option<mz_avro::Schema>,
582 format: &RecordFormat,
583 glue: bool,
584) -> Result<Vec<Record<DecodedValue>>, anyhow::Error> {
585 let mut actual_messages = vec![];
586
587 for record in actual_bytes {
588 let Record { key, value, .. } = record;
589 let key = if format.requires_key {
590 match (key, format.key) {
591 (Some(bytes), Format::Avro) => Some(DecodedValue::Avro(DebugValue(decode_avro(
592 key_schema.as_ref().unwrap(),
593 &bytes,
594 glue,
595 )?))),
596 (Some(bytes), Format::Json) => Some(DecodedValue::Json(
597 serde_json::from_slice(&bytes).context("decoding json")?,
598 )),
599 (Some(bytes), Format::Bytes) => Some(DecodedValue::Bytes(bytes)),
600 (Some(bytes), Format::Text) => Some(DecodedValue::Text(String::from_utf8(bytes)?)),
601 (None, _) if format.requires_key => bail!("empty message key"),
602 (None, _) => None,
603 }
604 } else {
605 None
606 };
607
608 let value = match (value, format.value) {
609 (Some(bytes), Format::Avro) => Some(DecodedValue::Avro(DebugValue(decode_avro(
610 value_schema.as_ref().unwrap(),
611 &bytes,
612 glue,
613 )?))),
614 (Some(bytes), Format::Json) => Some(DecodedValue::Json(
615 serde_json::from_slice(&bytes).context("decoding json")?,
616 )),
617 (Some(bytes), Format::Bytes) => Some(DecodedValue::Bytes(bytes)),
618 (Some(bytes), Format::Text) => Some(DecodedValue::Text(String::from_utf8(bytes)?)),
619 (None, _) => None,
620 };
621
622 actual_messages.push(Record {
623 headers: record.headers.clone(),
624 key,
625 value,
626 partition: record.partition,
627 });
628 }
629
630 Ok(actual_messages)
631}
632
633fn parse_expected_messages(
634 expected_messages: Vec<String>,
635 key_schema: Option<mz_avro::Schema>,
636 value_schema: Option<mz_avro::Schema>,
637 format: &RecordFormat,
638 header_keys: &[String],
639) -> Result<Vec<Record<DecodedValue>>, anyhow::Error> {
640 let mut expected = vec![];
641
642 for msg in expected_messages {
643 let (headers, content) = split_headers(&msg, header_keys.len())?;
644 let mut content = content.as_bytes();
645 let mut deserializer = serde_json::Deserializer::from_reader(&mut content).into_iter();
646
647 let key = if format.requires_key {
648 let key: serde_json::Value = deserializer
649 .next()
650 .context("key missing in input line")?
651 .context("parsing json")?;
652
653 Some(match format.key {
654 Format::Avro => DecodedValue::Avro(DebugValue(avro::from_json(
655 &key,
656 key_schema.as_ref().unwrap().top_node(),
657 )?)),
658 Format::Json => DecodedValue::Json(key),
659 Format::Bytes => {
660 unimplemented!("bytes format not yet supported in tests")
661 }
662 Format::Text => DecodedValue::Text(
663 key.as_str()
664 .map(|s| s.to_string())
665 .unwrap_or_else(|| key.to_string()),
666 ),
667 })
668 } else {
669 None
670 };
671
672 let value = match deserializer.next().transpose().context("parsing json")? {
673 None => None,
674 Some(value) if value.as_str() == Some("<null>") => None,
675 Some(value) => match format.value {
676 Format::Avro => Some(DecodedValue::Avro(DebugValue(avro::from_json(
677 &value,
678 value_schema.as_ref().unwrap().top_node(),
679 )?))),
680 Format::Json => Some(DecodedValue::Json(value)),
681 Format::Bytes => {
682 unimplemented!("bytes format not yet supported in tests")
683 }
684 Format::Text => Some(DecodedValue::Text(
688 value
689 .as_str()
690 .map(|s| s.to_string())
691 .unwrap_or_else(|| value.to_string()),
692 )),
693 },
694 };
695
696 let content =
697 str::from_utf8(content).context("internal error: contents were previously a string")?;
698 let partition = match content.trim().split_once("=") {
699 None if content.trim() != "" => bail!("unexpected cruft at end of line: {content}"),
700 None => None,
701 Some((label, partition)) => {
702 if label != "partition" {
703 bail!("partition expectation has unexpected label: {label}")
704 }
705 Some(partition.parse().context("parsing expected partition")?)
706 }
707 };
708
709 expected.push(Record {
710 headers,
711 key,
712 value,
713 partition,
714 });
715 }
716
717 Ok(expected)
718}
719
720fn verify_with_partial_search<A>(
721 expected: &[Record<A>],
722 actual: &[Record<A>],
723 regex: &Option<Regex>,
724 regex_replacement: &String,
725 partial_search: bool,
726) -> Result<(), anyhow::Error>
727where
728 A: Debug + Clone,
729{
730 let mut expected = expected.iter();
731 let mut actual = actual.iter();
732 let mut index = 0..;
733
734 let mut found_beginning = !partial_search;
735 let mut expected_item = expected.next();
736 let mut actual_item = actual.next();
737 loop {
738 let i = index.next().expect("known to exist");
739 match (expected_item, actual_item) {
740 (Some(e), Some(a)) => {
741 let mut a = a.clone();
742 if e.partition.is_none() {
743 a.partition = None;
744 }
745 let e_str = format!("{:#?}", e);
746 let a_str = match ®ex {
747 Some(regex) => regex
748 .replace_all(&format!("{:#?}", a).to_string(), regex_replacement.as_str())
749 .to_string(),
750 _ => format!("{:#?}", a),
751 };
752
753 if e_str != a_str {
754 if found_beginning {
755 bail!(
756 "record {} did not match\nexpected:\n{}\n\nactual:\n{}",
757 i,
758 e_str,
759 a_str,
760 );
761 }
762 actual_item = actual.next();
763 } else {
764 found_beginning = true;
765 expected_item = expected.next();
766 actual_item = actual.next();
767 }
768 }
769 (Some(e), None) => bail!("missing record {}: {:#?}", i, e),
770 (None, Some(a)) => {
771 if !partial_search {
772 bail!("extra record {}: {:#?}", i, a);
773 }
774 break;
775 }
776 (None, None) => break,
777 }
778 }
779 let expected: Vec<_> = expected.map(|e| format!("{:#?}", e)).collect();
780 let actual: Vec<_> = actual.map(|a| format!("{:#?}", a)).collect();
781
782 if !expected.is_empty() {
783 bail!("missing records:\n{}", expected.join("\n"))
784 } else if !actual.is_empty() && !partial_search {
785 bail!("extra records:\n{}", actual.join("\n"))
786 } else {
787 Ok(())
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794
795 #[mz_ore::test]
796 fn verification_start_offset_uses_committed_offset() {
797 assert_eq!(
798 verification_start_offset(Offset::Offset(5), 2, 8).unwrap(),
799 5
800 );
801 }
802
803 #[mz_ore::test]
804 fn verification_start_offset_resets_out_of_range_offsets() {
805 assert_eq!(
806 verification_start_offset(Offset::Offset(1), 2, 8).unwrap(),
807 2
808 );
809 assert_eq!(
810 verification_start_offset(Offset::Offset(9), 2, 8).unwrap(),
811 2
812 );
813 assert_eq!(verification_start_offset(Offset::Invalid, 2, 8).unwrap(), 2);
814 }
815}