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 = config.create().context("creating kafka consumer")?;
176    consumer
177        .subscribe(&[&topic])
178        .context("subscribing to kafka topic")?;
179
180    // NOTE: Consumption stops after exactly as many messages as expected. The
181    // consumer group's committed offsets make consecutive kafka-verify-data
182    // commands resume where the previous one stopped, so tests can verify a
183    // topic in chunks. The end-of-file check below verifies that no records
184    // remain after the final non-partial verification.
185    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    // Collect all messages that arrive without timing out. If we trip
196    // the timeout, suppress the error and return what we have. This
197    // is nicer than returning "timeout expired", as the user will
198    // instead get an error message about the expected messages that
199    // were missing.
200    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                    // We create topics after creating sinks, so we permit
210                    // retries here while waiting for the topic to get created.
211                    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                    // Expect a unique header with the given key and a UTF8-formatted body.
230                    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        // A Glue sink frames each record with an 18-byte header carrying the
264        // schema-version UUID. Resolve the writer schema for each side from Glue
265        // using the UUID on the first record of that side. All records in a
266        // verify batch share the sink's schema, so one lookup per side suffices.
267        resolve_glue_schemas(state, &actual_bytes, &mut format).await?
268    } else {
269        let key_schema = if let Format::Avro = format.key {
270            // A missing key subject means the topic is unkeyed. Any other error
271            // must propagate: swallowing it would silently disable key
272            // verification.
273            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            // for avro, we can determine if a key is required based on the presence of the key schema
288            // rather than requiring the user to specify the key=true flag
289            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    // The Glue path resolves schemas from the received records, so an empty batch
311    // leaves the schema unset. Parsing the (required, non-empty) expected messages
312    // would then unwrap a `None` schema and panic. Turn that into the normal
313    // "sink produced no output" diagnostic instead.
314    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
376/// Verifies that no records remain after each topic's final exact verification.
377pub 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    // Resume from each partition's committed offset, where the last
416    // kafka-verify-data on this topic stopped, and confirm that only the
417    // end-of-partition marker remains. `pending` tracks the partitions we have
418    // not yet drained.
419    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    // `enable.partition.eof` makes librdkafka emit a `PartitionEOF` once a
441    // partition is drained to its end. Wait for that marker on every partition
442    // rather than comparing consumer positions to high watermarks: a position
443    // never advances on a partition that begins already drained (so empty
444    // partitions would hang forever), and with `read_committed` the high
445    // watermark stays ahead of the last readable record whenever transaction
446    // control markers trail the data.
447    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
486/// Resolve the writer schema for each record side from AWS Glue.
487///
488/// Reads the schema-version UUID from the Glue header of the first record on each
489/// side and fetches that version's definition from Glue. Returns
490/// `(key_schema, value_schema)`, mirroring the Confluent resolution path, and
491/// sets `format.requires_key` when a key schema is present. All records in a
492/// verify batch share the sink's schema, so one lookup per side is enough.
493async 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
539/// Expect and split out `n` whitespace-delimited headers before the main contents of the 'expect' row.
540fn 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
564/// Decode Avro `bytes` with `schema`, stripping Glue framing when `glue` is set
565/// and Confluent framing otherwise.
566fn 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                // Take JSON strings verbatim, like the key path above. The
685                // actual message is decoded as raw text, so keeping the JSON
686                // quotes would make string values never match.
687                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 &regex {
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}