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}