Skip to main content

mito2/read/
pruner.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
15//! Pruner for parallel file pruning across scanner partitions.
16
17use std::collections::{HashMap, HashSet};
18use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
19use std::sync::{Arc, Mutex};
20use std::time::Instant;
21
22use common_telemetry::debug;
23use smallvec::SmallVec;
24use snafu::ResultExt;
25use store_api::region_engine::PartitionRange;
26use store_api::storage::FileId;
27use tokio::sync::{mpsc, oneshot};
28use uuid::Uuid;
29
30use crate::error::{PruneFileSnafu, Result};
31use crate::metrics::PRUNER_ACTIVE_BUILDERS;
32use crate::read::range::{FileRangeBuilder, RowGroupIndex};
33use crate::read::scan_region::StreamContext;
34use crate::read::scan_util::{FileScanMetrics, PartitionMetrics, new_filter_metrics};
35use crate::sst::parquet::file_range::{FileRange, PreFilterMode};
36use crate::sst::parquet::reader::ReaderMetrics;
37
38/// Number of files to pre-fetch ahead of the current position.
39const PREFETCH_COUNT: usize = 8;
40
41/// Local pruner in a partition that supports prefetching files to prune.
42pub struct PartitionPruner {
43    pruner: Arc<Pruner>,
44    /// Files to prune, in the order to scan.
45    file_indices: Vec<usize>,
46    /// Per-file pre-filter mode lookup indexed by file_index.
47    pre_filter_modes: Vec<PreFilterMode>,
48    /// Current position for tracking pre-fetch progress.
49    current_position: AtomicUsize,
50}
51
52impl PartitionPruner {
53    /// Creates a new `PartitionPruner` for the given partition ranges.
54    pub fn new(pruner: Arc<Pruner>, partition_ranges: &[PartitionRange]) -> Self {
55        let num_files = pruner.inner.stream_ctx.input.num_files();
56        let mut file_indices = Vec::with_capacity(num_files);
57        let mut pre_filter_modes = vec![PreFilterMode::SkipFields; num_files];
58        let mut dedup_set = HashSet::with_capacity(pruner.inner.stream_ctx.input.num_files());
59
60        let num_memtables = pruner.inner.stream_ctx.input.num_memtables();
61        for part_range in partition_ranges {
62            let range_meta = &pruner.inner.stream_ctx.ranges[part_range.identifier];
63            let pre_filter_mode = pruner.inner.stream_ctx.range_pre_filter_mode(part_range);
64            for row_group_index in &range_meta.row_group_indices {
65                if pruner
66                    .inner
67                    .stream_ctx
68                    .is_file_range_index(*row_group_index)
69                {
70                    let file_index = row_group_index.index - num_memtables;
71                    if dedup_set.contains(&file_index) {
72                        continue;
73                    } else {
74                        file_indices.push(file_index);
75                        pre_filter_modes[file_index] = pre_filter_mode;
76                        dedup_set.insert(file_index);
77                    }
78                }
79            }
80        }
81
82        Self {
83            pruner,
84            file_indices,
85            pre_filter_modes,
86            current_position: AtomicUsize::new(0),
87        }
88    }
89
90    /// Excludes files replaced by another candidate source from prefetching.
91    pub(crate) fn excluding_files(mut self, excluded: &HashSet<usize>) -> Self {
92        self.file_indices.retain(|index| !excluded.contains(index));
93        self
94    }
95
96    /// Gets or creates the FileRangeBuilder for a file.
97    ///
98    /// This method also triggers pre-fetching of upcoming files in the background
99    /// to improve performance by overlapping I/O with computation.
100    pub async fn build_file_ranges(
101        &self,
102        index: RowGroupIndex,
103        partition_metrics: &PartitionMetrics,
104        reader_metrics: &mut ReaderMetrics,
105    ) -> Result<SmallVec<[FileRange; 2]>> {
106        let file_index = index.index - self.pruner.inner.stream_ctx.input.num_memtables();
107        let pre_filter_mode = self.pre_filter_mode(file_index);
108
109        // Delegate to underlying Pruner
110        let ranges = self
111            .pruner
112            .build_file_ranges(index, pre_filter_mode, partition_metrics, reader_metrics)
113            .await?;
114
115        // Find position and trigger pre-fetch for upcoming files
116        if let Some(pos) = self.file_indices.iter().position(|&idx| idx == file_index) {
117            let prev_pos = self.current_position.fetch_max(pos, Ordering::Relaxed);
118            if pos > prev_pos || prev_pos == 0 {
119                self.prefetch_upcoming_files(pos, partition_metrics);
120            }
121        }
122
123        Ok(ranges)
124    }
125
126    /// Checks whether the file range at `index` can be skipped because the
127    /// current predicate definitively prunes it at manifest level (no I/O).
128    ///
129    /// Returns `true` if the range was skipped. When skipped, this method
130    /// balances the pruner's per-file reference count and merges the resulting
131    /// reader metrics into `part_metrics`.
132    ///
133    /// Uses shared per-file state so repeated row groups can skip cheaply after
134    /// the first manifest-prune decision.
135    pub fn try_skip_manifest_pruned_file_range(
136        &self,
137        index: RowGroupIndex,
138        part_metrics: &PartitionMetrics,
139    ) -> bool {
140        let Some(file_index) = self.file_index(index) else {
141            return false;
142        };
143        let mut reader_metrics = ReaderMetrics::default();
144        let pruned = self
145            .pruner
146            .inner
147            .try_mark_manifest_pruned(file_index, &mut reader_metrics);
148        if pruned {
149            self.pruner.skip_file_range(index, &mut reader_metrics);
150            part_metrics.merge_reader_metrics(&reader_metrics, None);
151        }
152        pruned
153    }
154
155    /// Returns the pre-filter mode for a file index in the scan input.
156    /// Defaults to [`PreFilterMode::SkipFields`] when no mode was recorded.
157    pub(crate) fn pre_filter_mode(&self, file_index: usize) -> PreFilterMode {
158        self.pre_filter_modes
159            .get(file_index)
160            .copied()
161            .unwrap_or(PreFilterMode::SkipFields)
162    }
163
164    /// Pre-fetches upcoming files starting from the given position.
165    fn prefetch_upcoming_files(&self, current_pos: usize, partition_metrics: &PartitionMetrics) {
166        let start = current_pos + 1;
167        let end = (start + PREFETCH_COUNT).min(self.file_indices.len());
168
169        for i in start..end {
170            let file_index = self.file_indices[i];
171            let pre_filter_mode = self.pre_filter_mode(file_index);
172            self.pruner.get_file_builder_background(
173                file_index,
174                pre_filter_mode,
175                Some(partition_metrics.clone()),
176            );
177        }
178    }
179
180    fn file_index(&self, index: RowGroupIndex) -> Option<usize> {
181        self.pruner
182            .inner
183            .stream_ctx
184            .is_file_range_index(index)
185            .then(|| index.index - self.pruner.inner.stream_ctx.input.num_memtables())
186    }
187}
188
189/// Options to create a [`Pruner`].
190#[derive(Debug, Clone, Copy)]
191pub struct PrunerOptions {
192    /// Keeps file range builders until the query-scoped pruner is dropped.
193    pub retain_builders: bool,
194    /// Whether [`FileRange`]s execute the reduced-column predicate prefilter.
195    ///
196    /// The flag is baked into the cached [`FileRangeBuilder`], so it belongs to
197    /// the whole scan. All partitions sharing this pruner must agree on it.
198    /// Two-stage series scans disable this prefilter: candidate discovery applies
199    /// tag predicates, then the data phase calls [`FileRange::precise_filter_flat`]
200    /// with tag filtering skipped so the predicates are applied exactly once.
201    pub enable_predicate_prefilter: bool,
202}
203
204impl Default for PrunerOptions {
205    fn default() -> Self {
206        Self {
207            retain_builders: false,
208            enable_predicate_prefilter: true,
209        }
210    }
211}
212
213/// A pruner that prunes files for all partitions of a scanner.
214pub struct Pruner {
215    /// Channels to send requests to workers.
216    worker_senders: Vec<mpsc::Sender<PruneRequest>>,
217    inner: Arc<PrunerInner>,
218}
219
220struct PrunerInner {
221    /// Number of worker tasks.
222    num_workers: usize,
223    /// Per-file state (indexed by file_index).
224    file_entries: Vec<Mutex<FileBuilderEntry>>,
225    /// StreamContext containing all context needed for pruning.
226    stream_ctx: Arc<StreamContext>,
227    /// Positive manifest-prune cache shared across all scan partitions.
228    ///
229    /// SAFETY: cached positives are valid because dynamic filters only tighten;
230    /// dynamic negative decisions are not cached. Reset by `add_partition_ranges()` for
231    /// each fresh batch of partition ranges.
232    manifest_pruned_files: Vec<AtomicBool>,
233    // Static predicates and file metadata remain unchanged for this pruner.
234    static_manifest_kept_files: Vec<AtomicBool>,
235    #[cfg(test)]
236    manifest_check_count: AtomicUsize,
237    /// Keeps file range builders until the query-scoped pruner is dropped.
238    retain_builders: bool,
239    /// Whether FileRanges should execute the reduced-column predicate prefilter.
240    enable_predicate_prefilter: bool,
241}
242
243impl Drop for PrunerInner {
244    fn drop(&mut self) {
245        let active_builders = self
246            .file_entries
247            .iter_mut()
248            .map(|entry| usize::from(entry.get_mut().is_ok_and(|entry| entry.builder.is_some())))
249            .sum::<usize>();
250        PRUNER_ACTIVE_BUILDERS.sub(active_builders as i64);
251    }
252}
253
254impl PrunerInner {
255    /// Checks whether manifest-level pruning proves this file is empty given the
256    /// current predicate. If true, CAS the shared cache from false→true and
257    /// record `files_time_range_pruned` in `reader_metrics`.
258    ///
259    /// Returns `true` if already cached or newly proven pruned.
260    fn try_mark_manifest_pruned(
261        &self,
262        file_index: usize,
263        reader_metrics: &mut ReaderMetrics,
264    ) -> bool {
265        if self.manifest_pruned_files[file_index].load(Ordering::Relaxed) {
266            return true;
267        }
268        let file = &self.stream_ctx.input.files[file_index];
269        // Dynamic filters may arrive or tighten after a static keep decision.
270        let has_dynamic_filters = self
271            .stream_ctx
272            .input
273            .predicate_for_file(file)
274            .is_some_and(|predicate| !predicate.dyn_filters().is_empty());
275        if !has_dynamic_filters
276            && self.static_manifest_kept_files[file_index].load(Ordering::Relaxed)
277        {
278            return false;
279        }
280        #[cfg(test)]
281        self.manifest_check_count.fetch_add(1, Ordering::Relaxed);
282        if !self.stream_ctx.input.can_manifest_prune_file(file) {
283            if !has_dynamic_filters {
284                self.static_manifest_kept_files[file_index].store(true, Ordering::Relaxed);
285            }
286            return false;
287        }
288        if self.manifest_pruned_files[file_index]
289            .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
290            .is_ok()
291        {
292            reader_metrics.filter_metrics.files_time_range_pruned += 1;
293        }
294        true
295    }
296}
297
298/// Per-file state tracking.
299struct FileBuilderEntry {
300    /// Cached builder for this file.
301    ///
302    /// A newly completed builder is cached only while `remaining_ranges > 0`.
303    /// In retaining mode, the builder may remain cached after
304    /// `remaining_ranges` reaches zero.
305    builder: Option<Arc<FileRangeBuilder>>,
306    /// Number of ranges remaining in the current initialized batch.
307    ///
308    /// This may be zero while `builder` is still present in retaining mode.
309    remaining_ranges: usize,
310    /// Waiters when pruning is in-progress.
311    waiters: Vec<oneshot::Sender<Result<Arc<FileRangeBuilder>>>>,
312}
313
314/// Request to prune a file.
315struct PruneRequest {
316    /// Index of the file in ScanInput.files.
317    file_index: usize,
318    /// Pre-filter mode to use for the file.
319    pre_filter_mode: PreFilterMode,
320    /// Oneshot channel to send back the result.
321    response_tx: Option<oneshot::Sender<Result<Arc<FileRangeBuilder>>>>,
322    /// Partition metrics for merging reader metrics.
323    partition_metrics: Option<PartitionMetrics>,
324}
325
326impl Pruner {
327    /// Creates a new Pruner with N worker tasks.
328    ///
329    /// Initially all file_entries have `remaining_ranges = 0`.
330    /// Call `add_partition_ranges()` to initialize ref counts.
331    pub fn new(stream_ctx: Arc<StreamContext>, num_workers: usize) -> Self {
332        Self::new_with_options(stream_ctx, num_workers, PrunerOptions::default())
333    }
334
335    /// Creates a new pruner with the given options.
336    pub fn new_with_options(
337        stream_ctx: Arc<StreamContext>,
338        num_workers: usize,
339        options: PrunerOptions,
340    ) -> Self {
341        let PrunerOptions {
342            retain_builders,
343            enable_predicate_prefilter,
344        } = options;
345        let num_files = stream_ctx.input.num_files();
346        let file_entries: Vec<_> = (0..num_files)
347            .map(|_| {
348                Mutex::new(FileBuilderEntry {
349                    builder: None,
350                    remaining_ranges: 0,
351                    waiters: Vec::new(),
352                })
353            })
354            .collect();
355        let manifest_pruned_files: Vec<AtomicBool> =
356            (0..num_files).map(|_| AtomicBool::new(false)).collect();
357        // Create channels and collect senders
358        let mut worker_senders = Vec::with_capacity(num_workers);
359        let mut receivers = Vec::with_capacity(num_workers);
360        for _ in 0..num_workers {
361            let (tx, rx) = mpsc::channel::<PruneRequest>(64);
362            worker_senders.push(tx);
363            receivers.push(rx);
364        }
365
366        let inner = Arc::new(PrunerInner {
367            num_workers,
368            file_entries,
369            stream_ctx,
370            manifest_pruned_files,
371            static_manifest_kept_files: (0..num_files).map(|_| AtomicBool::new(false)).collect(),
372            #[cfg(test)]
373            manifest_check_count: AtomicUsize::new(0),
374            retain_builders,
375            enable_predicate_prefilter,
376        });
377
378        // Keep pruning and prefetching on the runtime of the originating workload.
379        for (worker_id, rx) in receivers.into_iter().enumerate() {
380            let worker = Self::worker_loop(worker_id, rx, inner.clone());
381            if inner.stream_ctx.input.compaction {
382                common_runtime::spawn_compact(worker);
383            } else {
384                common_runtime::spawn_query(worker);
385            }
386        }
387
388        Self {
389            worker_senders,
390            inner,
391        }
392    }
393
394    /// Adds reference counts for partition ranges that the caller will scan.
395    ///
396    /// The caller must keep these ranges consistent with the ranges passed to
397    /// `PartitionPruner`. If called multiple times, all calls must describe
398    /// ranges from the same logical scan because their reference counts
399    /// accumulate in this pruner.
400    pub fn add_partition_ranges(&self, partition_ranges: &[PartitionRange]) {
401        // Reset manifest-prune results so the latest dynamic filters are visible.
402        for pruned in &self.inner.manifest_pruned_files {
403            pruned.store(false, Ordering::Relaxed);
404        }
405
406        // Add reference counts for each partition range
407        let num_memtables = self.inner.stream_ctx.input.num_memtables();
408        for part_range in partition_ranges {
409            let range_meta = &self.inner.stream_ctx.ranges[part_range.identifier];
410            for row_group_index in &range_meta.row_group_indices {
411                if self.inner.stream_ctx.is_file_range_index(*row_group_index) {
412                    let file_index = row_group_index.index - num_memtables;
413                    if file_index < self.inner.file_entries.len() {
414                        let mut entry = self.inner.file_entries[file_index].lock().unwrap();
415                        entry.remaining_ranges += 1;
416                    }
417                }
418            }
419        }
420    }
421
422    /// Gets or creates the FileRangeBuilder for a file, builds ranges,
423    /// and decrements its ref count. Non-retained builders are cleaned up at zero.
424    ///
425    /// Callers should invoke [add_partition_ranges()](Pruner::add_partition_ranges()) to initialize the
426    /// file entries and ref counts.
427    pub async fn build_file_ranges(
428        &self,
429        index: RowGroupIndex,
430        pre_filter_mode: PreFilterMode,
431        partition_metrics: &PartitionMetrics,
432        reader_metrics: &mut ReaderMetrics,
433    ) -> Result<SmallVec<[FileRange; 2]>> {
434        let file_index = index.index - self.inner.stream_ctx.input.num_memtables();
435
436        // Get builder (from cache or by pruning)
437        let builder = self
438            .get_file_builder(
439                file_index,
440                pre_filter_mode,
441                partition_metrics,
442                reader_metrics,
443            )
444            .await?;
445
446        // Build ranges
447        let mut ranges = SmallVec::new();
448        builder.build_ranges(index.row_group_index, &mut ranges);
449
450        // Decrement ref count and clean up non-retained builders if needed.
451        self.decrement_and_maybe_clear(file_index, reader_metrics);
452
453        Ok(ranges)
454    }
455
456    /// Skips a file range that has been pruned before entering the file pruner.
457    ///
458    /// This keeps the pruner's per-file reference counts balanced with
459    /// `add_partition_ranges()`. It may also clear a cached builder when this was the
460    /// last remaining range for the file.
461    pub fn skip_file_range(&self, index: RowGroupIndex, reader_metrics: &mut ReaderMetrics) {
462        if !self.inner.stream_ctx.is_file_range_index(index) {
463            return;
464        }
465        let file_index = index.index - self.inner.stream_ctx.input.num_memtables();
466        self.decrement_and_maybe_clear(file_index, reader_metrics);
467    }
468
469    /// Gets or creates the FileRangeBuilder for a file.
470    async fn get_file_builder(
471        &self,
472        file_index: usize,
473        pre_filter_mode: PreFilterMode,
474        partition_metrics: &PartitionMetrics,
475        reader_metrics: &mut ReaderMetrics,
476    ) -> Result<Arc<FileRangeBuilder>> {
477        // Fast path: checks cache
478        {
479            let entry = self.inner.file_entries[file_index].lock().unwrap();
480            if let Some(builder) = &entry.builder {
481                reader_metrics.filter_metrics.pruner_cache_hit += 1;
482                return Ok(builder.clone());
483            }
484        }
485
486        reader_metrics.filter_metrics.pruner_cache_miss += 1;
487        let prune_start = Instant::now();
488        let file = &self.inner.stream_ctx.input.files[file_index];
489        let file_id = file.file_id().file_id();
490        let worker_idx = self.get_worker_idx(file_id);
491
492        let (response_tx, response_rx) = oneshot::channel();
493        let request = PruneRequest {
494            file_index,
495            pre_filter_mode,
496            response_tx: Some(response_tx),
497            partition_metrics: Some(partition_metrics.clone()),
498        };
499
500        let result = if self.worker_senders[worker_idx].send(request).await.is_err() {
501            common_telemetry::warn!("Worker channel closed, falling back to direct pruning");
502            // Worker channel closed, falls back to direct pruning
503            self.prune_file_directly(file_index, pre_filter_mode, reader_metrics)
504                .await
505        } else {
506            // Waits for response
507            match response_rx.await {
508                Ok(result) => result,
509                Err(_) => {
510                    common_telemetry::warn!(
511                        "Response channel closed, falling back to direct pruning"
512                    );
513                    // Channel closed, falls back to direct pruning
514                    self.prune_file_directly(file_index, pre_filter_mode, reader_metrics)
515                        .await
516                }
517            }
518        };
519        reader_metrics.filter_metrics.pruner_prune_cost += prune_start.elapsed();
520        result
521    }
522
523    /// Gets or creates the FileRangeBuilder for a file.
524    pub fn get_file_builder_background(
525        &self,
526        file_index: usize,
527        pre_filter_mode: PreFilterMode,
528        partition_metrics: Option<PartitionMetrics>,
529    ) {
530        // Fast path: checks cache
531        {
532            let entry = self.inner.file_entries[file_index].lock().unwrap();
533            if entry.builder.is_some() {
534                return;
535            }
536        }
537
538        let file = &self.inner.stream_ctx.input.files[file_index];
539        let file_id = file.file_id().file_id();
540        let worker_idx = self.get_worker_idx(file_id);
541
542        let request = PruneRequest {
543            file_index,
544            pre_filter_mode,
545            response_tx: None,
546            partition_metrics,
547        };
548
549        // Sends request to worker
550        let _ = self.worker_senders[worker_idx].try_send(request);
551    }
552
553    /// Returns whether this pruner builds file ranges with the reduced-column
554    /// predicate prefilter enabled.
555    pub fn predicate_prefilter_enabled(&self) -> bool {
556        self.inner.enable_predicate_prefilter
557    }
558
559    fn get_worker_idx(&self, file_id: FileId) -> usize {
560        let file_id_hash = Uuid::from(file_id).as_u128() as usize;
561        file_id_hash % self.inner.num_workers
562    }
563
564    /// Prunes a file directly without going through a worker.
565    /// Used as fallback when worker channels are closed.
566    async fn prune_file_directly(
567        &self,
568        file_index: usize,
569        pre_filter_mode: PreFilterMode,
570        reader_metrics: &mut ReaderMetrics,
571    ) -> Result<Arc<FileRangeBuilder>> {
572        // Check manifest-level prune first (shared cache, no I/O).
573        if self
574            .inner
575            .try_mark_manifest_pruned(file_index, reader_metrics)
576        {
577            let arc_builder = Arc::new(FileRangeBuilder::default());
578            // Do NOT cache an empty manifest-pruned builder; the cache flag
579            // already records the decision.
580            return Ok(arc_builder);
581        }
582
583        let file = &self.inner.stream_ctx.input.files[file_index];
584        let predicate = self.inner.stream_ctx.input.predicate_for_file(file);
585        let builder = self
586            .inner
587            .stream_ctx
588            .input
589            .prune_file_after_manifest_check(
590                file,
591                pre_filter_mode,
592                self.inner.enable_predicate_prefilter,
593                predicate,
594                reader_metrics,
595            )
596            .await?;
597
598        let arc_builder = Arc::new(builder);
599
600        // Cache only while the file still has remaining ranges. Retaining mode
601        // affects cleanup after the builder has been cached, not cache eligibility.
602        {
603            let mut entry = self.inner.file_entries[file_index].lock().unwrap();
604            cache_builder_if_needed(&mut entry, &arc_builder, reader_metrics);
605        }
606
607        Ok(arc_builder)
608    }
609
610    /// Decrements ref count and clears builder if no longer needed.
611    fn decrement_and_maybe_clear(&self, file_index: usize, reader_metrics: &mut ReaderMetrics) {
612        let mut entry = self.inner.file_entries[file_index].lock().unwrap();
613        entry.remaining_ranges = entry.remaining_ranges.saturating_sub(1);
614
615        if !self.inner.retain_builders
616            && entry.remaining_ranges == 0
617            && let Some(builder) = entry.builder.take()
618        {
619            PRUNER_ACTIVE_BUILDERS.dec();
620            reader_metrics.metadata_mem_size -= builder.memory_size() as isize;
621            reader_metrics.num_range_builders -= 1;
622        }
623    }
624
625    /// Worker loop that processes prune requests.
626    async fn worker_loop(
627        worker_id: usize,
628        mut rx: mpsc::Receiver<PruneRequest>,
629        inner: Arc<PrunerInner>,
630    ) {
631        let mut worker_cache_hit = 0;
632        let mut worker_cache_miss = 0;
633        let mut pruned_files = Vec::new();
634
635        while let Some(request) = rx.recv().await {
636            let PruneRequest {
637                file_index,
638                pre_filter_mode,
639                response_tx,
640                partition_metrics,
641            } = request;
642
643            // Check if already cached or in-progress
644            {
645                let entry = inner.file_entries[file_index].lock().unwrap();
646                if let Some(builder) = &entry.builder {
647                    // Cache hit - send immediately
648                    if let Some(response_tx) = response_tx {
649                        let _ = response_tx.send(Ok(builder.clone()));
650                    }
651                    worker_cache_hit += 1;
652                    continue;
653                }
654            }
655            worker_cache_miss += 1;
656
657            let file = &inner.stream_ctx.input.files[file_index];
658            pruned_files.push(file.file_id().file_id());
659            let explain_verbose = partition_metrics
660                .as_ref()
661                .map(|m| m.explain_verbose())
662                .unwrap_or(false);
663            let mut metrics = ReaderMetrics {
664                filter_metrics: new_filter_metrics(explain_verbose),
665                ..Default::default()
666            };
667
668            // Check manifest-level prune first (shared cache, no I/O).
669            let result = if inner.try_mark_manifest_pruned(file_index, &mut metrics) {
670                // Manifest-level pruning proved the file empty — produce a
671                // default builder without reading any parquet metadata.
672                Ok(FileRangeBuilder::default())
673            } else {
674                let predicate = inner.stream_ctx.input.predicate_for_file(file);
675                inner
676                    .stream_ctx
677                    .input
678                    .prune_file_after_manifest_check(
679                        file,
680                        pre_filter_mode,
681                        inner.enable_predicate_prefilter,
682                        predicate,
683                        &mut metrics,
684                    )
685                    .await
686            };
687
688            // Update state and notify waiters
689            let mut entry = inner.file_entries[file_index].lock().unwrap();
690            match result {
691                Ok(builder) => {
692                    let arc_builder = Arc::new(builder);
693                    let is_background = response_tx.is_none();
694
695                    // Cache only if the file still has remaining ranges. If
696                    // remaining_ranges == 0, a concurrent `skip_file_range` already consumed
697                    // all ranges and may have cleared a previously cached builder. Retaining
698                    // mode only preserves builders that were cached before the count reached 0.
699                    // Skip caching manifest-pruned empty builders; the cache flag is enough.
700                    let did_cache =
701                        if inner.manifest_pruned_files[file_index].load(Ordering::Relaxed) {
702                            false
703                        } else {
704                            cache_builder_if_needed(&mut entry, &arc_builder, &mut metrics)
705                        };
706
707                    // Notify all waiters
708                    for waiter in entry.waiters.drain(..) {
709                        let _ = waiter.send(Ok(arc_builder.clone()));
710                    }
711                    // Always respond to foreground caller, even if we did not cache.
712                    if let Some(response_tx) = response_tx {
713                        let _ = response_tx.send(Ok(arc_builder));
714                    }
715
716                    debug!(
717                        "Pruner worker {} pruned file_index: {}, file: {:?}, metrics: {:?}",
718                        worker_id,
719                        file_index,
720                        file.file_id(),
721                        metrics
722                    );
723
724                    // Merge metrics if this is a foreground request, or if the builder
725                    // was cached. Skip stale per-file metrics
726                    // for background requests that completed after the file was already
727                    // fully skipped.
728                    if (!is_background || did_cache)
729                        && let Some(part_metrics) = &partition_metrics
730                    {
731                        let per_file_metrics = if part_metrics.explain_verbose() {
732                            let file_id = file.file_id();
733                            let mut map = HashMap::new();
734                            map.insert(
735                                file_id,
736                                FileScanMetrics {
737                                    build_part_cost: metrics.build_cost,
738                                    ..Default::default()
739                                },
740                            );
741                            Some(map)
742                        } else {
743                            None
744                        };
745                        part_metrics.merge_reader_metrics(&metrics, per_file_metrics.as_ref());
746                    }
747                }
748                Err(e) => {
749                    let arc_error = Arc::new(e);
750                    for waiter in entry.waiters.drain(..) {
751                        let _ = waiter.send(Err(arc_error.clone()).context(PruneFileSnafu));
752                    }
753                    if let Some(response_tx) = response_tx {
754                        let _ = response_tx.send(Err(arc_error).context(PruneFileSnafu));
755                    }
756                }
757            }
758        }
759
760        common_telemetry::debug!(
761            "Pruner worker {} finished, cache_hit: {}, cache_miss: {}, files: {:?}",
762            worker_id,
763            worker_cache_hit,
764            worker_cache_miss,
765            pruned_files,
766        );
767    }
768}
769
770#[cfg(test)]
771impl Pruner {
772    /// Returns the remaining range count for a file (test-only).
773    pub(crate) fn test_remaining_ranges(&self, file_index: usize) -> usize {
774        self.inner.file_entries[file_index]
775            .lock()
776            .unwrap()
777            .remaining_ranges
778    }
779
780    /// Returns whether a cached builder exists for a file (test-only).
781    fn test_has_builder(&self, file_index: usize) -> bool {
782        self.inner.file_entries[file_index]
783            .lock()
784            .unwrap()
785            .builder
786            .is_some()
787    }
788
789    /// Returns the manifest-pruned flag for a file (test-only).
790    fn test_is_manifest_pruned(&self, file_index: usize) -> bool {
791        self.inner.manifest_pruned_files[file_index].load(Ordering::Relaxed)
792    }
793
794    /// Clears a cached builder for a file, simulating stale cleanup (test-only).
795    #[allow(dead_code)]
796    fn test_clear_builder(&self, file_index: usize) {
797        let mut entry = self.inner.file_entries[file_index].lock().unwrap();
798        if entry.builder.take().is_some() {
799            PRUNER_ACTIVE_BUILDERS.dec();
800        }
801    }
802}
803
804/// Returns true if a freshly pruned builder should be cached for this file.
805fn should_cache_builder(entry: &FileBuilderEntry) -> bool {
806    entry.builder.is_none() && entry.remaining_ranges > 0
807}
808
809/// Caches a freshly pruned builder if it is still referenced, and records the
810/// corresponding builder memory/count deltas for verbose metrics.
811fn cache_builder_if_needed(
812    entry: &mut FileBuilderEntry,
813    builder: &Arc<FileRangeBuilder>,
814    reader_metrics: &mut ReaderMetrics,
815) -> bool {
816    if should_cache_builder(entry) {
817        reader_metrics.metadata_mem_size += builder.memory_size() as isize;
818        reader_metrics.num_range_builders += 1;
819        entry.builder = Some(builder.clone());
820        PRUNER_ACTIVE_BUILDERS.inc();
821        true
822    } else {
823        false
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use common_time::Timestamp;
830    use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet;
831    use datafusion_common::ScalarValue;
832    use datafusion_expr::{Expr, col, lit};
833    use store_api::region_engine::PartitionRange;
834    use store_api::storage::{FileId, RegionId};
835
836    use super::*;
837    use crate::read::flat_projection::FlatProjectionMapper;
838    use crate::read::range::{RangeMeta, RowGroupIndex};
839    use crate::read::scan_region::{PredicateGroup, ScanInput};
840    use crate::read::scan_util::PartitionMetrics;
841    use crate::sst::file::{FileHandle, FileMeta};
842    use crate::sst::parquet::reader::ReaderMetrics;
843    use crate::test_util::memtable_util::metadata_with_primary_key;
844    use crate::test_util::new_noop_file_purger;
845    use crate::test_util::scheduler_util::SchedulerEnv;
846
847    async fn make_test_pruner(num_files: usize) -> (SchedulerEnv, Arc<Pruner>) {
848        make_test_pruner_with_retained_builders(num_files, false).await
849    }
850
851    async fn make_test_pruner_with_retained_builders(
852        num_files: usize,
853        retain_builders: bool,
854    ) -> (SchedulerEnv, Arc<Pruner>) {
855        let env = SchedulerEnv::new().await;
856        let metadata = Arc::new(metadata_with_primary_key(vec![0, 1], false));
857        let mapper = FlatProjectionMapper::new(&metadata, [0, 2, 3]).unwrap();
858
859        let files: Vec<FileHandle> = (0..num_files)
860            .map(|_| {
861                let meta = FileMeta {
862                    region_id: RegionId::new(123, 456),
863                    file_id: FileId::random(),
864                    time_range: (
865                        Timestamp::new_millisecond(0),
866                        Timestamp::new_millisecond(1000),
867                    ),
868                    num_row_groups: 1,
869                    num_rows: 1024,
870                    level: 0,
871                    ..Default::default()
872                };
873                FileHandle::new(meta, new_noop_file_purger())
874            })
875            .collect();
876
877        let input = ScanInput::builder(env.access_layer.clone(), mapper)
878            .with_files(files)
879            .with_append_mode(true)
880            .build();
881        let query_start = input.query_start.unwrap_or_else(Instant::now);
882        let ranges = RangeMeta::unordered_scan_ranges(&input).await;
883        let stream_ctx = Arc::new(StreamContext::unordered_scan_ctx(
884            input,
885            ranges,
886            query_start,
887        ));
888        let pruner = Arc::new(Pruner::new_with_options(
889            stream_ctx,
890            1,
891            PrunerOptions {
892                retain_builders,
893                ..Default::default()
894            },
895        ));
896        (env, pruner)
897    }
898
899    /// Builds a minimal `PartitionRange` that references `file_index`.
900    /// `add_partition_ranges` will look up `stream_ctx.ranges[identifier]`
901    /// and find `row_group_indices[0] == RowGroupIndex { index: file_index,
902    /// row_group_index: 0 }` because `unordered_scan_ranges` with
903    /// `num_row_groups=1` produces one range per file.
904    fn file_partition_range(file_index: usize) -> PartitionRange {
905        PartitionRange {
906            start: Timestamp::new_millisecond(0),
907            end: Timestamp::new_millisecond(1001),
908            num_rows: 1024,
909            identifier: file_index,
910        }
911    }
912
913    async fn make_test_pruner_with_predicate(
914        num_files: usize,
915        row_groups_per_file: u64,
916        predicate_exprs: &[Expr],
917    ) -> (SchedulerEnv, Arc<Pruner>) {
918        let env = SchedulerEnv::new().await;
919        let metadata = Arc::new(metadata_with_primary_key(vec![0, 1], false));
920        let mapper = FlatProjectionMapper::new(&metadata, [0, 2, 3]).unwrap();
921        let predicate = PredicateGroup::new(&metadata, predicate_exprs).unwrap();
922
923        let files: Vec<FileHandle> = (0..num_files)
924            .map(|_| {
925                let meta = FileMeta {
926                    region_id: RegionId::new(123, 456),
927                    file_id: FileId::random(),
928                    time_range: (
929                        Timestamp::new_millisecond(0),
930                        Timestamp::new_millisecond(1000),
931                    ),
932                    num_row_groups: row_groups_per_file,
933                    num_rows: row_groups_per_file * 1024,
934                    level: 0,
935                    ..Default::default()
936                };
937                FileHandle::new(meta, new_noop_file_purger())
938            })
939            .collect();
940
941        let input = ScanInput::builder(env.access_layer.clone(), mapper)
942            .with_files(files)
943            .with_predicate(predicate)
944            .with_append_mode(true)
945            .build();
946        let query_start = input.query_start.unwrap_or_else(Instant::now);
947        let ranges = RangeMeta::unordered_scan_ranges(&input).await;
948        let stream_ctx = Arc::new(StreamContext::unordered_scan_ctx(
949            input,
950            ranges,
951            query_start,
952        ));
953        let pruner = Arc::new(Pruner::new(stream_ctx, 1));
954        (env, pruner)
955    }
956
957    fn make_partition_metrics() -> PartitionMetrics {
958        let metrics_set = ExecutionPlanMetricsSet::new();
959        PartitionMetrics::new(
960            RegionId::new(123, 456),
961            0,
962            "test",
963            Instant::now(),
964            false,
965            &metrics_set,
966        )
967    }
968
969    #[test]
970    fn should_cache_builder_when_ranges_remain() {
971        let entry = FileBuilderEntry {
972            builder: None,
973            remaining_ranges: 3,
974            waiters: Vec::new(),
975        };
976        assert!(should_cache_builder(&entry));
977    }
978
979    #[test]
980    fn should_not_cache_builder_when_no_ranges_remain() {
981        let entry = FileBuilderEntry {
982            builder: None,
983            remaining_ranges: 0,
984            waiters: Vec::new(),
985        };
986        assert!(!should_cache_builder(&entry));
987    }
988
989    #[test]
990    fn should_not_cache_builder_when_already_cached() {
991        let entry = FileBuilderEntry {
992            builder: Some(Arc::new(FileRangeBuilder::default())),
993            remaining_ranges: 1,
994            waiters: Vec::new(),
995        };
996        assert!(!should_cache_builder(&entry));
997    }
998
999    #[test]
1000    fn cache_builder_records_metrics() {
1001        let mut entry = FileBuilderEntry {
1002            builder: None,
1003            remaining_ranges: 1,
1004            waiters: Vec::new(),
1005        };
1006        let builder = Arc::new(FileRangeBuilder::default());
1007        let mut reader_metrics = ReaderMetrics::default();
1008
1009        assert!(cache_builder_if_needed(
1010            &mut entry,
1011            &builder,
1012            &mut reader_metrics,
1013        ));
1014        assert!(entry.builder.is_some());
1015        assert_eq!(
1016            reader_metrics.metadata_mem_size,
1017            builder.memory_size() as isize
1018        );
1019        assert_eq!(reader_metrics.num_range_builders, 1);
1020
1021        if entry.builder.take().is_some() {
1022            PRUNER_ACTIVE_BUILDERS.dec();
1023        }
1024    }
1025
1026    #[tokio::test]
1027    async fn skip_file_range_decrements_and_clears_builder() {
1028        let (_env, pruner) = make_test_pruner(1).await;
1029
1030        // Simulate 3 partition ranges for file 0.
1031        let ranges: Vec<PartitionRange> = (0..3).map(|_| file_partition_range(0)).collect();
1032        pruner.add_partition_ranges(&ranges);
1033        assert_eq!(pruner.test_remaining_ranges(0), 3);
1034
1035        // Manually set a cached builder (simulating a previous cache hit).
1036        {
1037            let mut entry = pruner.inner.file_entries[0].lock().unwrap();
1038            entry.builder = Some(Arc::new(FileRangeBuilder::default()));
1039            PRUNER_ACTIVE_BUILDERS.inc();
1040        }
1041        assert!(pruner.test_has_builder(0));
1042
1043        // Skip all 3 ranges; the third should clear the builder.
1044        let mut reader_metrics = ReaderMetrics::default();
1045        for i in 0..3 {
1046            let index = RowGroupIndex {
1047                index: 0,
1048                row_group_index: i as i64,
1049            };
1050            pruner.skip_file_range(index, &mut reader_metrics);
1051        }
1052
1053        assert_eq!(pruner.test_remaining_ranges(0), 0);
1054        assert!(!pruner.test_has_builder(0));
1055    }
1056
1057    #[tokio::test]
1058    async fn retained_builder_survives_after_last_range() {
1059        let (_env, pruner) = make_test_pruner_with_retained_builders(1, true).await;
1060        pruner.add_partition_ranges(&[file_partition_range(0)]);
1061        {
1062            let mut entry = pruner.inner.file_entries[0].lock().unwrap();
1063            entry.builder = Some(Arc::new(FileRangeBuilder::default()));
1064            PRUNER_ACTIVE_BUILDERS.inc();
1065        }
1066
1067        let mut reader_metrics = ReaderMetrics::default();
1068        pruner.skip_file_range(
1069            RowGroupIndex {
1070                index: 0,
1071                row_group_index: 0,
1072            },
1073            &mut reader_metrics,
1074        );
1075
1076        assert_eq!(pruner.test_remaining_ranges(0), 0);
1077        assert!(pruner.test_has_builder(0));
1078
1079        let partition_metrics = make_partition_metrics();
1080        let mut reader_metrics = ReaderMetrics::default();
1081        let _builder = pruner
1082            .get_file_builder(
1083                0,
1084                PreFilterMode::SkipFields,
1085                &partition_metrics,
1086                &mut reader_metrics,
1087            )
1088            .await
1089            .unwrap();
1090        assert_eq!(reader_metrics.filter_metrics.pruner_cache_hit, 1);
1091    }
1092
1093    #[tokio::test]
1094    async fn add_partition_ranges_keeps_retained_builder() {
1095        let (_env, pruner) = make_test_pruner_with_retained_builders(1, true).await;
1096        pruner.add_partition_ranges(&[file_partition_range(0)]);
1097        {
1098            let mut entry = pruner.inner.file_entries[0].lock().unwrap();
1099            entry.builder = Some(Arc::new(FileRangeBuilder::default()));
1100            PRUNER_ACTIVE_BUILDERS.inc();
1101        }
1102
1103        let mut reader_metrics = ReaderMetrics::default();
1104        pruner.skip_file_range(
1105            RowGroupIndex {
1106                index: 0,
1107                row_group_index: 0,
1108            },
1109            &mut reader_metrics,
1110        );
1111        assert!(pruner.test_has_builder(0));
1112
1113        pruner.add_partition_ranges(&[file_partition_range(0)]);
1114        assert!(pruner.test_has_builder(0));
1115        assert_eq!(pruner.test_remaining_ranges(0), 1);
1116    }
1117
1118    #[tokio::test]
1119    async fn retaining_mode_does_not_cache_after_skip_file_range_consumed_all() {
1120        let (_env, pruner) = make_test_pruner_with_retained_builders(1, true).await;
1121
1122        // Simulate one range for file 0.
1123        let ranges = vec![file_partition_range(0)];
1124        pruner.add_partition_ranges(&ranges);
1125        assert_eq!(pruner.test_remaining_ranges(0), 1);
1126
1127        // Simulate skip_file_range consuming the last range BEFORE the
1128        // background worker finishes. This mirrors the race: a dynamic filter
1129        // tightens and manifest-prune fast-skip zeros out remaining_ranges.
1130        let mut reader_metrics = ReaderMetrics::default();
1131        let index = RowGroupIndex {
1132            index: 0,
1133            row_group_index: 0,
1134        };
1135        pruner.skip_file_range(index, &mut reader_metrics);
1136        assert_eq!(pruner.test_remaining_ranges(0), 0);
1137        assert!(!pruner.test_has_builder(0));
1138
1139        // Now simulate the worker completing: check the caching guard.
1140        let entry = pruner.inner.file_entries[0].lock().unwrap();
1141        let should_cache = should_cache_builder(&entry);
1142        drop(entry);
1143
1144        assert!(!should_cache);
1145
1146        // Ensure the gauge was not incremented for a stale builder.
1147        // (skip_file_range already decremented it if there was one, but here
1148        // there was none, so the gauge should be at baseline.)
1149    }
1150
1151    #[tokio::test]
1152    async fn worker_caches_when_ranges_remain() {
1153        let (_env, pruner) = make_test_pruner(1).await;
1154
1155        // Simulate 2 ranges for file 0.
1156        let ranges: Vec<PartitionRange> = (0..2).map(|_| file_partition_range(0)).collect();
1157        pruner.add_partition_ranges(&ranges);
1158        assert_eq!(pruner.test_remaining_ranges(0), 2);
1159
1160        // Consume only 1 range.
1161        let mut reader_metrics = ReaderMetrics::default();
1162        let index = RowGroupIndex {
1163            index: 0,
1164            row_group_index: 0,
1165        };
1166        pruner.skip_file_range(index, &mut reader_metrics);
1167        assert_eq!(pruner.test_remaining_ranges(0), 1);
1168
1169        // The worker should still cache because remaining_ranges > 0.
1170        let entry = pruner.inner.file_entries[0].lock().unwrap();
1171        assert!(should_cache_builder(&entry));
1172    }
1173
1174    // ── Corner case: fast-skip across multiple row groups ─────────────
1175
1176    /// 1 file × 3 row groups, predicate `ts > 10000ms` prunes the file at
1177    /// manifest level. Fast-skipping each of the 3 row groups must return true
1178    /// and decrement remaining_ranges to 0.
1179    #[tokio::test]
1180    async fn try_skip_manifest_pruned_file_range_multi_row_groups() {
1181        let predicate_exprs: Vec<Expr> =
1182            vec![col("ts").gt(lit(ScalarValue::TimestampMillisecond(Some(10_000), None)))];
1183        let (_env, pruner) = make_test_pruner_with_predicate(1, 3, &predicate_exprs).await;
1184
1185        let ranges = pruner.inner.stream_ctx.partition_ranges();
1186        assert_eq!(ranges.len(), 3);
1187        pruner.add_partition_ranges(&ranges);
1188        assert_eq!(pruner.test_remaining_ranges(0), 3);
1189
1190        let partition_pruner = Arc::new(PartitionPruner::new(pruner.clone(), &ranges));
1191        let partition_metrics = make_partition_metrics();
1192
1193        // Fast-skip each of the 3 row groups.
1194        for rg in 0..3 {
1195            let index = RowGroupIndex {
1196                index: 0, // file_index == 0, no memtables
1197                row_group_index: rg,
1198            };
1199            let skipped =
1200                partition_pruner.try_skip_manifest_pruned_file_range(index, &partition_metrics);
1201            assert!(skipped, "row group {} should be skipped", rg);
1202        }
1203
1204        // All refs consumed.
1205        assert_eq!(pruner.test_remaining_ranges(0), 0);
1206        // manifest_pruned_files is CAS'd exactly once (first call).
1207        assert!(pruner.test_is_manifest_pruned(0));
1208    }
1209
1210    /// A file whose manifest time range may contain matching rows must not be
1211    /// fast-skipped. This protects query correctness over metrics precision.
1212    #[tokio::test]
1213    async fn try_skip_manifest_pruned_file_range_keeps_overlapping_file() {
1214        let predicate_exprs: Vec<Expr> =
1215            vec![col("ts").gt(lit(ScalarValue::TimestampMillisecond(Some(500), None)))];
1216        let (_env, pruner) = make_test_pruner_with_predicate(1, 2, &predicate_exprs).await;
1217
1218        let ranges = pruner.inner.stream_ctx.partition_ranges();
1219        assert_eq!(ranges.len(), 2);
1220        pruner.add_partition_ranges(&ranges);
1221        assert_eq!(pruner.test_remaining_ranges(0), 2);
1222
1223        let partition_pruner = Arc::new(PartitionPruner::new(pruner.clone(), &ranges));
1224        let partition_metrics = make_partition_metrics();
1225        let range_meta = &pruner.inner.stream_ctx.ranges[ranges[0].identifier];
1226        let index = range_meta.row_group_indices[0];
1227
1228        let skipped =
1229            partition_pruner.try_skip_manifest_pruned_file_range(index, &partition_metrics);
1230
1231        assert!(!skipped);
1232        assert_eq!(pruner.test_remaining_ranges(0), 2);
1233        assert!(!pruner.test_is_manifest_pruned(0));
1234    }
1235
1236    // ── Corner case: add_partition_ranges resets the manifest-pruned flag ──
1237
1238    #[tokio::test]
1239    async fn add_partition_ranges_resets_manifest_pruned_flag() {
1240        let predicate_exprs: Vec<Expr> =
1241            vec![col("ts").gt(lit(ScalarValue::TimestampMillisecond(Some(10_000), None)))];
1242        let (_env, pruner) = make_test_pruner_with_predicate(1, 1, &predicate_exprs).await;
1243
1244        // Mark file 0 as manifest-pruned.
1245        let mut reader_metrics = ReaderMetrics::default();
1246        let marked = pruner
1247            .inner
1248            .try_mark_manifest_pruned(0, &mut reader_metrics);
1249        assert!(marked);
1250        assert!(pruner.test_is_manifest_pruned(0));
1251        assert_eq!(reader_metrics.filter_metrics.files_time_range_pruned, 1);
1252
1253        // Calling add_partition_ranges must reset the flag.
1254        let ranges = vec![file_partition_range(0)];
1255        pruner.add_partition_ranges(&ranges);
1256        assert!(!pruner.test_is_manifest_pruned(0));
1257        // remaining_ranges was also incremented.
1258        assert_eq!(pruner.test_remaining_ranges(0), 1);
1259    }
1260
1261    // ── Corner case: prune_file_directly short-circuits via manifest prune ──
1262
1263    #[tokio::test]
1264    async fn prune_file_directly_manifest_pruned_returns_empty_builder() {
1265        let predicate_exprs: Vec<Expr> =
1266            vec![col("ts").gt(lit(ScalarValue::TimestampMillisecond(Some(10_000), None)))];
1267        let (_env, pruner) = make_test_pruner_with_predicate(1, 1, &predicate_exprs).await;
1268
1269        // Ensure there is no cached builder yet.
1270        assert!(!pruner.test_has_builder(0));
1271
1272        let mut reader_metrics = ReaderMetrics::default();
1273        let builder = pruner
1274            .prune_file_directly(0, PreFilterMode::SkipFields, &mut reader_metrics)
1275            .await
1276            .unwrap();
1277
1278        // Should be the default (empty) builder.
1279        assert_eq!(
1280            builder.memory_size(),
1281            FileRangeBuilder::default().memory_size()
1282        );
1283        // builder must NOT be cached — the manifest-pruned flag is enough.
1284        assert!(!pruner.test_has_builder(0));
1285        // files_time_range_pruned was recorded.
1286        assert_eq!(reader_metrics.filter_metrics.files_time_range_pruned, 1);
1287    }
1288
1289    // ── Corner case: try_mark_manifest_pruned does not double-count ───
1290
1291    #[tokio::test]
1292    async fn try_mark_manifest_pruned_only_counts_first_cas() {
1293        let predicate_exprs: Vec<Expr> =
1294            vec![col("ts").gt(lit(ScalarValue::TimestampMillisecond(Some(10_000), None)))];
1295        let (_env, pruner) = make_test_pruner_with_predicate(1, 1, &predicate_exprs).await;
1296
1297        // First call: CAS succeeds, metric incremented.
1298        let mut reader_metrics = ReaderMetrics::default();
1299        let marked = pruner
1300            .inner
1301            .try_mark_manifest_pruned(0, &mut reader_metrics);
1302        assert!(marked);
1303        assert_eq!(reader_metrics.filter_metrics.files_time_range_pruned, 1);
1304        assert!(pruner.test_is_manifest_pruned(0));
1305
1306        // Second call: already true, no metric delta.
1307        let mut reader_metrics2 = ReaderMetrics::default();
1308        let marked2 = pruner
1309            .inner
1310            .try_mark_manifest_pruned(0, &mut reader_metrics2);
1311        assert!(marked2);
1312        assert_eq!(reader_metrics2.filter_metrics.files_time_range_pruned, 0);
1313    }
1314
1315    #[tokio::test]
1316    async fn cached_builder_checks_late_and_tightened_dynamic_filters() {
1317        use datafusion::physical_plan::expressions::{
1318            DynamicFilterPhysicalExpr, lit as physical_lit,
1319        };
1320
1321        let expressions = [col("ts").gt(lit(ScalarValue::TimestampMillisecond(Some(500), None)))];
1322        let (_env, pruner) = make_test_pruner_with_predicate(1, 3, &expressions).await;
1323        let ranges = pruner.inner.stream_ctx.partition_ranges();
1324        pruner.add_partition_ranges(&ranges);
1325        let mut metrics = ReaderMetrics::default();
1326        assert!(!pruner.inner.try_mark_manifest_pruned(0, &mut metrics));
1327        cache_builder_if_needed(
1328            &mut pruner.inner.file_entries[0].lock().unwrap(),
1329            &Arc::new(FileRangeBuilder::default()),
1330            &mut metrics,
1331        );
1332        assert!(!pruner.inner.try_mark_manifest_pruned(0, &mut metrics));
1333        let dynamic = Arc::new(DynamicFilterPhysicalExpr::new(vec![], physical_lit(true)));
1334        pruner
1335            .inner
1336            .stream_ctx
1337            .input
1338            .predicate
1339            .add_dyn_filters(vec![dynamic.clone()]);
1340        assert!(!pruner.inner.try_mark_manifest_pruned(0, &mut metrics));
1341        assert_eq!(pruner.inner.manifest_check_count.load(Ordering::Relaxed), 2);
1342        dynamic.update(physical_lit(false)).unwrap();
1343        assert!(pruner.inner.try_mark_manifest_pruned(0, &mut metrics));
1344        assert_eq!(pruner.inner.manifest_check_count.load(Ordering::Relaxed), 3);
1345        assert_eq!(metrics.filter_metrics.files_time_range_pruned, 1);
1346    }
1347
1348    #[tokio::test]
1349    async fn static_manifest_keep_is_reused_without_builder() {
1350        let expressions = [col("ts").gt(lit(ScalarValue::TimestampMillisecond(Some(500), None)))];
1351        let (_env, pruner) = make_test_pruner_with_predicate(1, 3, &expressions).await;
1352        let ranges = pruner.inner.stream_ctx.partition_ranges();
1353        pruner.add_partition_ranges(&ranges);
1354        let mut metrics = ReaderMetrics::default();
1355        for _ in 0..3 {
1356            assert!(!pruner.inner.try_mark_manifest_pruned(0, &mut metrics));
1357        }
1358        assert_eq!(pruner.inner.manifest_check_count.load(Ordering::Relaxed), 1);
1359        pruner.add_partition_ranges(&ranges);
1360        assert!(!pruner.inner.try_mark_manifest_pruned(0, &mut metrics));
1361        assert_eq!(pruner.inner.manifest_check_count.load(Ordering::Relaxed), 1);
1362    }
1363}