Skip to main content

datafusion_cli/object_storage/
instrumented.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::{
19    cmp, fmt,
20    ops::AddAssign,
21    str::FromStr,
22    sync::{
23        Arc,
24        atomic::{AtomicU8, AtomicU64, Ordering},
25    },
26    time::Duration,
27};
28
29use arrow::array::{ArrayRef, RecordBatch, StringArray};
30use arrow::util::pretty::pretty_format_batches;
31use async_trait::async_trait;
32use chrono::Utc;
33use datafusion::{
34    common::{HashMap, instant::Instant},
35    error::DataFusionError,
36    execution::object_store::{DefaultObjectStoreRegistry, ObjectStoreRegistry},
37};
38use futures::stream::{BoxStream, Stream};
39use futures::{StreamExt, TryStreamExt};
40use object_store::{
41    CopyOptions, GetOptions, GetRange, GetResult, ListResult, MultipartUpload,
42    ObjectMeta, ObjectStore, ObjectStoreExt, PutMultipartOptions, PutOptions, PutPayload,
43    PutResult, Result, path::Path,
44};
45use parking_lot::{Mutex, RwLock};
46use url::Url;
47
48/// A stream wrapper that measures the time until the first response(item or end of stream) is yielded.
49///
50/// The timer starts on the first `poll_next` call (not at stream creation) to avoid
51/// measuring unrelated work between stream creation and first poll.
52/// Duration is stored as nanoseconds in an `AtomicU64` (0 = not yet set).
53struct TimeToFirstItemStream<S> {
54    inner: S,
55    start: Option<Instant>,
56    request_duration: Arc<AtomicU64>,
57    duration_recorded: bool,
58}
59
60impl<S> TimeToFirstItemStream<S> {
61    fn new(inner: S, request_duration: Arc<AtomicU64>) -> Self {
62        Self {
63            inner,
64            start: None,
65            request_duration,
66            duration_recorded: false,
67        }
68    }
69}
70
71impl<S> Stream for TimeToFirstItemStream<S>
72where
73    S: Stream<Item = Result<ObjectMeta>> + Unpin,
74{
75    type Item = Result<ObjectMeta>;
76
77    fn poll_next(
78        mut self: std::pin::Pin<&mut Self>,
79        cx: &mut std::task::Context<'_>,
80    ) -> std::task::Poll<Option<Self::Item>> {
81        let start = *self.start.get_or_insert_with(Instant::now);
82
83        let poll_result = std::pin::Pin::new(&mut self.inner).poll_next(cx);
84
85        if !self.duration_recorded && poll_result.is_ready() {
86            self.duration_recorded = true;
87            let nanos = start.elapsed().as_nanos() as u64;
88            self.request_duration.store(nanos, Ordering::Release);
89        }
90
91        poll_result
92    }
93}
94
95/// The profiling mode to use for an [`InstrumentedObjectStore`] instance. Collecting profiling
96/// data will have a small negative impact on both CPU and memory usage. Default is `Disabled`
97#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
98pub enum InstrumentedObjectStoreMode {
99    /// Disable collection of profiling data
100    #[default]
101    Disabled,
102    /// Enable collection of profiling data and output a summary
103    Summary,
104    /// Enable collection of profiling data and output a summary and all details
105    Trace,
106}
107
108impl fmt::Display for InstrumentedObjectStoreMode {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(f, "{self:?}")
111    }
112}
113
114impl FromStr for InstrumentedObjectStoreMode {
115    type Err = DataFusionError;
116
117    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
118        match s.to_lowercase().as_str() {
119            "disabled" => Ok(Self::Disabled),
120            "summary" => Ok(Self::Summary),
121            "trace" => Ok(Self::Trace),
122            _ => Err(DataFusionError::Execution(format!("Unrecognized mode {s}"))),
123        }
124    }
125}
126
127impl From<u8> for InstrumentedObjectStoreMode {
128    fn from(value: u8) -> Self {
129        match value {
130            1 => InstrumentedObjectStoreMode::Summary,
131            2 => InstrumentedObjectStoreMode::Trace,
132            _ => InstrumentedObjectStoreMode::Disabled,
133        }
134    }
135}
136
137/// Wrapped [`ObjectStore`] instances that record information for reporting on the usage of the
138/// inner [`ObjectStore`]
139#[derive(Debug)]
140pub struct InstrumentedObjectStore {
141    inner: Arc<dyn ObjectStore>,
142    instrument_mode: AtomicU8,
143    requests: Arc<Mutex<Vec<RequestDetails>>>,
144}
145
146impl InstrumentedObjectStore {
147    /// Returns a new [`InstrumentedObjectStore`] that wraps the provided [`ObjectStore`]
148    fn new(object_store: Arc<dyn ObjectStore>, instrument_mode: AtomicU8) -> Self {
149        Self {
150            inner: object_store,
151            instrument_mode,
152            requests: Arc::new(Mutex::new(Vec::new())),
153        }
154    }
155
156    fn set_instrument_mode(&self, mode: InstrumentedObjectStoreMode) {
157        self.instrument_mode.store(mode as u8, Ordering::Relaxed)
158    }
159
160    /// Returns all [`RequestDetails`] accumulated in this [`InstrumentedObjectStore`] and clears
161    /// the stored requests
162    pub fn take_requests(&self) -> Vec<RequestDetails> {
163        let mut req = self.requests.lock();
164
165        req.drain(..).collect()
166    }
167
168    fn enabled(&self) -> bool {
169        self.instrument_mode.load(Ordering::Relaxed)
170            != InstrumentedObjectStoreMode::Disabled as u8
171    }
172
173    async fn instrumented_put_opts(
174        &self,
175        location: &Path,
176        payload: PutPayload,
177        opts: PutOptions,
178    ) -> Result<PutResult> {
179        let timestamp = Utc::now();
180        let start = Instant::now();
181        let size = payload.content_length();
182        let ret = self.inner.put_opts(location, payload, opts).await?;
183        let elapsed = start.elapsed();
184
185        self.requests.lock().push(RequestDetails {
186            op: Operation::Put,
187            path: location.clone(),
188            timestamp,
189            duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)),
190            size: Some(size),
191            range: None,
192            extra_display: None,
193        });
194
195        Ok(ret)
196    }
197
198    async fn instrumented_put_multipart(
199        &self,
200        location: &Path,
201        opts: PutMultipartOptions,
202    ) -> Result<Box<dyn MultipartUpload>> {
203        let timestamp = Utc::now();
204        let start = Instant::now();
205        let ret = self.inner.put_multipart_opts(location, opts).await?;
206        let elapsed = start.elapsed();
207
208        self.requests.lock().push(RequestDetails {
209            op: Operation::Put,
210            path: location.clone(),
211            timestamp,
212            duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)),
213            size: None,
214            range: None,
215            extra_display: None,
216        });
217
218        Ok(ret)
219    }
220
221    async fn instrumented_get_opts(
222        &self,
223        location: &Path,
224        options: GetOptions,
225    ) -> Result<GetResult> {
226        let timestamp = Utc::now();
227        let range = options.range.clone();
228
229        let head = options.head;
230        let start = Instant::now();
231        let ret = self.inner.get_opts(location, options).await?;
232        let elapsed = start.elapsed();
233
234        let (op, size) = if head {
235            (Operation::Head, None)
236        } else {
237            (
238                Operation::Get,
239                Some((ret.range.end - ret.range.start) as usize),
240            )
241        };
242
243        self.requests.lock().push(RequestDetails {
244            op,
245            path: location.clone(),
246            timestamp,
247            duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)),
248            size,
249            range,
250            extra_display: None,
251        });
252
253        Ok(ret)
254    }
255
256    fn instrumented_delete_stream(
257        &self,
258        locations: BoxStream<'static, Result<Path>>,
259    ) -> BoxStream<'static, Result<Path>> {
260        let requests_captured = Arc::clone(&self.requests);
261
262        let timestamp = Utc::now();
263        let start = Instant::now();
264        self.inner
265            .delete_stream(locations)
266            .and_then(move |location| {
267                let elapsed = start.elapsed();
268                requests_captured.lock().push(RequestDetails {
269                    op: Operation::Delete,
270                    path: location.clone(),
271                    timestamp,
272                    duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)),
273                    size: None,
274                    range: None,
275                    extra_display: None,
276                });
277                futures::future::ok(location)
278            })
279            .boxed()
280    }
281
282    fn instrumented_list(
283        &self,
284        prefix: Option<&Path>,
285    ) -> BoxStream<'static, Result<ObjectMeta>> {
286        let timestamp = Utc::now();
287        let inner_stream = self.inner.list(prefix);
288
289        let duration_nanos = Arc::new(AtomicU64::new(0));
290        self.requests.lock().push(RequestDetails {
291            op: Operation::List,
292            path: prefix.cloned().unwrap_or_else(|| Path::from("")),
293            timestamp,
294            duration_nanos: Arc::clone(&duration_nanos),
295            size: None,
296            range: None,
297            extra_display: None,
298        });
299
300        Box::pin(TimeToFirstItemStream::new(inner_stream, duration_nanos))
301    }
302
303    async fn instrumented_list_with_delimiter(
304        &self,
305        prefix: Option<&Path>,
306    ) -> Result<ListResult> {
307        let timestamp = Utc::now();
308        let start = Instant::now();
309        let ret = self.inner.list_with_delimiter(prefix).await?;
310        let elapsed = start.elapsed();
311
312        self.requests.lock().push(RequestDetails {
313            op: Operation::List,
314            path: prefix.cloned().unwrap_or_else(|| Path::from("")),
315            timestamp,
316            duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)),
317            size: None,
318            range: None,
319            extra_display: None,
320        });
321
322        Ok(ret)
323    }
324
325    async fn instrumented_copy(&self, from: &Path, to: &Path) -> Result<()> {
326        let timestamp = Utc::now();
327        let start = Instant::now();
328        self.inner.copy(from, to).await?;
329        let elapsed = start.elapsed();
330
331        self.requests.lock().push(RequestDetails {
332            op: Operation::Copy,
333            path: from.clone(),
334            timestamp,
335            duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)),
336            size: None,
337            range: None,
338            extra_display: Some(format!("copy_to: {to}")),
339        });
340
341        Ok(())
342    }
343
344    async fn instrumented_copy_if_not_exists(
345        &self,
346        from: &Path,
347        to: &Path,
348    ) -> Result<()> {
349        let timestamp = Utc::now();
350        let start = Instant::now();
351        self.inner.copy_if_not_exists(from, to).await?;
352        let elapsed = start.elapsed();
353
354        self.requests.lock().push(RequestDetails {
355            op: Operation::Copy,
356            path: from.clone(),
357            timestamp,
358            duration_nanos: Arc::new(AtomicU64::new(elapsed.as_nanos() as u64)),
359            size: None,
360            range: None,
361            extra_display: Some(format!("copy_to: {to}")),
362        });
363
364        Ok(())
365    }
366}
367
368impl fmt::Display for InstrumentedObjectStore {
369    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370        let mode: InstrumentedObjectStoreMode =
371            self.instrument_mode.load(Ordering::Relaxed).into();
372        write!(
373            f,
374            "Instrumented Object Store: instrument_mode: {mode}, inner: {}",
375            self.inner
376        )
377    }
378}
379
380#[async_trait]
381impl ObjectStore for InstrumentedObjectStore {
382    async fn put_opts(
383        &self,
384        location: &Path,
385        payload: PutPayload,
386        opts: PutOptions,
387    ) -> Result<PutResult> {
388        if self.enabled() {
389            return self.instrumented_put_opts(location, payload, opts).await;
390        }
391
392        self.inner.put_opts(location, payload, opts).await
393    }
394
395    async fn put_multipart_opts(
396        &self,
397        location: &Path,
398        opts: PutMultipartOptions,
399    ) -> Result<Box<dyn MultipartUpload>> {
400        if self.enabled() {
401            return self.instrumented_put_multipart(location, opts).await;
402        }
403
404        self.inner.put_multipart_opts(location, opts).await
405    }
406
407    async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
408        if self.enabled() {
409            return self.instrumented_get_opts(location, options).await;
410        }
411
412        self.inner.get_opts(location, options).await
413    }
414
415    fn delete_stream(
416        &self,
417        locations: BoxStream<'static, Result<Path>>,
418    ) -> BoxStream<'static, Result<Path>> {
419        if self.enabled() {
420            return self.instrumented_delete_stream(locations);
421        }
422
423        self.inner.delete_stream(locations)
424    }
425
426    fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> {
427        if self.enabled() {
428            return self.instrumented_list(prefix);
429        }
430
431        self.inner.list(prefix)
432    }
433
434    async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> {
435        if self.enabled() {
436            return self.instrumented_list_with_delimiter(prefix).await;
437        }
438
439        self.inner.list_with_delimiter(prefix).await
440    }
441
442    async fn copy_opts(
443        &self,
444        from: &Path,
445        to: &Path,
446        options: CopyOptions,
447    ) -> Result<()> {
448        if self.enabled() {
449            return match options.mode {
450                object_store::CopyMode::Create => {
451                    self.instrumented_copy_if_not_exists(from, to).await
452                }
453                object_store::CopyMode::Overwrite => {
454                    self.instrumented_copy(from, to).await
455                }
456            };
457        }
458
459        self.inner.copy_opts(from, to, options).await
460    }
461}
462
463/// Object store operation types tracked by [`InstrumentedObjectStore`]
464#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
465pub enum Operation {
466    Copy,
467    Delete,
468    Get,
469    Head,
470    List,
471    Put,
472}
473
474impl fmt::Display for Operation {
475    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
476        write!(f, "{self:?}")
477    }
478}
479
480/// Holds profiling details about individual requests made through an [`InstrumentedObjectStore`]
481pub struct RequestDetails {
482    op: Operation,
483    path: Path,
484    timestamp: chrono::DateTime<Utc>,
485    /// Duration stored as nanoseconds in an AtomicU64. 0 means not yet set.
486    duration_nanos: Arc<AtomicU64>,
487    size: Option<usize>,
488    range: Option<GetRange>,
489    extra_display: Option<String>,
490}
491
492impl fmt::Debug for RequestDetails {
493    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
494        f.debug_struct("RequestDetails")
495            .field("op", &self.op)
496            .field("path", &self.path)
497            .field("timestamp", &self.timestamp)
498            .field("duration", &self.duration())
499            .field("size", &self.size)
500            .field("range", &self.range)
501            .field("extra_display", &self.extra_display)
502            .finish()
503    }
504}
505
506impl RequestDetails {
507    fn duration(&self) -> Option<Duration> {
508        let nanos = self.duration_nanos.load(Ordering::Acquire);
509        if nanos == 0 {
510            None
511        } else {
512            Some(Duration::from_nanos(nanos))
513        }
514    }
515}
516
517impl fmt::Display for RequestDetails {
518    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
519        let mut output_parts = vec![format!(
520            "{} operation={:?}",
521            self.timestamp.to_rfc3339(),
522            self.op
523        )];
524
525        if let Some(d) = self.duration() {
526            output_parts.push(format!("duration={:.6}s", d.as_secs_f32()));
527        }
528        if let Some(s) = self.size {
529            output_parts.push(format!("size={s}"));
530        }
531        if let Some(r) = &self.range {
532            output_parts.push(format!("range: {r}"));
533        }
534        output_parts.push(format!("path={}", self.path));
535
536        if let Some(ed) = &self.extra_display {
537            output_parts.push(ed.clone());
538        }
539
540        write!(f, "{}", output_parts.join(" "))
541    }
542}
543
544/// Summary statistics for all requests recorded in an [`InstrumentedObjectStore`]
545#[derive(Default)]
546pub struct RequestSummaries {
547    summaries: Vec<RequestSummary>,
548}
549
550/// Display the summary as a table
551impl fmt::Display for RequestSummaries {
552    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
553        // Don't expect an error, but avoid panicking if it happens
554        match pretty_format_batches(&[self.to_batch()]) {
555            Err(e) => {
556                write!(f, "Error formatting summary: {e}")
557            }
558            Ok(displayable) => {
559                write!(f, "{displayable}")
560            }
561        }
562    }
563}
564
565impl RequestSummaries {
566    /// Summarizes input [`RequestDetails`]
567    pub fn new(requests: &[RequestDetails]) -> Self {
568        let mut summaries: HashMap<Operation, RequestSummary> = HashMap::new();
569        for rd in requests {
570            match summaries.get_mut(&rd.op) {
571                Some(rs) => rs.push(rd),
572                None => {
573                    let mut rs = RequestSummary::new(rd.op);
574                    rs.push(rd);
575                    summaries.insert(rd.op, rs);
576                }
577            }
578        }
579        // Convert to a Vec with consistent ordering
580        let mut summaries: Vec<RequestSummary> = summaries.into_values().collect();
581        summaries.sort_by_key(|s| s.operation);
582        Self { summaries }
583    }
584
585    /// Convert the summaries into a `RecordBatch` for display
586    ///
587    /// Results in a table like:
588    /// ```text
589    /// +-----------+----------+-----------+-----------+-----------+-----------+-----------+
590    /// | Operation | Metric   | min       | max       | avg       | sum       | count     |
591    /// +-----------+----------+-----------+-----------+-----------+-----------+-----------+
592    /// | Get       | duration | 5.000000s | 5.000000s | 5.000000s |           | 1         |
593    /// | Get       | size     | 100 B     | 100 B     | 100 B     | 100 B     | 1         |
594    /// +-----------+----------+-----------+-----------+-----------+-----------+-----------+
595    /// ```
596    pub fn to_batch(&self) -> RecordBatch {
597        let operations: StringArray = self
598            .iter()
599            .flat_map(|s| std::iter::repeat_n(Some(s.operation.to_string()), 2))
600            .collect();
601        let metrics: StringArray = self
602            .iter()
603            .flat_map(|_s| [Some("duration"), Some("size")])
604            .collect();
605        let mins: StringArray = self
606            .stats_iter()
607            .flat_map(|(duration_stats, size_stats)| {
608                let dur_min =
609                    duration_stats.map(|d| format!("{:.6}s", d.min.as_secs_f32()));
610                let size_min = size_stats.map(|s| format!("{} B", s.min));
611                [dur_min, size_min]
612            })
613            .collect();
614        let maxs: StringArray = self
615            .stats_iter()
616            .flat_map(|(duration_stats, size_stats)| {
617                let dur_max =
618                    duration_stats.map(|d| format!("{:.6}s", d.max.as_secs_f32()));
619                let size_max = size_stats.map(|s| format!("{} B", s.max));
620                [dur_max, size_max]
621            })
622            .collect();
623        let avgs: StringArray = self
624            .iter()
625            .flat_map(|s| {
626                let count = s.count as f32;
627                let duration_stats = s.duration_stats.as_ref();
628                let size_stats = s.size_stats.as_ref();
629                let dur_avg = duration_stats.map(|d| {
630                    let avg = d.sum.as_secs_f32() / count;
631                    format!("{avg:.6}s")
632                });
633                let size_avg = size_stats.map(|s| {
634                    let avg = s.sum as f32 / count;
635                    format!("{avg} B")
636                });
637                [dur_avg, size_avg]
638            })
639            .collect();
640        let sums: StringArray = self
641            .stats_iter()
642            .flat_map(|(duration_stats, size_stats)| {
643                // Omit a sum stat for duration in the initial
644                // implementation because it can be a bit misleading (at least
645                // at first glance). For example, particularly large queries the
646                // sum of the durations was often larger than the total time of
647                // the query itself, can be confusing without additional
648                // explanation (e.g. that the sum is of individual requests,
649                // which may be concurrent).
650                let dur_sum =
651                    duration_stats.map(|d| format!("{:.6}s", d.sum.as_secs_f32()));
652                let size_sum = size_stats.map(|s| format!("{} B", s.sum));
653                [dur_sum, size_sum]
654            })
655            .collect();
656        let counts: StringArray = self
657            .iter()
658            .flat_map(|s| {
659                let count = s.count.to_string();
660                [Some(count.clone()), Some(count)]
661            })
662            .collect();
663
664        RecordBatch::try_from_iter(vec![
665            ("Operation", Arc::new(operations) as ArrayRef),
666            ("Metric", Arc::new(metrics) as ArrayRef),
667            ("min", Arc::new(mins) as ArrayRef),
668            ("max", Arc::new(maxs) as ArrayRef),
669            ("avg", Arc::new(avgs) as ArrayRef),
670            ("sum", Arc::new(sums) as ArrayRef),
671            ("count", Arc::new(counts) as ArrayRef),
672        ])
673        .expect("Created the batch correctly")
674    }
675
676    /// Return an iterator over the summaries
677    fn iter(&self) -> impl Iterator<Item = &RequestSummary> {
678        self.summaries.iter()
679    }
680
681    /// Return an iterator over (duration_stats, size_stats) tuples
682    /// for each summary
683    fn stats_iter(
684        &self,
685    ) -> impl Iterator<Item = (Option<&Stats<Duration>>, Option<&Stats<usize>>)> {
686        self.summaries
687            .iter()
688            .map(|s| (s.duration_stats.as_ref(), s.size_stats.as_ref()))
689    }
690}
691
692/// Summary statistics for a particular type of [`Operation`] (e.g. `GET` or `PUT`)
693/// in an [`InstrumentedObjectStore`]'s [`RequestDetails`]
694pub struct RequestSummary {
695    operation: Operation,
696    count: usize,
697    duration_stats: Option<Stats<Duration>>,
698    size_stats: Option<Stats<usize>>,
699}
700
701impl RequestSummary {
702    fn new(operation: Operation) -> Self {
703        Self {
704            operation,
705            count: 0,
706            duration_stats: None,
707            size_stats: None,
708        }
709    }
710    fn push(&mut self, request: &RequestDetails) {
711        self.count += 1;
712        if let Some(dur) = request.duration() {
713            self.duration_stats.get_or_insert_default().push(dur)
714        }
715        if let Some(size) = request.size {
716            self.size_stats.get_or_insert_default().push(size)
717        }
718    }
719}
720
721struct Stats<T: Copy + Ord + AddAssign<T>> {
722    min: T,
723    max: T,
724    sum: T,
725}
726
727impl<T: Copy + Ord + AddAssign<T>> Stats<T> {
728    fn push(&mut self, val: T) {
729        self.min = cmp::min(val, self.min);
730        self.max = cmp::max(val, self.max);
731        self.sum += val;
732    }
733}
734
735impl Default for Stats<Duration> {
736    fn default() -> Self {
737        Self {
738            min: Duration::MAX,
739            max: Duration::ZERO,
740            sum: Duration::ZERO,
741        }
742    }
743}
744
745impl Default for Stats<usize> {
746    fn default() -> Self {
747        Self {
748            min: usize::MAX,
749            max: usize::MIN,
750            sum: 0,
751        }
752    }
753}
754
755/// Provides access to [`InstrumentedObjectStore`] instances that record requests for reporting
756#[derive(Debug)]
757pub struct InstrumentedObjectStoreRegistry {
758    inner: Arc<dyn ObjectStoreRegistry>,
759    instrument_mode: AtomicU8,
760    stores: RwLock<Vec<Arc<InstrumentedObjectStore>>>,
761}
762
763impl Default for InstrumentedObjectStoreRegistry {
764    fn default() -> Self {
765        Self::new()
766    }
767}
768
769impl InstrumentedObjectStoreRegistry {
770    /// Returns a new [`InstrumentedObjectStoreRegistry`] that wraps the provided
771    /// [`ObjectStoreRegistry`]
772    pub fn new() -> Self {
773        Self {
774            inner: Arc::new(DefaultObjectStoreRegistry::new()),
775            instrument_mode: AtomicU8::new(InstrumentedObjectStoreMode::default() as u8),
776            stores: RwLock::new(Vec::new()),
777        }
778    }
779
780    pub fn with_profile_mode(self, mode: InstrumentedObjectStoreMode) -> Self {
781        self.instrument_mode.store(mode as u8, Ordering::Relaxed);
782        self
783    }
784
785    /// Provides access to all of the [`InstrumentedObjectStore`]s managed by this
786    /// [`InstrumentedObjectStoreRegistry`]
787    pub fn stores(&self) -> Vec<Arc<InstrumentedObjectStore>> {
788        self.stores.read().clone()
789    }
790
791    /// Returns the current [`InstrumentedObjectStoreMode`] for this
792    /// [`InstrumentedObjectStoreRegistry`]
793    pub fn instrument_mode(&self) -> InstrumentedObjectStoreMode {
794        self.instrument_mode.load(Ordering::Relaxed).into()
795    }
796
797    /// Sets the [`InstrumentedObjectStoreMode`] for this [`InstrumentedObjectStoreRegistry`]
798    pub fn set_instrument_mode(&self, mode: InstrumentedObjectStoreMode) {
799        self.instrument_mode.store(mode as u8, Ordering::Relaxed);
800        for s in self.stores.read().iter() {
801            s.set_instrument_mode(mode)
802        }
803    }
804}
805
806impl ObjectStoreRegistry for InstrumentedObjectStoreRegistry {
807    fn register_store(
808        &self,
809        url: &Url,
810        store: Arc<dyn ObjectStore>,
811    ) -> Option<Arc<dyn ObjectStore>> {
812        let mode = self.instrument_mode.load(Ordering::Relaxed);
813        let instrumented =
814            Arc::new(InstrumentedObjectStore::new(store, AtomicU8::new(mode)));
815        self.stores.write().push(Arc::clone(&instrumented));
816        self.inner.register_store(url, instrumented)
817    }
818
819    fn deregister_store(
820        &self,
821        url: &Url,
822    ) -> datafusion::common::Result<Arc<dyn ObjectStore>> {
823        self.inner.deregister_store(url)
824    }
825
826    fn get_store(&self, url: &Url) -> datafusion::common::Result<Arc<dyn ObjectStore>> {
827        self.inner.get_store(url)
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use futures::StreamExt;
834    use object_store::WriteMultipart;
835
836    use super::*;
837    use insta::assert_snapshot;
838
839    #[test]
840    fn instrumented_mode() {
841        assert!(matches!(
842            InstrumentedObjectStoreMode::default(),
843            InstrumentedObjectStoreMode::Disabled
844        ));
845
846        assert!(matches!(
847            "dIsABleD".parse().unwrap(),
848            InstrumentedObjectStoreMode::Disabled
849        ));
850        assert!(matches!(
851            "SUmMaRy".parse().unwrap(),
852            InstrumentedObjectStoreMode::Summary
853        ));
854        assert!(matches!(
855            "TRaCe".parse().unwrap(),
856            InstrumentedObjectStoreMode::Trace
857        ));
858        assert!(
859            "does_not_exist"
860                .parse::<InstrumentedObjectStoreMode>()
861                .is_err()
862        );
863
864        assert!(matches!(0.into(), InstrumentedObjectStoreMode::Disabled));
865        assert!(matches!(1.into(), InstrumentedObjectStoreMode::Summary));
866        assert!(matches!(2.into(), InstrumentedObjectStoreMode::Trace));
867        assert!(matches!(3.into(), InstrumentedObjectStoreMode::Disabled));
868    }
869
870    #[test]
871    fn instrumented_registry() {
872        let mut reg = InstrumentedObjectStoreRegistry::new();
873        assert!(reg.stores().is_empty());
874        assert_eq!(
875            reg.instrument_mode(),
876            InstrumentedObjectStoreMode::default()
877        );
878
879        reg = reg.with_profile_mode(InstrumentedObjectStoreMode::Trace);
880        assert_eq!(reg.instrument_mode(), InstrumentedObjectStoreMode::Trace);
881
882        let store = object_store::memory::InMemory::new();
883        let url = "mem://test".parse().unwrap();
884        let registered = reg.register_store(&url, Arc::new(store));
885        assert!(registered.is_none());
886
887        let fetched = reg.get_store(&url);
888        assert!(fetched.is_ok());
889        assert_eq!(reg.stores().len(), 1);
890    }
891
892    // Returns an `InstrumentedObjectStore` with some data loaded for testing and the path to
893    // access the data
894    async fn setup_test_store() -> (InstrumentedObjectStore, Path) {
895        let store = Arc::new(object_store::memory::InMemory::new());
896        let mode = AtomicU8::new(InstrumentedObjectStoreMode::default() as u8);
897        let instrumented = InstrumentedObjectStore::new(store, mode);
898
899        // Load the test store with some data we can read
900        let path = Path::from("test/data");
901        let payload = PutPayload::from_static(b"test_data");
902        instrumented.put(&path, payload).await.unwrap();
903
904        (instrumented, path)
905    }
906
907    #[tokio::test]
908    async fn instrumented_store_get() {
909        let (instrumented, path) = setup_test_store().await;
910
911        // By default no requests should be instrumented/stored
912        assert!(instrumented.requests.lock().is_empty());
913        let _ = instrumented.get(&path).await.unwrap();
914        assert!(instrumented.requests.lock().is_empty());
915
916        instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace);
917        assert!(instrumented.requests.lock().is_empty());
918        let _ = instrumented.get(&path).await.unwrap();
919        assert_eq!(instrumented.requests.lock().len(), 1);
920
921        let mut requests = instrumented.take_requests();
922        assert_eq!(requests.len(), 1);
923        assert!(instrumented.requests.lock().is_empty());
924
925        let request = requests.pop().unwrap();
926        assert_eq!(request.op, Operation::Get);
927        assert_eq!(request.path, path);
928        assert!(request.duration().is_some());
929        assert_eq!(request.size, Some(9));
930        assert_eq!(request.range, None);
931        assert!(request.extra_display.is_none());
932    }
933
934    #[tokio::test]
935    async fn instrumented_store_delete() {
936        let (instrumented, path) = setup_test_store().await;
937
938        // By default no requests should be instrumented/stored
939        assert!(instrumented.requests.lock().is_empty());
940        instrumented.delete(&path).await.unwrap();
941        assert!(instrumented.requests.lock().is_empty());
942
943        // We need a new store so we have data to delete again
944        let (instrumented, path) = setup_test_store().await;
945        instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace);
946        assert!(instrumented.requests.lock().is_empty());
947        instrumented.delete(&path).await.unwrap();
948        assert_eq!(instrumented.requests.lock().len(), 1);
949
950        let mut requests = instrumented.take_requests();
951        assert_eq!(requests.len(), 1);
952        assert!(instrumented.requests.lock().is_empty());
953
954        let request = requests.pop().unwrap();
955        assert_eq!(request.op, Operation::Delete);
956        assert_eq!(request.path, path);
957        assert!(request.duration().is_some());
958        assert!(request.size.is_none());
959        assert!(request.range.is_none());
960        assert!(request.extra_display.is_none());
961    }
962
963    #[tokio::test]
964    async fn instrumented_store_list() {
965        let (instrumented, path) = setup_test_store().await;
966
967        // By default no requests should be instrumented/stored
968        assert!(instrumented.requests.lock().is_empty());
969        let _ = instrumented.list(Some(&path));
970        assert!(instrumented.requests.lock().is_empty());
971
972        instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace);
973        assert!(instrumented.requests.lock().is_empty());
974        let mut stream = instrumented.list(Some(&path));
975        // Sleep between stream creation and first poll to verify the timer
976        // starts on first poll, not at stream creation.
977        let delay = Duration::from_millis(50);
978        tokio::time::sleep(delay).await;
979        let _ = stream.next().await;
980        assert_eq!(instrumented.requests.lock().len(), 1);
981
982        let request = instrumented.take_requests().pop().unwrap();
983        assert_eq!(request.op, Operation::List);
984        assert_eq!(request.path, path);
985        let duration = request
986            .duration()
987            .expect("duration should be set after consuming stream");
988        assert!(
989            duration < delay,
990            "duration {duration:?} should exclude the {delay:?} sleep before first poll"
991        );
992        assert!(request.size.is_none());
993        assert!(request.range.is_none());
994        assert!(request.extra_display.is_none());
995    }
996
997    #[tokio::test]
998    async fn time_to_first_item_stream_captures_inner_latency() {
999        let inner_delay = Duration::from_millis(50);
1000        let inner_stream = futures::stream::once(async move {
1001            tokio::time::sleep(inner_delay).await;
1002            Ok(ObjectMeta {
1003                location: Path::from("test"),
1004                last_modified: Utc::now(),
1005                size: 0,
1006                e_tag: None,
1007                version: None,
1008            })
1009        })
1010        .boxed();
1011
1012        let duration_nanos = Arc::new(AtomicU64::new(0));
1013        let mut stream = Box::pin(TimeToFirstItemStream::new(
1014            inner_stream,
1015            Arc::clone(&duration_nanos),
1016        ));
1017        let _ = stream.next().await;
1018
1019        let recorded = Duration::from_nanos(duration_nanos.load(Ordering::Acquire));
1020        assert!(
1021            recorded >= inner_delay,
1022            "recorded duration {recorded:?} should be >= inner stream delay {inner_delay:?}"
1023        );
1024    }
1025
1026    #[tokio::test]
1027    async fn instrumented_store_list_with_delimiter() {
1028        let (instrumented, path) = setup_test_store().await;
1029
1030        // By default no requests should be instrumented/stored
1031        assert!(instrumented.requests.lock().is_empty());
1032        let _ = instrumented.list_with_delimiter(Some(&path)).await.unwrap();
1033        assert!(instrumented.requests.lock().is_empty());
1034
1035        instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace);
1036        assert!(instrumented.requests.lock().is_empty());
1037        let _ = instrumented.list_with_delimiter(Some(&path)).await.unwrap();
1038        assert_eq!(instrumented.requests.lock().len(), 1);
1039
1040        let request = instrumented.take_requests().pop().unwrap();
1041        assert_eq!(request.op, Operation::List);
1042        assert_eq!(request.path, path);
1043        assert!(request.duration().is_some());
1044        assert!(request.size.is_none());
1045        assert!(request.range.is_none());
1046        assert!(request.extra_display.is_none());
1047    }
1048
1049    #[tokio::test]
1050    async fn instrumented_store_put_opts() {
1051        // The `setup_test_store()` method comes with data already `put` into it, so we'll setup
1052        // manually for this test
1053        let store = Arc::new(object_store::memory::InMemory::new());
1054        let mode = AtomicU8::new(InstrumentedObjectStoreMode::default() as u8);
1055        let instrumented = InstrumentedObjectStore::new(store, mode);
1056
1057        let path = Path::from("test/data");
1058        let payload = PutPayload::from_static(b"test_data");
1059        let size = payload.content_length();
1060
1061        // By default no requests should be instrumented/stored
1062        assert!(instrumented.requests.lock().is_empty());
1063        instrumented.put(&path, payload.clone()).await.unwrap();
1064        assert!(instrumented.requests.lock().is_empty());
1065
1066        instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace);
1067        assert!(instrumented.requests.lock().is_empty());
1068        instrumented.put(&path, payload).await.unwrap();
1069        assert_eq!(instrumented.requests.lock().len(), 1);
1070
1071        let request = instrumented.take_requests().pop().unwrap();
1072        assert_eq!(request.op, Operation::Put);
1073        assert_eq!(request.path, path);
1074        assert!(request.duration().is_some());
1075        assert_eq!(request.size.unwrap(), size);
1076        assert!(request.range.is_none());
1077        assert!(request.extra_display.is_none());
1078    }
1079
1080    #[tokio::test]
1081    async fn instrumented_store_put_multipart() {
1082        // The `setup_test_store()` method comes with data already `put` into it, so we'll setup
1083        // manually for this test
1084        let store = Arc::new(object_store::memory::InMemory::new());
1085        let mode = AtomicU8::new(InstrumentedObjectStoreMode::default() as u8);
1086        let instrumented = InstrumentedObjectStore::new(store, mode);
1087
1088        let path = Path::from("test/data");
1089
1090        // By default no requests should be instrumented/stored
1091        assert!(instrumented.requests.lock().is_empty());
1092        let mp = instrumented.put_multipart(&path).await.unwrap();
1093        let mut write = WriteMultipart::new(mp);
1094        write.write(b"test_data");
1095        write.finish().await.unwrap();
1096        assert!(instrumented.requests.lock().is_empty());
1097
1098        instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace);
1099        assert!(instrumented.requests.lock().is_empty());
1100        let mp = instrumented.put_multipart(&path).await.unwrap();
1101        let mut write = WriteMultipart::new(mp);
1102        write.write(b"test_data");
1103        write.finish().await.unwrap();
1104        assert_eq!(instrumented.requests.lock().len(), 1);
1105
1106        let request = instrumented.take_requests().pop().unwrap();
1107        assert_eq!(request.op, Operation::Put);
1108        assert_eq!(request.path, path);
1109        assert!(request.duration().is_some());
1110        assert!(request.size.is_none());
1111        assert!(request.range.is_none());
1112        assert!(request.extra_display.is_none());
1113    }
1114
1115    #[tokio::test]
1116    async fn instrumented_store_copy() {
1117        let (instrumented, path) = setup_test_store().await;
1118        let copy_to = Path::from("test/copied");
1119
1120        // By default no requests should be instrumented/stored
1121        assert!(instrumented.requests.lock().is_empty());
1122        instrumented.copy(&path, &copy_to).await.unwrap();
1123        assert!(instrumented.requests.lock().is_empty());
1124
1125        instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace);
1126        assert!(instrumented.requests.lock().is_empty());
1127        instrumented.copy(&path, &copy_to).await.unwrap();
1128        assert_eq!(instrumented.requests.lock().len(), 1);
1129
1130        let mut requests = instrumented.take_requests();
1131        assert_eq!(requests.len(), 1);
1132        assert!(instrumented.requests.lock().is_empty());
1133
1134        let request = requests.pop().unwrap();
1135        assert_eq!(request.op, Operation::Copy);
1136        assert_eq!(request.path, path);
1137        assert!(request.duration().is_some());
1138        assert!(request.size.is_none());
1139        assert!(request.range.is_none());
1140        assert_eq!(
1141            request.extra_display.unwrap(),
1142            format!("copy_to: {copy_to}")
1143        );
1144    }
1145
1146    #[tokio::test]
1147    async fn instrumented_store_copy_if_not_exists() {
1148        let (instrumented, path) = setup_test_store().await;
1149        let mut copy_to = Path::from("test/copied");
1150
1151        // By default no requests should be instrumented/stored
1152        assert!(instrumented.requests.lock().is_empty());
1153        instrumented
1154            .copy_if_not_exists(&path, &copy_to)
1155            .await
1156            .unwrap();
1157        assert!(instrumented.requests.lock().is_empty());
1158
1159        // Use a new destination since the previous one already exists
1160        copy_to = Path::from("test/copied_again");
1161        instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace);
1162        assert!(instrumented.requests.lock().is_empty());
1163        instrumented
1164            .copy_if_not_exists(&path, &copy_to)
1165            .await
1166            .unwrap();
1167        assert_eq!(instrumented.requests.lock().len(), 1);
1168
1169        let mut requests = instrumented.take_requests();
1170        assert_eq!(requests.len(), 1);
1171        assert!(instrumented.requests.lock().is_empty());
1172
1173        let request = requests.pop().unwrap();
1174        assert_eq!(request.op, Operation::Copy);
1175        assert_eq!(request.path, path);
1176        assert!(request.duration().is_some());
1177        assert!(request.size.is_none());
1178        assert!(request.range.is_none());
1179        assert_eq!(
1180            request.extra_display.unwrap(),
1181            format!("copy_to: {copy_to}")
1182        );
1183    }
1184
1185    #[tokio::test]
1186    async fn instrumented_store_head() {
1187        let (instrumented, path) = setup_test_store().await;
1188
1189        // By default no requests should be instrumented/stored
1190        assert!(instrumented.requests.lock().is_empty());
1191        let _ = instrumented.head(&path).await.unwrap();
1192        assert!(instrumented.requests.lock().is_empty());
1193
1194        instrumented.set_instrument_mode(InstrumentedObjectStoreMode::Trace);
1195        assert!(instrumented.requests.lock().is_empty());
1196        let _ = instrumented.head(&path).await.unwrap();
1197        assert_eq!(instrumented.requests.lock().len(), 1);
1198
1199        let mut requests = instrumented.take_requests();
1200        assert_eq!(requests.len(), 1);
1201        assert!(instrumented.requests.lock().is_empty());
1202
1203        let request = requests.pop().unwrap();
1204        assert_eq!(request.op, Operation::Head);
1205        assert_eq!(request.path, path);
1206        assert!(request.duration().is_some());
1207        assert!(request.size.is_none());
1208        assert!(request.range.is_none());
1209        assert!(request.extra_display.is_none());
1210    }
1211
1212    #[test]
1213    fn request_details() {
1214        let rd = RequestDetails {
1215            op: Operation::Get,
1216            path: Path::from("test"),
1217            timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(),
1218            duration_nanos: Arc::new(AtomicU64::new(
1219                Duration::new(5, 0).as_nanos() as u64
1220            )),
1221            size: Some(10),
1222            range: Some((..10).into()),
1223            extra_display: Some(String::from("extra info")),
1224        };
1225
1226        assert_eq!(
1227            format!("{rd}"),
1228            "1970-01-01T00:00:00+00:00 operation=Get duration=5.000000s size=10 range: bytes=0-9 path=test extra info"
1229        );
1230    }
1231
1232    #[test]
1233    fn request_summary() {
1234        // Test empty request list
1235        let mut requests = Vec::new();
1236        assert_snapshot!(RequestSummaries::new(&requests), @r"
1237        +-----------+--------+-----+-----+-----+-----+-------+
1238        | Operation | Metric | min | max | avg | sum | count |
1239        +-----------+--------+-----+-----+-----+-----+-------+
1240        +-----------+--------+-----+-----+-----+-----+-------+
1241        ");
1242
1243        requests.push(RequestDetails {
1244            op: Operation::Get,
1245            path: Path::from("test1"),
1246            timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(),
1247            duration_nanos: Arc::new(AtomicU64::new(
1248                Duration::from_secs(5).as_nanos() as u64
1249            )),
1250            size: Some(100),
1251            range: None,
1252            extra_display: None,
1253        });
1254
1255        assert_snapshot!(RequestSummaries::new(&requests), @r"
1256        +-----------+----------+-----------+-----------+-----------+-----------+-------+
1257        | Operation | Metric   | min       | max       | avg       | sum       | count |
1258        +-----------+----------+-----------+-----------+-----------+-----------+-------+
1259        | Get       | duration | 5.000000s | 5.000000s | 5.000000s | 5.000000s | 1     |
1260        | Get       | size     | 100 B     | 100 B     | 100 B     | 100 B     | 1     |
1261        +-----------+----------+-----------+-----------+-----------+-----------+-------+
1262        ");
1263
1264        // Add more Get requests to test aggregation
1265        requests.push(RequestDetails {
1266            op: Operation::Get,
1267            path: Path::from("test2"),
1268            timestamp: chrono::DateTime::from_timestamp(1, 0).unwrap(),
1269            duration_nanos: Arc::new(AtomicU64::new(
1270                Duration::from_secs(8).as_nanos() as u64
1271            )),
1272            size: Some(150),
1273            range: None,
1274            extra_display: None,
1275        });
1276        requests.push(RequestDetails {
1277            op: Operation::Get,
1278            path: Path::from("test3"),
1279            timestamp: chrono::DateTime::from_timestamp(2, 0).unwrap(),
1280            duration_nanos: Arc::new(AtomicU64::new(
1281                Duration::from_secs(2).as_nanos() as u64
1282            )),
1283            size: Some(50),
1284            range: None,
1285            extra_display: None,
1286        });
1287        assert_snapshot!(RequestSummaries::new(&requests), @r"
1288        +-----------+----------+-----------+-----------+-----------+------------+-------+
1289        | Operation | Metric   | min       | max       | avg       | sum        | count |
1290        +-----------+----------+-----------+-----------+-----------+------------+-------+
1291        | Get       | duration | 2.000000s | 8.000000s | 5.000000s | 15.000000s | 3     |
1292        | Get       | size     | 50 B      | 150 B     | 100 B     | 300 B      | 3     |
1293        +-----------+----------+-----------+-----------+-----------+------------+-------+
1294        ");
1295
1296        // Add Put requests to test grouping
1297        requests.push(RequestDetails {
1298            op: Operation::Put,
1299            path: Path::from("test4"),
1300            timestamp: chrono::DateTime::from_timestamp(3, 0).unwrap(),
1301            duration_nanos: Arc::new(AtomicU64::new(
1302                Duration::from_millis(200).as_nanos() as u64,
1303            )),
1304            size: Some(75),
1305            range: None,
1306            extra_display: None,
1307        });
1308
1309        assert_snapshot!(RequestSummaries::new(&requests), @r"
1310        +-----------+----------+-----------+-----------+-----------+------------+-------+
1311        | Operation | Metric   | min       | max       | avg       | sum        | count |
1312        +-----------+----------+-----------+-----------+-----------+------------+-------+
1313        | Get       | duration | 2.000000s | 8.000000s | 5.000000s | 15.000000s | 3     |
1314        | Get       | size     | 50 B      | 150 B     | 100 B     | 300 B      | 3     |
1315        | Put       | duration | 0.200000s | 0.200000s | 0.200000s | 0.200000s  | 1     |
1316        | Put       | size     | 75 B      | 75 B      | 75 B      | 75 B       | 1     |
1317        +-----------+----------+-----------+-----------+-----------+------------+-------+
1318        ");
1319    }
1320
1321    #[test]
1322    fn request_summary_only_duration() {
1323        // Test request with only duration (no size)
1324        let only_duration = vec![RequestDetails {
1325            op: Operation::Get,
1326            path: Path::from("test1"),
1327            timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(),
1328            duration_nanos: Arc::new(AtomicU64::new(
1329                Duration::from_secs(3).as_nanos() as u64
1330            )),
1331            size: None,
1332            range: None,
1333            extra_display: None,
1334        }];
1335        assert_snapshot!(RequestSummaries::new(&only_duration), @r"
1336        +-----------+----------+-----------+-----------+-----------+-----------+-------+
1337        | Operation | Metric   | min       | max       | avg       | sum       | count |
1338        +-----------+----------+-----------+-----------+-----------+-----------+-------+
1339        | Get       | duration | 3.000000s | 3.000000s | 3.000000s | 3.000000s | 1     |
1340        | Get       | size     |           |           |           |           | 1     |
1341        +-----------+----------+-----------+-----------+-----------+-----------+-------+
1342        ");
1343    }
1344
1345    #[test]
1346    fn request_summary_only_size() {
1347        // Test request with only size (no duration)
1348        let only_size = vec![RequestDetails {
1349            op: Operation::Get,
1350            path: Path::from("test1"),
1351            timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(),
1352            duration_nanos: Arc::new(AtomicU64::new(0)),
1353            size: Some(200),
1354            range: None,
1355            extra_display: None,
1356        }];
1357        assert_snapshot!(RequestSummaries::new(&only_size), @r"
1358        +-----------+----------+-------+-------+-------+-------+-------+
1359        | Operation | Metric   | min   | max   | avg   | sum   | count |
1360        +-----------+----------+-------+-------+-------+-------+-------+
1361        | Get       | duration |       |       |       |       | 1     |
1362        | Get       | size     | 200 B | 200 B | 200 B | 200 B | 1     |
1363        +-----------+----------+-------+-------+-------+-------+-------+
1364        ");
1365    }
1366
1367    #[test]
1368    fn request_summary_neither_duration_or_size() {
1369        // Test request with neither duration nor size
1370        let no_stats = vec![RequestDetails {
1371            op: Operation::Get,
1372            path: Path::from("test1"),
1373            timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(),
1374            duration_nanos: Arc::new(AtomicU64::new(0)),
1375            size: None,
1376            range: None,
1377            extra_display: None,
1378        }];
1379        assert_snapshot!(RequestSummaries::new(&no_stats), @r"
1380        +-----------+----------+-----+-----+-----+-----+-------+
1381        | Operation | Metric   | min | max | avg | sum | count |
1382        +-----------+----------+-----+-----+-----+-----+-------+
1383        | Get       | duration |     |     |     |     | 1     |
1384        | Get       | size     |     |     |     |     | 1     |
1385        +-----------+----------+-----+-----+-----+-----+-------+
1386        ");
1387    }
1388}