Skip to content

Commit 060cb69

Browse files
benthecarmanclaude
andcommitted
Add PostgreSQL storage backend
Add a PostgresStore implementation behind the "postgres" feature flag, mirroring the existing SqliteStore. Uses tokio-postgres (async-native) with an internal tokio runtime for the sync KVStoreSync trait, following the VssStore pattern. Includes unit tests, integration tests (channel full cycle and node restart), and a CI workflow that runs both against a PostgreSQL service container. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 109978d commit 060cb69

9 files changed

Lines changed: 1636 additions & 8 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: CI Checks - PostgreSQL Integration Tests
2+
3+
on: [ push, pull_request ]
4+
5+
concurrency:
6+
group: ${{ github.workflow }}-${{ github.ref }}
7+
cancel-in-progress: true
8+
9+
jobs:
10+
build-and-test:
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 60
13+
14+
services:
15+
postgres:
16+
image: postgres:latest
17+
ports:
18+
- 5432:5432
19+
env:
20+
POSTGRES_DB: postgres
21+
POSTGRES_USER: postgres
22+
POSTGRES_PASSWORD: postgres
23+
options: >-
24+
--health-cmd pg_isready
25+
--health-interval 10s
26+
--health-timeout 5s
27+
--health-retries 5
28+
29+
steps:
30+
- name: Checkout code
31+
uses: actions/checkout@v6
32+
- name: Install Rust stable toolchain
33+
run: |
34+
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable
35+
- name: Enable caching for bitcoind
36+
id: cache-bitcoind
37+
uses: actions/cache@v4
38+
with:
39+
path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }}
40+
key: bitcoind-27_2-${{ runner.os }}-${{ runner.arch }}
41+
- name: Enable caching for electrs
42+
id: cache-electrs
43+
uses: actions/cache@v4
44+
with:
45+
path: bin/electrs-${{ runner.os }}-${{ runner.arch }}
46+
key: electrs-esplora_a33e97e1-${{ runner.os }}-${{ runner.arch }}
47+
- name: Download bitcoind/electrs
48+
if: "steps.cache-bitcoind.outputs.cache-hit != 'true' || steps.cache-electrs.outputs.cache-hit != 'true'"
49+
run: |
50+
source ./scripts/download_bitcoind_electrs.sh
51+
mkdir -p bin
52+
mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }}
53+
mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }}
54+
- name: Set bitcoind/electrs environment variables
55+
run: |
56+
echo "BITCOIND_EXE=$( pwd )/bin/bitcoind-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV"
57+
echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV"
58+
- name: Run PostgreSQL store tests
59+
env:
60+
TEST_POSTGRES_URL: "host=localhost user=postgres password=postgres"
61+
run: cargo test --features postgres io::postgres_store
62+
- name: Run PostgreSQL integration tests
63+
env:
64+
TEST_POSTGRES_URL: "host=localhost user=postgres password=postgres"
65+
run: |
66+
RUSTFLAGS="--cfg no_download --cfg cycle_tests" cargo test --features postgres --test integration_tests_postgres

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ panic = 'abort' # Abort on panic
2525

2626
[features]
2727
default = []
28+
postgres = ["dep:tokio-postgres", "dep:native-tls", "dep:postgres-native-tls"]
2829

2930
[dependencies]
3031
#lightning = { version = "0.2.0", features = ["std"] }
@@ -78,6 +79,9 @@ serde_json = { version = "1.0.128", default-features = false, features = ["std"]
7879
log = { version = "0.4.22", default-features = false, features = ["std"]}
7980

8081
async-trait = { version = "0.1", default-features = false }
82+
tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"], optional = true }
83+
native-tls = { version = "0.2", default-features = false, optional = true }
84+
postgres-native-tls = { version = "0.5", default-features = false, features = ["runtime"], optional = true }
8185
vss-client = { package = "vss-client-ng", version = "0.5" }
8286
prost = { version = "0.11.6", default-features = false}
8387
#bitcoin-payment-instructions = { version = "0.6" }

bindings/ldk_node.udl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ interface Builder {
6464
[Throws=BuildError]
6565
Node build(NodeEntropy node_entropy);
6666
[Throws=BuildError]
67+
Node build_with_postgres_store(NodeEntropy node_entropy, string connection_string, string? db_name, string? kv_table_name, string? certificate_pem);
68+
[Throws=BuildError]
6769
Node build_with_fs_store(NodeEntropy node_entropy);
6870
[Throws=BuildError]
6971
Node build_with_vss_store(NodeEntropy node_entropy, string vss_url, string store_id, record<string, string> fixed_headers);

src/builder.rs

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,49 @@ impl NodeBuilder {
644644
self.build_with_store_and_logger(node_entropy, kv_store, logger)
645645
}
646646

647+
/// Builds a [`Node`] instance with a [PostgreSQL] backend and according to the options
648+
/// previously configured.
649+
///
650+
/// Connects to the PostgreSQL database at the given `connection_string`, e.g.,
651+
/// `"postgres://user:password@localhost/ldk_db"`.
652+
///
653+
/// The given `db_name` will be used or default to
654+
/// [`DEFAULT_DB_NAME`](io::postgres_store::DEFAULT_DB_NAME). The `connection_string` must
655+
/// not include a `dbname` when `db_name` is set, providing both is an error. The database
656+
/// will be created automatically if it doesn't already exist. The initial connection is
657+
/// made to the target database, and if it fails we fall back to the default `postgres`
658+
/// database to create it.
659+
///
660+
/// The given `kv_table_name` will be used or default to
661+
/// [`DEFAULT_KV_TABLE_NAME`](io::postgres_store::DEFAULT_KV_TABLE_NAME).
662+
///
663+
/// If `certificate_pem` is `Some`, TLS will be used for database connections and the
664+
/// provided PEM-encoded CA certificate will be added to the system's default root
665+
/// certificates (it does not replace them). If `certificate_pem` is `None`, connections
666+
/// will be unencrypted.
667+
///
668+
/// [PostgreSQL]: https://www.postgresql.org
669+
#[cfg(feature = "postgres")]
670+
pub fn build_with_postgres_store(
671+
&self, node_entropy: NodeEntropy, connection_string: String, db_name: Option<String>,
672+
kv_table_name: Option<String>, certificate_pem: Option<String>,
673+
) -> Result<Node, BuildError> {
674+
let logger = setup_logger(&self.log_writer_config, &self.config)?;
675+
let runtime = self.setup_runtime(&logger)?;
676+
let kv_store = runtime
677+
.block_on(io::postgres_store::PostgresStore::new(
678+
connection_string,
679+
db_name,
680+
kv_table_name,
681+
certificate_pem,
682+
))
683+
.map_err(|e| {
684+
log_error!(logger, "Failed to set up Postgres store: {e}");
685+
BuildError::KVStoreSetupFailed
686+
})?;
687+
self.build_with_store_runtime_and_logger(node_entropy, kv_store, runtime, logger)
688+
}
689+
647690
/// Builds a [`Node`] instance with a [`FilesystemStore`] backend and according to the options
648691
/// previously configured.
649692
pub fn build_with_fs_store(&self, node_entropy: NodeEntropy) -> Result<Node, BuildError> {
@@ -788,18 +831,27 @@ impl NodeBuilder {
788831
self.build_with_store_and_logger(node_entropy, kv_store, logger)
789832
}
790833

791-
fn build_with_store_and_logger<S: SyncAndAsyncKVStore + Send + Sync + 'static>(
792-
&self, node_entropy: NodeEntropy, kv_store: S, logger: Arc<Logger>,
793-
) -> Result<Node, BuildError> {
794-
let runtime = if let Some(handle) = self.runtime_handle.as_ref() {
795-
Arc::new(Runtime::with_handle(handle.clone(), Arc::clone(&logger)))
834+
fn setup_runtime(&self, logger: &Arc<Logger>) -> Result<Arc<Runtime>, BuildError> {
835+
if let Some(handle) = self.runtime_handle.as_ref() {
836+
Ok(Arc::new(Runtime::with_handle(handle.clone(), Arc::clone(logger))))
796837
} else {
797-
Arc::new(Runtime::new(Arc::clone(&logger)).map_err(|e| {
838+
Ok(Arc::new(Runtime::new(Arc::clone(logger)).map_err(|e| {
798839
log_error!(logger, "Failed to setup tokio runtime: {}", e);
799840
BuildError::RuntimeSetupFailed
800-
})?)
801-
};
841+
})?))
842+
}
843+
}
844+
845+
fn build_with_store_and_logger<S: SyncAndAsyncKVStore + Send + Sync + 'static>(
846+
&self, node_entropy: NodeEntropy, kv_store: S, logger: Arc<Logger>,
847+
) -> Result<Node, BuildError> {
848+
let runtime = self.setup_runtime(&logger)?;
849+
self.build_with_store_runtime_and_logger(node_entropy, kv_store, runtime, logger)
850+
}
802851

852+
fn build_with_store_runtime_and_logger<S: SyncAndAsyncKVStore + Send + Sync + 'static>(
853+
&self, node_entropy: NodeEntropy, kv_store: S, runtime: Arc<Runtime>, logger: Arc<Logger>,
854+
) -> Result<Node, BuildError> {
803855
let seed_bytes = node_entropy.to_seed_bytes();
804856
let config = Arc::new(self.config.clone());
805857

@@ -1115,6 +1167,58 @@ impl ArcedNodeBuilder {
11151167
self.inner.read().expect("lock").build(*node_entropy).map(Arc::new)
11161168
}
11171169

1170+
/// Builds a [`Node`] instance with a [PostgreSQL] backend and according to the options
1171+
/// previously configured.
1172+
///
1173+
/// Connects to the PostgreSQL database at the given `connection_string`, e.g.,
1174+
/// `"postgres://user:password@localhost/ldk_db"`.
1175+
///
1176+
/// The given `db_name` will be used or default to
1177+
/// [`DEFAULT_DB_NAME`](io::postgres_store::DEFAULT_DB_NAME). The `connection_string` must
1178+
/// not include a `dbname` when `db_name` is set, providing both is an error. The database
1179+
/// will be created automatically if it doesn't already exist. The initial connection is
1180+
/// made to the target database, and if it fails we fall back to the default `postgres`
1181+
/// database to create it.
1182+
///
1183+
/// The given `kv_table_name` will be used or default to
1184+
/// [`DEFAULT_KV_TABLE_NAME`](io::postgres_store::DEFAULT_KV_TABLE_NAME).
1185+
///
1186+
/// If `certificate_pem` is `Some`, TLS will be used for database connections and the
1187+
/// provided PEM-encoded CA certificate will be added to the system's default root
1188+
/// certificates (it does not replace them). If `certificate_pem` is `None`, connections
1189+
/// will be unencrypted.
1190+
///
1191+
/// [PostgreSQL]: https://www.postgresql.org
1192+
#[cfg(feature = "postgres")]
1193+
pub fn build_with_postgres_store(
1194+
&self, node_entropy: Arc<NodeEntropy>, connection_string: String, db_name: Option<String>,
1195+
kv_table_name: Option<String>, certificate_pem: Option<String>,
1196+
) -> Result<Arc<Node>, BuildError> {
1197+
self.inner
1198+
.read()
1199+
.unwrap()
1200+
.build_with_postgres_store(
1201+
*node_entropy,
1202+
connection_string,
1203+
db_name,
1204+
kv_table_name,
1205+
certificate_pem,
1206+
)
1207+
.map(Arc::new)
1208+
}
1209+
1210+
/// Builds a [`Node`] instance with a [PostgreSQL] backend and according to the options
1211+
/// previously configured.
1212+
///
1213+
/// This requires the `postgres` crate feature.
1214+
#[cfg(not(feature = "postgres"))]
1215+
pub fn build_with_postgres_store(
1216+
&self, _node_entropy: Arc<NodeEntropy>, _connection_string: String,
1217+
_db_name: Option<String>, _kv_table_name: Option<String>, _certificate_pem: Option<String>,
1218+
) -> Result<Arc<Node>, BuildError> {
1219+
Err(BuildError::KVStoreSetupFailed)
1220+
}
1221+
11181222
/// Builds a [`Node`] instance with a [`FilesystemStore`] backend and according to the options
11191223
/// previously configured.
11201224
pub fn build_with_fs_store(

src/io/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
//! Objects and traits for data persistence.
99
10+
#[cfg(feature = "postgres")]
11+
pub mod postgres_store;
1012
pub mod sqlite_store;
1113
#[cfg(test)]
1214
pub(crate) mod test_utils;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
use lightning::io;
9+
use tokio_postgres::Client;
10+
11+
pub(super) async fn migrate_schema(
12+
_client: &Client, _kv_table_name: &str, from_version: u16, to_version: u16,
13+
) -> io::Result<()> {
14+
assert!(from_version < to_version);
15+
// Future migrations go here, e.g.:
16+
// if from_version == 1 && to_version >= 2 {
17+
// migrate_v1_to_v2(client, kv_table_name).await?;
18+
// from_version = 2;
19+
// }
20+
Ok(())
21+
}

0 commit comments

Comments
 (0)