| // Licensed to the Apache Software Foundation (ASF) under one | |
| // or more contributor license agreements. See the NOTICE file | |
| // distributed with this work for additional information | |
| // regarding copyright ownership. The ASF licenses this file | |
| // to you under the Apache License, Version 2.0 (the | |
| // "License"); you may not use this file except in compliance | |
| // with the License. You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, | |
| // software distributed under the License is distributed on an | |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| // KIND, either express or implied. See the License for the | |
| // specific language governing permissions and limitations | |
| // under the License. | |
| use std::{ | |
| cmp, fmt, | |
| ops::AddAssign, | |
| str::FromStr, | |
| sync::{ | |
| Arc, | |
| atomic::{AtomicU8, AtomicU64, Ordering}, | |
| }, | |
| time::Duration, | |
| }; | |
| use arrow::array::{ArrayRef, RecordBatch, StringArray}; | |
| use arrow::util::pretty::pretty_format_batches; | |
| use async_trait::async_trait; | |
| use chrono::Utc; | |
| use datafusion::{ | |
| common::{HashMap, instant::Instant}, | |
| error::DataFusionError, | |
| execution::object_store::{DefaultObjectStoreRegistry, ObjectStoreRegistry}, | |
| }; | |
| use futures::stream::{BoxStream, Stream}; | |
| use futures::{StreamExt, TryStreamExt}; | |
| use object_store::{ | |
| CopyOptions, GetOptions, GetRange, GetResult, ListResult, MultipartUpload, | |
| ObjectMeta, ObjectStore, ObjectStoreExt, PutMultipartOptions, PutOptions, PutPayload, | |
| PutResult, Result, path::Path, | |
| }; | |
| use parking_lot::{Mutex, RwLock}; | |
| use url::Url; | |
| /// A stream wrapper that measures the time until the first response(item or end of stream) is yielded. | |
| /// | |
| /// The timer starts on the first `poll_next` call (not at stream creation) to avoid | |
| /// measuring unrelated work between stream creation and first poll. | |
| /// Duration is stored as nanoseconds in an `AtomicU64` (0 = not yet set). | |
| struct TimeToFirstItemStream<S> { | |
| inner: S, | |
| start: Option<Instant>, | |
| request_duration: Arc<AtomicU64>, | |
| duration_recorded: bool, | |
| } | |
| impl<S> TimeToFirstItemStream<S> { | |
| fn new(inner: S, request_duration: Arc<AtomicU64>) -> Self { | |
| Self { | |
| inner, | |
| start: None, | |
| request_duration, | |
| duration_recorded: false, | |
| } | |
| } | |
| } | |
| impl<S> Stream for TimeToFirstItemStream<S> | |
| where | |
| S: Stream<Item = Result<ObjectMeta>> + Unpin, | |
| { | |
| type Item = Result<ObjectMeta>; | |
| fn poll_next( | |
| mut self: std::pin::Pin<&mut Self>, | |
| cx: &mut std::task::Context<'_>, | |
| ) -> std::task::Poll<Option<Self::Item>> { | |
| let start = *self.start.get_or_insert_with(Instant::now); | |
| let poll_result = std::pin::Pin::new(&mut self.inner).poll_next(cx); | |
| if !self.duration_recorded && poll_result.is_ready() { | |
| self.duration_recorded = true; | |
| let nanos = start.elapsed().as_nanos() as u64; | |
| self.request_duration.store(nanos, Ordering::Release); | |
| } | |
| poll_result | |
| } | |
| } | |
| /// The profiling mode to use for an [`InstrumentedObjectStore`] instance. Collecting profiling | |
| /// data will have a small negative impact on both CPU and memory usage. Default is `Disabled` | |
| #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] | |
| pub enum InstrumentedObjectStoreMode { | |
| /// Disable collection of profiling data | |
| #[default] | |
| Disabled, | |
| /// Enable collection of profiling data and output a summary | |
| Summary, | |
| /// Enable collection of profiling data and output a summary and all details | |
| Trace, | |
| } | |
| impl fmt::Display for InstrumentedObjectStoreMode { | |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| write!(f, "{self:?}") | |
| } | |
| } | |
| impl FromStr for InstrumentedObjectStoreMode { | |
| type Err = DataFusionError; | |
| fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { | |
| match s.to_lowercase().as_str() { | |
| "disabled" => Ok(Self::Disabled), | |
| "summary" => Ok(Self::Summary), | |
| "trace" => Ok(Self::Trace), | |
| _ => Err(DataFusionError::Execution(format!("Unrecognized mode {s}"))), | |
| } | |
| } | |
| } | |
| impl From<u8> for InstrumentedObjectStoreMode { | |
| fn from(value: u8) -> Self { | |
| match value { | |
| 1 => InstrumentedObjectStoreMode::Summary, | |
| 2 => InstrumentedObjectStoreMode::Trace, | |
| _ => InstrumentedObjectStoreMode::Disabled, | |
| } | |
| } | |
| } | |
| /// Wrapped [`ObjectStore`] instances that record information for reporting on the usage of the | |
| /// inner [`ObjectStore`] | |
| #[derive(Debug)] | |
| pub struct InstrumentedObjectStore { | |
| inner: Arc<dyn ObjectStore>, | |
| instrument_mode: AtomicU8, | |
| requests: Arc<Mutex<Vec<RequestDetails>>>, | |
| } | |
| #[expect( | |
| clippy::result_large_err, | |
| reason = "error type is dictated by the object_store API" | |
| )] | |
| impl InstrumentedObjectStore { | |
| /// Returns a new [`InstrumentedObjectStore`] that wraps the provided [`ObjectStore`] | |
| fn new(object_store: Arc<dyn ObjectStore>, instrument_mode: AtomicU8) -> Self { | |
| Self { | |
| inner: object_store, | |
| instrument_mode, | |
| requests: Arc::new(Mutex::new(Vec::new())), | |
| } | |
| } | |
| fn set_instrument_mode(&self, mode: InstrumentedObjectStoreMode) { | |
| self.instrument_mode.store(mode as u8, Ordering::Relaxed) | |
| } | |
| /// Returns all [`RequestDetails`] accumulated in this [`InstrumentedObjectStore`] and clears | |
| /// the stored requests | |
| pub fn take_requests(&self) -> Vec<RequestDetails> { | |
| let mut req = self.requests.lock(); | |
| req.drain(..).collect() | |
| } | |
| fn enabled(&self) -> bool { | |
| self.instrument_mode.load(Ordering::Relaxed) | |
| != InstrumentedObjectStoreMode::Disabled as u8 | |
| } | |
| async fn instrumented_put_opts( | |
| &self, | |
| location: &Path, | |
| payload: PutPayload, | |
| opts: PutOptions, | |
| ) -> Result<PutResult> { | |
| let timestamp = Utc::now(); | |
| let start = Instant::now(); | |
| let size = payload.content_length(); | |
| let ret = self.inner.put_opts(location, payload, opts).await?; | |
| let elapsed = start.elapsed(); | |
| self.requests.lock().push(RequestDetails { | |
| op: Operation::Put, | |
| path: location.clone(), | |
| timestamp, | |
| duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)), | |
| size: Some(size), | |
| range: None, | |
| extra_display: None, | |
| }); | |
| Ok(ret) | |
| } | |
| async fn instrumented_put_multipart( | |
| &self, | |
| location: &Path, | |
| opts: PutMultipartOptions, | |
| ) -> Result<Box<dyn MultipartUpload>> { | |
| let timestamp = Utc::now(); | |
| let start = Instant::now(); | |
| let ret = self.inner.put_multipart_opts(location, opts).await?; | |
| let elapsed = start.elapsed(); | |
| self.requests.lock().push(RequestDetails { | |
| op: Operation::Put, | |
| path: location.clone(), | |
| timestamp, | |
| duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)), | |
| size: None, | |
| range: None, | |
| extra_display: None, | |
| }); | |
| Ok(ret) | |
| } | |
| async fn instrumented_get_opts( | |
| &self, | |
| location: &Path, | |
| options: GetOptions, | |
| ) -> Result<GetResult> { | |
| let timestamp = Utc::now(); | |
| let range = options.range.clone(); | |
| let head = options.head; | |
| let start = Instant::now(); | |
| let ret = self.inner.get_opts(location, options).await?; | |
| let elapsed = start.elapsed(); | |
| let (op, size) = if head { | |
| (Operation::Head, None) | |
| } else { | |
| ( | |
| Operation::Get, | |
| Some((ret.range.end - ret.range.start) as usize), | |
| ) | |
| }; | |
| self.requests.lock().push(RequestDetails { | |
| op, | |
| path: location.clone(), | |
| timestamp, | |
| duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)), | |
| size, | |
| range, | |
| extra_display: None, | |
| }); | |
| Ok(ret) | |
| } | |
| fn instrumented_delete_stream( | |
| &self, | |
| locations: BoxStream<'static, Result<Path>>, | |
| ) -> BoxStream<'static, Result<Path>> { | |
| let requests_captured = Arc::clone(&self.requests); | |
| let timestamp = Utc::now(); | |
| let start = Instant::now(); | |
| self.inner | |
| .delete_stream(locations) | |
| .and_then(move |location| { | |
| let elapsed = start.elapsed(); | |
| requests_captured.lock().push(RequestDetails { | |
| op: Operation::Delete, | |
| path: location.clone(), | |
| timestamp, | |
| duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)), | |
| size: None, | |
| range: None, | |
| extra_display: None, | |
| }); | |
| futures::future::ok(location) | |
| }) | |
| .boxed() | |
| } | |
| fn instrumented_list( | |
| &self, | |
| prefix: Option<&Path>, | |
| ) -> BoxStream<'static, Result<ObjectMeta>> { | |
| let timestamp = Utc::now(); | |
| let inner_stream = self.inner.list(prefix); | |
| let duration_nanos = Arc::new(AtomicU64::new(0)); | |
| self.requests.lock().push(RequestDetails { | |
| op: Operation::List, | |
| path: prefix.cloned().unwrap_or_else(|| Path::from("")), | |
| timestamp, | |
| duration_nanos: Arc::clone(&duration_nanos), | |
| size: None, | |
| range: None, | |
| extra_display: None, | |
| }); | |
| Box::pin(TimeToFirstItemStream::new(inner_stream, duration_nanos)) | |
| } | |
| async fn instrumented_list_with_delimiter( | |
| &self, | |
| prefix: Option<&Path>, | |
| ) -> Result<ListResult> { | |
| let timestamp = Utc::now(); | |
| let start = Instant::now(); | |
| let ret = self.inner.list_with_delimiter(prefix).await?; | |
| let elapsed = start.elapsed(); | |
| self.requests.lock().push(RequestDetails { | |
| op: Operation::List, | |
| path: prefix.cloned().unwrap_or_else(|| Path::from("")), | |
| timestamp, | |
| duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)), | |
| size: None, | |
| range: None, | |
| extra_display: None, | |
| }); | |
| Ok(ret) | |
| } | |
| async fn instrumented_copy(&self, from: &Path, to: &Path) -> Result<()> { | |
| let timestamp = Utc::now(); | |
| let start = Instant::now(); | |
| self.inner.copy(from, to).await?; | |
| let elapsed = start.elapsed(); | |
| self.requests.lock().push(RequestDetails { | |
| op: Operation::Copy, | |
| path: from.clone(), | |
| timestamp, | |
| duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)), | |
| size: None, | |
| range: None, | |
| extra_display: Some(format!("copy_to: {to}")), | |
| }); | |
| Ok(()) | |
| } | |
| async fn instrumented_copy_if_not_exists( | |
| &self, | |
| from: &Path, | |
| to: &Path, | |
| ) -> Result<()> { | |
| let timestamp = Utc::now(); | |
| let start = Instant::now(); | |
| self.inner.copy_if_not_exists(from, to).await?; | |
| let elapsed = start.elapsed(); | |
| self.requests.lock().push(RequestDetails { | |
| op: Operation::Copy, | |
| path: from.clone(), | |
| timestamp, | |
| duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)), | |
| size: None, | |
| range: None, | |
| extra_display: Some(format!("copy_to: {to}")), | |
| }); | |
| Ok(()) | |
| } | |
| } | |
| impl fmt::Display for InstrumentedObjectStore { | |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| let mode: InstrumentedObjectStoreMode = | |
| self.instrument_mode.load(Ordering::Relaxed).into(); | |
| write!( | |
| f, | |
| "Instrumented Object Store: instrument_mode: {mode}, inner: {}", | |
| self.inner | |
| ) | |
| } | |
| } | |
| #[async_trait] | |
| impl ObjectStore for InstrumentedObjectStore { | |
| async fn put_opts( | |
| &self, | |
| location: &Path, | |
| payload: PutPayload, | |
| opts: PutOptions, | |
| ) -> Result<PutResult> { | |
| if self.enabled() { | |
| return self.instrumented_put_opts(location, payload, opts).await; | |
| } | |
| self.inner.put_opts(location, payload, opts).await | |
| } | |
| async fn put_multipart_opts( | |
| &self, | |
| location: &Path, | |
| opts: PutMultipartOptions, | |
| ) -> Result<Box<dyn MultipartUpload>> { | |
| if self.enabled() { | |
| return self.instrumented_put_multipart(location, opts).await; | |
| } | |
| self.inner.put_multipart_opts(location, opts).await | |
| } | |
| async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> { | |
| if self.enabled() { | |
| return self.instrumented_get_opts(location, options).await; | |
| } | |
| self.inner.get_opts(location, options).await | |
| } | |
| fn delete_stream( | |
| &self, | |
| locations: BoxStream<'static, Result<Path>>, | |
| ) -> BoxStream<'static, Result<Path>> { | |
| if self.enabled() { | |
| return self.instrumented_delete_stream(locations); | |
| } | |
| self.inner.delete_stream(locations) | |
| } | |
| fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> { | |
| if self.enabled() { | |
| return self.instrumented_list(prefix); | |
| } | |
| self.inner.list(prefix) | |
| } | |
| async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> { | |
| if self.enabled() { | |
| return self.instrumented_list_with_delimiter(prefix).await; | |
| } | |
| self.inner.list_with_delimiter(prefix).await | |
| } | |
| async fn copy_opts( | |
| &self, | |
| from: &Path, | |
| to: &Path, | |
| options: CopyOptions, | |
| ) -> Result<()> { | |
| if self.enabled() { | |
| return match options.mode { | |
| object_store::CopyMode::Create => { | |
| self.instrumented_copy_if_not_exists(from, to).await | |
| } | |
| object_store::CopyMode::Overwrite => { | |
| self.instrumented_copy(from, to).await | |
| } | |
| }; | |
| } | |
| self.inner.copy_opts(from, to, options).await | |
| } | |
| } | |
| /// Object store operation types tracked by [`InstrumentedObjectStore`] | |
| #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| pub enum Operation { | |
| Copy, | |
| Delete, | |
| Get, | |
| Head, | |
| List, | |
| Put, | |
| } | |
| impl fmt::Display for Operation { | |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| write!(f, "{self:?}") | |
| } | |
| } | |
| /// Holds profiling details about individual requests made through an [`InstrumentedObjectStore`] | |
| pub struct RequestDetails { | |
| op: Operation, | |
| path: Path, | |
| timestamp: chrono::DateTime<Utc>, | |
| /// Duration stored as nanoseconds in an AtomicU64. 0 means not yet set. | |
| duration_nanos: Arc<AtomicU64>, | |
| size: Option<usize>, | |
| range: Option<GetRange>, | |
| extra_display: Option<String>, | |
| } | |
| impl fmt::Debug for RequestDetails { | |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| f.debug_struct("RequestDetails") | |
| .field("op", &self.op) | |
| .field("path", &self.path) | |
| .field("timestamp", &self.timestamp) | |
| .field("duration", &self.duration()) | |
| .field("size", &self.size) | |
| .field("range", &self.range) | |
| .field("extra_display", &self.extra_display) | |
| .finish() | |
| } | |
| } | |
| impl RequestDetails { | |
| fn duration(&self) -> Option<Duration> { | |
| let nanos = self.duration_nanos.load(Ordering::Acquire); | |
| if nanos == 0 { | |
| None | |
| } else { | |
| Some(Duration::from_nanos(nanos)) | |
| } | |
| } | |
| } | |
| impl fmt::Display for RequestDetails { | |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| let mut output_parts = vec![format!( | |
| "{} operation={:?}", | |
| self.timestamp.to_rfc3339(), | |
| self.op | |
| )]; | |
| if let Some(d) = self.duration() { | |
| output_parts.push(format!("duration={:.6}s", d.as_secs_f32())); | |
| } | |
| if let Some(s) = self.size { | |
| output_parts.push(format!("size={s}")); | |
| } | |
| if let Some(r) = &self.range { | |
| output_parts.push(format!("range: {r}")); | |
| } | |
| output_parts.push(format!("path={}", self.path)); | |
| if let Some(ed) = &self.extra_display { | |
| output_parts.push(ed.clone()); | |
| } | |
| write!(f, "{}", output_parts.join(" ")) | |
| } | |
| } | |
| /// Summary statistics for all requests recorded in an [`InstrumentedObjectStore`] | |
| #[derive(Default)] | |
| pub struct RequestSummaries { | |
| summaries: Vec<RequestSummary>, | |
| } | |
| /// Display the summary as a table | |
| impl fmt::Display for RequestSummaries { | |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| // Don't expect an error, but avoid panicking if it happens | |
| match pretty_format_batches(&[self.to_batch()]) { | |
| Err(e) => { | |
| write!(f, "Error formatting summary: {e}") | |
| } | |
| Ok(displayable) => { | |
| write!(f, "{displayable}") | |
| } | |
| } | |
| } | |
| } | |
| impl RequestSummaries { | |
| /// Summarizes input [`RequestDetails`] | |
| pub fn new(requests: &[RequestDetails]) -> Self { | |
| let mut summaries: HashMap<Operation, RequestSummary> = HashMap::new(); | |
| for rd in requests { | |
| match summaries.get_mut(&rd.op) { | |
| Some(rs) => rs.push(rd), | |
| None => { | |
| let mut rs = RequestSummary::new(rd.op); | |
| rs.push(rd); | |
| summaries.insert(rd.op, rs); | |
| } | |
| } | |
| } | |
| // Convert to a Vec with consistent ordering | |
| let mut summaries: Vec<RequestSummary> = summaries.into_values().collect(); | |
| summaries.sort_by_key(|s| s.operation); | |
| Self { summaries } | |
| } | |
| /// Convert the summaries into a `RecordBatch` for display | |
| /// | |
| /// Results in a table like: | |
| /// ```text | |
| /// +-----------+----------+-----------+-----------+-----------+-----------+-----------+ | |
| /// | Operation | Metric | min | max | avg | sum | count | | |
| /// +-----------+----------+-----------+-----------+-----------+-----------+-----------+ | |
| /// | Get | duration | 5.000000s | 5.000000s | 5.000000s | | 1 | | |
| /// | Get | size | 100 B | 100 B | 100 B | 100 B | 1 | | |
| /// +-----------+----------+-----------+-----------+-----------+-----------+-----------+ | |
| /// ``` | |
| pub fn to_batch(&self) -> RecordBatch { | |
| let operations: StringArray = self | |
| .iter() | |
| .flat_map(|s| std::iter::repeat_n(Some(s.operation.to_string()), 2)) | |
| .collect(); | |
| let metrics: StringArray = self | |
| .iter() | |
| .flat_map(|_s| [Some("duration"), Some("size")]) | |
| .collect(); | |
| let mins: StringArray = self | |
| .stats_iter() | |
| .flat_map(|(duration_stats, size_stats)| { | |
| let dur_min = | |
| duration_stats.map(|d| format!("{:.6}s", d.min.as_secs_f32())); | |
| let size_min = size_stats.map(|s| format!("{} B", s.min)); | |
| [dur_min, size_min] | |
| }) | |
| .collect(); | |
| let maxs: StringArray = self | |
| .stats_iter() | |
| .flat_map(|(duration_stats, size_stats)| { | |
| let dur_max = | |
| duration_stats.map(|d| format!("{:.6}s", d.max.as_secs_f32())); | |
| let size_max = size_stats.map(|s| format!("{} B", s.max)); | |
| [dur_max, size_max] | |
| }) | |
| .collect(); | |
| let avgs: StringArray = self | |
| .iter() | |
| .flat_map(|s| { | |
| let count = s.count as f32; | |
| let duration_stats = s.duration_stats.as_ref(); | |
| let size_stats = s.size_stats.as_ref(); | |
| let dur_avg = duration_stats.map(|d| { | |
| let avg = d.sum.as_secs_f32() / count; | |
| format!("{avg:.6}s") | |
| }); | |
| let size_avg = size_stats.map(|s| { | |
| let avg = s.sum as f32 / count; | |
| format!("{avg} B") | |
| }); | |
| [dur_avg, size_avg] | |
| }) | |
| .collect(); | |
| let sums: StringArray = self | |
| .stats_iter() | |
| .flat_map(|(duration_stats, size_stats)| { | |
| // Omit a sum stat for duration in the initial | |
| // implementation because it can be a bit misleading (at least | |
| // at first glance). For example, particularly large queries the | |
| // sum of the durations was often larger than the total time of | |
| // the query itself, can be confusing without additional | |
| // explanation (e.g. that the sum is of individual requests, | |
| // which may be concurrent). | |
| let dur_sum = | |
| duration_stats.map(|d| format!("{:.6}s", d.sum.as_secs_f32())); | |
| let size_sum = size_stats.map(|s| format!("{} B", s.sum)); | |
| [dur_sum, size_sum] | |
| }) | |
| .collect(); | |
| let counts: StringArray = self | |
| .iter() | |
| .flat_map(|s| { | |
| let count = s.count.to_string(); | |
| [Some(count.clone()), Some(count)] | |
| }) | |
| .collect(); | |
| RecordBatch::try_from_iter(vec![ | |
| ("Operation", Arc::new(operations) as ArrayRef), | |
| ("Metric", Arc::new(metrics) as ArrayRef), | |
| ("min", Arc::new(mins) as ArrayRef), | |
| ("max", Arc::new(maxs) as ArrayRef), | |
| ("avg", Arc::new(avgs) as ArrayRef), | |
| ("sum", Arc::new(sums) as ArrayRef), | |
| ("count", Arc::new(counts) as ArrayRef), | |
| ]) | |
| .expect("Created the batch correctly") | |
| } | |
| /// Return an iterator over the summaries | |
| fn iter(&self) -> impl Iterator<Item = &RequestSummary> { | |
| self.summaries.iter() | |
| } | |
| /// Return an iterator over (duration_stats, size_stats) tuples | |
| /// for each summary | |
| fn stats_iter( | |
| &self, | |
| ) -> impl Iterator<Item = (Option<&Stats<Duration>>, Option<&Stats<usize>>)> { | |
| self.summaries | |
| .iter() | |
| .map(|s| (s.duration_stats.as_ref(), s.size_stats.as_ref())) | |
| } | |
| } | |
| /// Summary statistics for a particular type of [`Operation`] (e.g. `GET` or `PUT`) | |
| /// in an [`InstrumentedObjectStore`]'s [`RequestDetails`] | |
| pub struct RequestSummary { | |
| operation: Operation, | |
| count: usize, | |
| duration_stats: Option<Stats<Duration>>, | |
| size_stats: Option<Stats<usize>>, | |
| } | |
| impl RequestSummary { | |
| fn new(operation: Operation) -> Self { | |
| Self { | |
| operation, | |
| count: 0, | |
| duration_stats: None, | |
| size_stats: None, | |
| } | |
| } | |
| fn push(&mut self, request: &RequestDetails) { | |
| self.count += 1; | |
| if let Some(dur) = request.duration() { | |
| self.duration_stats.get_or_insert_default().push(dur) | |
| } | |
| if let Some(size) = request.size { | |
| self.size_stats.get_or_insert_default().push(size) | |
| } | |
| } | |
| } | |
| struct Stats<T: Copy + Ord + AddAssign<T>> { | |
| min: T, | |
| max: T, | |
| sum: T, | |
| } | |
| impl<T: Copy + Ord + AddAssign<T>> Stats<T> { | |
| fn push(&mut self, val: T) { | |
| self.min = cmp::min(val, self.min); | |
| self.max = cmp::max(val, self.max); | |
| self.sum += val; | |
| } | |
| } | |
| impl Default for Stats<Duration> { | |
| fn default() -> Self { | |
| Self { | |
| min: Duration::MAX, | |
| max: Duration::ZERO, | |
| sum: Duration::ZERO, | |
| } | |
| } | |
| } | |
| impl Default for Stats<usize> { | |
| fn default() -> Self { | |
| Self { | |
| min: usize::MAX, | |
| max: usize::MIN, | |
| sum: 0, | |
| } | |
| } | |
| } | |
| /// Provides access to [`InstrumentedObjectStore`] instances that record requests for reporting | |
| #[derive(Debug)] | |
| pub struct InstrumentedObjectStoreRegistry { | |
| inner: Arc<dyn ObjectStoreRegistry>, | |
| instrument_mode: AtomicU8, | |
| stores: RwLock<Vec<Arc<InstrumentedObjectStore>>>, | |
| } | |
| impl Default for InstrumentedObjectStoreRegistry { | |
| fn default() -> Self { | |
| Self::new() | |
| } | |
| } | |
| impl InstrumentedObjectStoreRegistry { | |
| /// Returns a new [`InstrumentedObjectStoreRegistry`] that wraps the provided | |
| /// [`ObjectStoreRegistry`] | |
| pub fn new() -> Self { | |
| Self { | |
| inner: Arc::new(DefaultObjectStoreRegistry::new()), | |
| instrument_mode: AtomicU8::new(InstrumentedObjectStoreMode::default() as u8), | |
| stores: RwLock::new(Vec::new()), | |
| } | |
| } | |
| pub fn with_profile_mode(self, mode: InstrumentedObjectStoreMode) -> Self { | |
| self.instrument_mode.store(mode as u8, Ordering::Relaxed); | |
| self | |
| } | |
| /// Provides access to all of the [`InstrumentedObjectStore`]s managed by this | |
| /// [`InstrumentedObjectStoreRegistry`] | |
| pub fn stores(&self) -> Vec<Arc<InstrumentedObjectStore>> { | |
| self.stores.read().clone() | |
| } | |
| /// Returns the current [`InstrumentedObjectStoreMode`] for this | |
| /// [`InstrumentedObjectStoreRegistry`] | |
| pub fn instrument_mode(&self) -> InstrumentedObjectStoreMode { | |
| self.instrument_mode.load(Ordering::Relaxed).into() | |
| } | |
| /// Sets the [`InstrumentedObjectStoreMode`] for this [`InstrumentedObjectStoreRegistry`] | |
| pub fn set_instrument_mode(&self, mode: InstrumentedObjectStoreMode) { | |
| self.instrument_mode.store(mode as u8, Ordering::Relaxed); | |
| for s in self.stores.read().iter() { | |
| s.set_instrument_mode(mode) | |
| } | |
| } | |
| } | |
| impl ObjectStoreRegistry for InstrumentedObjectStoreRegistry { | |
| fn register_store( | |
| &self, | |
| url: &Url, | |
| store: Arc<dyn ObjectStore>, | |
| ) -> Option<Arc<dyn ObjectStore>> { | |
| let mode = self.instrument_mode.load(Ordering::Relaxed); | |
| let instrumented = | |
| Arc::new(InstrumentedObjectStore::new(store, AtomicU8::new(mode))); | |
| self.stores.write().push(Arc::clone(&instrumented)); | |
| self.inner.register_store(url, instrumented) | |
| } | |
| fn deregister_store( | |
| &self, | |
| url: &Url, | |
| ) -> datafusion::common::Result<Arc<dyn ObjectStore>> { | |
| self.inner.deregister_store(url) | |
| } | |
| fn get_store(&self, url: &Url) -> datafusion::common::Result<Arc<dyn ObjectStore>> { | |
| self.inner.get_store(url) | |
| } | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use futures::StreamExt; | |
| use object_store::WriteMultipart; | |
| use super::*; | |
| use insta::assert_snapshot; | |
| #[test] | |
| fn instrumented_mode() { | |
| assert!(matches!( | |
| InstrumentedObjectStoreMode::default(), | |
| InstrumentedObjectStoreMode::Disabled | |
| )); | |
| assert!(matches!( | |
| "dIsABleD".parse().unwrap(), | |
| InstrumentedObjectStoreMode::Disabled | |
| )); | |
| assert!(matches!( | |
| "SUmMaRy".parse().unwrap(), | |
| InstrumentedObjectStoreMode::Summary | |
| )); | |
| assert!(matches!( | |
| "TRaCe".parse().unwrap(), | |
| InstrumentedObjectStoreMode::Trace | |
| )); | |
| assert!( | |
| "does_not_exist" | |
| .parse::<InstrumentedObjectStoreMode>() | |
| .is_err() | |
| ); | |
| assert!(matches!(0.into(), InstrumentedObjectStoreMode::Disabled)); | |
| assert!(matches!(1.into(), InstrumentedObjectStoreMode::Summary)); | |
| assert!(matches!(2.into(), InstrumentedObjectStoreMode::Trace)); | |
| assert!(matches!(3.into(), InstrumentedObjectStoreMode::Disabled)); | |
| } | |
| #[test] | |
| fn instrumented_registry() { | |
| let mut reg = InstrumentedObjectStoreRegistry::new(); | |
| assert!(reg.stores().is_empty()); | |
| assert_eq!( | |
| reg.instrument_mode(), | |
| InstrumentedObjectStoreMode::default() | |
| ); | |
| reg = reg.with_profile_mode(InstrumentedObjectStoreMode::Trace); | |
| assert_eq!(reg.instrument_mode(), InstrumentedObjectStoreMode::Trace); | |
| let store = object_store::memory::InMemory::new(); | |
| let url = "mem://test".parse().unwrap(); | |
| let registered = reg.register_store(&url, Arc::new(store)); | |
| assert!(registered.is_none()); | |
| let fetched = reg.get_store(&url); | |
| assert!(fetched.is_ok()); | |
| assert_eq!(reg.stores().len(), 1); | |
| } | |
| // Returns an `InstrumentedObjectStore` with some data loaded for testing and the path to | |
| // access the data | |
| async fn setup_test_store() -> (InstrumentedObjectStore, Path) { | |
| let store = Arc::new(object_store::memory::InMemory::new()); | |
| let mode = AtomicU8::new(InstrumentedObjectStoreMode::default() as u8); | |
| let instrumented = InstrumentedObjectStore::new(store, mode); | |
| // Load the test store with some data we can read | |
| let path = Path::from("test/data"); | |
| let payload = PutPayload::from_static(b"test_data"); | |
| instrumented.put(&path, payload).await.unwrap(); | |
| (instrumented, path) | |
| } | |
| #[tokio::test] | |
| async fn instrumented_store_get() { | |
| let (instrumented, path) = setup_test_store().await; | |
| // By default no requests should be instrumented/stored | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let _ = instrumented.get(&path).await.unwrap(); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let _ = instrumented.get(&path).await.unwrap(); | |
| assert_eq!(instrumented.requests.lock().len(), 1); | |
| let mut requests = instrumented.take_requests(); | |
| assert_eq!(requests.len(), 1); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let request = requests.pop().unwrap(); | |
| assert_eq!(request.op, Operation::Get); | |
| assert_eq!(request.path, path); | |
| assert!(request.duration().is_some()); | |
| assert_eq!(request.size, Some(9)); | |
| assert_eq!(request.range, None); | |
| assert!(request.extra_display.is_none()); | |
| } | |
| #[tokio::test] | |
| async fn instrumented_store_delete() { | |
| let (instrumented, path) = setup_test_store().await; | |
| // By default no requests should be instrumented/stored | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.delete(&path).await.unwrap(); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| // We need a new store so we have data to delete again | |
| let (instrumented, path) = setup_test_store().await; | |
| instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.delete(&path).await.unwrap(); | |
| assert_eq!(instrumented.requests.lock().len(), 1); | |
| let mut requests = instrumented.take_requests(); | |
| assert_eq!(requests.len(), 1); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let request = requests.pop().unwrap(); | |
| assert_eq!(request.op, Operation::Delete); | |
| assert_eq!(request.path, path); | |
| assert!(request.duration().is_some()); | |
| assert!(request.size.is_none()); | |
| assert!(request.range.is_none()); | |
| assert!(request.extra_display.is_none()); | |
| } | |
| #[tokio::test] | |
| async fn instrumented_store_list() { | |
| let (instrumented, path) = setup_test_store().await; | |
| // By default no requests should be instrumented/stored | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let _ = instrumented.list(Some(&path)); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let mut stream = instrumented.list(Some(&path)); | |
| // Sleep between stream creation and first poll to verify the timer | |
| // starts on first poll, not at stream creation. | |
| let delay = Duration::from_millis(50); | |
| tokio::time::sleep(delay).await; | |
| let _ = stream.next().await; | |
| assert_eq!(instrumented.requests.lock().len(), 1); | |
| let request = instrumented.take_requests().pop().unwrap(); | |
| assert_eq!(request.op, Operation::List); | |
| assert_eq!(request.path, path); | |
| let duration = request | |
| .duration() | |
| .expect("duration should be set after consuming stream"); | |
| assert!( | |
| duration < delay, | |
| "duration {duration:?} should exclude the {delay:?} sleep before first poll" | |
| ); | |
| assert!(request.size.is_none()); | |
| assert!(request.range.is_none()); | |
| assert!(request.extra_display.is_none()); | |
| } | |
| #[tokio::test] | |
| async fn time_to_first_item_stream_captures_inner_latency() { | |
| let inner_delay = Duration::from_millis(50); | |
| let inner_stream = futures::stream::once(async move { | |
| tokio::time::sleep(inner_delay).await; | |
| Ok(ObjectMeta { | |
| location: Path::from("test"), | |
| last_modified: Utc::now(), | |
| size: 0, | |
| e_tag: None, | |
| version: None, | |
| }) | |
| }) | |
| .boxed(); | |
| let duration_nanos = Arc::new(AtomicU64::new(0)); | |
| let mut stream = Box::pin(TimeToFirstItemStream::new( | |
| inner_stream, | |
| Arc::clone(&duration_nanos), | |
| )); | |
| let _ = stream.next().await; | |
| let recorded = Duration::from_nanos(duration_nanos.load(Ordering::Acquire)); | |
| assert!( | |
| recorded >= inner_delay, | |
| "recorded duration {recorded:?} should be >= inner stream delay {inner_delay:?}" | |
| ); | |
| } | |
| #[tokio::test] | |
| async fn instrumented_store_list_with_delimiter() { | |
| let (instrumented, path) = setup_test_store().await; | |
| // By default no requests should be instrumented/stored | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let _ = instrumented.list_with_delimiter(Some(&path)).await.unwrap(); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let _ = instrumented.list_with_delimiter(Some(&path)).await.unwrap(); | |
| assert_eq!(instrumented.requests.lock().len(), 1); | |
| let request = instrumented.take_requests().pop().unwrap(); | |
| assert_eq!(request.op, Operation::List); | |
| assert_eq!(request.path, path); | |
| assert!(request.duration().is_some()); | |
| assert!(request.size.is_none()); | |
| assert!(request.range.is_none()); | |
| assert!(request.extra_display.is_none()); | |
| } | |
| #[tokio::test] | |
| async fn instrumented_store_put_opts() { | |
| // The `setup_test_store()` method comes with data already `put` into it, so we'll setup | |
| // manually for this test | |
| let store = Arc::new(object_store::memory::InMemory::new()); | |
| let mode = AtomicU8::new(InstrumentedObjectStoreMode::default() as u8); | |
| let instrumented = InstrumentedObjectStore::new(store, mode); | |
| let path = Path::from("test/data"); | |
| let payload = PutPayload::from_static(b"test_data"); | |
| let size = payload.content_length(); | |
| // By default no requests should be instrumented/stored | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.put(&path, payload.clone()).await.unwrap(); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.put(&path, payload).await.unwrap(); | |
| assert_eq!(instrumented.requests.lock().len(), 1); | |
| let request = instrumented.take_requests().pop().unwrap(); | |
| assert_eq!(request.op, Operation::Put); | |
| assert_eq!(request.path, path); | |
| assert!(request.duration().is_some()); | |
| assert_eq!(request.size.unwrap(), size); | |
| assert!(request.range.is_none()); | |
| assert!(request.extra_display.is_none()); | |
| } | |
| #[tokio::test] | |
| async fn instrumented_store_put_multipart() { | |
| // The `setup_test_store()` method comes with data already `put` into it, so we'll setup | |
| // manually for this test | |
| let store = Arc::new(object_store::memory::InMemory::new()); | |
| let mode = AtomicU8::new(InstrumentedObjectStoreMode::default() as u8); | |
| let instrumented = InstrumentedObjectStore::new(store, mode); | |
| let path = Path::from("test/data"); | |
| // By default no requests should be instrumented/stored | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let mp = instrumented.put_multipart(&path).await.unwrap(); | |
| let mut write = WriteMultipart::new(mp); | |
| write.write(b"test_data"); | |
| write.finish().await.unwrap(); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let mp = instrumented.put_multipart(&path).await.unwrap(); | |
| let mut write = WriteMultipart::new(mp); | |
| write.write(b"test_data"); | |
| write.finish().await.unwrap(); | |
| assert_eq!(instrumented.requests.lock().len(), 1); | |
| let request = instrumented.take_requests().pop().unwrap(); | |
| assert_eq!(request.op, Operation::Put); | |
| assert_eq!(request.path, path); | |
| assert!(request.duration().is_some()); | |
| assert!(request.size.is_none()); | |
| assert!(request.range.is_none()); | |
| assert!(request.extra_display.is_none()); | |
| } | |
| #[tokio::test] | |
| async fn instrumented_store_copy() { | |
| let (instrumented, path) = setup_test_store().await; | |
| let copy_to = Path::from("test/copied"); | |
| // By default no requests should be instrumented/stored | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.copy(&path, ©_to).await.unwrap(); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.copy(&path, ©_to).await.unwrap(); | |
| assert_eq!(instrumented.requests.lock().len(), 1); | |
| let mut requests = instrumented.take_requests(); | |
| assert_eq!(requests.len(), 1); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let request = requests.pop().unwrap(); | |
| assert_eq!(request.op, Operation::Copy); | |
| assert_eq!(request.path, path); | |
| assert!(request.duration().is_some()); | |
| assert!(request.size.is_none()); | |
| assert!(request.range.is_none()); | |
| assert_eq!( | |
| request.extra_display.unwrap(), | |
| format!("copy_to: {copy_to}") | |
| ); | |
| } | |
| #[tokio::test] | |
| async fn instrumented_store_copy_if_not_exists() { | |
| let (instrumented, path) = setup_test_store().await; | |
| let mut copy_to = Path::from("test/copied"); | |
| // By default no requests should be instrumented/stored | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented | |
| .copy_if_not_exists(&path, ©_to) | |
| .await | |
| .unwrap(); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| // Use a new destination since the previous one already exists | |
| copy_to = Path::from("test/copied_again"); | |
| instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented | |
| .copy_if_not_exists(&path, ©_to) | |
| .await | |
| .unwrap(); | |
| assert_eq!(instrumented.requests.lock().len(), 1); | |
| let mut requests = instrumented.take_requests(); | |
| assert_eq!(requests.len(), 1); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let request = requests.pop().unwrap(); | |
| assert_eq!(request.op, Operation::Copy); | |
| assert_eq!(request.path, path); | |
| assert!(request.duration().is_some()); | |
| assert!(request.size.is_none()); | |
| assert!(request.range.is_none()); | |
| assert_eq!( | |
| request.extra_display.unwrap(), | |
| format!("copy_to: {copy_to}") | |
| ); | |
| } | |
| #[tokio::test] | |
| async fn instrumented_store_head() { | |
| let (instrumented, path) = setup_test_store().await; | |
| // By default no requests should be instrumented/stored | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let _ = instrumented.head(&path).await.unwrap(); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let _ = instrumented.head(&path).await.unwrap(); | |
| assert_eq!(instrumented.requests.lock().len(), 1); | |
| let mut requests = instrumented.take_requests(); | |
| assert_eq!(requests.len(), 1); | |
| assert!(instrumented.requests.lock().is_empty()); | |
| let request = requests.pop().unwrap(); | |
| assert_eq!(request.op, Operation::Head); | |
| assert_eq!(request.path, path); | |
| assert!(request.duration().is_some()); | |
| assert!(request.size.is_none()); | |
| assert!(request.range.is_none()); | |
| assert!(request.extra_display.is_none()); | |
| } | |
| #[test] | |
| fn request_details() { | |
| let rd = RequestDetails { | |
| op: Operation::Get, | |
| path: Path::from("test"), | |
| timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(), | |
| duration_nanos: Arc::new(AtomicU64::new( | |
| Duration::new(5, 0).as_nanos() as u64 | |
| )), | |
| size: Some(10), | |
| range: Some((..10).into()), | |
| extra_display: Some(String::from("extra info")), | |
| }; | |
| assert_eq!( | |
| format!("{rd}"), | |
| "1970-01-01T00:00:00+00:00 operation=Get duration=5.000000s size=10 range: bytes=0-9 path=test extra info" | |
| ); | |
| } | |
| #[test] | |
| fn request_summary() { | |
| // Test empty request list | |
| let mut requests = Vec::new(); | |
| assert_snapshot!(RequestSummaries::new(&requests), @r" | |
| +-----------+--------+-----+-----+-----+-----+-------+ | |
| | Operation | Metric | min | max | avg | sum | count | | |
| +-----------+--------+-----+-----+-----+-----+-------+ | |
| +-----------+--------+-----+-----+-----+-----+-------+ | |
| "); | |
| requests.push(RequestDetails { | |
| op: Operation::Get, | |
| path: Path::from("test1"), | |
| timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(), | |
| duration_nanos: Arc::new(AtomicU64::new( | |
| Duration::from_secs(5).as_nanos() as u64 | |
| )), | |
| size: Some(100), | |
| range: None, | |
| extra_display: None, | |
| }); | |
| assert_snapshot!(RequestSummaries::new(&requests), @r" | |
| +-----------+----------+-----------+-----------+-----------+-----------+-------+ | |
| | Operation | Metric | min | max | avg | sum | count | | |
| +-----------+----------+-----------+-----------+-----------+-----------+-------+ | |
| | Get | duration | 5.000000s | 5.000000s | 5.000000s | 5.000000s | 1 | | |
| | Get | size | 100 B | 100 B | 100 B | 100 B | 1 | | |
| +-----------+----------+-----------+-----------+-----------+-----------+-------+ | |
| "); | |
| // Add more Get requests to test aggregation | |
| requests.push(RequestDetails { | |
| op: Operation::Get, | |
| path: Path::from("test2"), | |
| timestamp: chrono::DateTime::from_timestamp(1, 0).unwrap(), | |
| duration_nanos: Arc::new(AtomicU64::new( | |
| Duration::from_secs(8).as_nanos() as u64 | |
| )), | |
| size: Some(150), | |
| range: None, | |
| extra_display: None, | |
| }); | |
| requests.push(RequestDetails { | |
| op: Operation::Get, | |
| path: Path::from("test3"), | |
| timestamp: chrono::DateTime::from_timestamp(2, 0).unwrap(), | |
| duration_nanos: Arc::new(AtomicU64::new( | |
| Duration::from_secs(2).as_nanos() as u64 | |
| )), | |
| size: Some(50), | |
| range: None, | |
| extra_display: None, | |
| }); | |
| assert_snapshot!(RequestSummaries::new(&requests), @r" | |
| +-----------+----------+-----------+-----------+-----------+------------+-------+ | |
| | Operation | Metric | min | max | avg | sum | count | | |
| +-----------+----------+-----------+-----------+-----------+------------+-------+ | |
| | Get | duration | 2.000000s | 8.000000s | 5.000000s | 15.000000s | 3 | | |
| | Get | size | 50 B | 150 B | 100 B | 300 B | 3 | | |
| +-----------+----------+-----------+-----------+-----------+------------+-------+ | |
| "); | |
| // Add Put requests to test grouping | |
| requests.push(RequestDetails { | |
| op: Operation::Put, | |
| path: Path::from("test4"), | |
| timestamp: chrono::DateTime::from_timestamp(3, 0).unwrap(), | |
| duration_nanos: Arc::new(AtomicU64::new( | |
| Duration::from_millis(200).as_nanos() as u64, | |
| )), | |
| size: Some(75), | |
| range: None, | |
| extra_display: None, | |
| }); | |
| assert_snapshot!(RequestSummaries::new(&requests), @r" | |
| +-----------+----------+-----------+-----------+-----------+------------+-------+ | |
| | Operation | Metric | min | max | avg | sum | count | | |
| +-----------+----------+-----------+-----------+-----------+------------+-------+ | |
| | Get | duration | 2.000000s | 8.000000s | 5.000000s | 15.000000s | 3 | | |
| | Get | size | 50 B | 150 B | 100 B | 300 B | 3 | | |
| | Put | duration | 0.200000s | 0.200000s | 0.200000s | 0.200000s | 1 | | |
| | Put | size | 75 B | 75 B | 75 B | 75 B | 1 | | |
| +-----------+----------+-----------+-----------+-----------+------------+-------+ | |
| "); | |
| } | |
| #[test] | |
| fn request_summary_only_duration() { | |
| // Test request with only duration (no size) | |
| let only_duration = vec![RequestDetails { | |
| op: Operation::Get, | |
| path: Path::from("test1"), | |
| timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(), | |
| duration_nanos: Arc::new(AtomicU64::new( | |
| Duration::from_secs(3).as_nanos() as u64 | |
| )), | |
| size: None, | |
| range: None, | |
| extra_display: None, | |
| }]; | |
| assert_snapshot!(RequestSummaries::new(&only_duration), @r" | |
| +-----------+----------+-----------+-----------+-----------+-----------+-------+ | |
| | Operation | Metric | min | max | avg | sum | count | | |
| +-----------+----------+-----------+-----------+-----------+-----------+-------+ | |
| | Get | duration | 3.000000s | 3.000000s | 3.000000s | 3.000000s | 1 | | |
| | Get | size | | | | | 1 | | |
| +-----------+----------+-----------+-----------+-----------+-----------+-------+ | |
| "); | |
| } | |
| #[test] | |
| fn request_summary_only_size() { | |
| // Test request with only size (no duration) | |
| let only_size = vec![RequestDetails { | |
| op: Operation::Get, | |
| path: Path::from("test1"), | |
| timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(), | |
| duration_nanos: Arc::new(AtomicU64::new(0)), | |
| size: Some(200), | |
| range: None, | |
| extra_display: None, | |
| }]; | |
| assert_snapshot!(RequestSummaries::new(&only_size), @r" | |
| +-----------+----------+-------+-------+-------+-------+-------+ | |
| | Operation | Metric | min | max | avg | sum | count | | |
| +-----------+----------+-------+-------+-------+-------+-------+ | |
| | Get | duration | | | | | 1 | | |
| | Get | size | 200 B | 200 B | 200 B | 200 B | 1 | | |
| +-----------+----------+-------+-------+-------+-------+-------+ | |
| "); | |
| } | |
| #[test] | |
| fn request_summary_neither_duration_or_size() { | |
| // Test request with neither duration nor size | |
| let no_stats = vec![RequestDetails { | |
| op: Operation::Get, | |
| path: Path::from("test1"), | |
| timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(), | |
| duration_nanos: Arc::new(AtomicU64::new(0)), | |
| size: None, | |
| range: None, | |
| extra_display: None, | |
| }]; | |
| assert_snapshot!(RequestSummaries::new(&no_stats), @r" | |
| +-----------+----------+-----+-----+-----+-----+-------+ | |
| | Operation | Metric | min | max | avg | sum | count | | |
| +-----------+----------+-----+-----+-----+-----+-------+ | |
| | Get | duration | | | | | 1 | | |
| | Get | size | | | | | 1 | | |
| +-----------+----------+-----+-----+-----+-----+-------+ | |
| "); | |
| } | |
| } |