diff --git a/Cargo.lock b/Cargo.lock index be75bd332..83b5cbfab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1075,9 +1075,9 @@ dependencies = [ [[package]] name = "cellulite" -version = "0.3.1-nested-rtxns" +version = "0.3.1-nested-rtxns-2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db298d57a80b9284327800b394ee3921307c2fdda87c6d37202f5cf400478981" +checksum = "f22d721963ead1a144f10cb8b53dc9469e760723b069123c7c7bc675c7354270" dependencies = [ "crossbeam", "geo", diff --git a/crates/dump/src/lib.rs b/crates/dump/src/lib.rs index fdbd701be..5f2b63fae 100644 --- a/crates/dump/src/lib.rs +++ b/crates/dump/src/lib.rs @@ -158,6 +158,9 @@ pub enum KindDump { UpgradeDatabase { from: (u32, u32, u32), }, + IndexCompaction { + index_uid: String, + }, } impl From for TaskDump { @@ -240,6 +243,9 @@ impl From for KindDump { KindWithContent::UpgradeDatabase { from: version } => { KindDump::UpgradeDatabase { from: version } } + KindWithContent::IndexCompaction { index_uid } => { + KindDump::IndexCompaction { index_uid } + } } } } diff --git a/crates/index-scheduler/src/dump.rs b/crates/index-scheduler/src/dump.rs index e5e7a5d8c..7cc4b37a2 100644 --- a/crates/index-scheduler/src/dump.rs +++ b/crates/index-scheduler/src/dump.rs @@ -234,6 +234,9 @@ impl<'a> Dump<'a> { } } KindDump::UpgradeDatabase { from } => KindWithContent::UpgradeDatabase { from }, + KindDump::IndexCompaction { index_uid } => { + KindWithContent::IndexCompaction { index_uid } + } }, }; diff --git a/crates/index-scheduler/src/index_mapper/mod.rs b/crates/index-scheduler/src/index_mapper/mod.rs index 6103fe7fc..18c997d6f 100644 --- a/crates/index-scheduler/src/index_mapper/mod.rs +++ b/crates/index-scheduler/src/index_mapper/mod.rs @@ -341,6 +341,26 @@ impl IndexMapper { Ok(()) } + /// Closes the specified index. + /// + /// This operation involves closing the underlying environment and so can take a long time to complete. + /// + /// # Panics + /// + /// - If the Index corresponding to the passed name is concurrently being deleted/resized or cannot be found in the + /// in memory hash map. + pub fn close_index(&self, rtxn: &RoTxn, name: &str) -> Result<()> { + let uuid = self + .index_mapping + .get(rtxn, name)? + .ok_or_else(|| Error::IndexNotFound(name.to_string()))?; + + // We remove the index from the in-memory index map. + self.index_map.write().unwrap().close_for_resize(&uuid, self.enable_mdb_writemap, 0); + + Ok(()) + } + /// Return an index, may open it if it wasn't already opened. pub fn index(&self, rtxn: &RoTxn, name: &str) -> Result { if let Some((current_name, current_index)) = diff --git a/crates/index-scheduler/src/insta_snapshot.rs b/crates/index-scheduler/src/insta_snapshot.rs index df043ad87..d44a386be 100644 --- a/crates/index-scheduler/src/insta_snapshot.rs +++ b/crates/index-scheduler/src/insta_snapshot.rs @@ -317,6 +317,9 @@ fn snapshot_details(d: &Details) -> String { Details::UpgradeDatabase { from, to } => { format!("{{ from: {from:?}, to: {to:?} }}") } + Details::IndexCompaction { index_uid, pre_compaction_size, post_compaction_size } => { + format!("{{ index_uid: {index_uid:?}, pre_compaction_size: {pre_compaction_size:?}, post_compaction_size: {post_compaction_size:?} }}") + } } } diff --git a/crates/index-scheduler/src/processing.rs b/crates/index-scheduler/src/processing.rs index 3da81f143..cf6c8c686 100644 --- a/crates/index-scheduler/src/processing.rs +++ b/crates/index-scheduler/src/processing.rs @@ -138,6 +138,17 @@ make_enum_progress! { } } +make_enum_progress! { + pub enum IndexCompaction { + RetrieveTheIndex, + CreateTemporaryFile, + CopyAndCompactTheIndex, + PersistTheCompactedIndex, + CloseTheIndex, + ReopenTheIndex, + } +} + make_enum_progress! { pub enum InnerSwappingTwoIndexes { RetrieveTheTasks, diff --git a/crates/index-scheduler/src/scheduler/autobatcher.rs b/crates/index-scheduler/src/scheduler/autobatcher.rs index a88a9f0bf..94f3e64fc 100644 --- a/crates/index-scheduler/src/scheduler/autobatcher.rs +++ b/crates/index-scheduler/src/scheduler/autobatcher.rs @@ -25,6 +25,7 @@ enum AutobatchKind { IndexDeletion, IndexUpdate, IndexSwap, + IndexCompaction, } impl AutobatchKind { @@ -68,6 +69,7 @@ impl From for AutobatchKind { KindWithContent::IndexCreation { .. } => AutobatchKind::IndexCreation, KindWithContent::IndexUpdate { .. } => AutobatchKind::IndexUpdate, KindWithContent::IndexSwap { .. } => AutobatchKind::IndexSwap, + KindWithContent::IndexCompaction { .. } => AutobatchKind::IndexCompaction, KindWithContent::TaskCancelation { .. } | KindWithContent::TaskDeletion { .. } | KindWithContent::DumpCreation { .. } @@ -118,6 +120,9 @@ pub enum BatchKind { IndexSwap { id: TaskId, }, + IndexCompaction { + id: TaskId, + }, } impl BatchKind { @@ -183,6 +188,13 @@ impl BatchKind { )), false, ), + K::IndexCompaction => ( + Break(( + BatchKind::IndexCompaction { id: task_id }, + BatchStopReason::TaskCannotBeBatched { kind, id: task_id }, + )), + false, + ), K::DocumentClear => (Continue(BatchKind::DocumentClear { ids: vec![task_id] }), false), K::DocumentImport { allow_index_creation, primary_key: pk } if primary_key.is_none() || pk.is_none() || primary_key == pk.as_deref() => @@ -287,8 +299,10 @@ impl BatchKind { }; match (self, autobatch_kind) { - // We don't batch any of these operations - (this, K::IndexCreation | K::IndexUpdate | K::IndexSwap | K::DocumentEdition) => Break((this, BatchStopReason::TaskCannotBeBatched { kind, id })), + // We don't batch any of these operations + (this, K::IndexCreation | K::IndexUpdate | K::IndexSwap | K::DocumentEdition | K::IndexCompaction) => { + Break((this, BatchStopReason::TaskCannotBeBatched { kind, id })) + }, // We must not batch tasks that don't have the same index creation rights if the index doesn't already exists. (this, kind) if !index_already_exists && this.allow_index_creation() == Some(false) && kind.allow_index_creation() == Some(true) => { Break((this, BatchStopReason::IndexCreationMismatch { id })) @@ -483,6 +497,7 @@ impl BatchKind { | BatchKind::IndexDeletion { .. } | BatchKind::IndexUpdate { .. } | BatchKind::IndexSwap { .. } + | BatchKind::IndexCompaction { .. } | BatchKind::DocumentEdition { .. }, _, ) => { diff --git a/crates/index-scheduler/src/scheduler/create_batch.rs b/crates/index-scheduler/src/scheduler/create_batch.rs index c598ad405..06d796548 100644 --- a/crates/index-scheduler/src/scheduler/create_batch.rs +++ b/crates/index-scheduler/src/scheduler/create_batch.rs @@ -55,6 +55,10 @@ pub(crate) enum Batch { UpgradeDatabase { tasks: Vec, }, + IndexCompaction { + index_uid: String, + task: Task, + }, } #[derive(Debug)] @@ -110,7 +114,8 @@ impl Batch { | Batch::Dump(task) | Batch::IndexCreation { task, .. } | Batch::Export { task } - | Batch::IndexUpdate { task, .. } => { + | Batch::IndexUpdate { task, .. } + | Batch::IndexCompaction { task, .. } => { RoaringBitmap::from_sorted_iter(std::iter::once(task.uid)).unwrap() } Batch::SnapshotCreation(tasks) @@ -155,7 +160,8 @@ impl Batch { IndexOperation { op, .. } => Some(op.index_uid()), IndexCreation { index_uid, .. } | IndexUpdate { index_uid, .. } - | IndexDeletion { index_uid, .. } => Some(index_uid), + | IndexDeletion { index_uid, .. } + | IndexCompaction { index_uid, .. } => Some(index_uid), } } } @@ -175,6 +181,7 @@ impl fmt::Display for Batch { Batch::IndexUpdate { .. } => f.write_str("IndexUpdate")?, Batch::IndexDeletion { .. } => f.write_str("IndexDeletion")?, Batch::IndexSwap { .. } => f.write_str("IndexSwap")?, + Batch::IndexCompaction { .. } => f.write_str("IndexCompaction")?, Batch::Export { .. } => f.write_str("Export")?, Batch::UpgradeDatabase { .. } => f.write_str("UpgradeDatabase")?, }; @@ -430,6 +437,12 @@ impl IndexScheduler { current_batch.processing(Some(&mut task)); Ok(Some(Batch::IndexSwap { task })) } + BatchKind::IndexCompaction { id } => { + let mut task = + self.queue.tasks.get_task(rtxn, id)?.ok_or(Error::CorruptedTaskQueue)?; + current_batch.processing(Some(&mut task)); + Ok(Some(Batch::IndexCompaction { index_uid, task })) + } } } diff --git a/crates/index-scheduler/src/scheduler/process_batch.rs b/crates/index-scheduler/src/scheduler/process_batch.rs index efa137cdb..69ae64a70 100644 --- a/crates/index-scheduler/src/scheduler/process_batch.rs +++ b/crates/index-scheduler/src/scheduler/process_batch.rs @@ -1,22 +1,26 @@ use std::collections::{BTreeSet, HashMap, HashSet}; +use std::io::{Seek, SeekFrom}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::sync::atomic::Ordering; +use byte_unit::Byte; use meilisearch_types::batches::{BatchEnqueuedAt, BatchId}; use meilisearch_types::heed::{RoTxn, RwTxn}; +use meilisearch_types::milli::heed::CompactionOption; use meilisearch_types::milli::progress::{Progress, VariableNameStep}; use meilisearch_types::milli::{self, ChannelCongestion}; use meilisearch_types::tasks::{Details, IndexSwap, Kind, KindWithContent, Status, Task}; use meilisearch_types::versioning::{VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH}; use milli::update::Settings as MilliSettings; use roaring::RoaringBitmap; +use tempfile::PersistError; use time::OffsetDateTime; use super::create_batch::Batch; use crate::processing::{ AtomicBatchStep, AtomicTaskStep, CreateIndexProgress, DeleteIndexProgress, FinalizingIndexStep, - InnerSwappingTwoIndexes, SwappingTheIndexes, TaskCancelationProgress, TaskDeletionProgress, - UpdateIndexProgress, + IndexCompaction, InnerSwappingTwoIndexes, SwappingTheIndexes, TaskCancelationProgress, + TaskDeletionProgress, UpdateIndexProgress, }; use crate::utils::{ self, remove_n_tasks_datetime_earlier_than, remove_task_datetime, swap_index_uid_in_task, @@ -418,6 +422,47 @@ impl IndexScheduler { task.status = Status::Succeeded; Ok((vec![task], ProcessBatchInfo::default())) } + Batch::IndexCompaction { index_uid: _, mut task } => { + let KindWithContent::IndexCompaction { index_uid } = &task.kind else { + unreachable!() + }; + + let rtxn = self.env.read_txn()?; + let ret = catch_unwind(AssertUnwindSafe(|| { + self.apply_compaction(&rtxn, &progress, index_uid) + })); + + let (pre_size, post_size) = match ret { + Ok(Ok(stats)) => stats, + Ok(Err(Error::AbortedTask)) => return Err(Error::AbortedTask), + Ok(Err(e)) => return Err(e), + Err(e) => { + let msg = match e.downcast_ref::<&'static str>() { + Some(s) => *s, + None => match e.downcast_ref::() { + Some(s) => &s[..], + None => "Box", + }, + }; + return Err(Error::Export(Box::new(Error::ProcessBatchPanicked( + msg.to_string(), + )))); + } + }; + + task.status = Status::Succeeded; + if let Some(Details::IndexCompaction { + index_uid: _, + pre_compaction_size, + post_compaction_size, + }) = task.details.as_mut() + { + *pre_compaction_size = Some(Byte::from_u64(pre_size)); + *post_compaction_size = Some(Byte::from_u64(post_size)); + } + + Ok((vec![task], ProcessBatchInfo::default())) + } Batch::Export { mut task } => { let KindWithContent::Export { url, api_key, payload_size, indexes } = &task.kind else { @@ -493,6 +538,91 @@ impl IndexScheduler { } } + fn apply_compaction( + &self, + rtxn: &RoTxn, + progress: &Progress, + index_uid: &str, + ) -> Result<(u64, u64)> { + // 1. Verify that the index exists + if !self.index_mapper.index_exists(rtxn, index_uid)? { + return Err(Error::IndexNotFound(index_uid.to_owned())); + } + + // 2. We retrieve the index and create a temporary file in the index directory + progress.update_progress(IndexCompaction::RetrieveTheIndex); + let index = self.index_mapper.index(rtxn, index_uid)?; + + // the index operation can take a long time, so save this handle to make it available to the search for the duration of the tick + self.index_mapper + .set_currently_updating_index(Some((index_uid.to_string(), index.clone()))); + + progress.update_progress(IndexCompaction::CreateTemporaryFile); + let pre_size = std::fs::metadata(index.path().join("data.mdb"))?.len(); + let mut file = tempfile::Builder::new() + .suffix("data.") + .prefix(".mdb.cpy") + .tempfile_in(index.path())?; + + // 3. We copy the index data to the temporary file + progress.update_progress(IndexCompaction::CopyAndCompactTheIndex); + index + .copy_to_file(file.as_file_mut(), CompactionOption::Enabled) + .map_err(|error| Error::Milli { error, index_uid: Some(index_uid.to_string()) })?; + // ...and reset the file position as specified in the documentation + file.seek(SeekFrom::Start(0))?; + + // 4. We replace the index data file with the temporary file + progress.update_progress(IndexCompaction::PersistTheCompactedIndex); + match file.persist(index.path().join("data.mdb")) { + Ok(file) => file.sync_all()?, + // TODO see if we have a _resource busy_ error and probably handle this by: + // 1. closing the index, 2. replacing and 3. reopening it + Err(PersistError { error, file: _ }) => return Err(Error::IoError(error)), + }; + + // 5. Prepare to close the index + progress.update_progress(IndexCompaction::CloseTheIndex); + + // unmark that the index is the processing one so we don't keep a handle to it, preventing its closing + self.index_mapper.set_currently_updating_index(None); + + self.index_mapper.close_index(rtxn, index_uid)?; + drop(index); + + progress.update_progress(IndexCompaction::ReopenTheIndex); + // 6. Reopen the index + // The index will use the compacted data file when being reopened + let index = self.index_mapper.index(rtxn, index_uid)?; + + // if the update processed successfully, we're going to store the new + // stats of the index. Since the tasks have already been processed and + // this is a non-critical operation. If it fails, we should not fail + // the entire batch. + let res = || -> Result<_> { + let mut wtxn = self.env.write_txn()?; + let index_rtxn = index.read_txn()?; + let stats = crate::index_mapper::IndexStats::new(&index, &index_rtxn) + .map_err(|e| Error::from_milli(e, Some(index_uid.to_string())))?; + self.index_mapper.store_stats_of(&mut wtxn, index_uid, &stats)?; + wtxn.commit()?; + Ok(stats.database_size) + }(); + + let post_size = match res { + Ok(post_size) => post_size, + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "Could not write the stats of the index" + ); + 0 + } + }; + + Ok((pre_size, post_size)) + } + /// Swap the index `lhs` with the index `rhs`. fn apply_index_swap( &self, diff --git a/crates/index-scheduler/src/scheduler/test.rs b/crates/index-scheduler/src/scheduler/test.rs index 8cc1b8830..4ee473343 100644 --- a/crates/index-scheduler/src/scheduler/test.rs +++ b/crates/index-scheduler/src/scheduler/test.rs @@ -722,7 +722,7 @@ fn basic_get_stats() { let kind = index_creation_task("whalo", "fish"); let _task = index_scheduler.register(kind, None, false).unwrap(); - snapshot!(json_string!(index_scheduler.get_stats().unwrap()), @r#" + snapshot!(json_string!(index_scheduler.get_stats().unwrap()), @r###" { "indexes": { "catto": 1, @@ -742,6 +742,7 @@ fn basic_get_stats() { "documentEdition": 0, "dumpCreation": 0, "export": 0, + "indexCompaction": 0, "indexCreation": 3, "indexDeletion": 0, "indexSwap": 0, @@ -753,10 +754,10 @@ fn basic_get_stats() { "upgradeDatabase": 0 } } - "#); + "###); handle.advance_till([Start, BatchCreated]); - snapshot!(json_string!(index_scheduler.get_stats().unwrap()), @r#" + snapshot!(json_string!(index_scheduler.get_stats().unwrap()), @r###" { "indexes": { "catto": 1, @@ -776,6 +777,7 @@ fn basic_get_stats() { "documentEdition": 0, "dumpCreation": 0, "export": 0, + "indexCompaction": 0, "indexCreation": 3, "indexDeletion": 0, "indexSwap": 0, @@ -787,7 +789,7 @@ fn basic_get_stats() { "upgradeDatabase": 0 } } - "#); + "###); handle.advance_till([ InsideProcessBatch, @@ -797,7 +799,7 @@ fn basic_get_stats() { Start, BatchCreated, ]); - snapshot!(json_string!(index_scheduler.get_stats().unwrap()), @r#" + snapshot!(json_string!(index_scheduler.get_stats().unwrap()), @r###" { "indexes": { "catto": 1, @@ -817,6 +819,7 @@ fn basic_get_stats() { "documentEdition": 0, "dumpCreation": 0, "export": 0, + "indexCompaction": 0, "indexCreation": 3, "indexDeletion": 0, "indexSwap": 0, @@ -828,7 +831,7 @@ fn basic_get_stats() { "upgradeDatabase": 0 } } - "#); + "###); // now we make one more batch, the started_at field of the new tasks will be past `second_start_time` handle.advance_till([ @@ -839,7 +842,7 @@ fn basic_get_stats() { Start, BatchCreated, ]); - snapshot!(json_string!(index_scheduler.get_stats().unwrap()), @r#" + snapshot!(json_string!(index_scheduler.get_stats().unwrap()), @r###" { "indexes": { "catto": 1, @@ -859,6 +862,7 @@ fn basic_get_stats() { "documentEdition": 0, "dumpCreation": 0, "export": 0, + "indexCompaction": 0, "indexCreation": 3, "indexDeletion": 0, "indexSwap": 0, @@ -870,7 +874,7 @@ fn basic_get_stats() { "upgradeDatabase": 0 } } - "#); + "###); } #[test] diff --git a/crates/index-scheduler/src/utils.rs b/crates/index-scheduler/src/utils.rs index 2617aba99..776e50548 100644 --- a/crates/index-scheduler/src/utils.rs +++ b/crates/index-scheduler/src/utils.rs @@ -256,14 +256,15 @@ pub fn swap_index_uid_in_task(task: &mut Task, swap: (&str, &str)) { use KindWithContent as K; let mut index_uids = vec![]; match &mut task.kind { - K::DocumentAdditionOrUpdate { index_uid, .. } => index_uids.push(index_uid), - K::DocumentEdition { index_uid, .. } => index_uids.push(index_uid), - K::DocumentDeletion { index_uid, .. } => index_uids.push(index_uid), - K::DocumentDeletionByFilter { index_uid, .. } => index_uids.push(index_uid), - K::DocumentClear { index_uid } => index_uids.push(index_uid), - K::SettingsUpdate { index_uid, .. } => index_uids.push(index_uid), - K::IndexDeletion { index_uid } => index_uids.push(index_uid), - K::IndexCreation { index_uid, .. } => index_uids.push(index_uid), + K::DocumentAdditionOrUpdate { index_uid, .. } + | K::DocumentEdition { index_uid, .. } + | K::DocumentDeletion { index_uid, .. } + | K::DocumentDeletionByFilter { index_uid, .. } + | K::DocumentClear { index_uid } + | K::SettingsUpdate { index_uid, .. } + | K::IndexDeletion { index_uid } + | K::IndexCreation { index_uid, .. } + | K::IndexCompaction { index_uid, .. } => index_uids.push(index_uid), K::IndexUpdate { index_uid, new_index_uid, .. } => { index_uids.push(index_uid); if let Some(new_uid) = new_index_uid { @@ -618,6 +619,13 @@ impl crate::IndexScheduler { Details::UpgradeDatabase { from: _, to: _ } => { assert_eq!(kind.as_kind(), Kind::UpgradeDatabase); } + Details::IndexCompaction { + index_uid: _, + pre_compaction_size: _, + post_compaction_size: _, + } => { + assert_eq!(kind.as_kind(), Kind::IndexCompaction); + } } } diff --git a/crates/meilisearch-auth/src/store.rs b/crates/meilisearch-auth/src/store.rs index 919005289..d07f1f641 100644 --- a/crates/meilisearch-auth/src/store.rs +++ b/crates/meilisearch-auth/src/store.rs @@ -109,6 +109,7 @@ impl HeedAuthStore { Action::IndexesGet, Action::IndexesUpdate, Action::IndexesSwap, + Action::IndexesCompact, ] .iter(), ); diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index 06f621e70..4adbdff39 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -380,6 +380,9 @@ pub enum Action { #[serde(rename = "webhooks.*")] #[deserr(rename = "webhooks.*")] WebhooksAll, + #[serde(rename = "indexes.compact")] + #[deserr(rename = "indexes.compact")] + IndexesCompact, } impl Action { @@ -398,6 +401,7 @@ impl Action { INDEXES_UPDATE => Some(Self::IndexesUpdate), INDEXES_DELETE => Some(Self::IndexesDelete), INDEXES_SWAP => Some(Self::IndexesSwap), + INDEXES_COMPACT => Some(Self::IndexesCompact), TASKS_ALL => Some(Self::TasksAll), TASKS_CANCEL => Some(Self::TasksCancel), TASKS_DELETE => Some(Self::TasksDelete), @@ -462,6 +466,7 @@ impl Action { IndexesUpdate => false, IndexesDelete => false, IndexesSwap => false, + IndexesCompact => false, TasksCancel => false, TasksDelete => false, TasksGet => true, @@ -513,6 +518,7 @@ pub mod actions { pub const INDEXES_UPDATE: u8 = IndexesUpdate.repr(); pub const INDEXES_DELETE: u8 = IndexesDelete.repr(); pub const INDEXES_SWAP: u8 = IndexesSwap.repr(); + pub const INDEXES_COMPACT: u8 = IndexesCompact.repr(); pub const TASKS_ALL: u8 = TasksAll.repr(); pub const TASKS_CANCEL: u8 = TasksCancel.repr(); pub const TASKS_DELETE: u8 = TasksDelete.repr(); @@ -614,6 +620,7 @@ pub(crate) mod test { assert!(WebhooksDelete.repr() == 47 && WEBHOOKS_DELETE == 47); assert!(WebhooksCreate.repr() == 48 && WEBHOOKS_CREATE == 48); assert!(WebhooksAll.repr() == 49 && WEBHOOKS_ALL == 49); + assert!(IndexesCompact.repr() == 50 && INDEXES_COMPACT == 50); } #[test] diff --git a/crates/meilisearch-types/src/task_view.rs b/crates/meilisearch-types/src/task_view.rs index cbc29a11b..413f674b3 100644 --- a/crates/meilisearch-types/src/task_view.rs +++ b/crates/meilisearch-types/src/task_view.rs @@ -142,6 +142,11 @@ pub struct DetailsView { pub old_index_uid: Option, #[serde(skip_serializing_if = "Option::is_none")] pub new_index_uid: Option, + // index compaction + #[serde(skip_serializing_if = "Option::is_none")] + pub pre_compaction_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub post_compaction_size: Option, } impl DetailsView { @@ -314,6 +319,24 @@ impl DetailsView { // We should never be able to batch multiple renames at the same time. (Some(left), Some(_right)) => Some(left), }, + pre_compaction_size: match ( + self.pre_compaction_size.clone(), + other.pre_compaction_size.clone(), + ) { + (None, None) => None, + (None, Some(size)) | (Some(size), None) => Some(size), + // We should never be able to batch multiple compactions at the same time. + (Some(left), Some(_right)) => Some(left), + }, + post_compaction_size: match ( + self.post_compaction_size.clone(), + other.post_compaction_size.clone(), + ) { + (None, None) => None, + (None, Some(size)) | (Some(size), None) => Some(size), + // We should never be able to batch multiple compactions at the same time. + (Some(left), Some(_right)) => Some(left), + }, } } } @@ -415,6 +438,15 @@ impl From
for DetailsView { upgrade_to: Some(format!("v{}.{}.{}", to.0, to.1, to.2)), ..Default::default() }, + Details::IndexCompaction { pre_compaction_size, post_compaction_size, .. } => { + DetailsView { + pre_compaction_size: pre_compaction_size + .map(|size| size.get_appropriate_unit(UnitType::Both).to_string()), + post_compaction_size: post_compaction_size + .map(|size| size.get_appropriate_unit(UnitType::Both).to_string()), + ..Default::default() + } + } } } } diff --git a/crates/meilisearch-types/src/tasks.rs b/crates/meilisearch-types/src/tasks.rs index d0f668255..edfd250ce 100644 --- a/crates/meilisearch-types/src/tasks.rs +++ b/crates/meilisearch-types/src/tasks.rs @@ -67,7 +67,8 @@ impl Task { | SettingsUpdate { index_uid, .. } | IndexCreation { index_uid, .. } | IndexUpdate { index_uid, .. } - | IndexDeletion { index_uid } => Some(index_uid), + | IndexDeletion { index_uid } + | IndexCompaction { index_uid } => Some(index_uid), } } @@ -94,7 +95,8 @@ impl Task { | KindWithContent::DumpCreation { .. } | KindWithContent::SnapshotCreation | KindWithContent::Export { .. } - | KindWithContent::UpgradeDatabase { .. } => None, + | KindWithContent::UpgradeDatabase { .. } + | KindWithContent::IndexCompaction { .. } => None, } } } @@ -170,6 +172,9 @@ pub enum KindWithContent { UpgradeDatabase { from: (u32, u32, u32), }, + IndexCompaction { + index_uid: String, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] @@ -206,6 +211,7 @@ impl KindWithContent { KindWithContent::SnapshotCreation => Kind::SnapshotCreation, KindWithContent::Export { .. } => Kind::Export, KindWithContent::UpgradeDatabase { .. } => Kind::UpgradeDatabase, + KindWithContent::IndexCompaction { .. } => Kind::IndexCompaction, } } @@ -226,7 +232,8 @@ impl KindWithContent { | DocumentClear { index_uid } | SettingsUpdate { index_uid, .. } | IndexCreation { index_uid, .. } - | IndexDeletion { index_uid } => vec![index_uid], + | IndexDeletion { index_uid } + | IndexCompaction { index_uid } => vec![index_uid], IndexUpdate { index_uid, new_index_uid, .. } => { let mut indexes = vec![index_uid.as_str()]; if let Some(new_uid) = new_index_uid { @@ -325,6 +332,11 @@ impl KindWithContent { versioning::VERSION_PATCH, ), }), + KindWithContent::IndexCompaction { index_uid } => Some(Details::IndexCompaction { + index_uid: index_uid.clone(), + pre_compaction_size: None, + post_compaction_size: None, + }), } } @@ -407,6 +419,11 @@ impl KindWithContent { versioning::VERSION_PATCH, ), }), + KindWithContent::IndexCompaction { index_uid } => Some(Details::IndexCompaction { + index_uid: index_uid.clone(), + pre_compaction_size: None, + post_compaction_size: None, + }), } } } @@ -469,6 +486,11 @@ impl From<&KindWithContent> for Option
{ versioning::VERSION_PATCH, ), }), + KindWithContent::IndexCompaction { index_uid } => Some(Details::IndexCompaction { + index_uid: index_uid.clone(), + pre_compaction_size: None, + post_compaction_size: None, + }), } } } @@ -579,6 +601,7 @@ pub enum Kind { SnapshotCreation, Export, UpgradeDatabase, + IndexCompaction, } impl Kind { @@ -590,7 +613,8 @@ impl Kind { | Kind::SettingsUpdate | Kind::IndexCreation | Kind::IndexDeletion - | Kind::IndexUpdate => true, + | Kind::IndexUpdate + | Kind::IndexCompaction => true, Kind::IndexSwap | Kind::TaskCancelation | Kind::TaskDeletion @@ -618,6 +642,7 @@ impl Display for Kind { Kind::SnapshotCreation => write!(f, "snapshotCreation"), Kind::Export => write!(f, "export"), Kind::UpgradeDatabase => write!(f, "upgradeDatabase"), + Kind::IndexCompaction => write!(f, "indexCompaction"), } } } @@ -653,6 +678,8 @@ impl FromStr for Kind { Ok(Kind::Export) } else if kind.eq_ignore_ascii_case("upgradeDatabase") { Ok(Kind::UpgradeDatabase) + } else if kind.eq_ignore_ascii_case("indexCompaction") { + Ok(Kind::IndexCompaction) } else { Err(ParseTaskKindError(kind.to_owned())) } @@ -738,6 +765,11 @@ pub enum Details { from: (u32, u32, u32), to: (u32, u32, u32), }, + IndexCompaction { + index_uid: String, + pre_compaction_size: Option, + post_compaction_size: Option, + }, } #[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)] @@ -800,6 +832,10 @@ impl Details { Self::ClearAll { deleted_documents } => *deleted_documents = Some(0), Self::TaskCancelation { canceled_tasks, .. } => *canceled_tasks = Some(0), Self::TaskDeletion { deleted_tasks, .. } => *deleted_tasks = Some(0), + Self::IndexCompaction { pre_compaction_size, post_compaction_size, .. } => { + *pre_compaction_size = None; + *post_compaction_size = None; + } Self::SettingsUpdate { .. } | Self::IndexInfo { .. } | Self::Dump { .. } diff --git a/crates/meilisearch/src/routes/indexes/compact.rs b/crates/meilisearch/src/routes/indexes/compact.rs new file mode 100644 index 000000000..175564926 --- /dev/null +++ b/crates/meilisearch/src/routes/indexes/compact.rs @@ -0,0 +1,84 @@ +use actix_web::web::{self, Data}; +use actix_web::{HttpRequest, HttpResponse}; +use index_scheduler::IndexScheduler; +use meilisearch_types::error::ResponseError; +use meilisearch_types::index_uid::IndexUid; +use meilisearch_types::keys::actions; +use meilisearch_types::tasks::KindWithContent; +use tracing::debug; +use utoipa::OpenApi; + +use super::ActionPolicy; +use crate::analytics::Analytics; +use crate::extractors::authentication::GuardedData; +use crate::extractors::sequential_extractor::SeqHandler; +use crate::routes::SummarizedTaskView; + +#[derive(OpenApi)] +#[openapi( + paths(compact), + tags( + ( + name = "Compact an index", + description = "The /compact route uses compacts the database to reorganize and make it smaller and more efficient.", + external_docs(url = "https://www.meilisearch.com/docs/reference/api/compact"), + ), + ), +)] +pub struct CompactApi; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service(web::resource("").route(web::post().to(SeqHandler(compact)))); +} + +/// Compact an index +#[utoipa::path( + post, + path = "{indexUid}/compact", + tag = "Compact an index", + security(("Bearer" = ["search", "*"])), + params(("indexUid" = String, Path, example = "movies", description = "Index Unique Identifier", nullable = false)), + responses( + (status = ACCEPTED, description = "Task successfully enqueued", body = SummarizedTaskView, content_type = "application/json", example = json!( + { + "taskUid": 147, + "indexUid": null, + "status": "enqueued", + "type": "documentDeletion", + "enqueuedAt": "2024-08-08T17:05:55.791772Z" + } + )), + (status = 401, description = "The authorization header is missing", body = ResponseError, content_type = "application/json", example = json!( + { + "message": "The Authorization header is missing. It must use the bearer authorization method.", + "code": "missing_authorization_header", + "type": "auth", + "link": "https://docs.meilisearch.com/errors#missing_authorization_header" + } + )), + ) +)] +pub async fn compact( + index_scheduler: GuardedData, Data>, + index_uid: web::Path, + req: HttpRequest, + analytics: web::Data, +) -> Result { + let index_uid = IndexUid::try_from(index_uid.into_inner())?; + + analytics.publish(IndexCompacted::default(), &req); + + let task = KindWithContent::IndexCompaction { index_uid: index_uid.to_string() }; + let task = + match tokio::task::spawn_blocking(move || index_scheduler.register(task, None, false)) + .await? + { + Ok(task) => task, + Err(e) => return Err(e.into()), + }; + + debug!(returns = ?task, "Compact the {index_uid} index"); + Ok(HttpResponse::Accepted().json(SummarizedTaskView::from(task))) +} + +crate::empty_analytics!(IndexCompacted, "Index Compacted"); diff --git a/crates/meilisearch/src/routes/indexes/mod.rs b/crates/meilisearch/src/routes/indexes/mod.rs index 8e994bb43..d3c399dec 100644 --- a/crates/meilisearch/src/routes/indexes/mod.rs +++ b/crates/meilisearch/src/routes/indexes/mod.rs @@ -28,6 +28,7 @@ use crate::extractors::sequential_extractor::SeqHandler; use crate::routes::is_dry_run; use crate::Opt; +pub mod compact; pub mod documents; mod enterprise_edition; pub mod facet_search; @@ -49,8 +50,9 @@ pub use enterprise_edition::proxy::{PROXY_ORIGIN_REMOTE_HEADER, PROXY_ORIGIN_TAS (path = "/", api = facet_search::FacetSearchApi), (path = "/", api = similar::SimilarApi), (path = "/", api = settings::SettingsApi), + (path = "/", api = compact::CompactApi), ), - paths(list_indexes, create_index, get_index, update_index, delete_index, get_index_stats), + paths(list_indexes, create_index, get_index, update_index, delete_index, get_index_stats, compact::compact), tags( ( name = "Indexes", @@ -80,7 +82,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(web::scope("/search").configure(search::configure)) .service(web::scope("/facet-search").configure(facet_search::configure)) .service(web::scope("/similar").configure(similar::configure)) - .service(web::scope("/settings").configure(settings::configure)), + .service(web::scope("/settings").configure(settings::configure)) + .service(web::scope("/compact").configure(compact::configure)), ); } diff --git a/crates/meilisearch/src/routes/tasks_test.rs b/crates/meilisearch/src/routes/tasks_test.rs index b09eb0fb3..7c362c9d9 100644 --- a/crates/meilisearch/src/routes/tasks_test.rs +++ b/crates/meilisearch/src/routes/tasks_test.rs @@ -226,14 +226,14 @@ mod tests { { let params = "types=createIndex"; let err = deserr_query_params::(params).unwrap_err(); - snapshot!(meili_snap::json_string!(err), @r#" + snapshot!(meili_snap::json_string!(err), @r###" { - "message": "Invalid value in parameter `types`: `createIndex` is not a valid task type. Available types are `documentAdditionOrUpdate`, `documentEdition`, `documentDeletion`, `settingsUpdate`, `indexCreation`, `indexDeletion`, `indexUpdate`, `indexSwap`, `taskCancelation`, `taskDeletion`, `dumpCreation`, `snapshotCreation`, `export`, `upgradeDatabase`.", + "message": "Invalid value in parameter `types`: `createIndex` is not a valid task type. Available types are `documentAdditionOrUpdate`, `documentEdition`, `documentDeletion`, `settingsUpdate`, `indexCreation`, `indexDeletion`, `indexUpdate`, `indexSwap`, `taskCancelation`, `taskDeletion`, `dumpCreation`, `snapshotCreation`, `export`, `upgradeDatabase`, `indexCompaction`.", "code": "invalid_task_types", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_task_types" } - "#); + "###); } } #[test] diff --git a/crates/meilisearch/tests/auth/api_keys.rs b/crates/meilisearch/tests/auth/api_keys.rs index 8dca24ac4..6144f87bc 100644 --- a/crates/meilisearch/tests/auth/api_keys.rs +++ b/crates/meilisearch/tests/auth/api_keys.rs @@ -419,14 +419,14 @@ async fn error_add_api_key_invalid_parameters_actions() { let (response, code) = server.add_api_key(content).await; meili_snap::snapshot!(code, @"400 Bad Request"); - meili_snap::snapshot!(meili_snap::json_string!(response, { ".createdAt" => "[ignored]", ".updatedAt" => "[ignored]" }), @r#" + meili_snap::snapshot!(meili_snap::json_string!(response, { ".createdAt" => "[ignored]", ".updatedAt" => "[ignored]" }), @r###" { - "message": "Unknown value `doc.add` at `.actions[0]`: expected one of `*`, `search`, `documents.*`, `documents.add`, `documents.get`, `documents.delete`, `indexes.*`, `indexes.create`, `indexes.get`, `indexes.update`, `indexes.delete`, `indexes.swap`, `tasks.*`, `tasks.cancel`, `tasks.delete`, `tasks.get`, `settings.*`, `settings.get`, `settings.update`, `stats.*`, `stats.get`, `metrics.*`, `metrics.get`, `dumps.*`, `dumps.create`, `snapshots.*`, `snapshots.create`, `version`, `keys.create`, `keys.get`, `keys.update`, `keys.delete`, `experimental.get`, `experimental.update`, `export`, `network.get`, `network.update`, `chatCompletions`, `chats.*`, `chats.get`, `chats.delete`, `chatsSettings.*`, `chatsSettings.get`, `chatsSettings.update`, `*.get`, `webhooks.get`, `webhooks.update`, `webhooks.delete`, `webhooks.create`, `webhooks.*`", + "message": "Unknown value `doc.add` at `.actions[0]`: expected one of `*`, `search`, `documents.*`, `documents.add`, `documents.get`, `documents.delete`, `indexes.*`, `indexes.create`, `indexes.get`, `indexes.update`, `indexes.delete`, `indexes.swap`, `tasks.*`, `tasks.cancel`, `tasks.delete`, `tasks.get`, `settings.*`, `settings.get`, `settings.update`, `stats.*`, `stats.get`, `metrics.*`, `metrics.get`, `dumps.*`, `dumps.create`, `snapshots.*`, `snapshots.create`, `version`, `keys.create`, `keys.get`, `keys.update`, `keys.delete`, `experimental.get`, `experimental.update`, `export`, `network.get`, `network.update`, `chatCompletions`, `chats.*`, `chats.get`, `chats.delete`, `chatsSettings.*`, `chatsSettings.get`, `chatsSettings.update`, `*.get`, `webhooks.get`, `webhooks.update`, `webhooks.delete`, `webhooks.create`, `webhooks.*`, `indexes.compact`", "code": "invalid_api_key_actions", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_api_key_actions" } - "#); + "###); } #[actix_rt::test] diff --git a/crates/meilisearch/tests/auth/errors.rs b/crates/meilisearch/tests/auth/errors.rs index 2a40f4d2b..5fb8b353e 100644 --- a/crates/meilisearch/tests/auth/errors.rs +++ b/crates/meilisearch/tests/auth/errors.rs @@ -91,14 +91,14 @@ async fn create_api_key_bad_actions() { // can't parse let (response, code) = server.add_api_key(json!({ "actions": ["doggo"] })).await; snapshot!(code, @"400 Bad Request"); - snapshot!(json_string!(response), @r#" + snapshot!(json_string!(response), @r###" { - "message": "Unknown value `doggo` at `.actions[0]`: expected one of `*`, `search`, `documents.*`, `documents.add`, `documents.get`, `documents.delete`, `indexes.*`, `indexes.create`, `indexes.get`, `indexes.update`, `indexes.delete`, `indexes.swap`, `tasks.*`, `tasks.cancel`, `tasks.delete`, `tasks.get`, `settings.*`, `settings.get`, `settings.update`, `stats.*`, `stats.get`, `metrics.*`, `metrics.get`, `dumps.*`, `dumps.create`, `snapshots.*`, `snapshots.create`, `version`, `keys.create`, `keys.get`, `keys.update`, `keys.delete`, `experimental.get`, `experimental.update`, `export`, `network.get`, `network.update`, `chatCompletions`, `chats.*`, `chats.get`, `chats.delete`, `chatsSettings.*`, `chatsSettings.get`, `chatsSettings.update`, `*.get`, `webhooks.get`, `webhooks.update`, `webhooks.delete`, `webhooks.create`, `webhooks.*`", + "message": "Unknown value `doggo` at `.actions[0]`: expected one of `*`, `search`, `documents.*`, `documents.add`, `documents.get`, `documents.delete`, `indexes.*`, `indexes.create`, `indexes.get`, `indexes.update`, `indexes.delete`, `indexes.swap`, `tasks.*`, `tasks.cancel`, `tasks.delete`, `tasks.get`, `settings.*`, `settings.get`, `settings.update`, `stats.*`, `stats.get`, `metrics.*`, `metrics.get`, `dumps.*`, `dumps.create`, `snapshots.*`, `snapshots.create`, `version`, `keys.create`, `keys.get`, `keys.update`, `keys.delete`, `experimental.get`, `experimental.update`, `export`, `network.get`, `network.update`, `chatCompletions`, `chats.*`, `chats.get`, `chats.delete`, `chatsSettings.*`, `chatsSettings.get`, `chatsSettings.update`, `*.get`, `webhooks.get`, `webhooks.update`, `webhooks.delete`, `webhooks.create`, `webhooks.*`, `indexes.compact`", "code": "invalid_api_key_actions", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_api_key_actions" } - "#); + "###); } #[actix_rt::test] diff --git a/crates/meilisearch/tests/batches/errors.rs b/crates/meilisearch/tests/batches/errors.rs index bfc0d9251..c5e59fa87 100644 --- a/crates/meilisearch/tests/batches/errors.rs +++ b/crates/meilisearch/tests/batches/errors.rs @@ -40,14 +40,14 @@ async fn batch_bad_types() { let (response, code) = server.batches_filter("types=doggo").await; snapshot!(code, @"400 Bad Request"); - snapshot!(json_string!(response), @r#" + snapshot!(json_string!(response), @r###" { - "message": "Invalid value in parameter `types`: `doggo` is not a valid task type. Available types are `documentAdditionOrUpdate`, `documentEdition`, `documentDeletion`, `settingsUpdate`, `indexCreation`, `indexDeletion`, `indexUpdate`, `indexSwap`, `taskCancelation`, `taskDeletion`, `dumpCreation`, `snapshotCreation`, `export`, `upgradeDatabase`.", + "message": "Invalid value in parameter `types`: `doggo` is not a valid task type. Available types are `documentAdditionOrUpdate`, `documentEdition`, `documentDeletion`, `settingsUpdate`, `indexCreation`, `indexDeletion`, `indexUpdate`, `indexSwap`, `taskCancelation`, `taskDeletion`, `dumpCreation`, `snapshotCreation`, `export`, `upgradeDatabase`, `indexCompaction`.", "code": "invalid_task_types", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_task_types" } - "#); + "###); } #[actix_rt::test] diff --git a/crates/meilisearch/tests/tasks/errors.rs b/crates/meilisearch/tests/tasks/errors.rs index 9970bafa4..c12a7688d 100644 --- a/crates/meilisearch/tests/tasks/errors.rs +++ b/crates/meilisearch/tests/tasks/errors.rs @@ -97,7 +97,7 @@ async fn task_bad_types() { snapshot!(code, @"400 Bad Request"); snapshot!(json_string!(response), @r#" { - "message": "Invalid value in parameter `types`: `doggo` is not a valid task type. Available types are `documentAdditionOrUpdate`, `documentEdition`, `documentDeletion`, `settingsUpdate`, `indexCreation`, `indexDeletion`, `indexUpdate`, `indexSwap`, `taskCancelation`, `taskDeletion`, `dumpCreation`, `snapshotCreation`, `export`, `upgradeDatabase`.", + "message": "Invalid value in parameter `types`: `doggo` is not a valid task type. Available types are `documentAdditionOrUpdate`, `documentEdition`, `documentDeletion`, `settingsUpdate`, `indexCreation`, `indexDeletion`, `indexUpdate`, `indexSwap`, `taskCancelation`, `taskDeletion`, `dumpCreation`, `snapshotCreation`, `export`, `upgradeDatabase`, `indexCompaction`.", "code": "invalid_task_types", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_task_types" @@ -108,7 +108,7 @@ async fn task_bad_types() { snapshot!(code, @"400 Bad Request"); snapshot!(json_string!(response), @r#" { - "message": "Invalid value in parameter `types`: `doggo` is not a valid task type. Available types are `documentAdditionOrUpdate`, `documentEdition`, `documentDeletion`, `settingsUpdate`, `indexCreation`, `indexDeletion`, `indexUpdate`, `indexSwap`, `taskCancelation`, `taskDeletion`, `dumpCreation`, `snapshotCreation`, `export`, `upgradeDatabase`.", + "message": "Invalid value in parameter `types`: `doggo` is not a valid task type. Available types are `documentAdditionOrUpdate`, `documentEdition`, `documentDeletion`, `settingsUpdate`, `indexCreation`, `indexDeletion`, `indexUpdate`, `indexSwap`, `taskCancelation`, `taskDeletion`, `dumpCreation`, `snapshotCreation`, `export`, `upgradeDatabase`, `indexCompaction`.", "code": "invalid_task_types", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_task_types" @@ -119,7 +119,7 @@ async fn task_bad_types() { snapshot!(code, @"400 Bad Request"); snapshot!(json_string!(response), @r#" { - "message": "Invalid value in parameter `types`: `doggo` is not a valid task type. Available types are `documentAdditionOrUpdate`, `documentEdition`, `documentDeletion`, `settingsUpdate`, `indexCreation`, `indexDeletion`, `indexUpdate`, `indexSwap`, `taskCancelation`, `taskDeletion`, `dumpCreation`, `snapshotCreation`, `export`, `upgradeDatabase`.", + "message": "Invalid value in parameter `types`: `doggo` is not a valid task type. Available types are `documentAdditionOrUpdate`, `documentEdition`, `documentDeletion`, `settingsUpdate`, `indexCreation`, `indexDeletion`, `indexUpdate`, `indexSwap`, `taskCancelation`, `taskDeletion`, `dumpCreation`, `snapshotCreation`, `export`, `upgradeDatabase`, `indexCompaction`.", "code": "invalid_task_types", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_task_types" diff --git a/crates/meilitool/src/main.rs b/crates/meilitool/src/main.rs index e4f23a7c4..acf960e70 100644 --- a/crates/meilitool/src/main.rs +++ b/crates/meilitool/src/main.rs @@ -126,7 +126,7 @@ enum Command { /// before running the copy and compaction. This way the current indexation must finish before /// the compaction operation can start. Once the compaction is done, the big index is replaced /// by the compacted one and the mutable transaction is released. - CompactIndex { index_name: String }, + IndexCompaction { index_name: String }, /// Uses the hair dryer the dedicate pages hot in cache /// @@ -165,7 +165,7 @@ fn main() -> anyhow::Result<()> { let target_version = parse_version(&target_version).context("While parsing `--target-version`. Make sure `--target-version` is in the format MAJOR.MINOR.PATCH")?; OfflineUpgrade { db_path, current_version: detected_version, target_version }.upgrade() } - Command::CompactIndex { index_name } => compact_index(db_path, &index_name), + Command::IndexCompaction { index_name } => compact_index(db_path, &index_name), Command::HairDryer { index_name, index_part } => { hair_dryer(db_path, &index_name, &index_part) } diff --git a/crates/milli/Cargo.toml b/crates/milli/Cargo.toml index 7a9db7412..517cd102c 100644 --- a/crates/milli/Cargo.toml +++ b/crates/milli/Cargo.toml @@ -19,7 +19,7 @@ bstr = "1.12.0" bytemuck = { version = "1.23.1", features = ["extern_crate_alloc"] } byteorder = "1.5.0" charabia = { version = "0.9.7", default-features = false } -cellulite = "0.3.1-nested-rtxns" +cellulite = "0.3.1-nested-rtxns-2" concat-arrays = "0.1.2" convert_case = "0.8.0" crossbeam-channel = "0.5.15"