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