Skip to content

Commit ca9246f

Browse files
committed
Add PostgreSQL node failover leases
Make PostgresStore acquire a table-scoped lease during construction and fence schema setup, migrations, and every mutation with it. Track renewals conservatively and fail closed when the local deadline elapses, including while database I/O is stalled. Coordinate the one-way upgrade from schema v1 by taking the legacy advisory-lock key transactionally and committing the schema-v2 marker with the first lease. Older releases then reject the upgraded store. Treat runtime lease loss as process-fatal, reject startup after lease loss, and notify servers so they can exit without final persistence and restart from durable state.
1 parent 20820af commit ca9246f

9 files changed

Lines changed: 851 additions & 173 deletions

File tree

src/builder.rs

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,9 @@ impl NodeBuilder {
683683
/// Builds a [`Node`] instance with a [PostgreSQL] backend and according to the options
684684
/// previously configured.
685685
///
686+
/// This acquires an exclusive lease for the selected KV table before reading persisted node
687+
/// state. Nodes may share a database when each node identity uses a distinct `kv_table_name`.
688+
///
686689
/// Connects to the PostgreSQL database at the given `connection_string`, e.g.,
687690
/// `"postgres://user:password@localhost/ldk_db"`.
688691
///
@@ -696,13 +699,10 @@ impl NodeBuilder {
696699
/// The given `kv_table_name` will be used or default to
697700
/// [`DEFAULT_KV_TABLE_NAME`](io::postgres_store::DEFAULT_KV_TABLE_NAME).
698701
///
699-
/// # Warning
700-
///
701-
/// Do not point multiple [`Node`] instances at the same database and table. Concurrent access is
702-
/// unsafe and can corrupt node state. You must make sure that only one node accesses each
703-
/// database and table. The store uses a PostgreSQL advisory lock to reduce this risk. This lock
704-
/// is only a temporary safeguard and does not make concurrent access safe.
705-
/// Nodes using a different database or table on the same server may coexist.
702+
/// Opening a schema-v1 store upgrades it to the lease-aware schema v2. Stop all processes using
703+
/// the v1 store before upgrading. For the first v2 open, use the same resolved database name and
704+
/// byte-for-byte same `kv_table_name` spelling, including schema qualification, so its transition
705+
/// lock matches v1. Older releases cannot reopen a v2 store, so downgrading is unsupported.
706706
///
707707
/// If `certificate_pem` is `Some`, TLS will be used for database connections and the
708708
/// provided PEM-encoded CA certificate will be added to the system's default root
@@ -729,7 +729,13 @@ impl NodeBuilder {
729729
log_error!(logger, "Failed to set up Postgres store: {e}");
730730
BuildError::KVStoreSetupFailed
731731
})?;
732-
self.build_with_store_runtime_and_logger(node_entropy, kv_store, runtime, logger)
732+
let node_lease = kv_store.node_lease();
733+
let mut node =
734+
self.build_with_store_runtime_and_logger(node_entropy, kv_store, runtime, logger)?;
735+
if !node.install_node_lease(node_lease) {
736+
return Err(BuildError::KVStoreSetupFailed);
737+
}
738+
Ok(node)
733739
}
734740

735741
/// Builds a [`Node`] instance with a [`FilesystemStoreV2`] backend and according to the options
@@ -1225,6 +1231,9 @@ impl ArcedNodeBuilder {
12251231
/// Builds a [`Node`] instance with a [PostgreSQL] backend and according to the options
12261232
/// previously configured.
12271233
///
1234+
/// This acquires an exclusive lease for the selected KV table before reading persisted node
1235+
/// state. Nodes may share a database when each node identity uses a distinct `kv_table_name`.
1236+
///
12281237
/// Connects to the PostgreSQL database at the given `connection_string`, e.g.,
12291238
/// `"postgres://user:password@localhost/ldk_db"`.
12301239
///
@@ -1238,13 +1247,10 @@ impl ArcedNodeBuilder {
12381247
/// The given `kv_table_name` will be used or default to
12391248
/// [`DEFAULT_KV_TABLE_NAME`](io::postgres_store::DEFAULT_KV_TABLE_NAME).
12401249
///
1241-
/// # Warning
1242-
///
1243-
/// Do not point multiple [`Node`] instances at the same database and table. Concurrent access is
1244-
/// unsafe and can corrupt node state. You must make sure that only one node accesses each
1245-
/// database and table. The store uses a PostgreSQL advisory lock to reduce this risk. This lock
1246-
/// is only a temporary safeguard and does not make concurrent access safe.
1247-
/// Nodes using a different database or table on the same server may coexist.
1250+
/// Opening a schema-v1 store upgrades it to the lease-aware schema v2. Stop all processes using
1251+
/// the v1 store before upgrading. For the first v2 open, use the same resolved database name and
1252+
/// byte-for-byte same `kv_table_name` spelling, including schema qualification, so its transition
1253+
/// lock matches v1. Older releases cannot reopen a v2 store, so downgrading is unsupported.
12481254
///
12491255
/// If `certificate_pem` is `Some`, TLS will be used for database connections and the
12501256
/// provided PEM-encoded CA certificate will be added to the system's default root
@@ -2377,6 +2383,7 @@ fn build_with_store_internal(
23772383
payment_store,
23782384
lnurl_auth,
23792385
is_running,
2386+
node_lease: None,
23802387
node_metrics,
23812388
om_mailbox,
23822389
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: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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_started_at(&self, renewal_started_at: Instant) {
56+
if !self.is_lost() {
57+
let mut last_confirmed_renewal = self.last_confirmed_renewal.lock().expect("lock");
58+
*last_confirmed_renewal = (*last_confirmed_renewal).max(renewal_started_at);
59+
}
60+
}
61+
62+
pub(crate) fn renewal_deadline_elapsed(&self) -> bool {
63+
self.last_confirmed_renewal.lock().expect("lock").elapsed() >= NODE_LEASE_RENEWAL_DEADLINE
64+
}
65+
66+
pub(crate) async fn wait_for_renewal_deadline(&self) {
67+
loop {
68+
let last_confirmed_renewal = *self.last_confirmed_renewal.lock().expect("lock");
69+
let deadline = last_confirmed_renewal + NODE_LEASE_RENEWAL_DEADLINE;
70+
tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
71+
if self.renewal_deadline_elapsed() {
72+
return;
73+
}
74+
}
75+
}
76+
77+
pub(crate) fn ensure_operation_active(&self) -> io::Result<()> {
78+
if self.is_lost() || self.renewal_deadline_elapsed() {
79+
self.mark_lost();
80+
Err(lease_lost_error())
81+
} else {
82+
Ok(())
83+
}
84+
}
85+
86+
pub(crate) fn map_operation_error(&self, error: io::Error) -> io::Error {
87+
// Preserve transient database errors until they outlive the local safety margin.
88+
self.ensure_operation_active().err().unwrap_or(error)
89+
}
90+
91+
pub(crate) fn mark_lost(&self) {
92+
if self
93+
.lease_lost
94+
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
95+
.is_err()
96+
{
97+
return;
98+
}
99+
100+
// Run any installed containment handler before publishing lease loss.
101+
if let Some(handler) = self.loss_handler.lock().expect("lock").take() {
102+
handler();
103+
}
104+
self.loss_sender.send_replace(true);
105+
}
106+
107+
pub(crate) fn set_loss_handler(&self, handler: LeaseLossHandler) {
108+
let mut locked_handler = self.loss_handler.lock().expect("lock");
109+
if self.is_lost() {
110+
drop(locked_handler);
111+
handler();
112+
} else {
113+
*locked_handler = Some(handler);
114+
}
115+
}
116+
117+
pub(crate) async fn wait_for_loss(self: Arc<Self>) {
118+
let mut receiver = self.loss_sender.subscribe();
119+
let _ = receiver.wait_for(|lost| *lost).await;
120+
}
121+
}
122+
123+
pub(crate) fn lease_lost_error() -> io::Error {
124+
io::Error::new(io::ErrorKind::PermissionDenied, "PostgreSQL node lease was lost")
125+
}
126+
127+
#[cfg(test)]
128+
mod tests {
129+
use std::sync::atomic::{AtomicBool, Ordering};
130+
131+
use super::*;
132+
133+
#[test]
134+
fn expired_operation_marks_loss_before_returning_error() {
135+
let lease = NodeLease::new().unwrap();
136+
let handler_ran = Arc::new(AtomicBool::new(false));
137+
let handler_ran_ref = Arc::clone(&handler_ran);
138+
lease.set_loss_handler(Box::new(move || {
139+
handler_ran_ref.store(true, Ordering::Release);
140+
}));
141+
*lease.last_confirmed_renewal.lock().unwrap() =
142+
Instant::now() - NODE_LEASE_RENEWAL_DEADLINE;
143+
144+
let error = lease.map_operation_error(io::Error::from(io::ErrorKind::Other));
145+
146+
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
147+
assert!(lease.is_lost());
148+
assert!(handler_ran.load(Ordering::Acquire));
149+
}
150+
151+
#[test]
152+
fn confirmed_renewal_uses_attempt_time_and_does_not_regress() {
153+
let lease = NodeLease::new().unwrap();
154+
let renewal_started_at = Instant::now() - Duration::from_secs(1);
155+
*lease.last_confirmed_renewal.lock().unwrap() = renewal_started_at - Duration::from_secs(1);
156+
157+
lease.record_renewal_started_at(renewal_started_at);
158+
lease.record_renewal_started_at(renewal_started_at - Duration::from_secs(1));
159+
160+
assert_eq!(*lease.last_confirmed_renewal.lock().unwrap(), renewal_started_at);
161+
}
162+
163+
#[tokio::test]
164+
async fn expired_renewal_deadline_completes_immediately() {
165+
let lease = NodeLease::new().unwrap();
166+
*lease.last_confirmed_renewal.lock().unwrap() =
167+
Instant::now() - NODE_LEASE_RENEWAL_DEADLINE;
168+
169+
tokio::time::timeout(Duration::from_secs(1), lease.wait_for_renewal_deadline())
170+
.await
171+
.unwrap();
172+
}
173+
}

src/io/postgres_store/migrations.rs

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,35 @@
66
// accordance with one or both of these licenses.
77

88
use lightning::io;
9-
use tokio_postgres::Client;
9+
use tokio_postgres::Transaction;
1010

1111
pub(super) async fn migrate_schema(
12-
_client: &Client, _kv_table_name: &str, from_version: u16, to_version: u16,
12+
transaction: &Transaction<'_>, kv_table_name: &str, mut from_version: u16, to_version: u16,
1313
) -> io::Result<()> {
1414
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-
// }
15+
if from_version == 1 && to_version >= 2 {
16+
migrate_v1_to_v2(transaction, kv_table_name).await?;
17+
from_version = 2;
18+
}
19+
20+
if from_version != to_version {
21+
return Err(io::Error::new(
22+
io::ErrorKind::Other,
23+
format!("No PostgreSQL schema migration from version {from_version} to {to_version}"),
24+
));
25+
}
26+
Ok(())
27+
}
28+
29+
async fn migrate_v1_to_v2(transaction: &Transaction<'_>, kv_table_name: &str) -> io::Result<()> {
30+
// Schema v2 marks the transition from the legacy session advisory lock to fenced node leases.
31+
// Older releases reject this version instead of reopening the store without lease fencing.
32+
let sql = format!("COMMENT ON TABLE {kv_table_name} IS '2'");
33+
transaction.execute(&sql, &[]).await.map_err(|e| {
34+
io::Error::new(
35+
io::ErrorKind::Other,
36+
format!("Failed to set PostgreSQL schema version 2: {e}"),
37+
)
38+
})?;
2039
Ok(())
2140
}

0 commit comments

Comments
 (0)