Skip to main content

s2_sdk/session/
read.rs

1use std::{
2    future::Future,
3    pin::Pin,
4    task::{Context, Poll},
5    time::Duration,
6};
7
8use async_stream::{stream, try_stream};
9use futures_util::{
10    StreamExt,
11    future::{FutureExt, Shared},
12};
13use s2_api::v1::stream::{ReadEnd, ReadStart};
14use tokio::{
15    sync::oneshot,
16    time::{Instant, timeout},
17};
18use tracing::debug;
19
20use crate::{
21    api::{ApiError, BasinClient, retry_builder},
22    error::{ReadError, RequestError},
23    reconnect::{AdvisedReconnects, ReconnectAdvice},
24    retry::RetryBackoff,
25    types::{
26        AccessTokenMode, EncryptionKey, MeteredBytes, ReadBatch, ReadInput, ReadSessionConfig,
27        ReadSessionRetryPolicy, StreamName, StreamPosition,
28    },
29};
30
31#[derive(Debug, thiserror::Error)]
32enum ReadSessionFailure {
33    #[error(transparent)]
34    Api(#[from] ApiError),
35    #[error("heartbeat timeout")]
36    HeartbeatTimeout,
37}
38
39impl ReadSessionFailure {
40    pub fn is_retryable(&self) -> bool {
41        match self {
42            Self::Api(err) => err.is_retryable(),
43            Self::HeartbeatTimeout => true,
44        }
45    }
46
47    fn is_authentication_error(&self) -> bool {
48        matches!(self, Self::Api(error) if error.is_authentication_error())
49    }
50
51    fn is_server_draining(&self) -> bool {
52        matches!(self, Self::Api(error) if error.is_server_draining())
53    }
54}
55
56/// Errors returned by a read session.
57#[derive(Debug, Clone, thiserror::Error)]
58#[non_exhaustive]
59pub enum ReadSessionError {
60    /// An error with the read request underlying the session.
61    #[error(transparent)]
62    Read(#[from] ReadError),
63    /// The session heartbeat timed out.
64    #[error("heartbeat timeout")]
65    HeartbeatTimeout,
66}
67
68impl ReadSessionError {
69    /// Whether retrying the operation is safe or sensible.
70    pub fn is_retryable(&self) -> bool {
71        match self {
72            Self::Read(error) => error.is_retryable(),
73            Self::HeartbeatTimeout => true,
74        }
75    }
76
77    /// Return the underlying request error, if present.
78    pub fn request_error(&self) -> Option<&RequestError> {
79        match self {
80            Self::Read(error) => error.request_error(),
81            Self::HeartbeatTimeout => None,
82        }
83    }
84}
85
86impl From<ReadSessionFailure> for ReadSessionError {
87    fn from(error: ReadSessionFailure) -> Self {
88        match error {
89            ReadSessionFailure::Api(error) => Self::Read(error.into()),
90            ReadSessionFailure::HeartbeatTimeout => Self::HeartbeatTimeout,
91        }
92    }
93}
94
95/// The server heartbeats a tailing read session at a randomized gap of at most
96/// 15 seconds (<https://s2.dev/docs/api/protocol#data-flow>), plus some buffer.
97const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(20);
98
99type InternalStreaming<R> =
100    Pin<Box<dyn Send + futures_core::Stream<Item = Result<R, ReadSessionFailure>>>>;
101
102/// An item from a single read connection.
103enum ReadItem {
104    Batch(ReadBatch),
105    /// The server advised reconnecting and the response ended cleanly.
106    ///
107    /// Always the last item of a connection, emitted after the batch it rode
108    /// in on, so the resume position already accounts for that batch.
109    ReconnectAdvised,
110}
111
112#[derive(Debug, Clone, thiserror::Error)]
113#[non_exhaustive]
114/// Error returned while waiting for a read session to catch up.
115pub enum CaughtUpError {
116    #[error("read session ended before catching up")]
117    /// The session ended before reaching a reported tail.
118    SessionClosed,
119    #[error(transparent)]
120    /// The read failed.
121    Read(#[from] ReadSessionError),
122}
123
124impl CaughtUpError {
125    /// Whether retrying the operation is safe or sensible.
126    pub fn is_retryable(&self) -> bool {
127        match self {
128            Self::SessionClosed => false,
129            Self::Read(error) => error.is_retryable(),
130        }
131    }
132
133    /// Return the underlying request error, if present.
134    pub fn request_error(&self) -> Option<&RequestError> {
135        match self {
136            Self::SessionClosed => None,
137            Self::Read(error) => error.request_error(),
138        }
139    }
140}
141
142type CaughtUpResult = Result<StreamPosition, CaughtUpError>;
143
144#[derive(Clone)]
145enum CaughtUpFuture {
146    Pending(Shared<oneshot::Receiver<CaughtUpResult>>),
147    Ready(CaughtUpResult),
148}
149
150impl Future for CaughtUpFuture {
151    type Output = CaughtUpResult;
152
153    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
154        match &mut *self {
155            Self::Pending(future) => match Pin::new(future).poll(cx) {
156                Poll::Ready(Ok(result)) => Poll::Ready(result),
157                Poll::Ready(Err(_)) => Poll::Ready(Err(CaughtUpError::SessionClosed)),
158                Poll::Pending => Poll::Pending,
159            },
160            Self::Ready(result) => Poll::Ready(result.clone()),
161        }
162    }
163}
164
165struct CaughtUpState {
166    /// Latest reported tail we've fully delivered, if currently caught up.
167    tail: Option<StreamPosition>,
168    /// Once set, the session has ended.
169    terminal: bool,
170    /// Fires the current caught-up future.
171    tx: Option<oneshot::Sender<CaughtUpResult>>,
172    /// The future handed out by `caught_up()`.
173    future: CaughtUpFuture,
174}
175
176impl CaughtUpState {
177    fn new() -> Self {
178        let (tx, future) = pending_catch_up();
179        Self {
180            tail: None,
181            terminal: false,
182            tx: Some(tx),
183            future,
184        }
185    }
186
187    fn is_caught_up(&self) -> bool {
188        self.tail.is_some()
189    }
190
191    fn future(&self) -> CaughtUpFuture {
192        self.future.clone()
193    }
194
195    fn set_behind(&mut self) {
196        if self.terminal || self.tail.take().is_none() {
197            return;
198        }
199        let (tx, future) = pending_catch_up();
200        self.tx = Some(tx);
201        self.future = future;
202    }
203
204    fn set_caught_up(&mut self, tail: StreamPosition) {
205        if self.terminal || self.tail == Some(tail) {
206            return;
207        }
208        self.tail = Some(tail);
209        self.complete(Ok(tail));
210    }
211
212    fn end(&mut self, error: Option<ReadSessionError>) {
213        if self.terminal {
214            return;
215        }
216        self.terminal = true;
217        if let Some(error) = error {
218            self.tail = None;
219            self.complete(Err(CaughtUpError::Read(error)));
220        } else if self.tail.is_none() {
221            self.complete(Err(CaughtUpError::SessionClosed));
222        }
223    }
224
225    fn complete(&mut self, result: CaughtUpResult) {
226        if let Some(tx) = self.tx.take() {
227            let _ = tx.send(result);
228        } else {
229            self.future = CaughtUpFuture::Ready(result);
230        }
231    }
232}
233
234fn pending_catch_up() -> (oneshot::Sender<CaughtUpResult>, CaughtUpFuture) {
235    let (tx, rx) = oneshot::channel();
236    (tx, CaughtUpFuture::Pending(rx.shared()))
237}
238
239struct ReadUpdate {
240    batch: Option<ReadBatch>,
241    caught_up_tail: Option<StreamPosition>,
242    resume_seq_num: Option<u64>,
243}
244
245impl ReadUpdate {
246    fn behind() -> Self {
247        Self {
248            batch: None,
249            caught_up_tail: None,
250            resume_seq_num: None,
251        }
252    }
253
254    fn from_batch(mut batch: ReadBatch, ignore_command_records: bool) -> Self {
255        let resume_seq_num = resume_seq_num_after_batch(&batch);
256        let caught_up_tail = batch.tail.filter(|tail| {
257            batch.records.is_empty()
258                || batch
259                    .records
260                    .last()
261                    .is_some_and(|record| record.seq_num.checked_add(1) == Some(tail.seq_num))
262        });
263
264        if ignore_command_records {
265            batch.records.retain(|record| !record.is_command_record());
266        }
267
268        Self {
269            batch: (!batch.records.is_empty()).then_some(batch),
270            caught_up_tail,
271            resume_seq_num,
272        }
273    }
274}
275
276/// A continuous stream of read batches.
277pub struct ReadSession {
278    updates: InternalStreaming<ReadUpdate>,
279    state: CaughtUpState,
280    resume_seq_num: Option<u64>,
281}
282
283impl ReadSession {
284    fn new(updates: InternalStreaming<ReadUpdate>, resume_seq_num: Option<u64>) -> Self {
285        Self {
286            updates,
287            state: CaughtUpState::new(),
288            resume_seq_num,
289        }
290    }
291
292    /// Return the absolute sequence number from which the session would resume after a retry.
293    ///
294    /// An unclamped absolute starting sequence number is available immediately. A timestamp,
295    /// tail-relative, or clamped start returns `None` until the session receives a record or a
296    /// reported tail. The returned value is the sequence number of the next record the session
297    /// expects. It advances as the session is polled, including across records hidden by
298    /// [`ReadInput::ignore_command_records`](crate::types::ReadInput::ignore_command_records).
299    pub fn resume_seq_num(&self) -> Option<u64> {
300        self.resume_seq_num
301    }
302
303    /// Return whether all records through the latest reported tail were delivered.
304    ///
305    /// A later batch that does not reach a reported tail or a reconnect resets it.
306    /// Ignored command records count toward progress. Use
307    /// [`S2Stream::check_tail`](crate::S2Stream::check_tail) for the current tail.
308    pub fn is_caught_up(&self) -> bool {
309        self.state.is_caught_up()
310    }
311
312    /// Return a future for the current or next caught-up tail.
313    ///
314    /// Continue polling the read session while awaiting this future; the future does not drive
315    /// reads itself. It is ready immediately when the session is already caught up and remains
316    /// pending across retries. Once it resolves, its returned tail never changes. If the session
317    /// later falls behind, call `caught_up()` again to wait for the next catch-up. The future
318    /// returns [`CaughtUpError`] if the session fails or closes before catching up.
319    pub fn caught_up(
320        &self,
321    ) -> impl Future<Output = Result<StreamPosition, CaughtUpError>>
322    + Clone
323    + Send
324    + Sync
325    + Unpin
326    + 'static {
327        self.state.future()
328    }
329}
330
331impl futures_core::Stream for ReadSession {
332    type Item = Result<ReadBatch, ReadSessionError>;
333
334    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
335        loop {
336            match self.updates.as_mut().poll_next(cx) {
337                Poll::Pending => return Poll::Pending,
338                Poll::Ready(Some(Ok(update))) => {
339                    if let Some(resume_seq_num) = update.resume_seq_num {
340                        self.resume_seq_num = Some(resume_seq_num);
341                    }
342                    if let Some(tail) = update.caught_up_tail {
343                        self.state.set_caught_up(tail);
344                    } else {
345                        self.state.set_behind();
346                    }
347                    if let Some(batch) = update.batch {
348                        return Poll::Ready(Some(Ok(batch)));
349                    }
350                }
351                Poll::Ready(Some(Err(error))) => {
352                    let error = ReadSessionError::from(error);
353                    self.state.end(Some(error.clone()));
354                    return Poll::Ready(Some(Err(error)));
355                }
356                Poll::Ready(None) => {
357                    self.state.end(None);
358                    return Poll::Ready(None);
359                }
360            }
361        }
362    }
363}
364
365impl Drop for ReadSession {
366    fn drop(&mut self) {
367        self.state.end(None);
368    }
369}
370
371pub async fn read_session(
372    client: BasinClient,
373    name: StreamName,
374    encryption: Option<EncryptionKey>,
375    input: ReadInput,
376    config: ReadSessionConfig,
377) -> Result<ReadSession, ReadSessionError> {
378    let ReadInput {
379        start,
380        stop,
381        ignore_command_records,
382    } = input;
383    let mut start: ReadStart = start.into();
384    let mut end: ReadEnd = stop.into();
385    let retry_policy = config.retry_policy;
386    let mut retry_backoff = retry_builder(&client.config.retry).build();
387    let access_token_mode = client.config.access_token.mode();
388    let baseline_wait = end.wait;
389    let mut last_tail_at: Option<Instant> = None;
390    let mut advised_reconnects = AdvisedReconnects::default();
391    let initial_resume_seq_num = if start.clamp == Some(true) {
392        None
393    } else {
394        start.seq_num
395    };
396
397    let batches = loop {
398        end.wait = remaining_wait(baseline_wait, last_tail_at);
399        match session_inner(
400            client.clone(),
401            name.clone(),
402            encryption.clone(),
403            start.clone(),
404            end.clone(),
405            ReconnectAdvice::default(),
406            advised_reconnects,
407        )
408        .await
409        {
410            Ok(batches) => {
411                retry_backoff.reset();
412                break batches;
413            }
414            Err(err) => {
415                if take_server_draining_reconnect(&err, &mut advised_reconnects) {
416                    debug!(
417                        advised_reconnects = advised_reconnects.count(),
418                        "reconnecting initial read session while server drains"
419                    );
420                    continue;
421                }
422                if let Some(backoff) =
423                    retry_delay(&err, &mut retry_backoff, retry_policy, access_token_mode)
424                {
425                    tokio::time::sleep(backoff).await;
426                    continue;
427                }
428                return Err(err.into());
429            }
430        }
431    };
432
433    let updates = Box::pin(stream! {
434        let mut batches: Option<InternalStreaming<ReadItem>> = Some(batches);
435
436        loop {
437            if batches.is_none() {
438                end.wait = remaining_wait(baseline_wait, last_tail_at);
439                match session_inner(
440                    client.clone(),
441                    name.clone(),
442                    encryption.clone(),
443                    start.clone(),
444                    end.clone(),
445                    ReconnectAdvice::default(),
446                    advised_reconnects,
447                ).await {
448                    Ok(b) => batches = Some(b),
449                    Err(err) => {
450                        if take_server_draining_reconnect(&err, &mut advised_reconnects) {
451                            debug!(
452                                resume_seq_num = ?start.seq_num,
453                                advised_reconnects = advised_reconnects.count(),
454                                "reconnecting read session while server drains"
455                            );
456                            continue;
457                        }
458                        if let Some(backoff) =
459                            retry_delay(
460                                &err,
461                                &mut retry_backoff,
462                                retry_policy,
463                                access_token_mode,
464                            )
465                        {
466                            tokio::time::sleep(backoff).await;
467                            continue;
468                        }
469                        yield Err(err);
470                        break;
471                    }
472                }
473            }
474
475            match batches
476                .as_mut()
477                .expect("batches should not be None")
478                .next()
479                .await
480            {
481                Some(Ok(ReadItem::ReconnectAdvised)) => {
482                    batches = None;
483                    // The advised connection was already poisoned when the
484                    // advice was first decoded; reconnecting dials a fresh
485                    // one. Avoid a useless reconnect for a read that was
486                    // already satisfied when the advice arrived.
487                    if read_limits_exhausted(&end) {
488                        break;
489                    }
490                    advised_reconnects.record();
491                    debug!(
492                        resume_seq_num = ?start.seq_num,
493                        advised_reconnects = advised_reconnects.count(),
494                        "reconnecting read session on server advice"
495                    );
496                    yield Ok(ReadUpdate::behind());
497                    continue;
498                }
499                Some(Ok(ReadItem::Batch(batch))) => {
500                    if retry_backoff.used() > 0 {
501                        retry_backoff.reset();
502                    }
503
504                    if batch.tail.is_some() {
505                        last_tail_at = Some(Instant::now());
506                    }
507
508                    update_resume_start(&mut start, &batch);
509                    if let Some(count) = end.count.as_mut() {
510                        *count = count.saturating_sub(batch.records.len())
511                    }
512                    if let Some(bytes) = end.bytes.as_mut() {
513                        *bytes = bytes.saturating_sub(
514                            batch.records.iter().map(|r| r.metered_bytes()).sum()
515                        )
516                    }
517
518                    yield Ok(ReadUpdate::from_batch(batch, ignore_command_records));
519                }
520                Some(Err(err)) => {
521                    batches = None;
522                    if err.is_server_draining() && read_limits_exhausted(&end) {
523                        break;
524                    }
525                    if take_server_draining_reconnect(&err, &mut advised_reconnects) {
526                        debug!(
527                            resume_seq_num = ?start.seq_num,
528                            advised_reconnects = advised_reconnects.count(),
529                            "reconnecting read session while server drains"
530                        );
531                        yield Ok(ReadUpdate::behind());
532                        continue;
533                    }
534                    if let Some(backoff) =
535                        retry_delay(
536                            &err,
537                            &mut retry_backoff,
538                            retry_policy,
539                            access_token_mode,
540                        )
541                    {
542                        yield Ok(ReadUpdate::behind());
543                        tokio::time::sleep(backoff).await;
544                        continue;
545                    }
546                    yield Err(err);
547                    break;
548                }
549                None => break,
550            }
551        }
552    });
553    Ok(ReadSession::new(updates, initial_resume_seq_num))
554}
555
556fn resume_seq_num_after_batch(batch: &ReadBatch) -> Option<u64> {
557    batch
558        .records
559        .last()
560        .map(|record| record.seq_num + 1)
561        .or_else(|| batch.tail.as_ref().map(|tail| tail.seq_num))
562}
563
564/// Advance the absolute start used when reconnecting the read session.
565///
566/// An empty batch with a reported tail still resolves a relative or timestamp start. Anchoring it
567/// prevents a reconnect from evaluating the original start against a newer tail.
568fn update_resume_start(start: &mut ReadStart, batch: &ReadBatch) {
569    if let Some(seq_num) = resume_seq_num_after_batch(batch) {
570        *start = ReadStart {
571            seq_num: Some(seq_num),
572            timestamp: None,
573            tail_offset: None,
574            clamp: start.clamp,
575        };
576    }
577}
578
579async fn session_inner(
580    client: BasinClient,
581    name: StreamName,
582    encryption: Option<EncryptionKey>,
583    start: ReadStart,
584    end: ReadEnd,
585    reconnect: ReconnectAdvice,
586    advised_reconnects: AdvisedReconnects,
587) -> Result<InternalStreaming<ReadItem>, ReadSessionFailure> {
588    let mut batches = client
589        .read_session(&name, start, end, encryption.as_ref(), reconnect.clone())
590        .await?;
591
592    let mut declined_advice = false;
593    Ok(Box::pin(try_stream! {
594        loop {
595            match timeout(HEARTBEAT_TIMEOUT, batches.next()).await {
596                Ok(Some(batch)) => {
597                    yield ReadItem::Batch(ReadBatch::from_api(batch?));
598                    if reconnect.is_advised() && !declined_advice {
599                        if advised_reconnects.should_reconnect() {
600                            yield ReadItem::ReconnectAdvised;
601                            break;
602                        }
603                        declined_advice = true;
604                    }
605                }
606                Ok(None) => break,
607                Err(_) => Err(ReadSessionFailure::HeartbeatTimeout)?,
608            }
609        }
610    }))
611}
612
613/// Whether the read's `count` or `bytes` limit has been used up.
614fn read_limits_exhausted(end: &ReadEnd) -> bool {
615    end.count == Some(0) || end.bytes == Some(0)
616}
617
618/// Compute the remaining wait budget for a retry.
619///
620/// During catchup (tail not yet observed), the full wait is sent.
621/// Once tailing, the wait budget is depleted based on time since
622/// the last batch with tail info, which approximates how long the
623/// server has been in its long polling state.
624fn remaining_wait(baseline_wait: Option<u32>, last_tail_at: Option<Instant>) -> Option<u32> {
625    baseline_wait.map(|w| match last_tail_at {
626        Some(since) => w.saturating_sub(since.elapsed().as_secs() as u32),
627        None => w,
628    })
629}
630
631fn retry_delay(
632    err: &ReadSessionFailure,
633    backoffs: &mut RetryBackoff,
634    retry_policy: ReadSessionRetryPolicy,
635    access_token_mode: AccessTokenMode,
636) -> Option<Duration> {
637    let is_retryable =
638        err.is_retryable() || (access_token_mode.is_refreshable() && err.is_authentication_error());
639    if !is_retryable {
640        debug!(
641            %err,
642            is_retryable = false,
643            retries_exhausted = backoffs.is_exhausted(),
644            "not retrying read session"
645        );
646        return None;
647    }
648
649    let backoff = match retry_policy {
650        ReadSessionRetryPolicy::Budgeted => backoffs.next(),
651        ReadSessionRetryPolicy::Indefinite => Some(backoffs.next_or_max()),
652    };
653    if let Some(backoff) = backoff {
654        debug!(
655            %err,
656            ?backoff,
657            ?retry_policy,
658            num_retries_remaining = backoffs.remaining(),
659            "retrying read session"
660        );
661        Some(backoff)
662    } else {
663        debug!(
664            %err,
665            is_retryable,
666            retries_exhausted = backoffs.is_exhausted(),
667            "not retrying read session"
668        );
669        None
670    }
671}
672
673fn take_server_draining_reconnect(
674    err: &ReadSessionFailure,
675    advised_reconnects: &mut AdvisedReconnects,
676) -> bool {
677    if err.is_server_draining() {
678        advised_reconnects.record();
679        true
680    } else {
681        false
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use bytes::Bytes;
688    use futures_util::{StreamExt, poll, stream};
689    use tokio::sync::mpsc;
690    use tokio_stream::wrappers::UnboundedReceiverStream;
691
692    use super::*;
693    use crate::types::{Header, SequencedRecord};
694
695    fn position(seq_num: u64) -> StreamPosition {
696        StreamPosition {
697            seq_num,
698            timestamp: seq_num,
699        }
700    }
701
702    fn record(seq_num: u64, command: bool) -> SequencedRecord {
703        SequencedRecord {
704            seq_num,
705            timestamp: seq_num,
706            body: Bytes::new(),
707            headers: if command {
708                vec![Header::new("", "fence")]
709            } else {
710                Vec::new()
711            },
712        }
713    }
714
715    fn batch(records: Vec<SequencedRecord>, tail: Option<StreamPosition>) -> ReadBatch {
716        ReadBatch { records, tail }
717    }
718
719    #[test]
720    fn empty_tail_anchors_relative_resume_start() {
721        let mut start = ReadStart {
722            seq_num: None,
723            timestamp: None,
724            tail_offset: Some(0),
725            clamp: Some(true),
726        };
727
728        update_resume_start(&mut start, &batch(Vec::new(), Some(position(42))));
729
730        assert_eq!(start.seq_num, Some(42));
731        assert_eq!(start.timestamp, None);
732        assert_eq!(start.tail_offset, None);
733        assert_eq!(start.clamp, Some(true));
734    }
735
736    fn test_session(
737        updates: impl futures_core::Stream<Item = Result<ReadUpdate, ReadSessionFailure>>
738        + Send
739        + 'static,
740    ) -> ReadSession {
741        ReadSession::new(Box::pin(updates), None)
742    }
743
744    #[tokio::test]
745    async fn empty_tail_exposes_absolute_resume_seq_num() {
746        let (tx, rx) = mpsc::unbounded_channel();
747        let mut session = test_session(UnboundedReceiverStream::new(rx));
748
749        assert_eq!(session.resume_seq_num(), None);
750        tx.send(Ok(ReadUpdate::from_batch(
751            batch(Vec::new(), Some(position(42))),
752            false,
753        )))
754        .unwrap();
755
756        let mut next = Box::pin(session.next());
757        assert!(poll!(next.as_mut()).is_pending());
758        drop(next);
759
760        assert_eq!(session.resume_seq_num(), Some(42));
761    }
762
763    #[tokio::test]
764    async fn caught_up_follows_delivery_and_pins_tail() {
765        let tail = position(2);
766        let mut session = test_session(stream::iter([
767            Ok(ReadUpdate::from_batch(
768                batch(vec![record(0, false), record(1, false)], Some(tail)),
769                false,
770            )),
771            Ok(ReadUpdate::from_batch(
772                batch(vec![record(2, false)], Some(position(5))),
773                false,
774            )),
775        ]));
776        let caught_up = session.caught_up();
777        let mut pending = Box::pin(caught_up.clone());
778
779        assert!(poll!(pending.as_mut()).is_pending());
780        assert!(!session.is_caught_up());
781
782        let first = session.next().await.unwrap().unwrap();
783        assert_eq!(first.records.len(), 2);
784        assert!(session.is_caught_up());
785        assert_eq!(session.resume_seq_num(), Some(2));
786        let caught_up_while_caught = session.caught_up();
787
788        session.next().await.unwrap().unwrap();
789        assert!(!session.is_caught_up());
790        assert_eq!(session.resume_seq_num(), Some(3));
791        assert_eq!(caught_up.await.unwrap(), tail);
792        assert_eq!(caught_up_while_caught.await.unwrap(), tail);
793    }
794
795    #[tokio::test]
796    async fn heartbeat_waits_for_visible_batch() {
797        let tail = position(2);
798        let (tx, rx) = mpsc::unbounded_channel();
799        let mut session = test_session(UnboundedReceiverStream::new(rx));
800        let caught_up = session.caught_up();
801
802        tx.send(Ok(ReadUpdate::from_batch(
803            batch(vec![record(0, false), record(1, false)], None),
804            false,
805        )))
806        .unwrap();
807        tx.send(Ok(ReadUpdate::from_batch(
808            batch(Vec::new(), Some(tail)),
809            false,
810        )))
811        .unwrap();
812
813        assert_eq!(session.next().await.unwrap().unwrap().records.len(), 2);
814        assert!(!session.is_caught_up());
815
816        let mut next = Box::pin(session.next());
817        assert!(poll!(next.as_mut()).is_pending());
818        drop(next);
819        assert!(session.is_caught_up());
820        assert_eq!(caught_up.await.unwrap(), tail);
821    }
822
823    #[tokio::test]
824    async fn unchanged_heartbeat_reuses_caught_up_future() {
825        let tail = position(1);
826        let (tx, rx) = mpsc::unbounded_channel();
827        let mut session = test_session(UnboundedReceiverStream::new(rx));
828
829        tx.send(Ok(ReadUpdate::from_batch(
830            batch(vec![record(0, false)], Some(tail)),
831            false,
832        )))
833        .unwrap();
834        session.next().await.unwrap().unwrap();
835        let caught_up = session.state.future();
836
837        tx.send(Ok(ReadUpdate::from_batch(
838            batch(Vec::new(), Some(tail)),
839            false,
840        )))
841        .unwrap();
842        let mut next = Box::pin(session.next());
843        assert!(poll!(next.as_mut()).is_pending());
844        drop(next);
845
846        let CaughtUpFuture::Pending(caught_up) = caught_up else {
847            panic!("initial caught-up future should use the pending epoch");
848        };
849        let CaughtUpFuture::Pending(current) = session.state.future() else {
850            panic!("unchanged heartbeat should preserve the pending epoch");
851        };
852        assert!(caught_up.ptr_eq(&current));
853    }
854
855    #[tokio::test]
856    async fn filtered_command_counts_toward_caught_up() {
857        let tail = position(2);
858        let mut session = test_session(stream::iter([
859            Ok(ReadUpdate::from_batch(
860                batch(vec![record(0, false)], None),
861                true,
862            )),
863            Ok(ReadUpdate::from_batch(
864                batch(vec![record(1, true)], Some(tail)),
865                true,
866            )),
867        ]));
868        let caught_up = session.caught_up();
869
870        let delivered = session.next().await.unwrap().unwrap();
871        assert_eq!(delivered.records.len(), 1);
872        assert_eq!(delivered.records[0].seq_num, 0);
873        assert!(!session.is_caught_up());
874
875        assert!(session.next().await.is_none());
876        assert!(session.is_caught_up());
877        assert_eq!(session.resume_seq_num(), Some(2));
878        assert_eq!(caught_up.await.unwrap(), tail);
879    }
880
881    #[tokio::test]
882    async fn caught_up_wait_survives_retry() {
883        let first_tail = position(1);
884        let tail = position(3);
885        let (tx, rx) = mpsc::unbounded_channel();
886        let mut session = test_session(UnboundedReceiverStream::new(rx));
887
888        tx.send(Ok(ReadUpdate::from_batch(
889            batch(Vec::new(), Some(first_tail)),
890            false,
891        )))
892        .unwrap();
893        let mut next = Box::pin(session.next());
894        assert!(poll!(next.as_mut()).is_pending());
895        drop(next);
896        assert!(session.is_caught_up());
897
898        tx.send(Ok(ReadUpdate::behind())).unwrap();
899        let mut next = Box::pin(session.next());
900        assert!(poll!(next.as_mut()).is_pending());
901        drop(next);
902        assert!(!session.is_caught_up());
903        let caught_up = session.caught_up();
904
905        tx.send(Ok(ReadUpdate::behind())).unwrap();
906        tx.send(Ok(ReadUpdate::from_batch(
907            batch(Vec::new(), Some(tail)),
908            false,
909        )))
910        .unwrap();
911        drop(tx);
912        assert!(session.next().await.is_none());
913        assert_eq!(caught_up.await.unwrap(), tail);
914    }
915
916    #[tokio::test]
917    async fn clean_end_rejects_wait() {
918        let mut session = test_session(stream::empty());
919        let caught_up = session.caught_up();
920
921        assert!(session.next().await.is_none());
922        assert!(matches!(caught_up.await, Err(CaughtUpError::SessionClosed)));
923    }
924
925    #[tokio::test]
926    async fn read_error_rejects_wait() {
927        let mut session = test_session(stream::iter([Err(ReadSessionFailure::HeartbeatTimeout)]));
928        let caught_up = session.caught_up();
929
930        let error = session.next().await.unwrap().unwrap_err();
931        assert_eq!(error.to_string(), "heartbeat timeout");
932        assert!(matches!(
933            caught_up.await,
934            Err(CaughtUpError::Read(ReadSessionError::HeartbeatTimeout))
935        ));
936    }
937
938    #[tokio::test]
939    async fn read_error_after_caught_up_preserves_resolved_future() {
940        let tail = position(1);
941        let mut session = test_session(stream::iter([
942            Ok(ReadUpdate::from_batch(
943                batch(vec![record(0, false)], Some(tail)),
944                false,
945            )),
946            Err(ReadSessionFailure::HeartbeatTimeout),
947        ]));
948
949        session.next().await.unwrap().unwrap();
950        assert!(session.is_caught_up());
951        let caught_up = session.caught_up();
952
953        session.next().await.unwrap().unwrap_err();
954        assert!(!session.is_caught_up());
955        assert_eq!(caught_up.await.unwrap(), tail);
956        assert!(matches!(
957            session.caught_up().await,
958            Err(CaughtUpError::Read(ReadSessionError::HeartbeatTimeout))
959        ));
960    }
961
962    #[tokio::test]
963    async fn dropping_session_rejects_wait() {
964        let caught_up = {
965            let session = test_session(stream::pending());
966            session.caught_up()
967        };
968
969        assert!(matches!(caught_up.await, Err(CaughtUpError::SessionClosed)));
970    }
971}