Skip to content

Commit da0d838

Browse files
jkczyzclaude
andcommitted
f - Read the pending-index gate's status inside the pending store's lock
The check that only Pending payments enter the pending index read the payment store before taking the pending store's lock. Graduation could write Succeeded and remove the index entry between the check and the write, and the stale check would then re-create an entry for the graduated payment. The next chain tip re-graduates it from the entry's stale embedded copy — or, if that copy was still unconfirmed, keeps rebroadcasting an already-confirmed transaction on every tip. Move the decision into the pending store's critical section via a new DataStore::mutate that reads, transforms, and persists an entry under one hold of the mutation lock. Re-reading the payment's status there is race-free because graduation writes Succeeded before removing the entry: a read that still observes Pending precedes the removal, which then also deletes anything inserted here. The closure runs with the in-memory map lock released, so it may read other stores without ordering the stores' map locks against each other. The race spans a few instructions between two store writes and no seam exists to schedule a graduation inside it, so no test exercises it; the new unit tests cover the mutate primitive itself. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6168bb0 commit da0d838

2 files changed

Lines changed: 195 additions & 21 deletions

File tree

src/data_store.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,36 @@ where
223223
Ok(result)
224224
}
225225

226+
/// Atomically transforms the entry for `id` through `f` and persists the result.
227+
///
228+
/// `f` receives the current entry (`None` when absent) and returns the new state to write;
229+
/// returning `None` leaves the store untouched. The read, the closure, and the write share
230+
/// one critical section of the mutation lock, so no concurrent writer can land in between —
231+
/// unlike a separate [`Self::get`] followed by an insert or update.
232+
///
233+
/// The closure runs on a clone of the entry with the in-memory map lock released, so it may
234+
/// freely read this store or others (reads see the pre-mutation state) without ordering map
235+
/// locks against each other. Keep it cheap and non-blocking.
236+
///
237+
/// Returns the written object, or `None` when the closure declined to write.
238+
pub(crate) async fn mutate<F: FnOnce(Option<&SO>) -> Option<SO>>(
239+
&self, id: &SO::Id, f: F,
240+
) -> Result<Option<SO>, Error> {
241+
let _guard = self.mutation_lock.lock().await;
242+
243+
let current = self.objects.lock().expect("lock").get(id).cloned();
244+
let new_object = match f(current.as_ref()) {
245+
Some(new_object) => new_object,
246+
None => return Ok(None),
247+
};
248+
debug_assert!(new_object.id() == *id, "mutate closure must not change the object's id");
249+
250+
self.persist(&new_object).await?;
251+
let mut locked_objects = self.objects.lock().expect("lock");
252+
locked_objects.insert(new_object.id(), new_object.clone());
253+
Ok(Some(new_object))
254+
}
255+
226256
/// Returns in-memory objects matching `f`.
227257
///
228258
/// The async mutation lock serializes writers, but this synchronous reader cannot wait on it.
@@ -552,6 +582,131 @@ mod tests {
552582
assert!(data_store.get(&new_id).is_none());
553583
}
554584

585+
#[tokio::test]
586+
async fn mutate_inserts_when_absent() {
587+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
588+
let logger = Arc::new(TestLogger::new());
589+
let primary_namespace = "datastore_test_primary".to_string();
590+
let secondary_namespace = "datastore_test_secondary".to_string();
591+
let data_store: DataStore<TestObject, Arc<TestLogger>> = DataStore::new(
592+
Vec::new(),
593+
primary_namespace.clone(),
594+
secondary_namespace.clone(),
595+
Arc::clone(&store),
596+
logger,
597+
);
598+
599+
let id = TestObjectId { id: [42u8; 4] };
600+
let object = TestObject { id, data: [23u8; 3] };
601+
let result = data_store
602+
.mutate(&id, |existing| {
603+
assert!(existing.is_none());
604+
Some(object)
605+
})
606+
.await;
607+
assert_eq!(Ok(Some(object)), result);
608+
609+
assert_eq!(Some(object), data_store.get(&id));
610+
let store_key = id.encode_to_hex_str();
611+
assert!(KVStore::read(&*store, &primary_namespace, &secondary_namespace, &store_key)
612+
.await
613+
.is_ok());
614+
}
615+
616+
#[tokio::test]
617+
async fn mutate_transforms_existing_entry() {
618+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
619+
let logger = Arc::new(TestLogger::new());
620+
let id = TestObjectId { id: [42u8; 4] };
621+
let existing_object = TestObject { id, data: [23u8; 3] };
622+
let data_store: DataStore<TestObject, Arc<TestLogger>> = DataStore::new(
623+
vec![existing_object],
624+
"datastore_test_primary".to_string(),
625+
"datastore_test_secondary".to_string(),
626+
store,
627+
logger,
628+
);
629+
630+
// The closure sees the current entry and derives the new state from it.
631+
let result = data_store
632+
.mutate(&id, |existing| {
633+
let mut new_object = *existing.unwrap();
634+
new_object.data[0] += 1;
635+
Some(new_object)
636+
})
637+
.await;
638+
let expected = TestObject { id, data: [24u8, 23u8, 23u8] };
639+
assert_eq!(Ok(Some(expected)), result);
640+
assert_eq!(Some(expected), data_store.get(&id));
641+
}
642+
643+
#[tokio::test]
644+
async fn mutate_runs_the_closure_without_the_map_lock() {
645+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
646+
let logger = Arc::new(TestLogger::new());
647+
let id = TestObjectId { id: [42u8; 4] };
648+
let existing_object = TestObject { id, data: [23u8; 3] };
649+
let data_store: DataStore<TestObject, Arc<TestLogger>> = DataStore::new(
650+
vec![existing_object],
651+
"datastore_test_primary".to_string(),
652+
"datastore_test_secondary".to_string(),
653+
store,
654+
logger,
655+
);
656+
657+
// Closures gate cross-store decisions on reads of other stores, which lock their own
658+
// in-memory maps. Holding this store's map lock across the closure would order it
659+
// before theirs and invite lock-order inversions, so the closure must run with the map
660+
// lock released.
661+
let result = data_store
662+
.mutate(&id, |existing| {
663+
assert_eq!(Some(&existing_object), existing);
664+
assert!(data_store.objects.try_lock().is_ok());
665+
None
666+
})
667+
.await;
668+
assert_eq!(Ok(None), result);
669+
}
670+
671+
#[tokio::test]
672+
async fn mutate_persists_nothing_when_closure_declines() {
673+
let id = TestObjectId { id: [42u8; 4] };
674+
let existing_object = TestObject { id, data: [23u8; 3] };
675+
let data_store = new_failing_data_store(vec![existing_object]);
676+
677+
// Returning `None` must not attempt a write (the store fails all writes) nor touch memory.
678+
let result = data_store
679+
.mutate(&id, |existing| {
680+
assert_eq!(Some(&existing_object), existing);
681+
None
682+
})
683+
.await;
684+
assert_eq!(Ok(None), result);
685+
assert_eq!(Some(existing_object), data_store.get(&id));
686+
}
687+
688+
#[tokio::test]
689+
async fn mutate_does_not_mutate_memory_if_persist_fails() {
690+
let existing_id = TestObjectId { id: [42u8; 4] };
691+
let existing_object = TestObject { id: existing_id, data: [23u8; 3] };
692+
let data_store = new_failing_data_store(vec![existing_object]);
693+
694+
let changed = TestObject { id: existing_id, data: [24u8; 3] };
695+
assert_eq!(
696+
Err(Error::PersistenceFailed),
697+
data_store.mutate(&existing_id, |_| Some(changed)).await
698+
);
699+
assert_eq!(Some(existing_object), data_store.get(&existing_id));
700+
701+
let new_id = TestObjectId { id: [55u8; 4] };
702+
let new_object = TestObject { id: new_id, data: [34u8; 3] };
703+
assert_eq!(
704+
Err(Error::PersistenceFailed),
705+
data_store.mutate(&new_id, |_| Some(new_object)).await
706+
);
707+
assert!(data_store.get(&new_id).is_none());
708+
}
709+
555710
#[tokio::test]
556711
async fn insert_or_update_does_not_mutate_memory_if_persist_fails() {
557712
let existing_id = TestObjectId { id: [42u8; 4] };

src/wallet/mod.rs

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ use lightning_invoice::RawBolt11Invoice;
5454
use persist::KVStoreWalletPersister;
5555

5656
use crate::config::Config;
57+
use crate::data_store::StorableObject;
5758
use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator};
5859
use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger};
5960
use crate::payment::pending_payment_store::PendingPaymentDetailsUpdate;
@@ -1412,34 +1413,52 @@ impl Wallet {
14121413
&candidates,
14131414
self.payment_store.get(&details.id).as_ref(),
14141415
);
1415-
let pending_update = PendingPaymentDetailsUpdate {
1416-
id: update.id,
1417-
payment_update: Some(update.clone()),
1418-
conflicting_txids: None,
1419-
candidates: candidates.clone(),
1420-
};
1421-
self.payment_store.update_or_insert(update, details.clone()).await?;
1416+
let id = update.id;
1417+
self.payment_store.update_or_insert(update.clone(), details.clone()).await?;
14221418

14231419
// The pending index must exist exactly while the authoritative record is Pending:
14241420
// graduation and rebroadcast read it, and a graduated payment must not be re-indexed.
14251421
// Deciding by the post-write status rather than by whether the write inserted also
14261422
// repairs a missing index — a crash or failed write between the two stores leaves a
14271423
// Pending record with no entry, and a merge alone would never recreate it, leaving the
14281424
// payment unable to graduate and its txids unmapped.
1429-
let recorded = self.payment_store.get(&details.id).unwrap_or(details);
1430-
if recorded.status == PaymentStatus::Pending {
1431-
// Wallet sync can still land between the payment-store write above and this one and
1432-
// mirror an advanced confirmation into the pending store, so this write makes the
1433-
// same atomic decision: merge narrowly into an entry that appeared, insert otherwise.
1434-
// The inserted entry embeds the post-write record rather than the fresh details, so a
1435-
// confirmation wallet sync already recorded keeps driving graduation.
1436-
let pending = PendingPaymentDetails::new(recorded, Vec::new(), candidates);
1437-
self.pending_payment_store.update_or_insert(pending_update, pending).await?;
1438-
} else {
1439-
// The payment already advanced beyond Pending: the graduation path removed the
1440-
// entry, and `update`'s no-op on absence must not re-create it.
1441-
self.pending_payment_store.update(pending_update).await?;
1442-
}
1425+
//
1426+
// The status must be read inside the pending store's critical section. Graduation writes
1427+
// `Succeeded` before removing the entry, so a read there that still observes `Pending`
1428+
// is ordered before the removal, which then also deletes anything inserted here. A
1429+
// status read taken before this write goes stale when graduation lands in between, and
1430+
// would re-index the graduated payment.
1431+
self.pending_payment_store
1432+
.mutate(&id, |existing| {
1433+
// The record was written above and payment records are never removed, so absence
1434+
// means the write failed out; fall back to the fresh details.
1435+
let recorded = self.payment_store.get(&id).unwrap_or(details);
1436+
match existing {
1437+
// The inserted entry embeds the post-write record rather than the fresh
1438+
// details, so a confirmation wallet sync already recorded keeps driving
1439+
// graduation.
1440+
None if recorded.status == PaymentStatus::Pending => {
1441+
Some(PendingPaymentDetails::new(recorded, Vec::new(), candidates))
1442+
},
1443+
// The payment already advanced beyond Pending: the graduation path removed
1444+
// the entry and it must not be re-created.
1445+
None => None,
1446+
// Wallet sync can land between the payment-store write above and this one
1447+
// and mirror an advanced confirmation into the pending store: merge only the
1448+
// classification into the entry that appeared.
1449+
Some(entry) => {
1450+
let pending_update = PendingPaymentDetailsUpdate {
1451+
id,
1452+
payment_update: Some(update),
1453+
conflicting_txids: None,
1454+
candidates,
1455+
};
1456+
let mut updated = entry.clone();
1457+
updated.update(pending_update).then_some(updated)
1458+
},
1459+
}
1460+
})
1461+
.await?;
14431462
Ok(())
14441463
}
14451464

0 commit comments

Comments
 (0)