Skip to main content

relay_kafka/producer/
mod.rs

1//! This module contains the Kafka producer related code.
2
3use std::borrow::Cow;
4use std::collections::{BTreeMap, HashMap};
5use std::fmt;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicU32, Ordering};
8use std::time::{Duration, Instant};
9
10use rdkafka::message::Header;
11use rdkafka::producer::BaseRecord;
12use relay_statsd::metric;
13use sentry_arroyo::backends::ProducerError;
14use sentry_arroyo::backends::kafka::config::KafkaConfig;
15use sentry_arroyo::backends::kafka::producer::KafkaProducer as ArroyoKafkaProducer;
16use sentry_arroyo::types::Topic;
17use thiserror::Error;
18
19use crate::KafkaTopicConfig;
20use crate::config::{KafkaParams, KafkaTopic};
21use crate::debounced::Debounced;
22use crate::limits::KafkaRateLimits;
23use crate::producer::utils::KafkaHeaders;
24use crate::statsd::{KafkaCounters, KafkaDistributions, KafkaGauges};
25
26mod utils;
27use utils::Context;
28
29#[cfg(debug_assertions)]
30mod schemas;
31
32const REPORT_FREQUENCY_SECS: u64 = 1;
33const KAFKA_FETCH_METADATA_TIMEOUT: Duration = Duration::from_secs(30);
34
35/// Key type used for partitioning.
36pub type Key = u128;
37
38/// Kafka producer errors.
39#[derive(Error, Debug)]
40pub enum ClientError {
41    /// Failed to send a Kafka message using Arroyo.
42    #[error("failed to send kafka message")]
43    SendFailed(#[source] ProducerError),
44
45    /// Failed to find configured producer for the requested kafka topic.
46    #[error("failed to find producer for the requested kafka topic")]
47    InvalidTopicName,
48
49    /// Failed to create a kafka producer because of the invalid configuration.
50    #[error("failed to create kafka producer: invalid kafka config: {0}")]
51    InvalidConfig(#[source] rdkafka::error::KafkaError),
52
53    /// Failed to serialize the message.
54    #[error("failed to serialize kafka message")]
55    InvalidMsgPack(#[source] rmp_serde::encode::Error),
56
57    /// Failed to serialize the json message using serde.
58    #[error("failed to serialize json message")]
59    InvalidJson(#[from] serde_json::Error),
60
61    /// Failed to run schema validation on message.
62    #[cfg(debug_assertions)]
63    #[error("schema validation failed")]
64    SchemaValidationFailed(#[source] schemas::SchemaError),
65
66    /// Configuration is wrong and it cannot be used to identify the number of a shard.
67    #[error("no kafka configuration for topic")]
68    MissingTopic,
69
70    /// Failed to validate the topic using Arroyo.
71    #[error("failed to validate the topic with name {0}: {1}")]
72    TopicError(String, #[source] rdkafka::error::KafkaError),
73
74    /// Failed to encode the protobuf into the buffer
75    /// because the buffer is too small.
76    #[error("failed to encode protobuf because the buffer is too small")]
77    ProtobufEncodingFailed,
78}
79
80/// Describes the type which can be sent using kafka producer provided by this crate.
81pub trait Message {
82    /// Returns the partitioning key for this kafka message determining.
83    fn key(&self) -> Option<Key>;
84
85    /// Returns the type of the message.
86    fn variant(&self) -> &'static str;
87
88    /// Return the list of headers to be provided when payload is sent to Kafka.
89    fn headers(&self) -> Option<&BTreeMap<String, String>>;
90
91    /// Serializes the message into its binary format.
92    ///
93    /// # Errors
94    /// Returns the [`ClientError::InvalidMsgPack`], [`ClientError::InvalidJson`] or [`ClientError::ProtobufEncodingFailed`]  if the
95    /// serialization failed.
96    fn serialize(&self) -> Result<SerializationOutput<'_>, ClientError>;
97}
98
99/// The output of serializing a message for kafka.
100#[derive(Debug, Clone)]
101pub enum SerializationOutput<'a> {
102    /// Serialized as Json.
103    Json(Cow<'a, [u8]>),
104
105    /// Serialized as MsgPack.
106    MsgPack(Cow<'a, [u8]>),
107
108    /// Serialized as Protobuf.
109    Protobuf(Cow<'a, [u8]>),
110}
111
112impl SerializationOutput<'_> {
113    /// Return the serialized bytes.
114    pub fn as_bytes(&self) -> &[u8] {
115        match self {
116            SerializationOutput::Json(cow) => cow,
117            SerializationOutput::MsgPack(cow) => cow,
118            SerializationOutput::Protobuf(cow) => cow,
119        }
120    }
121}
122
123struct TopicProducers {
124    /// All configured producers.
125    producers: Vec<TopicProducer>,
126}
127
128impl TopicProducers {
129    fn new() -> Self {
130        Self {
131            producers: Vec::new(),
132        }
133    }
134
135    fn select(&self, key: u128) -> Option<&TopicProducer> {
136        debug_assert!(!self.producers.is_empty());
137
138        if self.producers.is_empty() {
139            return None;
140        } else if self.producers.len() == 1 {
141            return self.producers.first();
142        }
143
144        let select = (key % self.producers.len() as u128) as usize;
145        self.producers.get(select)
146    }
147
148    /// Validates the topic by fetching the metadata of the topic directly from Kafka.
149    fn validate_topic(&self) -> Result<(), ClientError> {
150        for tp in &self.producers {
151            tp.producer
152                .validate_topic(Topic::new(&tp.topic_name), KAFKA_FETCH_METADATA_TIMEOUT)
153                .map_err(|error| ClientError::TopicError(tp.topic_name.clone(), error))?;
154        }
155
156        Ok(())
157    }
158}
159
160struct TopicProducer {
161    pub topic_name: String,
162    pub producer: Arc<ArroyoKafkaProducer<Context>>,
163    pub rate_limiter: Option<KafkaRateLimits>,
164}
165
166/// Single kafka producer config with assigned topic.
167struct Producer {
168    /// Topic to producer and rate limiter mappings for sharding.
169    topic_producers: TopicProducers,
170    /// Debouncer for metrics.
171    metrics: Debounced,
172    next_key: AtomicU32,
173}
174
175impl Producer {
176    fn new(topic_producers: TopicProducers) -> Self {
177        Self {
178            topic_producers,
179            metrics: Debounced::new(REPORT_FREQUENCY_SECS),
180            next_key: AtomicU32::new(0),
181        }
182    }
183}
184
185impl Producer {
186    /// Sends the payload to the correct producer for the current topic.
187    fn send(
188        &self,
189        key: Option<Key>,
190        headers: Option<&BTreeMap<String, String>>,
191        variant: &str,
192        payload: &[u8],
193    ) -> Result<&str, ClientError> {
194        let now = Instant::now();
195
196        // Always generate a key to force a slightly more equal distribution across partitions,
197        // see also the documentation for `Self::next_key`.
198        let mut key = key.unwrap_or_else(|| self.next_key());
199
200        let Some(TopicProducer {
201            topic_name,
202            producer,
203            rate_limiter,
204        }) = self.topic_producers.select(key)
205        else {
206            return Err(ClientError::MissingTopic);
207        };
208
209        relay_log::configure_scope(|s| s.set_tag("topic", topic_name));
210
211        let producer_name = producer.context().producer_name();
212
213        metric!(
214            distribution(KafkaDistributions::KafkaMessageSize) = payload.len() as u64,
215            variant = variant,
216            topic = topic_name,
217            producer_name = producer_name
218        );
219
220        let mut headers = headers
221            .unwrap_or(&BTreeMap::new())
222            .iter()
223            .map(|(key, value)| Header {
224                key,
225                value: Some(value),
226            })
227            .collect::<KafkaHeaders>();
228
229        // Always rate limit if there is a rate limiter defined.
230        // Defining a rate limiter for a topic which does not have a consistent routing key is just
231        // a misconfiguration.
232        if let Some(limiter) = &rate_limiter {
233            let is_limited = limiter.try_increment(now, key, 1) < 1;
234
235            if is_limited {
236                metric!(
237                    counter(KafkaCounters::ProducerPartitionKeyRateLimit) += 1,
238                    variant = variant,
239                    topic = topic_name,
240                    producer_name = producer_name
241                );
242
243                headers.insert(Header {
244                    key: "sentry-reshuffled",
245                    value: Some("1"),
246                });
247
248                // Force a 'random' partition, instead the originally assigned partition.
249                key = self.next_key();
250            }
251        }
252
253        let key = u128::to_be_bytes(key);
254        let mut record = BaseRecord::to(topic_name).payload(payload).key(&key);
255        if let Some(headers) = headers.into_inner() {
256            record = record.headers(headers);
257        }
258
259        self.metrics.debounce(now, || {
260            metric!(
261                gauge(KafkaGauges::InFlightCount) = producer.in_flight_count() as u64,
262                variant = variant,
263                topic = topic_name,
264                producer_name = producer_name
265            );
266        });
267
268        producer
269            .produce_record(record)
270            .map_err(ClientError::SendFailed)
271            .inspect_err(|_| {
272                metric!(
273                    counter(KafkaCounters::ProducerEnqueueError) += 1,
274                    variant = variant,
275                    topic = topic_name,
276                    producer_name = producer_name
277                );
278            })?;
279
280        Ok(topic_name)
281    }
282
283    /// Returns a newly generated key.
284    ///
285    /// Keys are created in sequence, they can be used as a Kafka partition key to force an equal
286    /// distribution across partitions.
287    ///
288    /// Producing a Kafka message without a 'random' key, produces slightly uneven batches to
289    /// partitions. We've seen that this pattern does not play well with our Arroyo consumers
290    /// and leads to higher partition lags.
291    fn next_key(&self) -> u128 {
292        // Overflowing is fine, as it would wrap. The only use for the atomic is to generate
293        // a different key to the one before.
294        self.next_key.fetch_add(1, Ordering::Relaxed) as u128
295    }
296}
297
298impl fmt::Debug for Producer {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        let topic_names: Vec<&String> = self
301            .topic_producers
302            .producers
303            .iter()
304            .map(|tp| &tp.topic_name)
305            .collect();
306        f.debug_struct("Producer")
307            .field("topic_names", &topic_names)
308            .field("producers", &"<KafkaProducers>")
309            .finish_non_exhaustive()
310    }
311}
312
313/// Keeps all the configured Kafka producers and responsible for the routing of the messages.
314#[derive(Debug)]
315pub struct KafkaClient {
316    producers: HashMap<KafkaTopic, Producer>,
317    #[cfg(debug_assertions)]
318    schema_validator: schemas::Validator,
319}
320
321impl KafkaClient {
322    /// Creates a Kafka client builder.
323    pub fn builder() -> KafkaClientBuilder {
324        KafkaClientBuilder::new()
325    }
326
327    /// Sends message to the provided Kafka topic.
328    ///
329    /// Returns the name of the Kafka topic to which the message was produced.
330    pub fn send_message(
331        &self,
332        topic: KafkaTopic,
333        message: &impl Message,
334    ) -> Result<&str, ClientError> {
335        let serialized = message.serialize()?;
336
337        #[cfg(debug_assertions)]
338        if let SerializationOutput::Json(ref bytes) = serialized {
339            self.schema_validator
340                .validate_message_schema(topic, bytes)
341                .map_err(ClientError::SchemaValidationFailed)?;
342        }
343        self.send(
344            topic,
345            message.key(),
346            message.headers(),
347            message.variant(),
348            serialized.as_bytes(),
349        )
350    }
351
352    /// Sends the payload to the correct producer for the current topic.
353    ///
354    /// Returns the name of the Kafka topic to which the message was produced.
355    fn send(
356        &self,
357        topic: KafkaTopic,
358        key: Option<Key>,
359        headers: Option<&BTreeMap<String, String>>,
360        variant: &str,
361        payload: &[u8],
362    ) -> Result<&str, ClientError> {
363        let producer = self
364            .producers
365            .get(&topic)
366            .ok_or_else(|| ClientError::InvalidTopicName)?;
367
368        producer.send(key, headers, variant, payload)
369    }
370}
371
372/// Helper structure responsible for building the actual [`KafkaClient`].
373#[derive(Default)]
374pub struct KafkaClientBuilder {
375    reused_producers: BTreeMap<Option<String>, Arc<ArroyoKafkaProducer<Context>>>,
376    producers: HashMap<KafkaTopic, Producer>,
377}
378
379impl KafkaClientBuilder {
380    /// Creates an empty Kafka client builder.
381    pub fn new() -> Self {
382        Self::default()
383    }
384
385    /// Adds topic configuration to the current [`KafkaClientBuilder`], which in return assigns
386    /// dedicates producer to the topic which can will be used to send the messages.
387    ///
388    /// # Errors
389    /// Returns [`ClientError::InvalidConfig`] error if the provided configuration is wrong and
390    /// the producer could not be created.
391    pub fn add_kafka_topic_config(
392        mut self,
393        topic: KafkaTopic,
394        topic_config: &KafkaTopicConfig<'_>,
395        validate_topic: bool,
396    ) -> Result<Self, ClientError> {
397        let mut topic_producers = TopicProducers::new();
398
399        // Process each shard configuration (one KafkaParams per shard)
400        // We must preserve the original order from the configuration
401        // because hash-based routing depends on shard index positions
402        for params in topic_config.topics() {
403            let KafkaParams {
404                topic_name,
405                config_name,
406                params: config_params,
407                key_rate_limit,
408            } = params;
409
410            let rate_limiter = key_rate_limit.map(|limit| {
411                KafkaRateLimits::new(
412                    limit.limit_per_window,
413                    Duration::from_secs(limit.window_secs),
414                )
415            });
416
417            let config_name = config_name.map(str::to_owned);
418
419            // Get or create producer for this broker config
420            let kafka_producer = if let Some(producer) = self.reused_producers.get(&config_name) {
421                Arc::clone(producer)
422            } else {
423                // Extract producer name from client.id, fallback to config name, then "unknown"
424                let producer_name = config_params
425                    .iter()
426                    .find(|p| p.name == "client.id")
427                    .map(|p| p.value.clone())
428                    .or_else(|| config_name.clone())
429                    .unwrap_or_else(|| "unknown".to_owned());
430
431                let context = Context::new(producer_name);
432                let params = config_params
433                    .iter()
434                    .map(|param| (param.name.clone(), param.value.clone()))
435                    .collect();
436                let config = KafkaConfig::new_config(Vec::new(), Some(params));
437                let producer = ArroyoKafkaProducer::new_with_context(config, context)
438                    .map_err(ClientError::InvalidConfig)?;
439                let producer = Arc::new(producer);
440
441                self.reused_producers
442                    .insert(config_name, Arc::clone(&producer));
443
444                producer
445            };
446
447            topic_producers.producers.push(TopicProducer {
448                topic_name: topic_name.clone(),
449                producer: kafka_producer,
450                rate_limiter,
451            });
452        }
453
454        let producer = Producer::new(topic_producers);
455        if validate_topic {
456            producer.topic_producers.validate_topic()?;
457        }
458        self.producers.insert(topic, producer);
459
460        Ok(self)
461    }
462
463    /// Consumes self and returns the built [`KafkaClient`].
464    pub fn build(self) -> KafkaClient {
465        KafkaClient {
466            producers: self.producers,
467            #[cfg(debug_assertions)]
468            schema_validator: schemas::Validator::default(),
469        }
470    }
471}
472
473impl fmt::Debug for KafkaClientBuilder {
474    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475        f.debug_struct("KafkaClientBuilder")
476            .field("reused_producers", &"<CachedProducers>")
477            .field("producers", &self.producers)
478            .finish()
479    }
480}