Add embedded Tantivy full-text content search
/api/search now finds files by CONTENT as well as by name: BM25-ranked
matches over extracted text (PDF, Office OOXML/ODF, plain text/code)
with typo-tolerant fuzzy terms and search-as-you-type prefix matching,
served from an embedded Tantivy index at {storage}/.search-index.
Pipeline (all off the request path, mirroring tree-etag + thumbnails):
- statement triggers on storage.files append to a durable dirty queue
(storage.search_index_dirty) - every write surface (REST, WebDAV,
NextCloud, WOPI, trash) is covered, crash-safe by construction
- ContentIndexWorker drains the queue on the maintenance pool, extracts
text once per unique BLAKE3 blob (storage.blob_extracted_text cache:
N copies = 1 extraction, renames/moves = 0 re-extraction) and applies
batched single-writer Tantivy commits; queue rows are deleted only
after the commit succeeds (at-least-once, idempotent upserts)
- the index is a derived artifact: a version-marker mismatch wipes and
reseeds it from Postgres, which remains the single source of truth
SearchService merges content hits into the existing name search: hits
are hydrated through ONE SQL round-trip that re-applies user scope,
trash state and every active filter (a stale index id can never leak),
scored below name matches, and returned with a plain-text snippet and
a match_source field. Index failure or
OXICLOUD_ENABLE_CONTENT_SEARCH=false degrades to name-only search; a
discard-only janitor keeps the trigger-fed queue bounded while disabled.
The frontend renders the snippet under the file name in list view.
New dependencies: tantivy 0.26, zip 8.6 (deflate only), pdf-extract 0.10.
https://claude.ai/code/session_01Sc7F4xbo83YbFAQ4xEeDrX
This commit is contained in:
@@ -905,6 +905,44 @@ impl Default for FeaturesConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Content-search configuration (embedded Tantivy index over file names and
|
||||
/// extracted file content).
|
||||
///
|
||||
/// The index is a derived artifact fed by a background worker on the
|
||||
/// maintenance pool — none of these knobs affect request-path latency.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContentSearchConfig {
|
||||
/// Master switch. When disabled, search falls back to name-only SQL and
|
||||
/// a janitor keeps the (always-installed) dirty queue empty.
|
||||
/// Env: `OXICLOUD_ENABLE_CONTENT_SEARCH`.
|
||||
pub enabled: bool,
|
||||
/// Index directory. Default: `{storage_path}/.search-index`.
|
||||
/// Env: `OXICLOUD_CONTENT_INDEX_DIR`.
|
||||
pub index_dir: Option<PathBuf>,
|
||||
/// Worker drain cadence in milliseconds — the upper bound on how long a
|
||||
/// new upload takes to become content-searchable. Default: 1500.
|
||||
/// Env: `OXICLOUD_CONTENT_INDEX_FLUSH_MS`.
|
||||
pub flush_interval_ms: u64,
|
||||
/// Files larger than this are indexed by NAME only (no text extraction).
|
||||
/// Default: 32 MiB. Env: `OXICLOUD_CONTENT_INDEX_MAX_FILE_BYTES`.
|
||||
pub max_extract_file_bytes: u64,
|
||||
/// Hard cap on extracted text per blob fed to the index. Default: 1 MiB.
|
||||
/// Env: `OXICLOUD_CONTENT_INDEX_MAX_TEXT_BYTES`.
|
||||
pub max_text_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for ContentSearchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
index_dir: None,
|
||||
flush_interval_ms: 1500,
|
||||
max_extract_file_bytes: 32 * 1024 * 1024,
|
||||
max_text_bytes: 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global application configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppConfig {
|
||||
@@ -944,6 +982,8 @@ pub struct AppConfig {
|
||||
pub magic_link: MagicLinkConfig,
|
||||
/// I18n configuration (default locale for server-rendered surfaces)
|
||||
pub i18n: I18nConfig,
|
||||
/// Content-search configuration (embedded full-text index)
|
||||
pub content_search: ContentSearchConfig,
|
||||
}
|
||||
|
||||
/// Server-side i18n knobs.
|
||||
@@ -995,6 +1035,7 @@ impl Default for AppConfig {
|
||||
smtp: SmtpConfig::default(),
|
||||
magic_link: MagicLinkConfig::default(),
|
||||
i18n: I18nConfig::default(),
|
||||
content_search: ContentSearchConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1257,6 +1298,33 @@ impl AppConfig {
|
||||
config.features.enable_music = val;
|
||||
}
|
||||
|
||||
// Content search (embedded Tantivy index)
|
||||
if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.content_search.enabled = val;
|
||||
}
|
||||
if let Ok(dir) = env::var("OXICLOUD_CONTENT_INDEX_DIR")
|
||||
&& !dir.trim().is_empty()
|
||||
{
|
||||
config.content_search.index_dir = Some(PathBuf::from(dir.trim()));
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_CONTENT_INDEX_FLUSH_MS").map(|v| v.parse::<u64>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.content_search.flush_interval_ms = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_CONTENT_INDEX_MAX_FILE_BYTES").map(|v| v.parse::<u64>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.content_search.max_extract_file_bytes = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_CONTENT_INDEX_MAX_TEXT_BYTES").map(|v| v.parse::<usize>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.content_search.max_text_bytes = val;
|
||||
}
|
||||
|
||||
if let Ok(v) = env::var("OXICLOUD_EXPOSE_SYSTEM_USERS").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
|
||||
+83
-3
@@ -40,6 +40,8 @@ use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nSer
|
||||
use crate::infrastructure::services::nextcloud_chunked_upload_service::NextcloudChunkedUploadService;
|
||||
use crate::infrastructure::services::path_service::PathService;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use crate::infrastructure::services::search_index::content_index_worker::ContentIndexWorker;
|
||||
use crate::infrastructure::services::search_index::tantivy_content_index::TantivyContentIndex;
|
||||
use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService;
|
||||
|
||||
use crate::application::services::app_password_service::AppPasswordService;
|
||||
@@ -439,6 +441,7 @@ impl AppServiceFactory {
|
||||
repos: &RepositoryServices,
|
||||
trash_service: Option<Arc<TrashService>>,
|
||||
authz: &Arc<PgAclEngine>,
|
||||
content_index: Option<Arc<TantivyContentIndex>>,
|
||||
) -> ApplicationServices {
|
||||
// Main services
|
||||
let folder_service = Arc::new(FolderService::new(
|
||||
@@ -484,10 +487,15 @@ impl AppServiceFactory {
|
||||
|
||||
let i18n_service = Arc::new(I18nApplicationService::new(repos.i18n_repository.clone()));
|
||||
|
||||
// Search service with cache
|
||||
// Search service with cache. The optional content index widens the
|
||||
// same `/api/search` endpoint to full-text content matches.
|
||||
let content_index_port: Option<
|
||||
Arc<dyn crate::application::ports::content_index_ports::ContentIndexPort>,
|
||||
> = content_index.map(|idx| idx as _);
|
||||
let search_service: Option<Arc<SearchService>> = Some(Arc::new(SearchService::new(
|
||||
repos.file_read_repository.clone(),
|
||||
repos.folder_repository.clone(),
|
||||
content_index_port,
|
||||
300, // Cache TTL in seconds (5 minutes)
|
||||
1000, // Maximum cache entries
|
||||
)));
|
||||
@@ -688,6 +696,66 @@ impl AppServiceFactory {
|
||||
tracing::info!("Tree-ETag flush service initialized");
|
||||
}
|
||||
|
||||
/// Opens (or rebuilds) the embedded Tantivy content index. Returns the
|
||||
/// index plus a reseed flag (true when the on-disk index was missing or
|
||||
/// version-stale and must be repopulated from `storage.files`). Any
|
||||
/// failure degrades to name-only search instead of failing startup.
|
||||
fn create_content_index(&self) -> Option<(Arc<TantivyContentIndex>, bool)> {
|
||||
if !self.config.content_search.enabled {
|
||||
tracing::info!("Content search is disabled in configuration");
|
||||
return None;
|
||||
}
|
||||
let dir = self
|
||||
.config
|
||||
.content_search
|
||||
.index_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.storage_path.join(".search-index"));
|
||||
|
||||
match TantivyContentIndex::open_or_rebuild(&dir) {
|
||||
Ok((index, needs_reseed)) => {
|
||||
tracing::info!(
|
||||
"Content index ready at {} ({} doc(s), reseed: {})",
|
||||
dir.display(),
|
||||
index.num_docs(),
|
||||
needs_reseed
|
||||
);
|
||||
Some((Arc::new(index), needs_reseed))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Content index unavailable — search will be name-only: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the content-index pipeline on the maintenance pool. The
|
||||
/// `storage.files` triggers enqueue unconditionally, so when the feature
|
||||
/// is off (or the index failed to open) a discard-only janitor keeps the
|
||||
/// dirty queue bounded instead.
|
||||
fn start_content_index_job(
|
||||
&self,
|
||||
maintenance_pool: &Arc<PgPool>,
|
||||
core: &CoreServices,
|
||||
content_index: Option<(Arc<TantivyContentIndex>, bool)>,
|
||||
) {
|
||||
match content_index {
|
||||
Some((index, needs_reseed)) => {
|
||||
ContentIndexWorker::new(
|
||||
maintenance_pool.clone(),
|
||||
core.dedup_service.clone(),
|
||||
index,
|
||||
self.config.content_search.flush_interval_ms,
|
||||
self.config.content_search.max_extract_file_bytes,
|
||||
self.config.content_search.max_text_bytes,
|
||||
)
|
||||
.start(needs_reseed);
|
||||
tracing::info!("Content-index worker initialized");
|
||||
}
|
||||
None => ContentIndexWorker::start_drain_only_janitor(maintenance_pool.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the complete AppState using all factory services.
|
||||
///
|
||||
/// This is the main entry point that replaces all manual logic in `main.rs`.
|
||||
@@ -731,9 +799,19 @@ impl AppServiceFactory {
|
||||
.create_trash_service(&repos, &core, &authorization)
|
||||
.await;
|
||||
|
||||
// 3c. Content index (embedded Tantivy) — opened before application
|
||||
// services so SearchService can hold the query port; the feeding
|
||||
// worker starts further down with the maintenance pool.
|
||||
let content_index = self.create_content_index();
|
||||
|
||||
// 4. Application services (with trash + authz already wired)
|
||||
let mut apps =
|
||||
self.create_application_services(&core, &repos, trash_service.clone(), &authorization);
|
||||
let mut apps = self.create_application_services(
|
||||
&core,
|
||||
&repos,
|
||||
trash_service.clone(),
|
||||
&authorization,
|
||||
content_index.as_ref().map(|(idx, _)| idx.clone()),
|
||||
);
|
||||
|
||||
// 5. Share service
|
||||
let share_service = self.create_share_service(&repos, &pool, &authorization);
|
||||
@@ -778,6 +856,8 @@ impl AppServiceFactory {
|
||||
|
||||
self.start_tree_etag_flush_job(&maintenance_pool);
|
||||
|
||||
self.start_content_index_job(&maintenance_pool, &core, content_index);
|
||||
|
||||
// User-lifecycle dispatcher. Hook order is registration order;
|
||||
// document dependencies inline if/when any arise. Today:
|
||||
// 1. AuditLifecycleHook — fires first so the
|
||||
|
||||
Reference in New Issue
Block a user