|
24 | 24 | from pyspark.sql.types import * |
25 | 25 | import pyspark.sql.utils |
26 | 26 | import pyspark.sql.functions as f |
27 | | -from spark_session import with_cpu_session, with_gpu_session, is_databricks104_or_later, is_databricks_version_or_later, is_before_spark_320, is_spark_400_or_later, is_before_spark_340 |
| 27 | +from spark_session import with_cpu_session, with_gpu_session, is_databricks104_or_later, \ |
| 28 | + is_databricks_version_or_later, is_before_spark_320, is_spark_400_or_later, \ |
| 29 | + is_before_spark_340, spark_version |
28 | 30 |
|
29 | 31 | _regexp_conf = { 'spark.rapids.sql.regexp.enabled': 'true' } |
30 | 32 |
|
@@ -1150,3 +1152,57 @@ def test_multi_contains_conditional(ansi): |
1150 | 1152 | ELSE CAST(a AS LONG) |
1151 | 1153 | END as result'''), |
1152 | 1154 | conf = conf) |
| 1155 | + |
| 1156 | + |
| 1157 | +# SPARK-57507 / #15383: reverse must clamp truncated trailing UTF-8 to the row boundary. |
| 1158 | +# Neighbor sentinel rows make an over-read observable. |
| 1159 | +def _cpu_reverse_has_spark_57507(): |
| 1160 | + version = spark_version() |
| 1161 | + # The fix was backported to the 4.0.4, 4.1.3, and 4.2.0 release lines. |
| 1162 | + return ('4.0.4' <= version < '4.1.0' |
| 1163 | + or '4.1.3' <= version < '4.2.0' |
| 1164 | + or version >= '4.2.0') |
| 1165 | + |
| 1166 | + |
| 1167 | +@ignore_order(local=True) |
| 1168 | +@pytest.mark.parametrize('hex_in,expected_hex', [ |
| 1169 | + ('41CE', 'CE41'), # A + truncated 2-byte lead |
| 1170 | + ('41E4B8', 'E4B841'), # A + truncated 3-byte lead |
| 1171 | + ('41F090', 'F09041'), # A + truncated 4-byte lead |
| 1172 | + ('E4B896CE', 'CEE4B896'), # complete 世 + orphan 2-byte lead |
| 1173 | + ('41C3A9', 'C3A941'), # well-formed 2-byte control |
| 1174 | + ('41E4B896', 'E4B89641'), # well-formed 3-byte control |
| 1175 | + ('41F0908D88', 'F0908D8841'), # well-formed 4-byte control |
| 1176 | +], ids=idfn) |
| 1177 | +def test_reverse_truncated_trailing_utf8(hex_in, expected_hex): |
| 1178 | + def do_it(spark): |
| 1179 | + # Keep target + sentinel neighbors adjacent in one partition so an over-read |
| 1180 | + # into the next row is observable. Avoid ORDER BY (CPU shuffle) and unhex |
| 1181 | + # (not allowed in GpuCpuBridge); feed raw bytes via BINARY then cast to STRING. |
| 1182 | + df = spark.createDataFrame( |
| 1183 | + [ |
| 1184 | + (0, bytearray.fromhex(hex_in)), |
| 1185 | + (1, bytearray.fromhex('FFFFFFFF')), |
| 1186 | + (2, bytearray.fromhex('414243')), |
| 1187 | + ], |
| 1188 | + 'row_id INT, b BINARY' |
| 1189 | + ).coalesce(1).selectExpr('row_id', 'CAST(b AS STRING) AS v') |
| 1190 | + return df.selectExpr('row_id', 'hex(reverse(v)) as h') |
| 1191 | + |
| 1192 | + if _cpu_reverse_has_spark_57507(): |
| 1193 | + assert_cpu_and_gpu_are_equal_collect_with_capture( |
| 1194 | + do_it, exist_classes='GpuReverse', require_non_empty=True) |
| 1195 | + else: |
| 1196 | + # Older CPU builds over-read malformed trailing UTF-8, so validate the GPU result |
| 1197 | + # against Spark's corrected semantics instead of asserting CPU/GPU equality. |
| 1198 | + def collect_gpu(spark): |
| 1199 | + out = do_it(spark) |
| 1200 | + rows = out.collect() |
| 1201 | + plan = out._jdf.queryExecution().executedPlan().toString() |
| 1202 | + # Expression trees render as lowercase "gpureverse(...)", not the class name. |
| 1203 | + assert 'gpureverse' in plan.lower(), 'expected GpuReverse in plan, got:\n' + plan |
| 1204 | + return rows |
| 1205 | + |
| 1206 | + rows = with_gpu_session(collect_gpu) |
| 1207 | + rows_by_id = {row['row_id']: row['h'] for row in rows} |
| 1208 | + assert rows_by_id[0] == expected_hex |
0 commit comments