Skip to content

Commit 659fb95

Browse files
committed
Parallelize Orange store record reads
Bound list-backed reads to the VSS connection capacity so populated wallets do not add one remote round trip per transaction metadata or Cashu quote record. Preserve record order and existing storage formats.
1 parent 89dede2 commit 659fb95

3 files changed

Lines changed: 185 additions & 11 deletions

File tree

orange-sdk/src/dyn_store.rs

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ use std::sync::Arc;
1414

1515
use ldk_node::lightning::io;
1616
use 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+
}

orange-sdk/src/store.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
1414
use bitcoin_payment_instructions::amount::Amount;
1515

16-
use crate::dyn_store::DynStore;
16+
use crate::dyn_store::{DynStore, read_keys_bounded};
1717
use ldk_node::bitcoin::Txid;
1818
use ldk_node::bitcoin::hex::{DisplayHex, FromHex};
1919
use ldk_node::lightning::io;
@@ -458,11 +458,11 @@ impl TxMetadataStore {
458458
.await
459459
.expect("We do not allow reads to fail");
460460
let mut tx_metadata = HashMap::with_capacity(keys.len());
461-
for key in keys {
462-
let data_bytes =
463-
KVStore::read(store.as_ref(), STORE_PRIMARY_KEY, STORE_SECONDARY_KEY, &key)
464-
.await
465-
.expect("We do not allow reads to fail");
461+
let records =
462+
read_keys_bounded(Arc::clone(&store), STORE_PRIMARY_KEY, STORE_SECONDARY_KEY, keys)
463+
.await
464+
.expect("We do not allow reads to fail");
465+
for (key, data_bytes) in records {
466466
let key =
467467
PaymentId::from_str(&key).expect("Invalid key in transaction metadata storage");
468468
let data = Readable::read(&mut &data_bytes[..])

orange-sdk/src/trusted_wallet/cashu/cashu_store.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::fmt::Debug;
33
use std::str::FromStr;
44
use std::sync::{Arc, RwLock};
55

6-
use crate::dyn_store::DynStore;
6+
use crate::dyn_store::{DynStore, read_keys_bounded};
77
use async_trait::async_trait;
88
use cdk::cdk_database::WalletDatabase;
99
use cdk::wallet::types::WalletSaga;
@@ -646,12 +646,12 @@ impl WalletDatabase<cdk::cdk_database::Error> for CashuKvDatabase {
646646
.await
647647
.map_err(DatabaseError::Io)?;
648648

649-
let mut quotes = Vec::with_capacity(keys.len());
650-
for key in keys {
651-
let data = KVStore::read(self.store.as_ref(), CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, &key)
649+
let records =
650+
read_keys_bounded(Arc::clone(&self.store), CASHU_PRIMARY_KEY, MINT_QUOTES_KEY, keys)
652651
.await
653652
.map_err(DatabaseError::Io)?;
654-
653+
let mut quotes = Vec::with_capacity(records.len());
654+
for (_, data) in records {
655655
if !data.is_empty() {
656656
let quote: MintQuote = serde_json::from_slice(&data)
657657
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;

0 commit comments

Comments
 (0)