Skip to content

Commit a283d74

Browse files
authored
Merge pull request #94 from lightningdevkit/vss-orange-optimizations
Parallelize Cashu cache loading
2 parents 6bcb34c + 659fb95 commit a283d74

3 files changed

Lines changed: 193 additions & 14 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: 13 additions & 8 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;
@@ -180,15 +180,20 @@ impl CashuKvDatabase {
180180
}
181181

182182
async fn load_caches(&self) -> Result<(), DatabaseError> {
183-
// Load mints cache
184-
if let Ok(mints) = self.load_mints_from_store().await {
183+
// These use independent namespaces and can be restored concurrently. This
184+
// lets remote stores pipeline the requests while synchronous stores retain
185+
// their existing behavior.
186+
let (mints, proofs) =
187+
tokio::join!(self.load_mints_from_store(), self.load_proofs_from_store());
188+
189+
if let Ok(mints) = mints {
185190
let mut cache = self.mints_cache.write().unwrap();
186191
*cache = mints;
187192
}
188193

189194
// Proof errors must not be hidden: presenting an empty cache for an unreadable
190195
// proof set could make the wallet report an incorrect balance.
191-
let proofs = self.load_proofs_from_store().await?;
196+
let proofs = proofs?;
192197
let mut cache = self.proofs_cache.write().unwrap();
193198
*cache = proofs;
194199

@@ -641,12 +646,12 @@ impl WalletDatabase<cdk::cdk_database::Error> for CashuKvDatabase {
641646
.await
642647
.map_err(DatabaseError::Io)?;
643648

644-
let mut quotes = Vec::with_capacity(keys.len());
645-
for key in keys {
646-
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)
647651
.await
648652
.map_err(DatabaseError::Io)?;
649-
653+
let mut quotes = Vec::with_capacity(records.len());
654+
for (_, data) in records {
650655
if !data.is_empty() {
651656
let quote: MintQuote = serde_json::from_slice(&data)
652657
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;

0 commit comments

Comments
 (0)