Skip to main content

zebra_rpc/
sync.rs

1//! Syncer task for maintaining a non-finalized state in Zebra's ReadStateService and updating `ChainTipSender` via RPCs
2
3use std::{
4    net::SocketAddr,
5    sync::Arc,
6    time::{Duration, Instant},
7};
8
9use tokio::task::JoinHandle;
10use tonic::{Status, Streaming};
11use tower::BoxError;
12use zebra_chain::{
13    block::{self, Block, Height},
14    parameters::Network,
15    serialization::BytesInDisplayOrder,
16};
17use zebra_state::{
18    spawn_init_read_only, ChainTipBlock, ChainTipChange, ChainTipSender, CheckpointVerifiedBlock,
19    HashOrHeight, LatestChainTip, NonFinalizedState, ReadStateService, SemanticallyVerifiedBlock,
20    ValidateContextError, ZebraDb,
21};
22
23use zebra_chain::diagnostic::task::WaitForPanics;
24
25use crate::indexer::{
26    indexer_client::IndexerClient, BlockAndHash, BlockRequest, Empty,
27    NonFinalizedStateChangeRequest,
28};
29
30/// How long to wait between calls to `subscribe_to_non_finalized_state_change` when it returns an error.
31const POLL_DELAY: Duration = Duration::from_secs(5);
32
33/// How long to wait for a message on a gRPC subscription stream before assuming the stream is dead
34/// and re-subscribing.
35///
36/// Generous, because legitimate gaps between blocks/tip changes can be several minutes; this is a
37/// backstop against a wedged connection that the keep-alive ping below doesn't catch. Re-subscribing
38/// is cheap and resumes from the syncer's current chain tips, so an occasional false trigger during
39/// a quiet period is harmless.
40const STREAM_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10 * 60);
41
42/// HTTP/2 keep-alive ping interval for the indexer gRPC connection, so a half-open connection is
43/// detected promptly instead of hanging a stream read indefinitely.
44const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(60);
45
46/// How long to wait for a keep-alive ping response before treating the connection as dead.
47const KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(20);
48
49/// How long to wait before re-subscribing after a block fails to commit to the non-finalized state.
50///
51/// A block can persistently fail to commit (e.g. [`ValidateContextError::NotReadyToBeCommitted`])
52/// when the secondary finalized state hasn't yet caught up with the primary. Without this delay,
53/// re-subscribing immediately turns that into a full-speed busy loop that saturates the logs.
54const COMMIT_RETRY_DELAY: Duration = Duration::from_secs(1);
55
56/// How long to wait before re-subscribing after a subscription stream ends or returns an error.
57///
58/// A stream ending isn't necessarily a connection failure: the server can drop a subscription while
59/// it's still reachable, and re-subscribing then succeeds and is dropped again. Without this delay
60/// that becomes a full-speed subscribe/drop loop that saturates the logs on both sides.
61const STREAM_RESTART_DELAY: Duration = Duration::from_secs(1);
62
63/// How long to wait for a single `get_block` fetch while bridging the finalized gap before giving
64/// up. A single block fetch from a co-located node should return promptly, so this bounds a wedged
65/// connection without false-triggering; the bridge retries on the next subscription.
66const GET_BLOCK_TIMEOUT: Duration = Duration::from_secs(30);
67
68/// How long to wait to establish a gRPC subscription stream before assuming the request is wedged
69/// and retrying. The subscription handshake should complete promptly.
70const SUBSCRIBE_TIMEOUT: Duration = Duration::from_secs(30);
71
72/// Syncs non-finalized blocks in the best chain from a trusted Zebra node's RPC methods.
73#[derive(Debug)]
74pub struct TrustedChainSync {
75    /// gRPC client for calling Zebra's indexer methods.
76    pub indexer_rpc_client: IndexerClient<tonic::transport::Channel>,
77    /// The read state service.
78    db: ZebraDb,
79    /// The non-finalized state - currently only contains the best chain.
80    non_finalized_state: NonFinalizedState,
81    /// The chain tip sender for updating [`LatestChainTip`] and [`ChainTipChange`].
82    chain_tip_sender: ChainTipSender,
83    /// The non-finalized state sender, for updating the [`ReadStateService`] when the non-finalized best chain changes.
84    non_finalized_state_sender: tokio::sync::watch::Sender<NonFinalizedState>,
85    /// Flipped to `true` once `sync()` receives its first parseable block from the
86    /// `non_finalized_state_change` stream, which stops [`update_finalized_chain_tip`] so `sync()`
87    /// becomes the sole caller of `try_catch_up_with_primary` on the shared secondary db.
88    started_sync_sender: tokio::sync::watch::Sender<bool>,
89}
90
91async fn update_finalized_chain_tip(
92    db: ZebraDb,
93    mut indexer_rpc_client: IndexerClient<tonic::transport::Channel>,
94    mut finalized_chain_tip_sender: ChainTipSender,
95    mut started_sync_receiver: tokio::sync::watch::Receiver<bool>,
96) {
97    let mut chain_tip_change_stream = None;
98
99    loop {
100        // Stop as soon as `sync()` has received its first parseable non-finalized block. From then
101        // on `sync()` is the sole caller of `try_catch_up_with_primary` on the shared secondary, so
102        // this task must not advance the secondary's view concurrently (which would let a block be
103        // finalized between `sync()`'s check and its commit, failing as a duplicate-effects error).
104        // `sync()` also owns the published chain tip from then on, via its (higher) non-finalized
105        // tip, so publishing our lagging finalized tip would drag the reported tip backwards.
106        if *started_sync_receiver.borrow() {
107            return;
108        }
109
110        let Some(ref mut chain_tip_change) = chain_tip_change_stream else {
111            chain_tip_change_stream = match tokio::time::timeout(
112                SUBSCRIBE_TIMEOUT,
113                indexer_rpc_client.chain_tip_change(Empty {}),
114            )
115            .await
116            {
117                Ok(Ok(response)) => Some(response.into_inner()),
118                Ok(Err(err)) => {
119                    tracing::warn!(?err, "failed to subscribe to chain tip changes");
120                    tokio::time::sleep(POLL_DELAY).await;
121                    None
122                }
123                Err(_) => {
124                    tracing::warn!("timed out subscribing to chain tip changes");
125                    tokio::time::sleep(POLL_DELAY).await;
126                    None
127                }
128            };
129
130            continue;
131        };
132
133        // The message is only a signal that the primary's best chain advanced. We publish our own
134        // finalized tip below, not the primary's (non-finalized) best tip, so the hash is unused.
135        //
136        // Stop immediately if `sync()` takes over while we're parked here, rather than waiting for
137        // the next chain-tip change, so we never catch up concurrently with `sync()`'s commits.
138        let message = tokio::select! {
139            biased;
140            _ = started_sync_receiver.changed() => return,
141            message = tokio::time::timeout(STREAM_MESSAGE_TIMEOUT, chain_tip_change.message()) => message,
142        };
143
144        match message {
145            Ok(Ok(Some(_block_hash_and_height))) => {}
146            Ok(Ok(None)) => {
147                tracing::warn!("chain_tip_change stream ended unexpectedly");
148                chain_tip_change_stream = None;
149                tokio::time::sleep(STREAM_RESTART_DELAY).await;
150                continue;
151            }
152            Ok(Err(err)) => {
153                tracing::warn!(?err, "error receiving chain tip change");
154                chain_tip_change_stream = None;
155                tokio::time::sleep(STREAM_RESTART_DELAY).await;
156                continue;
157            }
158            Err(_) => {
159                tracing::debug!("chain tip change stream timed out, re-subscribing");
160                chain_tip_change_stream = None;
161                continue;
162            }
163        }
164
165        // Don't advance the secondary or publish once `sync()` has taken over (it may have done so
166        // while we waited above), so we never catch up concurrently with `sync()`'s commits.
167        if *started_sync_receiver.borrow() {
168            return;
169        }
170
171        // Catch the secondary's finalized state up to the primary, then publish its finalized tip.
172        //
173        // This keeps the finalized tip current while the secondary is still catching up, before
174        // `TrustedChainSync::sync()` has any non-finalized blocks of its own to publish.
175        //
176        // # Correctness
177        //
178        // Catching up with the primary db instance concurrent with commits to the non-finalized state
179        // when the underlying Zebra node is syncing blocks rapidly could cause repeated commit failures.
180        if let Err(error) = db.spawn_try_catch_up_with_primary().await {
181            tracing::debug!(
182                ?error,
183                "failed to catch up to the primary database while updating the finalized tip"
184            );
185            continue;
186        }
187
188        if let Some(tip_block) = finalized_chain_tip_block(&db).await {
189            // Re-check immediately before publishing, after the awaits above: `sync()` can take over
190            // while we wait on `spawn_try_catch_up_with_primary` or `finalized_chain_tip_block`, so
191            // checking any earlier would leave a window where we still publish the stale tip.
192            //
193            // `sync()`'s `ChainTipSender` latches onto the non-finalized tip, but this task holds a
194            // separate `finalized_sender()` handle that doesn't, so `set_finalized_tip` would keep
195            // overwriting the non-finalized tip rather than becoming a no-op. The task therefore
196            // stops itself. The non-finalized state only grows once populated, so this handover is
197            // permanent.
198            if *started_sync_receiver.borrow() {
199                return;
200            }
201
202            finalized_chain_tip_sender.set_finalized_tip(tip_block);
203        }
204    }
205}
206
207/// Reads the finalized tip block from the secondary db instance and converts it to a
208/// [`ChainTipBlock`].
209async fn finalized_chain_tip_block(db: &ZebraDb) -> Option<ChainTipBlock> {
210    let db = db.clone();
211    tokio::task::spawn_blocking(move || {
212        let (height, hash) = db.tip()?;
213        db.block(height.into())
214            .map(|block| CheckpointVerifiedBlock::with_hash(block, hash))
215            .map(ChainTipBlock::from)
216    })
217    .wait_for_panics()
218    .await
219}
220
221impl TrustedChainSync {
222    /// Creates a new [`TrustedChainSync`] with a [`ChainTipSender`], then spawns a task to sync blocks
223    /// from the node's non-finalized best chain.
224    ///
225    /// Returns the [`LatestChainTip`], [`ChainTipChange`], and a [`JoinHandle`] for the sync task.
226    pub async fn spawn(
227        indexer_rpc_address: SocketAddr,
228        db: ZebraDb,
229        non_finalized_state_sender: tokio::sync::watch::Sender<NonFinalizedState>,
230    ) -> Result<(LatestChainTip, ChainTipChange, JoinHandle<()>), BoxError> {
231        let non_finalized_state = NonFinalizedState::new(&db.network());
232        let (chain_tip_sender, latest_chain_tip, chain_tip_change) =
233            ChainTipSender::new(None, &db.network());
234        let channel =
235            tonic::transport::Endpoint::from_shared(format!("http://{indexer_rpc_address}"))?
236                .keep_alive_while_idle(true)
237                .http2_keep_alive_interval(KEEPALIVE_INTERVAL)
238                .keep_alive_timeout(KEEPALIVE_TIMEOUT)
239                .connect()
240                .await?;
241        let indexer_rpc_client = IndexerClient::new(channel);
242        let finalized_chain_tip_sender = chain_tip_sender.finalized_sender();
243
244        // `sync()` flips this to `true` as soon as it receives its first parseable non-finalized
245        // block, which stops `update_finalized_chain_tip` so `sync()` becomes the sole caller of
246        // `try_catch_up_with_primary` on the shared secondary db.
247        let (started_sync_sender, started_sync_receiver) = tokio::sync::watch::channel(false);
248
249        let mut syncer = Self {
250            indexer_rpc_client: indexer_rpc_client.clone(),
251            db: db.clone(),
252            non_finalized_state,
253            chain_tip_sender,
254            non_finalized_state_sender,
255            started_sync_sender,
256        };
257
258        // Spawn a task to send finalized chain tip changes to the chain tip change and latest chain tip channels.
259        tokio::spawn(async move {
260            update_finalized_chain_tip(
261                db,
262                indexer_rpc_client,
263                finalized_chain_tip_sender,
264                started_sync_receiver,
265            )
266            .await
267        });
268
269        let sync_task = tokio::spawn(async move {
270            syncer.sync().await;
271        });
272
273        Ok((latest_chain_tip, chain_tip_change, sync_task))
274    }
275
276    /// Starts syncing blocks from the node's non-finalized best chain and checking for chain tip changes in the finalized state.
277    ///
278    /// When the best chain tip in Zebra is not available in the finalized state or the local non-finalized state,
279    /// gets any unavailable blocks in Zebra's best chain from the RPC server, adds them to the local non-finalized state, then
280    /// sends the updated chain tip block and non-finalized state to the [`ChainTipSender`] and non-finalized state sender.
281    #[tracing::instrument(skip_all)]
282    async fn sync(&mut self) {
283        let mut non_finalized_blocks_listener = None;
284        // The hash of the block that most recently failed to commit, used to avoid re-logging the
285        // same warning at full rate while a block persistently fails to commit.
286        let mut last_failed_commit_hash = None;
287        // Whether we've signalled `update_finalized_chain_tip` to stop, so we only send once.
288        let mut signalled_started = false;
289        self.try_catch_up_with_primary().await;
290        if let Some(finalized_tip_block) = finalized_chain_tip_block(&self.db).await {
291            self.chain_tip_sender.set_finalized_tip(finalized_tip_block);
292        }
293
294        loop {
295            let Some(ref mut non_finalized_state_change) = non_finalized_blocks_listener else {
296                non_finalized_blocks_listener = match self
297                    .subscribe_to_non_finalized_state_change()
298                    .await
299                {
300                    Ok(listener) => Some(listener),
301                    Err(err) => {
302                        tracing::warn!(?err, "failed to subscribe to non-finalized state changes");
303                        tokio::time::sleep(POLL_DELAY).await;
304                        None
305                    }
306                };
307
308                continue;
309            };
310
311            let message = match tokio::time::timeout(
312                STREAM_MESSAGE_TIMEOUT,
313                non_finalized_state_change.message(),
314            )
315            .await
316            {
317                Ok(Ok(Some(block_and_hash))) => block_and_hash,
318                Ok(Ok(None)) => {
319                    tracing::warn!("non-finalized state change stream ended unexpectedly");
320                    non_finalized_blocks_listener = None;
321                    tokio::time::sleep(STREAM_RESTART_DELAY).await;
322                    continue;
323                }
324                Ok(Err(err)) => {
325                    tracing::warn!(?err, "error receiving non-finalized state change");
326                    non_finalized_blocks_listener = None;
327                    tokio::time::sleep(STREAM_RESTART_DELAY).await;
328                    continue;
329                }
330                Err(_) => {
331                    tracing::debug!("non-finalized state change stream timed out, re-subscribing");
332                    non_finalized_blocks_listener = None;
333                    continue;
334                }
335            };
336
337            let Some((block, hash)) = message.decode() else {
338                tracing::warn!("received malformed non-finalized state change message");
339                non_finalized_blocks_listener = None;
340                tokio::time::sleep(STREAM_RESTART_DELAY).await;
341                continue;
342            };
343
344            // We have a parseable block from the stream, so take over the finalized tip: stop
345            // `update_finalized_chain_tip` so it no longer catches up the shared secondary db
346            // concurrently with our commits below.
347            if !signalled_started {
348                self.started_sync_sender.send_replace(true);
349                signalled_started = true;
350            }
351
352            if self.non_finalized_state.any_chain_contains(&hash) {
353                // Expected and harmless: on a resumed or multi-chain stream the server can re-send a
354                // block the syncer already has (e.g. a fork's shared ancestors), so this is logged
355                // at debug rather than warn to avoid noise.
356                tracing::debug!(
357                    ?hash,
358                    "non-finalized state already contains block, skipping"
359                );
360                continue;
361            }
362
363            // Skip blocks that are already finalized on the secondary. The server streams its
364            // entire non-finalized chain on initial subscription, which can start below our
365            // secondary's finalized tip when the secondary has caught up past the server's
366            // non-finalized root. Trying to commit those blocks fails with NotReadyToBeCommitted
367            // and triggers an endless re-subscribe loop.
368            if let Some(height) = block.coinbase_height() {
369                if self
370                    .db
371                    .finalized_tip_height()
372                    .is_some_and(|tip| height <= tip)
373                {
374                    tracing::debug!(?height, "skipping block already finalized on secondary");
375                    continue;
376                }
377            }
378
379            // Stamp the receipt time so equal-work ties resolve like the source node: the
380            // listener streams the best chain first, so arrival order mirrors its preference.
381            let mut block = SemanticallyVerifiedBlock::with_hash(Arc::new(block), hash);
382            block.received_time = Some(Instant::now());
383            match self.try_commit(block.clone()).await {
384                Ok(()) => {
385                    last_failed_commit_hash = None;
386                }
387                Err(error) => {
388                    // Only log on transitions to avoid saturating the logs when the same block
389                    // persistently fails to commit (e.g. when the secondary finalized state hasn't
390                    // caught up with the primary yet).
391                    if last_failed_commit_hash != Some(hash) {
392                        tracing::warn!(
393                            ?error,
394                            ?hash,
395                            "failed to commit block to non-finalized state"
396                        );
397                        last_failed_commit_hash = Some(hash);
398                    }
399
400                    non_finalized_blocks_listener = None;
401
402                    // Back off before re-subscribing so a persistently failing block doesn't turn
403                    // re-subscription into a full-speed busy loop.
404                    tokio::time::sleep(COMMIT_RETRY_DELAY).await;
405                }
406            };
407        }
408    }
409
410    async fn try_commit(
411        &mut self,
412        block: SemanticallyVerifiedBlock,
413    ) -> Result<(), ValidateContextError> {
414        self.try_catch_up_with_primary().await;
415
416        // When the non-finalized state is empty and the incoming block doesn't build directly on
417        // the secondary's finalized tip, the secondary's finalized state is lagging the primary's.
418        // The streamed non-finalized blocks start at the primary's finalized tip, which can be
419        // several blocks above ours, so the incoming block has no parent to attach to. Bridge that
420        // gap by fetching the missing (already-finalized) blocks from the primary, so the incoming
421        // block has a contiguous chain to commit onto.
422        if self.non_finalized_state.best_chain().is_none()
423            && self.db.finalized_tip_hash() != block.block.header.previous_block_hash
424        {
425            self.fill_finalized_gap(block.height).await;
426        }
427
428        self.commit(block)
429    }
430
431    /// Commits `block` to the non-finalized state, starting a new chain if it builds on the
432    /// finalized tip or extending an existing chain otherwise, prunes newly-finalized blocks, and
433    /// publishes the updated chain tip and non-finalized state.
434    ///
435    /// Updating the channels here (rather than only after `try_commit` returns) means the bridge
436    /// blocks committed by [`Self::fill_finalized_gap`] also advance the published chain tip.
437    fn commit(&mut self, block: SemanticallyVerifiedBlock) -> Result<(), ValidateContextError> {
438        if self.db.finalized_tip_hash() == block.block.header.previous_block_hash {
439            self.prune_finalized();
440            self.non_finalized_state.commit_new_chain(block, &self.db)?;
441        } else {
442            self.non_finalized_state.commit_block(block, &self.db)?;
443            self.prune_finalized();
444        }
445
446        self.update_channels();
447
448        Ok(())
449    }
450
451    /// Fetches the blocks between the secondary's finalized tip and `target_height` (exclusive)
452    /// from the primary and commits them to the non-finalized state.
453    ///
454    /// These blocks are finalized on the primary but not yet on the lagging secondary. Committing
455    /// them as non-finalized bridge blocks gives the block at `target_height` a contiguous chain to
456    /// attach to; they are dropped again by [`Self::prune_finalized`] as the secondary catches up.
457    ///
458    /// This is best-effort: if a block can't be fetched or committed, it returns early and lets the
459    /// caller's commit fail so the block is retried on the next subscription.
460    async fn fill_finalized_gap(&mut self, target_height: Height) {
461        loop {
462            // Try to advance the secondary's finalized state first: if it catches up to the gap on
463            // its own, we can stop fetching blocks. This also drops any bridge blocks the secondary
464            // has since finalized, so the next height is recomputed from where we actually are.
465            self.try_catch_up_with_primary().await;
466            self.prune_finalized();
467
468            // The next height to bridge is just above the highest block we already have: the
469            // non-finalized tip if we've committed bridge blocks, otherwise the finalized tip.
470            let Some(highest) = self
471                .non_finalized_state
472                .best_tip()
473                .map(|(height, _hash)| height)
474                .max(self.db.finalized_tip_height())
475            else {
476                return;
477            };
478
479            let Ok(next_height) = highest.next() else {
480                return;
481            };
482
483            // Stop once the chain reaches the streamed block's parent, whether by the finalized
484            // state catching up or by the bridge blocks we fetched.
485            if next_height >= target_height {
486                return;
487            }
488
489            let (block, hash) = match self.get_block(next_height.into()).await {
490                Ok(block_and_hash) => block_and_hash,
491                Err(error) => {
492                    tracing::warn!(
493                        ?error,
494                        ?next_height,
495                        "failed to fetch a block while bridging the finalized gap; \
496                         will retry on the next subscription"
497                    );
498                    return;
499                }
500            };
501
502            let mut block = SemanticallyVerifiedBlock::with_hash(Arc::new(block), hash);
503            block.received_time = Some(Instant::now());
504            if let Err(error) = self.commit(block) {
505                tracing::warn!(
506                    ?error,
507                    ?next_height,
508                    "failed to commit a block while bridging the finalized gap; \
509                     will retry on the next subscription"
510                );
511                return;
512            }
513        }
514    }
515
516    /// Fetches a single block from the primary by hash or height via the indexer gRPC.
517    async fn get_block(
518        &self,
519        hash_or_height: HashOrHeight,
520    ) -> Result<(Block, block::Hash), Status> {
521        // Encode the request as a single `hash_or_height` byte string: a 32-byte hash in display
522        // order, or a 4-byte big-endian height. The server tells them apart by length.
523        let hash_or_height = match hash_or_height {
524            HashOrHeight::Hash(hash) => hash.bytes_in_display_order().to_vec(),
525            HashOrHeight::Height(height) => height.0.to_be_bytes().to_vec(),
526        };
527        let request = BlockRequest { hash_or_height };
528
529        let response = tokio::time::timeout(
530            GET_BLOCK_TIMEOUT,
531            self.indexer_rpc_client.clone().get_block(request),
532        )
533        .await
534        .map_err(|_| Status::deadline_exceeded("get_block request timed out"))??;
535
536        response
537            .into_inner()
538            .decode()
539            .ok_or_else(|| Status::internal("failed to decode block from get_block response"))
540    }
541
542    /// Calls the `non_finalized_state_change()` method on the indexer gRPC client to subscribe
543    /// to non-finalized state changes, and returns the response stream.
544    ///
545    /// Passes the tip hashes of every chain currently in this syncer's non-finalized state so the
546    /// server only streams blocks after the tips we already have, instead of re-sending the whole
547    /// non-finalized state on every (re)subscription. When the non-finalized state is empty, the
548    /// request carries no tips and the server streams every non-finalized block.
549    async fn subscribe_to_non_finalized_state_change(
550        &mut self,
551    ) -> Result<Streaming<BlockAndHash>, Status> {
552        let request = NonFinalizedStateChangeRequest {
553            chain_tip_hashes: self
554                .non_finalized_state
555                .chain_iter()
556                .map(|c| c.non_finalized_tip_hash().bytes_in_display_order().to_vec())
557                .collect(),
558        };
559
560        tokio::time::timeout(
561            SUBSCRIBE_TIMEOUT,
562            self.indexer_rpc_client
563                .clone()
564                .non_finalized_state_change(request),
565        )
566        .await
567        .map_err(|_| {
568            Status::deadline_exceeded("non_finalized_state_change subscription timed out")
569        })?
570        .map(|a| a.into_inner())
571    }
572
573    /// Tries to catch up to the primary db instance for an up-to-date view of finalized blocks.
574    async fn try_catch_up_with_primary(&self) {
575        let _ = self.db.spawn_try_catch_up_with_primary().await;
576    }
577
578    /// Finalizes any non-finalized blocks that are at or below the finalized tip height.
579    ///
580    /// The secondary's finalized state follows the primary, so after catching up it may have
581    /// advanced past the root of the non-finalized state. This drops those now-finalized blocks so
582    /// the non-finalized state only tracks blocks above the finalized tip. Does nothing when the
583    /// non-finalized state is empty.
584    fn prune_finalized(&mut self) {
585        let finalized_tip_height = self.db.finalized_tip_height().unwrap_or(Height::MIN);
586        while self
587            .non_finalized_state
588            .root_height()
589            .is_some_and(|root_height| root_height <= finalized_tip_height)
590        {
591            tracing::trace!("finalizing block past the reorg limit");
592            self.non_finalized_state.finalize();
593        }
594    }
595
596    /// Sends the new chain tip and non-finalized state to the latest chain channels.
597    // TODO: Replace this with the `update_latest_chain_channels()` fn in `write.rs`.
598    fn update_channels(&mut self) {
599        // If the final receiver was just dropped, ignore the error.
600        let _ = self
601            .non_finalized_state_sender
602            .send(self.non_finalized_state.clone());
603
604        let best_chain = self.non_finalized_state.best_chain().expect("unexpected empty non-finalized state: must commit at least one block before updating channels");
605
606        let tip_block = best_chain
607            .tip_block()
608            .expect(
609                "unexpected empty chain: must commit at least one block before updating channels",
610            )
611            .clone();
612
613        self.chain_tip_sender
614            .set_best_non_finalized_tip(Some(tip_block.into()));
615    }
616}
617
618/// Accepts a [zebra-state configuration](zebra_state::Config), a [`Network`], and
619/// the [`SocketAddr`] of a Zebra node's RPC server.
620///
621/// Initializes a [`ReadStateService`] and a [`TrustedChainSync`] to update the
622/// non-finalized best chain and the latest chain tip.
623///
624/// Returns a [`ReadStateService`], [`LatestChainTip`], [`ChainTipChange`], and
625/// a [`JoinHandle`] for the sync task.
626pub fn init_read_state_with_syncer(
627    config: zebra_state::Config,
628    network: &Network,
629    indexer_rpc_address: SocketAddr,
630) -> tokio::task::JoinHandle<
631    Result<
632        (
633            ReadStateService,
634            LatestChainTip,
635            ChainTipChange,
636            tokio::task::JoinHandle<()>,
637        ),
638        BoxError,
639    >,
640> {
641    let network = network.clone();
642    tokio::spawn(async move {
643        if config.ephemeral {
644            return Err("standalone read state service cannot be used with ephemeral state".into());
645        }
646
647        // The outer `?` propagates a `JoinError` if the blocking task panicked or was
648        // cancelled, and the inner `?` propagates a `StateInitError` (e.g. a missing
649        // read-only database).
650        let (read_state, db, non_finalized_state_sender) =
651            spawn_init_read_only(config, &network).await??;
652        let (latest_chain_tip, chain_tip_change, sync_task) =
653            TrustedChainSync::spawn(indexer_rpc_address, db, non_finalized_state_sender).await?;
654        Ok((read_state, latest_chain_tip, chain_tip_change, sync_task))
655    })
656}