Skip to content

Commit c769c3c

Browse files
committed
Add paginated payment listing API
Replace the full payment listing API with paginated listing so callers can migrate away from fetching every payment at once. Deprecate the filtering helper because it still implies scanning the full in-memory store. AI-assisted-by: OpenAI Codex
1 parent 08efb3a commit c769c3c

9 files changed

Lines changed: 305 additions & 91 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
succeeds, allowing restored wallets to rediscover funds sent to previously-unknown addresses.
2121
- `Config::anchor_channels_config` is no longer optional, hence anchor channels can no longer be
2222
disabled. We still negotiate legacy channels if the peer does not support anchor channels.
23+
- `Node::list_payments` now retrieves payments page-by-page, ordered from most recently created to
24+
least recently created, instead of returning all payments at once.
2325

2426
## Bug Fixes and Improvements
2527
- Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the

bindings/kotlin/ldk-node-jvm/lib/src/test/kotlin/org/lightningdevkit/ldknode/LibraryTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,8 @@ class LibraryTest {
301301
assert(paymentReceivedEvent is Event.PaymentReceived)
302302
node2.eventHandled()
303303

304-
assert(node1.listPayments().size == 3)
305-
assert(node2.listPayments().size == 2)
304+
assert(node1.listPayments(null).payments.size == 3)
305+
assert(node2.listPayments(null).payments.size == 2)
306306

307307
node2.closeChannel(userChannelId, nodeId1)
308308

bindings/ldk_node.udl

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ interface Node {
151151
[Throws=NodeError]
152152
void remove_payment([ByRef]PaymentId payment_id);
153153
BalanceDetails list_balances();
154-
sequence<PaymentDetails> list_payments();
154+
[Throws=NodeError]
155+
PaymentDetailsPage list_payments(PageToken? page_token);
155156
sequence<PeerDetails> list_peers();
156157
sequence<ChannelDetails> list_channels();
157158
NetworkGraph network_graph();
@@ -277,6 +278,11 @@ enum PaymentFailureReason {
277278

278279
typedef dictionary PaymentDetails;
279280

281+
typedef dictionary PaymentDetailsPage;
282+
283+
[Remote]
284+
interface PageToken {};
285+
280286
[Remote]
281287
dictionary RouteParametersConfig {
282288
u64? max_total_routing_fee_msat;

src/data_store.rs

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use std::collections::HashMap;
99
use std::ops::Deref;
1010
use std::sync::{Arc, Mutex};
1111

12-
use lightning::util::persist::KVStore;
12+
use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore};
1313
use lightning::util::ser::{Readable, Writeable};
1414

1515
use crate::logger::{log_error, LdkLogger};
@@ -44,7 +44,7 @@ pub(crate) struct DataStore<SO: StorableObject, L: Deref>
4444
where
4545
L::Target: LdkLogger,
4646
{
47-
objects: Mutex<HashMap<SO::Id, SO>>,
47+
objects: Mutex<HashMap<String, SO>>,
4848
mutation_lock: tokio::sync::Mutex<()>,
4949
primary_namespace: String,
5050
secondary_namespace: String,
@@ -60,8 +60,9 @@ where
6060
objects: Vec<SO>, primary_namespace: String, secondary_namespace: String,
6161
kv_store: Arc<DynStore>, logger: L,
6262
) -> Self {
63-
let objects =
64-
Mutex::new(HashMap::from_iter(objects.into_iter().map(|obj| (obj.id(), obj))));
63+
let objects = Mutex::new(HashMap::from_iter(
64+
objects.into_iter().map(|obj| (obj.id().encode_to_hex_str(), obj)),
65+
));
6566
Self {
6667
objects,
6768
mutation_lock: tokio::sync::Mutex::new(()),
@@ -74,20 +75,22 @@ where
7475

7576
pub(crate) async fn insert(&self, object: SO) -> Result<bool, Error> {
7677
let _guard = self.mutation_lock.lock().await;
78+
let store_key = object.id().encode_to_hex_str();
7779

7880
self.persist(&object).await?;
7981
let mut locked_objects = self.objects.lock().expect("lock");
80-
let updated = locked_objects.insert(object.id(), object).is_some();
82+
let updated = locked_objects.insert(store_key, object).is_some();
8183
Ok(updated)
8284
}
8385

8486
pub(crate) async fn insert_or_update(&self, object: SO) -> Result<bool, Error> {
8587
let _guard = self.mutation_lock.lock().await;
8688

8789
let id = object.id();
90+
let store_key = id.encode_to_hex_str();
8891
let data_to_persist = {
8992
let locked_objects = self.objects.lock().expect("lock");
90-
if let Some(existing_object) = locked_objects.get(&id) {
93+
if let Some(existing_object) = locked_objects.get(&store_key) {
9194
let mut updated_object = existing_object.clone();
9295
let updated = updated_object.update(object.to_update());
9396
if updated {
@@ -104,7 +107,7 @@ where
104107
Some(updated_object) => {
105108
self.persist(&updated_object).await?;
106109
let mut locked_objects = self.objects.lock().expect("lock");
107-
locked_objects.insert(id, updated_object);
110+
locked_objects.insert(store_key, updated_object);
108111
Ok(true)
109112
},
110113
None => Ok(false),
@@ -113,9 +116,9 @@ where
113116

114117
pub(crate) async fn remove(&self, id: &SO::Id) -> Result<(), Error> {
115118
let _guard = self.mutation_lock.lock().await;
116-
let should_remove = { self.objects.lock().expect("lock").contains_key(id) };
119+
let store_key = id.encode_to_hex_str();
120+
let should_remove = { self.objects.lock().expect("lock").contains_key(&store_key) };
117121
if should_remove {
118-
let store_key = id.encode_to_hex_str();
119122
KVStore::remove(
120123
&*self.kv_store,
121124
&self.primary_namespace,
@@ -135,7 +138,7 @@ where
135138
);
136139
Error::PersistenceFailed
137140
})?;
138-
self.objects.lock().expect("lock").remove(id);
141+
self.objects.lock().expect("lock").remove(&store_key);
139142
}
140143
Ok(())
141144
}
@@ -146,15 +149,16 @@ where
146149
/// Until store reads are async, callers may temporarily see in-memory state that has not yet
147150
/// caught up to a write in progress.
148151
pub(crate) fn get(&self, id: &SO::Id) -> Option<SO> {
149-
self.objects.lock().expect("lock").get(id).cloned()
152+
self.objects.lock().expect("lock").get(&id.encode_to_hex_str()).cloned()
150153
}
151154

152155
pub(crate) async fn update(&self, update: SO::Update) -> Result<DataStoreUpdateResult, Error> {
153156
let _guard = self.mutation_lock.lock().await;
154157
let id = update.id();
158+
let store_key = id.encode_to_hex_str();
155159
let updated_object = {
156160
let locked_objects = self.objects.lock().expect("lock");
157-
let Some(object) = locked_objects.get(&id) else {
161+
let Some(object) = locked_objects.get(&store_key) else {
158162
return Ok(DataStoreUpdateResult::NotFound);
159163
};
160164
let mut updated_object = object.clone();
@@ -166,7 +170,7 @@ where
166170

167171
self.persist(&updated_object).await?;
168172
let mut locked_objects = self.objects.lock().expect("lock");
169-
locked_objects.insert(id, updated_object);
173+
locked_objects.insert(store_key, updated_object);
170174
Ok(DataStoreUpdateResult::Updated)
171175
}
172176

@@ -179,6 +183,40 @@ where
179183
self.objects.lock().expect("lock").values().filter(f).cloned().collect::<Vec<SO>>()
180184
}
181185

186+
/// Returns a page of objects, ordered from most recently created to least recently created,
187+
/// together with a token that can be passed to a subsequent call to retrieve the next page.
188+
///
189+
/// The underlying store is only queried for the page's key order, which the in-memory map
190+
/// doesn't track; the objects themselves are served from memory.
191+
pub(crate) async fn list_page(
192+
&self, page_token: Option<PageToken>,
193+
) -> Result<(Vec<SO>, Option<PageToken>), Error> {
194+
let _guard = self.mutation_lock.lock().await;
195+
let response = PaginatedKVStore::list_paginated(
196+
&*self.kv_store,
197+
&self.primary_namespace,
198+
&self.secondary_namespace,
199+
page_token,
200+
)
201+
.await
202+
.map_err(|e| {
203+
log_error!(
204+
self.logger,
205+
"Listing object data under {}/{} failed due to: {}",
206+
&self.primary_namespace,
207+
&self.secondary_namespace,
208+
e
209+
);
210+
Error::PersistenceFailed
211+
})?;
212+
213+
let locked_objects = self.objects.lock().expect("lock");
214+
let objects =
215+
response.keys.iter().filter_map(|key| locked_objects.get(key).cloned()).collect();
216+
217+
Ok((objects, response.next_page_token))
218+
}
219+
182220
async fn persist(&self, object: &SO) -> Result<(), Error> {
183221
let (store_key, data) = Self::encode_object(object);
184222
self.persist_encoded(store_key, data).await
@@ -217,7 +255,7 @@ where
217255
/// Until store reads are async, callers may temporarily see in-memory state that has not yet
218256
/// caught up to a write in progress.
219257
pub(crate) fn contains_key(&self, id: &SO::Id) -> bool {
220-
self.objects.lock().expect("lock").contains_key(id)
258+
self.objects.lock().expect("lock").contains_key(&id.encode_to_hex_str())
221259
}
222260
}
223261

@@ -337,6 +375,74 @@ mod tests {
337375
)
338376
}
339377

378+
#[tokio::test]
379+
async fn list_page_paginates_in_reverse_creation_order() {
380+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
381+
let logger = Arc::new(TestLogger::new());
382+
let data_store: DataStore<TestObject, Arc<TestLogger>> = DataStore::new(
383+
Vec::new(),
384+
"datastore_test_primary".to_string(),
385+
"datastore_test_secondary".to_string(),
386+
Arc::clone(&store),
387+
logger,
388+
);
389+
390+
// Insert more objects than fit in a single page to exercise the pagination loop.
391+
let num_objects = 120u32;
392+
for i in 0..num_objects {
393+
let id = TestObjectId { id: i.to_be_bytes() };
394+
data_store.insert(TestObject { id, data: [7u8; 3] }).await.unwrap();
395+
}
396+
397+
let mut listed = Vec::with_capacity(num_objects as usize);
398+
let mut page_token = None;
399+
loop {
400+
let (page, next_page_token) = data_store.list_page(page_token).await.unwrap();
401+
assert!(!page.is_empty());
402+
listed.extend(page);
403+
page_token = next_page_token;
404+
if page_token.is_none() {
405+
break;
406+
}
407+
}
408+
409+
let expected: Vec<TestObject> = (0..num_objects)
410+
.rev()
411+
.map(|i| TestObject { id: TestObjectId { id: i.to_be_bytes() }, data: [7u8; 3] })
412+
.collect();
413+
assert_eq!(listed, expected);
414+
}
415+
416+
#[tokio::test]
417+
async fn list_page_waits_for_mutations() {
418+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
419+
let logger = Arc::new(TestLogger::new());
420+
let data_store: Arc<DataStore<TestObject, Arc<TestLogger>>> = Arc::new(DataStore::new(
421+
Vec::new(),
422+
"datastore_test_primary".to_string(),
423+
"datastore_test_secondary".to_string(),
424+
store,
425+
logger,
426+
));
427+
428+
let mutation_guard = data_store.mutation_lock.lock().await;
429+
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
430+
let task_store = Arc::clone(&data_store);
431+
let list_task = tokio::spawn(async move {
432+
started_tx.send(()).unwrap();
433+
task_store.list_page(None).await
434+
});
435+
436+
started_rx.await.unwrap();
437+
tokio::task::yield_now().await;
438+
assert!(!list_task.is_finished());
439+
440+
drop(mutation_guard);
441+
let (page, next_page_token) = list_task.await.unwrap().unwrap();
442+
assert!(page.is_empty());
443+
assert!(next_page_token.is_none());
444+
}
445+
340446
#[tokio::test]
341447
async fn data_is_persisted() {
342448
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));

src/lib.rs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ use lightning::ln::peer_handler::CustomMessageHandler;
158158
use lightning::routing::gossip::NodeAlias;
159159
use lightning::sign::EntropySource;
160160
use lightning::util::persist::KVStore;
161+
pub use lightning::util::persist::PageToken;
161162
use lightning::util::wallet_utils::{Input, Wallet as LdkWallet};
162163
use lightning_background_processor::process_events_async;
163164
pub use lightning_invoice;
@@ -2207,15 +2208,44 @@ impl Node {
22072208
/// # let node = builder.build(node_entropy.into()).unwrap();
22082209
/// node.list_payments_with_filter(|p| p.direction == PaymentDirection::Outbound);
22092210
/// ```
2211+
#[deprecated(
2212+
note = "Use the paginated list_payments API and filter the returned pages instead."
2213+
)]
22102214
pub fn list_payments_with_filter<F: FnMut(&&PaymentDetails) -> bool>(
22112215
&self, f: F,
22122216
) -> Vec<PaymentDetails> {
22132217
self.payment_store.list_filter(f)
22142218
}
22152219

2216-
/// Retrieves all payments.
2217-
pub fn list_payments(&self) -> Vec<PaymentDetails> {
2218-
self.payment_store.list_filter(|_| true)
2220+
/// Retrieves a page of payments from the underlying paginated store, ordered from most
2221+
/// recently created to least recently created.
2222+
///
2223+
/// Pass `None` to start listing from the most recently created payment. If the returned
2224+
/// [`PaymentDetailsPage::next_page_token`] is `Some`, pass it to a subsequent call to
2225+
/// retrieve the next page.
2226+
#[cfg(not(feature = "uniffi"))]
2227+
pub fn list_payments(
2228+
&self, page_token: Option<PageToken>,
2229+
) -> Result<PaymentDetailsPage, Error> {
2230+
let (payments, next_page_token) =
2231+
self.runtime.block_on(self.payment_store.list_page(page_token))?;
2232+
Ok(PaymentDetailsPage { payments, next_page_token })
2233+
}
2234+
2235+
/// Retrieves a page of payments from the underlying paginated store, ordered from most
2236+
/// recently created to least recently created.
2237+
///
2238+
/// Pass `None` to start listing from the most recently created payment. If the returned
2239+
/// [`PaymentDetailsPage::next_page_token`] is `Some`, pass it to a subsequent call to
2240+
/// retrieve the next page.
2241+
#[cfg(feature = "uniffi")]
2242+
pub fn list_payments(
2243+
&self, page_token: Option<Arc<PageToken>>,
2244+
) -> Result<PaymentDetailsPage, Error> {
2245+
let page_token = page_token.map(|t| (*t).clone());
2246+
let (payments, next_page_token) =
2247+
self.runtime.block_on(self.payment_store.list_page(page_token))?;
2248+
Ok(PaymentDetailsPage { payments, next_page_token: next_page_token.map(Arc::new) })
22192249
}
22202250

22212251
/// Retrieves a list of known peers.
@@ -2333,6 +2363,20 @@ impl Drop for Node {
23332363
}
23342364
}
23352365

2366+
/// A page of payments returned from a paginated listing.
2367+
#[derive(Clone, Debug, PartialEq, Eq)]
2368+
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
2369+
pub struct PaymentDetailsPage {
2370+
/// Payments in this page, ordered from most recently created to least recently created.
2371+
pub payments: Vec<PaymentDetails>,
2372+
/// Token to pass to the next call to continue listing, if another page exists.
2373+
#[cfg(not(feature = "uniffi"))]
2374+
pub next_page_token: Option<PageToken>,
2375+
/// Token to pass to the next call to continue listing, if another page exists.
2376+
#[cfg(feature = "uniffi")]
2377+
pub next_page_token: Option<Arc<PageToken>>,
2378+
}
2379+
23362380
/// The best known block as identified by its hash and height.
23372381
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
23382382
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]

0 commit comments

Comments
 (0)