Skip to content

Commit db0d215

Browse files
jkczyzclaude
andcommitted
Keep funding payment records consistent across sync and classification
Funding broadcasts are classified into payment records off the broadcaster's queue, which runs concurrently with wallet sync -- and can run after sync has already recorded the transaction, for instance when the counterparty's broadcast of a shared funding transaction is observed first. The two writers raced: a late classification overwrote confirmation state that wallet sync had already advanced, sync could observe a half-written classification and record a duplicate generic payment, and graduation could roll back figures a concurrent classification had just written. Make each writer's decision and writes atomic against the others: - Classification merges only the transaction type and our contribution figures into an existing record, leaving the confirmation state that wallet-sync events own in place. Once a record is confirmed, its txid and figures describe the candidate that actually confirmed and are kept on a late classification, except when the update names the confirmed txid itself. - Wallet sync resolves a transaction to its funding record before deciding how to record it -- including transactions known only as earlier RBF candidates -- and serializes with classification's two-store write pair from that resolution through its final write, so neither writer observes the other's torn state. - Graduation decides from the live record and updates only the payment status, so a stale snapshot cannot roll back concurrently written figures. - A missing pending-store entry is recreated while the payment is still Pending, repairing the index after a crash or failed write between the two stores. Raised by Codex in the review of #888 and hardened through subsequent review rounds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent abbed3e commit db0d215

4 files changed

Lines changed: 1470 additions & 48 deletions

File tree

src/data_store.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ where
8181
Ok(updated)
8282
}
8383

84+
/// Like [`Self::insert`], but when an entry with the object's id already exists, merges the
85+
/// object's full update ([`StorableObject::to_update`]) into it instead of replacing it.
8486
pub(crate) async fn insert_or_update(&self, object: SO) -> Result<bool, Error> {
8587
let _guard = self.mutation_lock.lock().await;
8688

@@ -170,6 +172,36 @@ where
170172
Ok(DataStoreUpdateResult::Updated)
171173
}
172174

175+
/// Atomically transforms the entry for `id` through `f` and persists the result.
176+
///
177+
/// `f` receives the current entry (`None` when absent) and returns the new state to write;
178+
/// returning `None` leaves the store untouched. The read, the closure, and the write share
179+
/// one critical section of the mutation lock, so no concurrent writer can land in between —
180+
/// unlike a separate [`Self::get`] followed by an insert or update.
181+
///
182+
/// The closure runs on a clone of the entry with the in-memory map lock released, so it may
183+
/// freely read this store or others (reads see the pre-mutation state) without ordering map
184+
/// locks against each other. Keep it cheap and non-blocking.
185+
///
186+
/// Returns the written object, or `None` when the closure declined to write.
187+
pub(crate) async fn mutate<F: FnOnce(Option<&SO>) -> Option<SO>>(
188+
&self, id: &SO::Id, f: F,
189+
) -> Result<Option<SO>, Error> {
190+
let _guard = self.mutation_lock.lock().await;
191+
192+
let current = self.objects.lock().expect("lock").get(id).cloned();
193+
let new_object = match f(current.as_ref()) {
194+
Some(new_object) => new_object,
195+
None => return Ok(None),
196+
};
197+
debug_assert!(new_object.id() == *id, "mutate closure must not change the object's id");
198+
199+
self.persist(&new_object).await?;
200+
let mut locked_objects = self.objects.lock().expect("lock");
201+
locked_objects.insert(new_object.id(), new_object.clone());
202+
Ok(Some(new_object))
203+
}
204+
173205
/// Returns in-memory objects matching `f`.
174206
///
175207
/// The async mutation lock serializes writers, but this synchronous reader cannot wait on it.
@@ -403,6 +435,131 @@ mod tests {
403435
assert_eq!(Ok(true), data_store.insert_or_update(new_iou_object).await);
404436
}
405437

438+
#[tokio::test]
439+
async fn mutate_inserts_when_absent() {
440+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
441+
let logger = Arc::new(TestLogger::new());
442+
let primary_namespace = "datastore_test_primary".to_string();
443+
let secondary_namespace = "datastore_test_secondary".to_string();
444+
let data_store: DataStore<TestObject, Arc<TestLogger>> = DataStore::new(
445+
Vec::new(),
446+
primary_namespace.clone(),
447+
secondary_namespace.clone(),
448+
Arc::clone(&store),
449+
logger,
450+
);
451+
452+
let id = TestObjectId { id: [42u8; 4] };
453+
let object = TestObject { id, data: [23u8; 3] };
454+
let result = data_store
455+
.mutate(&id, |existing| {
456+
assert!(existing.is_none());
457+
Some(object)
458+
})
459+
.await;
460+
assert_eq!(Ok(Some(object)), result);
461+
462+
assert_eq!(Some(object), data_store.get(&id));
463+
let store_key = id.encode_to_hex_str();
464+
assert!(KVStore::read(&*store, &primary_namespace, &secondary_namespace, &store_key)
465+
.await
466+
.is_ok());
467+
}
468+
469+
#[tokio::test]
470+
async fn mutate_transforms_existing_entry() {
471+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
472+
let logger = Arc::new(TestLogger::new());
473+
let id = TestObjectId { id: [42u8; 4] };
474+
let existing_object = TestObject { id, data: [23u8; 3] };
475+
let data_store: DataStore<TestObject, Arc<TestLogger>> = DataStore::new(
476+
vec![existing_object],
477+
"datastore_test_primary".to_string(),
478+
"datastore_test_secondary".to_string(),
479+
store,
480+
logger,
481+
);
482+
483+
// The closure sees the current entry and derives the new state from it.
484+
let result = data_store
485+
.mutate(&id, |existing| {
486+
let mut new_object = *existing.unwrap();
487+
new_object.data[0] += 1;
488+
Some(new_object)
489+
})
490+
.await;
491+
let expected = TestObject { id, data: [24u8, 23u8, 23u8] };
492+
assert_eq!(Ok(Some(expected)), result);
493+
assert_eq!(Some(expected), data_store.get(&id));
494+
}
495+
496+
#[tokio::test]
497+
async fn mutate_runs_the_closure_without_the_map_lock() {
498+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
499+
let logger = Arc::new(TestLogger::new());
500+
let id = TestObjectId { id: [42u8; 4] };
501+
let existing_object = TestObject { id, data: [23u8; 3] };
502+
let data_store: DataStore<TestObject, Arc<TestLogger>> = DataStore::new(
503+
vec![existing_object],
504+
"datastore_test_primary".to_string(),
505+
"datastore_test_secondary".to_string(),
506+
store,
507+
logger,
508+
);
509+
510+
// Closures gate cross-store decisions on reads of other stores, which lock their own
511+
// in-memory maps. Holding this store's map lock across the closure would order it
512+
// before theirs and invite lock-order inversions, so the closure must run with the map
513+
// lock released.
514+
let result = data_store
515+
.mutate(&id, |existing| {
516+
assert_eq!(Some(&existing_object), existing);
517+
assert!(data_store.objects.try_lock().is_ok());
518+
None
519+
})
520+
.await;
521+
assert_eq!(Ok(None), result);
522+
}
523+
524+
#[tokio::test]
525+
async fn mutate_persists_nothing_when_closure_declines() {
526+
let id = TestObjectId { id: [42u8; 4] };
527+
let existing_object = TestObject { id, data: [23u8; 3] };
528+
let data_store = new_failing_data_store(vec![existing_object]);
529+
530+
// Returning `None` must not attempt a write (the store fails all writes) nor touch memory.
531+
let result = data_store
532+
.mutate(&id, |existing| {
533+
assert_eq!(Some(&existing_object), existing);
534+
None
535+
})
536+
.await;
537+
assert_eq!(Ok(None), result);
538+
assert_eq!(Some(existing_object), data_store.get(&id));
539+
}
540+
541+
#[tokio::test]
542+
async fn mutate_does_not_mutate_memory_if_persist_fails() {
543+
let existing_id = TestObjectId { id: [42u8; 4] };
544+
let existing_object = TestObject { id: existing_id, data: [23u8; 3] };
545+
let data_store = new_failing_data_store(vec![existing_object]);
546+
547+
let changed = TestObject { id: existing_id, data: [24u8; 3] };
548+
assert_eq!(
549+
Err(Error::PersistenceFailed),
550+
data_store.mutate(&existing_id, |_| Some(changed)).await
551+
);
552+
assert_eq!(Some(existing_object), data_store.get(&existing_id));
553+
554+
let new_id = TestObjectId { id: [55u8; 4] };
555+
let new_object = TestObject { id: new_id, data: [34u8; 3] };
556+
assert_eq!(
557+
Err(Error::PersistenceFailed),
558+
data_store.mutate(&new_id, |_| Some(new_object)).await
559+
);
560+
assert!(data_store.get(&new_id).is_none());
561+
}
562+
406563
#[tokio::test]
407564
async fn insert_or_update_does_not_mutate_memory_if_persist_fails() {
408565
let existing_id = TestObjectId { id: [42u8; 4] };

src/payment/pending_payment_store.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,4 +242,79 @@ 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_locked`) before classification ran.
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 candidates while preserving the
299+
// confirmation state wallet sync owns. It names the confirmed txid, so its
300+
// contribution-derived figures replace the mirrored wallet-view ones.
301+
let mut merged = mirrored.clone();
302+
let narrow_update = PendingPaymentDetailsUpdate {
303+
id: payment_id,
304+
payment_update: Some(PaymentDetailsUpdate::funding_reclassification(fresh)),
305+
conflicting_txids: None,
306+
candidates: candidates.clone(),
307+
};
308+
assert!(merged.update(narrow_update));
309+
assert!(
310+
matches!(
311+
merged.details.kind,
312+
PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. }
313+
),
314+
"a narrow classification update must not downgrade a mirrored confirmation",
315+
);
316+
assert_eq!(merged.candidates, candidates);
317+
assert_eq!(merged.details.amount_msat, Some(1_000));
318+
assert_eq!(merged.details.fee_paid_msat, Some(100));
319+
}
245320
}

0 commit comments

Comments
 (0)