Skip to main content

mz_testdrive/action/kafka/
verify_data.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use 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        // If just a single format is provided, the user should specify `key=true` if they expect a
112        // in each message.  However for format=avro, we will conveniently set this based
113        // on the presence of the key schema in the registry, so this argument is not required.
114        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        // verify with 0 messages doesn't check that no messages have been written -
149        // it 'verifies' 0 messages and trivially returns true
150        bail!("kafka-verify-data requires a non-empty list of expected messages");
151    }
152    let partial_search = cmd.args.opt_parse("partial-search")?;
153    // When false, the topic is exempt from the end-of-file exhaustion check.
154    // Set it on topics that legitimately keep records this command does not
155    // verify: topics fed by more than one sink, topics that double as a sink's
156    // progress topic, or topics whose later records are checked by other means.
157    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    // When set, decode Avro from AWS Glue framing (18-byte header) and resolve the
160    // writer schema from Glue rather than Confluent Schema Registry.
161    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    // NOTE: Consumption stops after exactly as many messages as expected. The
182    // consumer group's committed offsets make consecutive kafka-verify-data
183    // commands resume where the previous one stopped, so tests can verify a
184    // topic in chunks. The end-of-file check below verifies that no records
185    // remain after the final non-partial verification.
186    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    // Collect all messages that arrive without timing out. If we trip
197    // the timeout, suppress the error and return what we have. This
198    // is nicer than returning "timeout expired", as the user will
199    // instead get an error message about the expected messages that
200    // were missing.
201    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                    // We create topics after creating sinks, so we permit
211                    // retries here while waiting for the topic to get created.
212                    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                    // Expect a unique header with the given key and a UTF8-formatted body.
231                    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        // A Glue sink frames each record with an 18-byte header carrying the
265        // schema-version UUID. Resolve the writer schema for each side from Glue
266        // using the UUID on the first record of that side. All records in a
267        // verify batch share the sink's schema, so one lookup per side suffices.
268        resolve_glue_schemas(state, &actual_bytes, &mut format).await?
269    } else {
270        let key_schema = if let Format::Avro = format.key {
271            // A missing key subject means the topic is unkeyed. Any other error
272            // must propagate: swallowing it would silently disable key
273            // verification.
274            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            // for avro, we can determine if a key is required based on the presence of the key schema
289            // rather than requiring the user to specify the key=true flag
290            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    // The Glue path resolves schemas from the received records, so an empty batch
312    // leaves the schema unset. Parsing the (required, non-empty) expected messages
313    // would then unwrap a `None` schema and panic. Turn that into the normal
314    // "sink produced no output" diagnostic instead.
315    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
377/// Verifies that no records remain after each topic's final exact verification.
378pub 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    // Resume from each partition's committed offset, where the last
418    // kafka-verify-data on this topic stopped, and confirm that only the
419    // end-of-partition marker remains. `pending` tracks the partitions we have
420    // not yet drained.
421    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    // `enable.partition.eof` makes librdkafka emit a `PartitionEOF` once a
443    // partition is drained to its end. Wait for that marker on every partition
444    // rather than comparing consumer positions to high watermarks: a position
445    // never advances on a partition that begins already drained (so empty
446    // partitions would hang forever), and with `read_committed` the high
447    // watermark stays ahead of the last readable record whenever transaction
448    // control markers trail the data.
449    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
488/// Resolve the writer schema for each record side from AWS Glue.
489///
490/// Reads the schema-version UUID from the Glue header of the first record on each
491/// side and fetches that version's definition from Glue. Returns
492/// `(key_schema, value_schema)`, mirroring the Confluent resolution path, and
493/// sets `format.requires_key` when a key schema is present. All records in a
494/// verify batch share the sink's schema, so one lookup per side is enough.
495async 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
541/// Expect and split out `n` whitespace-delimited headers before the main contents of the 'expect' row.
542fn 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
566/// Decode Avro `bytes` with `schema`, stripping Glue framing when `glue` is set
567/// and Confluent framing otherwise.
568fn 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                // Take JSON strings verbatim, like the key path above. The
687                // actual message is decoded as raw text, so keeping the JSON
688                // quotes would make string values never match.
689                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 &regex {
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}