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 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
47#[derive(Debug, Clone, thiserror::Error)]
49#[non_exhaustive]
50pub enum ReadSessionError {
51 #[error(transparent)]
53 Read(#[from] ReadError),
54 #[error("heartbeat timeout")]
56 HeartbeatTimeout,
57}
58
59impl ReadSessionError {
60 pub fn is_retryable(&self) -> bool {
62 match self {
63 Self::Read(error) => error.is_retryable(),
64 Self::HeartbeatTimeout => true,
65 }
66 }
67
68 pub fn request_error(&self) -> Option<&RequestError> {
70 match self {
71 Self::Read(error) => error.request_error(),
72 Self::HeartbeatTimeout => None,
73 }
74 }
75}
76
77impl From<ReadSessionFailure> for ReadSessionError {
78 fn from(error: ReadSessionFailure) -> Self {
79 match error {
80 ReadSessionFailure::Api(error) => Self::Read(error.into()),
81 ReadSessionFailure::HeartbeatTimeout => Self::HeartbeatTimeout,
82 }
83 }
84}
85
86type InternalStreaming<R> =
87 Pin<Box<dyn Send + futures_core::Stream<Item = Result<R, ReadSessionFailure>>>>;
88
89#[derive(Debug, Clone, thiserror::Error)]
90#[non_exhaustive]
91pub enum CaughtUpError {
93 #[error("read session ended before catching up")]
94 SessionClosed,
96 #[error(transparent)]
97 Read(#[from] ReadSessionError),
99}
100
101impl CaughtUpError {
102 pub fn is_retryable(&self) -> bool {
104 match self {
105 Self::SessionClosed => false,
106 Self::Read(error) => error.is_retryable(),
107 }
108 }
109
110 pub fn request_error(&self) -> Option<&RequestError> {
112 match self {
113 Self::SessionClosed => None,
114 Self::Read(error) => error.request_error(),
115 }
116 }
117}
118
119type CaughtUpResult = Result<StreamPosition, CaughtUpError>;
120
121#[derive(Clone)]
122enum CaughtUpFuture {
123 Pending(Shared<oneshot::Receiver<CaughtUpResult>>),
124 Ready(CaughtUpResult),
125}
126
127impl Future for CaughtUpFuture {
128 type Output = CaughtUpResult;
129
130 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
131 match &mut *self {
132 Self::Pending(future) => match Pin::new(future).poll(cx) {
133 Poll::Ready(Ok(result)) => Poll::Ready(result),
134 Poll::Ready(Err(_)) => Poll::Ready(Err(CaughtUpError::SessionClosed)),
135 Poll::Pending => Poll::Pending,
136 },
137 Self::Ready(result) => Poll::Ready(result.clone()),
138 }
139 }
140}
141
142struct CaughtUpState {
143 tail: Option<StreamPosition>,
145 terminal: bool,
147 tx: Option<oneshot::Sender<CaughtUpResult>>,
149 future: CaughtUpFuture,
151}
152
153impl CaughtUpState {
154 fn new() -> Self {
155 let (tx, future) = pending_catch_up();
156 Self {
157 tail: None,
158 terminal: false,
159 tx: Some(tx),
160 future,
161 }
162 }
163
164 fn is_caught_up(&self) -> bool {
165 self.tail.is_some()
166 }
167
168 fn future(&self) -> CaughtUpFuture {
169 self.future.clone()
170 }
171
172 fn set_behind(&mut self) {
173 if self.terminal || self.tail.take().is_none() {
174 return;
175 }
176 let (tx, future) = pending_catch_up();
177 self.tx = Some(tx);
178 self.future = future;
179 }
180
181 fn set_caught_up(&mut self, tail: StreamPosition) {
182 if self.terminal || self.tail == Some(tail) {
183 return;
184 }
185 self.tail = Some(tail);
186 self.complete(Ok(tail));
187 }
188
189 fn end(&mut self, error: Option<ReadSessionError>) {
190 if self.terminal {
191 return;
192 }
193 self.terminal = true;
194 if let Some(error) = error {
195 self.tail = None;
196 self.complete(Err(CaughtUpError::Read(error)));
197 } else if self.tail.is_none() {
198 self.complete(Err(CaughtUpError::SessionClosed));
199 }
200 }
201
202 fn complete(&mut self, result: CaughtUpResult) {
203 if let Some(tx) = self.tx.take() {
204 let _ = tx.send(result);
205 } else {
206 self.future = CaughtUpFuture::Ready(result);
207 }
208 }
209}
210
211fn pending_catch_up() -> (oneshot::Sender<CaughtUpResult>, CaughtUpFuture) {
212 let (tx, rx) = oneshot::channel();
213 (tx, CaughtUpFuture::Pending(rx.shared()))
214}
215
216struct ReadUpdate {
217 batch: Option<ReadBatch>,
218 caught_up_tail: Option<StreamPosition>,
219}
220
221impl ReadUpdate {
222 fn behind() -> Self {
223 Self {
224 batch: None,
225 caught_up_tail: None,
226 }
227 }
228
229 fn from_batch(mut batch: ReadBatch, ignore_command_records: bool) -> Self {
230 let caught_up_tail = batch.tail.filter(|tail| {
231 batch.records.is_empty()
232 || batch
233 .records
234 .last()
235 .is_some_and(|record| record.seq_num.checked_add(1) == Some(tail.seq_num))
236 });
237
238 if ignore_command_records {
239 batch.records.retain(|record| !record.is_command_record());
240 }
241
242 Self {
243 batch: (!batch.records.is_empty()).then_some(batch),
244 caught_up_tail,
245 }
246 }
247}
248
249pub struct ReadSession {
251 updates: InternalStreaming<ReadUpdate>,
252 state: CaughtUpState,
253}
254
255impl ReadSession {
256 fn new(updates: InternalStreaming<ReadUpdate>) -> Self {
257 Self {
258 updates,
259 state: CaughtUpState::new(),
260 }
261 }
262
263 pub fn is_caught_up(&self) -> bool {
269 self.state.is_caught_up()
270 }
271
272 pub fn caught_up(
280 &self,
281 ) -> impl Future<Output = Result<StreamPosition, CaughtUpError>>
282 + Clone
283 + Send
284 + Sync
285 + Unpin
286 + 'static {
287 self.state.future()
288 }
289}
290
291impl futures_core::Stream for ReadSession {
292 type Item = Result<ReadBatch, ReadSessionError>;
293
294 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
295 loop {
296 match self.updates.as_mut().poll_next(cx) {
297 Poll::Pending => return Poll::Pending,
298 Poll::Ready(Some(Ok(update))) => {
299 if let Some(tail) = update.caught_up_tail {
300 self.state.set_caught_up(tail);
301 } else {
302 self.state.set_behind();
303 }
304 if let Some(batch) = update.batch {
305 return Poll::Ready(Some(Ok(batch)));
306 }
307 }
308 Poll::Ready(Some(Err(error))) => {
309 let error = ReadSessionError::from(error);
310 self.state.end(Some(error.clone()));
311 return Poll::Ready(Some(Err(error)));
312 }
313 Poll::Ready(None) => {
314 self.state.end(None);
315 return Poll::Ready(None);
316 }
317 }
318 }
319 }
320}
321
322impl Drop for ReadSession {
323 fn drop(&mut self) {
324 self.state.end(None);
325 }
326}
327
328pub async fn read_session(
329 client: BasinClient,
330 name: StreamName,
331 encryption: Option<EncryptionKey>,
332 input: ReadInput,
333 config: ReadSessionConfig,
334) -> Result<ReadSession, ReadSessionError> {
335 let ReadInput {
336 start,
337 stop,
338 ignore_command_records,
339 } = input;
340 let mut start: ReadStart = start.into();
341 let mut end: ReadEnd = stop.into();
342 let retry_policy = config.retry_policy;
343 let mut retry_backoff = retry_builder(&client.config.retry).build();
344 let baseline_wait = end.wait;
345 let mut last_tail_at: Option<Instant> = None;
346
347 let batches = loop {
348 end.wait = remaining_wait(baseline_wait, last_tail_at);
349 match session_inner(
350 client.clone(),
351 name.clone(),
352 encryption.clone(),
353 start.clone(),
354 end.clone(),
355 )
356 .await
357 {
358 Ok(batches) => {
359 retry_backoff.reset();
360 break batches;
361 }
362 Err(err) => {
363 if let Some(backoff) = retry_delay(&err, &mut retry_backoff, retry_policy) {
364 tokio::time::sleep(backoff).await;
365 continue;
366 }
367 return Err(err.into());
368 }
369 }
370 };
371
372 let updates = Box::pin(stream! {
373 let mut batches: Option<InternalStreaming<ReadBatch>> = Some(batches);
374
375 loop {
376 if batches.is_none() {
377 end.wait = remaining_wait(baseline_wait, last_tail_at);
378 match session_inner(
379 client.clone(),
380 name.clone(),
381 encryption.clone(),
382 start.clone(),
383 end.clone(),
384 ).await {
385 Ok(b) => batches = Some(b),
386 Err(err) => {
387 if let Some(backoff) =
388 retry_delay(&err, &mut retry_backoff, retry_policy)
389 {
390 tokio::time::sleep(backoff).await;
391 continue;
392 }
393 yield Err(err);
394 break;
395 }
396 }
397 }
398
399 match batches
400 .as_mut()
401 .expect("batches should not be None")
402 .next()
403 .await
404 {
405 Some(Ok(batch)) => {
406 if retry_backoff.used() > 0 {
407 retry_backoff.reset();
408 }
409
410 if batch.tail.is_some() {
411 last_tail_at = Some(Instant::now());
412 }
413
414 if let Some(record) = batch.records.last() {
415 start = ReadStart {
416 seq_num: Some(record.seq_num + 1),
417 timestamp: None,
418 tail_offset: None,
419 clamp: start.clamp,
420 };
421 }
422 if let Some(count) = end.count.as_mut() {
423 *count = count.saturating_sub(batch.records.len())
424 }
425 if let Some(bytes) = end.bytes.as_mut() {
426 *bytes = bytes.saturating_sub(
427 batch.records.iter().map(|r| r.metered_bytes()).sum()
428 )
429 }
430
431 yield Ok(ReadUpdate::from_batch(batch, ignore_command_records));
432 }
433 Some(Err(err)) => {
434 batches = None;
435 if let Some(backoff) =
436 retry_delay(&err, &mut retry_backoff, retry_policy)
437 {
438 yield Ok(ReadUpdate::behind());
439 tokio::time::sleep(backoff).await;
440 continue;
441 }
442 yield Err(err);
443 break;
444 }
445 None => break,
446 }
447 }
448 });
449 Ok(ReadSession::new(updates))
450}
451
452async fn session_inner(
453 client: BasinClient,
454 name: StreamName,
455 encryption: Option<EncryptionKey>,
456 start: ReadStart,
457 end: ReadEnd,
458) -> Result<InternalStreaming<ReadBatch>, ReadSessionFailure> {
459 let mut batches = client
460 .read_session(&name, start, end, encryption.as_ref())
461 .await?;
462 Ok(Box::pin(try_stream! {
463 loop {
464 match timeout(Duration::from_secs(20), batches.next()).await {
465 Ok(Some(batch)) => {
466 yield ReadBatch::from_api(batch?);
467 }
468 Ok(None) => break,
469 Err(_) => Err(ReadSessionFailure::HeartbeatTimeout)?,
470 }
471 }
472 }))
473}
474
475fn remaining_wait(baseline_wait: Option<u32>, last_tail_at: Option<Instant>) -> Option<u32> {
482 baseline_wait.map(|w| match last_tail_at {
483 Some(since) => w.saturating_sub(since.elapsed().as_secs() as u32),
484 None => w,
485 })
486}
487
488fn retry_delay(
489 err: &ReadSessionFailure,
490 backoffs: &mut RetryBackoff,
491 retry_policy: ReadSessionRetryPolicy,
492) -> Option<Duration> {
493 if !err.is_retryable() {
494 debug!(
495 %err,
496 is_retryable = false,
497 retries_exhausted = backoffs.is_exhausted(),
498 "not retrying read session"
499 );
500 return None;
501 }
502
503 let backoff = match retry_policy {
504 ReadSessionRetryPolicy::Budgeted => backoffs.next(),
505 ReadSessionRetryPolicy::Indefinite => Some(backoffs.next_or_max()),
506 };
507 if let Some(backoff) = backoff {
508 debug!(
509 %err,
510 ?backoff,
511 ?retry_policy,
512 num_retries_remaining = backoffs.remaining(),
513 "retrying read session"
514 );
515 Some(backoff)
516 } else {
517 debug!(
518 %err,
519 is_retryable = err.is_retryable(),
520 retries_exhausted = backoffs.is_exhausted(),
521 "not retrying read session"
522 );
523 None
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use bytes::Bytes;
530 use futures_util::{StreamExt, poll, stream};
531 use tokio::sync::mpsc;
532 use tokio_stream::wrappers::UnboundedReceiverStream;
533
534 use super::*;
535 use crate::types::{Header, SequencedRecord};
536
537 fn position(seq_num: u64) -> StreamPosition {
538 StreamPosition {
539 seq_num,
540 timestamp: seq_num,
541 }
542 }
543
544 fn record(seq_num: u64, command: bool) -> SequencedRecord {
545 SequencedRecord {
546 seq_num,
547 timestamp: seq_num,
548 body: Bytes::new(),
549 headers: if command {
550 vec![Header::new("", "fence")]
551 } else {
552 Vec::new()
553 },
554 }
555 }
556
557 fn batch(records: Vec<SequencedRecord>, tail: Option<StreamPosition>) -> ReadBatch {
558 ReadBatch { records, tail }
559 }
560
561 fn test_session(
562 updates: impl futures_core::Stream<Item = Result<ReadUpdate, ReadSessionFailure>>
563 + Send
564 + 'static,
565 ) -> ReadSession {
566 ReadSession::new(Box::pin(updates))
567 }
568
569 #[tokio::test]
570 async fn caught_up_follows_delivery_and_pins_tail() {
571 let tail = position(2);
572 let mut session = test_session(stream::iter([
573 Ok(ReadUpdate::from_batch(
574 batch(vec![record(0, false), record(1, false)], Some(tail)),
575 false,
576 )),
577 Ok(ReadUpdate::from_batch(
578 batch(vec![record(2, false)], Some(position(5))),
579 false,
580 )),
581 ]));
582 let caught_up = session.caught_up();
583 let mut pending = Box::pin(caught_up.clone());
584
585 assert!(poll!(pending.as_mut()).is_pending());
586 assert!(!session.is_caught_up());
587
588 let first = session.next().await.unwrap().unwrap();
589 assert_eq!(first.records.len(), 2);
590 assert!(session.is_caught_up());
591 let caught_up_while_caught = session.caught_up();
592
593 session.next().await.unwrap().unwrap();
594 assert!(!session.is_caught_up());
595 assert_eq!(caught_up.await.unwrap(), tail);
596 assert_eq!(caught_up_while_caught.await.unwrap(), tail);
597 }
598
599 #[tokio::test]
600 async fn heartbeat_waits_for_visible_batch() {
601 let tail = position(2);
602 let (tx, rx) = mpsc::unbounded_channel();
603 let mut session = test_session(UnboundedReceiverStream::new(rx));
604 let caught_up = session.caught_up();
605
606 tx.send(Ok(ReadUpdate::from_batch(
607 batch(vec![record(0, false), record(1, false)], None),
608 false,
609 )))
610 .unwrap();
611 tx.send(Ok(ReadUpdate::from_batch(
612 batch(Vec::new(), Some(tail)),
613 false,
614 )))
615 .unwrap();
616
617 assert_eq!(session.next().await.unwrap().unwrap().records.len(), 2);
618 assert!(!session.is_caught_up());
619
620 let mut next = Box::pin(session.next());
621 assert!(poll!(next.as_mut()).is_pending());
622 drop(next);
623 assert!(session.is_caught_up());
624 assert_eq!(caught_up.await.unwrap(), tail);
625 }
626
627 #[tokio::test]
628 async fn unchanged_heartbeat_reuses_caught_up_future() {
629 let tail = position(1);
630 let (tx, rx) = mpsc::unbounded_channel();
631 let mut session = test_session(UnboundedReceiverStream::new(rx));
632
633 tx.send(Ok(ReadUpdate::from_batch(
634 batch(vec![record(0, false)], Some(tail)),
635 false,
636 )))
637 .unwrap();
638 session.next().await.unwrap().unwrap();
639 let caught_up = session.state.future();
640
641 tx.send(Ok(ReadUpdate::from_batch(
642 batch(Vec::new(), Some(tail)),
643 false,
644 )))
645 .unwrap();
646 let mut next = Box::pin(session.next());
647 assert!(poll!(next.as_mut()).is_pending());
648 drop(next);
649
650 let CaughtUpFuture::Pending(caught_up) = caught_up else {
651 panic!("initial caught-up future should use the pending epoch");
652 };
653 let CaughtUpFuture::Pending(current) = session.state.future() else {
654 panic!("unchanged heartbeat should preserve the pending epoch");
655 };
656 assert!(caught_up.ptr_eq(¤t));
657 }
658
659 #[tokio::test]
660 async fn filtered_command_counts_toward_caught_up() {
661 let tail = position(2);
662 let mut session = test_session(stream::iter([
663 Ok(ReadUpdate::from_batch(
664 batch(vec![record(0, false)], None),
665 true,
666 )),
667 Ok(ReadUpdate::from_batch(
668 batch(vec![record(1, true)], Some(tail)),
669 true,
670 )),
671 ]));
672 let caught_up = session.caught_up();
673
674 let delivered = session.next().await.unwrap().unwrap();
675 assert_eq!(delivered.records.len(), 1);
676 assert_eq!(delivered.records[0].seq_num, 0);
677 assert!(!session.is_caught_up());
678
679 assert!(session.next().await.is_none());
680 assert!(session.is_caught_up());
681 assert_eq!(caught_up.await.unwrap(), tail);
682 }
683
684 #[tokio::test]
685 async fn caught_up_wait_survives_retry() {
686 let first_tail = position(1);
687 let tail = position(3);
688 let (tx, rx) = mpsc::unbounded_channel();
689 let mut session = test_session(UnboundedReceiverStream::new(rx));
690
691 tx.send(Ok(ReadUpdate::from_batch(
692 batch(Vec::new(), Some(first_tail)),
693 false,
694 )))
695 .unwrap();
696 let mut next = Box::pin(session.next());
697 assert!(poll!(next.as_mut()).is_pending());
698 drop(next);
699 assert!(session.is_caught_up());
700
701 tx.send(Ok(ReadUpdate::behind())).unwrap();
702 let mut next = Box::pin(session.next());
703 assert!(poll!(next.as_mut()).is_pending());
704 drop(next);
705 assert!(!session.is_caught_up());
706 let caught_up = session.caught_up();
707
708 tx.send(Ok(ReadUpdate::behind())).unwrap();
709 tx.send(Ok(ReadUpdate::from_batch(
710 batch(Vec::new(), Some(tail)),
711 false,
712 )))
713 .unwrap();
714 drop(tx);
715 assert!(session.next().await.is_none());
716 assert_eq!(caught_up.await.unwrap(), tail);
717 }
718
719 #[tokio::test]
720 async fn clean_end_rejects_wait() {
721 let mut session = test_session(stream::empty());
722 let caught_up = session.caught_up();
723
724 assert!(session.next().await.is_none());
725 assert!(matches!(caught_up.await, Err(CaughtUpError::SessionClosed)));
726 }
727
728 #[tokio::test]
729 async fn read_error_rejects_wait() {
730 let mut session = test_session(stream::iter([Err(ReadSessionFailure::HeartbeatTimeout)]));
731 let caught_up = session.caught_up();
732
733 let error = session.next().await.unwrap().unwrap_err();
734 assert_eq!(error.to_string(), "heartbeat timeout");
735 assert!(matches!(
736 caught_up.await,
737 Err(CaughtUpError::Read(ReadSessionError::HeartbeatTimeout))
738 ));
739 }
740
741 #[tokio::test]
742 async fn read_error_after_caught_up_preserves_resolved_future() {
743 let tail = position(1);
744 let mut session = test_session(stream::iter([
745 Ok(ReadUpdate::from_batch(
746 batch(vec![record(0, false)], Some(tail)),
747 false,
748 )),
749 Err(ReadSessionFailure::HeartbeatTimeout),
750 ]));
751
752 session.next().await.unwrap().unwrap();
753 assert!(session.is_caught_up());
754 let caught_up = session.caught_up();
755
756 session.next().await.unwrap().unwrap_err();
757 assert!(!session.is_caught_up());
758 assert_eq!(caught_up.await.unwrap(), tail);
759 assert!(matches!(
760 session.caught_up().await,
761 Err(CaughtUpError::Read(ReadSessionError::HeartbeatTimeout))
762 ));
763 }
764
765 #[tokio::test]
766 async fn dropping_session_rejects_wait() {
767 let caught_up = {
768 let session = test_session(stream::pending());
769 session.caught_up()
770 };
771
772 assert!(matches!(caught_up.await, Err(CaughtUpError::SessionClosed)));
773 }
774}