hubcio commented on code in PR #3888:
URL: https://github.com/apache/iggy/pull/3888#discussion_r3851563522


##########
core/sdk/src/clients/consumer.rs:
##########
@@ -486,8 +526,8 @@ impl IggyConsumer {
         let consumer = self.consumer.clone();
         let stream_id = self.stream_id.clone();
         let topic_id = self.topic_id.clone();
-        let last_consumed_offsets = self.last_consumed_offsets.clone();
-        let last_stored_offsets = self.last_stored_offsets.clone();
+        let last_consumed_offsets = self.state.last_consumed_offsets.clone();

Review Comment:
   the loop below holds a dashmap shard read guard across 
`store_consumer_offset(...).await`, and the poll future can `insert` a new 
partition into the same map (line 776). that insert blocks synchronously inside 
an async task for a whole round trip - stalled worker normally, real deadlock 
with a single worker (`#[tokio::test]` defaults to current_thread). collect 
`(partition_id, offset)` into a vec first, then await.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -91,6 +91,47 @@ pub enum AutoCommitAfter {
     ConsumingEveryNthMessage(u32),
 }
 
+/// A cheap, cloneable view of the state shared with an [`IggyConsumer`].
+///
+/// Consuming borrows the consumer as `&mut` for the whole run, so reading its 
getters
+/// concurrently means sharing it behind a lock and then waiting on that lock. 
This view
+/// carries the same shared state and needs neither.
+#[derive(Clone)]

Review Comment:
   public through the prelude with no `Debug` - `#[derive(Clone, Debug)]` 
compiles. minor, `IggyConsumer` below has none either.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -91,6 +91,47 @@ pub enum AutoCommitAfter {
     ConsumingEveryNthMessage(u32),
 }
 
+/// A cheap, cloneable view of the state shared with an [`IggyConsumer`].
+///
+/// Consuming borrows the consumer as `&mut` for the whole run, so reading its 
getters
+/// concurrently means sharing it behind a lock and then waiting on that lock. 
This view
+/// carries the same shared state and needs neither.
+#[derive(Clone)]
+pub struct IggyConsumerState {
+    current_partition_id: Arc<AtomicU32>,
+    last_consumed_offsets: Arc<DashMap<u32, AtomicU64>>,
+    last_stored_offsets: Arc<DashMap<u32, AtomicU64>>,
+}
+
+impl IggyConsumerState {
+    fn new() -> Self {
+        Self {
+            current_partition_id: Arc::new(AtomicU32::new(0)),
+            last_consumed_offsets: Arc::new(DashMap::new()),
+            last_stored_offsets: Arc::new(DashMap::new()),
+        }
+    }
+
+    /// Returns the current partition ID of the consumer.
+    pub fn partition_id(&self) -> u32 {

Review Comment:
   worth saying these aren't a combined snapshot - two independent loads, so 
the partition id can be stale by the time you read an offset for it.



##########
foreign/python/src/consumer.rs:
##########
@@ -44,10 +44,16 @@ use crate::receive_message::ReceiveMessage;
 
 /// A Python class representing the Iggy consumer.
 /// It provides asynchronous functionality through the contained runtime.
+// `inner` stays locked for the whole duration of a consumption run, so the 
synchronous

Review Comment:
   `store_offset` and `delete_offset` still lock `inner`, so awaiting either 
from inside a `consume_messages` callback deadlocks - the run holds the guard 
while awaiting the callback. with `AutoCommit.Disabled()` that leaves no manual 
commit path from a callback at all. both take `&self` on the rust side, so the 
mutex isn't needed - same trick as the getters here.



##########
foreign/python/src/consumer.rs:
##########
@@ -56,39 +62,33 @@ impl IggyConsumer {
     /// Get the last consumed offset or `None` if no offset has been consumed 
yet.
     #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
     fn get_last_consumed_offset(&self, partition_id: u32) -> Option<u64> {
-        self.inner
-            .blocking_lock()
-            .get_last_consumed_offset(partition_id)
+        self.state.get_last_consumed_offset(partition_id)
     }
 
     /// Get the last stored offset or `None` if no offset has been stored yet.

Review Comment:
   same - after any poll this returns 0 even with `AutoCommit.Disabled()` and 
nothing stored server-side, which the new test at line 921 pins. don't change 
the sentinel though, `store_consumer_offset` reads 0 as "nothing stored".



##########
foreign/python/src/consumer.rs:
##########
@@ -56,39 +62,33 @@ impl IggyConsumer {
     /// Get the last consumed offset or `None` if no offset has been consumed 
yet.

Review Comment:
   `None` means the partition isn't tracked yet, not that nothing was consumed 
- the poll future seeds 0 on first sight of a partition.



##########
foreign/python/src/consumer.rs:
##########
@@ -56,39 +62,33 @@ impl IggyConsumer {
     /// Get the last consumed offset or `None` if no offset has been consumed 
yet.
     #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
     fn get_last_consumed_offset(&self, partition_id: u32) -> Option<u64> {
-        self.inner
-            .blocking_lock()
-            .get_last_consumed_offset(partition_id)
+        self.state.get_last_consumed_offset(partition_id)
     }
 
     /// Get the last stored offset or `None` if no offset has been stored yet.
     #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
     fn get_last_stored_offset(&self, partition_id: u32) -> Option<u64> {
-        self.inner
-            .blocking_lock()
-            .get_last_stored_offset(partition_id)
+        self.state.get_last_stored_offset(partition_id)
     }
 
     /// Gets the name of the consumer group.
     fn name(&self) -> String {

Review Comment:
   return `&str` and skip the clone - the generated stub comes out identical.



##########
foreign/python/src/consumer.rs:
##########
@@ -56,39 +62,33 @@ impl IggyConsumer {
     /// Get the last consumed offset or `None` if no offset has been consumed 
yet.
     #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
     fn get_last_consumed_offset(&self, partition_id: u32) -> Option<u64> {
-        self.inner
-            .blocking_lock()
-            .get_last_consumed_offset(partition_id)
+        self.state.get_last_consumed_offset(partition_id)
     }
 
     /// Get the last stored offset or `None` if no offset has been stored yet.
     #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
     fn get_last_stored_offset(&self, partition_id: u32) -> Option<u64> {
-        self.inner
-            .blocking_lock()
-            .get_last_stored_offset(partition_id)
+        self.state.get_last_stored_offset(partition_id)
     }
 
     /// Gets the name of the consumer group.
     fn name(&self) -> String {
-        self.inner.blocking_lock().name().to_string()
+        self.name.clone()
     }
 
     /// Gets the current partition id or `0` if no messages have been polled 
yet.
     fn partition_id(&self) -> u32 {
-        self.inner.blocking_lock().partition_id()
+        self.state.partition_id()
     }
 
     /// Gets the name of the stream this consumer group is configured for.

Review Comment:
   also line 89. these return `str | int`, not a name - a numeric-looking name 
becomes a numeric id, so `consumer_group("g", "42", "t")` gives back the int 
42. say identifier.



##########
foreign/python/tests/test_consumer_group.py:
##########
@@ -856,6 +857,74 @@ async def test_consumer_group_metadata(self, iggy_client: 
IggyClient, unique_nam
         assert consumer.get_last_consumed_offset(partition_id) is None
         assert consumer.get_last_stored_offset(partition_id) is None
 
+    @pytest.mark.asyncio
+    async def test_consumer_group_metadata_while_consuming(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test that metadata can be read while a consumption run is in 
progress."""
+        consumer_name = unique_name()
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        message = f"Metadata test - {unique_name()}"
+        received_messages = []
+        consuming = asyncio.Event()
+        shutdown_event = asyncio.Event()
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name,
+            name=topic_name,
+            partitions_count=1,
+        )
+
+        consumer = await iggy_client.consumer_group(
+            consumer_name,
+            stream_name,
+            topic_name,
+            partition_id,
+            PollingStrategy.First(),
+            10,
+            auto_commit=AutoCommit.Disabled(),
+            poll_interval=timedelta(milliseconds=25),
+        )
+
+        async def take(received: ReceiveMessage) -> None:
+            received_messages.append(received)
+            consuming.set()
+
+        await iggy_client.send_messages(
+            stream_name,
+            topic_name,
+            partition_id,
+            [Message(message)],
+        )
+
+        consume = consumer.consume_messages(take, shutdown_event)
+        try:
+            await asyncio.wait_for(consuming.wait(), timeout=10)
+
+            # A getter that blocks holds the GIL, so neither pytest-timeout 
nor asyncio
+            # can fire. The faulthandler watchdog needs no GIL and aborts 
instead.
+            # A regression hangs forever, so the timeout only has to outlast 
normal GIL
+            # contention -- it is generous because tripping it kills the whole 
run.
+            faulthandler.dump_traceback_later(5, exit=True)

Review Comment:
   `exit=True` takes the whole session down, and there's no alternative - a 
gil-pinned deadlock can't be recovered in process. pytest has it built in 
though: `faulthandler_timeout` + `faulthandler_exit_on_timeout` in pyproject 
gives the same thing suite-wide and drops these four lines.



-- 
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