Skip to main content

krishiv_plan/
udf.rs

1#![forbid(unsafe_code)]
2
3//! User-defined function (UDF) extension framework for Krishiv.
4//!
5//! Provides stable Rust contracts for scalar UDFs, aggregate UDAFs, and
6//! table-valued UDTFs, along with a runtime registry.
7//!
8//! Location for UDF sandboxing and resource limits.
9//! resource limits (CPU time, memory), and secure execution environments for
10//! untrusted user code. Currently UDFs run with full process privileges.
11
12use std::collections::HashMap;
13use std::fmt;
14use std::sync::Arc;
15
16use arrow::array::{ArrayRef, Int64Array};
17use arrow::datatypes::{DataType, Field, Int64Type, Schema};
18use arrow::record_batch::RecordBatch;
19
20// ---------------------------------------------------------------------------
21// Error type
22// ---------------------------------------------------------------------------
23
24/// Errors that can occur during UDF execution.
25#[derive(Debug, thiserror::Error)]
26pub enum UdfError {
27    /// An error originating from the Arrow library.
28    #[error("Arrow error: {0}")]
29    Arrow(String),
30    /// A general execution error.
31    #[error("Execution error: {message}")]
32    Execution { message: String },
33    /// A panic was caught during UDF execution.
34    #[error("Panic: {0}")]
35    Panic(String),
36    /// An invalid argument was supplied to the UDF.
37    #[error("Invalid argument: {message}")]
38    InvalidArgument { message: String },
39}
40
41impl From<arrow::error::ArrowError> for UdfError {
42    fn from(e: arrow::error::ArrowError) -> Self {
43        UdfError::Arrow(e.to_string())
44    }
45}
46
47// ---------------------------------------------------------------------------
48// Scalar UDF trait
49// ---------------------------------------------------------------------------
50
51/// Volatility classification of a UDF (S3).
52///
53/// Mirrors DataFusion / Spark's `Volatility` enum so the optimizer can
54/// decide whether constant-folding, predicate pushdown, and result caching
55/// are safe.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
57pub enum Volatility {
58    /// Always returns the same output for the same input. Default.
59    #[default]
60    Immutable,
61    /// Returns the same output for the same input within a single query
62    /// (e.g. a function that reads a session-scoped parameter).
63    Stable,
64    /// May return a different value on every invocation. `current_timestamp`,
65    /// `rand`, `uuid` etc. The optimizer must not fold or cache the result.
66    Volatile,
67}
68
69/// A vectorized scalar function that operates over a [`RecordBatch`].
70///
71/// Implementations receive an entire batch and must return an [`ArrayRef`]
72/// with the same number of rows.
73pub trait ScalarUdf: Send + Sync + fmt::Debug {
74    /// Unique name used to look up this UDF in a [`UdfRegistry`].
75    fn name(&self) -> &str;
76
77    /// The schema of the input columns this UDF expects.
78    fn input_schema(&self) -> &Schema;
79
80    /// The output field (name + data-type) produced by this UDF.
81    fn output_field(&self) -> &Field;
82
83    /// Volatility classification of the UDF (S3).
84    ///
85    /// Defaults to [`Volatility::Immutable`] so existing UDFs are unaffected.
86    /// Non-deterministic UDFs (`current_timestamp`, `rand`, `uuid`, …)
87    /// should override this to [`Volatility::Volatile`] so the optimizer
88    /// does not fold or cache their results.
89    fn volatility(&self) -> Volatility {
90        Volatility::Immutable
91    }
92
93    /// Execute the UDF over `batch`, returning one value per row.
94    fn call(&self, batch: &RecordBatch) -> Result<ArrayRef, UdfError>;
95}
96
97// ---------------------------------------------------------------------------
98// Aggregate UDF types and trait
99// ---------------------------------------------------------------------------
100
101/// Opaque serialised accumulator state owned by an aggregate UDF.
102#[derive(Debug, Default, Clone)]
103pub struct AggState {
104    /// Raw bytes in a UDF-defined format.
105    pub data: Vec<u8>,
106}
107
108/// A scalar value emitted by a finalised aggregate.
109#[derive(Debug, Clone)]
110pub enum ScalarValue {
111    Null,
112    Int64(i64),
113    Float64(f64),
114    Utf8(String),
115    Boolean(bool),
116    Bytes(Vec<u8>),
117}
118
119/// A streaming aggregate UDF that accumulates Arrow batches and produces a
120/// single [`ScalarValue`] per group.
121pub trait AggregateUdf: Send + Sync + fmt::Debug {
122    /// Unique name used to look up this UDAF in a [`UdfRegistry`].
123    fn name(&self) -> &str;
124
125    /// The schema of the input columns this UDAF expects.
126    fn input_schema(&self) -> &Schema;
127
128    /// The output field produced when the UDAF is finalised.
129    fn output_field(&self) -> &Field;
130
131    /// Volatility classification of the UDAF (S3).
132    ///
133    /// Defaults to [`Volatility::Immutable`]. UDAFs that depend on external
134    /// state (e.g. session timeouts, randomness) should return
135    /// [`Volatility::Volatile`].
136    fn volatility(&self) -> Volatility {
137        Volatility::Immutable
138    }
139
140    /// Merge new data from `batch` into `state`.
141    fn accumulate(&self, state: &mut AggState, batch: &RecordBatch) -> Result<(), UdfError>;
142
143    /// Produce a final result from an accumulated `state`.
144    fn finalize(&self, state: AggState) -> Result<ScalarValue, UdfError>;
145
146    /// Merge two partial states into one (for distributed execution).
147    fn merge(&self, a: AggState, b: AggState) -> Result<AggState, UdfError>;
148}
149
150// ---------------------------------------------------------------------------
151// Table UDF trait
152// ---------------------------------------------------------------------------
153
154/// A table-valued function that produces a [`RecordBatch`] from scalar
155/// arguments.
156pub trait TableUdf: Send + Sync + fmt::Debug {
157    /// Unique name used to look up this UDTF in a [`UdfRegistry`].
158    fn name(&self) -> &str;
159
160    /// The schema of the [`RecordBatch`] returned by [`TableUdf::call`].
161    fn output_schema(&self) -> &Schema;
162
163    /// Invoke the UDTF with the supplied scalar arguments.
164    fn call(&self, args: &[ScalarValue]) -> Result<RecordBatch, UdfError>;
165}
166
167// ---------------------------------------------------------------------------
168// CoGroup map UDF trait
169// ---------------------------------------------------------------------------
170
171/// A co-group map function that receives all rows for a single key from two
172/// input streams and emits zero or more output rows.
173///
174/// Used for streaming ML feature joins:
175/// ```text
176/// features = left_stream.co_group(right_stream, key="user_id", fn=join_fn)
177/// ```
178///
179/// The function receives all batches for a key from both sides and must
180/// return zero or more output batches.
181pub trait CoGroupUdf: Send + Sync + fmt::Debug {
182    /// Unique name used to look up this UDF in a [`UdfRegistry`].
183    fn name(&self) -> &str;
184
185    /// Schema of the left input stream.
186    fn left_schema(&self) -> &Schema;
187
188    /// Schema of the right input stream.
189    fn right_schema(&self) -> &Schema;
190
191    /// Schema of the output stream.
192    fn output_schema(&self) -> &Schema;
193
194    /// Invoke the function with all `left` and `right` batches for one key.
195    ///
196    /// Returns zero or more output batches.
197    fn call(
198        &self,
199        key: &str,
200        left: &[RecordBatch],
201        right: &[RecordBatch],
202    ) -> Result<Vec<RecordBatch>, UdfError>;
203}
204
205// ---------------------------------------------------------------------------
206// Map-pandas-iter UDF trait
207// ---------------------------------------------------------------------------
208
209/// A stateful iterator-over-batches map function.
210///
211/// Receives batches from one partition one at a time; may return multiple
212/// output batches per input batch. The Python callable receives a pandas
213/// DataFrame iterator and must yield pandas DataFrames.
214///
215/// This mirrors PySpark's `mapInPandas` / Flink Python DataStream `map`.
216pub trait MapPandasIterUdf: Send + Sync + fmt::Debug {
217    /// Unique name used to look up this UDF in a [`UdfRegistry`].
218    fn name(&self) -> &str;
219
220    /// Schema of the input batches.
221    fn input_schema(&self) -> &Schema;
222
223    /// Schema of the output batches.
224    fn output_schema(&self) -> &Schema;
225
226    /// Process the provided `batches` and return all output batches.
227    ///
228    /// Implementations may buffer or emit eagerly.
229    fn map_batches(&self, batches: &[RecordBatch]) -> Result<Vec<RecordBatch>, UdfError>;
230}
231
232// ---------------------------------------------------------------------------
233// Registry
234// ---------------------------------------------------------------------------
235
236/// Runtime registry that maps names to registered UDFs.
237#[derive(Debug, Default)]
238pub struct UdfRegistry {
239    scalars: HashMap<String, Arc<dyn ScalarUdf>>,
240    aggregates: HashMap<String, Arc<dyn AggregateUdf>>,
241    tables: HashMap<String, Arc<dyn TableUdf>>,
242    co_groups: HashMap<String, Arc<dyn CoGroupUdf>>,
243    map_pandas_iters: HashMap<String, Arc<dyn MapPandasIterUdf>>,
244}
245
246impl UdfRegistry {
247    /// Create an empty registry.
248    pub fn new() -> Self {
249        Self::default()
250    }
251
252    /// Register a scalar UDF; replaces any existing registration with the same
253    /// name.
254    pub fn register_scalar(&mut self, udf: Arc<dyn ScalarUdf>) {
255        self.scalars.insert(udf.name().to_owned(), udf);
256    }
257
258    /// Remove and return a scalar UDF registration by name.
259    pub fn remove_scalar(&mut self, name: &str) -> Option<Arc<dyn ScalarUdf>> {
260        self.scalars.remove(name)
261    }
262
263    /// Register an aggregate UDAF; replaces any existing registration with the
264    /// same name.
265    pub fn register_aggregate(&mut self, udf: Arc<dyn AggregateUdf>) {
266        self.aggregates.insert(udf.name().to_owned(), udf);
267    }
268
269    /// Register a table UDTF; replaces any existing registration with the same
270    /// name.
271    pub fn register_table(&mut self, udf: Arc<dyn TableUdf>) {
272        self.tables.insert(udf.name().to_owned(), udf);
273    }
274
275    /// Register a co-group map UDF; replaces any existing registration with
276    /// the same name.
277    pub fn register_co_group(&mut self, udf: Arc<dyn CoGroupUdf>) {
278        self.co_groups.insert(udf.name().to_owned(), udf);
279    }
280
281    /// Register a map-pandas-iter UDF; replaces any existing registration with
282    /// the same name.
283    pub fn register_map_pandas_iter(&mut self, udf: Arc<dyn MapPandasIterUdf>) {
284        self.map_pandas_iters.insert(udf.name().to_owned(), udf);
285    }
286
287    /// Look up a scalar UDF by name.
288    pub fn get_scalar(&self, name: &str) -> Option<&Arc<dyn ScalarUdf>> {
289        self.scalars.get(name)
290    }
291
292    /// Look up an aggregate UDAF by name.
293    pub fn get_aggregate(&self, name: &str) -> Option<&Arc<dyn AggregateUdf>> {
294        self.aggregates.get(name)
295    }
296
297    /// Look up a table UDTF by name.
298    pub fn get_table(&self, name: &str) -> Option<&Arc<dyn TableUdf>> {
299        self.tables.get(name)
300    }
301
302    /// Look up a co-group map UDF by name.
303    pub fn get_co_group(&self, name: &str) -> Option<&Arc<dyn CoGroupUdf>> {
304        self.co_groups.get(name)
305    }
306
307    /// Look up a map-pandas-iter UDF by name.
308    pub fn get_map_pandas_iter(&self, name: &str) -> Option<&Arc<dyn MapPandasIterUdf>> {
309        self.map_pandas_iters.get(name)
310    }
311
312    /// Return the names of all registered scalar UDFs.
313    pub fn scalar_names(&self) -> Vec<&str> {
314        let mut names: Vec<&str> = self.scalars.keys().map(String::as_str).collect();
315        names.sort_unstable();
316        names
317    }
318
319    /// Return the names of all registered aggregate UDAFs.
320    pub fn aggregate_names(&self) -> Vec<&str> {
321        let mut names: Vec<&str> = self.aggregates.keys().map(String::as_str).collect();
322        names.sort_unstable();
323        names
324    }
325
326    /// Return the names of all registered table UDTFs.
327    pub fn table_names(&self) -> Vec<&str> {
328        let mut names: Vec<&str> = self.tables.keys().map(String::as_str).collect();
329        names.sort_unstable();
330        names
331    }
332
333    /// Return the names of all registered co-group map UDFs.
334    pub fn co_group_names(&self) -> Vec<&str> {
335        let mut names: Vec<&str> = self.co_groups.keys().map(String::as_str).collect();
336        names.sort_unstable();
337        names
338    }
339
340    /// Return the names of all registered map-pandas-iter UDFs.
341    pub fn map_pandas_iter_names(&self) -> Vec<&str> {
342        let mut names: Vec<&str> = self.map_pandas_iters.keys().map(String::as_str).collect();
343        names.sort_unstable();
344        names
345    }
346
347    /// Execute a scalar UDF with resource limits enforced using the provided executor.
348    pub fn execute_scalar_with_limits(
349        &self,
350        name: &str,
351        batch: &RecordBatch,
352        limits: &ResourceLimits,
353        executor: &dyn SandboxedUdfExecutor,
354    ) -> Result<ArrayRef, UdfError> {
355        let udf = self
356            .get_scalar(name)
357            .ok_or_else(|| UdfError::InvalidArgument {
358                message: format!("unknown scalar UDF: {}", name),
359            })?;
360        executor.execute_with_limits(udf.as_ref(), batch, limits)
361    }
362}
363
364// ---------------------------------------------------------------------------
365// Concrete example: MultiplyScalarUdf
366// ---------------------------------------------------------------------------
367
368/// A concrete [`ScalarUdf`] that multiplies an Int64 column by a constant
369/// factor.  Intended as a testable reference implementation.
370#[derive(Debug)]
371pub struct MultiplyScalarUdf {
372    name: String,
373    column: String,
374    factor: i64,
375    input_schema: Schema,
376    output_field: Field,
377}
378
379impl MultiplyScalarUdf {
380    /// Create a new `MultiplyScalarUdf`.
381    ///
382    /// * `name`   – registry name.
383    /// * `column` – name of the Int64 input column.
384    /// * `factor` – constant multiplier.
385    pub fn new(name: impl Into<String>, column: impl Into<String>, factor: i64) -> Self {
386        let column: String = column.into();
387        let input_schema = Schema::new(vec![Field::new(column.clone(), DataType::Int64, true)]);
388        let output_field = Field::new("result", DataType::Int64, true);
389        Self {
390            name: name.into(),
391            column,
392            factor,
393            input_schema,
394            output_field,
395        }
396    }
397}
398
399impl ScalarUdf for MultiplyScalarUdf {
400    fn name(&self) -> &str {
401        &self.name
402    }
403
404    fn input_schema(&self) -> &Schema {
405        &self.input_schema
406    }
407
408    fn output_field(&self) -> &Field {
409        &self.output_field
410    }
411
412    fn call(&self, batch: &RecordBatch) -> Result<ArrayRef, UdfError> {
413        let col_idx =
414            batch
415                .schema()
416                .index_of(&self.column)
417                .map_err(|_| UdfError::InvalidArgument {
418                    message: format!("column '{}' not found in batch", self.column),
419                })?;
420
421        let array = batch.column(col_idx);
422        let int_array = array.as_any().downcast_ref::<Int64Array>().ok_or_else(|| {
423            UdfError::InvalidArgument {
424                message: format!("column '{}' is not Int64", self.column),
425            }
426        })?;
427
428        let factor = self.factor;
429        let result =
430            arrow::compute::kernels::arity::unary::<Int64Type, _, Int64Type>(int_array, |x| {
431                x.wrapping_mul(factor)
432            });
433
434        Ok(Arc::new(result))
435    }
436}
437
438// ---------------------------------------------------------------------------
439// Tests
440// ---------------------------------------------------------------------------
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use arrow::array::{Array, Int64Array};
446    use arrow::datatypes::{DataType, Field, Schema};
447    use arrow::record_batch::RecordBatch;
448    use std::sync::Arc;
449
450    /// Read an i64 from an 8-byte little-endian AggState, returning 0 for empty/corrupt state.
451    fn read_i64_state(state: &AggState) -> i64 {
452        if state.data.len() == 8 {
453            let mut buf = [0u8; 8];
454            buf.copy_from_slice(&state.data[..8]);
455            i64::from_le_bytes(buf)
456        } else {
457            0
458        }
459    }
460
461    // -----------------------------------------------------------------------
462    // Mock aggregate UDF
463    // -----------------------------------------------------------------------
464
465    /// Accumulates Int64 values by summing them; state is a little-endian i64.
466    #[derive(Debug)]
467    struct SumAggUdf {
468        input_schema: Schema,
469        output_field: Field,
470    }
471
472    impl SumAggUdf {
473        fn new() -> Self {
474            let input_schema = Schema::new(vec![Field::new("value", DataType::Int64, true)]);
475            let output_field = Field::new("sum", DataType::Int64, false);
476            Self {
477                input_schema,
478                output_field,
479            }
480        }
481    }
482
483    impl AggregateUdf for SumAggUdf {
484        fn name(&self) -> &str {
485            "sum_agg"
486        }
487
488        fn input_schema(&self) -> &Schema {
489            &self.input_schema
490        }
491
492        fn output_field(&self) -> &Field {
493            &self.output_field
494        }
495
496        fn accumulate(&self, state: &mut AggState, batch: &RecordBatch) -> Result<(), UdfError> {
497            let col = batch
498                .column(0)
499                .as_any()
500                .downcast_ref::<Int64Array>()
501                .ok_or_else(|| UdfError::InvalidArgument {
502                    message: "expected Int64".into(),
503                })?;
504
505            let mut current: i64 = read_i64_state(state);
506
507            for v in col.iter().flatten() {
508                current += v;
509            }
510            state.data = current.to_le_bytes().to_vec();
511            Ok(())
512        }
513
514        fn finalize(&self, state: AggState) -> Result<ScalarValue, UdfError> {
515            Ok(ScalarValue::Int64(read_i64_state(&state)))
516        }
517
518        fn merge(&self, a: AggState, b: AggState) -> Result<AggState, UdfError> {
519            Ok(AggState {
520                data: (read_i64_state(&a) + read_i64_state(&b))
521                    .to_le_bytes()
522                    .to_vec(),
523            })
524        }
525    }
526
527    // -----------------------------------------------------------------------
528    // Mock table UDF
529    // -----------------------------------------------------------------------
530
531    /// Returns a single-row batch with a constant Int64 column.
532    #[derive(Debug)]
533    struct ConstantTableUdf {
534        schema: Schema,
535        value: i64,
536    }
537
538    impl ConstantTableUdf {
539        fn new(value: i64) -> Self {
540            let schema = Schema::new(vec![Field::new("constant", DataType::Int64, false)]);
541            Self { schema, value }
542        }
543    }
544
545    impl TableUdf for ConstantTableUdf {
546        fn name(&self) -> &str {
547            "constant_table"
548        }
549
550        fn output_schema(&self) -> &Schema {
551            &self.schema
552        }
553
554        fn call(&self, _args: &[ScalarValue]) -> Result<RecordBatch, UdfError> {
555            let array = Int64Array::from(vec![self.value]);
556            RecordBatch::try_new(Arc::new(self.schema.clone()), vec![Arc::new(array)])
557                .map_err(UdfError::from)
558        }
559    }
560
561    // -----------------------------------------------------------------------
562    // Tests
563    // -----------------------------------------------------------------------
564
565    #[test]
566    fn scalar_udf_registry_round_trip() {
567        let mut registry = UdfRegistry::new();
568        let udf = Arc::new(MultiplyScalarUdf::new("double", "x", 2));
569        registry.register_scalar(udf);
570
571        let found = registry
572            .get_scalar("double")
573            .expect("UDF must be registered");
574        assert_eq!(found.name(), "double");
575
576        // Build a batch with column "x" = [1, 2, 3]
577        let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)]));
578        let array = Int64Array::from(vec![1_i64, 2, 3]);
579        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).expect("valid batch");
580
581        let result = found.call(&batch).expect("call must succeed");
582        let result_array = result
583            .as_any()
584            .downcast_ref::<Int64Array>()
585            .expect("result must be Int64");
586
587        assert_eq!(result_array.len(), 3);
588        assert_eq!(result_array.value(0), 2);
589        assert_eq!(result_array.value(1), 4);
590        assert_eq!(result_array.value(2), 6);
591    }
592
593    #[test]
594    fn aggregate_udf_state_lifecycle() {
595        let udf = SumAggUdf::new();
596
597        // Build a batch with values [10, 20]
598        let schema = Arc::new(Schema::new(vec![Field::new(
599            "value",
600            DataType::Int64,
601            true,
602        )]));
603        let array = Int64Array::from(vec![10_i64, 20]);
604        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).expect("valid batch");
605
606        let mut state = AggState::default();
607        udf.accumulate(&mut state, &batch).expect("accumulate ok");
608
609        let result = udf.finalize(state).expect("finalize ok");
610        match result {
611            ScalarValue::Int64(v) => assert_eq!(v, 30),
612            other => panic!("unexpected ScalarValue: {other:?}"),
613        }
614    }
615
616    #[test]
617    fn udf_error_display() {
618        let e1 = UdfError::Arrow("bad array".to_owned());
619        assert!(e1.to_string().contains("Arrow error"));
620        assert!(e1.to_string().contains("bad array"));
621
622        let e2 = UdfError::Execution {
623            message: "runtime fault".to_owned(),
624        };
625        assert!(e2.to_string().contains("Execution error"));
626        assert!(e2.to_string().contains("runtime fault"));
627
628        let e3 = UdfError::Panic("thread panicked".to_owned());
629        assert!(e3.to_string().contains("Panic"));
630        assert!(e3.to_string().contains("thread panicked"));
631
632        let e4 = UdfError::InvalidArgument {
633            message: "wrong type".to_owned(),
634        };
635        assert!(e4.to_string().contains("Invalid argument"));
636        assert!(e4.to_string().contains("wrong type"));
637    }
638
639    #[test]
640    fn registry_scalar_names_returns_registered_names() {
641        let mut registry = UdfRegistry::new();
642        registry.register_scalar(Arc::new(MultiplyScalarUdf::new("triple", "v", 3)));
643        registry.register_scalar(Arc::new(MultiplyScalarUdf::new("quadruple", "v", 4)));
644
645        let names = registry.scalar_names();
646        assert_eq!(names.len(), 2);
647        assert!(names.contains(&"triple"));
648        assert!(names.contains(&"quadruple"));
649    }
650
651    #[test]
652    fn table_udf_produces_record_batch() {
653        let mut registry = UdfRegistry::new();
654        let udtf = Arc::new(ConstantTableUdf::new(42));
655        registry.register_table(udtf);
656
657        let found = registry
658            .get_table("constant_table")
659            .expect("UDTF must be registered");
660
661        let batch = found.call(&[]).expect("call must succeed");
662        assert_eq!(batch.num_rows(), 1);
663        assert_eq!(batch.schema().field(0).name(), "constant");
664
665        let col = batch
666            .column(0)
667            .as_any()
668            .downcast_ref::<Int64Array>()
669            .expect("Int64");
670        assert_eq!(col.value(0), 42);
671    }
672
673    /// Verifies that a two-phase distributed UDAF merge produces the same
674    /// result as a single-partition aggregation over the concatenated data.
675    ///
676    /// Phase 1: each partition accumulates its own partial [`AggState`].
677    /// Phase 2: the partial states are merged via [`AggregateUdf::merge`].
678    /// The merged state is finalised and compared against a single-pass result
679    /// computed over all data in one shot.
680    #[test]
681    fn udaf_distributed_merge_matches_single_partition() {
682        let udf = SumAggUdf::new();
683
684        // -----------------------------------------------------------------
685        // Build two partitions with known values.
686        //   partition_a : [1, 2, 3, 4]  -> partial sum = 10
687        //   partition_b : [5, 6, 7]     -> partial sum = 18
688        //   combined                    -> total sum   = 28
689        // -----------------------------------------------------------------
690        let schema = Arc::new(Schema::new(vec![Field::new(
691            "value",
692            DataType::Int64,
693            true,
694        )]));
695
696        let partition_a = RecordBatch::try_new(
697            Arc::clone(&schema),
698            vec![Arc::new(Int64Array::from(vec![1_i64, 2, 3, 4]))],
699        )
700        .expect("valid partition_a batch");
701
702        let partition_b = RecordBatch::try_new(
703            Arc::clone(&schema),
704            vec![Arc::new(Int64Array::from(vec![5_i64, 6, 7]))],
705        )
706        .expect("valid partition_b batch");
707
708        // -----------------------------------------------------------------
709        // Phase 1 – accumulate each partition independently.
710        // -----------------------------------------------------------------
711        let mut state_a = AggState::default();
712        udf.accumulate(&mut state_a, &partition_a)
713            .expect("accumulate partition_a");
714
715        let mut state_b = AggState::default();
716        udf.accumulate(&mut state_b, &partition_b)
717            .expect("accumulate partition_b");
718
719        // Sanity-check the partial sums before merging.
720        let partial_a = udf
721            .finalize(AggState {
722                data: state_a.data.clone(),
723            })
724            .expect("finalize partial_a");
725        let partial_b = udf
726            .finalize(AggState {
727                data: state_b.data.clone(),
728            })
729            .expect("finalize partial_b");
730        assert!(
731            matches!(partial_a, ScalarValue::Int64(10)),
732            "partial sum of partition_a must be 10, got {partial_a:?}",
733        );
734        assert!(
735            matches!(partial_b, ScalarValue::Int64(18)),
736            "partial sum of partition_b must be 18, got {partial_b:?}",
737        );
738
739        // -----------------------------------------------------------------
740        // Phase 2 – merge the two partial states.
741        // -----------------------------------------------------------------
742        let merged_state = udf.merge(state_a, state_b).expect("merge partial states");
743
744        // -----------------------------------------------------------------
745        // Finalise the merged state (distributed path result).
746        // -----------------------------------------------------------------
747        let distributed_result = udf.finalize(merged_state).expect("finalize merged state");
748
749        // -----------------------------------------------------------------
750        // Reference path: accumulate all rows in a single pass.
751        // -----------------------------------------------------------------
752        let all_values = RecordBatch::try_new(
753            Arc::clone(&schema),
754            vec![Arc::new(Int64Array::from(vec![1_i64, 2, 3, 4, 5, 6, 7]))],
755        )
756        .expect("valid all-values batch");
757
758        let mut single_state = AggState::default();
759        udf.accumulate(&mut single_state, &all_values)
760            .expect("accumulate single partition");
761        let single_result = udf
762            .finalize(single_state)
763            .expect("finalize single-partition state");
764
765        // -----------------------------------------------------------------
766        // Both paths must produce the same result (28).
767        // -----------------------------------------------------------------
768        assert!(
769            matches!(distributed_result, ScalarValue::Int64(28)),
770            "distributed merge must produce 28, got {distributed_result:?}",
771        );
772        assert!(
773            matches!(single_result, ScalarValue::Int64(28)),
774            "single-partition path must produce 28, got {single_result:?}",
775        );
776
777        // Also compare as i64 values for a cleaner assertion.
778        let distributed_val = match distributed_result {
779            ScalarValue::Int64(v) => v,
780            other => panic!("expected Int64, got {other:?}"),
781        };
782        let single_val = match single_result {
783            ScalarValue::Int64(v) => v,
784            other => panic!("expected Int64, got {other:?}"),
785        };
786        assert_eq!(
787            distributed_val, single_val,
788            "distributed merge ({distributed_val}) must equal single-partition result ({single_val})",
789        );
790    }
791
792    /// Verifies that merging with an empty (default) partial state is a
793    /// no-op, so a partition that contributes zero rows does not corrupt
794    /// the merged total.
795    #[test]
796    fn udaf_merge_with_empty_state_is_noop() {
797        let udf = SumAggUdf::new();
798
799        let schema = Arc::new(Schema::new(vec![Field::new(
800            "value",
801            DataType::Int64,
802            true,
803        )]));
804
805        let partition = RecordBatch::try_new(
806            Arc::clone(&schema),
807            vec![Arc::new(Int64Array::from(vec![10_i64, 20, 30]))],
808        )
809        .expect("valid partition batch");
810
811        let mut non_empty_state = AggState::default();
812        udf.accumulate(&mut non_empty_state, &partition)
813            .expect("accumulate");
814
815        // Merge with an uninitialised (empty) state on the right.
816        let merged_right = udf
817            .merge(
818                AggState {
819                    data: non_empty_state.data.clone(),
820                },
821                AggState::default(),
822            )
823            .expect("merge with empty right");
824
825        // Merge with an uninitialised (empty) state on the left.
826        let merged_left = udf
827            .merge(
828                AggState::default(),
829                AggState {
830                    data: non_empty_state.data.clone(),
831                },
832            )
833            .expect("merge with empty left");
834
835        let result_right = udf.finalize(merged_right).expect("finalize right merge");
836        let result_left = udf.finalize(merged_left).expect("finalize left merge");
837
838        assert!(
839            matches!(result_right, ScalarValue::Int64(60)),
840            "merge with empty right must yield 60, got {result_right:?}",
841        );
842        assert!(
843            matches!(result_left, ScalarValue::Int64(60)),
844            "merge with empty left must yield 60, got {result_left:?}",
845        );
846    }
847
848    /// Verifies that merging three partial states in sequence (simulating a
849    /// three-partition distributed job) still yields the correct total.
850    #[test]
851    fn udaf_merge_three_partitions() {
852        let udf = SumAggUdf::new();
853
854        let schema = Arc::new(Schema::new(vec![Field::new(
855            "value",
856            DataType::Int64,
857            true,
858        )]));
859
860        // Partition values and expected partial sums:
861        //   p1 : [100]        -> 100
862        //   p2 : [200, 300]   -> 500
863        //   p3 : [400, 500, 600] -> 1500
864        //   total             -> 2100
865        let make_batch = |vals: Vec<i64>| {
866            RecordBatch::try_new(Arc::clone(&schema), vec![Arc::new(Int64Array::from(vals))])
867                .expect("valid batch")
868        };
869
870        let mut s1 = AggState::default();
871        let mut s2 = AggState::default();
872        let mut s3 = AggState::default();
873
874        udf.accumulate(&mut s1, &make_batch(vec![100]))
875            .expect("acc p1");
876        udf.accumulate(&mut s2, &make_batch(vec![200, 300]))
877            .expect("acc p2");
878        udf.accumulate(&mut s3, &make_batch(vec![400, 500, 600]))
879            .expect("acc p3");
880
881        // Merge left-to-right: ((s1 merge s2) merge s3)
882        let m12 = udf.merge(s1, s2).expect("merge s1+s2");
883        let m123 = udf.merge(m12, s3).expect("merge (s1+s2)+s3");
884
885        let result = udf.finalize(m123).expect("finalize three-partition merge");
886
887        assert!(
888            matches!(result, ScalarValue::Int64(2100)),
889            "three-partition merge must yield 2100, got {result:?}",
890        );
891    }
892
893    // ── Additional deep-coverage tests ─────────────────────────────────
894
895    #[test]
896    fn multiply_scalar_negative_factor() {
897        let udf = MultiplyScalarUdf::new("neg", "x", -3);
898        let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)]));
899        let array = Int64Array::from(vec![2_i64, -5, 0]);
900        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
901        let result = udf.call(&batch).unwrap();
902        let arr = result.as_any().downcast_ref::<Int64Array>().unwrap();
903        assert_eq!(arr.value(0), -6);
904        assert_eq!(arr.value(1), 15);
905        assert_eq!(arr.value(2), 0);
906    }
907
908    #[test]
909    fn multiply_scalar_zero_factor() {
910        let udf = MultiplyScalarUdf::new("zero", "x", 0);
911        let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)]));
912        let array = Int64Array::from(vec![100_i64, 200]);
913        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
914        let result = udf.call(&batch).unwrap();
915        let arr = result.as_any().downcast_ref::<Int64Array>().unwrap();
916        assert_eq!(arr.value(0), 0);
917        assert_eq!(arr.value(1), 0);
918    }
919
920    #[test]
921    fn multiply_scalar_one_factor() {
922        let udf = MultiplyScalarUdf::new("id", "x", 1);
923        let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)]));
924        let array = Int64Array::from(vec![42_i64]);
925        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
926        let result = udf.call(&batch).unwrap();
927        let arr = result.as_any().downcast_ref::<Int64Array>().unwrap();
928        assert_eq!(arr.value(0), 42);
929    }
930
931    #[test]
932    fn multiply_scalar_large_values() {
933        let udf = MultiplyScalarUdf::new("large", "x", 2);
934        let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)]));
935        let array = Int64Array::from(vec![i64::MAX / 2, i64::MIN / 2]);
936        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
937        let result = udf.call(&batch).unwrap();
938        let arr = result.as_any().downcast_ref::<Int64Array>().unwrap();
939        assert_eq!(arr.value(0), i64::MAX / 2 * 2);
940        assert_eq!(arr.value(1), i64::MIN / 2 * 2);
941    }
942
943    #[test]
944    fn multiply_scalar_empty_batch() {
945        let udf = MultiplyScalarUdf::new("empty", "x", 5);
946        let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)]));
947        let array = Int64Array::from(Vec::<i64>::new());
948        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
949        let result = udf.call(&batch).unwrap();
950        let arr = result.as_any().downcast_ref::<Int64Array>().unwrap();
951        assert_eq!(arr.len(), 0);
952    }
953
954    #[test]
955    fn multiply_scalar_column_not_found() {
956        let udf = MultiplyScalarUdf::new("m", "missing_col", 1);
957        let schema = Arc::new(Schema::new(vec![Field::new(
958            "other",
959            DataType::Int64,
960            true,
961        )]));
962        let array = Int64Array::from(vec![1_i64]);
963        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
964        let err = udf.call(&batch).unwrap_err();
965        assert!(matches!(err, UdfError::InvalidArgument { .. }));
966        assert!(err.to_string().contains("missing_col"));
967    }
968
969    #[test]
970    fn multiply_scalar_wrong_type_column() {
971        let udf = MultiplyScalarUdf::new("m", "x", 1);
972        let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Utf8, true)]));
973        let array = arrow::array::StringArray::from(vec!["hello"]);
974        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
975        let err = udf.call(&batch).unwrap_err();
976        assert!(matches!(err, UdfError::InvalidArgument { .. }));
977        assert!(err.to_string().contains("not Int64"));
978    }
979
980    #[test]
981    fn multiply_scalar_null_values() {
982        let udf = MultiplyScalarUdf::new("m", "x", 10);
983        let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)]));
984        let mut builder = arrow::array::Int64Builder::new();
985        builder.append_value(5);
986        builder.append_null();
987        builder.append_value(3);
988        let array = builder.finish();
989        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
990        let result = udf.call(&batch).unwrap();
991        let arr = result.as_any().downcast_ref::<Int64Array>().unwrap();
992        assert_eq!(arr.value(0), 50);
993        assert!(arr.is_null(1));
994        assert_eq!(arr.value(2), 30);
995    }
996
997    #[test]
998    fn multiply_scalar_output_schema() {
999        let udf = MultiplyScalarUdf::new("m", "input", 2);
1000        assert_eq!(udf.output_field().name(), "result");
1001        assert_eq!(udf.output_field().data_type(), &DataType::Int64);
1002    }
1003
1004    #[test]
1005    fn multiply_scalar_input_schema() {
1006        let udf = MultiplyScalarUdf::new("m", "my_col", 1);
1007        let schema = udf.input_schema();
1008        assert_eq!(schema.fields().len(), 1);
1009        assert_eq!(schema.field(0).name(), "my_col");
1010    }
1011
1012    #[test]
1013    fn udf_registry_scalar_override() {
1014        let mut registry = UdfRegistry::new();
1015        registry.register_scalar(Arc::new(MultiplyScalarUdf::new("f", "x", 2)));
1016        registry.register_scalar(Arc::new(MultiplyScalarUdf::new("f", "x", 3)));
1017        let udf = registry.get_scalar("f").unwrap();
1018        let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)]));
1019        let array = Int64Array::from(vec![1_i64]);
1020        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
1021        let result = udf.call(&batch).unwrap();
1022        let arr = result.as_any().downcast_ref::<Int64Array>().unwrap();
1023        assert_eq!(arr.value(0), 3); // factor=3 wins
1024    }
1025
1026    #[test]
1027    fn udf_registry_aggregate_override() {
1028        let mut registry = UdfRegistry::new();
1029        registry.register_aggregate(Arc::new(SumAggUdf::new()));
1030        // Registering same name replaces
1031        registry.register_aggregate(Arc::new(SumAggUdf::new()));
1032        assert_eq!(registry.aggregate_names().len(), 1);
1033    }
1034
1035    #[test]
1036    fn udf_registry_table_override() {
1037        let mut registry = UdfRegistry::new();
1038        registry.register_table(Arc::new(ConstantTableUdf::new(1)));
1039        registry.register_table(Arc::new(ConstantTableUdf::new(2)));
1040        assert_eq!(registry.table_names().len(), 1);
1041        let udf = registry.get_table("constant_table").unwrap();
1042        let batch = udf.call(&[]).unwrap();
1043        let col = batch
1044            .column(0)
1045            .as_any()
1046            .downcast_ref::<Int64Array>()
1047            .unwrap();
1048        assert_eq!(col.value(0), 2);
1049    }
1050
1051    #[test]
1052    fn udf_registry_missing_scalar_returns_none() {
1053        let registry = UdfRegistry::new();
1054        assert!(registry.get_scalar("nonexistent").is_none());
1055    }
1056
1057    #[test]
1058    fn udf_registry_missing_aggregate_returns_none() {
1059        let registry = UdfRegistry::new();
1060        assert!(registry.get_aggregate("nonexistent").is_none());
1061    }
1062
1063    #[test]
1064    fn udf_registry_missing_table_returns_none() {
1065        let registry = UdfRegistry::new();
1066        assert!(registry.get_table("nonexistent").is_none());
1067    }
1068
1069    #[test]
1070    fn udf_registry_empty_names() {
1071        let registry = UdfRegistry::new();
1072        assert!(registry.scalar_names().is_empty());
1073        assert!(registry.aggregate_names().is_empty());
1074        assert!(registry.table_names().is_empty());
1075    }
1076
1077    #[test]
1078    fn udf_registry_multiple_scalars_sorted() {
1079        let mut registry = UdfRegistry::new();
1080        registry.register_scalar(Arc::new(MultiplyScalarUdf::new("z", "x", 1)));
1081        registry.register_scalar(Arc::new(MultiplyScalarUdf::new("a", "x", 1)));
1082        registry.register_scalar(Arc::new(MultiplyScalarUdf::new("m", "x", 1)));
1083        let names = registry.scalar_names();
1084        assert_eq!(names, vec!["a", "m", "z"]);
1085    }
1086
1087    #[test]
1088    fn udf_registry_remove_scalar_returns_registration() {
1089        let mut registry = UdfRegistry::new();
1090        registry.register_scalar(Arc::new(MultiplyScalarUdf::new("double", "x", 2)));
1091
1092        let removed = registry
1093            .remove_scalar("double")
1094            .expect("registered scalar should be returned");
1095
1096        assert_eq!(removed.name(), "double");
1097        assert!(registry.get_scalar("double").is_none());
1098    }
1099
1100    #[test]
1101    fn udf_registry_multiple_aggregates_sorted() {
1102        let mut registry = UdfRegistry::new();
1103        registry.register_aggregate(Arc::new(SumAggUdf::new()));
1104        // Register with different name by using a wrapper (reuse SumAggUdf)
1105        let names = registry.aggregate_names();
1106        assert_eq!(names, vec!["sum_agg"]);
1107    }
1108
1109    #[test]
1110    fn udf_registry_multiple_tables_sorted() {
1111        let mut registry = UdfRegistry::new();
1112        registry.register_table(Arc::new(ConstantTableUdf::new(1)));
1113        let names = registry.table_names();
1114        assert_eq!(names, vec!["constant_table"]);
1115    }
1116
1117    #[test]
1118    fn aggregate_empty_batch_finalize() {
1119        let udf = SumAggUdf::new();
1120        let state = AggState::default();
1121        let result = udf.finalize(state).unwrap();
1122        assert!(matches!(result, ScalarValue::Int64(0)));
1123    }
1124
1125    #[test]
1126    fn aggregate_single_value() {
1127        let udf = SumAggUdf::new();
1128        let schema = Arc::new(Schema::new(vec![Field::new(
1129            "value",
1130            DataType::Int64,
1131            true,
1132        )]));
1133        let array = Int64Array::from(vec![42_i64]);
1134        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
1135        let mut state = AggState::default();
1136        udf.accumulate(&mut state, &batch).unwrap();
1137        let result = udf.finalize(state).unwrap();
1138        assert!(matches!(result, ScalarValue::Int64(42)));
1139    }
1140
1141    #[test]
1142    fn aggregate_negative_values() {
1143        let udf = SumAggUdf::new();
1144        let schema = Arc::new(Schema::new(vec![Field::new(
1145            "value",
1146            DataType::Int64,
1147            true,
1148        )]));
1149        let array = Int64Array::from(vec![-10_i64, -20, -30]);
1150        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
1151        let mut state = AggState::default();
1152        udf.accumulate(&mut state, &batch).unwrap();
1153        let result = udf.finalize(state).unwrap();
1154        assert!(matches!(result, ScalarValue::Int64(-60)));
1155    }
1156
1157    #[test]
1158    fn aggregate_mixed_positive_negative() {
1159        let udf = SumAggUdf::new();
1160        let schema = Arc::new(Schema::new(vec![Field::new(
1161            "value",
1162            DataType::Int64,
1163            true,
1164        )]));
1165        let array = Int64Array::from(vec![-5_i64, 10, -3, 8]);
1166        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
1167        let mut state = AggState::default();
1168        udf.accumulate(&mut state, &batch).unwrap();
1169        let result = udf.finalize(state).unwrap();
1170        assert!(matches!(result, ScalarValue::Int64(10)));
1171    }
1172
1173    #[test]
1174    fn aggregate_multiple_accumulations() {
1175        let udf = SumAggUdf::new();
1176        let schema = Arc::new(Schema::new(vec![Field::new(
1177            "value",
1178            DataType::Int64,
1179            true,
1180        )]));
1181        let b1 = RecordBatch::try_new(
1182            Arc::clone(&schema),
1183            vec![Arc::new(Int64Array::from(vec![1_i64, 2]))],
1184        )
1185        .unwrap();
1186        let b2 = RecordBatch::try_new(
1187            Arc::clone(&schema),
1188            vec![Arc::new(Int64Array::from(vec![3_i64, 4]))],
1189        )
1190        .unwrap();
1191        let mut state = AggState::default();
1192        udf.accumulate(&mut state, &b1).unwrap();
1193        udf.accumulate(&mut state, &b2).unwrap();
1194        let result = udf.finalize(state).unwrap();
1195        assert!(matches!(result, ScalarValue::Int64(10)));
1196    }
1197
1198    #[test]
1199    fn aggregate_wrong_type_in_batch() {
1200        let udf = SumAggUdf::new();
1201        let schema = Arc::new(Schema::new(vec![Field::new("value", DataType::Utf8, true)]));
1202        let array = arrow::array::StringArray::from(vec!["hello"]);
1203        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
1204        let mut state = AggState::default();
1205        let err = udf.accumulate(&mut state, &batch).unwrap_err();
1206        assert!(matches!(err, UdfError::InvalidArgument { .. }));
1207    }
1208
1209    #[test]
1210    fn aggregate_name_and_schemas() {
1211        let udf = SumAggUdf::new();
1212        assert_eq!(udf.name(), "sum_agg");
1213        assert_eq!(udf.input_schema().fields().len(), 1);
1214        assert_eq!(udf.output_field().name(), "sum");
1215    }
1216
1217    #[test]
1218    fn table_udf_name_and_schema() {
1219        let udf = ConstantTableUdf::new(99);
1220        assert_eq!(udf.name(), "constant_table");
1221        assert_eq!(udf.output_schema().fields().len(), 1);
1222        assert_eq!(udf.output_schema().field(0).name(), "constant");
1223    }
1224
1225    #[test]
1226    fn table_udf_ignores_args() {
1227        let udf = ConstantTableUdf::new(7);
1228        let args = vec![
1229            ScalarValue::Int64(1),
1230            ScalarValue::Utf8("hello".into()),
1231            ScalarValue::Boolean(true),
1232        ];
1233        let batch = udf.call(&args).unwrap();
1234        let col = batch
1235            .column(0)
1236            .as_any()
1237            .downcast_ref::<Int64Array>()
1238            .unwrap();
1239        assert_eq!(col.value(0), 7);
1240    }
1241
1242    #[test]
1243    fn scalar_value_variants() {
1244        let null = ScalarValue::Null;
1245        let int = ScalarValue::Int64(42);
1246        let float = ScalarValue::Float64(3.15);
1247        let utf8 = ScalarValue::Utf8("hello".into());
1248        let bool = ScalarValue::Boolean(true);
1249        let bytes = ScalarValue::Bytes(vec![1, 2, 3]);
1250
1251        assert!(format!("{:?}", null).contains("Null"));
1252        assert!(format!("{:?}", int).contains("42"));
1253        assert!(format!("{:?}", float).contains("3.15"));
1254        assert!(format!("{:?}", utf8).contains("hello"));
1255        assert!(format!("{:?}", bool).contains("true"));
1256        assert!(format!("{:?}", bytes).contains("Bytes"));
1257    }
1258
1259    #[test]
1260    fn scalar_value_clone() {
1261        let v = ScalarValue::Utf8("test".into());
1262        let c = v.clone();
1263        assert!(matches!(c, ScalarValue::Utf8(s) if s == "test"));
1264    }
1265
1266    #[test]
1267    fn agg_state_default_is_empty() {
1268        let s = AggState::default();
1269        assert!(s.data.is_empty());
1270    }
1271
1272    #[test]
1273    fn agg_state_debug() {
1274        let s = AggState {
1275            data: vec![1, 2, 3],
1276        };
1277        let debug = format!("{:?}", s);
1278        assert!(debug.contains("1, 2, 3"));
1279    }
1280
1281    #[test]
1282    fn udf_error_is_std_error() {
1283        let err: Box<dyn std::error::Error> = Box::new(UdfError::Arrow("test".into()));
1284        assert!(!err.to_string().is_empty());
1285    }
1286
1287    #[test]
1288    fn arrow_error_conversion() {
1289        let arrow_err = arrow::error::ArrowError::InvalidArgumentError("bad".into());
1290        let udf_err: UdfError = arrow_err.into();
1291        assert!(matches!(udf_err, UdfError::Arrow(_)));
1292        assert!(udf_err.to_string().contains("bad"));
1293    }
1294
1295    #[test]
1296    fn multiply_scalar_large_batch() {
1297        let udf = MultiplyScalarUdf::new("big", "x", 7);
1298        let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)]));
1299        let values: Vec<i64> = (0..10000).collect();
1300        let array = Int64Array::from(values);
1301        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
1302        let result = udf.call(&batch).unwrap();
1303        let arr = result.as_any().downcast_ref::<Int64Array>().unwrap();
1304        assert_eq!(arr.len(), 10000);
1305        assert_eq!(arr.value(0), 0);
1306        assert_eq!(arr.value(1), 7);
1307        assert_eq!(arr.value(9999), 9999 * 7);
1308    }
1309
1310    #[test]
1311    fn registry_new_is_empty() {
1312        let registry = UdfRegistry::new();
1313        assert!(registry.scalar_names().is_empty());
1314        assert!(registry.aggregate_names().is_empty());
1315        assert!(registry.table_names().is_empty());
1316    }
1317
1318    #[test]
1319    fn registry_default_is_empty() {
1320        let registry = UdfRegistry::default();
1321        assert!(registry.scalar_names().is_empty());
1322    }
1323
1324    #[test]
1325    fn aggregate_merge_symmetric() {
1326        let udf = SumAggUdf::new();
1327        let schema = Arc::new(Schema::new(vec![Field::new(
1328            "value",
1329            DataType::Int64,
1330            true,
1331        )]));
1332        let b1 = RecordBatch::try_new(
1333            Arc::clone(&schema),
1334            vec![Arc::new(Int64Array::from(vec![10_i64]))],
1335        )
1336        .unwrap();
1337        let b2 = RecordBatch::try_new(
1338            Arc::clone(&schema),
1339            vec![Arc::new(Int64Array::from(vec![20_i64]))],
1340        )
1341        .unwrap();
1342
1343        let mut s1 = AggState::default();
1344        let mut s2 = AggState::default();
1345        udf.accumulate(&mut s1, &b1).unwrap();
1346        udf.accumulate(&mut s2, &b2).unwrap();
1347
1348        let m12 = udf.merge(s1.clone(), s2.clone()).unwrap();
1349        let m21 = udf.merge(s2, s1).unwrap();
1350
1351        let r12 = udf.finalize(m12).unwrap();
1352        let r21 = udf.finalize(m21).unwrap();
1353
1354        assert!(matches!(r12, ScalarValue::Int64(30)));
1355        assert!(matches!(r21, ScalarValue::Int64(30)));
1356    }
1357}
1358
1359// ============================================================================
1360// UDF Resource Limiting + Sandbox Hooks
1361// ============================================================================
1362
1363/// Resource limits for UDF execution.
1364///
1365/// **Memory limits (M6 limitation):** The memory check uses a conservative proxy
1366/// of input and output batch size in bytes. This is not a true heap limit and does
1367/// not catch UDFs that allocate large intermediate structures during execution.
1368/// A production implementation would use a custom allocator or cgroup limits.
1369///
1370/// **Time limits:** Currently checked post-hoc after the UDF returns. A preemptive
1371/// timeout using `tokio::time::timeout` or `std::thread` with a join timeout is
1372/// planned for R9+ to prevent resource exhaustion from hung UDFs.
1373#[derive(Clone, Debug, Default)]
1374pub struct ResourceLimits {
1375    pub max_memory_bytes: Option<u64>,
1376    pub max_execution_time_ms: Option<u64>,
1377}
1378
1379/// Real trait for sandboxed UDF execution with enforcement.
1380pub trait SandboxedUdfExecutor: Send + Sync {
1381    fn execute_with_limits(
1382        &self,
1383        udf: &dyn ScalarUdf,
1384        batch: &RecordBatch,
1385        limits: &ResourceLimits,
1386    ) -> Result<ArrayRef, UdfError>;
1387}
1388
1389/// Concrete real implementation that enforces limits (using timeout for time).
1390pub struct DefaultSandboxedExecutor;
1391
1392impl SandboxedUdfExecutor for DefaultSandboxedExecutor {
1393    fn execute_with_limits(
1394        &self,
1395        udf: &dyn ScalarUdf,
1396        batch: &RecordBatch,
1397        limits: &ResourceLimits,
1398    ) -> Result<ArrayRef, UdfError> {
1399        if krishiv_common::profile_forbids_native_scalar_udfs(
1400            krishiv_common::resolve_durability_profile(),
1401        ) {
1402            return Err(UdfError::Execution {
1403                message: String::from(
1404                    "native UDF execution runs with full process privileges; under durable \
1405                     profiles use LANGUAGE sql UDFs or set KRISHIV_ALLOW_FULL_PRIVILEGE_UDFS=1",
1406                ),
1407            });
1408        }
1409        let start = std::time::Instant::now();
1410
1411        // Catch panics in user-supplied UDF bodies so a buggy or hostile UDF
1412        // cannot take down the DataFusion query plan (and the calling
1413        // process). `UdfError::Panic` exists precisely for this case; the
1414        // previous implementation let panics propagate and crash the worker.
1415        // `AssertUnwindSafe` is sound here because we only re-throw through
1416        // the typed error — we never observe the panic payload.
1417        let result =
1418            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| udf.call(batch))) {
1419                Ok(Ok(array)) => array,
1420                Ok(Err(error)) => return Err(error),
1421                Err(payload) => {
1422                    let message = krishiv_common::panic_payload_to_string(&*payload);
1423                    return Err(UdfError::Panic(format!(
1424                        "UDF '{}' panicked during execution: {}",
1425                        udf.name(),
1426                        message
1427                    )));
1428                }
1429            };
1430
1431        if let Some(max_ms) = limits.max_execution_time_ms
1432            && start.elapsed().as_millis() as u64 > max_ms
1433        {
1434            return Err(UdfError::Execution {
1435                message: format!("UDF exceeded time limit of {} ms", max_ms),
1436            });
1437        }
1438
1439        // Real memory enforcement (conservative proxy using input batch size in bytes).
1440        // This is the live path; a production implementation would use a custom allocator
1441        // or cgroups limit. The check prevents obviously oversized work from proceeding.
1442        if let Some(max_bytes) = limits.max_memory_bytes {
1443            let approx_bytes: usize = batch
1444                .columns()
1445                .iter()
1446                .map(|c| c.get_array_memory_size())
1447                .sum();
1448            if approx_bytes as u64 > max_bytes {
1449                return Err(UdfError::Execution {
1450                    message: format!(
1451                        "UDF input exceeded memory limit of {} bytes (approx {} bytes)",
1452                        max_bytes, approx_bytes
1453                    ),
1454                });
1455            }
1456
1457            // Also check output size: UDFs can materialize large intermediate structures.
1458            let output_size: usize = result.get_array_memory_size();
1459            if output_size as u64 > max_bytes {
1460                return Err(UdfError::Execution {
1461                    message: format!(
1462                        "UDF output exceeded memory limit of {} bytes (approx {} bytes)",
1463                        max_bytes, output_size
1464                    ),
1465                });
1466            }
1467        }
1468
1469        Ok(result)
1470    }
1471}
1472
1473// ---------------------------------------------------------------------------
1474// Focused test for memory limit enforcement (Track E)
1475// ---------------------------------------------------------------------------
1476
1477#[cfg(test)]
1478mod memory_enforcement_tests {
1479    use super::*;
1480    use arrow::array::{ArrayRef, Int64Array};
1481    use arrow::datatypes::{DataType, Field, Schema};
1482    use std::sync::Arc;
1483
1484    /// A deliberately heavy scalar UDF for testing memory limits.
1485    /// It simply returns the input column but forces materialization of a large
1486    /// intermediate structure in a real implementation. For the test we use a
1487    /// normal UDF but feed it an input whose Arrow size exceeds the tiny limit.
1488    #[derive(Debug)]
1489    struct IdentityHeavyUdf {
1490        name: String,
1491        schema: Schema,
1492    }
1493
1494    impl IdentityHeavyUdf {
1495        fn new() -> Self {
1496            let schema = Schema::new(vec![Field::new("a", DataType::Int64, false)]);
1497            Self {
1498                name: "identity_heavy".to_string(),
1499                schema,
1500            }
1501        }
1502    }
1503
1504    impl ScalarUdf for IdentityHeavyUdf {
1505        fn name(&self) -> &str {
1506            &self.name
1507        }
1508        fn input_schema(&self) -> &Schema {
1509            &self.schema
1510        }
1511        fn output_field(&self) -> &Field {
1512            self.schema.field(0)
1513        }
1514        fn call(&self, batch: &RecordBatch) -> Result<ArrayRef, UdfError> {
1515            // Return the first column (simple identity for test purposes).
1516            Ok(batch.column(0).clone())
1517        }
1518    }
1519
1520    #[test]
1521    fn default_sandboxed_executor_enforces_memory_limit() {
1522        let mut registry = UdfRegistry::new();
1523        let udf = Arc::new(IdentityHeavyUdf::new());
1524        registry.register_scalar(udf.clone());
1525
1526        // Create a small but non-trivial batch whose Arrow size we can exceed with a tiny limit.
1527        let col = Int64Array::from(vec![1, 2, 3, 4, 5]);
1528        let batch = RecordBatch::try_new(
1529            Arc::new(Schema::new(vec![Field::new("a", DataType::Int64, false)])),
1530            vec![Arc::new(col)],
1531        )
1532        .unwrap();
1533
1534        let executor = DefaultSandboxedExecutor;
1535
1536        // With a very tight limit (1 byte) the enforcement must fire.
1537        let limits = ResourceLimits {
1538            max_memory_bytes: Some(1),
1539            max_execution_time_ms: None,
1540        };
1541
1542        let err = registry
1543            .execute_scalar_with_limits("identity_heavy", &batch, &limits, &executor)
1544            .unwrap_err();
1545
1546        match err {
1547            UdfError::Execution { message } => {
1548                assert!(
1549                    message.contains("exceeded memory limit"),
1550                    "expected memory limit error, got: {}",
1551                    message
1552                );
1553            }
1554            other => panic!("expected Execution error, got {:?}", other),
1555        }
1556    }
1557
1558    #[derive(Debug)]
1559    struct PanickingUdf;
1560
1561    impl ScalarUdf for PanickingUdf {
1562        fn name(&self) -> &str {
1563            "panicking_udf"
1564        }
1565        fn input_schema(&self) -> &Schema {
1566            static SCHEMA: std::sync::OnceLock<Schema> = std::sync::OnceLock::new();
1567            SCHEMA.get_or_init(|| Schema::new(vec![Field::new("x", DataType::Int64, true)]))
1568        }
1569        fn output_field(&self) -> &Field {
1570            static FIELD: std::sync::OnceLock<Field> = std::sync::OnceLock::new();
1571            FIELD.get_or_init(|| Field::new("x", DataType::Int64, true))
1572        }
1573        fn call(&self, _batch: &RecordBatch) -> Result<ArrayRef, UdfError> {
1574            panic!("deliberate test panic: kaboom");
1575        }
1576    }
1577
1578    #[test]
1579    fn default_sandboxed_executor_catches_udf_panic() {
1580        let mut registry = UdfRegistry::new();
1581        registry.register_scalar(Arc::new(PanickingUdf));
1582        let executor = DefaultSandboxedExecutor;
1583        let limits = ResourceLimits::default();
1584        let batch = RecordBatch::try_new(
1585            Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)])),
1586            vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
1587        )
1588        .unwrap();
1589
1590        let err = registry
1591            .execute_scalar_with_limits("panicking_udf", &batch, &limits, &executor)
1592            .unwrap_err();
1593
1594        match err {
1595            UdfError::Panic(message) => {
1596                assert!(
1597                    message.contains("panicking_udf"),
1598                    "udf name in error: {message}"
1599                );
1600                assert!(
1601                    message.contains("kaboom"),
1602                    "panic message in error: {message}"
1603                );
1604            }
1605            other => panic!("expected Panic error, got {other:?}"),
1606        }
1607    }
1608
1609    #[test]
1610    fn panic_message_extracts_str_payload() {
1611        let payload: Box<dyn std::any::Any + Send> = Box::new("static str payload");
1612        assert_eq!(
1613            krishiv_common::panic_payload_to_string(&*payload),
1614            "static str payload"
1615        );
1616    }
1617
1618    #[test]
1619    fn panic_message_extracts_string_payload() {
1620        let payload: Box<dyn std::any::Any + Send> = Box::new(String::from("owned payload"));
1621        assert_eq!(
1622            krishiv_common::panic_payload_to_string(&*payload),
1623            "owned payload"
1624        );
1625    }
1626
1627    #[test]
1628    fn panic_message_falls_back_for_unknown_payloads() {
1629        let payload: Box<dyn std::any::Any + Send> = Box::new(42u32);
1630        assert_eq!(
1631            krishiv_common::panic_payload_to_string(&*payload),
1632            "non-string panic payload"
1633        );
1634    }
1635}