Skip to main content

sofie_client/
meilisearch.rs

1mod tasks;
2
3use std::collections::HashSet;
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6
7use serde_json::{Value, json};
8use sofie_core::{Error, Result, Uuid, unpoisoned};
9use sofie_db::{BoxFuture, Document, MATCHES_READ, SearchEngine, SearchIndex};
10
11use crate::{Outgoing, Request, Response, call};
12
13const VENDOR: &str = "meilisearch";
14
15/// A settings task still queued behind others after this is taken as prepared: Meilisearch
16/// applies an index's tasks in order, so the documents sent next follow it.
17const SETTINGS_CHECKS: u32 = 20;
18const SETTINGS_CHECK_PAUSE: Duration = Duration::from_millis(50);
19
20pub struct Meilisearch {
21    server: Arc<Server>,
22}
23
24struct Server {
25    http: Outgoing,
26    api_key: Option<String>,
27    /// Each settings update is a task Meilisearch queues, so it is sent once per index.
28    prepared: Mutex<HashSet<String>>,
29    tasks: Mutex<tasks::Pending>,
30    check_tasks_every: Duration,
31}
32
33impl Meilisearch {
34    #[must_use]
35    pub fn new(http: Outgoing, api_key: Option<String>) -> Self {
36        Self::built(http, api_key, tasks::CHECK_EVERY)
37    }
38
39    #[cfg(feature = "testing")]
40    #[must_use]
41    pub fn checking_tasks_every(http: Outgoing, api_key: Option<String>, every: Duration) -> Self {
42        Self::built(http, api_key, every)
43    }
44
45    fn built(http: Outgoing, api_key: Option<String>, every: Duration) -> Self {
46        Self {
47            server: Arc::new(Server {
48                http,
49                api_key,
50                prepared: Mutex::default(),
51                tasks: Mutex::default(),
52                check_tasks_every: every,
53            }),
54        }
55    }
56}
57
58impl Server {
59    fn authorized<'r>(&self, request: Request<'r>) -> Request<'r> {
60        match &self.api_key {
61            Some(key) => request.bearer(key),
62            None => request,
63        }
64    }
65
66    async fn search(
67        &self,
68        index: &SearchIndex,
69        words: &str,
70        owner: Option<Uuid>,
71    ) -> Result<Vec<Uuid>> {
72        let mut body = json!({
73            "q": words,
74            "limit": MATCHES_READ,
75            "attributesToRetrieve": ["id"],
76            "matchingStrategy": "all",
77        });
78        if let Some(owner) = owner {
79            body["filter"] = Value::String(format!("owner = \"{owner}\""));
80        }
81        let path = format!("/indexes/{}/search", index.uid());
82        let response = self
83            .authorized(self.http.post(&path))
84            .json(&body)
85            .repeatable()
86            .send()
87            .await?;
88        if response.status() == 404 && error_code(&response).as_deref() == Some("index_not_found") {
89            return Ok(Vec::new());
90        }
91        let answer: Value = accepted(response)?.json()?;
92        answer
93            .get("hits")
94            .and_then(Value::as_array)
95            .ok_or_else(|| Error::internal_message("meilisearch answered a search without hits"))?
96            .iter()
97            .map(hit_id)
98            .collect()
99    }
100
101    async fn add(self: &Arc<Self>, index: &SearchIndex, documents: &[Document]) -> Result<()> {
102        self.prepare(index).await?;
103        let documents: Vec<Value> = documents
104            .iter()
105            .map(|document| {
106                let mut fields = json!({ "id": document.id, "text": document.text });
107                if index.owned() {
108                    fields["owner"] = json!(document.owner);
109                }
110                fields
111            })
112            .collect();
113        let path = format!("/indexes/{}/documents?primaryKey=id", index.uid());
114        let request = self.authorized(self.http.post(&path)).json(&documents);
115        self.enqueued(request).await
116    }
117
118    async fn delete(self: &Arc<Self>, index: &SearchIndex, ids: &[Uuid]) -> Result<()> {
119        let path = format!("/indexes/{}/documents/delete-batch", index.uid());
120        let request = self.authorized(self.http.post(&path)).json(ids);
121        self.enqueued(request).await
122    }
123
124    async fn enqueued(self: &Arc<Self>, request: Request<'_>) -> Result<()> {
125        let answer: Value = accepted(request.send().await?)?.json()?;
126        if let Some(task) = answer.get("taskUid").and_then(Value::as_u64) {
127            self.watch(task);
128        }
129        Ok(())
130    }
131
132    /// Updating the settings of an index not there yet makes it. Remembered only once the task is
133    /// accepted and has not failed, so a refused one is sent again with the next documents.
134    async fn prepare(&self, index: &SearchIndex) -> Result<()> {
135        let uid = index.uid();
136        if self.prepared().contains(&uid) {
137            return Ok(());
138        }
139        let filterable: &[&str] = if index.owned() { &["owner"] } else { &[] };
140        let settings = json!({
141            "searchableAttributes": ["text"],
142            "filterableAttributes": filterable,
143        });
144        let path = format!("/indexes/{uid}/settings");
145        let request = self.authorized(self.http.patch(&path)).json(&settings);
146        let task: Value = accepted(request.send().await?)?.json()?;
147        if let Some(task) = task.get("taskUid").and_then(Value::as_u64) {
148            self.settled(task).await?;
149        }
150        self.prepared().insert(uid);
151        Ok(())
152    }
153
154    /// A key without `tasks.get` cannot see the task, which then counts as accepted.
155    async fn settled(&self, task: u64) -> Result<()> {
156        let path = format!("/tasks/{task}");
157        for _ in 0..SETTINGS_CHECKS {
158            let response = self.authorized(self.http.get(&path)).send().await?;
159            if matches!(response.status(), 401 | 403) {
160                return Ok(());
161            }
162            let answer: Value = accepted(response)?.json()?;
163            match answer.get("status").and_then(Value::as_str) {
164                Some("enqueued" | "processing") => tokio::time::sleep(SETTINGS_CHECK_PAUSE).await,
165                Some("failed" | "canceled") => {
166                    let code = answer
167                        .pointer("/error/code")
168                        .and_then(Value::as_str)
169                        .unwrap_or("no code");
170                    return Err(Error::internal_message(format!(
171                        "meilisearch refused the settings of an index ({code})"
172                    )));
173                }
174                _ => return Ok(()),
175            }
176        }
177        Ok(())
178    }
179
180    fn prepared(&self) -> std::sync::MutexGuard<'_, HashSet<String>> {
181        unpoisoned::lock(&self.prepared)
182    }
183}
184
185impl SearchEngine for Meilisearch {
186    fn matching<'a>(
187        &'a self,
188        index: &'a SearchIndex,
189        words: &'a str,
190        owner: Option<Uuid>,
191    ) -> BoxFuture<'a, Result<Vec<Uuid>>> {
192        Box::pin(call(
193            VENDOR,
194            "matching",
195            self.server.search(index, words, owner),
196        ))
197    }
198
199    fn put<'a>(
200        &'a self,
201        index: &'a SearchIndex,
202        documents: &'a [Document],
203    ) -> BoxFuture<'a, Result<()>> {
204        Box::pin(call(VENDOR, "put", self.server.add(index, documents)))
205    }
206
207    fn remove<'a>(&'a self, index: &'a SearchIndex, ids: &'a [Uuid]) -> BoxFuture<'a, Result<()>> {
208        Box::pin(call(VENDOR, "remove", self.server.delete(index, ids)))
209    }
210}
211
212/// Names Meilisearch's error code, never its message, which may quote a document.
213fn accepted(response: Response) -> Result<Response> {
214    let status = response.status();
215    if (200..300).contains(&status) {
216        return Ok(response);
217    }
218    let code = error_code(&response).unwrap_or_else(|| "no code".to_owned());
219    let problem = format!("meilisearch answered {status} ({code})");
220    Err(if status >= 500 || status == 429 {
221        Error::Unavailable(problem)
222    } else {
223        Error::internal_message(problem)
224    })
225}
226
227fn error_code(response: &Response) -> Option<String> {
228    let body: Value = serde_json::from_slice(response.body()).ok()?;
229    Some(body.get("code")?.as_str()?.to_owned())
230}
231
232fn hit_id(hit: &Value) -> Result<Uuid> {
233    hit.get("id")
234        .and_then(Value::as_str)
235        .and_then(|id| id.parse().ok())
236        .ok_or_else(|| Error::internal_message("meilisearch answered a hit whose id is no id"))
237}