Skip to main content

fv_streams_engine/
steps.rs

1//! INLINE STEPS ON THE BATCH: `select` / `rename` / `drop` / `filter` / `applyExpression` applied to
2//! an Arrow `RecordBatch`, with the value dialect compiled through `fv-value-datafusion` into
3//! DataFusion physical expressions (vectorised over the columns; a sub-expression the translator
4//! cannot reproduce exactly runs as a per-row UDF inside the same expression, so semantics never
5//! change — only speed).
6//!
7//! Compilation depends on the batch's schema (column types decide what is exact), so a chain
8//! compiles lazily on the first batch and again whenever the input schema changes. A step that
9//! cannot be compiled against the schema at all (a column of a type the dialect has no kind for)
10//! makes the whole chain fall back to the row path — today's behaviour, logged once.
11//!
12//! Errors are isolated per row exactly as before: a batch that fails to evaluate (the dialect's
13//! run-time errors, raised through the translator's error UDF) is re-applied one row at a time,
14//! the survivors kept and the poison rows counted.
15
16use std::sync::Arc;
17
18use arrow::array::{Array, ArrayRef, BooleanArray, RecordBatch};
19use arrow::compute::{concat_batches, filter_record_batch};
20use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
21use datafusion::common::DFSchema;
22use datafusion::execution::context::ExecutionProps;
23use datafusion::physical_expr::{create_physical_expr, PhysicalExpr};
24use fv_plan::inline::Step;
25use fv_value_datafusion::{data_type_for, Kind, Options, Translator};
26
27/// Why a chain could not be compiled for a schema (the caller falls back to rows).
28#[derive(Debug, Clone)]
29pub struct NotCompilable(pub String);
30
31/// Columns with this prefix are the engine's routing metadata (offset, key): a `select` keeps
32/// them, and they are stripped before the rows reach an operator or an output.
33pub use fv_streams_types::decode::META_PREFIX;
34
35enum Compiled {
36    /// Project to these input column indexes (`None` = a missing column, which lands null).
37    Select {
38        columns: Vec<(String, Option<usize>)>,
39    },
40    Rename {
41        mapping: Vec<(String, String)>,
42    },
43    Drop {
44        columns: Vec<String>,
45    },
46    Filter {
47        expr: Arc<dyn PhysicalExpr>,
48    },
49    Apply {
50        column: String,
51        expr: Arc<dyn PhysicalExpr>,
52        data_type: DataType,
53    },
54}
55
56/// A chain of inline steps compiled for one input schema.
57pub struct BatchSteps {
58    steps: Vec<Step>,
59    compiled: Option<(SchemaRef, Vec<Compiled>)>,
60    /// Rows dropped as poison (the per-row isolation path) since construction.
61    pub dropped: u64,
62    /// Whether every expression in the chain translated exactly (no per-row UDF fallback).
63    pub exact: bool,
64    /// The expressions that run per row are named once, at the first compile: a vectorised
65    /// chain costs a few ns per row per expression, a per-row fallback tens to hundreds — and a
66    /// regex compiled per call, thousands. Nobody should learn that from a benchmark.
67    reported: bool,
68}
69
70impl BatchSteps {
71    pub fn new(steps: Vec<Step>) -> Self {
72        BatchSteps {
73            steps,
74            compiled: None,
75            dropped: 0,
76            exact: true,
77            reported: false,
78        }
79    }
80
81    pub fn is_empty(&self) -> bool {
82        self.steps.is_empty()
83    }
84
85    /// Apply the chain to a batch. `Err(NotCompilable)` means this schema cannot be handled on the
86    /// batch path at all (fall back to rows); poison rows are isolated and counted, never an error.
87    pub fn apply(&mut self, batch: &RecordBatch) -> Result<RecordBatch, NotCompilable> {
88        if self.steps.is_empty() {
89            return Ok(batch.clone());
90        }
91        let schema = batch.schema();
92        let needs_compile = match &self.compiled {
93            Some((s, _)) => s.as_ref() != schema.as_ref(),
94            None => true,
95        };
96        if needs_compile {
97            let (plan, exact, per_row) = compile(&self.steps, &schema)?;
98            self.exact = exact;
99            if !per_row.is_empty() && !self.reported {
100                self.reported = true;
101                eprintln!(
102                    "steps: {} expression(s) run per row, not vectorised (the translator has no exact form for them): {}",
103                    per_row.len(),
104                    per_row.join("; ")
105                );
106            }
107            self.compiled = Some((Arc::clone(&schema), plan));
108        }
109        let plan = &self.compiled.as_ref().expect("compiled above").1;
110        match apply_plan(plan, batch) {
111            Ok(b) => Ok(b),
112            Err(_) => {
113                // per-row isolation: the batch has at least one poison row.
114                let mut good: Vec<RecordBatch> = Vec::new();
115                let mut out_schema: Option<SchemaRef> = None;
116                for i in 0..batch.num_rows() {
117                    match apply_plan(plan, &batch.slice(i, 1)) {
118                        Ok(b) => {
119                            out_schema.get_or_insert_with(|| b.schema());
120                            good.push(b);
121                        }
122                        Err(_) => self.dropped += 1,
123                    }
124                }
125                match out_schema {
126                    Some(s) => concat_batches(&s, &good).map_err(|e| NotCompilable(e.to_string())),
127                    // every row was poison: an empty batch of the plan's output shape.
128                    None => apply_plan(plan, &batch.slice(0, 0)).map_err(|e| NotCompilable(e.to_string())),
129                }
130            }
131        }
132    }
133}
134
135fn compile(steps: &[Step], input: &SchemaRef) -> Result<(Vec<Compiled>, bool, Vec<String>), NotCompilable> {
136    let mut schema = Arc::clone(input);
137    let mut plan = Vec::with_capacity(steps.len());
138    let mut exact = true;
139    let mut per_row: Vec<String> = Vec::new();
140    for step in steps {
141        let c = match step {
142            Step::Select { columns } => {
143                let cols: Vec<(String, Option<usize>)> =
144                    columns.iter().map(|c| (c.clone(), schema.index_of(c).ok())).collect();
145                let fields: Vec<Field> = cols
146                    .iter()
147                    .map(|(name, idx)| match idx {
148                        Some(i) => schema.field(*i).clone().with_name(name),
149                        None => Field::new(name, DataType::Null, true),
150                    })
151                    .collect();
152                schema = Arc::new(Schema::new(fields));
153                Compiled::Select { columns: cols }
154            }
155            Step::Rename { mapping } => {
156                let fields: Vec<Field> = schema
157                    .fields()
158                    .iter()
159                    .map(|f| {
160                        let name = mapping
161                            .iter()
162                            .find(|(from, _)| from == f.name())
163                            .map(|(_, to)| to.as_str())
164                            .unwrap_or(f.name());
165                        f.as_ref().clone().with_name(name)
166                    })
167                    .collect();
168                schema = Arc::new(Schema::new(fields));
169                Compiled::Rename {
170                    mapping: mapping.clone(),
171                }
172            }
173            Step::Drop { columns } => {
174                let fields: Vec<Field> = schema
175                    .fields()
176                    .iter()
177                    .filter(|f| !columns.contains(f.name()))
178                    .map(|f| f.as_ref().clone())
179                    .collect();
180                schema = Arc::new(Schema::new(fields));
181                Compiled::Drop {
182                    columns: columns.clone(),
183                }
184            }
185            Step::Filter { expression } => {
186                let ast = fv_value::compile(expression).map_err(|e| NotCompilable(format!("filter: {e}")))?;
187                let t = Translator::new(Arc::clone(&schema))
188                    .with_options(Options { assume_no_nan: true })
189                    .translate_predicate_or_fallback(&ast)
190                    .map_err(|e| NotCompilable(format!("filter '{expression}': {e}")))?;
191                exact &= t.exact;
192                if !t.exact {
193                    per_row.push(format!("filter `{expression}`"));
194                }
195                Compiled::Filter {
196                    expr: physical(&t.expr, &schema)?,
197                }
198            }
199            Step::ApplyExpression { column, expression } => {
200                let ast = fv_value::compile(expression).map_err(|e| NotCompilable(format!("applyExpression: {e}")))?;
201                let t = Translator::new(Arc::clone(&schema))
202                    .with_options(Options { assume_no_nan: true })
203                    .translate_or_fallback(&ast)
204                    .map_err(|e| NotCompilable(format!("applyExpression '{expression}': {e}")))?;
205                exact &= t.exact;
206                if !t.exact {
207                    per_row.push(format!("{column} = `{expression}`"));
208                }
209                let data_type = data_type_for(t.kind);
210                let expr = physical(&t.expr, &schema)?;
211                schema = Arc::new(schema_with(&schema, column, &data_type));
212                Compiled::Apply {
213                    column: column.clone(),
214                    expr,
215                    data_type,
216                }
217            }
218        };
219        plan.push(c);
220    }
221    Ok((plan, exact, per_row))
222}
223
224fn physical(expr: &datafusion::logical_expr::Expr, schema: &SchemaRef) -> Result<Arc<dyn PhysicalExpr>, NotCompilable> {
225    let df_schema = DFSchema::try_from(Arc::clone(schema)).map_err(|e| NotCompilable(e.to_string()))?;
226    create_physical_expr(expr, &df_schema, &ExecutionProps::new()).map_err(|e| NotCompilable(e.to_string()))
227}
228
229/// The schema after `column` is set to `data_type`: overwritten in place if present, else appended.
230fn schema_with(schema: &Schema, column: &str, data_type: &DataType) -> Schema {
231    let mut fields: Vec<Field> = schema.fields().iter().map(|f| f.as_ref().clone()).collect();
232    match fields.iter_mut().find(|f| f.name() == column) {
233        Some(f) => *f = Field::new(column, data_type.clone(), true),
234        None => fields.push(Field::new(column, data_type.clone(), true)),
235    }
236    Schema::new(fields)
237}
238
239fn apply_plan(plan: &[Compiled], batch: &RecordBatch) -> Result<RecordBatch, arrow::error::ArrowError> {
240    let mut b = batch.clone();
241    for step in plan {
242        b = match step {
243            Compiled::Select { columns } => {
244                let n = b.num_rows();
245                let (mut fields, mut arrays): (Vec<Field>, Vec<ArrayRef>) = columns
246                    .iter()
247                    .map(|(name, idx)| match idx {
248                        Some(i) => (b.schema().field(*i).clone().with_name(name), Arc::clone(b.column(*i))),
249                        None => (
250                            Field::new(name, DataType::Null, true),
251                            arrow::array::new_null_array(&DataType::Null, n),
252                        ),
253                    })
254                    .unzip();
255                // the engine's own routing columns ride along through a projection.
256                for (i, f) in b.schema().fields().iter().enumerate() {
257                    if f.name().starts_with(META_PREFIX) {
258                        fields.push(f.as_ref().clone());
259                        arrays.push(Arc::clone(b.column(i)));
260                    }
261                }
262                RecordBatch::try_new(Arc::new(Schema::new(fields)), arrays)?
263            }
264            Compiled::Rename { mapping } => {
265                let fields: Vec<Field> = b
266                    .schema()
267                    .fields()
268                    .iter()
269                    .map(|f| {
270                        let name = mapping
271                            .iter()
272                            .find(|(from, _)| from == f.name())
273                            .map(|(_, to)| to.as_str())
274                            .unwrap_or(f.name());
275                        f.as_ref().clone().with_name(name)
276                    })
277                    .collect();
278                RecordBatch::try_new(Arc::new(Schema::new(fields)), b.columns().to_vec())?
279            }
280            Compiled::Drop { columns } => {
281                let keep: Vec<usize> = b
282                    .schema()
283                    .fields()
284                    .iter()
285                    .enumerate()
286                    .filter(|(_, f)| !columns.contains(f.name()))
287                    .map(|(i, _)| i)
288                    .collect();
289                b.project(&keep)?
290            }
291            Compiled::Filter { expr } => {
292                let mask = expr
293                    .evaluate(&b)
294                    .and_then(|v| v.into_array(b.num_rows()))
295                    .map_err(|e| arrow::error::ArrowError::ComputeError(e.to_string()))?;
296                let mask = mask
297                    .as_any()
298                    .downcast_ref::<BooleanArray>()
299                    .ok_or_else(|| arrow::error::ArrowError::ComputeError("filter did not yield booleans".into()))?;
300                // a NULL predicate drops the row, which is the dialect's answer for a comparison with null.
301                filter_record_batch(&b, mask)?
302            }
303            Compiled::Apply {
304                column,
305                expr,
306                data_type,
307            } => {
308                let value = expr
309                    .evaluate(&b)
310                    .and_then(|v| v.into_array(b.num_rows()))
311                    .map_err(|e| arrow::error::ArrowError::ComputeError(e.to_string()))?;
312                let value = if value.data_type() == data_type {
313                    value
314                } else {
315                    arrow::compute::cast(&value, data_type)?
316                };
317                let schema = Arc::new(schema_with(&b.schema(), column, data_type));
318                let mut arrays: Vec<ArrayRef> = b.columns().to_vec();
319                match b.schema().index_of(column) {
320                    Ok(i) => arrays[i] = value,
321                    Err(_) => arrays.push(value),
322                }
323                RecordBatch::try_new(schema, arrays)?
324            }
325        };
326    }
327    Ok(b)
328}
329
330/// The Arrow type a dialect kind lands in; re-exported so callers can size declared columns.
331pub fn kind_type(kind: Kind) -> DataType {
332    data_type_for(kind)
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use arrow::array::{Float64Array, StringArray};
339
340    fn batch() -> RecordBatch {
341        let schema = Arc::new(Schema::new(vec![
342            Field::new("type", DataType::Utf8, true),
343            Field::new("price", DataType::Float64, true),
344            Field::new("auction", DataType::Float64, true),
345        ]));
346        let ty: StringArray = vec!["bid", "auction", "bid"].into();
347        let price: Float64Array = vec![Some(10.0), None, Some(0.0)].into();
348        let auction: Float64Array = vec![1.0, 2.0, 3.0].into();
349        RecordBatch::try_new(schema, vec![Arc::new(ty), Arc::new(price), Arc::new(auction)]).unwrap()
350    }
351
352    fn col_f64(b: &RecordBatch, name: &str) -> Vec<Option<f64>> {
353        let a = b.column_by_name(name).unwrap();
354        let a = a.as_any().downcast_ref::<Float64Array>().unwrap();
355        (0..a.len())
356            .map(|i| if a.is_null(i) { None } else { Some(a.value(i)) })
357            .collect()
358    }
359
360    #[test]
361    fn filter_keeps_only_true_rows_and_drops_null_comparisons() {
362        let mut s = BatchSteps::new(vec![Step::Filter {
363            expression: "price > 5".into(),
364        }]);
365        let out = s.apply(&batch()).unwrap();
366        assert_eq!(out.num_rows(), 1);
367        assert_eq!(col_f64(&out, "auction"), vec![Some(1.0)]);
368        assert!(s.exact, "a plain comparison translates exactly");
369    }
370
371    #[test]
372    fn apply_adds_or_overwrites_a_column_and_the_chain_sees_it() {
373        let mut s = BatchSteps::new(vec![
374            Step::ApplyExpression {
375                column: "eur".into(),
376                expression: "price * 2".into(),
377            },
378            Step::Filter {
379                expression: "eur >= 20".into(),
380            },
381            Step::ApplyExpression {
382                column: "auction".into(),
383                expression: "auction + 100".into(),
384            },
385        ]);
386        let out = s.apply(&batch()).unwrap();
387        assert_eq!(out.num_rows(), 1);
388        assert_eq!(col_f64(&out, "eur"), vec![Some(20.0)]);
389        assert_eq!(col_f64(&out, "auction"), vec![Some(101.0)]);
390        assert_eq!(out.schema().fields().len(), 4);
391    }
392
393    #[test]
394    fn select_rename_drop_shape_the_schema_in_order() {
395        let mut s = BatchSteps::new(vec![
396            Step::Rename {
397                mapping: vec![("auction".into(), "a".into())],
398            },
399            Step::Select {
400                columns: vec!["a".into(), "missing".into(), "price".into()],
401            },
402            Step::Drop {
403                columns: vec!["price".into()],
404            },
405        ]);
406        let out = s.apply(&batch()).unwrap();
407        let names: Vec<String> = out.schema().fields().iter().map(|f| f.name().clone()).collect();
408        assert_eq!(names, vec!["a", "missing"]);
409        assert_eq!(out.column(1).logical_null_count(), 3, "a missing column lands null");
410        assert_eq!(out.column(1).data_type(), &DataType::Null);
411        assert_eq!(col_f64(&out, "a"), vec![Some(1.0), Some(2.0), Some(3.0)]);
412    }
413
414    #[test]
415    fn select_keeps_the_engines_meta_columns() {
416        let b = batch();
417        let off: arrow::array::Int64Array = vec![7, 8, 9].into();
418        let with_meta = RecordBatch::try_new(
419            Arc::new(Schema::new(
420                b.schema()
421                    .fields()
422                    .iter()
423                    .map(|f| f.as_ref().clone())
424                    .chain([Field::new("__fv_offset", DataType::Int64, true)])
425                    .collect::<Vec<_>>(),
426            )),
427            b.columns().iter().cloned().chain([Arc::new(off) as ArrayRef]).collect(),
428        )
429        .unwrap();
430        let mut s = BatchSteps::new(vec![Step::Select {
431            columns: vec!["price".into()],
432        }]);
433        let out = s.apply(&with_meta).unwrap();
434        let names: Vec<String> = out.schema().fields().iter().map(|f| f.name().clone()).collect();
435        assert_eq!(names, vec!["price", "__fv_offset"]);
436    }
437
438    #[test]
439    fn a_poison_row_is_isolated_and_counted() {
440        // `/ 0` is a dialect run-time error, raised per row through the error UDF.
441        let mut s = BatchSteps::new(vec![Step::ApplyExpression {
442            column: "r".into(),
443            expression: "10 / price".into(),
444        }]);
445        let out = s.apply(&batch()).unwrap();
446        assert_eq!(out.num_rows(), 2, "the price=0 row is dropped");
447        assert_eq!(s.dropped, 1);
448        assert_eq!(col_f64(&out, "r"), vec![Some(1.0), None]);
449    }
450
451    #[test]
452    fn recompiles_when_the_schema_changes() {
453        let mut s = BatchSteps::new(vec![Step::Filter {
454            expression: "price > 5".into(),
455        }]);
456        assert_eq!(s.apply(&batch()).unwrap().num_rows(), 1);
457        let other = batch().project(&[1]).unwrap(); // just `price`
458        assert_eq!(s.apply(&other).unwrap().num_rows(), 1);
459    }
460
461    #[test]
462    fn a_column_the_dialect_has_no_kind_for_runs_through_the_row_fallback() {
463        let schema = Arc::new(Schema::new(vec![Field::new(
464            "s",
465            DataType::Struct(vec![Field::new("x", DataType::Int64, true)].into()),
466            true,
467        )]));
468        let inner = arrow::array::StructArray::from(vec![(
469            Arc::new(Field::new("x", DataType::Int64, true)),
470            Arc::new(arrow::array::Int64Array::from(vec![1])) as ArrayRef,
471        )]);
472        let b = RecordBatch::try_new(schema, vec![Arc::new(inner)]).unwrap();
473        let mut s = BatchSteps::new(vec![Step::Filter {
474            expression: "s == 1".into(),
475        }]);
476        // the translator cannot type a struct column, so the comparison becomes a per-row UDF
477        // (the dialect's own evaluator): the chain still runs, and reports itself inexact.
478        let out = s.apply(&b).unwrap();
479        assert_eq!(out.num_rows(), 0, "a struct never equals 1");
480        assert!(!s.exact);
481    }
482}