Skip to main content

databricks_zerobus_ingest_sdk/
multiplexed_stream.rs

1//! Fan-out wrapper that distributes ingestion across multiple [`ZerobusStream`]s.
2//!
3//! A single `ZerobusStream` is throughput-limited by its in-flight window;
4//! `MultiplexedStream` routes records round-robin across a fixed set of
5//! sub-streams to raise aggregate throughput. When the chosen sub-stream is at
6//! capacity the call awaits drain rather than rerouting, so per-sub-stream
7//! ordering is preserved.
8//!
9//! Each ingest returns an opaque [`MessageId`] that packs the sub-stream index
10//! and its offset into a single `i64` (6 bits of stream index → up to 64
11//! sub-streams). Callers later pass it to
12//! [`wait_for_message_id`](MultiplexedStream::wait_for_message_id) without
13//! needing to know which sub-stream handled the record.
14//!
15//! The mux is poisoned on the first unrecoverable sub-stream error: remaining
16//! sub-streams are flushed and further ingest calls fail. Any records still
17//! buffered can be recovered via
18//! [`get_unacked_records`](MultiplexedStream::get_unacked_records) or
19//! [`get_unacked_batches`](MultiplexedStream::get_unacked_batches).
20
21use futures::future::join_all;
22use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
23use std::sync::Arc;
24use tracing::{error, info, warn};
25
26use crate::{
27    AckCallback, EncodedBatch, EncodedRecord, OffsetId, ZerobusError, ZerobusResult, ZerobusStream,
28};
29
30/// Number of bits reserved for the stream index.
31/// 6 bits supports up to 64 sub-streams.
32const STREAM_BITS: u32 = 6;
33const OFFSET_MASK: i64 = (1i64 << (64 - STREAM_BITS)) - 1;
34
35/// Opaque identifier returned by ingest methods on MultiplexedStream.
36/// Encodes the sub-stream index and sub-stream offset in a single i64.
37///
38/// Unlike a `ZerobusStream` offset, `MessageId` values are not ordered — pass
39/// them to [`MultiplexedStream::wait_for_message_id`] to await acknowledgment.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41pub struct MessageId(i64);
42
43impl std::fmt::Display for MessageId {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        write!(
46            f,
47            "MessageId(stream={}, offset={})",
48            self.stream_index(),
49            self.sub_offset()
50        )
51    }
52}
53
54impl MessageId {
55    fn new(stream_index: usize, sub_offset: OffsetId) -> Self {
56        debug_assert!(stream_index < (1 << STREAM_BITS));
57        debug_assert!((0..=OFFSET_MASK).contains(&sub_offset));
58        Self(((stream_index as i64) << (64 - STREAM_BITS)) | (sub_offset & OFFSET_MASK))
59    }
60
61    /// Returns the sub-stream index this message was sent to.
62    pub fn stream_index(&self) -> usize {
63        ((self.0 as u64) >> (64 - STREAM_BITS)) as usize
64    }
65
66    /// Returns the offset within the sub-stream.
67    pub fn sub_offset(&self) -> OffsetId {
68        self.0 & OFFSET_MASK
69    }
70
71    /// Returns the raw i64 value, e.g. for transport across an FFI boundary.
72    pub fn raw(&self) -> i64 {
73        self.0
74    }
75
76    /// Construct from a raw i64 value previously obtained from [`MessageId::raw`].
77    ///
78    /// Only round-trip values from `raw()`: a fabricated id pointing at an
79    /// offset that was never ingested makes `wait_for_message_id` wait until
80    /// the flush timeout (indefinitely if none is configured).
81    pub fn from_raw(raw: i64) -> Self {
82        Self(raw)
83    }
84}
85
86struct MultiplexedAckCallbackAdapter {
87    stream_index: usize,
88    callback: Arc<dyn AckCallback<MessageId>>,
89}
90
91impl AckCallback for MultiplexedAckCallbackAdapter {
92    fn on_ack(&self, offset_id: OffsetId) {
93        self.callback
94            .on_ack(MessageId::new(self.stream_index, offset_id));
95    }
96
97    fn on_error(&self, offset_id: OffsetId, error_message: &str) {
98        self.callback
99            .on_error(MessageId::new(self.stream_index, offset_id), error_message);
100    }
101}
102
103/// Creates the callback installed on one multiplexed sub-stream.
104///
105/// The adapter captures the sub-stream index and converts each stream-local
106/// [`OffsetId`] into the [`MessageId`] exposed by [`MultiplexedStream`].
107#[allow(dead_code)]
108pub(crate) fn multiplexed_ack_callback(
109    stream_index: usize,
110    callback: Arc<dyn AckCallback<MessageId>>,
111) -> Arc<dyn AckCallback> {
112    assert!(
113        stream_index < (1 << STREAM_BITS),
114        "MultiplexedStream supports at most {} sub-streams",
115        1 << STREAM_BITS
116    );
117    Arc::new(MultiplexedAckCallbackAdapter {
118        stream_index,
119        callback,
120    })
121}
122
123/// Distributes ingestion round-robin across a fixed set of [`ZerobusStream`]s.
124///
125/// See the [module-level documentation](self) for routing, `MessageId`, and
126/// poisoning semantics.
127pub struct MultiplexedStream {
128    streams: Vec<ZerobusStream>,
129    round_robin_counter: AtomicUsize,
130    is_closed: AtomicBool,
131}
132
133impl MultiplexedStream {
134    /// Creates a multiplexed stream over the given sub-streams.
135    ///
136    /// # Panics
137    ///
138    /// Panics if `streams` is empty or holds more than 64 sub-streams.
139    pub fn new(streams: Vec<ZerobusStream>) -> Self {
140        assert!(
141            !streams.is_empty(),
142            "MultiplexedStream requires at least one sub-stream"
143        );
144        assert!(
145            streams.len() <= (1 << STREAM_BITS),
146            "MultiplexedStream supports at most {} sub-streams",
147            1 << STREAM_BITS
148        );
149        Self {
150            streams,
151            round_robin_counter: AtomicUsize::new(0),
152            is_closed: AtomicBool::new(false),
153        }
154    }
155
156    #[allow(clippy::result_large_err)]
157    fn check_closed(&self) -> ZerobusResult<()> {
158        if self.is_closed_fast() {
159            return Err(ZerobusError::InvalidStateError(
160                "MultiplexedStream is closed".to_string(),
161            ));
162        }
163        Ok(())
164    }
165
166    fn is_closed_fast(&self) -> bool {
167        self.is_closed.load(Ordering::Relaxed)
168    }
169
170    async fn shutdown_on_failure(&self, trigger_index: usize, cause: &ZerobusError) {
171        if self.is_closed.swap(true, Ordering::Relaxed) {
172            return;
173        }
174
175        error!(
176            trigger_stream_index = trigger_index,
177            cause = %cause,
178            num_streams = self.streams.len(),
179            "MultiplexedStream poisoned due to sub-stream failure"
180        );
181
182        let flush_results = join_all(self.streams.iter().map(|s| s.flush())).await;
183        for (i, result) in flush_results.into_iter().enumerate() {
184            if let Err(e) = result {
185                warn!(stream_index = i, error = %e, "Failed to flush sub-stream during shutdown");
186            }
187        }
188
189        // Signal each sub-stream to tear down its background tasks (gRPC
190        // connection, supervisor, callback handler). Full join/abort of the
191        // task handles happens later in `close` or `Drop`.
192        for s in &self.streams {
193            s.signal_shutdown();
194        }
195    }
196
197    // TODO: if the picked sub-stream is at capacity, try the next one before
198    // falling back to waiting.
199    fn pick_substream(&self) -> usize {
200        self.round_robin_counter.fetch_add(1, Ordering::Relaxed) % self.streams.len()
201    }
202
203    async fn wait_for_capacity(&self, stream: &ZerobusStream, idx: usize) -> ZerobusResult<()> {
204        let mut backoff_ms = 1u64;
205        let mut total_wait_ms = 0u64;
206        let mut logged_backpressure = false;
207
208        loop {
209            self.check_closed()?;
210
211            if stream.is_closed() {
212                let err = ZerobusError::InvalidStateError(format!(
213                    "Sub-stream {} closed unexpectedly",
214                    idx
215                ));
216                self.shutdown_on_failure(idx, &err).await;
217                return Err(err);
218            }
219
220            if stream.has_capacity() {
221                return Ok(());
222            }
223
224            tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
225            total_wait_ms += backoff_ms;
226            backoff_ms = (backoff_ms * 2).min(50);
227
228            if !logged_backpressure && total_wait_ms >= 1000 {
229                warn!(
230                    stream_index = idx,
231                    total_wait_ms, "Backpressure: sub-stream at capacity, waiting for drain"
232                );
233                logged_backpressure = true;
234            }
235        }
236    }
237
238    // Only poison the mux when the sub-stream itself has reached a terminal
239    // state (`is_closed`): recovery is exhausted or a non-retryable server
240    // error fired, so its offsets/pending records are unrecoverable. Other
241    // ingest errors (e.g. `InvalidArgument` on a record-type mismatch) leave
242    // the sub-stream healthy and would be wrong to escalate — one bad payload
243    // shouldn't kill the other sub-streams.
244    async fn handle_ingest_error(
245        &self,
246        e: ZerobusError,
247        stream: &ZerobusStream,
248        idx: usize,
249    ) -> ZerobusError {
250        if stream.is_closed() {
251            self.shutdown_on_failure(idx, &e).await;
252        } else {
253            warn!(stream_index = idx, error = %e, "Ingest errored but sub-stream still alive");
254        }
255        e
256    }
257
258    /// Ingests a single record into the next sub-stream (round-robin).
259    ///
260    /// Returns once the record is queued; use
261    /// [`wait_for_message_id`](Self::wait_for_message_id) with the returned id
262    /// to await server acknowledgment. If the chosen sub-stream is at capacity,
263    /// this waits for it to drain rather than rerouting.
264    pub async fn ingest_record(
265        &self,
266        payload: impl Into<EncodedRecord>,
267    ) -> ZerobusResult<MessageId> {
268        self.check_closed()?;
269        let record = payload.into();
270        let idx = self.pick_substream();
271        let stream = &self.streams[idx];
272        self.wait_for_capacity(stream, idx).await?;
273        self.check_closed()?;
274
275        match stream.ingest_record_offset(record).await {
276            Ok(off) => Ok(MessageId::new(idx, off)),
277            Err(e) => Err(self.handle_ingest_error(e, stream, idx).await),
278        }
279    }
280
281    /// Ingests a batch of records into a single sub-stream (round-robin).
282    ///
283    /// The whole batch lands on one sub-stream so a single returned id covers
284    /// it. Returns `None` for an empty batch.
285    // TODO: Check if there is a performance advantage in splitting this payload in multiple streams
286    pub async fn ingest_records<I, T>(&self, payload: I) -> ZerobusResult<Option<MessageId>>
287    where
288        I: IntoIterator<Item = T>,
289        T: Into<EncodedRecord>,
290    {
291        self.check_closed()?;
292        let records: Vec<EncodedRecord> = payload.into_iter().map(Into::into).collect();
293        if records.is_empty() {
294            return Ok(None);
295        }
296        let idx = self.pick_substream();
297        let stream = &self.streams[idx];
298        self.wait_for_capacity(stream, idx).await?;
299        self.check_closed()?;
300
301        match stream.ingest_records_offset(records).await {
302            Ok(sub_offset) => Ok(sub_offset.map(|off| MessageId::new(idx, off))),
303            Err(e) => Err(self.handle_ingest_error(e, stream, idx).await),
304        }
305    }
306
307    /// Waits until every record already queued on every sub-stream is
308    /// acknowledged by the server.
309    ///
310    /// If a sub-stream flush fails because that sub-stream reached a terminal
311    /// state, the mux is poisoned. The first flush error is returned;
312    /// additional ones are logged.
313    pub async fn flush(&self) -> ZerobusResult<()> {
314        self.check_closed()?;
315        let results = join_all(self.streams.iter().map(|s| s.flush())).await;
316        let mut first_error: Option<ZerobusError> = None;
317        let mut first_closed: Option<(usize, ZerobusError)> = None;
318        for (i, result) in results.into_iter().enumerate() {
319            if let Err(e) = result {
320                if self.streams[i].is_closed() && first_closed.is_none() {
321                    first_closed = Some((i, e.clone()));
322                }
323                if first_error.is_none() {
324                    first_error = Some(e);
325                } else {
326                    warn!(
327                        stream_index = i,
328                        error = %e,
329                        "Additional sub-stream flush error (first error will be returned)"
330                    );
331                }
332            }
333        }
334        match first_error {
335            Some(e) => {
336                if let Some((closed_idx, closed_err)) = first_closed {
337                    self.shutdown_on_failure(closed_idx, &closed_err).await;
338                } else {
339                    warn!(error = %e, "flush errored but sub-streams still alive");
340                }
341                Err(e)
342            }
343            None => Ok(()),
344        }
345    }
346
347    /// Waits for server acknowledgment of the record or batch behind a
348    /// [`MessageId`] returned from [`ingest_record`](Self::ingest_record) or
349    /// [`ingest_records`](Self::ingest_records).
350    pub async fn wait_for_message_id(&self, message_id: MessageId) -> ZerobusResult<()> {
351        let idx = message_id.stream_index();
352        if idx >= self.streams.len() {
353            return Err(ZerobusError::InvalidArgument(format!(
354                "Invalid stream index {} in message id",
355                idx
356            )));
357        }
358        match self.streams[idx]
359            .wait_for_offset(message_id.sub_offset())
360            .await
361        {
362            Ok(()) => Ok(()),
363            Err(e) => {
364                if self.streams[idx].is_closed() {
365                    self.shutdown_on_failure(idx, &e).await;
366                } else {
367                    warn!(
368                        stream_index = idx,
369                        error = %e,
370                        "wait_for_offset errored but sub-stream still alive"
371                    );
372                }
373                Err(e)
374            }
375        }
376    }
377
378    /// Flushes and closes all sub-streams, releasing their resources.
379    ///
380    /// The first flush/close error is returned (additional ones are logged);
381    /// on error, use [`get_unacked_records`](Self::get_unacked_records) to
382    /// recover records that were never acknowledged.
383    pub async fn close(&mut self) -> ZerobusResult<()> {
384        info!("Closing MultiplexedStream");
385        self.is_closed.store(true, Ordering::Relaxed);
386
387        let mut first_error: Option<ZerobusError> = None;
388
389        // Flush all sub-streams in parallel first; the per-stream `close`
390        // below flushes again, but by then each stream is already drained so
391        // the sequential pass is cheap.
392        let flush_results = join_all(self.streams.iter().map(|s| s.flush())).await;
393        for (i, result) in flush_results.into_iter().enumerate() {
394            if let Err(e) = result {
395                if first_error.is_none() {
396                    first_error = Some(e);
397                } else {
398                    warn!(
399                        stream_index = i,
400                        error = %e,
401                        "Additional sub-stream flush error during close"
402                    );
403                }
404            }
405        }
406
407        for (i, stream) in self.streams.iter_mut().enumerate() {
408            if let Err(e) = stream.close().await {
409                if first_error.is_none() {
410                    first_error = Some(e);
411                } else {
412                    warn!(
413                        stream_index = i,
414                        error = %e,
415                        "Additional sub-stream close error"
416                    );
417                }
418            }
419        }
420
421        match first_error {
422            Some(e) => Err(e),
423            None => Ok(()),
424        }
425    }
426
427    /// Returns whether the mux is closed — either via [`close`](Self::close)
428    /// or because a sub-stream failure poisoned it.
429    pub fn is_closed(&self) -> bool {
430        self.is_closed_fast() || self.streams.iter().any(ZerobusStream::is_closed)
431    }
432
433    /// Returns records that were ingested but not acknowledged.
434    ///
435    /// Closes the mux first to ensure all sub-streams have reached their terminal state,
436    /// so results are always complete. Any error from close is swallowed — if records can
437    /// still be recovered, they will be returned.
438    pub async fn get_unacked_records(
439        &mut self,
440    ) -> ZerobusResult<impl Iterator<Item = EncodedRecord>> {
441        let _ = self.close().await;
442        let mut all_records = Vec::new();
443        for stream in &self.streams {
444            all_records.extend(stream.get_unacked_records().await?);
445        }
446        Ok(all_records.into_iter())
447    }
448
449    /// Returns batches that were ingested but not acknowledged.
450    ///
451    /// Closes the mux first to ensure all sub-streams have reached their terminal state,
452    /// so results are always complete. Any error from close is swallowed — if records can
453    /// still be recovered, they will be returned.
454    pub async fn get_unacked_batches(&mut self) -> ZerobusResult<Vec<EncodedBatch>> {
455        let _ = self.close().await;
456        let mut all_batches = Vec::new();
457        for stream in &self.streams {
458            all_batches.extend(stream.get_unacked_batches().await?);
459        }
460        Ok(all_batches)
461    }
462}
463
464impl Drop for MultiplexedStream {
465    fn drop(&mut self) {
466        self.is_closed.store(true, Ordering::Relaxed);
467        // Fire cancellation on every sub-stream in parallel so their
468        // background tasks can start unwinding concurrently. The Vec drop
469        // below then runs each `ZerobusStream::Drop`, which aborts any
470        // JoinHandles that haven't already exited.
471        for stream in &self.streams {
472            stream.signal_shutdown();
473        }
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use std::sync::Mutex;
481
482    #[derive(Default)]
483    struct RecordingMultiplexedCallback {
484        acks: Mutex<Vec<MessageId>>,
485        errors: Mutex<Vec<(MessageId, String)>>,
486    }
487
488    impl AckCallback<MessageId> for RecordingMultiplexedCallback {
489        fn on_ack(&self, message_id: MessageId) {
490            self.acks.lock().unwrap().push(message_id);
491        }
492
493        fn on_error(&self, message_id: MessageId, error_message: &str) {
494            self.errors
495                .lock()
496                .unwrap()
497                .push((message_id, error_message.to_string()));
498        }
499    }
500
501    #[test]
502    #[should_panic(expected = "MultiplexedStream requires at least one sub-stream")]
503    fn test_constructor_panics_on_empty_streams() {
504        MultiplexedStream::new(vec![]);
505    }
506
507    #[test]
508    fn test_message_id_roundtrip() {
509        for stream_idx in 0..64 {
510            for sub_offset in [0i64, 1, 100, 1_000_000, i64::MAX >> STREAM_BITS] {
511                let id = MessageId::new(stream_idx, sub_offset);
512                assert_eq!(id.stream_index(), stream_idx);
513                assert_eq!(id.sub_offset(), sub_offset);
514            }
515        }
516    }
517
518    #[test]
519    fn test_message_id_zero() {
520        let id = MessageId::new(0, 0);
521        assert_eq!(id.raw(), 0);
522        assert_eq!(id.stream_index(), 0);
523        assert_eq!(id.sub_offset(), 0);
524    }
525
526    #[test]
527    fn test_message_id_different_streams_same_offset() {
528        let a = MessageId::new(0, 42);
529        let b = MessageId::new(1, 42);
530        assert_ne!(a, b);
531        assert_eq!(a.sub_offset(), b.sub_offset());
532        assert_ne!(a.stream_index(), b.stream_index());
533    }
534
535    #[test]
536    fn test_multiplexed_ack_callback_routes_substream_ids() {
537        let callback = Arc::new(RecordingMultiplexedCallback::default());
538        let stream_0 = multiplexed_ack_callback(0, callback.clone());
539        let stream_1 = multiplexed_ack_callback(1, callback.clone());
540
541        stream_0.on_ack(42);
542        stream_1.on_ack(42);
543        stream_1.on_error(43, "test error");
544
545        assert_eq!(
546            callback.acks.lock().unwrap().as_slice(),
547            &[MessageId::new(0, 42), MessageId::new(1, 42)]
548        );
549        assert_eq!(
550            callback.errors.lock().unwrap().as_slice(),
551            &[(MessageId::new(1, 43), "test error".to_string())]
552        );
553    }
554
555    #[test]
556    #[should_panic(expected = "MultiplexedStream supports at most 64 sub-streams")]
557    fn test_multiplexed_ack_callback_rejects_invalid_stream_index() {
558        let callback = Arc::new(RecordingMultiplexedCallback::default());
559        multiplexed_ack_callback(64, callback);
560    }
561}