Skip to content

Commit ff5c0a5

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 ff5c0a5

4 files changed

Lines changed: 315 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: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
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, MigratableKVStore};
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+
/// Everything needed to open a node's store on a particular backend: which backend, the dedicated
43+
/// data directory for the store (used by the filesystem and SQLite stores), and the Postgres
44+
/// connection string and table name (used only by the Postgres store).
45+
struct BackendInstance {
46+
backend: MigrationBackend,
47+
path: String,
48+
connection_string: String,
49+
table: String,
50+
}
51+
52+
/// Returns the dedicated data directory for `backend`'s store under the given per-node base
53+
/// directory, so each backend's store lives at a distinct, clearly-named location. (The Postgres
54+
/// store is remote and keyed by table name, so it just uses the base directory, which holds logs.)
55+
fn store_dir(base_dir: &str, backend: MigrationBackend) -> String {
56+
match backend {
57+
MigrationBackend::FilesystemStore => format!("{base_dir}/fs_store"),
58+
MigrationBackend::Sqlite => format!("{base_dir}/sqlite_store"),
59+
MigrationBackend::Postgres => base_dir.to_string(),
60+
}
61+
}
62+
63+
fn build_migration_node(
64+
instance: &BackendInstance, node_config: ldk_node::config::Config, node_entropy: NodeEntropy,
65+
esplora_url: &str,
66+
) -> ldk_node::Node {
67+
let mut builder = Builder::from_config(node_config);
68+
builder.set_chain_source_esplora(esplora_url.to_string(), None);
69+
// Build with the store opened at the instance's dedicated path so the node and any later
70+
// migration read and write the exact same location.
71+
match instance.backend {
72+
MigrationBackend::FilesystemStore => {
73+
builder.build_with_store(node_entropy, open_fs_store(&instance.path)).unwrap()
74+
},
75+
MigrationBackend::Sqlite => {
76+
builder.build_with_store(node_entropy, open_sqlite_store(&instance.path)).unwrap()
77+
},
78+
MigrationBackend::Postgres => builder
79+
.build_with_postgres_store(
80+
node_entropy,
81+
instance.connection_string.clone(),
82+
None,
83+
Some(instance.table.clone()),
84+
None,
85+
)
86+
.unwrap(),
87+
}
88+
}
89+
90+
fn open_fs_store(data_dir: &str) -> FilesystemStoreV2 {
91+
std::fs::create_dir_all(data_dir).unwrap();
92+
FilesystemStoreV2::new(PathBuf::from(data_dir)).unwrap()
93+
}
94+
95+
fn open_sqlite_store(data_dir: &str) -> SqliteStore {
96+
std::fs::create_dir_all(data_dir).unwrap();
97+
SqliteStore::new(
98+
PathBuf::from(data_dir),
99+
Some(SQLITE_DB_FILE_NAME.to_string()),
100+
Some(KV_TABLE_NAME.to_string()),
101+
)
102+
.unwrap()
103+
}
104+
105+
async fn open_postgres_store(connection_string: &str, table: &str) -> PostgresStore {
106+
PostgresStore::new(connection_string.to_string(), None, Some(table.to_string()), None)
107+
.await
108+
.unwrap()
109+
}
110+
111+
async fn migrate_into<S: MigratableKVStore>(source_store: &S, dest: &BackendInstance) {
112+
match dest.backend {
113+
MigrationBackend::FilesystemStore => {
114+
let dest_store = open_fs_store(&dest.path);
115+
migrate_kv_store_data_async(source_store, &dest_store).await.unwrap();
116+
},
117+
MigrationBackend::Sqlite => {
118+
let dest_store = open_sqlite_store(&dest.path);
119+
migrate_kv_store_data_async(source_store, &dest_store).await.unwrap();
120+
},
121+
MigrationBackend::Postgres => {
122+
let dest_store = open_postgres_store(&dest.connection_string, &dest.table).await;
123+
migrate_kv_store_data_async(source_store, &dest_store).await.unwrap();
124+
},
125+
}
126+
}
127+
128+
/// Migrates all data from a freshly-opened handle on the `source` backend to a freshly-opened
129+
/// handle on the `dest` backend. The node owning the source store must be stopped beforehand.
130+
async fn migrate_between_backends(source: &BackendInstance, dest: &BackendInstance) {
131+
match source.backend {
132+
MigrationBackend::FilesystemStore => {
133+
let source_store = open_fs_store(&source.path);
134+
migrate_into(&source_store, dest).await;
135+
},
136+
MigrationBackend::Sqlite => {
137+
let source_store = open_sqlite_store(&source.path);
138+
migrate_into(&source_store, dest).await;
139+
},
140+
MigrationBackend::Postgres => {
141+
let source_store = open_postgres_store(&source.connection_string, &source.table).await;
142+
migrate_into(&source_store, dest).await;
143+
},
144+
}
145+
}
146+
147+
/// Spins up a node on a KV store backend, creates some on-chain and Lightning transaction history,
148+
/// then migrates its data through every other backend in turn. After each migration it restarts
149+
/// the node on the new backend and verifies that the node identity, on-chain balance, channel, and
150+
/// payment history are all preserved.
151+
///
152+
/// The order in which the backends are visited is randomized.
153+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
154+
async fn migrate_node_across_all_backends() {
155+
let mut order =
156+
[MigrationBackend::FilesystemStore, MigrationBackend::Sqlite, MigrationBackend::Postgres];
157+
order.shuffle(&mut rand::rng());
158+
println!("Migrating node across backends in order: {:?}", order);
159+
160+
// Tables we might use: one per hop plus node B's. (Only the Postgres hops actually use them.)
161+
let tables: Vec<String> = (0..order.len()).map(|i| format!("migrate_chain_{i}")).collect();
162+
let node_b_table = "migrate_chain_node_b".to_string();
163+
drop_tables(tables.iter().chain(std::iter::once(&node_b_table))).await;
164+
165+
let (bitcoind, electrsd) = common::setup_bitcoind_and_electrsd();
166+
let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap());
167+
let connection_string = test_connection_string();
168+
169+
// Set up node B, the Lightning counterparty.
170+
let config_b = common::random_config(false);
171+
let node_b_instance = BackendInstance {
172+
backend: MigrationBackend::Postgres,
173+
path: store_dir(&config_b.node_config.storage_dir_path, MigrationBackend::Postgres),
174+
connection_string: connection_string.clone(),
175+
table: node_b_table.clone(),
176+
};
177+
let node_b = build_migration_node(
178+
&node_b_instance,
179+
config_b.node_config,
180+
config_b.node_entropy,
181+
&esplora_url,
182+
);
183+
node_b.start().unwrap();
184+
185+
// Spin up the node we'll migrate on the first backend. The same node config (storage dir,
186+
// listening addresses, identity) is reused across every hop — only the backend changes — so
187+
// each backend's store lives in its own subdirectory of the one storage dir.
188+
let config = common::random_config(false);
189+
let node_entropy = config.node_entropy;
190+
let node_config = config.node_config;
191+
let base_dir = node_config.storage_dir_path.clone();
192+
193+
let mut current = BackendInstance {
194+
backend: order[0],
195+
path: store_dir(&base_dir, order[0]),
196+
connection_string: connection_string.clone(),
197+
table: tables[0].clone(),
198+
};
199+
let mut node = build_migration_node(&current, node_config.clone(), node_entropy, &esplora_url);
200+
node.start().unwrap();
201+
let expected_node_id = node.node_id();
202+
203+
// On-chain receive: fund the node.
204+
let addr = node.onchain_payment().new_address().unwrap();
205+
common::premine_and_distribute_funds(
206+
&bitcoind.client,
207+
&electrsd.client,
208+
vec![addr],
209+
bitcoin::Amount::from_sat(1_000_000),
210+
)
211+
.await;
212+
node.sync_wallets().unwrap();
213+
214+
// Open a channel to node B (pushing half so both sides can route) and let it confirm.
215+
common::open_channel_push_amt(&node, &node_b, 200_000, Some(100_000_000), false, &electrsd)
216+
.await;
217+
common::generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
218+
node.sync_wallets().unwrap();
219+
node_b.sync_wallets().unwrap();
220+
expect_channel_ready_event!(node, node_b.node_id());
221+
expect_channel_ready_event!(node_b, node.node_id());
222+
223+
// Lightning send: node -> node B.
224+
let description =
225+
Bolt11InvoiceDescription::Direct(Description::new("ln send".to_string()).unwrap());
226+
let invoice = node_b.bolt11_payment().receive(10_000, &description.into(), 3600).unwrap();
227+
let ln_send_id = node.bolt11_payment().send(&invoice, None).unwrap();
228+
expect_payment_successful_event!(node, Some(ln_send_id), None);
229+
expect_payment_received_event!(node_b, 10_000);
230+
231+
// Lightning receive: node B -> node.
232+
let description =
233+
Bolt11InvoiceDescription::Direct(Description::new("ln receive".to_string()).unwrap());
234+
let invoice = node.bolt11_payment().receive(5_000, &description.into(), 3600).unwrap();
235+
let ln_receive_id = node_b.bolt11_payment().send(&invoice, None).unwrap();
236+
expect_payment_successful_event!(node_b, Some(ln_receive_id), None);
237+
expect_payment_received_event!(node, 5_000);
238+
239+
// On-chain send: node -> a foreign address.
240+
let bitcoind_addr = bitcoind.client.new_address().unwrap();
241+
let txid = node.onchain_payment().send_to_address(&bitcoind_addr, 50_000, None).unwrap();
242+
common::wait_for_tx(&electrsd.client, txid).await;
243+
common::generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
244+
node.sync_wallets().unwrap();
245+
246+
// Capture the state we expect to survive every migration.
247+
let expected_balance_sats = node.list_balances().total_onchain_balance_sats;
248+
let expected_ln_balance_sats = node.list_balances().total_lightning_balance_sats;
249+
let mut expected_payments = node.list_payments();
250+
expected_payments.sort_by_key(|p| p.id.0);
251+
assert!(expected_payments.len() >= 4);
252+
253+
for (i, &next_backend) in order.iter().enumerate().skip(1) {
254+
println!("Migrating from {:?} to {:?}", current.backend, next_backend);
255+
256+
let next = BackendInstance {
257+
backend: next_backend,
258+
path: store_dir(&base_dir, next_backend),
259+
connection_string: connection_string.clone(),
260+
table: tables[i].clone(),
261+
};
262+
263+
// Spin the node down so the source store is no longer being written to.
264+
node.stop().unwrap();
265+
drop(node);
266+
267+
migrate_between_backends(&current, &next).await;
268+
269+
// Spin the node back up on the new backend.
270+
node = build_migration_node(&next, node_config.clone(), node_entropy, &esplora_url);
271+
node.start().unwrap();
272+
node.sync_wallets().unwrap();
273+
274+
// The balance, channel, and transaction history are preserved across the migration.
275+
assert_eq!(node.node_id(), expected_node_id);
276+
assert_eq!(node.list_balances().total_onchain_balance_sats, expected_balance_sats);
277+
assert_eq!(node.list_balances().total_lightning_balance_sats, expected_ln_balance_sats);
278+
assert_eq!(node.list_channels().len(), 1);
279+
let mut migrated_payments = node.list_payments();
280+
migrated_payments.sort_by_key(|p| p.id.0);
281+
assert_eq!(migrated_payments, expected_payments);
282+
283+
current = next;
284+
}
285+
286+
node.stop().unwrap();
287+
node_b.stop().unwrap();
288+
289+
drop_tables(tables.iter().chain(std::iter::once(&node_b_table))).await;
290+
}

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)