Skip to content

Commit 7d872cc

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 477c1a3 commit 7d872cc

5 files changed

Lines changed: 98 additions & 15 deletions

File tree

src/bin/electrs.rs

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,23 @@ fn fetch_from(config: &Config, store: &Store) -> FetchFrom {
4848
}
4949
}
5050

51+
// TODO: configuration for which servers to start
52+
fn start_servers(
53+
config: &Arc<Config>,
54+
query: &Arc<Query>,
55+
metrics: &Metrics,
56+
salt_rwlock: &Arc<RwLock<String>>,
57+
) -> (rest::Handle, ElectrumRPC) {
58+
let rest_server = rest::start(Arc::clone(config), Arc::clone(query));
59+
let electrum_server = ElectrumRPC::start(
60+
Arc::clone(config),
61+
Arc::clone(query),
62+
metrics,
63+
Arc::clone(salt_rwlock),
64+
);
65+
(rest_server, electrum_server)
66+
}
67+
5168
fn run_server(config: Arc<Config>, salt_rwlock: Arc<RwLock<String>>) -> Result<()> {
5269
let (block_hash_notify, block_hash_receive) = channel::bounded(1);
5370
let signal = Waiter::start(block_hash_receive);
@@ -84,10 +101,15 @@ fn run_server(config: Arc<Config>, salt_rwlock: Arc<RwLock<String>>) -> Result<(
84101
&metrics,
85102
));
86103

104+
// Pre-caching is pure cache warming; run it in the background so it doesn't
105+
// delay startup. The file is still read upfront to fail fast on a bad path.
87106
if let Some(ref precache_file) = config.precache_scripts {
88107
let precache_scripthashes = precache::scripthashes_from_file(precache_file.to_string())
89108
.expect("cannot load scripts to precache");
90-
precache::precache(&chain, precache_scripthashes);
109+
let precache_chain = Arc::clone(&chain);
110+
thread::spawn(move || {
111+
precache::precache(&precache_chain, precache_scripthashes);
112+
});
91113
}
92114

93115
let mempool = Arc::new(RwLock::new(Mempool::new(
@@ -96,12 +118,6 @@ fn run_server(config: Arc<Config>, salt_rwlock: Arc<RwLock<String>>) -> Result<(
96118
Arc::clone(&config),
97119
)));
98120

99-
while !Mempool::update(&mempool, &daemon, &tip)? {
100-
// Mempool syncing was aborted because the chain tip moved;
101-
// Index the new block(s) and try again.
102-
tip = indexer.update(&daemon)?;
103-
}
104-
105121
#[cfg(feature = "liquid")]
106122
let asset_db = config.asset_db_path.as_ref().map(|db_dir| {
107123
let asset_db = Arc::new(RwLock::new(AssetRegistry::new(db_dir.clone())));
@@ -118,14 +134,26 @@ fn run_server(config: Arc<Config>, salt_rwlock: Arc<RwLock<String>>) -> Result<(
118134
asset_db,
119135
));
120136

121-
// TODO: configuration for which servers to start
122-
let rest_server = rest::start(Arc::clone(&config), Arc::clone(&query));
123-
let electrum_server = ElectrumRPC::start(
124-
Arc::clone(&config),
125-
Arc::clone(&query),
126-
&metrics,
127-
Arc::clone(&salt_rwlock),
128-
);
137+
// With --serve-during-mempool-sync, the servers start serving chain-based queries
138+
// right away while the mempool syncs; /health/ready reports mempool_synced=false
139+
// until the initial sync completes, keeping the instance out of LB rotation.
140+
let mut servers = if config.serve_during_mempool_sync {
141+
Some(start_servers(&config, &query, &metrics, &salt_rwlock))
142+
} else {
143+
None
144+
};
145+
146+
while !Mempool::update(&mempool, &daemon, &tip)? {
147+
// Mempool syncing was aborted because the chain tip moved;
148+
// Index the new block(s) and try again.
149+
tip = indexer.update(&daemon)?;
150+
}
151+
152+
let (rest_server, electrum_server) = match servers.take() {
153+
Some(servers) => servers,
154+
None => start_servers(&config, &query, &metrics, &salt_rwlock),
155+
};
156+
info!("startup complete");
129157

130158
let main_loop_count = metrics.gauge(MetricOpts::new(
131159
"electrs_main_loop_count",

src/config.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ pub struct Config {
3737
pub index_unspendables: bool,
3838
pub cors: Option<String>,
3939
pub precache_scripts: Option<String>,
40+
/// Start the REST and Electrum servers before the initial mempool sync completes.
41+
/// Chain-based queries are fully correct during the sync; mempool-derived data is
42+
/// incomplete until /health/ready reports mempool_synced=true, so readiness probes
43+
/// must use that endpoint instead of a TCP check when this is enabled.
44+
pub serve_during_mempool_sync: bool,
4045
pub utxos_limit: usize,
4146
pub electrum_txs_limit: usize,
4247
pub electrum_banner: String,
@@ -198,6 +203,11 @@ impl Config {
198203
.help("Path to file with list of scripts to pre-cache")
199204
.takes_value(true)
200205
)
206+
.arg(
207+
Arg::with_name("serve_during_mempool_sync")
208+
.long("serve-during-mempool-sync")
209+
.help("Start the REST/Electrum servers before the initial mempool sync completes. Requires readiness checks to use /health/ready instead of a TCP probe.")
210+
)
201211
.arg(
202212
Arg::with_name("utxos_limit")
203213
.long("utxos-limit")
@@ -487,6 +497,7 @@ impl Config {
487497
index_unspendables: m.is_present("index_unspendables"),
488498
cors: m.value_of("cors").map(|s| s.to_string()),
489499
precache_scripts: m.value_of("precache_scripts").map(|s| s.to_string()),
500+
serve_during_mempool_sync: m.is_present("serve_during_mempool_sync"),
490501
initial_sync_compaction: m.is_present("initial_sync_compaction"),
491502
db_block_cache_mb: value_t_or_exit!(m, "db_block_cache_mb", usize),
492503
db_parallelism: value_t_or_exit!(m, "db_parallelism", 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
@@ -632,6 +632,28 @@ fn handle_request(
632632
path.get(3),
633633
path.get(4),
634634
) {
635+
(&Method::GET, Some(&"health"), Some(&"ready"), None, None, None) => {
636+
// The chain index is always synced by the time the server is listening; the
637+
// mempool may still be performing its initial sync when started with
638+
// --serve-during-mempool-sync. 503 keeps not-fully-synced instances out of
639+
// load balancer rotation while still being reachable for diagnostics.
640+
let mempool_synced = query.mempool_synced();
641+
let status = if mempool_synced {
642+
StatusCode::OK
643+
} else {
644+
StatusCode::SERVICE_UNAVAILABLE
645+
};
646+
Ok(Response::builder()
647+
.status(status)
648+
.header("Content-Type", "application/json")
649+
.header("Cache-Control", "no-cache")
650+
.body(Body::from(format!(
651+
"{{\"chain_synced\":true,\"mempool_synced\":{}}}",
652+
mempool_synced
653+
)))
654+
.unwrap())
655+
}
656+
635657
(&Method::GET, Some(&"blocks"), Some(&"tip"), Some(&"hash"), None, None) => http_message(
636658
StatusCode::OK,
637659
query.chain().best_hash().to_string(),

0 commit comments

Comments
 (0)