Skip to main content

mz_storage/source/
kafka.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::collections::BTreeMap;
11use std::collections::btree_map::Entry;
12use std::str::{self};
13use std::sync::Arc;
14use std::thread;
15use std::time::Duration;
16
17use anyhow::anyhow;
18use chrono::{DateTime, NaiveDateTime};
19use differential_dataflow::{AsCollection, Hashable};
20use futures::StreamExt;
21use itertools::Itertools;
22use maplit::btreemap;
23use mz_kafka_util::client::{
24    GetPartitionsError, MzClientContext, PartitionId, TunnelingClientContext, get_partitions,
25};
26use mz_ore::assert_none;
27use mz_ore::cast::CastFrom;
28use mz_ore::error::ErrorExt;
29use mz_ore::future::InTask;
30use mz_ore::iter::IteratorExt;
31use mz_repr::adt::timestamp::CheckedTimestamp;
32use mz_repr::{Datum, Diff, GlobalId, Row, adt::jsonb::Jsonb};
33use mz_ssh_util::tunnel::SshTunnelStatus;
34use mz_storage_types::errors::{
35    ContextCreationError, DataflowError, SourceError, SourceErrorDetails,
36};
37use mz_storage_types::sources::kafka::{
38    KafkaMetadataKind, KafkaSourceConnection, KafkaTimestamp, RangeBound,
39};
40use mz_storage_types::sources::{MzOffset, SourceExport, SourceExportDetails, SourceTimestamp};
41use mz_timely_util::antichain::AntichainExt;
42use mz_timely_util::builder_async::{
43    Event, OperatorBuilder as AsyncOperatorBuilder, PressOnDropButton,
44};
45use mz_timely_util::containers::stack::FueledBuilder;
46use mz_timely_util::order::Partitioned;
47use rdkafka::consumer::base_consumer::PartitionQueue;
48use rdkafka::consumer::{BaseConsumer, Consumer, ConsumerContext};
49use rdkafka::error::KafkaError;
50use rdkafka::message::{BorrowedMessage, Headers};
51use rdkafka::statistics::Statistics;
52use rdkafka::topic_partition_list::Offset;
53use rdkafka::{ClientContext, Message, TopicPartitionList};
54use serde::{Deserialize, Serialize};
55use timely::PartialOrder;
56use timely::container::CapacityContainerBuilder;
57use timely::dataflow::channels::pact::Pipeline;
58use timely::dataflow::operators::Capability;
59use timely::dataflow::operators::core::Partition;
60use timely::dataflow::operators::vec::Broadcast;
61use timely::dataflow::{Scope, StreamVec};
62use timely::progress::Antichain;
63use timely::progress::Timestamp;
64use tokio::sync::{Notify, mpsc};
65use tracing::{error, info, trace};
66
67use crate::healthcheck::{HealthStatusMessage, HealthStatusUpdate, StatusNamespace};
68use crate::metrics::source::kafka::KafkaSourceMetrics;
69use crate::source::types::{FuelSize, Probe, SignaledFuture, SourceRender, StackedCollection};
70use crate::source::{RawSourceCreationConfig, SourceMessage, probe};
71use crate::statistics::SourceStatistics;
72
73#[derive(
74    Clone,
75    Debug,
76    Default,
77    PartialEq,
78    Eq,
79    PartialOrd,
80    Ord,
81    Serialize,
82    Deserialize
83)]
84struct HealthStatus {
85    kafka: Option<HealthStatusUpdate>,
86    ssh: Option<HealthStatusUpdate>,
87}
88
89impl HealthStatus {
90    fn kafka(update: HealthStatusUpdate) -> Self {
91        Self {
92            kafka: Some(update),
93            ssh: None,
94        }
95    }
96
97    fn ssh(update: HealthStatusUpdate) -> Self {
98        Self {
99            kafka: None,
100            ssh: Some(update),
101        }
102    }
103}
104
105/// Contains all information necessary to ingest data from Kafka
106pub struct KafkaSourceReader {
107    /// Name of the topic on which this source is backed on
108    topic_name: String,
109    /// Name of the source (will have format kafka-source-id)
110    source_name: String,
111    /// Source global ID
112    id: GlobalId,
113    /// Kafka consumer for this source
114    consumer: Arc<BaseConsumer<TunnelingClientContext<GlueConsumerContext>>>,
115    /// List of consumers. A consumer should be assigned per partition to guarantee fairness
116    partition_consumers: Vec<PartitionConsumer>,
117    /// Worker ID
118    worker_id: usize,
119    /// Total count of workers
120    worker_count: usize,
121    /// The most recently read offset for each partition known to this source
122    /// reader by output-index. An offset of -1 indicates that no prior message
123    /// has been read for the given partition.
124    last_offsets: BTreeMap<usize, BTreeMap<PartitionId, i64>>,
125    /// The offset to start reading from for each partition.
126    start_offsets: BTreeMap<PartitionId, i64>,
127    /// Channel to receive Kafka statistics JSON blobs from the stats callback.
128    stats_rx: crossbeam_channel::Receiver<Jsonb>,
129    /// A handle to the partition specific metrics
130    partition_metrics: KafkaSourceMetrics,
131    /// Per partition capabilities used to produce messages
132    partition_capabilities: BTreeMap<PartitionId, PartitionCapability>,
133}
134
135struct PartitionCapability {
136    /// The capability of the data produced
137    data: Capability<KafkaTimestamp>,
138}
139
140/// The high/low watermark offsets of a Kafka partition.
141///
142/// This is the offset of either the first available or the latest message in the topic/partition
143///  available for consumption + 1.
144type PartitionWatermark = u64;
145
146/// Processes `resume_uppers` stream updates, committing them upstream and
147/// storing them in the `progress_statistics` to be emitted later.
148pub struct KafkaResumeUpperProcessor {
149    config: RawSourceCreationConfig,
150    topic_name: String,
151    consumer: Arc<BaseConsumer<TunnelingClientContext<GlueConsumerContext>>>,
152    statistics: Vec<SourceStatistics>,
153}
154
155/// Computes whether this worker is responsible for consuming a partition. It assigns partitions to
156/// workers in a round-robin fashion, starting at an arbitrary worker based on the hash of the
157/// source id.
158fn responsible_for_pid(config: &RawSourceCreationConfig, pid: i32) -> bool {
159    let pid = usize::try_from(pid).expect("positive pid");
160    ((config.responsible_worker(config.id) + pid) % config.worker_count) == config.worker_id
161}
162
163struct SourceOutputInfo {
164    id: GlobalId,
165    output_index: usize,
166    resume_upper: Antichain<KafkaTimestamp>,
167    metadata_columns: Vec<KafkaMetadataKind>,
168}
169
170impl SourceRender for KafkaSourceConnection {
171    // TODO(petrosagg): The type used for the partition (RangeBound<PartitionId>) doesn't need to
172    // be so complicated and we could instead use `Partitioned<PartitionId, Option<u64>>` where all
173    // ranges are inclusive and a time of `None` signifies that a particular partition is not
174    // present. This requires an shard migration of the remap shard.
175    type Time = KafkaTimestamp;
176
177    const STATUS_NAMESPACE: StatusNamespace = StatusNamespace::Kafka;
178
179    fn render<'scope>(
180        self,
181        scope: Scope<'scope, KafkaTimestamp>,
182        config: &RawSourceCreationConfig,
183        resume_uppers: impl futures::Stream<Item = Antichain<KafkaTimestamp>> + 'static,
184        start_signal: impl std::future::Future<Output = ()> + 'static,
185    ) -> (
186        BTreeMap<
187            GlobalId,
188            StackedCollection<'scope, KafkaTimestamp, Result<SourceMessage, DataflowError>>,
189        >,
190        StreamVec<'scope, KafkaTimestamp, HealthStatusMessage>,
191        StreamVec<'scope, KafkaTimestamp, Probe<KafkaTimestamp>>,
192        Vec<PressOnDropButton>,
193    ) {
194        let (metadata, probes, metadata_token) =
195            render_metadata_fetcher(scope, self.clone(), config.clone());
196        let (data, health, reader_token) = render_reader(
197            scope,
198            self,
199            config.clone(),
200            resume_uppers,
201            metadata,
202            start_signal,
203        );
204
205        let partition_count = u64::cast_from(config.source_exports.len());
206        let data_streams: Vec<_> = data.inner.partition::<CapacityContainerBuilder<_>, _, _>(
207            partition_count,
208            |((output, data), time, diff)| {
209                let output = u64::cast_from(output);
210                (output, (data, time, diff))
211            },
212        );
213        let mut data_collections = BTreeMap::new();
214        for (id, data_stream) in config.source_exports.keys().zip_eq(data_streams) {
215            data_collections.insert(*id, data_stream.as_collection());
216        }
217
218        (
219            data_collections,
220            health,
221            probes,
222            vec![metadata_token, reader_token],
223        )
224    }
225}
226
227/// Render the reader of a Kafka source.
228///
229/// The reader is responsible for polling the Kafka topic partitions for new messages, and
230/// transforming them into a `SourceMessage` collection.
231fn render_reader<'scope>(
232    scope: Scope<'scope, KafkaTimestamp>,
233    connection: KafkaSourceConnection,
234    config: RawSourceCreationConfig,
235    resume_uppers: impl futures::Stream<Item = Antichain<KafkaTimestamp>> + 'static,
236    metadata_stream: StreamVec<'scope, KafkaTimestamp, (mz_repr::Timestamp, MetadataUpdate)>,
237    start_signal: impl std::future::Future<Output = ()> + 'static,
238) -> (
239    StackedCollection<'scope, KafkaTimestamp, (usize, Result<SourceMessage, DataflowError>)>,
240    StreamVec<'scope, KafkaTimestamp, HealthStatusMessage>,
241    PressOnDropButton,
242) {
243    let name = format!("KafkaReader({})", config.id);
244    let mut builder = AsyncOperatorBuilder::new(name, scope.clone());
245
246    let (data_output, stream) = builder.new_output::<FueledBuilder<_>>();
247    let (health_output, health_stream) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
248
249    let mut metadata_input = builder.new_disconnected_input(metadata_stream.broadcast(), Pipeline);
250
251    let mut outputs = vec![];
252
253    // Contains the `SourceStatistics` entries for exports that require a snapshot.
254    let mut all_export_stats = vec![];
255    let mut snapshot_export_stats = vec![];
256    for (idx, (id, export)) in config.source_exports.iter().enumerate() {
257        let SourceExport {
258            details,
259            storage_metadata: _,
260            data_config: _,
261        } = export;
262        let resume_upper = Antichain::from_iter(
263            config
264                .source_resume_uppers
265                .get(id)
266                .expect("all source exports must be present in source resume uppers")
267                .iter()
268                .map(Partitioned::<RangeBound<PartitionId>, MzOffset>::decode_row),
269        );
270
271        let metadata_columns = match details {
272            SourceExportDetails::Kafka(details) => details
273                .metadata_columns
274                .iter()
275                .map(|(_name, kind)| kind.clone())
276                .collect::<Vec<_>>(),
277            _ => panic!("unexpected source export details: {:?}", details),
278        };
279
280        let statistics = config
281            .statistics
282            .get(id)
283            .expect("statistics have been initialized")
284            .clone();
285        // export requires snapshot
286        if resume_upper.as_ref() == &[Partitioned::minimum()] {
287            snapshot_export_stats.push(statistics.clone());
288        }
289        all_export_stats.push(statistics);
290
291        let output = SourceOutputInfo {
292            id: *id,
293            resume_upper,
294            output_index: idx,
295            metadata_columns,
296        };
297        outputs.push(output);
298    }
299
300    let busy_signal = Arc::clone(&config.busy_signal);
301    let button = builder.build(move |caps| {
302        SignaledFuture::new(busy_signal, async move {
303            let [mut data_cap, health_cap] = caps.try_into().unwrap();
304
305            let client_id = connection.client_id(
306                config.config.config_set(),
307                &config.config.connection_context,
308                config.id,
309            );
310            let group_id = connection.group_id(&config.config.connection_context, config.id);
311            let KafkaSourceConnection {
312                connection,
313                topic,
314                topic_metadata_refresh_interval,
315                start_offsets,
316                metadata_columns: _,
317                // Exhaustive match protects against forgetting to apply an
318                // option. Ignored fields are justified below.
319                connection_id: _,   // not needed here
320                group_id_prefix: _, // used above via `connection.group_id`
321            } = connection;
322
323            info!(
324                source_id = config.id.to_string(),
325                worker_id = config.worker_id,
326                num_workers = config.worker_count,
327                "instantiating Kafka source reader at offsets {start_offsets:?}"
328            );
329
330            let (stats_tx, stats_rx) = crossbeam_channel::unbounded();
331            let notificator = Arc::new(Notify::new());
332
333            let consumer: Result<BaseConsumer<_>, _> = connection
334                .create_with_context(
335                    &config.config,
336                    GlueConsumerContext {
337                        notificator: Arc::clone(&notificator),
338                        stats_tx,
339                        inner: MzClientContext::default(),
340                    },
341                    &btreemap! {
342                        // Disable Kafka auto commit. We manually commit offsets
343                        // to Kafka once we have reclocked those offsets, so
344                        // that users can use standard Kafka tools for progress
345                        // tracking.
346                        "enable.auto.commit" => "false".into(),
347                        // Always begin ingest at 0 when restarted, even if Kafka
348                        // contains committed consumer read offsets
349                        "auto.offset.reset" => "earliest".into(),
350                        // Use the user-configured topic metadata refresh
351                        // interval.
352                        "topic.metadata.refresh.interval.ms" =>
353                            topic_metadata_refresh_interval
354                            .as_millis()
355                            .to_string(),
356                        // TODO: document the rationale for this.
357                        "fetch.message.max.bytes" => "134217728".into(),
358                        // Consumer group ID, which may have been overridden by
359                        // the user. librdkafka requires this, and we use offset
360                        // committing to provide a way for users to monitor
361                        // ingest progress, though we do not rely on the
362                        // committed offsets for any functionality.
363                        "group.id" => group_id.clone(),
364                        // Allow Kafka monitoring tools to identify this
365                        // consumer.
366                        "client.id" => client_id.clone(),
367                    },
368                    InTask::Yes,
369                )
370                .await;
371
372            let consumer = match consumer {
373                Ok(consumer) => Arc::new(consumer),
374                Err(e) => {
375                    let update = HealthStatusUpdate::halting(
376                        format!(
377                            "failed creating kafka reader consumer: {}",
378                            e.display_with_causes()
379                        ),
380                        None,
381                    );
382                    health_output.give(
383                        &health_cap,
384                        HealthStatusMessage {
385                            id: None,
386                            namespace: if matches!(e, ContextCreationError::Ssh(_)) {
387                                StatusNamespace::Ssh
388                            } else {
389                                StatusNamespace::Kafka
390                            },
391                            update: update.clone(),
392                        },
393                    );
394                    for (output, update) in outputs.iter().repeat_clone(update) {
395                        health_output.give(
396                            &health_cap,
397                            HealthStatusMessage {
398                                id: Some(output.id),
399                                namespace: if matches!(e, ContextCreationError::Ssh(_)) {
400                                    StatusNamespace::Ssh
401                                } else {
402                                    StatusNamespace::Kafka
403                                },
404                                update,
405                            },
406                        );
407                    }
408                    // IMPORTANT: wedge forever until the `SuspendAndRestart` is processed.
409                    // Returning would incorrectly present to the remap operator as progress to the
410                    // empty frontier which would be incorrectly recorded to the remap shard.
411                    std::future::pending::<()>().await;
412                    unreachable!("pending future never returns");
413                }
414            };
415
416            // Start offsets is a map from partition to the next offset to read from.
417            let mut start_offsets: BTreeMap<_, u64> = start_offsets
418                .clone()
419                .into_iter()
420                .filter(|(pid, _offset)| responsible_for_pid(&config, *pid))
421                .map(|(pid, offset)| (pid, u64::try_from(offset).expect("start offsets must be non-negative and fit into u64")))
422                .collect();
423
424            let mut partition_capabilities = BTreeMap::new();
425            let mut max_pid = None;
426            let resume_upper = Antichain::from_iter(
427                outputs
428                    .iter()
429                    .map(|output| output.resume_upper.clone())
430                    .flatten(),
431            );
432
433            tracing::info!(
434                source_id = config.id.to_string(),
435                worker_id = config.worker_id,
436                num_workers = config.worker_count,
437                "Kafka source reader starting rehydration with resume upper: {resume_upper:?} and start offsets: {start_offsets:?}"
438            );
439
440            for ts in resume_upper.elements() {
441                if let Some(pid) = ts.interval().singleton() {
442                    let pid = pid.unwrap_exact();
443                    max_pid = std::cmp::max(max_pid, Some(*pid));
444
445                    if responsible_for_pid(&config, *pid) {
446                        let restored_offset = ts.timestamp().offset;
447                        if let Some(start_offset) = start_offsets.get_mut(pid) {
448                            *start_offset = std::cmp::max(restored_offset, *start_offset);
449                        } else {
450                            start_offsets.insert(*pid, restored_offset);
451                        }
452
453                        let part_ts = Partitioned::new_singleton(
454                            RangeBound::exact(*pid),
455                            ts.timestamp().clone(),
456                        );
457                        let part_cap = PartitionCapability {
458                            data: data_cap.delayed(&part_ts),
459                        };
460                        partition_capabilities.insert(*pid, part_cap);
461                    }
462                }
463            }
464            let lower = max_pid
465                .map(RangeBound::after)
466                .unwrap_or(RangeBound::NegInfinity);
467            let future_ts =
468                Partitioned::new_range(lower, RangeBound::PosInfinity, MzOffset::from(0));
469            data_cap.downgrade(&future_ts);
470
471            if mz_storage_types::dyncfgs::KAFKA_LOW_WATERMARK_CHECK
472                .get(config.config.config_set())
473            {
474                let low_watermarks = fetch_partition_info(
475                    &consumer,
476                    topic.as_str(),
477                    config
478                        .config
479                        .parameters
480                        .kafka_timeout_config
481                        .fetch_metadata_timeout,
482                    Offset::Beginning, // fetch the low watermark
483                )
484                .unwrap_or_else(|e| {
485                    tracing::warn!(
486                        source_id = config.id.to_string(),
487                        worker_id = config.worker_id,
488                        num_workers = config.worker_count,
489                        "Failed to fetch watermarks for topic {topic}: {e}"
490                    );
491                    let update = HealthStatusUpdate::stalled(
492                        format!("Failed to fetch watermarks for topic {topic}: {e}"),
493                        None,
494                    );
495                    health_output.give(
496                        &health_cap,
497                        HealthStatusMessage {
498                            id: None,
499                            namespace: StatusNamespace::Kafka,
500                            update: update.clone(),
501                        },
502                    );
503                    for (output, update) in outputs.iter().repeat_clone(update) {
504                        health_output.give(
505                            &health_cap,
506                            HealthStatusMessage {
507                                id: Some(output.id),
508                                namespace: StatusNamespace::Kafka,
509                                update,
510                            },
511                        );
512                    }
513                    let ssh_update = match consumer.client().context().tunnel_status() {
514                        SshTunnelStatus::Running => HealthStatusUpdate::running(),
515                        SshTunnelStatus::Errored(e) => HealthStatusUpdate::stalled(e, None),
516                    };
517                    health_output.give(
518                        &health_cap,
519                        HealthStatusMessage {
520                            id: None,
521                            namespace: StatusNamespace::Ssh,
522                            update: ssh_update.clone(),
523                        },
524                    );
525                    for (output, ssh_update) in outputs.iter().repeat_clone(ssh_update) {
526                        health_output.give(
527                            &health_cap,
528                            HealthStatusMessage {
529                                id: Some(output.id),
530                                namespace: StatusNamespace::Ssh,
531                                update: ssh_update,
532                            },
533                        );
534                    }
535                    if let GetPartitionsError::TopicDoesNotExist = e {
536                        // If the topic doesn't exist, that is a definite error
537                        let error = Err(SourceError {
538                            error: SourceErrorDetails::Initialization(e.to_string().into()),
539                            hint: None,
540                        }
541                        .into());
542                        let time = data_cap.time().clone();
543                        for (output, error) in
544                            outputs.iter().map(|o| o.output_index).repeat_clone(error)
545                        {
546                            let update = ((output, error), time.clone(), Diff::ONE);
547                            data_output.give(&data_cap, update);
548                        }
549                    }
550                    BTreeMap::new()
551                });
552                for (pid, lwm) in &low_watermarks {
553                    if responsible_for_pid(&config, *pid) {
554                        // If a start offset exists for this partition, then either the user specified it
555                        // or we restored it from the resume upper. In either case, if the low watermark is
556                        // greater than the start offset, we know for certain that the offset we need to
557                        // start at has been compacted away or dropped from retention by Kafka. If there
558                        // is no start offset, then we set it to the low watermark and start consuming from
559                        // there, assuming the user doesn't care about the messages that have been compacted away.
560                        if let Some(start_offset) = start_offsets.get_mut(pid) {
561                            tracing::info!(
562                                source_id = config.id.to_string(),
563                                worker_id = config.worker_id,
564                                num_workers = config.worker_count,
565                                "restored offset {start_offset} for topic {topic} partition {pid} with low watermark {lwm}"
566                            );
567                            if lwm > start_offset {
568                                tracing::error!(
569                                    source_id = config.id.to_string(),
570                                    worker_id = config.worker_id,
571                                    num_workers = config.worker_count,
572                                    "start offset and resume upper {start_offset} for topic {topic} \
573                                    partition {pid} is behind the low watermark {lwm}. This likely \
574                                    means that the offsets have been compacted away by Kafka."
575                                );
576                                let err_str = format!(
577                                    "Low watermark {lwm} of kafka topic {topic} partition {pid} \
578                                    is past the start offset/resume upper: {start_offset} \
579                                    This likely means that the offsets have been compacted away \
580                                    by Kafka. Please consider setting a higher start offset or \
581                                    adjusting your retention policies to prevent this.",
582                                );
583
584                                let update = HealthStatusUpdate::stalled(
585                                    err_str.clone(),
586                                    None,
587                                );
588                                health_output.give(
589                                    &health_cap,
590                                    HealthStatusMessage {
591                                        id: None,
592                                        namespace: StatusNamespace::Kafka,
593                                        update: update.clone(),
594                                    },
595                                );
596                                let error = Err(
597                                    SourceError{
598                                        error:SourceErrorDetails::Initialization(err_str.into()),
599                                        hint: None,
600                                    }.into()
601                                );
602                                let time = data_cap.time().clone();
603                                for (output, error) in
604                                    outputs.iter().map(|o| o.output_index).repeat_clone(error)
605                                {
606                                    let update = ((output, error), time.clone(), Diff::ONE);
607                                    let size = update.fuel_size();
608                                    data_output
609                                        .give_fueled(&data_cap, update, size)
610                                        .await;
611                                }
612                                return;
613                            }
614                        } else {
615                            tracing::warn!(
616                                source_id = config.id.to_string(),
617                                worker_id = config.worker_id,
618                                num_workers = config.worker_count,
619                                "partition {pid} has a non-zero low watermark {lwm}, but no start offset or \
620                                resume upper was found for this partition. Setting start offset to low watermark"
621                            );
622                            start_offsets.insert(*pid, *lwm);
623                        }
624                    }
625                }
626            }
627
628            // Note that we wait for this AFTER we downgrade to the source `resume_upper`. This
629            // allows downstream operators (namely, the `reclock_operator`) to downgrade to the
630            // `resume_upper`, which is necessary for this basic form of backpressure to work.
631            start_signal.await;
632            info!(
633                source_id = config.id.to_string(),
634                worker_id = config.worker_id,
635                num_workers = config.worker_count,
636                "kafka worker noticed rehydration is finished, starting partition queues..."
637            );
638
639            let partition_ids = start_offsets.keys().copied().collect();
640            let offset_commit_metrics = config.metrics.get_offset_commit_metrics(config.id);
641            let start_offsets = start_offsets.iter().map(|(pid, offset)| (*pid, i64::try_from(*offset).expect("start offsets must fit into i64"))).collect();
642
643            let mut reader = KafkaSourceReader {
644                topic_name: topic.clone(),
645                source_name: config.name.clone(),
646                id: config.id,
647                partition_consumers: Vec::new(),
648                consumer: Arc::clone(&consumer),
649                worker_id: config.worker_id,
650                worker_count: config.worker_count,
651                last_offsets: outputs
652                    .iter()
653                    .map(|output| (output.output_index, BTreeMap::new()))
654                    .collect(),
655                start_offsets,
656                stats_rx,
657                partition_metrics: config.metrics.get_kafka_source_metrics(
658                    partition_ids,
659                    topic.clone(),
660                    config.id,
661                ),
662                partition_capabilities,
663            };
664
665            let offset_committer = KafkaResumeUpperProcessor {
666                config: config.clone(),
667                topic_name: topic.clone(),
668                consumer,
669                statistics: all_export_stats.clone(),
670            };
671
672            // Seed the progress metrics with `0` if we are snapshotting.
673            if !snapshot_export_stats.is_empty() {
674                if let Err(e) = offset_committer
675                    .process_frontier(resume_upper.clone())
676                    .await
677                {
678                    offset_commit_metrics.offset_commit_failures.inc();
679                    tracing::warn!(
680                        %e,
681                        "timely-{worker_id} source({source_id}) failed to commit offsets: resume_upper={upper}",
682                        worker_id = config.worker_id,
683                        source_id = config.id,
684                        upper = resume_upper.pretty()
685                    );
686                }
687                // Reset snapshot statistics for any exports that are not involved
688                // in this round of snapshotting. Those that are snapshotting this round will
689                // see updates as the snapshot commences.
690                for statistics in config.statistics.values() {
691                    statistics.set_snapshot_records_known(0);
692                    statistics.set_snapshot_records_staged(0);
693                }
694            }
695
696            let resume_uppers_process_loop = async move {
697                tokio::pin!(resume_uppers);
698                while let Some(frontier) = resume_uppers.next().await {
699                    if let Err(e) = offset_committer.process_frontier(frontier.clone()).await {
700                        offset_commit_metrics.offset_commit_failures.inc();
701                        tracing::warn!(
702                            %e,
703                            "timely-{worker_id} source({source_id}) failed to commit offsets: resume_upper={upper}",
704                            worker_id = config.worker_id,
705                            source_id = config.id,
706                            upper = frontier.pretty()
707                        );
708                    }
709                }
710                // During dataflow shutdown this loop can end due to the general chaos caused by
711                // dropping tokens as a means to shutdown. This call ensures this future never ends
712                // and we instead rely on this operator being dropped altogether when *its* token
713                // is dropped.
714                std::future::pending::<()>().await;
715            };
716            tokio::pin!(resume_uppers_process_loop);
717
718            let mut metadata_update: Option<MetadataUpdate> = None;
719            let mut snapshot_total = None;
720
721            let max_wait_time =
722                mz_storage_types::dyncfgs::KAFKA_POLL_MAX_WAIT.get(config.config.config_set());
723            loop {
724                // Wait for data or metadata events while also making progress with offset
725                // committing.
726                tokio::select! {
727                    // TODO(petrosagg): remove the timeout and rely purely on librdkafka waking us
728                    // up
729                    _ = tokio::time::timeout(max_wait_time, notificator.notified()) => {},
730
731                    _ = metadata_input.ready() => {
732                        // Collect all pending updates, then only keep the most recent one.
733                        let mut updates = Vec::new();
734                        while let Some(event) = metadata_input.next_sync() {
735                            if let Event::Data(_, mut data) = event {
736                                updates.append(&mut data);
737                            }
738                        }
739                        metadata_update = updates
740                            .into_iter()
741                            .max_by_key(|(ts, _)| *ts)
742                            .map(|(_, update)| update);
743                    }
744
745                    // This future is not cancel safe but we are only passing a reference to it in
746                    // the select! loop so the future stays on the stack and never gets cancelled
747                    // until the end of the function.
748                    _ = resume_uppers_process_loop.as_mut() => {},
749                }
750
751                match metadata_update.take() {
752                    Some(MetadataUpdate::Partitions(partitions)) => {
753                        let max_pid = partitions.keys().last().cloned();
754                        let lower = max_pid
755                            .map(RangeBound::after)
756                            .unwrap_or(RangeBound::NegInfinity);
757                        let future_ts = Partitioned::new_range(
758                            lower,
759                            RangeBound::PosInfinity,
760                            MzOffset::from(0),
761                        );
762
763                        let mut offset_known = 0;
764                        for (&pid, &high_watermark) in &partitions {
765                            if responsible_for_pid(&config, pid) {
766                                offset_known += high_watermark;
767                                reader.ensure_partition(pid);
768                                if let Entry::Vacant(entry) =
769                                    reader.partition_capabilities.entry(pid)
770                                {
771                                    let start_offset = match reader.start_offsets.get(&pid) {
772                                        Some(&offset) => offset.try_into().unwrap(),
773                                        None => 0u64,
774                                    };
775                                    let part_since_ts = Partitioned::new_singleton(
776                                        RangeBound::exact(pid),
777                                        MzOffset::from(start_offset),
778                                    );
779
780                                    entry.insert(PartitionCapability {
781                                        data: data_cap.delayed(&part_since_ts),
782                                    });
783                                }
784                            }
785                        }
786
787                        // If we are snapshotting, record our first set of partitions as the snapshot
788                        // size.
789                        if !snapshot_export_stats.is_empty() && snapshot_total.is_none() {
790                            // Note that we want to represent the _number of offsets_, which
791                            // means the watermark's frontier semantics is correct, without
792                            // subtracting (Kafka offsets start at 0).
793                            snapshot_total = Some(offset_known);
794                        }
795
796                        // Clear all the health namespaces we know about.
797                        // Note that many kafka sources's don't have an ssh tunnel, but the
798                        // `health_operator` handles this fine.
799                        for output in &outputs {
800                            for namespace in [StatusNamespace::Kafka, StatusNamespace::Ssh] {
801                                health_output.give(
802                                    &health_cap,
803                                    HealthStatusMessage {
804                                        id: Some(output.id),
805                                        namespace,
806                                        update: HealthStatusUpdate::running(),
807                                    },
808                                );
809                            }
810                        }
811                        for namespace in [StatusNamespace::Kafka, StatusNamespace::Ssh] {
812                            health_output.give(
813                                &health_cap,
814                                HealthStatusMessage {
815                                    id: None,
816                                    namespace,
817                                    update: HealthStatusUpdate::running(),
818                                },
819                            );
820                        }
821
822                        for export_stat in all_export_stats.iter() {
823                            export_stat.set_offset_known(offset_known);
824                        }
825
826                        data_cap.downgrade(&future_ts);
827                    }
828                    Some(MetadataUpdate::TransientError(status)) => {
829                        if let Some(update) = status.kafka {
830                            health_output.give(
831                                &health_cap,
832                                HealthStatusMessage {
833                                    id: None,
834                                    namespace: StatusNamespace::Kafka,
835                                    update: update.clone(),
836                                },
837                            );
838                            for (output, update) in outputs.iter().repeat_clone(update) {
839                                health_output.give(
840                                    &health_cap,
841                                    HealthStatusMessage {
842                                        id: Some(output.id),
843                                        namespace: StatusNamespace::Kafka,
844                                        update,
845                                    },
846                                );
847                            }
848                        }
849                        if let Some(update) = status.ssh {
850                            health_output.give(
851                                &health_cap,
852                                HealthStatusMessage {
853                                    id: None,
854                                    namespace: StatusNamespace::Ssh,
855                                    update: update.clone(),
856                                },
857                            );
858                            for (output, update) in outputs.iter().repeat_clone(update) {
859                                health_output.give(
860                                    &health_cap,
861                                    HealthStatusMessage {
862                                        id: Some(output.id),
863                                        namespace: StatusNamespace::Ssh,
864                                        update,
865                                    },
866                                );
867                            }
868                        }
869                    }
870                    Some(MetadataUpdate::DefiniteError(error)) => {
871                        health_output.give(
872                            &health_cap,
873                            HealthStatusMessage {
874                                id: None,
875                                namespace: StatusNamespace::Kafka,
876                                update: HealthStatusUpdate::stalled(
877                                    error.to_string(),
878                                    None,
879                                ),
880                            },
881                        );
882                        let error = Err(error.into());
883                        let time = data_cap.time().clone();
884                        for (output, error) in
885                            outputs.iter().map(|o| o.output_index).repeat_clone(error)
886                        {
887                            let update = ((output, error), time, Diff::ONE);
888                            let size = update.fuel_size();
889                            data_output
890                                .give_fueled(&data_cap, update, size)
891                                .await;
892                        }
893
894                        return;
895                    }
896                    None => {}
897                }
898
899                // Poll the consumer once. We split the consumer's partitions out into separate
900                // queues and poll those individually, but it's still necessary to drive logic that
901                // consumes from rdkafka's internal event queue, such as statistics callbacks.
902                //
903                // Additionally, assigning topics and splitting them off into separate queues is
904                // not atomic, so we expect to see at least some messages to show up when polling
905                // the consumer directly.
906                while let Some(result) = reader.consumer.poll(Duration::from_secs(0)) {
907                    match result {
908                        Err(e) => {
909                            let error = format!(
910                                "kafka error when polling consumer for source: {} topic: {} : {}",
911                                reader.source_name, reader.topic_name, e
912                            );
913                            let status = HealthStatusUpdate::stalled(error, None);
914                            health_output.give(
915                                &health_cap,
916                                HealthStatusMessage {
917                                    id: None,
918                                    namespace: StatusNamespace::Kafka,
919                                    update: status.clone(),
920                                },
921                            );
922                            for (output, status) in outputs.iter().repeat_clone(status) {
923                                health_output.give(
924                                    &health_cap,
925                                    HealthStatusMessage {
926                                        id: Some(output.id),
927                                        namespace: StatusNamespace::Kafka,
928                                        update: status,
929                                    },
930                                );
931                            }
932                        }
933                        Ok(message) => {
934                            let output_messages = outputs
935                                .iter()
936                                .map(|output| {
937                                    let (message, ts) = construct_source_message(
938                                        &message,
939                                        &output.metadata_columns,
940                                    );
941                                    (output.output_index, message, ts)
942                                })
943                                // This vec allocation is required to allow obtaining a `&mut`
944                                // on `reader` for the `reader.handle_message` call in the
945                                // loop below since  `message` is borrowed from `reader`.
946                                .collect::<Vec<_>>();
947                            for (output_index, message, ts) in output_messages {
948                                if let Some((msg, time, diff)) =
949                                    reader.handle_message(message, ts, &output_index)
950                                {
951                                    let pid = time.interval().singleton().unwrap().unwrap_exact();
952                                    let part_cap = &reader.partition_capabilities[pid].data;
953                                    let msg = msg.map_err(|e| {
954                                        DataflowError::SourceError(Box::new(SourceError {
955                                            error: SourceErrorDetails::Other(e.to_string().into()),
956                                            hint: None,
957                                        }))
958                                    });
959                                    let update = ((output_index, msg), time, diff);
960                                    let size = update.fuel_size();
961                                    data_output
962                                        .give_fueled(part_cap, update, size)
963                                        .await;
964                                }
965                            }
966                        }
967                    }
968                }
969
970                reader.update_stats();
971
972                // Take the consumers temporarily to get around borrow checker errors
973                let mut consumers = std::mem::take(&mut reader.partition_consumers);
974                for consumer in consumers.iter_mut() {
975                    let pid = consumer.pid();
976                    // We want to make sure the rest of the actions in the outer loops get
977                    // a chance to run. If rdkafka keeps pumping data at us we might find
978                    // ourselves in a situation where we keep dumping data into the
979                    // dataflow without signaling progress. For this reason we consume at most
980                    // 10k messages from each partition and go around the loop.
981                    let mut partition_exhausted = false;
982                    for _ in 0..10_000 {
983                        let Some(message) = consumer.get_next_message().transpose() else {
984                            partition_exhausted = true;
985                            break;
986                        };
987
988                        for output in outputs.iter() {
989                            let message = match &message {
990                                Ok((msg, pid)) => {
991                                    let (msg, ts) =
992                                        construct_source_message(msg, &output.metadata_columns);
993                                    assert_eq!(*pid, ts.0);
994                                    Ok(reader.handle_message(msg, ts, &output.output_index))
995                                }
996                                Err(err) => Err(err),
997                            };
998                            match message {
999                                Ok(Some((msg, time, diff))) => {
1000                                    let pid = time.interval().singleton().unwrap().unwrap_exact();
1001                                    let part_cap = &reader.partition_capabilities[pid].data;
1002                                    let msg = msg.map_err(|e| {
1003                                        DataflowError::SourceError(Box::new(SourceError {
1004                                            error: SourceErrorDetails::Other(e.to_string().into()),
1005                                            hint: None,
1006                                        }))
1007                                    });
1008                                    let update =
1009                                        ((output.output_index, msg), time, diff);
1010                                    let size = update.fuel_size();
1011                                    data_output
1012                                        .give_fueled(part_cap, update, size)
1013                                        .await;
1014                                }
1015                                // The message was from an offset we've already seen.
1016                                Ok(None) => continue,
1017                                Err(err) => {
1018                                    let last_offset = reader
1019                                        .last_offsets
1020                                        .get(&output.output_index)
1021                                        .expect("output known to be installed")
1022                                        .get(&pid)
1023                                        .expect("partition known to be installed");
1024
1025                                    let status = HealthStatusUpdate::stalled(
1026                                        format!(
1027                                            "error consuming from source: {} topic: {topic}:\
1028                                             partition: {pid} last processed offset:\
1029                                             {last_offset} : {err}",
1030                                            config.name
1031                                        ),
1032                                        None,
1033                                    );
1034                                    health_output.give(
1035                                        &health_cap,
1036                                        HealthStatusMessage {
1037                                            id: None,
1038                                            namespace: StatusNamespace::Kafka,
1039                                            update: status.clone(),
1040                                        },
1041                                    );
1042                                    health_output.give(
1043                                        &health_cap,
1044                                        HealthStatusMessage {
1045                                            id: Some(output.id),
1046                                            namespace: StatusNamespace::Kafka,
1047                                            update: status,
1048                                        },
1049                                    );
1050                                }
1051                            }
1052                        }
1053                    }
1054                    if !partition_exhausted {
1055                        notificator.notify_one();
1056                    }
1057                }
1058                // We can now put them back
1059                assert!(reader.partition_consumers.is_empty());
1060                reader.partition_consumers = consumers;
1061
1062                let positions = reader.consumer.position().unwrap();
1063                let topic_positions = positions.elements_for_topic(&reader.topic_name);
1064                let mut snapshot_staged = 0;
1065
1066                for position in topic_positions {
1067                    // The offset begins in the `Offset::Invalid` state in which case we simply
1068                    // skip this partition.
1069                    if let Offset::Offset(offset) = position.offset() {
1070                        let pid = position.partition();
1071                        let upper_offset = MzOffset::from(u64::try_from(offset).unwrap());
1072                        let upper =
1073                            Partitioned::new_singleton(RangeBound::exact(pid), upper_offset);
1074
1075                        let part_cap = reader.partition_capabilities.get_mut(&pid).unwrap();
1076                        match part_cap.data.try_downgrade(&upper) {
1077                            Ok(()) => {
1078                                if !snapshot_export_stats.is_empty() {
1079                                    // The `.position()` of the consumer represents what offset we have
1080                                    // read up to.
1081                                    snapshot_staged += offset.try_into().unwrap_or(0u64);
1082                                    // This will always be `Some` at this point.
1083                                    if let Some(snapshot_total) = snapshot_total {
1084                                        // We will eventually read past the snapshot total, so we need
1085                                        // to bound it here.
1086                                        snapshot_staged =
1087                                            std::cmp::min(snapshot_staged, snapshot_total);
1088                                    }
1089                                }
1090                            }
1091                            Err(_) => {
1092                                // If we can't downgrade, it means we have already seen this offset.
1093                                // This is expected and we can safely ignore it.
1094                                info!(
1095                                    source_id = config.id.to_string(),
1096                                    worker_id = config.worker_id,
1097                                    num_workers = config.worker_count,
1098                                    "kafka source frontier downgrade skipped due to already \
1099                                     seen offset: {:?}",
1100                                    upper
1101                                );
1102                            }
1103                        };
1104
1105                    }
1106                }
1107
1108                if let (Some(snapshot_total), true) =
1109                    (snapshot_total, !snapshot_export_stats.is_empty())
1110                {
1111                    for export_stat in snapshot_export_stats.iter() {
1112                        export_stat.set_snapshot_records_known(snapshot_total);
1113                        export_stat.set_snapshot_records_staged(snapshot_staged);
1114                    }
1115                    if snapshot_total == snapshot_staged {
1116                        snapshot_export_stats.clear();
1117                    }
1118                }
1119            }
1120        })
1121    });
1122
1123    (
1124        stream.as_collection(),
1125        health_stream,
1126        button.press_on_drop(),
1127    )
1128}
1129
1130impl KafkaResumeUpperProcessor {
1131    async fn process_frontier(
1132        &self,
1133        frontier: Antichain<KafkaTimestamp>,
1134    ) -> Result<(), anyhow::Error> {
1135        use rdkafka::consumer::CommitMode;
1136
1137        // Generate a list of partitions that this worker is responsible for
1138        let mut offsets = vec![];
1139        let mut offset_committed = 0;
1140        for ts in frontier.iter() {
1141            if let Some(pid) = ts.interval().singleton() {
1142                let pid = pid.unwrap_exact();
1143                if responsible_for_pid(&self.config, *pid) {
1144                    offsets.push((pid.clone(), *ts.timestamp()));
1145
1146                    // Note that we do not subtract 1 from the frontier. Imagine
1147                    // that frontier is 2 for this pid. That means we have
1148                    // full processed offset 0 and offset 1, which means we have
1149                    // processed _2_ offsets.
1150                    offset_committed += ts.timestamp().offset;
1151                }
1152            }
1153        }
1154
1155        for export_stat in self.statistics.iter() {
1156            export_stat.set_offset_committed(offset_committed);
1157        }
1158
1159        if !offsets.is_empty() {
1160            let mut tpl = TopicPartitionList::new();
1161            for (pid, offset) in offsets {
1162                let offset_to_commit =
1163                    Offset::Offset(offset.offset.try_into().expect("offset to be vald i64"));
1164                tpl.add_partition_offset(&self.topic_name, pid, offset_to_commit)
1165                    .expect("offset known to be valid");
1166            }
1167            let consumer = Arc::clone(&self.consumer);
1168            mz_ore::task::spawn_blocking(
1169                || format!("source({}) kafka offset commit", self.config.id),
1170                move || consumer.commit(&tpl, CommitMode::Sync),
1171            )
1172            .await?;
1173        }
1174        Ok(())
1175    }
1176}
1177
1178impl KafkaSourceReader {
1179    /// Ensures that a partition queue for `pid` exists.
1180    fn ensure_partition(&mut self, pid: PartitionId) {
1181        if self.last_offsets.is_empty() {
1182            tracing::info!(
1183                source_id = %self.id,
1184                worker_id = %self.worker_id,
1185                "kafka source does not have any outputs, not creating partition queue");
1186
1187            return;
1188        }
1189        for last_offsets in self.last_offsets.values() {
1190            // early exit if we've already inserted this partition
1191            if last_offsets.contains_key(&pid) {
1192                return;
1193            }
1194        }
1195
1196        let start_offset = self.start_offsets.get(&pid).copied().unwrap_or(0);
1197        self.create_partition_queue(pid, Offset::Offset(start_offset));
1198
1199        for last_offsets in self.last_offsets.values_mut() {
1200            let prev = last_offsets.insert(pid, start_offset - 1);
1201            assert_none!(prev);
1202        }
1203    }
1204
1205    /// Creates a new partition queue for `partition_id`.
1206    fn create_partition_queue(&mut self, partition_id: PartitionId, initial_offset: Offset) {
1207        info!(
1208            source_id = self.id.to_string(),
1209            worker_id = self.worker_id,
1210            num_workers = self.worker_count,
1211            "activating Kafka queue for topic {}, partition {}",
1212            self.topic_name,
1213            partition_id,
1214        );
1215
1216        // Collect old partition assignments
1217        let tpl = self.consumer.assignment().unwrap();
1218        // Create list from assignments
1219        let mut partition_list = TopicPartitionList::new();
1220        for partition in tpl.elements_for_topic(&self.topic_name) {
1221            partition_list
1222                .add_partition_offset(partition.topic(), partition.partition(), partition.offset())
1223                .expect("offset known to be valid");
1224        }
1225        // Add new partition
1226        partition_list
1227            .add_partition_offset(&self.topic_name, partition_id, initial_offset)
1228            .expect("offset known to be valid");
1229        self.consumer
1230            .assign(&partition_list)
1231            .expect("assignment known to be valid");
1232
1233        // Since librdkafka v1.6.0, we need to recreate all partition queues
1234        // after every call to `self.consumer.assign`.
1235        let context = Arc::clone(self.consumer.context());
1236        for pc in &mut self.partition_consumers {
1237            pc.partition_queue = self
1238                .consumer
1239                .split_partition_queue(&self.topic_name, pc.pid)
1240                .expect("partition known to be valid");
1241            pc.partition_queue.set_nonempty_callback({
1242                let context = Arc::clone(&context);
1243                move || context.inner().activate()
1244            });
1245        }
1246
1247        let mut partition_queue = self
1248            .consumer
1249            .split_partition_queue(&self.topic_name, partition_id)
1250            .expect("partition known to be valid");
1251        partition_queue.set_nonempty_callback(move || context.inner().activate());
1252        self.partition_consumers
1253            .push(PartitionConsumer::new(partition_id, partition_queue));
1254        assert_eq!(
1255            self.consumer
1256                .assignment()
1257                .unwrap()
1258                .elements_for_topic(&self.topic_name)
1259                .len(),
1260            self.partition_consumers.len()
1261        );
1262    }
1263
1264    /// Read any statistics JSON blobs generated via the rdkafka statistics callback.
1265    fn update_stats(&mut self) {
1266        while let Ok(stats) = self.stats_rx.try_recv() {
1267            match serde_json::from_str::<Statistics>(&stats.to_string()) {
1268                Ok(statistics) => {
1269                    let topic = statistics.topics.get(&self.topic_name);
1270                    match topic {
1271                        Some(topic) => {
1272                            for (id, partition) in &topic.partitions {
1273                                self.partition_metrics
1274                                    .set_offset_max(*id, partition.hi_offset);
1275                            }
1276                        }
1277                        None => error!("No stats found for topic: {}", &self.topic_name),
1278                    }
1279                }
1280                Err(e) => {
1281                    error!("failed decoding librdkafka statistics JSON: {}", e);
1282                }
1283            }
1284        }
1285    }
1286
1287    /// Checks if the given message is viable for emission. This checks if the message offset is
1288    /// past the expected offset and returns None if it is not.
1289    fn handle_message(
1290        &mut self,
1291        message: Result<SourceMessage, KafkaHeaderParseError>,
1292        (partition, offset): (PartitionId, MzOffset),
1293        output_index: &usize,
1294    ) -> Option<(
1295        Result<SourceMessage, KafkaHeaderParseError>,
1296        KafkaTimestamp,
1297        Diff,
1298    )> {
1299        // Offsets are guaranteed to be 1) monotonically increasing *unless* there is
1300        // a network issue or a new partition added, at which point the consumer may
1301        // start processing the topic from the beginning, or we may see duplicate offsets
1302        // At all times, the guarantee : if we see offset x, we have seen all offsets [0,x-1]
1303        // that we are ever going to see holds.
1304        // Offsets are guaranteed to be contiguous when compaction is disabled. If compaction
1305        // is enabled, there may be gaps in the sequence.
1306        // If we see an "old" offset, we skip that message.
1307
1308        // Given the explicit consumer to partition assignment, we should never receive a message
1309        // for a partition for which we have no metadata
1310        assert!(
1311            self.last_offsets
1312                .get(output_index)
1313                .unwrap()
1314                .contains_key(&partition)
1315        );
1316
1317        let last_offset_ref = self
1318            .last_offsets
1319            .get_mut(output_index)
1320            .expect("output known to be installed")
1321            .get_mut(&partition)
1322            .expect("partition known to be installed");
1323
1324        let last_offset = *last_offset_ref;
1325        let offset_as_i64: i64 = offset.offset.try_into().expect("offset to be < i64::MAX");
1326        if offset_as_i64 <= last_offset {
1327            info!(
1328                source_id = self.id.to_string(),
1329                worker_id = self.worker_id,
1330                num_workers = self.worker_count,
1331                "kafka message before expected offset: \
1332                 source {} (reading topic {}, partition {}, output {}) \
1333                 received offset {} expected offset {:?}",
1334                self.source_name,
1335                self.topic_name,
1336                partition,
1337                output_index,
1338                offset.offset,
1339                last_offset + 1,
1340            );
1341            // We explicitly should not consume the message as we have already processed it.
1342            None
1343        } else {
1344            *last_offset_ref = offset_as_i64;
1345
1346            let ts = Partitioned::new_singleton(RangeBound::exact(partition), offset);
1347            Some((message, ts, Diff::ONE))
1348        }
1349    }
1350}
1351
1352fn construct_source_message(
1353    msg: &BorrowedMessage<'_>,
1354    metadata_columns: &[KafkaMetadataKind],
1355) -> (
1356    Result<SourceMessage, KafkaHeaderParseError>,
1357    (PartitionId, MzOffset),
1358) {
1359    let pid = msg.partition();
1360    let Ok(offset) = u64::try_from(msg.offset()) else {
1361        panic!(
1362            "got negative offset ({}) from otherwise non-error'd kafka message",
1363            msg.offset()
1364        );
1365    };
1366
1367    let mut metadata = Row::default();
1368    let mut packer = metadata.packer();
1369    for kind in metadata_columns {
1370        match kind {
1371            KafkaMetadataKind::Partition => packer.push(Datum::from(pid)),
1372            KafkaMetadataKind::Offset => packer.push(Datum::UInt64(offset)),
1373            KafkaMetadataKind::Timestamp => {
1374                let ts = msg
1375                    .timestamp()
1376                    .to_millis()
1377                    .expect("kafka sources always have upstream_time");
1378
1379                let d: Datum = DateTime::from_timestamp_millis(ts)
1380                    .and_then(|dt| {
1381                        let ct: Option<CheckedTimestamp<NaiveDateTime>> =
1382                            dt.naive_utc().try_into().ok();
1383                        ct
1384                    })
1385                    .into();
1386                packer.push(d)
1387            }
1388            KafkaMetadataKind::Header { key, use_bytes } => {
1389                match msg.headers() {
1390                    Some(headers) => {
1391                        let d = headers
1392                            .iter()
1393                            .filter(|header| header.key == key)
1394                            .last()
1395                            .map(|header| match header.value {
1396                                Some(v) => {
1397                                    if *use_bytes {
1398                                        Ok(Datum::Bytes(v))
1399                                    } else {
1400                                        match str::from_utf8(v) {
1401                                            Ok(str) => Ok(Datum::String(str)),
1402                                            Err(_) => Err(KafkaHeaderParseError::Utf8Error {
1403                                                key: key.clone(),
1404                                                raw: v.to_vec(),
1405                                            }),
1406                                        }
1407                                    }
1408                                }
1409                                None => Ok(Datum::Null),
1410                            })
1411                            .unwrap_or_else(|| {
1412                                Err(KafkaHeaderParseError::KeyNotFound { key: key.clone() })
1413                            });
1414                        match d {
1415                            Ok(d) => packer.push(d),
1416                            //abort with a definite error when the header is not found or cannot be parsed correctly
1417                            Err(err) => return (Err(err), (pid, offset.into())),
1418                        }
1419                    }
1420                    None => packer.push(Datum::Null),
1421                }
1422            }
1423            KafkaMetadataKind::Headers => {
1424                packer.push_list_with(|r| {
1425                    if let Some(headers) = msg.headers() {
1426                        for header in headers.iter() {
1427                            match header.value {
1428                                Some(v) => r.push_list_with(|record_row| {
1429                                    record_row.push(Datum::String(header.key));
1430                                    record_row.push(Datum::Bytes(v));
1431                                }),
1432                                None => r.push_list_with(|record_row| {
1433                                    record_row.push(Datum::String(header.key));
1434                                    record_row.push(Datum::Null);
1435                                }),
1436                            }
1437                        }
1438                    }
1439                });
1440            }
1441        }
1442    }
1443
1444    let key = match msg.key() {
1445        Some(bytes) => Row::pack([Datum::Bytes(bytes)]),
1446        None => Row::pack([Datum::Null]),
1447    };
1448    let value = match msg.payload() {
1449        Some(bytes) => Row::pack([Datum::Bytes(bytes)]),
1450        None => Row::pack([Datum::Null]),
1451    };
1452    (
1453        Ok(SourceMessage {
1454            key,
1455            value,
1456            metadata,
1457        }),
1458        (pid, offset.into()),
1459    )
1460}
1461
1462/// Wrapper around a partition containing the underlying consumer
1463struct PartitionConsumer {
1464    /// the partition id with which this consumer is associated
1465    pid: PartitionId,
1466    /// The underlying Kafka partition queue
1467    partition_queue: PartitionQueue<TunnelingClientContext<GlueConsumerContext>>,
1468}
1469
1470impl PartitionConsumer {
1471    /// Creates a new partition consumer from underlying Kafka consumer
1472    fn new(
1473        pid: PartitionId,
1474        partition_queue: PartitionQueue<TunnelingClientContext<GlueConsumerContext>>,
1475    ) -> Self {
1476        PartitionConsumer {
1477            pid,
1478            partition_queue,
1479        }
1480    }
1481
1482    /// Returns the next message to process for this partition (if any).
1483    ///
1484    /// The outer `Result` represents irrecoverable failures, the inner one can and will
1485    /// be transformed into empty values.
1486    ///
1487    /// The inner `Option` represents if there is a message to process.
1488    fn get_next_message(&self) -> Result<Option<(BorrowedMessage<'_>, PartitionId)>, KafkaError> {
1489        match self.partition_queue.poll(Duration::from_millis(0)) {
1490            Some(Ok(msg)) => Ok(Some((msg, self.pid))),
1491            Some(Err(err)) => Err(err),
1492            _ => Ok(None),
1493        }
1494    }
1495
1496    /// Return the partition id for this PartitionConsumer
1497    fn pid(&self) -> PartitionId {
1498        self.pid
1499    }
1500}
1501
1502/// An implementation of [`ConsumerContext`] that forwards statistics to the
1503/// worker
1504struct GlueConsumerContext {
1505    notificator: Arc<Notify>,
1506    stats_tx: crossbeam_channel::Sender<Jsonb>,
1507    inner: MzClientContext,
1508}
1509
1510impl ClientContext for GlueConsumerContext {
1511    fn stats_raw(&self, statistics: &[u8]) {
1512        match Jsonb::from_slice(statistics) {
1513            Ok(statistics) => {
1514                self.stats_tx
1515                    .send(statistics)
1516                    .expect("timely operator hung up while Kafka source active");
1517                self.activate();
1518            }
1519            Err(e) => error!("failed decoding librdkafka statistics JSON: {}", e),
1520        };
1521    }
1522
1523    // The shape of the rdkafka *Context traits require us to forward to the `MzClientContext`
1524    // implementation.
1525    fn log(&self, level: rdkafka::config::RDKafkaLogLevel, fac: &str, log_message: &str) {
1526        self.inner.log(level, fac, log_message)
1527    }
1528    fn error(&self, error: rdkafka::error::KafkaError, reason: &str) {
1529        self.inner.error(error, reason)
1530    }
1531}
1532
1533impl GlueConsumerContext {
1534    fn activate(&self) {
1535        self.notificator.notify_one();
1536    }
1537}
1538
1539impl ConsumerContext for GlueConsumerContext {}
1540
1541#[cfg(test)]
1542mod tests {
1543    use std::sync::Arc;
1544    use std::time::Duration;
1545
1546    use mz_kafka_util::client::create_new_client_config_simple;
1547    use rdkafka::consumer::{BaseConsumer, Consumer};
1548    use rdkafka::{Message, Offset, TopicPartitionList};
1549    use uuid::Uuid;
1550
1551    // Splitting off a partition queue with an `Offset` that is not `Offset::Beginning` seems to
1552    // lead to a race condition where sometimes we receive messages from polling the main consumer
1553    // instead of on the partition queue. This can be surfaced by running the test in a loop (in
1554    // the dataflow directory) using:
1555    //
1556    // cargo stress --lib --release source::kafka::tests::reproduce_kafka_queue_issue
1557    //
1558    // cargo-stress can be installed via `cargo install cargo-stress`
1559    //
1560    // You need to set up a topic "queue-test" with 1000 "hello" messages in it. Obviously, running
1561    // this test requires a running Kafka instance at localhost:9092.
1562    #[mz_ore::test]
1563    #[ignore]
1564    fn demonstrate_kafka_queue_race_condition() -> Result<(), anyhow::Error> {
1565        let topic_name = "queue-test";
1566        let pid = 0;
1567
1568        let mut kafka_config = create_new_client_config_simple();
1569        kafka_config.set("bootstrap.servers", "localhost:9092".to_string());
1570        kafka_config.set("enable.auto.commit", "false");
1571        kafka_config.set("group.id", Uuid::new_v4().to_string());
1572        kafka_config.set("fetch.message.max.bytes", "100");
1573        let consumer: BaseConsumer<_> = kafka_config.create()?;
1574
1575        let consumer = Arc::new(consumer);
1576
1577        let mut partition_list = TopicPartitionList::new();
1578        // Using Offset:Beginning here will work fine, only Offset:Offset(0) leads to the race
1579        // condition.
1580        partition_list.add_partition_offset(topic_name, pid, Offset::Offset(0))?;
1581
1582        consumer.assign(&partition_list)?;
1583
1584        let partition_queue = consumer
1585            .split_partition_queue(topic_name, pid)
1586            .expect("missing partition queue");
1587
1588        let expected_messages = 1_000;
1589
1590        let mut common_queue_count = 0;
1591        let mut partition_queue_count = 0;
1592
1593        loop {
1594            if let Some(msg) = consumer.poll(Duration::from_millis(0)) {
1595                match msg {
1596                    Ok(msg) => {
1597                        let _payload =
1598                            std::str::from_utf8(msg.payload().expect("missing payload"))?;
1599                        if partition_queue_count > 0 {
1600                            anyhow::bail!(
1601                                "Got message from common queue after we internally switched to partition queue."
1602                            );
1603                        }
1604
1605                        common_queue_count += 1;
1606                    }
1607                    Err(err) => anyhow::bail!("{}", err),
1608                }
1609            }
1610
1611            match partition_queue.poll(Duration::from_millis(0)) {
1612                Some(Ok(msg)) => {
1613                    let _payload = std::str::from_utf8(msg.payload().expect("missing payload"))?;
1614                    partition_queue_count += 1;
1615                }
1616                Some(Err(err)) => anyhow::bail!("{}", err),
1617                _ => (),
1618            }
1619
1620            if (common_queue_count + partition_queue_count) == expected_messages {
1621                break;
1622            }
1623        }
1624
1625        assert!(
1626            common_queue_count == 0,
1627            "Got {} out of {} messages from common queue. Partition queue: {}",
1628            common_queue_count,
1629            expected_messages,
1630            partition_queue_count
1631        );
1632
1633        Ok(())
1634    }
1635}
1636
1637/// Fetches the list of partitions and their corresponding high watermark.
1638fn fetch_partition_info<C: ConsumerContext>(
1639    consumer: &BaseConsumer<C>,
1640    topic: &str,
1641    fetch_timeout: Duration,
1642    offset_requested: Offset,
1643) -> Result<BTreeMap<PartitionId, PartitionWatermark>, GetPartitionsError> {
1644    let pids = get_partitions(consumer.client(), topic, fetch_timeout)?;
1645
1646    let mut offset_requests = TopicPartitionList::with_capacity(pids.len());
1647    for pid in pids {
1648        offset_requests.add_partition_offset(topic, pid, offset_requested)?;
1649    }
1650
1651    let offset_responses = consumer.offsets_for_times(offset_requests, fetch_timeout)?;
1652
1653    let mut result = BTreeMap::new();
1654    for entry in offset_responses.elements() {
1655        let offset = match entry.offset() {
1656            Offset::Offset(offset) => offset,
1657            offset => Err(anyhow!("unexpected high watermark offset: {offset:?}"))?,
1658        };
1659
1660        let pid = entry.partition();
1661        let watermark = offset.try_into().expect("invalid negative offset");
1662        result.insert(pid, watermark);
1663    }
1664
1665    Ok(result)
1666}
1667
1668/// An update produced by the metadata fetcher.
1669#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1670enum MetadataUpdate {
1671    /// The current IDs and high watermarks of all topic partitions.
1672    Partitions(BTreeMap<PartitionId, PartitionWatermark>),
1673    /// A transient error.
1674    ///
1675    /// Transient errors stall the source until their cause has been resolved.
1676    TransientError(HealthStatus),
1677    /// A definite error.
1678    ///
1679    /// Definite errors cannot be recovered from. They poison the source until the end of time.
1680    DefiniteError(SourceError),
1681}
1682
1683impl MetadataUpdate {
1684    /// Return the upstream frontier resulting from the metadata update, if any.
1685    fn upstream_frontier(&self) -> Option<Antichain<KafkaTimestamp>> {
1686        match self {
1687            Self::Partitions(partitions) => {
1688                let max_pid = partitions.keys().last().copied();
1689                let lower = max_pid
1690                    .map(RangeBound::after)
1691                    .unwrap_or(RangeBound::NegInfinity);
1692                let future_ts =
1693                    Partitioned::new_range(lower, RangeBound::PosInfinity, MzOffset::from(0));
1694
1695                let mut frontier = Antichain::from_elem(future_ts);
1696                for (pid, high_watermark) in partitions {
1697                    frontier.insert(Partitioned::new_singleton(
1698                        RangeBound::exact(*pid),
1699                        MzOffset::from(*high_watermark),
1700                    ));
1701                }
1702
1703                Some(frontier)
1704            }
1705            Self::DefiniteError(_) => Some(Antichain::new()),
1706            Self::TransientError(_) => None,
1707        }
1708    }
1709}
1710
1711#[derive(Debug, thiserror::Error)]
1712pub enum KafkaHeaderParseError {
1713    #[error("A header with key '{key}' was not found in the message headers")]
1714    KeyNotFound { key: String },
1715    #[error(
1716        "Found ill-formed byte sequence in header '{key}' that cannot be decoded as valid utf-8 (original bytes: {raw:x?})"
1717    )]
1718    Utf8Error { key: String, raw: Vec<u8> },
1719}
1720
1721/// Render the metadata fetcher of a Kafka source.
1722///
1723/// The metadata fetcher is a single-worker operator that is responsible for periodically fetching
1724/// the Kafka topic metadata (partition IDs and high watermarks) and making it available as a
1725/// Timely stream.
1726fn render_metadata_fetcher<'scope>(
1727    scope: Scope<'scope, KafkaTimestamp>,
1728    connection: KafkaSourceConnection,
1729    config: RawSourceCreationConfig,
1730) -> (
1731    StreamVec<'scope, KafkaTimestamp, (mz_repr::Timestamp, MetadataUpdate)>,
1732    StreamVec<'scope, KafkaTimestamp, Probe<KafkaTimestamp>>,
1733    PressOnDropButton,
1734) {
1735    let active_worker_id = usize::cast_from(config.id.hashed());
1736    let is_active_worker = active_worker_id % scope.peers() == scope.index();
1737
1738    let resume_upper = Antichain::from_iter(
1739        config
1740            .source_resume_uppers
1741            .values()
1742            .map(|uppers| uppers.iter().map(KafkaTimestamp::decode_row))
1743            .flatten(),
1744    );
1745
1746    let name = format!("KafkaMetadataFetcher({})", config.id);
1747    let mut builder = AsyncOperatorBuilder::new(name, scope.clone());
1748
1749    let (metadata_output, metadata_stream) =
1750        builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
1751    let (probe_output, probe_stream) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
1752
1753    let button = builder.build(move |caps| async move {
1754        if !is_active_worker {
1755            return;
1756        }
1757
1758        let [metadata_cap, probe_cap] = caps.try_into().unwrap();
1759
1760        let client_id = connection.client_id(
1761            config.config.config_set(),
1762            &config.config.connection_context,
1763            config.id,
1764        );
1765        let KafkaSourceConnection {
1766            connection,
1767            topic,
1768            topic_metadata_refresh_interval,
1769            ..
1770        } = connection;
1771
1772        let consumer: Result<BaseConsumer<_>, _> = connection
1773            .create_with_context(
1774                &config.config,
1775                MzClientContext::default(),
1776                &btreemap! {
1777                    // Use the user-configured topic metadata refresh
1778                    // interval.
1779                    "topic.metadata.refresh.interval.ms" =>
1780                        topic_metadata_refresh_interval
1781                        .as_millis()
1782                        .to_string(),
1783                    // Allow Kafka monitoring tools to identify this
1784                    // consumer.
1785                    "client.id" => format!("{client_id}-metadata"),
1786                },
1787                InTask::Yes,
1788            )
1789            .await;
1790
1791        let consumer = match consumer {
1792            Ok(consumer) => consumer,
1793            Err(e) => {
1794                let msg = format!(
1795                    "failed creating kafka metadata consumer: {}",
1796                    e.display_with_causes()
1797                );
1798                let status_update = HealthStatusUpdate::halting(msg, None);
1799                let status = match e {
1800                    ContextCreationError::Ssh(_) => HealthStatus::ssh(status_update),
1801                    _ => HealthStatus::kafka(status_update),
1802                };
1803                let error = MetadataUpdate::TransientError(status);
1804                let timestamp = (config.now_fn)().into();
1805                metadata_output.give(&metadata_cap, (timestamp, error));
1806
1807                // IMPORTANT: wedge forever until the `SuspendAndRestart` is processed.
1808                // Returning would incorrectly present to the remap operator as progress to the
1809                // empty frontier which would be incorrectly recorded to the remap shard.
1810                std::future::pending::<()>().await;
1811                unreachable!("pending future never returns");
1812            }
1813        };
1814
1815        let (tx, mut rx) = mpsc::unbounded_channel();
1816        spawn_metadata_thread(config, consumer, topic, tx);
1817
1818        let mut prev_upstream_frontier = resume_upper;
1819
1820        while let Some((timestamp, mut update)) = rx.recv().await {
1821            if prev_upstream_frontier.is_empty() {
1822                return;
1823            }
1824
1825            if let Some(upstream_frontier) = update.upstream_frontier() {
1826                // Topics are identified by name but it's possible that a user recreates a topic
1827                // with the same name. Ideally we'd want to catch all of these cases and
1828                // immediately error out the source, since the data is effectively gone.
1829                // Unfortunately this is not possible without something like KIP-516.
1830                //
1831                // The best we can do is check whether the upstream frontier regressed. This tells
1832                // us that the topic was recreated and now contains fewer offsets and/or fewer
1833                // partitions. Note that we are not able to detect topic recreation if neither of
1834                // the two are true.
1835                if !PartialOrder::less_equal(&prev_upstream_frontier, &upstream_frontier) {
1836                    let error = SourceError {
1837                        error: SourceErrorDetails::Other("topic was recreated".into()),
1838                        hint: None,
1839                    };
1840                    update = MetadataUpdate::DefiniteError(error);
1841                }
1842            }
1843
1844            if let Some(upstream_frontier) = update.upstream_frontier() {
1845                prev_upstream_frontier = upstream_frontier.clone();
1846
1847                let probe = Probe {
1848                    probe_ts: timestamp,
1849                    upstream_frontier,
1850                };
1851                probe_output.give(&probe_cap, probe);
1852            }
1853
1854            metadata_output.give(&metadata_cap, (timestamp, update));
1855        }
1856    });
1857
1858    (metadata_stream, probe_stream, button.press_on_drop())
1859}
1860
1861fn spawn_metadata_thread<C: ConsumerContext>(
1862    config: RawSourceCreationConfig,
1863    consumer: BaseConsumer<TunnelingClientContext<C>>,
1864    topic: String,
1865    tx: mpsc::UnboundedSender<(mz_repr::Timestamp, MetadataUpdate)>,
1866) {
1867    // Linux thread names are limited to 15 characters. Use a truncated ID to fit the name.
1868    thread::Builder::new()
1869        .name(format!("kfk-mtdt-{}", config.id))
1870        .spawn(move || {
1871            trace!(
1872                source_id = config.id.to_string(),
1873                worker_id = config.worker_id,
1874                num_workers = config.worker_count,
1875                "kafka metadata thread: starting..."
1876            );
1877
1878            let timestamp_interval = config.timestamp_interval;
1879            let mut ticker = probe::Ticker::new(move || timestamp_interval, config.now_fn);
1880
1881            loop {
1882                let probe_ts = ticker.tick_blocking();
1883                let result = fetch_partition_info(
1884                    &consumer,
1885                    &topic,
1886                    config
1887                        .config
1888                        .parameters
1889                        .kafka_timeout_config
1890                        .fetch_metadata_timeout,
1891                    Offset::End,
1892                );
1893                trace!(
1894                    source_id = config.id.to_string(),
1895                    worker_id = config.worker_id,
1896                    num_workers = config.worker_count,
1897                    "kafka metadata thread: metadata fetch result: {:?}",
1898                    result
1899                );
1900                let update = match result {
1901                    Ok(partitions) => {
1902                        trace!(
1903                            source_id = config.id.to_string(),
1904                            worker_id = config.worker_id,
1905                            num_workers = config.worker_count,
1906                            "kafka metadata thread: fetched partition metadata info",
1907                        );
1908
1909                        MetadataUpdate::Partitions(partitions)
1910                    }
1911                    Err(GetPartitionsError::TopicDoesNotExist) => {
1912                        let error = SourceError {
1913                            error: SourceErrorDetails::Other("topic was deleted".into()),
1914                            hint: None,
1915                        };
1916                        MetadataUpdate::DefiniteError(error)
1917                    }
1918                    Err(e) => {
1919                        let kafka_status = Some(HealthStatusUpdate::stalled(
1920                            format!("{}", e.display_with_causes()),
1921                            None,
1922                        ));
1923
1924                        let ssh_status = consumer.client().context().tunnel_status();
1925                        let ssh_status = match ssh_status {
1926                            SshTunnelStatus::Running => Some(HealthStatusUpdate::running()),
1927                            SshTunnelStatus::Errored(e) => {
1928                                Some(HealthStatusUpdate::stalled(e, None))
1929                            }
1930                        };
1931
1932                        MetadataUpdate::TransientError(HealthStatus {
1933                            kafka: kafka_status,
1934                            ssh: ssh_status,
1935                        })
1936                    }
1937                };
1938
1939                if tx.send((probe_ts, update)).is_err() {
1940                    break;
1941                }
1942            }
1943
1944            info!(
1945                source_id = config.id.to_string(),
1946                worker_id = config.worker_id,
1947                num_workers = config.worker_count,
1948                "kafka metadata thread: receiver has gone away; shutting down."
1949            )
1950        })
1951        .unwrap();
1952}