Skip to main content

timeseries_table_format/datafusion/
ts_table_provider.rs

1mod segment_pruning;
2#[cfg(test)]
3mod tests;
4mod timestamp_pruning;
5
6use crate::storage::file_size;
7
8use std::collections::HashSet;
9use std::path::Path;
10use std::sync::Arc;
11
12use arrow::datatypes::{DataType, SchemaRef};
13use async_trait::async_trait;
14
15use datafusion::catalog::Session;
16use datafusion::catalog::TableProvider;
17use datafusion::common::DFSchema;
18
19use datafusion::datasource::listing::PartitionedFile;
20use datafusion::datasource::physical_plan::FileScanConfigBuilder;
21use datafusion::datasource::physical_plan::ParquetSource;
22use datafusion::datasource::source::DataSourceExec;
23use datafusion::error::{DataFusionError, Result as DFResult};
24use datafusion::execution::object_store::ObjectStoreUrl;
25
26use datafusion::logical_expr::{Expr, Operator};
27
28use datafusion::logical_expr::TableProviderFilterPushDown;
29
30use crate::metadata::index::{IndexKind, IndexSpec};
31use crate::table::TimeSeriesTable;
32use crate::transaction_log::SegmentMeta;
33use crate::transaction_log::TableState;
34use datafusion::logical_expr::utils::{conjunction, expr_to_columns};
35use datafusion::physical_expr::PhysicalExpr;
36use datafusion::physical_plan::ExecutionPlan;
37use datafusion::physical_plan::expressions::lit;
38use datafusion::scalar::ScalarValue;
39use tokio::sync::RwLock;
40
41/// DataFusion table provider for a timeseries table schema.
42///
43/// The schema is captured when the provider is constructed. If the table schema
44/// evolves, re-register a new provider to pick up the updated schema.
45#[derive(Debug)]
46pub struct TsTableProvider {
47    table: Arc<TimeSeriesTable>,
48    schema: SchemaRef,
49    cache: RwLock<Cache>,
50
51    object_store_url: ObjectStoreUrl,
52}
53
54#[derive(Debug)]
55struct Cache {
56    version: Option<u64>,
57    state: Option<TableState>,
58}
59
60/// Wrap a generic error for DataFusion APIs.
61fn df_external<E>(e: E) -> DataFusionError
62where
63    E: std::error::Error + Send + Sync + 'static,
64{
65    DataFusionError::External(Box::new(e))
66}
67
68fn metadata_pruning_expr(
69    expr: &Expr,
70    index: &IndexSpec,
71    schema: &SchemaRef,
72) -> DFResult<Option<Expr>> {
73    if is_exact_entity_equality(expr, index, schema) {
74        return Ok(Some(expr.clone()));
75    }
76
77    if let Expr::BinaryExpr(binary) = expr
78        && matches!(binary.op, Operator::And | Operator::Or)
79    {
80        let left = metadata_pruning_expr(&binary.left, index, schema)?;
81        let right = metadata_pruning_expr(&binary.right, index, schema)?;
82        return Ok(if binary.op == Operator::And {
83            match (left, right) {
84                (Some(left), Some(right)) => Some(left.and(right)),
85                (left @ Some(_), None) | (None, left @ Some(_)) => left,
86                (None, None) => None,
87            }
88        } else {
89            left.zip(right).map(|(left, right)| left.or(right))
90        });
91    }
92
93    let mut columns = HashSet::new();
94    expr_to_columns(expr, &mut columns)?;
95    Ok(
96        (!columns.is_empty() && columns.iter().all(|column| column.name == index.column))
97            .then(|| expr.clone()),
98    )
99}
100
101fn is_exact_entity_equality(expr: &Expr, index: &IndexSpec, schema: &SchemaRef) -> bool {
102    let Expr::BinaryExpr(binary) = expr else {
103        return false;
104    };
105    if binary.op != Operator::Eq {
106        return false;
107    }
108    let (column, literal) = match (&*binary.left, &*binary.right) {
109        (Expr::Column(column), Expr::Literal(literal, _))
110        | (Expr::Literal(literal, _), Expr::Column(column)) => (column, literal),
111        _ => return false,
112    };
113    entity_literal_matches(column.name.as_str(), literal, index, schema)
114}
115
116fn entity_literal_matches(
117    column: &str,
118    literal: &ScalarValue,
119    index: &IndexSpec,
120    schema: &SchemaRef,
121) -> bool {
122    if !index.entity_columns.iter().any(|entity| entity == column) {
123        return false;
124    }
125    let Ok(field) = schema.field_with_name(column) else {
126        return false;
127    };
128    matches!(
129        (field.data_type(), literal),
130        (DataType::Utf8, ScalarValue::Utf8(Some(_)))
131            | (DataType::LargeUtf8, ScalarValue::LargeUtf8(Some(_)))
132            | (DataType::Int32, ScalarValue::Int32(Some(_)))
133            | (DataType::Int64, ScalarValue::Int64(Some(_)))
134            | (DataType::UInt64, ScalarValue::UInt64(Some(_)))
135    )
136}
137
138impl TsTableProvider {
139    /// Creates a new provider backed by the given `TimeSeriesTable`.
140    pub fn try_new(table: Arc<TimeSeriesTable>) -> DFResult<Self> {
141        // Use the table's current in-memory snapshot to get schema.
142        // (No schema evolution in v0.1, so this is stable.)
143        let schema = table
144            .state()
145            .table_meta
146            .arrow_schema_ref()
147            .map_err(df_external)?;
148
149        let object_store_url =
150            ObjectStoreUrl::parse(table.location().object_store_url()).map_err(df_external)?;
151        let state = table.state().clone();
152
153        Ok(Self {
154            table,
155            schema,
156            cache: RwLock::new(Cache {
157                version: Some(state.version),
158                state: Some(state),
159            }),
160            object_store_url,
161        })
162    }
163
164    async fn latest_state(&self) -> DFResult<TableState> {
165        let current_version = self.table.current_version().await.map_err(df_external)?;
166
167        // Fast path: cache hit
168        {
169            let cache = self.cache.read().await;
170            if cache.version == Some(current_version)
171                && let Some(st) = cache.state.clone()
172            {
173                return Ok(st);
174            }
175        }
176
177        // Refresh from log
178        let state = self.table.load_latest_state().await.map_err(df_external)?;
179        let mut cache = self.cache.write().await;
180        cache.version = Some(state.version);
181        cache.state = Some(state.clone());
182        Ok(state)
183    }
184
185    async fn segment_file_size(&self, seg: &SegmentMeta) -> datafusion::error::Result<u64> {
186        if let Some(sz) = seg.file_size {
187            return Ok(sz);
188        }
189
190        let sz = file_size(self.table.location().storage(), Path::new(&seg.path))
191            .await
192            .map_err(|source| {
193                df_external(source).context(format!(
194                    "missing segment file size; failed to inspect {}",
195                    seg.path
196                ))
197            })?;
198        Ok(sz)
199    }
200
201    /// Return the ordered-index column name from the table's index spec.
202    fn index_column_name(&self) -> &str {
203        self.table.index_spec().column.as_str()
204    }
205
206    fn prune_segments_by_metadata<'a>(
207        &self,
208        segments: Vec<&'a SegmentMeta>,
209        metadata_filters: &[Expr],
210        pruning_predicate: &Arc<dyn PhysicalExpr>,
211    ) -> DFResult<Vec<&'a SegmentMeta>> {
212        if metadata_filters.is_empty() {
213            return Ok(segments);
214        }
215
216        segment_pruning::prune_segments(
217            &self.schema,
218            self.table.index_spec(),
219            segments,
220            pruning_predicate,
221        )
222    }
223}
224
225#[async_trait]
226impl TableProvider for TsTableProvider {
227    fn schema(&self) -> arrow::datatypes::SchemaRef {
228        Arc::clone(&self.schema)
229    }
230
231    fn table_type(&self) -> datafusion::datasource::TableType {
232        datafusion::datasource::TableType::Base
233    }
234
235    fn supports_filters_pushdown(
236        &self,
237        filters: &[&Expr],
238    ) -> DFResult<Vec<TableProviderFilterPushDown>> {
239        // Inexact: we may prune files, and Parquet may prune row groups/pages,
240        // but DataFusion will still apply the filter for correctness.
241        Ok(vec![TableProviderFilterPushDown::Inexact; filters.len()])
242    }
243
244    #[tracing::instrument(
245        name = "table.scan.plan",
246        level = "debug",
247        skip_all,
248        fields(
249            snapshot_version = tracing::field::Empty,
250            total_candidate_segments = tracing::field::Empty,
251            selected_segments = tracing::field::Empty,
252            pruned_segments = tracing::field::Empty,
253            filter_count = filters.len(),
254            projection_column_count = tracing::field::Empty,
255            limit = tracing::field::Empty
256        )
257    )]
258    async fn scan(
259        &self,
260        state: &dyn Session,
261        projection: Option<&Vec<usize>>,
262        filters: &[Expr], // may include all WHERE predicates
263        limit: Option<usize>,
264    ) -> DFResult<Arc<dyn ExecutionPlan>> {
265        let span = tracing::Span::current();
266        if let Some(projection) = projection {
267            span.record("projection_column_count", projection.len());
268        }
269        if let Some(limit) = limit {
270            span.record("limit", limit);
271        }
272
273        // 1) Get a snapshot (TableState) from core table
274        let snapshot = self.latest_state().await?;
275        span.record("snapshot_version", snapshot.version);
276
277        for segment in snapshot.segments.values() {
278            segment
279                .validate_bounds(&self.table.index_spec().kind)
280                .map_err(df_external)?;
281        }
282
283        let segments = snapshot.segments_sorted_by_index().map_err(df_external)?;
284        let total_candidate_segments = segments.len();
285        span.record("total_candidate_segments", total_candidate_segments);
286
287        let df_schema = DFSchema::try_from(self.schema().as_ref().clone())?;
288        let exact_predicate = conjunction(filters.to_vec())
289            .map(|p| state.create_physical_expr(p, &df_schema))
290            .transpose()?
291            .unwrap_or_else(|| lit(true));
292
293        let metadata_filters = filters
294            .iter()
295            .map(|filter| metadata_pruning_expr(filter, self.table.index_spec(), &self.schema))
296            .collect::<DFResult<Vec<_>>>()?
297            .into_iter()
298            .flatten()
299            .collect::<Vec<_>>();
300
301        let pruning_filters = if matches!(self.table.index_spec().kind, IndexKind::Timestamp { .. })
302        {
303            let index_column = self.index_column_name();
304            let index_type = self.schema.field_with_name(index_column)?.data_type();
305            metadata_filters
306                .iter()
307                .cloned()
308                .map(|filter| {
309                    timestamp_pruning::normalize_timestamp_predicate(
310                        filter,
311                        index_column,
312                        index_type,
313                    )
314                })
315                .collect::<DFResult<Vec<_>>>()?
316        } else {
317            metadata_filters.clone()
318        };
319        let pruning_predicate = if pruning_filters.as_slice() == filters {
320            Arc::clone(&exact_predicate)
321        } else {
322            conjunction(pruning_filters)
323                .map(|p| state.create_physical_expr(p, &df_schema))
324                .transpose()?
325                .unwrap_or_else(|| lit(true))
326        };
327
328        // Build Parquet scan plan (DataSourceExec + ParquetSource)
329        let parquet_source = Arc::new(
330            ParquetSource::new(Arc::clone(&self.schema))
331                .with_predicate(Arc::clone(&exact_predicate)),
332        );
333
334        let mut builder = FileScanConfigBuilder::new(self.object_store_url.clone(), parquet_source)
335            .with_projection_indices(projection.cloned())?
336            .with_limit(limit);
337
338        let selected =
339            self.prune_segments_by_metadata(segments, &metadata_filters, &pruning_predicate)?;
340        span.record("selected_segments", selected.len());
341        span.record(
342            "pruned_segments",
343            total_candidate_segments.saturating_sub(selected.len()),
344        );
345        for seg in selected {
346            let file_size = self.segment_file_size(seg).await?;
347            let location = self
348                .table
349                .location()
350                .object_store_path(Path::new(&seg.path))
351                .map_err(df_external)?;
352            let pf = PartitionedFile::new(location.as_ref(), file_size);
353
354            builder = builder.with_file(pf);
355        }
356
357        // Produce the execution plan
358        let plan = DataSourceExec::from_data_source(builder.build());
359        Ok(plan)
360    }
361}