Skip to content

Commit 2f0a93e

Browse files
Add continuous SQLite replica via Online Backup API
Mirror `{storage_dir}/ldk_node_data.sqlite` to a second file after open and after every successful KV write/remove, Core Lightning-style. Replica failures fail the persist. Restore by placing the replica at `{storage_dir}/ldk_node_data.sqlite`. Seed/entropy is not included. AI assistance: Goose (AAIF).
1 parent cd4fe79 commit 2f0a93e

4 files changed

Lines changed: 245 additions & 25 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
`Event::PaymentClaimable`.
3131

3232
## Feature and API updates
33+
- `Builder::set_sqlite_backup_path` continuously replicates `{storage_dir}/ldk_node_data.sqlite`
34+
to a second SQLite file via the Online Backup API. Replica failures fail the persist.
35+
Restore by placing the replica at `{storage_dir}/ldk_node_data.sqlite`. Seed/entropy is not
36+
included.
3337
- Language-binding `Mnemonic` objects can be generated or constructed from entropy and expose
3438
their words, word indices, word count, entropy, checksum, and passphrase-derived seed.
3539
- `Node::list_payments` is now paginated: it takes an optional `PageToken` and returns a

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "
106106

107107
bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] }
108108
rustls = { version = "0.23", default-features = false }
109-
rusqlite = { version = "0.31.0", features = ["bundled"], optional = true }
109+
rusqlite = { version = "0.31.0", features = ["bundled", "backup"], optional = true }
110110
bitcoin = "0.32.7"
111111
bip39 = { version = "3.0.0", features = ["rand"] }
112112
bip21 = { version = "0.5", features = ["std"], default-features = false, optional = true }

‎src/builder.rs‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,8 @@ pub struct NodeBuilder {
334334
runtime_handle: Option<tokio::runtime::Handle>,
335335
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
336336
probing_config: Option<ProbingConfig>,
337+
#[cfg(feature = "storage-sqlite")]
338+
sqlite_backup_path: Option<PathBuf>,
337339
}
338340

339341
#[cfg(not(feature = "uniffi"))]
@@ -365,6 +367,8 @@ impl NodeBuilder {
365367
async_payments_role: None,
366368
pathfinding_scores_sync_config,
367369
probing_config,
370+
#[cfg(feature = "storage-sqlite")]
371+
sqlite_backup_path: None,
368372
}
369373
}
370374

@@ -566,6 +570,29 @@ impl NodeBuilder {
566570
self
567571
}
568572

573+
/// Continuously replicates `{storage_dir_path}/ldk_node_data.sqlite` to `backup_path`.
574+
///
575+
/// After opening the primary database and after every successful persist, the store copies
576+
/// it to `backup_path` using SQLite's [Online Backup API]. A failed replica update fails the
577+
/// persist.
578+
///
579+
/// `backup_path` must be a filesystem path to a database *file* (not a `primary:backup`
580+
/// URI — `:` is a valid path character). Parent directories are created if missing.
581+
///
582+
/// To restore, copy or place the replica at `{storage_dir}/ldk_node_data.sqlite` and call
583+
/// [`build`] as usual. The replica is a hot-spare file, not a second live node. Seed/entropy
584+
/// is not included.
585+
///
586+
/// Only applies to [`build`].
587+
///
588+
/// [Online Backup API]: https://www.sqlite.org/backup.html
589+
/// [`build`]: Self::build
590+
#[cfg(feature = "storage-sqlite")]
591+
pub fn set_sqlite_backup_path(&mut self, backup_path: String) -> &mut Self {
592+
self.sqlite_backup_path = Some(backup_path.into());
593+
self
594+
}
595+
569596
/// Configures the [`Node`] instance to write logs to the filesystem.
570597
///
571598
/// The `log_file_path` defaults to [`DEFAULT_LOG_FILENAME`] in the configured
@@ -707,10 +734,11 @@ impl NodeBuilder {
707734
let storage_dir_path = self.config.storage_dir_path.clone();
708735
create_dir_all_private(storage_dir_path.as_ref())
709736
.map_err(|_| BuildError::StoragePathAccessFailed)?;
710-
let kv_store = SqliteStore::new(
737+
let kv_store = SqliteStore::new_with_backup(
711738
storage_dir_path.into(),
712739
Some(io::sqlite_store::SQLITE_DB_FILE_NAME.to_string()),
713740
Some(io::sqlite_store::KV_TABLE_NAME.to_string()),
741+
self.sqlite_backup_path.clone(),
714742
)
715743
.map_err(|e| {
716744
log_error!(logger, "Failed to setup Sqlite store: {}", e);
@@ -1306,6 +1334,15 @@ impl Builder {
13061334
#[cfg(all(feature = "uniffi", feature = "storage-sqlite"))]
13071335
#[uniffi::export]
13081336
impl Builder {
1337+
/// Continuously replicates the SQLite database to `backup_path`.
1338+
///
1339+
/// See [`NodeBuilder::set_sqlite_backup_path`].
1340+
///
1341+
/// [`NodeBuilder::set_sqlite_backup_path`]: NodeBuilder::set_sqlite_backup_path
1342+
pub fn set_sqlite_backup_path(&self, backup_path: String) {
1343+
self.inner.write().expect("lock").set_sqlite_backup_path(backup_path);
1344+
}
1345+
13091346
/// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options
13101347
/// previously configured.
13111348
pub fn build(&self, node_entropy: Arc<NodeEntropy>) -> Result<Arc<Node>, BuildError> {

‎src/io/sqlite_store/mod.rs‎

Lines changed: 202 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use std::fs::OpenOptions;
1414
use std::future::Future;
1515
#[cfg(unix)]
1616
use std::os::unix::fs::OpenOptionsExt;
17-
use std::path::PathBuf;
17+
use std::path::{Path, PathBuf};
1818
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
1919
use std::sync::{Arc, Mutex};
2020

@@ -23,7 +23,7 @@ use lightning::util::persist::{
2323
KVStore, MigratableKVStore, PageToken, PaginatedKVStore, PaginatedListResponse,
2424
};
2525
use lightning_types::string::PrintableString;
26-
use rusqlite::{named_params, Connection};
26+
use rusqlite::{named_params, Connection, DatabaseName};
2727

2828
use crate::io::utils::{check_namespace_key_validity, create_dir_all_private};
2929

@@ -69,7 +69,30 @@ impl SqliteStore {
6969
pub fn new(
7070
data_dir: PathBuf, db_file_name: Option<String>, kv_table_name: Option<String>,
7171
) -> io::Result<Self> {
72-
let inner = Arc::new(SqliteStoreInner::new(data_dir, db_file_name, kv_table_name)?);
72+
Self::new_with_backup(data_dir, db_file_name, kv_table_name, None)
73+
}
74+
75+
/// Constructs a new [`SqliteStore`], continuously replicating to `backup_path`.
76+
///
77+
/// After opening (including schema setup/migration) and after every successful
78+
/// [`KVStore::write`] / [`KVStore::remove`], the primary database is copied to `backup_path`
79+
/// via SQLite's [Online Backup API]. A failed replica update fails the persist.
80+
///
81+
/// `backup_path` must be a filesystem path to a database *file* (parent directories are
82+
/// created if missing). SQLite's `:memory:` database name and `file:` URI filenames are not
83+
/// supported. The replica must not be the same file as the primary database.
84+
///
85+
/// The replica is a complete SQLite database and may later be opened as a primary
86+
/// [`SqliteStore`]. It is a hot-spare file, not a second live node: do not open it with
87+
/// another store while this one is running.
88+
///
89+
/// [Online Backup API]: https://www.sqlite.org/backup.html
90+
pub fn new_with_backup(
91+
data_dir: PathBuf, db_file_name: Option<String>, kv_table_name: Option<String>,
92+
backup_path: Option<PathBuf>,
93+
) -> io::Result<Self> {
94+
let inner =
95+
Arc::new(SqliteStoreInner::new(data_dir, db_file_name, kv_table_name, backup_path)?);
7396

7497
let next_write_version = AtomicU64::new(1);
7598
Ok(Self { inner, next_write_version })
@@ -230,25 +253,66 @@ struct SqliteStoreInner {
230253
connection: Arc<Mutex<Connection>>,
231254
data_dir: PathBuf,
232255
kv_table_name: String,
256+
backup_path: Option<PathBuf>,
233257
write_version_locks: Mutex<HashMap<String, Arc<Mutex<u64>>>>,
234258
next_sort_order: AtomicI64,
235259
}
236260

261+
fn backup_connection(src: &Connection, backup_path: &Path) -> io::Result<()> {
262+
src.backup(DatabaseName::Main, backup_path, None).map_err(|e| {
263+
let msg = format!("Failed to backup SQLite database to {}: {}", backup_path.display(), e);
264+
io::Error::new(io::ErrorKind::Other, msg)
265+
})
266+
}
267+
268+
fn reject_sqlite_uri_path(path: &Path, name: &str) -> io::Result<()> {
269+
let path_str = path.to_string_lossy();
270+
if name == ":memory:" || name.starts_with("file:") || path_str.starts_with("file:") {
271+
return Err(io::Error::new(
272+
io::ErrorKind::InvalidInput,
273+
"SQLite :memory: and file: database names are not supported",
274+
));
275+
}
276+
Ok(())
277+
}
278+
279+
#[cfg(unix)]
280+
fn ensure_private_sqlite_file(path: &Path) -> io::Result<()> {
281+
match OpenOptions::new().create_new(true).write(true).mode(0o600).open(path) {
282+
Ok(_) => Ok(()),
283+
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
284+
Err(e) => {
285+
let msg = format!("Failed to create database file {}: {}", path.display(), e);
286+
Err(io::Error::new(io::ErrorKind::Other, msg))
287+
},
288+
}
289+
}
290+
237291
impl SqliteStoreInner {
238292
fn new(
239293
data_dir: PathBuf, db_file_name: Option<String>, kv_table_name: Option<String>,
294+
backup_path: Option<PathBuf>,
240295
) -> io::Result<Self> {
241296
let db_file_name = db_file_name.unwrap_or(DEFAULT_SQLITE_DB_FILE_NAME.to_string());
242297
let mut db_file_path = data_dir.clone();
243298
db_file_path.push(&db_file_name);
244-
if db_file_name == ":memory:"
245-
|| db_file_name.starts_with("file:")
246-
|| db_file_path.to_string_lossy().starts_with("file:")
247-
{
248-
return Err(io::Error::new(
249-
io::ErrorKind::InvalidInput,
250-
"SQLite :memory: and file: database names are not supported",
251-
));
299+
reject_sqlite_uri_path(&db_file_path, &db_file_name)?;
300+
if let Some(backup_path) = backup_path.as_ref() {
301+
let backup_name = backup_path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
302+
reject_sqlite_uri_path(backup_path, backup_name)?;
303+
if backup_path.is_dir() {
304+
let msg = format!(
305+
"SQLite backup path must be a database file, not a directory: {}",
306+
backup_path.display()
307+
);
308+
return Err(io::Error::new(io::ErrorKind::InvalidInput, msg));
309+
}
310+
if backup_path == &db_file_path {
311+
return Err(io::Error::new(
312+
io::ErrorKind::InvalidInput,
313+
"SQLite backup path must differ from the primary database file",
314+
));
315+
}
252316
}
253317
let kv_table_name = kv_table_name.unwrap_or(DEFAULT_KV_TABLE_NAME.to_string());
254318

@@ -261,15 +325,7 @@ impl SqliteStoreInner {
261325
io::Error::new(io::ErrorKind::Other, msg)
262326
})?;
263327
#[cfg(unix)]
264-
match OpenOptions::new().create_new(true).write(true).mode(0o600).open(&db_file_path) {
265-
Ok(_) => {},
266-
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {},
267-
Err(e) => {
268-
let msg =
269-
format!("Failed to create database file {}: {}", db_file_path.display(), e);
270-
return Err(io::Error::new(io::ErrorKind::Other, msg));
271-
},
272-
}
328+
ensure_private_sqlite_file(&db_file_path)?;
273329

274330
let mut connection = Connection::open(db_file_path.clone()).map_err(|e| {
275331
let msg =
@@ -351,9 +407,41 @@ impl SqliteStoreInner {
351407
})?;
352408
let next_sort_order = AtomicI64::new(max_sort_order + 1);
353409

410+
if let Some(backup_path) = backup_path.as_ref() {
411+
if let Some(parent) = backup_path.parent() {
412+
if !parent.as_os_str().is_empty() {
413+
create_dir_all_private(parent).map_err(|e| {
414+
let msg = format!(
415+
"Failed to create SQLite backup directory {}: {}",
416+
parent.display(),
417+
e
418+
);
419+
io::Error::new(io::ErrorKind::Other, msg)
420+
})?;
421+
}
422+
}
423+
#[cfg(unix)]
424+
ensure_private_sqlite_file(backup_path)?;
425+
backup_connection(&connection, backup_path)?;
426+
}
427+
354428
let connection = Arc::new(Mutex::new(connection));
355429
let write_version_locks = Mutex::new(HashMap::new());
356-
Ok(Self { connection, data_dir, kv_table_name, write_version_locks, next_sort_order })
430+
Ok(Self {
431+
connection,
432+
data_dir,
433+
kv_table_name,
434+
backup_path,
435+
write_version_locks,
436+
next_sort_order,
437+
})
438+
}
439+
440+
fn backup_to_replica(&self, connection: &Connection) -> io::Result<()> {
441+
if let Some(backup_path) = self.backup_path.as_ref() {
442+
backup_connection(connection, backup_path)?;
443+
}
444+
Ok(())
357445
}
358446

359447
fn get_inner_lock_ref(&self, locking_key: String) -> Arc<Mutex<u64>> {
@@ -449,7 +537,9 @@ impl SqliteStoreInner {
449537
e
450538
);
451539
io::Error::new(io::ErrorKind::Other, msg)
452-
})
540+
})?;
541+
drop(stmt);
542+
self.backup_to_replica(&locked_conn)
453543
})
454544
}
455545

@@ -484,7 +574,8 @@ impl SqliteStoreInner {
484574
);
485575
io::Error::new(io::ErrorKind::Other, msg)
486576
})?;
487-
Ok(())
577+
drop(stmt);
578+
self.backup_to_replica(&locked_conn)
488579
})
489580
}
490581

@@ -1134,6 +1225,94 @@ mod tests {
11341225
assert_eq!(response.keys, vec!["key_c", "key_b", "key_a"]);
11351226
}
11361227
}
1228+
1229+
#[tokio::test]
1230+
async fn test_sqlite_store_continuous_backup() {
1231+
let mut temp_path = random_storage_path();
1232+
temp_path.push("test_sqlite_store_continuous_backup");
1233+
let backup_dir = temp_path.join("backup");
1234+
let backup_path = backup_dir.join("replica.sqlite");
1235+
let db_file_name = "test_db".to_string();
1236+
let kv_table_name = "test_table".to_string();
1237+
1238+
let primary_namespace = "test_ns";
1239+
let secondary_namespace = "test_sub";
1240+
let key = "test_key";
1241+
let value = vec![7u8; 16];
1242+
1243+
{
1244+
let store = SqliteStore::new_with_backup(
1245+
temp_path.clone(),
1246+
Some(db_file_name.clone()),
1247+
Some(kv_table_name.clone()),
1248+
Some(backup_path.clone()),
1249+
)
1250+
.unwrap();
1251+
1252+
KVStore::write(&store, primary_namespace, secondary_namespace, key, value.clone())
1253+
.await
1254+
.unwrap();
1255+
1256+
std::mem::forget(store);
1257+
}
1258+
1259+
let restored = SqliteStore::new(
1260+
backup_dir.clone(),
1261+
Some("replica.sqlite".to_string()),
1262+
Some(kv_table_name),
1263+
)
1264+
.unwrap();
1265+
let restored_value =
1266+
KVStore::read(&restored, primary_namespace, secondary_namespace, key).await.unwrap();
1267+
assert_eq!(restored_value, value);
1268+
1269+
std::mem::forget(restored);
1270+
1271+
{
1272+
let store = SqliteStore::new_with_backup(
1273+
temp_path.clone(),
1274+
Some(db_file_name),
1275+
Some("test_table".to_string()),
1276+
Some(backup_path.clone()),
1277+
)
1278+
.unwrap();
1279+
KVStore::remove(&store, primary_namespace, secondary_namespace, key, false)
1280+
.await
1281+
.unwrap();
1282+
std::mem::forget(store);
1283+
}
1284+
1285+
let restored = SqliteStore::new(
1286+
backup_dir.clone(),
1287+
Some("replica.sqlite".to_string()),
1288+
Some("test_table".to_string()),
1289+
)
1290+
.unwrap();
1291+
let missing = KVStore::read(&restored, primary_namespace, secondary_namespace, key)
1292+
.await
1293+
.unwrap_err();
1294+
assert_eq!(missing.kind(), io::ErrorKind::NotFound);
1295+
1296+
std::mem::forget(restored);
1297+
let _ = fs::remove_dir_all(&temp_path);
1298+
}
1299+
1300+
#[tokio::test]
1301+
async fn test_sqlite_store_backup_rejects_primary_path() {
1302+
let mut temp_path = random_storage_path();
1303+
temp_path.push("test_sqlite_store_backup_same_path");
1304+
let backup_path = temp_path.join("test_db");
1305+
let err = match SqliteStore::new_with_backup(
1306+
temp_path,
1307+
Some("test_db".to_string()),
1308+
Some("test_table".to_string()),
1309+
Some(backup_path),
1310+
) {
1311+
Ok(_) => panic!("expected backup path coinciding with the primary database to fail"),
1312+
Err(e) => e,
1313+
};
1314+
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1315+
}
11371316
}
11381317

11391318
#[cfg(ldk_bench)]

0 commit comments

Comments
 (0)