Skip to main content

fuel_core_compression_service/
service.rs

1use crate::{
2    config::CompressionConfig,
3    errors::CompressionError,
4    metrics::CompressionMetricsManager,
5    ports::{
6        block_source::{
7            BlockAt,
8            BlockSource,
9            BlockWithMetadataExt,
10        },
11        canonical_height::CanonicalHeight,
12        compression_storage::{
13            CompressionStorage,
14            LatestHeight,
15            WriteCompressedBlock,
16        },
17        configuration::CompressionConfigProvider,
18    },
19    sync_state::{
20        SyncStateNotifier,
21        SyncStateObserver,
22        new_sync_state_channel,
23    },
24    temporal_registry::CompressionContext,
25};
26use fuel_core_compression::compress::compress;
27use fuel_core_services::{
28    RunnableService,
29    RunnableTask,
30    ServiceRunner,
31    StateWatcher,
32    TaskNextAction,
33    try_or_continue,
34};
35use fuel_core_storage::transactional::WriteTransaction;
36use fuel_core_types::{
37    blockchain::block::Block,
38    fuel_types::ChainId,
39};
40use futures::{
41    FutureExt,
42    StreamExt,
43};
44use std::time::Instant;
45
46/// Uninitialized compression service.
47pub struct UninitializedCompressionService<B, S, CH> {
48    /// The block source.
49    block_source: B,
50    /// The canonical height getter.
51    canonical_height: CH,
52    /// The compression storage.
53    storage: S,
54    /// The compression config.
55    config: CompressionConfig,
56    /// The sync notifier
57    sync_notifier: SyncStateNotifier,
58    /// metrics manager
59    metrics_manager: Option<CompressionMetricsManager>,
60}
61
62impl<B, S, CH> UninitializedCompressionService<B, S, CH> {
63    fn new(
64        block_source: B,
65        storage: S,
66        config: CompressionConfig,
67        canonical_height: CH,
68    ) -> Self {
69        let (sync_notifier, _) = new_sync_state_channel();
70
71        let metrics_manager = if config.metrics() {
72            Some(CompressionMetricsManager::new())
73        } else {
74            None
75        };
76
77        Self {
78            block_source,
79            canonical_height,
80            storage,
81            config,
82            sync_notifier,
83            metrics_manager,
84        }
85    }
86}
87
88/// The compression service.
89/// Responsible for subscribing to the l2 block stream,
90/// perform compression of the block, and store the compressed block.
91/// We don't need to share any data between this task and anything else yet(?)
92/// perhaps we want to expose a way to get the registry root
93pub struct CompressionService<S> {
94    /// The block stream.
95    block_stream: crate::ports::block_source::BlockStream,
96    /// The compression storage.
97    storage: S,
98    /// The compression config.
99    config: CompressionConfig,
100    /// The sync notifier
101    sync_notifier: SyncStateNotifier,
102    /// metrics manager,
103    metrics_manager: Option<CompressionMetricsManager>,
104}
105
106impl<S> CompressionService<S>
107where
108    S: CompressionStorage,
109{
110    fn new(
111        block_stream: crate::ports::block_source::BlockStream,
112        storage: S,
113        config: CompressionConfig,
114        sync_notifier: SyncStateNotifier,
115        metrics_manager: Option<CompressionMetricsManager>,
116    ) -> Self {
117        Self {
118            block_stream,
119            storage,
120            config,
121            sync_notifier,
122            metrics_manager,
123        }
124    }
125}
126
127/// reused by uninit and init task
128fn compress_block<S>(
129    storage: &mut S,
130    block: &Block,
131    config: &CompressionConfig,
132) -> crate::Result<usize>
133where
134    S: CompressionStorage,
135{
136    let mut storage_tx = storage.write_transaction();
137
138    // compress the block
139    let chain_id = config.chain_id();
140    let compression_context =
141        CompressionContext::create_from_block(&mut storage_tx, block, chain_id)
142            .map_err(CompressionError::FailedToCompressBlock)?;
143    let compressed_block = compress(&config.into(), compression_context, block)
144        .now_or_never()
145        .expect("The current implementation should resolve all futures instantly")
146        .map_err(crate::errors::CompressionError::FailedToCompressBlock)?;
147
148    let size_of_compressed_block =
149        storage_tx.write_compressed_block(block.header().height(), &compressed_block)?;
150
151    storage_tx
152        .commit()
153        .map_err(crate::errors::CompressionError::FailedToCommitTransaction)?;
154
155    Ok(size_of_compressed_block)
156}
157
158fn handle_new_block<S>(
159    storage: &mut S,
160    block: &Block,
161    config: &CompressionConfig,
162    sync_notifier: &SyncStateNotifier,
163    metrics_manager: &Option<CompressionMetricsManager>,
164) -> crate::Result<()>
165where
166    S: CompressionStorage,
167{
168    // set the status to not synced
169    sync_notifier
170        .send(crate::sync_state::SyncState::NotSynced)
171        .ok();
172    let height = (*block.header().height()).into();
173    // compress the block
174    if let Some(metrics_manager) = metrics_manager {
175        let (compressed_block_size, compression_duration) = {
176            let start = Instant::now();
177            let compressed_block_size = compress_block(storage, block, config)?;
178            (compressed_block_size, start.elapsed().as_secs_f64())
179        };
180
181        metrics_manager.record_compression_duration_ms(compression_duration);
182        metrics_manager.record_compressed_block_size(compressed_block_size);
183        metrics_manager.record_compression_block_height(height);
184    } else {
185        compress_block(storage, block, config)?;
186    }
187    // set the status to synced
188    sync_notifier
189        .send(crate::sync_state::SyncState::Synced(height))
190        .ok();
191
192    Ok(())
193}
194
195impl<S> CompressionService<S>
196where
197    S: CompressionStorage,
198{
199    fn handle_new_block(&mut self, block: &Block) -> crate::Result<()> {
200        handle_new_block(
201            &mut self.storage,
202            block,
203            &self.config,
204            &self.sync_notifier,
205            &self.metrics_manager,
206        )
207    }
208}
209
210#[derive(Debug)]
211enum SyncHeight {
212    StorageHeight(u32),
213    ConfiguredHeight(u32),
214    Genesis,
215}
216
217impl SyncHeight {
218    #[inline]
219    const fn value(&self) -> u32 {
220        match self {
221            SyncHeight::StorageHeight(height) => *height,
222            SyncHeight::ConfiguredHeight(height) => *height,
223            SyncHeight::Genesis => 0,
224        }
225    }
226
227    #[inline]
228    const fn is_from_storage(&self) -> bool {
229        matches!(self, SyncHeight::StorageHeight(_))
230    }
231}
232
233impl<B, S, CH> UninitializedCompressionService<B, S, CH>
234where
235    B: BlockSource,
236    S: CompressionStorage + LatestHeight,
237    CH: CanonicalHeight,
238{
239    async fn sync_previously_produced_blocks(
240        &mut self,
241        state_watcher: &StateWatcher,
242    ) -> crate::Result<()> {
243        loop {
244            // allows early exit if the service is stopping
245            let state = state_watcher.borrow();
246            if !state.starting() {
247                break;
248            }
249
250            let canonical_height = match self.canonical_height.get() {
251                Some(height) => height,
252                None => {
253                    // fuel-core started for first time,
254                    // no need to backfill blocks
255                    break;
256                }
257            };
258
259            let maybe_height =
260                match (self.storage.latest_height(), self.config.starting_height()) {
261                    (Some(height), _) => SyncHeight::StorageHeight(height),
262                    (None, Some(height)) => SyncHeight::ConfiguredHeight(height),
263                    (None, None) => SyncHeight::Genesis,
264                };
265
266            if canonical_height < maybe_height.value() {
267                tracing::error!(
268                    "Canonical height is less than fetched height: Canonical height: {:?}, Fetched height: {:?}",
269                    &canonical_height,
270                    &maybe_height
271                );
272                return Err(crate::errors::CompressionError::FailedToGetSyncStatus);
273            }
274
275            if canonical_height == maybe_height.value() && maybe_height.is_from_storage()
276            {
277                tracing::info!("Compression database is up to date");
278                break;
279            }
280
281            let height_to_sync = match maybe_height {
282                SyncHeight::Genesis => BlockAt::Genesis,
283                SyncHeight::StorageHeight(height) => {
284                    BlockAt::Specific(height.saturating_add(1))
285                }
286                SyncHeight::ConfiguredHeight(height) => BlockAt::Specific(height),
287            };
288
289            let block = self.block_source.get_block(height_to_sync).map_err(|err| {
290                CompressionError::FailedToGetBlock(format!(
291                    "during synchronization of canonical chain at height: {:?}: {}",
292                    height_to_sync, err
293                ))
294            })?;
295
296            handle_new_block(
297                &mut self.storage,
298                &block,
299                &self.config,
300                &self.sync_notifier,
301                &self.metrics_manager,
302            )?;
303        }
304
305        Ok(())
306    }
307}
308
309/// Shared data for the compression service.
310#[derive(Debug, Clone)]
311pub struct SharedData {
312    /// Allows to observe the sync state.
313    sync_observer: SyncStateObserver,
314}
315
316impl SharedData {
317    /// Waits until the compression service has synced
318    /// with the given block height
319    pub async fn await_synced_until(&self, block_height: &u32) -> crate::Result<()> {
320        let mut observer = self.sync_observer.clone();
321        loop {
322            if observer.borrow_and_update().is_synced_until(block_height) {
323                break;
324            }
325
326            observer
327                .changed()
328                .await
329                .map_err(|_| crate::errors::CompressionError::FailedToGetSyncStatus)?;
330        }
331
332        Ok(())
333    }
334}
335
336#[async_trait::async_trait]
337impl<B, S, CH> RunnableService for UninitializedCompressionService<B, S, CH>
338where
339    B: BlockSource,
340    S: CompressionStorage + LatestHeight,
341    CH: CanonicalHeight,
342{
343    const NAME: &'static str = "CompressionService";
344    type Task = CompressionService<S>;
345    type SharedData = SharedData;
346    type TaskParams = ();
347
348    fn shared_data(&self) -> Self::SharedData {
349        SharedData {
350            sync_observer: self.sync_notifier.subscribe(),
351        }
352    }
353
354    async fn into_task(
355        mut self,
356        state_watcher: &StateWatcher,
357        _params: Self::TaskParams,
358    ) -> anyhow::Result<Self::Task> {
359        self.sync_previously_produced_blocks(state_watcher).await?;
360
361        let compression_service = CompressionService::new(
362            self.block_source.subscribe(),
363            self.storage,
364            self.config,
365            self.sync_notifier,
366            self.metrics_manager,
367        );
368
369        Ok(compression_service)
370    }
371}
372
373impl<S> RunnableTask for CompressionService<S>
374where
375    S: CompressionStorage,
376{
377    async fn run(&mut self, watcher: &mut StateWatcher) -> TaskNextAction {
378        tokio::select! {
379            biased;
380
381            _ = watcher.while_started() => {
382                TaskNextAction::Stop
383            }
384
385            block_res = self.block_stream.next() => {
386                match block_res {
387                    None => {
388                        tracing::warn!("No blocks available");
389                        TaskNextAction::Stop
390                    }
391                    Some(block_with_metadata) => {
392                        tracing::debug!("Got new block: {:?}", &block_with_metadata.height());
393                        try_or_continue!(
394                            self.handle_new_block(block_with_metadata.block()),
395                            |e| tracing::error!("Error handling new block: {:?}", e)
396                        );
397                        TaskNextAction::Continue
398                    }
399                }
400            }
401        }
402    }
403
404    async fn shutdown(mut self) -> anyhow::Result<()> {
405        // gracefully handle all the remaining blocks in the stream and then stop
406        if let Some(Some(block_with_metadata)) = self.block_stream.next().now_or_never()
407            && let Err(e) = self.handle_new_block(block_with_metadata.block())
408        {
409            return Err(anyhow::anyhow!(e).context(format!(
410                "Couldn't compress block: {}. Shutting down. \
411                        Node will be in indeterminate state upon restart. \
412                        Suggested to delete compression database.",
413                block_with_metadata.height()
414            )));
415        }
416        Ok(())
417    }
418}
419
420/// Create a new compression service.
421pub fn new_service<B, S, C, CH>(
422    block_source: B,
423    storage: S,
424    config_provider: C,
425    canonical_height: CH,
426    chain_id: ChainId,
427) -> crate::Result<ServiceRunner<UninitializedCompressionService<B, S, CH>>>
428where
429    B: BlockSource,
430    S: CompressionStorage + LatestHeight,
431    C: CompressionConfigProvider,
432    CH: CanonicalHeight,
433{
434    let config = config_provider.config(chain_id);
435    Ok(ServiceRunner::new(UninitializedCompressionService::new(
436        block_source,
437        storage,
438        config,
439        canonical_height,
440    )))
441}
442
443#[allow(non_snake_case)]
444#[cfg(test)]
445mod tests {
446    use std::num::NonZeroU32;
447
448    use super::*;
449    use crate::{
450        ports::block_source::{
451            BlockWithMetadata,
452            BlockWithMetadataExt,
453        },
454        storage,
455        storage::CompressedBlocks,
456    };
457    use fuel_core_services::{
458        Service,
459        stream::{
460            BoxStream,
461            IntoBoxStream,
462        },
463    };
464    use fuel_core_storage::{
465        StorageAsRef,
466        iter::{
467            IterDirection,
468            IteratorOverTable,
469            changes_iterator::ChangesIterator,
470        },
471        merkle::column::MerkleizedColumn,
472        structured_storage::test::InMemoryStorage,
473        transactional::{
474            IntoTransaction,
475            StorageChanges,
476            StorageTransaction,
477        },
478    };
479
480    struct EmptyBlockSource;
481
482    impl BlockSource for EmptyBlockSource {
483        fn subscribe(&self) -> BoxStream<BlockWithMetadata> {
484            tokio_stream::pending().into_boxed()
485        }
486
487        fn get_block(
488            &self,
489            _: crate::ports::block_source::BlockAt,
490        ) -> anyhow::Result<Block> {
491            anyhow::bail!("Block not found")
492        }
493    }
494
495    type MockStorage = StorageTransaction<
496        InMemoryStorage<MerkleizedColumn<crate::storage::column::CompressionColumn>>,
497    >;
498
499    fn test_storage() -> MockStorage {
500        InMemoryStorage::default().into_transaction()
501    }
502
503    impl LatestHeight for MockStorage {
504        fn latest_height(&self) -> Option<u32> {
505            let changes = StorageChanges::Changes(self.changes().clone());
506            let view = ChangesIterator::new(&changes);
507            let compressed_block_changes = view
508                .iter_all_keys::<CompressedBlocks>(Some(IterDirection::Reverse))
509                .next()
510                .transpose()
511                .unwrap();
512            compressed_block_changes.map(Into::into)
513        }
514    }
515
516    struct MockConfigProvider(crate::config::CompressionConfig);
517
518    impl Default for MockConfigProvider {
519        fn default() -> Self {
520            let chain_id = ChainId::default();
521            Self(CompressionConfig::new(
522                std::time::Duration::from_secs(10),
523                None,
524                false,
525                chain_id,
526            ))
527        }
528    }
529
530    impl MockConfigProvider {
531        fn new(config: crate::config::CompressionConfig) -> Self {
532            Self(config)
533        }
534    }
535
536    impl CompressionConfigProvider for MockConfigProvider {
537        fn config(&self, _: ChainId) -> crate::config::CompressionConfig {
538            self.0
539        }
540    }
541
542    #[derive(Default, Clone)]
543    struct MockCanonicalHeightProvider(u32);
544
545    impl MockCanonicalHeightProvider {
546        fn new(height: u32) -> Self {
547            Self(height)
548        }
549    }
550
551    impl CanonicalHeight for MockCanonicalHeightProvider {
552        fn get(&self) -> Option<u32> {
553            match self.0 {
554                0 => None,
555                _ => Some(self.0),
556            }
557        }
558    }
559
560    #[tokio::test]
561    async fn compression_service__can_be_started_and_stopped() {
562        // given
563        let chain_id = ChainId::default();
564        let block_source = EmptyBlockSource;
565        let storage = test_storage();
566        let config_provider = MockConfigProvider::default();
567        let canonical_height_provider = MockCanonicalHeightProvider::default();
568
569        let service = new_service(
570            block_source,
571            storage,
572            config_provider,
573            canonical_height_provider,
574            chain_id,
575        )
576        .unwrap();
577
578        // when
579        service.start_and_await().await.unwrap();
580
581        // then
582        // no assertions, just checking if it runs without panicking
583        service.stop_and_await().await.unwrap();
584    }
585
586    struct MockBlockSource(Vec<BlockWithMetadata>);
587
588    impl MockBlockSource {
589        fn new(blocks: Vec<BlockWithMetadata>) -> Self {
590            Self(blocks)
591        }
592    }
593
594    impl BlockSource for MockBlockSource {
595        fn subscribe(&self) -> BoxStream<BlockWithMetadata> {
596            tokio_stream::iter(self.0.clone()).into_boxed()
597        }
598
599        fn get_block(&self, height: BlockAt) -> anyhow::Result<Block> {
600            let block_height = match height {
601                BlockAt::Specific(h) => h,
602                // this is not always true, but for our tests we assume genesis is at height 0
603                BlockAt::Genesis => 0,
604            };
605            self.0
606                .iter()
607                .find(|block| block.height() == &block_height)
608                .map(|block| block.block().clone())
609                .ok_or_else(|| anyhow::anyhow!("Block not found"))
610        }
611    }
612
613    #[tokio::test]
614    async fn compression_service__run__does_not_compress_blocks_if_no_blocks_provided() {
615        // given
616        // we provide a block source that will return None upon calling .next()
617        let chain_id = ChainId::default();
618        let block_source = MockBlockSource::new(vec![]);
619        let storage = test_storage();
620        let config_provider = MockConfigProvider::default();
621        let (sync_notifier, _) = new_sync_state_channel();
622
623        let mut service = CompressionService::new(
624            block_source.subscribe(),
625            storage,
626            config_provider.config(chain_id),
627            sync_notifier,
628            None,
629        );
630
631        // when
632        let _ = service.run(&mut StateWatcher::started()).await;
633
634        // then
635        let maybe_block = service
636            .storage
637            .storage_as_ref::<storage::CompressedBlocks>()
638            .get(&0.into())
639            .unwrap();
640        assert!(maybe_block.is_none());
641        service.shutdown().await.unwrap();
642    }
643
644    #[tokio::test]
645    async fn compression_service__run__compresses_blocks() {
646        // given
647        // we provide a block source that will return a block upon calling .next()
648        let chain_id = ChainId::default();
649        let block_with_metadata = BlockWithMetadata::default();
650        let block_source = MockBlockSource::new(vec![block_with_metadata]);
651        let storage = test_storage();
652        let config_provider = MockConfigProvider::default();
653        let canonical_height_provider = MockCanonicalHeightProvider::default();
654
655        let uninit_service = UninitializedCompressionService::new(
656            block_source,
657            storage,
658            config_provider.config(chain_id),
659            canonical_height_provider,
660        );
661        let sync_observer = uninit_service.shared_data();
662
663        // when
664        let mut service = uninit_service
665            .into_task(&Default::default(), ())
666            .await
667            .unwrap();
668        let _ = service.run(&mut StateWatcher::started()).await;
669
670        // then
671        let target_block_height = 0;
672        sync_observer
673            .await_synced_until(&target_block_height)
674            .await
675            .unwrap();
676        let maybe_block = service
677            .storage
678            .storage_as_ref::<storage::CompressedBlocks>()
679            .get(&target_block_height.into())
680            .unwrap();
681        assert!(maybe_block.is_some());
682        service.shutdown().await.unwrap();
683    }
684
685    #[tokio::test]
686    async fn compression_service__syncs_from_scratch_when_database_is_empty() {
687        // given: we start the compression service, with a canonical height provider of height 5
688        let block_count = 10;
689        let chain_id = ChainId::default();
690        let mut blocks = Vec::with_capacity(block_count);
691        for i in 0..u32::try_from(block_count).unwrap() {
692            blocks.push(BlockWithMetadata::test_block_with_height(i));
693        }
694        let block_source = MockBlockSource::new(blocks);
695        let storage = test_storage();
696        let config_provider = MockConfigProvider::default();
697        let canonical_height_provider = MockCanonicalHeightProvider::new(5);
698
699        let uninit_service = UninitializedCompressionService::new(
700            block_source,
701            storage,
702            config_provider.config(chain_id),
703            canonical_height_provider.clone(),
704        );
705
706        // when: the syncing with canonical height provider occurs
707        let service = uninit_service
708            .into_task(&StateWatcher::starting(), ())
709            .await
710            .unwrap();
711
712        // then: it has a compressed block for the canonical height
713        let maybe_block = service
714            .storage
715            .storage_as_ref::<storage::CompressedBlocks>()
716            .get(&canonical_height_provider.get().unwrap().into())
717            .unwrap();
718        assert!(maybe_block.is_some());
719        service.shutdown().await.unwrap();
720    }
721
722    #[tokio::test]
723    async fn compression_service__syncs_from_overridden_starting_height_when_provided() {
724        // given: we start the compression service, with a canonical height provider of height 5,
725        // and a config override of starting height 1
726        let block_count = 10;
727        let chain_id = ChainId::default();
728        let override_starting_height = 1;
729        let mut blocks = Vec::with_capacity(block_count);
730        for i in 0..u32::try_from(block_count).unwrap() {
731            blocks.push(BlockWithMetadata::test_block_with_height(i));
732        }
733        let block_source = MockBlockSource::new(blocks);
734        let storage = test_storage();
735        let config_provider =
736            MockConfigProvider::new(crate::config::CompressionConfig::new(
737                std::time::Duration::from_secs(10),
738                Some(NonZeroU32::new(override_starting_height).unwrap()),
739                false,
740                chain_id,
741            ));
742        let canonical_height_provider = MockCanonicalHeightProvider::new(5);
743
744        let uninit_service = UninitializedCompressionService::new(
745            block_source,
746            storage,
747            config_provider.config(chain_id),
748            canonical_height_provider.clone(),
749        );
750
751        // when: the syncing with canonical height provider occurs
752        let service = uninit_service
753            .into_task(&StateWatcher::starting(), ())
754            .await
755            .unwrap();
756
757        // then: it has a block for the overridden starting height
758        let maybe_block = service
759            .storage
760            .storage_as_ref::<storage::CompressedBlocks>()
761            .get(&override_starting_height.into())
762            .unwrap();
763        assert!(maybe_block.is_some());
764        service.shutdown().await.unwrap();
765    }
766
767    #[cfg(feature = "fault-proving")]
768    #[tokio::test]
769    async fn compression_service__writes_to_registrations_table() {
770        // given: we create the compression service
771        let block_with_metadata = BlockWithMetadata::default();
772        let block_source = MockBlockSource::new(vec![block_with_metadata]);
773        let storage = test_storage();
774        let config_provider = MockConfigProvider::default();
775        let canonical_height_provider = MockCanonicalHeightProvider::default();
776        let chain_id = ChainId::default();
777
778        let uninit_service = UninitializedCompressionService::new(
779            block_source,
780            storage,
781            config_provider.config(chain_id),
782            canonical_height_provider,
783        );
784        let sync_observer = uninit_service.shared_data();
785
786        // when: the compression service is started
787        let mut service = uninit_service
788            .into_task(&Default::default(), ())
789            .await
790            .unwrap();
791        let _ = service.run(&mut StateWatcher::started()).await;
792
793        // then: we ensure we can get the registrations per block + root of that table
794        let target_block_height = 0;
795        sync_observer
796            .await_synced_until(&target_block_height)
797            .await
798            .unwrap();
799        let maybe_registrations = service
800            .storage
801            .storage_as_ref::<storage::registrations::Registrations>()
802            .get(&target_block_height.into())
803            .unwrap();
804        assert!(maybe_registrations.is_some());
805    }
806}