Skip to main content

mapreduce/
mapreduce.rs

1use std::collections::HashMap;
2use std::error::Error;
3use std::hash::Hash;
4use std::marker::PhantomData;
5use std::sync::Arc;
6use tokio::task::JoinSet;
7
8/// A bounded-concurrency MapReduce executor.
9pub struct MapReduce<K, V, R>
10where
11    K: Clone + Eq + Hash + Send + 'static,
12    V: Send + 'static,
13    R: Send + 'static,
14{
15    max_concurrent_tasks: usize,
16    _marker: PhantomData<(K, V, R)>,
17}
18
19impl<K, V, R> MapReduce<K, V, R>
20where
21    K: Clone + Eq + Hash + Send + 'static,
22    V: Send + 'static,
23    R: Send + 'static,
24{
25    /// Creates an executor that runs at most `max_concurrent_tasks` map or reduce jobs at once.
26    pub fn new(max_concurrent_tasks: usize) -> Self {
27        assert!(
28            max_concurrent_tasks > 0,
29            "maximum concurrent tasks must be greater than zero"
30        );
31
32        Self {
33            max_concurrent_tasks,
34            _marker: PhantomData,
35        }
36    }
37
38    /// Maps the input concurrently, groups mapped values by key, and reduces each group concurrently.
39    pub async fn execute<M, Red, I>(
40        &self,
41        input_data: I,
42        map_fn: M,
43        reduce_fn: Red,
44    ) -> Result<HashMap<K, R>, Box<dyn Error + Send + Sync>>
45    where
46        I: IntoIterator<Item = V> + Send + 'static,
47        M: Fn(V) -> Vec<(K, R)> + Send + Sync + 'static,
48        Red: Fn(K, Vec<R>) -> R + Send + Sync + 'static,
49    {
50        let map_results = self.map_phase(input_data, map_fn).await?;
51        let grouped_results = self.shuffle_phase(map_results);
52        self.reduce_phase(grouped_results, reduce_fn).await
53    }
54
55    async fn map_phase<M, I>(
56        &self,
57        input_data: I,
58        map_fn: M,
59    ) -> Result<Vec<(K, R)>, Box<dyn Error + Send + Sync>>
60    where
61        I: IntoIterator<Item = V> + Send + 'static,
62        M: Fn(V) -> Vec<(K, R)> + Send + Sync + 'static,
63    {
64        let mut jobs = JoinSet::new();
65        let mut mapped = Vec::new();
66        let map_fn = Arc::new(map_fn);
67
68        for item in input_data {
69            if jobs.len() == self.max_concurrent_tasks {
70                mapped.extend(
71                    jobs.join_next()
72                        .await
73                        .expect("a non-empty task set must yield a task")?,
74                );
75            }
76
77            let map_fn = Arc::clone(&map_fn);
78            jobs.spawn(async move { map_fn(item) });
79        }
80
81        while let Some(result) = jobs.join_next().await {
82            mapped.extend(result?);
83        }
84
85        Ok(mapped)
86    }
87
88    fn shuffle_phase(&self, map_results: Vec<(K, R)>) -> HashMap<K, Vec<R>> {
89        let mut grouped: HashMap<K, Vec<R>> = HashMap::new();
90
91        for (key, value) in map_results {
92            grouped.entry(key).or_default().push(value);
93        }
94
95        grouped
96    }
97
98    async fn reduce_phase<Red>(
99        &self,
100        grouped_results: HashMap<K, Vec<R>>,
101        reduce_fn: Red,
102    ) -> Result<HashMap<K, R>, Box<dyn Error + Send + Sync>>
103    where
104        Red: Fn(K, Vec<R>) -> R + Send + Sync + 'static,
105    {
106        let mut jobs = JoinSet::new();
107        let mut reduced = HashMap::new();
108        let reduce_fn = Arc::new(reduce_fn);
109
110        for (key, values) in grouped_results {
111            if jobs.len() == self.max_concurrent_tasks {
112                let (key, value) = jobs
113                    .join_next()
114                    .await
115                    .expect("a non-empty task set must yield a task")?;
116                reduced.insert(key, value);
117            }
118
119            let reduce_fn = Arc::clone(&reduce_fn);
120            jobs.spawn(async move {
121                let value = reduce_fn(key.clone(), values);
122                (key, value)
123            });
124        }
125
126        while let Some(result) = jobs.join_next().await {
127            let (key, value) = result?;
128            reduced.insert(key, value);
129        }
130
131        Ok(reduced)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::MapReduce;
138    use std::sync::atomic::{AtomicUsize, Ordering};
139    use std::sync::Arc;
140    use std::time::Duration;
141
142    #[tokio::test]
143    async fn counts_words_across_documents() {
144        let map_reduce = MapReduce::<String, String, i32>::new(2);
145        let documents = vec!["the quick brown fox".to_owned(), "the quick dog".to_owned()];
146
147        let results = map_reduce
148            .execute(
149                documents,
150                |document| {
151                    document
152                        .split_whitespace()
153                        .map(|word| (word.to_owned(), 1))
154                        .collect()
155                },
156                |_, counts| counts.into_iter().sum(),
157            )
158            .await
159            .unwrap();
160
161        assert_eq!(results.get("the"), Some(&2));
162        assert_eq!(results.get("quick"), Some(&2));
163        assert_eq!(results.get("brown"), Some(&1));
164        assert_eq!(results.get("dog"), Some(&1));
165    }
166
167    #[tokio::test]
168    async fn processes_more_than_channel_capacity_without_deadlocking() {
169        let map_reduce = MapReduce::<usize, usize, usize>::new(1);
170        let results = tokio::time::timeout(
171            Duration::from_secs(1),
172            map_reduce.execute(
173                0..1_000,
174                |value| vec![(value, value)],
175                |_, values| values[0],
176            ),
177        )
178        .await
179        .expect("map-reduce must not deadlock");
180
181        assert_eq!(results.unwrap().len(), 1_000);
182    }
183
184    #[tokio::test]
185    async fn respects_the_configured_concurrency_limit() {
186        let map_reduce = MapReduce::<usize, usize, usize>::new(3);
187        let active = Arc::new(AtomicUsize::new(0));
188        let maximum = Arc::new(AtomicUsize::new(0));
189
190        let results = map_reduce
191            .execute(
192                0..24,
193                {
194                    let active = Arc::clone(&active);
195                    let maximum = Arc::clone(&maximum);
196                    move |value| {
197                        let current = active.fetch_add(1, Ordering::SeqCst) + 1;
198                        maximum.fetch_max(current, Ordering::SeqCst);
199                        std::thread::sleep(Duration::from_millis(5));
200                        active.fetch_sub(1, Ordering::SeqCst);
201                        vec![(value, value)]
202                    }
203                },
204                |_, values| values[0],
205            )
206            .await
207            .unwrap();
208
209        assert_eq!(results.len(), 24);
210        assert!(maximum.load(Ordering::SeqCst) <= 3);
211    }
212
213    #[test]
214    #[should_panic(expected = "maximum concurrent tasks must be greater than zero")]
215    fn rejects_a_zero_concurrency_limit() {
216        MapReduce::<usize, usize, usize>::new(0);
217    }
218}