Skip to content

Commit 7a82462

Browse files
DeviaVirclaude
andcommitted
feat(startup): decouple precache and mempool sync from server startup
After a pod/node restart, electrs refused to listen on any port until it had serially completed (1) the popular-scripts precache and (2) a full initial mempool sync over JSONRPC. On a congested mainnet mempool this kept instances unready for 15-30+ minutes even though the chain index was fully usable within seconds (measured: 17 min total, of which 15 min was mempool sync). - Run --precache-scripts in a background thread; it is pure cache warming and never affects correctness. The file is still read upfront to fail fast on a bad path. - Add --serve-during-mempool-sync (default off, no behavior change): start the REST/Electrum servers before the initial mempool sync. Chain-based queries are fully correct during the sync; mempool-derived data is incomplete until the first full sync completes. - Add GET /health/ready returning {chain_synced, mempool_synced} with 200 once the initial mempool sync has completed and 503 before that, so load balancers / readiness probes can keep early-serving instances out of rotation. Deployments enabling --serve-during-mempool-sync MUST switch readiness probes from a TCP check to this endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4607b77 commit 7a82462

5 files changed

Lines changed: 96 additions & 15 deletions

File tree

src/bin/electrs.rs

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,23 @@ fn fetch_from(config: &Config, store: &Store) -> FetchFrom {
5151
}
5252
}
5353

54+
// TODO: configuration for which servers to start
55+
fn start_servers(
56+
config: &Arc<Config>,
57+
query: &Arc<Query>,
58+
metrics: &Metrics,
59+
salt_rwlock: &Arc<RwLock<String>>,
60+
) -> (rest::Handle, ElectrumRPC) {
61+
let rest_server = rest::start(Arc::clone(config), Arc::clone(query));
62+
let electrum_server = ElectrumRPC::start(
63+
Arc::clone(config),
64+
Arc::clone(query),
65+
metrics,
66+
Arc::clone(salt_rwlock),
67+
);
68+
(rest_server, electrum_server)
69+
}
70+
5471
fn run_server(config: Arc<Config>, salt_rwlock: Arc<RwLock<String>>) -> Result<()> {
5572
let (block_hash_notify, block_hash_receive) = channel::bounded(1);
5673
let signal = Waiter::start(block_hash_receive);
@@ -94,10 +111,15 @@ fn run_server(config: Arc<Config>, salt_rwlock: Arc<RwLock<String>>) -> Result<(
94111
&metrics,
95112
));
96113

114+
// Pre-caching is pure cache warming; run it in the background so it doesn't
115+
// delay startup. The file is still read upfront to fail fast on a bad path.
97116
if let Some(ref precache_file) = config.precache_scripts {
98117
let precache_scripthashes = precache::scripthashes_from_file(precache_file.to_string())
99118
.expect("cannot load scripts to precache");
100-
precache::precache(&chain, precache_scripthashes);
119+
let precache_chain = Arc::clone(&chain);
120+
thread::spawn(move || {
121+
precache::precache(&precache_chain, precache_scripthashes);
122+
});
101123
}
102124

103125
info!("loading mempool");
@@ -107,12 +129,6 @@ fn run_server(config: Arc<Config>, salt_rwlock: Arc<RwLock<String>>) -> Result<(
107129
Arc::clone(&config),
108130
)));
109131

110-
while !Mempool::update(&mempool, &daemon, &tip)? {
111-
// Mempool syncing was aborted because the chain tip moved;
112-
// Index the new block(s) and try again.
113-
tip = indexer.update(&daemon)?;
114-
}
115-
116132
#[cfg(feature = "liquid")]
117133
let asset_db = config.asset_db_path.as_ref().map(|db_dir| {
118134
let asset_db = Arc::new(RwLock::new(AssetRegistry::new(db_dir.clone())));
@@ -129,15 +145,25 @@ fn run_server(config: Arc<Config>, salt_rwlock: Arc<RwLock<String>>) -> Result<(
129145
asset_db,
130146
));
131147

132-
// TODO: configuration for which servers to start
133-
let rest_server = rest::start(Arc::clone(&config), Arc::clone(&query));
134-
let electrum_server = ElectrumRPC::start(
135-
Arc::clone(&config),
136-
Arc::clone(&query),
137-
&metrics,
138-
Arc::clone(&salt_rwlock),
139-
);
148+
// With --serve-during-mempool-sync, the servers start serving chain-based queries
149+
// right away while the mempool syncs; /health/ready reports mempool_synced=false
150+
// until the initial sync completes, keeping the instance out of LB rotation.
151+
let mut servers = if config.serve_during_mempool_sync {
152+
Some(start_servers(&config, &query, &metrics, &salt_rwlock))
153+
} else {
154+
None
155+
};
156+
157+
while !Mempool::update(&mempool, &daemon, &tip)? {
158+
// Mempool syncing was aborted because the chain tip moved;
159+
// Index the new block(s) and try again.
160+
tip = indexer.update(&daemon)?;
161+
}
140162

163+
let (rest_server, electrum_server) = match servers.take() {
164+
Some(servers) => servers,
165+
None => start_servers(&config, &query, &metrics, &salt_rwlock),
166+
};
141167
info!("startup complete");
142168

143169
let main_loop_count = metrics.gauge(MetricOpts::new(

src/config.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ pub struct Config {
3838
pub index_unspendables: bool,
3939
pub cors: Option<String>,
4040
pub precache_scripts: Option<String>,
41+
/// Start the REST and Electrum servers before the initial mempool sync completes.
42+
/// Chain-based queries are fully correct during the sync; mempool-derived data is
43+
/// incomplete until /health/ready reports mempool_synced=true, so readiness probes
44+
/// must use that endpoint instead of a TCP check when this is enabled.
45+
pub serve_during_mempool_sync: bool,
4146
pub utxos_limit: usize,
4247
pub electrum_txs_limit: usize,
4348
pub electrum_banner: String,
@@ -215,6 +220,11 @@ impl Config {
215220
.help("Path to file with list of scripts to pre-cache")
216221
.takes_value(true)
217222
)
223+
.arg(
224+
Arg::with_name("serve_during_mempool_sync")
225+
.long("serve-during-mempool-sync")
226+
.help("Start the REST/Electrum servers before the initial mempool sync completes. Requires readiness checks to use /health/ready instead of a TCP probe.")
227+
)
218228
.arg(
219229
Arg::with_name("utxos_limit")
220230
.long("utxos-limit")
@@ -517,6 +527,7 @@ impl Config {
517527
index_unspendables: m.is_present("index_unspendables"),
518528
cors: m.value_of("cors").map(|s| s.to_string()),
519529
precache_scripts: m.value_of("precache_scripts").map(|s| s.to_string()),
530+
serve_during_mempool_sync: m.is_present("serve_during_mempool_sync"),
520531
db_block_cache_mb: value_t_or_exit!(m, "db_block_cache_mb", usize),
521532
db_parallelism: value_t_or_exit!(m, "db_parallelism", usize),
522533
db_write_buffer_size_mb: value_t_or_exit!(m, "db_write_buffer_size_mb", usize),

src/new_index/mempool.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ pub struct Mempool {
4141
edges: HashMap<OutPoint, (Txid, u32)>, // OutPoint -> (spending_txid, spending_vin)
4242
recent: ArrayDeque<TxOverview, RECENT_TXS_SIZE, Wrapping>, // The N most recent txs to enter the mempool
4343
backlog_stats: (BacklogStats, Instant),
44+
// Whether the initial sync with bitcoind's mempool has completed at least once.
45+
// Until then, mempool-derived data (unconfirmed history, outspends, backlog stats)
46+
// is incomplete; exposed via /health/ready for readiness checks.
47+
synced: bool,
4448

4549
// monitoring
4650
latency: HistogramVec, // mempool requests latency
@@ -81,6 +85,7 @@ impl Mempool {
8185
BacklogStats::default(),
8286
Instant::now() - Duration::from_secs(BACKLOG_STATS_TTL),
8387
),
88+
synced: false,
8489
latency: metrics.histogram_vec(
8590
HistogramOpts::new("mempool_latency", "Mempool requests latency (in seconds)"),
8691
&["part"],
@@ -105,6 +110,10 @@ impl Mempool {
105110
self.config.network_type
106111
}
107112

113+
pub fn is_synced(&self) -> bool {
114+
self.synced
115+
}
116+
108117
pub fn lookup_txn(&self, txid: &Txid) -> Option<Transaction> {
109118
self.txstore.get(txid).cloned()
110119
}
@@ -576,6 +585,7 @@ impl Mempool {
576585
.set(new_txids.len() as f64);
577586

578587
if new_txids.is_empty() {
588+
Self::mark_synced(mempool);
579589
return Ok(true);
580590
}
581591

@@ -648,9 +658,17 @@ impl Mempool {
648658
}
649659

650660
trace!("mempool is synced");
661+
Self::mark_synced(mempool);
651662

652663
Ok(true)
653664
}
665+
666+
fn mark_synced(mempool: &Arc<RwLock<Mempool>>) {
667+
if !mempool.read().unwrap().synced {
668+
mempool.write().unwrap().synced = true;
669+
info!("initial mempool sync complete");
670+
}
671+
}
654672
}
655673

656674
fn prune_history_entries(

src/new_index/query.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ impl Query {
6969
self.mempool.read().unwrap()
7070
}
7171

72+
pub fn mempool_synced(&self) -> bool {
73+
self.mempool.read().unwrap().is_synced()
74+
}
75+
7276
#[trace]
7377
pub fn broadcast_raw(&self, txhex: &str) -> Result<Txid> {
7478
let txid = self.daemon.broadcast_raw(txhex)?;

src/rest.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,28 @@ fn handle_request(
697697
path.get(3),
698698
path.get(4),
699699
) {
700+
(&Method::GET, Some(&"health"), Some(&"ready"), None, None, None) => {
701+
// The chain index is always synced by the time the server is listening; the
702+
// mempool may still be performing its initial sync when started with
703+
// --serve-during-mempool-sync. 503 keeps not-fully-synced instances out of
704+
// load balancer rotation while still being reachable for diagnostics.
705+
let mempool_synced = query.mempool_synced();
706+
let status = if mempool_synced {
707+
StatusCode::OK
708+
} else {
709+
StatusCode::SERVICE_UNAVAILABLE
710+
};
711+
Ok(Response::builder()
712+
.status(status)
713+
.header("Content-Type", "application/json")
714+
.header("Cache-Control", "no-cache")
715+
.body(Full::new(Bytes::from(format!(
716+
"{{\"chain_synced\":true,\"mempool_synced\":{}}}",
717+
mempool_synced
718+
))))
719+
.unwrap())
720+
}
721+
700722
(&Method::GET, Some(&"blocks"), Some(&"tip"), Some(&"hash"), None, None) => http_message(
701723
StatusCode::OK,
702724
query.chain().best_hash().to_string(),

0 commit comments

Comments
 (0)