numinnex commented on code in PR #4502:
URL: https://github.com/apache/iggy/pull/4502#discussion_r4256853854


##########
gateways/kafka/src/protocol/handlers/produce.rs:
##########
@@ -156,17 +162,14 @@ pub async fn handle(state: &GatewayState, api_version: 
i16, body: Bytes) -> Hand
 
     // Before planning, not after: the client waits through decompression too.
     let deadline = Deadline::new(request.timeout_ms);
-    let Ok(Ok(_slot)) = tokio::time::timeout_at(deadline.at, 
state.produce_slots.acquire()).await
-    else {
-        tracing::debug!("Produce deadline passed while waiting for a slot");
-        return if request.acks == ACKS_NONE {
-            HandleOutcome::Close
-        } else {
-            respond_or_close(
-                encode_uniform_response(api_version, &request, 
ERROR_REQUEST_TIMED_OUT),
-                "Produce",
-            )
-        };
+    let cost = decode_cost(&request, state.max_frame_size);
+    let Ok(Ok(_admission)) = tokio::time::timeout_at(deadline.at, admit(state, 
cost)).await else {

Review Comment:
   `_admission` is released when the handler returns at the deadline, but a 
timed-out send keeps running in the task spawned by `send_in_slot` 
(`iggy_bridge/mod.rs:101`) and still owns the decoded `messages` for up to 
`SLOT_LIMIT` (45s).
   Before, the single `send_slot` meant at most one such batch. Now it is one 
per session, so up to 256 partition caps (~2 GiB at the default frame) sit 
outside `produce_memory`, and that happens exactly when Iggy is slow.
   Could `produce_memory` become an `Arc<Semaphore>` and the owned permit move 
into the send future?



##########
gateways/kafka/src/protocol/handlers/produce.rs:
##########
@@ -156,17 +162,14 @@ pub async fn handle(state: &GatewayState, api_version: 
i16, body: Bytes) -> Hand
 
     // Before planning, not after: the client waits through decompression too.
     let deadline = Deadline::new(request.timeout_ms);
-    let Ok(Ok(_slot)) = tokio::time::timeout_at(deadline.at, 
state.produce_slots.acquire()).await
-    else {
-        tracing::debug!("Produce deadline passed while waiting for a slot");
-        return if request.acks == ACKS_NONE {
-            HandleOutcome::Close
-        } else {
-            respond_or_close(
-                encode_uniform_response(api_version, &request, 
ERROR_REQUEST_TIMED_OUT),
-                "Produce",
-            )
-        };
+    let cost = decode_cost(&request, state.max_frame_size);
+    let Ok(Ok(_admission)) = tokio::time::timeout_at(deadline.at, admit(state, 
cost)).await else {
+        tracing::debug!("Produce deadline passed while waiting to decode");
+        return timed_out(api_version, &request);
+    };
+    let Some(session) = bridge.produce_session(producer_of(&request, 
connection)) else {

Review Comment:
   Admission is taken before the session, so a retry queued behind its 
producer's stuck send holds a large slot plus its memory while it waits on 
`turn`, up to the 20s deadline.
   Four such producers pin every `PRODUCE_LARGE` slot and all other large 
requests answer 7, which is the head-of-line blocking this PR removes elsewhere.
   Taking the session and checking its turn before `admit` would avoid that.



##########
gateways/kafka/src/protocol/handlers/produce.rs:
##########
@@ -206,6 +209,71 @@ fn stub_outcome(api_version: i16, request: 
&ProduceRequest) -> HandleOutcome {
     respond_or_close(encode_response(api_version, request), "Produce")
 }
 
+/// Answers 7 for every partition. `acks=0` has no response to carry it, so 
that closes instead.
+fn timed_out(api_version: i16, request: &ProduceRequest) -> HandleOutcome {
+    if request.acks == ACKS_NONE {
+        HandleOutcome::Close
+    } else {
+        respond_or_close(
+            encode_uniform_response(api_version, request, 
ERROR_REQUEST_TIMED_OUT),
+            "Produce",
+        )
+    }
+}
+
+/// What the request may hold decoded, in frame bytes, at most one partition 
cap.
+///
+/// Partitions decode one at a time, so the largest counts. Uncompressed, a 
partition holds its own
+/// bytes or a record slot per declared record, whichever is more. Compressed, 
it may fill the cap.
+fn decode_cost(request: &ProduceRequest, max_frame_size: usize) -> usize {
+    let largest = request
+        .topic_data
+        .iter()
+        .flat_map(|topic| &topic.partition_data)
+        .filter_map(|partition| partition.records.as_deref())
+        .map(|blob| {
+            if is_compressed(blob) {
+                max_frame_size
+            } else {
+                let slots = declared_records(blob).unwrap_or(0);
+                blob.len().max(slots.saturating_mul(FRAME_BYTES_PER_RECORD))
+            }
+        })
+        .max()
+        .unwrap_or(0);
+    largest.min(max_frame_size)
+}
+
+/// A large slot when the request is large, and its decode memory.
+type Admission<'a> = (Option<SemaphorePermit<'a>>, SemaphorePermit<'a>);
+
+/// Waits for the request's decode memory. A large request, 
`CODEC_OFF_WORKER_BYTES` or more,
+/// first waits for a large slot, so while it waits its turn it holds no 
memory.
+async fn admit(
+    state: &GatewayState,
+    cost: usize,
+) -> std::result::Result<Admission<'_>, AcquireError> {
+    let large = if cost >= CODEC_OFF_WORKER_BYTES {
+        Some(state.produce_large.acquire().await?)
+    } else {
+        None
+    };
+    let permits = u32::try_from(cost).unwrap_or(u32::MAX);
+    let memory = state.produce_memory.acquire_many(permits).await?;
+    Ok((large, memory))
+}
+
+/// The producer id of the request's first batch, or the connection when that 
batch has none.
+fn producer_of(request: &ProduceRequest, connection: &ConnectionState) -> 
Producer {
+    request
+        .topic_data
+        .iter()
+        .flat_map(|topic| &topic.partition_data)
+        .find_map(|partition| partition.records.as_deref())
+        .and_then(producer_id)
+        .map_or_else(|| Producer::Connection(connection.id()), Producer::Id)

Review Comment:
   For producers without an id, ordering does not survive a reconnect. Batch A 
times out and its send keeps running in the background. The client reconnects, 
gets a new `ConnectionId` and so a new session with its own turn, and the retry 
of A or the next batch B lands before A.
   The old global `send_slot` kept B behind A, and the README's 
`max.in.flight=1` / `retries=0` advice relies on that.
   Either keep the old connection's turn reachable until its send finishes, or 
drop the ordering claim for these producers.



##########
gateways/kafka/src/protocol/api.rs:
##########
@@ -343,9 +347,15 @@ pub struct GatewayState {
     pub(crate) probe_board: ProbeBoard,
 }
 
-/// Each holds one decoded partition at a time, so about 160 MB at the default 
8 MiB frame. Sends
-/// run one at a time anyway.
-const PRODUCE_SLOTS: usize = 4;
+/// Partition caps Produce may hold decoded at once, about 160 MB at the 
default 8 MiB frame.
+///
+/// Counted in bytes, not requests: a small request takes a small share, so it 
does not queue
+/// behind a few large ones.
+const PRODUCE_CAPS: usize = 4;
+
+/// Large Produce requests that decode and send at once. More at once only 
contend for the
+/// gateway's workers and for Iggy: at full load they cost CPU and gave no 
throughput.
+const PRODUCE_LARGE: usize = 4;

Review Comment:
   With `PRODUCE_LARGE == PRODUCE_CAPS` and a compressed partition costing a 
full cap, four compressed requests (even tiny lz4 ones) take all of 
`produce_memory`, and FIFO `acquire_many` queues every small request behind 
them. The doc at :352 says the opposite.
   It also means compressed and >=64 KiB traffic is still capped at 4 in 
flight, same as before this PR.
   Could `PRODUCE_LARGE` be below `PRODUCE_CAPS`, with a test that admits 4 
full-frame requests and then a small one?



##########
gateways/kafka/src/protocol/handlers/produce.rs:
##########
@@ -206,6 +209,71 @@ fn stub_outcome(api_version: i16, request: 
&ProduceRequest) -> HandleOutcome {
     respond_or_close(encode_response(api_version, request), "Produce")
 }
 
+/// Answers 7 for every partition. `acks=0` has no response to carry it, so 
that closes instead.
+fn timed_out(api_version: i16, request: &ProduceRequest) -> HandleOutcome {
+    if request.acks == ACKS_NONE {
+        HandleOutcome::Close
+    } else {
+        respond_or_close(
+            encode_uniform_response(api_version, request, 
ERROR_REQUEST_TIMED_OUT),
+            "Produce",
+        )
+    }
+}
+
+/// What the request may hold decoded, in frame bytes, at most one partition 
cap.
+///
+/// Partitions decode one at a time, so the largest counts. Uncompressed, a 
partition holds its own
+/// bytes or a record slot per declared record, whichever is more. Compressed, 
it may fill the cap.
+fn decode_cost(request: &ProduceRequest, max_frame_size: usize) -> usize {
+    let largest = request
+        .topic_data
+        .iter()
+        .flat_map(|topic| &topic.partition_data)
+        .filter_map(|partition| partition.records.as_deref())
+        .map(|blob| {
+            if is_compressed(blob) {
+                max_frame_size
+            } else {
+                let slots = declared_records(blob).unwrap_or(0);
+                blob.len().max(slots.saturating_mul(FRAME_BYTES_PER_RECORD))
+            }
+        })
+        .max()
+        .unwrap_or(0);
+    largest.min(max_frame_size)
+}
+
+/// A large slot when the request is large, and its decode memory.
+type Admission<'a> = (Option<SemaphorePermit<'a>>, SemaphorePermit<'a>);
+
+/// Waits for the request's decode memory. A large request, 
`CODEC_OFF_WORKER_BYTES` or more,
+/// first waits for a large slot, so while it waits its turn it holds no 
memory.
+async fn admit(
+    state: &GatewayState,
+    cost: usize,
+) -> std::result::Result<Admission<'_>, AcquireError> {
+    let large = if cost >= CODEC_OFF_WORKER_BYTES {
+        Some(state.produce_large.acquire().await?)
+    } else {
+        None
+    };
+    let permits = u32::try_from(cost).unwrap_or(u32::MAX);
+    let memory = state.produce_memory.acquire_many(permits).await?;
+    Ok((large, memory))
+}
+
+/// The producer id of the request's first batch, or the connection when that 
batch has none.
+fn producer_of(request: &ProduceRequest, connection: &ConnectionState) -> 
Producer {

Review Comment:
   The pool key is the producer id straight from the client's batch header, 
never checked against `ProducerIdAllocator` or scoped by principal.
   A client that rotates ids forces a new Iggy connection plus an argon2 login 
per request and evicts legitimate free sessions. Reusing someone else's id puts 
the victim behind the attacker's stuck send (7 for up to 45s).
   Suggest keying `Producer::Id` only for ids this gateway issued, as 
(principal, id), falling back to the connection otherwise. Keying by connection 
alone would bring the reconnect reorder above to idempotent producers.



##########
gateways/kafka/README.md:
##########
@@ -507,13 +512,16 @@ cut before that threshold is reached - worth knowing 
rather than discovering lat
 
 ### Concurrency ceiling
 
-- One `IggyClient` serves Produce, Metadata, CreateTopics, DescribeConfigs and 
AlterConfigs for
-  every Kafka connection, one Iggy request at a time. Fetch polls use 4 more, 
one per read slot.
-  Topic probes use 1 more. Offset  calls use 8 more: a commit client and a 
read client per offset slot.
+- Each producer sends on an `IggyClient` of its own, one send at a time, so 
its batches land in
+  order. A producer is its producer id, or its connection when its batches 
carry none. Up to 256
+  of these stay open. Opening one closes the free ones idle for 10 minutes, 
then the least
+  recently used free one if 256 are open. A Produce request that finds all 256 
busy answers 7.
+- Metadata, CreateTopics, DeleteTopics, DescribeConfigs and AlterConfigs share 
1 more client, one
+  Iggy request at a time. Fetch polls use 4 more, one per read slot. Topic 
probes use 1 more.
+  Offset calls use 8 more: a commit client and a read client per offset slot.
 - OffsetCommit and OffsetFetch cost one Iggy call per partition, spread over 
the offset slots. In a
   cluster, an offset commit client can also open one connection per node for 
stores.
-- `IGGY_KAFKA_MAX_CONNECTIONS` does not change that. A Produce client pool is 
a TODO in
-  [docs/SCOPE.md](docs/SCOPE.md).
+- `IGGY_KAFKA_MAX_CONNECTIONS` does not change that.

Review Comment:
   A few docs no longer match the code:
   - this line: the session cap now interacts with `IGGY_KAFKA_MAX_CONNECTIONS`.
   - README.md:209 says Produce ignores the producer id, but it is now the pool 
key.
   - README.md:457-459, "one send runs in the SDK at a time" and "ListOffsets 
waits behind a stuck send", are stale. The 7 row at :353 also misses the 
busy-pool case.
   - docs/IDEMPOTENCE.md:138-142, "harmless while no code path reads the 
producer id": this PR adds that reader without the safeguards listed there.



##########
gateways/kafka/src/bridge/iggy_bridge/produce.rs:
##########
@@ -19,58 +19,171 @@
 //!
 //! Separate from `fetch.rs` so the two never edit one file.
 
+use std::collections::HashMap;
 use std::future::Future;
-use std::sync::Arc;
+use std::sync::{Arc, Mutex, PoisonError};
+use std::time::Duration;
 
 use iggy::prelude::{IggyError, IggyMessage, MessageClient, Partitioning};
 use tokio::sync::Semaphore;
-use tokio::time::{Instant, timeout};
+use tokio::time::{Instant, timeout, timeout_at};
 
+use super::fetch::LazyClient;
 use super::{IggyBridge, SLOT_LIMIT, TopicTarget, in_slot, permit_by};
 use crate::bridge::error::BridgeError;
 
 /// Frame bytes besides the messages: request header, batch header and ids.
 const SEND_FRAME_ROOM: u64 = 4 * 1024;
 
+/// Produce sessions open at once, each an Iggy connection.
+const PRODUCE_SESSIONS: usize = 256;
+
+/// A free session unused this long closes when a new producer opens one.
+const SESSION_IDLE: Duration = Duration::from_secs(600);
+
+/// Who sends a Produce request. Each producer gets an Iggy session of its own.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum Producer {
+    /// The producer id of the request's record batches.
+    Id(i64),
+    /// The Kafka connection, for a producer without an id.
+    Connection(u64),
+}
+
+/// One producer's Iggy client, and one send at a time on it, so its sends 
reach Iggy in order.
+pub struct ProduceSession {
+    client: LazyClient,
+    turn: Arc<Semaphore>,
+}
+
+/// The Produce sessions, one per producer, so different producers send at 
once.
+pub(super) struct ProducePool {
+    sessions: Mutex<HashMap<Producer, Held>>,
+    capacity: usize,
+}
+
+/// A session, and when a request last took it.
+struct Held {
+    session: Arc<ProduceSession>,
+    used: Instant,
+}
+
+impl ProducePool {
+    pub(super) fn new() -> Self {
+        Self::with_capacity(PRODUCE_SESSIONS)
+    }
+
+    fn with_capacity(capacity: usize) -> Self {
+        Self {
+            sessions: Mutex::new(HashMap::new()),
+            capacity,
+        }
+    }
+
+    /// The session of `producer`, opened if it has none. `None` when the pool 
is full and every
+    /// session is busy.
+    ///
+    /// Opening one first drops the free sessions idle past [`SESSION_IDLE`], 
then the least
+    /// recently used free one if the pool is still full.
+    fn take(&self, producer: Producer) -> Option<Arc<ProduceSession>> {
+        let now = Instant::now();
+        let mut sessions = 
self.sessions.lock().unwrap_or_else(PoisonError::into_inner);
+        if let Some(held) = sessions.get_mut(&producer) {
+            held.used = now;
+            return Some(Arc::clone(&held.session));
+        }
+        sessions
+            .retain(|_, held| !(held.is_free() && 
now.duration_since(held.used) >= SESSION_IDLE));

Review Comment:
   Sessions are only swept here, when a new producer opens one. A 
`Producer::Connection` session outlives its Kafka connection, and its heartbeat 
keeps both the Iggy TCP connection and the server-side registration alive.
   Short-lived non-idempotent clients (librdkafka default, kcat) hit this 
constantly.
   Can the session be removed and logged out on connection teardown, plus a 
periodic sweep?



##########
gateways/kafka/src/bridge/iggy_bridge/produce.rs:
##########
@@ -191,4 +304,91 @@ mod tests {
             Err(BridgeError::SendLost(IggyError::Disconnected))
         ));
     }
+
+    #[tokio::test]
+    async fn given_one_producer_when_taken_twice_should_get_one_session() {

Review Comment:
   These tests cover pool bookkeeping only. Nothing checks two producers 
sending concurrently through `send_records`, busy pool -> 7 (and `acks=0` -> 
close) in the handler, or eviction / `close()` actually shutting a client down.
   `IggyBridge::close` also has no production caller, so the pool is never 
closed on shutdown.



##########
gateways/kafka/src/bridge/iggy_bridge/produce.rs:
##########
@@ -19,58 +19,171 @@
 //!
 //! Separate from `fetch.rs` so the two never edit one file.
 
+use std::collections::HashMap;
 use std::future::Future;
-use std::sync::Arc;
+use std::sync::{Arc, Mutex, PoisonError};
+use std::time::Duration;
 
 use iggy::prelude::{IggyError, IggyMessage, MessageClient, Partitioning};
 use tokio::sync::Semaphore;
-use tokio::time::{Instant, timeout};
+use tokio::time::{Instant, timeout, timeout_at};
 
+use super::fetch::LazyClient;
 use super::{IggyBridge, SLOT_LIMIT, TopicTarget, in_slot, permit_by};
 use crate::bridge::error::BridgeError;
 
 /// Frame bytes besides the messages: request header, batch header and ids.
 const SEND_FRAME_ROOM: u64 = 4 * 1024;
 
+/// Produce sessions open at once, each an Iggy connection.
+const PRODUCE_SESSIONS: usize = 256;
+
+/// A free session unused this long closes when a new producer opens one.
+const SESSION_IDLE: Duration = Duration::from_secs(600);
+
+/// Who sends a Produce request. Each producer gets an Iggy session of its own.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum Producer {
+    /// The producer id of the request's record batches.
+    Id(i64),
+    /// The Kafka connection, for a producer without an id.
+    Connection(u64),
+}
+
+/// One producer's Iggy client, and one send at a time on it, so its sends 
reach Iggy in order.
+pub struct ProduceSession {
+    client: LazyClient,
+    turn: Arc<Semaphore>,
+}
+
+/// The Produce sessions, one per producer, so different producers send at 
once.
+pub(super) struct ProducePool {
+    sessions: Mutex<HashMap<Producer, Held>>,
+    capacity: usize,
+}
+
+/// A session, and when a request last took it.
+struct Held {
+    session: Arc<ProduceSession>,
+    used: Instant,
+}
+
+impl ProducePool {
+    pub(super) fn new() -> Self {
+        Self::with_capacity(PRODUCE_SESSIONS)
+    }
+
+    fn with_capacity(capacity: usize) -> Self {
+        Self {
+            sessions: Mutex::new(HashMap::new()),
+            capacity,
+        }
+    }
+
+    /// The session of `producer`, opened if it has none. `None` when the pool 
is full and every
+    /// session is busy.
+    ///
+    /// Opening one first drops the free sessions idle past [`SESSION_IDLE`], 
then the least
+    /// recently used free one if the pool is still full.
+    fn take(&self, producer: Producer) -> Option<Arc<ProduceSession>> {
+        let now = Instant::now();
+        let mut sessions = 
self.sessions.lock().unwrap_or_else(PoisonError::into_inner);
+        if let Some(held) = sessions.get_mut(&producer) {
+            held.used = now;
+            return Some(Arc::clone(&held.session));
+        }
+        sessions
+            .retain(|_, held| !(held.is_free() && 
now.duration_since(held.used) >= SESSION_IDLE));
+        if sessions.len() >= self.capacity {
+            let oldest = sessions
+                .iter()
+                .filter(|(_, held)| held.is_free())
+                .min_by_key(|(_, held)| held.used)
+                .map(|(producer, _)| *producer)?;
+            sessions.remove(&oldest);

Review Comment:
   Evicted clients are dropped with no logout (`TcpClient::shutdown` does not 
log out either), so on the VSR server each one holds a client-table slot until 
the 30s session timeout. `clients_table_max` is 8192 and never evicts live 
entries.
   With more than 256 active producers (the connection limit defaults to 1024) 
the pool thrashes into connect + login per request, and above ~270 new 
sessions/s logins are refused cluster-wide, for every client.
   A shared overflow client when the pool is full, explicit logout on eviction 
and a cap on the open rate would bound this. 256 / 600s are also hardcoded.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to