Skip to content

Commit 2196187

Browse files
committed
Add seeded startup operations benchmark
Add a startup benchmark that restarts a node whose store already contains channel and payment data, so startup cost reflects persisted node state. AI-assisted-by: OpenAI Codex
1 parent 3909051 commit 2196187

1 file changed

Lines changed: 306 additions & 2 deletions

File tree

benches/operations.rs

Lines changed: 306 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,66 @@ mod common;
1111
use std::sync::Arc;
1212
use std::time::{Duration, Instant};
1313

14+
use bitcoin::secp256k1::PublicKey;
1415
use bitcoin::Amount;
1516
use common::{
1617
expect_channel_pending_event, expect_channel_ready_event, expect_event,
17-
generate_blocks_and_wait, premine_and_distribute_funds, random_config,
18+
generate_blocks_and_wait, premine_and_distribute_funds, random_config, random_storage_path,
1819
setup_bitcoind_and_electrsd, setup_node, setup_two_nodes_with_store,
1920
};
2021
use criterion::{criterion_group, criterion_main, Criterion};
2122
use electrsd::corepc_node::{Client as BitcoindClient, Node as BitcoinD};
23+
use ldk_node::io::sqlite_store::{SqliteStore, KV_TABLE_NAME, SQLITE_DB_FILE_NAME};
2224
use ldk_node::{Event, Node};
2325
use lightning::ln::channelmanager::PaymentId;
26+
use lightning::util::persist::migrate_kv_store_data_async;
2427
use lightning_invoice::{Bolt11InvoiceDescription, Description};
28+
use lightning_persister::fs_store::v2::FilesystemStoreV2;
29+
30+
use crate::common::{open_channel_push_amt, TestChainSource, TestConfig, TestStoreType};
31+
32+
#[cfg(feature = "postgres")]
33+
use ldk_node::io::postgres_store::{PostgresStore, POSTGRES_TEST_URL_ENV_VAR};
34+
35+
const STARTUP_SEED_SCENARIOS: [StartupSeedScenario; 6] = [
36+
StartupSeedScenario { channel_count: 1, payment_count: 2 },
37+
StartupSeedScenario { channel_count: 1, payment_count: 100 },
38+
StartupSeedScenario { channel_count: 1, payment_count: 1_000 },
39+
StartupSeedScenario { channel_count: 10, payment_count: 2 },
40+
StartupSeedScenario { channel_count: 100, payment_count: 2 },
41+
StartupSeedScenario { channel_count: 100, payment_count: 1_000 },
42+
];
43+
const STARTUP_SEED_PAYMENT_AMOUNT_MSAT: u64 = 1_000_000;
44+
const STARTUP_SEED_MIN_CHANNEL_FUNDING_SAT: u64 = 100_000;
45+
const STARTUP_SEED_CHANNEL_BUFFER_SAT: u64 = 1_000_000;
46+
const STARTUP_SEED_CHANNEL_BATCH_SIZE: u64 = 2;
2547

26-
use crate::common::{open_channel_push_amt, TestChainSource, TestStoreType};
48+
#[derive(Clone, Copy)]
49+
struct StartupSeedScenario {
50+
channel_count: u64,
51+
payment_count: u64,
52+
}
53+
54+
impl StartupSeedScenario {
55+
fn bench_name(self, store_name: &str) -> String {
56+
format!("{}/channels_{}_payments_{}", store_name, self.channel_count, self.payment_count)
57+
}
58+
59+
fn runs_in_ci(self) -> bool {
60+
self.channel_count == 1 && self.payment_count == 2
61+
}
62+
63+
fn channel_funding_sat(self) -> u64 {
64+
let payment_amount_sat = STARTUP_SEED_PAYMENT_AMOUNT_MSAT / 1_000;
65+
let payment_funding_sat =
66+
self.payment_count * payment_amount_sat + STARTUP_SEED_CHANNEL_BUFFER_SAT;
67+
payment_funding_sat.max(STARTUP_SEED_MIN_CHANNEL_FUNDING_SAT)
68+
}
69+
70+
fn premine_amount_sat(self) -> u64 {
71+
self.channel_count * self.channel_funding_sat() + STARTUP_SEED_CHANNEL_BUFFER_SAT
72+
}
73+
}
2774

2875
#[derive(Clone, Copy)]
2976
struct StoreBenchConfig {
@@ -34,6 +81,7 @@ struct StoreBenchConfig {
3481
fn operations_benchmark(c: &mut Criterion) {
3582
forwarding_benchmark(c);
3683
channel_open_benchmark(c);
84+
startup_benchmark(c);
3785
}
3886

3987
fn forwarding_benchmark(c: &mut Criterion) {
@@ -147,6 +195,230 @@ fn channel_open_benchmark(c: &mut Criterion) {
147195
}
148196
}
149197

198+
fn startup_benchmark(c: &mut Criterion) {
199+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
200+
let chain_source = TestChainSource::BitcoindRpcSync(&bitcoind);
201+
let runtime = benchmark_runtime();
202+
203+
let mut group = c.benchmark_group("startup");
204+
group.sample_size(10);
205+
206+
for startup_seed_scenario in STARTUP_SEED_SCENARIOS {
207+
// Larger seeded startup scenarios are useful locally, but take too long to run in CI.
208+
if is_ci() && !startup_seed_scenario.runs_in_ci() {
209+
continue;
210+
}
211+
212+
let matching_store_configs: Vec<_> = store_bench_configs()
213+
.into_iter()
214+
.filter(|store_config| {
215+
let bench_name = startup_seed_scenario.bench_name(store_config.name);
216+
should_register_bench("startup", &bench_name)
217+
})
218+
.collect();
219+
if matching_store_configs.is_empty() {
220+
continue;
221+
}
222+
223+
// Seed a canonical sqlite node once, then copy its store into each backend under test. This
224+
// keeps the channel/payment history identical across stores while avoiding repeated expensive
225+
// channel and payment setup for every store backend.
226+
let seeded_config = setup_startup_seed_node(
227+
&chain_source,
228+
&bitcoind,
229+
&electrsd,
230+
startup_seed_scenario,
231+
&runtime,
232+
);
233+
let startup_configs = migrate_startup_seed_configs(
234+
&seeded_config,
235+
startup_seed_scenario,
236+
matching_store_configs,
237+
&runtime,
238+
);
239+
240+
for (bench_name, config) in startup_configs {
241+
group.bench_function(bench_name, |b| {
242+
b.iter_custom(|iter| {
243+
let mut total = Duration::ZERO;
244+
for _ in 0..iter {
245+
let start = Instant::now();
246+
let node = setup_node(&chain_source, config.clone());
247+
total += start.elapsed();
248+
node.stop().unwrap();
249+
}
250+
total
251+
});
252+
});
253+
}
254+
}
255+
}
256+
257+
/// Builds a canonical sqlite node store with the requested channel and payment history.
258+
///
259+
/// Startup benchmarks use this store as the source fixture for every backend so differences in
260+
/// measured startup time come from loading equivalent persisted state, not from different setup
261+
/// runs.
262+
fn setup_startup_seed_node(
263+
chain_source: &TestChainSource, bitcoind: &BitcoinD, electrsd: &electrsd::ElectrsD,
264+
seed_scenario: StartupSeedScenario, runtime: &tokio::runtime::Runtime,
265+
) -> TestConfig {
266+
let mut config_a = random_config(true);
267+
config_a.store_type = TestStoreType::Sqlite;
268+
let node_a = Arc::new(setup_node(chain_source, config_a.clone()));
269+
270+
let mut config_b = random_config(true);
271+
config_b.store_type = TestStoreType::Sqlite;
272+
let node_b = Arc::new(setup_node(chain_source, config_b));
273+
274+
runtime.block_on(async {
275+
let address_a = node_a.onchain_payment().new_address().unwrap();
276+
premine_and_distribute_funds(
277+
&bitcoind.client,
278+
&electrsd.client,
279+
vec![address_a],
280+
Amount::from_sat(seed_scenario.premine_amount_sat()),
281+
)
282+
.await;
283+
node_a.sync_wallets().unwrap();
284+
node_b.sync_wallets().unwrap();
285+
286+
let funding_amount_sat = seed_scenario.channel_funding_sat();
287+
let mut remaining_channel_count = seed_scenario.channel_count;
288+
while remaining_channel_count > 0 {
289+
let channel_batch_size = remaining_channel_count.min(STARTUP_SEED_CHANNEL_BATCH_SIZE);
290+
for _ in 0..channel_batch_size {
291+
node_a
292+
.open_channel(
293+
node_b.node_id(),
294+
node_b.listening_addresses().unwrap().first().unwrap().clone(),
295+
funding_amount_sat,
296+
None,
297+
None,
298+
)
299+
.unwrap();
300+
assert!(node_a.list_peers().iter().any(|peer| peer.node_id == node_b.node_id()));
301+
302+
let funding_txo_a = expect_channel_pending_event!(node_a, node_b.node_id());
303+
let funding_txo_b = expect_channel_pending_event!(node_b, node_a.node_id());
304+
assert_eq!(funding_txo_a, funding_txo_b);
305+
node_a.sync_wallets().unwrap();
306+
}
307+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
308+
309+
for _ in 0..channel_batch_size {
310+
node_a.sync_wallets().unwrap();
311+
node_b.sync_wallets().unwrap();
312+
wait_for_channel_ready_events(&node_a, node_b.node_id(), 1).await;
313+
wait_for_channel_ready_events(&node_b, node_a.node_id(), 1).await;
314+
}
315+
remaining_channel_count -= channel_batch_size;
316+
}
317+
318+
for idx in 0..seed_scenario.payment_count {
319+
let invoice_description = Bolt11InvoiceDescription::Direct(
320+
Description::new(format!("startup seed {}", idx + 1)).unwrap(),
321+
);
322+
let invoice = node_b
323+
.bolt11_payment()
324+
.receive(STARTUP_SEED_PAYMENT_AMOUNT_MSAT, &invoice_description.into(), 9217)
325+
.unwrap();
326+
let payment_id = node_a.bolt11_payment().send(&invoice, None).unwrap();
327+
wait_for_payment_success(&node_a, payment_id).await;
328+
}
329+
330+
drain_events(&node_a);
331+
drain_events(&node_b);
332+
});
333+
334+
node_a.stop().unwrap();
335+
node_b.stop().unwrap();
336+
337+
config_a
338+
}
339+
340+
/// Produces benchmark configs backed by copies of the canonical seeded store.
341+
///
342+
/// Sqlite can reuse the source store directly. Other store backends get a fresh storage path and a
343+
/// migrated copy of the same key-value data.
344+
fn migrate_startup_seed_configs(
345+
source_config: &TestConfig, seed_scenario: StartupSeedScenario,
346+
store_configs: Vec<StoreBenchConfig>, runtime: &tokio::runtime::Runtime,
347+
) -> Vec<(String, TestConfig)> {
348+
// Open the seeded source store with the same db file and table the node itself uses, otherwise
349+
// we'd read from an empty default-named store and migrate nothing into the other backends.
350+
let source_store = SqliteStore::new(
351+
source_config.node_config.storage_dir_path.clone().into(),
352+
Some(SQLITE_DB_FILE_NAME.to_string()),
353+
Some(KV_TABLE_NAME.to_string()),
354+
)
355+
.unwrap();
356+
357+
store_configs
358+
.into_iter()
359+
.map(|store_config| {
360+
let mut config = source_config.clone();
361+
config.store_type = store_config.store_type;
362+
if !matches!(store_config.store_type, TestStoreType::Sqlite) {
363+
config.node_config.storage_dir_path =
364+
random_storage_path().to_str().unwrap().to_owned();
365+
migrate_startup_seed_store(&source_store, &config, runtime);
366+
}
367+
368+
(seed_scenario.bench_name(store_config.name), config)
369+
})
370+
.collect()
371+
}
372+
373+
fn migrate_startup_seed_store(
374+
source_store: &SqliteStore, destination_config: &TestConfig, runtime: &tokio::runtime::Runtime,
375+
) {
376+
runtime.block_on(async {
377+
match destination_config.store_type {
378+
TestStoreType::Sqlite => {},
379+
TestStoreType::FilesystemStore => {
380+
let destination_store = FilesystemStoreV2::new(
381+
destination_config.node_config.storage_dir_path.clone().into(),
382+
)
383+
.unwrap();
384+
migrate_kv_store_data_async(source_store, &destination_store).await.unwrap();
385+
},
386+
#[cfg(feature = "postgres")]
387+
TestStoreType::Postgres => {
388+
let connection_string = postgres_connection_string();
389+
let table_name = postgres_table_name(destination_config);
390+
let destination_store =
391+
PostgresStore::new(connection_string, None, Some(table_name), None)
392+
.await
393+
.unwrap();
394+
migrate_kv_store_data_async(source_store, &destination_store).await.unwrap();
395+
},
396+
TestStoreType::TestSyncStore => {
397+
unreachable!("startup benches do not use TestSyncStore")
398+
},
399+
}
400+
});
401+
}
402+
403+
#[cfg(feature = "postgres")]
404+
fn postgres_connection_string() -> String {
405+
std::env::var(POSTGRES_TEST_URL_ENV_VAR)
406+
.unwrap_or_else(|_| "host=localhost user=postgres password=postgres".to_string())
407+
}
408+
409+
#[cfg(feature = "postgres")]
410+
fn postgres_table_name(config: &TestConfig) -> String {
411+
format!(
412+
"test_{}",
413+
config
414+
.node_config
415+
.storage_dir_path
416+
.chars()
417+
.filter(|c| c.is_ascii_alphanumeric())
418+
.collect::<String>()
419+
)
420+
}
421+
150422
/// Returns whether the benchmark identified by `group/name` matches the CLI filters.
151423
///
152424
/// Criterion applies its own filters after benchmark registration, but these benches do expensive
@@ -163,6 +435,10 @@ fn should_register_bench(group: &str, name: &str) -> bool {
163435
})
164436
}
165437

438+
fn is_ci() -> bool {
439+
std::env::var("CI").is_ok_and(|value| !value.is_empty() && value != "0" && value != "false")
440+
}
441+
166442
fn setup_forwarding_nodes(
167443
chain_source: &TestChainSource, bitcoind: &BitcoinD, electrsd: &electrsd::ElectrsD,
168444
store_type: TestStoreType, runtime: &tokio::runtime::Runtime,
@@ -373,6 +649,34 @@ async fn wait_for_payment_success(node: &Node, expected_payment_id: PaymentId) {
373649
}
374650
}
375651

652+
async fn wait_for_channel_ready_events(node: &Node, counterparty_node_id: PublicKey, count: u64) {
653+
let mut remaining_count = count;
654+
while remaining_count > 0 {
655+
let event = tokio::time::timeout(
656+
Duration::from_secs(common::INTEROP_TIMEOUT_SECS),
657+
node.next_event_async(),
658+
)
659+
.await
660+
.unwrap_or_else(|_| {
661+
panic!("{} timed out waiting for ChannelReady event after 60s", node.node_id())
662+
});
663+
664+
match event {
665+
ref e @ Event::ChannelReady { counterparty_node_id: Some(node_id), .. }
666+
if node_id == counterparty_node_id =>
667+
{
668+
println!("{} got event {:?}", node.node_id(), e);
669+
remaining_count -= 1;
670+
},
671+
ref e @ Event::ChannelReady { .. } => {
672+
panic!("{} got unexpected ChannelReady event: {:?}", node.node_id(), e);
673+
},
674+
_ => {},
675+
}
676+
node.event_handled().unwrap();
677+
}
678+
}
679+
376680
fn drain_events(node: &Node) {
377681
while node.next_event().is_some() {
378682
node.event_handled().unwrap();

0 commit comments

Comments
 (0)