Skip to content

Commit cb99564

Browse files
jkczyzclaude
andcommitted
f - Make funding-payment classification write atomic with its existence check
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 98b11a5 commit cb99564

4 files changed

Lines changed: 362 additions & 21 deletions

File tree

src/data_store.rs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ pub(crate) enum DataStoreUpdateResult {
4040
NotFound,
4141
}
4242

43+
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
44+
pub(crate) enum DataStoreUpdateOrInsertResult {
45+
Inserted,
46+
Updated,
47+
Unchanged,
48+
}
49+
4350
pub(crate) struct DataStore<SO: StorableObject, L: Deref>
4451
where
4552
L::Target: LdkLogger,
@@ -81,6 +88,11 @@ where
8188
Ok(updated)
8289
}
8390

91+
/// Like [`Self::insert`], but when an entry with the object's id already exists, merges the
92+
/// object's full update ([`StorableObject::to_update`]) into it instead of replacing it.
93+
///
94+
/// Unlike [`Self::update_or_insert`], the caller does not choose what is merged into an
95+
/// existing entry: the full update is always applied.
8496
pub(crate) async fn insert_or_update(&self, object: SO) -> Result<bool, Error> {
8597
let _guard = self.mutation_lock.lock().await;
8698

@@ -170,6 +182,47 @@ where
170182
Ok(DataStoreUpdateResult::Updated)
171183
}
172184

185+
/// Applies `update` when an object with its id already exists, or inserts `object` when none
186+
/// does.
187+
///
188+
/// Like [`Self::update`], but falls back to inserting `object` instead of returning
189+
/// [`DataStoreUpdateResult::NotFound`]. Unlike [`Self::insert_or_update`], the caller chooses
190+
/// exactly what is merged into an existing entry: `update` may carry less than the full
191+
/// object.
192+
///
193+
/// The existence check and the write share one critical section of the mutation lock, so a
194+
/// concurrent writer cannot land in between and later have its state clobbered by the insert
195+
/// fallback — the check-then-act race that separate [`Self::contains_key`] +
196+
/// [`Self::insert_or_update`] calls reintroduce.
197+
pub(crate) async fn update_or_insert(
198+
&self, update: SO::Update, object: SO,
199+
) -> Result<DataStoreUpdateOrInsertResult, Error> {
200+
debug_assert!(update.id() == object.id(), "update and object must share an id");
201+
let _guard = self.mutation_lock.lock().await;
202+
203+
let id = update.id();
204+
let (data_to_persist, result) = {
205+
let locked_objects = self.objects.lock().expect("lock");
206+
match locked_objects.get(&id) {
207+
Some(existing_object) => {
208+
let mut updated_object = existing_object.clone();
209+
if updated_object.update(update) {
210+
(Some(updated_object), DataStoreUpdateOrInsertResult::Updated)
211+
} else {
212+
(None, DataStoreUpdateOrInsertResult::Unchanged)
213+
}
214+
},
215+
None => (Some(object), DataStoreUpdateOrInsertResult::Inserted),
216+
}
217+
};
218+
219+
if let Some(object) = data_to_persist {
220+
self.persist(&object).await?;
221+
self.objects.lock().expect("lock").insert(id, object);
222+
}
223+
Ok(result)
224+
}
225+
173226
/// Returns in-memory objects matching `f`.
174227
///
175228
/// The async mutation lock serializes writers, but this synchronous reader cannot wait on it.
@@ -403,6 +456,102 @@ mod tests {
403456
assert_eq!(Ok(true), data_store.insert_or_update(new_iou_object).await);
404457
}
405458

459+
#[tokio::test]
460+
async fn update_or_insert_inserts_when_absent() {
461+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
462+
let logger = Arc::new(TestLogger::new());
463+
let primary_namespace = "datastore_test_primary".to_string();
464+
let secondary_namespace = "datastore_test_secondary".to_string();
465+
let data_store: DataStore<TestObject, Arc<TestLogger>> = DataStore::new(
466+
Vec::new(),
467+
primary_namespace.clone(),
468+
secondary_namespace.clone(),
469+
Arc::clone(&store),
470+
logger,
471+
);
472+
473+
let id = TestObjectId { id: [42u8; 4] };
474+
let object = TestObject { id, data: [23u8; 3] };
475+
let update = TestObjectUpdate { id, data: [25u8; 3] };
476+
assert_eq!(
477+
Ok(DataStoreUpdateOrInsertResult::Inserted),
478+
data_store.update_or_insert(update, object).await
479+
);
480+
481+
// The insert path stores the fallback object as-is; the update is not applied to it.
482+
assert_eq!(Some(object), data_store.get(&id));
483+
let store_key = id.encode_to_hex_str();
484+
assert!(KVStore::read(&*store, &primary_namespace, &secondary_namespace, &store_key)
485+
.await
486+
.is_ok());
487+
}
488+
489+
#[tokio::test]
490+
async fn update_or_insert_applies_update_when_present() {
491+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
492+
let logger = Arc::new(TestLogger::new());
493+
let id = TestObjectId { id: [42u8; 4] };
494+
let existing_object = TestObject { id, data: [23u8; 3] };
495+
let data_store: DataStore<TestObject, Arc<TestLogger>> = DataStore::new(
496+
vec![existing_object],
497+
"datastore_test_primary".to_string(),
498+
"datastore_test_secondary".to_string(),
499+
store,
500+
logger,
501+
);
502+
503+
// When an entry exists, only the update is applied; the fallback object must not replace
504+
// it.
505+
let update = TestObjectUpdate { id, data: [24u8; 3] };
506+
let object = TestObject { id, data: [99u8; 3] };
507+
assert_eq!(
508+
Ok(DataStoreUpdateOrInsertResult::Updated),
509+
data_store.update_or_insert(update, object).await
510+
);
511+
assert_eq!(data_store.get(&id).unwrap().data, [24u8; 3]);
512+
}
513+
514+
#[tokio::test]
515+
async fn update_or_insert_returns_unchanged_without_persisting() {
516+
let id = TestObjectId { id: [42u8; 4] };
517+
let existing_object = TestObject { id, data: [23u8; 3] };
518+
let data_store = new_failing_data_store(vec![existing_object]);
519+
520+
// A no-op update returns `Unchanged` without attempting to persist (the store fails all
521+
// writes) and without falling back to the object.
522+
let update = TestObjectUpdate { id, data: [23u8; 3] };
523+
let object = TestObject { id, data: [99u8; 3] };
524+
assert_eq!(
525+
Ok(DataStoreUpdateOrInsertResult::Unchanged),
526+
data_store.update_or_insert(update, object).await
527+
);
528+
assert_eq!(Some(existing_object), data_store.get(&id));
529+
}
530+
531+
#[tokio::test]
532+
async fn update_or_insert_does_not_mutate_memory_if_persist_fails() {
533+
let existing_id = TestObjectId { id: [42u8; 4] };
534+
let existing_object = TestObject { id: existing_id, data: [23u8; 3] };
535+
let data_store = new_failing_data_store(vec![existing_object]);
536+
537+
let update = TestObjectUpdate { id: existing_id, data: [24u8; 3] };
538+
let object = TestObject { id: existing_id, data: [24u8; 3] };
539+
assert_eq!(
540+
Err(Error::PersistenceFailed),
541+
data_store.update_or_insert(update, object).await
542+
);
543+
assert_eq!(Some(existing_object), data_store.get(&existing_id));
544+
545+
let new_id = TestObjectId { id: [55u8; 4] };
546+
let new_object = TestObject { id: new_id, data: [34u8; 3] };
547+
let new_update = TestObjectUpdate { id: new_id, data: [34u8; 3] };
548+
assert_eq!(
549+
Err(Error::PersistenceFailed),
550+
data_store.update_or_insert(new_update, new_object).await
551+
);
552+
assert!(data_store.get(&new_id).is_none());
553+
}
554+
406555
#[tokio::test]
407556
async fn insert_or_update_does_not_mutate_memory_if_persist_fails() {
408557
let existing_id = TestObjectId { id: [42u8; 4] };

src/payment/pending_payment_store.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,4 +242,78 @@ mod tests {
242242
"current txid must not remain in its own conflict list"
243243
);
244244
}
245+
246+
#[test]
247+
fn funding_classification_pending_update_preserves_mirrored_confirmation() {
248+
use bitcoin::BlockHash;
249+
250+
use crate::payment::store::PaymentDetailsUpdate;
251+
252+
let txid = test_txid(7);
253+
let payment_id = PaymentId(txid.to_byte_array());
254+
255+
// A pending entry wallet sync has already mirrored a confirmation into (via
256+
// `apply_funding_status_update`) while classification was still writing its two stores.
257+
let confirmed_details = PaymentDetails::new(
258+
payment_id,
259+
PaymentKind::Onchain {
260+
txid,
261+
status: ConfirmationStatus::Confirmed {
262+
block_hash: BlockHash::from_byte_array([8u8; 32]),
263+
height: 100,
264+
timestamp: 1,
265+
},
266+
tx_type: None,
267+
},
268+
Some(2_000_000),
269+
Some(999),
270+
PaymentDirection::Outbound,
271+
PaymentStatus::Pending,
272+
);
273+
let mirrored = PendingPaymentDetails::new(confirmed_details, Vec::new(), Vec::new());
274+
275+
// A fresh classification is always Unconfirmed and carries the candidate history; its
276+
// figures are the active candidate's.
277+
let fresh = pending_onchain_payment(payment_id, txid);
278+
let candidates = vec![FundingTxCandidate {
279+
txid,
280+
amount_msat: fresh.amount_msat,
281+
fee_paid_msat: fresh.fee_paid_msat,
282+
}];
283+
284+
// The old fresh-insert path merged the full fresh record, downgrading the mirrored
285+
// confirmation.
286+
let mut downgraded = mirrored.clone();
287+
let full_update =
288+
PendingPaymentDetails::new(fresh.clone(), Vec::new(), candidates.clone()).to_update();
289+
assert!(downgraded.update(full_update));
290+
assert!(
291+
matches!(
292+
downgraded.details.kind,
293+
PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. }
294+
),
295+
"a full merge of a fresh classification downgrades a mirrored confirmation",
296+
);
297+
298+
// The narrow classification update merges the figures and candidates while preserving the
299+
// confirmation state wallet sync owns.
300+
let mut merged = mirrored.clone();
301+
let narrow_update = PendingPaymentDetailsUpdate {
302+
id: payment_id,
303+
payment_update: Some(PaymentDetailsUpdate::funding_reclassification(fresh)),
304+
conflicting_txids: None,
305+
candidates: candidates.clone(),
306+
};
307+
assert!(merged.update(narrow_update));
308+
assert!(
309+
matches!(
310+
merged.details.kind,
311+
PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. }
312+
),
313+
"a narrow classification update must not downgrade a mirrored confirmation",
314+
);
315+
assert_eq!(merged.candidates, candidates);
316+
assert_eq!(merged.details.amount_msat, Some(1_000));
317+
assert_eq!(merged.details.fee_paid_msat, Some(100));
318+
}
245319
}

src/payment/store.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,6 +1137,111 @@ mod tests {
11371137
assert_eq!(merged.fee_paid_msat, Some(500));
11381138
}
11391139

1140+
#[tokio::test]
1141+
async fn funding_classification_update_or_insert_preserves_advanced_record() {
1142+
use bitcoin::hashes::Hash;
1143+
use lightning::util::test_utils::TestLogger;
1144+
use std::str::FromStr;
1145+
use std::sync::Arc;
1146+
1147+
use crate::data_store::{DataStore, DataStoreUpdateOrInsertResult};
1148+
use crate::io::test_utils::InMemoryStore;
1149+
use crate::types::{DynStore, DynStoreWrapper};
1150+
1151+
let txid = Txid::from_byte_array([7u8; 32]);
1152+
let id = PaymentId(txid.to_byte_array());
1153+
let tx_type = Some(TransactionType::InteractiveFunding {
1154+
channels: vec![Channel {
1155+
counterparty_node_id: PublicKey::from_str(
1156+
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
1157+
)
1158+
.unwrap(),
1159+
channel_id: ChannelId([3u8; 32]),
1160+
}],
1161+
});
1162+
// A funding payment wallet sync has already advanced to Succeeded/Confirmed.
1163+
let advanced = PaymentDetails::new(
1164+
id,
1165+
PaymentKind::Onchain {
1166+
txid,
1167+
status: ConfirmationStatus::Confirmed {
1168+
block_hash: BlockHash::from_byte_array([8u8; 32]),
1169+
height: 100,
1170+
timestamp: 1,
1171+
},
1172+
tx_type: tx_type.clone(),
1173+
},
1174+
Some(2_000_000),
1175+
Some(999),
1176+
PaymentDirection::Outbound,
1177+
PaymentStatus::Succeeded,
1178+
);
1179+
// A fresh funding classification for the same payment is always Pending/Unconfirmed.
1180+
let fresh = PaymentDetails::new(
1181+
id,
1182+
PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type },
1183+
Some(1_000_000),
1184+
Some(500),
1185+
PaymentDirection::Outbound,
1186+
PaymentStatus::Pending,
1187+
);
1188+
1189+
let new_store = |seed: Vec<PaymentDetails>| {
1190+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
1191+
let logger = Arc::new(TestLogger::new());
1192+
DataStore::<PaymentDetails, Arc<TestLogger>>::new(
1193+
seed,
1194+
"payment_test_primary".to_string(),
1195+
"payment_test_secondary".to_string(),
1196+
store,
1197+
logger,
1198+
)
1199+
};
1200+
1201+
// The pre-fix fresh-insert path — a full `insert_or_update` merge landing after a racing
1202+
// wallet sync already advanced the record — downgrades it.
1203+
let store = new_store(vec![advanced.clone()]);
1204+
store.insert_or_update(fresh.clone()).await.unwrap();
1205+
let downgraded = store.get(&id).unwrap();
1206+
assert_eq!(
1207+
downgraded.status,
1208+
PaymentStatus::Pending,
1209+
"a full merge of a fresh classification downgrades an advanced record",
1210+
);
1211+
1212+
// `update_or_insert` applies only the narrow reclassification when a record exists — no
1213+
// matter when it appeared — preserving the confirmation state wallet sync owns while
1214+
// still merging the contribution-derived figures.
1215+
let store = new_store(vec![advanced.clone()]);
1216+
let update = PaymentDetailsUpdate::funding_reclassification(fresh.clone());
1217+
assert_eq!(
1218+
Ok(DataStoreUpdateOrInsertResult::Updated),
1219+
store.update_or_insert(update, fresh.clone()).await
1220+
);
1221+
let merged = store.get(&id).unwrap();
1222+
assert_eq!(merged.status, PaymentStatus::Succeeded);
1223+
assert!(matches!(
1224+
merged.kind,
1225+
PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. }
1226+
));
1227+
assert_eq!(merged.amount_msat, Some(1_000_000));
1228+
assert_eq!(merged.fee_paid_msat, Some(500));
1229+
1230+
// And it inserts the fresh details when no record exists yet.
1231+
let store = new_store(Vec::new());
1232+
let update = PaymentDetailsUpdate::funding_reclassification(fresh.clone());
1233+
assert_eq!(
1234+
Ok(DataStoreUpdateOrInsertResult::Inserted),
1235+
store.update_or_insert(update, fresh).await
1236+
);
1237+
let inserted = store.get(&id).unwrap();
1238+
assert_eq!(inserted.status, PaymentStatus::Pending);
1239+
assert!(matches!(
1240+
inserted.kind,
1241+
PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. }
1242+
));
1243+
}
1244+
11401245
#[derive(Clone, Debug, PartialEq, Eq)]
11411246
struct LegacyBolt11JitKind {
11421247
hash: PaymentHash,

0 commit comments

Comments
 (0)