sunchao commented on code in PR #5763:
URL: https://github.com/apache/datafusion-comet/pull/5763#discussion_r4000202635
##########
spark/src/main/scala/org/apache/comet/serde/operator/NativeWriteUtils.scala:
##########
@@ -51,4 +67,166 @@ object NativeWriteUtils {
.setScan(scan.build())
.build())
}
+
+ /**
+ * ASCII characters the native URL parser rewrites inside a path. Determined
against the locked
+ * `url` 2.5 crate by parsing `hdfs://ns/pre<c>post/output` for every
printable ASCII `c` and
+ * comparing `url.path()` with the input: these nine are rewritten and the
rest survive,
+ * including `%`, `[`, `\`, `]`, `^` and `|`. Control characters and DEL are
handled separately
+ * in [[needsNativeUrlEscaping]] rather than listed here.
+ *
+ * `native/core/src/parquet/parquet_support.rs` has a
`url_path_rewritten_characters` test that
+ * fails if a `url` upgrade changes this set, so the two cannot drift apart
silently.
+ *
+ * `?` and `#` are the worst of the nine: they are not escaped but treated
as delimiters, so the
+ * native path is *truncated* there rather than merely spelled differently.
+ */
+ private val nativeUrlEscapedAscii: Set[Char] =
+ Set(' ', '"', '#', '<', '>', '?', '`', '{', '}')
+
+ /**
+ * Whether the native URL parser would rewrite `s`, so that the name it
creates on HDFS differs
+ * from the Hadoop filename Spark commits.
+ *
+ * `percent_encoding`'s `should_percent_encode` is `!byte.is_ascii() ||
set.contains(byte)`, so
+ * every non-ASCII byte is escaped regardless of the encode set. That is the
case Java's URI
+ * comparison cannot see, because `java.net.URI` leaves non-ASCII path
characters alone: a name
+ * holding U+00E9 comes back identically from `getRawPath` and `getPath`,
while the native
+ * parser produces `caf%C3%A9`.
+ */
+ private def needsNativeUrlEscaping(s: String): Boolean =
+ s.exists(c => c < ' ' || c > '~' || nativeUrlEscapedAscii.contains(c))
+
+ /**
+ * Whether the native writer and Spark would spell `path` differently, and
how.
+ *
+ * The native side receives `Path.toString`, which is not a URI string, and
reaches HDFS through
+ * `create_hdfs_object_store`, which hands `url.path()` -- now escaped by
the Rust parser -- to
+ * `object_store::path::Path::parse`. So the native writer creates a
directory literally called
+ * `dir%20with%20space`, or `caf%C3%A9` for a name holding U+00E9. Spark's
committer, meanwhile,
+ * works with the unescaped Hadoop `Path` and commits `dir with space`, or
that same U+00E9 name
+ * unescaped. Job commit then succeeds while the data sits somewhere else,
which is worse than
+ * not accelerating the write.
+ *
+ * Two conditions, because neither covers the other:
+ *
+ * - the native parser escapes a character, which is the direct statement
of the divergence
+ * and the only condition that catches non-ASCII names;
+ * - `java.net.URI` had to escape something, which catches a literal `%`
in the Hadoop name.
+ * The native parser leaves `%` alone, so `50%off` reaches `Path::parse`
as an invalid
+ * escape rather than as a rewritten name.
+ *
+ * Local `file:` destinations are unaffected and deliberately not gated
here: they go through a
+ * different object-store constructor that keeps the string verbatim.
+ */
+ private def hdfsPathDivergence(path: String): Option[String] = {
+ if (!path.startsWith("hdfs:")) return None
+ val uri = new Path(path).toUri
+ val raw = uri.getRawPath
+ val decoded = uri.getPath
+ val javaEscaped = raw != null && decoded != null && raw != decoded
+ // Checked against the string handed to the native writer, which is `path`
itself.
+ if (javaEscaped || needsNativeUrlEscaping(path)) {
+ Some(if (decoded != null) decoded else path)
+ } else {
+ None
+ }
+ }
+
+ /**
+ * A fallback reason when a write to `outputPath` would land somewhere the
committer is not
+ * looking, or `None` when the write can proceed.
+ *
+ * Two things go into every committed file name, and the native writer has
to reproduce both
+ * byte for byte (see [[hdfsPathDivergence]] for why it may not):
+ *
+ * - the destination directory, and
+ * - `fileNamePrefix`, the basename every file name is built from. On
Spark 4.0+ that is
+ * `mapreduce.output.basename`, which
`HadoopMapReduceCommitProtocol.getFilename`
+ * interpolates into `<basename>-<split>-<jobId>`; on 3.x Comet names
the files itself and
+ * the basename is always the literal `part`. A basename holding `?` or
`#` is the dangerous
+ * one: the native URL parser truncates there, so *every* task writes a
file with the same
+ * truncated name and they overwrite each other during commit.
+ */
+ def escapedHdfsDestination(outputPath: String, fileNamePrefix: String):
Option[String] = {
+ if (!outputPath.startsWith("hdfs:")) return None
+ hdfsPathDivergence(outputPath)
+ .map(shown =>
+ "HDFS output paths needing URI escaping are not supported: the native
writer would " +
+ s"write to the escaped path while Spark commits the unescaped one
($shown)")
+ .orElse {
+ if (needsNativeUrlEscaping(fileNamePrefix)) {
Review Comment:
[P2] Fall back for percent-bearing HDFS basenames during planning
Could we apply the Java URI-escaping check to `fileNamePrefix` as well?
`needsNativeUrlEscaping` deliberately excludes `%`, so
`mapreduce.output.basename=part%foo` or `part%25` is accepted here. Once Spark
includes it in the task filename, `hdfsPathDivergence` detects `getRawPath !=
getPath` and `checkNativeWriteDestination` aborts the job instead of falling
back.
I reproduced both cases with a native scan over 100 rows in two partitions
on Spark 4.1.3 and a real MiniDFS cluster, using native and JVM code built from
`5f5397f5`. The Spark-writer controls preserve all 100 IDs. With native writing
enabled, both plans contain `CometWriteFiles` and fail with
`UnsupportedOperationException` at the complete-path guard. The ordinary `part`
control writes natively, and the fixed `?`/`#` cases fall back successfully.
The basename is already available during planning. Please reject its
Java-escaped forms here too, and add `%` basename cases to the admission and
fallback regression coverage. The existing basename test currently covers `?`,
`#`, spaces and Unicode, but no literal percent.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]