Skip to content

Commit 941839e

Browse files
authored
Merge pull request #308 from carlaKC/determinism-fixups
multi: Small Determinism Fixups
2 parents 66daf69 + e30aedb commit 941839e

4 files changed

Lines changed: 88 additions & 10 deletions

File tree

sim-cli/src/main.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ async fn main() -> anyhow::Result<()> {
3939
} else {
4040
let latency = cli.latency_ms.unwrap_or(0);
4141
let interceptors = if latency > 0 {
42-
vec![Arc::new(LatencyIntercepor::new_poisson(latency as f32)?) as Arc<dyn Interceptor>]
42+
vec![Arc::new(LatencyIntercepor::new_poisson(
43+
latency as f32,
44+
cli.fix_seed,
45+
)?) as Arc<dyn Interceptor>]
4346
} else {
4447
vec![]
4548
};

simln-lib/src/latency_interceptor.rs

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ use crate::sim_node::{
33
};
44
use crate::SimulationError;
55
use async_trait::async_trait;
6+
use rand::{rngs::StdRng, SeedableRng};
67
use rand_distr::{Distribution, Poisson};
8+
use std::sync::Mutex;
79
use std::time::Duration;
810
use tokio::{select, time};
911

@@ -13,20 +15,34 @@ where
1315
D: Distribution<f32> + Send + Sync,
1416
{
1517
latency_dist: D,
18+
/// Seedable RNG used to sample the latency distribution. Held behind a mutex because the interceptor is shared
19+
/// across concurrent HTLCs, and seeded (rather than `thread_rng`) so that simulation runs are reproducible.
20+
rng: Mutex<StdRng>,
1621
}
1722

1823
impl LatencyIntercepor<Poisson<f32>> {
19-
pub fn new_poisson(lambda_ms: f32) -> Result<Self, SimulationError> {
24+
/// Creates a latency interceptor that samples delays from a Poisson distribution. If `seed` is provided the
25+
/// sampled latencies are reproducible; otherwise the RNG is seeded from entropy.
26+
pub fn new_poisson(lambda_ms: f32, seed: Option<u64>) -> Result<Self, SimulationError> {
2027
let poisson_dist = Poisson::new(lambda_ms).map_err(|e| {
2128
SimulationError::SimulatedNetworkError(format!("Could not create possion: {e}"))
2229
})?;
2330

2431
Ok(Self {
2532
latency_dist: poisson_dist,
33+
rng: Mutex::new(seeded_rng(seed)),
2634
})
2735
}
2836
}
2937

38+
/// Builds an RNG from an optional seed, falling back to entropy when no seed is provided.
39+
fn seeded_rng(seed: Option<u64>) -> StdRng {
40+
match seed {
41+
Some(seed) => StdRng::seed_from_u64(seed),
42+
None => StdRng::from_entropy(),
43+
}
44+
}
45+
3046
#[async_trait]
3147
impl<D> Interceptor for LatencyIntercepor<D>
3248
where
@@ -37,7 +53,13 @@ where
3753
&self,
3854
req: InterceptRequest,
3955
) -> Result<Result<CustomRecords, ForwardingError>, CriticalError> {
40-
let latency = self.latency_dist.sample(&mut rand::thread_rng());
56+
let latency = {
57+
let mut rng = self
58+
.rng
59+
.lock()
60+
.expect("latency interceptor RNG lock poisoned");
61+
self.latency_dist.sample(&mut *rng)
62+
};
4163

4264
select! {
4365
_ = req.shutdown_listener => log::debug!("Latency interceptor exiting due to shutdown signal received."),
@@ -62,7 +84,9 @@ mod tests {
6284
use lightning::ln::PaymentHash;
6385
use ntest::assert_true;
6486
use rand::distributions::Distribution;
65-
use rand::Rng;
87+
use rand::rngs::StdRng;
88+
use rand::{Rng, SeedableRng};
89+
use std::sync::Mutex;
6690
use tokio::time::timeout;
6791
use triggered::Trigger;
6892

@@ -105,7 +129,10 @@ mod tests {
105129
async fn test_shutdown_signal() {
106130
// Set fixed dist to a high value so that the test won't flake.
107131
let latency_dist = ConstantDistribution { value: 1000.0 };
108-
let interceptor = LatencyIntercepor { latency_dist };
132+
let interceptor = LatencyIntercepor {
133+
latency_dist,
134+
rng: Mutex::new(StdRng::seed_from_u64(0)),
135+
};
109136

110137
let (request, trigger) = test_request();
111138
trigger.trigger();
@@ -121,7 +148,10 @@ mod tests {
121148
#[tokio::test]
122149
async fn test_latency_response() {
123150
let latency_dist = ConstantDistribution { value: 0.0 };
124-
let interceptor = LatencyIntercepor { latency_dist };
151+
let interceptor = LatencyIntercepor {
152+
latency_dist,
153+
rng: Mutex::new(StdRng::seed_from_u64(0)),
154+
};
125155

126156
let (request, _) = test_request();
127157
// We should return immediately because timeout is zero.

simln-lib/src/lib.rs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,12 @@ struct ExecutorPaymentTracker {
644644

645645
impl Ord for PaymentEvent {
646646
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
647-
self.execution_time.cmp(&other.execution_time)
647+
// Order primarily by execution time, then break ties on the source node's public key. The heap only ever holds
648+
// at most one event per source at a time, so `(execution_time, source)` is a total order. Without the tie-break,
649+
// events scheduled for the same instant pop in an unspecified order, which makes runs non-reproducible.
650+
self.execution_time
651+
.cmp(&other.execution_time)
652+
.then_with(|| self.source.cmp(&other.source))
648653
}
649654
}
650655

@@ -656,7 +661,7 @@ impl PartialOrd for PaymentEvent {
656661

657662
impl PartialEq for PaymentEvent {
658663
fn eq(&self, other: &Self) -> bool {
659-
self.execution_time == other.execution_time
664+
self.execution_time == other.execution_time && self.source == other.source
660665
}
661666
}
662667

@@ -1190,12 +1195,14 @@ async fn produce_payment_events<C: Clock>(
11901195
_ = pe_clock.sleep(wait_time) => {
11911196
generate_payment(&mut heap, source, &mut payments_tracker, clock.now()).await?;
11921197

1198+
// Stamp the dispatch time from the simulation clock now that the wait has elapsed.
1199+
let dispatch_time = pe_clock.now();
11931200
tasks.spawn(async move {
11941201
log::debug!("Generated payment: {source} -> {}: {amount} msat.", destination);
11951202

11961203
// Send the payment, exiting if we can no longer send to the consumer.
11971204
let event = SimulationEvent::SendPayment(destination.clone(), amount);
1198-
if let Err(e) = send_payment(node, pe_output_sender, event.clone()).await {
1205+
if let Err(e) = send_payment(node, pe_output_sender, event.clone(), dispatch_time).await {
11991206
pe_shutdown.trigger();
12001207
log::debug!("Not able to send event payment for {amount}: {source} -> {}. Exited with error {e}.", destination);
12011208
} else {
@@ -1270,6 +1277,7 @@ async fn send_payment(
12701277
node: Arc<Mutex<dyn LightningNode>>,
12711278
sender: Sender<SimulationOutput>,
12721279
simulation_event: SimulationEvent,
1280+
dispatch_time: SystemTime,
12731281
) -> Result<(), SimulationError> {
12741282
match simulation_event {
12751283
SimulationEvent::SendPayment(dest, amt_msat) => {
@@ -1280,7 +1288,9 @@ async fn send_payment(
12801288
hash: None,
12811289
amount_msat: amt_msat,
12821290
destination: dest.pubkey,
1283-
dispatch_time: SystemTime::now(),
1291+
// Take the dispatch time from the simulation clock rather than the wall clock, so that it advances with
1292+
// virtual time under discrete-event simulation and stays reproducible across runs.
1293+
dispatch_time,
12841294
};
12851295

12861296
let outcome = match node.send_payment(dest.pubkey, amt_msat).await {
@@ -1509,6 +1519,7 @@ async fn produce_simulation_results(
15091519
},
15101520
SimulationOutput::SendPaymentFailure(payment, result) => {
15111521
select! {
1522+
biased;
15121523
_ = listener.clone() => {
15131524
return Ok(());
15141525
},
@@ -1669,6 +1680,39 @@ mod tests {
16691680
assert_eq!(seq1, seq1_again);
16701681
}
16711682

1683+
#[test]
1684+
fn test_payment_event_orders_ties_by_source() {
1685+
use crate::PaymentEvent;
1686+
use std::cmp::Reverse;
1687+
use std::collections::BinaryHeap;
1688+
use std::time::{Duration, SystemTime};
1689+
1690+
let nodes = test_utils::create_nodes(2, 100_000);
1691+
let (mut low, mut high) = (nodes[0].0.clone(), nodes[1].0.clone());
1692+
// Order our two nodes so that `low` has the smaller public key.
1693+
if low.pubkey > high.pubkey {
1694+
std::mem::swap(&mut low, &mut high);
1695+
}
1696+
1697+
let when = SystemTime::UNIX_EPOCH + Duration::from_secs(10);
1698+
let event = |source: &NodeInfo, destination: &NodeInfo| PaymentEvent {
1699+
source: source.pubkey,
1700+
execution_time: when,
1701+
destination: destination.clone(),
1702+
amount: 1_000,
1703+
};
1704+
1705+
// Push the higher-keyed source first to prove that pop order is decided by the tie-break, not by
1706+
// insertion order.
1707+
let mut heap: BinaryHeap<Reverse<PaymentEvent>> = BinaryHeap::new();
1708+
heap.push(Reverse(event(&high, &low)));
1709+
heap.push(Reverse(event(&low, &high)));
1710+
1711+
// Both events share an execution time, so the min-heap must pop the smaller public key first.
1712+
assert_eq!(heap.pop().unwrap().0.source, low.pubkey);
1713+
assert_eq!(heap.pop().unwrap().0.source, high.pubkey);
1714+
}
1715+
16721716
mock! {
16731717
pub Generator {}
16741718

simln-lib/src/sim_node.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,7 @@ async fn handle_intercepted_htlc(
964964
let mut interceptor_failure = None;
965965
'get_resp: loop {
966966
tokio::select! {
967+
biased;
967968
res = intercepts.join_next() => {
968969
let res = match res {
969970
Some(res) => res,

0 commit comments

Comments
 (0)