Skip to main content

geode_client/
batch.rs

1//! Batch and pipelined statement execution (port of Go batch.go).
2
3use std::future::Future;
4use std::sync::Arc;
5
6use tokio::sync::{Mutex, Semaphore};
7use tokio::task::JoinSet;
8
9use crate::error::{Error, Result};
10use crate::schema::Statement;
11
12/// Default chunk size for [`ConnectionPool::pipeline_exec`](crate::ConnectionPool::pipeline_exec).
13pub const DEFAULT_CHUNK_SIZE: usize = 100;
14/// Default worker count for pipelined execution.
15pub const DEFAULT_MAX_WORKERS: usize = 8;
16
17/// Split statements into contiguous chunks of at most `chunk_size`.
18/// A `chunk_size` of 0 is treated as [`DEFAULT_CHUNK_SIZE`].
19pub(crate) fn chunk_statements(stmts: &[Statement], chunk_size: usize) -> Vec<Vec<Statement>> {
20    let chunk_size = if chunk_size == 0 {
21        DEFAULT_CHUNK_SIZE
22    } else {
23        chunk_size
24    };
25    stmts.chunks(chunk_size).map(|c| c.to_vec()).collect()
26}
27
28/// Run `exec` over the chunks of `stmts` with bounded concurrency.
29/// First error stops dispatching new chunks; in-flight chunks complete.
30/// Returns the first error recorded (or Ok if all succeed).
31pub(crate) async fn pipeline_drive<F, Fut>(
32    stmts: &[Statement],
33    chunk_size: usize,
34    max_workers: usize,
35    exec: F,
36) -> Result<()>
37where
38    F: Fn(Vec<Statement>) -> Fut + Send + Sync + 'static,
39    Fut: Future<Output = Result<()>> + Send + 'static,
40{
41    if stmts.is_empty() {
42        return Ok(());
43    }
44    let max_workers = if max_workers == 0 {
45        DEFAULT_MAX_WORKERS
46    } else {
47        max_workers
48    };
49    let chunks = chunk_statements(stmts, chunk_size);
50
51    let sem = Arc::new(Semaphore::new(max_workers));
52    let first_err: Arc<Mutex<Option<Error>>> = Arc::new(Mutex::new(None));
53    let exec = Arc::new(exec);
54    let mut set: JoinSet<()> = JoinSet::new();
55
56    for chunk in chunks {
57        // Stop dispatching new chunks once an error has been recorded.
58        if first_err.lock().await.is_some() {
59            break;
60        }
61        let permit = Arc::clone(&sem)
62            .acquire_owned()
63            .await
64            .map_err(|_| Error::pool("pipeline semaphore closed"))?;
65        let exec = Arc::clone(&exec);
66        let first_err = Arc::clone(&first_err);
67        set.spawn(async move {
68            let _permit = permit; // released on drop
69            if let Err(e) = exec(chunk).await {
70                let mut slot = first_err.lock().await;
71                if slot.is_none() {
72                    *slot = Some(e);
73                }
74            }
75        });
76    }
77
78    // Wait for all in-flight chunks to complete.
79    while set.join_next().await.is_some() {}
80
81    match Arc::try_unwrap(first_err) {
82        Ok(m) => match m.into_inner() {
83            Some(e) => Err(e),
84            None => Ok(()),
85        },
86        Err(arc) => {
87            let mut guard = arc.lock().await;
88            match guard.take() {
89                Some(e) => Err(e),
90                None => Ok(()),
91            }
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::error::Error;
100    use crate::schema::Statement;
101    use std::sync::Arc;
102    use std::sync::atomic::{AtomicUsize, Ordering};
103    use std::time::Duration;
104
105    /// Maximum concurrent in-flight chunks observed by a test counter helper.
106    struct ConcurrencyGauge {
107        current: AtomicUsize,
108        max: AtomicUsize,
109    }
110
111    impl ConcurrencyGauge {
112        fn new() -> Self {
113            Self {
114                current: AtomicUsize::new(0),
115                max: AtomicUsize::new(0),
116            }
117        }
118        fn enter(&self) {
119            let now = self.current.fetch_add(1, Ordering::SeqCst) + 1;
120            self.max.fetch_max(now, Ordering::SeqCst);
121        }
122        fn leave(&self) {
123            self.current.fetch_sub(1, Ordering::SeqCst);
124        }
125    }
126
127    fn stmts(n: usize) -> Vec<Statement> {
128        (0..n)
129            .map(|i| Statement::new(format!("CREATE (n {{i: {}}})", i)))
130            .collect()
131    }
132
133    #[test]
134    fn test_chunk_statements_exact() {
135        let c = chunk_statements(&stmts(10), 3);
136        assert_eq!(c.len(), 4); // 3+3+3+1
137        assert_eq!(c[0].len(), 3);
138        assert_eq!(c[3].len(), 1);
139    }
140
141    #[test]
142    fn test_chunk_statements_single_chunk() {
143        let c = chunk_statements(&stmts(50), 100);
144        assert_eq!(c.len(), 1);
145        assert_eq!(c[0].len(), 50);
146    }
147
148    #[test]
149    fn test_chunk_statements_chunk_size_one() {
150        let c = chunk_statements(&stmts(5), 1);
151        assert_eq!(c.len(), 5);
152    }
153
154    #[test]
155    fn test_chunk_statements_zero_defaults() {
156        let c = chunk_statements(&stmts(250), 0);
157        assert_eq!(c.len(), 3); // 100+100+50
158    }
159
160    #[test]
161    fn test_chunk_statements_empty() {
162        let c = chunk_statements(&[], 10);
163        assert!(c.is_empty());
164    }
165
166    #[tokio::test]
167    async fn test_pipeline_drive_all_succeed() {
168        let s = stmts(25);
169        let res = super::pipeline_drive(&s, 10, 4, |_chunk| async { Ok(()) }).await;
170        assert!(res.is_ok());
171    }
172
173    #[tokio::test]
174    async fn test_pipeline_drive_first_error_returned() {
175        let s = stmts(30);
176        // chunk size 10 => 3 chunks; the chunk containing index 0 errors.
177        let res = super::pipeline_drive(&s, 10, 8, |chunk| async move {
178            if chunk[0].query.contains("i: 0") {
179                Err(Error::Query {
180                    code: "42000".into(),
181                    message: "boom".into(),
182                })
183            } else {
184                Ok(())
185            }
186        })
187        .await;
188        let err = res.unwrap_err();
189        assert!(err.to_string().contains("boom"));
190    }
191
192    #[tokio::test]
193    async fn test_pipeline_drive_respects_worker_bound() {
194        let s = stmts(40); // 4 chunks of 10
195        let gauge = Arc::new(ConcurrencyGauge::new());
196        let g = gauge.clone();
197        let res = super::pipeline_drive(&s, 10, 2, move |_chunk| {
198            let g = g.clone();
199            async move {
200                g.enter();
201                tokio::time::sleep(Duration::from_millis(20)).await;
202                g.leave();
203                Ok(())
204            }
205        })
206        .await;
207        assert!(res.is_ok());
208        assert!(
209            gauge.max.load(Ordering::SeqCst) <= 2,
210            "exceeded worker bound"
211        );
212    }
213
214    #[tokio::test]
215    async fn test_pipeline_drive_empty_ok() {
216        let res = super::pipeline_drive(&[], 10, 4, |_c| async { Ok(()) }).await;
217        assert!(res.is_ok());
218    }
219}