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}