Skip to main content

sui_indexer_alt_framework/pipeline/sequential/
mod.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use serde::Deserialize;
8use serde::Serialize;
9use sui_futures::service::Service;
10use tokio::sync::mpsc;
11use tracing::info;
12
13use crate::config::ConcurrencyConfig;
14use crate::ingestion::ingestion_client::CheckpointEnvelope;
15use crate::metrics::IndexerMetrics;
16use crate::pipeline::CommitterConfig;
17use crate::pipeline::IngestionConfig;
18use crate::pipeline::Processor;
19use crate::pipeline::processor::processor;
20use crate::pipeline::sequential::collector::BatchedRows;
21use crate::pipeline::sequential::collector::collector;
22use crate::pipeline::sequential::committer::committer;
23use crate::store::SequentialStore;
24use crate::store::Store;
25
26mod collector;
27mod committer;
28
29/// Handlers implement the logic for a given indexing pipeline: How to process checkpoint data (by
30/// implementing [Processor]) into rows for their table, how to combine multiple rows into a single
31/// DB operation, and then how to write those rows atomically to the database.
32///
33/// The handler is also responsible for tuning the various parameters of the pipeline (provided as
34/// associated values).
35///
36/// Sequential handlers can only be used in sequential pipelines, where checkpoint data is
37/// processed out-of-order, but then gathered and written in order. If multiple checkpoints are
38/// available, the pipeline will attempt to combine their writes taking advantage of batching to
39/// avoid emitting redundant writes.
40///
41/// Back-pressure is handled by the bounded subscriber channel from the ingestion service, the
42/// same as concurrent pipelines: the channel blocks broadcaster sends when full, and the adaptive
43/// ingestion controller cuts fetch concurrency as the channel fills up.
44#[async_trait]
45pub trait Handler: Processor {
46    type Store: SequentialStore;
47
48    /// If at least this many rows are pending, the committer will commit them eagerly.
49    const MIN_EAGER_ROWS: usize = 50;
50
51    /// Soft cap: once this many rows are pending, the collector stops eagerly draining
52    /// its input channel and yields to the flush phase. Receive is never hard-gated — unlike
53    /// concurrent pipelines, a missing predecessor may be buried in the input channel, and
54    /// blocking receive would risk deadlock. The cap only bounds receive-to-flush latency in
55    /// the happy path.
56    const MAX_PENDING_ROWS: usize = 5000;
57
58    /// Maximum number of checkpoints to try and write in a single batch. The larger this number
59    /// is, the more chances the pipeline has to merge redundant writes, but the longer each write
60    /// transaction is likely to be.
61    const MAX_BATCH_CHECKPOINTS: usize = 5 * 60;
62
63    /// A type to combine multiple `Self::Value`-s into. This can be used to avoid redundant writes
64    /// by combining multiple rows into one (e.g. if one row supersedes another, the latter can be
65    /// omitted).
66    type Batch: Default + Send + Sync + 'static;
67
68    /// Add `values` from processing a checkpoint to the current `batch`. Checkpoints are
69    /// guaranteed to be presented to the batch in checkpoint order. The handler takes ownership
70    /// of the iterator and consumes all values.
71    ///
72    /// Returns `BatchStatus::Ready` if the batch is full and should be committed,
73    /// or `BatchStatus::Pending` if the batch can accept more values.
74    ///
75    /// Note: The handler can signal batch readiness via `BatchStatus::Ready`, but the framework
76    /// may also decide to commit a batch based on the trait parameters above.
77    fn batch(&self, batch: &mut Self::Batch, values: std::vec::IntoIter<Self::Value>);
78
79    /// Take a batch of values and commit them to the database, returning the number of rows
80    /// affected.
81    async fn commit<'a>(
82        &self,
83        batch: &Self::Batch,
84        conn: &mut <Self::Store as Store>::Connection<'a>,
85    ) -> anyhow::Result<usize>;
86}
87
88/// Configuration for a sequential pipeline
89#[derive(Serialize, Deserialize, Debug, Clone, Default)]
90pub struct SequentialConfig {
91    /// Configuration for the writer, that makes forward progress.
92    pub committer: CommitterConfig,
93
94    /// Per-pipeline ingestion overrides.
95    pub ingestion: IngestionConfig,
96
97    /// Processor concurrency. Defaults to adaptive scaling up to the number of CPUs.
98    pub fanout: Option<ConcurrencyConfig>,
99
100    /// Override for `Handler::MIN_EAGER_ROWS` (eager batch threshold).
101    pub min_eager_rows: Option<usize>,
102
103    /// Override for `Handler::MAX_PENDING_ROWS` (soft cap).
104    pub max_pending_rows: Option<usize>,
105
106    /// Override for `Handler::MAX_BATCH_CHECKPOINTS` (checkpoints per write batch).
107    pub max_batch_checkpoints: Option<usize>,
108
109    /// Size of the channel between the processor and committer.
110    pub processor_channel_size: Option<usize>,
111
112    /// Depth of the channel between the collector and committer tasks. Allows the collector
113    /// to build the next batch while the previous batch is being flushed to the DB.
114    pub pipeline_depth: Option<usize>,
115}
116
117/// Start a new sequential (in-order) indexing pipeline, served by the handler, `H`. Starting
118/// strictly after the `watermark` (or from the beginning if no watermark was provided).
119///
120/// Each pipeline consists of a processor which takes checkpoint data and breaks it down into rows,
121/// ready for insertion, and a committer which orders the rows and combines them into batches to
122/// write to the database.
123///
124/// Commits are performed in checkpoint order, potentially involving multiple checkpoints at a
125/// time. The call to [Handler::commit] and the associated watermark update are performed in a
126/// transaction to ensure atomicity. Unlike in the case of concurrent pipelines, the data passed to
127/// [Handler::commit] is not chunked up, so the handler must perform this step itself, if
128/// necessary.
129///
130/// Checkpoint data is fed into the pipeline through the `checkpoint_rx` channel, and internal
131/// channels are created to communicate between its various components. The pipeline will shutdown
132/// if any of its input or output channels close, any of its independent tasks fail, or if it is
133/// signalled to shutdown through the returned service handle.
134pub(crate) fn pipeline<H: Handler>(
135    handler: H,
136    next_checkpoint: u64,
137    config: SequentialConfig,
138    store: H::Store,
139    checkpoint_rx: mpsc::Receiver<Arc<CheckpointEnvelope>>,
140    metrics: Arc<IndexerMetrics>,
141) -> Service {
142    info!(
143        pipeline = H::NAME,
144        "Starting pipeline with config: {config:#?}",
145    );
146
147    let concurrency = config
148        .fanout
149        .clone()
150        .unwrap_or(ConcurrencyConfig::Adaptive {
151            initial: 1,
152            min: 1,
153            max: num_cpus::get().max(1),
154            dead_band: None,
155        });
156    let min_eager_rows = config.min_eager_rows.unwrap_or(H::MIN_EAGER_ROWS);
157    let max_pending_rows = config.max_pending_rows.unwrap_or(H::MAX_PENDING_ROWS);
158    let max_batch_checkpoints = config
159        .max_batch_checkpoints
160        .unwrap_or(H::MAX_BATCH_CHECKPOINTS);
161
162    let processor_channel_size = config
163        .processor_channel_size
164        .unwrap_or_else(|| num_cpus::get() / 2)
165        .max(1);
166    let (processor_tx, collector_rx) = mpsc::channel(processor_channel_size);
167
168    let pipeline_depth = config
169        .pipeline_depth
170        .unwrap_or_else(|| num_cpus::get() / 2)
171        .max(4);
172    let (collector_tx, committer_rx) = mpsc::channel::<BatchedRows<H>>(pipeline_depth);
173
174    let handler = Arc::new(handler);
175
176    let s_processor = processor(
177        handler.clone(),
178        checkpoint_rx,
179        processor_tx,
180        metrics.clone(),
181        concurrency,
182        store.clone(),
183    );
184
185    let s_collector = collector::<H>(
186        handler.clone(),
187        config,
188        next_checkpoint,
189        collector_rx,
190        metrics.clone(),
191        min_eager_rows,
192        max_pending_rows,
193        max_batch_checkpoints,
194        collector_tx,
195    );
196
197    let s_committer = committer::<H>(handler, store, metrics.clone(), committer_rx);
198
199    s_processor.merge(s_collector).merge(s_committer)
200}
201
202#[cfg(test)]
203mod tests {
204    use std::time::Duration;
205
206    use prometheus::Registry;
207    use sui_types::full_checkpoint_content::Checkpoint;
208
209    use crate::mocks::store::FallibleMockConnection;
210    use crate::mocks::store::FallibleMockStore;
211    use crate::pipeline::IndexedCheckpoint;
212
213    use super::*;
214
215    // Test implementation of Handler
216    #[derive(Default)]
217    struct TestHandler;
218
219    #[async_trait]
220    impl Processor for TestHandler {
221        const NAME: &'static str = "test";
222        type Value = u64;
223
224        async fn process(&self, _checkpoint: &Arc<Checkpoint>) -> anyhow::Result<Vec<Self::Value>> {
225            Ok(vec![])
226        }
227    }
228
229    #[async_trait]
230    impl Handler for TestHandler {
231        type Store = FallibleMockStore;
232        type Batch = Vec<u64>;
233        const MAX_BATCH_CHECKPOINTS: usize = 3; // Using small max value for testing.
234        const MIN_EAGER_ROWS: usize = 4; // Using small eager value for testing.
235
236        fn batch(&self, batch: &mut Self::Batch, values: std::vec::IntoIter<Self::Value>) {
237            batch.extend(values);
238        }
239
240        async fn commit<'a>(
241            &self,
242            batch: &Self::Batch,
243            conn: &mut FallibleMockConnection<'a>,
244        ) -> anyhow::Result<usize> {
245            if !batch.is_empty() {
246                let mut sequential_data = conn.0.sequential_checkpoint_data.lock().unwrap();
247                sequential_data.extend(batch.iter().cloned());
248            }
249            Ok(batch.len())
250        }
251    }
252
253    struct TestSetup {
254        store: FallibleMockStore,
255        checkpoint_tx: mpsc::Sender<IndexedCheckpoint<TestHandler>>,
256        #[allow(unused)]
257        service: Service,
258    }
259
260    /// Emulates adding a sequential pipeline to the indexer. Bypasses the processor stage and
261    /// feeds [IndexedCheckpoint]s directly to the collector. `next_checkpoint` is the starting
262    /// checkpoint for the indexer.
263    fn setup_test(
264        next_checkpoint: u64,
265        config: SequentialConfig,
266        store: FallibleMockStore,
267    ) -> TestSetup {
268        let metrics = IndexerMetrics::new(None, &Registry::default());
269
270        let min_eager_rows = config.min_eager_rows.unwrap_or(TestHandler::MIN_EAGER_ROWS);
271        let max_pending_rows = config
272            .max_pending_rows
273            .unwrap_or(TestHandler::MAX_PENDING_ROWS);
274        let max_batch_checkpoints = config
275            .max_batch_checkpoints
276            .unwrap_or(TestHandler::MAX_BATCH_CHECKPOINTS);
277        let pipeline_depth = config
278            .pipeline_depth
279            .unwrap_or_else(|| num_cpus::get() / 2)
280            .max(4);
281
282        let (checkpoint_tx, checkpoint_rx) = mpsc::channel(10);
283        let (collector_tx, committer_rx) =
284            mpsc::channel::<BatchedRows<TestHandler>>(pipeline_depth);
285
286        let store_clone = store.clone();
287        let handler = Arc::new(TestHandler);
288
289        let s_collector = collector(
290            handler.clone(),
291            config,
292            next_checkpoint,
293            checkpoint_rx,
294            metrics.clone(),
295            min_eager_rows,
296            max_pending_rows,
297            max_batch_checkpoints,
298            collector_tx,
299        );
300        let s_committer = committer(handler, store_clone, metrics, committer_rx);
301
302        TestSetup {
303            store,
304            checkpoint_tx,
305            service: s_collector.merge(s_committer),
306        }
307    }
308
309    async fn send_checkpoint(setup: &mut TestSetup, checkpoint: u64) {
310        setup
311            .checkpoint_tx
312            .send(create_checkpoint(checkpoint))
313            .await
314            .unwrap();
315    }
316
317    fn create_checkpoint(checkpoint: u64) -> IndexedCheckpoint<TestHandler> {
318        IndexedCheckpoint::new(
319            checkpoint,        // epoch
320            checkpoint,        // checkpoint number
321            checkpoint,        // tx_hi
322            checkpoint * 1000, // timestamp
323            vec![checkpoint],  // values
324        )
325    }
326
327    #[tokio::test]
328    async fn test_committer_processes_sequential_checkpoints() {
329        let config = SequentialConfig::default();
330        let mut setup = setup_test(0, config, FallibleMockStore::default());
331
332        // Send checkpoints in order
333        for i in 0..3 {
334            send_checkpoint(&mut setup, i).await;
335        }
336
337        // Wait for processing
338        tokio::time::sleep(Duration::from_millis(200)).await;
339
340        // Verify data was written in order
341        assert_eq!(setup.store.get_sequential_data(), vec![0, 1, 2]);
342
343        // Verify watermark was updated
344        let watermark = setup.store.watermark(TestHandler::NAME).unwrap();
345        assert_eq!(watermark.checkpoint_hi_inclusive, Some(2));
346        assert_eq!(watermark.tx_hi, 2);
347    }
348
349    /// Configure `FallibleMockStore` with no watermark, and emulate `first_checkpoint` by passing
350    /// the `initial_watermark` into the setup.
351    #[tokio::test]
352    async fn test_committer_processes_sequential_checkpoints_with_initial_watermark() {
353        let config = SequentialConfig::default();
354        let mut setup = setup_test(5, config, FallibleMockStore::default());
355
356        // Verify watermark hasn't progressed
357        let watermark = setup.store.watermark(TestHandler::NAME);
358        assert!(watermark.is_none());
359
360        // Send checkpoints in order
361        for i in 0..5 {
362            send_checkpoint(&mut setup, i).await;
363        }
364
365        // Wait for processing
366        tokio::time::sleep(Duration::from_millis(1000)).await;
367
368        // Verify watermark hasn't progressed
369        let watermark = setup.store.watermark(TestHandler::NAME);
370        assert!(watermark.is_none());
371
372        for i in 5..8 {
373            send_checkpoint(&mut setup, i).await;
374        }
375
376        // Wait for processing
377        tokio::time::sleep(Duration::from_millis(1000)).await;
378
379        // Verify data was written in order
380        assert_eq!(setup.store.get_sequential_data(), vec![5, 6, 7]);
381
382        // Verify watermark was updated
383        let watermark = setup.store.watermark(TestHandler::NAME).unwrap();
384        assert_eq!(watermark.checkpoint_hi_inclusive, Some(7));
385        assert_eq!(watermark.tx_hi, 7);
386    }
387
388    #[tokio::test]
389    async fn test_committer_processes_out_of_order_checkpoints() {
390        let config = SequentialConfig::default();
391        let mut setup = setup_test(0, config, FallibleMockStore::default());
392
393        // Send checkpoints out of order
394        for i in [1, 0, 2] {
395            send_checkpoint(&mut setup, i).await;
396        }
397
398        // Wait for processing
399        tokio::time::sleep(Duration::from_millis(200)).await;
400
401        // Verify data was written in order despite receiving out of order
402        assert_eq!(setup.store.get_sequential_data(), vec![0, 1, 2]);
403
404        // Verify watermark was updated
405        let watermark = setup.store.watermark(TestHandler::NAME).unwrap();
406        assert_eq!(watermark.checkpoint_hi_inclusive, Some(2));
407        assert_eq!(watermark.tx_hi, 2);
408    }
409
410    #[tokio::test]
411    async fn test_committer_commit_up_to_max_batch_checkpoints() {
412        let config = SequentialConfig::default();
413        let mut setup = setup_test(0, config, FallibleMockStore::default());
414
415        // Send checkpoints up to MAX_BATCH_CHECKPOINTS
416        for i in 0..4 {
417            send_checkpoint(&mut setup, i).await;
418        }
419
420        // Wait for processing
421        tokio::time::sleep(Duration::from_millis(200)).await;
422
423        // Verify data is written in order across batches
424        assert_eq!(setup.store.get_sequential_data(), vec![0, 1, 2, 3]);
425    }
426
427    #[tokio::test]
428    async fn test_committer_commits_eagerly() {
429        let config = SequentialConfig {
430            committer: CommitterConfig {
431                collect_interval_ms: 4_000, // Long polling to test eager commit
432                ..Default::default()
433            },
434            ..Default::default()
435        };
436        let mut setup = setup_test(0, config, FallibleMockStore::default());
437
438        // Wait for initial poll to be over
439        tokio::time::sleep(Duration::from_millis(200)).await;
440
441        // Send checkpoints 0-2
442        for i in 0..3 {
443            send_checkpoint(&mut setup, i).await;
444        }
445
446        // Verify no checkpoints are written yet (not enough rows for eager commit)
447        assert_eq!(setup.store.get_sequential_data(), Vec::<u64>::new());
448
449        // Send checkpoint 3 to trigger the eager commit (3 + 1 >= MIN_EAGER_ROWS)
450        send_checkpoint(&mut setup, 3).await;
451
452        // Wait for processing
453        tokio::time::sleep(Duration::from_millis(200)).await;
454
455        // Verify all checkpoints are written
456        assert_eq!(setup.store.get_sequential_data(), vec![0, 1, 2, 3]);
457    }
458
459    #[tokio::test]
460    async fn test_committer_retries_on_transaction_failure() {
461        let config = SequentialConfig {
462            committer: CommitterConfig {
463                collect_interval_ms: 1_000, // Long polling to test retry logic
464                ..Default::default()
465            },
466            ..Default::default()
467        };
468
469        // Create store with transaction failure configuration
470        let store = FallibleMockStore::default().with_transaction_failures(1); // Will fail once before succeeding
471
472        let mut setup = setup_test(10, config, store);
473
474        // Send a checkpoint
475        send_checkpoint(&mut setup, 10).await;
476
477        // Wait long enough for the collector to poll-tick (collect_interval = 1s),
478        // hand the batch to the committer, and for the committer to complete one failed
479        // attempt + one successful retry under exponential backoff (100ms initial).
480        tokio::time::sleep(Duration::from_millis(1_500)).await;
481
482        assert_eq!(setup.store.get_sequential_data(), vec![10]);
483    }
484
485    /// Smoke test for pipelined operation under a slow-commit store: with
486    /// `pipeline_depth = 1` and two full batches to process, both batches must land and
487    /// the watermark must reach the last checkpoint.
488    #[tokio::test]
489    async fn pipelined_commit_runs_under_slow_commit() {
490        let config = SequentialConfig {
491            committer: CommitterConfig::default(),
492            max_batch_checkpoints: Some(3),
493            min_eager_rows: Some(1),
494            pipeline_depth: Some(1),
495            ..Default::default()
496        };
497
498        let store = FallibleMockStore::default().with_commit_delay(700);
499        let mut setup = setup_test(0, config, store);
500
501        for i in 0..6 {
502            send_checkpoint(&mut setup, i).await;
503        }
504
505        // Two batches × 700ms commit each + slack.
506        tokio::time::sleep(Duration::from_millis(2_000)).await;
507
508        assert_eq!(setup.store.get_sequential_data(), vec![0, 1, 2, 3, 4, 5]);
509        let watermark = setup.store.watermark(TestHandler::NAME).unwrap();
510        assert_eq!(watermark.checkpoint_hi_inclusive, Some(5));
511    }
512
513    /// Watermarks must advance strictly in batch order, even under pipelining.
514    #[tokio::test]
515    async fn pipelined_commit_preserves_watermark_ordering() {
516        let config = SequentialConfig {
517            committer: CommitterConfig::default(),
518            max_batch_checkpoints: Some(2),
519            min_eager_rows: Some(1),
520            pipeline_depth: Some(2),
521            ..Default::default()
522        };
523
524        let store = FallibleMockStore::default().with_commit_delay(100);
525        let mut setup = setup_test(0, config, store);
526
527        for i in 0..6 {
528            send_checkpoint(&mut setup, i).await;
529        }
530
531        tokio::time::sleep(Duration::from_millis(1_500)).await;
532
533        assert_eq!(setup.store.get_sequential_data(), vec![0, 1, 2, 3, 4, 5]);
534        let watermark = setup.store.watermark(TestHandler::NAME).unwrap();
535        assert_eq!(watermark.checkpoint_hi_inclusive, Some(5));
536        assert_eq!(watermark.tx_hi, 5);
537    }
538}