Skip to main content

polyester/realtime/
snapshot_then_stream.rs

1//! Snapshot-then-stream coordinator (Go `realtime.SnapshotThenStream` parity).
2
3use crate::errors::{Error, Result};
4use crate::realtime::{Client, ReconnectBackoff, TypedSubscription, lock_unpoisoned};
5use futures_util::future::BoxFuture;
6use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
7use std::sync::{Arc, Mutex};
8use tokio::sync::{Mutex as AsyncMutex, watch};
9use tokio::time::Duration;
10
11const MAX_REQUEST_REFRESH_ATTEMPTS: usize = 3;
12const REQUEST_REFRESH_INITIAL_BACKOFF: Duration = Duration::from_millis(50);
13const REQUEST_REFRESH_MAX_BACKOFF: Duration = Duration::from_millis(200);
14
15type FetchSnapshotFn<TSnapshot> =
16    Arc<dyn Fn() -> BoxFuture<'static, Result<TSnapshot>> + Send + Sync>;
17type ApplySnapshotFn<TSnapshot, TPublication> =
18    Arc<dyn Fn(TSnapshot, Vec<TPublication>) + Send + Sync>;
19type ApplyLiveFn<TPublication> = Arc<dyn Fn(Vec<TPublication>) + Send + Sync>;
20type ReadPublicationFn<TPublication> = Arc<dyn Fn(TPublication) -> Vec<TPublication> + Send + Sync>;
21type DecodeFn<TPublication> = Arc<dyn Fn(&[u8]) -> Result<TPublication> + Send + Sync>;
22type NotifyFn = Arc<dyn Fn() + Send + Sync>;
23/// Callback invoked when the coordinator observes a transport, decode, snapshot,
24/// or terminal buffer error.
25pub type SnapshotErrorFn = Arc<dyn Fn(Error) + Send + Sync>;
26
27/// Configuration for [`SnapshotThenStream`].
28pub struct SnapshotThenStreamConfig<TSnapshot, TPublication> {
29    pub client: Client,
30    pub channel: String,
31    pub decode: DecodeFn<TPublication>,
32    pub fetch_snapshot: FetchSnapshotFn<TSnapshot>,
33    pub read_publication: ReadPublicationFn<TPublication>,
34    pub apply_snapshot: ApplySnapshotFn<TSnapshot, TPublication>,
35    pub apply_live_publications: ApplyLiveFn<TPublication>,
36    pub max_buffered: usize,
37    pub on_reconnect: Option<NotifyFn>,
38    pub on_snapshot_refresh: Option<NotifyFn>,
39    pub on_error: Option<SnapshotErrorFn>,
40}
41
42/// Coordinates REST snapshot hydration with a live protobuf channel.
43pub struct SnapshotThenStream<TSnapshot, TPublication> {
44    inner: Arc<Inner<TSnapshot, TPublication>>,
45    counts_handle: bool,
46}
47
48struct Inner<TSnapshot, TPublication> {
49    client: Client,
50    channel: String,
51    decode: DecodeFn<TPublication>,
52    fetch_snapshot: FetchSnapshotFn<TSnapshot>,
53    read_publication: ReadPublicationFn<TPublication>,
54    apply_snapshot: ApplySnapshotFn<TSnapshot, TPublication>,
55    apply_live_publications: ApplyLiveFn<TPublication>,
56    max_buffered: usize,
57    on_reconnect: Option<NotifyFn>,
58    on_snapshot_refresh: Option<NotifyFn>,
59    on_error: Mutex<Option<SnapshotErrorFn>>,
60    ready: AtomicBool,
61    disposed: AtomicBool,
62    generation: AtomicU64,
63    publications: Mutex<PublicationState<TPublication>>,
64    refresh_gate: AsyncMutex<()>,
65    refresh_worker_running: AtomicBool,
66    refresh_requested: AtomicBool,
67    last_error: Mutex<Option<Error>>,
68    stop_tx: watch::Sender<bool>,
69    connection_tx: watch::Sender<Option<Result<()>>>,
70    started: AtomicBool,
71    connected_once: AtomicBool,
72    handles: AtomicUsize,
73}
74
75enum PublicationState<TPublication> {
76    Buffering(Vec<TPublication>),
77    Ready,
78}
79
80impl<TSnapshot, TPublication> SnapshotThenStream<TSnapshot, TPublication>
81where
82    TSnapshot: Send + 'static,
83    TPublication: Send + 'static,
84{
85    pub fn new(cfg: SnapshotThenStreamConfig<TSnapshot, TPublication>) -> Self {
86        let max_buffered = if cfg.max_buffered == 0 {
87            200
88        } else {
89            cfg.max_buffered
90        };
91        let (stop_tx, _) = watch::channel(false);
92        let (connection_tx, _) = watch::channel(None);
93        Self {
94            inner: Arc::new(Inner {
95                client: cfg.client,
96                channel: cfg.channel,
97                decode: cfg.decode,
98                fetch_snapshot: cfg.fetch_snapshot,
99                read_publication: cfg.read_publication,
100                apply_snapshot: cfg.apply_snapshot,
101                apply_live_publications: cfg.apply_live_publications,
102                max_buffered,
103                on_reconnect: cfg.on_reconnect,
104                on_snapshot_refresh: cfg.on_snapshot_refresh,
105                on_error: Mutex::new(cfg.on_error),
106                ready: AtomicBool::new(false),
107                disposed: AtomicBool::new(false),
108                generation: AtomicU64::new(0),
109                publications: Mutex::new(PublicationState::Buffering(Vec::new())),
110                refresh_gate: AsyncMutex::new(()),
111                refresh_worker_running: AtomicBool::new(false),
112                refresh_requested: AtomicBool::new(false),
113                last_error: Mutex::new(None),
114                stop_tx,
115                connection_tx,
116                started: AtomicBool::new(false),
117                connected_once: AtomicBool::new(false),
118                handles: AtomicUsize::new(1),
119            }),
120            counts_handle: true,
121        }
122    }
123
124    /// Begin websocket streaming and perform the initial snapshot refresh.
125    pub async fn start(&self) -> Result<()> {
126        let timeout = self.inner.client.request_timeout();
127        match tokio::time::timeout(timeout, self.start_within_deadline()).await {
128            Ok(result) => result,
129            Err(_) => {
130                let err = Error::realtime(format!(
131                    "snapshot-then-stream startup timed out after {timeout:?}"
132                ));
133                self.inner.fail_closed(err.clone());
134                Err(err)
135            }
136        }
137    }
138
139    async fn start_within_deadline(&self) -> Result<()> {
140        if !self.inner.started.swap(true, Ordering::SeqCst) {
141            let inner = self.inner.clone();
142            tokio::spawn(async move {
143                inner.run().await;
144            });
145        }
146        let mut connection_rx = self.inner.connection_tx.subscribe();
147        loop {
148            if self.inner.disposed.load(Ordering::SeqCst) {
149                return match self.err() {
150                    Some(err) => Err(err),
151                    None => Ok(()),
152                };
153            }
154            // A connection can complete its handshake and close before this
155            // receiver observes the transient watch value. This latch preserves
156            // that first successful generation.
157            if self.inner.connected_once.load(Ordering::SeqCst) {
158                break;
159            }
160            let status = connection_rx.borrow().clone();
161            if let Some(result) = status {
162                result?;
163                break;
164            }
165            if connection_rx.changed().await.is_err() {
166                return Ok(());
167            }
168        }
169        self.refresh_snapshot().await
170    }
171
172    /// Fetch a REST snapshot and merge buffered publications.
173    ///
174    /// On failure, readiness stays false, [`Self::err`] is set, and the pending
175    /// buffer is retained so a successful retry merges each buffered publication
176    /// exactly once. Success clears `err`.
177    pub async fn refresh_snapshot(&self) -> Result<()> {
178        let _refresh_guard = self.inner.refresh_gate.lock().await;
179        self.inner.refresh_snapshot_once().await
180    }
181
182    /// Request a snapshot refresh from a sync context (e.g. sequence gap handler).
183    ///
184    /// Requests are coalesced behind one worker. A request arriving during a
185    /// fetch schedules a follow-up, while repeated failures or persistent gaps
186    /// fail closed after a bounded number of attempts.
187    pub fn request_refresh(&self) {
188        self.inner.request_refresh();
189    }
190
191    pub fn is_ready(&self) -> bool {
192        self.inner.ready.load(Ordering::SeqCst)
193    }
194
195    pub fn is_disposed(&self) -> bool {
196        self.inner.disposed.load(Ordering::SeqCst)
197    }
198
199    /// Terminal stream error, if recovery failed closed.
200    pub fn err(&self) -> Option<Error> {
201        lock_unpoisoned(&self.inner.last_error).clone()
202    }
203
204    /// Register a callback for transport, decode, snapshot, and terminal
205    /// buffering errors.
206    ///
207    /// If an error was already recorded, the callback is invoked immediately.
208    /// Callback panics are isolated from the stream worker.
209    pub fn set_on_error<F>(&self, callback: F)
210    where
211        F: Fn(Error) + Send + Sync + 'static,
212    {
213        let callback: SnapshotErrorFn = Arc::new(callback);
214        let current = {
215            *lock_unpoisoned(&self.inner.on_error) = Some(callback.clone());
216            lock_unpoisoned(&self.inner.last_error).clone()
217        };
218        if let Some(err) = current {
219            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(err)));
220        }
221    }
222
223    /// Stop the stream.
224    pub fn close(&self) {
225        if self.inner.disposed.swap(true, Ordering::SeqCst) {
226            return;
227        }
228        self.inner.generation.fetch_add(1, Ordering::SeqCst);
229        {
230            let mut publications = lock_unpoisoned(&self.inner.publications);
231            *publications = PublicationState::Buffering(Vec::new());
232        }
233        self.inner.ready.store(false, Ordering::SeqCst);
234        self.inner.connection_tx.send_replace(Some(Ok(())));
235        let _ = self.inner.stop_tx.send(true);
236    }
237
238    /// Terminate the managed stream with an observable error.
239    pub(crate) fn fail(&self, err: Error) {
240        self.inner.fail_closed(err);
241    }
242}
243
244impl<TSnapshot, TPublication> Inner<TSnapshot, TPublication>
245where
246    TSnapshot: Send + 'static,
247    TPublication: Send + 'static,
248{
249    async fn refresh_snapshot_once(&self) -> Result<()> {
250        if self.disposed.load(Ordering::SeqCst) {
251            return match lock_unpoisoned(&self.last_error).clone() {
252                Some(err) => Err(err),
253                None => Ok(()),
254            };
255        }
256        let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
257        self.begin_buffering();
258        // Do not clear pending here: publications buffered during a failed fetch
259        // must survive for the next successful refresh.
260
261        let mut stop_rx = self.stop_tx.subscribe();
262        let snapshot = match tokio::select! {
263            _ = stop_rx.changed() => return Ok(()),
264            snapshot = (self.fetch_snapshot)() => snapshot,
265        } {
266            Ok(snapshot) => snapshot,
267            Err(err) => {
268                self.record_error(err.clone());
269                return Err(err);
270            }
271        };
272
273        if self.disposed.load(Ordering::SeqCst)
274            || self.generation.load(Ordering::SeqCst) != generation
275        {
276            return Ok(());
277        }
278        let Some(buffered) = self.take_buffered_if_current(generation) else {
279            return Ok(());
280        };
281        if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
282            (self.apply_snapshot)(snapshot, buffered)
283        }))
284        .is_err()
285        {
286            let err = Error::realtime("apply_snapshot callback panicked".to_owned());
287            self.fail_closed(err.clone());
288            return Err(err);
289        }
290
291        // Publications can arrive after the initial take and while the user
292        // snapshot callback runs. Drain those batches before atomically changing
293        // Buffering -> Ready under the same lock used by handle_publication.
294        loop {
295            let Some(buffered) = self.take_or_mark_ready(generation) else {
296                return Ok(());
297            };
298            if buffered.is_empty() {
299                break;
300            }
301            if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
302                (self.apply_live_publications)(buffered)
303            }))
304            .is_err()
305            {
306                let err = Error::realtime("apply_live_publications callback panicked".to_owned());
307                self.fail_closed(err.clone());
308                return Err(err);
309            }
310        }
311
312        self.clear_error();
313        if let Some(cb) = &self.on_snapshot_refresh {
314            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cb()));
315        }
316        Ok(())
317    }
318
319    fn request_refresh(self: &Arc<Self>) {
320        if self.disposed.load(Ordering::SeqCst) {
321            return;
322        }
323        self.refresh_requested.store(true, Ordering::SeqCst);
324        self.generation.fetch_add(1, Ordering::SeqCst);
325        self.begin_buffering();
326        if self
327            .refresh_worker_running
328            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
329            .is_err()
330        {
331            return;
332        }
333        let inner = self.clone();
334        tokio::spawn(async move {
335            inner.run_requested_refreshes().await;
336        });
337    }
338
339    async fn run_requested_refreshes(self: Arc<Self>) {
340        let mut attempts = 0usize;
341        loop {
342            self.refresh_requested.store(false, Ordering::SeqCst);
343            attempts += 1;
344            let last_error = {
345                let _refresh_guard = self.refresh_gate.lock().await;
346                self.refresh_snapshot_once().await
347            }
348            .err();
349
350            if self.disposed.load(Ordering::SeqCst) {
351                return;
352            }
353            let follow_up = self.refresh_requested.load(Ordering::SeqCst);
354            if last_error.is_none() && !follow_up {
355                self.refresh_worker_running.store(false, Ordering::SeqCst);
356                // Close the handoff race with a request that observed the worker
357                // as running immediately before the store above.
358                if !self.refresh_requested.load(Ordering::SeqCst)
359                    || self
360                        .refresh_worker_running
361                        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
362                        .is_err()
363                {
364                    return;
365                }
366                attempts = 0;
367                continue;
368            }
369            if attempts >= MAX_REQUEST_REFRESH_ATTEMPTS {
370                if last_error.is_some() {
371                    self.stop_after_recorded_error();
372                } else {
373                    self.fail_closed(Error::realtime(
374                        "snapshot refresh did not converge after repeated publication gaps"
375                            .to_owned(),
376                    ));
377                }
378                return;
379            }
380
381            let multiplier = 1u32 << (attempts - 1).min(16);
382            let delay = REQUEST_REFRESH_INITIAL_BACKOFF
383                .saturating_mul(multiplier)
384                .min(REQUEST_REFRESH_MAX_BACKOFF);
385            let mut stop_rx = self.stop_tx.subscribe();
386            let stopped = tokio::select! {
387                changed = stop_rx.changed() => changed.is_err() || *stop_rx.borrow(),
388                _ = tokio::time::sleep(delay) => false,
389            };
390            if stopped {
391                return;
392            }
393        }
394    }
395
396    fn begin_buffering(&self) {
397        let mut publications = lock_unpoisoned(&self.publications);
398        if matches!(*publications, PublicationState::Ready) {
399            *publications = PublicationState::Buffering(Vec::new());
400        }
401        self.ready.store(false, Ordering::SeqCst);
402    }
403
404    fn take_buffered_if_current(&self, generation: u64) -> Option<Vec<TPublication>> {
405        let mut publications = lock_unpoisoned(&self.publications);
406        if self.disposed.load(Ordering::SeqCst)
407            || self.generation.load(Ordering::SeqCst) != generation
408        {
409            return None;
410        }
411        match &mut *publications {
412            PublicationState::Buffering(pending) => Some(std::mem::take(pending)),
413            PublicationState::Ready => None,
414        }
415    }
416
417    fn take_or_mark_ready(&self, generation: u64) -> Option<Vec<TPublication>> {
418        let mut publications = lock_unpoisoned(&self.publications);
419        if self.disposed.load(Ordering::SeqCst)
420            || self.generation.load(Ordering::SeqCst) != generation
421        {
422            return None;
423        }
424        match &mut *publications {
425            PublicationState::Buffering(pending) if pending.is_empty() => {
426                *publications = PublicationState::Ready;
427                self.ready.store(true, Ordering::SeqCst);
428                Some(Vec::new())
429            }
430            PublicationState::Buffering(pending) => Some(std::mem::take(pending)),
431            PublicationState::Ready => Some(Vec::new()),
432        }
433    }
434
435    fn clear_publications(&self) {
436        let mut publications = lock_unpoisoned(&self.publications);
437        *publications = PublicationState::Buffering(Vec::new());
438    }
439}
440
441impl<TSnapshot, TPublication> Drop for SnapshotThenStream<TSnapshot, TPublication> {
442    fn drop(&mut self) {
443        if !self.counts_handle {
444            return;
445        }
446        // Background tasks also hold Arc references, so Arc::strong_count cannot
447        // identify the last public handle. Track public clones explicitly.
448        if self.inner.handles.fetch_sub(1, Ordering::SeqCst) != 1 {
449            return;
450        }
451        if self.inner.disposed.swap(true, Ordering::SeqCst) {
452            return;
453        }
454        self.inner.generation.fetch_add(1, Ordering::SeqCst);
455        {
456            let mut publications = lock_unpoisoned(&self.inner.publications);
457            *publications = PublicationState::Buffering(Vec::new());
458        }
459        self.inner.ready.store(false, Ordering::SeqCst);
460        self.inner.connection_tx.send_replace(Some(Ok(())));
461        let _ = self.inner.stop_tx.send(true);
462    }
463}
464
465impl<TSnapshot, TPublication> Clone for SnapshotThenStream<TSnapshot, TPublication> {
466    fn clone(&self) -> Self {
467        self.inner.handles.fetch_add(1, Ordering::SeqCst);
468        Self {
469            inner: self.inner.clone(),
470            counts_handle: true,
471        }
472    }
473}
474
475impl<TSnapshot, TPublication> Inner<TSnapshot, TPublication>
476where
477    TSnapshot: Send + 'static,
478    TPublication: Send + 'static,
479{
480    fn fail_closed(&self, err: Error) {
481        self.ready.store(false, Ordering::SeqCst);
482        self.disposed.store(true, Ordering::SeqCst);
483        self.generation.fetch_add(1, Ordering::SeqCst);
484        self.clear_publications();
485        self.record_error(err.clone());
486        self.connection_tx.send_replace(Some(Err(err)));
487        let _ = self.stop_tx.send(true);
488    }
489
490    fn stop_after_recorded_error(&self) {
491        self.ready.store(false, Ordering::SeqCst);
492        self.disposed.store(true, Ordering::SeqCst);
493        self.generation.fetch_add(1, Ordering::SeqCst);
494        self.clear_publications();
495        let _ = self.stop_tx.send(true);
496    }
497
498    async fn run(self: Arc<Self>) {
499        let mut stop_rx = self.stop_tx.subscribe();
500        let mut first = true;
501        let mut backoff = ReconnectBackoff::new();
502        loop {
503            if self.disposed.load(Ordering::SeqCst) || *stop_rx.borrow() {
504                break;
505            }
506            let decode = self.decode.clone();
507            let sub = tokio::select! {
508                changed = stop_rx.changed() => {
509                    if changed.is_err() || *stop_rx.borrow() {
510                        break;
511                    }
512                    continue;
513                }
514                sub = self.client.subscribe_proto_with_options(
515                    &self.channel,
516                    move |bytes| decode(bytes),
517                    false,
518                ) => {
519                    match sub {
520                        Ok(sub) => {
521                            backoff.reset();
522                            self.connected_once.store(true, Ordering::SeqCst);
523                            self.connection_tx.send_replace(Some(Ok(())));
524                            sub
525                        }
526                        Err(err) => {
527                            self.connection_tx.send_replace(Some(Err(err)));
528                            if self.disposed.load(Ordering::SeqCst) {
529                                break;
530                            }
531                            let delay = backoff.next_delay();
532                            let stopped = tokio::select! {
533                                changed = stop_rx.changed() => {
534                                    changed.is_err() || *stop_rx.borrow()
535                                }
536                                _ = tokio::time::sleep(delay) => false,
537                            };
538                            if stopped {
539                                break;
540                            }
541                            self.connection_tx.send_replace(None);
542                            continue;
543                        }
544                    }
545                }
546            };
547            if self.disposed.load(Ordering::SeqCst) || *stop_rx.borrow() {
548                sub.close();
549                break;
550            }
551            if !first {
552                if let Some(cb) = &self.on_reconnect {
553                    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cb()));
554                }
555                let this = SnapshotThenStream {
556                    inner: self.clone(),
557                    counts_handle: false,
558                };
559                // One bounded retry, then fail-closed with err() set.
560                let mut refresh = this.refresh_snapshot().await;
561                if refresh.is_err() {
562                    refresh = this.refresh_snapshot().await;
563                }
564                if refresh.is_err() {
565                    // refresh_snapshot already preserved and reported the
566                    // underlying error. Stop without invoking the callback a
567                    // second time for the same terminal attempt.
568                    self.stop_after_recorded_error();
569                    self.ready.store(false, Ordering::SeqCst);
570                    sub.close();
571                    break;
572                }
573            }
574            first = false;
575            if let Err(err) = self.pump_subscription(sub, &mut stop_rx).await
576                && !self.disposed.load(Ordering::SeqCst)
577            {
578                self.record_error(err);
579            }
580            self.connection_tx.send_replace(None);
581            if self.disposed.load(Ordering::SeqCst) || *stop_rx.borrow() {
582                break;
583            }
584            let delay = backoff.next_delay();
585            let stopped = tokio::select! {
586                changed = stop_rx.changed() => changed.is_err() || *stop_rx.borrow(),
587                _ = tokio::time::sleep(delay) => false,
588            };
589            if stopped {
590                break;
591            }
592        }
593    }
594
595    async fn pump_subscription(
596        &self,
597        mut sub: TypedSubscription<TPublication>,
598        stop_rx: &mut watch::Receiver<bool>,
599    ) -> Result<()> {
600        loop {
601            tokio::select! {
602                changed = stop_rx.changed() => {
603                    if changed.is_err() || *stop_rx.borrow() {
604                        sub.close();
605                        return Ok(());
606                    }
607                }
608                item = sub.recv_result() => {
609                    match item {
610                        Ok(Some(msg)) => self.handle_publication(msg)?,
611                        Ok(None) => return Ok(()),
612                        Err(err) => return Err(err),
613                    }
614                }
615            }
616        }
617    }
618
619    fn handle_publication(&self, msg: TPublication) -> Result<()> {
620        if self.disposed.load(Ordering::SeqCst) {
621            return Ok(());
622        }
623        let items = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
624            (self.read_publication)(msg)
625        })) {
626            Ok(items) => items,
627            Err(_) => {
628                let err = Error::realtime("read_publication callback panicked".to_owned());
629                self.fail_closed(err.clone());
630                return Err(err);
631            }
632        };
633        if items.is_empty() {
634            return Ok(());
635        }
636        let live_items = {
637            let mut publications = lock_unpoisoned(&self.publications);
638            match &mut *publications {
639                PublicationState::Buffering(pending) => {
640                    pending.extend(items);
641                    if pending.len() > self.max_buffered {
642                        pending.clear();
643                        None
644                    } else {
645                        return Ok(());
646                    }
647                }
648                PublicationState::Ready => Some(items),
649            }
650        };
651        let Some(items) = live_items else {
652            self.fail_closed(Error::queue_overflow(
653                "snapshot recovery buffer full; recreate the subscription",
654            ));
655            return Ok(());
656        };
657        if self.disposed.load(Ordering::SeqCst) {
658            return Ok(());
659        }
660        if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
661            (self.apply_live_publications)(items)
662        }))
663        .is_err()
664        {
665            let err = Error::realtime("apply_live_publications callback panicked".to_owned());
666            self.fail_closed(err.clone());
667            return Err(err);
668        }
669        Ok(())
670    }
671
672    fn record_error(&self, err: Error) {
673        let callback = {
674            *lock_unpoisoned(&self.last_error) = Some(err.clone());
675            lock_unpoisoned(&self.on_error).clone()
676        };
677        if let Some(callback) = callback {
678            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(err)));
679        }
680    }
681
682    fn clear_error(&self) {
683        *lock_unpoisoned(&self.last_error) = None;
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use futures_util::future::FutureExt;
691    use std::sync::Barrier;
692    use std::sync::atomic::AtomicUsize;
693
694    #[tokio::test]
695    async fn refresh_snapshot_fires_on_snapshot_refresh() {
696        let fired = Arc::new(AtomicUsize::new(0));
697        let fired_cb = fired.clone();
698        let client = Client::new(
699            "wss://example.invalid",
700            "https://example.invalid",
701            None,
702            None,
703        );
704        let sts = SnapshotThenStream::new(SnapshotThenStreamConfig {
705            client,
706            channel: "public:test".into(),
707            decode: Arc::new(|_b| Ok(1u8)),
708            fetch_snapshot: Arc::new(|| async { Ok("snap".to_string()) }.boxed()),
709            read_publication: Arc::new(|p| vec![p]),
710            apply_snapshot: Arc::new(|_s, _p| {}),
711            apply_live_publications: Arc::new(|_p| {}),
712            max_buffered: 8,
713            on_reconnect: None,
714            on_snapshot_refresh: Some(Arc::new(move || {
715                fired_cb.fetch_add(1, Ordering::SeqCst);
716            })),
717            on_error: None,
718        });
719        sts.refresh_snapshot().await.expect("refresh");
720        assert_eq!(fired.load(Ordering::SeqCst), 1);
721        assert!(sts.is_ready());
722    }
723
724    #[tokio::test]
725    async fn failed_refresh_retains_buffer_and_success_merges_each_item_once() {
726        let attempts = Arc::new(AtomicUsize::new(0));
727        let fetch_attempts = attempts.clone();
728        let merged = Arc::new(Mutex::new(Vec::<u8>::new()));
729        let merged_cb = merged.clone();
730        let client = Client::new(
731            "wss://example.invalid",
732            "https://example.invalid",
733            None,
734            None,
735        );
736        let sts = SnapshotThenStream::new(SnapshotThenStreamConfig {
737            client,
738            channel: "public:test".into(),
739            decode: Arc::new(|_b| Ok(1u8)),
740            fetch_snapshot: Arc::new(move || {
741                let attempt = fetch_attempts.fetch_add(1, Ordering::SeqCst);
742                async move {
743                    if attempt == 0 {
744                        Err(Error::transport("snapshot refresh failed"))
745                    } else {
746                        Ok("recovered".to_owned())
747                    }
748                }
749                .boxed()
750            }),
751            read_publication: Arc::new(|p| vec![p]),
752            apply_snapshot: Arc::new(move |_snapshot, pending| {
753                merged_cb.lock().expect("merged lock").extend(pending);
754            }),
755            apply_live_publications: Arc::new(|_publications| {}),
756            max_buffered: 8,
757            on_reconnect: None,
758            on_snapshot_refresh: None,
759            on_error: None,
760        });
761
762        assert!(sts.refresh_snapshot().await.is_err());
763        assert!(!sts.is_ready());
764        assert!(sts.err().is_some());
765
766        sts.inner.handle_publication(7).expect("buffer 7");
767        sts.inner.handle_publication(9).expect("buffer 9");
768        {
769            let publications = lock_unpoisoned(&sts.inner.publications);
770            let PublicationState::Buffering(pending) = &*publications else {
771                panic!("expected buffering state");
772            };
773            assert_eq!(pending.as_slice(), &[7, 9]);
774        }
775
776        sts.refresh_snapshot().await.expect("retry succeeds");
777        assert!(sts.is_ready());
778        assert!(sts.err().is_none());
779        assert_eq!(merged.lock().expect("merged lock").as_slice(), &[7, 9]);
780        assert!(matches!(
781            *lock_unpoisoned(&sts.inner.publications),
782            PublicationState::Ready
783        ));
784
785        sts.refresh_snapshot()
786            .await
787            .expect("later refresh succeeds");
788        assert_eq!(
789            merged.lock().expect("merged lock").as_slice(),
790            &[7, 9],
791            "buffered publications must be applied exactly once"
792        );
793    }
794
795    #[tokio::test]
796    async fn refresh_snapshot_retries_after_initial_failure() {
797        let attempts = Arc::new(AtomicUsize::new(0));
798        let fetch_attempts = attempts.clone();
799        let client = Client::new(
800            "wss://example.invalid",
801            "https://example.invalid",
802            None,
803            None,
804        );
805        let sts = SnapshotThenStream::new(SnapshotThenStreamConfig {
806            client,
807            channel: "public:test".into(),
808            decode: Arc::new(|_b| Ok(1u8)),
809            fetch_snapshot: Arc::new(move || {
810                let attempt = fetch_attempts.fetch_add(1, Ordering::SeqCst);
811                async move {
812                    if attempt == 0 {
813                        Err(crate::Error::transport("transient snapshot failure"))
814                    } else {
815                        Ok("snap".to_string())
816                    }
817                }
818                .boxed()
819            }),
820            read_publication: Arc::new(|p| vec![p]),
821            apply_snapshot: Arc::new(|_s, _p| {}),
822            apply_live_publications: Arc::new(|_p| {}),
823            max_buffered: 8,
824            on_reconnect: None,
825            on_snapshot_refresh: None,
826            on_error: None,
827        });
828
829        assert!(sts.refresh_snapshot().await.is_err());
830        assert!(sts.err().is_some());
831        assert!(!sts.is_ready());
832        sts.refresh_snapshot().await.expect("snapshot retry");
833        assert_eq!(attempts.load(Ordering::SeqCst), 2);
834        assert!(sts.is_ready());
835        assert!(sts.err().is_none());
836        sts.close();
837    }
838
839    #[tokio::test]
840    async fn refresh_snapshot_failure_retains_buffer_for_successful_retry() {
841        let attempts = Arc::new(AtomicUsize::new(0));
842        let fetch_attempts = attempts.clone();
843        let merged = Arc::new(Mutex::new(Vec::<u8>::new()));
844        let merged_cb = merged.clone();
845        let client = Client::new(
846            "wss://example.invalid",
847            "https://example.invalid",
848            None,
849            None,
850        );
851        let sts = SnapshotThenStream::new(SnapshotThenStreamConfig {
852            client,
853            channel: "public:test".into(),
854            decode: Arc::new(|_b| Ok(1u8)),
855            fetch_snapshot: Arc::new(move || {
856                let attempt = fetch_attempts.fetch_add(1, Ordering::SeqCst);
857                async move {
858                    if attempt == 0 {
859                        Err(crate::Error::transport("transient snapshot failure"))
860                    } else {
861                        Ok("snap".to_string())
862                    }
863                }
864                .boxed()
865            }),
866            read_publication: Arc::new(|p| vec![p]),
867            apply_snapshot: Arc::new(move |_s, pending| {
868                merged_cb.lock().expect("merged").extend(pending);
869            }),
870            apply_live_publications: Arc::new(|_p| {}),
871            max_buffered: 8,
872            on_reconnect: None,
873            on_snapshot_refresh: None,
874            on_error: None,
875        });
876
877        // Simulate publications arriving while not ready / during failed fetch.
878        sts.inner.handle_publication(10).expect("buffer 10");
879        sts.inner.handle_publication(11).expect("buffer 11");
880        assert!(sts.refresh_snapshot().await.is_err());
881        sts.inner.handle_publication(12).expect("buffer 12");
882        sts.refresh_snapshot().await.expect("retry");
883        let got = merged.lock().expect("merged").clone();
884        assert_eq!(
885            got,
886            vec![10, 11, 12],
887            "each buffered pub merged exactly once"
888        );
889        assert!(sts.err().is_none());
890        sts.close();
891    }
892
893    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
894    async fn publication_arriving_after_pending_take_is_drained_exactly_once() {
895        let snapshot_entered = Arc::new(Barrier::new(2));
896        let release_snapshot = Arc::new(Barrier::new(2));
897        let snapshot_calls = Arc::new(AtomicUsize::new(0));
898        let live = Arc::new(Mutex::new(Vec::<u8>::new()));
899        let client = Client::new(
900            "wss://example.invalid",
901            "https://example.invalid",
902            None,
903            None,
904        );
905        let sts = SnapshotThenStream::new(SnapshotThenStreamConfig {
906            client,
907            channel: "public:test".into(),
908            decode: Arc::new(|_b| Ok(1u8)),
909            fetch_snapshot: Arc::new(|| async { Ok("snap".to_owned()) }.boxed()),
910            read_publication: Arc::new(|p| vec![p]),
911            apply_snapshot: {
912                let entered = snapshot_entered.clone();
913                let release = release_snapshot.clone();
914                let calls = snapshot_calls.clone();
915                Arc::new(move |_snapshot, pending| {
916                    assert!(pending.is_empty());
917                    if calls.fetch_add(1, Ordering::SeqCst) == 0 {
918                        entered.wait();
919                        release.wait();
920                    }
921                })
922            },
923            apply_live_publications: {
924                let live = live.clone();
925                Arc::new(move |publications| {
926                    live.lock().expect("live lock").extend(publications);
927                })
928            },
929            max_buffered: 8,
930            on_reconnect: None,
931            on_snapshot_refresh: None,
932            on_error: None,
933        });
934
935        let refresh = {
936            let sts = sts.clone();
937            tokio::spawn(async move { sts.refresh_snapshot().await })
938        };
939        snapshot_entered.wait();
940        sts.inner
941            .handle_publication(42)
942            .expect("publication in vulnerable window");
943        release_snapshot.wait();
944        refresh.await.expect("refresh task").expect("refresh");
945
946        assert!(sts.is_ready());
947        assert_eq!(live.lock().expect("live lock").as_slice(), &[42]);
948        sts.refresh_snapshot().await.expect("second refresh");
949        assert_eq!(
950            live.lock().expect("live lock").as_slice(),
951            &[42],
952            "drained publication must not be replayed"
953        );
954    }
955
956    #[tokio::test]
957    async fn request_refresh_burst_is_single_flight_and_coalesced() {
958        let attempts = Arc::new(AtomicUsize::new(0));
959        let active = Arc::new(AtomicUsize::new(0));
960        let max_active = Arc::new(AtomicUsize::new(0));
961        let first_started = Arc::new(tokio::sync::Semaphore::new(0));
962        let release_first = Arc::new(tokio::sync::Semaphore::new(0));
963        let client = Client::new(
964            "wss://example.invalid",
965            "https://example.invalid",
966            None,
967            None,
968        );
969        let sts = SnapshotThenStream::new(SnapshotThenStreamConfig {
970            client,
971            channel: "public:test".into(),
972            decode: Arc::new(|_b| Ok(1u8)),
973            fetch_snapshot: {
974                let attempts = attempts.clone();
975                let active = active.clone();
976                let max_active = max_active.clone();
977                let first_started = first_started.clone();
978                let release_first = release_first.clone();
979                Arc::new(move || {
980                    let attempt = attempts.fetch_add(1, Ordering::SeqCst);
981                    let now_active = active.fetch_add(1, Ordering::SeqCst) + 1;
982                    max_active.fetch_max(now_active, Ordering::SeqCst);
983                    let active = active.clone();
984                    let first_started = first_started.clone();
985                    let release_first = release_first.clone();
986                    async move {
987                        if attempt == 0 {
988                            first_started.add_permits(1);
989                            release_first.acquire().await.expect("release").forget();
990                        }
991                        active.fetch_sub(1, Ordering::SeqCst);
992                        Ok("snap".to_owned())
993                    }
994                    .boxed()
995                })
996            },
997            read_publication: Arc::new(|p| vec![p]),
998            apply_snapshot: Arc::new(|_snapshot: String, _pending: Vec<u8>| {}),
999            apply_live_publications: Arc::new(|_publications| {}),
1000            max_buffered: 8,
1001            on_reconnect: None,
1002            on_snapshot_refresh: None,
1003            on_error: None,
1004        });
1005
1006        sts.request_refresh();
1007        first_started.acquire().await.expect("first fetch").forget();
1008        for _ in 0..100 {
1009            sts.request_refresh();
1010        }
1011        release_first.add_permits(1);
1012        tokio::time::timeout(Duration::from_secs(2), async {
1013            while sts.inner.refresh_worker_running.load(Ordering::SeqCst) {
1014                tokio::task::yield_now().await;
1015            }
1016        })
1017        .await
1018        .expect("refresh worker completion");
1019
1020        assert_eq!(max_active.load(Ordering::SeqCst), 1);
1021        assert_eq!(
1022            attempts.load(Ordering::SeqCst),
1023            2,
1024            "the burst should produce one in-flight fetch and one coalesced follow-up"
1025        );
1026        assert!(sts.is_ready());
1027    }
1028
1029    #[tokio::test]
1030    async fn request_refresh_persistent_failure_retries_bounded_then_fails_closed() {
1031        let attempts = Arc::new(AtomicUsize::new(0));
1032        let active = Arc::new(AtomicUsize::new(0));
1033        let max_active = Arc::new(AtomicUsize::new(0));
1034        let client = Client::new(
1035            "wss://example.invalid",
1036            "https://example.invalid",
1037            None,
1038            None,
1039        );
1040        let sts = SnapshotThenStream::new(SnapshotThenStreamConfig {
1041            client,
1042            channel: "public:test".into(),
1043            decode: Arc::new(|_b| Ok(1u8)),
1044            fetch_snapshot: {
1045                let attempts = attempts.clone();
1046                let active = active.clone();
1047                let max_active = max_active.clone();
1048                Arc::new(move || {
1049                    attempts.fetch_add(1, Ordering::SeqCst);
1050                    let now_active = active.fetch_add(1, Ordering::SeqCst) + 1;
1051                    max_active.fetch_max(now_active, Ordering::SeqCst);
1052                    let active = active.clone();
1053                    async move {
1054                        active.fetch_sub(1, Ordering::SeqCst);
1055                        Err(Error::transport("persistent snapshot failure"))
1056                    }
1057                    .boxed()
1058                })
1059            },
1060            read_publication: Arc::new(|p| vec![p]),
1061            apply_snapshot: Arc::new(|_snapshot: String, _pending: Vec<u8>| {}),
1062            apply_live_publications: Arc::new(|_publications| {}),
1063            max_buffered: 8,
1064            on_reconnect: None,
1065            on_snapshot_refresh: None,
1066            on_error: None,
1067        });
1068
1069        sts.request_refresh();
1070        tokio::time::timeout(Duration::from_secs(2), async {
1071            while !sts.is_disposed() {
1072                tokio::task::yield_now().await;
1073            }
1074        })
1075        .await
1076        .expect("bounded failure completion");
1077
1078        assert_eq!(
1079            attempts.load(Ordering::SeqCst),
1080            MAX_REQUEST_REFRESH_ATTEMPTS
1081        );
1082        assert_eq!(max_active.load(Ordering::SeqCst), 1);
1083        assert!(!sts.is_ready());
1084        assert!(matches!(sts.err(), Some(Error::Transport(_))));
1085    }
1086
1087    #[tokio::test]
1088    async fn request_refresh_persistent_gaps_fail_closed_after_bounded_followups() {
1089        let attempts = Arc::new(AtomicUsize::new(0));
1090        let stream_slot: Arc<Mutex<Option<SnapshotThenStream<String, u8>>>> =
1091            Arc::new(Mutex::new(None));
1092        let client = Client::new(
1093            "wss://example.invalid",
1094            "https://example.invalid",
1095            None,
1096            None,
1097        );
1098        let sts = SnapshotThenStream::new(SnapshotThenStreamConfig {
1099            client,
1100            channel: "public:test".into(),
1101            decode: Arc::new(|_b| Ok(1u8)),
1102            fetch_snapshot: {
1103                let attempts = attempts.clone();
1104                Arc::new(move || {
1105                    attempts.fetch_add(1, Ordering::SeqCst);
1106                    async { Ok("snap".to_owned()) }.boxed()
1107                })
1108            },
1109            read_publication: Arc::new(|p| vec![p]),
1110            apply_snapshot: {
1111                let stream_slot = stream_slot.clone();
1112                Arc::new(move |_snapshot, _pending| {
1113                    lock_unpoisoned(&stream_slot)
1114                        .as_ref()
1115                        .expect("stream installed")
1116                        .request_refresh();
1117                })
1118            },
1119            apply_live_publications: Arc::new(|_publications| {}),
1120            max_buffered: 8,
1121            on_reconnect: None,
1122            on_snapshot_refresh: None,
1123            on_error: None,
1124        });
1125        *lock_unpoisoned(&stream_slot) = Some(sts.clone());
1126
1127        sts.request_refresh();
1128        tokio::time::timeout(Duration::from_secs(2), async {
1129            while !sts.is_disposed() {
1130                tokio::task::yield_now().await;
1131            }
1132        })
1133        .await
1134        .expect("bounded persistent-gap completion");
1135
1136        assert_eq!(
1137            attempts.load(Ordering::SeqCst),
1138            MAX_REQUEST_REFRESH_ATTEMPTS
1139        );
1140        assert!(!sts.is_ready());
1141        assert!(matches!(sts.err(), Some(Error::Realtime(_))));
1142        lock_unpoisoned(&stream_slot).take();
1143    }
1144
1145    #[test]
1146    fn snapshot_buffer_overflow_fails_closed() {
1147        let client = Client::new(
1148            "wss://example.invalid",
1149            "https://example.invalid",
1150            None,
1151            None,
1152        );
1153        let sts = SnapshotThenStream::new(SnapshotThenStreamConfig {
1154            client,
1155            channel: "public:test".into(),
1156            decode: Arc::new(|_b| Ok(1u8)),
1157            fetch_snapshot: Arc::new(|| async { Ok("snap".to_string()) }.boxed()),
1158            read_publication: Arc::new(|p| vec![p]),
1159            apply_snapshot: Arc::new(|_s, _p| {}),
1160            apply_live_publications: Arc::new(|_p| {}),
1161            max_buffered: 1,
1162            on_reconnect: None,
1163            on_snapshot_refresh: None,
1164            on_error: None,
1165        });
1166
1167        sts.inner.handle_publication(1).expect("first publication");
1168        assert!(!sts.is_disposed());
1169        sts.inner
1170            .handle_publication(2)
1171            .expect("overflow is persisted on the coordinator");
1172        assert!(sts.is_disposed());
1173        assert!(matches!(sts.err(), Some(Error::QueueOverflow(_))));
1174    }
1175
1176    #[test]
1177    fn dropping_last_public_handle_disposes_background_coordinator() {
1178        let client = Client::new(
1179            "wss://example.invalid",
1180            "https://example.invalid",
1181            None,
1182            None,
1183        );
1184        let sts = SnapshotThenStream::new(SnapshotThenStreamConfig {
1185            client,
1186            channel: "public:test".into(),
1187            decode: Arc::new(|_b| Ok(1u8)),
1188            fetch_snapshot: Arc::new(|| async { Ok("snap".to_string()) }.boxed()),
1189            read_publication: Arc::new(|p| vec![p]),
1190            apply_snapshot: Arc::new(|_s, _p| {}),
1191            apply_live_publications: Arc::new(|_p| {}),
1192            max_buffered: 1,
1193            on_reconnect: None,
1194            on_snapshot_refresh: None,
1195            on_error: None,
1196        });
1197        let clone = sts.clone();
1198        let observer = sts.inner.clone();
1199        drop(sts);
1200        assert!(
1201            !observer.disposed.load(Ordering::SeqCst),
1202            "one public clone remains"
1203        );
1204        drop(clone);
1205        assert!(
1206            observer.disposed.load(Ordering::SeqCst),
1207            "last public handle must stop the coordinator even when tasks hold Arc references"
1208        );
1209    }
1210}