Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ jobs:
if: "matrix.platform != 'windows-latest'"
run: |
RUSTFLAGS="--cfg no_download --cfg cycle_tests --cfg tokio_unstable" cargo test
- name: Test tiered storage
if: matrix.check-fmt
run: |
cargo test --lib --features storage-tier io::tier_store
RUSTFLAGS="--cfg no_download" cargo test \
--features storage-tier \
--test integration_tests_rust \
builder_configures_sqlite_backup_store
- name: Test with UniFFI support on Rust ${{ matrix.toolchain }}
if: "matrix.platform != 'windows-latest' && matrix.build-uniffi"
run: |
Expand Down
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ keywords = ["bitcoin", "lightning", "ldk", "bdk"]
categories = ["cryptography::cryptocurrencies"]

[package.metadata.docs.rs]
features = ["storage-postgres-vendored-tls"]
features = ["storage-postgres-vendored-tls", "storage-tier"]
rustdoc-args = ["--cfg", "docsrs"]

[lib]
Expand Down Expand Up @@ -53,6 +53,7 @@ chain-electrum = [
]
chain-bitcoind = ["dep:lightning-block-sync"]
storage-sqlite = ["dep:rusqlite"]
storage-tier = ["storage-sqlite"]
storage-filesystem = ["dep:lightning-persister"]
storage-vss = ["dep:vss-client", "dep:prost"]
storage-postgres = ["dep:tokio-postgres", "dep:native-tls", "dep:postgres-native-tls"]
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ LDK Node's optional dependencies are grouped by the functionality they provide:
| `chain-electrum` | Electrum chain source |
| `chain-bitcoind` | Bitcoin Core RPC and REST chain source |
| `storage-sqlite` | SQLite storage |
| `storage-tier` | Tiered storage with optional ephemeral and backup SQLite stores |
| `storage-filesystem` | Filesystem storage |
| `storage-vss` | Versioned Storage Service storage |
| `storage-postgres` | PostgreSQL storage |
Expand All @@ -88,7 +89,8 @@ LDK Node's optional dependencies are grouped by the functionality they provide:

The `default` feature set preserves the native Rust API's previous behavior. It enables all three
chain sources, SQLite, filesystem and VSS storage, and unified payments. PostgreSQL and UniFFI
remain opt-in. Every build must enable at least one chain source feature.
remain opt-in. Tiered storage is also opt-in; enabling `storage-tier` automatically enables
`storage-sqlite`. Every build must enable at least one chain source feature.

On Linux, `storage-postgres` uses the system OpenSSL installation and requires the OpenSSL
development headers and `pkg-config`. Enable `storage-postgres-vendored-tls` instead to build
Expand Down
110 changes: 107 additions & 3 deletions src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::convert::TryInto;
use std::default::Default;
#[cfg(feature = "unified-payments")]
use std::net::ToSocketAddrs;
#[cfg(feature = "storage-filesystem")]
#[cfg(any(feature = "storage-filesystem", feature = "storage-tier"))]
use std::path::PathBuf;
use std::sync::{Arc, Mutex, Once, RwLock};
use std::time::SystemTime;
Expand Down Expand Up @@ -72,6 +72,8 @@ use crate::gossip::GossipSource;
use crate::io::fs_store::open_or_migrate_fs_store;
#[cfg(feature = "storage-sqlite")]
use crate::io::sqlite_store::SqliteStore;
#[cfg(feature = "storage-tier")]
use crate::io::tier_store::{setup_index_store, TierStore};
use crate::io::utils::{
read_all_objects, read_event_queue, read_external_pathfinding_scores_from_cache,
read_n_objects, read_network_graph, read_node_metrics, read_output_sweeper, read_peer_info,
Expand Down Expand Up @@ -173,6 +175,13 @@ impl std::fmt::Debug for LogWriterConfig {
}
}

#[cfg(feature = "storage-tier")]
#[derive(Default, Debug)]
struct TierStoreConfig {
ephemeral_storage_dir_path: Option<PathBuf>,
backup_storage_dir_path: Option<PathBuf>,
}

/// An error encountered during building a [`Node`].
///
/// [`Node`]: crate::Node
Expand Down Expand Up @@ -326,6 +335,8 @@ pub struct NodeBuilder {
liquidity_source_config: Option<LiquiditySourceConfig>,
log_writer_config: Option<LogWriterConfig>,
async_payments_role: Option<AsyncPaymentsRole>,
#[cfg(feature = "storage-tier")]
tier_store_config: Option<TierStoreConfig>,
runtime_handle: Option<tokio::runtime::Handle>,
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
probing_config: Option<ProbingConfig>,
Expand All @@ -347,6 +358,8 @@ impl NodeBuilder {
let gossip_source_config = None;
let liquidity_source_config = None;
let log_writer_config = None;
#[cfg(feature = "storage-tier")]
let tier_store_config = None;
let runtime_handle = None;
let pathfinding_scores_sync_config = None;
let probing_config = None;
Expand All @@ -356,6 +369,8 @@ impl NodeBuilder {
gossip_source_config,
liquidity_source_config,
log_writer_config,
#[cfg(feature = "storage-tier")]
tier_store_config,
runtime_handle,
async_payments_role: None,
pathfinding_scores_sync_config,
Expand Down Expand Up @@ -686,6 +701,41 @@ impl NodeBuilder {
self
}

/// Configures a local SQLite backup store for disaster recovery.
///
/// When building with tiered storage, a SQLite store will be created at the
/// given directory path using [`SQLITE_BACKUP_DB_FILE_NAME`] as its database
/// file name. It receives a second durable copy of data written to the
/// primary store.
///
/// Writes and removals for primary-backed data only succeed once both the
/// primary and backup SQLite stores complete successfully.
///
/// If not set, durable data will be stored only in the primary store.
///
/// [`SQLITE_BACKUP_DB_FILE_NAME`]: crate::io::sqlite_store::SQLITE_BACKUP_DB_FILE_NAME
#[cfg(all(not(feature = "uniffi"), feature = "storage-tier"))]
pub fn set_backup_storage_dir_path(&mut self, backup_storage_dir_path: String) -> &mut Self {
let tier_store_config = self.tier_store_config.get_or_insert(TierStoreConfig::default());
tier_store_config.backup_storage_dir_path = Some(backup_storage_dir_path.into());
self
}

/// Configures the ephemeral storage directory path for non-critical, frequently-accessed data.
///
/// When set, a local SQLite store is created at this path for ephemeral data like
/// the network graph and scorer. Data stored here can be rebuilt if lost.
///
/// If not set, non-critical data will be stored in the primary store.
#[cfg(all(not(feature = "uniffi"), feature = "storage-tier"))]
pub fn set_ephemeral_storage_dir_path(
&mut self, ephemeral_storage_dir_path: String,
) -> &mut Self {
let tier_store_config = self.tier_store_config.get_or_insert(TierStoreConfig::default());
tier_store_config.ephemeral_storage_dir_path = Some(ephemeral_storage_dir_path.into());
self
}

/// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options
/// previously configured.
#[cfg(feature = "storage-sqlite")]
Expand Down Expand Up @@ -901,11 +951,18 @@ impl NodeBuilder {
}

/// Builds a [`Node`] instance according to the options previously configured.
///
/// The provided `kv_store` will be used as the primary storage backend. Optionally,
/// an ephemeral store for frequently-accessed non-critical data (e.g., network graph, scorer)
/// and a local SQLite backup store for disaster recovery can be configured via
/// [`set_ephemeral_storage_dir_path`] and [`set_backup_storage_dir_path`].
///
/// [`set_ephemeral_storage_dir_path`]: Self::set_ephemeral_storage_dir_path
/// [`set_backup_storage_dir_path`]: Self::set_backup_storage_dir_path
pub fn build_with_store<S: PaginatedKVStore + Send + Sync + 'static>(
&self, node_entropy: NodeEntropy, kv_store: S,
) -> Result<Node, BuildError> {
let logger = setup_logger(&self.log_writer_config, &self.config)?;

self.build_with_store_and_logger(node_entropy, kv_store, logger)
}

Expand All @@ -930,6 +987,53 @@ impl NodeBuilder {
fn build_with_store_runtime_and_logger<S: PaginatedKVStore + Send + Sync + 'static>(
&self, node_entropy: NodeEntropy, kv_store: S, runtime: Arc<Runtime>, logger: Arc<Logger>,
) -> Result<Node, BuildError> {
#[cfg(feature = "storage-tier")]
let store: Arc<DynStore> = {
let ts_config = self.tier_store_config.as_ref();
let primary_store = Arc::new(DynStoreWrapper(kv_store));
let mut tier_store = TierStore::new(primary_store, Arc::clone(&logger));
if let Some(config) = ts_config {
if let Some(ephemeral_storage_dir_path) = config.ephemeral_storage_dir_path.as_ref()
{
let index_store = runtime
.block_on(setup_index_store(self.config.storage_dir_path.clone().into()))
.map_err(|e| {
log_error!(logger, "Failed to setup tier-store index: {}", e);
BuildError::KVStoreSetupFailed
})?;
let ephemeral_store = SqliteStore::new(
ephemeral_storage_dir_path.clone(),
Some(io::sqlite_store::SQLITE_EPHEMERAL_DB_FILE_NAME.to_string()),
Some(io::sqlite_store::KV_TABLE_NAME.to_string()),
)
.map_err(|e| {
log_error!(logger, "Failed to setup ephemeral SQLite store: {}", e);
BuildError::KVStoreSetupFailed
})?;
let ephemeral_store: Arc<DynStore> = Arc::new(DynStoreWrapper(ephemeral_store));
tier_store.set_index_store(index_store);
tier_store.set_ephemeral_store(ephemeral_store);
}

if let Some(backup_storage_dir_path) = config.backup_storage_dir_path.as_ref() {
let backup_store = SqliteStore::new(
backup_storage_dir_path.clone(),
Some(io::sqlite_store::SQLITE_BACKUP_DB_FILE_NAME.to_string()),
Some(io::sqlite_store::KV_TABLE_NAME.to_string()),
)
.map_err(|e| {
log_error!(logger, "Failed to setup backup SQLite store: {}", e);
BuildError::KVStoreSetupFailed
})?;
let backup_store: Arc<DynStore> = Arc::new(DynStoreWrapper(backup_store));
tier_store.set_backup_store(backup_store);
}
}
Arc::new(DynStoreWrapper(tier_store))
};
#[cfg(not(feature = "storage-tier"))]
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(kv_store));

let seed_bytes = node_entropy.to_seed_bytes();
let config = Arc::new(self.config.clone());

Expand All @@ -944,7 +1048,7 @@ impl NodeBuilder {
seed_bytes,
runtime,
logger,
Arc::new(DynStoreWrapper(kv_store)),
store,
)
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/io/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub mod postgres_store;
pub mod sqlite_store;
#[cfg(test)]
pub(crate) mod test_utils;
#[cfg(feature = "storage-tier")]
pub(crate) mod tier_store;
pub(crate) mod utils;
#[cfg(feature = "storage-vss")]
pub mod vss_store;
Expand Down
83 changes: 82 additions & 1 deletion src/io/sqlite_store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ use std::future::Future;
use std::path::PathBuf;
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use lightning::io;
use lightning::util::persist::{
KVStore, MigratableKVStore, PageToken, PaginatedKVStore, PaginatedListResponse,
};
use lightning_types::string::PrintableString;
use rusqlite::ffi::ErrorCode;
use rusqlite::{named_params, Connection};

use crate::io::utils::check_namespace_key_validity;
Expand All @@ -26,6 +28,15 @@ mod migrations;

/// LDK Node's database file name.
pub const SQLITE_DB_FILE_NAME: &str = "ldk_node_data.sqlite";
/// LDK Node's internal tier-store index database file name.
#[cfg(feature = "storage-tier")]
pub(crate) const SQLITE_TIER_INDEX_DB_FILE_NAME: &str = "ldk_node_tier_index.sqlite";
/// LDK Node's backup database file name.
#[cfg(feature = "storage-tier")]
pub const SQLITE_BACKUP_DB_FILE_NAME: &str = "ldk_node_data_backup.sqlite";
/// LDK Node's ephemeral database file name.
#[cfg(feature = "storage-tier")]
pub const SQLITE_EPHEMERAL_DB_FILE_NAME: &str = "ldk_node_data_ephemeral.sqlite";
/// LDK Node's table in which we store all data.
pub const KV_TABLE_NAME: &str = "ldk_node_data";

Expand All @@ -41,6 +52,17 @@ const SCHEMA_USER_VERSION: u16 = 3;
// The number of entries returned per page in paginated list operations.
const PAGE_SIZE: usize = 50;

fn exclusive_lock_error_kind(error: &rusqlite::Error) -> io::ErrorKind {
match error {
rusqlite::Error::SqliteFailure(error, _)
if matches!(error.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked) =>
{
io::ErrorKind::AlreadyExists
},
_ => io::ErrorKind::Other,
}
}

/// A [`KVStore`] implementation that writes to and reads from an [SQLite] database.
///
/// [SQLite]: https://sqlite.org
Expand All @@ -62,7 +84,23 @@ impl SqliteStore {
pub fn new(
data_dir: PathBuf, db_file_name: Option<String>, kv_table_name: Option<String>,
) -> io::Result<Self> {
let inner = Arc::new(SqliteStoreInner::new(data_dir, db_file_name, kv_table_name)?);
Self::new_internal(data_dir, db_file_name, kv_table_name, false)
}

/// Constructs a new [`SqliteStore`] which exclusively owns its database for its lifetime.
#[cfg(feature = "storage-tier")]
pub(crate) fn new_exclusive(
data_dir: PathBuf, db_file_name: Option<String>, kv_table_name: Option<String>,
) -> io::Result<Self> {
Self::new_internal(data_dir, db_file_name, kv_table_name, true)
}

fn new_internal(
data_dir: PathBuf, db_file_name: Option<String>, kv_table_name: Option<String>,
exclusive: bool,
) -> io::Result<Self> {
let inner =
Arc::new(SqliteStoreInner::new(data_dir, db_file_name, kv_table_name, exclusive)?);

let next_write_version = AtomicU64::new(1);
Ok(Self { inner, next_write_version })
Expand Down Expand Up @@ -230,6 +268,7 @@ struct SqliteStoreInner {
impl SqliteStoreInner {
fn new(
data_dir: PathBuf, db_file_name: Option<String>, kv_table_name: Option<String>,
exclusive: bool,
) -> io::Result<Self> {
let db_file_name = db_file_name.unwrap_or(DEFAULT_SQLITE_DB_FILE_NAME.to_string());
let kv_table_name = kv_table_name.unwrap_or(DEFAULT_KV_TABLE_NAME.to_string());
Expand All @@ -251,6 +290,33 @@ impl SqliteStoreInner {
io::Error::new(io::ErrorKind::Other, msg)
})?;

if exclusive {
connection.busy_timeout(Duration::ZERO).map_err(|e| {
let msg = format!(
"Failed to configure exclusive database lock timeout for {}: {}",
db_file_path.display(),
e
);
io::Error::new(io::ErrorKind::Other, msg)
})?;
connection.pragma_update(None, "locking_mode", "EXCLUSIVE").map_err(|e| {
let msg = format!(
"Failed to exclusively lock database file {}: {}",
db_file_path.display(),
e
);
io::Error::new(io::ErrorKind::Other, msg)
})?;
connection.execute_batch("BEGIN EXCLUSIVE; COMMIT;").map_err(|e| {
let msg = format!(
"Failed to exclusively lock database file {}: {}",
db_file_path.display(),
e
);
io::Error::new(exclusive_lock_error_kind(&e), msg)
})?;
}

let sql = format!("SELECT user_version FROM pragma_user_version");
let version_res: u16 = connection.query_row(&sql, [], |row| row.get(0)).map_err(|e| {
let msg = format!("Failed to read PRAGMA user_version: {}", e);
Expand Down Expand Up @@ -700,6 +766,21 @@ mod tests {
}
}

#[test]
fn exclusive_lock_error_kind_distinguishes_contention_from_other_failures() {
for result_code in [rusqlite::ffi::SQLITE_BUSY, rusqlite::ffi::SQLITE_LOCKED] {
let error =
rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(result_code), None);
assert_eq!(exclusive_lock_error_kind(&error), io::ErrorKind::AlreadyExists);
}

let io_error = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR),
None,
);
assert_eq!(exclusive_lock_error_kind(&io_error), io::ErrorKind::Other);
}

#[tokio::test]
async fn read_write_remove_list_persist() {
let mut temp_path = random_storage_path();
Expand Down
Loading
Loading