Skip to main content

lance_io/utils/
tracking_store.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Make assertions about IO operations to an [ObjectStore].
5//!
6//! When testing code that performs IO, you will often want to make assertions
7//! about the number of reads and writes performed, the amount of data read or
8//! written, and the number of disjoint periods where at least one IO is in-flight.
9//!
10//! This modules provides [`IOTracker`] which can be used to wrap any object store.
11use std::fmt::{Display, Formatter};
12use std::ops::Range;
13#[cfg(feature = "test-util")]
14use std::sync::atomic::AtomicU16;
15use std::sync::{Arc, Mutex};
16#[cfg(feature = "metrics")]
17use std::time::Instant;
18
19use bytes::Bytes;
20use futures::StreamExt;
21use futures::TryStreamExt;
22use futures::stream::BoxStream;
23use object_store::path::Path;
24use object_store::{
25    CopyOptions, GetOptions, GetRange, GetResult, ListResult, MultipartUpload, ObjectMeta,
26    ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult, RenameOptions,
27    Result as OSResult, UploadPart,
28};
29
30use crate::object_store::WrappingObjectStore;
31#[cfg(feature = "metrics")]
32use crate::object_store::metrics::{InFlightGuard, record_outcome};
33use object_store::list::PaginatedListStore;
34
35#[derive(Debug, Default, Clone)]
36pub struct IOTracker {
37    stats: Arc<Mutex<IoStats>>,
38    /// The `base` label for the object store metrics published by IO that
39    /// bypasses the `object_store` layer (see [`Self::begin_io`]). `None` when
40    /// the IO cannot be attributed to a store, in which case no metrics are
41    /// published.
42    #[cfg(feature = "metrics")]
43    metrics_base: Option<Arc<str>>,
44}
45
46impl IOTracker {
47    /// Get IO statistics and reset the counters (incremental pattern).
48    ///
49    /// This returns the accumulated statistics since the last call and resets
50    /// the internal counters to zero.
51    pub fn incremental_stats(&self) -> IoStats {
52        std::mem::take(&mut *self.stats.lock().unwrap())
53    }
54
55    /// Get a snapshot of current IO statistics without resetting counters.
56    ///
57    /// This returns a clone of the current statistics without modifying the
58    /// internal state. Use this when you need to check stats without resetting.
59    pub fn stats(&self) -> IoStats {
60        self.stats.lock().unwrap().clone()
61    }
62
63    /// Record a read operation for tracking.
64    ///
65    /// This is used by readers that bypass the ObjectStore layer (like LocalObjectReader)
66    /// to ensure their IO operations are still tracked.
67    pub fn record_read(
68        &self,
69        #[allow(unused_variables)] method: &'static str,
70        #[allow(unused_variables)] path: Path,
71        num_bytes: u64,
72        #[allow(unused_variables)] range: Option<Range<u64>>,
73    ) {
74        let mut stats = self.stats.lock().unwrap();
75        stats.read_iops += 1;
76        stats.read_bytes += num_bytes;
77        #[cfg(feature = "test-util")]
78        stats.requests.push(IoRequestRecord {
79            method,
80            path,
81            range,
82        });
83    }
84
85    /// Record a write operation for tracking.
86    ///
87    /// This is used by writers that bypass the ObjectStore layer (like LocalWriter)
88    /// to ensure their IO operations are still tracked.
89    pub fn record_write(
90        &self,
91        #[allow(unused_variables)] method: &'static str,
92        #[allow(unused_variables)] path: Path,
93        num_bytes: u64,
94    ) {
95        let mut stats = self.stats.lock().unwrap();
96        stats.write_iops += 1;
97        stats.written_bytes += num_bytes;
98        #[cfg(feature = "test-util")]
99        stats.requests.push(IoRequestRecord {
100            method,
101            path,
102            range: None,
103        });
104    }
105
106    /// Label the metrics published through [`Self::begin_io`] with the prefix of
107    /// the store this tracker belongs to, so IO that bypasses the `object_store`
108    /// layer carries the same `base` label as the store's metered operations.
109    ///
110    /// Only `meter_store` should call this, so that labelling the tracker and
111    /// wrapping the store stay inseparable — see the rationale there.
112    #[cfg(feature = "metrics")]
113    pub(crate) fn set_metrics_base(&mut self, base: &str) {
114        self.metrics_base = Some(base.into());
115    }
116
117    /// Begin an operation that talks to storage without going through the
118    /// `object_store` layer, and so is invisible to the `MeteredObjectStore`
119    /// wrapper: the optimized local reads and writes go straight to the
120    /// filesystem. `operation` must be one of the labels that wrapper uses
121    /// (`get`, `put`, `head`, ...) so this IO aggregates with the rest.
122    ///
123    /// The returned guard keeps the in-flight gauge raised until it is dropped.
124    #[cfg(feature = "metrics")]
125    pub fn begin_io(&self, operation: &'static str) -> IoMetricsGuard {
126        IoMetricsGuard {
127            state: self.metrics_base.as_ref().map(|base| IoMetricsState {
128                _in_flight: InFlightGuard::new(base, operation),
129                base: base.clone(),
130                operation,
131                start: Instant::now(),
132            }),
133        }
134    }
135
136    /// Without the `metrics` feature there is nothing to publish.
137    #[cfg(not(feature = "metrics"))]
138    pub fn begin_io(&self, _operation: &'static str) -> IoMetricsGuard {
139        IoMetricsGuard {}
140    }
141}
142
143/// Publishes the object store metrics for a single operation that bypassed the
144/// `object_store` layer (see [`IOTracker::begin_io`]).
145///
146/// The operation is only counted by [`Self::record`]; one dropped before that —
147/// a cancelled read, an abandoned write — counts as neither a success nor a
148/// failure, and only lowers the in-flight gauge.
149#[must_use = "the operation is not recorded until `record` is called"]
150pub struct IoMetricsGuard {
151    #[cfg(feature = "metrics")]
152    state: Option<IoMetricsState>,
153}
154
155#[cfg(feature = "metrics")]
156struct IoMetricsState {
157    base: Arc<str>,
158    operation: &'static str,
159    start: Instant,
160    /// Lowers the in-flight gauge when the guard is dropped.
161    _in_flight: InFlightGuard,
162}
163
164impl IoMetricsGuard {
165    /// Record the operation's count and latency, along with `num_bytes`
166    /// transferred if `result` is `Ok` or an error if it is not.
167    pub fn record<T, E>(self, result: &std::result::Result<T, E>, num_bytes: u64) {
168        #[cfg(feature = "metrics")]
169        if let Some(state) = self.state {
170            record_outcome(
171                &state.base,
172                state.operation,
173                state.start,
174                num_bytes,
175                result.is_err(),
176            );
177        }
178        #[cfg(not(feature = "metrics"))]
179        let _ = (result, num_bytes);
180    }
181}
182
183impl WrappingObjectStore for IOTracker {
184    fn wrap(&self, _store_prefix: &str, target: Arc<dyn ObjectStore>) -> Arc<dyn ObjectStore> {
185        Arc::new(IoTrackingStore::new(target, self.stats.clone()))
186    }
187
188    // A pushed-down listing records itself against the store's tracker, so it is already
189    // counted without passing through here.
190    fn wrap_paginated(
191        &self,
192        _store_prefix: &str,
193        original: Arc<dyn PaginatedListStore>,
194    ) -> Option<Arc<dyn PaginatedListStore>> {
195        Some(original)
196    }
197}
198
199#[derive(Debug, Default, Clone)]
200pub struct IoStats {
201    pub read_iops: u64,
202    pub read_bytes: u64,
203    pub write_iops: u64,
204    pub written_bytes: u64,
205    // This is only really meaningful in tests where there isn't any concurrent IO.
206    #[cfg(feature = "test-util")]
207    /// Number of disjoint periods where at least one IO is in-flight.
208    pub num_stages: u64,
209    #[cfg(feature = "test-util")]
210    pub requests: Vec<IoRequestRecord>,
211}
212
213/// Assertions on IO statistics.
214/// assert_io_eq!(io_stats, read_iops, 1);
215/// assert_io_eq!(io_stats, write_iops, 0, "should be no writes");
216/// assert_io_eq!(io_stats, num_hops, 1, "should be just {}", "one hop");
217#[cfg(feature = "test-util")]
218#[macro_export]
219macro_rules! assert_io_eq {
220    ($io_stats:expr, $field:ident, $expected:expr) => {
221        assert_eq!(
222            $io_stats.$field, $expected,
223            "Expected {} to be {}, got {}. Requests: {:#?}",
224            stringify!($field),
225            $expected,
226            $io_stats.$field,
227            $io_stats.requests
228        );
229    };
230    ($io_stats:expr, $field:ident, $expected:expr, $($arg:tt)+) => {
231        assert_eq!(
232            $io_stats.$field, $expected,
233            "Expected {} to be {}, got {}. Requests: {:#?} {}",
234            stringify!($field),
235            $expected,
236            $io_stats.$field,
237            $io_stats.requests,
238            format_args!($($arg)+)
239        );
240    };
241}
242
243#[cfg(feature = "test-util")]
244#[macro_export]
245macro_rules! assert_io_gt {
246    ($io_stats:expr, $field:ident, $expected:expr) => {
247        assert!(
248            $io_stats.$field > $expected,
249            "Expected {} to be > {}, got {}. Requests: {:#?}",
250            stringify!($field),
251            $expected,
252            $io_stats.$field,
253            $io_stats.requests
254        );
255    };
256    ($io_stats:expr, $field:ident, $expected:expr, $($arg:tt)+) => {
257        assert!(
258            $io_stats.$field > $expected,
259            "Expected {} to be > {}, got {}. Requests: {:#?} {}",
260            stringify!($field),
261            $expected,
262            $io_stats.$field,
263            $io_stats.requests,
264            format_args!($($arg)+)
265        );
266    };
267}
268
269#[cfg(feature = "test-util")]
270#[macro_export]
271macro_rules! assert_io_lt {
272    ($io_stats:expr, $field:ident, $expected:expr) => {
273        assert!(
274            $io_stats.$field < $expected,
275            "Expected {} to be < {}, got {}. Requests: {:#?}",
276            stringify!($field),
277            $expected,
278            $io_stats.$field,
279            $io_stats.requests
280        );
281    };
282    ($io_stats:expr, $field:ident, $expected:expr, $($arg:tt)+) => {
283        assert!(
284            $io_stats.$field < $expected,
285            "Expected {} to be < {}, got {}. Requests: {:#?} {}",
286            stringify!($field),
287            $expected,
288            $io_stats.$field,
289            $io_stats.requests,
290            format_args!($($arg)+)
291        );
292    };
293}
294
295// These request records only exist for test-only diagnostics.
296#[cfg(feature = "test-util")]
297#[derive(Clone)]
298pub struct IoRequestRecord {
299    pub method: &'static str,
300    pub path: Path,
301    pub range: Option<Range<u64>>,
302}
303
304#[cfg(feature = "test-util")]
305impl std::fmt::Debug for IoRequestRecord {
306    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
307        // For example: "put /path/to/file range: 0-100"
308        write!(
309            f,
310            "IORequest(method={}, path=\"{}\"",
311            self.method, self.path
312        )?;
313        if let Some(range) = &self.range {
314            write!(f, ", range={:?}", range)?;
315        }
316        write!(f, ")")?;
317        Ok(())
318    }
319}
320
321impl Display for IoStats {
322    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
323        write!(f, "{:#?}", self)
324    }
325}
326
327#[derive(Debug)]
328pub struct IoTrackingStore {
329    target: Arc<dyn ObjectStore>,
330    stats: Arc<Mutex<IoStats>>,
331    #[cfg(feature = "test-util")]
332    active_requests: Arc<AtomicU16>,
333}
334
335impl Display for IoTrackingStore {
336    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
337        write!(f, "{:#?}", self)
338    }
339}
340
341impl IoTrackingStore {
342    pub fn new(target: Arc<dyn ObjectStore>, stats: Arc<Mutex<IoStats>>) -> Self {
343        Self {
344            target,
345            stats,
346            #[cfg(feature = "test-util")]
347            active_requests: Arc::new(AtomicU16::new(0)),
348        }
349    }
350
351    fn record_read(
352        &self,
353        method: &'static str,
354        path: Path,
355        num_bytes: u64,
356        range: Option<Range<u64>>,
357    ) {
358        let mut stats = self.stats.lock().unwrap();
359        stats.read_iops += 1;
360        stats.read_bytes += num_bytes;
361        #[cfg(feature = "test-util")]
362        stats.requests.push(IoRequestRecord {
363            method,
364            path,
365            range,
366        });
367        #[cfg(not(feature = "test-util"))]
368        let _ = (method, path, range); // Suppress unused variable warnings
369    }
370
371    fn record_write(&self, method: &'static str, path: Path, num_bytes: u64) {
372        let mut stats = self.stats.lock().unwrap();
373        stats.write_iops += 1;
374        stats.written_bytes += num_bytes;
375        #[cfg(feature = "test-util")]
376        stats.requests.push(IoRequestRecord {
377            method,
378            path,
379            range: None,
380        });
381        #[cfg(not(feature = "test-util"))]
382        let _ = (method, path); // Suppress unused variable warnings
383    }
384
385    #[cfg(feature = "test-util")]
386    fn stage_guard(&self) -> StageGuard {
387        StageGuard::new(self.active_requests.clone(), self.stats.clone())
388    }
389
390    #[cfg(not(feature = "test-util"))]
391    fn stage_guard(&self) -> StageGuard {
392        StageGuard
393    }
394}
395
396#[async_trait::async_trait]
397#[deny(clippy::missing_trait_methods)]
398impl ObjectStore for IoTrackingStore {
399    async fn put_opts(
400        &self,
401        location: &Path,
402        bytes: PutPayload,
403        opts: PutOptions,
404    ) -> OSResult<PutResult> {
405        let _guard = self.stage_guard();
406        self.record_write(
407            "put_opts",
408            location.to_owned(),
409            bytes.content_length() as u64,
410        );
411        self.target.put_opts(location, bytes, opts).await
412    }
413
414    async fn put_multipart_opts(
415        &self,
416        location: &Path,
417        opts: PutMultipartOptions,
418    ) -> OSResult<Box<dyn MultipartUpload>> {
419        let _guard = self.stage_guard();
420        let target = self.target.put_multipart_opts(location, opts).await?;
421        Ok(Box::new(IoTrackingMultipartUpload {
422            target,
423            stats: self.stats.clone(),
424            #[cfg(feature = "test-util")]
425            path: location.to_owned(),
426            #[cfg(feature = "test-util")]
427            _guard,
428        }))
429    }
430
431    async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult<GetResult> {
432        let _guard = self.stage_guard();
433        let is_head = options.head;
434        let range = match &options.range {
435            Some(GetRange::Bounded(range)) => Some(range.clone()),
436            _ => None, // TODO: fill in other options.
437        };
438        let result = self.target.get_opts(location, options).await;
439        if let Ok(result) = &result {
440            let num_bytes = if is_head {
441                0
442            } else {
443                result.range.end - result.range.start
444            };
445            self.record_read(
446                if is_head { "head" } else { "get_opts" },
447                location.to_owned(),
448                num_bytes,
449                range,
450            );
451        }
452        result
453    }
454
455    async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> OSResult<Vec<Bytes>> {
456        let _guard = self.stage_guard();
457        let result = self.target.get_ranges(location, ranges).await;
458        if let Ok(result) = &result {
459            self.record_read(
460                "get_ranges",
461                location.to_owned(),
462                result.iter().map(|b| b.len() as u64).sum(),
463                None,
464            );
465        }
466        result
467    }
468
469    fn delete_stream(
470        &self,
471        locations: BoxStream<'static, OSResult<Path>>,
472    ) -> BoxStream<'static, OSResult<Path>> {
473        let stats = Arc::clone(&self.stats);
474        let tracked = locations
475            .map_ok(move |path| {
476                let mut stats = stats.lock().unwrap();
477                stats.write_iops += 1;
478                #[cfg(feature = "test-util")]
479                stats.requests.push(IoRequestRecord {
480                    method: "delete",
481                    path: path.clone(),
482                    range: None,
483                });
484                path
485            })
486            .boxed();
487        self.target.delete_stream(tracked)
488    }
489
490    fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
491        let _guard = self.stage_guard();
492        self.record_read("list", prefix.cloned().unwrap_or_default(), 0, None);
493        self.target.list(prefix)
494    }
495
496    fn list_with_offset(
497        &self,
498        prefix: Option<&Path>,
499        offset: &Path,
500    ) -> BoxStream<'static, OSResult<ObjectMeta>> {
501        self.record_read(
502            "list_with_offset",
503            prefix.cloned().unwrap_or_default(),
504            0,
505            None,
506        );
507        self.target.list_with_offset(prefix, offset)
508    }
509
510    async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
511        let _guard = self.stage_guard();
512        self.record_read(
513            "list_with_delimiter",
514            prefix.cloned().unwrap_or_default(),
515            0,
516            None,
517        );
518        self.target.list_with_delimiter(prefix).await
519    }
520
521    async fn copy_opts(&self, from: &Path, to: &Path, opts: CopyOptions) -> OSResult<()> {
522        let _guard = self.stage_guard();
523        self.record_write("copy", from.to_owned(), 0);
524        self.target.copy_opts(from, to, opts).await
525    }
526
527    async fn rename_opts(&self, from: &Path, to: &Path, opts: RenameOptions) -> OSResult<()> {
528        let _guard = self.stage_guard();
529        self.record_write("rename", from.to_owned(), 0);
530        self.target.rename_opts(from, to, opts).await
531    }
532}
533
534#[derive(Debug)]
535struct IoTrackingMultipartUpload {
536    target: Box<dyn MultipartUpload>,
537    #[cfg(feature = "test-util")]
538    path: Path,
539    stats: Arc<Mutex<IoStats>>,
540    #[cfg(feature = "test-util")]
541    _guard: StageGuard,
542}
543
544#[async_trait::async_trait]
545impl MultipartUpload for IoTrackingMultipartUpload {
546    async fn abort(&mut self) -> OSResult<()> {
547        self.target.abort().await
548    }
549
550    async fn complete(&mut self) -> OSResult<PutResult> {
551        self.target.complete().await
552    }
553
554    fn put_part(&mut self, payload: PutPayload) -> UploadPart {
555        {
556            let mut stats = self.stats.lock().unwrap();
557            stats.write_iops += 1;
558            stats.written_bytes += payload.content_length() as u64;
559            #[cfg(feature = "test-util")]
560            stats.requests.push(IoRequestRecord {
561                method: "put_part",
562                path: self.path.to_owned(),
563                range: None,
564            });
565        }
566        self.target.put_part(payload)
567    }
568}
569
570#[cfg(feature = "test-util")]
571#[derive(Debug)]
572struct StageGuard {
573    active_requests: Arc<AtomicU16>,
574    stats: Arc<Mutex<IoStats>>,
575}
576
577#[cfg(not(feature = "test-util"))]
578struct StageGuard;
579
580#[cfg(feature = "test-util")]
581impl StageGuard {
582    fn new(active_requests: Arc<AtomicU16>, stats: Arc<Mutex<IoStats>>) -> Self {
583        active_requests.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
584        Self {
585            active_requests,
586            stats,
587        }
588    }
589}
590
591#[cfg(feature = "test-util")]
592impl Drop for StageGuard {
593    fn drop(&mut self) {
594        if self
595            .active_requests
596            .fetch_sub(1, std::sync::atomic::Ordering::SeqCst)
597            == 1
598        {
599            let mut stats = self.stats.lock().unwrap();
600            stats.num_stages += 1;
601        }
602    }
603}