Skip to main content

espresso_node/
context.rs

1use std::{
2    fmt::{Debug, Display},
3    future::Future,
4    marker::PhantomData,
5    sync::Arc,
6    time::{Duration, Instant},
7};
8
9use anyhow::Context;
10use async_lock::RwLock;
11use derivative::Derivative;
12use espresso_types::{
13    NodeState, PubKey, Transaction, ValidatedState,
14    v0::traits::{EventConsumer as PersistenceEventConsumer, SequencerPersistence},
15};
16use futures::{
17    future::join_all,
18    stream::{BoxStream, Stream, StreamExt},
19};
20use hotshot::{HotShotInitializer, SystemContext};
21use hotshot_events_service::events_source::{EventConsumer, EventsStreamer};
22use hotshot_new_protocol::{
23    coordinator::Coordinator,
24    network::{Cliquenet, NetworkError},
25};
26use hotshot_orchestrator::client::OrchestratorClient;
27use hotshot_types::{
28    PeerConfig, ValidatorConfig,
29    consensus::ConsensusMetricsValue,
30    constants::EXTERNAL_EVENT_CHANNEL_SIZE,
31    data::{Leaf2, ViewNumber},
32    epoch_membership::EpochMembershipCoordinator,
33    message::UpgradeLock,
34    network::NetworkConfig,
35    new_protocol::CoordinatorEvent,
36    simple_certificate::CertificatePair,
37    storage_metrics::StorageMetricsValue,
38    traits::{
39        metrics::{Counter, Gauge, Histogram, Metrics},
40        network::ConnectedNetwork,
41    },
42};
43use parking_lot::Mutex;
44use request_response::RequestResponseConfig;
45use tokio::{
46    spawn,
47    sync::{mpsc::channel, watch},
48    task::JoinHandle,
49};
50use tracing::{Instrument, Level, info};
51use url::Url;
52use versions::NEW_PROTOCOL_VERSION;
53
54use crate::{
55    Node, SeqTypes, SequencerApiVersion,
56    catchup::ParallelStateCatchup,
57    consensus_handle::ConsensusHandle,
58    external_event_handler::ExternalEventHandler,
59    proposal_fetcher::ProposalFetcherConfig,
60    request_response::{
61        RequestResponseProtocol,
62        data_source::{DataSource, Storage as RequestResponseStorage},
63        network::Sender as RequestResponseSender,
64        recipient_source::RecipientSource,
65    },
66    startup_catchup::bootstrap_epoch_window,
67    state_signature::{self, StateSigner},
68};
69pub(crate) type ConsensusNode<N, P> = Node<N, P>;
70pub type Consensus<N, P> = hotshot::types::SystemContextHandle<SeqTypes, ConsensusNode<N, P>>;
71
72/// The sequencer context contains a consensus handle and other sequencer specific information.
73#[derive(Derivative, Clone)]
74#[derivative(Debug(bound = ""))]
75pub struct SequencerContext<N: ConnectedNetwork<PubKey>, P: SequencerPersistence> {
76    /// The consensus adapter that dispatches between old HotShot and new coordinator.
77    #[derivative(Debug = "ignore")]
78    consensus_handle: Arc<ConsensusHandle<SeqTypes, ConsensusNode<N, P>>>,
79
80    /// The request-response protocol
81    #[derivative(Debug = "ignore")]
82    #[allow(dead_code)]
83    pub request_response_protocol: RequestResponseProtocol<ConsensusNode<N, P>, N, P>,
84
85    /// Context for generating state signatures.
86    state_signer: Arc<RwLock<StateSigner<SequencerApiVersion>>>,
87
88    /// An orchestrator to wait for before starting consensus.
89    #[derivative(Debug = "ignore")]
90    wait_for_orchestrator: Option<Arc<OrchestratorClient>>,
91
92    /// Background tasks to shut down when the node is dropped.
93    tasks: TaskList,
94
95    /// events streamer to stream hotshot events to external clients
96    events_streamer: Arc<RwLock<EventsStreamer<SeqTypes>>>,
97
98    detached: bool,
99
100    node_state: NodeState,
101
102    network_config: NetworkConfig<SeqTypes>,
103
104    #[derivative(Debug = "ignore")]
105    validator_config: ValidatorConfig<SeqTypes>,
106}
107
108impl<N, P> SequencerContext<N, P>
109where
110    N: ConnectedNetwork<PubKey>,
111    P: SequencerPersistence,
112{
113    #[tracing::instrument(skip_all, fields(node_id = initializer.instance_state().node_id))]
114    #[allow(clippy::too_many_arguments)]
115    pub async fn init<F>(
116        network_config: NetworkConfig<SeqTypes>,
117        upgrade: versions::Upgrade,
118        validator_config: ValidatorConfig<SeqTypes>,
119        membership_coordinator: EpochMembershipCoordinator<SeqTypes>,
120        initializer: HotShotInitializer<SeqTypes>,
121        anchor_view: Option<ViewNumber>,
122        storage: Option<RequestResponseStorage>,
123        state_catchup: ParallelStateCatchup,
124        persistence: Arc<P>,
125        network: Arc<N>,
126        coordinator_network: F,
127        state_relay_server: Option<Url>,
128        metrics: &dyn Metrics,
129        stake_table_capacity: usize,
130        event_consumer: impl PersistenceEventConsumer + 'static,
131        proposal_fetcher_cfg: ProposalFetcherConfig,
132        bootstrap_epoch_catchup_timeout: Duration,
133    ) -> anyhow::Result<Self>
134    where
135        F: AsyncFnOnce(UpgradeLock<SeqTypes>) -> Result<Cliquenet<SeqTypes>, NetworkError>,
136    {
137        let config = &network_config.config;
138        let pub_key = validator_config.public_key;
139        tracing::info!(%pub_key, "initializing consensus");
140
141        let instance_state = initializer.instance_state().clone();
142
143        // Stick our node ID in `metrics` so it is easily accessible via the status API.
144        metrics
145            .create_gauge("node_index".into(), None)
146            .set(instance_state.node_id as usize);
147
148        // Start L1 client if it isn't already.
149        instance_state.l1_client.spawn_tasks().await;
150
151        info!(target: "announce", ?initializer, "starting up sequencer context with initializer");
152
153        let stake_table = config.hotshot_stake_table();
154        let stake_table_commit = stake_table.commitment(stake_table_capacity)?;
155        let stake_table_epoch = None;
156        let should_vote =
157            state_signature::should_vote(&stake_table, &validator_config.state_public_key);
158
159        let epoch_height = initializer.epoch_height();
160
161        let initializer_for_coordinator = initializer.clone();
162
163        let event_streamer = Arc::new(RwLock::new(EventsStreamer::<SeqTypes>::new(
164            stake_table.0,
165            0,
166        )));
167        let consensus_metrics = ConsensusMetricsValue::new(metrics);
168
169        let handle = SystemContext::init(
170            validator_config.public_key,
171            validator_config.private_key.clone(),
172            validator_config.state_private_key.clone(),
173            instance_state.node_id,
174            config.clone(),
175            upgrade,
176            membership_coordinator.clone(),
177            network.clone(),
178            initializer,
179            consensus_metrics.clone(),
180            Arc::clone(&persistence),
181            StorageMetricsValue::new(metrics),
182        )
183        .await?
184        .0;
185
186        let mut coordinator_network =
187            coordinator_network(handle.hotshot.upgrade_lock.clone()).await?;
188
189        // `load_start_epoch_info` ran inside `SystemContext::init`, so
190        // `first_epoch` is now seeded on the shared membership. Walk the
191        // catchup chain forward to populate the stake-table window for the
192        // current epoch.
193        //
194        // Only the new protocol (cliquenet) needs this
195        let max_configured_version = std::cmp::max(upgrade.base, upgrade.target);
196        if max_configured_version >= NEW_PROTOCOL_VERSION {
197            let current_epoch = bootstrap_epoch_window(
198                &membership_coordinator,
199                epoch_height,
200                bootstrap_epoch_catchup_timeout,
201            )
202            .await
203            .context("startup stake-table catchup failed")?;
204            tracing::info!(%current_epoch, "Startup catchup complete");
205
206            // Push the resolved peer window into the coordinator network. For
207            // cliquenet this dials the N-1/N/N+1 sliding window for the current
208            // epoch before consensus starts.
209            if let Err(err) =
210                coordinator_network.apply_epoch(current_epoch, &membership_coordinator)
211            {
212                tracing::warn!(%current_epoch, %err, "coordinator network apply_epoch failed at startup");
213            }
214        }
215
216        // Restore the persisted lock so the new protocol resumes with the lock
217        // it actually held, not the older decided-anchor QC.
218        let locked_qc = persistence
219            .load_high_qc2()
220            .await
221            .context("loading persisted locked QC")?;
222
223        let coordinator = Coordinator::maker()
224            .membership_coordinator(membership_coordinator.clone())
225            .network(coordinator_network)
226            .initializer(&initializer_for_coordinator)
227            .upgrade_lock(handle.hotshot.upgrade_lock.clone())
228            .public_key(validator_config.public_key)
229            .private_key(validator_config.private_key.clone())
230            .state_private_key(validator_config.state_private_key.clone())
231            .stake_table_capacity(stake_table_capacity)
232            .timeout_duration(Duration::from_secs(10))
233            .storage(Arc::clone(&persistence))
234            .metrics(metrics)
235            .consensus_metrics(consensus_metrics)
236            .maybe_locked_qc(locked_qc)
237            .make();
238
239        let legacy_event_rx = handle.event_stream_known_impl().deactivate();
240        let hotshot_handle = Arc::new(RwLock::new(handle));
241
242        let consensus_handle = {
243            let handle = ConsensusHandle::new(
244                hotshot_handle.clone(),
245                coordinator,
246                epoch_height.into(),
247                legacy_event_rx,
248                EXTERNAL_EVENT_CHANNEL_SIZE,
249                metrics,
250            )
251            .await;
252            Arc::new(handle)
253        };
254
255        let mut state_signer = StateSigner::new(
256            validator_config.state_private_key.clone(),
257            validator_config.state_public_key.clone(),
258            stake_table_commit,
259            stake_table_epoch,
260            stake_table_capacity,
261            should_vote,
262        );
263        if let Some(url) = state_relay_server {
264            state_signer = state_signer.with_relay_server(url);
265        }
266
267        // Create the channel for sending outbound messages from the external event handler
268        let (outbound_message_sender, outbound_message_receiver) = channel(20);
269        let (request_response_sender, request_response_receiver) = channel(20);
270
271        // Configure the request-response protocol
272        let request_response_config = RequestResponseConfig {
273            incoming_request_ttl: Duration::from_secs(40),
274            incoming_request_timeout: Duration::from_secs(5),
275            incoming_response_timeout: Duration::from_secs(5),
276            request_batch_size: 5,
277            request_batch_interval: Duration::from_secs(2),
278            max_incoming_requests: 10,
279            max_incoming_requests_per_key: 1,
280            max_incoming_responses: 200,
281        };
282
283        // Create the request-response protocol
284        let request_response_protocol = RequestResponseProtocol::new(
285            request_response_config,
286            RequestResponseSender::new(outbound_message_sender),
287            request_response_receiver,
288            RecipientSource {
289                memberships: membership_coordinator,
290                consensus_handle: consensus_handle.clone(),
291                public_key: validator_config.public_key,
292            },
293            DataSource {
294                node_state: instance_state.clone(),
295                storage,
296                persistence: persistence.clone(),
297                consensus_handle: consensus_handle.clone(),
298                phantom: PhantomData,
299            },
300            validator_config.public_key,
301            validator_config.private_key.clone(),
302        );
303
304        // Add the request-response protocol to the list of providers for state catchup. Since the interior is mutable,
305        // the request-response protocol will now retroactively be used anywhere we passed in the original struct (e.g. in consensus
306        // itself)
307        state_catchup.add_provider(Arc::new(request_response_protocol.clone()));
308
309        // Create the external event handler
310        let mut tasks = TaskList::default();
311        let external_event_handler = ExternalEventHandler::new(
312            &mut tasks,
313            request_response_sender,
314            outbound_message_receiver,
315            consensus_handle.clone(),
316            network,
317            pub_key,
318        )
319        .await
320        .with_context(|| "Failed to create external event handler")?;
321
322        Ok(Self::new(
323            consensus_handle,
324            persistence,
325            state_signer,
326            external_event_handler,
327            request_response_protocol,
328            event_streamer,
329            instance_state,
330            network_config,
331            validator_config,
332            event_consumer,
333            anchor_view,
334            proposal_fetcher_cfg,
335            metrics,
336        )
337        .with_task_list(tasks))
338    }
339
340    /// Constructor
341    #[allow(clippy::too_many_arguments)]
342    fn new(
343        consensus_handle: Arc<ConsensusHandle<SeqTypes, ConsensusNode<N, P>>>,
344        persistence: Arc<P>,
345        state_signer: StateSigner<SequencerApiVersion>,
346        external_event_handler: ExternalEventHandler,
347        request_response_protocol: RequestResponseProtocol<ConsensusNode<N, P>, N, P>,
348        event_streamer: Arc<RwLock<EventsStreamer<SeqTypes>>>,
349        node_state: NodeState,
350        network_config: NetworkConfig<SeqTypes>,
351        validator_config: ValidatorConfig<SeqTypes>,
352        event_consumer: impl PersistenceEventConsumer + 'static,
353        anchor_view: Option<ViewNumber>,
354        proposal_fetcher_cfg: ProposalFetcherConfig,
355        metrics: &dyn Metrics,
356    ) -> Self {
357        let events = consensus_handle.event_stream();
358
359        let node_id = node_state.node_id;
360        let mut ctx = Self {
361            consensus_handle,
362            state_signer: Arc::new(RwLock::new(state_signer)),
363            request_response_protocol,
364            tasks: Default::default(),
365            detached: false,
366            wait_for_orchestrator: None,
367            events_streamer: event_streamer.clone(),
368            node_state,
369            network_config,
370            validator_config,
371        };
372
373        // Spawn proposal fetching tasks.
374        proposal_fetcher_cfg.spawn(
375            &mut ctx.tasks,
376            ctx.consensus_handle.clone(),
377            persistence.clone(),
378            metrics,
379        );
380
381        // Shared between the event loop and the background decide processor.
382        let event_consumer = Arc::new(event_consumer);
383
384        // Wakes the background decide processor. `watch` coalesces: the processor is cursor-driven,
385        // so it only needs the latest decided view.
386        let (decide_tx, decide_rx) = watch::channel::<DecideSignal>(None);
387
388        // Background decide processor: query-service ingestion + GC, decoupled from the event loop.
389        ctx.spawn(
390            "decide processor",
391            process_decided_events_task(
392                persistence.clone(),
393                event_consumer.clone(),
394                decide_rx,
395                anchor_view,
396                DecideProcessorMetrics::new(metrics),
397            ),
398        );
399
400        // Event loop. On a decide this only does the leaf write, then signals `decide_tx`.
401        ctx.spawn(
402            "event handler",
403            handle_events(
404                ctx.consensus_handle.clone(),
405                node_id,
406                events,
407                persistence,
408                ctx.state_signer.clone(),
409                external_event_handler,
410                Some(event_streamer.clone()),
411                event_consumer,
412                decide_tx,
413            ),
414        );
415
416        ctx
417    }
418
419    /// Wait for a signal from the orchestrator before starting consensus.
420    pub fn wait_for_orchestrator(mut self, client: OrchestratorClient) -> Self {
421        self.wait_for_orchestrator = Some(Arc::new(client));
422        self
423    }
424
425    /// Add a list of tasks to the given context.
426    pub(crate) fn with_task_list(mut self, tasks: TaskList) -> Self {
427        self.tasks.extend(tasks);
428        self
429    }
430
431    /// Return a reference to the consensus state signer.
432    pub fn state_signer(&self) -> Arc<RwLock<StateSigner<SequencerApiVersion>>> {
433        self.state_signer.clone()
434    }
435
436    /// Stream consensus events.
437    pub fn event_stream(&self) -> BoxStream<'static, CoordinatorEvent<SeqTypes>> {
438        self.consensus_handle.event_stream()
439    }
440
441    pub async fn submit_transaction(&self, tx: Transaction) -> anyhow::Result<()> {
442        self.consensus_handle.submit_transaction(tx).await
443    }
444
445    /// get event streamer
446    pub fn event_streamer(&self) -> Arc<RwLock<EventsStreamer<SeqTypes>>> {
447        self.events_streamer.clone()
448    }
449
450    /// Return a reference to the consensus adapter.
451    pub fn consensus_handle(&self) -> Arc<ConsensusHandle<SeqTypes, ConsensusNode<N, P>>> {
452        self.consensus_handle.clone()
453    }
454
455    pub fn validator_config(&self) -> &ValidatorConfig<SeqTypes> {
456        &self.validator_config
457    }
458
459    pub async fn upgrade_lock(&self) -> UpgradeLock<SeqTypes> {
460        self.consensus_handle.upgrade_lock().await
461    }
462
463    pub async fn shutdown_consensus(&self) {
464        self.consensus_handle.shut_down().await
465    }
466
467    pub async fn decided_leaf(&self) -> Leaf2<SeqTypes> {
468        self.consensus_handle.decided_leaf().await
469    }
470
471    pub async fn state(&self, view: ViewNumber) -> Option<Arc<ValidatedState>> {
472        self.consensus_handle.state(view).await
473    }
474
475    pub async fn decided_state(&self) -> Option<Arc<ValidatedState>> {
476        self.consensus_handle.decided_state().await
477    }
478
479    pub fn node_id(&self) -> u64 {
480        self.node_state.node_id
481    }
482
483    pub fn node_state(&self) -> NodeState {
484        self.node_state.clone()
485    }
486
487    /// Start participating in consensus.
488    pub async fn start_consensus(&self) {
489        if let Some(orchestrator_client) = &self.wait_for_orchestrator {
490            tracing::warn!("waiting for orchestrated start");
491            let peer_config = PeerConfig::to_bytes(&self.validator_config.public_config()).clone();
492            orchestrator_client
493                .wait_for_all_nodes_ready(peer_config)
494                .await;
495        } else {
496            // the network config was loaded from storage or fetched from
497            // peers, so there is no need of orchestrator
498            // This is the normal path for a node rejoining an existing network.
499            tracing::info!("no orchestrator configured");
500        }
501        tracing::warn!("starting consensus");
502        self.consensus_handle.start_consensus().await;
503    }
504
505    /// Spawn a background task attached to this context.
506    ///
507    /// When this context is dropped or [`shut_down`](Self::shut_down), background tasks will be
508    /// cancelled in the reverse order that they were spawned.
509    pub fn spawn(&mut self, name: impl Display, task: impl Future<Output: Debug> + Send + 'static) {
510        self.tasks.spawn(name, task);
511    }
512
513    /// Spawn a short-lived background task attached to this context.
514    ///
515    /// When this context is dropped or [`shut_down`](Self::shut_down), background tasks will be
516    /// cancelled in the reverse order that they were spawned.
517    ///
518    /// The only difference between a short-lived background task and a [long-lived](Self::spawn)
519    /// one is how urgently logging related to the task is treated.
520    pub fn spawn_short_lived(
521        &mut self,
522        name: impl Display,
523        task: impl Future<Output: Debug> + Send + 'static,
524    ) {
525        self.tasks.spawn_short_lived(name, task);
526    }
527
528    /// Stop participating in consensus.
529    pub async fn shut_down(&mut self) {
530        tracing::info!("shutting down SequencerContext");
531        self.consensus_handle.shut_down().await;
532        self.tasks.shut_down();
533        self.node_state.l1_client.shut_down_tasks().await;
534
535        // Since we've already shut down, we can set `detached` so the drop
536        // handler doesn't call `shut_down` again.
537        self.detached = true;
538    }
539
540    /// Wait for consensus to complete.
541    ///
542    /// Under normal conditions, this function will block forever, which is a convenient way of
543    /// keeping the main thread from exiting as long as there are still active background tasks.
544    pub async fn join(&mut self) {
545        self.tasks.join().await;
546    }
547
548    /// Allow this node to continue participating in consensus even after it is dropped.
549    pub fn detach(&mut self) {
550        // Set `detached` so the drop handler doesn't call `shut_down`.
551        self.detached = true;
552    }
553
554    /// Get the network config
555    pub fn network_config(&self) -> NetworkConfig<SeqTypes> {
556        self.network_config.clone()
557    }
558}
559
560impl<N: ConnectedNetwork<PubKey>, P: SequencerPersistence> Drop for SequencerContext<N, P> {
561    fn drop(&mut self) {
562        if !self.detached {
563            // Spawn a task to shut down the context
564            let consensus_handle = self.consensus_handle.clone();
565            let tasks_clone = self.tasks.clone();
566            let node_state_clone = self.node_state.clone();
567
568            spawn(async move {
569                tracing::info!("shutting down SequencerContext");
570                consensus_handle.shut_down().await;
571                tasks_clone.shut_down();
572                node_state_clone.l1_client.shut_down_tasks().await;
573            });
574
575            // Set `detached` so the drop handler doesn't call `shut_down` again.
576            self.detached = true;
577        }
578    }
579}
580
581/// Latest decided view and its (optional) deciding QC, sent from the event loop to the background
582/// decide processor. `None` is the initial/no-op value of the `watch` channel.
583type DecideSignal = Option<(ViewNumber, Option<Arc<CertificatePair<SeqTypes>>>)>;
584
585/// Metrics for the background decide processor. `backlog` (decided - processed) is the key signal:
586/// sustained growth means staging tables accumulate (no data lost, but disk grows).
587struct DecideProcessorMetrics {
588    last_decided: Arc<dyn Gauge>,
589    last_processed: Arc<dyn Gauge>,
590    backlog: Arc<dyn Gauge>,
591    duration: Arc<dyn Histogram>,
592    failures: Arc<dyn Counter>,
593}
594
595impl DecideProcessorMetrics {
596    fn new(metrics: &(impl Metrics + ?Sized)) -> Self {
597        let metrics = metrics.subgroup("decide_processor".into());
598        Self {
599            last_decided: metrics
600                .create_gauge("last_decided".into(), Some("view".into()))
601                .into(),
602            last_processed: metrics
603                .create_gauge("last_processed".into(), Some("view".into()))
604                .into(),
605            backlog: metrics
606                .create_gauge("backlog".into(), Some("view".into()))
607                .into(),
608            duration: metrics
609                .create_histogram("process_duration".into(), Some("seconds".into()))
610                .into(),
611            failures: metrics.create_counter("failures".into(), None).into(),
612        }
613    }
614}
615
616/// How many new-protocol decides to wait for before tearing down the legacy
617/// consensus stack (tasks + network). Once the coordinator has decided even a
618/// single leaf the cutover boundary is final and all consensus traffic runs
619/// on the coordinator's network; the margin only gives slightly-lagging peers
620/// a window to finish crossing the boundary with legacy help.
621const LEGACY_SHUTDOWN_DECIDE_COUNT: u64 = 100;
622
623#[tracing::instrument(skip_all, fields(node_id))]
624#[allow(clippy::too_many_arguments)]
625async fn handle_events<N, P, C>(
626    consensus_handle: Arc<ConsensusHandle<SeqTypes, ConsensusNode<N, P>>>,
627    node_id: u64,
628    mut events: impl Stream<Item = CoordinatorEvent<SeqTypes>> + Unpin,
629    persistence: Arc<P>,
630    state_signer: Arc<RwLock<StateSigner<SequencerApiVersion>>>,
631    external_event_handler: ExternalEventHandler,
632    events_streamer: Option<Arc<RwLock<EventsStreamer<SeqTypes>>>>,
633    event_consumer: Arc<C>,
634    decide_tx: watch::Sender<DecideSignal>,
635) where
636    N: ConnectedNetwork<PubKey>,
637    P: SequencerPersistence,
638    C: PersistenceEventConsumer + 'static,
639{
640    let mut new_protocol_decides: u64 = 0;
641
642    while let Some(event) = events.next().await {
643        tracing::debug!(node_id, ?event, "consensus event");
644
645        match &event {
646            CoordinatorEvent::NewDecide { .. } => {
647                new_protocol_decides += 1;
648                if new_protocol_decides == LEGACY_SHUTDOWN_DECIDE_COUNT {
649                    tracing::info!(
650                        node_id,
651                        "new protocol is live, shutting down legacy consensus and network"
652                    );
653                    let handle = consensus_handle.clone();
654                    spawn(async move { handle.shut_down_legacy().await });
655                }
656            },
657            CoordinatorEvent::LegacyEvent(hotshot_event) => {
658                if let hotshot_types::event::EventType::ExternalMessageReceived { ref data, .. } =
659                    hotshot_event.event
660                    && let Err(err) = external_event_handler.handle_event(data).await
661                {
662                    tracing::warn!(%err, "Failed to handle legacy external message");
663                }
664                consensus_handle.activate().await;
665            },
666            CoordinatorEvent::ExternalMessageReceived { data, .. } => {
667                if let Err(err) = external_event_handler.handle_event(data).await {
668                    tracing::warn!("Failed to handle external message: {:?}", err);
669                }
670            },
671            CoordinatorEvent::BlockPayloadReconstructed { .. } => {
672                // Forward straight to the consumer: reconstructed payloads might not yet
673                // have been stored by consensus storage,
674                // Query service verifies the block against a decided leaf before storing it.
675                if let Err(err) = event_consumer.handle_event(&event).await {
676                    tracing::warn!("failed to handle reconstructed payload: {err:#}");
677                }
678            },
679            _ => {},
680        }
681
682        // Critical path: only persist the decided leaves, then signal the background processor.
683        // Signalling after the persist future means it never reads ahead of committed state.
684        let persistence_fut = async {
685            if let Some(signal) = persistence
686                .persist_event(&event, event_consumer.as_ref())
687                .await
688            {
689                // Keep the max view: a gap-fill decide signals an *older* view
690                // and must not hide a newer, unconsumed tip signal.
691                decide_tx.send_modify(|current| match current {
692                    Some((view, _)) if *view > signal.0 => {},
693                    _ => *current = Some(signal),
694                });
695            }
696        };
697
698        let state_signer_fut = async {
699            state_signer
700                .write()
701                .await
702                .handle_event(&event, consensus_handle.as_ref())
703                .await;
704        };
705
706        let events_streamer_fut = async {
707            if let CoordinatorEvent::LegacyEvent(ref hotshot_event) = event
708                && let Some(events_streamer) = events_streamer.as_ref()
709            {
710                events_streamer
711                    .write()
712                    .await
713                    .handle_event(hotshot_event.clone())
714                    .await;
715            }
716        };
717
718        tokio::join!(persistence_fut, state_signer_fut, events_streamer_fut);
719    }
720}
721
722const PROCESS_RETRY_INTERVAL: Duration = Duration::from_secs(30);
723
724/// Turns persisted decided leaves into query-service decide events and GCs processed data.
725/// Decoupled from [`handle_events`] so slow ingestion/GC can't stall (or drop) consensus events;
726/// cursor-driven, so it can lag without losing data.
727#[tracing::instrument(skip_all)]
728async fn process_decided_events_task<P, C>(
729    persistence: Arc<P>,
730    consumer: Arc<C>,
731    mut decide_rx: watch::Receiver<DecideSignal>,
732    anchor_view: Option<ViewNumber>,
733    metrics: DecideProcessorMetrics,
734) where
735    P: SequencerPersistence,
736    C: PersistenceEventConsumer + 'static,
737{
738    // Highest view confirmed processed, for the backlog gauge. Floored at the anchor view; the
739    // cursor reported below raises it.
740    let mut last_processed = anchor_view.map(|v| v.u64()).unwrap_or(0);
741
742    // Process leaves persisted before a previous shutdown but not yet handled.
743    if let Some(view) = anchor_view {
744        match persistence
745            .process_decided_events(view, None, consumer.as_ref())
746            .await
747        {
748            Ok(processed) => {
749                if let Some(v) = processed {
750                    last_processed = last_processed.max(v.u64());
751                }
752            },
753            Err(err) => tracing::warn!(
754                "failed to process decided leaves on startup, chain may not be up to date: {err:#}"
755            ),
756        }
757    }
758
759    // Reused on a timeout to re-attempt the most recent decide when no new one has arrived.
760    let mut latest: DecideSignal = None;
761
762    loop {
763        // Wait for the next decide, retrying the most recent one if none arrives within the timeout.
764        match tokio::time::timeout(PROCESS_RETRY_INTERVAL, decide_rx.changed()).await {
765            Ok(Ok(())) => latest = decide_rx.borrow_and_update().clone(),
766            Ok(Err(_)) => {
767                tracing::info!("decide signal channel closed, stopping decide processor");
768                return;
769            },
770            Err(_) => {}, // Timed out; fall through to retry `latest`.
771        }
772
773        let Some((view, deciding_qc)) = latest.clone() else {
774            continue;
775        };
776        let decided = view.u64();
777        metrics.last_decided.set(decided as usize);
778        metrics
779            .backlog
780            .set(decided.saturating_sub(last_processed) as usize);
781
782        let start = Instant::now();
783        let result = persistence
784            .process_decided_events(view, deciding_qc, consumer.as_ref())
785            .await;
786        metrics.duration.add_point(start.elapsed().as_secs_f64());
787
788        match result {
789            Ok(processed) => {
790                // Advance from the real cursor, not `decided`: if ingestion/GC lagged, `processed`
791                // stays behind and the backlog gauge reflects it.
792                if let Some(v) = processed {
793                    last_processed = last_processed.max(v.u64());
794                }
795                // reset latest if we have processed all the decided leaves
796                if let Some((view, _)) = latest.clone()
797                    && last_processed >= view.u64()
798                {
799                    latest = None;
800                }
801                metrics.last_processed.set(last_processed as usize);
802                metrics
803                    .backlog
804                    .set(decided.saturating_sub(last_processed) as usize);
805            },
806            Err(err) => {
807                // Cursor not advanced, so this range is retried next iteration; no data is lost.
808                metrics.failures.add(1);
809                tracing::warn!(?view, "deferred decide processing failed: {err:#}");
810            },
811        }
812    }
813}
814
815#[derive(Debug, Default, Clone)]
816#[allow(clippy::type_complexity)]
817pub(crate) struct TaskList(Arc<Mutex<Vec<(String, JoinHandle<()>)>>>);
818
819macro_rules! spawn_with_log_level {
820    ($this:expr, $lvl:expr, $name:expr, $task: expr) => {
821        let name = $name.to_string();
822        let task = {
823            let name = name.clone();
824            let span = tracing::span!($lvl, "background task", name);
825            spawn(
826                async move {
827                    tracing::event!($lvl, "spawning background task");
828                    let res = $task.await;
829                    tracing::event!($lvl, ?res, "background task exited");
830                }
831                .instrument(span),
832            )
833        };
834        $this.0.lock().push((name, task));
835    };
836}
837
838impl TaskList {
839    /// Spawn a background task attached to this [`TaskList`].
840    ///
841    /// When this [`TaskList`] is dropped or [`shut_down`](Self::shut_down), background tasks will
842    /// be cancelled in the reverse order that they were spawned.
843    pub fn spawn(&mut self, name: impl Display, task: impl Future<Output: Debug> + Send + 'static) {
844        spawn_with_log_level!(self, Level::INFO, name, task);
845    }
846
847    /// Spawn a short-lived background task attached to this [`TaskList`].
848    ///
849    /// When this [`TaskList`] is dropped or [`shut_down`](Self::shut_down), background tasks will
850    /// be cancelled in the reverse order that they were spawned.
851    ///
852    /// The only difference between a short-lived background task and a [long-lived](Self::spawn)
853    /// one is how urgently logging related to the task is treated.
854    pub fn spawn_short_lived(
855        &mut self,
856        name: impl Display,
857        task: impl Future<Output: Debug> + Send + 'static,
858    ) {
859        spawn_with_log_level!(self, Level::DEBUG, name, task);
860    }
861
862    /// Stop all background tasks.
863    pub fn shut_down(&self) {
864        let tasks: Vec<(String, JoinHandle<()>)> = self.0.lock().drain(..).collect();
865        for (name, task) in tasks.into_iter().rev() {
866            tracing::info!(name, "cancelling background task");
867            task.abort();
868        }
869    }
870
871    /// Wait for all background tasks to complete.
872    pub async fn join(&mut self) {
873        let tasks: Vec<(String, JoinHandle<()>)> = self.0.lock().drain(..).collect();
874        join_all(tasks.into_iter().map(|(_, task)| task)).await;
875    }
876
877    pub fn extend(&mut self, tasks: TaskList) {
878        self.0.lock().extend(
879            tasks
880                .0
881                .lock()
882                .drain(..)
883                .collect::<Vec<(String, JoinHandle<()>)>>(),
884        );
885    }
886}
887
888impl Drop for TaskList {
889    fn drop(&mut self) {
890        self.shut_down()
891    }
892}