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 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 match client
309 .get_schema_by_definition(®istry_name, &schema_name, &schema)
310 .await
311 {
312 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 ®istry_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 let registered = match client
340 .register_schema_version(®istry_name, &schema_name, &schema)
341 .await
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 registered
352 }
353 Err(RegisterSchemaVersionError::SchemaNotFound) => {
354 let compatibility = compatibility.unwrap_or(GlueCompatibility::Backward);
356 match client
357 .create_schema(
358 ®istry_name,
359 &schema_name,
360 DataFormat::Avro,
361 compatibility,
362 &schema,
363 )
364 .await
365 {
366 Ok(registered) => registered,
367 Err(CreateSchemaError::AlreadyExists) => client
370 .register_schema_version(®istry_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
385async fn await_glue_schema_version_available(
395 client: &GlueClient,
396 registered: RegisteredSchemaVersion,
397) -> Result<Uuid, anyhow::Error> {
398 let id = registered.id;
399 let mut write_status = registered.lifecycle_status;
402
403 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
450async 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 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 #[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, ®ister, &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 #[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, ®ister, &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 #[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 #[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, ®ister]))
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}