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/// Map a Confluent compatibility level onto its AWS Glue equivalent.
290///
291/// The sink's `COMPATIBILITY` option is represented with the Confluent type
292/// throughout planning, even for Glue. Every Confluent level has a Glue
293/// analogue (the transitive levels map to Glue's `*All` modes), so this is
294/// total. Glue's `DISABLED` has no Confluent analogue and is rejected at
295/// `CREATE SINK` time, so it never reaches here.
296pub fn glue_compatibility_from_csr(level: mz_ccsr::CompatibilityLevel) -> GlueCompatibility {
297    match level {
298        mz_ccsr::CompatibilityLevel::Backward => GlueCompatibility::Backward,
299        mz_ccsr::CompatibilityLevel::BackwardTransitive => GlueCompatibility::BackwardAll,
300        mz_ccsr::CompatibilityLevel::Forward => GlueCompatibility::Forward,
301        mz_ccsr::CompatibilityLevel::ForwardTransitive => GlueCompatibility::ForwardAll,
302        mz_ccsr::CompatibilityLevel::Full => GlueCompatibility::Full,
303        mz_ccsr::CompatibilityLevel::FullTransitive => GlueCompatibility::FullAll,
304        mz_ccsr::CompatibilityLevel::None => GlueCompatibility::None,
305    }
306}
307
308/// Register `schema` for a sink in an AWS Glue Schema Registry, returning the
309/// schema-version UUID to frame records with.
310///
311/// Reuses an already-registered definition so a sink restart does not create a
312/// duplicate version. On first publish the schema is created with
313/// `compatibility` as its evolution policy, defaulting to Glue's `BACKWARD` when
314/// unset. For an existing schema the compatibility is only read and warned on
315/// when it differs, never overwritten: Glue fixes compatibility at creation
316/// time, and the sink must not silently change a policy it may share with other
317/// producers.
318pub async fn publish_glue_schema(
319    client: GlueClient,
320    registry_name: String,
321    schema_name: String,
322    schema: String,
323    compatibility: Option<GlueCompatibility>,
324) -> Result<Uuid, anyhow::Error> {
325    async move {
326        // Reuse: if this exact definition is already registered, we're done.
327        match client
328            .get_schema_by_definition(&registry_name, &schema_name, &schema)
329            .await
330        {
331            // Glue matches by definition only, so this can return a version
332            // that failed its compatibility check or is mid-deletion. Such a
333            // version is unusable: fall through and register, which surfaces
334            // a clear error if Glue still resolves the definition to it.
335            Ok(RegisteredSchemaVersion {
336                lifecycle_status:
337                    Some(
338                        SchemaVersionLifecycleStatus::Failure
339                        | SchemaVersionLifecycleStatus::Deleting,
340                    ),
341                ..
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                return await_glue_schema_version_available(&client, registered).await;
352            }
353            Err(GetSchemaByDefinitionError::NotFound) => {}
354            Err(e) => return Err(e).context("looking up Glue schema by definition"),
355        }
356
357        // Not yet registered. Add a version if the schema exists, else create it.
358        let registered = match client
359            .register_schema_version(&registry_name, &schema_name, &schema)
360            .await
361        {
362            Ok(registered) => {
363                warn_on_glue_compatibility_mismatch(
364                    &client,
365                    &registry_name,
366                    &schema_name,
367                    compatibility.as_ref(),
368                )
369                .await;
370                registered
371            }
372            Err(RegisterSchemaVersionError::SchemaNotFound) => {
373                // First publish: create the schema and set its compatibility.
374                let compatibility = compatibility.unwrap_or(GlueCompatibility::Backward);
375                match client
376                    .create_schema(
377                        &registry_name,
378                        &schema_name,
379                        DataFormat::Avro,
380                        compatibility,
381                        &schema,
382                    )
383                    .await
384                {
385                    Ok(registered) => registered,
386                    // Lost a race with another writer that created the schema
387                    // between our register and create. Retry the register.
388                    Err(CreateSchemaError::AlreadyExists) => client
389                        .register_schema_version(&registry_name, &schema_name, &schema)
390                        .await
391                        .context("registering Glue schema version after create race")?,
392                    Err(e) => return Err(e).context("creating Glue schema"),
393                }
394            }
395            Err(e) => return Err(e).context("registering Glue schema version"),
396        };
397        await_glue_schema_version_available(&client, registered).await
398    }
399    .run_in_task(|| "publish_glue_schema".to_string())
400    .await
401    .context("unable to publish schema to registry in kafka sink")
402}
403
404/// Wait until Glue reports the schema version `registered` as `Available`,
405/// returning its id.
406///
407/// Glue runs compatibility checks asynchronously: a freshly registered version
408/// is `Pending` and only later transitions to `Available` or `Failure`.
409/// Records must not be framed with a version id until it is `Available`,
410/// otherwise a version that fails its check leaves the topic holding ids that
411/// consumers cannot resolve. Errors if the version resolves to `Failure` or
412/// `Deleting`, or does not become `Available` within the polling deadline.
413async fn await_glue_schema_version_available(
414    client: &GlueClient,
415    registered: RegisteredSchemaVersion,
416) -> Result<Uuid, anyhow::Error> {
417    let id = registered.id;
418    // The status from the write response answers the first poll without
419    // another API call. Later polls re-fetch.
420    let mut write_status = registered.lifecycle_status;
421
422    // The timeouts are arbitrary, but are roughly similar to default AWS SDK behavior.
423    Retry::default()
424        .initial_backoff(Duration::from_millis(200))
425        .clamp_backoff(Duration::from_secs(2))
426        .max_duration(Duration::from_secs(30))
427        .retry_async(|_| {
428            let known = write_status.take();
429            async move {
430                let status = match known {
431                    Some(status) => Some(status),
432                    None => match client.get_schema_version_by_id(id).await {
433                        Ok(version) => version.lifecycle_status,
434                        Err(GetSchemaVersionError::NotFound) => {
435                            return RetryResult::FatalErr(anyhow!(
436                                "Glue schema version {id} disappeared while waiting for it \
437                                 to become available"
438                            ));
439                        }
440                        Err(e) => {
441                            return RetryResult::RetryableErr(
442                                anyhow::Error::new(e)
443                                    .context(format!("polling status of Glue schema version {id}")),
444                            );
445                        }
446                    },
447                };
448                match status {
449                    Some(SchemaVersionLifecycleStatus::Available) => RetryResult::Ok(id),
450                    Some(SchemaVersionLifecycleStatus::Failure) => RetryResult::FatalErr(anyhow!(
451                        "Glue schema version {id} failed its compatibility check"
452                    )),
453                    Some(SchemaVersionLifecycleStatus::Deleting) => {
454                        RetryResult::FatalErr(anyhow!("Glue schema version {id} is being deleted"))
455                    }
456                    status @ (Some(
457                        SchemaVersionLifecycleStatus::Pending
458                        | SchemaVersionLifecycleStatus::Unknown(_),
459                    )
460                    | None) => RetryResult::RetryableErr(anyhow!(
461                        "Glue schema version {id} is not yet available (status: {status:?})"
462                    )),
463                }
464            }
465        })
466        .await
467}
468
469/// Warn if the schema's existing compatibility differs from `desired`.
470///
471/// Best-effort and advisory only: a failed read is logged and ignored. The sink
472/// never changes an existing schema's compatibility (see [`publish_glue_schema`]),
473/// so a mismatch is surfaced for the operator, not acted on.
474async fn warn_on_glue_compatibility_mismatch(
475    client: &GlueClient,
476    registry_name: &str,
477    schema_name: &str,
478    desired: Option<&GlueCompatibility>,
479) {
480    let Some(desired) = desired else { return };
481    match client.get_schema(registry_name, schema_name).await {
482        Ok(schema) => {
483            if schema.compatibility.as_ref() != Some(desired) {
484                warn!(
485                    "Glue schema {schema_name:?} has compatibility {:?}, which does not match \
486                     the intended {desired:?}; leaving it unchanged",
487                    schema.compatibility
488                );
489            }
490        }
491        Err(e) => warn!(
492            "unable to read Glue schema {schema_name:?} compatibility to check for a mismatch: {e}"
493        ),
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use aws_sdk_glue::operation::get_schema_by_definition::{
500        GetSchemaByDefinitionError as SdkGetSchemaByDefinitionError, GetSchemaByDefinitionOutput,
501    };
502    use aws_sdk_glue::operation::get_schema_version::GetSchemaVersionOutput;
503    use aws_sdk_glue::operation::register_schema_version::RegisterSchemaVersionOutput;
504    use aws_sdk_glue::types::SchemaVersionStatus;
505    use aws_sdk_glue::types::error::EntityNotFoundException;
506    use aws_smithy_mocks::{Rule, RuleMode, mock, mock_client};
507
508    use super::*;
509
510    const PUBLISHED_ID: &str = "12345678-1234-5678-1234-567812345678";
511    const REUSED_ID: &str = "87654321-4321-8765-4321-876543218765";
512
513    /// A mocked client that panics on any request no rule covers, so each test
514    /// also asserts which Glue APIs the publish path may touch.
515    fn glue_client(rules: &[&Rule]) -> GlueClient {
516        GlueClient::from_sdk_client(mock_client!(aws_sdk_glue, RuleMode::MatchAny, rules))
517    }
518
519    async fn publish(client: GlueClient) -> Result<Uuid, anyhow::Error> {
520        publish_glue_schema(
521            client,
522            "registry".to_string(),
523            "schema".to_string(),
524            "{}".to_string(),
525            None,
526        )
527        .await
528    }
529
530    fn definition_not_found() -> Rule {
531        mock!(aws_sdk_glue::Client::get_schema_by_definition).then_error(|| {
532            SdkGetSchemaByDefinitionError::EntityNotFoundException(
533                EntityNotFoundException::builder().build(),
534            )
535        })
536    }
537
538    fn register_returns(status: SchemaVersionStatus) -> Rule {
539        mock!(aws_sdk_glue::Client::register_schema_version).then_output(move || {
540            RegisterSchemaVersionOutput::builder()
541                .schema_version_id(PUBLISHED_ID)
542                .status(status.clone())
543                .build()
544        })
545    }
546
547    /// A version that registers as `Pending` is polled until Glue reports it
548    /// `Available`.
549    #[mz_ore::test(tokio::test)]
550    #[cfg_attr(miri, ignore)] // can't call foreign function `sha256_compress` on OS `linux`
551    async fn publish_glue_schema_waits_for_pending_version() {
552        let lookup = definition_not_found();
553        let register = register_returns(SchemaVersionStatus::Pending);
554        let poll = mock!(aws_sdk_glue::Client::get_schema_version)
555            .sequence()
556            .output(|| {
557                GetSchemaVersionOutput::builder()
558                    .schema_version_id(PUBLISHED_ID)
559                    .status(SchemaVersionStatus::Pending)
560                    .build()
561            })
562            .output(|| {
563                GetSchemaVersionOutput::builder()
564                    .schema_version_id(PUBLISHED_ID)
565                    .status(SchemaVersionStatus::Available)
566                    .build()
567            })
568            .build();
569
570        let id = publish(glue_client(&[&lookup, &register, &poll]))
571            .await
572            .expect("pending version becomes available");
573        assert_eq!(id.to_string(), PUBLISHED_ID);
574        assert_eq!(poll.num_calls(), 2);
575    }
576
577    /// A version whose asynchronous compatibility check fails surfaces an
578    /// error instead of an id that consumers could never resolve.
579    #[mz_ore::test(tokio::test)]
580    #[cfg_attr(miri, ignore)] // can't call foreign function `sha256_compress` on OS `linux`
581    async fn publish_glue_schema_errors_on_failed_compatibility_check() {
582        let lookup = definition_not_found();
583        let register = register_returns(SchemaVersionStatus::Pending);
584        let poll = mock!(aws_sdk_glue::Client::get_schema_version).then_output(|| {
585            GetSchemaVersionOutput::builder()
586                .schema_version_id(PUBLISHED_ID)
587                .status(SchemaVersionStatus::Failure)
588                .build()
589        });
590
591        let err = publish(glue_client(&[&lookup, &register, &poll]))
592            .await
593            .expect_err("failed version must not publish");
594        assert!(
595            format!("{err:#}").contains("failed its compatibility check"),
596            "unexpected error: {err:#}"
597        );
598    }
599
600    /// A definition match in `Available` state is reused as-is, with no
601    /// registration and no status polling.
602    #[mz_ore::test(tokio::test)]
603    #[cfg_attr(miri, ignore)] // can't call foreign function `sha256_compress` on OS `linux`
604    async fn publish_glue_schema_reuses_available_definition_match() {
605        let lookup = mock!(aws_sdk_glue::Client::get_schema_by_definition).then_output(|| {
606            GetSchemaByDefinitionOutput::builder()
607                .schema_version_id(REUSED_ID)
608                .status(SchemaVersionStatus::Available)
609                .build()
610        });
611
612        let id = publish(glue_client(&[&lookup]))
613            .await
614            .expect("available version is reused");
615        assert_eq!(id.to_string(), REUSED_ID);
616    }
617
618    /// A definition match whose version failed its compatibility check is not
619    /// reused: the definition is registered anew.
620    #[mz_ore::test(tokio::test)]
621    #[cfg_attr(miri, ignore)] // can't call foreign function `sha256_compress` on OS `linux`
622    async fn publish_glue_schema_skips_failed_definition_match() {
623        let lookup = mock!(aws_sdk_glue::Client::get_schema_by_definition).then_output(|| {
624            GetSchemaByDefinitionOutput::builder()
625                .schema_version_id(REUSED_ID)
626                .status(SchemaVersionStatus::Failure)
627                .build()
628        });
629        let register = register_returns(SchemaVersionStatus::Available);
630
631        let id = publish(glue_client(&[&lookup, &register]))
632            .await
633            .expect("failed match falls through to registration");
634        assert_eq!(id.to_string(), PUBLISHED_ID);
635        assert_eq!(register.num_calls(), 1);
636    }
637
638    #[mz_ore::test]
639    fn glue_compatibility_mapping_is_total() {
640        // Exhaustive: every Confluent level maps to a distinct Glue mode, and
641        // transitive levels map to Glue's `*All` variants.
642        use mz_ccsr::CompatibilityLevel::*;
643        let cases = [
644            (Backward, GlueCompatibility::Backward),
645            (BackwardTransitive, GlueCompatibility::BackwardAll),
646            (Forward, GlueCompatibility::Forward),
647            (ForwardTransitive, GlueCompatibility::ForwardAll),
648            (Full, GlueCompatibility::Full),
649            (FullTransitive, GlueCompatibility::FullAll),
650            (None, GlueCompatibility::None),
651        ];
652        for (csr, glue) in cases {
653            assert_eq!(glue_compatibility_from_csr(csr), glue);
654        }
655    }
656}