Skip to main content

sui_indexer_alt_framework/pipeline/concurrent/
mod.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5use std::time::Duration;
6
7use async_trait::async_trait;
8use serde::Deserialize;
9use serde::Serialize;
10use sui_futures::service::Service;
11use tokio::sync::SetOnce;
12use tokio::sync::mpsc;
13use tracing::info;
14
15use crate::Task;
16use crate::config::ConcurrencyConfig;
17use crate::ingestion::ingestion_client::CheckpointEnvelope;
18use crate::metrics::IndexerMetrics;
19use crate::pipeline::CommitterConfig;
20use crate::pipeline::IngestionConfig;
21use crate::pipeline::Processor;
22use crate::pipeline::WatermarkPart;
23use crate::pipeline::concurrent::collector::collector;
24use crate::pipeline::concurrent::commit_watermark::commit_watermark;
25use crate::pipeline::concurrent::committer::committer;
26use crate::pipeline::concurrent::main_reader_lo::track_main_reader_lo;
27use crate::pipeline::concurrent::pruner::pruner;
28use crate::pipeline::concurrent::reader_watermark::reader_watermark;
29use crate::pipeline::processor::processor;
30use crate::store::ConcurrentStore;
31use crate::store::Store;
32
33mod collector;
34mod commit_watermark;
35mod committer;
36mod main_reader_lo;
37mod pruner;
38mod reader_watermark;
39
40/// Status returned by `Handler::batch` to indicate whether the batch is ready to be committed.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum BatchStatus {
43    /// The batch can accept more values.
44    Pending,
45    /// The batch is full and should be committed.
46    Ready,
47}
48
49/// Handlers implement the logic for a given indexing pipeline: How to process checkpoint data (by
50/// implementing [Processor]) into rows for their table, and how to write those rows to the database.
51///
52/// The handler is also responsible for tuning the various parameters of the pipeline (provided as
53/// associated values). Reasonable defaults have been chosen to balance concurrency with memory
54/// usage, but each handle may choose to override these defaults, e.g.
55///
56/// - Handlers that produce many small rows may wish to increase their batch/chunk/max-pending
57///   sizes).
58/// - Handlers that do more work during processing may wish to increase their fanout so more of it
59///   can be done concurrently, to preserve throughput.
60///
61/// Concurrent handlers can only be used in concurrent pipelines, where checkpoint data is
62/// processed and committed out-of-order and a watermark table is kept up-to-date with the latest
63/// checkpoint below which all data has been committed.
64///
65/// Back-pressure is handled through the `MAX_PENDING_SIZE` constant -- if more than this many rows
66/// build up, the collector will stop accepting new checkpoints, which will eventually propagate
67/// back to the ingestion service.
68#[async_trait]
69pub trait Handler: Processor {
70    type Store: ConcurrentStore;
71    type Batch: Default + Send + Sync + 'static;
72
73    /// If there are more than this many rows pending, the committer applies backpressure.
74    const MAX_PENDING_ROWS: usize = 5000;
75
76    /// The maximum number of watermarks that can show up in a single batch.
77    /// This limit exists to deal with pipelines that produce no data for a majority of
78    /// checkpoints -- the size of these pipeline's batches will be dominated by watermark updates.
79    const MAX_WATERMARK_UPDATES: usize = 10_000;
80
81    /// Add values from the iterator to the batch. The implementation may take all, some, or none
82    /// of the values from the iterator by calling `.next()`.
83    ///
84    /// Returns `BatchStatus::Ready` if the batch is full and should be committed,
85    /// or `BatchStatus::Pending` if the batch can accept more values.
86    ///
87    /// Note: The handler can signal batch readiness via `BatchStatus::Ready`, but the framework
88    /// may also decide to commit a batch based on the trait parameters above.
89    fn batch(
90        &self,
91        batch: &mut Self::Batch,
92        values: &mut std::vec::IntoIter<Self::Value>,
93    ) -> BatchStatus;
94
95    /// Commit the batch to the database, returning the number of rows affected.
96    async fn commit<'a>(
97        &self,
98        batch: &Self::Batch,
99        conn: &mut <Self::Store as Store>::Connection<'a>,
100    ) -> anyhow::Result<usize>;
101
102    /// Clean up data between checkpoints `_from` and `_to_exclusive` (exclusive) in the database, returning
103    /// the number of rows affected. This function is optional, and defaults to not pruning at all.
104    async fn prune<'a>(
105        &self,
106        _from: u64,
107        _to_exclusive: u64,
108        _conn: &mut <Self::Store as Store>::Connection<'a>,
109    ) -> anyhow::Result<usize> {
110        Ok(0)
111    }
112}
113
114/// Configuration for a concurrent pipeline
115#[derive(Serialize, Deserialize, Debug, Clone, Default)]
116pub struct ConcurrentConfig {
117    /// Configuration for the writer, that makes forward progress.
118    pub committer: CommitterConfig,
119
120    /// Per-pipeline ingestion overrides.
121    pub ingestion: IngestionConfig,
122
123    /// Configuration for the pruner, that deletes old data.
124    pub pruner: Option<PrunerConfig>,
125
126    /// Processor concurrency. Defaults to adaptive scaling up to the number of CPUs.
127    pub fanout: Option<ConcurrencyConfig>,
128
129    /// Override for `Handler::MAX_PENDING_ROWS` (backpressure threshold).
130    pub max_pending_rows: Option<usize>,
131
132    /// Override for `Handler::MAX_WATERMARK_UPDATES` (watermarks per batch cap).
133    pub max_watermark_updates: Option<usize>,
134
135    /// Size of the channel between the processor and collector.
136    pub processor_channel_size: Option<usize>,
137
138    /// Size of the channel between the collector and committer.
139    pub collector_channel_size: Option<usize>,
140
141    /// Size of the channel between the committer and the watermark updater.
142    pub committer_channel_size: Option<usize>,
143}
144
145#[derive(Serialize, Deserialize, Debug, Clone)]
146pub struct PrunerConfig {
147    /// How often the pruner should check whether there is any data to prune, in milliseconds.
148    pub interval_ms: u64,
149
150    /// How long to wait after the reader low watermark was set, until it is safe to prune up until
151    /// this new watermark, in milliseconds.
152    pub delay_ms: u64,
153
154    /// How much data to keep, this is measured in checkpoints.
155    pub retention: u64,
156
157    /// The maximum range to try and prune in one request, measured in checkpoints.
158    pub max_chunk_size: u64,
159
160    /// The max number of tasks to run in parallel for pruning.
161    pub prune_concurrency: u64,
162}
163
164/// Values ready to be written to the database. This is an internal type used to communicate
165/// between the collector and the committer parts of the pipeline.
166///
167/// Values inside each batch may or may not be from the same checkpoint. Values in the same
168/// checkpoint can also be split across multiple batches.
169struct BatchedRows<H: Handler> {
170    /// The batch to write
171    batch: H::Batch,
172    /// Number of rows in the batch
173    batch_len: usize,
174    /// Proportions of all the watermarks that are represented in this chunk
175    watermark: Vec<WatermarkPart>,
176}
177
178impl<H, V> BatchedRows<H>
179where
180    H: Handler<Batch = Vec<V>, Value = V>,
181{
182    #[cfg(test)]
183    pub fn from_vec(batch: Vec<V>, watermark: Vec<WatermarkPart>) -> Self {
184        let batch_len = batch.len();
185        Self {
186            batch,
187            batch_len,
188            watermark,
189        }
190    }
191}
192
193impl PrunerConfig {
194    pub fn interval(&self) -> Duration {
195        Duration::from_millis(self.interval_ms)
196    }
197
198    pub fn delay(&self) -> Duration {
199        Duration::from_millis(self.delay_ms)
200    }
201}
202
203impl Default for PrunerConfig {
204    fn default() -> Self {
205        Self {
206            interval_ms: 300_000,
207            delay_ms: 120_000,
208            retention: 4_000_000,
209            max_chunk_size: 2_000,
210            prune_concurrency: 1,
211        }
212    }
213}
214
215/// Start a new concurrent (out-of-order) indexing pipeline served by the handler, `H`. Starting
216/// strictly after the `watermark` (or from the beginning if no watermark was provided).
217///
218/// Each pipeline consists of a processor task which takes checkpoint data and breaks it down into
219/// rows, ready for insertion, a collector which batches those rows into an appropriate size for
220/// the database, a committer which writes the rows out concurrently, and a watermark task to
221/// update the high watermark.
222///
223/// Committing is performed out-of-order: the pipeline may write out checkpoints out-of-order,
224/// either because it received the checkpoints out-of-order or because of variance in processing
225/// time.
226///
227/// The pipeline also maintains a row in the `watermarks` table for the pipeline which tracks the
228/// watermark below which all data has been committed (modulo pruning).
229///
230/// Checkpoint data is fed into the pipeline through the `checkpoint_rx` channel, and internal
231/// channels are created to communicate between its various components. The pipeline will shutdown
232/// if any of its input or output channels close, any of its independent tasks fail, or if it is
233/// signalled to shutdown through the returned service handle.
234pub(crate) fn pipeline<H: Handler>(
235    handler: H,
236    next_checkpoint: u64,
237    config: ConcurrentConfig,
238    store: H::Store,
239    task: Option<Task>,
240    checkpoint_rx: mpsc::Receiver<Arc<CheckpointEnvelope>>,
241    metrics: Arc<IndexerMetrics>,
242) -> Service {
243    info!(
244        pipeline = H::NAME,
245        "Starting pipeline with config: {config:#?}",
246    );
247
248    let ConcurrentConfig {
249        committer: committer_config,
250        ingestion: _,
251        pruner: pruner_config,
252        fanout,
253        max_pending_rows,
254        max_watermark_updates,
255        processor_channel_size,
256        collector_channel_size,
257        committer_channel_size,
258    } = config;
259
260    let concurrency = fanout.unwrap_or(ConcurrencyConfig::Adaptive {
261        initial: 1,
262        min: 1,
263        max: num_cpus::get().max(1),
264        dead_band: None,
265    });
266    let max_pending_rows = max_pending_rows.unwrap_or(H::MAX_PENDING_ROWS);
267    let max_watermark_updates = max_watermark_updates
268        .unwrap_or(H::MAX_WATERMARK_UPDATES)
269        .max(1);
270
271    let processor_channel_size = processor_channel_size
272        .unwrap_or_else(|| num_cpus::get() / 2)
273        .max(1);
274    let (processor_tx, collector_rx) = mpsc::channel(processor_channel_size);
275
276    let collector_channel_size = collector_channel_size
277        .unwrap_or_else(|| num_cpus::get() / 2)
278        .max(1);
279    //docs::#buff (see https://docs.sui.io/develop/accessing-data/custom-indexer/custom-indexers)
280    let (collector_tx, committer_rx) = mpsc::channel(collector_channel_size);
281    //docs::/#buff
282    let committer_channel_size = committer_channel_size.unwrap_or_else(num_cpus::get).max(1);
283    let (committer_tx, watermark_rx) = mpsc::channel(committer_channel_size);
284    let main_reader_lo = Arc::new(SetOnce::new());
285
286    let handler = Arc::new(handler);
287
288    let s_processor = processor(
289        handler.clone(),
290        checkpoint_rx,
291        processor_tx,
292        metrics.clone(),
293        concurrency,
294        store.clone(),
295    );
296
297    let s_collector = collector::<H>(
298        handler.clone(),
299        committer_config.clone(),
300        collector_rx,
301        collector_tx,
302        main_reader_lo.clone(),
303        metrics.clone(),
304        max_pending_rows,
305        max_watermark_updates,
306    );
307
308    let s_committer = committer::<H>(
309        handler.clone(),
310        committer_config.clone(),
311        committer_rx,
312        committer_tx,
313        store.clone(),
314        metrics.clone(),
315    );
316
317    let s_commit_watermark = commit_watermark::<H>(
318        next_checkpoint,
319        committer_config,
320        watermark_rx,
321        store.clone(),
322        task.as_ref().map(|t| t.task.clone()),
323        metrics.clone(),
324    );
325
326    let s_track_reader_lo = track_main_reader_lo::<H>(
327        main_reader_lo.clone(),
328        task.as_ref().map(|t| t.reader_interval),
329        store.clone(),
330    );
331
332    let s_reader_watermark =
333        reader_watermark::<H>(pruner_config.clone(), store.clone(), metrics.clone());
334
335    let s_pruner = pruner(handler, pruner_config, store, metrics);
336
337    s_processor
338        .merge(s_collector)
339        .merge(s_committer)
340        .merge(s_commit_watermark)
341        .attach(s_track_reader_lo)
342        .attach(s_reader_watermark)
343        .attach(s_pruner)
344}
345
346#[cfg(test)]
347mod tests {
348    use std::sync::Arc;
349    use std::time::Duration;
350
351    use prometheus::Registry;
352    use sui_types::digests::CheckpointDigest;
353    use tokio::sync::mpsc;
354    use tokio::time::timeout;
355
356    use crate::FieldCount;
357    use crate::metrics::IndexerMetrics;
358    use crate::mocks::store::FallibleMockConnection;
359    use crate::mocks::store::FallibleMockStore;
360    use crate::pipeline::Processor;
361    use crate::types::full_checkpoint_content::Checkpoint;
362    use crate::types::test_checkpoint_data_builder::TestCheckpointBuilder;
363
364    use super::*;
365
366    const TEST_TIMEOUT: Duration = Duration::from_secs(60);
367    const TEST_SUBSCRIBER_CHANNEL_SIZE: usize = 3; // Critical for back-pressure testing calculations
368
369    #[derive(Clone, Debug, FieldCount)]
370    struct TestValue {
371        checkpoint: u64,
372        data: u64,
373    }
374
375    struct DataPipeline;
376
377    #[async_trait]
378    impl Processor for DataPipeline {
379        const NAME: &'static str = "test_handler";
380        type Value = TestValue;
381
382        async fn process(&self, checkpoint: &Arc<Checkpoint>) -> anyhow::Result<Vec<Self::Value>> {
383            let cp_num = checkpoint.summary.sequence_number;
384
385            // Every checkpoint will come with 2 processed values
386            Ok(vec![
387                TestValue {
388                    checkpoint: cp_num,
389                    data: cp_num * 10 + 1,
390                },
391                TestValue {
392                    checkpoint: cp_num,
393                    data: cp_num * 10 + 2,
394                },
395            ])
396        }
397    }
398
399    #[async_trait]
400    impl Handler for DataPipeline {
401        type Store = FallibleMockStore;
402        type Batch = Vec<TestValue>;
403
404        const MAX_PENDING_ROWS: usize = 4; // Small value to trigger back pressure quickly
405        const MAX_WATERMARK_UPDATES: usize = 1; // Each batch will have 1 checkpoint for an ease of testing.
406
407        fn batch(
408            &self,
409            batch: &mut Self::Batch,
410            values: &mut std::vec::IntoIter<Self::Value>,
411        ) -> BatchStatus {
412            // Take all values
413            batch.extend(values);
414            BatchStatus::Pending
415        }
416
417        async fn commit<'a>(
418            &self,
419            batch: &Self::Batch,
420            conn: &mut FallibleMockConnection<'a>,
421        ) -> anyhow::Result<usize> {
422            // Group values by checkpoint
423            let mut grouped: std::collections::HashMap<u64, Vec<u64>> =
424                std::collections::HashMap::new();
425            for value in batch {
426                grouped
427                    .entry(value.checkpoint)
428                    .or_default()
429                    .push(value.data);
430            }
431
432            // Commit all data at once
433            conn.0.commit_bulk_data(DataPipeline::NAME, grouped).await
434        }
435
436        async fn prune<'a>(
437            &self,
438            from: u64,
439            to_exclusive: u64,
440            conn: &mut FallibleMockConnection<'a>,
441        ) -> anyhow::Result<usize> {
442            conn.0.prune_data(DataPipeline::NAME, from, to_exclusive)
443        }
444    }
445
446    struct TestSetup {
447        store: FallibleMockStore,
448        checkpoint_tx: mpsc::Sender<Arc<CheckpointEnvelope>>,
449        #[allow(unused)]
450        pipeline: Service,
451    }
452
453    impl TestSetup {
454        async fn new(
455            config: ConcurrentConfig,
456            store: FallibleMockStore,
457            next_checkpoint: u64,
458        ) -> Self {
459            let (checkpoint_tx, checkpoint_rx) = mpsc::channel(TEST_SUBSCRIBER_CHANNEL_SIZE);
460            let metrics = IndexerMetrics::new(None, &Registry::default());
461
462            let pipeline = pipeline(
463                DataPipeline,
464                next_checkpoint,
465                config,
466                store.clone(),
467                None,
468                checkpoint_rx,
469                metrics,
470            );
471
472            Self {
473                store,
474                checkpoint_tx,
475                pipeline,
476            }
477        }
478
479        async fn send_checkpoint(&self, checkpoint: u64) -> anyhow::Result<()> {
480            let checkpoint_envelope = Arc::new(CheckpointEnvelope {
481                checkpoint: Arc::new(
482                    TestCheckpointBuilder::new(checkpoint)
483                        .with_epoch(1)
484                        .with_network_total_transactions(checkpoint * 2)
485                        .with_timestamp_ms(1000000000 + checkpoint * 1000)
486                        .build_checkpoint(),
487                ),
488                chain_id: CheckpointDigest::new([1; 32]).into(),
489            });
490            self.checkpoint_tx.send(checkpoint_envelope).await?;
491            Ok(())
492        }
493
494        async fn send_checkpoint_with_timeout(
495            &self,
496            checkpoint: u64,
497            timeout_duration: Duration,
498        ) -> anyhow::Result<()> {
499            timeout(timeout_duration, self.send_checkpoint(checkpoint)).await?
500        }
501
502        async fn send_checkpoint_expect_timeout(
503            &self,
504            checkpoint: u64,
505            timeout_duration: Duration,
506        ) {
507            timeout(timeout_duration, self.send_checkpoint(checkpoint))
508                .await
509                .unwrap_err(); // Panics if send succeeds
510        }
511    }
512
513    #[tokio::test]
514    async fn test_e2e_pipeline() {
515        let config = ConcurrentConfig {
516            pruner: Some(PrunerConfig {
517                interval_ms: 5_000, // Long interval to test states before pruning
518                delay_ms: 100,      // Short delay for faster tests
519                retention: 3,       // Keep only 3 checkpoints
520                ..Default::default()
521            }),
522            ..Default::default()
523        };
524        let store = FallibleMockStore::default();
525        let setup = TestSetup::new(config, store, 0).await;
526
527        // Send initial checkpoints
528        for i in 0..3 {
529            setup
530                .send_checkpoint_with_timeout(i, Duration::from_millis(200))
531                .await
532                .unwrap();
533        }
534
535        // Verify all initial data is available (before any pruning)
536        for i in 0..3 {
537            let data = setup
538                .store
539                .wait_for_data(DataPipeline::NAME, i, TEST_TIMEOUT)
540                .await;
541            assert_eq!(data, vec![i * 10 + 1, i * 10 + 2]);
542        }
543
544        // Add more checkpoints to trigger pruning
545        for i in 3..6 {
546            setup
547                .send_checkpoint_with_timeout(i, Duration::from_millis(200))
548                .await
549                .unwrap();
550        }
551
552        // Verify data is still available BEFORE pruning kicks in
553        // With long pruning interval (5s), we can safely verify data without race conditions
554        for i in 0..6 {
555            let data = setup
556                .store
557                .wait_for_data(DataPipeline::NAME, i, Duration::from_secs(1))
558                .await;
559            assert_eq!(data, vec![i * 10 + 1, i * 10 + 2]);
560        }
561
562        // Wait for pruning to occur. The pruner and reader_watermark tasks both run on
563        // the same interval, so poll until the pruner has caught up rather instead of using a
564        // fixed sleep.
565        let pruning_deadline = Duration::from_secs(15);
566        let start = tokio::time::Instant::now();
567        loop {
568            let pruned = {
569                let data = setup.store.data.get(DataPipeline::NAME).unwrap();
570                !data.contains_key(&0) && !data.contains_key(&1) && !data.contains_key(&2)
571            };
572            if pruned {
573                break;
574            }
575            assert!(
576                start.elapsed() < pruning_deadline,
577                "Timed out waiting for pruning to occur"
578            );
579            tokio::time::sleep(Duration::from_millis(100)).await;
580        }
581
582        // Verify recent checkpoints are still available
583        {
584            let data = setup.store.data.get(DataPipeline::NAME).unwrap();
585            assert!(data.contains_key(&3));
586            assert!(data.contains_key(&4));
587            assert!(data.contains_key(&5));
588        };
589    }
590
591    #[tokio::test]
592    async fn test_e2e_pipeline_without_pruning() {
593        let config = ConcurrentConfig {
594            pruner: None,
595            ..Default::default()
596        };
597        let store = FallibleMockStore::default();
598        let setup = TestSetup::new(config, store, 0).await;
599
600        // Send several checkpoints
601        for i in 0..10 {
602            setup
603                .send_checkpoint_with_timeout(i, Duration::from_millis(200))
604                .await
605                .unwrap();
606        }
607
608        // Wait for all data to be processed and committed
609        let watermark = setup
610            .store
611            .wait_for_watermark(DataPipeline::NAME, 9, TEST_TIMEOUT)
612            .await;
613
614        // Verify ALL data was processed correctly (no pruning)
615        for i in 0..10 {
616            let data = setup
617                .store
618                .wait_for_data(DataPipeline::NAME, i, Duration::from_secs(1))
619                .await;
620            assert_eq!(data, vec![i * 10 + 1, i * 10 + 2]);
621        }
622
623        // Verify watermark progression
624        assert_eq!(watermark.checkpoint_hi_inclusive, Some(9));
625        assert_eq!(watermark.tx_hi, 18); // 9 * 2
626        assert_eq!(watermark.timestamp_ms_hi_inclusive, 1000009000); // 1000000000 + 9 * 1000
627
628        // Verify no data was pruned - all 10 checkpoints should still exist
629        let total_checkpoints = {
630            let data = setup.store.data.get(DataPipeline::NAME).unwrap();
631            data.len()
632        };
633        assert_eq!(total_checkpoints, 10);
634    }
635
636    #[tokio::test]
637    async fn test_out_of_order_processing() {
638        let config = ConcurrentConfig::default();
639        let store = FallibleMockStore::default();
640        let setup = TestSetup::new(config, store, 0).await;
641
642        // Send checkpoints out of order
643        let checkpoints = vec![2, 0, 4, 1, 3];
644        for cp in checkpoints {
645            setup
646                .send_checkpoint_with_timeout(cp, Duration::from_millis(200))
647                .await
648                .unwrap();
649        }
650
651        // Wait for all data to be processed
652        setup
653            .store
654            .wait_for_watermark(DataPipeline::NAME, 4, Duration::from_secs(5))
655            .await;
656
657        // Verify all checkpoints were processed correctly despite out-of-order arrival
658        for i in 0..5 {
659            let data = setup
660                .store
661                .wait_for_data(DataPipeline::NAME, i, Duration::from_secs(1))
662                .await;
663            assert_eq!(data, vec![i * 10 + 1, i * 10 + 2]);
664        }
665    }
666
667    #[tokio::test]
668    async fn test_watermark_progression_with_gaps() {
669        let config = ConcurrentConfig::default();
670        let store = FallibleMockStore::default();
671        let setup = TestSetup::new(config, store, 0).await;
672
673        // Send checkpoints with a gap (0, 1, 3, 4) - missing checkpoint 2
674        for cp in [0, 1, 3, 4] {
675            setup
676                .send_checkpoint_with_timeout(cp, Duration::from_millis(200))
677                .await
678                .unwrap();
679        }
680
681        // Wait for processing
682        tokio::time::sleep(Duration::from_secs(1)).await;
683
684        // Watermark should only progress to 1 (can't progress past the gap)
685        let watermark = setup.store.watermark(DataPipeline::NAME).unwrap();
686        assert_eq!(watermark.checkpoint_hi_inclusive, Some(1));
687
688        // Now send the missing checkpoint 2
689        setup
690            .send_checkpoint_with_timeout(2, Duration::from_millis(200))
691            .await
692            .unwrap();
693
694        // Now watermark should progress to 4
695        let watermark = setup
696            .store
697            .wait_for_watermark(DataPipeline::NAME, 4, TEST_TIMEOUT)
698            .await;
699        assert_eq!(watermark.checkpoint_hi_inclusive, Some(4));
700    }
701
702    // ==================== BACK-PRESSURE TESTING ====================
703
704    #[tokio::test]
705    async fn test_back_pressure_collector_max_pending_rows() {
706        // Pipeline Diagram - Collector Back Pressure via MAX_PENDING_ROWS:
707        //
708        // ┌────────────┐    ┌────────────┐    ┌────────────┐    ┌────────────┐
709        // │ Checkpoint │ ─► │ Processor  │ ─► │ Collector  │ ─► │ Committer  │
710        // │   Input    │    │ (fanout=2) │    │            │    │            │
711        // └────────────┘    └────────────┘    └[BOTTLENECK]┘    └────────────┘
712        //                │                 │                 │
713        //              [●●●]           [●●●●●●●]         [●●●●●●]
714        //            buffer: 3         buffer: 7         buffer: 6
715        //
716        // BOTTLENECK: Collector stops accepting when pending rows ≥ MAX_PENDING_ROWS (4)
717
718        let config = ConcurrentConfig {
719            committer: CommitterConfig {
720                // Hold rows in the collector for longer than the test runs, so it fills up to
721                // `MAX_PENDING_ROWS` instead of handing batches to the committer.
722                min_batch_rows: 1000,
723                max_batch_delay_ms: 5_000,
724                write_concurrency: 1,
725                ..Default::default()
726            },
727            fanout: Some(ConcurrencyConfig::Fixed { value: 2 }),
728            processor_channel_size: Some(7),
729            collector_channel_size: Some(6),
730            ..Default::default()
731        };
732        let store = FallibleMockStore::default();
733        let setup = TestSetup::new(config, store, 0).await;
734
735        // Wait for initial setup
736        tokio::time::sleep(Duration::from_millis(200)).await;
737
738        // Pipeline capacity analysis with collector back pressure:
739        // Configuration: MAX_PENDING_ROWS=4, fanout=2
740        //
741        // Channel and task breakdown:
742        // - Checkpoint->Processor channel: 3 slots (TEST_SUBSCRIBER_CHANNEL_SIZE)
743        // - Processor tasks: 2 tasks (fanout=2)
744        // - Processor->Collector channel: 7 slots (processor_channel_size=7)
745        // - Collector pending: 2 checkpoints × 2 values = 4 values (hits MAX_PENDING_ROWS=4)
746        //
747        // Total capacity: 3 + 2 + 7 + 2 = 14 checkpoints
748
749        // Fill pipeline to capacity - these should all succeed
750        for i in 0..14 {
751            setup
752                .send_checkpoint_with_timeout(i, Duration::from_millis(200))
753                .await
754                .unwrap();
755        }
756
757        // Checkpoint 14 should block due to MAX_PENDING_ROWS back pressure
758        setup
759            .send_checkpoint_expect_timeout(14, Duration::from_millis(200))
760            .await;
761
762        // Allow pipeline to drain by sending the blocked checkpoint with longer timeout
763        setup
764            .send_checkpoint_with_timeout(14, TEST_TIMEOUT)
765            .await
766            .unwrap();
767
768        // Verify data was processed correctly
769        let data = setup
770            .store
771            .wait_for_data(DataPipeline::NAME, 0, TEST_TIMEOUT)
772            .await;
773        assert_eq!(data, vec![1, 2]);
774    }
775
776    #[tokio::test]
777    async fn test_back_pressure_committer_slow_commits() {
778        // Pipeline Diagram - Committer Back Pressure via Slow Database Commits:
779        //
780        // ┌────────────┐    ┌────────────┐    ┌────────────┐    ┌────────────┐
781        // │ Checkpoint │ ─► │ Processor  │ ─► │ Collector  │ ─► │ Committer  │
782        // │   Input    │    │ (fanout=2) │    │            │    │🐌 10s Delay│
783        // └────────────┘    └────────────┘    └────────────┘    └[BOTTLENECK]┘
784        //                │                 │                 │
785        //              [●●●]           [●●●●●●●]          [●●●●●●]
786        //            buffer: 3    proc_chan: 7       coll_chan: 6
787        //
788        // BOTTLENECK: Committer with 10s delay blocks entire pipeline
789
790        let config = ConcurrentConfig {
791            committer: CommitterConfig {
792                write_concurrency: 1, // Single committer for deterministic blocking
793                ..Default::default()
794            },
795            fanout: Some(ConcurrencyConfig::Fixed { value: 2 }),
796            processor_channel_size: Some(7),
797            collector_channel_size: Some(6),
798            ..Default::default()
799        };
800        let store = FallibleMockStore::default().with_commit_delay(10_000); // 10 seconds delay
801        let setup = TestSetup::new(config, store, 0).await;
802
803        // Pipeline capacity analysis with slow commits:
804        // Configuration: fanout=2, write_concurrency=1
805        //
806        // Channel and task breakdown:
807        // - Checkpoint->Processor channel: 3 slots (TEST_SUBSCRIBER_CHANNEL_SIZE)
808        // - Processor tasks: 2 tasks (fanout=2)
809        // - Processor->Collector channel: 7 slots (processor_channel_size=7)
810        // - Collector->Committer channel: 6 slots (collector_channel_size=6)
811        // - Committer task: 1 task (blocked by slow commit)
812        //
813        // Total theoretical capacity: 3 + 2 + 7 + 6 + 1 = 19 checkpoints
814
815        // Fill pipeline to theoretical capacity - these should all succeed
816        for i in 0..19 {
817            setup
818                .send_checkpoint_with_timeout(i, Duration::from_millis(100))
819                .await
820                .unwrap();
821        }
822
823        // Find the actual back pressure point
824        // Due to non-determinism in collector's tokio::select!, the collector may consume
825        // up to 2 checkpoints (filling MAX_PENDING_ROWS=4) before applying back pressure.
826        // This means back pressure occurs somewhere in range 19-21.
827        let mut back_pressure_checkpoint = None;
828        for checkpoint in 19..22 {
829            if setup
830                .send_checkpoint_with_timeout(checkpoint, Duration::from_millis(100))
831                .await
832                .is_err()
833            {
834                back_pressure_checkpoint = Some(checkpoint);
835                break;
836            }
837        }
838        assert!(
839            back_pressure_checkpoint.is_some(),
840            "Back pressure should occur between checkpoints 19-21"
841        );
842
843        // Verify that some data has been processed (pipeline is working)
844        setup
845            .store
846            .wait_for_any_data(DataPipeline::NAME, TEST_TIMEOUT)
847            .await;
848
849        // Allow pipeline to drain by sending the blocked checkpoint with longer timeout
850        setup
851            .send_checkpoint_with_timeout(back_pressure_checkpoint.unwrap(), TEST_TIMEOUT)
852            .await
853            .unwrap();
854    }
855
856    // ==================== FAILURE TESTING ====================
857
858    #[tokio::test]
859    async fn test_commit_failure_retry() {
860        let config = ConcurrentConfig::default();
861        let store = FallibleMockStore::default().with_commit_failures(2); // Fail 2 times, then succeed
862        let setup = TestSetup::new(config, store, 0).await;
863
864        // Send a checkpoint
865        setup
866            .send_checkpoint_with_timeout(0, Duration::from_millis(200))
867            .await
868            .unwrap();
869
870        // Should eventually succeed despite initial commit failures
871        setup
872            .store
873            .wait_for_watermark(DataPipeline::NAME, 0, TEST_TIMEOUT)
874            .await;
875
876        // Verify data was eventually committed
877        let data = setup
878            .store
879            .wait_for_data(DataPipeline::NAME, 0, Duration::from_secs(1))
880            .await;
881        assert_eq!(data, vec![1, 2]);
882    }
883
884    #[tokio::test]
885    async fn test_prune_failure_retry() {
886        let config = ConcurrentConfig {
887            pruner: Some(PrunerConfig {
888                interval_ms: 2000, // 2 seconds interval for testing
889                delay_ms: 100,     // Short delay
890                retention: 2,      // Keep only 2 checkpoints
891                ..Default::default()
892            }),
893            ..Default::default()
894        };
895
896        // Configure prune failures for range [0, 2) - fail twice then succeed
897        let store = FallibleMockStore::default().with_prune_failures(0, 2, 1);
898        let setup = TestSetup::new(config, store, 0).await;
899
900        // Send enough checkpoints to trigger pruning
901        for i in 0..4 {
902            setup
903                .send_checkpoint_with_timeout(i, Duration::from_millis(200))
904                .await
905                .unwrap();
906        }
907
908        // Verify data is still available BEFORE pruning kicks in
909        // With long pruning interval (5s), we can safely verify data without race conditions
910        for i in 0..4 {
911            let data = setup
912                .store
913                .wait_for_data(DataPipeline::NAME, i, Duration::from_secs(1))
914                .await;
915            assert_eq!(data, vec![i * 10 + 1, i * 10 + 2]);
916        }
917
918        // Wait for first pruning attempt (should fail) and verify no data has been pruned
919        setup
920            .store
921            .wait_for_prune_attempts(0, 2, 1, TEST_TIMEOUT)
922            .await;
923        {
924            let data = setup.store.data.get(DataPipeline::NAME).unwrap();
925            for i in 0..4 {
926                assert!(data.contains_key(&i));
927            }
928        };
929
930        // Wait for second pruning attempt (should succeed)
931        setup
932            .store
933            .wait_for_prune_attempts(0, 2, 2, TEST_TIMEOUT)
934            .await;
935        {
936            let data = setup.store.data.get(DataPipeline::NAME).unwrap();
937            // Verify recent checkpoints are still available
938            assert!(data.contains_key(&2));
939            assert!(data.contains_key(&3));
940
941            // Verify old checkpoints are pruned
942            assert!(!data.contains_key(&0));
943            assert!(!data.contains_key(&1));
944        };
945    }
946
947    #[tokio::test]
948    async fn test_reader_watermark_failure_retry() {
949        let config = ConcurrentConfig {
950            pruner: Some(PrunerConfig {
951                interval_ms: 100, // Fast interval for testing
952                delay_ms: 100,    // Short delay
953                retention: 3,     // Keep 3 checkpoints
954                ..Default::default()
955            }),
956            ..Default::default()
957        };
958
959        // Configure reader watermark failures - fail 2 times then succeed
960        let store = FallibleMockStore::default().with_reader_watermark_failures(2);
961        let setup = TestSetup::new(config, store, 0).await;
962
963        // Send checkpoints to trigger reader watermark updates
964        for i in 0..6 {
965            setup
966                .send_checkpoint_with_timeout(i, Duration::from_millis(200))
967                .await
968                .unwrap();
969        }
970
971        // Wait for processing to complete
972        setup
973            .store
974            .wait_for_watermark(DataPipeline::NAME, 5, TEST_TIMEOUT)
975            .await;
976
977        // Wait for reader watermark task to attempt updates (with failures and retries)
978        tokio::time::sleep(Duration::from_secs(2)).await;
979
980        // Verify that reader watermark was eventually updated despite failures
981        let watermark = setup.store.watermark(DataPipeline::NAME).unwrap();
982        assert_eq!(watermark.reader_lo, 3);
983    }
984
985    #[tokio::test]
986    async fn test_database_connection_failure_retry() {
987        let config = ConcurrentConfig::default();
988        let store = FallibleMockStore::default().with_connection_failures(2); // Fail 2 times, then succeed
989        let setup = TestSetup::new(config, store, 0).await;
990
991        // Send a checkpoint
992        setup
993            .send_checkpoint_with_timeout(0, Duration::from_millis(200))
994            .await
995            .unwrap();
996
997        // Should eventually succeed despite initial failures
998        setup
999            .store
1000            .wait_for_watermark(DataPipeline::NAME, 0, TEST_TIMEOUT)
1001            .await;
1002
1003        // Verify data was eventually committed
1004        let data = setup
1005            .store
1006            .wait_for_data(DataPipeline::NAME, 0, TEST_TIMEOUT)
1007            .await;
1008        assert_eq!(data, vec![1, 2]);
1009    }
1010}