sui_indexer_alt_framework/
lib.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::BTreeSet;
5use std::ops::Bound;
6use std::sync::Arc;
7use std::time::Duration;
8
9use anyhow::Context;
10use anyhow::bail;
11use anyhow::ensure;
12use cohort::cohorts;
13use ingestion::ArcStreamingClient;
14use ingestion::ClientArgs;
15use ingestion::IngestionConfig;
16use ingestion::IngestionFactory;
17use ingestion::IngestionService;
18use ingestion::ingestion_client::IngestionClient;
19use metrics::IndexerMetrics;
20use prometheus::Registry;
21use sui_indexer_alt_framework_store_traits::ConcurrentStore;
22use sui_indexer_alt_framework_store_traits::Connection;
23use sui_indexer_alt_framework_store_traits::SequentialStore;
24use sui_indexer_alt_framework_store_traits::Store;
25use sui_indexer_alt_framework_store_traits::pipeline_task;
26use tracing::info;
27
28use crate::metrics::IngestionMetrics;
29use crate::pipeline::Processor;
30use crate::pipeline::concurrent::ConcurrentConfig;
31use crate::pipeline::concurrent::{self};
32use crate::pipeline::sequential::SequentialConfig;
33use crate::pipeline::sequential::{self};
34use crate::service::Service;
35
36pub use sui_field_count::FieldCount;
37pub use sui_futures::service;
38/// External users access the store trait through framework::store
39pub use sui_indexer_alt_framework_store_traits as store;
40pub use sui_types as types;
41
42#[cfg(feature = "cluster")]
43pub mod cluster;
44mod cohort;
45pub mod config;
46pub mod ingestion;
47pub mod metrics;
48pub mod pipeline;
49#[cfg(feature = "postgres")]
50pub mod postgres;
51
52#[cfg(test)]
53pub mod mocks;
54
55/// Command-line arguments for the indexer
56#[derive(clap::Args, Default, Debug, Clone)]
57pub struct IndexerArgs {
58    /// Override the next checkpoint for all pipelines without a committer watermark to start
59    /// processing from, which is 0 by default. Pipelines with existing watermarks will ignore this
60    /// setting and always resume from their committer watermark + 1.
61    ///
62    /// Setting this value indirectly affects ingestion, as the checkpoint to start ingesting from
63    /// is the minimum across all pipelines' next checkpoints.
64    #[arg(long)]
65    pub first_checkpoint: Option<u64>,
66
67    /// Override for the checkpoint to end ingestion at (inclusive) -- useful for backfills. By
68    /// default, ingestion will not stop, and will continue to poll for new checkpoints.
69    #[arg(long)]
70    pub last_checkpoint: Option<u64>,
71
72    /// Only run the following pipelines. If not provided, all pipelines found in the
73    /// configuration file will be run.
74    #[arg(long, action = clap::ArgAction::Append)]
75    pub pipeline: Vec<String>,
76
77    /// Additional configurations for running a tasked indexer.
78    #[clap(flatten)]
79    pub task: TaskArgs,
80}
81
82/// Command-line arguments for configuring a tasked indexer.
83#[derive(clap::Parser, Default, Debug, Clone)]
84pub struct TaskArgs {
85    /// An optional task name for this indexer. When set, pipelines will record watermarks using the
86    /// delimiter defined on the store. This allows the same pipelines to run under multiple
87    /// indexers (e.g. for backfills or temporary workflows) while maintaining separate watermark
88    /// entries in the database.
89    ///
90    /// By default there is no task name, and watermarks are keyed only by `pipeline`.
91    ///
92    /// Sequential pipelines cannot be attached to a tasked indexer.
93    ///
94    /// The framework ensures that tasked pipelines never commit checkpoints below the main
95    /// pipeline’s pruner watermark. Requires `--reader-interval-ms`.
96    #[arg(long, requires = "reader_interval_ms")]
97    task: Option<String>,
98
99    /// The interval in milliseconds at which each of the pipelines on a tasked indexer should
100    /// refetch its main pipeline's reader watermark.
101    ///
102    /// This is required when `--task` is set and should should ideally be set to a value that is
103    /// an order of magnitude smaller than the main pipeline's pruning interval, to ensure this
104    /// task pipeline can pick up the new reader watermark before the main pipeline prunes up to
105    /// it.
106    ///
107    /// If the main pipeline does not have pruning enabled, this value can be set to some high
108    /// value, as the tasked pipeline will never see an updated reader watermark.
109    #[arg(long, requires = "task")]
110    reader_interval_ms: Option<u64>,
111}
112
113pub struct Indexer<S: Store> {
114    /// The storage backend that the indexer uses to write and query indexed data. This
115    /// generic implementation allows for plugging in different storage solutions that implement the
116    /// `Store` trait.
117    store: S,
118
119    /// Prometheus Metrics.
120    metrics: Arc<IndexerMetrics>,
121
122    /// Creates the services that download and disseminate checkpoint data -- one per cohort of
123    /// pipelines with similar distances from the network tip, determined in [Self::run].
124    ingestion_factory: IngestionFactory,
125
126    /// Optional override of the checkpoint lowerbound. When set, pipelines without a committer
127    /// watermark will start processing at this checkpoint.
128    first_checkpoint: Option<u64>,
129
130    /// Optional override of the checkpoint upperbound. When set, the indexer will stop ingestion at
131    /// this checkpoint.
132    last_checkpoint: Option<u64>,
133
134    /// The network's latest checkpoint, when the indexer was started.
135    latest_checkpoint: u64,
136
137    /// The minimum `next_checkpoint` across all pipelines. This is the checkpoint for the indexer
138    /// to start ingesting from.
139    next_checkpoint: u64,
140
141    /// The minimum `next_checkpoint` across all sequential pipelines. This is used to initialize
142    /// the regulator to prevent ingestion from running too far ahead of sequential pipelines.
143    next_sequential_checkpoint: Option<u64>,
144
145    /// An optional task name for this indexer. When set, pipelines will record watermarks using the
146    /// delimiter defined on the store. This allows the same pipelines to run under multiple
147    /// indexers (e.g. for backfills or temporary workflows) while maintaining separate watermark
148    /// entries in the database.
149    ///
150    /// By default there is no task name, and watermarks are keyed only by `pipeline`.
151    ///
152    /// Sequential pipelines cannot be attached to a tasked indexer.
153    ///
154    /// The framework ensures that tasked pipelines never commit checkpoints below the main
155    /// pipeline’s pruner watermark.
156    task: Option<Task>,
157
158    /// Optional filter for pipelines to run. If `None`, all pipelines added to the indexer will
159    /// run. Any pipelines that are present in this filter but not added to the indexer will yield
160    /// a warning when the indexer is run.
161    enabled_pipelines: Option<BTreeSet<String>>,
162
163    /// Pipelines that have already been registered with the indexer. Used to make sure a pipeline
164    /// with the same name isn't added twice.
165    added_pipelines: BTreeSet<&'static str>,
166
167    /// Registered pipelines, waiting to be grouped into ingestion cohorts and started when the
168    /// indexer is run.
169    pipelines: Vec<PendingPipeline>,
170}
171
172/// Configuration for a tasked indexer.
173#[derive(Clone)]
174pub(crate) struct Task {
175    /// Name of the tasked indexer, to be used with the delimiter defined on the indexer's store to
176    /// record pipeline watermarks.
177    task: String,
178    /// The interval at which each of the pipelines on a tasked indexer should refecth its main
179    /// pipeline's reader watermark.
180    reader_interval: Duration,
181}
182
183/// A pipeline that has been registered but whose tasks have not been started yet. Pipelines are
184/// held in this form until [Indexer::run], when they are grouped into ingestion cohorts by how
185/// far behind the network tip they will resume from.
186struct PendingPipeline {
187    /// The pipeline's name, for logging cohort composition.
188    name: &'static str,
189
190    /// The checkpoint this pipeline will resume processing from.
191    next_checkpoint: u64,
192
193    /// Deferred constructor, invoked in [Indexer::run] once the pipeline's cohort has an
194    /// ingestion service to subscribe to.
195    build: PipelineBuilder,
196}
197
198/// Subscribes a pipeline to its cohort's ingestion service and starts the pipeline's tasks,
199/// returning the service handle over those tasks.
200///
201/// `Sync` is required (in addition to `Send`) because the `Indexer` holding these builders is kept
202/// alive across await points, and the simulator test runtime requires the resulting futures to be
203/// `Sync`. The builder closures only capture `Send + Sync` state (the handler, store, config, and
204/// metrics), so this bound is satisfied.
205type PipelineBuilder = Box<dyn FnOnce(&mut IngestionService) -> Service + Send + Sync>;
206
207impl TaskArgs {
208    pub fn tasked(task: String, reader_interval_ms: u64) -> Self {
209        Self {
210            task: Some(task),
211            reader_interval_ms: Some(reader_interval_ms),
212        }
213    }
214
215    fn into_task(self) -> Option<Task> {
216        Some(Task {
217            task: self.task?,
218            reader_interval: Duration::from_millis(self.reader_interval_ms?),
219        })
220    }
221}
222
223impl<S: Store> Indexer<S> {
224    /// Create a new instance of the indexer framework from a store that implements the `Store`
225    /// trait, along with `indexer_args`, `client_args`, and `ingestion_config`. Together, these
226    /// arguments configure the following:
227    ///
228    /// - What is indexed (which checkpoints, which pipelines, whether to track and update
229    ///   watermarks) and where to serve metrics from,
230    /// - Where to download checkpoints from,
231    /// - Concurrency and buffering parameters for downloading checkpoints.
232    ///
233    /// After initialization, at least one pipeline must be added using [Self::concurrent_pipeline]
234    /// or [Self::sequential_pipeline], before the indexer is started using [Self::run].
235    pub async fn new(
236        store: S,
237        indexer_args: IndexerArgs,
238        client_args: ClientArgs,
239        ingestion_config: IngestionConfig,
240        metrics_prefix: Option<&str>,
241        registry: &Registry,
242    ) -> anyhow::Result<Self> {
243        let ingestion_factory =
244            IngestionFactory::new(client_args, ingestion_config, metrics_prefix, registry)?;
245        Self::with_ingestion_factory(
246            store,
247            indexer_args,
248            ingestion_factory,
249            metrics_prefix,
250            registry,
251        )
252        .await
253    }
254
255    /// Variant of [`Self::new`] that accepts pre-built ingestion clients, bypassing
256    /// [`ClientArgs`]-driven construction. Callers that supply their own
257    /// [`IngestionClientTrait`] / [`CheckpointStreamingClient`] implementations — for example,
258    /// when embedding the indexer in a fullnode that already has checkpoint data on hand — hand
259    /// them in here, and the indexer creates its ingestion services from them.
260    ///
261    /// All the indexer's ingestion services clone `ingestion_client` and report to its metrics
262    /// handle, and each gets its own clone of the streaming client (if any).
263    ///
264    /// [`IngestionClientTrait`]: crate::ingestion::ingestion_client::IngestionClientTrait
265    /// [`CheckpointStreamingClient`]: crate::ingestion::streaming_client::CheckpointStreamingClient
266    pub async fn with_ingestion_clients(
267        store: S,
268        indexer_args: IndexerArgs,
269        ingestion_client: IngestionClient,
270        streaming_client: Option<ArcStreamingClient>,
271        ingestion_config: IngestionConfig,
272        metrics_prefix: Option<&str>,
273        registry: &Registry,
274    ) -> anyhow::Result<Self> {
275        let ingestion_factory =
276            IngestionFactory::with_clients(ingestion_client, streaming_client, ingestion_config);
277        Self::with_ingestion_factory(
278            store,
279            indexer_args,
280            ingestion_factory,
281            metrics_prefix,
282            registry,
283        )
284        .await
285    }
286
287    /// Common assembly point for [`Self::new`] and [`Self::with_ingestion_clients`]: probes the
288    /// network tip through the factory and stamps the fields onto the struct.
289    async fn with_ingestion_factory(
290        store: S,
291        indexer_args: IndexerArgs,
292        ingestion_factory: IngestionFactory,
293        metrics_prefix: Option<&str>,
294        registry: &Registry,
295    ) -> anyhow::Result<Self> {
296        let IndexerArgs {
297            first_checkpoint,
298            last_checkpoint,
299            pipeline,
300            task,
301        } = indexer_args;
302
303        let metrics = IndexerMetrics::new(metrics_prefix, registry);
304
305        let latest_checkpoint = ingestion_factory.latest_checkpoint_number().await?;
306
307        info!(latest_checkpoint);
308
309        Ok(Self {
310            store,
311            metrics,
312            ingestion_factory,
313            first_checkpoint,
314            last_checkpoint,
315            latest_checkpoint,
316            next_checkpoint: u64::MAX,
317            next_sequential_checkpoint: None,
318            task: task.into_task(),
319            enabled_pipelines: if pipeline.is_empty() {
320                None
321            } else {
322                Some(pipeline.into_iter().collect())
323            },
324            added_pipelines: BTreeSet::new(),
325            pipelines: vec![],
326        })
327    }
328
329    /// The store used by the indexer.
330    pub fn store(&self) -> &S {
331        &self.store
332    }
333
334    /// The ingestion client used by the indexer to fetch checkpoints.
335    pub fn ingestion_client(&self) -> &IngestionClient {
336        self.ingestion_factory.ingestion_client()
337    }
338
339    /// The indexer's metrics.
340    pub fn indexer_metrics(&self) -> &Arc<IndexerMetrics> {
341        &self.metrics
342    }
343
344    /// The ingestion metrics shared by all of this indexer's ingestion services.
345    pub fn ingestion_metrics(&self) -> &Arc<IngestionMetrics> {
346        self.ingestion_factory.metrics()
347    }
348
349    /// The pipelines that this indexer will run.
350    pub fn pipelines(&self) -> impl Iterator<Item = &'static str> + '_ {
351        self.added_pipelines.iter().copied().filter(|p| {
352            self.enabled_pipelines
353                .as_ref()
354                .is_none_or(|e| e.contains(*p))
355        })
356    }
357
358    /// The minimum next checkpoint across all sequential pipelines. This value is used to
359    /// initialize the ingestion regulator's high watermark to prevent ingestion from running
360    /// too far ahead of sequential pipelines.
361    pub fn next_sequential_checkpoint(&self) -> Option<u64> {
362        self.next_sequential_checkpoint
363    }
364
365    /// Group the registered pipelines into ingestion cohorts by how far behind the network tip
366    /// they will resume from, and start one ingestion service per cohort. Each cohort ingests
367    /// from the smallest `next_checkpoint` among its members, so pipelines near the tip are not
368    /// held back (through channel backpressure) by pipelines that have a long backfill ahead of
369    /// them.
370    ///
371    /// Ingestion will stop after consuming the configured `last_checkpoint` if one is provided.
372    /// Note that a pipeline dropping its checkpoint subscription only winds down its own
373    /// cohort's ingestion service; other cohorts keep running.
374    pub async fn run(self) -> anyhow::Result<Service> {
375        let Self {
376            ingestion_factory,
377            last_checkpoint,
378            latest_checkpoint,
379            enabled_pipelines,
380            pipelines,
381            ..
382        } = self;
383
384        if let Some(enabled_pipelines) = enabled_pipelines {
385            ensure!(
386                enabled_pipelines.is_empty(),
387                "Tried to enable pipelines that this indexer does not know about: \
388                {enabled_pipelines:#?}",
389            );
390        }
391
392        ensure!(!pipelines.is_empty(), "No pipelines registered to run");
393
394        let groups = cohorts(
395            pipelines,
396            latest_checkpoint,
397            ingestion_factory.config().min_cohort_boundary,
398        );
399
400        info!(
401            cohorts = groups.len(),
402            latest_checkpoint, "Grouped pipelines into ingestion cohorts"
403        );
404
405        let end = last_checkpoint.map_or(Bound::Unbounded, Bound::Included);
406
407        let mut service = Service::new();
408        for (cohort, group) in groups.into_iter().enumerate() {
409            let mut ingestion = ingestion_factory.create(cohort);
410
411            let mut start = u64::MAX;
412            let mut names = Vec::with_capacity(group.len());
413            let mut members = Vec::with_capacity(group.len());
414            for pending in group {
415                start = start.min(pending.next_checkpoint);
416                names.push(pending.name);
417                members.push((pending.build)(&mut ingestion));
418            }
419
420            info!(cohort, start, end = last_checkpoint, pipelines = ?names, "Ingestion range");
421
422            let mut cohort_service = ingestion
423                .run((Bound::Included(start), end))
424                .await
425                .context("Failed to start ingestion service")?;
426
427            for member in members {
428                cohort_service = cohort_service.merge(member);
429            }
430
431            service = service.merge(cohort_service);
432        }
433
434        Ok(service)
435    }
436
437    /// Determine the checkpoint for the pipeline to resume processing from. This is either the
438    /// checkpoint after its watermark, or if that doesn't exist, then the provided
439    /// [Self::first_checkpoint], and if that is not set, then 0 (genesis).
440    ///
441    /// Update the starting ingestion checkpoint as the minimum across all the next checkpoints
442    /// calculated above.
443    ///
444    /// Returns `Ok(None)` if the pipeline is disabled.
445    async fn add_pipeline<P: Processor + 'static>(
446        &mut self,
447        pipeline_task: String,
448        retention: Option<u64>,
449    ) -> anyhow::Result<Option<u64>> {
450        ensure!(
451            self.added_pipelines.insert(P::NAME),
452            "Pipeline {:?} already added",
453            P::NAME,
454        );
455
456        if let Some(enabled_pipelines) = &mut self.enabled_pipelines
457            && !enabled_pipelines.remove(P::NAME)
458        {
459            info!(pipeline = P::NAME, "Skipping");
460            return Ok(None);
461        }
462
463        // Create a new record based on `proposed_next_checkpoint` if one does not exist.
464        // Otherwise, use the existing record and disregard the proposed value.
465        let proposed_next_checkpoint = if let Some(first_checkpoint) = self.first_checkpoint {
466            first_checkpoint
467        } else if let Some(retention) = retention {
468            self.latest_checkpoint.saturating_sub(retention)
469        } else {
470            0
471        };
472        let mut conn = self.store.connect().await?;
473        let init_watermark = conn
474            .init_watermark(&pipeline_task, proposed_next_checkpoint.checked_sub(1))
475            .await
476            .with_context(|| format!("Failed to init watermark for {pipeline_task}"))?;
477
478        let next_checkpoint = if let Some(init_watermark) = init_watermark {
479            if let Some(checkpoint_hi_inclusive) = init_watermark.checkpoint_hi_inclusive {
480                checkpoint_hi_inclusive + 1
481            } else {
482                0
483            }
484        } else {
485            proposed_next_checkpoint
486        };
487
488        self.next_checkpoint = next_checkpoint.min(self.next_checkpoint);
489
490        Ok(Some(next_checkpoint))
491    }
492}
493
494impl<S: ConcurrentStore> Indexer<S> {
495    /// Adds a new pipeline to this indexer. Its tasks are started when the indexer is run, and
496    /// will be idle until its cohort's ingestion service serves it checkpoint data.
497    ///
498    /// Concurrent pipelines commit checkpoint data out-of-order to maximise throughput, and they
499    /// keep the watermark table up-to-date with the highest point they can guarantee all data
500    /// exists for, for their pipeline.
501    pub async fn concurrent_pipeline<H: concurrent::Handler<Store = S>>(
502        &mut self,
503        handler: H,
504        config: ConcurrentConfig,
505    ) -> anyhow::Result<()> {
506        let pipeline_task =
507            pipeline_task::<S>(H::NAME, self.task.as_ref().map(|t| t.task.as_str()))?;
508        let retention = config.pruner.as_ref().map(|p| p.retention);
509        let Some(next_checkpoint) = self.add_pipeline::<H>(pipeline_task, retention).await? else {
510            return Ok(());
511        };
512
513        let store = self.store.clone();
514        let task = self.task.clone();
515        let metrics = self.metrics.clone();
516        self.pipelines.push(PendingPipeline {
517            name: H::NAME,
518            next_checkpoint,
519            build: Box::new(move |ingestion| {
520                let checkpoint_rx =
521                    ingestion.subscribe_bounded(config.ingestion.subscriber_channel_size());
522                concurrent::pipeline::<H>(
523                    handler,
524                    next_checkpoint,
525                    config,
526                    store,
527                    task,
528                    checkpoint_rx,
529                    metrics,
530                )
531            }),
532        });
533
534        Ok(())
535    }
536}
537
538impl<T: SequentialStore> Indexer<T> {
539    /// Adds a new pipeline to this indexer. Its tasks are started when the indexer is run, and
540    /// will be idle until its cohort's ingestion service serves it checkpoint data.
541    ///
542    /// Sequential pipelines commit checkpoint data in-order which sacrifices throughput, but may be
543    /// required to handle pipelines that modify data in-place (where each update is not an insert,
544    /// but could be a modification of an existing row, where ordering between updates is
545    /// important).
546    pub async fn sequential_pipeline<H: sequential::Handler<Store = T>>(
547        &mut self,
548        handler: H,
549        config: SequentialConfig,
550    ) -> anyhow::Result<()> {
551        if self.task.is_some() {
552            bail!(
553                "Sequential pipelines do not support pipeline tasks. \
554                These pipelines guarantee that each checkpoint is committed exactly once and in order. \
555                Running the same pipeline under a different task would violate these guarantees."
556            );
557        }
558
559        let Some(next_checkpoint) = self.add_pipeline::<H>(H::NAME.to_owned(), None).await? else {
560            return Ok(());
561        };
562
563        // Track the minimum next_checkpoint across all sequential pipelines
564        self.next_sequential_checkpoint = Some(
565            self.next_sequential_checkpoint
566                .map_or(next_checkpoint, |n| n.min(next_checkpoint)),
567        );
568
569        let store = self.store.clone();
570        let metrics = self.metrics.clone();
571        self.pipelines.push(PendingPipeline {
572            name: H::NAME,
573            next_checkpoint,
574            build: Box::new(move |ingestion| {
575                let checkpoint_rx =
576                    ingestion.subscribe_bounded(config.ingestion.subscriber_channel_size());
577                sequential::pipeline::<H>(
578                    handler,
579                    next_checkpoint,
580                    config,
581                    store,
582                    checkpoint_rx,
583                    metrics,
584                )
585            }),
586        });
587
588        Ok(())
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use std::sync::Arc;
595
596    use async_trait::async_trait;
597    use clap::Parser;
598    use sui_indexer_alt_framework_store_traits::PrunerWatermark;
599    use sui_synthetic_ingestion::synthetic_ingestion;
600    use tokio::sync::watch;
601
602    use crate::FieldCount;
603    use crate::config::ConcurrencyConfig;
604    use crate::ingestion::ingestion_client::IngestionClientArgs;
605    use crate::ingestion::store_client::ObjectStoreWatermark;
606    use crate::ingestion::store_client::WATERMARK_PATH;
607    use crate::mocks::store::FallibleMockStore;
608    use crate::pipeline::CommitterConfig;
609    use crate::pipeline::Processor;
610    use crate::pipeline::concurrent::ConcurrentConfig;
611    use crate::store::CommitterWatermark;
612    use crate::store::ConcurrentConnection as _;
613    use crate::store::Connection as _;
614
615    use super::*;
616
617    #[allow(dead_code)]
618    #[derive(Clone, FieldCount)]
619    struct MockValue(u64);
620
621    /// A handler that can be controlled externally to block checkpoint processing.
622    struct ControllableHandler {
623        /// Process checkpoints less than or equal to this value.
624        process_below: watch::Receiver<u64>,
625    }
626
627    impl ControllableHandler {
628        fn with_limit(limit: u64) -> (Self, watch::Sender<u64>) {
629            let (tx, rx) = watch::channel(limit);
630            (Self { process_below: rx }, tx)
631        }
632    }
633
634    #[async_trait]
635    impl Processor for ControllableHandler {
636        const NAME: &'static str = "controllable";
637        type Value = MockValue;
638
639        async fn process(
640            &self,
641            checkpoint: &Arc<sui_types::full_checkpoint_content::Checkpoint>,
642        ) -> anyhow::Result<Vec<Self::Value>> {
643            let cp_num = checkpoint.summary.sequence_number;
644
645            // Wait until the checkpoint is allowed to be processed
646            self.process_below
647                .clone()
648                .wait_for(|&limit| cp_num <= limit)
649                .await
650                .ok();
651
652            Ok(vec![MockValue(cp_num)])
653        }
654    }
655
656    #[async_trait]
657    impl concurrent::Handler for ControllableHandler {
658        type Store = FallibleMockStore;
659        type Batch = Vec<MockValue>;
660
661        fn batch(
662            &self,
663            batch: &mut Self::Batch,
664            values: &mut std::vec::IntoIter<Self::Value>,
665        ) -> concurrent::BatchStatus {
666            batch.extend(values);
667            concurrent::BatchStatus::Ready
668        }
669
670        async fn commit<'a>(
671            &self,
672            batch: &Self::Batch,
673            conn: &mut <Self::Store as Store>::Connection<'a>,
674        ) -> anyhow::Result<usize> {
675            for value in batch {
676                conn.0
677                    .commit_data(Self::NAME, value.0, vec![value.0])
678                    .await?;
679            }
680            Ok(batch.len())
681        }
682    }
683
684    macro_rules! test_pipeline {
685        ($handler:ident, $name:literal) => {
686            struct $handler;
687
688            #[async_trait]
689            impl Processor for $handler {
690                const NAME: &'static str = $name;
691                type Value = MockValue;
692                async fn process(
693                    &self,
694                    checkpoint: &Arc<sui_types::full_checkpoint_content::Checkpoint>,
695                ) -> anyhow::Result<Vec<Self::Value>> {
696                    Ok(vec![MockValue(checkpoint.summary.sequence_number)])
697                }
698            }
699
700            #[async_trait]
701            impl crate::pipeline::concurrent::Handler for $handler {
702                type Store = FallibleMockStore;
703                type Batch = Vec<Self::Value>;
704
705                fn batch(
706                    &self,
707                    batch: &mut Self::Batch,
708                    values: &mut std::vec::IntoIter<Self::Value>,
709                ) -> crate::pipeline::concurrent::BatchStatus {
710                    batch.extend(values);
711                    crate::pipeline::concurrent::BatchStatus::Pending
712                }
713
714                async fn commit<'a>(
715                    &self,
716                    batch: &Self::Batch,
717                    conn: &mut <Self::Store as Store>::Connection<'a>,
718                ) -> anyhow::Result<usize> {
719                    for value in batch {
720                        conn.0
721                            .commit_data(Self::NAME, value.0, vec![value.0])
722                            .await?;
723                    }
724                    Ok(batch.len())
725                }
726            }
727
728            #[async_trait]
729            impl crate::pipeline::sequential::Handler for $handler {
730                type Store = FallibleMockStore;
731                type Batch = Vec<Self::Value>;
732
733                fn batch(&self, batch: &mut Self::Batch, values: std::vec::IntoIter<Self::Value>) {
734                    batch.extend(values);
735                }
736
737                async fn commit<'a>(
738                    &self,
739                    _batch: &Self::Batch,
740                    _conn: &mut <Self::Store as Store>::Connection<'a>,
741                ) -> anyhow::Result<usize> {
742                    Ok(1)
743                }
744            }
745        };
746    }
747
748    test_pipeline!(MockHandler, "test_processor");
749    test_pipeline!(SequentialHandler, "sequential_handler");
750    test_pipeline!(MockCheckpointSequenceNumberHandler, "test");
751
752    fn init_ingestion_dir(latest_checkpoint: Option<u64>) -> tempfile::TempDir {
753        let dir = tempfile::tempdir().unwrap();
754        if let Some(cp) = latest_checkpoint {
755            let watermark_path = dir.path().join(WATERMARK_PATH);
756            std::fs::create_dir_all(watermark_path.parent().unwrap()).unwrap();
757            let watermark = ObjectStoreWatermark {
758                checkpoint_hi_inclusive: cp,
759            };
760            std::fs::write(watermark_path, serde_json::to_string(&watermark).unwrap()).unwrap();
761        }
762        dir
763    }
764
765    /// If `ingestion_data` is `Some((num_checkpoints, checkpoint_size))`, synthetic ingestion
766    /// data will be generated in the temp directory before creating the indexer.
767    async fn create_test_indexer(
768        store: FallibleMockStore,
769        indexer_args: IndexerArgs,
770        registry: &Registry,
771        ingestion_data: Option<(u64, u64)>,
772    ) -> (Indexer<FallibleMockStore>, tempfile::TempDir) {
773        let temp_dir = init_ingestion_dir(None);
774        if let Some((num_checkpoints, checkpoint_size)) = ingestion_data {
775            synthetic_ingestion::generate_ingestion(synthetic_ingestion::Config {
776                ingestion_dir: temp_dir.path().to_owned(),
777                starting_checkpoint: 0,
778                num_checkpoints,
779                checkpoint_size,
780            })
781            .await;
782        }
783        let client_args = ClientArgs {
784            ingestion: IngestionClientArgs {
785                local_ingestion_path: Some(temp_dir.path().to_owned()),
786                ..Default::default()
787            },
788            ..Default::default()
789        };
790        let indexer = Indexer::new(
791            store,
792            indexer_args,
793            client_args,
794            IngestionConfig::default(),
795            None,
796            registry,
797        )
798        .await
799        .unwrap();
800        (indexer, temp_dir)
801    }
802
803    async fn set_committer_watermark(
804        conn: &mut <FallibleMockStore as Store>::Connection<'_>,
805        name: &str,
806        hi: u64,
807    ) {
808        conn.set_committer_watermark(
809            name,
810            CommitterWatermark {
811                checkpoint_hi_inclusive: hi,
812                ..Default::default()
813            },
814        )
815        .await
816        .unwrap();
817    }
818
819    async fn add_concurrent<H: concurrent::Handler<Store = FallibleMockStore>>(
820        indexer: &mut Indexer<FallibleMockStore>,
821        handler: H,
822    ) {
823        indexer
824            .concurrent_pipeline(handler, ConcurrentConfig::default())
825            .await
826            .unwrap();
827    }
828
829    async fn add_sequential<H: sequential::Handler<Store = FallibleMockStore>>(
830        indexer: &mut Indexer<FallibleMockStore>,
831        handler: H,
832    ) {
833        indexer
834            .sequential_pipeline(handler, SequentialConfig::default())
835            .await
836            .unwrap();
837    }
838
839    /// Seed `store` so `near` resumes just past a fake network tip of 100,000 (its distance
840    /// from the tip saturates to zero) while `far` resumes 99,990 checkpoints behind it, and
841    /// return an ingestion directory advertising that tip with checkpoints 0..60 on disk.
842    /// Cohorts are determined by distance from the advertised tip, but ingestion stops at
843    /// `last_checkpoint`. Checkpoint 0 must exist because the ingestion client derives the
844    /// chain identifier from it, and retries indefinitely until it appears.
845    async fn multi_cohort_setup(
846        store: &FallibleMockStore,
847        near: &str,
848        far: &str,
849    ) -> tempfile::TempDir {
850        let mut conn = store.connect().await.unwrap();
851        set_committer_watermark(&mut conn, near, 100_100).await;
852        set_committer_watermark(&mut conn, far, 9).await;
853
854        let temp_dir = init_ingestion_dir(Some(100_000));
855        synthetic_ingestion::generate_ingestion(synthetic_ingestion::Config {
856            ingestion_dir: temp_dir.path().to_owned(),
857            starting_checkpoint: 0,
858            num_checkpoints: 60,
859            checkpoint_size: 1,
860        })
861        .await;
862
863        temp_dir
864    }
865
866    macro_rules! assert_out_of_order {
867        ($metrics:expr, $pipeline:expr, $expected:expr) => {
868            assert_eq!(
869                $metrics
870                    .total_watermarks_out_of_order
871                    .get_metric_with_label_values(&[$pipeline])
872                    .unwrap()
873                    .get(),
874                $expected,
875            );
876        };
877    }
878
879    async fn test_init_watermark(
880        first_checkpoint: Option<u64>,
881        is_concurrent: bool,
882    ) -> (Option<CommitterWatermark>, Option<PrunerWatermark>) {
883        let registry = Registry::new();
884        let store = FallibleMockStore::default();
885
886        test_pipeline!(A, "pipeline_name");
887
888        let mut conn = store.connect().await.unwrap();
889
890        let indexer_args = IndexerArgs {
891            first_checkpoint,
892            ..IndexerArgs::default()
893        };
894        let (mut indexer, _temp_dir) =
895            create_test_indexer(store.clone(), indexer_args, &registry, None).await;
896
897        if is_concurrent {
898            add_concurrent(&mut indexer, A).await;
899        } else {
900            add_sequential(&mut indexer, A).await;
901        }
902
903        (
904            conn.committer_watermark(A::NAME).await.unwrap(),
905            conn.pruner_watermark(A::NAME, Duration::ZERO)
906                .await
907                .unwrap(),
908        )
909    }
910
911    const LATEST_CHECKPOINT: u64 = 10;
912
913    /// Set up an indexer as if the network is at `latest_checkpoint` (the next checkpoint to
914    /// ingest). Runs a single concurrent pipeline with the given config. If `watermark` is
915    /// provided, the pipeline's high watermark is pre-set; `first_checkpoint` controls where
916    /// ingestion begins.
917    async fn test_next_checkpoint(
918        watermark: Option<u64>,
919        first_checkpoint: Option<u64>,
920        concurrent_config: ConcurrentConfig,
921    ) -> Indexer<FallibleMockStore> {
922        let registry = Registry::new();
923        let store = FallibleMockStore::default();
924
925        test_pipeline!(A, "concurrent_a");
926
927        if let Some(checkpoint_hi_inclusive) = watermark {
928            let mut conn = store.connect().await.unwrap();
929            conn.set_committer_watermark(
930                A::NAME,
931                CommitterWatermark {
932                    checkpoint_hi_inclusive,
933                    ..Default::default()
934                },
935            )
936            .await
937            .unwrap();
938        }
939
940        let temp_dir = init_ingestion_dir(Some(LATEST_CHECKPOINT));
941        let mut indexer = Indexer::new(
942            store,
943            IndexerArgs {
944                first_checkpoint,
945                ..Default::default()
946            },
947            ClientArgs {
948                ingestion: IngestionClientArgs {
949                    local_ingestion_path: Some(temp_dir.path().to_owned()),
950                    ..Default::default()
951                },
952                ..Default::default()
953            },
954            IngestionConfig::default(),
955            None,
956            &registry,
957        )
958        .await
959        .unwrap();
960
961        assert_eq!(indexer.latest_checkpoint, LATEST_CHECKPOINT);
962
963        indexer
964            .concurrent_pipeline::<A>(A, concurrent_config)
965            .await
966            .unwrap();
967
968        indexer
969    }
970
971    fn pruner_config(retention: u64) -> ConcurrentConfig {
972        ConcurrentConfig {
973            pruner: Some(concurrent::PrunerConfig {
974                retention,
975                ..Default::default()
976            }),
977            ..Default::default()
978        }
979    }
980
981    #[test]
982    fn test_arg_parsing() {
983        #[derive(Parser)]
984        struct Args {
985            #[clap(flatten)]
986            indexer: IndexerArgs,
987        }
988
989        let args = Args::try_parse_from([
990            "cmd",
991            "--first-checkpoint",
992            "10",
993            "--last-checkpoint",
994            "100",
995            "--pipeline",
996            "a",
997            "--pipeline",
998            "b",
999            "--task",
1000            "t",
1001            "--reader-interval-ms",
1002            "5000",
1003        ])
1004        .unwrap();
1005
1006        assert_eq!(args.indexer.first_checkpoint, Some(10));
1007        assert_eq!(args.indexer.last_checkpoint, Some(100));
1008        assert_eq!(args.indexer.pipeline, vec!["a", "b"]);
1009        assert_eq!(args.indexer.task.task, Some("t".to_owned()));
1010        assert_eq!(args.indexer.task.reader_interval_ms, Some(5000));
1011    }
1012
1013    /// next_checkpoint is smallest among existing watermarks + 1.
1014    #[tokio::test]
1015    async fn test_next_checkpoint_all_pipelines_have_watermarks() {
1016        let registry = Registry::new();
1017        let store = FallibleMockStore::default();
1018
1019        test_pipeline!(A, "concurrent_a");
1020        test_pipeline!(B, "concurrent_b");
1021        test_pipeline!(C, "sequential_c");
1022        test_pipeline!(D, "sequential_d");
1023
1024        let mut conn = store.connect().await.unwrap();
1025
1026        conn.init_watermark(A::NAME, Some(0)).await.unwrap();
1027        set_committer_watermark(&mut conn, A::NAME, 100).await;
1028
1029        conn.init_watermark(B::NAME, Some(0)).await.unwrap();
1030        set_committer_watermark(&mut conn, B::NAME, 10).await;
1031
1032        conn.init_watermark(C::NAME, Some(0)).await.unwrap();
1033        set_committer_watermark(&mut conn, C::NAME, 1).await;
1034
1035        conn.init_watermark(D::NAME, Some(0)).await.unwrap();
1036        set_committer_watermark(&mut conn, D::NAME, 50).await;
1037
1038        let (mut indexer, _temp_dir) =
1039            create_test_indexer(store, IndexerArgs::default(), &registry, None).await;
1040
1041        add_concurrent(&mut indexer, A).await;
1042        add_concurrent(&mut indexer, B).await;
1043        add_sequential(&mut indexer, C).await;
1044        add_sequential(&mut indexer, D).await;
1045
1046        assert_eq!(indexer.first_checkpoint, None);
1047        assert_eq!(indexer.last_checkpoint, None);
1048        assert_eq!(indexer.latest_checkpoint, 0);
1049        assert_eq!(indexer.next_checkpoint, 2);
1050        assert_eq!(indexer.next_sequential_checkpoint, Some(2));
1051    }
1052
1053    /// next_checkpoint is 0 when at least one pipeline has no watermark.
1054    #[tokio::test]
1055    async fn test_next_checkpoint_not_all_pipelines_have_watermarks() {
1056        let registry = Registry::new();
1057        let store = FallibleMockStore::default();
1058
1059        test_pipeline!(A, "concurrent_a");
1060        test_pipeline!(B, "concurrent_b");
1061        test_pipeline!(C, "sequential_c");
1062        test_pipeline!(D, "sequential_d");
1063
1064        let mut conn = store.connect().await.unwrap();
1065        set_committer_watermark(&mut conn, B::NAME, 10).await;
1066        set_committer_watermark(&mut conn, C::NAME, 1).await;
1067
1068        let (mut indexer, _temp_dir) =
1069            create_test_indexer(store, IndexerArgs::default(), &registry, None).await;
1070
1071        add_concurrent(&mut indexer, A).await;
1072        add_concurrent(&mut indexer, B).await;
1073        add_sequential(&mut indexer, C).await;
1074        add_sequential(&mut indexer, D).await;
1075
1076        assert_eq!(indexer.first_checkpoint, None);
1077        assert_eq!(indexer.last_checkpoint, None);
1078        assert_eq!(indexer.latest_checkpoint, 0);
1079        assert_eq!(indexer.next_checkpoint, 0);
1080        assert_eq!(indexer.next_sequential_checkpoint, Some(0));
1081    }
1082
1083    /// next_checkpoint is 1 when smallest committer watermark is 0.
1084    #[tokio::test]
1085    async fn test_next_checkpoint_smallest_is_0() {
1086        let registry = Registry::new();
1087        let store = FallibleMockStore::default();
1088
1089        test_pipeline!(A, "concurrent_a");
1090        test_pipeline!(B, "concurrent_b");
1091        test_pipeline!(C, "sequential_c");
1092        test_pipeline!(D, "sequential_d");
1093
1094        let mut conn = store.connect().await.unwrap();
1095        set_committer_watermark(&mut conn, A::NAME, 100).await;
1096        set_committer_watermark(&mut conn, B::NAME, 10).await;
1097        set_committer_watermark(&mut conn, C::NAME, 1).await;
1098        set_committer_watermark(&mut conn, D::NAME, 0).await;
1099
1100        let (mut indexer, _temp_dir) =
1101            create_test_indexer(store, IndexerArgs::default(), &registry, None).await;
1102
1103        add_concurrent(&mut indexer, A).await;
1104        add_concurrent(&mut indexer, B).await;
1105        add_sequential(&mut indexer, C).await;
1106        add_sequential(&mut indexer, D).await;
1107
1108        assert_eq!(indexer.next_checkpoint, 1);
1109    }
1110
1111    /// next_checkpoint is first_checkpoint when at least one pipeline has no
1112    /// watermark, and first_checkpoint is smallest.
1113    #[tokio::test]
1114    async fn test_next_checkpoint_first_checkpoint_and_no_watermark() {
1115        let registry = Registry::new();
1116        let store = FallibleMockStore::default();
1117
1118        test_pipeline!(A, "concurrent_a");
1119        test_pipeline!(B, "concurrent_b");
1120        test_pipeline!(C, "sequential_c");
1121        test_pipeline!(D, "sequential_d");
1122
1123        let mut conn = store.connect().await.unwrap();
1124        set_committer_watermark(&mut conn, B::NAME, 50).await;
1125        set_committer_watermark(&mut conn, C::NAME, 10).await;
1126
1127        let indexer_args = IndexerArgs {
1128            first_checkpoint: Some(5),
1129            ..Default::default()
1130        };
1131        let (mut indexer, _temp_dir) =
1132            create_test_indexer(store, indexer_args, &registry, None).await;
1133
1134        add_concurrent(&mut indexer, A).await;
1135        add_concurrent(&mut indexer, B).await;
1136        add_sequential(&mut indexer, C).await;
1137        add_sequential(&mut indexer, D).await;
1138
1139        assert_eq!(indexer.first_checkpoint, Some(5));
1140        assert_eq!(indexer.last_checkpoint, None);
1141        assert_eq!(indexer.latest_checkpoint, 0);
1142        assert_eq!(indexer.next_checkpoint, 5);
1143        assert_eq!(indexer.next_sequential_checkpoint, Some(5));
1144    }
1145
1146    /// next_checkpoint is smallest among existing watermarks + 1 if
1147    /// all pipelines have watermarks (ignores first_checkpoint).
1148    #[tokio::test]
1149    async fn test_next_checkpoint_ignore_first_checkpoint() {
1150        let registry = Registry::new();
1151        let store = FallibleMockStore::default();
1152
1153        test_pipeline!(B, "concurrent_b");
1154        test_pipeline!(C, "sequential_c");
1155
1156        let mut conn = store.connect().await.unwrap();
1157        set_committer_watermark(&mut conn, B::NAME, 50).await;
1158        set_committer_watermark(&mut conn, C::NAME, 10).await;
1159
1160        let indexer_args = IndexerArgs {
1161            first_checkpoint: Some(5),
1162            ..Default::default()
1163        };
1164        let (mut indexer, _temp_dir) =
1165            create_test_indexer(store, indexer_args, &registry, None).await;
1166
1167        add_concurrent(&mut indexer, B).await;
1168        add_sequential(&mut indexer, C).await;
1169
1170        assert_eq!(indexer.first_checkpoint, Some(5));
1171        assert_eq!(indexer.last_checkpoint, None);
1172        assert_eq!(indexer.latest_checkpoint, 0);
1173        assert_eq!(indexer.next_checkpoint, 11);
1174        assert_eq!(indexer.next_sequential_checkpoint, Some(11));
1175    }
1176
1177    /// If the first_checkpoint is being considered, because pipelines are missing watermarks, it
1178    /// will not be used as the starting point if it is not the smallest valid committer watermark
1179    /// to resume ingesting from.
1180    #[tokio::test]
1181    async fn test_next_checkpoint_large_first_checkpoint() {
1182        let registry = Registry::new();
1183        let store = FallibleMockStore::default();
1184
1185        test_pipeline!(A, "concurrent_a");
1186        test_pipeline!(B, "concurrent_b");
1187        test_pipeline!(C, "sequential_c");
1188
1189        let mut conn = store.connect().await.unwrap();
1190        set_committer_watermark(&mut conn, B::NAME, 50).await;
1191        set_committer_watermark(&mut conn, C::NAME, 10).await;
1192
1193        let indexer_args = IndexerArgs {
1194            first_checkpoint: Some(24),
1195            ..Default::default()
1196        };
1197        let (mut indexer, _temp_dir) =
1198            create_test_indexer(store, indexer_args, &registry, None).await;
1199
1200        add_concurrent(&mut indexer, A).await;
1201        add_concurrent(&mut indexer, B).await;
1202        add_sequential(&mut indexer, C).await;
1203
1204        assert_eq!(indexer.first_checkpoint, Some(24));
1205        assert_eq!(indexer.last_checkpoint, None);
1206        assert_eq!(indexer.latest_checkpoint, 0);
1207        assert_eq!(indexer.next_checkpoint, 11);
1208        assert_eq!(indexer.next_sequential_checkpoint, Some(11));
1209    }
1210
1211    /// latest_checkpoint is read from the watermark file.
1212    #[tokio::test]
1213    async fn test_latest_checkpoint_from_watermark() {
1214        let registry = Registry::new();
1215        let store = FallibleMockStore::default();
1216        let temp_dir = init_ingestion_dir(Some(30));
1217        let indexer = Indexer::new(
1218            store,
1219            IndexerArgs::default(),
1220            ClientArgs {
1221                ingestion: IngestionClientArgs {
1222                    local_ingestion_path: Some(temp_dir.path().to_owned()),
1223                    ..Default::default()
1224                },
1225                ..Default::default()
1226            },
1227            IngestionConfig::default(),
1228            None,
1229            &registry,
1230        )
1231        .await
1232        .unwrap();
1233
1234        assert_eq!(indexer.latest_checkpoint, 30);
1235    }
1236
1237    /// No watermark, no first_checkpoint, pruner with retention:
1238    /// next_checkpoint = LATEST_CHECKPOINT - retention.
1239    #[tokio::test]
1240    async fn test_next_checkpoint_with_pruner_uses_retention() {
1241        let retention = LATEST_CHECKPOINT - 1;
1242        let indexer = test_next_checkpoint(None, None, pruner_config(retention)).await;
1243        assert_eq!(indexer.next_checkpoint, LATEST_CHECKPOINT - retention);
1244    }
1245
1246    /// No watermark, no first_checkpoint, no pruner: falls back to 0.
1247    #[tokio::test]
1248    async fn test_next_checkpoint_without_pruner_falls_back_to_genesis() {
1249        let indexer = test_next_checkpoint(None, None, ConcurrentConfig::default()).await;
1250        assert_eq!(indexer.next_checkpoint, 0);
1251    }
1252
1253    /// Watermark at 5 takes priority over latest_checkpoint - retention.
1254    #[tokio::test]
1255    async fn test_next_checkpoint_watermark_takes_priority_over_pruner() {
1256        let retention = LATEST_CHECKPOINT - 1;
1257        let indexer = test_next_checkpoint(Some(5), None, pruner_config(retention)).await;
1258        assert_eq!(indexer.next_checkpoint, 6);
1259    }
1260
1261    /// first_checkpoint takes priority over latest_checkpoint - retention when
1262    /// there's no watermark.
1263    #[tokio::test]
1264    async fn test_next_checkpoint_first_checkpoint_takes_priority_over_pruner() {
1265        let retention = LATEST_CHECKPOINT - 1;
1266        let indexer = test_next_checkpoint(None, Some(2), pruner_config(retention)).await;
1267        assert_eq!(indexer.next_checkpoint, 2);
1268    }
1269
1270    /// When retention exceeds latest_checkpoint, saturating_sub clamps to 0.
1271    #[tokio::test]
1272    async fn test_next_checkpoint_retention_exceeds_latest_checkpoint() {
1273        let retention = LATEST_CHECKPOINT + 1;
1274        let indexer = test_next_checkpoint(None, None, pruner_config(retention)).await;
1275        assert_eq!(indexer.next_checkpoint, 0);
1276    }
1277
1278    // test ingestion, all pipelines have watermarks, no first_checkpoint provided
1279    #[tokio::test]
1280    async fn test_indexer_ingestion_existing_watermarks_no_first_checkpoint() {
1281        let registry = Registry::new();
1282        let store = FallibleMockStore::default();
1283
1284        test_pipeline!(A, "concurrent_a");
1285        test_pipeline!(B, "concurrent_b");
1286        test_pipeline!(C, "sequential_c");
1287        test_pipeline!(D, "sequential_d");
1288
1289        let mut conn = store.connect().await.unwrap();
1290        set_committer_watermark(&mut conn, A::NAME, 5).await;
1291        set_committer_watermark(&mut conn, B::NAME, 10).await;
1292        set_committer_watermark(&mut conn, C::NAME, 15).await;
1293        set_committer_watermark(&mut conn, D::NAME, 20).await;
1294
1295        let indexer_args = IndexerArgs {
1296            last_checkpoint: Some(29),
1297            ..Default::default()
1298        };
1299        let (mut indexer, _temp_dir) =
1300            create_test_indexer(store.clone(), indexer_args, &registry, Some((30, 1))).await;
1301
1302        add_concurrent(&mut indexer, A).await;
1303        add_concurrent(&mut indexer, B).await;
1304        add_sequential(&mut indexer, C).await;
1305        add_sequential(&mut indexer, D).await;
1306
1307        let ingestion_metrics = indexer.ingestion_metrics().clone();
1308        let indexer_metrics = indexer.indexer_metrics().clone();
1309
1310        indexer.run().await.unwrap().join().await.unwrap();
1311
1312        assert_eq!(
1313            ingestion_metrics
1314                .total_ingested_checkpoints
1315                .with_label_values(&["0"])
1316                .get(),
1317            24
1318        );
1319        assert_out_of_order!(indexer_metrics, A::NAME, 0);
1320        assert_out_of_order!(indexer_metrics, B::NAME, 5);
1321        assert_out_of_order!(indexer_metrics, C::NAME, 10);
1322        assert_out_of_order!(indexer_metrics, D::NAME, 15);
1323    }
1324
1325    // test ingestion, no pipelines missing watermarks, first_checkpoint provided
1326    #[tokio::test]
1327    async fn test_indexer_ingestion_existing_watermarks_ignore_first_checkpoint() {
1328        let registry = Registry::new();
1329        let store = FallibleMockStore::default();
1330
1331        test_pipeline!(A, "concurrent_a");
1332        test_pipeline!(B, "concurrent_b");
1333        test_pipeline!(C, "sequential_c");
1334        test_pipeline!(D, "sequential_d");
1335
1336        let mut conn = store.connect().await.unwrap();
1337        set_committer_watermark(&mut conn, A::NAME, 5).await;
1338        set_committer_watermark(&mut conn, B::NAME, 10).await;
1339        set_committer_watermark(&mut conn, C::NAME, 15).await;
1340        set_committer_watermark(&mut conn, D::NAME, 20).await;
1341
1342        let indexer_args = IndexerArgs {
1343            first_checkpoint: Some(3),
1344            last_checkpoint: Some(29),
1345            ..Default::default()
1346        };
1347        let (mut indexer, _temp_dir) =
1348            create_test_indexer(store.clone(), indexer_args, &registry, Some((30, 1))).await;
1349
1350        add_concurrent(&mut indexer, A).await;
1351        add_concurrent(&mut indexer, B).await;
1352        add_sequential(&mut indexer, C).await;
1353        add_sequential(&mut indexer, D).await;
1354
1355        let ingestion_metrics = indexer.ingestion_metrics().clone();
1356        let metrics = indexer.indexer_metrics().clone();
1357        indexer.run().await.unwrap().join().await.unwrap();
1358
1359        assert_eq!(
1360            ingestion_metrics
1361                .total_ingested_checkpoints
1362                .with_label_values(&["0"])
1363                .get(),
1364            24
1365        );
1366        assert_out_of_order!(metrics, A::NAME, 0);
1367        assert_out_of_order!(metrics, B::NAME, 5);
1368        assert_out_of_order!(metrics, C::NAME, 10);
1369        assert_out_of_order!(metrics, D::NAME, 15);
1370    }
1371
1372    // test ingestion, some pipelines missing watermarks, no first_checkpoint provided
1373    #[tokio::test]
1374    async fn test_indexer_ingestion_missing_watermarks_no_first_checkpoint() {
1375        let registry = Registry::new();
1376        let store = FallibleMockStore::default();
1377
1378        test_pipeline!(A, "concurrent_a");
1379        test_pipeline!(B, "concurrent_b");
1380        test_pipeline!(C, "sequential_c");
1381        test_pipeline!(D, "sequential_d");
1382
1383        let mut conn = store.connect().await.unwrap();
1384        set_committer_watermark(&mut conn, B::NAME, 10).await;
1385        set_committer_watermark(&mut conn, C::NAME, 15).await;
1386        set_committer_watermark(&mut conn, D::NAME, 20).await;
1387
1388        let indexer_args = IndexerArgs {
1389            last_checkpoint: Some(29),
1390            ..Default::default()
1391        };
1392        let (mut indexer, _temp_dir) =
1393            create_test_indexer(store.clone(), indexer_args, &registry, Some((30, 1))).await;
1394
1395        add_concurrent(&mut indexer, A).await;
1396        add_concurrent(&mut indexer, B).await;
1397        add_sequential(&mut indexer, C).await;
1398        add_sequential(&mut indexer, D).await;
1399
1400        let ingestion_metrics = indexer.ingestion_metrics().clone();
1401        let metrics = indexer.indexer_metrics().clone();
1402        indexer.run().await.unwrap().join().await.unwrap();
1403
1404        assert_eq!(
1405            ingestion_metrics
1406                .total_ingested_checkpoints
1407                .with_label_values(&["0"])
1408                .get(),
1409            30
1410        );
1411        assert_out_of_order!(metrics, A::NAME, 0);
1412        assert_out_of_order!(metrics, B::NAME, 11);
1413        assert_out_of_order!(metrics, C::NAME, 16);
1414        assert_out_of_order!(metrics, D::NAME, 21);
1415    }
1416
1417    // test ingestion, some pipelines missing watermarks, use first_checkpoint
1418    #[tokio::test]
1419    async fn test_indexer_ingestion_use_first_checkpoint() {
1420        let registry = Registry::new();
1421        let store = FallibleMockStore::default();
1422
1423        test_pipeline!(A, "concurrent_a");
1424        test_pipeline!(B, "concurrent_b");
1425        test_pipeline!(C, "sequential_c");
1426        test_pipeline!(D, "sequential_d");
1427
1428        let mut conn = store.connect().await.unwrap();
1429        set_committer_watermark(&mut conn, B::NAME, 10).await;
1430        set_committer_watermark(&mut conn, C::NAME, 15).await;
1431        set_committer_watermark(&mut conn, D::NAME, 20).await;
1432
1433        let indexer_args = IndexerArgs {
1434            first_checkpoint: Some(10),
1435            last_checkpoint: Some(29),
1436            ..Default::default()
1437        };
1438        let (mut indexer, _temp_dir) =
1439            create_test_indexer(store.clone(), indexer_args, &registry, Some((30, 1))).await;
1440
1441        add_concurrent(&mut indexer, A).await;
1442        add_concurrent(&mut indexer, B).await;
1443        add_sequential(&mut indexer, C).await;
1444        add_sequential(&mut indexer, D).await;
1445
1446        let ingestion_metrics = indexer.ingestion_metrics().clone();
1447        let metrics = indexer.indexer_metrics().clone();
1448        indexer.run().await.unwrap().join().await.unwrap();
1449
1450        assert_eq!(
1451            ingestion_metrics
1452                .total_ingested_checkpoints
1453                .with_label_values(&["0"])
1454                .get(),
1455            20
1456        );
1457        assert_out_of_order!(metrics, A::NAME, 0);
1458        assert_out_of_order!(metrics, B::NAME, 1);
1459        assert_out_of_order!(metrics, C::NAME, 6);
1460        assert_out_of_order!(metrics, D::NAME, 11);
1461    }
1462
1463    #[tokio::test]
1464    async fn test_init_watermark_concurrent_no_first_checkpoint() {
1465        let (committer_watermark, pruner_watermark) = test_init_watermark(None, true).await;
1466        assert_eq!(committer_watermark, None);
1467        assert_eq!(pruner_watermark, None);
1468    }
1469
1470    #[tokio::test]
1471    async fn test_init_watermark_concurrent_first_checkpoint_0() {
1472        let (committer_watermark, pruner_watermark) = test_init_watermark(Some(0), true).await;
1473        assert_eq!(committer_watermark, None);
1474        assert_eq!(pruner_watermark, None);
1475    }
1476
1477    #[tokio::test]
1478    async fn test_init_watermark_concurrent_first_checkpoint_1() {
1479        let (committer_watermark, pruner_watermark) = test_init_watermark(Some(1), true).await;
1480
1481        let committer_watermark = committer_watermark.unwrap();
1482        assert_eq!(committer_watermark.checkpoint_hi_inclusive, 0);
1483
1484        let pruner_watermark = pruner_watermark.unwrap();
1485        assert_eq!(pruner_watermark.reader_lo, 1);
1486        assert_eq!(pruner_watermark.pruner_hi, 1);
1487    }
1488
1489    #[tokio::test]
1490    async fn test_init_watermark_sequential() {
1491        let (committer_watermark, pruner_watermark) = test_init_watermark(Some(1), false).await;
1492
1493        let committer_watermark = committer_watermark.unwrap();
1494        assert_eq!(committer_watermark.checkpoint_hi_inclusive, 0);
1495
1496        let pruner_watermark = pruner_watermark.unwrap();
1497        assert_eq!(pruner_watermark.reader_lo, 1);
1498        assert_eq!(pruner_watermark.pruner_hi, 1);
1499    }
1500
1501    #[tokio::test]
1502    async fn test_multiple_sequential_pipelines_next_checkpoint() {
1503        let registry = Registry::new();
1504        let store = FallibleMockStore::default();
1505
1506        let mut conn = store.connect().await.unwrap();
1507        set_committer_watermark(&mut conn, MockHandler::NAME, 10).await;
1508        set_committer_watermark(&mut conn, SequentialHandler::NAME, 5).await;
1509
1510        let indexer_args = IndexerArgs {
1511            first_checkpoint: None,
1512            last_checkpoint: Some(19),
1513            pipeline: vec![],
1514            ..Default::default()
1515        };
1516        let (mut indexer, _temp_dir) =
1517            create_test_indexer(store.clone(), indexer_args, &registry, Some((20, 2))).await;
1518
1519        // Add first sequential pipeline
1520        add_sequential(&mut indexer, MockHandler).await;
1521
1522        // Verify next_sequential_checkpoint is set correctly (10 + 1 = 11)
1523        assert_eq!(
1524            indexer.next_sequential_checkpoint(),
1525            Some(11),
1526            "next_sequential_checkpoint should be 11"
1527        );
1528
1529        // Add second sequential pipeline
1530        add_sequential(&mut indexer, SequentialHandler).await;
1531
1532        // Should change to 6 (minimum of 6 and 11)
1533        assert_eq!(
1534            indexer.next_sequential_checkpoint(),
1535            Some(6),
1536            "next_sequential_checkpoint should still be 6"
1537        );
1538
1539        // Run indexer to verify it can make progress past the initial hi and finish ingesting.
1540        indexer.run().await.unwrap().join().await.unwrap();
1541
1542        // Verify each pipeline made some progress independently
1543        let watermark1 = conn.committer_watermark(MockHandler::NAME).await.unwrap();
1544        let watermark2 = conn
1545            .committer_watermark(SequentialHandler::NAME)
1546            .await
1547            .unwrap();
1548
1549        assert_eq!(watermark1.unwrap().checkpoint_hi_inclusive, 19);
1550        assert_eq!(watermark2.unwrap().checkpoint_hi_inclusive, 19);
1551    }
1552
1553    /// When a tasked indexer is initialized such that a tasked pipeline is run with a
1554    /// `first_checkpoint` less than the main pipeline's reader_lo, the indexer will correctly skip
1555    /// committing checkpoints less than the main pipeline's reader watermark.
1556    #[tokio::test]
1557    async fn test_tasked_pipelines_ignore_below_main_reader_lo() {
1558        let registry = Registry::new();
1559        let store = FallibleMockStore::default();
1560
1561        // Mock the store as if we have a main pipeline with a committer watermark at `10` and a
1562        // reader watermark at `7`.
1563        let mut conn = store.connect().await.unwrap();
1564        set_committer_watermark(&mut conn, MockCheckpointSequenceNumberHandler::NAME, 10).await;
1565        conn.set_reader_watermark(MockCheckpointSequenceNumberHandler::NAME, 7)
1566            .await
1567            .unwrap();
1568
1569        // Start a tasked indexer that will ingest from checkpoint 0. Checkpoints 0 through 6 should
1570        // be ignored by the tasked indexer.
1571        let indexer_args = IndexerArgs {
1572            first_checkpoint: Some(0),
1573            last_checkpoint: Some(15),
1574            task: TaskArgs::tasked("task".to_string(), 10),
1575            ..Default::default()
1576        };
1577        let (mut tasked_indexer, _temp_dir) =
1578            create_test_indexer(store.clone(), indexer_args, &registry, Some((16, 2))).await;
1579
1580        add_concurrent(&mut tasked_indexer, MockCheckpointSequenceNumberHandler).await;
1581
1582        let ingestion_metrics = tasked_indexer.ingestion_metrics().clone();
1583        let metrics = tasked_indexer.indexer_metrics().clone();
1584
1585        tasked_indexer.run().await.unwrap().join().await.unwrap();
1586
1587        assert_eq!(
1588            ingestion_metrics
1589                .total_ingested_checkpoints
1590                .with_label_values(&["0"])
1591                .get(),
1592            16
1593        );
1594        assert_eq!(
1595            metrics
1596                .total_collector_skipped_checkpoints
1597                .get_metric_with_label_values(&[MockCheckpointSequenceNumberHandler::NAME])
1598                .unwrap()
1599                .get(),
1600            7
1601        );
1602        let data = store
1603            .data
1604            .get(MockCheckpointSequenceNumberHandler::NAME)
1605            .unwrap();
1606        assert_eq!(data.len(), 9);
1607        for i in 0..7 {
1608            assert!(data.get(&i).is_none());
1609        }
1610        for i in 7..16 {
1611            assert!(data.get(&i).is_some());
1612        }
1613    }
1614
1615    /// Tasked pipelines can run ahead of the main pipeline's committer watermark.
1616    #[tokio::test]
1617    async fn test_tasked_pipelines_surpass_main_pipeline_committer_hi() {
1618        let registry = Registry::new();
1619        let store = FallibleMockStore::default();
1620
1621        let mut conn = store.connect().await.unwrap();
1622        set_committer_watermark(&mut conn, "test", 10).await;
1623        conn.set_reader_watermark("test", 5).await.unwrap();
1624
1625        // Start a tasked indexer that will ingest from checkpoint 9 and go past the main pipeline's
1626        // watermarks.
1627        let indexer_args = IndexerArgs {
1628            first_checkpoint: Some(9),
1629            last_checkpoint: Some(25),
1630            task: TaskArgs::tasked("task".to_string(), 10),
1631            ..Default::default()
1632        };
1633        let (mut tasked_indexer, _temp_dir) =
1634            create_test_indexer(store.clone(), indexer_args, &registry, Some((26, 2))).await;
1635
1636        add_concurrent(&mut tasked_indexer, MockCheckpointSequenceNumberHandler).await;
1637
1638        let ingestion_metrics = tasked_indexer.ingestion_metrics().clone();
1639        let metrics = tasked_indexer.indexer_metrics().clone();
1640
1641        tasked_indexer.run().await.unwrap().join().await.unwrap();
1642
1643        assert_eq!(
1644            ingestion_metrics
1645                .total_ingested_checkpoints
1646                .with_label_values(&["0"])
1647                .get(),
1648            17
1649        );
1650        assert_out_of_order!(metrics, "test", 0);
1651        assert_eq!(
1652            metrics
1653                .total_collector_skipped_checkpoints
1654                .get_metric_with_label_values(&[MockCheckpointSequenceNumberHandler::NAME])
1655                .unwrap()
1656                .get(),
1657            0
1658        );
1659
1660        let data = store.data.get("test").unwrap();
1661        assert_eq!(data.len(), 17);
1662        for i in 0..9 {
1663            assert!(data.get(&i).is_none());
1664        }
1665        for i in 9..26 {
1666            assert!(data.get(&i).is_some());
1667        }
1668        let main_pipeline_watermark = store.watermark("test").unwrap();
1669        // assert that the main pipeline's watermarks are not updated
1670        assert_eq!(main_pipeline_watermark.checkpoint_hi_inclusive, Some(10));
1671        assert_eq!(main_pipeline_watermark.reader_lo, 5);
1672        let tasked_pipeline_watermark = store.watermark("test@task").unwrap();
1673        assert_eq!(tasked_pipeline_watermark.checkpoint_hi_inclusive, Some(25));
1674        assert_eq!(tasked_pipeline_watermark.reader_lo, 9);
1675    }
1676
1677    /// Test that when the collector observes `reader_lo = X`, that all checkpoints >= X will be
1678    /// committed, and any checkpoints inflight < X will be skipped.
1679    #[tokio::test]
1680    async fn test_tasked_pipelines_skip_checkpoints_trailing_main_reader_lo() {
1681        let registry = Registry::new();
1682        let store = FallibleMockStore::default();
1683        let mut conn = store.connect().await.unwrap();
1684        // Set the main pipeline watermark.
1685        set_committer_watermark(&mut conn, ControllableHandler::NAME, 11).await;
1686
1687        // Generate 500 checkpoints upfront, for the indexer to process all at once.
1688        let indexer_args = IndexerArgs {
1689            first_checkpoint: Some(0),
1690            last_checkpoint: Some(500),
1691            task: TaskArgs::tasked("task".to_string(), 10 /* reader_interval_ms */),
1692            ..Default::default()
1693        };
1694        let (mut tasked_indexer, _temp_dir) =
1695            create_test_indexer(store.clone(), indexer_args, &registry, Some((501, 2))).await;
1696        let mut allow_process = 10;
1697        // Limit the pipeline to process only checkpoints `[0, 10]`.
1698        let (controllable_handler, process_below) = ControllableHandler::with_limit(allow_process);
1699        let _ = tasked_indexer
1700            .concurrent_pipeline(
1701                controllable_handler,
1702                ConcurrentConfig {
1703                    committer: CommitterConfig {
1704                        collect_interval_ms: 10,
1705                        watermark_interval_ms: 10,
1706                        ..Default::default()
1707                    },
1708                    // High fixed concurrency so all checkpoints can be processed
1709                    // concurrently despite out-of-order arrival.
1710                    fanout: Some(ConcurrencyConfig::Fixed { value: 501 }),
1711                    ..Default::default()
1712                },
1713            )
1714            .await;
1715        let metrics = tasked_indexer.indexer_metrics().clone();
1716
1717        let mut s_indexer = tasked_indexer.run().await.unwrap();
1718
1719        // Wait for pipeline to commit up to configured checkpoint 10 inclusive. With the main
1720        // pipeline `reader_lo` currently unset, all checkpoints are allowed and should be
1721        // committed.
1722        store
1723            .wait_for_watermark(
1724                &pipeline_task::<FallibleMockStore>(ControllableHandler::NAME, Some("task"))
1725                    .unwrap(),
1726                10,
1727                Duration::from_secs(10),
1728            )
1729            .await;
1730
1731        // Set the reader_lo to 250, simulating the main pipeline getting ahead. The
1732        // track_main_reader_lo task will eventually pick this up and update the atomic. The
1733        // collector reads from the atomic when it receives checkpoints, so we release checkpoints
1734        // one at a time until the collector_reader_lo metric shows the new value.
1735        conn.set_reader_watermark(ControllableHandler::NAME, 250)
1736            .await
1737            .unwrap();
1738
1739        let reader_lo = metrics
1740            .collector_reader_lo
1741            .with_label_values(&[ControllableHandler::NAME]);
1742
1743        // Send checkpoints one at a time at 10ms intervals. The tasked indexer has a reader refresh
1744        // interval of 10ms as well, so the collector should pick up the new reader_lo after a few
1745        // checkpoints have been processed.
1746        let mut interval = tokio::time::interval(Duration::from_millis(10));
1747        while reader_lo.get() != 250 {
1748            interval.tick().await;
1749            // allow_process is initialized to 11, bump to 11 for the next checkpoint
1750            allow_process += 1;
1751            assert!(
1752                allow_process <= 500,
1753                "Released all checkpoints but collector never observed new reader_lo"
1754            );
1755            process_below.send(allow_process).ok();
1756        }
1757
1758        // At this point, the collector has observed reader_lo = 250. Release all remaining
1759        // checkpoints. Guarantees:
1760        // - [0, 10]: committed (before reader_lo was set)
1761        // - [11, allow_process]: some committed, some skipped (timing-dependent during detection)
1762        // - (allow_process, 250): skipped (in-flight, filtered by collector)
1763        // - [250, 500]: committed (>= reader_lo)
1764        process_below.send(500).ok();
1765
1766        s_indexer.join().await.unwrap();
1767
1768        let data = store.data.get(ControllableHandler::NAME).unwrap();
1769
1770        // Checkpoints (allow_process, 250) must be skipped.
1771        for chkpt in (allow_process + 1)..250 {
1772            assert!(
1773                data.get(&chkpt).is_none(),
1774                "Checkpoint {chkpt} should have been skipped"
1775            );
1776        }
1777
1778        // Checkpoints >= reader_lo must be committed.
1779        for chkpt in 250..=500 {
1780            assert!(
1781                data.get(&chkpt).is_some(),
1782                "Checkpoint {chkpt} should have been committed (>= reader_lo)"
1783            );
1784        }
1785
1786        // Baseline: checkpoints [0, 10] were committed before reader_lo was set.
1787        for chkpt in 0..=10 {
1788            assert!(
1789                data.get(&chkpt).is_some(),
1790                "Checkpoint {chkpt} should have been committed (baseline)"
1791            );
1792        }
1793    }
1794
1795    /// Pipelines whose distances from the network tip are far apart are split into separate
1796    /// ingestion cohorts, each served by its own ingestion service, and the indexer completes
1797    /// even though the cohorts' ingestion services finish at different times.
1798    #[tokio::test]
1799    async fn test_multi_cohort_ingestion() {
1800        let registry = Registry::new();
1801        let store = FallibleMockStore::default();
1802
1803        test_pipeline!(A, "near_tip");
1804        test_pipeline!(B, "far_behind");
1805
1806        let temp_dir = multi_cohort_setup(&store, A::NAME, B::NAME).await;
1807
1808        let mut indexer = Indexer::new(
1809            store.clone(),
1810            IndexerArgs {
1811                last_checkpoint: Some(59),
1812                ..Default::default()
1813            },
1814            ClientArgs {
1815                ingestion: IngestionClientArgs {
1816                    local_ingestion_path: Some(temp_dir.path().to_owned()),
1817                    ..Default::default()
1818                },
1819                ..Default::default()
1820            },
1821            IngestionConfig::default(),
1822            None,
1823            &registry,
1824        )
1825        .await
1826        .unwrap();
1827
1828        add_concurrent(&mut indexer, A).await;
1829        add_concurrent(&mut indexer, B).await;
1830
1831        let ingestion_metrics = indexer.ingestion_metrics().clone();
1832        let indexer_metrics = indexer.indexer_metrics().clone();
1833        indexer.run().await.unwrap().join().await.unwrap();
1834
1835        // Both cohorts' services report to the shared metrics, each under its own cohort label.
1836        // Only the lagging cohort (1) had anything to ingest; the near-tip cohort (0)'s range
1837        // [100_101, 59] is empty.
1838        assert_eq!(
1839            ingestion_metrics
1840                .total_ingested_checkpoints
1841                .with_label_values(&["0"])
1842                .get(),
1843            0
1844        );
1845        assert_eq!(
1846            ingestion_metrics
1847                .total_ingested_checkpoints
1848                .with_label_values(&["1"])
1849                .get(),
1850            50
1851        );
1852
1853        // The near-tip pipeline received no checkpoints at all, proving it was not subscribed
1854        // to the lagging cohort's backfill (unsplit, it would have received all 50).
1855        let received = |name| {
1856            indexer_metrics
1857                .total_handler_checkpoints_received
1858                .get_metric_with_label_values(&[name])
1859                .unwrap()
1860                .get()
1861        };
1862        assert_eq!(received(A::NAME), 0);
1863        assert_eq!(received(B::NAME), 50);
1864
1865        // The lagging pipeline committed everything up to `last_checkpoint`, while the near-tip
1866        // pipeline had nothing to process.
1867        let watermark = store.watermark(B::NAME).unwrap();
1868        assert_eq!(watermark.checkpoint_hi_inclusive, Some(59));
1869        let watermark = store.watermark(A::NAME).unwrap();
1870        assert_eq!(watermark.checkpoint_hi_inclusive, Some(100_100));
1871    }
1872
1873    /// The client-driven constructor groups pipelines into cohorts too, with all cohorts
1874    /// sharing the supplied ingestion client and its metrics handle.
1875    #[tokio::test]
1876    async fn test_multi_cohort_ingestion_with_clients() {
1877        let registry = Registry::new();
1878        let store = FallibleMockStore::default();
1879
1880        test_pipeline!(A, "near_tip");
1881        test_pipeline!(B, "far_behind");
1882
1883        let temp_dir = multi_cohort_setup(&store, A::NAME, B::NAME).await;
1884
1885        let ingestion_metrics = IngestionMetrics::new(None, &registry);
1886        let ingestion_client = IngestionClient::new(
1887            IngestionClientArgs {
1888                local_ingestion_path: Some(temp_dir.path().to_owned()),
1889                ..Default::default()
1890            },
1891            ingestion_metrics.clone(),
1892        )
1893        .unwrap();
1894
1895        let mut indexer = Indexer::with_ingestion_clients(
1896            store.clone(),
1897            IndexerArgs {
1898                last_checkpoint: Some(59),
1899                ..Default::default()
1900            },
1901            ingestion_client,
1902            None,
1903            IngestionConfig::default(),
1904            None,
1905            &registry,
1906        )
1907        .await
1908        .unwrap();
1909
1910        add_concurrent(&mut indexer, A).await;
1911        add_concurrent(&mut indexer, B).await;
1912
1913        let indexer_metrics = indexer.indexer_metrics().clone();
1914        indexer.run().await.unwrap().join().await.unwrap();
1915
1916        // Both cohorts' services report to the one shared metrics handle, each under its own
1917        // cohort label, and only the lagging cohort (1) ingested checkpoints.
1918        assert_eq!(
1919            ingestion_metrics
1920                .total_ingested_checkpoints
1921                .with_label_values(&["0"])
1922                .get(),
1923            0
1924        );
1925        assert_eq!(
1926            ingestion_metrics
1927                .total_ingested_checkpoints
1928                .with_label_values(&["1"])
1929                .get(),
1930            50
1931        );
1932        assert_eq!(
1933            indexer_metrics
1934                .total_handler_checkpoints_received
1935                .get_metric_with_label_values(&[A::NAME])
1936                .unwrap()
1937                .get(),
1938            0
1939        );
1940
1941        let watermark = store.watermark(B::NAME).unwrap();
1942        assert_eq!(watermark.checkpoint_hi_inclusive, Some(59));
1943    }
1944
1945    /// Hard isolation: a pipeline at the tip runs to completion even while a far-behind pipeline in
1946    /// a separate cohort is wedged and backpressuring its own ingestion service. Before cohorts,
1947    /// both pipelines shared one ingestion service, so the far-behind pipeline's full subscriber
1948    /// channel would have throttled the shared broadcaster and held the tip pipeline back too.
1949    #[tokio::test]
1950    async fn test_near_tip_cohort_progresses_while_far_behind_stalls() {
1951        const TIP: u64 = 100;
1952
1953        let registry = Registry::new();
1954        let store = FallibleMockStore::default();
1955
1956        test_pipeline!(A, "near_tip");
1957
1958        // The far-behind pipeline's handler blocks every checkpoint in its range (all > 9), so its
1959        // cohort's ingestion service fills its subscriber channel and backpressures to a halt. Keep
1960        // the release sender alive for the whole test -- dropping it would unblock the handler.
1961        let (far_behind, _release) = ControllableHandler::with_limit(9);
1962
1963        let temp_dir = init_ingestion_dir(Some(TIP));
1964        let mut conn = store.connect().await.unwrap();
1965        set_committer_watermark(&mut conn, A::NAME, 94).await; // near tip: resumes at 95
1966        set_committer_watermark(&mut conn, ControllableHandler::NAME, 9).await; // far behind: resumes at 10
1967        synthetic_ingestion::generate_ingestion(synthetic_ingestion::Config {
1968            ingestion_dir: temp_dir.path().to_owned(),
1969            starting_checkpoint: 0,
1970            num_checkpoints: TIP + 1, // checkpoints [0, TIP]
1971            checkpoint_size: 1,
1972        })
1973        .await;
1974
1975        let mut indexer = Indexer::new(
1976            store.clone(),
1977            IndexerArgs {
1978                last_checkpoint: Some(TIP),
1979                ..Default::default()
1980            },
1981            ClientArgs {
1982                ingestion: IngestionClientArgs {
1983                    local_ingestion_path: Some(temp_dir.path().to_owned()),
1984                    ..Default::default()
1985                },
1986                ..Default::default()
1987            },
1988            // A small cohort boundary splits the near-tip and far-behind pipelines into separate
1989            // cohorts without having to generate tens of thousands of checkpoints.
1990            IngestionConfig {
1991                min_cohort_boundary: 10,
1992                ..Default::default()
1993            },
1994            None,
1995            &registry,
1996        )
1997        .await
1998        .unwrap();
1999
2000        // Near-tip pipeline: commit promptly so its watermark advances without waiting on the
2001        // default collector/watermark intervals.
2002        indexer
2003            .concurrent_pipeline(
2004                A,
2005                ConcurrentConfig {
2006                    committer: CommitterConfig {
2007                        collect_interval_ms: 10,
2008                        watermark_interval_ms: 10,
2009                        ..Default::default()
2010                    },
2011                    ..Default::default()
2012                },
2013            )
2014            .await
2015            .unwrap();
2016        // Far-behind pipeline: its handler blocks, so it never commits regardless of config.
2017        add_concurrent(&mut indexer, far_behind).await;
2018
2019        let service = indexer.run().await.unwrap();
2020
2021        // The near-tip cohort ingests [95, 100] and commits to completion even though the
2022        // far-behind cohort is wedged. Were the two not isolated, the far-behind pipeline's
2023        // backpressure would stall this too and the wait would time out.
2024        store
2025            .wait_for_watermark(A::NAME, TIP, Duration::from_secs(10))
2026            .await;
2027
2028        // The far-behind pipeline made no progress: still at its initial watermark, nothing
2029        // committed -- it is stalled inside its own cohort, not holding the tip pipeline back.
2030        assert_eq!(
2031            store
2032                .watermark(ControllableHandler::NAME)
2033                .unwrap()
2034                .checkpoint_hi_inclusive,
2035            Some(9),
2036        );
2037        assert!(store.data.get(ControllableHandler::NAME).is_none());
2038
2039        // The merged service never completes on its own while a cohort is wedged; drop aborts it.
2040        drop(service);
2041    }
2042}