Skip to main content

mz_testdrive/action/
schema_registry.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::sync::atomic::{AtomicBool, Ordering};
11use std::time::Duration;
12
13use anyhow::{Context, bail};
14use mz_ccsr::{SchemaReference, SchemaType};
15use mz_ore::retry::Retry;
16use mz_ore::str::StrExt;
17use serde_json::Value as JsonValue;
18
19use crate::action::{ControlFlow, State};
20use crate::format::avro;
21use crate::parser::BuiltinCommand;
22
23/// Extracts the fully qualified name from an Avro schema JSON string.
24/// For record types, this combines namespace and name (e.g., "com.example.User").
25fn extract_avro_fullname(schema_json: &str) -> anyhow::Result<String> {
26    let value: JsonValue =
27        serde_json::from_str(schema_json).context("parsing schema JSON to extract fullname")?;
28
29    let name = value
30        .get("name")
31        .and_then(|v| v.as_str())
32        .ok_or_else(|| anyhow::anyhow!("schema missing 'name' field"))?;
33
34    let namespace = value.get("namespace").and_then(|v| v.as_str());
35
36    // If name contains dots, it's already fully qualified
37    if name.contains('.') {
38        Ok(name.to_string())
39    } else if let Some(ns) = namespace {
40        Ok(format!("{}.{}", ns, name))
41    } else {
42        Ok(name.to_string())
43    }
44}
45
46pub async fn run_publish(
47    mut cmd: BuiltinCommand,
48    state: &State,
49) -> Result<ControlFlow, anyhow::Error> {
50    // Parse arguments.
51    let subject = cmd.args.string("subject")?;
52    let schema_type = match cmd.args.string("schema-type")?.as_str() {
53        "avro" => SchemaType::Avro,
54        "json" => SchemaType::Json,
55        "protobuf" => SchemaType::Protobuf,
56        s => bail!("unknown schema type: {}", s),
57    };
58    let references_in = match cmd.args.opt_string("references") {
59        None => vec![],
60        Some(s) => s.split(',').map(|s| s.to_string()).collect(),
61    };
62    cmd.args.done()?;
63    let schema = cmd.input.join("\n");
64
65    // Run action.
66    println!(
67        "Publishing schema for subject {} to the schema registry...",
68        subject.quoted(),
69    );
70    let mut references = vec![];
71    for reference in references_in {
72        let subject = state
73            .ccsr_client
74            .get_subject_latest(&reference)
75            .await
76            .with_context(|| format!("fetching reference {}", reference))?;
77        let type_name = match schema_type {
78            // Extract the fully qualified Avro type name from the schema.
79            // The Schema Registry reference `name` field should be the type name
80            // (e.g., "com.example.Address"), not the subject name.
81            SchemaType::Avro => extract_avro_fullname(&subject.schema.raw).with_context(|| {
82                format!("extracting type name from reference schema {}", reference)
83            })?,
84            SchemaType::Protobuf | SchemaType::Json => subject.name,
85        };
86
87        references.push(SchemaReference {
88            name: type_name,
89            subject: reference.to_string(),
90            version: subject.version,
91        })
92    }
93    state
94        .ccsr_client
95        .publish_schema(&subject, &schema, schema_type, &references)
96        .await
97        .context("publishing schema")?;
98    Ok(ControlFlow::Continue)
99}
100
101pub async fn run_verify(
102    mut cmd: BuiltinCommand,
103    state: &State,
104) -> Result<ControlFlow, anyhow::Error> {
105    // Parse arguments.
106    let subject = cmd.args.string("subject")?;
107    match cmd.args.string("schema-type")?.as_str() {
108        "avro" => (),
109        f => bail!("unknown format: {}", f),
110    };
111    let compatibility_level = cmd.args.opt_string("compatibility-level");
112    cmd.args.done()?;
113    let expected_schema = match &cmd.input[..] {
114        [expected_schema] => {
115            avro::parse_schema(expected_schema, &[]).context("parsing expected avro schema")?
116        }
117        _ => bail!("unable to read expected schema input"),
118    };
119
120    // Run action.
121    println!(
122        "Verifying contents of latest schema for subject {} in the schema registry...",
123        subject.quoted(),
124    );
125
126    // Finding the published schema is retryable because it's published
127    // asynchronously and only after the source/sink is created. Use
128    // `state.timeout` so `$ set-sql-timeout` extends this wait too.
129    let actual_schema = mz_ore::retry::Retry::default()
130        .max_duration(state.timeout)
131        .retry_async(|_| async {
132            match state.ccsr_client.get_schema_by_subject(&subject).await {
133                Ok(s) => mz_ore::retry::RetryResult::Ok(s.raw),
134                Err(
135                    e @ mz_ccsr::GetBySubjectError::SubjectNotFound
136                    | e @ mz_ccsr::GetBySubjectError::VersionNotFound(_),
137                ) => mz_ore::retry::RetryResult::RetryableErr(e),
138                Err(e) => mz_ore::retry::RetryResult::FatalErr(e),
139            }
140        })
141        .await
142        .context("fetching schema")?;
143
144    let actual_schema =
145        avro::parse_schema(&actual_schema, &[]).context("parsing actual avro schema")?;
146
147    if expected_schema != actual_schema {
148        bail!(
149            "schema did not match\nexpected:\n{:?}\n\nactual:\n{:?}",
150            expected_schema,
151            actual_schema,
152        );
153    }
154
155    if let Some(compatibility_level) = compatibility_level {
156        println!(
157            "Verifying compatibility level of subject {} in the schema registry...",
158            subject.quoted(),
159        );
160        let res = state.ccsr_client.get_subject_config(&subject).await?;
161        if compatibility_level != res.compatibility_level.to_string() {
162            bail!(
163                "compatibility level did not match\nexpected: {}\nactual: {}",
164                compatibility_level,
165                res.compatibility_level,
166            );
167        }
168    }
169    Ok(ControlFlow::Continue)
170}
171
172pub async fn run_wait(
173    mut cmd: BuiltinCommand,
174    state: &State,
175) -> Result<ControlFlow, anyhow::Error> {
176    // Parse arguments.
177    let topic = cmd.args.string("topic")?;
178    let subjects = [format!("{}-value", topic), format!("{}-key", topic)];
179
180    cmd.args.done()?;
181    cmd.assert_no_input()?;
182
183    // Run action.
184
185    // Tracks whether the schemas have been confirmed, so later retry attempts
186    // skip straight to the topic check. An `AtomicBool` because a plain `bool`
187    // would be copied into each `async move` future and updates would be lost.
188    let waiting_for_kafka = AtomicBool::new(false);
189    let waiting_for_kafka = &waiting_for_kafka;
190
191    println!(
192        "Waiting for schema for subjects {:?} to become available in the schema registry...",
193        subjects
194    );
195
196    let topic = &topic;
197    let subjects = &subjects;
198    Retry::default()
199        .initial_backoff(Duration::from_millis(50))
200        .factor(1.5)
201        .max_duration(state.timeout)
202        .retry_async_canceling(|_| async move {
203            if !waiting_for_kafka.load(Ordering::Relaxed) {
204                futures::future::try_join_all(subjects.iter().map(|subject| async move {
205                    state
206                        .ccsr_client
207                        // This doesn't take `ccsr_client` by `&mut self`, so it should be safe to cancel
208                        // by try-joining.
209                        .get_schema_by_subject(subject)
210                        .await
211                        .context("fetching schema")
212                        .and(Ok(()))
213                }))
214                .await?;
215
216                waiting_for_kafka.store(true, Ordering::Relaxed);
217                println!("Waiting for Kafka topic {} to exist", topic);
218            }
219
220            if waiting_for_kafka.load(Ordering::Relaxed) {
221                super::kafka::check_topic_exists(topic, state).await?
222            }
223
224            Ok::<(), anyhow::Error>(())
225        })
226        .await?;
227
228    Ok(ControlFlow::Continue)
229}