Skip to main content

s2_sdk/session/
append.rs

1use std::{
2    collections::VecDeque,
3    future::Future,
4    num::NonZeroU32,
5    pin::Pin,
6    sync::{Arc, OnceLock},
7    task::{Context, Poll},
8    time::Duration,
9};
10
11use futures_util::StreamExt;
12use tokio::{
13    sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot},
14    time::Instant,
15};
16use tokio_muxt::{CoalesceMode, MuxTimer};
17use tokio_stream::wrappers::ReceiverStream;
18use tokio_util::task::AbortOnDropHandle;
19use tracing::debug;
20
21use crate::{
22    api::{ApiError, BasinClient, Streaming, retry_builder},
23    error::{AppendError, RequestError},
24    frame_signal::FrameSignal,
25    retry::RetryBackoffBuilder,
26    types::{
27        AccessTokenMode, AppendAck, AppendInput, AppendRetryPolicy, EncryptionKey, MeteredBytes,
28        ONE_MIB, StreamName, StreamPosition, ValidationError,
29    },
30};
31
32/// Errors returned by an append session.
33#[derive(Debug, Clone, thiserror::Error)]
34#[non_exhaustive]
35pub enum AppendSessionError {
36    /// An error with the append request underlying the session.
37    #[error(transparent)]
38    Append(#[from] AppendError),
39    /// An append acknowledgement timed out.
40    #[error("append acknowledgement timed out")]
41    AckTimeout,
42    /// The server disconnected during the session.
43    #[error("server disconnected")]
44    ServerDisconnected,
45    /// The response stream closed while appends were in flight.
46    #[error("response stream closed early while appends in flight")]
47    StreamClosedEarly,
48    /// The session was already closed.
49    #[error("session already closed")]
50    SessionClosed,
51    /// The session is closing.
52    #[error("session is closing")]
53    SessionClosing,
54    /// The session was dropped without being closed.
55    #[error("session dropped without calling close")]
56    SessionDropped,
57    /// The server returned an invalid append acknowledgement.
58    #[error("invalid append acknowledgement: {0}")]
59    InvalidAck(String),
60}
61
62impl AppendSessionError {
63    /// Whether retrying the operation is safe or sensible.
64    pub fn is_retryable(&self) -> bool {
65        match self {
66            Self::Append(error) => error.is_retryable(),
67            Self::AckTimeout | Self::ServerDisconnected => true,
68            Self::StreamClosedEarly
69            | Self::SessionClosed
70            | Self::SessionClosing
71            | Self::SessionDropped
72            | Self::InvalidAck(_) => false,
73        }
74    }
75
76    /// Whether retrying the operation cannot duplicate a mutation.
77    pub fn has_no_side_effects(&self) -> bool {
78        match self {
79            Self::Append(error) => error.has_no_side_effects(),
80            Self::SessionClosed | Self::SessionClosing => true,
81            Self::AckTimeout
82            | Self::ServerDisconnected
83            | Self::StreamClosedEarly
84            | Self::SessionDropped
85            | Self::InvalidAck(_) => false,
86        }
87    }
88
89    /// Return the underlying request error, if present.
90    pub fn request_error(&self) -> Option<&RequestError> {
91        match self {
92            Self::Append(error) => error.request_error(),
93            Self::AckTimeout
94            | Self::ServerDisconnected
95            | Self::StreamClosedEarly
96            | Self::SessionClosed
97            | Self::SessionClosing
98            | Self::SessionDropped
99            | Self::InvalidAck(_) => None,
100        }
101    }
102
103    fn is_authentication_error(&self) -> bool {
104        matches!(
105            self,
106            Self::Append(AppendError::Request(error)) if error.is_authentication_error()
107        )
108    }
109}
110
111impl From<ApiError> for AppendSessionError {
112    fn from(error: ApiError) -> Self {
113        match error {
114            ApiError::AppendConditionFailed(condition) => {
115                Self::Append(AppendError::ConditionFailed(condition.into()))
116            }
117            other => Self::Append(AppendError::Request(other.into())),
118        }
119    }
120}
121
122/// A [`Future`] that resolves to an acknowledgement once the batch of records is appended.
123pub struct BatchSubmitTicket {
124    rx: oneshot::Receiver<Result<AppendAck, AppendSessionError>>,
125    terminal_err: Arc<OnceLock<AppendSessionError>>,
126}
127
128impl Future for BatchSubmitTicket {
129    type Output = Result<AppendAck, AppendSessionError>;
130
131    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
132        match Pin::new(&mut self.rx).poll(cx) {
133            Poll::Ready(Ok(res)) => Poll::Ready(res),
134            Poll::Ready(Err(_)) => Poll::Ready(Err(self
135                .terminal_err
136                .get()
137                .cloned()
138                .unwrap_or(AppendSessionError::SessionDropped))),
139            Poll::Pending => Poll::Pending,
140        }
141    }
142}
143
144#[derive(Debug, Clone)]
145/// Configuration for an [`AppendSession`].
146pub struct AppendSessionConfig {
147    max_unacked_bytes: u32,
148    max_unacked_batches: Option<u32>,
149}
150
151impl Default for AppendSessionConfig {
152    fn default() -> Self {
153        Self {
154            max_unacked_bytes: 5 * ONE_MIB,
155            max_unacked_batches: None,
156        }
157    }
158}
159
160impl AppendSessionConfig {
161    /// Create a new [`AppendSessionConfig`] with default settings.
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// Set the limit on total metered bytes of unacknowledged [`AppendInput`]s held in memory.
167    ///
168    /// **Note:** It must be at least `1MiB`.
169    ///
170    /// Defaults to `5MiB`.
171    pub fn with_max_unacked_bytes(self, max_unacked_bytes: u32) -> Result<Self, ValidationError> {
172        if max_unacked_bytes < ONE_MIB {
173            return Err(format!("max_unacked_bytes must be at least {ONE_MIB}").into());
174        }
175        Ok(Self {
176            max_unacked_bytes,
177            ..self
178        })
179    }
180
181    /// Set the limit on number of unacknowledged [`AppendInput`]s held in memory.
182    ///
183    /// Defaults to no limit.
184    pub fn with_max_unacked_batches(self, max_unacked_batches: NonZeroU32) -> Self {
185        Self {
186            max_unacked_batches: Some(max_unacked_batches.get()),
187            ..self
188        }
189    }
190}
191
192struct SessionState {
193    cmd_rx: mpsc::Receiver<Command>,
194    inflight_appends: VecDeque<InflightAppend>,
195    inflight_bytes: usize,
196    close_tx: Option<oneshot::Sender<Result<(), AppendSessionError>>>,
197    total_records: usize,
198    total_acked_records: usize,
199    prev_ack_end: Option<StreamPosition>,
200    stashed_submission: Option<StashedSubmission>,
201}
202
203/// A session for high-throughput appending with backpressure control. It can be created from
204/// [`append_session`](crate::S2Stream::append_session).
205///
206/// Supports pipelining multiple [`AppendInput`]s while preserving submission order.
207pub struct AppendSession {
208    cmd_tx: mpsc::Sender<Command>,
209    permits: AppendPermits,
210    terminal_err: Arc<OnceLock<AppendSessionError>>,
211    _handle: AbortOnDropHandle<()>,
212}
213
214impl AppendSession {
215    pub(crate) fn new(
216        client: BasinClient,
217        stream: StreamName,
218        encryption: Option<EncryptionKey>,
219        config: AppendSessionConfig,
220    ) -> Self {
221        let buffer_size = config
222            .max_unacked_batches
223            .map(|mib| mib as usize)
224            .unwrap_or(DEFAULT_CHANNEL_BUFFER_SIZE);
225        let (cmd_tx, cmd_rx) = mpsc::channel(buffer_size);
226        let permits = AppendPermits::new(config.max_unacked_batches, config.max_unacked_bytes);
227        let retry_builder = retry_builder(&client.config.retry);
228        let terminal_err = Arc::new(OnceLock::new());
229        let handle = AbortOnDropHandle::new(tokio::spawn(run_session_with_retry(
230            client,
231            stream,
232            encryption,
233            cmd_rx,
234            retry_builder,
235            buffer_size,
236            terminal_err.clone(),
237        )));
238        Self {
239            cmd_tx,
240            permits,
241            terminal_err,
242            _handle: handle,
243        }
244    }
245
246    /// Submit a batch of records for appending.
247    ///
248    /// Internally, it waits on [`reserve`](Self::reserve), then submits using the permit.
249    /// This provides backpressure when inflight limits are reached.
250    /// For explicit control, use [`reserve`](Self::reserve) followed by
251    /// [`BatchSubmitPermit::submit`].
252    ///
253    /// **Note**: After all submits, you must call [`close`](Self::close) to ensure all batches are
254    /// appended.
255    pub async fn submit(
256        &self,
257        input: AppendInput,
258    ) -> Result<BatchSubmitTicket, AppendSessionError> {
259        let permit = self.reserve(input.records.metered_bytes() as u32).await?;
260        Ok(permit.submit(input))
261    }
262
263    /// Reserve capacity for a batch to be submitted. Useful in [`select!`](tokio::select) loops
264    /// where you want to interleave submission with other async work. See [`submit`](Self::submit)
265    /// for a simpler API.
266    ///
267    /// Waits when inflight limits are reached, providing explicit backpressure control.
268    /// The returned permit must be used to submit the batch.
269    ///
270    /// **Note**: After all submits, you must call [`close`](Self::close) to ensure all batches are
271    /// appended.
272    ///
273    /// # Cancel safety
274    ///
275    /// This method is cancel safe. Internally, it only awaits
276    /// [`Semaphore::acquire_many_owned`](tokio::sync::Semaphore::acquire_many_owned) and
277    /// [`Sender::reserve_owned`](tokio::sync::mpsc::Sender::reserve), both of which are cancel
278    /// safe.
279    pub async fn reserve(&self, bytes: u32) -> Result<BatchSubmitPermit, AppendSessionError> {
280        let append_permit = self.permits.acquire(bytes).await;
281        let cmd_tx_permit = self
282            .cmd_tx
283            .clone()
284            .reserve_owned()
285            .await
286            .map_err(|_| self.terminal_err())?;
287        Ok(BatchSubmitPermit {
288            append_permit,
289            cmd_tx_permit,
290            terminal_err: self.terminal_err.clone(),
291        })
292    }
293
294    /// Close the session and wait for all submitted batch of records to be appended.
295    pub async fn close(self) -> Result<(), AppendSessionError> {
296        let (done_tx, done_rx) = oneshot::channel();
297        self.cmd_tx
298            .send(Command::Close { done_tx })
299            .await
300            .map_err(|_| self.terminal_err())?;
301        done_rx.await.map_err(|_| self.terminal_err())??;
302        Ok(())
303    }
304
305    fn terminal_err(&self) -> AppendSessionError {
306        self.terminal_err
307            .get()
308            .cloned()
309            .unwrap_or(AppendSessionError::SessionClosed)
310    }
311}
312
313/// A permit to submit a batch after reserving capacity.
314pub struct BatchSubmitPermit {
315    append_permit: AppendPermit,
316    cmd_tx_permit: mpsc::OwnedPermit<Command>,
317    terminal_err: Arc<OnceLock<AppendSessionError>>,
318}
319
320impl BatchSubmitPermit {
321    /// Submit the batch using this permit.
322    pub fn submit(self, input: AppendInput) -> BatchSubmitTicket {
323        let (ack_tx, ack_rx) = oneshot::channel();
324        self.cmd_tx_permit.send(Command::Submit {
325            input,
326            ack_tx,
327            permit: Some(self.append_permit),
328        });
329        BatchSubmitTicket {
330            rx: ack_rx,
331            terminal_err: self.terminal_err,
332        }
333    }
334}
335
336pub(crate) struct AppendSessionInternal {
337    cmd_tx: mpsc::Sender<Command>,
338    terminal_err: Arc<OnceLock<AppendSessionError>>,
339    _handle: AbortOnDropHandle<()>,
340}
341
342impl AppendSessionInternal {
343    pub(crate) fn new(
344        client: BasinClient,
345        stream: StreamName,
346        encryption: Option<EncryptionKey>,
347    ) -> Self {
348        let buffer_size = DEFAULT_CHANNEL_BUFFER_SIZE;
349        let (cmd_tx, cmd_rx) = mpsc::channel(buffer_size);
350        let retry_builder = retry_builder(&client.config.retry);
351        let terminal_err = Arc::new(OnceLock::new());
352        let handle = AbortOnDropHandle::new(tokio::spawn(run_session_with_retry(
353            client,
354            stream,
355            encryption,
356            cmd_rx,
357            retry_builder,
358            buffer_size,
359            terminal_err.clone(),
360        )));
361        Self {
362            cmd_tx,
363            terminal_err,
364            _handle: handle,
365        }
366    }
367
368    pub(crate) fn submit(
369        &self,
370        input: AppendInput,
371    ) -> impl Future<Output = Result<BatchSubmitTicket, AppendSessionError>> + Send + 'static {
372        let cmd_tx = self.cmd_tx.clone();
373        let terminal_err = self.terminal_err.clone();
374        async move {
375            let (ack_tx, ack_rx) = oneshot::channel();
376            cmd_tx
377                .send(Command::Submit {
378                    input,
379                    ack_tx,
380                    permit: None,
381                })
382                .await
383                .map_err(|_| {
384                    terminal_err
385                        .get()
386                        .cloned()
387                        .unwrap_or(AppendSessionError::SessionClosed)
388                })?;
389            Ok(BatchSubmitTicket {
390                rx: ack_rx,
391                terminal_err,
392            })
393        }
394    }
395
396    pub(crate) async fn close(self) -> Result<(), AppendSessionError> {
397        let (done_tx, done_rx) = oneshot::channel();
398        self.cmd_tx
399            .send(Command::Close { done_tx })
400            .await
401            .map_err(|_| self.terminal_err())?;
402        done_rx.await.map_err(|_| self.terminal_err())??;
403        Ok(())
404    }
405
406    fn terminal_err(&self) -> AppendSessionError {
407        self.terminal_err
408            .get()
409            .cloned()
410            .unwrap_or(AppendSessionError::SessionClosed)
411    }
412}
413
414#[derive(Debug)]
415pub(crate) struct AppendPermit {
416    _count: Option<OwnedSemaphorePermit>,
417    _bytes: OwnedSemaphorePermit,
418}
419
420#[derive(Clone)]
421pub(crate) struct AppendPermits {
422    count: Option<Arc<Semaphore>>,
423    bytes: Arc<Semaphore>,
424}
425
426impl AppendPermits {
427    pub(crate) fn new(count_permits: Option<u32>, bytes_permits: u32) -> Self {
428        Self {
429            count: count_permits.map(|permits| Arc::new(Semaphore::new(permits as usize))),
430            bytes: Arc::new(Semaphore::new(bytes_permits as usize)),
431        }
432    }
433
434    pub(crate) async fn acquire(&self, bytes: u32) -> AppendPermit {
435        AppendPermit {
436            _count: if let Some(count) = self.count.as_ref() {
437                Some(
438                    count
439                        .clone()
440                        .acquire_many_owned(1)
441                        .await
442                        .expect("semaphore should not be closed"),
443                )
444            } else {
445                None
446            },
447            _bytes: self
448                .bytes
449                .clone()
450                .acquire_many_owned(bytes)
451                .await
452                .expect("semaphore should not be closed"),
453        }
454    }
455}
456
457async fn run_session_with_retry(
458    client: BasinClient,
459    stream: StreamName,
460    encryption: Option<EncryptionKey>,
461    cmd_rx: mpsc::Receiver<Command>,
462    retry_builder: RetryBackoffBuilder,
463    buffer_size: usize,
464    terminal_err: Arc<OnceLock<AppendSessionError>>,
465) {
466    let access_token_mode = client.config.access_token.mode();
467    let frame_signal = match client.config.retry.append_retry_policy {
468        AppendRetryPolicy::NoSideEffects => Some(FrameSignal::new()),
469        AppendRetryPolicy::All => None,
470    };
471
472    let mut state = SessionState {
473        cmd_rx,
474        inflight_appends: VecDeque::new(),
475        inflight_bytes: 0,
476        close_tx: None,
477        total_records: 0,
478        total_acked_records: 0,
479        prev_ack_end: None,
480        stashed_submission: None,
481    };
482    let mut prev_total_acked_records = 0;
483    let mut retry_backoff = retry_builder.build();
484
485    loop {
486        let result = run_session(
487            &client,
488            &stream,
489            encryption.as_ref(),
490            &mut state,
491            buffer_size,
492            &frame_signal,
493        )
494        .await;
495
496        match result {
497            Ok(()) => {
498                break;
499            }
500            Err(err) => {
501                if prev_total_acked_records < state.total_acked_records {
502                    prev_total_acked_records = state.total_acked_records;
503                    retry_backoff.reset();
504                }
505
506                if is_safe_to_retry(
507                    &err,
508                    client.config.retry.append_retry_policy,
509                    !state.inflight_appends.is_empty(),
510                    frame_signal.as_ref(),
511                    access_token_mode,
512                ) && let Some(backoff) = retry_backoff.next()
513                {
514                    debug!(
515                        %err,
516                        ?backoff,
517                        num_retries_remaining = retry_backoff.remaining(),
518                        "retrying append session"
519                    );
520                    tokio::time::sleep(backoff).await;
521                } else {
522                    debug!(
523                        %err,
524                        retries_exhausted = retry_backoff.is_exhausted(),
525                        "not retrying append session"
526                    );
527
528                    let err: AppendSessionError = err;
529
530                    let _ = terminal_err.set(err.clone());
531
532                    for inflight_append in state.inflight_appends.drain(..) {
533                        let _ = inflight_append.ack_tx.send(Err(err.clone()));
534                    }
535
536                    if let Some(stashed) = state.stashed_submission.take() {
537                        let _ = stashed.ack_tx.send(Err(err.clone()));
538                    }
539
540                    if let Some(done_tx) = state.close_tx.take() {
541                        let _ = done_tx.send(Err(err.clone()));
542                    }
543
544                    state.cmd_rx.close();
545                    while let Some(cmd) = state.cmd_rx.recv().await {
546                        cmd.reject(err.clone());
547                    }
548                    break;
549                }
550            }
551        }
552    }
553
554    if let Some(done_tx) = state.close_tx.take() {
555        let _ = done_tx.send(Ok(()));
556    }
557}
558
559async fn run_session(
560    client: &BasinClient,
561    stream: &StreamName,
562    encryption: Option<&EncryptionKey>,
563    state: &mut SessionState,
564    buffer_size: usize,
565    frame_signal: &Option<FrameSignal>,
566) -> Result<(), AppendSessionError> {
567    if let Some(s) = frame_signal {
568        s.reset();
569    }
570
571    let (input_tx, mut acks) = connect(
572        client,
573        stream,
574        encryption,
575        buffer_size,
576        frame_signal.clone(),
577    )
578    .await?;
579    let ack_timeout = client.config.request_timeout;
580
581    if !state.inflight_appends.is_empty() {
582        resend(state, &input_tx, &mut acks, ack_timeout).await?;
583
584        if let Some(s) = frame_signal {
585            s.reset();
586        }
587
588        assert!(state.inflight_appends.is_empty());
589        assert_eq!(state.inflight_bytes, 0);
590    }
591
592    let timer = MuxTimer::<N_TIMER_VARIANTS>::default();
593    tokio::pin!(timer);
594
595    loop {
596        tokio::select! {
597            (event_ord, _deadline) = &mut timer, if timer.is_armed() => {
598                match TimerEvent::from(event_ord) {
599                    TimerEvent::AckDeadline => {
600                        return Err(AppendSessionError::AckTimeout);
601                    }
602                }
603            }
604
605            input_tx_permit = input_tx.reserve(), if state.stashed_submission.is_some() => {
606                let input_tx_permit = input_tx_permit
607                    .map_err(|_| AppendSessionError::ServerDisconnected)?;
608                let submission = state.stashed_submission
609                    .take()
610                    .expect("stashed_submission should not be None");
611
612                let ack_deadline = Instant::now() + ack_timeout;
613                input_tx_permit.send(submission.input.clone());
614
615                state.total_records += submission.input.records.len();
616                state.inflight_bytes += submission.input_metered_bytes;
617
618                timer.as_mut().fire_at(
619                    TimerEvent::AckDeadline,
620                    ack_deadline,
621                    CoalesceMode::Earliest,
622                );
623                state.inflight_appends.push_back(InflightAppend {
624                    input: submission.input,
625                    input_metered_bytes: submission.input_metered_bytes,
626                    ack_tx: submission.ack_tx,
627                    ack_deadline,
628                    _permit: submission.permit,
629                });
630            }
631
632            cmd = state.cmd_rx.recv(), if state.stashed_submission.is_none() => {
633                match cmd {
634                    Some(Command::Submit { input, ack_tx, permit }) => {
635                        if state.close_tx.is_some() {
636                            let _ = ack_tx.send(
637                                Err(AppendSessionError::SessionClosing)
638                            );
639                        } else {
640                            let input_metered_bytes = input.records.metered_bytes();
641                            state.stashed_submission = Some(StashedSubmission {
642                                input,
643                                input_metered_bytes,
644                                ack_tx,
645                                permit,
646                            });
647                        }
648                    }
649                    Some(Command::Close { done_tx }) => {
650                        state.close_tx = Some(done_tx);
651                    }
652                    None => {
653                        return Err(AppendSessionError::SessionDropped);
654                    }
655                }
656            }
657
658            ack = acks.next() => {
659                match ack {
660                    Some(Ok(ack)) => {
661                        process_ack(
662                            ack,
663                            state,
664                            timer.as_mut(),
665                        )?;
666                    }
667                    Some(Err(err)) => {
668                        return Err(err.into());
669                    }
670                    None => {
671                        if !state.inflight_appends.is_empty() || state.stashed_submission.is_some() {
672                            return Err(AppendSessionError::StreamClosedEarly);
673                        }
674                        break;
675                    }
676                }
677            }
678        }
679
680        if state.close_tx.is_some()
681            && state.inflight_appends.is_empty()
682            && state.stashed_submission.is_none()
683        {
684            break;
685        }
686    }
687
688    assert!(state.inflight_appends.is_empty());
689    assert_eq!(state.inflight_bytes, 0);
690    assert!(state.stashed_submission.is_none());
691
692    Ok(())
693}
694
695async fn resend(
696    state: &mut SessionState,
697    input_tx: &mpsc::Sender<AppendInput>,
698    acks: &mut Streaming<AppendAck>,
699    ack_timeout: Duration,
700) -> Result<(), AppendSessionError> {
701    debug!(
702        inflight_appends_len = state.inflight_appends.len(),
703        inflight_bytes = state.inflight_bytes,
704        "resending inflight appends"
705    );
706
707    let mut resend_index = 0;
708    let mut resend_finished = false;
709
710    let timer = MuxTimer::<N_TIMER_VARIANTS>::default();
711    tokio::pin!(timer);
712
713    while !state.inflight_appends.is_empty() {
714        tokio::select! {
715            (event_ord, _deadline) = &mut timer, if timer.is_armed() => {
716                match TimerEvent::from(event_ord) {
717                    TimerEvent::AckDeadline => {
718                        return Err(AppendSessionError::AckTimeout);
719                    }
720                }
721            }
722
723            input_tx_permit = input_tx.reserve(), if !resend_finished => {
724                let input_tx_permit = input_tx_permit
725                    .map_err(|_| AppendSessionError::ServerDisconnected)?;
726
727                if let Some(inflight_append) = state.inflight_appends.get_mut(resend_index) {
728                    inflight_append.ack_deadline = Instant::now() + ack_timeout;
729                    timer.as_mut().fire_at(
730                        TimerEvent::AckDeadline,
731                        inflight_append.ack_deadline,
732                        CoalesceMode::Latest,
733                    );
734                    input_tx_permit.send(inflight_append.input.clone());
735                    resend_index += 1;
736                } else {
737                    resend_finished = true;
738                }
739            }
740
741            ack = acks.next() => {
742                match ack {
743                    Some(Ok(ack)) => {
744                        process_ack(
745                            ack,
746                            state,
747                            timer.as_mut(),
748                        )?;
749                        resend_index = resend_index.checked_sub(1).ok_or_else(|| {
750                            AppendSessionError::InvalidAck(
751                                "received ack without a corresponding resent append in flight".to_string(),
752                            )
753                        })?;
754                    }
755                    Some(Err(err)) => {
756                        return Err(err.into());
757                    }
758                    None => {
759                        return Err(AppendSessionError::StreamClosedEarly);
760                    }
761                }
762            }
763        }
764    }
765
766    assert_eq!(
767        resend_index, 0,
768        "resend_index should be 0 after resend completes"
769    );
770    debug!("finished resending inflight appends");
771    Ok(())
772}
773
774async fn connect(
775    client: &BasinClient,
776    stream: &StreamName,
777    encryption: Option<&EncryptionKey>,
778    buffer_size: usize,
779    frame_signal: Option<FrameSignal>,
780) -> Result<(mpsc::Sender<AppendInput>, Streaming<AppendAck>), AppendSessionError> {
781    let (input_tx, input_rx) = mpsc::channel::<AppendInput>(buffer_size);
782    let ack_stream = Box::pin(
783        client
784            .append_session(
785                stream,
786                ReceiverStream::new(input_rx).map(|i| i.into()),
787                encryption,
788                frame_signal,
789            )
790            .await?
791            .map(|ack| match ack {
792                Ok(ack) => Ok(ack.into()),
793                Err(err) => Err(err),
794            }),
795    );
796    Ok((input_tx, ack_stream))
797}
798
799fn process_ack(
800    ack: AppendAck,
801    state: &mut SessionState,
802    timer: Pin<&mut MuxTimer<N_TIMER_VARIANTS>>,
803) -> Result<(), AppendSessionError> {
804    let corresponding_append = state.inflight_appends.pop_front().ok_or_else(|| {
805        AppendSessionError::InvalidAck(
806            "received ack without a corresponding append in flight".to_string(),
807        )
808    })?;
809
810    if ack.end.seq_num < ack.start.seq_num {
811        return Err(AppendSessionError::InvalidAck(
812            "ack end seq_num should be greater than or equal to start seq_num".to_string(),
813        ));
814    }
815
816    if state
817        .prev_ack_end
818        .is_some_and(|end| ack.end.seq_num <= end.seq_num)
819    {
820        return Err(AppendSessionError::InvalidAck(
821            "ack end seq_num should be greater than previous ack end".to_string(),
822        ));
823    }
824
825    let num_acked_records = (ack.end.seq_num - ack.start.seq_num) as usize;
826    let expected_records = corresponding_append.input.records.len();
827    if num_acked_records != expected_records {
828        return Err(AppendSessionError::InvalidAck(format!(
829            "acked record count {num_acked_records} does not match submitted batch size {expected_records}"
830        )));
831    }
832
833    state.total_acked_records += num_acked_records;
834    state.inflight_bytes -= corresponding_append.input_metered_bytes;
835    state.prev_ack_end = Some(ack.end);
836
837    let _ = corresponding_append.ack_tx.send(Ok(ack));
838
839    if let Some(oldest_append) = state.inflight_appends.front() {
840        timer.fire_at(
841            TimerEvent::AckDeadline,
842            oldest_append.ack_deadline,
843            CoalesceMode::Latest,
844        );
845    } else {
846        timer.cancel(TimerEvent::AckDeadline);
847        assert_eq!(
848            state.total_records, state.total_acked_records,
849            "all records should be acked when inflight is empty"
850        );
851    }
852
853    Ok(())
854}
855
856struct StashedSubmission {
857    input: AppendInput,
858    input_metered_bytes: usize,
859    ack_tx: oneshot::Sender<Result<AppendAck, AppendSessionError>>,
860    permit: Option<AppendPermit>,
861}
862
863struct InflightAppend {
864    input: AppendInput,
865    input_metered_bytes: usize,
866    ack_tx: oneshot::Sender<Result<AppendAck, AppendSessionError>>,
867    ack_deadline: Instant,
868    _permit: Option<AppendPermit>,
869}
870
871enum Command {
872    Submit {
873        input: AppendInput,
874        ack_tx: oneshot::Sender<Result<AppendAck, AppendSessionError>>,
875        permit: Option<AppendPermit>,
876    },
877    Close {
878        done_tx: oneshot::Sender<Result<(), AppendSessionError>>,
879    },
880}
881
882impl Command {
883    fn reject(self, err: AppendSessionError) {
884        match self {
885            Command::Submit { ack_tx, .. } => {
886                let _ = ack_tx.send(Err(err));
887            }
888            Command::Close { done_tx } => {
889                let _ = done_tx.send(Err(err));
890            }
891        }
892    }
893}
894
895fn is_safe_to_retry(
896    err: &AppendSessionError,
897    policy: AppendRetryPolicy,
898    has_inflight: bool,
899    frame_signal: Option<&FrameSignal>,
900    access_token_mode: AccessTokenMode,
901) -> bool {
902    let policy_compliant = match policy {
903        AppendRetryPolicy::All => true,
904        AppendRetryPolicy::NoSideEffects => {
905            !has_inflight
906                || !frame_signal.is_none_or(|s| s.is_signalled())
907                || err.has_no_side_effects()
908        }
909    };
910    policy_compliant
911        && (err.is_retryable()
912            || (access_token_mode.is_refreshable() && err.is_authentication_error()))
913}
914
915const DEFAULT_CHANNEL_BUFFER_SIZE: usize = 100;
916
917#[derive(Debug, Clone, Copy, PartialEq, Eq)]
918enum TimerEvent {
919    AckDeadline,
920}
921
922const N_TIMER_VARIANTS: usize = 1;
923
924impl From<TimerEvent> for usize {
925    fn from(event: TimerEvent) -> Self {
926        match event {
927            TimerEvent::AckDeadline => 0,
928        }
929    }
930}
931
932impl From<usize> for TimerEvent {
933    fn from(value: usize) -> Self {
934        match value {
935            0 => TimerEvent::AckDeadline,
936            _ => panic!("invalid ordinal"),
937        }
938    }
939}
940
941#[cfg(test)]
942mod tests {
943    use http::StatusCode;
944
945    use super::{AppendSessionError, is_safe_to_retry};
946    use crate::{
947        api::{ApiError, ServerErrorBody},
948        error::{AppendError, RequestError},
949        frame_signal::FrameSignal,
950        types::{AccessTokenMode, AppendRetryPolicy},
951    };
952
953    fn server_error(status: StatusCode, code: &str) -> AppendSessionError {
954        AppendSessionError::Append(AppendError::Request(RequestError::from(ApiError::Server(
955            status,
956            ServerErrorBody {
957                code: code.to_owned(),
958                message: "test".to_owned(),
959            },
960        ))))
961    }
962
963    #[test]
964    fn safe_to_retry_session_all_policy() {
965        let retryable = server_error(StatusCode::INTERNAL_SERVER_ERROR, "internal");
966        let non_retryable = server_error(StatusCode::BAD_REQUEST, "bad_request");
967        let policy = AppendRetryPolicy::All;
968        let static_mode = AccessTokenMode::Static;
969
970        // All policy — always policy-compliant, just needs retryable.
971        assert!(is_safe_to_retry(
972            &retryable,
973            policy,
974            true,
975            None,
976            static_mode
977        ));
978        assert!(!is_safe_to_retry(
979            &non_retryable,
980            policy,
981            true,
982            None,
983            static_mode,
984        ));
985
986        let unauthorized = server_error(StatusCode::UNAUTHORIZED, "authn");
987        #[cfg(feature = "_hidden")]
988        assert!(is_safe_to_retry(
989            &unauthorized,
990            policy,
991            true,
992            None,
993            AccessTokenMode::Refreshable,
994        ));
995        assert!(!is_safe_to_retry(
996            &unauthorized,
997            policy,
998            true,
999            None,
1000            static_mode,
1001        ));
1002
1003        #[cfg(feature = "_hidden")]
1004        let unrelated_unauthorized = server_error(StatusCode::UNAUTHORIZED, "other");
1005        #[cfg(feature = "_hidden")]
1006        assert!(!is_safe_to_retry(
1007            &unrelated_unauthorized,
1008            policy,
1009            true,
1010            None,
1011            AccessTokenMode::Refreshable,
1012        ));
1013    }
1014
1015    #[test]
1016    fn safe_to_retry_session_no_side_effects_policy() {
1017        let retryable = server_error(StatusCode::INTERNAL_SERVER_ERROR, "internal");
1018        let no_side_effect = server_error(StatusCode::TOO_MANY_REQUESTS, "rate_limited");
1019        let policy = AppendRetryPolicy::NoSideEffects;
1020        let signal = FrameSignal::new();
1021        let mode = AccessTokenMode::Static;
1022
1023        // No inflight — always safe.
1024        signal.signal();
1025        assert!(is_safe_to_retry(
1026            &retryable,
1027            policy,
1028            false,
1029            Some(&signal),
1030            mode,
1031        ));
1032
1033        // Inflight + signal not set — safe (no data sent this attempt).
1034        signal.reset();
1035        assert!(is_safe_to_retry(
1036            &retryable,
1037            policy,
1038            true,
1039            Some(&signal),
1040            mode,
1041        ));
1042
1043        // Inflight + signal set + error with possible side effects — not safe.
1044        signal.signal();
1045        assert!(!is_safe_to_retry(
1046            &retryable,
1047            policy,
1048            true,
1049            Some(&signal),
1050            mode,
1051        ));
1052
1053        // Inflight + signal set + no-side-effect error — safe.
1054        assert!(is_safe_to_retry(
1055            &no_side_effect,
1056            policy,
1057            true,
1058            Some(&signal),
1059            mode,
1060        ));
1061
1062        // AckTimeout — retryable but has possible side effects.
1063        assert!(!is_safe_to_retry(
1064            &AppendSessionError::AckTimeout,
1065            policy,
1066            true,
1067            Some(&signal),
1068            mode,
1069        ));
1070    }
1071}