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