Skip to main content

docbox_search/
lib.rs

1#![forbid(unsafe_code)]
2#![recursion_limit = "256"]
3//! Search index abstraction with multiple supported backends
4//!
5//! ## Environment Variables
6//!
7//! * `DOCBOX_SEARCH_INDEX_FACTORY` - Which search index to use ("opensearch", "typesense", or "database")
8
9use aws_config::SdkConfig;
10use chrono::Utc;
11use docbox_database::{
12    DatabasePoolCache, DbTransaction,
13    models::{
14        document_box::{DocumentBoxScopeRaw, DocumentBoxScopeRawRef},
15        file::FileId,
16        folder::FolderId,
17        tenant::Tenant,
18        tenant_migration::{CreateTenantMigration, TenantMigration},
19    },
20};
21use docbox_secrets::SecretManager;
22use models::{
23    FileSearchRequest, FileSearchResults, SearchIndexData, SearchRequest, SearchResults,
24    UpdateSearchIndexData,
25};
26use serde::{Deserialize, Serialize};
27use std::{ops::DerefMut, sync::Arc};
28use thiserror::Error;
29use uuid::Uuid;
30
31pub mod models;
32
33pub use database::{
34    DatabaseSearchConfig, DatabaseSearchError, DatabaseSearchIndex, DatabaseSearchIndexFactory,
35    DatabaseSearchIndexFactoryError,
36};
37pub use opensearch::{
38    OpenSearchConfig, OpenSearchIndex, OpenSearchIndexFactory, OpenSearchIndexFactoryError,
39    OpenSearchSearchError,
40};
41pub use typesense::{
42    TypesenseApiKey, TypesenseApiKeyProvider, TypesenseApiKeySecret, TypesenseIndex,
43    TypesenseIndexFactory, TypesenseIndexFactoryError, TypesenseSearchConfig, TypesenseSearchError,
44};
45
46mod database;
47mod opensearch;
48mod typesense;
49
50#[derive(Debug, Clone, Deserialize, Serialize)]
51#[serde(tag = "provider", rename_all = "snake_case")]
52pub enum SearchIndexFactoryConfig {
53    Typesense(typesense::TypesenseSearchConfig),
54    OpenSearch(opensearch::OpenSearchConfig),
55    Database(database::DatabaseSearchConfig),
56}
57
58impl Default for SearchIndexFactoryConfig {
59    fn default() -> Self {
60        Self::Database(Default::default())
61    }
62}
63
64#[derive(Debug, Error)]
65pub enum SearchIndexFactoryError {
66    #[error(transparent)]
67    Typesense(#[from] typesense::TypesenseIndexFactoryError),
68    #[error(transparent)]
69    OpenSearch(#[from] opensearch::OpenSearchIndexFactoryError),
70    #[error(transparent)]
71    Database(#[from] database::DatabaseSearchIndexFactoryError),
72    #[error("unknown search index factory type requested")]
73    UnknownIndexFactory,
74}
75
76impl SearchIndexFactoryConfig {
77    pub fn from_env() -> Result<Self, SearchIndexFactoryError> {
78        let variant = std::env::var("DOCBOX_SEARCH_INDEX_FACTORY")
79            .unwrap_or_else(|_| "database".to_string())
80            .to_lowercase();
81        match variant.as_str() {
82            "open_search" | "opensearch" => opensearch::OpenSearchConfig::from_env()
83                .map(Self::OpenSearch)
84                .map_err(SearchIndexFactoryError::OpenSearch),
85
86            "typesense" => typesense::TypesenseSearchConfig::from_env()
87                .map(Self::Typesense)
88                .map_err(SearchIndexFactoryError::Typesense),
89
90            "database" => database::DatabaseSearchConfig::from_env()
91                .map(Self::Database)
92                .map_err(SearchIndexFactoryError::Database),
93
94            // Unknown type requested
95            _ => Err(SearchIndexFactoryError::UnknownIndexFactory),
96        }
97    }
98}
99
100#[derive(Clone)]
101pub enum SearchIndexFactory {
102    Typesense(typesense::TypesenseIndexFactory),
103    OpenSearch(opensearch::OpenSearchIndexFactory),
104    Database(database::DatabaseSearchIndexFactory),
105}
106
107impl SearchIndexFactory {
108    /// Create a search index factory from the provided `config`
109    pub fn from_config(
110        aws_config: &SdkConfig,
111        secrets: SecretManager,
112        db: Arc<DatabasePoolCache>,
113        config: SearchIndexFactoryConfig,
114    ) -> Result<Self, SearchIndexFactoryError> {
115        match config {
116            SearchIndexFactoryConfig::Typesense(config) => {
117                tracing::debug!("using typesense search index");
118                typesense::TypesenseIndexFactory::from_config(secrets, config)
119                    .map(SearchIndexFactory::Typesense)
120                    .map_err(SearchIndexFactoryError::Typesense)
121            }
122
123            SearchIndexFactoryConfig::OpenSearch(config) => {
124                tracing::debug!("using opensearch search index");
125                opensearch::OpenSearchIndexFactory::from_config(aws_config, config)
126                    .map(SearchIndexFactory::OpenSearch)
127                    .map_err(SearchIndexFactoryError::OpenSearch)
128            }
129
130            SearchIndexFactoryConfig::Database(config) => {
131                tracing::debug!("using opensearch search index");
132                database::DatabaseSearchIndexFactory::from_config(db, config)
133                    .map(SearchIndexFactory::Database)
134                    .map_err(SearchIndexFactoryError::Database)
135            }
136        }
137    }
138
139    /// Create a new "OpenSearch" search index for the tenant
140    pub fn create_search_index(&self, tenant: &Tenant) -> TenantSearchIndex {
141        match self {
142            SearchIndexFactory::Typesense(factory) => {
143                let search_index = tenant.os_index_name.clone();
144                TenantSearchIndex::Typesense(factory.create_search_index(search_index))
145            }
146
147            SearchIndexFactory::OpenSearch(factory) => {
148                let search_index = opensearch::TenantSearchIndexName::from_tenant(tenant);
149                TenantSearchIndex::OpenSearch(factory.create_search_index(search_index))
150            }
151
152            SearchIndexFactory::Database(factory) => {
153                TenantSearchIndex::Database(factory.create_search_index(tenant))
154            }
155        }
156    }
157}
158
159#[derive(Clone)]
160pub enum TenantSearchIndex {
161    Typesense(typesense::TypesenseIndex),
162    OpenSearch(opensearch::OpenSearchIndex),
163    Database(database::DatabaseSearchIndex),
164}
165
166#[derive(Debug, Error)]
167pub enum SearchError {
168    #[error(transparent)]
169    Typesense(#[from] typesense::TypesenseSearchError),
170    #[error(transparent)]
171    OpenSearch(#[from] opensearch::OpenSearchSearchError),
172    #[error(transparent)]
173    Database(#[from] database::DatabaseSearchError),
174    #[error("failed to perform migration")]
175    Migration,
176}
177
178impl TenantSearchIndex {
179    /// Creates a search index for the tenant
180    #[tracing::instrument(skip(self))]
181    pub async fn create_index(&self) -> Result<(), SearchError> {
182        match self {
183            TenantSearchIndex::Typesense(index) => index.create_index().await,
184            TenantSearchIndex::OpenSearch(index) => index.create_index().await,
185            TenantSearchIndex::Database(index) => index.create_index().await,
186        }
187    }
188
189    /// Checks if the tenant search index exists
190    #[tracing::instrument(skip(self))]
191    pub async fn index_exists(&self) -> Result<bool, SearchError> {
192        match self {
193            TenantSearchIndex::Typesense(index) => index.index_exists().await,
194            TenantSearchIndex::OpenSearch(index) => index.index_exists().await,
195            TenantSearchIndex::Database(index) => index.index_exists().await,
196        }
197    }
198
199    /// Deletes the search index for the tenant
200    #[tracing::instrument(skip(self))]
201    pub async fn delete_index(&self) -> Result<(), SearchError> {
202        match self {
203            TenantSearchIndex::Typesense(index) => index.delete_index().await,
204            TenantSearchIndex::OpenSearch(index) => index.delete_index().await,
205            TenantSearchIndex::Database(index) => index.delete_index().await,
206        }
207    }
208
209    /// Searches the search index with the provided query
210    #[tracing::instrument(skip(self))]
211    pub async fn search_index(
212        &self,
213        scope: &[DocumentBoxScopeRaw],
214        query: SearchRequest,
215        folder_children: Option<Vec<FolderId>>,
216    ) -> Result<SearchResults, SearchError> {
217        match self {
218            TenantSearchIndex::Typesense(index) => {
219                index.search_index(scope, query, folder_children).await
220            }
221            TenantSearchIndex::OpenSearch(index) => {
222                index.search_index(scope, query, folder_children).await
223            }
224            TenantSearchIndex::Database(index) => {
225                index.search_index(scope, query, folder_children).await
226            }
227        }
228    }
229
230    /// Searches the index for matches scoped to a specific file
231    #[tracing::instrument(skip(self))]
232    pub async fn search_index_file(
233        &self,
234        scope: &DocumentBoxScopeRaw,
235        file_id: FileId,
236        query: FileSearchRequest,
237    ) -> Result<FileSearchResults, SearchError> {
238        match self {
239            TenantSearchIndex::Typesense(index) => {
240                index.search_index_file(scope, file_id, query).await
241            }
242            TenantSearchIndex::OpenSearch(index) => {
243                index.search_index_file(scope, file_id, query).await
244            }
245            TenantSearchIndex::Database(index) => {
246                index.search_index_file(scope, file_id, query).await
247            }
248        }
249    }
250
251    /// Adds the provided data to the search index
252    #[tracing::instrument(skip(self))]
253    pub async fn add_data(&self, data: Vec<SearchIndexData>) -> Result<(), SearchError> {
254        match self {
255            TenantSearchIndex::Typesense(index) => index.add_data(data).await,
256            TenantSearchIndex::OpenSearch(index) => index.add_data(data).await,
257            TenantSearchIndex::Database(index) => index.add_data(data).await,
258        }
259    }
260
261    /// Updates the provided data in the search index
262    #[tracing::instrument(skip(self))]
263    pub async fn update_data(
264        &self,
265        item_id: Uuid,
266        data: UpdateSearchIndexData,
267    ) -> Result<(), SearchError> {
268        match self {
269            TenantSearchIndex::Typesense(index) => index.update_data(item_id, data).await,
270            TenantSearchIndex::OpenSearch(index) => index.update_data(item_id, data).await,
271            TenantSearchIndex::Database(index) => index.update_data(item_id, data).await,
272        }
273    }
274
275    /// Deletes the provided data from the search index by `id`
276    #[tracing::instrument(skip(self))]
277    pub async fn delete_data(&self, id: Uuid) -> Result<(), SearchError> {
278        match self {
279            TenantSearchIndex::Typesense(index) => index.delete_data(id).await,
280            TenantSearchIndex::OpenSearch(index) => index.delete_data(id).await,
281            TenantSearchIndex::Database(index) => index.delete_data(id).await,
282        }
283    }
284
285    /// Deletes all data contained within the specified `scope`
286    #[tracing::instrument(skip(self))]
287    pub async fn delete_by_scope(
288        &self,
289        scope: DocumentBoxScopeRawRef<'_>,
290    ) -> Result<(), SearchError> {
291        match self {
292            TenantSearchIndex::Typesense(index) => index.delete_by_scope(scope).await,
293            TenantSearchIndex::OpenSearch(index) => index.delete_by_scope(scope).await,
294            TenantSearchIndex::Database(index) => index.delete_by_scope(scope).await,
295        }
296    }
297
298    /// Get all pending migrations based on the `applied_names` list of applied migrations
299    #[tracing::instrument(skip(self))]
300    pub async fn get_pending_migrations(
301        &self,
302        applied_names: Vec<String>,
303    ) -> Result<Vec<String>, SearchError> {
304        match self {
305            TenantSearchIndex::Typesense(index) => {
306                index.get_pending_migrations(applied_names).await
307            }
308            TenantSearchIndex::OpenSearch(index) => {
309                index.get_pending_migrations(applied_names).await
310            }
311            TenantSearchIndex::Database(index) => index.get_pending_migrations(applied_names).await,
312        }
313    }
314
315    /// Apply a specific migration for a `tenant` by `name`
316    #[tracing::instrument(skip(self))]
317    pub async fn apply_migration(
318        &self,
319        tenant: &Tenant,
320        root_t: &mut DbTransaction<'_>,
321        tenant_t: &mut DbTransaction<'_>,
322        name: &str,
323    ) -> Result<(), SearchError> {
324        // Apply migration logic
325        match self {
326            TenantSearchIndex::Typesense(index) => {
327                index
328                    .apply_migration(tenant, root_t, tenant_t, name)
329                    .await?
330            }
331
332            TenantSearchIndex::OpenSearch(index) => {
333                index
334                    .apply_migration(tenant, root_t, tenant_t, name)
335                    .await?
336            }
337
338            TenantSearchIndex::Database(index) => {
339                index
340                    .apply_migration(tenant, root_t, tenant_t, name)
341                    .await?
342            }
343        }
344
345        // Store the applied migration
346        TenantMigration::create(
347            root_t.deref_mut(),
348            CreateTenantMigration {
349                tenant_id: tenant.id,
350                env: tenant.env.clone(),
351                name: name.to_string(),
352                applied_at: Utc::now(),
353            },
354        )
355        .await
356        .map_err(|error| {
357            tracing::error!(?error, "failed to create tenant migration");
358            SearchError::Migration
359        })?;
360
361        Ok(())
362    }
363
364    /// Apply all pending migrations for a `tenant`
365    ///
366    /// When `target_migration_name` is specified only that target migration will
367    /// be run
368    #[tracing::instrument(skip_all, fields(?tenant, ?target_migration_name))]
369    pub async fn apply_migrations(
370        &self,
371        tenant: &Tenant,
372        root_t: &mut DbTransaction<'_>,
373        tenant_t: &mut DbTransaction<'_>,
374        target_migration_name: Option<&str>,
375    ) -> Result<(), SearchError> {
376        let applied_migrations =
377            TenantMigration::find_by_tenant(root_t.deref_mut(), tenant.id, &tenant.env)
378                .await
379                .map_err(|error| {
380                    tracing::error!(?error, "failed to query tenant migrations");
381                    SearchError::Migration
382                })?;
383        let pending_migrations = self
384            .get_pending_migrations(
385                applied_migrations
386                    .into_iter()
387                    .map(|value| value.name)
388                    .collect(),
389            )
390            .await?;
391
392        for migration_name in pending_migrations {
393            // If targeting a specific migration only apply the target one
394            if target_migration_name
395                .is_some_and(|target_migration_name| target_migration_name.ne(&migration_name))
396            {
397                continue;
398            }
399
400            // Apply the migration
401            if let Err(error) = self
402                .apply_migration(tenant, root_t, tenant_t, &migration_name)
403                .await
404            {
405                tracing::error!(%migration_name, ?error, "failed to apply migration");
406                return Err(error);
407            }
408        }
409
410        Ok(())
411    }
412}
413
414pub(crate) trait SearchIndex: Send + Sync + 'static {
415    async fn create_index(&self) -> Result<(), SearchError>;
416
417    async fn index_exists(&self) -> Result<bool, SearchError>;
418
419    async fn delete_index(&self) -> Result<(), SearchError>;
420
421    async fn search_index(
422        &self,
423        scope: &[DocumentBoxScopeRaw],
424        query: SearchRequest,
425        folder_children: Option<Vec<FolderId>>,
426    ) -> Result<SearchResults, SearchError>;
427
428    async fn search_index_file(
429        &self,
430        scope: &DocumentBoxScopeRaw,
431        file_id: FileId,
432        query: FileSearchRequest,
433    ) -> Result<FileSearchResults, SearchError>;
434
435    async fn add_data(&self, data: Vec<SearchIndexData>) -> Result<(), SearchError>;
436
437    async fn update_data(
438        &self,
439        item_id: Uuid,
440        data: UpdateSearchIndexData,
441    ) -> Result<(), SearchError>;
442
443    async fn delete_data(&self, id: Uuid) -> Result<(), SearchError>;
444
445    async fn delete_by_scope(&self, scope: DocumentBoxScopeRawRef<'_>) -> Result<(), SearchError>;
446
447    async fn get_pending_migrations(
448        &self,
449        applied_names: Vec<String>,
450    ) -> Result<Vec<String>, SearchError>;
451
452    async fn apply_migration(
453        &self,
454        tenant: &Tenant,
455        root_t: &mut DbTransaction<'_>,
456        t: &mut DbTransaction<'_>,
457        name: &str,
458    ) -> Result<(), SearchError>;
459}