Skip to content

Commit 607aa48

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 8219d71 commit 607aa48

9 files changed

Lines changed: 265 additions & 78 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: 80 additions & 1 deletion
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};
@@ -179,6 +179,47 @@ where
179179
self.objects.lock().expect("lock").values().filter(f).cloned().collect::<Vec<SO>>()
180180
}
181181

182+
/// Returns a page of objects, ordered from most recently created to least recently created,
183+
/// together with a token that can be passed to a subsequent call to retrieve the next page.
184+
///
185+
/// The underlying store is only queried for the page's key order, which the in-memory map
186+
/// doesn't track; the objects themselves are served from memory.
187+
pub(crate) async fn list_page(
188+
&self, page_token: Option<PageToken>,
189+
) -> Result<(Vec<SO>, Option<PageToken>), Error> {
190+
let response = PaginatedKVStore::list_paginated(
191+
&*self.kv_store,
192+
&self.primary_namespace,
193+
&self.secondary_namespace,
194+
page_token,
195+
)
196+
.await
197+
.map_err(|e| {
198+
log_error!(
199+
self.logger,
200+
"Listing object data under {}/{} failed due to: {}",
201+
&self.primary_namespace,
202+
&self.secondary_namespace,
203+
e
204+
);
205+
Error::PersistenceFailed
206+
})?;
207+
208+
let locked_objects = self.objects.lock().expect("lock");
209+
let objects_by_store_key: HashMap<String, &SO> =
210+
locked_objects.values().map(|obj| (obj.id().encode_to_hex_str(), obj)).collect();
211+
212+
// Objects removed between listing the page's keys and this lookup are skipped rather
213+
// than failing the page.
214+
let objects = response
215+
.keys
216+
.iter()
217+
.filter_map(|key| objects_by_store_key.get(key).map(|obj| (*obj).clone()))
218+
.collect();
219+
220+
Ok((objects, response.next_page_token))
221+
}
222+
182223
async fn persist(&self, object: &SO) -> Result<(), Error> {
183224
let (store_key, data) = Self::encode_object(object);
184225
self.persist_encoded(store_key, data).await
@@ -337,6 +378,44 @@ mod tests {
337378
)
338379
}
339380

381+
#[tokio::test]
382+
async fn list_page_paginates_in_reverse_creation_order() {
383+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
384+
let logger = Arc::new(TestLogger::new());
385+
let data_store: DataStore<TestObject, Arc<TestLogger>> = DataStore::new(
386+
Vec::new(),
387+
"datastore_test_primary".to_string(),
388+
"datastore_test_secondary".to_string(),
389+
Arc::clone(&store),
390+
logger,
391+
);
392+
393+
// Insert more objects than fit in a single page to exercise the pagination loop.
394+
let num_objects = 120u32;
395+
for i in 0..num_objects {
396+
let id = TestObjectId { id: i.to_be_bytes() };
397+
data_store.insert(TestObject { id, data: [7u8; 3] }).await.unwrap();
398+
}
399+
400+
let mut listed = Vec::with_capacity(num_objects as usize);
401+
let mut page_token = None;
402+
loop {
403+
let (page, next_page_token) = data_store.list_page(page_token).await.unwrap();
404+
assert!(!page.is_empty());
405+
listed.extend(page);
406+
page_token = next_page_token;
407+
if page_token.is_none() {
408+
break;
409+
}
410+
}
411+
412+
let expected: Vec<TestObject> = (0..num_objects)
413+
.rev()
414+
.map(|i| TestObject { id: TestObjectId { id: i.to_be_bytes() }, data: [7u8; 3] })
415+
.collect();
416+
assert_eq!(listed, expected);
417+
}
418+
340419
#[tokio::test]
341420
async fn data_is_persisted() {
342421
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;
@@ -2199,15 +2200,44 @@ impl Node {
21992200
/// # let node = builder.build(node_entropy.into()).unwrap();
22002201
/// node.list_payments_with_filter(|p| p.direction == PaymentDirection::Outbound);
22012202
/// ```
2203+
#[deprecated(
2204+
note = "Use the paginated list_payments API and filter the returned pages instead."
2205+
)]
22022206
pub fn list_payments_with_filter<F: FnMut(&&PaymentDetails) -> bool>(
22032207
&self, f: F,
22042208
) -> Vec<PaymentDetails> {
22052209
self.payment_store.list_filter(f)
22062210
}
22072211

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

22132243
/// Retrieves a list of known peers.
@@ -2325,6 +2355,20 @@ impl Drop for Node {
23252355
}
23262356
}
23272357

2358+
/// A page of payments returned from a paginated listing.
2359+
#[derive(Clone, Debug, PartialEq, Eq)]
2360+
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
2361+
pub struct PaymentDetailsPage {
2362+
/// Payments in this page, ordered from most recently created to least recently created.
2363+
pub payments: Vec<PaymentDetails>,
2364+
/// Token to pass to the next call to continue listing, if another page exists.
2365+
#[cfg(not(feature = "uniffi"))]
2366+
pub next_page_token: Option<PageToken>,
2367+
/// Token to pass to the next call to continue listing, if another page exists.
2368+
#[cfg(feature = "uniffi")]
2369+
pub next_page_token: Option<Arc<PageToken>>,
2370+
}
2371+
23282372
/// The best known block as identified by its hash and height.
23292373
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
23302374
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]

0 commit comments

Comments
 (0)