Skip to main content

ruststream_rdkafka/
seek.rs

1//! Repositioning a live subscription: the Kafka position vocabulary and the seeker that
2//! applies it.
3//!
4//! Kafka is a replayable log, so a running consumer can be moved: back to reprocess, forward to
5//! skip a poison region, or to the first record at a wall-clock time. The reposition applies to
6//! the partitions **this consumer instance** currently holds, not to the group - other members
7//! keep reading where they were, and nothing is committed on their behalf.
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use rdkafka::consumer::{Consumer as _, StreamConsumer};
13use rdkafka::error::RDKafkaErrorCode;
14use rdkafka::{Offset, TopicPartitionList};
15use ruststream::Seeker;
16use tokio::task;
17
18use crate::error::KafkaError;
19use crate::tracker::{CommitTracker, TrackingContext};
20
21/// How long a reposition waits for librdkafka (the seek itself, and the timestamp lookup).
22const SEEK_TIMEOUT: Duration = Duration::from_secs(10);
23/// How long a reposition waits for the group to assign partitions before giving up. A seek
24/// issued at startup (the `start_at(..)` clause) runs before the first fetch, so the assignment
25/// may still be in flight.
26const ASSIGNMENT_TIMEOUT: Duration = Duration::from_secs(30);
27const ASSIGNMENT_POLL: Duration = Duration::from_millis(50);
28
29/// Where a subscription should resume reading.
30///
31/// The stream-wide variants ([`Earliest`](Self::Earliest), [`Latest`](Self::Latest),
32/// [`Timestamp`](Self::Timestamp)) apply to every partition currently assigned to this
33/// consumer; [`Offset`](Self::Offset) names one partition. Build them with the constructors
34/// ([`earliest`](Self::earliest), [`offset`](Self::offset), ...) - the variants are what the
35/// seeker matches on.
36///
37/// # Examples
38///
39/// ```
40/// use ruststream_rdkafka::KafkaPosition;
41///
42/// let replay_all = KafkaPosition::earliest();
43/// let skip_ahead = KafkaPosition::offset(3, 1_024);
44/// let since_noon = KafkaPosition::timestamp(1_767_000_000_000);
45/// # let _ = (replay_all, skip_ahead, since_noon);
46/// ```
47#[derive(Debug, Clone, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum KafkaPosition {
50    /// The earliest offset still retained, on every assigned partition.
51    Earliest,
52    /// The end of the log, on every assigned partition, as the broker reports it while the seek
53    /// runs: records published after that point arrive normally, everything before is skipped.
54    Latest,
55    /// The first record at or after this timestamp (epoch milliseconds), resolved per assigned
56    /// partition. A partition with no such record resumes at its end.
57    Timestamp(i64),
58    /// An absolute offset on one partition.
59    Offset {
60        /// The topic, when the position names one. [`Positioned`](ruststream::Positioned)
61        /// captures the delivery's own topic here; [`offset`](Self::offset) leaves it unset,
62        /// which repositions that partition index on every assigned topic (one topic being the
63        /// usual case).
64        topic: Option<String>,
65        /// The partition to reposition.
66        partition: i32,
67        /// The offset to resume from: the record at this offset is delivered next.
68        offset: i64,
69    },
70}
71
72impl KafkaPosition {
73    /// Every assigned partition, at the earliest retained offset.
74    #[must_use]
75    pub const fn earliest() -> Self {
76        Self::Earliest
77    }
78
79    /// Every assigned partition, at the end of the log.
80    #[must_use]
81    pub const fn latest() -> Self {
82        Self::Latest
83    }
84
85    /// One partition, at an absolute offset.
86    #[must_use]
87    pub const fn offset(partition: i32, offset: i64) -> Self {
88        Self::Offset {
89            topic: None,
90            partition,
91            offset,
92        }
93    }
94
95    /// One partition of one topic, at an absolute offset. The form a delivery's
96    /// [`position`](ruststream::Positioned::position) returns, and what a multi-topic
97    /// subscription needs to name a partition unambiguously.
98    #[must_use]
99    pub fn topic_offset(topic: impl Into<String>, partition: i32, offset: i64) -> Self {
100        Self::Offset {
101            topic: Some(topic.into()),
102            partition,
103            offset,
104        }
105    }
106
107    /// Every assigned partition, at the first record whose timestamp is at or after
108    /// `when_millis` (epoch milliseconds).
109    #[must_use]
110    pub const fn timestamp(when_millis: i64) -> Self {
111        Self::Timestamp(when_millis)
112    }
113}
114
115/// Repositions a live [`KafkaSubscriber`](crate::KafkaSubscriber), minted by
116/// [`Seekable::seeker`](ruststream::Seekable::seeker).
117///
118/// Cheap to clone and usable while the subscription's stream runs, which is the point: the
119/// runtime owns the subscriber, so a handler reaches its subscription through an injected
120/// `Seek(seeker)` parameter or through a token minted at the mount site.
121///
122/// # Scope
123///
124/// A seek moves **this consumer instance**, over the partitions it currently holds. It is not a
125/// group operation: it commits nothing, and other members of the group are unaffected.
126///
127/// # Rebalances discard a seek
128///
129/// The reposition lives in the assignment it was applied to. When a rebalance revokes those
130/// partitions - a member joining or leaving, a session timeout, a topic-metadata change - the
131/// seek goes with them: whoever gets the partitions next (this instance included) resumes from
132/// the group's committed offsets. Repositioning is therefore an operational action on a running
133/// consumer, not durable state; a position that must survive restarts belongs in the
134/// subscription descriptor ([`StartOffset`](crate::StartOffset)) or in a `start_at(..)` clause,
135/// which reapplies it on every startup.
136#[derive(Clone)]
137pub struct KafkaSeeker {
138    consumer: Arc<StreamConsumer<TrackingContext>>,
139    tracker: Arc<CommitTracker>,
140}
141
142impl std::fmt::Debug for KafkaSeeker {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.debug_struct("KafkaSeeker").finish_non_exhaustive()
145    }
146}
147
148impl KafkaSeeker {
149    pub(crate) const fn new(
150        consumer: Arc<StreamConsumer<TrackingContext>>,
151        tracker: Arc<CommitTracker>,
152    ) -> Self {
153        Self { consumer, tracker }
154    }
155}
156
157impl Seeker for KafkaSeeker {
158    type Position = KafkaPosition;
159    type Error = KafkaError;
160
161    /// Moves this consumer's assigned partitions to `to`; the next delivery comes from there.
162    ///
163    /// The offset bookkeeping moves with the read position: the tracked watermark of every
164    /// repositioned partition is dropped, so a later commit cannot advance past a record the
165    /// seek replayed but nobody handled, and an exactly-once window that was open when the seek
166    /// landed aborts instead of committing offsets from the position it replaced.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`KafkaError::InvalidOptions`] when the position names no partition this
171    /// consumer holds (including a subscription whose assignment never arrived) and
172    /// [`KafkaError::Consume`] when librdkafka rejects the reposition or the timestamp lookup
173    /// fails.
174    ///
175    /// # Cancel safety
176    ///
177    /// Not cancel safe: dropping the future may leave the consumer repositioned, its
178    /// bookkeeping already reset.
179    async fn seek(&self, to: Self::Position) -> Result<(), Self::Error> {
180        let consumer = Arc::clone(&self.consumer);
181        let tracker = Arc::clone(&self.tracker);
182        // Every librdkafka call on this path blocks (the assignment wait, the timestamp lookup,
183        // the seek itself), so the whole reposition runs on the blocking pool.
184        task::spawn_blocking(move || reposition(&consumer, &tracker, &to))
185            .await
186            .map_err(|err| KafkaError::Consume(Box::new(err)))?
187    }
188}
189
190/// Resolves `to` against the current assignment, resets the bookkeeping of every partition it
191/// names, and moves the consumer.
192fn reposition(
193    consumer: &StreamConsumer<TrackingContext>,
194    tracker: &CommitTracker,
195    to: &KafkaPosition,
196) -> Result<(), KafkaError> {
197    let assignment = await_assignment(consumer)?;
198    let targets = resolve(consumer, &assignment, to)?;
199    if targets.count() == 0 {
200        return Err(KafkaError::InvalidOptions(format!(
201            "{to:?} names no partition assigned to this consumer; a seek moves the partitions \
202             this instance holds, and its assignment is {}",
203            describe(&assignment),
204        )));
205    }
206
207    // The bookkeeping is reset before the consumer moves: between the two, a delivery pulled
208    // from the old position could otherwise settle into the new one and commit past records the
209    // replay has not handled yet.
210    for element in targets.elements() {
211        tracker.reposition(element.topic(), element.partition());
212    }
213    clear_stored_offsets(consumer, &targets)?;
214
215    let outcome = consumer
216        .seek_partitions(targets, SEEK_TIMEOUT)
217        .map_err(KafkaError::consume)?;
218    for element in outcome.elements() {
219        element.error().map_err(KafkaError::consume)?;
220    }
221    Ok(())
222}
223
224/// Clears librdkafka's own stored offsets for the repositioned partitions.
225///
226/// The crate's watermark is not the only offset bookkeeping in play: librdkafka keeps a stored
227/// position per partition and auto-commit flushes it, periodically and once more when the
228/// consumer closes. That store still holds the position from before the seek, so leaving it
229/// would let a commit carry the group past everything the seek replayed - the acks that built
230/// it describe a read position this subscription no longer has. A logical `Invalid` offset is
231/// how librdkafka itself clears the store when a partition is revoked.
232pub(crate) fn clear_stored_offsets(
233    consumer: &StreamConsumer<TrackingContext>,
234    targets: &TopicPartitionList,
235) -> Result<(), KafkaError> {
236    let mut cleared = TopicPartitionList::new();
237    for element in targets.elements() {
238        cleared
239            .add_partition_offset(element.topic(), element.partition(), Offset::Invalid)
240            .map_err(KafkaError::consume)?;
241    }
242    match consumer.store_offsets(&cleared) {
243        Ok(()) => Ok(()),
244        // `Commit::Auto` leaves librdkafka's own offset store enabled, which is exactly the
245        // mode where it refuses application stores - and where it owns the position anyway, so
246        // there is nothing of ours to clear.
247        Err(err) if err.rdkafka_error_code() == Some(RDKafkaErrorCode::InvalidArgument) => Ok(()),
248        Err(err) => Err(KafkaError::consume(err)),
249    }
250}
251
252/// The partitions this consumer holds, waiting out the group assignment when a seek runs before
253/// the first fetch (the `start_at(..)` clause does).
254fn await_assignment(
255    consumer: &StreamConsumer<TrackingContext>,
256) -> Result<TopicPartitionList, KafkaError> {
257    let deadline = std::time::Instant::now() + ASSIGNMENT_TIMEOUT;
258    loop {
259        let assignment = consumer.assignment().map_err(KafkaError::consume)?;
260        if assignment.count() > 0 || std::time::Instant::now() >= deadline {
261            return Ok(assignment);
262        }
263        // The group assigns partitions asynchronously after subscribe; librdkafka's background
264        // poll drives it, so waiting here does not deadlock against the message stream.
265        std::thread::sleep(ASSIGNMENT_POLL);
266    }
267}
268
269/// Builds the seek list: which assigned partitions move, and to which offsets.
270fn resolve(
271    consumer: &StreamConsumer<TrackingContext>,
272    assignment: &TopicPartitionList,
273    to: &KafkaPosition,
274) -> Result<TopicPartitionList, KafkaError> {
275    let mut targets = TopicPartitionList::new();
276    match to {
277        // The log ends are resolved here rather than handed to librdkafka as logical offsets:
278        // a logical end resolves whenever the fetcher next asks the broker, which would put
279        // records published in the meantime on the wrong side of the reposition. Asking now
280        // pins "the end of the log" to the moment the seek ran.
281        KafkaPosition::Earliest | KafkaPosition::Latest => {
282            for element in assignment.elements() {
283                let (low, high) = consumer
284                    .fetch_watermarks(element.topic(), element.partition(), SEEK_TIMEOUT)
285                    .map_err(KafkaError::consume)?;
286                let offset = if matches!(to, KafkaPosition::Earliest) {
287                    low
288                } else {
289                    high
290                };
291                targets
292                    .add_partition_offset(
293                        element.topic(),
294                        element.partition(),
295                        Offset::Offset(offset),
296                    )
297                    .map_err(KafkaError::consume)?;
298            }
299        }
300        KafkaPosition::Timestamp(when_millis) => {
301            let mut query = TopicPartitionList::new();
302            for element in assignment.elements() {
303                query
304                    .add_partition_offset(
305                        element.topic(),
306                        element.partition(),
307                        Offset::Offset(*when_millis),
308                    )
309                    .map_err(KafkaError::consume)?;
310            }
311            let resolved = consumer
312                .offsets_for_times(query, SEEK_TIMEOUT)
313                .map_err(KafkaError::consume)?;
314            for element in resolved.elements() {
315                // A partition with no record at or after the timestamp answers with no usable
316                // offset; the log end is the honest resume point (everything older is before
317                // the requested time).
318                let offset = match element.offset() {
319                    Offset::Offset(offset) if offset >= 0 => Offset::Offset(offset),
320                    _ => Offset::End,
321                };
322                targets
323                    .add_partition_offset(element.topic(), element.partition(), offset)
324                    .map_err(KafkaError::consume)?;
325            }
326        }
327        KafkaPosition::Offset {
328            topic,
329            partition,
330            offset,
331        } => {
332            for element in assignment.elements() {
333                let named = topic.as_ref().is_none_or(|name| name == element.topic());
334                if named && element.partition() == *partition {
335                    targets
336                        .add_partition_offset(
337                            element.topic(),
338                            element.partition(),
339                            Offset::Offset(*offset),
340                        )
341                        .map_err(KafkaError::consume)?;
342                }
343            }
344        }
345    }
346    Ok(targets)
347}
348
349/// A human-readable assignment for the "nothing to seek" error.
350fn describe(assignment: &TopicPartitionList) -> String {
351    if assignment.count() == 0 {
352        return "empty".to_owned();
353    }
354    assignment
355        .elements()
356        .iter()
357        .map(|element| format!("{}[{}]", element.topic(), element.partition()))
358        .collect::<Vec<_>>()
359        .join(", ")
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn constructors_build_the_variants_the_seeker_matches_on() {
368        assert_eq!(KafkaPosition::earliest(), KafkaPosition::Earliest);
369        assert_eq!(KafkaPosition::latest(), KafkaPosition::Latest);
370        assert_eq!(KafkaPosition::timestamp(42), KafkaPosition::Timestamp(42));
371        assert_eq!(
372            KafkaPosition::offset(3, 1_024),
373            KafkaPosition::Offset {
374                topic: None,
375                partition: 3,
376                offset: 1_024,
377            },
378        );
379        assert_eq!(
380            KafkaPosition::topic_offset("orders", 3, 1_024),
381            KafkaPosition::Offset {
382                topic: Some("orders".to_owned()),
383                partition: 3,
384                offset: 1_024,
385            },
386        );
387    }
388}