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