Skip to main content

mz_testdrive/action/kafka/
ingest.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::cmp;
11use std::io::{BufRead, Read};
12use std::time::Duration;
13
14use anyhow::{Context, anyhow, bail};
15use byteorder::{NetworkEndian, WriteBytesExt};
16use futures::stream::{FuturesUnordered, StreamExt};
17use maplit::btreemap;
18use prost::Message;
19use prost_reflect::{DescriptorPool, DynamicMessage, MessageDescriptor};
20use rdkafka::message::{Header, OwnedHeaders};
21use rdkafka::producer::FutureRecord;
22use serde::de::DeserializeOwned;
23use tokio::fs;
24use uuid::Uuid;
25
26use crate::action::{self, ControlFlow, State};
27use crate::format::avro::{self, Schema};
28use crate::format::bytes;
29use crate::parser::BuiltinCommand;
30
31const INGEST_BATCH_SIZE: isize = 10000;
32
33/// Extracts ALL type names defined in an Avro schema (including nested types).
34/// Returns a set of fully qualified type names.
35#[allow(clippy::disallowed_types)]
36fn extract_all_defined_types(
37    schema_json: &str,
38) -> anyhow::Result<std::collections::HashSet<String>> {
39    let value: serde_json::Value = serde_json::from_str(schema_json)
40        .context("parsing schema JSON to extract defined types")?;
41
42    let mut types = std::collections::HashSet::new();
43    collect_defined_types(&value, None, &mut types);
44    Ok(types)
45}
46
47/// Recursively collects all named type definitions from an Avro schema.
48#[allow(clippy::disallowed_types)]
49fn collect_defined_types(
50    value: &serde_json::Value,
51    parent_namespace: Option<&str>,
52    types: &mut std::collections::HashSet<String>,
53) {
54    match value {
55        serde_json::Value::Object(map) => {
56            // Get this schema's namespace (falls back to parent's namespace)
57            let namespace = map
58                .get("namespace")
59                .and_then(|v| v.as_str())
60                .or(parent_namespace);
61
62            // Check if this is a named type definition (record, enum, or fixed)
63            if let Some(type_val) = map.get("type")
64                && type_val
65                    .as_str()
66                    .is_some_and(|typ| ["record", "enum", "fixed"].contains(&typ))
67            {
68                if let Some(name) = map.get("name").and_then(|v| v.as_str()) {
69                    // Construct fully qualified name
70                    let fullname = if name.contains('.') {
71                        name.to_string()
72                    } else if let Some(ns) = namespace {
73                        format!("{}.{}", ns, name)
74                    } else {
75                        name.to_string()
76                    };
77                    types.insert(fullname);
78                }
79            }
80
81            // The following types may have references:
82            // type field, items (array types), values (map types), and fields (e.g. unions)
83            for entity_type in &["type", "items", "values", "fields"] {
84                if let Some(val) = map.get(*entity_type) {
85                    collect_defined_types(val, namespace, types);
86                }
87            }
88        }
89        serde_json::Value::Array(arr) => {
90            for item in arr {
91                collect_defined_types(item, parent_namespace, types);
92            }
93        }
94        _ => {}
95    }
96}
97
98/// Extracts all type references from an Avro schema JSON string.
99/// This finds all fully qualified type names that are referenced but not defined in the schema.
100#[allow(clippy::disallowed_types)]
101fn extract_type_references(schema_json: &str) -> anyhow::Result<std::collections::HashSet<String>> {
102    let value: serde_json::Value = serde_json::from_str(schema_json)
103        .context("parsing schema JSON to extract type references")?;
104
105    let mut references = std::collections::HashSet::new();
106    collect_type_references(&value, &mut references);
107    Ok(references)
108}
109
110/// Recursively collects type references from an Avro schema JSON value.
111#[allow(clippy::disallowed_types)]
112fn collect_type_references(
113    value: &serde_json::Value,
114    references: &mut std::collections::HashSet<String>,
115) {
116    match value {
117        serde_json::Value::String(s) => {
118            // A string type that contains a dot is likely a fully qualified type reference
119            if s.contains('.')
120                && ![
121                    "null", "boolean", "int", "long", "float", "double", "bytes", "string",
122                ]
123                .contains(&s.as_str())
124            {
125                references.insert(s.clone());
126            }
127        }
128        serde_json::Value::Object(map) => {
129            // For named types, we want to recurse into the fields, but the named type doesn't
130            // get added to references.
131            if let Some(type_val) = map.get("type")
132                && type_val
133                    .as_str()
134                    .is_some_and(|typ| ["record", "enum", "fixed"].contains(&typ))
135            {
136                if let Some(fields) = map.get("fields") {
137                    collect_type_references(fields, references);
138                }
139                return;
140            }
141
142            // The following types may have references:
143            // type field, items (array types), values (map types), and fields (e.g. unions)
144            for entity_type in &["type", "items", "values", "fields"] {
145                if let Some(val) = map.get(*entity_type) {
146                    collect_type_references(val, references);
147                }
148            }
149        }
150        serde_json::Value::Array(arr) => {
151            for item in arr {
152                collect_type_references(item, references);
153            }
154        }
155        _ => {}
156    }
157}
158
159#[derive(Clone)]
160enum Format {
161    Avro {
162        schema: String,
163        confluent_wire_format: bool,
164        /// If set, override the wire format with AWS Glue Schema Registry framing
165        /// using the given schema-version UUID. Mutually exclusive with
166        /// `confluent_wire_format=true`.
167        glue_schema_version_id: Option<Uuid>,
168        /// Schema references (subject names) for Confluent Schema Registry
169        references: Vec<String>,
170    },
171    Protobuf {
172        descriptor_file: String,
173        message: String,
174        confluent_wire_format: bool,
175        schema_id_subject: Option<String>,
176        schema_message_id: u8,
177    },
178    Bytes {
179        terminator: Option<u8>,
180    },
181}
182
183enum Transcoder {
184    PlainAvro {
185        schema: Schema,
186    },
187    ConfluentAvro {
188        schema: Schema,
189        schema_id: i32,
190    },
191    GlueAvro {
192        schema: Schema,
193        schema_version_id: Uuid,
194    },
195    Protobuf {
196        message: MessageDescriptor,
197        confluent_wire_format: bool,
198        schema_id: i32,
199        schema_message_id: u8,
200    },
201    Bytes {
202        terminator: Option<u8>,
203    },
204}
205
206impl Transcoder {
207    fn decode_json<R, T>(row: R) -> Result<Option<T>, anyhow::Error>
208    where
209        R: Read,
210        T: DeserializeOwned,
211    {
212        let deserializer = serde_json::Deserializer::from_reader(row);
213        deserializer
214            .into_iter()
215            .next()
216            .transpose()
217            .context("parsing json")
218    }
219
220    fn transcode<R>(&self, mut row: R) -> Result<Option<Vec<u8>>, anyhow::Error>
221    where
222        R: BufRead,
223    {
224        match self {
225            Transcoder::ConfluentAvro { schema, schema_id } => {
226                if let Some(val) = Self::decode_json(row)? {
227                    let val = avro::from_json(&val, schema.top_node())?;
228                    let mut out = vec![];
229                    // The first byte is a magic byte (0) that indicates the Confluent
230                    // serialization format version, and the next four bytes are a
231                    // 32-bit schema ID.
232                    //
233                    // https://docs.confluent.io/3.3.0/schema-registry/docs/serializer-formatter.html#wire-format
234                    out.write_u8(0).unwrap();
235                    out.write_i32::<NetworkEndian>(*schema_id).unwrap();
236                    out.extend(avro::to_avro_datum(schema, val)?);
237                    Ok(Some(out))
238                } else {
239                    Ok(None)
240                }
241            }
242            Transcoder::GlueAvro {
243                schema,
244                schema_version_id,
245            } => {
246                if let Some(val) = Self::decode_json(row)? {
247                    let val = avro::from_json(&val, schema.top_node())?;
248                    let mut out = vec![];
249                    // AWS Glue Schema Registry wire format:
250                    // byte 0   = 0x03 (header version)
251                    // byte 1   = compression byte (0x00 = none)
252                    // bytes 2..18 = 16-byte schema-version UUID
253                    out.write_u8(0x03).unwrap();
254                    out.write_u8(0x00).unwrap();
255                    out.extend_from_slice(schema_version_id.as_bytes());
256                    out.extend(avro::to_avro_datum(schema, val)?);
257                    Ok(Some(out))
258                } else {
259                    Ok(None)
260                }
261            }
262            Transcoder::PlainAvro { schema } => {
263                if let Some(val) = Self::decode_json(row)? {
264                    let val = avro::from_json(&val, schema.top_node())?;
265                    let mut out = vec![];
266                    out.extend(avro::to_avro_datum(schema, val)?);
267                    Ok(Some(out))
268                } else {
269                    Ok(None)
270                }
271            }
272            Transcoder::Protobuf {
273                message,
274                confluent_wire_format,
275                schema_id,
276                schema_message_id,
277            } => {
278                if let Some(val) = Self::decode_json::<_, serde_json::Value>(row)? {
279                    let message = DynamicMessage::deserialize(message.clone(), val)
280                        .context("parsing protobuf JSON")?;
281                    let mut out = vec![];
282                    if *confluent_wire_format {
283                        // See: https://github.com/MaterializeInc/database-issues/issues/2837
284                        // The first byte is a magic byte (0) that indicates the Confluent
285                        // serialization format version, and the next four bytes are a
286                        // 32-bit schema ID, which we default to something fun.
287                        // And, as we only support single-message proto files for now,
288                        // we also set the following message id to 0.
289                        out.write_u8(0).unwrap();
290                        out.write_i32::<NetworkEndian>(*schema_id).unwrap();
291                        out.write_u8(*schema_message_id).unwrap();
292                    }
293                    message.encode(&mut out)?;
294                    Ok(Some(out))
295                } else {
296                    Ok(None)
297                }
298            }
299            Transcoder::Bytes { terminator } => {
300                let mut out = vec![];
301                match terminator {
302                    Some(t) => {
303                        row.read_until(*t, &mut out)?;
304                        if out.last() == Some(t) {
305                            out.pop();
306                        }
307                    }
308                    None => {
309                        row.read_to_end(&mut out)?;
310                    }
311                }
312                if out.is_empty() {
313                    Ok(None)
314                } else {
315                    Ok(Some(bytes::unescape(&out)?))
316                }
317            }
318        }
319    }
320}
321
322pub async fn run_ingest(
323    mut cmd: BuiltinCommand,
324    state: &mut State,
325) -> Result<ControlFlow, anyhow::Error> {
326    let topic_prefix = format!("testdrive-{}", cmd.args.string("topic")?);
327    let partition = cmd.args.opt_parse::<i32>("partition")?;
328    let start_iteration = cmd.args.opt_parse::<isize>("start-iteration")?.unwrap_or(0);
329    let repeat = cmd.args.opt_parse::<isize>("repeat")?.unwrap_or(1);
330    let omit_key = cmd.args.opt_bool("omit-key")?.unwrap_or(false);
331    let omit_value = cmd.args.opt_bool("omit-value")?.unwrap_or(false);
332    let schema_id_var = cmd.args.opt_parse("set-schema-id-var")?;
333    let key_schema_id_var = cmd.args.opt_parse("set-key-schema-id-var")?;
334    // `confluent-wire-format` applies to both the value and the key format.
335    // `ArgMap` reads are destructive, so read it once up front: reading it in
336    // both format matches would leave the key side always seeing `None`.
337    let confluent_wire_format_arg = cmd.args.opt_bool("confluent-wire-format")?;
338    let format = match cmd.args.string("format")?.as_str() {
339        "avro" => {
340            let glue_schema_version_id = cmd
341                .args
342                .opt_string("glue-schema-version-id")
343                .map(|s| Uuid::parse_str(&s).context("parsing glue-schema-version-id as UUID"))
344                .transpose()?;
345            let confluent_wire_format_explicit = confluent_wire_format_arg;
346            if glue_schema_version_id.is_some() && confluent_wire_format_explicit == Some(true) {
347                bail!("confluent-wire-format=true is incompatible with glue-schema-version-id");
348            }
349            // Default: confluent unless Glue framing is requested.
350            let confluent_wire_format =
351                confluent_wire_format_explicit.unwrap_or_else(|| glue_schema_version_id.is_none());
352            Format::Avro {
353                schema: cmd.args.string("schema")?,
354                confluent_wire_format,
355                glue_schema_version_id,
356                references: cmd
357                    .args
358                    .opt_string("references")
359                    .map(|s| s.split(',').map(|s| s.to_string()).collect())
360                    .unwrap_or_default(),
361            }
362        }
363        "protobuf" => {
364            let descriptor_file = cmd.args.string("descriptor-file")?;
365            let message = cmd.args.string("message")?;
366            Format::Protobuf {
367                descriptor_file,
368                message,
369                // This was introduced after the avro format's confluent-wire-format, so it defaults to
370                // false
371                confluent_wire_format: confluent_wire_format_arg.unwrap_or(false),
372                schema_id_subject: cmd.args.opt_string("schema-id-subject"),
373                schema_message_id: cmd.args.opt_parse::<u8>("schema-message-id")?.unwrap_or(0),
374            }
375        }
376        "bytes" => Format::Bytes { terminator: None },
377        f => bail!("unknown format: {}", f),
378    };
379    let mut key_schema = cmd.args.opt_string("key-schema");
380    let key_format = match cmd.args.opt_string("key-format").as_deref() {
381        Some("avro") => {
382            let key_glue_schema_version_id = cmd
383                .args
384                .opt_string("key-glue-schema-version-id")
385                .map(|s| Uuid::parse_str(&s).context("parsing key-glue-schema-version-id as UUID"))
386                .transpose()?;
387            let confluent_wire_format_explicit = confluent_wire_format_arg;
388            if key_glue_schema_version_id.is_some() && confluent_wire_format_explicit == Some(true)
389            {
390                bail!("confluent-wire-format=true is incompatible with key-glue-schema-version-id");
391            }
392            // Default: confluent unless Glue framing is requested.
393            let confluent_wire_format = confluent_wire_format_explicit
394                .unwrap_or_else(|| key_glue_schema_version_id.is_none());
395            Some(Format::Avro {
396                schema: key_schema.take().ok_or_else(|| {
397                    anyhow!("key-schema parameter required when key-format is present")
398                })?,
399                confluent_wire_format,
400                glue_schema_version_id: key_glue_schema_version_id,
401                references: cmd
402                    .args
403                    .opt_string("key-references")
404                    .map(|s| s.split(',').map(|s| s.to_string()).collect())
405                    .unwrap_or_default(),
406            })
407        }
408        Some("protobuf") => {
409            let descriptor_file = cmd.args.string("key-descriptor-file")?;
410            let message = cmd.args.string("key-message")?;
411            Some(Format::Protobuf {
412                descriptor_file,
413                message,
414                confluent_wire_format: confluent_wire_format_arg.unwrap_or(false),
415                schema_id_subject: cmd.args.opt_string("key-schema-id-subject"),
416                schema_message_id: cmd
417                    .args
418                    .opt_parse::<u8>("key-schema-message-id")?
419                    .unwrap_or(0),
420            })
421        }
422        Some("bytes") => Some(Format::Bytes {
423            terminator: match cmd.args.opt_parse::<char>("key-terminator")? {
424                Some(c) => match u8::try_from(c) {
425                    Ok(c) => Some(c),
426                    Err(_) => bail!("key terminator must be single ASCII character"),
427                },
428                None => Some(b':'),
429            },
430        }),
431        Some(f) => bail!("unknown key format: {}", f),
432        None => None,
433    };
434    if key_schema.is_some() {
435        anyhow::bail!("key-schema specified without a matching key-format");
436    }
437
438    let timestamp = cmd.args.opt_parse("timestamp")?;
439
440    use serde_json::Value;
441    let headers = if let Some(headers_val) = cmd.args.opt_parse::<serde_json::Value>("headers")? {
442        let mut headers = Vec::new();
443        let headers_maps = match headers_val {
444            Value::Array(values) => {
445                let mut headers_map = Vec::new();
446                for value in values {
447                    if let Value::Object(m) = value {
448                        headers_map.push(m)
449                    } else {
450                        bail!("`headers` array values must be maps")
451                    }
452                }
453                headers_map
454            }
455            Value::Object(v) => vec![v],
456            _ => bail!("`headers` must be a map or an array"),
457        };
458
459        for headers_map in headers_maps {
460            for (k, v) in headers_map.iter() {
461                headers.push((k.clone(), match v {
462                    Value::String(val) => Some(val.as_bytes().to_vec()),
463                    Value::Array(val) => {
464                        let mut values = Vec::new();
465                        for value in val {
466                            if let Value::Number(int) = value {
467                                values.push(u8::try_from(int.as_i64().unwrap()).unwrap())
468                            } else {
469                                bail!("`headers` value arrays must only contain numbers (to represent bytes)")
470                            }
471                        }
472                        Some(values.clone())
473                    },
474                    Value::Null => None,
475                    _ => bail!("`headers` must have string, int array or null values")
476                }));
477            }
478        }
479        Some(headers)
480    } else {
481        None
482    };
483
484    cmd.args.done()?;
485
486    if let Some(kf) = &key_format {
487        fn is_confluent_format(fmt: &Format) -> Option<bool> {
488            match fmt {
489                Format::Avro {
490                    confluent_wire_format,
491                    glue_schema_version_id,
492                    ..
493                } => {
494                    // Glue framing is its own wire format — don't compare against CSR.
495                    if glue_schema_version_id.is_some() {
496                        None
497                    } else {
498                        Some(*confluent_wire_format)
499                    }
500                }
501                Format::Protobuf {
502                    confluent_wire_format,
503                    ..
504                } => Some(*confluent_wire_format),
505                Format::Bytes { .. } => None,
506            }
507        }
508        match (is_confluent_format(kf), is_confluent_format(&format)) {
509            (Some(false), Some(true)) | (Some(true), Some(false)) => {
510                bail!(
511                    "It does not make sense to have the key be in confluent format and not the value, or vice versa."
512                );
513            }
514            _ => {}
515        }
516    }
517
518    let topic_name = &format!("{}-{}", topic_prefix, state.seed);
519    println!(
520        "Ingesting data into Kafka topic {} with start_iteration = {}, repeat = {}",
521        topic_name, start_iteration, repeat
522    );
523
524    let set_schema_id_var = |state: &mut State, schema_id_var, transcoder| match transcoder {
525        &Transcoder::ConfluentAvro { schema_id, .. } | &Transcoder::Protobuf { schema_id, .. } => {
526            state.cmd_vars.insert(schema_id_var, schema_id.to_string());
527        }
528        _ => (),
529    };
530
531    let value_transcoder =
532        make_transcoder(state, format.clone(), format!("{}-value", topic_name)).await?;
533    if let Some(var) = schema_id_var {
534        set_schema_id_var(state, var, &value_transcoder);
535    }
536
537    let key_transcoder = match key_format.clone() {
538        None => None,
539        Some(f) => {
540            let transcoder = make_transcoder(state, f, format!("{}-key", topic_name)).await?;
541            if let Some(var) = key_schema_id_var {
542                set_schema_id_var(state, var, &transcoder);
543            }
544            Some(transcoder)
545        }
546    };
547
548    let mut futs = FuturesUnordered::new();
549
550    for iteration in start_iteration..(start_iteration + repeat) {
551        let iter = &mut cmd.input.iter().peekable();
552
553        for row in iter {
554            let row = action::substitute_vars(
555                row,
556                &btreemap! { "kafka-ingest.iteration".into() => iteration.to_string() },
557                &None,
558                false,
559            )?;
560            let mut row = row.as_bytes();
561            let key = match (omit_key, &key_transcoder) {
562                (true, _) => None,
563                (false, None) => None,
564                (false, Some(kt)) => kt.transcode(&mut row)?,
565            };
566            let value = if omit_value {
567                None
568            } else {
569                value_transcoder
570                    .transcode(&mut row)
571                    .with_context(|| format!("parsing row: {}", String::from_utf8_lossy(row)))?
572            };
573            let producer = &state.kafka_producer;
574            let timeout = cmp::max(state.default_timeout, Duration::from_secs(1));
575            let headers = headers.clone();
576            futs.push(async move {
577                let mut record: FutureRecord<_, _> = FutureRecord::to(topic_name);
578
579                if let Some(partition) = partition {
580                    record = record.partition(partition);
581                }
582                if let Some(key) = &key {
583                    record = record.key(key);
584                }
585                if let Some(value) = &value {
586                    record = record.payload(value);
587                }
588                if let Some(timestamp) = timestamp {
589                    record = record.timestamp(timestamp);
590                }
591                if let Some(headers) = headers {
592                    let mut rd_meta = OwnedHeaders::new();
593                    for (k, v) in &headers {
594                        rd_meta = rd_meta.insert(Header {
595                            key: k,
596                            value: v.as_deref(),
597                        });
598                    }
599                    record = record.headers(rd_meta);
600                }
601                producer.send(record, timeout).await
602            });
603        }
604
605        // Reap the futures thus produced periodically or after the last iteration
606        if iteration % INGEST_BATCH_SIZE == 0 || iteration == (start_iteration + repeat - 1) {
607            while let Some(res) = futs.next().await {
608                res.map_err(|(e, _message)| e)?;
609            }
610        }
611    }
612    Ok(ControlFlow::Continue)
613}
614
615async fn make_transcoder(
616    state: &State,
617    format: Format,
618    ccsr_subject: String,
619) -> Result<Transcoder, anyhow::Error> {
620    match format {
621        Format::Avro {
622            schema,
623            confluent_wire_format,
624            glue_schema_version_id,
625            references,
626        } => {
627            if let Some(schema_version_id) = glue_schema_version_id {
628                if !references.is_empty() {
629                    bail!("schema references are not supported with glue-schema-version-id");
630                }
631                let schema = avro::parse_schema(&schema, &[])
632                    .with_context(|| format!("parsing avro schema: {}", schema))?;
633                return Ok(Transcoder::GlueAvro {
634                    schema,
635                    schema_version_id,
636                });
637            }
638            if confluent_wire_format {
639                // Build references list by fetching each subject from the registry.
640                // Start with immediate references and automatically resolve transitive ones.
641                // We need ALL references for local parsing, but only DIRECT references for the registry.
642                #[allow(clippy::disallowed_types)]
643                let mut reference_subjects = vec![];
644                #[allow(clippy::disallowed_types)]
645                let mut seen_subjects: std::collections::HashSet<String> =
646                    std::collections::HashSet::new();
647                let mut queue: Vec<String> = references.clone();
648
649                // Process queue (as a stack), adding transitive dependencies as we discover them
650                while let Some(ref_name) = queue.pop() {
651                    if seen_subjects.contains(&ref_name) {
652                        continue;
653                    }
654                    seen_subjects.insert(ref_name.clone());
655
656                    let (subject, ref_deps) = state
657                        .ccsr_client
658                        .get_subject_with_references(&ref_name)
659                        .await
660                        .with_context(|| format!("fetching reference {}", ref_name))?;
661
662                    // Add newly discovered dependencies to the queue
663                    for dep in ref_deps {
664                        if !seen_subjects.contains(&dep.subject) {
665                            queue.push(dep.subject);
666                        }
667                    }
668
669                    // Extract ALL type names defined in this schema (including nested types)
670                    let defined_types = extract_all_defined_types(&subject.schema.raw)
671                        .with_context(|| {
672                            format!("extracting type names from reference schema {}", ref_name)
673                        })?;
674                    reference_subjects.push((
675                        ref_name,
676                        subject.version,
677                        subject.schema.raw,
678                        defined_types,
679                    ));
680                }
681
682                // Reverse to get dependency order: since we use a stack, dependencies are
683                // discovered and added after the schemas that depend on them, so reversing
684                // puts dependencies first (required for incremental schema parsing)
685                reference_subjects.reverse();
686
687                // Extract types directly referenced by the primary schema
688                let direct_refs = extract_type_references(&schema)
689                    .context("extracting type references from schema")?;
690
691                // For the registry, create a reference for each type in direct_refs
692                // that is defined in one of the reference subjects
693                let mut schema_references = vec![];
694                for type_name in &direct_refs {
695                    for (subject_name, version, _, defined_types) in &reference_subjects {
696                        if defined_types.contains(type_name) {
697                            schema_references.push(mz_ccsr::SchemaReference {
698                                name: type_name.clone(),
699                                subject: subject_name.clone(),
700                                version: *version,
701                            });
702                            break;
703                        }
704                    }
705                }
706
707                // For local parsing, we need all reference schemas
708                let reference_raw_schemas: Vec<_> = reference_subjects
709                    .into_iter()
710                    .map(|(_, _, raw, _)| raw)
711                    .collect();
712
713                let schema_id = state
714                    .ccsr_client
715                    .publish_schema(
716                        &ccsr_subject,
717                        &schema,
718                        mz_ccsr::SchemaType::Avro,
719                        &schema_references,
720                    )
721                    .await
722                    .context("publishing to schema registry")?;
723
724                // Parse schema, handling references if any
725                let schema = if reference_raw_schemas.is_empty() {
726                    avro::parse_schema(&schema, &[])
727                        .with_context(|| format!("parsing avro schema: {}", schema))?
728                } else {
729                    // Parse reference schemas incrementally (each may depend on previous ones).
730                    // References must be specified in dependency order (dependencies first).
731                    let mut parsed_refs: Vec<Schema> = vec![];
732                    for raw in &reference_raw_schemas {
733                        let schema_value: serde_json::Value = serde_json::from_str(raw)
734                            .with_context(|| format!("parsing reference schema JSON: {}", raw))?;
735                        let parsed = Schema::parse_with_references(&schema_value, &parsed_refs)
736                            .with_context(|| format!("parsing reference avro schema: {}", raw))?;
737                        parsed_refs.push(parsed);
738                    }
739
740                    // Parse primary schema with all reference types available
741                    let schema_value: serde_json::Value = serde_json::from_str(&schema)
742                        .with_context(|| format!("parsing schema JSON: {}", schema))?;
743                    Schema::parse_with_references(&schema_value, &parsed_refs).with_context(
744                        || format!("parsing avro schema with references: {}", schema),
745                    )?
746                };
747
748                Ok::<_, anyhow::Error>(Transcoder::ConfluentAvro { schema, schema_id })
749            } else {
750                let schema = avro::parse_schema(&schema, &[])
751                    .with_context(|| format!("parsing avro schema: {}", schema))?;
752                Ok(Transcoder::PlainAvro { schema })
753            }
754        }
755        Format::Protobuf {
756            descriptor_file,
757            message,
758            confluent_wire_format,
759            schema_id_subject,
760            schema_message_id,
761        } => {
762            let schema_id = if confluent_wire_format {
763                state
764                    .ccsr_client
765                    .get_schema_by_subject(schema_id_subject.as_deref().unwrap_or(&ccsr_subject))
766                    .await
767                    .context("fetching schema from registry")?
768                    .id
769            } else {
770                0
771            };
772
773            let bytes = fs::read(state.temp_path.join(descriptor_file))
774                .await
775                .context("reading protobuf descriptor file")?;
776            let fd = DescriptorPool::decode(&*bytes).context("parsing protobuf descriptor file")?;
777            let message = fd
778                .get_message_by_name(&message)
779                .ok_or_else(|| anyhow!("unknown message name {}", message))?;
780            Ok(Transcoder::Protobuf {
781                message,
782                confluent_wire_format,
783                schema_id,
784                schema_message_id,
785            })
786        }
787        Format::Bytes { terminator } => Ok(Transcoder::Bytes { terminator }),
788    }
789}