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