Skip to main content

opensearch_client/
bulker.rs

1use std::sync::{Arc, Mutex};
2
3use serde::{Deserialize, Serialize};
4use tokio::{
5    sync::mpsc,
6    task::JoinHandle,
7    time::{Duration, sleep},
8};
9use tracing::{debug, error};
10
11use crate::{
12    Error, OsClient,
13    bulk::{BulkAction, CreateAction, DeleteAction, IndexAction, UpdateAction, UpdateActionBody},
14};
15
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17pub struct BulkerStatistic {
18    // Number of deleted actions
19    pub delete_actions: u64,
20    // Number of created actions
21    pub create_actions: u64,
22    // Number of updated actions
23    pub update_actions: u64,
24    // Number of index actions
25    pub index_actions: u64,
26    // Number of records in the queue
27    pub queue_size: usize,
28    // Number of running Reqwest calls
29    pub running_reqwest_calls: usize,
30    // Number of total Reqwest calls
31    pub total_reqwest_calls: usize,
32    // Number of finished Reqwest calls
33    pub finished_reqwest_calls: usize,
34    // Number of error calls
35    pub error_reqwest_calls: usize,
36    // Number of action without errors
37    pub success_actions: usize,
38    // Number of action with errors
39    pub error_actions: usize,
40    // Number of creation action with errors
41    pub error_create_actions: usize,
42}
43
44#[derive(Debug, Clone)]
45struct Action {
46    action: BulkAction,
47    document: Option<String>,
48}
49// BulkerBuilder is a helper struct to build a Bulker instance.
50#[derive(Clone)]
51pub struct BulkerBuilder {
52    // OpenSearch Client
53    os_client: Arc<OsClient>,
54    // Max items for bulk
55    bulk_size: u32,
56    // Max parallel bulks
57    max_concurrent_connections: u32,
58}
59
60impl BulkerBuilder {
61    /// Create a new BulkerBuilder instance.
62    pub fn new(os_client: Arc<OsClient>, bulk_size: u32) -> Self {
63        BulkerBuilder {
64            os_client,
65            bulk_size,
66            max_concurrent_connections: 10,
67        }
68    }
69
70    /// Set the bulk size.
71    pub fn bulk_size(mut self, bulk_size: u32) -> Self {
72        self.bulk_size = bulk_size;
73        self
74    }
75
76    /// Set the max concurrent connections.
77    pub fn max_concurrent_connections(mut self, max_concurrent_connections: u32) -> Self {
78        self.max_concurrent_connections = max_concurrent_connections;
79        self
80    }
81
82    /// Build a Bulker instance.
83    pub fn build(self) -> Bulker {
84        let (handle, bulker) = Bulker::new(
85            self.os_client,
86            self.bulk_size,
87            self.max_concurrent_connections,
88        );
89        tokio::spawn(async move {
90            handle.await.unwrap();
91        });
92        bulker
93    }
94}
95
96/// Bulker is a helper struct to make bulk requests to OpenSearch.
97#[derive(Clone)]
98pub struct Bulker {
99    sender: mpsc::Sender<Action>,
100    // Create a shared queue using Arc and Mutex
101    queue: Arc<Mutex<Vec<Action>>>,
102    // OPenSearch Client
103    os_client: Arc<OsClient>,
104    // Max items for bulk
105    #[allow(dead_code)]
106    bulk_size: u32,
107    // Max parallel bulks
108    // max_concurrent_connections: u32,
109    // statistics
110    statistics: Arc<Mutex<BulkerStatistic>>,
111}
112
113impl Bulker {
114    /// Spawn an extraction service on a separate thread and return an extraction
115    /// instance to interact with it
116    pub fn new(
117        os_client: Arc<OsClient>,
118        bulk_size: u32,
119        max_concurrent_connections: u32,
120    ) -> (JoinHandle<()>, Bulker) {
121        let (sender, receiver) =
122            mpsc::channel::<Action>((bulk_size * (max_concurrent_connections + 1)) as usize);
123        let statistics = Arc::new(Mutex::new(BulkerStatistic::default()));
124        let service = Bulker {
125            bulk_size,
126            // max_concurrent_connections,
127            sender,
128            os_client: os_client.clone(),
129            queue: Arc::new(Mutex::new(Vec::new())),
130            statistics: statistics.clone(),
131        };
132        let queue = service.queue.clone();
133        let o_client = os_client.clone();
134        // Spawn a background task to process the queue and make async Reqwest calls
135        let handle = tokio::spawn(async move {
136            process_queue(
137                queue.clone(),
138                receiver,
139                o_client,
140                bulk_size as usize,
141                max_concurrent_connections as usize,
142                statistics.clone(),
143            )
144            .await
145            .unwrap();
146        });
147
148        (handle, service)
149    }
150
151    pub fn statistics(&self) -> BulkerStatistic {
152        self.statistics.lock().unwrap().clone()
153    }
154
155    /// Sends a bulk index request to OpenSearch with the specified index, id and
156    /// document body.
157    ///
158    /// # Arguments
159    ///
160    /// * `index` - A string slice that holds the name of the index.
161    /// * `id` - An optional string slice that holds the id of the document.
162    /// * `body` - A reference to a serializable document body.
163    ///
164    /// # Returns
165    ///
166    /// Returns () on success, or an `Error` on failure.
167    pub async fn index<T: Serialize>(
168        &self,
169        index: &str,
170        body: &T,
171        id: Option<String>,
172    ) -> Result<(), Error> {
173        let action = BulkAction::Index(IndexAction {
174            index: index.to_owned(),
175            id: id.clone(),
176            pipeline: None,
177        });
178        self.sender
179            .send(Action {
180                action,
181                document: Some(serde_json::to_string(&body)?),
182            })
183            .await
184            .map_err(|e| Error::InternalError(format!("{}", e)))?;
185
186        self.statistics.lock().unwrap().index_actions += 1;
187        Ok(())
188    }
189
190    /// Sends a bulk create request to the OpenSearch cluster with the specified
191    /// index, id and body.
192    ///
193    /// # Arguments
194    ///
195    /// * `index` - A string slice that holds the name of the index.
196    /// * `id` - A string slice that holds the id of the document.
197    /// * `body` - A generic type `T` that holds the body of the document to be
198    ///   created.
199    ///
200    /// # Returns
201    ///
202    /// Returns () on success, or an `Error` on failure.
203    pub async fn create<T: Serialize>(&self, index: &str, id: &str, body: &T) -> Result<(), Error> {
204        let action = BulkAction::Create(CreateAction {
205            index: index.to_owned(),
206            id: id.to_owned(),
207            ..Default::default()
208        });
209        self.sender
210            .send(Action {
211                action,
212                document: Some(serde_json::to_string(&body)?),
213            })
214            .await
215            .map_err(|e| Error::InternalError(format!("{}", e)))?;
216        self.statistics.lock().unwrap().create_actions += 1;
217        Ok(())
218    }
219
220    /// Sends a bulk delete request to the OpenSearch cluster with the specified
221    /// index and id.
222    ///
223    /// # Arguments
224    ///
225    /// * `index` - A string slice that holds the name of the index.
226    /// * `id` - A string slice that holds the id of the document.
227    ///
228    /// # Returns
229    ///
230    /// Returns () on success, or an `Error` on failure.
231    pub async fn delete<T: Serialize>(&self, index: &str, id: &str) -> Result<(), Error> {
232        let action = BulkAction::Delete(DeleteAction {
233            index: index.to_owned(),
234            id: id.to_owned(),
235        });
236        self.sender
237            .send(Action {
238                action,
239                document: None,
240            })
241            .await
242            .map_err(|e| Error::InternalError(format!("{}", e)))?;
243        self.statistics.lock().unwrap().delete_actions += 1;
244        Ok(())
245    }
246
247    /// Asynchronously updates a document in bulk.
248    ///
249    /// # Arguments
250    ///
251    /// * `index` - A string slice that holds the name of the index.
252    /// * `id` - A string slice that holds the ID of the document to update.
253    /// * `body` - An `UpdateAction` struct that holds the update action to
254    ///   perform.
255    ///
256    /// # Returns
257    ///
258    /// Returns a `Result` containing a `serde_json::Value` on success, or an
259    /// `Error` on failure.
260    pub async fn update(
261        &self,
262        index: &str,
263        id: &str,
264        body: &UpdateActionBody,
265    ) -> Result<(), Error> {
266        let action = BulkAction::Update(UpdateAction {
267            index: index.to_owned(),
268            id: id.to_owned(),
269            ..Default::default()
270        });
271        self.sender
272            .send(Action {
273                action,
274                document: Some(serde_json::to_string(&body)?),
275            })
276            .await
277            .map_err(|e| Error::InternalError(format!("{}", e)))?;
278        self.statistics.lock().unwrap().update_actions += 1;
279        Ok(())
280    }
281
282    // wait that every reqwest is completed
283    pub async fn flush(&self) {
284        loop {
285            self.refresh_queue_size();
286            let (status, done) = {
287                let statistics = self.statistics.lock().unwrap();
288                let status = format!(
289                    "Bulker: Finished reqwest calls: {}, Total reqwest calls: {}, Queue size: {}, Running reqwest calls: {}, Error reqwest calls: {}, Success actions: {}, Error actions: {}, Error create actions: {}",
290                    statistics.finished_reqwest_calls,
291                    statistics.total_reqwest_calls,
292                    statistics.queue_size,
293                    statistics.running_reqwest_calls,
294                    statistics.error_reqwest_calls,
295                    statistics.success_actions,
296                    statistics.error_actions,
297                    statistics.error_create_actions
298                );
299                let done = statistics.finished_reqwest_calls == statistics.total_reqwest_calls
300                    && statistics.queue_size == 0;
301                (status, done)
302                // statistics guard dropped here
303            };
304            println!("{}", status);
305            if done {
306                break;
307            }
308            sleep(Duration::from_secs(1)).await;
309        }
310    }
311
312    fn refresh_queue_size(&self) {
313        // we fresh the queue size
314        let mut statistics = self.statistics.lock().unwrap();
315        statistics.queue_size = self.queue.lock().unwrap().len();
316    }
317}
318
319impl Drop for Bulker {
320    fn drop(&mut self) {
321        tokio::task::block_in_place(|| {
322            tokio::runtime::Handle::current().block_on(async {
323                // Clone the records from the queue to process synchronously
324                let records_to_process: Vec<Action> = self.queue.lock().unwrap().clone();
325                if !records_to_process.is_empty() {
326                    debug!(
327                        "Bulker: Processing remaining records: {:?}",
328                        records_to_process.len()
329                    );
330                    make_reqwest_calls(
331                        self.os_client.clone(),
332                        records_to_process,
333                        self.statistics.clone(),
334                    )
335                    .await;
336                }
337                // Clear the queue
338                self.queue.lock().unwrap().clear();
339            });
340        });
341    }
342}
343
344async fn process_queue(
345    queue: Arc<Mutex<Vec<Action>>>,
346    mut receiver: mpsc::Receiver<Action>,
347    os_client: Arc<OsClient>,
348    bulk_size: usize,
349    max_concurrent_connections: usize,
350    statistics: Arc<Mutex<BulkerStatistic>>,
351) -> Result<(), Error> {
352    let mut reqwest_calls: Vec<tokio::task::JoinHandle<()>> = Vec::new();
353    let mut start = std::time::Instant::now();
354    loop {
355        tokio::select! {
356            // Handle incoming JSON records
357            Some(json_record) = receiver.recv() => {
358                // Put the JSON record in the queue
359                queue.lock().unwrap().push(json_record.clone());
360
361                // Check conditions for making async Reqwest calls
362                let queue_size = queue.lock().unwrap().len();
363                let running_reqwest_calls = reqwest_calls.iter().filter(|task| !task.is_finished()).count();
364                {
365                    let mut statistics = statistics.lock().unwrap();
366                    statistics.queue_size = queue_size;
367                    statistics.running_reqwest_calls = running_reqwest_calls;
368                }
369                let end= std::time::Instant::now();
370
371                if (queue_size >= bulk_size && running_reqwest_calls <= max_concurrent_connections) || (queue_size > 0 && end.duration_since(start).as_secs() > 1 && running_reqwest_calls <= max_concurrent_connections) {
372                    // Clone the records from the queue to process asynchronously
373                    let records_to_process: Vec<Action> = queue.lock().unwrap().clone();
374
375                    // Clear the queue
376                    queue.lock().unwrap().clear();
377                    {
378                      let mut statistics = statistics.lock().unwrap();
379                      statistics.total_reqwest_calls += 1;
380                      statistics.queue_size = 0;
381                    }
382
383                    // Spawn an async task to make the Reqwest calls
384                    reqwest_calls.push(tokio::spawn(make_reqwest_calls(os_client.clone(), records_to_process, statistics.clone())));
385                    start= std::time::Instant::now();
386                }
387            }
388            // Handle elapsed time for Reqwest calls
389            _ = sleep(Duration::from_secs(1)) => {
390                reqwest_calls.retain(|task| !task.is_finished());
391            }
392        }
393        // Check if all the records have been processed or timeout to send data
394        {
395            let end = std::time::Instant::now();
396            let queue_size = queue.lock().unwrap().len();
397            let running_reqwest_calls = reqwest_calls
398                .iter()
399                .filter(|task| !task.is_finished())
400                .count();
401            if (queue_size >= bulk_size && running_reqwest_calls <= max_concurrent_connections)
402                || (queue_size > 0
403                    && end.duration_since(start).as_secs() > 1
404                    && running_reqwest_calls <= max_concurrent_connections)
405            {
406                // Clone the records from the queue to process asynchronously
407                let records_to_process: Vec<Action> = queue.lock().unwrap().clone();
408
409                // Clear the queue
410                queue.lock().unwrap().clear();
411                {
412                    let mut statistics = statistics.lock().unwrap();
413                    statistics.total_reqwest_calls += 1;
414                    statistics.queue_size = 0;
415                }
416
417                // Spawn an async task to make the Reqwest calls
418                reqwest_calls.push(tokio::spawn(make_reqwest_calls(
419                    os_client.clone(),
420                    records_to_process,
421                    statistics.clone(),
422                )));
423                start = std::time::Instant::now();
424            }
425        }
426    }
427}
428
429async fn make_reqwest_calls(
430    os_client: Arc<OsClient>,
431    records: Vec<Action>,
432    statistics: Arc<Mutex<BulkerStatistic>>,
433) {
434    let mut bulker = String::new();
435    let total = &records.len();
436
437    for record in records {
438        let j = serde_json::to_string(&record.action).unwrap();
439        bulker.push_str(j.as_str());
440        bulker.push('\n');
441        if let Some(document) = record.document {
442            bulker.push_str(document.as_str());
443            bulker.push('\n');
444        }
445    }
446
447    match os_client.bulk().body(bulker).call().await {
448        Ok(bulk_response) => {
449            let mut statistics = statistics.lock().unwrap();
450            statistics.finished_reqwest_calls += 1;
451            statistics.success_actions += bulk_response.count_ok();
452            statistics.error_actions += bulk_response.count_errors();
453            statistics.error_create_actions += bulk_response.count_create_errors();
454            debug!(
455                "Request successful for record: {:?}",
456                &bulk_response.items.len()
457            );
458        }
459        Err(e) => {
460            let mut statistics = statistics.lock().unwrap();
461            statistics.total_reqwest_calls += 1;
462            statistics.finished_reqwest_calls += 1;
463            statistics.error_reqwest_calls += 1;
464            statistics.error_actions += total;
465            let message = format!("Error making Reqwest call: {:?}", e);
466            error!(message);
467        }
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use crate::{ConfigurationBuilder, OsClient};
474    use opensearch_testcontainer::*;
475    use serde_json::json;
476    use std::env;
477    use testcontainers::runners::AsyncRunner;
478    use tracing_test::traced_test;
479    use url::Url;
480
481    async fn get_client() -> OsClient {
482        if let Some(_) = env::var("OPENSEARCH_URL").ok() {
483            let client = OsClient::from_environment().unwrap();
484            return client;
485        } else {
486            let os_image = OpenSearch::default();
487            let opensearch = os_image.clone().start().await.unwrap();
488            let host_port = opensearch.get_host_port_ipv4(9200).await.unwrap();
489
490            let client = ConfigurationBuilder::new()
491                .accept_invalid_certificates(true)
492                .base_url(&format!("https://127.0.0.1:{host_port}"))
493                .basic_auth(os_image.username(), os_image.password())
494                .build();
495            return client;
496        }
497    }
498
499    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
500    #[traced_test]
501    async fn bulker_ingester() -> Result<(), Box<dyn std::error::Error>> {
502        let client = get_client().await;
503
504        let test_size: u32 = 100000;
505        let bulker = client
506            .bulker()
507            .bulk_size(1000)
508            .max_concurrent_connections(10)
509            .build();
510        for i in 0..test_size {
511            bulker
512                .index("test", &json!({"id":i}), Some(i.to_string()))
513                .await
514                .unwrap();
515        }
516        bulker.flush().await;
517        let statitics = bulker.statistics();
518        drop(bulker);
519
520        assert_eq!(statitics.index_actions, test_size as u64);
521        assert_eq!(statitics.create_actions, 0);
522        assert_eq!(statitics.delete_actions, 0);
523        assert_eq!(statitics.update_actions, 0);
524        client.indices().refresh().call().await.unwrap();
525
526        let count = client.count().index("test").call().await.unwrap();
527        assert_eq!(count.count, test_size);
528        Ok(())
529    }
530}