Skip to content

Commit 629386d

Browse files
committed
Add PostgreSQL node failover leases
Make PostgresStore acquire a table-scoped lease during construction and fence every mutation with it. Treat runtime lease loss as process-fatal so servers can exit without final persistence and restart from durable state.
1 parent 7d26c23 commit 629386d

10 files changed

Lines changed: 759 additions & 35 deletions

File tree

PLAN.md

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Minimal Safe Failover for LDK Node
2+
3+
The minimal safe implementation makes `PostgresStore` always leased, with node integration automatic for the built-in PostgreSQL builder and process termination and restart handled by ldk-server and its supervisor. It requires no rust-lightning changes, no PostgreSQL advisory locks, no standby state machine, and no extension of the generic `KVStore` interface.
4+
5+
## Scope and assumptions
6+
7+
- Support only the built-in PostgreSQL store. Generic user-provided stores are out of scope.
8+
- Require one PostgreSQL KV table per node identity. Multiple nodes may share a database when each uses a distinct `kv_table_name`; each KV table has its own companion lease table.
9+
- Assume every safety-critical external effect has an immediately preceding fenced PostgreSQL mutation and completes within the lease interval renewed by that mutation. An unbounded pause between the committed mutation and the external effect is not protected by this design.
10+
- All processes using a node identity must run the failover-aware implementation. An older process that ignores the lease can still corrupt state.
11+
- The application or process supervisor is responsible for retrying construction after `BuildError::NodeLeaseUnavailable`. ldk-node does not keep a deserialized standby node in memory.
12+
13+
## 1. Add an internal lease primitive
14+
15+
Add `src/io/node_lease.rs` containing:
16+
17+
- A random owner ID generated for each `PostgresStore` construction attempt.
18+
- A shared `lease_lost: AtomicBool`, initialized to `false`, with a single terminal transition to `true`.
19+
- A fixed initial lease duration and renewal interval, for example 30 seconds and 10 seconds.
20+
- A notification that becomes permanently signalled when leadership is lost.
21+
- The owner ID is held by `NodeLease`; renewal cancellation and best-effort clean release are owned by `PostgresStore`. They do not require additional lease states.
22+
- No automatic reacquisition. Once a `Node` loses its lease, the process must terminate and construct a new `Node` after restart.
23+
24+
Keep this crate-internal initially. Configuration can be added later if operational experience shows the default timings are insufficient.
25+
26+
## 2. Implement PostgreSQL lease rows
27+
28+
Change `src/io/postgres_store/mod.rs` so every constructed store owns a lease for its KV table.
29+
30+
When `PostgresStore` creates its PostgreSQL KV table, also create an ordinary companion lease table in the same schema, not an advisory lock. Derive its name from the effective `kv_table_name`, including the default. For example, `ldk_data` uses `ldk_data_node_lease`, and `alice.ldk_data` uses `alice.ldk_data_node_lease`.
31+
32+
```sql
33+
CREATE TABLE IF NOT EXISTS <kv_table_name>_node_lease (
34+
id SMALLINT PRIMARY KEY CHECK (id = 1),
35+
owner_id BYTEA NOT NULL,
36+
expires_at TIMESTAMPTZ NOT NULL
37+
);
38+
```
39+
40+
Each companion table contains exactly one row with `id = 1`. It leases only the node state in its corresponding KV table. There is no shared lease table and no `store_key` column.
41+
42+
Validate that the derived PostgreSQL identifier fits PostgreSQL's identifier length limit rather than allowing truncation to make two companion table names collide.
43+
44+
Lease acquisition should atomically insert the singleton row or replace it only when `expires_at <= clock_timestamp()`. PostgreSQL time is authoritative, so failover does not depend on clock synchronization between nodes.
45+
46+
Run renewal on the store's existing private runtime. Renewal must update the row only when:
47+
48+
```text
49+
id is 1
50+
owner_id matches
51+
the existing lease has not already expired
52+
```
53+
54+
A timeout, ownership mismatch, expired lease, or ambiguous database result eventually marks the lease permanently lost. The same `Node` must never reacquire it.
55+
56+
## 3. Fence every PostgreSQL mutation
57+
58+
Modify `write_internal` and `remove_internal` so each mutation runs in a database transaction:
59+
60+
1. Atomically lock and update the singleton row only if the owner ID matches and the lease remains unexpired.
61+
2. Refresh `expires_at`, giving the following critical operation a full lease interval.
62+
3. Perform the KV write or removal.
63+
4. Commit.
64+
65+
Holding the row lock ensures a fallback cannot take over while an old leader's persistence transaction is still executing.
66+
67+
Reads and list operations do not need transaction-level fencing because:
68+
69+
- Node construction acquires the lease before reading live state.
70+
- Runtime reads have no external effect.
71+
- All state mutations and assumed critical operations are fenced separately.
72+
73+
Missing or expired ownership marks the lease lost inside `PostgresStore` before returning a persistence error. Database errors remain ordinary persistence errors while the conservative local renewal deadline remains valid; once that deadline has elapsed, an ambiguous PostgreSQL operation marks the lease lost before returning the error.
74+
75+
## 4. Acquire before deserializing the node
76+
77+
Change the built-in PostgreSQL path in `src/builder.rs`:
78+
79+
1. Construct `PostgresStore`, which acquires the lease and starts renewing it before returning.
80+
2. Only then build the node through the ordinary store path.
81+
3. Install the store's lease handle on the fully constructed node before returning it. Check for loss before and after installing the containment handler.
82+
83+
Acquiring in `Node::start()` is too late because `build_with_store_internal` already reads and may migrate persisted wallet and LDK state.
84+
85+
Add:
86+
87+
```rust
88+
BuildError::NodeLeaseUnavailable
89+
```
90+
91+
This lets a supervisor distinguish normal standby contention from database setup or corrupted-state failures.
92+
93+
Leave `build_with_store` unchanged. Directly constructed `PostgresStore` instances still fence their mutations, but the generic builder path does not install node-level lease-loss containment or notification. Applications needing failover must use `build_with_postgres_store`. Adding a new generic lease trait is unnecessary for the first implementation.
94+
95+
## 5. Treat runtime lease loss as process-fatal
96+
97+
Add the lease handle to `Node` in `src/lib.rs`.
98+
99+
Required semantics:
100+
101+
- Confirmed ownership loss, confirmed expiration, or failure to renew before the conservative lease deadline atomically changes `lease_lost` from `false` to `true`. This transition is one-way.
102+
- `ensure_lease_active()` checks at API entry are not a safety boundary because leadership can be lost immediately afterward. Do not add them to public operations.
103+
- Every leased PostgreSQL write and removal is the authoritative fence. It locks and validates the companion lease row, renews it, performs the KV mutation, and commits in one transaction.
104+
- `PostgresStore` calls the one-way `mark_lost()` before returning a persistence error whenever ownership is missing, expired, or ambiguous after the conservative local deadline.
105+
- The transition stops background processing, disconnects peers, and only then notifies ldk-server. This containment begins even when an intermediate caller converts or ignores the persistence error.
106+
- The application must treat the notification as fatal and terminate the process immediately, without calling `Node::stop()`. The same process must never use or restart that `Node` after lease loss.
107+
- Higher layers continue to expose `Error::PersistenceFailed` for the failed operation. Safety does not depend on classifying lease loss in the public error enum or changing the node's ordinary running state.
108+
- The PostgreSQL store rejects every later write and removal after the terminal transition. Reads remain unfenced.
109+
- Expose a lease-loss notification, such as `Node::wait_for_lease_loss()`, for ldk-server. Do not use the persistent event queue for this notification because persistence is no longer available after lease loss.
110+
- Correctness does not depend on how quickly the application handles the notification. The store has already rejected further mutations; prompt process exit limits unfenced external activity after loss.
111+
- `Node::stop()` does not release the lease. The current API permits stopping and restarting the same already-deserialized `Node`; releasing at `stop()` would make a later restart unsafe.
112+
- Dropping the `Node` performs a best-effort conditional lease release after normal shutdown. If release fails, the fallback waits for expiration.
113+
- Planned handoff therefore means `stop()`, then drop all references to the `Node`.
114+
- Process termination after lease loss skips destructors, final persistence, lease release, and reacquisition.
115+
116+
## 6. Terminate and restart in ldk-server
117+
118+
ldk-server waits for `Node::wait_for_lease_loss()` in a prioritized select branch. When notified, it immediately calls `std::process::exit(1)` without calling `Node::stop()` or dropping the node. Its process supervisor then starts a new ldk-server process, which constructs a fresh `Node` from PostgreSQL.
119+
120+
Expose an optional `kv_table_name` in ldk-server's PostgreSQL configuration and pass it to the existing PostgreSQL build method. Repeated process starts by the supervisor call that build method with the same database and KV table configuration:
121+
122+
```text
123+
NodeLeaseUnavailable => wait and retry
124+
success => start the newly deserialized Node
125+
other error => report/fail
126+
```
127+
128+
This avoids introducing a standby `Node`, a `wait_for_leadership` API, or state reload logic. A node is only constructed after it has leadership. ldk-node never terminates the host process itself because it is a library; ldk-server owns that policy.
129+
130+
This does not make a committed database transaction atomic with a later peer message or Bitcoin transaction broadcast. A process can pause after the fenced commit and resume after takeover. Supporting arbitrary pauses would require fencing at the external system itself, which Lightning peers and Bitcoin transaction broadcast generally do not provide.
131+
132+
## 7. Tests
133+
134+
Add PostgreSQL integration tests covering:
135+
136+
- A second builder receives `NodeLeaseUnavailable`.
137+
- Builders for distinct KV table names in the same database can acquire leases independently.
138+
- Exactly one contender acquires an expired lease.
139+
- A fallback acquires after renewal stops and the lease expires.
140+
- The original store cannot write or remove after fallback takeover.
141+
- A lost original node cannot renew or reacquire, even if database connectivity returns.
142+
- Lease loss runs the containment handler before its notification is delivered and before the failed operation returns its ordinary persistence error.
143+
- A lost store rejects all later writes and removals.
144+
- ldk-server exits without final persistence or conditional lease release.
145+
- ldk-server receives the lease-loss notification and exits with a failure status.
146+
- Dropping the leader permits immediate clean takeover.
147+
- `stop()` without dropping continues to hold the lease.
148+
- A build failure after acquisition releases the lease or leaves it to expire safely.
149+
- Connection loss and an ambiguous renewal result fail closed.
150+
- A KV mutation and takeover racing at expiration cannot both succeed out of order.

src/builder.rs

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ pub enum BuildError {
190190
///
191191
/// [`KVStore`]: lightning::util::persist::KVStore
192192
KVStoreSetupFailed,
193+
/// Another node currently owns the PostgreSQL node lease.
194+
NodeLeaseUnavailable,
193195
/// We failed to setup the onchain wallet.
194196
WalletSetupFailed,
195197
/// We failed to setup the logger.
@@ -232,6 +234,7 @@ impl fmt::Display for BuildError {
232234
Self::WriteFailed => write!(f, "Failed to write to store."),
233235
Self::StoragePathAccessFailed => write!(f, "Failed to access the given storage path."),
234236
Self::KVStoreSetupFailed => write!(f, "Failed to setup KVStore."),
237+
Self::NodeLeaseUnavailable => write!(f, "The PostgreSQL node lease is unavailable."),
235238
Self::WalletSetupFailed => write!(f, "Failed to setup onchain wallet."),
236239
Self::LoggerSetupFailed => write!(f, "Failed to setup the logger."),
237240
Self::ChainSourceSetupFailed => write!(f, "Failed to setup the chain source."),
@@ -683,6 +686,9 @@ impl NodeBuilder {
683686
/// Builds a [`Node`] instance with a [PostgreSQL] backend and according to the options
684687
/// previously configured.
685688
///
689+
/// This acquires an exclusive lease for the selected KV table before reading persisted node
690+
/// state. Nodes may share a database when each node identity uses a distinct `kv_table_name`.
691+
///
686692
/// Connects to the PostgreSQL database at the given `connection_string`, e.g.,
687693
/// `"postgres://user:password@localhost/ldk_db"`.
688694
///
@@ -701,6 +707,9 @@ impl NodeBuilder {
701707
/// certificates (it does not replace them). If `certificate_pem` is `None`, connections
702708
/// will be unencrypted.
703709
///
710+
/// Returns [`BuildError::NodeLeaseUnavailable`] while another process owns the selected KV
711+
/// table's lease.
712+
///
704713
/// [PostgreSQL]: https://www.postgresql.org
705714
#[cfg(feature = "postgres")]
706715
pub fn build_with_postgres_store(
@@ -709,19 +718,29 @@ impl NodeBuilder {
709718
) -> Result<Node, BuildError> {
710719
let logger = setup_logger(&self.log_writer_config, &self.config)?;
711720
let runtime = self.setup_runtime(&logger)?;
712-
let kv_store = runtime
713-
.block_on(io::postgres_store::PostgresStore::new_with_logger(
714-
connection_string,
715-
db_name,
716-
kv_table_name,
717-
certificate_pem,
718-
Some(Arc::clone(&logger)),
719-
))
720-
.map_err(|e| {
721+
let kv_store = match runtime.block_on(io::postgres_store::PostgresStore::new_with_logger(
722+
connection_string,
723+
db_name,
724+
kv_table_name,
725+
certificate_pem,
726+
Some(Arc::clone(&logger)),
727+
)) {
728+
Ok(kv_store) => kv_store,
729+
Err(e) if e.kind() == lightning::io::ErrorKind::WouldBlock => {
730+
return Err(BuildError::NodeLeaseUnavailable);
731+
},
732+
Err(e) => {
721733
log_error!(logger, "Failed to set up Postgres store: {e}");
722-
BuildError::KVStoreSetupFailed
723-
})?;
724-
self.build_with_store_runtime_and_logger(node_entropy, kv_store, runtime, logger)
734+
return Err(BuildError::KVStoreSetupFailed);
735+
},
736+
};
737+
let node_lease = kv_store.node_lease();
738+
let mut node =
739+
self.build_with_store_runtime_and_logger(node_entropy, kv_store, runtime, logger)?;
740+
if !node.install_node_lease(node_lease) {
741+
return Err(BuildError::NodeLeaseUnavailable);
742+
}
743+
Ok(node)
725744
}
726745

727746
/// Builds a [`Node`] instance with a [`FilesystemStoreV2`] backend and according to the options
@@ -1217,6 +1236,9 @@ impl ArcedNodeBuilder {
12171236
/// Builds a [`Node`] instance with a [PostgreSQL] backend and according to the options
12181237
/// previously configured.
12191238
///
1239+
/// This acquires an exclusive lease for the selected KV table before reading persisted node
1240+
/// state. Nodes may share a database when each node identity uses a distinct `kv_table_name`.
1241+
///
12201242
/// Connects to the PostgreSQL database at the given `connection_string`, e.g.,
12211243
/// `"postgres://user:password@localhost/ldk_db"`.
12221244
///
@@ -1235,6 +1257,9 @@ impl ArcedNodeBuilder {
12351257
/// certificates (it does not replace them). If `certificate_pem` is `None`, connections
12361258
/// will be unencrypted.
12371259
///
1260+
/// Returns [`BuildError::NodeLeaseUnavailable`] while another process owns the selected KV
1261+
/// table's lease.
1262+
///
12381263
/// [PostgreSQL]: https://www.postgresql.org
12391264
#[cfg(feature = "postgres")]
12401265
pub fn build_with_postgres_store(
@@ -2335,6 +2360,7 @@ fn build_with_store_internal(
23352360
payment_store,
23362361
lnurl_auth,
23372362
is_running,
2363+
node_lease: None,
23382364
node_metrics,
23392365
om_mailbox,
23402366
async_payments_role,

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_attr(not(feature = "postgres"), allow(dead_code))]
11+
pub(crate) mod node_lease;
1012
#[cfg(feature = "postgres")]
1113
pub mod postgres_store;
1214
pub mod sqlite_store;

0 commit comments

Comments
 (0)