1use 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 #[derive(Debug, Clone)]
42 pub struct ProgressKey(String);
43
44 impl ProgressKey {
45 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
154pub 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 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 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 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
237pub 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 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
289pub 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
308pub 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 match client
328 .get_schema_by_definition(®istry_name, &schema_name, &schema)
329 .await
330 {
331 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 ®istry_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 let registered = match client
359 .register_schema_version(®istry_name, &schema_name, &schema)
360 .await
361 {
362 Ok(registered) => {
363 warn_on_glue_compatibility_mismatch(
364 &client,
365 ®istry_name,
366 &schema_name,
367 compatibility.as_ref(),
368 )
369 .await;
370 registered
371 }
372 Err(RegisterSchemaVersionError::SchemaNotFound) => {
373 let compatibility = compatibility.unwrap_or(GlueCompatibility::Backward);
375 match client
376 .create_schema(
377 ®istry_name,
378 &schema_name,
379 DataFormat::Avro,
380 compatibility,
381 &schema,
382 )
383 .await
384 {
385 Ok(registered) => registered,
386 Err(CreateSchemaError::AlreadyExists) => client
389 .register_schema_version(®istry_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
404async fn await_glue_schema_version_available(
414 client: &GlueClient,
415 registered: RegisteredSchemaVersion,
416) -> Result<Uuid, anyhow::Error> {
417 let id = registered.id;
418 let mut write_status = registered.lifecycle_status;
421
422 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
469async 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 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 #[mz_ore::test(tokio::test)]
550 #[cfg_attr(miri, ignore)] 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, ®ister, &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 #[mz_ore::test(tokio::test)]
580 #[cfg_attr(miri, ignore)] 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, ®ister, &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 #[mz_ore::test(tokio::test)]
603 #[cfg_attr(miri, ignore)] 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 #[mz_ore::test(tokio::test)]
621 #[cfg_attr(miri, ignore)] 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, ®ister]))
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 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}