Skip to content

Commit 42276a1

Browse files
benthecarmanclaude
andcommitted
Add end-to-end KV store migration test
Creates a node with LN and on-chain state and then randomly migrates through all the KV store options and checks it still has its state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 43b122b commit 42276a1

4 files changed

Lines changed: 288 additions & 17 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ winapi = { version = "0.3", features = ["winbase"] }
9292

9393
[dev-dependencies]
9494
lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["std", "_test_utils"] }
95+
lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["tokio"] }
9596
rand = { version = "0.9.2", default-features = false, features = ["std", "thread_rng", "os_rng"] }
9697
proptest = "1.0.0"
9798
regex = "1.5.6"

tests/common/mod.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1922,3 +1922,26 @@ impl TestSyncStoreInner {
19221922
}
19231923
}
19241924
}
1925+
1926+
/// The PostgreSQL connection string used by the Postgres-backed tests, overridable via the
1927+
/// `TEST_POSTGRES_URL` environment variable.
1928+
#[cfg(feature = "postgres")]
1929+
pub(crate) fn test_connection_string() -> String {
1930+
std::env::var("TEST_POSTGRES_URL")
1931+
.unwrap_or_else(|_| "host=localhost user=postgres password=postgres".to_string())
1932+
}
1933+
1934+
/// Drops the given table from the `ldk_db` database, ignoring the case where the database doesn't
1935+
/// exist yet. Used to ensure a clean slate before and after Postgres-backed tests.
1936+
#[cfg(feature = "postgres")]
1937+
pub(crate) async fn drop_table(table_name: &str) {
1938+
let connection_string = format!("{} dbname=ldk_db", test_connection_string());
1939+
let Ok((client, connection)) =
1940+
tokio_postgres::connect(&connection_string, tokio_postgres::NoTls).await
1941+
else {
1942+
// Database doesn't exist yet — nothing to drop.
1943+
return;
1944+
};
1945+
tokio::spawn(connection);
1946+
let _ = client.execute(&format!("DROP TABLE IF EXISTS {table_name}"), &[]).await;
1947+
}
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// This file is Copyright its original authors, visible in version control history.
2+
//
3+
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4+
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
5+
// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
6+
// accordance with one or both of these licenses.
7+
8+
// The migration test exercises the filesystem, SQLite, and Postgres stores. It is gated on the
9+
// `postgres` feature because Postgres is the only one of the three that needs an external service.
10+
#![cfg(feature = "postgres")]
11+
12+
mod common;
13+
14+
use std::path::PathBuf;
15+
16+
use common::{
17+
drop_table, expect_channel_ready_event, expect_payment_received_event,
18+
expect_payment_successful_event, test_connection_string,
19+
};
20+
use ldk_node::entropy::NodeEntropy;
21+
use ldk_node::io::postgres_store::PostgresStore;
22+
use ldk_node::io::sqlite_store::{SqliteStore, KV_TABLE_NAME, SQLITE_DB_FILE_NAME};
23+
use ldk_node::{Builder, Event};
24+
use lightning::util::persist::migrate_kv_store_data_async;
25+
use lightning_invoice::{Bolt11InvoiceDescription, Description};
26+
use lightning_persister::fs_store::v2::FilesystemStoreV2;
27+
use rand::seq::SliceRandom;
28+
29+
async fn drop_tables<'a>(table_names: impl IntoIterator<Item = &'a String>) {
30+
for table_name in table_names {
31+
drop_table(table_name).await;
32+
}
33+
}
34+
35+
#[derive(Clone, Copy, Debug, PartialEq)]
36+
enum MigrationBackend {
37+
FilesystemStore,
38+
Sqlite,
39+
Postgres,
40+
}
41+
42+
struct BackendInstance {
43+
backend: MigrationBackend,
44+
path: String,
45+
connection_string: String,
46+
table: String,
47+
}
48+
49+
impl BackendInstance {
50+
fn new(
51+
backend: MigrationBackend, base_dir: &str, connection_string: &str, table: &str,
52+
) -> Self {
53+
let path = match backend {
54+
MigrationBackend::FilesystemStore => format!("{base_dir}/fs_store"),
55+
MigrationBackend::Sqlite => format!("{base_dir}/sqlite_store"),
56+
MigrationBackend::Postgres => base_dir.to_string(),
57+
};
58+
BackendInstance {
59+
backend,
60+
path,
61+
connection_string: connection_string.to_string(),
62+
table: table.to_string(),
63+
}
64+
}
65+
}
66+
67+
macro_rules! with_opened_store {
68+
($instance:expr, |$store:ident| $body:expr) => {{
69+
let instance = $instance;
70+
match instance.backend {
71+
MigrationBackend::FilesystemStore => {
72+
let $store = open_fs_store(&instance.path);
73+
$body
74+
},
75+
MigrationBackend::Sqlite => {
76+
let $store = open_sqlite_store(&instance.path);
77+
$body
78+
},
79+
MigrationBackend::Postgres => {
80+
let $store =
81+
open_postgres_store(&instance.connection_string, &instance.table).await;
82+
$body
83+
},
84+
}
85+
}};
86+
}
87+
88+
async fn build_migration_node(
89+
instance: &BackendInstance, node_config: ldk_node::config::Config, node_entropy: NodeEntropy,
90+
esplora_url: &str,
91+
) -> ldk_node::Node {
92+
let mut builder = Builder::from_config(node_config);
93+
builder.set_chain_source_esplora(esplora_url.to_string(), None);
94+
with_opened_store!(instance, |store| builder.build_with_store(node_entropy, store).unwrap())
95+
}
96+
97+
fn open_fs_store(data_dir: &str) -> FilesystemStoreV2 {
98+
std::fs::create_dir_all(data_dir).unwrap();
99+
FilesystemStoreV2::new(PathBuf::from(data_dir)).unwrap()
100+
}
101+
102+
fn open_sqlite_store(data_dir: &str) -> SqliteStore {
103+
std::fs::create_dir_all(data_dir).unwrap();
104+
SqliteStore::new(
105+
PathBuf::from(data_dir),
106+
Some(SQLITE_DB_FILE_NAME.to_string()),
107+
Some(KV_TABLE_NAME.to_string()),
108+
)
109+
.unwrap()
110+
}
111+
112+
async fn open_postgres_store(connection_string: &str, table: &str) -> PostgresStore {
113+
PostgresStore::new(connection_string.to_string(), None, Some(table.to_string()), None)
114+
.await
115+
.unwrap()
116+
}
117+
118+
/// Migrates all data from a freshly-opened handle on the `source` backend to a freshly-opened
119+
/// handle on the `dest` backend. The node owning the source store must be stopped beforehand.
120+
async fn migrate_between_backends(source: &BackendInstance, dest: &BackendInstance) {
121+
with_opened_store!(source, |source_store| {
122+
with_opened_store!(dest, |dest_store| {
123+
migrate_kv_store_data_async(&source_store, &dest_store).await.unwrap();
124+
})
125+
})
126+
}
127+
128+
/// Spins up a node on a KV store backend, creates some on-chain and Lightning transaction history,
129+
/// then migrates its data through every other backend in turn. After each migration it restarts
130+
/// the node on the new backend and verifies that the node identity, on-chain balance, channel, and
131+
/// payment history are all preserved.
132+
///
133+
/// The order in which the backends are visited is randomized.
134+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
135+
async fn migrate_node_across_all_backends() {
136+
let mut order =
137+
[MigrationBackend::FilesystemStore, MigrationBackend::Sqlite, MigrationBackend::Postgres];
138+
order.shuffle(&mut rand::rng());
139+
println!("Migrating node across backends in order: {:?}", order);
140+
141+
// Tables we might use: one per hop plus node B's. (Only the Postgres hops actually use them.)
142+
let tables: Vec<String> = (0..order.len()).map(|i| format!("migrate_chain_{i}")).collect();
143+
let node_b_table = "migrate_chain_node_b".to_string();
144+
drop_tables(tables.iter().chain(std::iter::once(&node_b_table))).await;
145+
146+
let (bitcoind, electrsd) = common::setup_bitcoind_and_electrsd();
147+
let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap());
148+
let connection_string = test_connection_string();
149+
150+
// Set up node B, the Lightning counterparty.
151+
let config_b = common::random_config(false);
152+
let node_b_instance = BackendInstance::new(
153+
MigrationBackend::Postgres,
154+
&config_b.node_config.storage_dir_path,
155+
&connection_string,
156+
&node_b_table,
157+
);
158+
let node_b = build_migration_node(
159+
&node_b_instance,
160+
config_b.node_config,
161+
config_b.node_entropy,
162+
&esplora_url,
163+
)
164+
.await;
165+
node_b.start().unwrap();
166+
167+
// Spin up the node we'll migrate on the first backend. The same node config (storage dir,
168+
// listening addresses, identity) is reused across every hop — only the backend changes — so
169+
// each backend's store lives in its own subdirectory of the one storage dir.
170+
let config = common::random_config(false);
171+
let node_entropy = config.node_entropy;
172+
let node_config = config.node_config;
173+
let base_dir = node_config.storage_dir_path.clone();
174+
175+
let mut current = BackendInstance::new(order[0], &base_dir, &connection_string, &tables[0]);
176+
let mut node =
177+
build_migration_node(&current, node_config.clone(), node_entropy, &esplora_url).await;
178+
node.start().unwrap();
179+
let expected_node_id = node.node_id();
180+
181+
// On-chain receive: fund the node.
182+
let addr = node.onchain_payment().new_address().unwrap();
183+
common::premine_and_distribute_funds(
184+
&bitcoind.client,
185+
&electrsd.client,
186+
vec![addr],
187+
bitcoin::Amount::from_sat(1_000_000),
188+
)
189+
.await;
190+
node.sync_wallets().unwrap();
191+
192+
// Open a channel to node B (pushing half so both sides can route) and let it confirm.
193+
common::open_channel_push_amt(&node, &node_b, 200_000, Some(100_000_000), false, &electrsd)
194+
.await;
195+
common::generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
196+
node.sync_wallets().unwrap();
197+
node_b.sync_wallets().unwrap();
198+
expect_channel_ready_event!(node, node_b.node_id());
199+
expect_channel_ready_event!(node_b, node.node_id());
200+
201+
// Lightning send: node -> node B.
202+
let description =
203+
Bolt11InvoiceDescription::Direct(Description::new("ln send".to_string()).unwrap());
204+
let invoice = node_b.bolt11_payment().receive(10_000, &description.into(), 3600).unwrap();
205+
let ln_send_id = node.bolt11_payment().send(&invoice, None).unwrap();
206+
expect_payment_successful_event!(node, Some(ln_send_id), None);
207+
expect_payment_received_event!(node_b, 10_000);
208+
209+
// Lightning receive: node B -> node.
210+
let description =
211+
Bolt11InvoiceDescription::Direct(Description::new("ln receive".to_string()).unwrap());
212+
let invoice = node.bolt11_payment().receive(5_000, &description.into(), 3600).unwrap();
213+
let ln_receive_id = node_b.bolt11_payment().send(&invoice, None).unwrap();
214+
expect_payment_successful_event!(node_b, Some(ln_receive_id), None);
215+
expect_payment_received_event!(node, 5_000);
216+
217+
// On-chain send: node -> a foreign address.
218+
let bitcoind_addr = bitcoind.client.new_address().unwrap();
219+
let txid = node.onchain_payment().send_to_address(&bitcoind_addr, 50_000, None).unwrap();
220+
common::wait_for_tx(&electrsd.client, txid).await;
221+
common::generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
222+
node.sync_wallets().unwrap();
223+
224+
// Capture the state we expect to survive every migration.
225+
let expected_balance_sats = node.list_balances().total_onchain_balance_sats;
226+
let expected_ln_balance_sats = node.list_balances().total_lightning_balance_sats;
227+
let mut expected_payments = node.list_payments();
228+
expected_payments.sort_by_key(|p| p.id.0);
229+
assert!(expected_payments.len() >= 4);
230+
231+
for (i, &next_backend) in order.iter().enumerate().skip(1) {
232+
println!("Migrating from {:?} to {:?}", current.backend, next_backend);
233+
234+
let next = BackendInstance::new(next_backend, &base_dir, &connection_string, &tables[i]);
235+
236+
// Spin the node down so the source store is no longer being written to.
237+
node.stop().unwrap();
238+
drop(node);
239+
240+
migrate_between_backends(&current, &next).await;
241+
242+
// Spin the node back up on the new backend.
243+
node = build_migration_node(&next, node_config.clone(), node_entropy, &esplora_url).await;
244+
node.start().unwrap();
245+
node.sync_wallets().unwrap();
246+
247+
// The balance, channel, and transaction history are preserved across the migration.
248+
assert_eq!(node.node_id(), expected_node_id);
249+
assert_eq!(node.list_balances().total_onchain_balance_sats, expected_balance_sats);
250+
assert_eq!(node.list_balances().total_lightning_balance_sats, expected_ln_balance_sats);
251+
assert_eq!(node.list_channels().len(), 1);
252+
let mut migrated_payments = node.list_payments();
253+
migrated_payments.sort_by_key(|p| p.id.0);
254+
assert_eq!(migrated_payments, expected_payments);
255+
256+
current = next;
257+
}
258+
259+
node.stop().unwrap();
260+
node_b.stop().unwrap();
261+
262+
drop_tables(tables.iter().chain(std::iter::once(&node_b_table))).await;
263+
}

tests/integration_tests_postgres.rs

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,11 @@
99

1010
mod common;
1111

12+
use common::{drop_table, test_connection_string};
1213
use ldk_node::entropy::NodeEntropy;
1314
use ldk_node::Builder;
1415
use rand::RngCore;
1516

16-
fn test_connection_string() -> String {
17-
std::env::var("TEST_POSTGRES_URL")
18-
.unwrap_or_else(|_| "host=localhost user=postgres password=postgres".to_string())
19-
}
20-
21-
async fn drop_table(table_name: &str) {
22-
let connection_string = format!("{} dbname=ldk_db", test_connection_string());
23-
let Ok((client, connection)) =
24-
tokio_postgres::connect(&connection_string, tokio_postgres::NoTls).await
25-
else {
26-
// Database doesn't exist yet — nothing to drop.
27-
return;
28-
};
29-
tokio::spawn(connection);
30-
let _ = client.execute(&format!("DROP TABLE IF EXISTS {table_name}"), &[]).await;
31-
}
32-
3317
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3418
async fn channel_full_cycle_with_postgres_store() {
3519
drop_table("channel_cycle_a").await;

0 commit comments

Comments
 (0)