1pub mod balance;
21pub mod checkpoint_broadcast;
22pub mod checkpoint_contents;
23pub mod checkpoint_seq_by_digest;
24pub mod checkpoint_summary;
25pub mod effects;
26pub mod epochs;
27pub mod event_bitmap;
28pub mod events;
29pub mod object_by_owner;
30pub mod object_by_type;
31pub mod object_version_by_checkpoint;
32pub mod objects;
33pub mod package_versions;
34pub mod pruner;
35pub mod restore;
36pub mod transaction_bitmap;
37pub mod transactions;
38pub mod tx_metadata_by_seq;
39pub mod tx_seq_by_digest;
40
41use std::collections::BTreeMap;
42use std::collections::HashSet;
43use std::collections::btree_map::Entry;
44use std::path::Path;
45use std::sync::Arc;
46
47use anyhow::Context as _;
48use prometheus::Registry;
49use sui_consistent_store::Db;
50use sui_consistent_store::DbOptions;
51use sui_consistent_store::PipelineTaskKey;
52use sui_consistent_store::Synchronizer;
53use sui_consistent_store::restore_state;
54use sui_indexer_alt_framework as framework;
55use sui_indexer_alt_framework::IndexerArgs;
56use sui_indexer_alt_framework::ingestion::ArcStreamingClient;
57use sui_indexer_alt_framework::ingestion::IngestionConfig;
58use sui_indexer_alt_framework::ingestion::ingestion_client::IngestionClient;
59use sui_indexer_alt_framework::pipeline::CommitterConfig;
60use sui_indexer_alt_framework::pipeline::sequential::SequentialConfig;
61use sui_indexer_alt_framework::pipeline::sequential::{self};
62use sui_indexer_alt_framework::service::Service;
63use sui_types::base_types::ObjectID;
64use sui_types::digests::ObjectDigest;
65use sui_types::effects::TransactionEffectsAPI;
66use sui_types::full_checkpoint_content::Checkpoint;
67use sui_types::object::Object;
68
69use crate::RpcStoreSchema;
70use crate::config::PipelineLayer;
71use crate::config::PrunerConfig;
72use crate::indexer::pruner::PrunerMetrics;
73use crate::indexer::restore::LIVE_COHORT;
74
75pub const METRICS_PREFIX: &str = "rpc_store_indexer";
81
82pub type Schema = RpcStoreSchema;
85
86pub type Store = sui_consistent_store::Store<Schema>;
88
89pub fn first_tx_seq(checkpoint: &Checkpoint) -> u64 {
96 checkpoint.summary.network_total_transactions - checkpoint.transactions.len() as u64
97}
98
99pub fn tx_seq_at(checkpoint: &Checkpoint, i: usize) -> u64 {
102 first_tx_seq(checkpoint) + i as u64
103}
104
105pub fn checkpoint_input_objects(
116 checkpoint: &Checkpoint,
117) -> anyhow::Result<BTreeMap<ObjectID, (&Object, ObjectDigest)>> {
118 let mut from_this_checkpoint = HashSet::new();
119 let mut input_objects = BTreeMap::new();
120 for tx in &checkpoint.transactions {
121 let input_objects_map: BTreeMap<_, _> = tx
122 .input_objects(&checkpoint.object_set)
123 .map(|obj| ((obj.id(), obj.version()), obj))
124 .collect();
125
126 for change in tx.effects.object_changes() {
127 let id = change.id;
128
129 let Some(version) = change.input_version else {
130 continue;
131 };
132
133 if from_this_checkpoint.contains(&id) {
134 continue;
135 }
136
137 let Entry::Vacant(entry) = input_objects.entry(id) else {
138 continue;
139 };
140
141 let input_object = *input_objects_map
142 .get(&(id, version))
143 .with_context(|| format!("{id} at {version} in effects, not in input_objects"))?;
144
145 let digest = change.input_digest.unwrap_or_else(|| input_object.digest());
148 entry.insert((input_object, digest));
149 }
150
151 for change in tx.effects.object_changes() {
152 if change.output_version.is_some() {
153 from_this_checkpoint.insert(change.id);
154 }
155 }
156 }
157 Ok(input_objects)
158}
159
160pub fn checkpoint_output_objects(
169 checkpoint: &Checkpoint,
170) -> anyhow::Result<BTreeMap<ObjectID, (&Object, ObjectDigest)>> {
171 let mut output_objects = BTreeMap::new();
172 for tx in &checkpoint.transactions {
173 let output_objects_map: BTreeMap<_, _> = tx
174 .output_objects(&checkpoint.object_set)
175 .map(|obj| ((obj.id(), obj.version()), obj))
176 .collect();
177
178 for change in tx.effects.object_changes() {
179 let id = change.id;
180
181 output_objects.remove(&id);
183
184 let (Some(version), Some(digest)) = (change.output_version, change.output_digest)
185 else {
186 continue;
187 };
188
189 let output_object = *output_objects_map
190 .get(&(id, version))
191 .with_context(|| format!("{id} at {version} in effects, not in output_objects"))?;
192
193 output_objects.insert(id, (output_object, digest));
194 }
195 }
196 Ok(output_objects)
197}
198
199pub struct Indexer {
233 indexer: framework::Indexer<Store>,
234
235 sync: Synchronizer,
239
240 pruner: Option<(PrunerConfig, Arc<PrunerMetrics>)>,
245}
246
247impl Indexer {
248 #[allow(clippy::too_many_arguments)]
267 pub async fn new(
268 path: impl AsRef<Path>,
269 indexer_args: IndexerArgs,
270 ingestion_client: IngestionClient,
271 streaming_client: Option<ArcStreamingClient>,
272 gap_fill_client: Option<IngestionClient>,
273 consistency_config: crate::config::ConsistencyConfig,
274 pruner_config: Option<PrunerConfig>,
275 ingestion_config: IngestionConfig,
276 db_options: DbOptions,
277 registry: &Registry,
278 ) -> anyhow::Result<Self> {
279 let (db, schema) = Db::open::<RpcStoreSchema>(path, db_options)
280 .context("Failed to open sui-rpc-store database")?;
281 let store = sui_consistent_store::Store::new(db, Arc::new(schema));
282 Self::from_store(
283 store,
284 indexer_args,
285 ingestion_client,
286 streaming_client,
287 gap_fill_client,
288 consistency_config,
289 pruner_config,
290 ingestion_config,
291 registry,
292 )
293 .await
294 }
295
296 #[allow(clippy::too_many_arguments)]
307 pub async fn from_store(
308 store: Store,
309 indexer_args: IndexerArgs,
310 ingestion_client: IngestionClient,
311 streaming_client: Option<ArcStreamingClient>,
312 gap_fill_client: Option<IngestionClient>,
313 consistency_config: crate::config::ConsistencyConfig,
314 pruner_config: Option<PrunerConfig>,
315 ingestion_config: IngestionConfig,
316 registry: &Registry,
317 ) -> anyhow::Result<Self> {
318 let metrics_prefix = Some(METRICS_PREFIX);
319
320 anyhow::ensure!(
321 indexer_args.pipeline.is_empty(),
322 "The rpc-store indexer selects pipelines through its pipeline layer; \
323 IndexerArgs::pipeline must be empty, got {:?}",
324 indexer_args.pipeline,
325 );
326
327 let sync = Synchronizer::new(
328 store.db().clone(),
329 consistency_config.buffer_size,
330 indexer_args.first_checkpoint,
331 );
332
333 let indexer = framework::Indexer::with_ingestion_clients(
334 store,
335 indexer_args,
336 ingestion_client,
337 streaming_client,
338 gap_fill_client,
339 ingestion_config,
340 metrics_prefix,
341 registry,
342 )
343 .await
344 .context("Failed to construct framework indexer")?;
345
346 let pruner = pruner_config.map(|config| (config, PrunerMetrics::new(None, registry)));
349
350 Ok(Self {
351 indexer,
352 sync,
353 pruner,
354 })
355 }
356
357 pub fn store(&self) -> &Store {
361 self.indexer.store()
362 }
363
364 pub fn pipelines(&self) -> impl Iterator<Item = &'static str> + '_ {
370 self.indexer.pipelines()
371 }
372
373 pub async fn add_pipelines(
383 &mut self,
384 layer: PipelineLayer,
385 committer: CommitterConfig,
386 ) -> anyhow::Result<()> {
387 let PipelineLayer {
388 epochs,
389 checkpoint_summary,
390 checkpoint_contents,
391 checkpoint_seq_by_digest,
392 transactions,
393 tx_seq_by_digest,
394 tx_metadata_by_seq,
395 effects,
396 events,
397 objects,
398 object_version_by_checkpoint,
399 object_by_owner,
400 object_by_type,
401 balance,
402 package_versions,
403 transaction_bitmap,
404 event_bitmap,
405 } = layer;
406
407 macro_rules! add {
408 ($handler:expr, $cfg:expr) => {
409 if let Some(layer) = $cfg {
410 self.sequential_pipeline(
411 $handler,
412 SequentialConfig {
413 committer: layer.finish(committer.clone()),
414 max_batch_checkpoints: Some(1),
419 ..Default::default()
420 },
421 )
422 .await?
423 }
424 };
425 }
426
427 add!(self::epochs::Epochs, epochs);
429 add!(
430 self::checkpoint_summary::CheckpointSummary,
431 checkpoint_summary
432 );
433 add!(
434 self::checkpoint_contents::CheckpointContents,
435 checkpoint_contents
436 );
437 add!(
438 self::checkpoint_seq_by_digest::CheckpointSeqByDigest,
439 checkpoint_seq_by_digest
440 );
441 add!(self::transactions::Transactions, transactions);
442 add!(self::tx_seq_by_digest::TxSeqByDigest, tx_seq_by_digest);
443 add!(
444 self::tx_metadata_by_seq::TxMetadataBySeq,
445 tx_metadata_by_seq
446 );
447 add!(self::effects::Effects, effects);
448 add!(self::events::Events, events);
449 add!(self::objects::Objects, objects);
450 let ovbc_anchor = self::object_version_by_checkpoint::restored_anchor(self.store().db())?;
455 add!(
456 self::object_version_by_checkpoint::ObjectVersionByCheckpoint::with_anchor(ovbc_anchor),
457 object_version_by_checkpoint
458 );
459
460 add!(self::object_by_owner::ObjectByOwner, object_by_owner);
462 add!(self::object_by_type::ObjectByType, object_by_type);
463 add!(self::balance::Balance, balance);
464 add!(self::package_versions::PackageVersions, package_versions);
465 add!(
466 self::transaction_bitmap::TransactionBitmap,
467 transaction_bitmap
468 );
469 add!(self::event_bitmap::EventBitmap, event_bitmap);
470
471 Ok(())
472 }
473
474 pub async fn add_checkpoint_broadcast(
490 &mut self,
491 sender: tokio::sync::broadcast::Sender<Arc<Checkpoint>>,
492 committer: CommitterConfig,
493 ) -> anyhow::Result<()> {
494 self.sequential_pipeline(
495 self::checkpoint_broadcast::CheckpointBroadcast::new(sender),
496 SequentialConfig {
497 committer,
498 max_batch_checkpoints: Some(1),
499 ..Default::default()
500 },
501 )
502 .await
503 }
504
505 async fn sequential_pipeline<H>(
521 &mut self,
522 handler: H,
523 config: SequentialConfig,
524 ) -> anyhow::Result<()>
525 where
526 H: sequential::Handler<Store = Store> + Send + Sync + 'static,
527 {
528 let restore_state = self
529 .store()
530 .db()
531 .framework()
532 .restore
533 .get(&PipelineTaskKey::new(H::NAME))
534 .with_context(|| format!("Reading restore state for pipeline {:?}", H::NAME))?;
535
536 if let Some(state) = restore_state.as_ref().and_then(|s| s.state.as_ref()) {
537 match state {
538 restore_state::State::InProgress(_) => {
539 anyhow::bail!("Restoration in progress for pipeline {:?}", H::NAME);
540 }
541 restore_state::State::Complete(_) => {
542 }
544 }
545 }
546
547 if LIVE_COHORT.contains(&H::NAME) {
548 self.sync.register_required_pipeline(H::NAME)
549 } else {
550 self.sync.register_pipeline(H::NAME)
551 }
552 .with_context(|| format!("Failed to add pipeline {:?} to synchronizer", H::NAME))?;
553
554 self.indexer
555 .sequential_pipeline(handler, config)
556 .await
557 .with_context(|| format!("Failed to add pipeline {:?} to indexer", H::NAME))?;
558
559 Ok(())
560 }
561
562 pub async fn run(self) -> anyhow::Result<Service> {
566 let Self {
567 indexer,
568 sync,
569 pruner: pruner_setup,
570 } = self;
571
572 let store = indexer.store().clone();
575
576 let mut sync_join_set = indexer
577 .store()
578 .install_sync(sync)
579 .context("Failed to install synchronizer onto store")?;
580
581 let s_sync = Service::new().spawn(async move {
587 while let Some(res) = sync_join_set.join_next().await {
588 res.context("Synchronizer task panicked")??;
589 }
590 Ok(())
591 });
592
593 let s_indexer = indexer.run().await?;
594 let mut service = s_indexer.attach(s_sync);
595
596 if let Some((config, metrics)) = pruner_setup {
600 let s_pruner = pruner::start_pruner(store, config, metrics)
601 .context("Failed to start the rpc-store pruner")?;
602 service = service.attach(s_pruner);
603 }
604
605 Ok(service)
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use async_trait::async_trait;
612 use sui_indexer_alt_framework::ingestion::ingestion_client::CheckpointError;
613 use sui_indexer_alt_framework::ingestion::ingestion_client::CheckpointResult;
614 use sui_indexer_alt_framework::ingestion::ingestion_client::IngestionClientTrait;
615 use sui_indexer_alt_framework::metrics::IngestionMetrics;
616 use sui_types::digests::ChainIdentifier;
617
618 use super::*;
619
620 #[derive(Default)]
625 struct FixedIngestionClient {
626 checkpoints: BTreeMap<u64, Checkpoint>,
627 }
628
629 #[async_trait]
630 impl IngestionClientTrait for FixedIngestionClient {
631 async fn chain_id(&self) -> anyhow::Result<ChainIdentifier> {
632 Ok(ChainIdentifier::from(
633 sui_types::digests::CheckpointDigest::new([0u8; 32]),
634 ))
635 }
636
637 async fn checkpoint(&self, checkpoint: u64) -> CheckpointResult {
638 self.checkpoints
639 .get(&checkpoint)
640 .cloned()
641 .ok_or(CheckpointError::NotFound)
642 }
643
644 async fn latest_checkpoint_number(&self) -> anyhow::Result<u64> {
645 Ok(self.checkpoints.keys().next_back().copied().unwrap_or(0))
646 }
647 }
648
649 async fn build_indexer(layer: PipelineLayer) -> Indexer {
650 let dir = tempfile::tempdir().unwrap();
651 let registry = Registry::new();
652 let ingestion_metrics = IngestionMetrics::new(Some(METRICS_PREFIX), ®istry);
653 let ingestion_client = IngestionClient::from_trait(
654 Arc::new(FixedIngestionClient::default()),
655 ingestion_metrics,
656 );
657 let mut indexer = Indexer::new(
658 dir.path().join("db"),
659 IndexerArgs::default(),
660 ingestion_client,
661 None,
662 None,
663 crate::config::ConsistencyConfig::default(),
664 None,
665 IngestionConfig::default(),
666 DbOptions::default(),
667 ®istry,
668 )
669 .await
670 .expect("Indexer::new");
671
672 indexer
673 .add_pipelines(layer, CommitterConfig::default())
674 .await
675 .expect("add_pipelines");
676
677 std::mem::forget(dir);
681 indexer
682 }
683
684 #[tokio::test]
695 async fn embedded_registers_only_cohort_pipelines() {
696 let indexer = build_indexer(PipelineLayer::embedded()).await;
697 let names: std::collections::BTreeSet<_> = indexer.pipelines().collect();
698 let expected: std::collections::BTreeSet<_> = crate::indexer::restore::LIVE_COHORT
699 .iter()
700 .chain(crate::indexer::restore::HISTORY_COHORT)
701 .copied()
702 .collect();
703 assert_eq!(names, expected);
704 }
705
706 #[tokio::test]
708 async fn all_registers_every_pipeline() {
709 let indexer = build_indexer(PipelineLayer::all()).await;
710 let names: std::collections::BTreeSet<_> = indexer.pipelines().collect();
711 assert_eq!(
712 names,
713 std::collections::BTreeSet::from([
714 "epochs",
716 "checkpoint_summary",
717 "checkpoint_contents",
718 "checkpoint_seq_by_digest",
719 "transactions",
720 "tx_seq_by_digest",
721 "tx_metadata_by_seq",
722 "effects",
723 "events",
724 "objects",
725 "object_version_by_checkpoint",
726 "object_by_owner",
728 "object_by_type",
729 "balance",
730 "package_versions",
731 "transaction_bitmap",
732 "event_bitmap",
733 ])
734 );
735 }
736
737 fn open_with_seeded_restore(
744 path: &std::path::Path,
745 pipeline: &str,
746 state: sui_consistent_store::RestoreState,
747 ) -> Store {
748 let (db, schema) = Db::open::<RpcStoreSchema>(path, DbOptions::default()).unwrap();
749 let framework = sui_consistent_store::FrameworkSchema::new(db.clone());
750 let mut batch = db.batch();
751 batch
752 .put(&framework.restore, &PipelineTaskKey::new(pipeline), &state)
753 .unwrap();
754 batch.commit().unwrap();
755 sui_consistent_store::Store::new(db, Arc::new(schema))
756 }
757
758 async fn build_indexer_with_store(store: Store) -> anyhow::Result<Indexer> {
759 let registry = Registry::new();
760 let ingestion_metrics = IngestionMetrics::new(Some(METRICS_PREFIX), ®istry);
761 let ingestion_client = IngestionClient::from_trait(
762 Arc::new(FixedIngestionClient::default()),
763 ingestion_metrics,
764 );
765 Indexer::from_store(
766 store,
767 IndexerArgs::default(),
768 ingestion_client,
769 None,
770 None,
771 crate::config::ConsistencyConfig::default(),
772 None,
773 IngestionConfig::default(),
774 ®istry,
775 )
776 .await
777 }
778
779 #[tokio::test]
780 async fn add_pipelines_refuses_pipeline_with_in_progress_restore() {
781 let dir = tempfile::tempdir().unwrap();
782 let in_progress = sui_consistent_store::RestoreState {
783 state: Some(restore_state::State::InProgress(
784 restore_state::InProgress::default(),
785 )),
786 };
787 let store = open_with_seeded_restore(&dir.path().join("db"), "balance", in_progress);
788
789 let mut indexer = build_indexer_with_store(store).await.unwrap();
790 let err = indexer
791 .add_pipelines(
792 PipelineLayer {
793 balance: Some(crate::config::CommitterLayer::default()),
794 ..PipelineLayer::default()
795 },
796 CommitterConfig::default(),
797 )
798 .await
799 .unwrap_err();
800 assert!(
801 format!("{err:#}").contains("Restoration in progress for pipeline"),
802 "expected restore-in-progress error, got: {err:#}",
803 );
804 }
805
806 #[tokio::test]
807 async fn add_pipelines_allows_pipeline_with_completed_restore() {
808 let dir = tempfile::tempdir().unwrap();
809 let complete = sui_consistent_store::RestoreState {
810 state: Some(restore_state::State::Complete(restore_state::Complete {
811 restored_at: 42,
812 })),
813 };
814 let store = open_with_seeded_restore(&dir.path().join("db"), "balance", complete);
815
816 let mut indexer = build_indexer_with_store(store).await.unwrap();
817 indexer
818 .add_pipelines(
819 PipelineLayer {
820 balance: Some(crate::config::CommitterLayer::default()),
821 ..PipelineLayer::default()
822 },
823 CommitterConfig::default(),
824 )
825 .await
826 .unwrap();
827 let names: std::collections::BTreeSet<_> = indexer.pipelines().collect();
828 assert_eq!(names, std::collections::BTreeSet::from(["balance"]));
829 }
830
831 const RESTART: u64 = 10;
834 const TIP: u64 = 13;
835
836 async fn run_until_tip_snapshot(
841 path: &std::path::Path,
842 watermarks: &[(&str, u64)],
843 ) -> (Db, Service) {
844 let (db, schema) = Db::open::<RpcStoreSchema>(path, DbOptions::default()).unwrap();
845 let framework = sui_consistent_store::FrameworkSchema::new(db.clone());
846 let mut batch = db.batch();
847 for &(pipeline, checkpoint) in watermarks {
848 batch
849 .put(
850 &framework.watermarks,
851 &PipelineTaskKey::new(pipeline),
852 &sui_consistent_store::Watermark::for_checkpoint(checkpoint),
853 )
854 .unwrap();
855 }
856 batch.commit().unwrap();
857 let store = sui_consistent_store::Store::new(db.clone(), Arc::new(schema));
858
859 let mut builder =
860 sui_types::test_checkpoint_data_builder::TestCheckpointBuilder::new(RESTART);
861 let checkpoints = (RESTART..=TIP)
862 .map(|checkpoint| (checkpoint, builder.build_checkpoint()))
863 .collect();
864
865 let registry = Registry::new();
866 let ingestion_metrics = IngestionMetrics::new(Some(METRICS_PREFIX), ®istry);
867 let ingestion_client = IngestionClient::from_trait(
868 Arc::new(FixedIngestionClient { checkpoints }),
869 ingestion_metrics,
870 );
871 let ingestion_config = IngestionConfig {
874 min_cohort_boundary: 1,
875 ..IngestionConfig::default()
876 };
877 let mut indexer = Indexer::from_store(
878 store,
879 IndexerArgs::default(),
880 ingestion_client,
881 None,
882 None,
883 crate::config::ConsistencyConfig::default(),
884 None,
885 ingestion_config,
886 ®istry,
887 )
888 .await
889 .unwrap();
890 indexer
891 .add_pipelines(
892 PipelineLayer {
893 object_by_owner: Some(crate::config::CommitterLayer::default()),
894 object_by_type: Some(crate::config::CommitterLayer::default()),
895 balance: Some(crate::config::CommitterLayer::default()),
896 tx_seq_by_digest: Some(crate::config::CommitterLayer::default()),
897 ..PipelineLayer::default()
898 },
899 CommitterConfig::default(),
900 )
901 .await
902 .unwrap();
903 let service = indexer.run().await.unwrap();
904
905 tokio::time::timeout(std::time::Duration::from_secs(60), async {
906 while db.at_snapshot(TIP).is_none() {
907 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
908 }
909 })
910 .await
911 .expect("live snapshots should reach the tip");
912
913 (db, service)
914 }
915
916 fn snapshot_watermark(db: &Db, checkpoint: u64, pipeline: &str) -> Option<u64> {
919 db.at_snapshot(checkpoint)
920 .unwrap()
921 .framework()
922 .watermarks
923 .get(&PipelineTaskKey::new(pipeline))
924 .unwrap()
925 .map(|w| w.checkpoint_hi_inclusive)
926 }
927
928 #[tokio::test]
929 async fn restart_with_uneven_live_watermarks_snapshots_only_complete_live_state() {
930 let dir = tempfile::tempdir().unwrap();
935 let (db, service) = run_until_tip_snapshot(
936 dir.path(),
937 &[
938 ("object_by_owner", RESTART),
939 ("object_by_type", RESTART),
940 ("balance", RESTART - 1),
941 ("tx_seq_by_digest", 0),
942 ],
943 )
944 .await;
945
946 assert_eq!(db.snapshot_range(), Some(RESTART..=TIP));
949 for checkpoint in RESTART..=TIP {
950 for pipeline in LIVE_COHORT {
951 assert_eq!(
952 snapshot_watermark(&db, checkpoint, pipeline),
953 Some(checkpoint),
954 "snapshot {checkpoint} is missing {pipeline}'s writes",
955 );
956 }
957 }
958
959 drop(service);
960 }
961
962 #[tokio::test]
963 async fn from_store_refuses_pipeline_filter() {
964 let dir = tempfile::tempdir().unwrap();
968 let (db, schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
969 let registry = Registry::new();
970 let ingestion_metrics = IngestionMetrics::new(Some(METRICS_PREFIX), ®istry);
971 let ingestion_client = IngestionClient::from_trait(
972 Arc::new(FixedIngestionClient::default()),
973 ingestion_metrics,
974 );
975 let Err(err) = Indexer::from_store(
976 sui_consistent_store::Store::new(db, Arc::new(schema)),
977 IndexerArgs {
978 pipeline: vec!["object_by_owner".to_owned()],
979 ..IndexerArgs::default()
980 },
981 ingestion_client,
982 None,
983 None,
984 crate::config::ConsistencyConfig::default(),
985 None,
986 IngestionConfig::default(),
987 ®istry,
988 )
989 .await
990 else {
991 panic!("a pipeline filter must be refused");
992 };
993 assert!(
994 format!("{err:#}").contains("IndexerArgs::pipeline must be empty"),
995 "unexpected error: {err:#}",
996 );
997 }
998}