Skip to content

Commit 6cf748a

Browse files
jkczyzclaude
andcommitted
f - Bound and deduplicate pending classification retries
LDK re-broadcasts pending claims every 30 seconds (and sweeps once per block) until they confirm, so while the payment store is unavailable, the list of pending retries accumulated a copy per rebroadcast — memory, retry load on the struggling store, and a duplicate broadcast burst on recovery all growing with the outage's duration. A package whose transactions already await a retry is not queued again, and the rest are bounded: at the bound, the oldest waiting non-funding package is dropped to make room — its transactions return with LDK's next periodic rebroadcast — but never a funding package, whose transaction would be left confirming without a recorded candidate. Fee-bumped rebroadcast variants carry new txids, so the bound, not the dedup, is what limits their accumulation. Implemented with Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3a8eb12 commit 6cf748a

2 files changed

Lines changed: 280 additions & 20 deletions

File tree

src/chain/mod.rs

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ use crate::config::{BackgroundSyncConfig, Config, WALLET_SYNC_INTERVAL_MINIMUM_S
3737
use crate::fee_estimator::OnchainFeeEstimator;
3838
use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger};
3939
use crate::runtime::Runtime;
40-
use crate::tx_broadcaster::BroadcastPackage;
40+
use crate::tx_broadcaster::{BroadcastPackage, RetryQueue, ScheduleOutcome};
4141
use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet};
4242
use crate::{Error, PersistedNodeMetrics};
4343

@@ -610,33 +610,49 @@ impl ChainSource {
610610
// Packages whose classification failed, each waiting out FAILED_CLASSIFY_RETRY_DELAY
611611
// before its next attempt. New packages keep flowing while these wait, and pending
612612
// retries die with the loop on shutdown rather than resurfacing after a later start.
613-
let mut parked: Vec<(tokio::time::Instant, BroadcastPackage)> = Vec::new();
613+
let mut retries = RetryQueue::new();
614614
loop {
615-
let tx_bcast_logger = Arc::clone(&self.logger);
616-
// Entries are appended with a fixed delay, so the first is always the next due.
617-
let next_retry_at = parked.first().map(|(deadline, _)| *deadline);
618-
tokio::select! {
615+
let next_retry_at = retries.next_retry_at();
616+
let package = tokio::select! {
619617
_ = stop_tx_bcast_receiver.changed() => {
620618
log_debug!(
621-
tx_bcast_logger,
619+
self.logger,
622620
"Stopping broadcasting transactions.",
623621
);
624622
return;
625623
}
626-
Some(next_package) = receiver.recv() => {
627-
if let Err(package) = self.classify_and_broadcast(next_package).await {
628-
let retry_at = tokio::time::Instant::now() + FAILED_CLASSIFY_RETRY_DELAY;
629-
parked.push((retry_at, package));
630-
}
631-
}
624+
Some(next_package) = receiver.recv() => next_package,
632625
_ = tokio::time::sleep_until(
633626
next_retry_at.unwrap_or_else(tokio::time::Instant::now)
634627
), if next_retry_at.is_some() => {
635-
let (_, package) = parked.remove(0);
636-
if let Err(package) = self.classify_and_broadcast(package).await {
637-
let retry_at = tokio::time::Instant::now() + FAILED_CLASSIFY_RETRY_DELAY;
638-
parked.push((retry_at, package));
639-
}
628+
retries.pop_next().expect("a retry is queued")
629+
}
630+
};
631+
if let Err(package) = self.classify_and_broadcast(package).await {
632+
let retry_at = tokio::time::Instant::now() + FAILED_CLASSIFY_RETRY_DELAY;
633+
match retries.schedule(package, retry_at) {
634+
ScheduleOutcome::Scheduled { dropped: None } => {},
635+
ScheduleOutcome::Scheduled { dropped: Some(dropped) } => {
636+
log_error!(
637+
self.logger,
638+
"Dropped the oldest package awaiting a classification retry; LDK re-broadcasts its transactions periodically: {:?}",
639+
dropped.sorted_txids(),
640+
);
641+
},
642+
ScheduleOutcome::AlreadyQueued(duplicate) => {
643+
log_debug!(
644+
self.logger,
645+
"Dropped a re-broadcast package; an identical one already awaits a classification retry: {:?}",
646+
duplicate.sorted_txids(),
647+
);
648+
},
649+
ScheduleOutcome::Refused(package) => {
650+
log_error!(
651+
self.logger,
652+
"Dropped a package failing classification; too many await retries: {:?}",
653+
package.sorted_txids(),
654+
);
655+
},
640656
}
641657
}
642658
}

src/tx_broadcaster.rs

Lines changed: 246 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,30 @@
55
// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
66
// accordance with one or both of these licenses.
77

8+
use std::collections::VecDeque;
89
use std::ops::Deref;
910
use std::sync::{Mutex as StdMutex, Weak};
1011

11-
use bitcoin::Transaction;
12+
use bitcoin::{Transaction, Txid};
1213
use lightning::chain::chaininterface::{
1314
BroadcasterInterface, TransactionType as LdkTransactionType,
1415
};
1516
use tokio::sync::{mpsc, Mutex, MutexGuard};
17+
use tokio::time::Instant;
1618

1719
use crate::logger::{log_error, LdkLogger};
1820
use crate::types::Wallet;
1921
use crate::Error;
2022

2123
const BCAST_PACKAGE_QUEUE_SIZE: usize = 256;
2224

25+
/// The most non-funding packages [`RetryQueue`] holds. Claims and sweeps re-enter the
26+
/// broadcast queue on LDK's periodic rebroadcast timers, so one dropped here resurfaces on its
27+
/// own once the store recovers. Funding packages don't count against the bound: nothing
28+
/// re-broadcasts them for us, and they are finite — one per negotiated candidate, since a copy
29+
/// of a waiting package is never queued twice.
30+
const MAX_QUEUED_RETRIES: usize = BCAST_PACKAGE_QUEUE_SIZE;
31+
2332
/// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions`
2433
/// call, along with each transaction's type. Queued until the background task classifies and
2534
/// broadcasts it. Built only via [`BroadcastPackage::new`] from such a call, so unrelated
@@ -47,6 +56,100 @@ impl BroadcastPackage {
4756
let txs = self.0.into_iter().map(|(tx, _)| tx).collect();
4857
SortedTransactions::sort_parents_child_package_topologically(txs)
4958
}
59+
60+
/// The packaged transactions' txids in sorted order, identifying the package's effect on
61+
/// chain: two packages with the same txids broadcast the same transactions.
62+
pub(crate) fn sorted_txids(&self) -> Vec<Txid> {
63+
let mut txids: Vec<Txid> = self.0.iter().map(|(tx, _)| tx.compute_txid()).collect();
64+
txids.sort_unstable();
65+
txids
66+
}
67+
68+
/// Whether the package contains a funding transaction (a channel open or splice), whose
69+
/// classification writes the payment record tracking the funding.
70+
fn contains_funding(&self) -> bool {
71+
self.0.iter().any(|(_, tx_type)| {
72+
matches!(
73+
tx_type,
74+
Some(
75+
LdkTransactionType::Funding { .. }
76+
| LdkTransactionType::InteractiveFunding { .. }
77+
)
78+
)
79+
})
80+
}
81+
}
82+
83+
/// What [`RetryQueue::schedule`] did with a package, so the caller can log the cases in which
84+
/// the package won't be retried as-is.
85+
pub(crate) enum ScheduleOutcome {
86+
/// The package waits for its retry deadline. When the bound was reached, the oldest waiting
87+
/// non-funding package was dropped to make room and is returned — its transactions resurface
88+
/// with LDK's next periodic rebroadcast.
89+
Scheduled { dropped: Option<BroadcastPackage> },
90+
/// A package broadcasting the same transactions already waits, and its retry covers this
91+
/// one: the incoming package is dropped and returned.
92+
AlreadyQueued(BroadcastPackage),
93+
/// The bound was reached and every waiting package is a funding package, which must not be
94+
/// dropped: the incoming package is refused and returned.
95+
Refused(BroadcastPackage),
96+
}
97+
98+
/// Packages whose classification failed, each waiting out a retry delay before its next attempt.
99+
/// Deduplicated and bounded: LDK re-broadcasts pending claims every 30 seconds (and sweeps once
100+
/// per block) until they confirm, so while the store is unavailable, copies would otherwise
101+
/// accumulate without bound and replay as a burst on recovery. An identical copy is never queued
102+
/// twice — the waiting entry and its deadline stand; fee-bumped rebroadcast variants carry new
103+
/// txids, so the bound — not the dedup — is what limits their accumulation.
104+
pub(crate) struct RetryQueue(VecDeque<(Instant, Vec<Txid>, BroadcastPackage)>);
105+
106+
impl RetryQueue {
107+
pub(crate) fn new() -> Self {
108+
Self(VecDeque::new())
109+
}
110+
111+
/// The deadline of the next retry, if a package is waiting. Packages are scheduled with a fixed
112+
/// delay, so the front entry is always the next to retry.
113+
pub(crate) fn next_retry_at(&self) -> Option<Instant> {
114+
self.0.front().map(|(deadline, _, _)| *deadline)
115+
}
116+
117+
/// Removes and returns the package scheduled to retry first.
118+
pub(crate) fn pop_next(&mut self) -> Option<BroadcastPackage> {
119+
self.0.pop_front().map(|(_, _, package)| package)
120+
}
121+
122+
/// Schedules a package to retry at `retry_at`, unless a package with the same transactions already
123+
/// waits or accepting it would exceed [`MAX_QUEUED_RETRIES`] with no non-funding package to
124+
/// drop for it; see [`ScheduleOutcome`].
125+
pub(crate) fn schedule(
126+
&mut self, package: BroadcastPackage, retry_at: Instant,
127+
) -> ScheduleOutcome {
128+
let txids = package.sorted_txids();
129+
if self.0.iter().any(|(_, waiting, _)| *waiting == txids) {
130+
// Same transactions, same classification outcome: keep the waiting entry and its
131+
// earlier deadline. The one same-txid package with a *different* type is LDK's
132+
// re-typed generic-funding rebroadcast of a promoted 0conf splice, which always
133+
// arrives after the interactive-funding original (the zero-conf rebroadcast canary
134+
// tests assert that ordering), so the entry kept is the richer of the two — and its
135+
// classification declines the downgrade anyway.
136+
return ScheduleOutcome::AlreadyQueued(package);
137+
}
138+
139+
let mut dropped = None;
140+
if !package.contains_funding() && self.0.len() >= MAX_QUEUED_RETRIES {
141+
// Drop the oldest non-funding package: LDK re-broadcasts its transactions
142+
// periodically, while the incoming package may carry a fresher fee-bumped variant.
143+
// A funding package is never dropped — nothing would re-broadcast it, and losing it
144+
// leaves its transaction confirming without a recorded candidate.
145+
match self.0.iter().position(|(_, _, waiting)| !waiting.contains_funding()) {
146+
Some(oldest) => dropped = self.0.remove(oldest).map(|(_, _, package)| package),
147+
None => return ScheduleOutcome::Refused(package),
148+
}
149+
}
150+
self.0.push_back((retry_at, txids, package));
151+
ScheduleOutcome::Scheduled { dropped }
152+
}
50153
}
51154

52155
pub(crate) struct SortedTransactions(Vec<Transaction>);
@@ -171,7 +274,10 @@ mod tests {
171274
use bitcoin::hashes::Hash;
172275
use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness};
173276

174-
use super::SortedTransactions;
277+
use super::{
278+
BroadcastPackage, LdkTransactionType, RetryQueue, ScheduleOutcome, SortedTransactions,
279+
MAX_QUEUED_RETRIES,
280+
};
175281

176282
fn txin(txid: Txid, vout: u32) -> TxIn {
177283
TxIn {
@@ -312,4 +418,142 @@ mod tests {
312418
fn topological_sort_accepts_empty_vec() {
313419
SortedTransactions::sort_parents_child_package_topologically(Vec::new());
314420
}
421+
422+
fn funding_package(tx: &Transaction) -> BroadcastPackage {
423+
BroadcastPackage::new(&[(tx, LdkTransactionType::Funding { channels: vec![] })])
424+
}
425+
426+
fn deadline(secs: u64) -> tokio::time::Instant {
427+
tokio::time::Instant::now() + std::time::Duration::from_secs(secs)
428+
}
429+
430+
/// A re-broadcast of the same transactions is not queued again: the waiting entry keeps its
431+
/// earlier deadline and its package — the first arrival carries the richer classification
432+
/// when LDK later re-types a rebroadcast.
433+
#[tokio::test]
434+
async fn retry_queue_queues_identical_transactions_once() {
435+
let tx = parent_tx(1);
436+
let mut retries = RetryQueue::new();
437+
438+
let first_deadline = deadline(2);
439+
assert!(matches!(
440+
retries.schedule(funding_package(&tx), first_deadline),
441+
ScheduleOutcome::Scheduled { dropped: None }
442+
));
443+
assert!(matches!(
444+
retries.schedule(BroadcastPackage::unclassified(tx.clone()), deadline(4)),
445+
ScheduleOutcome::AlreadyQueued(_)
446+
));
447+
448+
assert_eq!(retries.next_retry_at(), Some(first_deadline));
449+
let kept = retries.pop_next().expect("the first package is kept");
450+
assert!(
451+
matches!(kept.transactions()[0].1, Some(LdkTransactionType::Funding { .. })),
452+
"the first-scheduled package must be kept"
453+
);
454+
assert!(retries.pop_next().is_none());
455+
}
456+
457+
#[tokio::test]
458+
async fn retry_queue_retries_in_schedule_order() {
459+
let (tx_a, tx_b) = (parent_tx(1), parent_tx(2));
460+
let mut retries = RetryQueue::new();
461+
462+
assert!(matches!(
463+
retries.schedule(BroadcastPackage::unclassified(tx_a.clone()), deadline(2)),
464+
ScheduleOutcome::Scheduled { dropped: None }
465+
));
466+
assert!(matches!(
467+
retries.schedule(BroadcastPackage::unclassified(tx_b.clone()), deadline(2)),
468+
ScheduleOutcome::Scheduled { dropped: None }
469+
));
470+
471+
let popped = retries.pop_next().expect("first package");
472+
assert_eq!(popped.sorted_txids(), vec![tx_a.compute_txid()]);
473+
let popped = retries.pop_next().expect("second package");
474+
assert_eq!(popped.sorted_txids(), vec![tx_b.compute_txid()]);
475+
}
476+
477+
/// Distinct transactions (e.g. fee-bumped claim variants during a store outage) are held to
478+
/// the bound: the oldest non-funding package is dropped for an incoming one, never a funding
479+
/// package.
480+
#[tokio::test]
481+
async fn retry_queue_drops_the_oldest_non_funding_package_at_the_bound() {
482+
fn numbered_tx(n: u32) -> Transaction {
483+
Transaction {
484+
version: bitcoin::transaction::Version::TWO,
485+
lock_time: bitcoin::absolute::LockTime::ZERO,
486+
input: vec![txin(Txid::from_byte_array([7u8; 32]), n)],
487+
output: vec![txout(1_000)],
488+
}
489+
}
490+
491+
let mut retries = RetryQueue::new();
492+
let funding_tx = numbered_tx(0);
493+
assert!(matches!(
494+
retries.schedule(funding_package(&funding_tx), deadline(2)),
495+
ScheduleOutcome::Scheduled { dropped: None }
496+
));
497+
let oldest_claim = numbered_tx(1);
498+
for n in 1..(MAX_QUEUED_RETRIES as u32) {
499+
assert!(matches!(
500+
retries.schedule(BroadcastPackage::unclassified(numbered_tx(n)), deadline(2)),
501+
ScheduleOutcome::Scheduled { dropped: None }
502+
));
503+
}
504+
505+
// At the bound, an incoming non-funding package drops the oldest waiting one — not the
506+
// older funding package.
507+
let new_claim = numbered_tx(MAX_QUEUED_RETRIES as u32);
508+
match retries.schedule(BroadcastPackage::unclassified(new_claim.clone()), deadline(2)) {
509+
ScheduleOutcome::Scheduled { dropped: Some(dropped) } => {
510+
assert_eq!(dropped.sorted_txids(), vec![oldest_claim.compute_txid()]);
511+
},
512+
_ => panic!("the incoming claim must be scheduled by dropping the oldest one"),
513+
}
514+
515+
// An incoming funding package is never dropped for the bound.
516+
let new_funding_tx = numbered_tx(MAX_QUEUED_RETRIES as u32 + 1);
517+
assert!(matches!(
518+
retries.schedule(funding_package(&new_funding_tx), deadline(2)),
519+
ScheduleOutcome::Scheduled { dropped: None }
520+
));
521+
522+
let mut remaining = Vec::new();
523+
while let Some(package) = retries.pop_next() {
524+
remaining.extend(package.sorted_txids());
525+
}
526+
assert!(remaining.contains(&funding_tx.compute_txid()), "funding is never dropped");
527+
assert!(remaining.contains(&new_claim.compute_txid()));
528+
assert!(!remaining.contains(&oldest_claim.compute_txid()));
529+
}
530+
531+
/// When only funding packages wait at the bound, an incoming non-funding package is refused:
532+
/// LDK re-broadcasts claims and sweeps periodically, while a dropped funding package would
533+
/// leave its transaction confirming without a recorded candidate.
534+
#[tokio::test]
535+
async fn retry_queue_refuses_a_non_funding_package_over_waiting_funding_packages() {
536+
fn numbered_tx(n: u32) -> Transaction {
537+
Transaction {
538+
version: bitcoin::transaction::Version::TWO,
539+
lock_time: bitcoin::absolute::LockTime::ZERO,
540+
input: vec![txin(Txid::from_byte_array([8u8; 32]), n)],
541+
output: vec![txout(1_000)],
542+
}
543+
}
544+
545+
let mut retries = RetryQueue::new();
546+
for n in 0..(MAX_QUEUED_RETRIES as u32) {
547+
assert!(matches!(
548+
retries.schedule(funding_package(&numbered_tx(n)), deadline(2)),
549+
ScheduleOutcome::Scheduled { dropped: None }
550+
));
551+
}
552+
553+
let claim = numbered_tx(MAX_QUEUED_RETRIES as u32);
554+
assert!(matches!(
555+
retries.schedule(BroadcastPackage::unclassified(claim), deadline(2)),
556+
ScheduleOutcome::Refused(_)
557+
));
558+
}
315559
}

0 commit comments

Comments
 (0)