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