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