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