Skip to content

read_side_padding ignores the pad string for dictionary inputs in lpad and rpad #7453

Open

Description

@tlfua

Describe the bug

In read_side_padding.rs, the three-argument arm ([Array, Scalar(Int32), Scalar(Utf8)]) ignores the pad string when the input is a dictionary array. The Utf8 and LargeUtf8 branches pass string to spark_read_side_padding_internal, but both Dictionary branches pass SPACE. So rpad/lpad with a custom pad pads with spaces instead.

DataType::Dictionary(_, value_type) => {
let dict = as_dictionary_array::<Int32Type>(array);
let col = if value_type.as_ref() == &DataType::Utf8 {
spark_read_side_padding_internal::<i32>(
dict.values(),
truncate,
ColumnarValue::Scalar(ScalarValue::Int32(Some(*length))),
SPACE,
is_left_pad,
)?
} else {
spark_read_side_padding_internal::<i64>(
dict.values(),
truncate,
ColumnarValue::Scalar(ScalarValue::Int32(Some(*length))),
SPACE,
is_left_pad,
)?

Steps to reproduce

Kernel-level, on main at 644a870:

let dict: DictionaryArray<Int32Type> = vec!["ab", "ab"].into_iter().collect();
let args = vec![
    ColumnarValue::Array(Arc::new(dict)),
    ColumnarValue::Scalar(ScalarValue::Int32(Some(5))),
    ColumnarValue::Scalar(ScalarValue::Utf8(Some("x".to_string()))),
];
spark_rpad(&args) // ["ab   ", "ab   "]

I couldn't find a SQL query that reaches this today. The native scan input unpacks dictionary-encoded strings (see test_unpack_dictionary_string in planner.rs), and the existing string_rpad.sql tests with a custom pad pass. So this looks latent, but it would give silent wrong results if a dictionary array ever reaches the kernel, which is what the Dictionary arm (added for SPARK-48498) is there for.

Expected behavior

["abxxx", "abxxx"], the same as for a Utf8 input.

Additional context

Found while reviewing my change for #7245 (PR #7450). The fix is to pass string instead of SPACE in the two Dictionary branches and add a unit test with a dictionary input and a non-space pad.

Willingness to contribute

I can contribute a fix for this bug independently

AI disclosure: this issue was investigated and written with an AI coding assistant, and reviewed by me.

Activity

  1. added
    bugSomething isn't working
    priority:criticalData corruption, silent wrong results, security issues
    and removed on Nov 9, 2026
  2. added
    priority:highCrashes, panics, segfaults, major functional breakage
    and removed
    priority:criticalData corruption, silent wrong results, security issues
    on Nov 9, 2026
  3. kazuyukitanimura commented on Nov 9, 2026

    @kazuyukitanimura
    Contributor

    Hi @tlfua
    Thank you for reporting. Would you mind checking if this bug is reachable? It could be we are always converting to non-dictionary...

  4. tlfua commented on Nov 10, 2026

    @tlfua
    Author

    Thanks @kazuyukitanimura, you're right. I traced every path that feeds a native expression on main (8c145b7), and none of them can hand read_side_padding a dictionary array today:

    • JVM input (ScanExec, which covers JVM shuffle reads, broadcasts and local table scans): schema_from_data_types unpacks every dictionary type, and build_record_batch casts each column to that schema.
      /// The schema of an input leaf whose columns have `data_types`, without dictionary types.
      pub(crate) fn schema_from_data_types(data_types: &[DataType]) -> SchemaRef {
      let fields = data_types
      .iter()
      .enumerate()
      .map(|(idx, dt)| {
      let datatype = ScanExec::unpack_dictionary_type(dt);
      // We don't use the field name. Put a placeholder.
      Field::new(format!("col_{idx}"), datatype, true)
      })
      .collect::<Vec<Field>>();
      Arc::new(Schema::new(fields))
      }

      fn build_record_batch(
      &self,
      columns: &[ArrayRef],
      num_rows: usize,
      ) -> DataFusionResult<RecordBatch, DataFusionError> {
      let schema_fields = self.schema.fields();
      assert_eq!(columns.len(), schema_fields.len());
      // Cast dictionary-encoded primitive arrays to regular arrays and cast
      // Utf8/LargeUtf8/Binary arrays to dictionary-encoded if the schema is
      // defined as dictionary-encoded and the data in this batch is not
      // dictionary-encoded (could also be the other way around)
      let new_columns: Vec<ArrayRef> = columns
      .iter()
      .zip(schema_fields.iter())
      .enumerate()
      .map(|(idx, (column, f))| {
      if column.data_type() != f.data_type() {
      self.check_timestamp_labels(idx, f, column)?;
      let mut timer = self.cast_time.timer();
      let cast_array = cast_with_options(column, f.data_type(), &self.cast_options);
      timer.stop();
      cast_array.map_err(DataFusionError::from)
      } else {
      Ok(Arc::clone(column))
    • Native shuffle read unpacks dictionaries before handing batches on.
      // Extract column arrays, unpacking any dictionary-encoded columns.
      // Native shuffle may dictionary-encode string/binary columns for efficiency,
      // but downstream DataFusion operators expect the value types declared in the
      // schema (e.g. Utf8, not Dictionary<Int32, Utf8>).
      let columns: Vec<ArrayRef> = batch
      .columns()
      .iter()
      .map(|col| unpack_dictionary(col))
      .collect();
    • Native Parquet and Iceberg scans adapt to the required Spark schema, which unwraps dictionary columns even when the file's ARROW:schema declares them. This is covered by dictionary_columns_are_unwrapped_to_required_types.

      let adapter_factory = SparkPhysicalExprAdapterFactory::new(spark_options, None);
    • Target types come from to_arrow_datatype, which maps StringType to Utf8 and never to Dictionary.
      String => ArrowDataType::Utf8,
    • Other expressions: no non-test code in native/ builds a dictionary array from a plain one. The only place that creates one is read_side_padding itself, and only when its input is already a dictionary.

    The arm was reachable when it was written. #1482 added the Dictionary arm, and #2436 copied it into the three-argument case with SPACE. At that time ScanExec still kept string dictionaries. #2635 then changed ScanExec to unpack every dictionary type, which made the arm dead code.

    ),
    // Dictionary support required for SPARK-48498
    DataType::Dictionary(_, value_type) => {
    let dict = as_dictionary_array::<Int32Type>(array);
    let col = if value_type.as_ref() == &DataType::Utf8 {
    spark_read_side_padding_internal::<i32>(
    dict.values(),
    truncate,
    ColumnarValue::Scalar(ScalarValue::Int32(Some(*length))),
    SPACE,
    is_left_pad,
    )?
    } else {
    spark_read_side_padding_internal::<i64>(
    dict.values(),
    truncate,
    ColumnarValue::Scalar(ScalarValue::Int32(Some(*length))),
    SPACE,
    is_left_pad,
    )?
    };
    // col consists of an array, so arg of to_array() is not used. Can be anything
    let values = col.to_array(0)?;
    let result = DictionaryArray::try_new(dict.keys().clone(), values)?;
    Ok(ColumnarValue::Array(make_array(result.into())))

    So this is latent rather than a user-facing wrong result, and I don't think it should be priority:critical. I'll leave the exact priority to the maintainers. I'm happy to send a small PR that passes the pad string in both Dictionary branches and adds a kernel unit test, so the arm stays correct if a dictionary ever reaches it again. Would you prefer that, or should I close this as not reachable?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:expressionsExpression evaluationbugSomething isn't workingpriority:highCrashes, panics, segfaults, major functional breakage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions