use docbox_database::{
DbErr, DbPool, DbResult,
models::{
document_box::DocumentBoxScopeRaw,
file::{File, FileWithScope},
folder::Folder,
generated_file::{GeneratedFile, GeneratedFileType},
link::{Link, LinkWithScope},
},
};
use docbox_processing::{office::is_pdf_compatible, pdf::split_pdf_text_pages};
use docbox_search::{
SearchError, TenantSearchIndex,
models::{DocumentPage, SearchIndexData, SearchIndexType},
};
use docbox_storage::{StorageLayer, StorageLayerError};
use futures::{StreamExt, future::BoxFuture, stream::FuturesUnordered};
use std::{str::FromStr, string::FromUtf8Error};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum RebuildTenantIndexError {
#[error(transparent)]
Database(#[from] DbErr),
#[error(transparent)]
Search(#[from] SearchError),
#[error(transparent)]
WriteIndexData(std::io::Error),
#[error(transparent)]
SerializeIndexData(serde_json::Error),
}
pub async fn rebuild_tenant_index(
db: &DbPool,
search: &TenantSearchIndex,
storage: &StorageLayer,
) -> Result<(), RebuildTenantIndexError> {
tracing::info!("started re-indexing tenant");
let index_data = recreate_search_index_data(db, storage).await?;
tracing::debug!("all data loaded: {}", index_data.len());
{
let serialized = serde_json::to_string(&index_data)
.map_err(RebuildTenantIndexError::SerializeIndexData)?;
tokio::fs::write("index_data.json", serialized)
.await
.map_err(RebuildTenantIndexError::WriteIndexData)?;
}
apply_rebuilt_tenant_index(search, index_data).await?;
Ok(())
}
pub async fn apply_rebuilt_tenant_index(
search: &TenantSearchIndex,
data: Vec<SearchIndexData>,
) -> Result<(), SearchError> {
_ = search.create_index().await;
let mut iter = data.into_iter();
loop {
let chunk: Vec<_> = iter.by_ref().take(INDEX_CHUNK_SIZE).collect();
if chunk.is_empty() {
break;
}
search.add_data(chunk).await?;
}
Ok(())
}
pub async fn recreate_search_index_data(
db: &DbPool,
storage: &StorageLayer,
) -> DbResult<Vec<SearchIndexData>> {
let links = create_links_index_data(db).await?;
let folders = create_folders_index_data(db).await?;
let files = create_files_index_data(db, storage).await?;
let index_data = links
.into_iter()
.chain(folders)
.chain(files)
.collect::<Vec<SearchIndexData>>();
Ok(index_data)
}
const INDEX_CHUNK_SIZE: usize = 5000;
const DATABASE_PAGE_SIZE: u64 = 1000;
const FILE_PROCESS_SIZE: usize = 500;
pub async fn create_links_index_data(db: &DbPool) -> DbResult<Vec<SearchIndexData>> {
let mut page_index = 0;
let mut data = Vec::new();
loop {
let links = match Link::all(db, page_index * DATABASE_PAGE_SIZE, DATABASE_PAGE_SIZE).await {
Ok(value) => value,
Err(error) => {
tracing::error!(?error, ?page_index, "failed to load links page");
return Err(error);
}
};
let is_end = (links.len() as u64) < DATABASE_PAGE_SIZE;
for LinkWithScope { link, scope } in links {
data.push(SearchIndexData {
ty: SearchIndexType::Link,
item_id: link.id,
folder_id: link.folder_id,
name: link.name.to_string(),
mime: None,
content: Some(link.value.clone()),
pages: None,
created_at: link.created_at,
created_by: link.created_by.clone(),
document_box: scope.clone(),
})
}
if is_end {
break;
}
page_index += 1;
}
Ok(data)
}
pub async fn create_folders_index_data(db: &DbPool) -> DbResult<Vec<SearchIndexData>> {
let mut page_index = 0;
let mut data = Vec::new();
loop {
let folders =
match Folder::all_non_root(db, page_index * DATABASE_PAGE_SIZE, DATABASE_PAGE_SIZE)
.await
{
Ok(value) => value,
Err(error) => {
tracing::error!(?error, ?page_index, "failed to load folders page");
return Err(error);
}
};
let is_end = (folders.len() as u64) < DATABASE_PAGE_SIZE;
for folder in folders {
let folder_id = match folder.folder_id {
Some(value) => value,
None => continue,
};
data.push(SearchIndexData {
ty: SearchIndexType::Folder,
item_id: folder.id,
folder_id,
name: folder.name.to_string(),
mime: None,
content: None,
pages: None,
created_at: folder.created_at,
created_by: folder.created_by.clone(),
document_box: folder.document_box.clone(),
})
}
if is_end {
break;
}
page_index += 1;
}
Ok(data)
}
pub async fn create_files_index_data(
db: &DbPool,
storage: &StorageLayer,
) -> DbResult<Vec<SearchIndexData>> {
let mut page_index = 0;
let mut data = Vec::new();
let mut files_for_processing = Vec::new();
loop {
let files = match File::all(db, page_index * DATABASE_PAGE_SIZE, DATABASE_PAGE_SIZE).await {
Ok(value) => value,
Err(error) => {
tracing::error!(?error, ?page_index, "failed to load files page");
return Err(error);
}
};
let is_end = (files.len() as u64) < DATABASE_PAGE_SIZE;
for FileWithScope { file, scope } in files {
let mime = match mime::Mime::from_str(&file.mime) {
Ok(value) => value,
Err(error) => {
tracing::error!(?error, ?file, "file has an invalid mime type");
continue;
}
};
if file.encrypted || !is_pdf_compatible(&mime) {
data.push(SearchIndexData {
ty: SearchIndexType::File,
item_id: file.id,
folder_id: file.folder_id,
name: file.name,
mime: Some(file.mime),
content: None,
created_at: file.created_at,
created_by: file.created_by,
document_box: scope,
pages: None,
})
} else {
files_for_processing.push((file, scope));
}
}
if is_end {
break;
}
page_index += 1;
}
for chunk in files_for_processing.chunks(FILE_PROCESS_SIZE) {
let mut results: Vec<SearchIndexData> = chunk
.iter()
.map(|(file, scope)| -> BoxFuture<'_, SearchIndexData> {
Box::pin(async move {
let pages =
match try_pdf_compatible_document_pages(db, storage, scope, file).await {
Ok(value) => value,
Err(error) => {
tracing::error!(?error, "failed to re-create pdf index data pages");
return SearchIndexData {
ty: SearchIndexType::File,
item_id: file.id,
folder_id: file.folder_id,
name: file.name.clone(),
mime: Some(file.mime.clone()),
content: None,
created_at: file.created_at,
created_by: file.created_by.clone(),
document_box: scope.clone(),
pages: None,
};
}
};
SearchIndexData {
ty: SearchIndexType::File,
item_id: file.id,
folder_id: file.folder_id,
name: file.name.clone(),
mime: Some(file.mime.clone()),
content: None,
created_at: file.created_at,
created_by: file.created_by.clone(),
document_box: scope.clone(),
pages: Some(pages),
}
})
})
.collect::<FuturesUnordered<BoxFuture<'_, SearchIndexData>>>()
.collect()
.await;
data.append(&mut results);
}
Ok(data)
}
#[derive(Debug, Error)]
pub enum PdfCompatibleRebuildError {
#[error("file is missing text content")]
MissingTextContent,
#[error(transparent)]
Database(#[from] DbErr),
#[error(transparent)]
Storage(#[from] StorageLayerError),
#[error(transparent)]
InvalidTextContent(#[from] FromUtf8Error),
}
pub async fn try_pdf_compatible_document_pages(
db: &DbPool,
storage: &StorageLayer,
scope: &DocumentBoxScopeRaw,
file: &File,
) -> Result<Vec<DocumentPage>, PdfCompatibleRebuildError> {
let text_file = GeneratedFile::find(db, scope, file.id, GeneratedFileType::TextContent)
.await?
.ok_or(PdfCompatibleRebuildError::MissingTextContent)?;
tracing::debug!(?text_file, "loaded file generated text content");
let text_content = storage
.get_file(&text_file.file_key)
.await?
.collect_bytes()
.await
.inspect_err(|error| {
tracing::error!(?error, "failed to load pdf bytes from s3 stream");
})?;
let text_content = text_content.to_vec();
let text_content =
String::from_utf8(text_content).map_err(PdfCompatibleRebuildError::InvalidTextContent)?;
let pages = split_pdf_text_pages(&text_content);
let pages = pages
.into_iter()
.enumerate()
.map(|(page, content)| DocumentPage {
page: page as u64,
content: content.to_string(),
})
.collect();
Ok(pages)
}