Background
Spark's StringType can hold arbitrary bytes, including sequences that are not valid UTF-8 (Spark stores them verbatim via UTF8String). Arrow's Utf8/LargeUtf8 types require valid UTF-8, and arrow-rs StringArray::value hands out a &str via an unchecked decode that trusts the array's UTF-8 invariant. As a result, any native string kernel that reads an Arrow string array containing invalid UTF-8 (via value()) materializes an invalid &str, which is undefined behaviour (iterating chars, slicing on char boundaries, etc. can misbehave, panic, or be miscompiled).
Comet currently handles invalid-UTF-8 strings inconsistently across sites. This EPIC tracks giving Comet a single, coherent policy.
Current state
String-producing sites (resolved -- decode JVM-compatibly to valid UTF-8):
| Site |
Behavior |
Native shuffle get_string |
Decodes via decode_utf8_spark_lossy (matches the JVM's new String(bytes, UTF_8) byte-for-byte, including surrogate-range cases). #4521 |
CAST(binary AS string) |
Same decode_utf8_spark_lossy (shared with shuffle). #4488 / PR #4763 |
Both produce valid UTF-8 that matches Spark's rendered output, so neither feeds the downstream-kernel UB.
String-ingress sites (the gaps):
| Site |
Behavior |
Problem |
| Native (Rust) Parquet reader |
Rejects invalid UTF-8 (encountered non UTF-8 data) |
Comet errors where Spark succeeds (#4121) |
JVM -> native Arrow FFI import (jni_api.rs from_ffi) |
Passes bytes through unchecked |
Latent UB: invalid UTF-8 enters the native pipeline and downstream value() is unsound |
The two ingress paths are simultaneously too strict (reject) and too loose (unchecked) for the same input.
Gap A -- native scan rejects invalid UTF-8 (#4121)
The arrow-rs Parquet reader validates UTF-8 and errors on non-UTF-8 STRING columns. Spark reads them fine. Surfaces in Spark 4.1.1's hll.sql; currently worked around by disabling Comet for that file.
Gap B -- FFI import does not validate (confirmed)
arrow::ffi::from_ffi builds the imported array with ArrayData::new_unchecked and does not validate UTF-8 (arrow-array src/ffi.rs even carries a // Should FFI be checking validity? comment). Comet adds no validation on the import side (prepare_output's validate_full() runs only on the output/export path under a debug flag). So invalid-UTF-8 string bytes arriving from a JVM-side source (a JVM-side scan, or any Spark -> Comet columnar handoff) enter the native pipeline as an Arrow Utf8 array that lies about its validity.
Severity: a latent default-config UB hazard. It requires (1) genuinely non-UTF-8 string bytes, (2) flowing through the unchecked FFI path rather than the validating native reader, and (3) consumption by a native string kernel that reads &str. That conjunction is uncommon, which is why it has not surfaced as crashes -- but it is unsound by default.
Proposed direction
Establish a single ingress policy: native string data must be valid UTF-8. Decode (not reinterpret) at the boundary using the existing decode_utf8_spark_lossy (in spark-expr/src/utils.rs), which is zero-copy for the valid-UTF-8 common case:
- Gap A: read the Parquet
STRING column as Binary, then convert to Utf8 via decode_utf8_spark_lossy instead of erroring -- consistent with cast/shuffle and with Spark.
- Gap B: decode imported
Utf8/LargeUtf8 columns through decode_utf8_spark_lossy at the FFI import boundary.
This unifies all four sites on one decoder and removes the UB. It touches the scan/FFI hot path, so it needs a perf evaluation (the from_utf8 validation pass is SIMD-fast and the native Rust reader already pays it, but string-heavy workloads should be benchmarked) and belongs in its own PR, not bundled with the cast fix.
Alternatives considered
- Document + accept as a rare known limitation (leaves the UB in default config).
- Validate-and-error at import like the native scan (trades rare UB for rare query failures / Spark-incompatibility).
- Harden every string kernel to be byte-safe (impractically large surface).
Observable caveat (already documented)
Decoding (rather than preserving raw bytes) diverges from Spark only under byte-level round-trips: CAST(CAST(X'FF' AS STRING) AS BINARY) returns X'FF' in Spark but X'EFBFBD' (UTF-8 of U+FFFD) in Comet; likewise octet_length/hashing on the raw bytes. This is noted in the compatibility guide and the contributor guide.
Tasks
Related
Background
Spark's
StringTypecan hold arbitrary bytes, including sequences that are not valid UTF-8 (Spark stores them verbatim viaUTF8String). Arrow'sUtf8/LargeUtf8types require valid UTF-8, and arrow-rsStringArray::valuehands out a&strvia an unchecked decode that trusts the array's UTF-8 invariant. As a result, any native string kernel that reads an Arrow string array containing invalid UTF-8 (viavalue()) materializes an invalid&str, which is undefined behaviour (iterating chars, slicing on char boundaries, etc. can misbehave, panic, or be miscompiled).Comet currently handles invalid-UTF-8 strings inconsistently across sites. This EPIC tracks giving Comet a single, coherent policy.
Current state
String-producing sites (resolved -- decode JVM-compatibly to valid UTF-8):
get_stringdecode_utf8_spark_lossy(matches the JVM'snew String(bytes, UTF_8)byte-for-byte, including surrogate-range cases). #4521CAST(binary AS string)decode_utf8_spark_lossy(shared with shuffle). #4488 / PR #4763Both produce valid UTF-8 that matches Spark's rendered output, so neither feeds the downstream-kernel UB.
String-ingress sites (the gaps):
encountered non UTF-8 data)jni_api.rsfrom_ffi)value()is unsoundThe two ingress paths are simultaneously too strict (reject) and too loose (unchecked) for the same input.
Gap A -- native scan rejects invalid UTF-8 (#4121)
The arrow-rs Parquet reader validates UTF-8 and errors on non-UTF-8
STRINGcolumns. Spark reads them fine. Surfaces in Spark 4.1.1'shll.sql; currently worked around by disabling Comet for that file.Gap B -- FFI import does not validate (confirmed)
arrow::ffi::from_ffibuilds the imported array withArrayData::new_uncheckedand does not validate UTF-8 (arrow-arraysrc/ffi.rseven carries a// Should FFI be checking validity?comment). Comet adds no validation on the import side (prepare_output'svalidate_full()runs only on the output/export path under a debug flag). So invalid-UTF-8 string bytes arriving from a JVM-side source (a JVM-side scan, or any Spark -> Comet columnar handoff) enter the native pipeline as an ArrowUtf8array that lies about its validity.Severity: a latent default-config UB hazard. It requires (1) genuinely non-UTF-8 string bytes, (2) flowing through the unchecked FFI path rather than the validating native reader, and (3) consumption by a native string kernel that reads
&str. That conjunction is uncommon, which is why it has not surfaced as crashes -- but it is unsound by default.Proposed direction
Establish a single ingress policy: native string data must be valid UTF-8. Decode (not reinterpret) at the boundary using the existing
decode_utf8_spark_lossy(inspark-expr/src/utils.rs), which is zero-copy for the valid-UTF-8 common case:STRINGcolumn asBinary, then convert toUtf8viadecode_utf8_spark_lossyinstead of erroring -- consistent with cast/shuffle and with Spark.Utf8/LargeUtf8columns throughdecode_utf8_spark_lossyat the FFI import boundary.This unifies all four sites on one decoder and removes the UB. It touches the scan/FFI hot path, so it needs a perf evaluation (the
from_utf8validation pass is SIMD-fast and the native Rust reader already pays it, but string-heavy workloads should be benchmarked) and belongs in its own PR, not bundled with the cast fix.Alternatives considered
Observable caveat (already documented)
Decoding (rather than preserving raw bytes) diverges from Spark only under byte-level round-trips:
CAST(CAST(X'FF' AS STRING) AS BINARY)returnsX'FF'in Spark butX'EFBFBD'(UTF-8 of U+FFFD) in Comet; likewiseoctet_length/hashing on the raw bytes. This is noted in the compatibility guide and the contributor guide.Tasks
STRINGcolumns (Comet native scan rejects invalid UTF-8 byte sequences in STRING column (hll.sql on Spark 4.1) #4121); re-enablehll.sqlRelated
CAST(binary AS string)decode (done)get_stringdecode (done)