Skip to content

Commit ebaa7ed

Browse files
committed
lsps1: Add prune_orders API to remove completed order state
Add `prune_orders(counterparty_node_id, max_age: Duration)` to both `LSPS1ServiceHandler` and `LSPS1ServiceHandlerSync`. It removes all terminal orders (`CompletedAndChannelOpened` / `FailedAndRefunded`) for a given peer that are at least `max_age` old, persists the updated state, and returns the number of entries removed. Passing `Duration::ZERO` prunes all terminal orders immediately regardless of age, which is the recommended approach to unblock a client that has hit the per-peer request limit due to accumulated failed orders. On the `PeerState` layer, `prune_terminal_orders(now, max_age)` uses `retain` for a single-pass removal and sets `needs_persist` only when at least one entry is removed.
1 parent 47122e8 commit ebaa7ed

2 files changed

Lines changed: 243 additions & 0 deletions

File tree

lightning-liquidity/src/lsps1/peer_state.rs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ use super::msgs::{
1717
use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId};
1818
use crate::prelude::HashMap;
1919

20+
use core::time::Duration;
21+
2022
use lightning::util::hash_tables::new_hash_map;
2123
use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum};
2224

@@ -397,6 +399,36 @@ impl PeerState {
397399
});
398400
}
399401

402+
/// Removes all terminal orders from state that are at least `max_age` old.
403+
///
404+
/// Terminal orders are those in the [`ChannelOrderState::CompletedAndChannelOpened`] or
405+
/// [`ChannelOrderState::FailedAndRefunded`] state. `max_age` is measured from the order's
406+
/// `created_at` timestamp. Pass [`Duration::ZERO`] to prune all terminal orders regardless
407+
/// of age, which is useful to immediately free per-peer quota when a client is blocked by
408+
/// the request limit due to accumulated `FailedAndRefunded` entries.
409+
///
410+
/// Returns the number of orders removed.
411+
pub(super) fn prune_terminal_orders(&mut self, now: &LSPSDateTime, max_age: Duration) -> usize {
412+
let mut pruned = 0usize;
413+
self.outbound_channels_by_order_id.retain(|_order_id, order| {
414+
let is_terminal = matches!(
415+
order.state,
416+
ChannelOrderState::CompletedAndChannelOpened { .. }
417+
| ChannelOrderState::FailedAndRefunded { .. }
418+
);
419+
if is_terminal && now.duration_since(&order.created_at) >= max_age {
420+
pruned += 1;
421+
false
422+
} else {
423+
true
424+
}
425+
});
426+
if pruned > 0 {
427+
self.needs_persist |= true;
428+
}
429+
pruned
430+
}
431+
400432
fn pending_requests_and_unpaid_orders(&self) -> usize {
401433
let pending_requests = self.pending_requests.len();
402434
// We exclude paid and completed orders.
@@ -778,4 +810,149 @@ mod tests {
778810
// Available in CompletedAndChannelOpened
779811
assert_eq!(state.channel_info(), Some(&channel_info));
780812
}
813+
814+
fn create_test_order_params() -> LSPS1OrderParams {
815+
LSPS1OrderParams {
816+
lsp_balance_sat: 100_000,
817+
client_balance_sat: 0,
818+
required_channel_confirmations: 0,
819+
funding_confirms_within_blocks: 6,
820+
channel_expiry_blocks: 144,
821+
token: None,
822+
announce_channel: false,
823+
}
824+
}
825+
826+
#[test]
827+
fn test_prune_terminal_orders_completed() {
828+
let mut peer_state = PeerState::default();
829+
let order_id = LSPS1OrderId("order1".to_string());
830+
peer_state.new_order(
831+
order_id.clone(),
832+
create_test_order_params(),
833+
LSPSDateTime::from_str("2024-01-01T00:00:00Z").unwrap(),
834+
create_test_payment_info_bolt11_only(),
835+
);
836+
peer_state.order_payment_received(&order_id, PaymentMethod::Bolt11).unwrap();
837+
peer_state.order_channel_opened(&order_id, create_test_channel_info()).unwrap();
838+
839+
// max_age=0 prunes all terminal orders regardless of age.
840+
let now = LSPSDateTime::from_str("2024-01-01T01:00:00Z").unwrap();
841+
assert_eq!(peer_state.prune_terminal_orders(&now, Duration::ZERO), 1);
842+
assert!(peer_state.get_order(&order_id).is_err());
843+
}
844+
845+
#[test]
846+
fn test_prune_terminal_orders_failed_and_refunded() {
847+
let mut peer_state = PeerState::default();
848+
let order_id = LSPS1OrderId("order2".to_string());
849+
// Non-expired invoice: verify we do not require invoice expiry before pruning.
850+
peer_state.new_order(
851+
order_id.clone(),
852+
create_test_order_params(),
853+
LSPSDateTime::from_str("2024-01-01T00:00:00Z").unwrap(),
854+
create_test_payment_info_bolt11_only(),
855+
);
856+
peer_state.order_failed_and_refunded(&order_id).unwrap();
857+
858+
let now = LSPSDateTime::from_str("2024-01-01T01:00:00Z").unwrap();
859+
assert_eq!(peer_state.prune_terminal_orders(&now, Duration::ZERO), 1);
860+
assert!(peer_state.get_order(&order_id).is_err());
861+
}
862+
863+
#[test]
864+
fn test_prune_terminal_orders_age_filter() {
865+
let mut peer_state = PeerState::default();
866+
867+
// Old order (2 hours before now) — must be pruned when max_age = 1 hour.
868+
let old_id = LSPS1OrderId("old".to_string());
869+
peer_state.new_order(
870+
old_id.clone(),
871+
create_test_order_params(),
872+
LSPSDateTime::from_str("2024-01-01T00:00:00Z").unwrap(),
873+
create_test_payment_info_bolt11_only(),
874+
);
875+
peer_state.order_failed_and_refunded(&old_id).unwrap();
876+
877+
// Recent order (10 minutes before now) — must NOT be pruned when max_age = 1 hour.
878+
let recent_id = LSPS1OrderId("recent".to_string());
879+
peer_state.new_order(
880+
recent_id.clone(),
881+
create_test_order_params(),
882+
LSPSDateTime::from_str("2024-01-01T01:50:00Z").unwrap(),
883+
create_test_payment_info_bolt11_only(),
884+
);
885+
peer_state.order_failed_and_refunded(&recent_id).unwrap();
886+
887+
let now = LSPSDateTime::from_str("2024-01-01T02:00:00Z").unwrap();
888+
let pruned = peer_state.prune_terminal_orders(&now, Duration::from_secs(3600));
889+
assert_eq!(pruned, 1);
890+
assert!(peer_state.get_order(&old_id).is_err());
891+
assert!(peer_state.get_order(&recent_id).is_ok());
892+
}
893+
894+
#[test]
895+
fn test_prune_terminal_orders_non_terminal_skipped() {
896+
let mut peer_state = PeerState::default();
897+
898+
// ExpectingPayment is not a terminal state.
899+
let expecting_id = LSPS1OrderId("expecting".to_string());
900+
peer_state.new_order(
901+
expecting_id.clone(),
902+
create_test_order_params(),
903+
LSPSDateTime::from_str("2024-01-01T00:00:00Z").unwrap(),
904+
create_test_payment_info_bolt11_only(),
905+
);
906+
907+
// OrderPaid is not a terminal state.
908+
let paid_id = LSPS1OrderId("paid".to_string());
909+
peer_state.new_order(
910+
paid_id.clone(),
911+
create_test_order_params(),
912+
LSPSDateTime::from_str("2024-01-01T00:00:00Z").unwrap(),
913+
create_test_payment_info_bolt11_only(),
914+
);
915+
peer_state.order_payment_received(&paid_id, PaymentMethod::Bolt11).unwrap();
916+
917+
let now = LSPSDateTime::from_str("2024-01-01T02:00:00Z").unwrap();
918+
assert_eq!(peer_state.prune_terminal_orders(&now, Duration::ZERO), 0);
919+
assert!(peer_state.get_order(&expecting_id).is_ok());
920+
assert!(peer_state.get_order(&paid_id).is_ok());
921+
}
922+
923+
#[test]
924+
fn test_prune_terminal_orders_frees_quota() {
925+
let mut peer_state = PeerState::default();
926+
927+
// Fill up to the limit with FailedAndRefunded orders.
928+
for i in 0..MAX_PENDING_REQUESTS_PER_PEER {
929+
let order_id = LSPS1OrderId(format!("order{}", i));
930+
peer_state.new_order(
931+
order_id.clone(),
932+
create_test_order_params(),
933+
LSPSDateTime::from_str("2024-01-01T00:00:00Z").unwrap(),
934+
create_test_payment_info_bolt11_only(),
935+
);
936+
peer_state.order_failed_and_refunded(&order_id).unwrap();
937+
}
938+
939+
// Registering another request must fail: quota is exhausted.
940+
let dummy_request = LSPS1Request::GetInfo(Default::default());
941+
assert!(matches!(
942+
peer_state.register_request(LSPSRequestId("r0".to_string()), dummy_request.clone()),
943+
Err(PeerStateError::TooManyPendingRequests)
944+
));
945+
946+
// Prune all failed orders with max_age=0.
947+
let now = LSPSDateTime::from_str("2024-01-01T01:00:00Z").unwrap();
948+
assert_eq!(
949+
peer_state.prune_terminal_orders(&now, Duration::ZERO),
950+
MAX_PENDING_REQUESTS_PER_PEER
951+
);
952+
953+
// Now registering a new request must succeed.
954+
assert!(peer_state
955+
.register_request(LSPSRequestId("r1".to_string()), dummy_request)
956+
.is_ok());
957+
}
781958
}

lightning-liquidity/src/lsps1/service.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use core::ops::Deref;
1717
use core::pin::pin;
1818
use core::sync::atomic::{AtomicUsize, Ordering};
1919
use core::task;
20+
use core::time::Duration;
2021

2122
use super::event::LSPS1ServiceEvent;
2223
use super::msgs::{
@@ -752,6 +753,52 @@ where
752753
Ok(())
753754
}
754755

756+
/// Prunes terminal orders for a peer that are at least `max_age` old, freeing memory and
757+
/// per-peer quota.
758+
///
759+
/// Terminal orders are those in the [`LSPS1OrderState::Completed`] or
760+
/// [`LSPS1OrderState::Failed`] state. `max_age` is measured from each order's `created_at`
761+
/// timestamp. Pass [`Duration::ZERO`] to prune all terminal orders regardless of age,
762+
/// which is useful to immediately free per-peer quota when a client is blocked by the
763+
/// per-peer request limit due to accumulated failed orders.
764+
///
765+
/// Returns the number of orders removed, or an [`APIError::APIMisuseError`] if no state
766+
/// exists for the given counterparty.
767+
pub async fn prune_orders(
768+
&self, counterparty_node_id: PublicKey, max_age: Duration,
769+
) -> Result<usize, APIError> {
770+
let now =
771+
LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
772+
let pruned;
773+
{
774+
let outer_state_lock = self.per_peer_state.read().unwrap();
775+
let inner_state_lock =
776+
outer_state_lock.get(&counterparty_node_id).ok_or_else(|| {
777+
APIError::APIMisuseError {
778+
err: format!(
779+
"No existing state with counterparty {}",
780+
counterparty_node_id
781+
),
782+
}
783+
})?;
784+
let mut peer_state = inner_state_lock.lock().unwrap();
785+
pruned = peer_state.prune_terminal_orders(&now, max_age);
786+
}
787+
788+
if pruned > 0 {
789+
self.persist_peer_state(counterparty_node_id).await.map_err(|e| {
790+
APIError::APIMisuseError {
791+
err: format!(
792+
"Failed to persist peer state for {}: {}",
793+
counterparty_node_id, e
794+
),
795+
}
796+
})?;
797+
}
798+
799+
Ok(pruned)
800+
}
801+
755802
fn generate_order_id(&self) -> LSPS1OrderId {
756803
let bytes = self.entropy_source.get_secure_random_bytes();
757804
LSPS1OrderId(utils::hex_str(&bytes[0..16]))
@@ -930,6 +977,25 @@ where
930977
},
931978
}
932979
}
980+
981+
/// Prunes terminal orders for a peer that are at least `max_age` old.
982+
///
983+
/// Wraps [`LSPS1ServiceHandler::prune_orders`].
984+
pub fn prune_orders(
985+
&self, counterparty_node_id: PublicKey, max_age: Duration,
986+
) -> Result<usize, APIError> {
987+
let mut fut = pin!(self.inner.prune_orders(counterparty_node_id, max_age));
988+
989+
let mut waker = dummy_waker();
990+
let mut ctx = task::Context::from_waker(&mut waker);
991+
match fut.as_mut().poll(&mut ctx) {
992+
task::Poll::Ready(result) => result,
993+
task::Poll::Pending => {
994+
// In a sync context, we can't wait for the future to complete.
995+
unreachable!("Should not be pending in a sync context");
996+
},
997+
}
998+
}
933999
}
9341000

9351001
fn check_range(min: u64, max: u64, value: u64) -> bool {

0 commit comments

Comments
 (0)