Skip to content

Commit 0bd6089

Browse files
committed
Poll the directory on a Poisson schedule
1 parent 94aa2aa commit 0bd6089

3 files changed

Lines changed: 130 additions & 37 deletions

File tree

payjoin-cli/src/app/v2/mod.rs

Lines changed: 69 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,16 @@ use crate::db::v2::{ReceiverPersister, SenderPersister, SessionId};
3030
use crate::db::Database;
3131

3232
mod ohttp;
33+
mod schedule;
3334

3435
const W_ID: usize = 12;
3536
const W_ROLE: usize = 25;
3637
const W_DONE: usize = 15;
3738
const W_STATUS: usize = 15;
39+
// Mean gap of the Poisson poll schedule. The directory learns only this rate,
40+
// so it must stay uniform across clients, not a per-user knob.
41+
const POLL_MEAN: std::time::Duration = std::time::Duration::from_secs(5);
42+
const POLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
3843

3944
#[derive(Clone)]
4045
pub(crate) struct App {
@@ -704,27 +709,44 @@ impl App {
704709
sender: Sender<PollingForProposal>,
705710
persister: &SenderPersister,
706711
) -> Result<()> {
707-
let mut session = sender.clone();
708-
// Long poll until we get a response
712+
let session = sender;
713+
let mut schedule = schedule::Poisson::new(POLL_MEAN);
714+
let mut polls = tokio::task::JoinSet::new();
715+
let next = tokio::time::sleep(schedule.next_gap());
716+
tokio::pin!(next);
709717
loop {
710-
let (response, ctx) =
711-
self.post_via_relay(|relay| session.create_poll_request(relay)).await?;
712-
let res = session.process_response(&response.bytes().await?, ctx).save(persister);
713-
match res {
714-
Ok(OptionalTransitionOutcome::Progress(psbt)) => {
715-
println!("Proposal received. Processing...");
716-
self.process_pj_response(psbt)?;
717-
return Ok(());
718-
}
719-
Ok(OptionalTransitionOutcome::Stasis(current_state)) => {
720-
println!("No response yet.");
721-
session = current_state;
722-
continue;
718+
tokio::select! {
719+
Some(joined) = polls.join_next(), if !polls.is_empty() => {
720+
let (body, ctx): (Vec<u8>, _) = match joined {
721+
Ok(Ok(v)) => v,
722+
_ => continue,
723+
};
724+
match session.clone().process_response(&body, ctx).save(persister) {
725+
Ok(OptionalTransitionOutcome::Progress(psbt)) => {
726+
println!("Proposal received. Processing...");
727+
self.process_pj_response(psbt)?;
728+
return Ok(());
729+
}
730+
Ok(OptionalTransitionOutcome::Stasis(_)) => {
731+
println!("No response yet.");
732+
}
733+
Err(re) => {
734+
println!("{re}");
735+
tracing::debug!("{re:?}");
736+
return Err(anyhow!("Response error").context(re));
737+
}
738+
}
723739
}
724-
Err(re) => {
725-
println!("{re}");
726-
tracing::debug!("{re:?}");
727-
return Err(anyhow!("Response error").context(re));
740+
() = &mut next => {
741+
next.as_mut().reset(tokio::time::Instant::now() + schedule.next_gap());
742+
let relay = self.relay_manager.choose_relay()?;
743+
let (req, ctx) = session.create_poll_request(relay.as_str())?;
744+
let app = self.clone();
745+
polls.spawn(async move {
746+
let resp =
747+
tokio::time::timeout(POLL_TIMEOUT, app.post_request(req)).await??;
748+
Ok::<_, anyhow::Error>((resp.bytes().await?.to_vec(), ctx))
749+
});
728750
}
729751
}
730752
}
@@ -735,24 +757,37 @@ impl App {
735757
session: Receiver<Initialized>,
736758
persister: &ReceiverPersister,
737759
) -> Result<Receiver<UncheckedOriginalPayload>> {
738-
let mut session = session;
760+
let mut schedule = schedule::Poisson::new(POLL_MEAN);
761+
let mut polls = tokio::task::JoinSet::new();
762+
let next = tokio::time::sleep(schedule.next_gap());
763+
tokio::pin!(next);
739764
loop {
740-
println!("Polling receive request...");
741-
let (ohttp_response, context) =
742-
self.post_via_relay(|relay| session.create_poll_request(relay)).await?;
743-
let state_transition = session
744-
.process_response(ohttp_response.bytes().await?.to_vec().as_slice(), context)
745-
.save(persister);
746-
match state_transition {
747-
Ok(OptionalTransitionOutcome::Progress(next_state)) => {
748-
println!("Got a request from the sender. Responding with a Payjoin proposal.");
749-
return Ok(next_state);
765+
tokio::select! {
766+
Some(joined) = polls.join_next(), if !polls.is_empty() => {
767+
let (body, ctx): (Vec<u8>, _) = match joined {
768+
Ok(Ok(v)) => v,
769+
_ => continue,
770+
};
771+
match session.clone().process_response(&body, ctx).save(persister) {
772+
Ok(OptionalTransitionOutcome::Progress(next_state)) => {
773+
println!("Got a request from the sender. Responding with a Payjoin proposal.");
774+
return Ok(next_state);
775+
}
776+
Ok(OptionalTransitionOutcome::Stasis(_)) => {}
777+
Err(e) => return Err(e.into()),
778+
}
750779
}
751-
Ok(OptionalTransitionOutcome::Stasis(current_state)) => {
752-
session = current_state;
753-
continue;
780+
() = &mut next => {
781+
next.as_mut().reset(tokio::time::Instant::now() + schedule.next_gap());
782+
let relay = self.relay_manager.choose_relay()?;
783+
let (req, ctx) = session.create_poll_request(relay.as_str())?;
784+
let app = self.clone();
785+
polls.spawn(async move {
786+
let resp =
787+
tokio::time::timeout(POLL_TIMEOUT, app.post_request(req)).await??;
788+
Ok::<_, anyhow::Error>((resp.bytes().await?.to_vec(), ctx))
789+
});
754790
}
755-
Err(e) => return Err(e.into()),
756791
}
757792
}
758793
}

payjoin-cli/src/app/v2/schedule.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
use std::collections::hash_map::RandomState;
2+
use std::hash::{BuildHasher, Hasher};
3+
use std::time::Duration;
4+
5+
/// Inter-poll gaps come from an independent Exp(1/mean) clock, so the
6+
/// interval never depends on the previous response latency.
7+
pub(crate) struct Poisson {
8+
state: u64,
9+
mean: Duration,
10+
}
11+
12+
impl Poisson {
13+
pub(crate) fn new(mean: Duration) -> Self {
14+
// Seed from OS entropy without adding a crate: RandomState's hasher is randomly
15+
// keyed; hashing a fixed value mixes those keys into a u64.
16+
let mut h = RandomState::new().build_hasher();
17+
h.write_u64(0);
18+
Self { state: h.finish(), mean }
19+
}
20+
21+
pub(crate) fn next_gap(&mut self) -> Duration {
22+
// SplitMix64
23+
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
24+
let mut z = self.state;
25+
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
26+
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
27+
z ^= z >> 31;
28+
let u = ((z >> 11) as f64 + 1.0) / ((1u64 << 53) as f64 + 1.0); // uniform in (0,1)
29+
Duration::from_secs_f64(-self.mean.as_secs_f64() * u.ln())
30+
}
31+
}
32+
33+
#[cfg(test)]
34+
mod tests {
35+
use std::time::Duration;
36+
37+
use super::*;
38+
39+
fn from_seed(seed: u64, mean: Duration) -> Poisson { Poisson { state: seed, mean } }
40+
41+
#[test]
42+
fn same_seed_is_deterministic() {
43+
let mut a = from_seed(1, Duration::from_secs(5));
44+
let mut b = from_seed(1, Duration::from_secs(5));
45+
for _ in 0..100 {
46+
assert_eq!(a.next_gap(), b.next_gap());
47+
}
48+
}
49+
50+
#[test]
51+
fn mean_is_near_lambda_inverse() {
52+
let mut s = from_seed(440, Duration::from_secs(5));
53+
let n = 20_000;
54+
let total: f64 = (0..n).map(|_| s.next_gap().as_secs_f64()).sum();
55+
let mean = total / n as f64;
56+
assert!((mean - 5.0).abs() < 0.2, "mean gap {mean} not ~5s");
57+
}
58+
}

payjoin-cli/tests/e2e.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ mod e2e {
421421
async fn respond_with_payjoin(mut cli_receive_resumer: Child) -> Result<()> {
422422
let mut stdout =
423423
cli_receive_resumer.stdout.take().expect("Failed to take stdout of child process");
424-
let timeout = tokio::time::Duration::from_secs(10);
424+
let timeout = tokio::time::Duration::from_secs(45);
425425
let res = tokio::time::timeout(
426426
timeout,
427427
wait_for_stdout_match(&mut stdout, |line| line.contains("Response successful")),
@@ -436,7 +436,7 @@ mod e2e {
436436
async fn check_payjoin_sent(mut cli_send_resumer: Child) -> Result<()> {
437437
let mut stdout =
438438
cli_send_resumer.stdout.take().expect("Failed to take stdout of child process");
439-
let timeout = tokio::time::Duration::from_secs(10);
439+
let timeout = tokio::time::Duration::from_secs(45);
440440
let res = tokio::time::timeout(
441441
timeout,
442442
wait_for_stdout_match(&mut stdout, |line| line.contains("Payjoin sent")),
@@ -466,7 +466,7 @@ mod e2e {
466466
async fn check_resume_completed(mut cli_resumer: Child) -> Result<()> {
467467
let mut stdout =
468468
cli_resumer.stdout.take().expect("Failed to take stdout of child process");
469-
let timeout = tokio::time::Duration::from_secs(10);
469+
let timeout = tokio::time::Duration::from_secs(45);
470470
let res = tokio::time::timeout(
471471
timeout,
472472
wait_for_stdout_match(&mut stdout, |line| {

0 commit comments

Comments
 (0)