Skip to main content

mito2/compaction/
window.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
16use std::fmt::Debug;
17
18use common_telemetry::info;
19use common_time::Timestamp;
20use common_time::range::TimestampRange;
21use common_time::timestamp::TimeUnit;
22use common_time::timestamp_millis::BucketAligned;
23use snafu::ResultExt;
24use store_api::storage::RegionId;
25
26use crate::compaction::CompactionOutput;
27use crate::compaction::buckets::infer_time_bucket;
28use crate::compaction::compactor::{CompactionRegion, CompactionVersion};
29use crate::compaction::last_non_null::inputs_precede_memtables;
30use crate::compaction::picker::{Picker, PickerOutput, get_expired_ssts};
31use crate::error::{JoinSnafu, Result};
32use crate::region::options::{CompactionOptions, MergeMode};
33use crate::sst::file::FileHandle;
34
35/// Compaction picker that splits input files into disjoint time windows and merges the
36/// segments within each window. Outputs may be split further by the configured file size
37/// threshold, at series boundaries for regions with a primary key.
38#[derive(Clone, Debug)]
39pub struct WindowedCompactionPicker {
40    compaction_time_window_seconds: Option<i64>,
41    time_range: Option<TimestampRange>,
42}
43
44impl WindowedCompactionPicker {
45    pub fn new(window_seconds: Option<i64>) -> Self {
46        Self {
47            compaction_time_window_seconds: window_seconds,
48            time_range: None,
49        }
50    }
51
52    /// Sets the time range used to select compaction windows.
53    pub(crate) fn with_time_range(mut self, time_range: Option<TimestampRange>) -> Self {
54        self.time_range = time_range;
55        self
56    }
57
58    // Computes compaction time window. First we respect user specified parameter, then
59    // use persisted window. If persist window is not present, we check the time window
60    // provided while creating table. If all of those are absent, we infer the window
61    // from files in level0.
62    fn calculate_time_window(
63        &self,
64        region_id: RegionId,
65        current_version: &CompactionVersion,
66    ) -> i64 {
67        self.compaction_time_window_seconds
68            .or(current_version
69                .compaction_time_window
70                .map(|t| t.as_secs() as i64))
71            .unwrap_or_else(|| {
72                let levels = current_version.ssts.levels();
73                let inferred = infer_time_bucket(levels[0].files());
74                info!(
75                    "Compaction window for region {} is not present, inferring from files: {:?}",
76                    region_id, inferred
77                );
78                inferred
79            })
80    }
81
82    fn pick_inner(
83        &self,
84        region_id: RegionId,
85        current_version: &CompactionVersion,
86        current_time: Timestamp,
87    ) -> (Vec<CompactionOutput>, Vec<FileHandle>, i64) {
88        let time_window = self.calculate_time_window(region_id, current_version);
89        info!(
90            "Compaction window for region: {} is {} seconds",
91            region_id, time_window
92        );
93
94        let expired_ssts = get_expired_ssts(
95            current_version.ssts.levels(),
96            current_version.options.ttl,
97            current_time,
98        );
99        if !expired_ssts.is_empty() {
100            info!("Expired SSTs in region {}: {:?}", region_id, expired_ssts);
101        }
102        let expired_file_ids = expired_ssts
103            .iter()
104            .map(|file| file.file_id())
105            .collect::<HashSet<_>>();
106
107        let last_non_null = current_version.options.merge_mode() == MergeMode::LastNonNull;
108        // Busy SSTs remain LastNonNull dependencies: skipping an intermediate
109        // version would allow older fields to be promoted above it.
110        let windows = assign_files_to_time_windows(
111            time_window,
112            current_version
113                .ssts
114                .levels()
115                .iter()
116                .flat_map(|level| level.files.values())
117                .filter(|file| !expired_file_ids.contains(&file.file_id()))
118                .filter(|file| last_non_null || !file.compacting()),
119        );
120        let windows = filter_time_windows(windows, self.time_range);
121
122        // One SST can span several outputs. Deferring only an unsafe window
123        // would remove that SST without rewriting all its remaining segments.
124        if last_non_null
125            && windows.values().any(|(_, inputs)| {
126                inputs.iter().any(FileHandle::compacting)
127                    || !inputs_precede_memtables(inputs, current_version.memtable_min_sequence)
128            })
129        {
130            return (vec![], expired_ssts, time_window);
131        }
132
133        (build_output(windows), expired_ssts, time_window)
134    }
135}
136
137#[async_trait::async_trait]
138impl Picker for WindowedCompactionPicker {
139    async fn pick(&self, compaction_region: &CompactionRegion) -> Result<Option<PickerOutput>> {
140        // TODO: With per-region compaction concurrency, the scheduler must wait
141        // for all running compactions to finish and apply their SST edits, and
142        // drain or cancel queued compactions before capturing current_version
143        // for SWCS. Block new compactions through snapshot capture and picking
144        // so the strict-window plan sees the complete SST view.
145        let picker = self.clone();
146        let region_id = compaction_region.current_version.metadata.region_id;
147        let current_version = compaction_region.current_version.clone();
148        let CompactionOptions::Twcs(options) = &compaction_region.region_options.compaction;
149        // Use the same output size threshold as TWCS, including zero meaning unlimited.
150        let max_file_size = options
151            .max_output_file_size
152            .map(|size| size.as_bytes())
153            .filter(|size| *size > 0)
154            .map(|size| size as usize);
155        let (outputs, expired_ssts, time_window) =
156            common_runtime::spawn_blocking_compact(move || {
157                picker.pick_inner(region_id, &current_version, Timestamp::current_millis())
158            })
159            .await
160            .context(JoinSnafu)?;
161
162        Ok(Some(PickerOutput {
163            outputs,
164            expired_ssts,
165            time_window_size: time_window,
166            max_file_size,
167        }))
168    }
169}
170
171/// Keeps windows that overlap the requested range and their transitive dependencies.
172///
173/// [`assign_files_to_time_windows`] adds an SST to every time window that the SST covers. If a
174/// selected window contains such a cross-window SST, compaction will remove that input SST after
175/// rewriting it. Keeping only the directly selected window would therefore omit the SST's rows in
176/// the other windows. We must include every window covered by the SST, then repeat the process for
177/// other cross-window SSTs in those windows, until the complete dependency closure is selected.
178fn filter_time_windows(
179    mut windows: BTreeMap<i64, (i64, Vec<FileHandle>)>,
180    time_range: Option<TimestampRange>,
181) -> BTreeMap<i64, (i64, Vec<FileHandle>)> {
182    let Some(time_range) = time_range else {
183        return windows;
184    };
185
186    let mut selected_windows = windows
187        .iter()
188        .filter_map(|(lower_bound, (upper_bound, _))| {
189            let window_start = Timestamp::new_second(*lower_bound);
190            let window_end = Timestamp::new_second(*upper_bound);
191            let starts_before_range_end = time_range
192                .end()
193                .is_none_or(|range_end| window_start < range_end);
194            let ends_after_range_start = time_range
195                .start()
196                .is_none_or(|range_start| range_start < window_end);
197            (starts_before_range_end && ends_after_range_start).then_some(*lower_bound)
198        })
199        .collect::<HashSet<_>>();
200
201    let mut file_windows = HashMap::new();
202    for (lower_bound, (_, files)) in &windows {
203        for file in files {
204            file_windows
205                .entry(file.file_id())
206                .or_insert_with(Vec::new)
207                .push(*lower_bound);
208        }
209    }
210
211    let mut pending_windows = selected_windows.iter().copied().collect::<VecDeque<_>>();
212    let mut visited_files = HashSet::new();
213    while let Some(lower_bound) = pending_windows.pop_front() {
214        let (_, files) = &windows[&lower_bound];
215        for file in files {
216            if !visited_files.insert(file.file_id()) {
217                continue;
218            }
219            for dependent_window in &file_windows[&file.file_id()] {
220                if selected_windows.insert(*dependent_window) {
221                    pending_windows.push_back(*dependent_window);
222                }
223            }
224        }
225    }
226
227    windows.retain(|lower_bound, _| selected_windows.contains(lower_bound));
228    windows
229}
230
231fn build_output(windows: BTreeMap<i64, (i64, Vec<FileHandle>)>) -> Vec<CompactionOutput> {
232    let mut outputs = Vec::with_capacity(windows.len());
233    for (lower_bound, (upper_bound, files)) in windows {
234        // safety: the upper bound must > lower bound.
235        let output_time_range = Some(
236            TimestampRange::new(
237                Timestamp::new_second(lower_bound),
238                Timestamp::new_second(upper_bound),
239            )
240            .unwrap(),
241        );
242
243        let output = CompactionOutput {
244            output_level: 1,
245            inputs: files,
246            filter_deleted: false,
247            output_time_range,
248        };
249        outputs.push(output);
250    }
251
252    outputs
253}
254
255/// Assigns files to time windows. If file does not contain a time range in metadata, it will be
256/// assigned to a special bucket `i64::MAX` (normally no timestamp can be aligned to this bucket)
257/// so that all files without timestamp can be compacted together.
258fn assign_files_to_time_windows<'a>(
259    bucket_sec: i64,
260    files: impl Iterator<Item = &'a FileHandle>,
261) -> BTreeMap<i64, (i64, Vec<FileHandle>)> {
262    let mut buckets = BTreeMap::new();
263
264    for file in files {
265        let (start, end) = file.time_range();
266        let bounds = file_time_bucket_span(
267            // safety: converting whatever timestamp to seconds will not overflow.
268            start.convert_to(TimeUnit::Second).unwrap().value(),
269            end.convert_to(TimeUnit::Second).unwrap().value(),
270            bucket_sec,
271        );
272        for (lower_bound, upper_bound) in bounds {
273            let (_, files) = buckets
274                .entry(lower_bound)
275                .or_insert_with(|| (upper_bound, Vec::new()));
276            files.push(file.clone());
277        }
278    }
279    buckets
280}
281
282/// Calculates timestamp span between start and end timestamp.
283fn file_time_bucket_span(start_sec: i64, end_sec: i64, bucket_sec: i64) -> Vec<(i64, i64)> {
284    assert!(start_sec <= end_sec);
285
286    // if timestamp is between `[i64::MIN, i64::MIN.align_by_bucket(bucket)]`, which cannot
287    // be aligned to a valid i64 bound, simply return `i64::MIN` rather than just underflow.
288    let mut start_aligned = start_sec.align_by_bucket(bucket_sec).unwrap_or(i64::MIN);
289    let end_aligned = end_sec
290        .align_by_bucket(bucket_sec)
291        .unwrap_or(start_aligned + (end_sec - start_sec));
292
293    let mut res = Vec::with_capacity(((end_aligned - start_aligned) / bucket_sec + 1) as usize);
294    while start_aligned <= end_aligned {
295        let window_size = if start_aligned % bucket_sec == 0 {
296            bucket_sec
297        } else {
298            (start_aligned % bucket_sec).abs()
299        };
300        let upper_bound = start_aligned.checked_add(window_size).unwrap_or(i64::MAX);
301        res.push((start_aligned, upper_bound));
302        start_aligned = upper_bound;
303    }
304    res
305}
306
307#[cfg(test)]
308mod tests {
309    use std::collections::HashSet;
310    use std::sync::Arc;
311    use std::time::Duration;
312
313    use common_base::readable_size::ReadableSize;
314    use common_time::Timestamp;
315    use common_time::range::TimestampRange;
316    use store_api::storage::{FileId, RegionId};
317
318    use crate::compaction::compactor::CompactionVersion;
319    use crate::compaction::picker::Picker;
320    use crate::compaction::test_util::compaction_region_with_ssts;
321    use crate::compaction::window::{WindowedCompactionPicker, file_time_bucket_span};
322    use crate::region::options::{CompactionOptions, MergeMode, RegionOptions};
323    use crate::sst::file::{FileMeta, Level};
324    use crate::sst::file_purger::NoopFilePurger;
325    use crate::sst::version::SstVersion;
326    use crate::test_util::memtable_util::metadata_for_test;
327
328    fn build_version(
329        files: &[(FileId, i64, i64, Level)],
330        ttl: Option<Duration>,
331    ) -> CompactionVersion {
332        let metadata = metadata_for_test();
333        let file_purger_ref = Arc::new(NoopFilePurger);
334
335        let mut ssts = SstVersion::new(metadata.clone());
336
337        ssts.add_files(
338            file_purger_ref,
339            files.iter().map(|(file_id, start, end, level)| FileMeta {
340                file_id: *file_id,
341                time_range: (
342                    Timestamp::new_millisecond(*start),
343                    Timestamp::new_millisecond(*end),
344                ),
345                level: *level,
346                ..Default::default()
347            }),
348        );
349
350        CompactionVersion {
351            metadata,
352            ssts: Arc::new(ssts),
353            memtable_min_sequence: None,
354            options: RegionOptions {
355                ttl: ttl.map(|t| t.into()),
356                auto_flush_interval: None,
357                compaction: Default::default(),
358                compaction_override: false,
359                compaction_output_parallelism: None,
360                storage: None,
361                append_mode: false,
362                skip_wal: false,
363                wal_options: Default::default(),
364                index_options: Default::default(),
365                memtable: None,
366                merge_mode: None,
367                sst_format: None,
368                max_row_group_row_count: None,
369                primary_key_encoding: None,
370                write_buffer_size: None,
371                preserve_row_sequence: false,
372                float_field_encoding: Default::default(),
373            },
374            compaction_time_window: None,
375        }
376    }
377
378    #[tokio::test]
379    async fn test_pick_output_file_size_threshold() {
380        let mut region = compaction_region_with_ssts(
381            [FileMeta {
382                file_id: FileId::random(),
383                time_range: (Timestamp::new_second(0), Timestamp::new_second(1)),
384                ..Default::default()
385            }],
386            Duration::from_secs(3600),
387        )
388        .await;
389        let picker = WindowedCompactionPicker::new(Some(3600));
390
391        for (size, expected) in [(None, None), (Some(0), None), (Some(1024), Some(1024))] {
392            let CompactionOptions::Twcs(options) = &mut region.region_options.compaction;
393            options.max_output_file_size = size.map(ReadableSize);
394
395            let output = picker.pick(&region).await.unwrap().unwrap();
396            assert_eq!(1, output.outputs.len());
397            assert_eq!(expected, output.max_file_size);
398        }
399    }
400
401    #[test]
402    fn test_pick_expired_ssts_without_marking_compacting() {
403        let picker = WindowedCompactionPicker::new(None);
404        let files = vec![(FileId::random(), 0, 10, 0)];
405        let version = build_version(&files, Some(Duration::from_millis(1)));
406        let (outputs, expired_ssts, _) = picker.pick_inner(
407            RegionId::new(0, 0),
408            &version,
409            Timestamp::new_millisecond(12),
410        );
411
412        assert!(outputs.is_empty());
413        assert_eq!(1, expired_ssts.len());
414        assert!(expired_ssts.iter().all(|file| !file.compacting()));
415    }
416
417    const HOUR: i64 = 60 * 60 * 1000;
418
419    #[test]
420    fn test_infer_window() {
421        let picker = WindowedCompactionPicker::new(None);
422
423        let files = vec![
424            (FileId::random(), 0, HOUR, 0),
425            (FileId::random(), HOUR, HOUR * 2 - 1, 0),
426        ];
427
428        let version = build_version(&files, Some(Duration::from_millis(3 * HOUR as u64)));
429
430        let (outputs, expired_ssts, window_seconds) = picker.pick_inner(
431            RegionId::new(0, 0),
432            &version,
433            Timestamp::new_millisecond(HOUR * 2),
434        );
435        assert!(expired_ssts.is_empty());
436        assert_eq!(2 * HOUR / 1000, window_seconds);
437        assert_eq!(1, outputs.len());
438        assert_eq!(2, outputs[0].inputs.len());
439    }
440
441    #[test]
442    fn test_assign_files_to_windows() {
443        let picker = WindowedCompactionPicker::new(Some(HOUR / 1000));
444        let files = vec![
445            (FileId::random(), 0, 2 * HOUR - 1, 0),
446            (FileId::random(), HOUR, HOUR * 3 - 1, 0),
447        ];
448        let version = build_version(&files, Some(Duration::from_millis(3 * HOUR as u64)));
449        let (outputs, expired_ssts, window_seconds) = picker.pick_inner(
450            RegionId::new(0, 0),
451            &version,
452            Timestamp::new_millisecond(HOUR * 3),
453        );
454
455        assert!(expired_ssts.is_empty());
456        assert_eq!(HOUR / 1000, window_seconds);
457        assert_eq!(3, outputs.len());
458
459        assert_eq!(1, outputs[0].inputs.len());
460        assert_eq!(files[0].0, outputs[0].inputs[0].file_id().file_id());
461        assert_eq!(
462            TimestampRange::new(
463                Timestamp::new_millisecond(0),
464                Timestamp::new_millisecond(HOUR)
465            ),
466            outputs[0].output_time_range
467        );
468
469        assert_eq!(2, outputs[1].inputs.len());
470        assert_eq!(
471            TimestampRange::new(
472                Timestamp::new_millisecond(HOUR),
473                Timestamp::new_millisecond(2 * HOUR)
474            ),
475            outputs[1].output_time_range
476        );
477
478        assert_eq!(1, outputs[2].inputs.len());
479        assert_eq!(files[1].0, outputs[2].inputs[0].file_id().file_id());
480        assert_eq!(
481            TimestampRange::new(
482                Timestamp::new_millisecond(2 * HOUR),
483                Timestamp::new_millisecond(3 * HOUR)
484            ),
485            outputs[2].output_time_range
486        );
487    }
488
489    #[test]
490    fn test_pick_time_range_expands_for_cross_window_files() {
491        let time_range = TimestampRange::new(
492            Timestamp::new_millisecond(HOUR / 2),
493            Timestamp::new_millisecond(HOUR * 3 / 4),
494        )
495        .unwrap();
496        let picker =
497            WindowedCompactionPicker::new(Some(HOUR / 1000)).with_time_range(Some(time_range));
498        let files = vec![
499            (FileId::random(), 0, 2 * HOUR - 1, 0),
500            (FileId::random(), HOUR, HOUR * 3 - 1, 0),
501            (FileId::random(), 4 * HOUR, 5 * HOUR - 1, 0),
502        ];
503        let version = build_version(&files, None);
504
505        let (outputs, _, _) = picker.pick_inner(
506            RegionId::new(0, 0),
507            &version,
508            Timestamp::new_millisecond(6 * HOUR),
509        );
510
511        assert_eq!(3, outputs.len());
512        assert_eq!(
513            Some(TimestampRange::new(
514                Timestamp::new_millisecond(0),
515                Timestamp::new_millisecond(HOUR),
516            )),
517            outputs.first().map(|output| output.output_time_range)
518        );
519        assert_eq!(
520            Some(TimestampRange::new(
521                Timestamp::new_millisecond(2 * HOUR),
522                Timestamp::new_millisecond(3 * HOUR),
523            )),
524            outputs.last().map(|output| output.output_time_range)
525        );
526    }
527
528    #[test]
529    fn test_pick_time_range_expands_long_dependency_chain() {
530        const CHAIN_LEN: i64 = 128;
531
532        let time_range = TimestampRange::new(
533            Timestamp::new_millisecond(0),
534            Timestamp::new_millisecond(HOUR / 2),
535        )
536        .unwrap();
537        let picker =
538            WindowedCompactionPicker::new(Some(HOUR / 1000)).with_time_range(Some(time_range));
539        let files = (0..CHAIN_LEN)
540            .map(|window| (FileId::random(), window * HOUR, (window + 2) * HOUR - 1, 0))
541            .collect::<Vec<_>>();
542        let version = build_version(&files, None);
543
544        let (outputs, _, _) = picker.pick_inner(
545            RegionId::new(0, 0),
546            &version,
547            Timestamp::new_millisecond((CHAIN_LEN + 2) * HOUR),
548        );
549
550        assert_eq!(CHAIN_LEN as usize + 1, outputs.len());
551    }
552
553    #[test]
554    fn test_assign_compacting_files_to_windows() {
555        let picker = WindowedCompactionPicker::new(Some(HOUR / 1000));
556        let files = vec![
557            (FileId::random(), 0, 2 * HOUR - 1, 0),
558            (FileId::random(), HOUR, HOUR * 3 - 1, 0),
559        ];
560        let version = build_version(&files, Some(Duration::from_millis(3 * HOUR as u64)));
561        version.ssts.levels()[0]
562            .files()
563            .for_each(|f| f.set_compacting(true));
564        let (outputs, expired_ssts, window_seconds) = picker.pick_inner(
565            RegionId::new(0, 0),
566            &version,
567            Timestamp::new_millisecond(HOUR * 3),
568        );
569
570        assert!(expired_ssts.is_empty());
571        assert_eq!(HOUR / 1000, window_seconds);
572        assert!(outputs.is_empty());
573    }
574
575    #[rstest::rstest]
576    #[case::requested_window(0)]
577    #[case::transitive_window(1)]
578    #[case::unrelated_window(3)]
579    fn test_busy_dependencies_defer_last_non_null_plan_until_released(
580        #[case] busy_window: i64,
581        #[values(MergeMode::LastRow, MergeMode::LastNonNull)] merge_mode: MergeMode,
582    ) {
583        // A and C span both output windows. Skipping a busy intermediate B
584        // would allow an unsafe A+C merge; deferring just one output would
585        // lose the other segment of these shared inputs when they are removed.
586        let [a, b, c, expired] = std::array::from_fn(|_| FileId::random());
587        let files = [
588            (a, 0, 2 * HOUR - 1, 0),
589            (b, busy_window * HOUR, (busy_window + 1) * HOUR - 1, 1),
590            (c, 0, 2 * HOUR - 1, 0),
591            (expired, -2 * HOUR, -2 * HOUR, 0),
592        ];
593        let mut version = build_version(&files, Some(Duration::from_millis(3 * HOUR as u64)));
594        version.options.merge_mode = Some(merge_mode);
595        // No memtable barrier: only the busy SST dependency may defer this plan.
596        assert_eq!(None, version.memtable_min_sequence);
597        let busy_file = version.ssts.levels()[1].files().next().unwrap();
598        let picker =
599            WindowedCompactionPicker::new(Some(HOUR / 1000)).with_time_range(TimestampRange::new(
600                Timestamp::new_millisecond(0),
601                Timestamp::new_millisecond(HOUR),
602            ));
603
604        for busy in [true, false] {
605            busy_file.set_compacting(busy);
606            let (outputs, expired_ssts, _) = picker.pick_inner(
607                version.metadata.region_id,
608                &version,
609                Timestamp::new_millisecond(2 * HOUR),
610            );
611            assert_eq!(1, expired_ssts.len());
612            assert_eq!(expired, expired_ssts[0].meta_ref().file_id);
613            assert!(!expired_ssts[0].compacting());
614
615            if busy && merge_mode == MergeMode::LastNonNull && busy_window < 2 {
616                assert!(outputs.is_empty(), "the entire dependent plan must wait");
617                continue;
618            }
619
620            assert_eq!(2, outputs.len());
621            for (window, output) in outputs.iter().enumerate() {
622                let window = window as i64;
623                let mut expected = HashSet::from([a, c]);
624                if !busy && busy_window == window {
625                    expected.insert(b);
626                }
627                let actual: HashSet<_> = output
628                    .inputs
629                    .iter()
630                    .map(|file| file.meta_ref().file_id)
631                    .collect();
632                assert_eq!(expected, actual);
633                assert_eq!(
634                    TimestampRange::new(
635                        Timestamp::new_millisecond(window * HOUR),
636                        Timestamp::new_millisecond((window + 1) * HOUR),
637                    ),
638                    output.output_time_range
639                );
640            }
641        }
642    }
643
644    #[test]
645    fn test_file_time_bucket_span() {
646        assert_eq!(
647            vec![(i64::MIN, i64::MIN + 8),],
648            file_time_bucket_span(i64::MIN, i64::MIN + 1, 10)
649        );
650
651        assert_eq!(
652            vec![(i64::MIN, i64::MIN + 8), (i64::MIN + 8, i64::MIN + 18)],
653            file_time_bucket_span(i64::MIN, i64::MIN + 8, 10)
654        );
655
656        assert_eq!(
657            vec![
658                (i64::MIN, i64::MIN + 8),
659                (i64::MIN + 8, i64::MIN + 18),
660                (i64::MIN + 18, i64::MIN + 28)
661            ],
662            file_time_bucket_span(i64::MIN, i64::MIN + 20, 10)
663        );
664
665        assert_eq!(
666            vec![(-10, 0), (0, 10), (10, 20)],
667            file_time_bucket_span(-1, 11, 10)
668        );
669
670        assert_eq!(
671            vec![(-3, 0), (0, 3), (3, 6)],
672            file_time_bucket_span(-1, 3, 3)
673        );
674
675        assert_eq!(vec![(0, 10)], file_time_bucket_span(0, 9, 10));
676
677        assert_eq!(
678            vec![(i64::MAX - (i64::MAX % 10), i64::MAX)],
679            file_time_bucket_span(i64::MAX - 1, i64::MAX, 10)
680        );
681    }
682}