1use std::cell::RefCell;
75use std::cmp::Ordering;
76use std::collections::BTreeMap;
77use std::future::Future;
78use std::rc::Rc;
79use std::sync::atomic::AtomicU64;
80use std::sync::{Arc, Weak};
81use std::time::Duration;
82
83use crate::healthcheck::{HealthStatusMessage, HealthStatusUpdate, StatusNamespace};
84use crate::metrics::sink::kafka::KafkaSinkMetrics;
85use crate::render::sinks::{PkViolationWarner, SinkBatchStream, SinkRender};
86use crate::statistics::SinkStatistics;
87use crate::storage_state::StorageState;
88use anyhow::{Context, anyhow, bail};
89use differential_dataflow::{AsCollection, Hashable, VecCollection};
90use futures::StreamExt;
91use maplit::btreemap;
92use mz_expr::{Eval, MirScalarExpr};
93use mz_interchange::avro::{AvroEncoder, AvroSchemaId};
94use mz_interchange::encode::Encode;
95use mz_interchange::envelopes::{dbz_format, for_each_diff_pair};
96use mz_interchange::json::JsonEncoder;
97use mz_interchange::text_binary::{BinaryEncoder, TextEncoder};
98use mz_kafka_util::admin::EnsureTopicConfig;
99use mz_kafka_util::client::{
100 DEFAULT_FETCH_METADATA_TIMEOUT, GetPartitionsError, MzClientContext, TimeoutConfig,
101 TunnelingClientContext,
102};
103use mz_ore::cast::CastFrom;
104use mz_ore::collections::CollectionExt;
105use mz_ore::error::ErrorExt;
106use mz_ore::future::InTask;
107use mz_ore::soft_assert_or_log;
108use mz_ore::task::{self, AbortOnDropHandle};
109use mz_persist_client::Diagnostics;
110use mz_persist_client::write::WriteHandle;
111use mz_persist_types::codec_impls::UnitSchema;
112use mz_repr::{Datum, DatumVec, Diff, GlobalId, RelationDesc, Row, RowArena, Timestamp};
113use mz_storage_client::sink::progress_key::ProgressKey;
114use mz_storage_types::StorageDiff;
115use mz_storage_types::configuration::StorageConfiguration;
116use mz_storage_types::controller::CollectionMetadata;
117use mz_storage_types::dyncfgs::{
118 KAFKA_BUFFERED_EVENT_RESIZE_THRESHOLD_ELEMENTS, KAFKA_SINK_BATCH_NUM_MESSAGES,
119 KAFKA_SINK_BATCH_SIZE, KAFKA_SINK_EMIT_SINK_ID_HEADER, KAFKA_SINK_MESSAGE_MAX_BYTES,
120 SINK_ENSURE_TOPIC_CONFIG, SINK_PROGRESS_SEARCH,
121};
122use mz_storage_types::errors::{ContextCreationError, ContextCreationErrorExt, DataflowError};
123use mz_storage_types::sinks::{
124 KafkaSinkConnection, KafkaSinkFormatType, SinkEnvelope, StorageSinkDesc,
125};
126use mz_storage_types::sources::SourceData;
127use mz_storage_types::wire_format::WireFormat;
128use mz_timely_util::antichain::AntichainExt;
129use mz_timely_util::builder_async::{
130 Event, OperatorBuilder as AsyncOperatorBuilder, PressOnDropButton,
131};
132use rdkafka::consumer::{BaseConsumer, Consumer, ConsumerContext};
133use rdkafka::error::KafkaError;
134use rdkafka::message::{Header, OwnedHeaders, ToBytes};
135use rdkafka::producer::{BaseRecord, Producer, ThreadedProducer};
136use rdkafka::types::RDKafkaErrorCode;
137use rdkafka::{Message, Offset, Statistics, TopicPartitionList};
138use serde::{Deserialize, Deserializer, Serialize, Serializer};
139use timely::PartialOrder;
140use timely::container::CapacityContainerBuilder;
141use timely::dataflow::StreamVec;
142use timely::dataflow::channels::pact::{Exchange, Pipeline};
143use timely::dataflow::operators::vec::{Map, ToStream};
144use timely::dataflow::operators::{CapabilitySet, Concatenate};
145use timely::progress::{Antichain, Timestamp as _};
146use tokio::sync::watch;
147use tokio::time::{self, MissedTickBehavior};
148use tracing::{debug, error, info, warn};
149
150impl<'scope> SinkRender<'scope> for KafkaSinkConnection {
151 fn get_key_indices(&self) -> Option<&[usize]> {
152 self.key_desc_and_indices
153 .as_ref()
154 .map(|(_desc, indices)| indices.as_slice())
155 }
156
157 fn get_relation_key_indices(&self) -> Option<&[usize]> {
158 self.relation_key_indices.as_deref()
159 }
160
161 fn render_sink(
162 &self,
163 storage_state: &mut StorageState,
164 sink: &StorageSinkDesc<CollectionMetadata, Timestamp>,
165 sink_id: GlobalId,
166 batches: SinkBatchStream<'scope>,
167 key_is_synthetic: bool,
168 _err_collection: VecCollection<'scope, Timestamp, DataflowError, Diff>,
171 ) -> (
172 StreamVec<'scope, Timestamp, HealthStatusMessage>,
173 Vec<PressOnDropButton>,
174 ) {
175 let scope = batches.scope();
176
177 let write_handle = {
178 let persist = Arc::clone(&storage_state.persist_clients);
179 let shard_meta = sink.to_storage_metadata.clone();
180 async move {
181 let client = persist.open(shard_meta.persist_location).await?;
182 let handle = client
183 .open_writer(
184 shard_meta.data_shard,
185 Arc::new(shard_meta.relation_desc),
186 Arc::new(UnitSchema),
187 Diagnostics::from_purpose("sink handle"),
188 )
189 .await?;
190 Ok(handle)
191 }
192 };
193
194 let write_frontier = Rc::new(RefCell::new(Antichain::from_elem(Timestamp::minimum())));
195 storage_state
196 .sink_write_frontiers
197 .insert(sink_id, Rc::clone(&write_frontier));
198
199 let (encoded, encode_status, encode_token) = encode_collection(
200 format!("kafka-{sink_id}-{}-encode", self.format.get_format_name()),
201 batches,
202 sink.envelope,
203 self.clone(),
204 storage_state.storage_configuration.clone(),
205 sink_id,
206 sink.from,
207 key_is_synthetic,
208 );
209
210 let metrics = storage_state.metrics.get_kafka_sink_metrics(sink_id);
211 let statistics = storage_state
212 .aggregated_statistics
213 .get_sink(&sink_id)
214 .expect("statistics initialized")
215 .clone();
216
217 let (sink_status, sink_token) = sink_collection(
218 format!("kafka-{sink_id}-sink"),
219 encoded,
220 sink_id,
221 self.clone(),
222 storage_state.storage_configuration.clone(),
223 sink,
224 metrics,
225 statistics,
226 write_handle,
227 write_frontier,
228 );
229
230 let running_status = Some(HealthStatusMessage {
231 id: None,
232 update: HealthStatusUpdate::Running,
233 namespace: StatusNamespace::Kafka,
234 })
235 .to_stream(scope);
236
237 let status = scope.concatenate([running_status, encode_status, sink_status]);
238
239 (status, vec![encode_token, sink_token])
240 }
241}
242
243struct TransactionalProducer {
244 task_name: String,
246 data_topic: String,
248 progress_topic: String,
250 progress_key: ProgressKey,
252 sink_version: u64,
254 sink_id_header: Option<String>,
256 partition_count: Arc<AtomicU64>,
258 _partition_count_task: AbortOnDropHandle<()>,
260 producer: ThreadedProducer<TunnelingClientContext<MzClientContext>>,
262 statistics: SinkStatistics,
264 staged_messages: u64,
267 staged_bytes: u64,
270 socket_timeout: Duration,
272 transaction_timeout: Duration,
274}
275
276impl TransactionalProducer {
277 async fn new(
281 sink_id: GlobalId,
282 connection: &KafkaSinkConnection,
283 storage_configuration: &StorageConfiguration,
284 metrics: Arc<KafkaSinkMetrics>,
285 statistics: SinkStatistics,
286 sink_version: u64,
287 ) -> Result<(Self, Antichain<mz_repr::Timestamp>), ContextCreationError> {
288 let client_id = connection.client_id(
289 storage_configuration.config_set(),
290 &storage_configuration.connection_context,
291 sink_id,
292 );
293 let transactional_id =
294 connection.transactional_id(&storage_configuration.connection_context, sink_id);
295
296 let timeout_config = &storage_configuration.parameters.kafka_timeout_config;
297 let mut options = BTreeMap::new();
298 options.insert("enable.idempotence", "true".into());
302 options.insert(
304 "compression.type",
305 connection.compression_type.to_librdkafka_option().into(),
306 );
307 options.insert("queue.buffering.max.kbytes", "2147483647".into());
310 options.insert("queue.buffering.max.messages", "0".into());
313 options.insert("queue.buffering.max.ms", format!("{}", 10));
315 options.insert(
317 "transaction.timeout.ms",
318 format!("{}", timeout_config.transaction_timeout.as_millis()),
319 );
320 options.insert("transactional.id", transactional_id);
322 options.insert("client.id", client_id);
324 options.insert("statistics.interval.ms", "1000".into());
326 options.insert(
330 "message.max.bytes",
331 format!(
332 "{}",
333 KAFKA_SINK_MESSAGE_MAX_BYTES.get(storage_configuration.config_set())
334 ),
335 );
336 options.insert(
338 "batch.size",
339 format!(
340 "{}",
341 KAFKA_SINK_BATCH_SIZE.get(storage_configuration.config_set())
342 ),
343 );
344 options.insert(
346 "batch.num.messages",
347 format!(
348 "{}",
349 KAFKA_SINK_BATCH_NUM_MESSAGES.get(storage_configuration.config_set())
350 ),
351 );
352
353 let ctx = MzClientContext::default();
354
355 let stats_receiver = ctx.subscribe_statistics();
356 let task_name = format!("kafka_sink_metrics_collector:{sink_id}");
357 task::spawn(
358 || &task_name,
359 collect_statistics(stats_receiver, Arc::clone(&metrics)),
360 );
361
362 let producer: ThreadedProducer<_> = connection
363 .connection
364 .create_with_context(storage_configuration, ctx, &options, InTask::Yes)
365 .await?;
366
367 let partition_count = Arc::new(AtomicU64::new(0));
369 let update_partition_count = {
370 let partition_count = Arc::clone(&partition_count);
371 let metrics = Arc::clone(&metrics);
372 Arc::new(move |pc| {
373 partition_count.store(pc, std::sync::atomic::Ordering::SeqCst);
374 metrics.partition_count.set(pc);
375 })
376 };
377
378 let partition_count_task = task::spawn(
381 || format!("kafka_sink_producer_fetch_metadata_loop:{sink_id}"),
382 fetch_partition_count_loop(
383 producer.clone(),
384 sink_id,
385 connection.topic.clone(),
386 connection.topic_metadata_refresh_interval,
387 Arc::clone(&update_partition_count),
388 ),
389 );
390
391 let task_name = format!("kafka_sink_producer:{sink_id}");
392 let progress_key = ProgressKey::new(sink_id);
393
394 let producer = Self {
395 task_name,
396 data_topic: connection.topic.clone(),
397 partition_count,
398 _partition_count_task: partition_count_task.abort_on_drop(),
399 progress_topic: connection
400 .progress_topic(&storage_configuration.connection_context)
401 .into_owned(),
402 progress_key,
403 sink_version,
404 sink_id_header: KAFKA_SINK_EMIT_SINK_ID_HEADER
405 .get(storage_configuration.config_set())
406 .then(|| sink_id.to_string()),
407 producer,
408 statistics,
409 staged_messages: 0,
410 staged_bytes: 0,
411 socket_timeout: timeout_config.socket_timeout,
412 transaction_timeout: timeout_config.transaction_timeout,
413 };
414
415 let timeout = timeout_config.socket_timeout;
416 producer
417 .spawn_blocking(move |p| p.init_transactions(timeout))
418 .await?;
419
420 let progress = determine_sink_progress(
423 sink_id,
424 connection,
425 storage_configuration,
426 Arc::clone(&metrics),
427 )
428 .await?;
429
430 let resume_upper = match progress {
431 Some(progress) => {
432 if sink_version < progress.version {
433 return Err(ContextCreationError::Other(anyhow!(
434 "Fenced off by newer version of the sink. ours={} theirs={}",
435 sink_version,
436 progress.version
437 )));
438 }
439 progress.frontier
440 }
441 None => {
442 mz_storage_client::sink::ensure_kafka_topic(
443 connection,
444 storage_configuration,
445 &connection.topic,
446 &connection.topic_options,
447 EnsureTopicConfig::Skip,
448 )
449 .await?;
450 Antichain::from_elem(Timestamp::minimum())
451 }
452 };
453
454 let partition_count =
459 fetch_partition_count(&producer.producer, sink_id, &connection.topic).await?;
460 update_partition_count(partition_count);
461
462 Ok((producer, resume_upper))
463 }
464
465 async fn spawn_blocking<F, R>(&self, f: F) -> Result<R, ContextCreationError>
468 where
469 F: FnOnce(
470 ThreadedProducer<TunnelingClientContext<MzClientContext>>,
471 ) -> Result<R, KafkaError>
472 + Send
473 + 'static,
474 R: Send + 'static,
475 {
476 let producer = self.producer.clone();
477 task::spawn_blocking(|| &self.task_name, move || f(producer))
478 .await
479 .check_ssh_status(self.producer.context())
480 }
481
482 async fn begin_transaction(&self) -> Result<(), ContextCreationError> {
483 self.spawn_blocking(|p| p.begin_transaction()).await
484 }
485
486 fn send(
492 &mut self,
493 message: &KafkaMessage,
494 time: Timestamp,
495 diff: Diff,
496 ) -> Result<(), KafkaError> {
497 assert_eq!(diff, Diff::ONE, "invalid sink update");
498
499 let mut headers = OwnedHeaders::new().insert(Header {
500 key: "materialize-timestamp",
501 value: Some(time.to_string().as_bytes()),
502 });
503 if let Some(sink_id) = &self.sink_id_header {
504 headers = headers.insert(Header {
505 key: "materialize-sink-id",
506 value: Some(sink_id.as_bytes()),
507 });
508 }
509 for header in &message.headers {
510 if header.key.starts_with("materialize-") {
516 continue;
517 }
518
519 headers = headers.insert(Header {
520 key: header.key.as_str(),
521 value: header.value.as_ref(),
522 });
523 }
524
525 let pc = self
526 .partition_count
527 .load(std::sync::atomic::Ordering::SeqCst);
528 let partition = Some(i32::try_from(message.hash % pc).unwrap());
529
530 let record = BaseRecord {
531 topic: &self.data_topic,
532 key: message.key.as_ref(),
533 payload: message.value.as_ref(),
534 headers: Some(headers),
535 partition,
536 timestamp: None,
537 delivery_opaque: (),
538 };
539 let key_size = message.key.as_ref().map(|k| k.len()).unwrap_or(0);
540 let value_size = message.value.as_ref().map(|k| k.len()).unwrap_or(0);
541 let headers_size = message
542 .headers
543 .iter()
544 .map(|h| h.key.len() + h.value.as_ref().map(|v| v.len()).unwrap_or(0))
545 .sum::<usize>();
546 let record_size = u64::cast_from(key_size + value_size + headers_size);
547 self.statistics.inc_messages_staged_by(1);
548 self.staged_messages += 1;
549 self.statistics.inc_bytes_staged_by(record_size);
550 self.staged_bytes += record_size;
551 self.producer.send(record).map_err(|(e, _)| e)
552 }
553
554 async fn commit_transaction(
557 &mut self,
558 upper: Antichain<Timestamp>,
559 ) -> Result<(), ContextCreationError> {
560 let progress = ProgressRecord {
561 frontier: upper,
562 version: self.sink_version,
563 };
564 let payload = serde_json::to_vec(&progress).expect("infallible");
565 let record = BaseRecord::to(&self.progress_topic)
566 .payload(&payload)
567 .key(&self.progress_key);
568 self.producer.send(record).map_err(|(e, _)| e)?;
569
570 fail::fail_point!("kafka_sink_commit_transaction");
571
572 let timeout = self.transaction_timeout;
573 match self
574 .spawn_blocking(move |p| p.commit_transaction(timeout))
575 .await
576 {
577 Ok(()) => {
578 self.statistics
579 .inc_messages_committed_by(self.staged_messages);
580 self.statistics.inc_bytes_committed_by(self.staged_bytes);
581 self.staged_messages = 0;
582 self.staged_bytes = 0;
583 Ok(())
584 }
585 Err(ContextCreationError::KafkaError(KafkaError::Transaction(err))) => {
586 if err.txn_requires_abort() {
592 let timeout = self.socket_timeout;
593 self.spawn_blocking(move |p| p.abort_transaction(timeout))
594 .await?;
595 }
596 Err(ContextCreationError::KafkaError(KafkaError::Transaction(
597 err,
598 )))
599 }
600 Err(err) => Err(err),
601 }
602 }
603}
604
605async fn collect_statistics(
607 mut receiver: watch::Receiver<Statistics>,
608 metrics: Arc<KafkaSinkMetrics>,
609) {
610 while receiver.changed().await.is_ok() {
611 let mut outbuf_cnt: i64 = 0;
618 let mut outbuf_msg_cnt: i64 = 0;
619 let mut waitresp_cnt: i64 = 0;
620 let mut waitresp_msg_cnt: i64 = 0;
621 let mut txerrs: u64 = 0;
622 let mut txretries: u64 = 0;
623 let mut req_timeouts: u64 = 0;
624 let mut connects: i64 = 0;
625 let mut disconnects: i64 = 0;
626
627 let stats = receiver.borrow();
628 for broker in stats.brokers.values() {
629 outbuf_cnt += broker.outbuf_cnt;
630 outbuf_msg_cnt += broker.outbuf_msg_cnt;
631 waitresp_cnt += broker.waitresp_cnt;
632 waitresp_msg_cnt += broker.waitresp_msg_cnt;
633 txerrs += broker.txerrs;
634 txretries += broker.txretries;
635 req_timeouts += broker.req_timeouts;
636 connects += broker.connects.unwrap_or(0);
637 disconnects += broker.disconnects.unwrap_or(0);
638 }
639 metrics.rdkafka_msg_cnt.set(stats.msg_cnt);
640 metrics.rdkafka_msg_size.set(stats.msg_size);
641 metrics.rdkafka_txmsgs.set(stats.txmsgs);
642 metrics.rdkafka_txmsg_bytes.set(stats.txmsg_bytes);
643 metrics.rdkafka_tx.set(stats.tx);
644 metrics.rdkafka_tx_bytes.set(stats.tx_bytes);
645 metrics.rdkafka_outbuf_cnt.set(outbuf_cnt);
646 metrics.rdkafka_outbuf_msg_cnt.set(outbuf_msg_cnt);
647 metrics.rdkafka_waitresp_cnt.set(waitresp_cnt);
648 metrics.rdkafka_waitresp_msg_cnt.set(waitresp_msg_cnt);
649 metrics.rdkafka_txerrs.set(txerrs);
650 metrics.rdkafka_txretries.set(txretries);
651 metrics.rdkafka_req_timeouts.set(req_timeouts);
652 metrics.rdkafka_connects.set(connects);
653 metrics.rdkafka_disconnects.set(disconnects);
654 }
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize)]
659struct KafkaMessage {
660 hash: u64,
662 key: Option<Vec<u8>>,
664 value: Option<Vec<u8>>,
666 headers: Vec<KafkaHeader>,
668}
669
670#[derive(Debug, Clone, Serialize, Deserialize)]
672struct KafkaHeader {
673 key: String,
675 value: Option<Vec<u8>>,
677}
678
679fn sink_collection<'scope>(
685 name: String,
686 input: VecCollection<'scope, Timestamp, KafkaMessage, Diff>,
687 sink_id: GlobalId,
688 connection: KafkaSinkConnection,
689 storage_configuration: StorageConfiguration,
690 sink: &StorageSinkDesc<CollectionMetadata, Timestamp>,
691 metrics: KafkaSinkMetrics,
692 statistics: SinkStatistics,
693 write_handle: impl Future<
694 Output = anyhow::Result<WriteHandle<SourceData, (), Timestamp, StorageDiff>>,
695 > + 'static,
696 write_frontier: Rc<RefCell<Antichain<Timestamp>>>,
697) -> (
698 StreamVec<'scope, Timestamp, HealthStatusMessage>,
699 PressOnDropButton,
700) {
701 let scope = input.scope();
702 let mut builder = AsyncOperatorBuilder::new(name.clone(), input.inner.scope());
703
704 let hashed_id = sink_id.hashed();
706 let is_active_worker = usize::cast_from(hashed_id) % scope.peers() == scope.index();
707 let buffer_min_capacity =
708 KAFKA_BUFFERED_EVENT_RESIZE_THRESHOLD_ELEMENTS.handle(storage_configuration.config_set());
709
710 let mut input = builder.new_disconnected_input(input.inner, Exchange::new(move |_| hashed_id));
711
712 let as_of = sink.as_of.clone();
713 let sink_version = sink.version;
714 let (button, errors) = builder.build_fallible(move |_caps| {
715 Box::pin(async move {
716 if !is_active_worker {
717 write_frontier.borrow_mut().clear();
718 return Ok(());
719 }
720
721 fail::fail_point!("kafka_sink_creation_error", |_| Err(
722 ContextCreationError::Other(anyhow::anyhow!("synthetic error"))
723 ));
724
725 let mut write_handle = write_handle.await?;
726
727 let metrics = Arc::new(metrics);
728
729 let (mut producer, resume_upper) = TransactionalProducer::new(
730 sink_id,
731 &connection,
732 &storage_configuration,
733 Arc::clone(&metrics),
734 statistics,
735 sink_version,
736 )
737 .await?;
738
739 let overcompacted =
741 *resume_upper != [Timestamp::minimum()] &&
743 !PartialOrder::less_equal(&as_of, &resume_upper);
745 if overcompacted {
746 let err = format!(
747 "{name}: input compacted past resume upper: as_of {}, resume_upper: {}",
748 as_of.pretty(),
749 resume_upper.pretty()
750 );
751 error!("{err}");
755 return Err(anyhow!("{err}").into());
756 }
757
758 info!(
759 "{name}: as_of: {}, resume upper: {}",
760 as_of.pretty(),
761 resume_upper.pretty()
762 );
763
764 let Some(mut upper) = resume_upper.clone().into_option() else {
768 write_frontier.borrow_mut().clear();
769 return Ok(());
770 };
771
772 let mut deferred_updates = vec![];
773 let mut extra_updates = vec![];
774 let mut transaction_begun = false;
778 while let Some(event) = input.next().await {
779 match event {
780 Event::Data(_cap, batch) => {
781 for (message, time, diff) in batch {
782 match upper.cmp(&time) {
793 Ordering::Less => deferred_updates.push((message, time, diff)),
794 Ordering::Equal => {
795 if !transaction_begun {
796 producer.begin_transaction().await?;
797 transaction_begun = true;
798 }
799 producer.send(&message, time, diff)?;
800 }
801 Ordering::Greater => continue,
802 }
803 }
804 }
805 Event::Progress(progress) => {
806 if !PartialOrder::less_equal(&resume_upper, &progress) {
808 continue;
809 }
810 if !as_of.iter().all(|t| !progress.less_equal(t)) {
830 continue;
831 }
832 if !transaction_begun {
833 producer.begin_transaction().await?;
834 }
835
836 extra_updates.extend(
837 deferred_updates
838 .extract_if(.., |(_, time, _)| !progress.less_equal(time)),
839 );
840 deferred_updates.shrink_to(buffer_min_capacity.get());
844 extra_updates.sort_unstable_by(|a, b| a.1.cmp(&b.1));
845
846 for (message, time, diff) in extra_updates.drain(..) {
847 producer.send(&message, time, diff)?;
848 }
849 extra_updates.shrink_to(buffer_min_capacity.get());
850
851 debug!("{name}: committing transaction for {}", progress.pretty());
852 producer.commit_transaction(progress.clone()).await?;
853 transaction_begun = false;
854 let mut expect_upper = write_handle.shared_upper();
855 loop {
856 if PartialOrder::less_equal(&progress, &expect_upper) {
857 break;
859 }
860 const EMPTY: &[((SourceData, ()), Timestamp, StorageDiff)] = &[];
865 match write_handle
866 .compare_and_append(EMPTY, expect_upper, progress.clone())
867 .await
868 .expect("valid usage")
869 {
870 Ok(()) => break,
871 Err(mismatch) => {
872 expect_upper = mismatch.current;
873 }
874 }
875 }
876 write_frontier.borrow_mut().clone_from(&progress);
877 match progress.into_option() {
878 Some(new_upper) => upper = new_upper,
879 None => break,
880 }
881 }
882 }
883 }
884 Ok(())
885 })
886 });
887
888 let statuses = errors.map(|error: Rc<ContextCreationError>| {
889 let hint = match *error {
890 ContextCreationError::KafkaError(KafkaError::Transaction(ref e)) => {
891 if e.is_retriable() && e.code() == RDKafkaErrorCode::OperationTimedOut {
892 let hint = "If you're running a single Kafka broker, ensure that the configs \
893 transaction.state.log.replication.factor, transaction.state.log.min.isr, \
894 and offsets.topic.replication.factor are set to 1 on the broker";
895 Some(hint.to_owned())
896 } else {
897 None
898 }
899 }
900 _ => None,
901 };
902
903 HealthStatusMessage {
904 id: None,
905 update: HealthStatusUpdate::halting(format!("{}", error.display_with_causes()), hint),
906 namespace: if matches!(*error, ContextCreationError::Ssh(_)) {
907 StatusNamespace::Ssh
908 } else {
909 StatusNamespace::Kafka
910 },
911 }
912 });
913
914 (statuses, button.press_on_drop())
915}
916
917async fn determine_sink_progress(
924 sink_id: GlobalId,
925 connection: &KafkaSinkConnection,
926 storage_configuration: &StorageConfiguration,
927 metrics: Arc<KafkaSinkMetrics>,
928) -> Result<Option<ProgressRecord>, ContextCreationError> {
929 let TimeoutConfig {
936 fetch_metadata_timeout,
937 progress_record_fetch_timeout,
938 ..
939 } = storage_configuration.parameters.kafka_timeout_config;
940
941 let client_id = connection.client_id(
942 storage_configuration.config_set(),
943 &storage_configuration.connection_context,
944 sink_id,
945 );
946 let group_id = connection.progress_group_id(&storage_configuration.connection_context, sink_id);
947 let progress_topic = connection
948 .progress_topic(&storage_configuration.connection_context)
949 .into_owned();
950 let progress_topic_options = &connection.connection.progress_topic_options;
951 let progress_key = ProgressKey::new(sink_id);
952
953 let common_options = btreemap! {
954 "group.id" => group_id,
957 "client.id" => client_id,
959 "enable.auto.commit" => "false".into(),
960 "auto.offset.reset" => "earliest".into(),
961 "enable.partition.eof" => "true".into(),
964 };
965
966 let progress_client_read_committed: BaseConsumer<_> = {
969 let mut opts = common_options.clone();
970 opts.insert("isolation.level", "read_committed".into());
971 let ctx = MzClientContext::default();
972 connection
973 .connection
974 .create_with_context(storage_configuration, ctx, &opts, InTask::Yes)
975 .await?
976 };
977
978 let progress_client_read_uncommitted: BaseConsumer<_> = {
979 let mut opts = common_options;
980 opts.insert("isolation.level", "read_uncommitted".into());
981 let ctx = MzClientContext::default();
982 connection
983 .connection
984 .create_with_context(storage_configuration, ctx, &opts, InTask::Yes)
985 .await?
986 };
987
988 let ctx = Arc::clone(progress_client_read_committed.client().context());
989
990 let ensure_topic_config =
992 match &*SINK_ENSURE_TOPIC_CONFIG.get(storage_configuration.config_set()) {
993 "skip" => EnsureTopicConfig::Skip,
994 "check" => EnsureTopicConfig::Check,
995 "alter" => EnsureTopicConfig::Alter,
996 _ => {
997 tracing::warn!(
998 topic = progress_topic,
999 "unexpected value for ensure-topic-config; skipping checks"
1000 );
1001 EnsureTopicConfig::Skip
1002 }
1003 };
1004 mz_storage_client::sink::ensure_kafka_topic(
1005 connection,
1006 storage_configuration,
1007 &progress_topic,
1008 progress_topic_options,
1009 ensure_topic_config,
1010 )
1011 .await
1012 .add_context("error registering kafka progress topic for sink")?;
1013
1014 let parent_token = Arc::new(());
1021 let child_token = Arc::downgrade(&parent_token);
1022 let task_name = format!("get_latest_ts:{sink_id}");
1023 let sink_progress_search = SINK_PROGRESS_SEARCH.get(storage_configuration.config_set());
1024 let result = task::spawn_blocking(|| task_name, move || {
1025 let progress_topic = progress_topic.as_ref();
1026 let partitions = match mz_kafka_util::client::get_partitions(
1030 progress_client_read_committed.client(),
1031 progress_topic,
1032 fetch_metadata_timeout,
1033 ) {
1034 Ok(partitions) => partitions,
1035 Err(GetPartitionsError::TopicDoesNotExist) => {
1036 return Ok(None);
1039 }
1040 e => e.with_context(|| {
1041 format!(
1042 "Unable to fetch metadata about progress topic {}",
1043 progress_topic
1044 )
1045 })?,
1046 };
1047 if partitions.len() != 1 {
1048 bail!(
1049 "Progress topic {} should contain a single partition, but instead contains {} partitions",
1050 progress_topic, partitions.len(),
1051 );
1052 }
1053 let partition = partitions.into_element();
1054
1055 metrics.consumed_progress_records.set(0);
1064
1065 let (lo, hi) = progress_client_read_uncommitted
1091 .fetch_watermarks(progress_topic, partition, fetch_metadata_timeout)
1092 .map_err(|e| {
1093 anyhow!(
1094 "Failed to fetch metadata while reading from progress topic: {}",
1095 e
1096 )
1097 })?;
1098
1099 let mut start_indices = vec![lo];
1105 if sink_progress_search {
1106 let mut lookback = hi.saturating_sub(lo) / 10;
1107 while lookback >= 20_000 {
1108 start_indices.push(hi - lookback);
1109 lookback /= 10;
1110 }
1111 }
1112 for lo in start_indices.into_iter().rev() {
1113 if let Some(found) = progress_search(
1114 &progress_client_read_committed,
1115 progress_record_fetch_timeout,
1116 progress_topic,
1117 partition,
1118 lo,
1119 hi,
1120 progress_key.clone(),
1121 Weak::clone(&child_token),
1122 Arc::clone(&metrics)
1123 )? {
1124 return Ok(Some(found));
1125 }
1126 }
1127 Ok(None)
1128 }).await.check_ssh_status(&ctx);
1129 drop(parent_token);
1131 result
1132}
1133
1134fn progress_search<C: ConsumerContext + 'static>(
1135 progress_client_read_committed: &BaseConsumer<C>,
1136 progress_record_fetch_timeout: Duration,
1137 progress_topic: &str,
1138 partition: i32,
1139 lo: i64,
1140 hi: i64,
1141 progress_key: ProgressKey,
1142 child_token: Weak<()>,
1143 metrics: Arc<KafkaSinkMetrics>,
1144) -> anyhow::Result<Option<ProgressRecord>> {
1145 let mut tps = TopicPartitionList::new();
1147 tps.add_partition(progress_topic, partition);
1148 tps.set_partition_offset(progress_topic, partition, Offset::Offset(lo))?;
1149 progress_client_read_committed
1150 .assign(&tps)
1151 .with_context(|| {
1152 format!(
1153 "Error seeking in progress topic {}:{}",
1154 progress_topic, partition
1155 )
1156 })?;
1157
1158 let get_position = || {
1160 if child_token.strong_count() == 0 {
1161 bail!("operation cancelled");
1162 }
1163 let position = progress_client_read_committed
1164 .position()?
1165 .find_partition(progress_topic, partition)
1166 .ok_or_else(|| {
1167 anyhow!(
1168 "No position info found for progress topic {}",
1169 progress_topic
1170 )
1171 })?
1172 .offset();
1173 let position = match position {
1174 Offset::Offset(position) => position,
1175 Offset::Invalid => lo,
1185 _ => bail!(
1186 "Consumer::position returned offset of wrong type: {:?}",
1187 position
1188 ),
1189 };
1190 let outstanding = u64::try_from(std::cmp::max(0, hi - position)).unwrap();
1192 metrics.outstanding_progress_records.set(outstanding);
1193 Ok(position)
1194 };
1195
1196 info!("fetching latest progress record for {progress_key}, lo/hi: {lo}/{hi}");
1197
1198 let mut last_progress: Option<ProgressRecord> = None;
1222 loop {
1223 let current_position = get_position()?;
1224
1225 if current_position >= hi {
1226 break;
1228 }
1229
1230 let message = match progress_client_read_committed.poll(progress_record_fetch_timeout) {
1231 Some(Ok(message)) => message,
1232 Some(Err(KafkaError::PartitionEOF(_))) => {
1233 continue;
1238 }
1239 Some(Err(e)) => bail!("failed to fetch progress message {e}"),
1240 None => {
1241 bail!(
1242 "timed out while waiting to reach high water mark of non-empty \
1243 topic {progress_topic}:{partition}, lo/hi: {lo}/{hi}, current position: {current_position}"
1244 );
1245 }
1246 };
1247
1248 if message.key() != Some(progress_key.to_bytes()) {
1249 continue;
1251 }
1252
1253 metrics.consumed_progress_records.inc();
1254
1255 let Some(payload) = message.payload() else {
1256 continue;
1257 };
1258 let progress = parse_progress_record(payload)?;
1259
1260 match last_progress {
1261 Some(last_progress)
1262 if !PartialOrder::less_equal(&last_progress.frontier, &progress.frontier) =>
1263 {
1264 bail!(
1265 "upper regressed in topic {progress_topic}:{partition} from {:?} to {:?}",
1266 last_progress.frontier,
1267 progress.frontier,
1268 );
1269 }
1270 _ => last_progress = Some(progress),
1271 }
1272 }
1273
1274 Ok(last_progress)
1278}
1279
1280#[derive(Debug, PartialEq, Serialize, Deserialize)]
1287pub struct LegacyProgressRecord {
1288 #[serde(default, deserialize_with = "deserialize_some")]
1291 pub timestamp: Option<Option<Timestamp>>,
1292}
1293
1294fn deserialize_some<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
1296where
1297 T: Deserialize<'de>,
1298 D: Deserializer<'de>,
1299{
1300 Deserialize::deserialize(deserializer).map(Some)
1301}
1302
1303#[derive(Debug, PartialEq, Serialize, Deserialize)]
1306pub struct ProgressRecord {
1307 #[serde(
1308 deserialize_with = "deserialize_frontier",
1309 serialize_with = "serialize_frontier"
1310 )]
1311 pub frontier: Antichain<Timestamp>,
1312 #[serde(default)]
1313 pub version: u64,
1314}
1315fn serialize_frontier<S>(frontier: &Antichain<Timestamp>, serializer: S) -> Result<S::Ok, S::Error>
1316where
1317 S: Serializer,
1318{
1319 Serialize::serialize(frontier.elements(), serializer)
1320}
1321
1322fn deserialize_frontier<'de, D>(deserializer: D) -> Result<Antichain<Timestamp>, D::Error>
1323where
1324 D: Deserializer<'de>,
1325{
1326 let times: Vec<Timestamp> = Deserialize::deserialize(deserializer)?;
1327 Ok(Antichain::from(times))
1328}
1329
1330fn parse_progress_record(payload: &[u8]) -> Result<ProgressRecord, anyhow::Error> {
1331 Ok(match serde_json::from_slice::<ProgressRecord>(payload) {
1332 Ok(progress) => progress,
1333 Err(_) => match serde_json::from_slice::<LegacyProgressRecord>(payload) {
1335 Ok(LegacyProgressRecord {
1336 timestamp: Some(Some(time)),
1337 }) => ProgressRecord {
1338 frontier: Antichain::from_elem(time.step_forward()),
1339 version: 0,
1340 },
1341 Ok(LegacyProgressRecord {
1342 timestamp: Some(None),
1343 }) => ProgressRecord {
1344 frontier: Antichain::new(),
1345 version: 0,
1346 },
1347 _ => match std::str::from_utf8(payload) {
1348 Ok(payload) => bail!("invalid progress record: {payload}"),
1349 Err(_) => bail!("invalid progress record bytes: {payload:?}"),
1350 },
1351 },
1352 })
1353}
1354
1355async fn fetch_partition_count(
1357 producer: &ThreadedProducer<TunnelingClientContext<MzClientContext>>,
1358 sink_id: GlobalId,
1359 topic_name: &str,
1360) -> Result<u64, anyhow::Error> {
1361 let meta = task::spawn_blocking(|| format!("kafka_sink_fetch_partition_count:{sink_id}"), {
1362 let producer = producer.clone();
1363 move || {
1364 producer
1365 .client()
1366 .fetch_metadata(None, DEFAULT_FETCH_METADATA_TIMEOUT)
1367 }
1368 })
1369 .await
1370 .check_ssh_status(producer.context())?;
1371
1372 match meta.topics().iter().find(|t| t.name() == topic_name) {
1373 Some(topic) => {
1374 let partition_count = u64::cast_from(topic.partitions().len());
1375 if partition_count == 0 {
1376 bail!("topic {topic_name} has an impossible partition count of zero");
1377 }
1378 Ok(partition_count)
1379 }
1380 None => bail!("topic {topic_name} does not exist"),
1381 }
1382}
1383
1384async fn fetch_partition_count_loop<F>(
1390 producer: ThreadedProducer<TunnelingClientContext<MzClientContext>>,
1391 sink_id: GlobalId,
1392 topic_name: String,
1393 interval: Duration,
1394 update_partition_count: Arc<F>,
1395) where
1396 F: Fn(u64),
1397{
1398 let mut interval = time::interval(interval);
1399 interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
1400 loop {
1401 interval.tick().await;
1402 match fetch_partition_count(&producer, sink_id, &topic_name).await {
1403 Ok(pc) => update_partition_count(pc),
1404 Err(e) => {
1405 warn!(%sink_id, "failed updating partition count: {e}");
1406 continue;
1407 }
1408 };
1409 }
1410}
1411
1412async fn build_avro_encoder(
1422 desc: RelationDesc,
1423 debezium: bool,
1424 schema: String,
1425 compatibility_level: Option<mz_ccsr::CompatibilityLevel>,
1426 wire_format: WireFormat,
1427 subject: String,
1428 storage_configuration: &StorageConfiguration,
1429) -> Result<AvroEncoder, anyhow::Error> {
1430 let schema_id = match wire_format {
1431 WireFormat::Confluent {
1432 registry: Some(csr),
1433 } => {
1434 let ccsr = csr.connect(storage_configuration, InTask::Yes).await?;
1435 let schema_id = mz_storage_client::sink::publish_kafka_schema(
1436 ccsr,
1437 subject,
1438 schema.clone(),
1439 mz_ccsr::SchemaType::Avro,
1440 compatibility_level,
1441 )
1442 .await
1443 .context("error publishing kafka schemas for sink")?;
1444 AvroSchemaId::Confluent(schema_id)
1445 }
1446 WireFormat::Glue {
1447 registry: Some(glue),
1448 } => {
1449 let enforce_external_addresses = mz_storage_types::dyncfgs::ENFORCE_EXTERNAL_ADDRESSES
1450 .get(storage_configuration.config_set());
1451 let sdk_config = glue
1452 .aws_connection
1453 .connection
1454 .load_sdk_config(
1455 &storage_configuration.connection_context,
1456 glue.aws_connection.connection_id,
1457 InTask::Yes,
1458 enforce_external_addresses,
1459 )
1460 .await?;
1461 let client = mz_aws_glue_schema_registry::ClientConfig::new(sdk_config).build();
1462 let compatibility =
1463 compatibility_level.map(mz_storage_client::sink::glue_compatibility_from_csr);
1464 let schema_version_id = mz_storage_client::sink::publish_glue_schema(
1480 client,
1481 glue.registry_name,
1482 subject,
1483 schema.clone(),
1484 compatibility,
1485 )
1486 .await
1487 .context("error publishing glue schemas for sink")?;
1488 AvroSchemaId::Glue(schema_version_id)
1489 }
1490 other => unreachable!(
1491 "sink Avro wire_format must carry a registry, got {:?}",
1492 other
1493 ),
1494 };
1495 Ok(AvroEncoder::new(desc, debezium, &schema, schema_id))
1496}
1497
1498fn encode_collection<'scope>(
1504 name: String,
1505 batches: SinkBatchStream<'scope>,
1506 envelope: SinkEnvelope,
1507 connection: KafkaSinkConnection,
1508 storage_configuration: StorageConfiguration,
1509 sink_id: GlobalId,
1510 from_id: GlobalId,
1511 key_is_synthetic: bool,
1512) -> (
1513 VecCollection<'scope, Timestamp, KafkaMessage, Diff>,
1514 StreamVec<'scope, Timestamp, HealthStatusMessage>,
1515 PressOnDropButton,
1516) {
1517 let mut builder = AsyncOperatorBuilder::new(name, batches.scope());
1518
1519 let (output, stream) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
1520 let mut input = builder.new_input_for(batches, Pipeline, &output);
1521
1522 let (button, errors) = builder.build_fallible(move |caps| {
1523 Box::pin(async move {
1524 let [capset]: &mut [_; 1] = caps.try_into().unwrap();
1525 let key_desc = connection
1526 .key_desc_and_indices
1527 .as_ref()
1528 .map(|(desc, _indices)| desc.clone());
1529 let value_desc = connection.value_desc;
1530
1531 let key_encoder: Option<Box<dyn Encode>> =
1532 match (key_desc, connection.format.key_format) {
1533 (Some(desc), Some(KafkaSinkFormatType::Bytes)) => {
1534 Some(Box::new(BinaryEncoder::new(desc, false)))
1535 }
1536 (Some(desc), Some(KafkaSinkFormatType::Text)) => {
1537 Some(Box::new(TextEncoder::new(desc, false)))
1538 }
1539 (Some(desc), Some(KafkaSinkFormatType::Json)) => {
1540 Some(Box::new(JsonEncoder::new(desc, false)))
1541 }
1542 (Some(desc), Some(KafkaSinkFormatType::Avro {
1543 schema,
1544 compatibility_level,
1545 schema_name,
1546 wire_format,
1547 })) => {
1548 let subject = schema_name
1549 .unwrap_or_else(|| format!("{}-key", connection.topic));
1550 let encoder = build_avro_encoder(
1551 desc,
1552 false,
1553 schema,
1554 compatibility_level,
1555 wire_format,
1556 subject,
1557 &storage_configuration,
1558 )
1559 .await?;
1560 Some(Box::new(encoder))
1561 }
1562 (None, None) => None,
1563 (desc, format) => {
1564 return Err(anyhow!(
1565 "key_desc and key_format must be both set or both unset, but key_desc: {:?}, key_format: {:?}",
1566 desc,
1567 format
1568 ))
1569 }
1570 };
1571
1572 let debezium = matches!(envelope, SinkEnvelope::Debezium);
1574
1575 let value_encoder: Box<dyn Encode> = match connection.format.value_format {
1576 KafkaSinkFormatType::Bytes => Box::new(BinaryEncoder::new(value_desc, debezium)),
1577 KafkaSinkFormatType::Text => Box::new(TextEncoder::new(value_desc, debezium)),
1578 KafkaSinkFormatType::Json => Box::new(JsonEncoder::new(value_desc, debezium)),
1579 KafkaSinkFormatType::Avro {
1580 schema,
1581 compatibility_level,
1582 schema_name,
1583 wire_format,
1584 } => {
1585 let subject = schema_name
1586 .unwrap_or_else(|| format!("{}-value", connection.topic));
1587 let encoder = build_avro_encoder(
1588 value_desc,
1589 debezium,
1590 schema,
1591 compatibility_level,
1592 wire_format,
1593 subject,
1594 &storage_configuration,
1595 )
1596 .await?;
1597 Box::new(encoder)
1598 }
1599 };
1600
1601 *capset = CapabilitySet::new();
1607
1608 let mut row_buf = Row::default();
1609 let mut datums = DatumVec::new();
1610 let mut pk_warner =
1611 (!key_is_synthetic).then(|| PkViolationWarner::new(sink_id, from_id));
1612
1613 while let Some(event) = input.next().await {
1614 if let Event::Data(cap, mut batches) = event {
1615 for batch in batches.drain(..) {
1616 for_each_diff_pair(&batch, |key, time, value| {
1617 if let Some(warner) = pk_warner.as_mut() {
1618 warner.observe(key, time);
1619 }
1620 let key_for_message = if key_encoder.is_some() { key } else { &None };
1624
1625 let mut hash = None;
1626 let mut headers = vec![];
1627 if connection.headers_index.is_some()
1628 || connection.partition_by.is_some()
1629 {
1630 let row = value
1644 .after
1645 .as_ref()
1646 .or(value.before.as_ref())
1647 .expect("one of before or after must be set");
1648 let row = datums.borrow_with(row);
1649
1650 if let Some(i) = connection.headers_index {
1651 headers = encode_headers(row[i]);
1652 }
1653
1654 if let Some(partition_by) = &connection.partition_by {
1655 hash = Some(evaluate_partition_by(partition_by, &row));
1656 }
1657 }
1658 let (encoded_key, hash) = match key_for_message {
1659 Some(key) => {
1660 let key_encoder =
1661 key_encoder.as_ref().expect("key present");
1662 let encoded = key_encoder.encode_unchecked(key.clone());
1663 let hash =
1664 hash.unwrap_or_else(|| key_encoder.hash(&encoded));
1665 (Some(encoded), hash)
1666 }
1667 None => (None, hash.unwrap_or(0)),
1668 };
1669 let value = match envelope {
1670 SinkEnvelope::Upsert => value.after,
1671 SinkEnvelope::Debezium => {
1672 dbz_format(&mut row_buf.packer(), value);
1673 Some(row_buf.clone())
1674 }
1675 SinkEnvelope::Append => {
1676 unreachable!("Append envelope is not valid for Kafka sinks")
1677 }
1678 };
1679 let value = value.map(|value| value_encoder.encode_unchecked(value));
1680 let message = KafkaMessage {
1681 hash,
1682 key: encoded_key,
1683 value,
1684 headers,
1685 };
1686 output.give(&cap, (message, time, Diff::ONE));
1687 });
1688 if let Some(warner) = pk_warner.as_mut() {
1692 warner.flush();
1693 }
1694 }
1695 }
1696 }
1697
1698 Ok::<(), anyhow::Error>(())
1699 })
1700 });
1701
1702 let statuses = errors.map(|error| HealthStatusMessage {
1703 id: None,
1704 update: HealthStatusUpdate::halting(format!("{}", error.display_with_causes()), None),
1705 namespace: StatusNamespace::Kafka,
1706 });
1707
1708 (stream.as_collection(), statuses, button.press_on_drop())
1709}
1710
1711fn encode_headers(datum: Datum) -> Vec<KafkaHeader> {
1712 let mut out = vec![];
1713 if datum.is_null() {
1714 return out;
1715 }
1716 for (key, value) in datum.unwrap_map().iter() {
1717 out.push(KafkaHeader {
1718 key: key.into(),
1719 value: match value {
1720 Datum::Null => None,
1721 Datum::String(s) => Some(s.as_bytes().to_vec()),
1722 Datum::Bytes(b) => Some(b.to_vec()),
1723 _ => panic!("encode_headers called with unexpected header value {value:?}"),
1724 },
1725 })
1726 }
1727 out
1728}
1729
1730fn evaluate_partition_by(partition_by: &MirScalarExpr, row: &[Datum]) -> u64 {
1738 let temp_storage = RowArena::new();
1746 match partition_by.eval(row, &temp_storage) {
1747 Ok(Datum::UInt64(u)) => u,
1748 Ok(datum) => {
1749 soft_assert_or_log!(datum.is_null(), "unexpected partition_by result: {datum:?}");
1752 0
1754 }
1755 Err(_) => 0,
1756 }
1757}
1758
1759#[cfg(test)]
1760mod test {
1761 use mz_ore::assert_err;
1762
1763 use super::*;
1764
1765 #[mz_ore::test]
1766 fn progress_record_migration() {
1767 assert_err!(parse_progress_record(b"{}"));
1768
1769 assert_eq!(
1770 parse_progress_record(b"{\"timestamp\":1}").unwrap(),
1771 ProgressRecord {
1772 frontier: Antichain::from_elem(2.into()),
1773 version: 0,
1774 }
1775 );
1776
1777 assert_eq!(
1778 parse_progress_record(b"{\"timestamp\":null}").unwrap(),
1779 ProgressRecord {
1780 frontier: Antichain::new(),
1781 version: 0,
1782 }
1783 );
1784
1785 assert_eq!(
1786 parse_progress_record(b"{\"frontier\":[1]}").unwrap(),
1787 ProgressRecord {
1788 frontier: Antichain::from_elem(1.into()),
1789 version: 0,
1790 }
1791 );
1792
1793 assert_eq!(
1794 parse_progress_record(b"{\"frontier\":[]}").unwrap(),
1795 ProgressRecord {
1796 frontier: Antichain::new(),
1797 version: 0,
1798 }
1799 );
1800
1801 assert_eq!(
1802 parse_progress_record(b"{\"frontier\":[], \"version\": 42}").unwrap(),
1803 ProgressRecord {
1804 frontier: Antichain::new(),
1805 version: 42,
1806 }
1807 );
1808
1809 assert_err!(parse_progress_record(b"{\"frontier\":null}"));
1810 }
1811}