This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/rocketmq-clients.git


The following commit(s) were added to refs/heads/master by this push:
     new d8637d26 [ISSUE #1410] [Rust] Treat a panic in the PushConsumer 
listener as consume failure (#1411)
d8637d26 is described below

commit d8637d26bc7dc6a02eb9714b775050b11e4abdfa
Author: Lei Zhiyuan <[email protected]>
AuthorDate: Fri Oct 23 13:54:54 2026 +0800

    [ISSUE #1410] [Rust] Treat a panic in the PushConsumer listener as consume 
failure (#1411)
    
    The message listener was called directly by the consumer workers, so a
    panic unwound through the worker task and stopped it for good, and the
    message being consumed was neither acked nor nacked.
    
    Align with the Java client (ConsumeTask#call catches any Throwable raised
    by the listener and marks the consumption as FAILURE): invoke the listener
    through catch_unwind in both the standard and the FIFO worker, log the
    panic and continue with ConsumeResult::FAILURE, so the message goes
    through the normal redelivery / local retry / DLQ path.
    
    Also take the static-mock lock in client::tests::client_get_session; it
    set MockSession::new expectations without it and could race with
    test_get_session_for_lite_consumer.
---
 rust/src/client.rs        |   1 +
 rust/src/push_consumer.rs | 160 +++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 159 insertions(+), 2 deletions(-)

diff --git a/rust/src/client.rs b/rust/src/client.rs
index e17b8617..8243d75e 100644
--- a/rust/src/client.rs
+++ b/rust/src/client.rs
@@ -1011,6 +1011,7 @@ pub(crate) mod tests {
 
     #[tokio::test]
     async fn client_get_session() {
+        let _m = MTX.lock();
         let context = MockSession::new_context();
         context.expect().returning(|_, _, _| {
             let mut session = MockSession::default();
diff --git a/rust/src/push_consumer.rs b/rust/src/push_consumer.rs
index 60ce721d..0058ba82 100644
--- a/rust/src/push_consumer.rs
+++ b/rust/src/push_consumer.rs
@@ -75,6 +75,34 @@ fn receive_failure_backoff_delay(error: &ClientError) -> 
Duration {
 
 pub type MessageListener = Box<dyn Fn(&MessageView) -> ConsumeResult + Send + 
Sync>;
 
+/// Invoke the message listener, treating a panic as 
[`ConsumeResult::FAILURE`].
+///
+/// Reference Java: `ConsumeTask#call` catches any `Throwable` raised by the 
listener, logs it
+/// and marks the consumption as failure, so the message goes through the 
normal retry or
+/// dead-letter path and the consumer keeps running. Without this, a panic 
would unwind
+/// through the consumer worker task and stop it for good.
+///
+/// Panics cannot be caught when the binary is built with `panic = "abort"`.
+fn consume_message(message_listener: &MessageListener, message: &MessageView) 
-> ConsumeResult {
+    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| 
message_listener(message))) {
+        Ok(consume_result) => consume_result,
+        Err(panic) => {
+            let reason = panic
+                .downcast_ref::<&str>()
+                .map(|s| s.to_string())
+                .or_else(|| panic.downcast_ref::<String>().cloned())
+                .unwrap_or_else(|| "unknown panic payload".to_string());
+            error!(
+                "message listener panicked while consuming message, treat it 
as failure, topic={}, message_id={}, reason={}",
+                message.topic(),
+                message.message_id(),
+                reason
+            );
+            ConsumeResult::FAILURE
+        }
+    }
+}
+
 pub struct PushConsumer {
     client: Client,
     message_listener: Arc<MessageListener>,
@@ -788,7 +816,7 @@ impl StandardConsumerWorker {
         )
         .await?;
         for message in messages {
-            let consume_result = (self.message_listener)(&message);
+            let consume_result = consume_message(&self.message_listener, 
&message);
             match consume_result {
                 ConsumeResult::SUCCESS => {
                     ack_processor.ack_message(message).await;
@@ -927,7 +955,7 @@ impl FifoConsumerWorker {
         let mut delivery_attempt = message.delivery_attempt();
         let max_delivery_attempts = retry_policy.get_max_attempts();
         loop {
-            let consume_result = (message_listener)(&message);
+            let consume_result = consume_message(message_listener, &message);
             match consume_result {
                 ConsumeResult::SUCCESS => {
                     ack_processor.ack_message(message).await;
@@ -2278,4 +2306,132 @@ mod tests {
         assert!(result.is_ok(), "{:?}", result);
         assert_eq!(2, outer_call_counter.load(Ordering::Relaxed));
     }
+
+    // Reference Java: ConsumeTask#call catches any Throwable raised by the 
listener and treats
+    // the consumption as FAILURE, so the message is redelivered and the 
worker keeps running.
+    #[tokio::test]
+    async fn test_standard_consume_messages_listener_panic() {
+        let mut rpc_client = Session::default();
+        rpc_client.expect_receive_message().returning(|_| {
+            Ok(vec![ReceiveMessageResponse {
+                content: Some(Content::Message(new_message())),
+            }])
+        });
+        let consumer_worker = StandardConsumerWorker::new(
+            rpc_client,
+            Arc::new(Box::new(|_| panic!("listener panic in test"))),
+        );
+        let consumer_group = Resource {
+            name: "test_group".to_string(),
+            resource_namespace: "".to_string(),
+        };
+        let topic = Resource {
+            name: "test_topic".to_string(),
+            resource_namespace: "".to_string(),
+        };
+        let retry_policy = 
&BackOffRetryPolicy::Exponential(ExponentialBackOffRetryPolicy::new(
+            pb::ExponentialBackoff {
+                initial: Some(prost_types::Duration::from_str("1s").unwrap()),
+                max: Some(prost_types::Duration::from_str("60s").unwrap()),
+                multiplier: 2.0,
+            },
+            3,
+        ));
+        let mut option = PushConsumerOption::default();
+        option.subscribe("test_topic", FilterExpression::new(FilterType::Tag, 
"*"));
+        let mut session = Session::default();
+        session
+            .expect_shadow_session()
+            .returning(|| Session::default());
+        session.expect_ack_message().never();
+        session
+            .expect_change_invisible_duration()
+            .times(1)
+            .returning(|_| {
+                Ok(ChangeInvisibleDurationResponse {
+                    status: Some(Status {
+                        code: Code::Ok as i32,
+                        message: "".to_string(),
+                    }),
+                    receipt_handle: "".to_string(),
+                })
+            });
+        let mut ack_processor = AckEntryProcessor::new(session, 
consumer_group, topic, false);
+        let is_start_ok = ack_processor.start().await;
+        assert!(is_start_ok.is_ok());
+        let result = ConsumerWorker::Standard(consumer_worker)
+            .receive_messages(
+                &new_message_queue(),
+                &FilterExpression::sub_all(),
+                &option,
+                &mut ack_processor,
+                retry_policy,
+            )
+            .await;
+        assert!(result.is_ok(), "{:?}", result);
+    }
+
+    #[tokio::test]
+    async fn test_fifo_consume_message_listener_panic_then_success() {
+        let mut rpc_client = Session::default();
+        rpc_client.expect_receive_message().returning(|_| {
+            Ok(vec![ReceiveMessageResponse {
+                content: Some(Content::Message(new_message())),
+            }])
+        });
+        let call_count = Arc::new(AtomicUsize::new(0));
+        let outer_call_counter = Arc::clone(&call_count);
+        let consumer_worker = FifoConsumerWorker::new(
+            rpc_client,
+            Arc::new(Box::new(move |_| {
+                if call_count.fetch_add(1, Ordering::Relaxed) == 0 {
+                    panic!("listener panic in test");
+                }
+                ConsumeResult::SUCCESS
+            })),
+        );
+        let consumer_group = Resource {
+            name: "test_group".to_string(),
+            resource_namespace: "".to_string(),
+        };
+        let topic = Resource {
+            name: "test_topic".to_string(),
+            resource_namespace: "".to_string(),
+        };
+        let retry_policy = 
&BackOffRetryPolicy::Customized(CustomizedBackOffRetryPolicy::new(
+            pb::CustomizedBackoff {
+                next: vec![prost_types::Duration::from_str("0.01s").unwrap()],
+            },
+            2,
+        ));
+        let mut option = PushConsumerOption::default();
+        option.subscribe("test_topic", FilterExpression::new(FilterType::Tag, 
"*"));
+        let mut session = Session::default();
+        session
+            .expect_shadow_session()
+            .returning(|| Session::default());
+        session.expect_forward_to_deadletter_queue().never();
+        session.expect_ack_message().times(1).returning(|_| {
+            Ok(AckMessageResponse {
+                status: Some(Status {
+                    code: Code::Ok as i32,
+                    message: "".to_string(),
+                }),
+                entries: vec![],
+            })
+        });
+        let mut ack_processor = AckEntryProcessor::new(session, 
consumer_group, topic, false);
+        let result = ConsumerWorker::Fifo(consumer_worker)
+            .receive_messages(
+                &new_message_queue(),
+                &FilterExpression::sub_all(),
+                &option,
+                &mut ack_processor,
+                retry_policy,
+            )
+            .await;
+        assert!(result.is_ok(), "{:?}", result);
+        // The panic counts as one failed attempt; the retry succeeds and is 
acked.
+        assert_eq!(2, outer_call_counter.load(Ordering::Relaxed));
+    }
 }

Reply via email to