pub mod actions;
pub use actions::KafkaClientProtocol;
use anyhow::{anyhow, bail, Context, Result};
use bytes::Bytes;
use std::collections::{HashMap, VecDeque};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, error, info, trace, warn};
use crate::client::kafka::actions::{
kafka_error_name, KAFKA_CLIENT_CONNECTED_EVENT, KAFKA_CLIENT_MESSAGE_DELIVERED_EVENT,
KAFKA_CLIENT_METADATA_RECEIVED_EVENT, KAFKA_CLIENT_RECORDS_RECEIVED_EVENT,
};
use crate::client::llm_budget::call_llm_for_client;
use crate::llm::actions::client_trait::{Client, ClientActionResult};
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ClientLlmResult;
use crate::protocol::{Event, StartupParams};
use crate::state::app_state::AppState;
use crate::state::{ClientId, ClientStatus};
use crate::server::kafka::kafka_protocol;
use crate::server::kafka::{decode_field, encode_field};
use kafka_protocol::messages::{
ApiKey, ApiVersionsRequest, ApiVersionsResponse, FetchRequest, FetchResponse, MetadataRequest,
MetadataResponse, OffsetCommitRequest, OffsetCommitResponse, ProduceRequest, ProduceResponse,
RequestHeader, ResponseHeader,
};
use kafka_protocol::protocol::{Decodable, Encodable, StrBytes};
use kafka_protocol::records::{
Compression, Record, RecordBatchDecoder, RecordBatchEncoder, RecordEncodeOptions, TimestampType,
};
const IO_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_RESPONSE_BYTES: i64 = 100 * 1024 * 1024;
const CLIENT_MAX_API_VERSIONS: i16 = 3;
const CLIENT_MAX_METADATA: i16 = 12;
const CLIENT_MAX_PRODUCE: i16 = 11;
const CLIENT_MAX_FETCH: i16 = 16;
const CLIENT_MAX_OFFSET_COMMIT: i16 = 9;
const FETCH_BY_TOPIC_ID: i16 = 13;
const METADATA_WITH_TOPIC_IDS: i16 = 10;
const MAX_TOPIC_IDS: usize = 1024;
const MAX_EVENT_RECORDS: usize = 20;
const MAX_EVENT_VALUE_BYTES: usize = 1024;
const DEFAULT_PARTITION_MAX_BYTES: i32 = 1024 * 1024;
const DEFAULT_POLL_INTERVAL_MS: u64 = 1000;
const MIN_POLL_INTERVAL_MS: u64 = 50;
const DEFAULT_CLIENT_ID: &str = "netget-kafka-client";
const DEFAULT_GROUP_ID: &str = "netget-consumer-group";
type Compressor = fn(&mut bytes::BytesMut, &mut Vec<u8>, Compression) -> Result<()>;
type Decompressor = fn(&mut Bytes, Compression) -> Result<std::io::Cursor<Bytes>>;
#[derive(Clone, Debug)]
struct Assignment {
topic: String,
partition: i32,
next_offset: i64,
}
struct KafkaConn {
stream: TcpStream,
next_correlation_id: i32,
versions: HashMap<i16, (i16, i16)>,
client_id: StrBytes,
last_request_bytes: usize,
topic_ids: HashMap<String, uuid::Uuid>,
}
impl KafkaConn {
async fn topic_id(&mut self, topic: &str) -> Result<Option<uuid::Uuid>> {
if let Some(id) = self.topic_ids.get(topic) {
return Ok(Some(*id));
}
use kafka_protocol::messages::metadata_request::MetadataRequestTopic;
let version = self.version_for(ApiKey::Metadata, CLIENT_MAX_METADATA)?;
if version < METADATA_WITH_TOPIC_IDS {
return Ok(None);
}
let response: MetadataResponse = self
.exchange(
ApiKey::Metadata,
version,
&MetadataRequest::default()
.with_topics(Some(vec![MetadataRequestTopic::default()
.with_name(Some(StrBytes::from_string(topic.to_string()).into()))])),
)
.await
.context("Metadata request for a topic id failed")?;
self.remember_topic_ids(&response);
Ok(self.topic_ids.get(topic).copied())
}
fn remember_topic_ids(&mut self, response: &MetadataResponse) {
for t in &response.topics {
let Some(name) = t.name.as_ref() else {
continue;
};
if t.error_code != 0 || t.topic_id.is_nil() {
continue;
}
if self.topic_ids.len() >= MAX_TOPIC_IDS && !self.topic_ids.contains_key(name.as_str())
{
self.topic_ids.clear();
}
self.topic_ids.insert(name.to_string(), t.topic_id);
}
}
fn version_for(&self, api_key: ApiKey, our_max: i16) -> Result<i16> {
let (broker_min, broker_max) =
*self.versions.get(&(api_key as i16)).with_context(|| {
format!(
"broker does not advertise {api_key:?} (key {})",
api_key as i16
)
})?;
let chosen = our_max.min(broker_max);
if chosen < broker_min {
bail!(
"broker supports {api_key:?} v{broker_min}-v{broker_max}, but this client \
implements at most v{our_max}"
);
}
Ok(chosen)
}
fn build_request<B: Encodable>(
&mut self,
api_key: ApiKey,
api_version: i16,
body: &B,
) -> Result<(i32, Vec<u8>)> {
let correlation_id = self.next_correlation_id;
self.next_correlation_id = self.next_correlation_id.wrapping_add(1);
let mut buf = Vec::new();
RequestHeader::default()
.with_request_api_key(api_key as i16)
.with_request_api_version(api_version)
.with_correlation_id(correlation_id)
.with_client_id(Some(self.client_id.clone()))
.encode(&mut buf, api_key.request_header_version(api_version))
.with_context(|| format!("encoding {api_key:?} v{api_version} request header"))?;
body.encode(&mut buf, api_version)
.with_context(|| format!("encoding {api_key:?} v{api_version} request body"))?;
Ok((correlation_id, buf))
}
async fn write_frame(&mut self, body: &[u8]) -> Result<()> {
let size = i32::try_from(body.len()).context("request does not fit in an i32 frame")?;
tokio::time::timeout(IO_TIMEOUT, self.stream.write_all(&size.to_be_bytes()))
.await
.context("timed out writing Kafka frame size")??;
tokio::time::timeout(IO_TIMEOUT, self.stream.write_all(body))
.await
.context("timed out writing Kafka frame body")??;
self.last_request_bytes = 4 + body.len();
Ok(())
}
async fn read_frame(&mut self) -> Result<Vec<u8>> {
let mut size = [0u8; 4];
tokio::time::timeout(IO_TIMEOUT, self.stream.read_exact(&mut size))
.await
.context("timed out reading Kafka response size")??;
let announced = i64::from(i32::from_be_bytes(size));
if announced <= 0 || announced > MAX_RESPONSE_BYTES {
bail!("broker announced an implausible response size of {announced} bytes");
}
let mut buf = vec![0u8; announced as usize];
tokio::time::timeout(IO_TIMEOUT, self.stream.read_exact(&mut buf))
.await
.context("timed out reading Kafka response body")??;
Ok(buf)
}
async fn exchange<B: Encodable, R: Decodable>(
&mut self,
api_key: ApiKey,
api_version: i16,
body: &B,
) -> Result<R> {
let (correlation_id, request) = self.build_request(api_key, api_version, body)?;
self.write_frame(&request).await?;
let response = self.read_frame().await?;
let mut cursor = std::io::Cursor::new(response.as_slice());
let header =
ResponseHeader::decode(&mut cursor, api_key.response_header_version(api_version))
.with_context(|| format!("decoding {api_key:?} v{api_version} response header"))?;
if header.correlation_id != correlation_id {
bail!(
"broker answered {api_key:?} with correlation id {} instead of {correlation_id}; \
the connection is desynchronised",
header.correlation_id
);
}
R::decode(&mut cursor, api_version)
.with_context(|| format!("decoding {api_key:?} v{api_version} response body"))
}
async fn send_only<B: Encodable>(
&mut self,
api_key: ApiKey,
api_version: i16,
body: &B,
) -> Result<()> {
let (_, request) = self.build_request(api_key, api_version, body)?;
self.write_frame(&request).await
}
}
#[derive(Clone)]
struct Session {
conn: Arc<Mutex<KafkaConn>>,
assignments: Arc<Mutex<Vec<Assignment>>>,
memory: Arc<Mutex<String>>,
client_id: ClientId,
app_state: Arc<AppState>,
llm_client: OllamaClient,
status_tx: mpsc::UnboundedSender<String>,
default_group_id: String,
remote_addr: String,
}
pub struct KafkaClient;
impl KafkaClient {
pub async fn connect_with_llm_actions(
remote_addr: String,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
client_id: ClientId,
startup_params: Option<StartupParams>,
) -> Result<SocketAddr> {
let params = startup_params.as_ref();
let client_id_str = params
.map(|p| p.get_optional_string("client_id"))
.transpose()?
.flatten()
.unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string());
let topics: Vec<String> = params
.map(|p| p.get_optional_array("topics"))
.transpose()?
.flatten()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
let partition_raw = params
.map(|p| p.get_optional_i64("partition"))
.transpose()?
.flatten()
.unwrap_or(0);
if !(0..=actions::MAX_PARTITION).contains(&partition_raw) {
bail!(
"Kafka client 'partition' must be between 0 and {}, got {partition_raw}",
actions::MAX_PARTITION
);
}
let partition = partition_raw as i32;
let start_offset = params
.map(|p| p.get_optional_i64("start_offset"))
.transpose()?
.flatten()
.unwrap_or(0);
if start_offset < 0 {
bail!("Kafka client 'start_offset' must not be negative, got {start_offset}");
}
let poll_interval_raw = params
.map(|p| p.get_optional_u64("poll_interval_ms"))
.transpose()?
.flatten()
.unwrap_or(DEFAULT_POLL_INTERVAL_MS);
if poll_interval_raw < MIN_POLL_INTERVAL_MS {
bail!(
"Kafka client 'poll_interval_ms' must be at least {MIN_POLL_INTERVAL_MS}, got \
{poll_interval_raw}"
);
}
let poll_interval = Duration::from_millis(poll_interval_raw);
let group_id = params
.map(|p| p.get_optional_string("group_id"))
.transpose()?
.flatten()
.unwrap_or_else(|| DEFAULT_GROUP_ID.to_string());
info!(
"Kafka client {} connecting to {} (client_id={}, topics={:?})",
client_id, remote_addr, client_id_str, topics
);
let stream = tokio::time::timeout(IO_TIMEOUT, TcpStream::connect(&remote_addr))
.await
.with_context(|| format!("timed out connecting to Kafka broker {remote_addr}"))?
.with_context(|| format!("failed to connect to Kafka broker {remote_addr}"))?;
let local_addr = stream.local_addr()?;
let mut conn = KafkaConn {
stream,
next_correlation_id: 1,
versions: HashMap::new(),
client_id: StrBytes::from_string(client_id_str.clone()),
last_request_bytes: 0,
topic_ids: HashMap::new(),
};
conn.versions = Self::negotiate_api_versions(&mut conn).await?;
let negotiated = serde_json::json!({
"metadata": conn.version_for(ApiKey::Metadata, CLIENT_MAX_METADATA)?,
"produce": conn.version_for(ApiKey::Produce, CLIENT_MAX_PRODUCE)?,
"fetch": conn.version_for(ApiKey::Fetch, CLIENT_MAX_FETCH)?,
"offset_commit": conn.version_for(ApiKey::OffsetCommit, CLIENT_MAX_OFFSET_COMMIT)?,
});
let requested: Option<Vec<String>> = if topics.is_empty() {
None
} else {
Some(topics.clone())
};
let mut metadata = Self::request_metadata(&mut conn, requested.as_deref()).await?;
info!(
"Kafka client {} negotiated {} and read metadata for {} topic(s)",
client_id,
negotiated,
metadata
.get("topics")
.and_then(|t| t.as_array())
.map(|a| a.len())
.unwrap_or(0)
);
app_state
.update_client_status(client_id, ClientStatus::Connected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] Kafka client {client_id} connected to {remote_addr}"
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
app_state
.with_client_mut(client_id, |client_inst| {
client_inst
.set_protocol_field("brokers".to_string(), serde_json::json!(remote_addr));
client_inst.set_protocol_field(
"kafka_client_id".to_string(),
serde_json::json!(client_id_str),
);
client_inst.set_protocol_field("api_versions".to_string(), negotiated.clone());
})
.await;
let assignments: Vec<Assignment> = topics
.iter()
.map(|topic| Assignment {
topic: topic.clone(),
partition,
next_offset: start_offset,
})
.collect();
let polling = !assignments.is_empty();
let memory = app_state
.get_memory_for_client(client_id)
.await
.unwrap_or_default();
let session = Session {
conn: Arc::new(Mutex::new(conn)),
assignments: Arc::new(Mutex::new(assignments)),
memory: Arc::new(Mutex::new(memory)),
client_id,
app_state: app_state.clone(),
llm_client,
status_tx: status_tx.clone(),
default_group_id: group_id,
remote_addr: remote_addr.clone(),
};
let connected_data = {
let obj = metadata
.as_object_mut()
.expect("request_metadata builds an object");
obj.insert("remote_addr".to_string(), serde_json::json!(remote_addr));
obj.insert("api_versions".to_string(), negotiated);
metadata
};
let command_rx =
crate::client::command_support::register_command_channel(&app_state, client_id).await;
let command_session = session.clone();
let command_task = tokio::spawn(async move {
command_session.command_loop(command_rx).await;
});
app_state
.register_client_task(client_id, command_task)
.await;
let task_registrar = app_state.clone();
let task_handle = tokio::spawn(async move {
let stop = session
.drive(Event::new(&KAFKA_CLIENT_CONNECTED_EVENT, connected_data))
.await;
if stop {
session.close("handler asked to disconnect").await;
return;
}
if !polling {
debug!(
"Kafka client {} has no topics to poll; the connection stays open for \
scheduled tasks and further instructions",
session.client_id
);
return;
}
session.poll_loop(poll_interval).await;
});
task_registrar
.register_client_task(client_id, task_handle)
.await;
Ok(local_addr)
}
async fn negotiate_api_versions(conn: &mut KafkaConn) -> Result<HashMap<i16, (i16, i16)>> {
let request = ApiVersionsRequest::default()
.with_client_software_name(StrBytes::from_static_str("netget"))
.with_client_software_version(StrBytes::from_static_str(env!("CARGO_PKG_VERSION")));
let (correlation_id, bytes) =
conn.build_request(ApiKey::ApiVersions, CLIENT_MAX_API_VERSIONS, &request)?;
conn.write_frame(&bytes).await?;
let response = conn.read_frame().await?;
let decoded = Self::decode_api_versions(&response, CLIENT_MAX_API_VERSIONS, correlation_id)
.and_then(|(code, body)| {
if code == 0 {
Ok(body)
} else {
Err(anyhow!(
"ApiVersions v{CLIENT_MAX_API_VERSIONS} refused with error {code}"
))
}
});
let body = match decoded {
Ok(body) => body,
Err(first) => {
debug!("Kafka client stepping ApiVersions down to v0: {}", first);
let (code, body) = Self::decode_api_versions(&response, 0, correlation_id)
.context("broker's ApiVersions reply is unreadable at v3 and at v0")?;
if code != 0 && body.api_keys.is_empty() {
bail!(
"broker refused ApiVersions with error {code} ({}) and sent no supported-API \
table, so there is no version to step down to",
kafka_error_name(code)
);
}
body
}
};
if body.api_keys.is_empty() {
bail!("broker advertised no APIs at all");
}
Ok(body
.api_keys
.iter()
.map(|a| (a.api_key, (a.min_version, a.max_version)))
.collect())
}
fn decode_api_versions(
bytes: &[u8],
api_version: i16,
expected_correlation_id: i32,
) -> Result<(i16, ApiVersionsResponse)> {
let mut cursor = std::io::Cursor::new(bytes);
let header = ResponseHeader::decode(
&mut cursor,
ApiKey::ApiVersions.response_header_version(api_version),
)
.context("decoding ApiVersions response header")?;
if header.correlation_id != expected_correlation_id {
bail!(
"broker answered ApiVersions with correlation id {} instead of {}",
header.correlation_id,
expected_correlation_id
);
}
let body = ApiVersionsResponse::decode(&mut cursor, api_version)
.context("decoding ApiVersions response body")?;
Ok((body.error_code, body))
}
async fn request_metadata(
conn: &mut KafkaConn,
topics: Option<&[String]>,
) -> Result<serde_json::Value> {
use kafka_protocol::messages::metadata_request::MetadataRequestTopic;
let version = conn.version_for(ApiKey::Metadata, CLIENT_MAX_METADATA)?;
let named: Option<Vec<MetadataRequestTopic>> = match topics {
Some(names) => Some(
names
.iter()
.map(|n| {
MetadataRequestTopic::default()
.with_name(Some(StrBytes::from_string(n.clone()).into()))
})
.collect(),
),
None if version == 0 => Some(Vec::new()),
None => None,
};
let response: MetadataResponse = conn
.exchange(
ApiKey::Metadata,
version,
&MetadataRequest::default().with_topics(named),
)
.await
.context("Metadata request failed")?;
conn.remember_topic_ids(&response);
Ok(metadata_to_json(&response))
}
}
impl Session {
async fn drive(&self, first: Event) -> bool {
let Some(instruction) = self
.app_state
.get_instruction_for_client(self.client_id)
.await
else {
return false;
};
let mut queue: VecDeque<Event> = VecDeque::from([first]);
while let Some(event) = queue.pop_front() {
let memory = self.memory.lock().await.clone();
let protocol = KafkaClientProtocol::new();
let result = call_llm_for_client(
&self.llm_client,
&self.app_state,
self.client_id.to_string(),
&instruction,
&memory,
Some(&event),
&protocol,
&self.status_tx,
)
.await;
let ClientLlmResult {
actions,
memory_updates,
} = match result {
Ok(r) => r,
Err(e) => {
error!(
"Kafka client {} could not handle {}: {}",
self.client_id,
event.id(),
e
);
let _ = self
.status_tx
.send(format!("[CLIENT] Kafka handler failed: {e}"));
return false;
}
};
if let Some(mem) = memory_updates {
*self.memory.lock().await = mem;
}
for action in actions {
match protocol.execute_action(action) {
Ok(ClientActionResult::Disconnect) => return true,
Ok(ClientActionResult::WaitForMore) | Ok(ClientActionResult::NoAction) => {}
Ok(ClientActionResult::Custom { name, data }) => {
match self.perform(&name, &data).await {
Ok((Some(follow_up), _)) => queue.push_back(follow_up),
Ok((None, _)) => {}
Err(e) => {
error!(
"Kafka client {} action '{}' failed: {}",
self.client_id, name, e
);
let _ = self
.status_tx
.send(format!("[CLIENT] Kafka {name} failed: {e}"));
if is_fatal(&e) {
self.mark_error(&e.to_string()).await;
return true;
}
}
}
}
Ok(other) => {
warn!(
"Kafka client {} ignoring unsupported action result {:?}",
self.client_id, other
);
}
Err(e) => {
error!("Kafka client {} rejected an action: {}", self.client_id, e);
let _ = self
.status_tx
.send(format!("[CLIENT] Kafka action rejected: {e}"));
}
}
}
}
false
}
async fn command_loop(
&self,
mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
) {
use crate::llm::actions::protocol_trait::Protocol;
use crate::state::client_handles::ClientSendOutcome;
use crate::state::AccessLogOwner;
let protocol = KafkaClientProtocol::new();
while let Some(command) = command_rx.recv().await {
let action = command.action.clone();
let mut follow_up: Option<Event> = None;
let mut disconnect = false;
let outcome: Result<ClientSendOutcome> = match protocol.execute_action(action.clone()) {
Err(e) => Ok(ClientSendOutcome::Rejected {
error: e.to_string(),
}),
Ok(ClientActionResult::Disconnect) => {
disconnect = true;
Ok(ClientSendOutcome::Disconnected)
}
Ok(ClientActionResult::WaitForMore) => Ok(ClientSendOutcome::Executed {
detail: "wait_for_more: nothing was asked of the broker".to_string(),
}),
Ok(ClientActionResult::NoAction) => Ok(ClientSendOutcome::Executed {
detail: "no_action".to_string(),
}),
Ok(ClientActionResult::Custom { name, data }) => {
match self.perform(&name, &data).await {
Ok((event, wrote)) => {
follow_up = event;
Ok(ClientSendOutcome::Sent { bytes_sent: wrote })
}
Err(e) => {
if is_fatal(&e) {
self.mark_error(&e.to_string()).await;
}
Err(e.context(format!("injected Kafka action '{name}'")))
}
}
}
Ok(other) => Ok(ClientSendOutcome::Executed {
detail: format!("unsupported action result {other:?}"),
}),
};
let outcome_json = match &outcome {
Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
Err(e) => serde_json::json!({"error": e.to_string()}),
};
self.app_state
.record_access_log(
AccessLogOwner::Client(self.client_id.as_u32()),
protocol.protocol_name(),
None,
"injected_action",
action,
vec![outcome_json],
)
.await;
if let Err(e) = &outcome {
error!(
"Kafka client {} injected action failed: {}",
self.client_id, e
);
let _ = self
.status_tx
.send(format!("[CLIENT] Kafka injected action failed: {e}"));
}
let _ = self.status_tx.send("__UPDATE_UI__".to_string());
crate::client::command_support::reply(command, outcome);
if disconnect {
self.close("injected disconnect").await;
break;
}
if let Some(event) = follow_up {
let session = self.clone();
let handle = tokio::spawn(async move {
if session.drive(event).await {
session.close("handler asked to disconnect").await;
}
});
self.app_state
.register_client_task(self.client_id, handle)
.await;
}
}
self.app_state.remove_client_handle(self.client_id).await;
}
async fn perform(
&self,
name: &str,
data: &serde_json::Value,
) -> Result<(Option<Event>, usize)> {
match name {
"kafka_produce" => self.produce(data).await,
"kafka_fetch" => {
let topic = str_field(data, "topic")?;
let partition = i64_field(data, "partition")? as i32;
let offset = match data.get("offset").and_then(|v| v.as_i64()) {
Some(o) => o,
None => self.tracked_offset(&topic, partition).await.unwrap_or(0),
};
let max_bytes = data
.get("max_bytes")
.and_then(|v| v.as_i64())
.unwrap_or(i64::from(DEFAULT_PARTITION_MAX_BYTES))
as i32;
self.fetch(&topic, partition, offset, max_bytes).await
}
"kafka_metadata" => {
let topics: Option<Vec<String>> =
data.get("topics").and_then(|v| v.as_array()).map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect()
});
let mut conn = self.conn.lock().await;
let metadata = KafkaClient::request_metadata(&mut conn, topics.as_deref()).await?;
let wrote = conn.last_request_bytes;
drop(conn);
Ok((
Some(Event::new(&KAFKA_CLIENT_METADATA_RECEIVED_EVENT, metadata)),
wrote,
))
}
"kafka_commit" => self.commit(data).await.map(|wrote| (None, wrote)),
other => Err(anyhow!("unknown Kafka client action result '{other}'")),
}
}
async fn produce(&self, data: &serde_json::Value) -> Result<(Option<Event>, usize)> {
use kafka_protocol::messages::produce_request::{PartitionProduceData, TopicProduceData};
let topic = str_field(data, "topic")?;
let partition = i64_field(data, "partition")? as i32;
let acks = i64_field(data, "acks")? as i16;
let key = decode_field(
data.get("key"),
data.get("key_encoding")
.and_then(|v| v.as_str())
.unwrap_or("utf8"),
)
.context("record key")?;
let value = decode_field(
data.get("value"),
data.get("value_encoding")
.and_then(|v| v.as_str())
.unwrap_or("utf8"),
)
.context("record value")?;
let batch = encode_single_record(key, value)?;
let request = ProduceRequest::default()
.with_acks(acks)
.with_timeout_ms(IO_TIMEOUT.as_millis() as i32)
.with_topic_data(vec![TopicProduceData::default()
.with_name(StrBytes::from_string(topic.clone()).into())
.with_partition_data(vec![PartitionProduceData::default()
.with_index(partition)
.with_records(Some(batch))])]);
let mut conn = self.conn.lock().await;
let version = conn.version_for(ApiKey::Produce, CLIENT_MAX_PRODUCE)?;
if acks == 0 {
conn.send_only(ApiKey::Produce, version, &request).await?;
let wrote = conn.last_request_bytes;
drop(conn);
info!(
"Kafka client {} produced to {}/{} with acks=0 (no acknowledgement requested)",
self.client_id, topic, partition
);
let _ = self.status_tx.send(format!(
"[CLIENT] Kafka produced to {topic}/{partition} (acks=0, unacknowledged)"
));
return Ok((None, wrote));
}
let response: ProduceResponse = conn.exchange(ApiKey::Produce, version, &request).await?;
let wrote = conn.last_request_bytes;
drop(conn);
let topic_response = response
.responses
.iter()
.find(|t| t.name.as_str() == topic)
.or_else(|| response.responses.first())
.context("Produce response named no topic")?;
let partition_response = topic_response
.partition_responses
.iter()
.find(|p| p.index == partition)
.or_else(|| topic_response.partition_responses.first())
.context("Produce response named no partition")?;
let error_code = partition_response.error_code;
let delivered = error_code == 0;
if delivered {
info!(
"Kafka client {} produced to {}/{} at offset {}",
self.client_id, topic, partition, partition_response.base_offset
);
let _ = self.status_tx.send(format!(
"[CLIENT] Kafka produced to {topic}/{partition} at offset {}",
partition_response.base_offset
));
} else {
error!(
"Kafka client {} produce to {}/{} rejected: {} ({})",
self.client_id,
topic,
partition,
error_code,
kafka_error_name(error_code)
);
let _ = self.status_tx.send(format!(
"[CLIENT] Kafka produce to {topic}/{partition} rejected: {} ({error_code})",
kafka_error_name(error_code)
));
}
Ok((
Some(Event::new(
&KAFKA_CLIENT_MESSAGE_DELIVERED_EVENT,
serde_json::json!({
"topic": topic,
"partition": partition,
"base_offset": partition_response.base_offset,
"error_code": error_code,
"error_name": kafka_error_name(error_code),
"delivered": delivered,
"api_version": version,
}),
)),
wrote,
))
}
async fn fetch(
&self,
topic: &str,
partition: i32,
offset: i64,
max_bytes: i32,
) -> Result<(Option<Event>, usize)> {
use kafka_protocol::messages::fetch_request::{FetchPartition, FetchTopic};
let mut conn = self.conn.lock().await;
let mut version = conn.version_for(ApiKey::Fetch, CLIENT_MAX_FETCH)?;
let mut fetch_topic = FetchTopic::default();
if version >= FETCH_BY_TOPIC_ID {
match conn.topic_id(topic).await? {
Some(id) => fetch_topic = fetch_topic.with_topic_id(id),
None => version = conn.version_for(ApiKey::Fetch, FETCH_BY_TOPIC_ID - 1)?,
}
}
if version < FETCH_BY_TOPIC_ID {
fetch_topic = fetch_topic.with_topic(StrBytes::from_string(topic.to_string()).into());
}
let request = FetchRequest::default()
.with_max_wait_ms(500)
.with_min_bytes(1)
.with_max_bytes(max_bytes)
.with_topics(vec![fetch_topic.with_partitions(vec![
FetchPartition::default()
.with_partition(partition)
.with_fetch_offset(offset)
.with_partition_max_bytes(max_bytes),
])]);
let response: FetchResponse = conn.exchange(ApiKey::Fetch, version, &request).await?;
let wrote = conn.last_request_bytes;
drop(conn);
if response.error_code != 0 {
bail!(
"Fetch failed at the response level: {} ({})",
response.error_code,
kafka_error_name(response.error_code)
);
}
let Some(topic_response) = response.responses.first() else {
debug!(
"Kafka client {} fetched {}/{}: broker returned no topic",
self.client_id, topic, partition
);
return Ok((None, wrote));
};
let Some(partition_data) = topic_response.partitions.first() else {
return Ok((None, wrote));
};
if partition_data.error_code != 0 {
bail!(
"Fetch of {topic}/{partition} failed: {} ({})",
partition_data.error_code,
kafka_error_name(partition_data.error_code)
);
}
let records = match partition_data.records.as_ref() {
Some(raw) if !raw.is_empty() => {
let owned = Bytes::copy_from_slice(raw.as_ref());
let mut cursor = std::io::Cursor::new(owned);
RecordBatchDecoder::decode_with_custom_compression::<_, Decompressor>(
&mut cursor,
None,
)
.context("broker's record batch did not decode")?
}
_ => Vec::new(),
};
if records.is_empty() {
trace!(
"Kafka client {} fetched {}/{} from offset {}: nothing new",
self.client_id,
topic,
partition,
offset
);
return Ok((None, wrote));
}
let last_offset = records.iter().map(|r| r.offset).max().unwrap_or(offset);
let next_offset = last_offset + 1;
self.set_tracked_offset(topic, partition, next_offset).await;
info!(
"Kafka client {} fetched {} record(s) from {}/{} at offset {}",
self.client_id,
records.len(),
topic,
partition,
offset
);
let _ = self.status_tx.send(format!(
"[CLIENT] Kafka received {} record(s) from {topic}/{partition}",
records.len()
));
Ok((
Some(Event::new(
&KAFKA_CLIENT_RECORDS_RECEIVED_EVENT,
serde_json::json!({
"topic": topic,
"partition": partition,
"api_version": version,
"high_watermark": partition_data.high_watermark,
"next_offset": next_offset,
"record_count": records.len(),
"records": records.iter().take(MAX_EVENT_RECORDS).map(record_to_json).collect::<Vec<_>>(),
}),
)),
wrote,
))
}
async fn commit(&self, data: &serde_json::Value) -> Result<usize> {
use kafka_protocol::messages::offset_commit_request::{
OffsetCommitRequestPartition, OffsetCommitRequestTopic,
};
let topic = str_field(data, "topic")?;
let partition = i64_field(data, "partition")? as i32;
let offset = i64_field(data, "offset")?;
let group_id = data
.get("group_id")
.and_then(|v| v.as_str())
.unwrap_or(&self.default_group_id)
.to_string();
let request = OffsetCommitRequest::default()
.with_group_id(StrBytes::from_string(group_id.clone()).into())
.with_generation_id_or_member_epoch(-1)
.with_topics(vec![OffsetCommitRequestTopic::default()
.with_name(StrBytes::from_string(topic.clone()).into())
.with_partitions(vec![OffsetCommitRequestPartition::default()
.with_partition_index(partition)
.with_committed_offset(offset)])]);
let mut conn = self.conn.lock().await;
let version = conn.version_for(ApiKey::OffsetCommit, CLIENT_MAX_OFFSET_COMMIT)?;
let response: OffsetCommitResponse = conn
.exchange(ApiKey::OffsetCommit, version, &request)
.await?;
let wrote = conn.last_request_bytes;
drop(conn);
let error_code = response
.topics
.first()
.and_then(|t| t.partitions.first())
.map(|p| p.error_code)
.context("OffsetCommit response named no partition")?;
if error_code != 0 {
bail!(
"commit of {topic}/{partition} offset {offset} was refused: {error_code} ({})",
kafka_error_name(error_code)
);
}
info!(
"Kafka client {} committed {}/{} offset {} for group {}",
self.client_id, topic, partition, offset, group_id
);
let _ = self.status_tx.send(format!(
"[CLIENT] Kafka committed {topic}/{partition} offset {offset}"
));
Ok(wrote)
}
async fn poll_loop(&self, interval: Duration) {
let mut first = true;
loop {
if !first {
tokio::time::sleep(interval).await;
}
first = false;
if self.app_state.get_client(self.client_id).await.is_none() {
info!("Kafka client {} stopped", self.client_id);
return;
}
let assignments = self.assignments.lock().await.clone();
for assignment in assignments {
match self
.fetch(
&assignment.topic,
assignment.partition,
assignment.next_offset,
DEFAULT_PARTITION_MAX_BYTES,
)
.await
{
Ok((Some(event), _)) => {
if self.drive(event).await {
self.close("handler asked to disconnect").await;
return;
}
}
Ok((None, _)) => {}
Err(e) => {
error!(
"Kafka client {} polling {}/{} failed: {}",
self.client_id, assignment.topic, assignment.partition, e
);
let _ = self
.status_tx
.send(format!("[CLIENT] Kafka poll failed: {e}"));
if is_fatal(&e) {
self.mark_error(&e.to_string()).await;
return;
}
}
}
}
}
}
async fn tracked_offset(&self, topic: &str, partition: i32) -> Option<i64> {
self.assignments
.lock()
.await
.iter()
.find(|a| a.topic == topic && a.partition == partition)
.map(|a| a.next_offset)
}
async fn set_tracked_offset(&self, topic: &str, partition: i32, next_offset: i64) {
let mut assignments = self.assignments.lock().await;
match assignments
.iter_mut()
.find(|a| a.topic == topic && a.partition == partition)
{
Some(existing) => existing.next_offset = next_offset,
None => assignments.push(Assignment {
topic: topic.to_string(),
partition,
next_offset,
}),
}
}
async fn mark_error(&self, message: &str) {
self.app_state.remove_client_handle(self.client_id).await;
self.app_state
.update_client_status(self.client_id, ClientStatus::Error(message.to_string()))
.await;
let _ = self.status_tx.send("__UPDATE_UI__".to_string());
}
async fn close(&self, why: &str) {
info!(
"Kafka client {} disconnecting from {}: {}",
self.client_id, self.remote_addr, why
);
let _ = self.conn.lock().await.stream.shutdown().await;
self.app_state.remove_client_handle(self.client_id).await;
self.app_state
.update_client_status(self.client_id, ClientStatus::Disconnected)
.await;
let _ = self.status_tx.send(format!(
"[CLIENT] Kafka client {} disconnected",
self.client_id
));
let _ = self.status_tx.send("__UPDATE_UI__".to_string());
}
}
fn is_fatal(e: &anyhow::Error) -> bool {
let text = e.to_string();
e.downcast_ref::<std::io::Error>().is_some()
|| e.chain()
.any(|c| c.downcast_ref::<std::io::Error>().is_some())
|| text.contains("desynchronised")
|| text.contains("timed out")
|| text.contains("implausible response size")
}
fn encode_single_record(key: Option<Vec<u8>>, value: Option<Vec<u8>>) -> Result<Bytes> {
let timestamp = crate::utils::clock::SystemTime::now()
.duration_since(crate::utils::clock::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(-1);
let records = vec![Record {
transactional: false,
control: false,
partition_leader_epoch: 0,
producer_id: -1,
producer_epoch: -1,
timestamp_type: TimestampType::Creation,
offset: 0,
sequence: 0,
timestamp,
key: key.map(Bytes::from),
value: value.map(Bytes::from),
headers: Default::default(),
}];
let mut buf = Vec::new();
RecordBatchEncoder::encode_with_custom_compression::<_, _, Compressor>(
&mut buf,
&records,
&RecordEncodeOptions {
version: 2,
compression: Compression::None,
},
None,
)
.context("encoding the record batch")?;
Ok(Bytes::from(buf))
}
fn record_to_json(record: &Record) -> serde_json::Value {
let (key, key_encoding) = match record.key.as_ref() {
Some(k) => {
let (v, e) = encode_field(k.as_ref(), MAX_EVENT_VALUE_BYTES);
(v, Some(e))
}
None => (serde_json::Value::Null, None),
};
let (value, value_encoding) = match record.value.as_ref() {
Some(v) => {
let (val, e) = encode_field(v.as_ref(), MAX_EVENT_VALUE_BYTES);
(val, Some(e))
}
None => (serde_json::Value::Null, None),
};
serde_json::json!({
"offset": record.offset,
"timestamp": record.timestamp,
"key": key,
"key_encoding": key_encoding,
"value": value,
"value_encoding": value_encoding,
})
}
fn metadata_to_json(response: &MetadataResponse) -> serde_json::Value {
let brokers: Vec<serde_json::Value> = response
.brokers
.iter()
.map(|b| {
serde_json::json!({
"node_id": b.node_id.0,
"host": b.host.to_string(),
"port": b.port,
})
})
.collect();
let topics: Vec<serde_json::Value> = response
.topics
.iter()
.map(|t| {
serde_json::json!({
"name": t.name.as_ref().map(|n| n.to_string()),
"error_code": t.error_code,
"error_name": kafka_error_name(t.error_code),
"partitions": t.partitions.iter().map(|p| serde_json::json!({
"partition": p.partition_index,
"leader": p.leader_id.0,
"error_code": p.error_code,
"replicas": p.replica_nodes.iter().map(|r| r.0).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
})
})
.collect();
serde_json::json!({
"cluster_id": response.cluster_id.as_ref().map(|c| c.to_string()),
"controller_id": response.controller_id.0,
"brokers": brokers,
"topics": topics,
})
}
fn str_field(data: &serde_json::Value, key: &str) -> Result<String> {
data.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.with_context(|| format!("action data is missing '{key}'"))
}
fn i64_field(data: &serde_json::Value, key: &str) -> Result<i64> {
data.get(key)
.and_then(|v| v.as_i64())
.with_context(|| format!("action data is missing '{key}'"))
}