diff --git a/crates/index-scheduler/src/scheduler/autobatcher.rs b/crates/index-scheduler/src/scheduler/autobatcher.rs index 605bf80dd..42a508cb0 100644 --- a/crates/index-scheduler/src/scheduler/autobatcher.rs +++ b/crates/index-scheduler/src/scheduler/autobatcher.rs @@ -5,9 +5,10 @@ tasks affecting a single index into a [batch](crate::batch::Batch). The main function of the autobatcher is [`next_autobatch`]. */ -use meilisearch_types::tasks::TaskId; use std::ops::ControlFlow::{self, Break, Continue}; +use meilisearch_types::tasks::{BatchStopReason, TaskId}; + use crate::KindWithContent; /// Succinctly describes a task's [`Kind`](meilisearch_types::tasks::Kind) @@ -147,14 +148,38 @@ impl BatchKind { task_id: TaskId, kind: KindWithContent, primary_key: Option<&str>, - ) -> (ControlFlow, bool) { + ) -> (ControlFlow<(BatchKind, BatchStopReason), BatchKind>, bool) { use AutobatchKind as K; match AutobatchKind::from(kind) { - K::IndexCreation => (Break(BatchKind::IndexCreation { id: task_id }), true), - K::IndexDeletion => (Break(BatchKind::IndexDeletion { ids: vec![task_id] }), false), - K::IndexUpdate => (Break(BatchKind::IndexUpdate { id: task_id }), false), - K::IndexSwap => (Break(BatchKind::IndexSwap { id: task_id }), false), + K::IndexCreation => ( + Break(( + BatchKind::IndexCreation { id: task_id }, + BatchStopReason::TaskCannotBeBatched { kind, id: task_id }, + )), + true, + ), + K::IndexDeletion => ( + Break(( + BatchKind::IndexDeletion { ids: vec![task_id] }, + BatchStopReason::TaskCannotBeBatched { kind, id: task_id }, + )), + false, + ), + K::IndexUpdate => ( + Break(( + BatchKind::IndexUpdate { id: task_id }, + BatchStopReason::TaskCannotBeBatched { kind, id: task_id }, + )), + false, + ), + K::IndexSwap => ( + Break(( + BatchKind::IndexSwap { 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() => @@ -169,15 +194,28 @@ impl BatchKind { ) } // if the primary key set in the task was different than ours we should stop and make this batch fail asap. - K::DocumentImport { allow_index_creation, primary_key } => ( - Break(BatchKind::DocumentOperation { - allow_index_creation, - primary_key, - operation_ids: vec![task_id], - }), + K::DocumentImport { allow_index_creation, primary_key: pk } => ( + Break(( + BatchKind::DocumentOperation { + allow_index_creation, + primary_key: pk, + operation_ids: vec![task_id], + }, + BatchStopReason::PrimaryKeyIndexMismatch { + id: task_id, + in_index: primary_key.unwrap().to_owned(), + in_task: pk.unwrap(), + }, + )), allow_index_creation, ), - K::DocumentEdition => (Break(BatchKind::DocumentEdition { id: task_id }), false), + K::DocumentEdition => ( + Break(( + BatchKind::DocumentEdition { id: task_id }, + BatchStopReason::TaskCannotBeBatched { kind, id: task_id }, + )), + false, + ), K::DocumentDeletion { by_filter: includes_by_filter } => ( Continue(BatchKind::DocumentDeletion { deletion_ids: vec![task_id], @@ -197,43 +235,40 @@ impl BatchKind { /// To ease the writing of the code. `true` can be returned when you don't need to create an index /// but false can't be returned if you needs to create an index. #[rustfmt::skip] - fn accumulate(self, id: TaskId, kind: AutobatchKind, index_already_exists: bool, primary_key: Option<&str>) -> ControlFlow { + fn accumulate(self, id: TaskId, kind: AutobatchKind, index_already_exists: bool, primary_key: Option<&str>) -> ControlFlow<(BatchKind, BatchStopReason), BatchKind> { use AutobatchKind as K; + let pk: Option = match (self.primary_key(), kind.primary_key(), primary_key) { + // 1. If both task don't interact with primary key -> we can continue + (batch_pk, None | Some(None), _) => { + batch_pk.flatten().to_owned() + }, + // 2.1 If we already have a primary-key -> + // 2.1.1 If the task we're trying to accumulate have a pk it must be equal to our primary key + (batch_pk, Some(Some(task_pk)), Some(index_pk)) => if task_pk == index_pk { + Some(task_pk.to_owned()) + } else { + return Break((this, BatchStopReason::PrimaryKeyMismatch { id, batch_pk: todo!(), task_pk: todo!() })) + }, + // 2.2 If we don't have a primary-key -> + // 2.2.2 If the batch is set to Some(None), the task should be too + (Some(None), Some(None), None) => None, + (Some(None), Some(Some(_)), None) => return Break((this, BatchStopReason::PrimaryKeyMismatch { id, batch_pk: todo!(), task_pk: todo!() })), + (Some(Some(batch_pk)), Some(None), None) => Some(batch_pk.to_owned()), + (Some(Some(batch_pk)), Some(Some(task_pk)), None) => if task_pk == batch_pk { + Some(task_pk.to_owned()) + } else { + return Break((this, BatchStopReason::PrimaryKeyMismatch { id, batch_pk: todo!(), task_pk: todo!() })) + }, + (None, Some(Some(task_pk)), None) => Some(task_pk.to_owned()) + }; + match (self, kind) { // We don't batch any of these operations - (this, K::IndexCreation | K::IndexUpdate | K::IndexSwap | K::DocumentEdition) => Break(this), + (this, K::IndexCreation | K::IndexUpdate | K::IndexSwap | K::DocumentEdition) => 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) - }, - // NOTE: We need to negate the whole condition since we're checking if we need to break instead of continue. - // I wrote it this way because it's easier to understand than the other way around. - (this, kind) if !( - // 1. If both task don't interact with primary key -> we can continue - (this.primary_key().is_none() && kind.primary_key().is_none()) || - // 2. Else -> - ( - // 2.1 If we already have a primary-key -> - ( - primary_key.is_some() && - // 2.1.1 If the task we're trying to accumulate have a pk it must be equal to our primary key - // 2.1.2 If the task don't have a primary-key -> we can continue - kind.primary_key().is_none_or(|pk| pk == primary_key) - ) || - // 2.2 If we don't have a primary-key -> - ( - // 2.2.1 If both the batch and the task have a primary key they should be equal - // 2.2.2 If the batch is set to Some(None), the task should be too - // 2.2.3 If the batch is set to None -> we can continue - this.primary_key().zip(kind.primary_key()).map_or(true, |(this, kind)| this == kind) - ) - ) - - ) // closing the negation - - => { - Break(this) + Break((this, BatchStopReason::IndexCreationMismatch { id })) }, // The index deletion can batch with everything but must stop after ( @@ -244,7 +279,7 @@ impl BatchKind { K::IndexDeletion, ) => { ids.push(id); - Break(BatchKind::IndexDeletion { ids }) + Break((BatchKind::IndexDeletion { ids }, BatchStopReason::IndexDeletion { id })) } ( BatchKind::ClearAndSettings { settings_ids: mut ids, allow_index_creation: _, mut other }, @@ -252,7 +287,7 @@ impl BatchKind { ) => { ids.push(id); ids.append(&mut other); - Break(BatchKind::IndexDeletion { ids }) + Break((BatchKind::IndexDeletion { ids }, BatchStopReason::IndexDeletion { id })) } ( @@ -265,7 +300,7 @@ impl BatchKind { ( this @ BatchKind::DocumentClear { .. }, K::DocumentImport { .. } | K::Settings { .. }, - ) => Break(this), + ) => Break((this, BatchStopReason::DocumentOperationWithSettings { id })), ( BatchKind::DocumentOperation { allow_index_creation: _, primary_key: _, mut operation_ids }, K::DocumentClear, @@ -277,7 +312,7 @@ impl BatchKind { // we can autobatch different kind of document operations and mix replacements with updates ( BatchKind::DocumentOperation { allow_index_creation, primary_key: _, mut operation_ids }, - K::DocumentImport { primary_key: pk, .. }, + K::DocumentImport { primary_key, .. }, ) => { operation_ids.push(id); Continue(BatchKind::DocumentOperation { @@ -287,15 +322,15 @@ impl BatchKind { }) } ( - BatchKind::DocumentOperation { allow_index_creation, primary_key, mut operation_ids }, + BatchKind::DocumentOperation { allow_index_creation, primary_key: _, mut operation_ids }, K::DocumentDeletion { by_filter: false }, ) => { operation_ids.push(id); Continue(BatchKind::DocumentOperation { allow_index_creation, - primary_key, operation_ids, + primary_key: pk, }) } // We can't batch a document operation with a delete by filter @@ -303,12 +338,12 @@ impl BatchKind { this @ BatchKind::DocumentOperation { .. }, K::DocumentDeletion { by_filter: true }, ) => { - Break(this) + Break((this, BatchStopReason::DocumentOperationWithDeletionByFilter { id })) } ( this @ BatchKind::DocumentOperation { .. }, K::Settings { .. }, - ) => Break(this), + ) => Break((this, BatchStopReason::DocumentOperationWithSettings { id })), (BatchKind::DocumentDeletion { mut deletion_ids, includes_by_filter: _ }, K::DocumentClear) => { deletion_ids.push(id); @@ -318,7 +353,7 @@ impl BatchKind { ( this @ BatchKind::DocumentDeletion { deletion_ids: _, includes_by_filter: true }, K::DocumentImport { .. } - ) => Break(this), + ) => Break((this, BatchStopReason::DeletionByFilterWithDocumentOperation { id })), // we can autobatch the deletion and import if the index already exists ( BatchKind::DocumentDeletion { mut deletion_ids, includes_by_filter: false }, @@ -345,18 +380,18 @@ impl BatchKind { operation_ids: deletion_ids, }) } - // we can't autobatch a deletion and an import if the index does not exists but would be created by an addition + // we can't autobatch a deletion and an import if the index does not exist but would be created by an addition ( this @ BatchKind::DocumentDeletion { .. }, K::DocumentImport { .. } ) => { - Break(this) + Break((this, BatchStopReason::IndexCreationMismatch { id })) } (BatchKind::DocumentDeletion { mut deletion_ids, includes_by_filter }, K::DocumentDeletion { by_filter }) => { deletion_ids.push(id); Continue(BatchKind::DocumentDeletion { deletion_ids, includes_by_filter: includes_by_filter | by_filter }) } - (this @ BatchKind::DocumentDeletion { .. }, K::Settings { .. }) => Break(this), + (this @ BatchKind::DocumentDeletion { .. }, K::Settings { .. }) => Break((this, BatchStopReason::DocumentOperationWithSettings { id })), ( BatchKind::Settings { settings_ids, allow_index_creation }, @@ -369,7 +404,7 @@ impl BatchKind { ( this @ BatchKind::Settings { .. }, K::DocumentImport { .. } | K::DocumentDeletion { .. }, - ) => Break(this), + ) => Break((this, BatchStopReason::SettingsWithDocumentOperation { id })), ( BatchKind::Settings { mut settings_ids, allow_index_creation }, K::Settings { .. }, @@ -448,7 +483,7 @@ pub fn autobatch( enqueued: Vec<(TaskId, KindWithContent)>, index_already_exists: bool, primary_key: Option<&str>, -) -> Option<(BatchKind, bool)> { +) -> Option<(BatchKind, bool, Option)> { let mut enqueued = enqueued.into_iter(); let (id, kind) = enqueued.next()?; @@ -457,7 +492,9 @@ pub fn autobatch( let (mut acc, must_create_index) = match BatchKind::new(id, kind, primary_key) { (Continue(acc), create) => (acc, create), - (Break(acc), create) => return Some((acc, create)), + (Break((acc, batch_stop_reason)), create) => { + return Some((acc, create, Some(batch_stop_reason))) + } }; // if an index has been created in the previous step we can consider it as existing. @@ -466,9 +503,11 @@ pub fn autobatch( for (id, kind) in enqueued { acc = match acc.accumulate(id, kind.into(), index_exist, primary_key) { Continue(acc) => acc, - Break(acc) => return Some((acc, must_create_index)), + Break((acc, batch_stop_reason)) => { + return Some((acc, must_create_index, Some(batch_stop_reason))) + } }; } - Some((acc, must_create_index)) + Some((acc, must_create_index, None)) } diff --git a/crates/index-scheduler/src/scheduler/create_batch.rs b/crates/index-scheduler/src/scheduler/create_batch.rs index 10f480d12..dbd4cecb1 100644 --- a/crates/index-scheduler/src/scheduler/create_batch.rs +++ b/crates/index-scheduler/src/scheduler/create_batch.rs @@ -3,7 +3,7 @@ use std::fmt; use meilisearch_types::heed::RoTxn; use meilisearch_types::milli::update::IndexDocumentsMethod; use meilisearch_types::settings::{Settings, Unchecked}; -use meilisearch_types::tasks::{Kind, KindWithContent, Status, Task}; +use meilisearch_types::tasks::{BatchStopReason, Kind, KindWithContent, Status, Task}; use roaring::RoaringBitmap; use uuid::Uuid; @@ -440,6 +440,7 @@ impl IndexScheduler { let mut current_batch = ProcessingBatch::new(batch_id); let enqueued = &self.queue.tasks.get_status(rtxn, Status::Enqueued)?; + let count_total_enqueued = enqueued.len(); let failed = &self.queue.tasks.get_status(rtxn, Status::Failed)?; // 0. The priority over everything is to upgrade the instance @@ -453,6 +454,10 @@ impl IndexScheduler { current_batch.uid = batch_uid; } current_batch.processing(&mut tasks); + current_batch.reason(BatchStopReason::TaskCannotBeBatched { + kind: Kind::UpgradeDatabase, + id: tasks.last().unwrap(), + }); return Ok(Some((Batch::UpgradeDatabase { tasks }, current_batch))); } @@ -462,6 +467,10 @@ impl IndexScheduler { let mut task = self.queue.tasks.get_task(rtxn, task_id)?.ok_or(Error::CorruptedTaskQueue)?; current_batch.processing(Some(&mut task)); + current_batch.reason(BatchStopReason::TaskCannotBeBatched { + kind: Kind::TaskCancelation, + id: tasks.last().unwrap(), + }); return Ok(Some((Batch::TaskCancelation { task }, current_batch))); } @@ -470,6 +479,10 @@ impl IndexScheduler { if !to_delete.is_empty() { let mut tasks = self.queue.tasks.get_existing_tasks(rtxn, to_delete)?; current_batch.processing(&mut tasks); + current_batch.reason(BatchStopReason::TaskCannotBeBatched { + kind: Kind::TaskDeletion, + id: tasks.last().unwrap(), + }); return Ok(Some((Batch::TaskDeletions(tasks), current_batch))); } @@ -478,6 +491,10 @@ impl IndexScheduler { if !to_snapshot.is_empty() { let mut tasks = self.queue.tasks.get_existing_tasks(rtxn, to_snapshot)?; current_batch.processing(&mut tasks); + current_batch.reason(BatchStopReason::TaskCannotBeBatched { + kind: Kind::SnapshotCreation, + id: tasks.last().unwrap(), + }); return Ok(Some((Batch::SnapshotCreation(tasks), current_batch))); } @@ -487,6 +504,10 @@ impl IndexScheduler { let mut task = self.queue.tasks.get_task(rtxn, to_dump)?.ok_or(Error::CorruptedTaskQueue)?; current_batch.processing(Some(&mut task)); + current_batch.reason(BatchStopReason::TaskCannotBeBatched { + kind: Kind::DumpCreation, + id: tasks.last().unwrap(), + }); return Ok(Some((Batch::Dump(task), current_batch))); } @@ -504,6 +525,10 @@ impl IndexScheduler { } else { assert!(matches!(&task.kind, KindWithContent::IndexSwap { swaps } if swaps.is_empty())); current_batch.processing(Some(&mut task)); + current_batch.reason(BatchStopReason::TaskCannotBeBatched { + kind: Kind::IndexSwap, + id: tasks.last().unwrap(), + }); return Ok(Some((Batch::IndexSwap { task }, current_batch))); }; @@ -525,9 +550,14 @@ impl IndexScheduler { 1 }; + let mut stop_reason = BatchStopReason::default(); let mut enqueued = Vec::new(); let mut total_size: u64 = 0; - for task_id in index_tasks.into_iter().take(tasks_limit) { + for task_id in index_tasks.into_iter() { + if enqueued.len() >= task_limit { + stop_reason = BatchStopReason::ReachedTaskLimit { task_limit }; + break; + } let task = self .queue .tasks @@ -539,16 +569,27 @@ impl IndexScheduler { total_size = total_size.saturating_add(content_size); } - if total_size > self.scheduler.batched_tasks_size_limit && !enqueued.is_empty() { + let size_limit = self.scheduler.batched_tasks_size_limit; + if total_size > size_limit && !enqueued.is_empty() { + stop_reason = BatchStopReason::ReachedSizeLimit { size_limit, size: total_size }; break; } enqueued.push((task.uid, task.kind)); } - if let Some((batchkind, create_index)) = + stop_reason.replace_unspecified({ + if enqueued.len() == count_total_enqueued as usize { + BatchStopReason::ExhaustedEnqueuedTasks + } else { + BatchStopReason::ExhaustedEnqueuedTasksForIndex { index: index_name.to_owned() } + } + }); + + if let Some((batchkind, create_index, autobatch_stop_reason)) = autobatcher::autobatch(enqueued, index_already_exists, primary_key.as_deref()) { + current_batch.reason(autobatch_stop_reason.unwrap_or(stop_reason)); return Ok(self .create_next_batch_index( rtxn, diff --git a/crates/index-scheduler/src/utils.rs b/crates/index-scheduler/src/utils.rs index 42bf253ad..67e8fc090 100644 --- a/crates/index-scheduler/src/utils.rs +++ b/crates/index-scheduler/src/utils.rs @@ -7,7 +7,9 @@ use meilisearch_types::batches::{Batch, BatchEnqueuedAt, BatchId, BatchStats}; use meilisearch_types::heed::{Database, RoTxn, RwTxn}; use meilisearch_types::milli::CboRoaringBitmapCodec; use meilisearch_types::task_view::DetailsView; -use meilisearch_types::tasks::{Details, IndexSwap, Kind, KindWithContent, Status}; +use meilisearch_types::tasks::{ + BatchStopReason, Details, IndexSwap, Kind, KindWithContent, Status, +}; use roaring::RoaringBitmap; use time::OffsetDateTime; @@ -33,6 +35,7 @@ pub struct ProcessingBatch { pub enqueued_at: Option, pub started_at: OffsetDateTime, pub finished_at: Option, + pub reason: BatchStopReason, } impl ProcessingBatch { @@ -53,6 +56,7 @@ impl ProcessingBatch { enqueued_at: None, started_at: OffsetDateTime::now_utc(), finished_at: None, + reason: Default::default(), } } @@ -93,6 +97,10 @@ impl ProcessingBatch { } } + pub fn reason(&mut self, reason: BatchStopReason) { + self.reason = reason; + } + /// Must be called once the batch has finished processing. pub fn finished(&mut self) { self.details = DetailsView::default(); @@ -141,6 +149,7 @@ impl ProcessingBatch { started_at: self.started_at, finished_at: self.finished_at, enqueued_at: self.enqueued_at, + stop_reason: self.reason.to_string(), } } } diff --git a/crates/meilisearch-types/src/batches.rs b/crates/meilisearch-types/src/batches.rs index 904682585..e4934bffd 100644 --- a/crates/meilisearch-types/src/batches.rs +++ b/crates/meilisearch-types/src/batches.rs @@ -6,7 +6,7 @@ use time::OffsetDateTime; use utoipa::ToSchema; use crate::task_view::DetailsView; -use crate::tasks::{Kind, Status}; +use crate::tasks::{BatchStopReason, Kind, Status}; pub type BatchId = u32; @@ -28,11 +28,26 @@ pub struct Batch { // Enqueued at is never displayed and is only required when removing a batch. // It's always some except when upgrading from a database pre v1.12 pub enqueued_at: Option, + #[serde(default = "default_stop_reason")] + pub stop_reason: String, +} + +fn default_stop_reason() -> String { + BatchStopReason::default().to_string() } impl PartialEq for Batch { fn eq(&self, other: &Self) -> bool { - let Self { uid, progress, details, stats, started_at, finished_at, enqueued_at } = self; + let Self { + uid, + progress, + details, + stats, + started_at, + finished_at, + enqueued_at, + stop_reason, + } = self; *uid == other.uid && progress.is_none() == other.progress.is_none() @@ -41,6 +56,7 @@ impl PartialEq for Batch { && started_at == &other.started_at && finished_at == &other.finished_at && enqueued_at == &other.enqueued_at + && stop_reason == &other.stop_reason } } diff --git a/crates/meilisearch-types/src/tasks.rs b/crates/meilisearch-types/src/tasks.rs index 6b237ee1f..cbed6df78 100644 --- a/crates/meilisearch-types/src/tasks.rs +++ b/crates/meilisearch-types/src/tasks.rs @@ -675,6 +675,121 @@ impl Details { } } +#[derive(Default, Debug, Clone)] +pub enum BatchStopReason { + #[default] + Unspecified, + TaskCannotBeBatched { + kind: Kind, + id: TaskId, + }, + ExhaustedEnqueuedTasks, + ExhaustedEnqueuedTasksForIndex { + index: String, + }, + ReachedTaskLimit { + task_limit: usize, + }, + ReachedSizeLimit { + size_limit: usize, + size: usize, + }, + PrimaryKeyIndexMismatch { + id: TaskId, + in_index: String, + in_task: String, + }, + IndexCreationMismatch { + id: TaskId, + }, + PrimaryKeyMismatch { + id: TaskId, + batch_pk: Option, + task_pk: Option, + }, + IndexDeletion { + id: TaskId, + }, + DocumentOperationWithSettings { + id: TaskId, + }, + DocumentOperationWithDeletionByFilter { + id: TaskId, + }, + DeletionByFilterWithDocumentOperation { + id: TaskId, + }, + SettingsWithDocumentOperation { + id: TaskId, + }, +} + +impl BatchStopReason { + pub fn replace_unspecified(&mut self, new: BatchStopReason) { + if let BatchStopReason::Unspecified = self { + *self = new; + } + } +} + +pub enum PrimaryKeyMismatchReason {} + +impl Display for BatchStopReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BatchStopReason::Unspecified => f.write_str("unspecified"), + BatchStopReason::TaskCannotBeBatched { kind, id } => { + write!(f, "task with id {id} of type `{kind}` cannot be batched") + } + BatchStopReason::ExhaustedEnqueuedTasks => f.write_str("batched all enqueued tasks"), + BatchStopReason::ExhaustedEnqueuedTasksForIndex { index } => { + write!(f, "batched all enqueued tasks for index `{index}`") + } + BatchStopReason::ReachedTaskLimit { task_limit } => { + write!(f, "reached configured batch limit of {task_limit} tasks") + } + BatchStopReason::ReachedSizeLimit { size_limit, size } => write!( + f, + "reached configured batch size limit of {size_limit}B with a total of {size}B" + ), + BatchStopReason::PrimaryKeyIndexMismatch { id, in_index, in_task } => { + write!(f, "primary key `{in_task}` in task with id {id} is different from the primary key of the index `{in_index}`") + } + BatchStopReason::IndexCreationMismatch { id } => { + write!(f, "task with id {id} has different index creation rules as in the batch") + } + BatchStopReason::PrimaryKeyMismatch { id, batch_pk, task_pk } => {} + BatchStopReason::IndexDeletion { id } => { + write!(f, "task with id {id} deletes the index") + } + BatchStopReason::DocumentOperationWithSettings { id } => { + write!( + f, + "task with id {id} is a settings change in a batch of document operations" + ) + } + BatchStopReason::DocumentOperationWithDeletionByFilter { id } => { + write!( + f, + "task with id {id} is a deletion by filter in a batch of document operations" + ) + } + BatchStopReason::DeletionByFilterWithDocumentOperation { id } => { + write!( + f, + "task with id {id} is a document operation in a batch of deletions by filter" + ) + } + BatchStopReason::SettingsWithDocumentOperation { id } => { + write!( + f, + "task with id {id} is a document operation in a batch of settings changes" + ) + } + } + } +} + /// Serialize a `time::Duration` as a best effort ISO 8601 while waiting for /// https://github.com/time-rs/time/issues/378. /// This code is a port of the old code of time that was removed in 0.2.