Skip to content

Commit 056be7a

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 056be7a

9 files changed

Lines changed: 609 additions & 35 deletions

File tree

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;

src/io/node_lease.rs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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 std::sync::atomic::{AtomicBool, Ordering};
9+
use std::sync::{Arc, Mutex};
10+
use std::time::{Duration, Instant};
11+
12+
use lightning::io;
13+
14+
pub(crate) const NODE_LEASE_DURATION: Duration = Duration::from_secs(30);
15+
// Fail closed before the database lease expires, leaving time for process termination.
16+
pub(crate) const NODE_LEASE_RENEWAL_DEADLINE: Duration = Duration::from_secs(20);
17+
pub(crate) const NODE_LEASE_RENEWAL_INTERVAL: Duration = Duration::from_secs(10);
18+
pub(crate) const NODE_LEASE_RETRY_INTERVAL: Duration = Duration::from_secs(1);
19+
pub(crate) const NODE_LEASE_RELEASE_TIMEOUT: Duration = Duration::from_secs(5);
20+
21+
type LeaseLossHandler = Box<dyn FnOnce() + Send>;
22+
23+
pub(crate) struct NodeLease {
24+
owner_id: [u8; 32],
25+
lease_lost: AtomicBool,
26+
last_confirmed_renewal: Mutex<Instant>,
27+
loss_sender: tokio::sync::watch::Sender<bool>,
28+
loss_handler: Mutex<Option<LeaseLossHandler>>,
29+
}
30+
31+
impl NodeLease {
32+
pub(crate) fn new() -> io::Result<Arc<Self>> {
33+
let mut owner_id = [0u8; 32];
34+
getrandom::fill(&mut owner_id).map_err(|e| {
35+
io::Error::new(io::ErrorKind::Other, format!("Failed to generate lease owner ID: {e}"))
36+
})?;
37+
let (loss_sender, _) = tokio::sync::watch::channel(false);
38+
Ok(Arc::new(Self {
39+
owner_id,
40+
lease_lost: AtomicBool::new(false),
41+
last_confirmed_renewal: Mutex::new(Instant::now()),
42+
loss_sender,
43+
loss_handler: Mutex::new(None),
44+
}))
45+
}
46+
47+
pub(crate) fn owner_id(&self) -> &[u8; 32] {
48+
&self.owner_id
49+
}
50+
51+
pub(crate) fn is_lost(&self) -> bool {
52+
self.lease_lost.load(Ordering::Acquire)
53+
}
54+
55+
pub(crate) fn record_renewal(&self) {
56+
if !self.is_lost() {
57+
*self.last_confirmed_renewal.lock().expect("lock") = Instant::now();
58+
}
59+
}
60+
61+
pub(crate) fn renewal_deadline_elapsed(&self) -> bool {
62+
self.last_confirmed_renewal.lock().expect("lock").elapsed() >= NODE_LEASE_RENEWAL_DEADLINE
63+
}
64+
65+
pub(crate) fn ensure_operation_active(&self) -> io::Result<()> {
66+
if self.is_lost() || self.renewal_deadline_elapsed() {
67+
self.mark_lost();
68+
Err(lease_lost_error())
69+
} else {
70+
Ok(())
71+
}
72+
}
73+
74+
pub(crate) fn map_operation_error(&self, error: io::Error) -> io::Error {
75+
// Preserve transient database errors until they outlive the local safety margin.
76+
self.ensure_operation_active().err().unwrap_or(error)
77+
}
78+
79+
pub(crate) fn mark_lost(&self) {
80+
if self
81+
.lease_lost
82+
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
83+
.is_err()
84+
{
85+
return;
86+
}
87+
88+
// Run any installed containment handler before publishing lease loss.
89+
if let Some(handler) = self.loss_handler.lock().expect("lock").take() {
90+
handler();
91+
}
92+
self.loss_sender.send_replace(true);
93+
}
94+
95+
pub(crate) fn set_loss_handler(&self, handler: LeaseLossHandler) {
96+
let mut locked_handler = self.loss_handler.lock().expect("lock");
97+
if self.is_lost() {
98+
drop(locked_handler);
99+
handler();
100+
} else {
101+
*locked_handler = Some(handler);
102+
}
103+
}
104+
105+
pub(crate) async fn wait_for_loss(self: Arc<Self>) {
106+
let mut receiver = self.loss_sender.subscribe();
107+
let _ = receiver.wait_for(|lost| *lost).await;
108+
}
109+
}
110+
111+
pub(crate) fn lease_lost_error() -> io::Error {
112+
io::Error::new(io::ErrorKind::PermissionDenied, "PostgreSQL node lease was lost")
113+
}
114+
115+
#[cfg(test)]
116+
mod tests {
117+
use std::sync::atomic::{AtomicBool, Ordering};
118+
119+
use super::*;
120+
121+
#[test]
122+
fn expired_operation_marks_loss_before_returning_error() {
123+
let lease = NodeLease::new().unwrap();
124+
let handler_ran = Arc::new(AtomicBool::new(false));
125+
let handler_ran_ref = Arc::clone(&handler_ran);
126+
lease.set_loss_handler(Box::new(move || {
127+
handler_ran_ref.store(true, Ordering::Release);
128+
}));
129+
*lease.last_confirmed_renewal.lock().unwrap() =
130+
Instant::now() - NODE_LEASE_RENEWAL_DEADLINE;
131+
132+
let error = lease.map_operation_error(io::Error::from(io::ErrorKind::Other));
133+
134+
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
135+
assert!(lease.is_lost());
136+
assert!(handler_ran.load(Ordering::Acquire));
137+
}
138+
}

0 commit comments

Comments
 (0)