dongjoon-hyun commented on PR #58603:
URL: https://github.com/apache/spark/pull/58603#issuecomment-5581156579

   Thanks for the PR. I checked out the branch and read the surrounding parser 
code. The overall
   direction (opt-in option, unlimited by default) is fine, but there are a few 
things I'd like to
   resolve before this goes in.
   
   ### 1. `depth` is not propagated on three recursion paths, so the limit can 
be bypassed
   
   `StaxXmlParser.scala` lines 391, 515 and 523 call `convertField` without 
passing `depth`, so it
   falls back to the default `1` and the counter resets:
   
   ```scala
   // 523: in convertObject. `dt` here is whatever is not Struct/Array/Variant 
-- i.e. MapType
   case dt: DataType => row(index) = convertField(parser, dt, field, 
attributes)   // depth = 1
   
   // 391: in convertMap
   kvPairs += (UTF8String.fromString(key) -> convertField(parser, valueType, 
key)) // depth = 1
   
   // 515: in the ArrayType(dt) branch, when dt is itself ArrayType(...) / 
MapType(...)
   case dt: DataType => convertField(parser, dt, field)                         
   // depth = 1
   ```
   
   Concrete case -- `maxNestingDepth=2` with schema
   `struct<m: map<string, struct<a: struct<b: struct<c: string>>>>>`: field `m` 
goes 523 ->
   `convertMap` -> 391, the depth resets to 1, and the three nested structs 
below it are never
   checked. `array<array<struct<...>>>` bypasses it the same way through 515.
   
   The `depth: Int = 1` default parameter is what makes this easy to get wrong. 
I'd drop the default
   on `convertField` / `convertObject` / `convertObjectWithAttributes` and make 
it a required
   parameter, passing `1` only at the two entry points (lines 160 and 245) -- 
then the compiler
   catches any missed call site. `convertMap` needs to take and forward `depth` 
too.
   
   ### 2. The stated motivation doesn't match where the recursion actually is
   
   Recursion in `convertObject` is driven by the **schema**, not by the 
document. Subtrees not present
   in the schema go through `skipChildren`, and `StringType` fields through
   `currentStructureAsString` -- both iterative. So parsing a very deeply 
nested document against a
   shallow schema never recurses deeply, and this option is effectively a 
per-record re-check of the
   schema nesting depth.
   
   The place that does recurse per document element, without any bound, is
   `XmlInferSchema.inferObject` / `inferField`, which this PR doesn't touch. 
That means the common
   case -- `spark.read.xml(path)` with schema inference -- still overflows the 
stack during inference
   before the new limit can ever apply.
   
   If bounding parser stack depth is the goal, inference needs the same 
treatment. If not, could we
   reword the motivation in the PR description to match what the option 
actually does?
   
   ### 3. The subtree isn't consumed before throwing, which desynchronizes the 
event stream
   
   The `throw` at line 475 happens before any of the offending element's child 
events are consumed.
   That exception is caught by the parent `convertObject`'s `case NonFatal(e)` 
handler (line 547) and
   the loop keeps going, so the remaining child `StartElement`s get interpreted 
as fields of the
   *parent* struct and the child's `</...>` is taken as the parent's 
`EndElement`, ending that level
   early.
   
   The record becomes a bad record via `PartialResultException` either way, so 
no wrong values are
   returned. But the optimized path (`doParseColumnOptimized`) resynchronizes 
with
   `skipToNextRowStart`, so for a document with a **nested `rowTag`** -- which 
is exactly the kind of
   recursive XML this option targets -- the leftover events can be picked up as 
a spurious extra
   record. Skipping the subtree (`StaxXmlParserUtils.skipChildren(parser, 
field, options)`) before
   throwing, or doing the check in the parent before descending, would keep the 
stream in sync.
   
   ### 4. Please use the error class framework
   
   ```scala
   throw new IllegalStateException(
     s"XML element nesting depth exceeds the configured maximum of ...")
   ```
   
   New user-facing errors should go through an error condition in 
`error-conditions.json`; line 363 of
   the same file already uses `SparkIllegalArgumentException(errorClass = 
...)`. Also, the trailing
   `(${XmlOptions.MAX_NESTING_DEPTH})` renders as the literal option *name* 
`(maxNestingDepth)`, which
   reads ambiguously -- something like "set via the `maxNestingDepth` option" 
would be clearer.
   
   ### 5. No validation on the option value
   
   ```scala
   val maxNestingDepth = 
parameters.get(MAX_NESTING_DEPTH).map(_.toInt).getOrElse(-1)
   ```
   
   A non-numeric value produces a bare `NumberFormatException` with no context, 
and `0` or `-5`
   silently mean "unlimited" even though the comment says only `-1` does. 
`samplingRatio` right above
   it (`XmlOptions.scala:112`) uses `require(...)`; the same here would be good.
   
   ### 6. No tests
   
   This adds a user-facing option with no test coverage -- 
"`XmlExpressionsSuite` continues to pass"
   is a regression check, not a test of the feature. At minimum I'd like to 
see, in `XmlSuite`:
   
   - a record exceeding the limit landing in the corrupt-record column under 
PERMISSIVE **while the
     following well-formed records still parse** (the behavior the description 
claims)
   - FAILFAST / DROPMALFORMED behavior
   - no behavior change at the default (`-1`)
   - the map / nested-array cases from (1), which should currently fail
   
   ### 7. Missing documentation and PySpark plumbing
   
   - No entry in the option table in `docs/sql-data-sources-xml.md`; every 
other XML option is listed
     there.
   - Not in the explicit kwargs of `DataFrameReader.xml` 
(`python/pyspark/sql/readwriter.py`), nor the
     Connect and streaming readers.
   
   ### Minor
   
   `Generated-by: Isaac` -- the template expects the model/tool that was used, 
so reviewers may ask
   about this.
   
   Items (1) and (6) look like blockers to me; (2) is worth settling first, 
since it determines what
   this option is really for.
   


-- 
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]

Reply via email to