1#![forbid(unsafe_code)]
2
3use 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#[derive(Debug, thiserror::Error)]
26pub enum UdfError {
27 #[error("Arrow error: {0}")]
29 Arrow(String),
30 #[error("Execution error: {message}")]
32 Execution { message: String },
33 #[error("Panic: {0}")]
35 Panic(String),
36 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
57pub enum Volatility {
58 #[default]
60 Immutable,
61 Stable,
64 Volatile,
67}
68
69pub trait ScalarUdf: Send + Sync + fmt::Debug {
74 fn name(&self) -> &str;
76
77 fn input_schema(&self) -> &Schema;
79
80 fn output_field(&self) -> &Field;
82
83 fn volatility(&self) -> Volatility {
90 Volatility::Immutable
91 }
92
93 fn call(&self, batch: &RecordBatch) -> Result<ArrayRef, UdfError>;
95}
96
97#[derive(Debug, Default, Clone)]
103pub struct AggState {
104 pub data: Vec<u8>,
106}
107
108#[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
119pub trait AggregateUdf: Send + Sync + fmt::Debug {
122 fn name(&self) -> &str;
124
125 fn input_schema(&self) -> &Schema;
127
128 fn output_field(&self) -> &Field;
130
131 fn volatility(&self) -> Volatility {
137 Volatility::Immutable
138 }
139
140 fn accumulate(&self, state: &mut AggState, batch: &RecordBatch) -> Result<(), UdfError>;
142
143 fn finalize(&self, state: AggState) -> Result<ScalarValue, UdfError>;
145
146 fn merge(&self, a: AggState, b: AggState) -> Result<AggState, UdfError>;
148}
149
150pub trait TableUdf: Send + Sync + fmt::Debug {
157 fn name(&self) -> &str;
159
160 fn output_schema(&self) -> &Schema;
162
163 fn call(&self, args: &[ScalarValue]) -> Result<RecordBatch, UdfError>;
165}
166
167pub trait CoGroupUdf: Send + Sync + fmt::Debug {
182 fn name(&self) -> &str;
184
185 fn left_schema(&self) -> &Schema;
187
188 fn right_schema(&self) -> &Schema;
190
191 fn output_schema(&self) -> &Schema;
193
194 fn call(
198 &self,
199 key: &str,
200 left: &[RecordBatch],
201 right: &[RecordBatch],
202 ) -> Result<Vec<RecordBatch>, UdfError>;
203}
204
205pub trait MapPandasIterUdf: Send + Sync + fmt::Debug {
217 fn name(&self) -> &str;
219
220 fn input_schema(&self) -> &Schema;
222
223 fn output_schema(&self) -> &Schema;
225
226 fn map_batches(&self, batches: &[RecordBatch]) -> Result<Vec<RecordBatch>, UdfError>;
230}
231
232#[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 pub fn new() -> Self {
249 Self::default()
250 }
251
252 pub fn register_scalar(&mut self, udf: Arc<dyn ScalarUdf>) {
255 self.scalars.insert(udf.name().to_owned(), udf);
256 }
257
258 pub fn remove_scalar(&mut self, name: &str) -> Option<Arc<dyn ScalarUdf>> {
260 self.scalars.remove(name)
261 }
262
263 pub fn register_aggregate(&mut self, udf: Arc<dyn AggregateUdf>) {
266 self.aggregates.insert(udf.name().to_owned(), udf);
267 }
268
269 pub fn register_table(&mut self, udf: Arc<dyn TableUdf>) {
272 self.tables.insert(udf.name().to_owned(), udf);
273 }
274
275 pub fn register_co_group(&mut self, udf: Arc<dyn CoGroupUdf>) {
278 self.co_groups.insert(udf.name().to_owned(), udf);
279 }
280
281 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 pub fn get_scalar(&self, name: &str) -> Option<&Arc<dyn ScalarUdf>> {
289 self.scalars.get(name)
290 }
291
292 pub fn get_aggregate(&self, name: &str) -> Option<&Arc<dyn AggregateUdf>> {
294 self.aggregates.get(name)
295 }
296
297 pub fn get_table(&self, name: &str) -> Option<&Arc<dyn TableUdf>> {
299 self.tables.get(name)
300 }
301
302 pub fn get_co_group(&self, name: &str) -> Option<&Arc<dyn CoGroupUdf>> {
304 self.co_groups.get(name)
305 }
306
307 pub fn get_map_pandas_iter(&self, name: &str) -> Option<&Arc<dyn MapPandasIterUdf>> {
309 self.map_pandas_iters.get(name)
310 }
311
312 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 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 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 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 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 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#[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 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#[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 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 #[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 #[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 #[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 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 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 #[test]
681 fn udaf_distributed_merge_matches_single_partition() {
682 let udf = SumAggUdf::new();
683
684 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 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 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 let merged_state = udf.merge(state_a, state_b).expect("merge partial states");
743
744 let distributed_result = udf.finalize(merged_state).expect("finalize merged state");
748
749 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 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 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 #[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 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 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 #[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 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 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 #[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); }
1025
1026 #[test]
1027 fn udf_registry_aggregate_override() {
1028 let mut registry = UdfRegistry::new();
1029 registry.register_aggregate(Arc::new(SumAggUdf::new()));
1030 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 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#[derive(Clone, Debug, Default)]
1374pub struct ResourceLimits {
1375 pub max_memory_bytes: Option<u64>,
1376 pub max_execution_time_ms: Option<u64>,
1377}
1378
1379pub 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
1389pub 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 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 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 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#[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 #[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 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 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 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}