Skip to content

Commit db9098e

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 68daede commit db9098e

2 files changed

Lines changed: 312 additions & 5 deletions

File tree

benches/operations.rs

Lines changed: 282 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,69 @@ use bitcoin::Amount;
1515
use common::{
1616
expect_channel_pending_event, expect_channel_ready_event, expect_event,
1717
generate_blocks_and_wait, open_channel_no_wait, premine_and_distribute_funds, random_config,
18-
setup_bitcoind_and_electrsd, setup_node, setup_two_nodes_with_store, store_bench_configs,
19-
wait_for_payment_success,
18+
random_storage_path, setup_bitcoind_and_electrsd, setup_node, setup_two_nodes_with_store,
19+
store_bench_configs, wait_for_channel_ready_events, wait_for_payment_success,
2020
};
2121
use criterion::{criterion_group, criterion_main, Criterion};
2222
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};
2324
use ldk_node::{Event, Node};
2425
use lightning::ln::channelmanager::PaymentId;
26+
use lightning::util::persist::migrate_kv_store_data_async;
2527
use lightning_invoice::{Bolt11InvoiceDescription, Description};
28+
use lightning_persister::fs_store::v2::FilesystemStoreV2;
2629

27-
use crate::common::{open_channel_push_amt, TestChainSource, TestStoreType};
30+
use crate::common::{
31+
open_channel_push_amt, StoreBenchConfig, TestChainSource, TestConfig, TestStoreType,
32+
};
33+
34+
#[cfg(feature = "postgres")]
35+
use ldk_node::io::postgres_store::{PostgresStore, POSTGRES_TEST_URL_ENV_VAR};
36+
37+
const STARTUP_SEED_SCENARIOS: [StartupSeedScenario; 6] = [
38+
StartupSeedScenario { channel_count: 1, payment_count: 2 },
39+
StartupSeedScenario { channel_count: 1, payment_count: 100 },
40+
StartupSeedScenario { channel_count: 1, payment_count: 1_000 },
41+
StartupSeedScenario { channel_count: 10, payment_count: 2 },
42+
StartupSeedScenario { channel_count: 100, payment_count: 2 },
43+
StartupSeedScenario { channel_count: 100, payment_count: 1_000 },
44+
];
45+
const STARTUP_SEED_PAYMENT_AMOUNT_MSAT: u64 = 1_000_000;
46+
const STARTUP_SEED_MIN_CHANNEL_FUNDING_SAT: u64 = 100_000;
47+
const STARTUP_SEED_CHANNEL_BUFFER_SAT: u64 = 1_000_000;
48+
const STARTUP_SEED_CHANNEL_BATCH_SIZE: u64 = 2;
49+
50+
#[derive(Clone, Copy)]
51+
struct StartupSeedScenario {
52+
channel_count: u64,
53+
payment_count: u64,
54+
}
55+
56+
impl StartupSeedScenario {
57+
fn bench_name(self, store_name: &str) -> String {
58+
format!("{}/channels_{}_payments_{}", store_name, self.channel_count, self.payment_count)
59+
}
60+
61+
fn runs_in_ci(self) -> bool {
62+
self.channel_count == 1 && self.payment_count == 2
63+
}
64+
65+
fn channel_funding_sat(self) -> u64 {
66+
let payment_amount_sat = STARTUP_SEED_PAYMENT_AMOUNT_MSAT / 1_000;
67+
let payment_funding_sat =
68+
self.payment_count * payment_amount_sat + STARTUP_SEED_CHANNEL_BUFFER_SAT;
69+
payment_funding_sat.max(STARTUP_SEED_MIN_CHANNEL_FUNDING_SAT)
70+
}
71+
72+
fn premine_amount_sat(self) -> u64 {
73+
self.channel_count * self.channel_funding_sat() + STARTUP_SEED_CHANNEL_BUFFER_SAT
74+
}
75+
}
2876

2977
fn operations_benchmark(c: &mut Criterion) {
3078
forwarding_benchmark(c);
3179
channel_open_benchmark(c);
80+
startup_benchmark(c);
3281
}
3382

3483
fn forwarding_benchmark(c: &mut Criterion) {
@@ -89,7 +138,7 @@ fn channel_open_benchmark(c: &mut Criterion) {
89138
continue;
90139
}
91140
let (node_a, node_b) =
92-
setup_two_nodes_with_store(&chain_source, false, true, false, store_config.store_type);
141+
setup_two_nodes_with_store(&chain_source, false, false, store_config.store_type);
93142
let node_a = Arc::new(node_a);
94143
let node_b = Arc::new(node_b);
95144

@@ -142,6 +191,230 @@ fn channel_open_benchmark(c: &mut Criterion) {
142191
}
143192
}
144193

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

434+
fn is_ci() -> bool {
435+
std::env::var("CI").is_ok_and(|value| !value.is_empty() && value != "0" && value != "false")
436+
}
437+
161438
fn setup_forwarding_nodes(
162439
chain_source: &TestChainSource, bitcoind: &BitcoinD, electrsd: &electrsd::ElectrsD,
163440
store_type: TestStoreType, runtime: &tokio::runtime::Runtime,
164441
) -> Vec<Arc<Node>> {
165442
let mut nodes = Vec::new();
166443
for _ in 0..3 {
167-
let mut config = random_config(true);
444+
let mut config = random_config();
168445
config.store_type = store_type;
169446
nodes.push(Arc::new(setup_node(chain_source, config)));
170447
}

tests/common/mod.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,36 @@ pub async fn open_channel_push_amt(
11001100
funding_txo
11011101
}
11021102

1103+
pub async fn wait_for_channel_ready_events(
1104+
node: &Node, counterparty_node_id: PublicKey, count: u64,
1105+
) {
1106+
let mut remaining_count = count;
1107+
while remaining_count > 0 {
1108+
let event = tokio::time::timeout(
1109+
Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS),
1110+
node.next_event_async(),
1111+
)
1112+
.await
1113+
.unwrap_or_else(|_| {
1114+
panic!("{} timed out waiting for ChannelReady event after 60s", node.node_id())
1115+
});
1116+
1117+
match event {
1118+
ref e @ Event::ChannelReady { counterparty_node_id: Some(node_id), .. }
1119+
if node_id == counterparty_node_id =>
1120+
{
1121+
println!("{} got event {:?}", node.node_id(), e);
1122+
remaining_count -= 1;
1123+
},
1124+
ref e @ Event::ChannelReady { .. } => {
1125+
panic!("{} got unexpected ChannelReady event: {:?}", node.node_id(), e);
1126+
},
1127+
_ => {},
1128+
}
1129+
node.event_handled().unwrap();
1130+
}
1131+
}
1132+
11031133
pub async fn open_channel_with_all(
11041134
node_a: &TestNode, node_b: &TestNode, should_announce: bool, electrsd: &ElectrsD,
11051135
) -> OutPoint {

0 commit comments

Comments
 (0)