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