@@ -14,6 +14,11 @@ use std::sync::Arc;
1414
1515use ldk_node:: lightning:: io;
1616use ldk_node:: lightning:: util:: persist:: KVStore ;
17+ use tokio:: task:: JoinSet ;
18+
19+ /// Matches the connection capacity used by the VSS HTTP client. Keeping the
20+ /// limit here also prevents large wallets from spawning one task per record.
21+ const MAX_CONCURRENT_READS : usize = 10 ;
1722
1823/// Object-safe view of a `KVStore` backend. Async methods return boxed futures so the trait
1924/// can be used through `dyn`.
@@ -119,3 +124,172 @@ impl KVStore for LdkNodeStore {
119124 self . 0 . list_async ( p, s)
120125 }
121126}
127+
128+ /// Reads a set of keys concurrently while preserving the input order.
129+ ///
130+ /// Storage formats expose record collections as a list followed by individual
131+ /// reads. For a remote store, performing those reads serially adds one network
132+ /// round trip per record. This helper bounds that fan-out to the VSS connection
133+ /// pool size and retains the same fail-fast I/O behavior as a serial loop.
134+ pub ( crate ) async fn read_keys_bounded (
135+ store : Arc < dyn DynStore > , primary_namespace : & str , secondary_namespace : & str , keys : Vec < String > ,
136+ ) -> Result < Vec < ( String , Vec < u8 > ) > , io:: Error > {
137+ if keys. is_empty ( ) {
138+ return Ok ( Vec :: new ( ) ) ;
139+ }
140+
141+ type ReadResult = ( usize , String , Result < Vec < u8 > , io:: Error > ) ;
142+
143+ let primary_namespace = primary_namespace. to_owned ( ) ;
144+ let secondary_namespace = secondary_namespace. to_owned ( ) ;
145+ let mut pending = keys. into_iter ( ) . enumerate ( ) ;
146+ let mut reads: JoinSet < ReadResult > = JoinSet :: new ( ) ;
147+ let mut results = Vec :: with_capacity ( pending. len ( ) ) ;
148+ results. resize_with ( pending. len ( ) , || None ) ;
149+
150+ let spawn_read = |reads : & mut JoinSet < ReadResult > , index, key : String | {
151+ let store = Arc :: clone ( & store) ;
152+ let primary_namespace = primary_namespace. clone ( ) ;
153+ let secondary_namespace = secondary_namespace. clone ( ) ;
154+ reads. spawn ( async move {
155+ let data = store. read_async ( & primary_namespace, & secondary_namespace, & key) . await ;
156+ ( index, key, data)
157+ } ) ;
158+ } ;
159+
160+ for _ in 0 ..MAX_CONCURRENT_READS {
161+ if let Some ( ( index, key) ) = pending. next ( ) {
162+ spawn_read ( & mut reads, index, key) ;
163+ } else {
164+ break ;
165+ }
166+ }
167+
168+ while let Some ( result) = reads. join_next ( ) . await {
169+ let ( index, key, data) = result. map_err ( |e| {
170+ io:: Error :: new ( io:: ErrorKind :: Other , format ! ( "store read task failed: {e}" ) )
171+ } ) ?;
172+ results[ index] = Some ( ( key, data?) ) ;
173+
174+ if let Some ( ( index, key) ) = pending. next ( ) {
175+ spawn_read ( & mut reads, index, key) ;
176+ }
177+ }
178+
179+ Ok ( results
180+ . into_iter ( )
181+ . map ( |result| result. expect ( "every bounded store read must complete" ) )
182+ . collect ( ) )
183+ }
184+
185+ #[ cfg( test) ]
186+ mod tests {
187+ use super :: * ;
188+ use std:: future:: ready;
189+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
190+ use tokio:: sync:: { Semaphore , mpsc} ;
191+
192+ struct ControlledStore {
193+ entered : mpsc:: UnboundedSender < String > ,
194+ release : Arc < Semaphore > ,
195+ active : Arc < AtomicUsize > ,
196+ max_active : Arc < AtomicUsize > ,
197+ fail_key : Option < String > ,
198+ }
199+
200+ impl KVStore for ControlledStore {
201+ fn read (
202+ & self , _primary_namespace : & str , _secondary_namespace : & str , key : & str ,
203+ ) -> impl Future < Output = Result < Vec < u8 > , io:: Error > > + Send + ' static {
204+ let key = key. to_owned ( ) ;
205+ let entered = self . entered . clone ( ) ;
206+ let release = Arc :: clone ( & self . release ) ;
207+ let active = Arc :: clone ( & self . active ) ;
208+ let max_active = Arc :: clone ( & self . max_active ) ;
209+ let should_fail = self . fail_key . as_ref ( ) == Some ( & key) ;
210+ async move {
211+ let active_count = active. fetch_add ( 1 , Ordering :: SeqCst ) + 1 ;
212+ max_active. fetch_max ( active_count, Ordering :: SeqCst ) ;
213+ let _ = entered. send ( key. clone ( ) ) ;
214+ let _permit =
215+ release. acquire_owned ( ) . await . expect ( "test semaphore must remain open" ) ;
216+ active. fetch_sub ( 1 , Ordering :: SeqCst ) ;
217+
218+ if should_fail {
219+ Err ( io:: Error :: new ( io:: ErrorKind :: InvalidData , "controlled read failure" ) )
220+ } else {
221+ Ok ( key. into_bytes ( ) )
222+ }
223+ }
224+ }
225+
226+ fn write (
227+ & self , _primary_namespace : & str , _secondary_namespace : & str , _key : & str , _buf : Vec < u8 > ,
228+ ) -> impl Future < Output = Result < ( ) , io:: Error > > + Send + ' static {
229+ ready ( Ok ( ( ) ) )
230+ }
231+
232+ fn remove (
233+ & self , _primary_namespace : & str , _secondary_namespace : & str , _key : & str , _lazy : bool ,
234+ ) -> impl Future < Output = Result < ( ) , io:: Error > > + Send + ' static {
235+ ready ( Ok ( ( ) ) )
236+ }
237+
238+ fn list (
239+ & self , _primary_namespace : & str , _secondary_namespace : & str ,
240+ ) -> impl Future < Output = Result < Vec < String > , io:: Error > > + Send + ' static {
241+ ready ( Ok ( Vec :: new ( ) ) )
242+ }
243+ }
244+
245+ #[ tokio:: test]
246+ async fn bounded_reads_limit_concurrency_and_preserve_order ( ) {
247+ let ( entered, mut entered_rx) = mpsc:: unbounded_channel ( ) ;
248+ let release = Arc :: new ( Semaphore :: new ( 0 ) ) ;
249+ let max_active = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
250+ let store: Arc < dyn DynStore > = Arc :: new ( ControlledStore {
251+ entered,
252+ release : Arc :: clone ( & release) ,
253+ active : Arc :: new ( AtomicUsize :: new ( 0 ) ) ,
254+ max_active : Arc :: clone ( & max_active) ,
255+ fail_key : None ,
256+ } ) ;
257+ let keys: Vec < _ > = ( 0 ..12 ) . rev ( ) . map ( |index| format ! ( "key-{index:02}" ) ) . collect ( ) ;
258+
259+ let reads = tokio:: spawn ( read_keys_bounded ( store, "primary" , "secondary" , keys. clone ( ) ) ) ;
260+ for _ in 0 ..MAX_CONCURRENT_READS {
261+ entered_rx. recv ( ) . await . expect ( "bounded read must start" ) ;
262+ }
263+ assert_eq ! ( max_active. load( Ordering :: SeqCst ) , MAX_CONCURRENT_READS ) ;
264+ assert ! ( entered_rx. try_recv( ) . is_err( ) ) ;
265+
266+ release. add_permits ( keys. len ( ) ) ;
267+ let records = reads. await . unwrap ( ) . unwrap ( ) ;
268+ let result_keys: Vec < _ > = records. iter ( ) . map ( |( key, _) | key. clone ( ) ) . collect ( ) ;
269+ assert_eq ! ( result_keys, keys) ;
270+ assert ! ( records. iter( ) . all( |( key, data) | key. as_bytes( ) == data) ) ;
271+ assert_eq ! ( max_active. load( Ordering :: SeqCst ) , MAX_CONCURRENT_READS ) ;
272+ }
273+
274+ #[ tokio:: test]
275+ async fn bounded_reads_propagate_store_errors ( ) {
276+ let ( entered, _entered_rx) = mpsc:: unbounded_channel ( ) ;
277+ let store: Arc < dyn DynStore > = Arc :: new ( ControlledStore {
278+ entered,
279+ release : Arc :: new ( Semaphore :: new ( 2 ) ) ,
280+ active : Arc :: new ( AtomicUsize :: new ( 0 ) ) ,
281+ max_active : Arc :: new ( AtomicUsize :: new ( 0 ) ) ,
282+ fail_key : Some ( "bad" . to_owned ( ) ) ,
283+ } ) ;
284+
285+ let error = read_keys_bounded (
286+ store,
287+ "primary" ,
288+ "secondary" ,
289+ vec ! [ "good" . to_owned( ) , "bad" . to_owned( ) ] ,
290+ )
291+ . await
292+ . unwrap_err ( ) ;
293+ assert_eq ! ( error. kind( ) , io:: ErrorKind :: InvalidData ) ;
294+ }
295+ }
0 commit comments