Skip to main content

mz_storage_client/
sink.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::BTreeMap;
11use std::time::Duration;
12
13use anyhow::{Context, anyhow, bail};
14use mz_aws_glue_schema_registry::{
15    Client as GlueClient, Compatibility as GlueCompatibility, CreateSchemaError, DataFormat,
16    GetSchemaByDefinitionError, GetSchemaVersionError, RegisterSchemaVersionError,
17    RegisteredSchemaVersion, SchemaVersionLifecycleStatus,
18};
19use mz_ccsr::GetSubjectConfigError;
20use mz_kafka_util::admin::EnsureTopicConfig;
21use mz_kafka_util::client::MzClientContext;
22use mz_ore::collections::CollectionExt;
23use mz_ore::future::{InTask, OreFutureExt};
24use mz_ore::retry::{Retry, RetryResult};
25use mz_storage_types::configuration::StorageConfiguration;
26use mz_storage_types::connections::KafkaTopicOptions;
27use mz_storage_types::errors::ContextCreationErrorExt;
28use mz_storage_types::sinks::KafkaSinkConnection;
29use rdkafka::ClientContext;
30use rdkafka::admin::{AdminClient, AdminOptions, NewTopic, ResourceSpecifier, TopicReplication};
31use tracing::warn;
32use uuid::Uuid;
33
34pub mod progress_key {
35    use std::fmt;
36
37    use mz_repr::GlobalId;
38    use rdkafka::message::ToBytes;
39
40    /// A key identifying a given sink within a progress topic.
41    #[derive(Debug, Clone)]
42    pub struct ProgressKey(String);
43
44    impl ProgressKey {
45        /// Constructs a progress key for the sink with the specified ID.
46        pub fn new(sink_id: GlobalId) -> ProgressKey {
47            ProgressKey(format!("mz-sink-{sink_id}"))
48        }
49    }
50
51    impl ToBytes for ProgressKey {
52        fn to_bytes(&self) -> &[u8] {
53            self.0.as_bytes()
54        }
55    }
56
57    impl fmt::Display for ProgressKey {
58        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
59            self.0.fmt(f)
60        }
61    }
62}
63
64struct TopicConfigs {
65    partition_count: i32,
66    replication_factor: i32,
67}
68
69async fn discover_topic_configs<C: ClientContext>(
70    client: &AdminClient<C>,
71    topic: &str,
72    fetch_timeout: Duration,
73) -> Result<TopicConfigs, anyhow::Error> {
74    let mut partition_count = -1;
75    let mut replication_factor = -1;
76
77    let metadata = client
78        .inner()
79        .fetch_metadata(None, fetch_timeout)
80        .with_context(|| {
81            format!(
82                "error fetching metadata when creating new topic {} for sink",
83                topic
84            )
85        })?;
86
87    if metadata.brokers().len() == 0 {
88        Err(anyhow!("zero brokers discovered in metadata request"))?;
89    }
90
91    let broker = metadata.brokers()[0].id();
92    let configs = client
93        .describe_configs(
94            &[ResourceSpecifier::Broker(broker)],
95            &AdminOptions::new().request_timeout(Some(Duration::from_secs(5))),
96        )
97        .await
98        .with_context(|| {
99            format!(
100                "error fetching configuration from broker {} when creating new topic {} for sink",
101                broker, topic
102            )
103        })?;
104
105    if configs.len() != 1 {
106        Err(anyhow!(
107            "error creating topic {} for sink: broker {} returned {} config results, but one was expected",
108            topic,
109            broker,
110            configs.len()
111        ))?;
112    }
113
114    let config = configs.into_element().map_err(|e| {
115        anyhow!(
116            "error reading broker configuration when creating topic {} for sink: {}",
117            topic,
118            e
119        )
120    })?;
121
122    if config.entries.is_empty() {
123        bail!("read empty cluster configuration; do we have DescribeConfigs permissions?")
124    }
125
126    for entry in config.entries {
127        if entry.name == "num.partitions" && partition_count == -1 {
128            if let Some(s) = entry.value {
129                partition_count = s.parse::<i32>().with_context(|| {
130                    format!(
131                        "default partition count {} cannot be parsed into an integer",
132                        s
133                    )
134                })?;
135            }
136        } else if entry.name == "default.replication.factor" && replication_factor == -1 {
137            if let Some(s) = entry.value {
138                replication_factor = s.parse::<i32>().with_context(|| {
139                    format!(
140                        "default replication factor {} cannot be parsed into an integer",
141                        s
142                    )
143                })?;
144            }
145        }
146    }
147
148    Ok(TopicConfigs {
149        partition_count,
150        replication_factor,
151    })
152}
153
154/// Ensures that the named Kafka topic exists.
155///
156/// If the topic does not exist, the function creates the topic with the
157/// provided `config`. Note that if the topic already exists, the function does
158/// *not* verify that the topic's configuration matches `config`.
159///
160/// Returns a boolean indicating whether the topic already existed.
161pub async fn ensure_kafka_topic(
162    connection: &KafkaSinkConnection,
163    storage_configuration: &StorageConfiguration,
164    topic: &str,
165    KafkaTopicOptions {
166        partition_count,
167        replication_factor,
168        topic_config,
169    }: &KafkaTopicOptions,
170    ensure_topic_config: EnsureTopicConfig,
171) -> Result<bool, anyhow::Error> {
172    let client: AdminClient<_> = connection
173        .connection
174        .create_with_context(
175            storage_configuration,
176            MzClientContext::default(),
177            &BTreeMap::new(),
178            // Only called from `mz_storage`.
179            InTask::Yes,
180        )
181        .await
182        .add_context("creating admin client failed")?;
183    let mut partition_count = partition_count.map(|f| *f);
184    let mut replication_factor = replication_factor.map(|f| *f);
185    // If either partition count or replication factor should be defaulted to the broker's config
186    // (signaled by a value of None), explicitly poll the broker to discover the defaults.
187    // Newer versions of Kafka can instead send create topic requests with -1 and have this happen
188    // behind the scenes, but this is unsupported and will result in errors on pre-2.4 Kafka.
189    if partition_count.is_none() || replication_factor.is_none() {
190        let fetch_timeout = storage_configuration
191            .parameters
192            .kafka_timeout_config
193            .fetch_metadata_timeout;
194        match discover_topic_configs(&client, topic, fetch_timeout).await {
195            Ok(configs) => {
196                if partition_count.is_none() {
197                    partition_count = Some(configs.partition_count);
198                }
199                if replication_factor.is_none() {
200                    replication_factor = Some(configs.replication_factor);
201                }
202            }
203            Err(e) => {
204                // Recent versions of Kafka can handle an explicit -1 config, so use this instead
205                // and the request will probably still succeed. Logging anyways for visibility.
206                warn!("Failed to discover default values for topic configs: {e}");
207                if partition_count.is_none() {
208                    partition_count = Some(-1);
209                }
210                if replication_factor.is_none() {
211                    replication_factor = Some(-1);
212                }
213            }
214        };
215    }
216
217    let mut kafka_topic = NewTopic::new(
218        topic,
219        partition_count.expect("always set above"),
220        TopicReplication::Fixed(replication_factor.expect("always set above")),
221    );
222
223    for (key, value) in topic_config {
224        kafka_topic = kafka_topic.set(key, value);
225    }
226
227    mz_kafka_util::admin::ensure_topic(
228        &client,
229        &AdminOptions::new().request_timeout(Some(Duration::from_secs(5))),
230        &kafka_topic,
231        ensure_topic_config,
232    )
233    .await
234    .with_context(|| format!("Error creating topic {} for sink", topic))
235}
236
237/// Publish a schema for a given subject, and set
238/// compatibility levels for the schema if applicable.
239///
240/// TODO(benesch): do we need to delete the Kafka topic if publishing the
241/// schema fails?
242pub async fn publish_kafka_schema(
243    ccsr: mz_ccsr::Client,
244    subject: String,
245    schema: String,
246    schema_type: mz_ccsr::SchemaType,
247    compatibility_level: Option<mz_ccsr::CompatibilityLevel>,
248) -> Result<i32, anyhow::Error> {
249    if let Some(compatibility_level) = compatibility_level {
250        let ccsr = ccsr.clone();
251        let subject = subject.clone();
252        async move {
253            // Only update the compatibility level if it's not already set to something.
254            match ccsr.get_subject_config(&subject).await {
255                Ok(config) => {
256                    if config.compatibility_level != compatibility_level {
257                        tracing::debug!(
258                            "compatibility level '{}' does not match intended '{}'",
259                            config.compatibility_level,
260                            compatibility_level
261                        );
262                    }
263                    Ok(())
264                }
265                Err(GetSubjectConfigError::SubjectCompatibilityLevelNotSet)
266                | Err(GetSubjectConfigError::SubjectNotFound) => ccsr
267                    .set_subject_compatibility_level(&subject, compatibility_level)
268                    .await
269                    .map_err(anyhow::Error::from),
270                Err(e) => Err(e.into()),
271            }
272        }
273        .run_in_task(|| "set_compatibility_level".to_string())
274        .await
275        .context("unable to update schema compatibility level in kafka sink")?;
276    }
277
278    let schema_id = async move {
279        ccsr.publish_schema(&subject, &schema, schema_type, &[])
280            .await
281    }
282    .run_in_task(|| "publish_kafka_schema".to_string())
283    .await
284    .context("unable to publish schema to registry in kafka sink")?;
285
286    Ok(schema_id)
287}
288
289/// Register `schema` for a sink in an AWS Glue Schema Registry, returning the
290/// schema-version UUID to frame records with.
291///
292/// Reuses an already-registered definition so a sink restart does not create a
293/// duplicate version. On first publish the schema is created with
294/// `compatibility` as its evolution policy, defaulting to Glue's `BACKWARD` when
295/// unset. For an existing schema the compatibility is only read and warned on
296/// when it differs, never overwritten: Glue fixes compatibility at creation
297/// time, and the sink must not silently change a policy it may share with other
298/// producers.
299pub async fn publish_glue_schema(
300    client: GlueClient,
301    registry_name: String,
302    schema_name: String,
303    schema: String,
304    compatibility: Option<GlueCompatibility>,
305) -> Result<Uuid, anyhow::Error> {
306    async move {
307        // Reuse: if this exact definition is already registered, we're done.
308        match client
309            .get_schema_by_definition(&registry_name, &schema_name, &schema)
310            .await
311        {
312            // Glue matches by definition only, so this can return a version
313            // that failed its compatibility check or is mid-deletion. Such a
314            // version is unusable: fall through and register, which surfaces
315            // a clear error if Glue still resolves the definition to it.
316            Ok(RegisteredSchemaVersion {
317                lifecycle_status:
318                    Some(
319                        SchemaVersionLifecycleStatus::Failure
320                        | SchemaVersionLifecycleStatus::Deleting,
321                    ),
322                ..
323            }) => {}
324            Ok(registered) => {
325                warn_on_glue_compatibility_mismatch(
326                    &client,
327                    &registry_name,
328                    &schema_name,
329                    compatibility.as_ref(),
330                )
331                .await;
332                return await_glue_schema_version_available(&client, registered).await;
333            }
334            Err(GetSchemaByDefinitionError::NotFound) => {}
335            Err(e) => return Err(e).context("looking up Glue schema by definition"),
336        }
337
338        // Not yet registered. Add a version if the schema exists, else create it.
339        let registered = match client
340            .register_schema_version(&registry_name, &schema_name, &schema)
341            .await
342        {
343            Ok(registered) => {
344                warn_on_glue_compatibility_mismatch(
345                    &client,
346                    &registry_name,
347                    &schema_name,
348                    compatibility.as_ref(),
349                )
350                .await;
351                registered
352            }
353            Err(RegisterSchemaVersionError::SchemaNotFound) => {
354                // First publish: create the schema and set its compatibility.
355                let compatibility = compatibility.unwrap_or(GlueCompatibility::Backward);
356                match client
357                    .create_schema(
358                        &registry_name,
359                        &schema_name,
360                        DataFormat::Avro,
361                        compatibility,
362                        &schema,
363                    )
364                    .await
365                {
366                    Ok(registered) => registered,
367                    // Lost a race with another writer that created the schema
368                    // between our register and create. Retry the register.
369                    Err(CreateSchemaError::AlreadyExists) => client
370                        .register_schema_version(&registry_name, &schema_name, &schema)
371                        .await
372                        .context("registering Glue schema version after create race")?,
373                    Err(e) => return Err(e).context("creating Glue schema"),
374                }
375            }
376            Err(e) => return Err(e).context("registering Glue schema version"),
377        };
378        await_glue_schema_version_available(&client, registered).await
379    }
380    .run_in_task(|| "publish_glue_schema".to_string())
381    .await
382    .context("unable to publish schema to registry in kafka sink")
383}
384
385/// Wait until Glue reports the schema version `registered` as `Available`,
386/// returning its id.
387///
388/// Glue runs compatibility checks asynchronously: a freshly registered version
389/// is `Pending` and only later transitions to `Available` or `Failure`.
390/// Records must not be framed with a version id until it is `Available`,
391/// otherwise a version that fails its check leaves the topic holding ids that
392/// consumers cannot resolve. Errors if the version resolves to `Failure` or
393/// `Deleting`, or does not become `Available` within the polling deadline.
394async fn await_glue_schema_version_available(
395    client: &GlueClient,
396    registered: RegisteredSchemaVersion,
397) -> Result<Uuid, anyhow::Error> {
398    let id = registered.id;
399    // The status from the write response answers the first poll without
400    // another API call. Later polls re-fetch.
401    let mut write_status = registered.lifecycle_status;
402
403    // The timeouts are arbitrary, but are roughly similar to default AWS SDK behavior.
404    Retry::default()
405        .initial_backoff(Duration::from_millis(200))
406        .clamp_backoff(Duration::from_secs(2))
407        .max_duration(Duration::from_secs(30))
408        .retry_async(|_| {
409            let known = write_status.take();
410            async move {
411                let status = match known {
412                    Some(status) => Some(status),
413                    None => match client.get_schema_version_by_id(id).await {
414                        Ok(version) => version.lifecycle_status,
415                        Err(GetSchemaVersionError::NotFound) => {
416                            return RetryResult::FatalErr(anyhow!(
417                                "Glue schema version {id} disappeared while waiting for it \
418                                 to become available"
419                            ));
420                        }
421                        Err(e) => {
422                            return RetryResult::RetryableErr(
423                                anyhow::Error::new(e)
424                                    .context(format!("polling status of Glue schema version {id}")),
425                            );
426                        }
427                    },
428                };
429                match status {
430                    Some(SchemaVersionLifecycleStatus::Available) => RetryResult::Ok(id),
431                    Some(SchemaVersionLifecycleStatus::Failure) => RetryResult::FatalErr(anyhow!(
432                        "Glue schema version {id} failed its compatibility check"
433                    )),
434                    Some(SchemaVersionLifecycleStatus::Deleting) => {
435                        RetryResult::FatalErr(anyhow!("Glue schema version {id} is being deleted"))
436                    }
437                    status @ (Some(
438                        SchemaVersionLifecycleStatus::Pending
439                        | SchemaVersionLifecycleStatus::Unknown(_),
440                    )
441                    | None) => RetryResult::RetryableErr(anyhow!(
442                        "Glue schema version {id} is not yet available (status: {status:?})"
443                    )),
444                }
445            }
446        })
447        .await
448}
449
450/// Warn if the schema's existing compatibility differs from `desired`.
451///
452/// Best-effort and advisory only: a failed read is logged and ignored. The sink
453/// never changes an existing schema's compatibility (see [`publish_glue_schema`]),
454/// so a mismatch is surfaced for the operator, not acted on.
455async fn warn_on_glue_compatibility_mismatch(
456    client: &GlueClient,
457    registry_name: &str,
458    schema_name: &str,
459    desired: Option<&GlueCompatibility>,
460) {
461    let Some(desired) = desired else { return };
462    match client.get_schema(registry_name, schema_name).await {
463        Ok(schema) => {
464            if schema.compatibility.as_ref() != Some(desired) {
465                warn!(
466                    "Glue schema {schema_name:?} has compatibility {:?}, which does not match \
467                     the intended {desired:?}; leaving it unchanged",
468                    schema.compatibility
469                );
470            }
471        }
472        Err(e) => warn!(
473            "unable to read Glue schema {schema_name:?} compatibility to check for a mismatch: {e}"
474        ),
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use aws_sdk_glue::operation::get_schema_by_definition::{
481        GetSchemaByDefinitionError as SdkGetSchemaByDefinitionError, GetSchemaByDefinitionOutput,
482    };
483    use aws_sdk_glue::operation::get_schema_version::GetSchemaVersionOutput;
484    use aws_sdk_glue::operation::register_schema_version::RegisterSchemaVersionOutput;
485    use aws_sdk_glue::types::SchemaVersionStatus;
486    use aws_sdk_glue::types::error::EntityNotFoundException;
487    use aws_smithy_mocks::{Rule, RuleMode, mock, mock_client};
488
489    use super::*;
490
491    const PUBLISHED_ID: &str = "12345678-1234-5678-1234-567812345678";
492    const REUSED_ID: &str = "87654321-4321-8765-4321-876543218765";
493
494    /// A mocked client that panics on any request no rule covers, so each test
495    /// also asserts which Glue APIs the publish path may touch.
496    fn glue_client(rules: &[&Rule]) -> GlueClient {
497        GlueClient::from_sdk_client(mock_client!(aws_sdk_glue, RuleMode::MatchAny, rules))
498    }
499
500    async fn publish(client: GlueClient) -> Result<Uuid, anyhow::Error> {
501        publish_glue_schema(
502            client,
503            "registry".to_string(),
504            "schema".to_string(),
505            "{}".to_string(),
506            None,
507        )
508        .await
509    }
510
511    fn definition_not_found() -> Rule {
512        mock!(aws_sdk_glue::Client::get_schema_by_definition).then_error(|| {
513            SdkGetSchemaByDefinitionError::EntityNotFoundException(
514                EntityNotFoundException::builder().build(),
515            )
516        })
517    }
518
519    fn register_returns(status: SchemaVersionStatus) -> Rule {
520        mock!(aws_sdk_glue::Client::register_schema_version).then_output(move || {
521            RegisterSchemaVersionOutput::builder()
522                .schema_version_id(PUBLISHED_ID)
523                .status(status.clone())
524                .build()
525        })
526    }
527
528    /// A version that registers as `Pending` is polled until Glue reports it
529    /// `Available`.
530    #[mz_ore::test(tokio::test)]
531    async fn publish_glue_schema_waits_for_pending_version() {
532        let lookup = definition_not_found();
533        let register = register_returns(SchemaVersionStatus::Pending);
534        let poll = mock!(aws_sdk_glue::Client::get_schema_version)
535            .sequence()
536            .output(|| {
537                GetSchemaVersionOutput::builder()
538                    .schema_version_id(PUBLISHED_ID)
539                    .status(SchemaVersionStatus::Pending)
540                    .build()
541            })
542            .output(|| {
543                GetSchemaVersionOutput::builder()
544                    .schema_version_id(PUBLISHED_ID)
545                    .status(SchemaVersionStatus::Available)
546                    .build()
547            })
548            .build();
549
550        let id = publish(glue_client(&[&lookup, &register, &poll]))
551            .await
552            .expect("pending version becomes available");
553        assert_eq!(id.to_string(), PUBLISHED_ID);
554        assert_eq!(poll.num_calls(), 2);
555    }
556
557    /// A version whose asynchronous compatibility check fails surfaces an
558    /// error instead of an id that consumers could never resolve.
559    #[mz_ore::test(tokio::test)]
560    async fn publish_glue_schema_errors_on_failed_compatibility_check() {
561        let lookup = definition_not_found();
562        let register = register_returns(SchemaVersionStatus::Pending);
563        let poll = mock!(aws_sdk_glue::Client::get_schema_version).then_output(|| {
564            GetSchemaVersionOutput::builder()
565                .schema_version_id(PUBLISHED_ID)
566                .status(SchemaVersionStatus::Failure)
567                .build()
568        });
569
570        let err = publish(glue_client(&[&lookup, &register, &poll]))
571            .await
572            .expect_err("failed version must not publish");
573        assert!(
574            format!("{err:#}").contains("failed its compatibility check"),
575            "unexpected error: {err:#}"
576        );
577    }
578
579    /// A definition match in `Available` state is reused as-is, with no
580    /// registration and no status polling.
581    #[mz_ore::test(tokio::test)]
582    async fn publish_glue_schema_reuses_available_definition_match() {
583        let lookup = mock!(aws_sdk_glue::Client::get_schema_by_definition).then_output(|| {
584            GetSchemaByDefinitionOutput::builder()
585                .schema_version_id(REUSED_ID)
586                .status(SchemaVersionStatus::Available)
587                .build()
588        });
589
590        let id = publish(glue_client(&[&lookup]))
591            .await
592            .expect("available version is reused");
593        assert_eq!(id.to_string(), REUSED_ID);
594    }
595
596    /// A definition match whose version failed its compatibility check is not
597    /// reused: the definition is registered anew.
598    #[mz_ore::test(tokio::test)]
599    async fn publish_glue_schema_skips_failed_definition_match() {
600        let lookup = mock!(aws_sdk_glue::Client::get_schema_by_definition).then_output(|| {
601            GetSchemaByDefinitionOutput::builder()
602                .schema_version_id(REUSED_ID)
603                .status(SchemaVersionStatus::Failure)
604                .build()
605        });
606        let register = register_returns(SchemaVersionStatus::Available);
607
608        let id = publish(glue_client(&[&lookup, &register]))
609            .await
610            .expect("failed match falls through to registration");
611        assert_eq!(id.to_string(), PUBLISHED_ID);
612        assert_eq!(register.num_calls(), 1);
613    }
614}