use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use chrono::{DateTime, Utc};
use rainier_support::{Error, Result};
use rskafka::client::error::{Error as KafkaError, ProtocolError};
use rskafka::client::partition::{Compression, OffsetAt, PartitionClient, UnknownTopicHandling};
use rskafka::client::{Client, ClientBuilder};
use rskafka::record::Record;
pub const DEFAULT_PORT: u16 = 9092;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone)]
pub struct KafkaCredentials {
mechanism: SaslMechanism,
username: String,
password: String,
}
impl KafkaCredentials {
pub fn new(
mechanism: SaslMechanism,
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
Self { mechanism, username: username.into(), password: password.into() }
}
pub fn mechanism(&self) -> SaslMechanism {
self.mechanism
}
pub fn username(&self) -> &str {
&self.username
}
}
impl std::fmt::Debug for KafkaCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KafkaCredentials")
.field("mechanism", &self.mechanism)
.field("username", &self.username)
.field("password", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaslMechanism {
Plain,
ScramSha256,
ScramSha512,
}
impl SaslMechanism {
pub fn name(&self) -> &'static str {
match self {
Self::Plain => "PLAIN",
Self::ScramSha256 => "SCRAM-SHA-256",
Self::ScramSha512 => "SCRAM-SHA-512",
}
}
}
#[derive(Clone, Debug)]
pub struct KafkaConnector {
brokers: Vec<String>,
client_id: Option<String>,
credentials: Option<KafkaCredentials>,
tls: bool,
max_message_size: Option<usize>,
timeout: Duration,
}
impl KafkaConnector {
pub fn new(brokers: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
brokers: brokers.into_iter().map(Into::into).map(|b| with_default_port(&b)).collect(),
client_id: None,
credentials: None,
tls: false,
max_message_size: None,
timeout: DEFAULT_TIMEOUT,
}
}
pub fn parse(brokers: &str) -> Self {
Self::new(brokers.split(',').map(str::trim).filter(|broker| !broker.is_empty()))
}
pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
self.client_id = Some(client_id.into());
self
}
pub fn with_credentials(mut self, credentials: KafkaCredentials) -> Self {
self.credentials = Some(credentials);
self
}
pub fn with_tls(mut self) -> Self {
self.tls = true;
self
}
pub fn with_max_message_size(mut self, bytes: usize) -> Self {
self.max_message_size = Some(bytes);
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn timeout(&self) -> Duration {
self.timeout
}
pub fn brokers(&self) -> &[String] {
&self.brokers
}
pub fn is_tls(&self) -> bool {
self.tls
}
pub fn credentials(&self) -> Option<&KafkaCredentials> {
self.credentials.as_ref()
}
}
fn with_default_port(host: &str) -> String {
let has_port = match host.rsplit_once(']') {
Some((_, rest)) => rest.starts_with(':'),
None => host.contains(':'),
};
if has_port {
host.to_string()
} else {
format!("{host}:{DEFAULT_PORT}")
}
}
#[derive(Debug, Clone, Default)]
pub struct KafkaRecord {
pub key: Option<Vec<u8>>,
pub value: Vec<u8>,
pub headers: BTreeMap<String, Vec<u8>>,
pub timestamp: Option<DateTime<Utc>>,
}
impl KafkaRecord {
pub fn new(value: impl Into<Vec<u8>>) -> Self {
Self { value: value.into(), ..Default::default() }
}
pub fn keyed(mut self, key: impl Into<Vec<u8>>) -> Self {
self.key = Some(key.into());
self
}
pub fn header(mut self, name: impl Into<String>, value: impl Into<Vec<u8>>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
pub fn at(mut self, timestamp: DateTime<Utc>) -> Self {
self.timestamp = Some(timestamp);
self
}
}
#[derive(Debug, Clone)]
pub struct KafkaMessage {
pub topic: String,
pub partition: i32,
pub offset: i64,
pub key: Option<Vec<u8>>,
pub value: Vec<u8>,
pub headers: BTreeMap<String, Vec<u8>>,
pub timestamp: DateTime<Utc>,
}
impl KafkaMessage {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).and_then(|value| std::str::from_utf8(value).ok())
}
pub fn position(&self) -> KafkaPosition {
KafkaPosition { topic: self.topic.clone(), partition: self.partition, offset: self.offset }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KafkaPosition {
pub topic: String,
pub partition: i32,
pub offset: i64,
}
impl std::fmt::Display for KafkaPosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}:{}", self.topic, self.partition, self.offset)
}
}
impl std::str::FromStr for KafkaPosition {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
let (topic, rest) = value
.rsplit_once(':')
.and_then(|(head, offset)| head.rsplit_once(':').map(|(t, p)| (t, (p, offset))))
.ok_or_else(|| Error::internal(format!("`{value}` is not a Kafka position")))?;
let (partition, offset) = rest;
Ok(Self {
topic: topic.to_string(),
partition: partition
.parse()
.map_err(|_| Error::internal(format!("`{partition}` is not a partition")))?,
offset: offset
.parse()
.map_err(|_| Error::internal(format!("`{offset}` is not an offset")))?,
})
}
}
#[derive(Debug, Clone)]
pub struct KafkaFetch {
pub messages: Vec<KafkaMessage>,
pub high_watermark: i64,
}
impl KafkaFetch {
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
pub fn next_offset(&self) -> Option<i64> {
self.messages.last().map(|message| message.offset + 1)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KafkaOffset {
Earliest,
Latest,
}
pub struct KafkaClient {
client: Client,
timeout: Duration,
partitions: Mutex<HashMap<(String, i32), Arc<PartitionClient>>>,
next_partition: AtomicUsize,
}
impl KafkaClient {
pub async fn connect(connector: &KafkaConnector) -> Result<Self> {
if connector.brokers.is_empty() {
return Err(Error::internal("Kafka needs at least one bootstrap broker."));
}
let mut builder =
ClientBuilder::new(connector.brokers.clone()).backoff_config(rskafka::BackoffConfig {
init_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(2),
base: 2.0,
deadline: Some(connector.timeout),
});
if let Some(client_id) = &connector.client_id {
builder = builder.client_id(client_id.clone());
}
if let Some(size) = connector.max_message_size {
builder = builder.max_message_size(size);
}
if let Some(credentials) = &connector.credentials {
builder = builder.sasl_config(sasl_config(credentials));
}
builder = with_tls(builder, connector)?;
let brokers = connector.brokers.join(", ");
let client =
within(connector.timeout, format!("connecting to Kafka at {brokers}"), async {
builder.build().await.map_err(|e| {
connection_error(format!("could not reach Kafka at {brokers}: {e}"))
})
})
.await?;
Ok(Self {
client,
timeout: connector.timeout,
partitions: Mutex::new(HashMap::new()),
next_partition: AtomicUsize::new(0),
})
}
pub async fn topics(&self) -> Result<Vec<String>> {
Ok(self.metadata().await?.into_iter().map(|topic| topic.name).collect())
}
async fn metadata(&self) -> Result<Vec<rskafka::topic::Topic>> {
within(self.timeout, "listing Kafka topics", async {
self.client
.list_topics()
.await
.map_err(|e| connection_error(format!("could not list topics: {e}")))
})
.await
}
pub async fn partitions(&self, topic: &str) -> Result<Option<Vec<i32>>> {
let topics = self.metadata().await?;
Ok(topics
.into_iter()
.find(|candidate| candidate.name == topic)
.map(|found| found.partitions.into_iter().collect()))
}
pub async fn create_topic(
&self,
topic: &str,
partitions: i32,
replication_factor: i16,
) -> Result<bool> {
let controller = self
.client
.controller_client()
.map_err(|e| connection_error(format!("could not reach the controller: {e}")))?;
let created = within(self.timeout, format!("creating the Kafka topic `{topic}`"), async {
match controller.create_topic(topic, partitions, replication_factor, 5_000).await {
Ok(()) => Ok(true),
Err(e) if already_exists(&e) => Ok(false),
Err(e) => Err(connection_error(format!("could not create `{topic}`: {e}"))),
}
})
.await?;
self.await_topic(topic).await?;
Ok(created)
}
async fn await_topic(&self, topic: &str) -> Result<()> {
let deadline = tokio::time::Instant::now() + self.timeout;
loop {
if self.partitions(topic).await?.is_some_and(|found| !found.is_empty()) {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(connection_error(format!(
"`{topic}` was created but the cluster has not reported it within {:?}",
self.timeout
)));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
pub async fn produce(
&self,
topic: &str,
records: Vec<KafkaRecord>,
) -> Result<Vec<KafkaPosition>> {
if records.is_empty() {
return Ok(vec![]);
}
let partitions = self
.partitions(topic)
.await?
.filter(|found| !found.is_empty())
.ok_or_else(|| unknown_topic(topic))?;
let mut batched: HashMap<i32, Vec<(usize, KafkaRecord)>> = HashMap::new();
for (index, record) in records.into_iter().enumerate() {
let partition = self.partition_for(&record, &partitions);
batched.entry(partition).or_default().push((index, record));
}
let mut placed: Vec<Option<KafkaPosition>> =
(0..batched.values().map(Vec::len).sum()).map(|_| None).collect();
for (partition, batch) in batched {
let (indexes, records): (Vec<_>, Vec<_>) = batch.into_iter().unzip();
let offsets = self.produce_to(topic, partition, records).await?;
for (index, offset) in indexes.into_iter().zip(offsets) {
placed[index] = Some(KafkaPosition { topic: topic.to_string(), partition, offset });
}
}
Ok(placed.into_iter().flatten().collect())
}
pub async fn produce_to(
&self,
topic: &str,
partition: i32,
records: Vec<KafkaRecord>,
) -> Result<Vec<i64>> {
if records.is_empty() {
return Ok(vec![]);
}
let client = self.partition_client(topic, partition).await?;
let records = records.into_iter().map(Record::from).collect();
client.produce(records, Compression::NoCompression).await.map_err(|e| {
connection_error(format!("could not produce to `{topic}/{partition}`: {e}"))
})
}
pub async fn fetch(
&self,
topic: &str,
partition: i32,
offset: i64,
max_bytes: i32,
max_wait: Duration,
) -> Result<Option<KafkaFetch>> {
let client = self.partition_client(topic, partition).await?;
let outcome =
within(self.timeout + max_wait, format!("fetching `{topic}/{partition}`"), async {
Ok(client
.fetch_records(
offset,
1..max_bytes.max(2),
max_wait.as_millis().min(i32::MAX as u128) as i32,
)
.await)
})
.await?;
let (records, high_watermark) = match outcome {
Ok(fetched) => fetched,
Err(e) if out_of_range(&e) => return Ok(None),
Err(e) => {
return Err(connection_error(format!(
"could not fetch from `{topic}/{partition}` at {offset}: {e}"
)))
}
};
Ok(Some(KafkaFetch {
messages: records
.into_iter()
.map(|found| KafkaMessage {
topic: topic.to_string(),
partition,
offset: found.offset,
key: found.record.key,
value: found.record.value.unwrap_or_default(),
headers: found.record.headers,
timestamp: found.record.timestamp,
})
.collect(),
high_watermark,
}))
}
pub async fn offset(&self, topic: &str, partition: i32, at: KafkaOffset) -> Result<i64> {
let client = self.partition_client(topic, partition).await?;
let at = match at {
KafkaOffset::Earliest => OffsetAt::Earliest,
KafkaOffset::Latest => OffsetAt::Latest,
};
within(self.timeout, format!("reading the offsets of `{topic}/{partition}`"), async {
client
.get_offset(at)
.await
.map_err(|e| connection_error(format!("could not read `{topic}/{partition}`: {e}")))
})
.await
}
fn partition_for(&self, record: &KafkaRecord, partitions: &[i32]) -> i32 {
match &record.key {
Some(key) => partitions[partition_for_key(key, partitions.len())],
None => {
partitions[self.next_partition.fetch_add(1, Ordering::Relaxed) % partitions.len()]
}
}
}
async fn partition_client(&self, topic: &str, partition: i32) -> Result<Arc<PartitionClient>> {
let key = (topic.to_string(), partition);
if let Some(client) = self.cached(&key) {
return Ok(client);
}
let client =
within(self.timeout, format!("finding the leader of `{topic}/{partition}`"), async {
self.client
.partition_client(topic.to_string(), partition, UnknownTopicHandling::Error)
.await
.map_err(|e| {
connection_error(format!(
"could not reach the leader of `{topic}/{partition}`: {e}"
))
})
})
.await?;
let client = Arc::new(client);
self.partitions.lock().expect("kafka clients poisoned").insert(key, Arc::clone(&client));
Ok(client)
}
fn cached(&self, key: &(String, i32)) -> Option<Arc<PartitionClient>> {
self.partitions.lock().expect("kafka clients poisoned").get(key).map(Arc::clone)
}
}
impl std::fmt::Debug for KafkaClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KafkaClient")
.field("partition_clients", &self.partitions.lock().map(|p| p.len()).unwrap_or(0))
.finish()
}
}
impl From<KafkaRecord> for Record {
fn from(record: KafkaRecord) -> Self {
Record {
key: record.key,
value: Some(record.value),
headers: record.headers,
timestamp: record.timestamp.unwrap_or_else(Utc::now),
}
}
}
pub fn partition_for_key(key: &[u8], partitions: usize) -> usize {
assert!(partitions > 0, "a topic with no partitions cannot hold a record");
(murmur2(key) & 0x7fff_ffff) as usize % partitions
}
fn murmur2(data: &[u8]) -> u32 {
const SEED: u32 = 0x9747_b28c;
const M: u32 = 0x5bd1_e995;
const R: u32 = 24;
let length = data.len();
let mut h = SEED ^ (length as u32);
let blocks = length / 4;
for block in 0..blocks {
let i = block * 4;
let mut k = (data[i] as u32)
| ((data[i + 1] as u32) << 8)
| ((data[i + 2] as u32) << 16)
| ((data[i + 3] as u32) << 24);
k = k.wrapping_mul(M);
k ^= k >> R;
k = k.wrapping_mul(M);
h = h.wrapping_mul(M);
h ^= k;
}
let tail = length & !3;
match length % 4 {
3 => {
h ^= (data[tail + 2] as u32) << 16;
h ^= (data[tail + 1] as u32) << 8;
h ^= data[tail] as u32;
h = h.wrapping_mul(M);
}
2 => {
h ^= (data[tail + 1] as u32) << 8;
h ^= data[tail] as u32;
h = h.wrapping_mul(M);
}
1 => {
h ^= data[tail] as u32;
h = h.wrapping_mul(M);
}
_ => {}
}
h ^= h >> 13;
h = h.wrapping_mul(M);
h ^= h >> 15;
h
}
fn sasl_config(credentials: &KafkaCredentials) -> rskafka::client::SaslConfig {
use rskafka::client::{Credentials, SaslConfig};
let credential = Credentials::new(credentials.username.clone(), credentials.password.clone());
match credentials.mechanism {
SaslMechanism::Plain => SaslConfig::Plain(credential),
SaslMechanism::ScramSha256 => SaslConfig::ScramSha256(credential),
SaslMechanism::ScramSha512 => SaslConfig::ScramSha512(credential),
}
}
#[cfg(feature = "kafka-tls")]
fn with_tls(builder: ClientBuilder, connector: &KafkaConnector) -> Result<ClientBuilder> {
if !connector.tls {
return Ok(builder);
}
let mut roots = rustls::RootCertStore::empty();
let found = rustls_native_certs::load_native_certs();
for certificate in found.certs {
let _ = roots.add(certificate);
}
if roots.is_empty() {
return Err(Error::internal(
"Kafka was asked for TLS but this machine has no trusted root certificates.",
));
}
let config = rustls::ClientConfig::builder_with_provider(
rustls::crypto::ring::default_provider().into(),
)
.with_safe_default_protocol_versions()
.map_err(|e| Error::internal(format!("TLS could not be configured: {e}")))?
.with_root_certificates(roots)
.with_no_client_auth();
Ok(builder.tls_config(std::sync::Arc::new(config)))
}
#[cfg(not(feature = "kafka-tls"))]
fn with_tls(builder: ClientBuilder, connector: &KafkaConnector) -> Result<ClientBuilder> {
if connector.tls {
return Err(Error::internal(
"Kafka TLS needs the `kafka-tls` feature. Connecting without it would send \
credentials in the clear, so this stops instead.",
));
}
Ok(builder)
}
async fn within<T>(
budget: Duration,
what: impl std::fmt::Display,
work: impl std::future::Future<Output = Result<T>>,
) -> Result<T> {
match tokio::time::timeout(budget, work).await {
Ok(outcome) => outcome,
Err(_) => Err(connection_error(format!("{what} timed out after {budget:?}"))),
}
}
fn already_exists(error: &KafkaError) -> bool {
matches!(
error,
KafkaError::ServerError { protocol_error, .. }
if matches!(protocol_error, ProtocolError::TopicAlreadyExists)
)
}
fn out_of_range(error: &KafkaError) -> bool {
matches!(
error,
KafkaError::ServerError { protocol_error, .. }
if matches!(protocol_error, ProtocolError::OffsetOutOfRange)
)
}
fn unknown_topic(topic: &str) -> Error {
Error::service_unavailable(format!(
"the Kafka topic `{topic}` does not exist or has no partitions."
))
}
fn connection_error(message: String) -> Error {
Error::service_unavailable(message)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_host_gets_the_default_port() {
assert_eq!(with_default_port("localhost"), "localhost:9092");
assert_eq!(with_default_port("localhost:19092"), "localhost:19092");
}
#[test]
fn an_ipv6_literal_is_not_mistaken_for_a_port() {
assert_eq!(with_default_port("[::1]"), "[::1]:9092");
assert_eq!(with_default_port("[::1]:9092"), "[::1]:9092");
}
#[test]
fn there_is_a_deadline_on_retrying() {
assert_eq!(KafkaConnector::parse("kafka:9092").timeout(), DEFAULT_TIMEOUT);
assert_eq!(
KafkaConnector::parse("kafka:9092").with_timeout(Duration::from_secs(2)).timeout(),
Duration::from_secs(2)
);
}
#[test]
fn a_broker_list_is_split_and_trimmed() {
let connector = KafkaConnector::parse(" kafka-1:9092 , kafka-2 ,, ");
assert_eq!(connector.brokers(), ["kafka-1:9092", "kafka-2:9092"]);
}
#[test]
fn the_same_key_always_reaches_the_same_partition() {
for partitions in [1usize, 3, 8, 12, 64] {
let first = partition_for_key(b"orders-7", partitions);
assert_eq!(first, partition_for_key(b"orders-7", partitions));
assert!(first < partitions, "{first} is not a partition of {partitions}");
}
}
#[test]
fn murmur2_matches_the_java_client() {
assert_eq!(murmur2(b"21") as i32, -973_932_308);
assert_eq!(murmur2(b"") as i32, 275_646_681);
assert_eq!(murmur2(b"a") as i32, -1_563_381_124);
assert_eq!(murmur2(b"ab") as i32, 316_155_434);
assert_eq!(murmur2(b"abc") as i32, 479_470_107);
assert_eq!(murmur2(b"abcd") as i32, -1_323_649_548);
assert_eq!(murmur2(b"abcde") as i32, 461_995_741);
assert_eq!(murmur2(b"user-7") as i32, 696_778_364);
}
#[test]
fn a_key_lands_where_the_java_producer_would_put_it() {
assert_eq!(partition_for_key(b"user-7", 12), 8);
assert_eq!(partition_for_key(b"orders.7", 12), 7);
assert_eq!(partition_for_key(b"21", 12), 0);
}
#[test]
fn a_key_never_lands_on_a_negative_partition() {
for n in 0..500u32 {
let key = format!("key-{n}");
let partition = partition_for_key(key.as_bytes(), 7);
assert!(partition < 7, "`{key}` landed on {partition}");
}
}
#[test]
fn keys_spread_across_the_partitions() {
let mut seen = std::collections::HashSet::new();
for n in 0..200u32 {
seen.insert(partition_for_key(format!("user-{n}").as_bytes(), 8));
}
assert!(seen.len() > 5, "200 keys reached only {} of 8 partitions", seen.len());
}
#[test]
fn a_position_round_trips_through_its_string() {
let position = KafkaPosition { topic: "jobs".into(), partition: 3, offset: 4_982 };
assert_eq!(position.to_string(), "jobs:3:4982");
assert_eq!("jobs:3:4982".parse::<KafkaPosition>().unwrap(), position);
}
#[test]
fn a_position_that_is_not_one_is_an_error_rather_than_a_panic() {
assert!("jobs:3".parse::<KafkaPosition>().is_err());
assert!("jobs:everywhere:4".parse::<KafkaPosition>().is_err());
assert!("".parse::<KafkaPosition>().is_err());
}
#[test]
fn credentials_do_not_print_the_password() {
let credentials = KafkaCredentials::new(SaslMechanism::Plain, "svc-checkout", "hunter2");
let printed = format!("{credentials:?}");
assert!(printed.contains("svc-checkout"));
assert!(!printed.contains("hunter2"), "{printed}");
}
#[test]
fn a_record_carries_its_key_and_headers() {
let record = KafkaRecord::new("body").keyed("orders.7").header("event", "OrderShipped");
assert_eq!(record.key.as_deref(), Some(&b"orders.7"[..]));
assert_eq!(record.headers.get("event").map(Vec::as_slice), Some(&b"OrderShipped"[..]));
}
#[test]
fn the_next_offset_is_one_past_the_last_message() {
let fetch = KafkaFetch { messages: vec![message(10), message(11)], high_watermark: 12 };
assert_eq!(fetch.next_offset(), Some(12));
assert!(!fetch.is_empty());
}
#[test]
fn an_empty_fetch_has_nowhere_to_advance_to() {
let fetch = KafkaFetch { messages: vec![], high_watermark: 12 };
assert_eq!(fetch.next_offset(), None, "advancing past nothing would skip a record");
assert!(fetch.is_empty());
}
fn message(offset: i64) -> KafkaMessage {
KafkaMessage {
topic: "jobs".into(),
partition: 0,
offset,
key: None,
value: vec![],
headers: BTreeMap::new(),
timestamp: Utc::now(),
}
}
}