Skip to content

Commit ed6f751

Browse files
committed
Move DedupError beside the Deduplicator that produces it
It sat in the trait module fetcher.rs but is produced and returned only by the deduplicator; keeping it with its producer leaves fetcher.rs a pure trait. Also state the panic-safety contract symmetrically (Fetcher and Input impls must not panic) and assert the cancelled join in the abort test.
1 parent 0c89cd9 commit ed6f751

4 files changed

Lines changed: 101 additions & 22 deletions

File tree

examples/deduplicator.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
use std::convert::Infallible;
2+
use std::sync::Arc;
3+
use std::sync::atomic::{AtomicUsize, Ordering};
4+
5+
use carpool::{Deduplicator, Fetcher};
6+
7+
// Counts fetches so dedup is an observable fact, not just equal results.
8+
#[derive(Clone)]
9+
struct CountingFetch {
10+
calls: Arc<AtomicUsize>,
11+
}
12+
13+
impl Fetcher for CountingFetch {
14+
type Input = u64;
15+
type Output = u64;
16+
type Error = Infallible;
17+
18+
async fn load(&self, input: u64) -> Result<u64, Infallible> {
19+
self.calls.fetch_add(1, Ordering::SeqCst);
20+
Ok(input * input)
21+
}
22+
}
23+
24+
#[tokio::main(flavor = "current_thread")]
25+
async fn main() {
26+
let calls = Arc::new(AtomicUsize::new(0));
27+
let d = Deduplicator::new(CountingFetch {
28+
calls: calls.clone(),
29+
});
30+
31+
// Concurrent calls for one input in a single task, so they all subscribe
32+
// before the leader's fetch runs and collapse to one downstream hit.
33+
let (a, b, c, e, f) = tokio::join!(d.call(9), d.call(9), d.call(9), d.call(9), d.call(9));
34+
let results = [a, b, c, e, f];
35+
36+
let downstream = calls.load(Ordering::SeqCst);
37+
println!("concurrent calls: {}", results.len());
38+
println!("downstream hits: {downstream}");
39+
40+
for r in &results {
41+
assert_eq!(
42+
*r.as_ref().unwrap(),
43+
81,
44+
"every caller gets the shared value"
45+
);
46+
}
47+
assert_eq!(
48+
downstream, 1,
49+
"five concurrent calls of one input collapse to one fetch"
50+
);
51+
52+
println!(
53+
"dedup confirmed: {} calls served by {downstream} fetch",
54+
results.len()
55+
);
56+
}

src/deduplicator.rs

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,19 @@ use std::collections::HashMap;
22
use std::collections::hash_map::Entry;
33
use std::sync::{Arc, Mutex};
44

5+
use thiserror::Error;
56
use tokio::sync::oneshot;
67

7-
use crate::fetcher::{DedupError, Fetcher};
8+
use crate::fetcher::Fetcher;
9+
10+
#[derive(Debug, Clone, Error)]
11+
#[non_exhaustive]
12+
pub enum DedupError<E> {
13+
#[error("fetch failed: {0}")]
14+
Load(#[source] E),
15+
#[error("the fetcher panicked while loading")]
16+
Panic,
17+
}
818

919
// Aliased to keep the nested generics under clippy's type-complexity lint.
1020
type Responder<F> =
@@ -58,14 +68,15 @@ impl<F: Fetcher> Deduplicator<F> {
5868

5969
match reply.await {
6070
Ok(result) => result,
61-
// Sender gone without a reply: run_fetch died abnormally, report don't hang.
71+
// Sender gone without a reply: run_fetch died abnormally, report instead of hanging.
6272
Err(_) => Err(DedupError::Panic),
6373
}
6474
}
6575
}
6676

67-
// A load panic becomes a JoinError (never a hung waiter); assumes the input's
68-
// Clone/Hash/Eq do not panic.
77+
// A load panic becomes a JoinError (never a hung waiter).
78+
// The Fetcher and Input trait impls (Clone, Hash, Eq) run outside that isolation:
79+
// they must not panic, or the entry orphans and this input's later callers hang.
6980
async fn run_fetch<F: Fetcher>(fetcher: F, in_flight: InFlight<F>, input: F::Input) {
7081
let key = input.clone();
7182
let outcome = tokio::spawn(async move { fetcher.load(input).await }).await;
@@ -254,8 +265,8 @@ mod tests {
254265
}
255266
}
256267

257-
// A fetch panic reaches all callers as DedupError::Panic and frees the input,
258-
// so the next call refetches. Looped on virtual time for the cleanup race;
268+
// A fetch panic reaches all callers as DedupError::Panic and frees the input,
269+
// so the next call refetches. Looped on virtual time for the cleanup race;
259270
// the printed panic is expected.
260271
#[tokio::test(start_paused = true)]
261272
async fn a_fetch_panic_reaches_callers_and_frees_the_input() {
@@ -324,7 +335,7 @@ mod tests {
324335
(fetcher, calls, completed, entered, finished)
325336
}
326337

327-
// The initiator's wait times out, but the started fetch is not cancelled:
338+
// The initiator's wait times out, but the started fetch is not cancelled:
328339
// the follower still gets the value and the fetch ran once. Auto-advance drives the clock.
329340
#[tokio::test(start_paused = true)]
330341
async fn abandoning_the_initiator_does_not_cancel_the_fetch() {
@@ -348,7 +359,7 @@ mod tests {
348359
}
349360
}
350361

351-
// All waiters leave after the fetch started: it still runs to completion,
362+
// All waiters leave after the fetch started: it still runs to completion,
352363
// and delivery into the closed receiver does not panic.
353364
#[tokio::test(start_paused = true)]
354365
async fn all_waiters_leaving_does_not_cancel_the_started_fetch() {
@@ -364,7 +375,7 @@ mod tests {
364375
entered.notified().await;
365376

366377
caller.abort();
367-
let _ = caller.await;
378+
assert!(caller.await.unwrap_err().is_cancelled());
368379

369380
tokio::time::advance(HOLD).await;
370381
finished.notified().await;
@@ -377,4 +388,27 @@ mod tests {
377388
);
378389
}
379390
}
391+
392+
// One of several waiters for an input departs; the rest still get the value
393+
// from the single shared fetch, and delivery into the departed receiver does
394+
// not panic. Auto-advance drives the virtual clock.
395+
#[tokio::test(start_paused = true)]
396+
async fn a_departed_waiter_does_not_starve_the_others() {
397+
const HOLD: Duration = Duration::from_secs(10);
398+
const SHORT: Duration = Duration::from_secs(1);
399+
for _ in 0..50 {
400+
let (fetcher, calls, _completed, _entered, _finished) = slow(HOLD);
401+
let d = Deduplicator::new(fetcher);
402+
403+
let departing = tokio::time::timeout(SHORT, d.call(1));
404+
let survivor_a = d.call(1);
405+
let survivor_b = d.call(1);
406+
let (gone, a, b) = tokio::join!(departing, survivor_a, survivor_b);
407+
408+
assert!(gone.is_err(), "one waiter timed out and departed");
409+
assert_eq!(a.unwrap(), 1, "a surviving waiter still gets the value");
410+
assert_eq!(b.unwrap(), 1, "the other survivor too");
411+
assert_eq!(calls.load(Ordering::SeqCst), 1, "one shared fetch for all");
412+
}
413+
}
380414
}

src/fetcher.rs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
use std::future::Future;
22
use std::hash::Hash;
33

4-
use thiserror::Error;
5-
64
pub trait Fetcher: Clone + Send + Sync + 'static {
75
type Input: Hash + Eq + Clone + Send + 'static;
86
type Output: Send + Clone + 'static;
@@ -13,12 +11,3 @@ pub trait Fetcher: Clone + Send + Sync + 'static {
1311
input: Self::Input,
1412
) -> impl Future<Output = Result<Self::Output, Self::Error>> + Send;
1513
}
16-
17-
#[derive(Debug, Clone, Error)]
18-
#[non_exhaustive]
19-
pub enum DedupError<E> {
20-
#[error("fetch failed: {0}")]
21-
Load(#[source] E),
22-
#[error("the fetcher panicked while loading")]
23-
Panic,
24-
}

src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ mod window;
1212

1313
pub use collector::BatchCollector;
1414
pub use config::BatchLoaderConfig;
15-
pub use deduplicator::Deduplicator;
15+
pub use deduplicator::{DedupError, Deduplicator};
1616
pub use error::Error;
17-
pub use fetcher::{DedupError, Fetcher};
17+
pub use fetcher::Fetcher;
1818
pub use loader::BatchLoader;

0 commit comments

Comments
 (0)