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, StreamConsumer};
13use rdkafka::error::RDKafkaErrorCode;
14use rdkafka::{Offset, TopicPartitionList};
15use ruststream::{Seeker, Str};
16use tokio::task;
17
18use crate::broker::ConnState;
19use crate::error::KafkaError;
20#[cfg(feature = "testing")]
21use crate::in_process::Member;
22use crate::tracker::{CommitTracker, TrackedConsumer, TrackingContext};
23
24/// How long a reposition waits for librdkafka (the seek itself, and the timestamp lookup).
25const SEEK_TIMEOUT: Duration = Duration::from_secs(10);
26
27/// Where a subscription should resume reading.
28///
29/// The stream-wide variants ([`Earliest`](Self::Earliest), [`Latest`](Self::Latest),
30/// [`Timestamp`](Self::Timestamp)) apply to every partition currently assigned to this
31/// consumer; [`Offset`](Self::Offset) names one partition. Build them with the constructors
32/// ([`earliest`](Self::earliest), [`offset`](Self::offset), ...) - the variants are what the
33/// seeker matches on.
34///
35/// # Examples
36///
37/// A read model rebuilt from the start of the retained log on every startup, whatever the group
38/// committed before:
39///
40/// ```
41/// use ruststream_rdkafka::prelude::*;
42/// # #[derive(serde::Deserialize)]
43/// # struct Order { id: u64 }
44///
45/// #[subscriber(
46///     KafkaTopic::new("orders").group("orders-projection"),
47///     start_at(KafkaPosition::earliest())
48/// )]
49/// async fn project(order: &Order) -> HandlerOutcome {
50///     tracing::info!(order.id, "projecting");
51///     HandlerOutcome::ack()
52/// }
53///
54/// fn app() -> RustStream {
55///     RustStream::new(AppInfo::new("projection", "0.1.0"))
56///         .with_broker(KafkaBroker::new(["localhost:9092"]), |b| {
57///             b.include(project);
58///         })
59/// }
60/// # let _ = app;
61/// ```
62#[derive(Debug, Clone, PartialEq, Eq)]
63#[non_exhaustive]
64pub enum KafkaPosition {
65    /// The earliest offset still retained, on every assigned partition.
66    Earliest,
67    /// The end of the log, on every assigned partition, as the broker reports it while the seek
68    /// runs: records published after that point arrive normally, everything before is skipped.
69    Latest,
70    /// The first record at or after this timestamp (epoch milliseconds), resolved per assigned
71    /// partition. A partition with no such record resumes at its end.
72    Timestamp(i64),
73    /// An absolute offset on one partition.
74    Offset {
75        /// The topic, when the position names one. [`Positioned`](ruststream::Positioned)
76        /// captures the delivery's own topic here; [`offset`](Self::offset) leaves it unset,
77        /// which repositions that partition index on every assigned topic (one topic being the
78        /// usual case).
79        topic: Option<String>,
80        /// The partition to reposition.
81        partition: i32,
82        /// The offset to resume from: the record at this offset is delivered next.
83        offset: i64,
84    },
85}
86
87impl KafkaPosition {
88    /// Every assigned partition, at the earliest retained offset.
89    #[must_use]
90    pub const fn earliest() -> Self {
91        Self::Earliest
92    }
93
94    /// Every assigned partition, at the end of the log.
95    #[must_use]
96    pub const fn latest() -> Self {
97        Self::Latest
98    }
99
100    /// One partition, at an absolute offset.
101    #[must_use]
102    pub const fn offset(partition: i32, offset: i64) -> Self {
103        Self::Offset {
104            topic: None,
105            partition,
106            offset,
107        }
108    }
109
110    /// One partition of one topic, at an absolute offset. The form a delivery's
111    /// [`position`](ruststream::Positioned::position) returns, and what a multi-topic
112    /// subscription needs to name a partition unambiguously.
113    #[must_use]
114    pub fn topic_offset(topic: impl Into<String>, partition: i32, offset: i64) -> Self {
115        Self::Offset {
116            topic: Some(topic.into()),
117            partition,
118            offset,
119        }
120    }
121
122    /// Every assigned partition, at the first record whose timestamp is at or after
123    /// `when_millis` (epoch milliseconds).
124    #[must_use]
125    pub const fn timestamp(when_millis: i64) -> Self {
126        Self::Timestamp(when_millis)
127    }
128}
129
130/// What a [`KafkaSeeker`] repositions.
131///
132/// A live consumer moves its assigned partitions through librdkafka; under the `testing`
133/// feature a member of the in-process cluster moves its partitions there, by the same rules.
134/// Without the feature there is one variant and no branch.
135#[derive(Clone)]
136enum Repositioner {
137    /// A librdkafka consumer: the reposition is a real seek over the partitions this member
138    /// holds, plus the offset bookkeeping the new read position invalidates.
139    Live {
140        consumer: Arc<TrackedConsumer>,
141        tracker: Arc<CommitTracker>,
142    },
143    /// A member of the in-process cluster.
144    #[cfg(feature = "testing")]
145    InProcess(Arc<Member>),
146}
147
148#[cfg(not(feature = "testing"))]
149const _: () = assert!(size_of::<Repositioner>() == 2 * size_of::<usize>());
150
151/// Repositions a live [`KafkaSubscriber`](crate::KafkaSubscriber), minted by
152/// [`Seekable::seeker`](ruststream::Seekable::seeker).
153///
154/// Cheap to clone and usable while the subscription's stream runs, which is the point: the
155/// runtime owns the subscriber, so a handler reaches its subscription through the `SeekHandle`
156/// context key ([`Ctx(seeker): Ctx<SeekHandle>`](crate::context::keys::SeekHandle)) or through a
157/// token minted at the mount site.
158///
159/// # Scope
160///
161/// A seek moves **this consumer instance**, over the partitions it currently holds. It is not a
162/// group operation: it commits nothing, and other members of the group are unaffected.
163///
164/// # Rebalances discard a seek
165///
166/// The reposition lives in the assignment it was applied to. When a rebalance revokes those
167/// partitions - a member joining or leaving, a session timeout, a topic-metadata change - the
168/// seek goes with them: whoever gets the partitions next (this instance included) resumes from
169/// the group's committed offsets. Repositioning is therefore an operational action on a running
170/// consumer, not durable state; a position that must survive restarts belongs in the
171/// subscription descriptor ([`StartOffset`](crate::StartOffset)) or in a `start_at(..)` clause,
172/// which reapplies it on every startup.
173#[derive(Clone)]
174pub struct KafkaSeeker {
175    inner: Repositioner,
176    /// The connection the subscription was opened on. A seeker outlives the shutdown in a
177    /// handler's hands, and a seek through it must then error rather than move a consumer the
178    /// service no longer runs.
179    connection: Arc<ConnState>,
180    /// The subscription's name, for the error a seek after shutdown reports.
181    subscription: Str,
182}
183
184impl std::fmt::Debug for KafkaSeeker {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        let transport = match &self.inner {
187            Repositioner::Live { .. } => "live",
188            #[cfg(feature = "testing")]
189            Repositioner::InProcess(_) => "in-process",
190        };
191        f.debug_struct("KafkaSeeker")
192            .field("transport", &transport)
193            .finish_non_exhaustive()
194    }
195}
196
197impl KafkaSeeker {
198    pub(crate) const fn new(
199        consumer: Arc<TrackedConsumer>,
200        tracker: Arc<CommitTracker>,
201        connection: Arc<ConnState>,
202        subscription: Str,
203    ) -> Self {
204        Self {
205            inner: Repositioner::Live { consumer, tracker },
206            connection,
207            subscription,
208        }
209    }
210
211    #[cfg(feature = "testing")]
212    pub(crate) const fn in_process(
213        member: Arc<Member>,
214        connection: Arc<ConnState>,
215        subscription: Str,
216    ) -> Self {
217        Self {
218            inner: Repositioner::InProcess(member),
219            connection,
220            subscription,
221        }
222    }
223}
224
225impl Seeker for KafkaSeeker {
226    type Position = KafkaPosition;
227    type Error = KafkaError;
228
229    /// Moves this consumer's assigned partitions to `to`; the next delivery comes from there.
230    ///
231    /// The offset bookkeeping moves with the read position: the tracked watermark of every
232    /// repositioned partition is dropped, so a later commit cannot advance past a record the
233    /// seek replayed but nobody handled, and an exactly-once window that was open when the seek
234    /// landed aborts instead of committing offsets from the position it replaced.
235    ///
236    /// A consumer that holds no partitions yet is not an error: a group can open a subscription
237    /// with none, when it has more members than partitions or the topic does not exist yet. The
238    /// position is kept and applied to the first assignment the group hands over, ahead of its
239    /// first record.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`KafkaError::Closed`] once the broker the subscription was opened on has shut
244    /// down, [`KafkaError::InvalidOptions`] when the position names no partition among the ones
245    /// this consumer holds and [`KafkaError::Consume`] when librdkafka rejects the reposition or
246    /// the timestamp lookup fails.
247    ///
248    /// # Cancel safety
249    ///
250    /// Not cancel safe: dropping the future may leave the consumer repositioned, its bookkeeping
251    /// already reset.
252    async fn seek(&self, to: Self::Position) -> Result<(), Self::Error> {
253        self.connection.ensure_open(&self.subscription)?;
254        match &self.inner {
255            Repositioner::Live { consumer, tracker } => {
256                let consumer = Arc::clone(consumer);
257                let tracker = Arc::clone(tracker);
258                // Every librdkafka call on this path blocks (the assignment wait, the timestamp
259                // lookup, the seek itself), so the whole reposition runs on the blocking pool.
260                task::spawn_blocking(move || reposition(&consumer, &tracker, &to))
261                    .await
262                    .map_err(|err| KafkaError::Consume(Box::new(err)))?
263            }
264            #[cfg(feature = "testing")]
265            Repositioner::InProcess(member) => member.seek(&to),
266        }
267    }
268}
269
270/// Resolves `to` against the current assignment, resets the bookkeeping of every partition it
271/// names, and moves the consumer.
272///
273/// A consumer with nothing assigned keeps the position instead: a group can open a subscription
274/// with no partitions, and a `start_at(..)` on it still has to take effect. [`TrackingContext`]
275/// applies it to the assignment when it lands.
276fn reposition(
277    consumer: &StreamConsumer<TrackingContext>,
278    tracker: &CommitTracker,
279    to: &KafkaPosition,
280) -> Result<(), KafkaError> {
281    let assignment = consumer.assignment().map_err(KafkaError::consume)?;
282    if assignment.count() == 0 {
283        consumer.context().hold_start(to.clone());
284        return Ok(());
285    }
286    let targets = apply_position(consumer, tracker, &assignment, to)?;
287    // A subscription whose stream has not started yet resumes from here when it does.
288    consumer.context().startup.moved(&targets);
289    Ok(())
290}
291
292/// Moves `assignment`'s partitions to `to`, resetting the offset bookkeeping of each one first:
293/// the reposition of a running subscription.
294fn apply_position<C>(
295    consumer: &C,
296    tracker: &CommitTracker,
297    assignment: &TopicPartitionList,
298    to: &KafkaPosition,
299) -> Result<TopicPartitionList, KafkaError>
300where
301    C: Consumer<TrackingContext>,
302{
303    let targets = targets_in(consumer, assignment, to)?;
304
305    // The bookkeeping is reset before the consumer moves: between the two, a delivery pulled
306    // from the old position could otherwise settle into the new one and commit past records the
307    // replay has not handled yet.
308    for element in targets.elements() {
309        tracker.reposition(&Str::from(element.topic()), element.partition());
310    }
311    clear_stored_offsets(consumer, &targets)?;
312
313    let outcome = consumer
314        .seek_partitions(targets.clone(), SEEK_TIMEOUT)
315        .map_err(KafkaError::consume)?;
316    for element in outcome.elements() {
317        element.error().map_err(KafkaError::consume)?;
318    }
319    Ok(targets)
320}
321
322/// Writes `to` into an assignment the group has just handed over, before librdkafka takes it, so
323/// fetching begins there; the offset bookkeeping of each partition it names is reset first.
324///
325/// This is how a start position reaches a group subscription. Seeking the new partitions instead
326/// would race librdkafka: it refuses a seek (`Local: Erroneous state`) until it has fetched the
327/// group's committed offset for the partition, and that fetch is still in flight when the
328/// rebalance callback runs. An offset carried by the assignment itself needs no committed offset.
329pub(crate) fn position_assignment<C>(
330    consumer: &C,
331    tracker: &CommitTracker,
332    assignment: &mut TopicPartitionList,
333    to: &KafkaPosition,
334) -> Result<(), KafkaError>
335where
336    C: Consumer<TrackingContext>,
337{
338    let targets = targets_in(consumer, assignment, to)?;
339    for element in targets.elements() {
340        tracker.reposition(&Str::from(element.topic()), element.partition());
341        assignment
342            .set_partition_offset(element.topic(), element.partition(), element.offset())
343            .map_err(KafkaError::consume)?;
344    }
345    Ok(())
346}
347
348/// Resolves `to` against `assignment`, refusing a position that names none of its partitions.
349fn targets_in<C>(
350    consumer: &C,
351    assignment: &TopicPartitionList,
352    to: &KafkaPosition,
353) -> Result<TopicPartitionList, KafkaError>
354where
355    C: Consumer<TrackingContext>,
356{
357    let targets = resolve(consumer, assignment, to)?;
358    if targets.count() == 0 {
359        return Err(KafkaError::InvalidOptions(format!(
360            "{to:?} names no partition assigned to this consumer; a seek moves the partitions \
361             this instance holds, and its assignment is {}",
362            describe(assignment),
363        )));
364    }
365    Ok(targets)
366}
367
368/// Clears librdkafka's own stored offsets for the repositioned partitions.
369///
370/// The crate's watermark is not the only offset bookkeeping in play: librdkafka keeps a stored
371/// position per partition and auto-commit flushes it, periodically and once more when the
372/// consumer closes. That store still holds the position from before the seek, so leaving it
373/// would let a commit carry the group past everything the seek replayed - the acks that built
374/// it describe a read position this subscription no longer has. A logical `Invalid` offset is
375/// how librdkafka itself clears the store when a partition is revoked.
376pub(crate) fn clear_stored_offsets<C>(
377    consumer: &C,
378    targets: &TopicPartitionList,
379) -> Result<(), KafkaError>
380where
381    C: Consumer<TrackingContext>,
382{
383    let mut cleared = TopicPartitionList::new();
384    for element in targets.elements() {
385        cleared
386            .add_partition_offset(element.topic(), element.partition(), Offset::Invalid)
387            .map_err(KafkaError::consume)?;
388    }
389    match consumer.store_offsets(&cleared) {
390        Ok(()) => Ok(()),
391        // `Commit::Auto` leaves librdkafka's own offset store enabled, which is exactly the
392        // mode where it refuses application stores - and where it owns the position anyway, so
393        // there is nothing of ours to clear.
394        Err(err) if err.rdkafka_error_code() == Some(RDKafkaErrorCode::InvalidArgument) => Ok(()),
395        Err(err) => Err(KafkaError::consume(err)),
396    }
397}
398
399/// Builds the seek list: which assigned partitions move, and to which offsets.
400fn resolve<C>(
401    consumer: &C,
402    assignment: &TopicPartitionList,
403    to: &KafkaPosition,
404) -> Result<TopicPartitionList, KafkaError>
405where
406    C: Consumer<TrackingContext>,
407{
408    let mut targets = TopicPartitionList::new();
409    match to {
410        // The log ends are resolved here rather than handed to librdkafka as logical offsets:
411        // a logical end resolves whenever the fetcher next asks the broker, which would put
412        // records published in the meantime on the wrong side of the reposition. Asking now
413        // pins "the end of the log" to the moment the seek ran.
414        KafkaPosition::Earliest | KafkaPosition::Latest => {
415            for element in assignment.elements() {
416                let (low, high) = consumer
417                    .fetch_watermarks(element.topic(), element.partition(), SEEK_TIMEOUT)
418                    .map_err(KafkaError::consume)?;
419                let offset = if matches!(to, KafkaPosition::Earliest) {
420                    low
421                } else {
422                    high
423                };
424                targets
425                    .add_partition_offset(
426                        element.topic(),
427                        element.partition(),
428                        Offset::Offset(offset),
429                    )
430                    .map_err(KafkaError::consume)?;
431            }
432        }
433        KafkaPosition::Timestamp(when_millis) => {
434            let mut query = TopicPartitionList::new();
435            for element in assignment.elements() {
436                query
437                    .add_partition_offset(
438                        element.topic(),
439                        element.partition(),
440                        Offset::Offset(*when_millis),
441                    )
442                    .map_err(KafkaError::consume)?;
443            }
444            let resolved = consumer
445                .offsets_for_times(query, SEEK_TIMEOUT)
446                .map_err(KafkaError::consume)?;
447            for element in resolved.elements() {
448                // A partition with no record at or after the timestamp answers with no usable
449                // offset; the log end is the honest resume point (everything older is before
450                // the requested time), pinned now for the reason the arm above pins it.
451                let offset = match element.offset() {
452                    Offset::Offset(offset) if offset >= 0 => offset,
453                    _ => {
454                        consumer
455                            .fetch_watermarks(element.topic(), element.partition(), SEEK_TIMEOUT)
456                            .map_err(KafkaError::consume)?
457                            .1
458                    }
459                };
460                let offset = Offset::Offset(offset);
461                targets
462                    .add_partition_offset(element.topic(), element.partition(), offset)
463                    .map_err(KafkaError::consume)?;
464            }
465        }
466        KafkaPosition::Offset {
467            topic,
468            partition,
469            offset,
470        } => {
471            for element in assignment.elements() {
472                let named = topic.as_ref().is_none_or(|name| name == element.topic());
473                if named && element.partition() == *partition {
474                    targets
475                        .add_partition_offset(
476                            element.topic(),
477                            element.partition(),
478                            Offset::Offset(*offset),
479                        )
480                        .map_err(KafkaError::consume)?;
481                }
482            }
483        }
484    }
485    Ok(targets)
486}
487
488/// A human-readable assignment for the "nothing to seek" error and for the rebalance log line.
489pub(crate) fn describe(assignment: &TopicPartitionList) -> String {
490    if assignment.count() == 0 {
491        return "empty".to_owned();
492    }
493    assignment
494        .elements()
495        .iter()
496        .map(|element| format!("{}[{}]", element.topic(), element.partition()))
497        .collect::<Vec<_>>()
498        .join(", ")
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn constructors_build_the_variants_the_seeker_matches_on() {
507        assert_eq!(KafkaPosition::earliest(), KafkaPosition::Earliest);
508        assert_eq!(KafkaPosition::latest(), KafkaPosition::Latest);
509        assert_eq!(KafkaPosition::timestamp(42), KafkaPosition::Timestamp(42));
510        assert_eq!(
511            KafkaPosition::offset(3, 1_024),
512            KafkaPosition::Offset {
513                topic: None,
514                partition: 3,
515                offset: 1_024,
516            },
517        );
518        assert_eq!(
519            KafkaPosition::topic_offset("orders", 3, 1_024),
520            KafkaPosition::Offset {
521                topic: Some("orders".to_owned()),
522                partition: 3,
523                offset: 1_024,
524            },
525        );
526    }
527}