Skip to main content

krabka_client_producer/
partitioner.rs

1//! Kafka's built-in partitioner (KIP-480 and KIP-794).
2//!
3//! A keyed record goes to `toPositive(murmur2(key)) % partitions`, unless
4//! `partitioner.ignore.keys` is set. A keyless record goes to the sticky
5//! partition of its topic. The producer keeps a sticky partition until about
6//! `batch.size` bytes went to it, and then picks a new one at random. The pick
7//! uses only partitions with a leader. With adaptive partitioning, the sender
8//! weights the pick by the queue size of each partition, so a partition with a
9//! long queue gets fewer new records. This is Apache Kafka's
10//! `BuiltInPartitioner`.
11
12use std::{
13    collections::HashMap,
14    fmt,
15    hash::{BuildHasher as _, RandomState},
16    sync::{
17        Arc, Mutex, MutexGuard, PoisonError,
18        atomic::{AtomicU64, Ordering},
19    },
20    time::Duration,
21};
22
23/// The settings of the built-in partitioner.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct PartitionerConfig {
26    /// The bytes that go to a sticky partition before the producer picks a
27    /// new one. Kafka uses `batch.size`.
28    pub sticky_batch_size: usize,
29    /// Kafka's `partitioner.ignore.keys`.
30    pub ignore_keys: bool,
31    /// Kafka's `partitioner.adaptive.partitioning.enable`.
32    pub adaptive_partitioning: bool,
33    /// Kafka's `partitioner.availability.timeout.ms`. Zero turns the check
34    /// off.
35    pub availability_timeout: Duration,
36    /// Kafka's `partitioner.rack.aware`. With `client.rack` set, the sticky
37    /// pick prefers a partition whose leader is in that rack, and falls back
38    /// to every partition with a leader when none is. See
39    /// [`BuiltInPartitioner::peek_rack_aware`].
40    pub rack_aware: bool,
41}
42
43impl Default for PartitionerConfig {
44    /// Kafka's defaults.
45    fn default() -> Self {
46        Self {
47            sticky_batch_size: crate::DEFAULT_PRODUCER_BATCH_BYTES,
48            ignore_keys: crate::DEFAULT_PRODUCER_PARTITIONER_IGNORE_KEYS,
49            adaptive_partitioning: crate::DEFAULT_PRODUCER_PARTITIONER_ADAPTIVE_PARTITIONING_ENABLE,
50            availability_timeout: crate::DEFAULT_PRODUCER_PARTITIONER_AVAILABILITY_TIMEOUT,
51            rack_aware: crate::DEFAULT_PRODUCER_PARTITIONER_RACK_AWARE,
52        }
53    }
54}
55
56/// One partition of a topic, as Kafka's `PartitionInfo` describes it to a
57/// custom [`Partitioner`].
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct PartitionInfo {
60    /// The partition index.
61    pub partition: i32,
62    /// The id of the broker that leads the partition, or `None` when it is
63    /// not known.
64    pub leader_id: Option<i32>,
65    /// The rack of the leader, or `None` when it is not known or the leader
66    /// has none. Kafka's `Node.rack()`.
67    pub leader_rack: Option<String>,
68}
69
70/// A caller-supplied `partitioner.class` hook (Kafka's `Partitioner`
71/// interface).
72///
73/// `Producer::builder().partitioner(...)` installs one. `send` then calls it,
74/// instead of the built-in partitioner, for every record with no explicit
75/// partition — keyed or not. A negative result fails the send with
76/// [`crate::ProducerError::InvalidPartitionerResult`], the message Kafka's
77/// `KafkaProducer.partition` throws. Setting a partitioner also turns
78/// adaptive partitioning off, matching Kafka's
79/// `enableAdaptivePartitioning = partitionerPlugin.get() == null && ...`.
80pub trait Partitioner: Send + Sync {
81    /// Pick the partition of a record. `available_partitions` holds the
82    /// partitions of `topic` that have a leader; `all_partitions` holds every
83    /// partition. Kafka's `Partitioner.partition` takes the unserialized and
84    /// the serialized key and value; this trait takes only the serialized
85    /// forms, which is what the producer already holds at partitioning time.
86    fn partition(
87        &self,
88        topic: &str,
89        key: Option<&[u8]>,
90        value: Option<&[u8]>,
91        available_partitions: &[PartitionInfo],
92        all_partitions: &[PartitionInfo],
93    ) -> i32;
94}
95
96/// The rack of the leader of a partition, or `None` when it is not known or
97/// the leader has none. `producer.rs` builds one from the broker rack cache;
98/// tests script one directly.
99pub type LeaderRack<'a> = &'a (dyn Fn(i32) -> Option<String> + Sync);
100
101/// The rack preference of `partitioner.rack.aware`: the rack of each
102/// partition leader, and the `client.rack` of the producer.
103#[derive(Clone, Copy)]
104pub struct RackPreference<'a> {
105    pub leader_rack: LeaderRack<'a>,
106    pub client_rack: &'a str,
107}
108
109/// A source of random 32-bit values. Tests give a scripted source.
110pub type RandomSource = Arc<dyn Fn() -> u32 + Send + Sync>;
111
112/// A random 32-bit value.
113///
114/// Each `RandomState` has new random keys, so its hash of a fixed value is a
115/// random number. This needs no extra dependency.
116fn random_u32() -> u32 {
117    let bits = RandomState::new().hash_one(0_u8) >> 32;
118    u32::try_from(bits).unwrap_or(u32::MAX)
119}
120
121/// The partitions of a topic, as the metadata of the producer shows them.
122pub struct TopicPartitions<'a> {
123    /// The partition count. It is greater than zero.
124    pub count: i32,
125    /// Tell if a partition has a leader.
126    pub has_leader: &'a (dyn Fn(i32) -> bool + Sync),
127}
128
129/// The sticky partition of a topic at one time.
130///
131/// Two values are equal only when they come from the same pick. Kafka compares
132/// the identity of its `StickyPartitionInfo` object in the same way, so a pick
133/// of the same partition number still counts as a change.
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub struct StickyPartition {
136    partition: i32,
137    pick: u64,
138}
139
140impl StickyPartition {
141    pub(crate) const fn partition(self) -> i32 {
142        self.partition
143    }
144}
145
146/// The queue sizes of the partitions of a topic, for adaptive partitioning.
147///
148/// Kafka's `RecordAccumulator.partitionReady` builds them. `sizes` and
149/// `partition_ids` hold only the partitions that the pick can use: they have a
150/// leader, and the availability timeout did not exclude them.
151#[derive(Clone, Debug, PartialEq, Eq)]
152pub struct QueueSizes {
153    pub sizes: Vec<u32>,
154    pub partition_ids: Vec<i32>,
155    /// The number of partitions that have a queue, with or without a leader.
156    pub all: usize,
157}
158
159/// The cumulative frequency table of a weighted pick.
160#[derive(Clone, Debug, PartialEq, Eq)]
161struct LoadStats {
162    cumulative_frequency: Vec<u32>,
163    partition_ids: Vec<i32>,
164}
165
166impl LoadStats {
167    /// Kafka's `BuiltInPartitioner.updatePartitionLoadStats`.
168    ///
169    /// It gives `None` when a uniform pick is correct: no partition can take
170    /// records, the topic has fewer than two partitions, or every partition
171    /// can take records and all queues have the same size.
172    fn from_queue_sizes(queues: QueueSizes) -> Option<Self> {
173        let QueueSizes {
174            sizes,
175            partition_ids,
176            all,
177        } = queues;
178        if sizes.is_empty() || all < 2 || sizes.len() != partition_ids.len() {
179            return None;
180        }
181        let largest = sizes.iter().copied().max()?;
182        if sizes.iter().all(|&size| size == largest) && sizes.len() == all {
183            return None;
184        }
185        // Invert each size, so a shorter queue gets a larger weight, and sum
186        // the weights. A value from 0 to the last sum then maps to the first
187        // partition whose sum is greater. Sizes 0, 3, 1 give weights 4, 1, 3
188        // and the table 4, 5, 8.
189        let weight_base = largest.saturating_add(1);
190        let mut running = 0_u32;
191        let cumulative_frequency = sizes
192            .iter()
193            .map(|&size| {
194                running = running.saturating_add(weight_base - size);
195                running
196            })
197            .collect();
198        Some(Self {
199            cumulative_frequency,
200            partition_ids,
201        })
202    }
203}
204
205/// The partitioner state of one topic.
206#[derive(Debug, Default)]
207struct TopicState {
208    /// The sticky partition and the bytes that went to it.
209    sticky: Option<(StickyPartition, usize)>,
210    load_stats: Option<LoadStats>,
211}
212
213/// Kafka's `BuiltInPartitioner`, for every topic of a producer.
214pub struct BuiltInPartitioner {
215    config: PartitionerConfig,
216    random: RandomSource,
217    picks: AtomicU64,
218    /// No code holds this lock across an `.await`.
219    topics: Mutex<HashMap<String, TopicState>>,
220}
221
222impl fmt::Debug for BuiltInPartitioner {
223    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
224        formatter
225            .debug_struct("BuiltInPartitioner")
226            .field("config", &self.config)
227            .finish_non_exhaustive()
228    }
229}
230
231impl BuiltInPartitioner {
232    pub(crate) fn new(config: PartitionerConfig) -> Self {
233        Self::with_random(config, Arc::new(random_u32))
234    }
235
236    /// A partitioner that takes its random values from `random`.
237    pub(crate) fn with_random(config: PartitionerConfig, random: RandomSource) -> Self {
238        Self {
239            config,
240            random,
241            picks: AtomicU64::new(0),
242            topics: Mutex::new(HashMap::new()),
243        }
244    }
245
246    pub(crate) const fn config(&self) -> PartitionerConfig {
247        self.config
248    }
249
250    /// The partition of a keyed record, or `None` when the sticky partition
251    /// takes the record. Kafka's `KafkaProducer.partition`.
252    pub(crate) fn keyed_partition(&self, key: Option<&[u8]>, count: i32) -> Option<i32> {
253        key.filter(|_| !self.config.ignore_keys)
254            .map(|key| partition_for_key(key, count))
255    }
256
257    fn lock(&self) -> MutexGuard<'_, HashMap<String, TopicState>> {
258        self.topics.lock().unwrap_or_else(PoisonError::into_inner)
259    }
260
261    /// Run `f` on the state of `topic` under the lock, when the topic has one.
262    fn with_topic<R>(&self, topic: &str, f: impl FnOnce(&mut TopicState) -> R) -> Option<R> {
263        self.lock().get_mut(topic).map(f)
264    }
265
266    /// Kafka's `BuiltInPartitioner.peekCurrentPartitionInfo`: the sticky
267    /// partition of `topic`, picked now when the topic has none.
268    pub(crate) fn peek(&self, topic: &str, partitions: &TopicPartitions<'_>) -> StickyPartition {
269        self.peek_inner(topic, partitions, None)
270    }
271
272    /// [`Self::peek`], with `partitioner.rack.aware`: the pick prefers a
273    /// partition whose leader is in `client_rack`, and falls back to
274    /// [`Self::peek`]'s pick when no partition with a leader is. The caller
275    /// checks `config().rack_aware` and its own `client_rack` first, so this
276    /// always applies the preference when called.
277    pub(crate) fn peek_rack_aware(
278        &self,
279        topic: &str,
280        partitions: &TopicPartitions<'_>,
281        rack: RackPreference<'_>,
282    ) -> StickyPartition {
283        self.peek_inner(topic, partitions, Some(rack))
284    }
285
286    fn peek_inner(
287        &self,
288        topic: &str,
289        partitions: &TopicPartitions<'_>,
290        rack: Option<RackPreference<'_>>,
291    ) -> StickyPartition {
292        self.peek_locked(
293            self.lock().entry(topic.to_owned()).or_default(),
294            partitions,
295            rack,
296        )
297    }
298
299    /// [`Self::peek_inner`] for the locked `state` of the topic.
300    fn peek_locked(
301        &self,
302        state: &mut TopicState,
303        partitions: &TopicPartitions<'_>,
304        rack: Option<RackPreference<'_>>,
305    ) -> StickyPartition {
306        if let Some((sticky, _)) = state.sticky {
307            return sticky;
308        }
309        let sticky = self.pick(state.load_stats.as_ref(), partitions, rack);
310        state.sticky = Some((sticky, 0));
311        sticky
312    }
313
314    /// Kafka's `BuiltInPartitioner.isPartitionChanged`: tell if another send
315    /// picked a new sticky partition after `sticky`.
316    pub(crate) fn is_changed(&self, topic: &str, sticky: StickyPartition) -> bool {
317        self.lock()
318            .get(topic)
319            .and_then(|state| state.sticky)
320            .is_none_or(|(current, _)| current != sticky)
321    }
322
323    /// Kafka's `BuiltInPartitioner.updatePartitionInfo`: count `appended`
324    /// bytes on `sticky`, and pick a new sticky partition when enough bytes
325    /// went to it.
326    ///
327    /// The switch needs `sticky_batch_size` bytes and `enable_switch`, or
328    /// twice `sticky_batch_size` bytes. The caller turns the switch off while
329    /// the partition has a batch that is not full, so a switch does not leave
330    /// a small batch behind.
331    pub(crate) fn update(
332        &self,
333        topic: &str,
334        sticky: StickyPartition,
335        appended: usize,
336        partitions: &TopicPartitions<'_>,
337        enable_switch: bool,
338    ) {
339        self.update_inner(topic, sticky, appended, partitions, enable_switch, None);
340    }
341
342    /// [`Self::update`], with the rack preference of [`Self::peek_rack_aware`]
343    /// for the partition picked when the sticky partition switches.
344    pub(crate) fn update_rack_aware(
345        &self,
346        topic: &str,
347        sticky: StickyPartition,
348        appended: usize,
349        partitions: &TopicPartitions<'_>,
350        enable_switch: bool,
351        rack: RackPreference<'_>,
352    ) {
353        self.update_inner(
354            topic,
355            sticky,
356            appended,
357            partitions,
358            enable_switch,
359            Some(rack),
360        );
361    }
362
363    fn update_inner(
364        &self,
365        topic: &str,
366        sticky: StickyPartition,
367        appended: usize,
368        partitions: &TopicPartitions<'_>,
369        enable_switch: bool,
370        rack: Option<RackPreference<'_>>,
371    ) {
372        let size = self.config.sticky_batch_size.max(1);
373        self.with_topic(topic, |state| {
374            let Some((current, produced)) = state.sticky.as_mut() else {
375                return;
376            };
377            if *current != sticky {
378                return;
379            }
380            *produced = produced.saturating_add(appended);
381            if (*produced >= size && enable_switch) || *produced >= size.saturating_mul(2) {
382                let next = self.pick(state.load_stats.as_ref(), partitions, rack);
383                state.sticky = Some((next, 0));
384            }
385        });
386    }
387
388    /// Store the queue sizes of `topic` for the next pick, or `None` for a
389    /// uniform pick. Kafka's `BuiltInPartitioner.updatePartitionLoadStats`.
390    pub(crate) fn update_load_stats(&self, topic: &str, queues: Option<QueueSizes>) {
391        let load_stats = queues.and_then(LoadStats::from_queue_sizes);
392        let mut topics = self.lock();
393        match topics.get_mut(topic) {
394            Some(state) => state.load_stats = load_stats,
395            None if load_stats.is_some() => {
396                topics.insert(
397                    topic.to_owned(),
398                    TopicState {
399                        sticky: None,
400                        load_stats,
401                    },
402                );
403            }
404            None => {}
405        }
406    }
407
408    /// Kafka's `BuiltInPartitioner.nextPartition`, with the in-rack form of
409    /// `createPartitionLoadStatsForThisRackIfNeeded` when `rack` names a
410    /// client rack and `config.rack_aware` is set.
411    fn pick(
412        &self,
413        load_stats: Option<&LoadStats>,
414        partitions: &TopicPartitions<'_>,
415        rack: Option<RackPreference<'_>>,
416    ) -> StickyPartition {
417        // Kafka's `Utils.toPositive` of a random `int`.
418        let random = (self.random)() & 0x7fff_ffff;
419        let rack_pick = self
420            .rack_aware_candidates(partitions, rack)
421            .and_then(|candidates| Self::pick_among(load_stats, &candidates, random));
422        let partition = match (rack_pick, load_stats) {
423            (Some(partition), _) => partition,
424            (None, Some(stats)) => {
425                let total = stats
426                    .cumulative_frequency
427                    .last()
428                    .copied()
429                    .unwrap_or(1)
430                    .max(1);
431                let weighted = random % total;
432                // The table only grows, so the first sum greater than the value
433                // names the partition.
434                let index = stats
435                    .cumulative_frequency
436                    .partition_point(|&sum| sum <= weighted);
437                stats.partition_ids.get(index).copied().unwrap_or(0)
438            }
439            (None, None) => {
440                let available: Vec<i32> = (0..partitions.count)
441                    .filter(|&partition| (partitions.has_leader)(partition))
442                    .collect();
443                let random = usize::try_from(random).unwrap_or(0);
444                if let Some(&partition) = random
445                    .checked_rem(available.len())
446                    .and_then(|index| available.get(index))
447                {
448                    partition
449                } else {
450                    // No partition has a leader, so every partition can take
451                    // the record.
452                    let count = usize::try_from(partitions.count.max(1)).unwrap_or(1);
453                    i32::try_from(random % count).unwrap_or(0)
454                }
455            }
456        };
457        StickyPartition {
458            partition,
459            pick: self.picks.fetch_add(1, Ordering::AcqRel),
460        }
461    }
462
463    /// The partitions with a leader in `client_rack`, or `None` when rack
464    /// awareness does not apply (`config.rack_aware` is off, or `rack` is
465    /// `None`) or no partition with a leader is in the rack. Kafka's
466    /// `BuiltInPartitioner.nextPartition` filters the same way before it
467    /// falls back to every partition with a leader.
468    fn rack_aware_candidates(
469        &self,
470        partitions: &TopicPartitions<'_>,
471        rack: Option<RackPreference<'_>>,
472    ) -> Option<Vec<i32>> {
473        if !self.config.rack_aware {
474            return None;
475        }
476        let RackPreference {
477            leader_rack,
478            client_rack,
479        } = rack?;
480        let in_rack: Vec<i32> = (0..partitions.count)
481            .filter(|&partition| (partitions.has_leader)(partition))
482            .filter(|&partition| leader_rack(partition).as_deref() == Some(client_rack))
483            .collect();
484        (!in_rack.is_empty()).then_some(in_rack)
485    }
486
487    /// Pick among `candidates`, weighted by `load_stats` when it covers at
488    /// least one of them, and uniformly otherwise. This is the in-rack
489    /// variant of the adaptive table: rather than store a second table, it
490    /// derives each candidate's weight from the primary table's cumulative
491    /// frequencies and renormalizes over the restricted set. Kafka's
492    /// `createPartitionLoadStatsForThisRackIfNeeded` instead caches a second
493    /// table, but the resulting distribution is the same. It is `None` when
494    /// `candidates` is empty.
495    fn pick_among(load_stats: Option<&LoadStats>, candidates: &[i32], random: u32) -> Option<i32> {
496        if let Some(stats) = load_stats {
497            let mut previous_cumulative = 0_u32;
498            let mut running = 0_u32;
499            let mut cumulative_frequency = Vec::new();
500            let mut partition_ids = Vec::new();
501            for (index, &partition_id) in stats.partition_ids.iter().enumerate() {
502                let cumulative = stats.cumulative_frequency.get(index).copied().unwrap_or(0);
503                let weight = cumulative.saturating_sub(previous_cumulative);
504                previous_cumulative = cumulative;
505                if candidates.contains(&partition_id) {
506                    running = running.saturating_add(weight);
507                    cumulative_frequency.push(running);
508                    partition_ids.push(partition_id);
509                }
510            }
511            if !partition_ids.is_empty() {
512                let total = cumulative_frequency.last().copied().unwrap_or(1).max(1);
513                let weighted = random % total;
514                let index = cumulative_frequency.partition_point(|&sum| sum <= weighted);
515                return partition_ids
516                    .get(index)
517                    .or_else(|| candidates.first())
518                    .copied();
519            }
520        }
521        let index = usize::try_from(random)
522            .unwrap_or(0)
523            .checked_rem(candidates.len())?;
524        candidates.get(index).copied()
525    }
526}
527
528/// Pick the partition that Kafka picks for `key`.
529///
530/// Kafka computes `toPositive(murmur2(key)) % num_partitions`, and
531/// `Utils.toPositive` masks the sign bit with `& 0x7fffffff`. It does not take
532/// the absolute value. The two agree for a non-negative hash and disagree for
533/// every negative one, so the mask is what keeps a Krabka producer and a JVM
534/// producer on the same partition for the same key.
535///
536/// # Panics
537///
538/// Panics when `num_partitions` is not greater than zero.
539#[must_use]
540pub fn partition_for_key(key: &[u8], num_partitions: i32) -> i32 {
541    assert2::assert!(num_partitions > 0);
542    // The mask clears the sign bit, so the value is in [0, i32::MAX] and the
543    // remainder is in [0, num_partitions).
544    (murmur2(key) & 0x7fff_ffff) % num_partitions
545}
546
547/// `MurmurHash2`, the key hash of Kafka's `DefaultPartitioner`.
548///
549/// This is the reference implementation. The length cast to u32 matches the
550/// canonical spec.
551fn murmur2(data: &[u8]) -> i32 {
552    const SEED: u32 = 0x9747_b28c;
553    const M: u32 = 0x5bd1_e995;
554    const R: u32 = 24;
555
556    let length = data.len();
557    // Reference MurmurHash2 impl truncates length to u32 as part of the spec:
558    // the low four bytes of the little-endian length.
559    let [b0, b1, b2, b3, ..] = length.to_le_bytes();
560    let length_low = u32::from_le_bytes([b0, b1, b2, b3]);
561    let mut h: u32 = SEED ^ length_low;
562
563    let (chunks, remainder) = data.as_chunks::<4>();
564    for chunk in chunks {
565        let mut k = u32::from_le_bytes(*chunk);
566        k = k.wrapping_mul(M);
567        k ^= k >> R;
568        k = k.wrapping_mul(M);
569        h = h.wrapping_mul(M);
570        h ^= k;
571    }
572
573    match *remainder {
574        [r0, r1, r2] => {
575            h ^= u32::from(r2) << 16;
576            h ^= u32::from(r1) << 8;
577            h ^= u32::from(r0);
578            h = h.wrapping_mul(M);
579        }
580        [r0, r1] => {
581            h ^= u32::from(r1) << 8;
582            h ^= u32::from(r0);
583            h = h.wrapping_mul(M);
584        }
585        [r0] => {
586            h ^= u32::from(r0);
587            h = h.wrapping_mul(M);
588        }
589        _ => {}
590    }
591
592    h ^= h >> 13;
593    h = h.wrapping_mul(M);
594    h ^= h >> 15;
595
596    // Reinterpret bits as i32 — intentional per MurmurHash2 reference spec.
597    h.cast_signed()
598}
599
600#[cfg(test)]
601mod tests {
602    use std::{collections::VecDeque, iter};
603
604    use assert2::check;
605
606    use super::*;
607
608    const TOPIC: &str = "t";
609
610    fn config(sticky_batch_size: usize) -> PartitionerConfig {
611        PartitionerConfig {
612            sticky_batch_size,
613            ignore_keys: false,
614            adaptive_partitioning: true,
615            availability_timeout: Duration::ZERO,
616            rack_aware: false,
617        }
618    }
619
620    /// A partitioner whose random values come from `values` in order.
621    fn scripted(config: PartitionerConfig, values: &[u32]) -> BuiltInPartitioner {
622        let values = Arc::new(Mutex::new(values.iter().copied().collect::<VecDeque<_>>()));
623        BuiltInPartitioner::with_random(
624            config,
625            Arc::new(move || {
626                values
627                    .lock()
628                    .unwrap()
629                    .pop_front()
630                    .expect("the test scripts enough random values")
631            }),
632        )
633    }
634
635    fn all_leaders(_: i32) -> bool {
636        true
637    }
638
639    #[test]
640    fn a_new_sticky_partition_is_random_among_partitions_with_a_leader() {
641        // Each row: partition count, the partitions with a leader, the random
642        // value, and the pick. Kafka's `nextPartition` masks the sign bit and
643        // takes the value modulo the partitions with a leader, or modulo all
644        // partitions when none has a leader.
645        let cases: [(&str, i32, &[i32], u32, i32); 7] = [
646            ("zero", 6, &[0, 1, 2, 3, 4, 5], 0, 0),
647            ("value 7 of 6", 6, &[0, 1, 2, 3, 4, 5], 7, 1),
648            ("sign bit masked", 6, &[0, 1, 2, 3, 4, 5], 0x8000_0005, 5),
649            ("p1 and p2 have no leader, value 0", 4, &[0, 3], 0, 0),
650            ("p1 and p2 have no leader, value 1", 4, &[0, 3], 1, 3),
651            ("p1 and p2 have no leader, value 5", 4, &[0, 3], 5, 3),
652            ("no partition has a leader", 4, &[], 6, 2),
653        ];
654        let mut actual = Vec::new();
655        let mut expected = Vec::new();
656        for (name, count, with_leader, random, want) in cases {
657            let partitioner = scripted(config(100), &[random]);
658            let has_leader = |partition: i32| with_leader.contains(&partition);
659            let partitions = TopicPartitions {
660                count,
661                has_leader: &has_leader,
662            };
663            let first = partitioner.peek(TOPIC, &partitions).partition();
664            // A second peek keeps the partition and takes no random value.
665            let second = partitioner.peek(TOPIC, &partitions).partition();
666            actual.push((name, first, second));
667            expected.push((name, want, want));
668        }
669        assert2::assert!(actual == expected);
670    }
671
672    #[test]
673    fn the_sticky_partition_switches_after_batch_size_bytes() {
674        // Each row: the appends as (bytes, enable_switch), and the partition
675        // after each append. The random values pick 1, then 2, then 3.
676        /// A row: the name, the appends as `(bytes, enable_switch)`, and the
677        /// partition after each append.
678        type SwitchRow = (&'static str, &'static [(usize, bool)], &'static [i32]);
679        let cases: [SwitchRow; 5] = [
680            (
681                "no switch before batch.size",
682                &[(30, true), (30, true), (30, true)],
683                &[1, 1, 1],
684            ),
685            (
686                "switch once batch.size bytes went to the partition",
687                &[(30, true), (30, true), (30, true), (30, true), (30, true)],
688                &[1, 1, 1, 2, 2],
689            ),
690            (
691                "a batch that is not full defers the switch",
692                &[(60, false), (60, false), (0, true)],
693                &[1, 1, 2],
694            ),
695            (
696                "twice batch.size switches with the switch off",
697                &[(60, false), (60, false), (60, false), (60, false)],
698                &[1, 1, 1, 2],
699            ),
700            (
701                "a new partition counts from zero",
702                &[(100, true), (99, true), (1, true)],
703                &[2, 2, 3],
704            ),
705        ];
706        let mut actual = Vec::new();
707        let mut expected = Vec::new();
708        for (name, appends, want) in cases {
709            let partitioner = scripted(config(100), &[1, 2, 3]);
710            let partitions = TopicPartitions {
711                count: 4,
712                has_leader: &all_leaders,
713            };
714            let mut after = Vec::new();
715            for &(bytes, enable_switch) in appends {
716                let sticky = partitioner.peek(TOPIC, &partitions);
717                partitioner.update(TOPIC, sticky, bytes, &partitions, enable_switch);
718                after.push(partitioner.peek(TOPIC, &partitions).partition());
719            }
720            actual.push((name, after));
721            expected.push((name, want.to_vec()));
722        }
723        assert2::assert!(actual == expected);
724    }
725
726    #[test]
727    fn a_stale_sticky_partition_is_a_change_and_does_not_count_bytes() {
728        let partitioner = scripted(config(100), &[2, 2]);
729        let partitions = TopicPartitions {
730            count: 4,
731            has_leader: &all_leaders,
732        };
733        let old = partitioner.peek(TOPIC, &partitions);
734        partitioner.update(TOPIC, old, 100, &partitions, true);
735        let new = partitioner.peek(TOPIC, &partitions);
736        // The new pick is the same partition number, and it is still a new
737        // pick. Bytes for the old pick do not count on the new one.
738        partitioner.update(TOPIC, old, 100, &partitions, true);
739        check!(
740            (
741                old.partition(),
742                new.partition(),
743                partitioner.is_changed(TOPIC, old),
744                partitioner.is_changed(TOPIC, new),
745                partitioner.peek(TOPIC, &partitions),
746            ) == (2, 2, true, false, new)
747        );
748    }
749
750    #[test]
751    fn keys_pick_the_partition_unless_ignore_keys_is_set() {
752        let cases = [
753            ("keyed", false, Some(b"kafka".as_slice()), Some(0)),
754            ("keyless", false, None, None),
755            (
756                "keyed with ignore keys",
757                true,
758                Some(b"kafka".as_slice()),
759                None,
760            ),
761        ];
762        for (name, ignore_keys, key, want) in cases {
763            let partitioner = BuiltInPartitioner::new(PartitionerConfig {
764                ignore_keys,
765                ..config(100)
766            });
767            check!(partitioner.keyed_partition(key, 4) == want, "{name}");
768        }
769    }
770
771    #[test]
772    fn distinct_topics_have_distinct_sticky_partitions() {
773        let partitioner = scripted(config(100), &[1, 3]);
774        let partitions = TopicPartitions {
775            count: 4,
776            has_leader: &all_leaders,
777        };
778        let a = partitioner.peek("a", &partitions).partition();
779        let b = partitioner.peek("b", &partitions).partition();
780        check!((a, b) == (1, 3));
781    }
782
783    #[test]
784    fn load_stats_follow_kafka() {
785        let queues = |sizes: &[u32], partition_ids: &[i32], all: usize| QueueSizes {
786            sizes: sizes.to_vec(),
787            partition_ids: partition_ids.to_vec(),
788            all,
789        };
790        let stats = |cumulative_frequency: &[u32], partition_ids: &[i32]| {
791            Some(LoadStats {
792                cumulative_frequency: cumulative_frequency.to_vec(),
793                partition_ids: partition_ids.to_vec(),
794            })
795        };
796        let cases = [
797            (
798                "Kafka's example",
799                queues(&[0, 3, 1], &[0, 1, 2], 3),
800                stats(&[4, 5, 8], &[0, 1, 2]),
801            ),
802            ("equal queues", queues(&[2, 2, 2], &[0, 1, 2], 3), None),
803            (
804                "equal queues, one partition excluded",
805                queues(&[2, 2], &[0, 2], 3),
806                stats(&[1, 2], &[0, 2]),
807            ),
808            ("one partition", queues(&[5], &[0], 1), None),
809            (
810                "one partition left of two",
811                queues(&[5], &[1], 2),
812                stats(&[1], &[1]),
813            ),
814            ("no partition left", queues(&[], &[], 3), None),
815        ];
816        for (name, queues, want) in cases {
817            check!(LoadStats::from_queue_sizes(queues) == want, "{name}");
818        }
819    }
820
821    #[test]
822    fn adaptive_pick_weights_by_queue_size() {
823        // Queue sizes 0, 3, 1 on partitions 4, 5, 6 give the table 4, 5, 8.
824        // Values 0 to 3 pick partition 4, value 4 picks 5, and values 5 to 7
825        // pick 6. Value 8 wraps to 0. Each update switches, so each loop takes
826        // one value, and the last switch takes one more.
827        let partitioner = scripted(config(1), &[0, 1, 2, 3, 4, 5, 6, 7, 8, 0]);
828        let partitions = TopicPartitions {
829            count: 7,
830            has_leader: &all_leaders,
831        };
832        partitioner.update_load_stats(
833            TOPIC,
834            Some(QueueSizes {
835                sizes: vec![0, 3, 1],
836                partition_ids: vec![4, 5, 6],
837                all: 3,
838            }),
839        );
840        let picks: Vec<i32> = iter::repeat_with(|| {
841            let sticky = partitioner.peek(TOPIC, &partitions);
842            partitioner.update(TOPIC, sticky, 1, &partitions, true);
843            sticky.partition()
844        })
845        .take(9)
846        .collect();
847        check!(picks == vec![4, 4, 4, 4, 5, 6, 6, 6, 4]);
848    }
849
850    /// Two racks of leaders: partitions 0 and 1 lead in rack "a", partitions
851    /// 2 and 3 in rack "b".
852    fn two_rack_leaders(partition: i32) -> Option<String> {
853        match partition {
854            0 | 1 => Some("a".to_owned()),
855            2 | 3 => Some("b".to_owned()),
856            _ => None,
857        }
858    }
859
860    /// The rack preference of a producer in `client_rack`, with the leaders
861    /// of [`two_rack_leaders`].
862    fn in_rack(client_rack: &str) -> RackPreference<'_> {
863        RackPreference {
864            leader_rack: &two_rack_leaders,
865            client_rack,
866        }
867    }
868
869    #[test]
870    fn rack_aware_pick_prefers_a_partition_whose_leader_is_in_the_client_rack() {
871        // Each row: the random value, and the pick. `partitioner.rack.aware`
872        // restricts the pick to partitions 0 and 1, which lead in rack "a".
873        let cases: [(&str, u32, i32); 4] = [
874            ("value 0 of the 2 in-rack", 0, 0),
875            ("value 1 of the 2 in-rack", 1, 1),
876            ("value 2 wraps to the first in-rack", 2, 0),
877            ("sign bit masked", 0x8000_0001, 1),
878        ];
879        let mut actual = Vec::new();
880        let mut expected = Vec::new();
881        for (name, random, want) in cases {
882            let partitioner = scripted(
883                PartitionerConfig {
884                    rack_aware: true,
885                    ..config(100)
886                },
887                &[random],
888            );
889            let partitions = TopicPartitions {
890                count: 4,
891                has_leader: &all_leaders,
892            };
893            let sticky = partitioner.peek_rack_aware(TOPIC, &partitions, in_rack("a"));
894            actual.push((name, sticky.partition()));
895            expected.push((name, want));
896        }
897        assert2::assert!(actual == expected);
898    }
899
900    #[test]
901    fn rack_aware_pick_falls_back_to_every_partition_with_a_leader_when_none_is_in_rack() {
902        // No leader is in rack "z", so the pick falls back to Kafka's
903        // ordinary uniform pick among the 4 partitions with a leader.
904        let partitioner = scripted(
905            PartitionerConfig {
906                rack_aware: true,
907                ..config(100)
908            },
909            &[1],
910        );
911        let partitions = TopicPartitions {
912            count: 4,
913            has_leader: &all_leaders,
914        };
915        let sticky = partitioner.peek_rack_aware(TOPIC, &partitions, in_rack("z"));
916        assert2::check!(sticky.partition() == 1);
917    }
918
919    #[test]
920    fn rack_awareness_applies_only_when_the_config_and_the_client_rack_agree() {
921        // `rack_aware: false`: the client rack is ignored, and the pick is
922        // Kafka's ordinary uniform pick among the 4 partitions with a leader.
923        let partitioner = scripted(config(100), &[3]);
924        let partitions = TopicPartitions {
925            count: 4,
926            has_leader: &all_leaders,
927        };
928        let sticky = partitioner.peek_rack_aware(TOPIC, &partitions, in_rack("a"));
929        assert2::check!(sticky.partition() == 3);
930    }
931
932    #[test]
933    fn rack_aware_adaptive_pick_weights_by_queue_size_within_the_rack() {
934        // Queue sizes 0, 3, 1, 2 on partitions 0..=3 give the full-table
935        // weights 4, 1, 3, 2 (`largest + 1 - size`). Restricted to the
936        // in-rack partitions 0 and 1, the weights renormalize to a table of
937        // 4, 5: values 0-3 pick partition 0, and 4 picks partition 1.
938        let partitioner = scripted(
939            PartitionerConfig {
940                rack_aware: true,
941                ..config(1)
942            },
943            &[0, 4],
944        );
945        let partitions = TopicPartitions {
946            count: 4,
947            has_leader: &all_leaders,
948        };
949        partitioner.update_load_stats(
950            TOPIC,
951            Some(QueueSizes {
952                sizes: vec![0, 3, 1, 2],
953                partition_ids: vec![0, 1, 2, 3],
954                all: 4,
955            }),
956        );
957        let first = partitioner.peek_rack_aware(TOPIC, &partitions, in_rack("a"));
958        partitioner.update_rack_aware(TOPIC, first, 1, &partitions, true, in_rack("a"));
959        let second = partitioner.peek_rack_aware(TOPIC, &partitions, in_rack("a"));
960        assert2::check!((first.partition(), second.partition()) == (0, 1));
961    }
962
963    #[test]
964    fn keyed_partitioning_matches_kafka_to_positive_not_absolute_value() {
965        // Kafka computes toPositive(murmur2(key)) % n, and toPositive masks the
966        // sign bit. An absolute value instead of the mask sends every key whose
967        // hash is negative to a different partition than a JVM producer picks,
968        // which breaks key ordering and co-partitioning across the two clients.
969        // Each row below is (key, num_partitions, partition), computed with
970        // Kafka's own expression.
971        for (key, partitions, want) in [
972            (b"".as_slice(), 10, 1),
973            (b"a".as_slice(), 10, 4),
974            (b"ab".as_slice(), 10, 4),
975            (b"abc".as_slice(), 10, 7),
976            (b"abcd".as_slice(), 10, 0),
977            (b"abcde".as_slice(), 10, 1),
978            (b"kafka".as_slice(), 10, 0),
979            (b"my-key".as_slice(), 10, 9),
980            (b"abcd".as_slice(), 16, 4),
981            (b"kafka".as_slice(), 16, 4),
982            (b"abcd".as_slice(), 3, 2),
983            (b"kafka".as_slice(), 3, 1),
984        ] {
985            let got = partition_for_key(key, partitions);
986            assert2::assert!(got == want, "key {key:?} over {partitions} partitions");
987            assert2::assert!(got >= 0 && got < partitions);
988        }
989    }
990
991    #[test]
992    fn a_negative_hash_masks_its_sign_bit_rather_than_negating() {
993        // "kafka" hashes to -798503068. The mask gives 1348980580, and the
994        // absolute value would give 798503068. The two land on different
995        // partitions, so this pins the one Kafka uses.
996        let hash = murmur2(b"kafka");
997        assert2::assert!(hash == -798_503_068);
998        assert2::assert!((hash & 0x7fff_ffff) == 1_348_980_580);
999        assert2::assert!(partition_for_key(b"kafka", 10) == 1_348_980_580 % 10);
1000    }
1001
1002    #[test]
1003    fn murmur2_matches_kafka_golden_vectors() {
1004        for (_name, input, want) in [
1005            ("empty", b"".as_slice(), 275_646_681),
1006            ("one byte", b"a".as_slice(), -1_563_381_124),
1007            ("two bytes", b"ab".as_slice(), 316_155_434),
1008            ("three bytes", b"abc".as_slice(), 479_470_107),
1009            ("four bytes", b"abcd".as_slice(), -1_323_649_548),
1010            ("five bytes", b"abcde".as_slice(), 461_995_741),
1011            ("kafka", b"kafka".as_slice(), -798_503_068),
1012            ("my key", b"my-key".as_slice(), 1_748_425_209),
1013        ] {
1014            assert2::assert!(murmur2(input) == want);
1015        }
1016    }
1017}