Skip to main content

krabka_client_consumer/
control.rs

1//! Consumer control calls: Kafka's `KafkaConsumer.wakeup`, `enforceRebalance`
2//! and `close(CloseOptions)`.
3
4use std::sync::{
5    Arc,
6    atomic::{AtomicBool, Ordering},
7};
8
9use krabka_units::{Time, convert::TimeExt, secs};
10
11use crate::{
12    consumer::{Consumer, GroupMembershipOperation},
13    error::ConsumerError,
14};
15
16/// Kafka's `ConsumerUtils.DEFAULT_CLOSE_TIMEOUT_MS`.
17pub const DEFAULT_CONSUMER_CLOSE_TIMEOUT: Time = secs(30);
18
19/// The `JoinGroup` reason of [`Consumer::enforce_rebalance`] without a reason.
20/// Kafka's `ClassicKafkaConsumer.DEFAULT_REASON`.
21pub(crate) const ENFORCED_REBALANCE_REASON: &str = "rebalance enforced by user";
22
23/// How [`Consumer::close_with`](Consumer::close_with) closes the
24/// consumer. Kafka's `CloseOptions` (KIP-1092).
25#[derive(Clone, Copy, Debug, Default, PartialEq, bon::Builder)]
26pub struct CloseOptions {
27    /// What the consumer does with its group membership.
28    #[builder(default)]
29    pub group_membership_operation: GroupMembershipOperation,
30    /// The time that the close can take. `None` is
31    /// [`DEFAULT_CONSUMER_CLOSE_TIMEOUT`].
32    #[builder(with = |millis: i64| Time::from_millis(millis))]
33    pub timeout: Option<Time>,
34}
35
36impl CloseOptions {
37    /// The options with `timeout`. Kafka's `CloseOptions.timeout`.
38    #[must_use]
39    pub fn timeout(timeout: Time) -> Self {
40        Self {
41            timeout: Some(timeout),
42            ..Self::default()
43        }
44    }
45
46    /// The options with `operation`. Kafka's
47    /// `CloseOptions.groupMembershipOperation`.
48    #[must_use]
49    pub fn group_membership_operation(operation: GroupMembershipOperation) -> Self {
50        Self::builder()
51            .group_membership_operation(operation)
52            .build()
53    }
54}
55
56/// What the coordinator task needs when it stops.
57#[derive(Clone, Copy, Debug, Default)]
58pub(crate) struct CloseRequest {
59    pub operation: GroupMembershipOperation,
60}
61
62/// The close timeout of `options`, or an error for a negative timeout.
63pub(crate) fn close_timeout(options: CloseOptions) -> Result<Time, ConsumerError> {
64    let timeout = options.timeout.unwrap_or(DEFAULT_CONSUMER_CLOSE_TIMEOUT);
65    if timeout.secs_f64() < 0.0 || !timeout.secs_f64().is_finite() {
66        return Err(ConsumerError::InvalidArgument(
67            "The timeout cannot be negative.".to_owned(),
68        ));
69    }
70    Ok(timeout)
71}
72
73/// A handle that wakes a [`Consumer::poll`](Consumer::poll) from
74/// another task. Kafka's `KafkaConsumer.wakeup`.
75///
76/// Get it with [`Consumer::wakeup_handle`](Consumer::wakeup_handle).
77/// The poll that runs, or the next poll, returns [`ConsumerError::Wakeup`]
78/// once.
79#[derive(Clone, Debug, Default)]
80pub struct WakeupHandle {
81    inner: Arc<WakeupState>,
82}
83
84#[derive(Debug, Default)]
85struct WakeupState {
86    pending: AtomicBool,
87    notify: tokio::sync::Notify,
88}
89
90impl WakeupHandle {
91    /// Wake the poll that runs, or the next poll.
92    pub fn wakeup(&self) {
93        self.inner.pending.store(true, Ordering::SeqCst);
94        self.inner.notify.notify_waiters();
95    }
96
97    /// Take a pending wakeup. Kafka's `ConsumerNetworkClient.maybeTriggerWakeup`.
98    pub(crate) fn take(&self) -> bool {
99        self.inner.pending.swap(false, Ordering::SeqCst)
100    }
101
102    /// Wait until a wakeup comes, and take it.
103    pub(crate) async fn woken(&self) {
104        loop {
105            let notified = self.inner.notify.notified();
106            tokio::pin!(notified);
107            notified.as_mut().enable();
108            if self.take() {
109                return;
110            }
111            notified.await;
112        }
113    }
114}
115
116impl Consumer {
117    /// A handle that wakes the `poll` of this consumer from another task.
118    /// Kafka's `KafkaConsumer.wakeup` is thread safe; `poll` takes `&mut self`
119    /// here, so another task needs its own handle.
120    #[must_use]
121    pub fn wakeup_handle(&self) -> WakeupHandle {
122        self.wakeup.clone()
123    }
124
125    /// Make the next `poll` return [`ConsumerError::Wakeup`]. Kafka's
126    /// `KafkaConsumer.wakeup`.
127    pub fn wakeup(&self) {
128        self.wakeup.wakeup();
129    }
130
131    /// Ask the group to rebalance. The next `poll` sends `JoinGroup` with
132    /// `reason`, or with Kafka's default reason `rebalance enforced by user`
133    /// when `reason` is `None` or empty. Kafka's
134    /// `KafkaConsumer.enforceRebalance`.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`ConsumerError::InvalidGroupId`] without a group id, and
139    /// [`ConsumerError::IllegalState`] for a consumer without group
140    /// membership (Kafka: `Tried to force a rebalance but consumer does not
141    /// have a group.`). A member of the consumer group protocol logs a
142    /// warning and does nothing, as Kafka's `AsyncKafkaConsumer` does.
143    pub fn enforce_rebalance(&self, reason: Option<&str>) -> Result<(), ConsumerError> {
144        self.require_group_membership()?;
145        if self.group_protocol == crate::GroupProtocol::Consumer {
146            // Kafka's `AsyncKafkaConsumer.enforceRebalance` logs
147            // `Operation not supported in new consumer group protocol` and
148            // does nothing: the group coordinator owns the assignment.
149            tracing::warn!("enforce_rebalance is not supported in the consumer group protocol");
150            return Ok(());
151        }
152        if self.subscription.borrow().manual_assignment {
153            return Err(ConsumerError::IllegalState(
154                "Tried to force a rebalance but the consumer has a manual assignment.".to_owned(),
155            ));
156        }
157        let reason = reason
158            .filter(|reason| !reason.is_empty())
159            .unwrap_or(ENFORCED_REBALANCE_REASON);
160        // A closed channel means that the coordinator task stopped, and no
161        // rebalance can come.
162        // The `poll` count now: the rebalance starts with the first `poll`
163        // after this call, also when the task sees the request after that
164        // `poll`.
165        let polls = *self.poll_signal.borrow();
166        let _ = self.enforced_rebalances.send((reason.to_owned(), polls));
167        Ok(())
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use krabka_units::millis;
174
175    use super::*;
176
177    #[test]
178    fn close_timeout_follows_kafkas_close() {
179        let cases = [
180            (
181                "default timeout",
182                CloseOptions::builder().build(),
183                Ok(DEFAULT_CONSUMER_CLOSE_TIMEOUT),
184            ),
185            (
186                "finite timeout",
187                CloseOptions::timeout(millis(250)),
188                Ok(millis(250)),
189            ),
190            (
191                "negative timeout",
192                CloseOptions::timeout(Time::from_secs_f64(-1.0)),
193                Err("invalid argument: The timeout cannot be negative.".to_owned()),
194            ),
195            (
196                "NaN timeout",
197                CloseOptions::timeout(Time::from_secs_f64(f64::NAN)),
198                Err("invalid argument: The timeout cannot be negative.".to_owned()),
199            ),
200            (
201                "infinite timeout",
202                CloseOptions::timeout(Time::from_secs_f64(f64::INFINITY)),
203                Err("invalid argument: The timeout cannot be negative.".to_owned()),
204            ),
205        ];
206
207        for (name, options, expected) in cases {
208            let actual = close_timeout(options).map_err(|error| error.to_string());
209            assert2::assert!(actual == expected, "case: {name}");
210        }
211    }
212
213    #[tokio::test]
214    async fn wakeup_is_taken_once() {
215        let handle = WakeupHandle::default();
216        handle.wakeup();
217        let first =
218            tokio::time::timeout(std::time::Duration::from_millis(50), handle.woken()).await;
219        let second =
220            tokio::time::timeout(std::time::Duration::from_millis(50), handle.woken()).await;
221        assert2::assert!((first.is_ok(), second.is_ok()) == (true, false));
222    }
223}