Skip to content

Commit c8f415f

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 f47860f commit c8f415f

1 file changed

Lines changed: 302 additions & 2 deletions

File tree

benches/operations.rs

Lines changed: 302 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;
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 {
@@ -36,6 +83,7 @@ fn operations_benchmark(c: &mut Criterion) {
3683

3784
forwarding_benchmark(c);
3885
channel_open_benchmark(c);
86+
startup_benchmark(c);
3987
}
4088

4189
fn forwarding_benchmark(c: &mut Criterion) {
@@ -149,6 +197,226 @@ fn channel_open_benchmark(c: &mut Criterion) {
149197
}
150198
}
151199

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

436+
fn is_ci() -> bool {
437+
std::env::var("CI").is_ok_and(|value| !value.is_empty() && value != "0" && value != "false")
438+
}
439+
168440
fn setup_forwarding_nodes(
169441
chain_source: &TestChainSource, bitcoind: &BitcoinD, electrsd: &electrsd::ElectrsD,
170442
store_type: TestStoreType, runtime: &tokio::runtime::Runtime,
@@ -375,6 +647,34 @@ async fn wait_for_payment_success(node: &Node, expected_payment_id: PaymentId) {
375647
}
376648
}
377649

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

0 commit comments

Comments
 (0)