Repository navigation
feat: txt file support - #6784
parthchandra wants to merge 19 commits into
Conversation
andygrove
left a comment
There was a problem hiding this comment.
Thanks for taking this on, Parth. Lining the reader up with the CSV path and covering BOMs, custom lineSep bytes and wholetext is a lot of careful work. I checked the line splitting against Spark with a differential fuzz of 150 random byte files across 8 option variants. That is 1200 comparisons with BOMs, bare \r, \r\n, invalid UTF-8 and several lineSep values, and I found no differences on Spark 4.1 and 3.5. A Parquet fact table joined to a text lookup also goes fully native, which is the point of the PR. The optimized native library is faster than Spark's reader as well.
I am requesting changes for two cases where the native scan returns different rows from Spark without raising an error. Both are in inline comments.
- On Spark 4.1 and later,
.gzipand.zstdfiles are decompressed by Spark but are not caught by the compressed-file check, so the native scan returns the raw compressed bytes. mapreduce.input.linerecordreader.line.maxlengthmakes Spark skip long lines, and the native scan ignores it.
I would also like the memory use addressed in the same round. The reader holds about 3.3 times the file size outside the memory pool, and the inline comment on text_scan.rs has the numbers and some options. The remaining inline comments cover the duplicated checks in CometScanRule, stringArgs, and test coverage.
Two doc updates fall outside this diff, so I am noting them here. The Scans table in docs/source/user-guide/latest/understanding-comet-plans.md lists CometCsvNativeScan and needs a CometTextNativeScan row. The list of paths that decode non-UTF-8 bytes in docs/source/user-guide/latest/compatibility/scans.md should include the native text reader.
The compression handling depends on the Spark version and the PR CI only runs the default profile, so the run-all-spark-profiles label is worth running once the fix is in.
| val lines = (1 to 500).map(i => s"row-$i") | ||
| Files.write(file.toPath, lines.mkString("\n").getBytes(StandardCharsets.UTF_8)) | ||
| withTextConf { | ||
| val got = spark.read.text(file.getAbsolutePath).collect().map(_.getString(0)).toSeq |
There was a problem hiding this comment.
This test only calls collect(), so it would still pass if the scan fell back to Spark. Could it also assert that the executed plan contains CometTextNativeScanExec?
There was a problem hiding this comment.
Done. preserves line order within a file now calls
checkSparkAnswerAndOperator(df, Seq(classOf[CometTextNativeScanExec]))
first, so it fails if the scan falls back. The collect() is kept only to check order.
| falls back to Spark for cases it does not handle -- including partitioned tables, compressed files, files large | ||
| enough that Spark splits them into byte ranges, `wholetext` files larger than roughly 2GB (capped at | ||
| `min(spark.sql.files.maxPartitionBytes, 2GB)`), unsupported filesystem schemes, and `input_file_name()` or |
There was a problem hiding this comment.
Two details here don't match the code. The wholetext cap in CometScanRule is min(spark.sql.files.maxPartitionBytes, Int.MaxValue / 3), which is 128 MB by default, so "roughly 2GB" is misleading. And only reads of partition columns fall back. Selecting just value from a partitioned directory stays native.
Text files are also the most likely way for ill-formed UTF-8 to reach Comet. The reader replaces it with U+FFFD, so byte-level functions and equality can differ from Spark on those rows. For the bytes 61 FF 62, value = 'a\uFFFDb' is true in Comet and false in Spark. Could this section link to "Strings with non-UTF-8 bytes" in the compatibility guide?
There was a problem hiding this comment.
Fixed all three: the size-limit wording, the partition-column note (selecting value stays native), and a link to the non-UTF-8 section.
parthchandra
left a comment
There was a problem hiding this comment.
Thanks for the thorough review and the differential fuzz — really appreciate it. I've addressed all the inline comments plus the two out-of-diff doc notes.
| falls back to Spark for cases it does not handle -- including partitioned tables, compressed files, files large | ||
| enough that Spark splits them into byte ranges, `wholetext` files larger than roughly 2GB (capped at | ||
| `min(spark.sql.files.maxPartitionBytes, 2GB)`), unsupported filesystem schemes, and `input_file_name()` or |
There was a problem hiding this comment.
Fixed all three: the size-limit wording, the partition-column note (selecting value stays native), and a link to the non-UTF-8 section.
sunchao
left a comment
There was a problem hiding this comment.
Reviewed 23bba3ea1824d3b40db0b2126be162576bbe1097 against base 9876862df98ee10fbded5365a4abeaa4a2b022b9. The PR is not a draft. Reviewed all 23 files across all three PR commits using merge base 7a1c042f37c0a4e0cad97ba16aaf558ad30dcb69. Base-only join and SQL-test changes are not PR regressions.
Routed skills: review-comet-pr, review-comet-ffi-pr, and review-comet-memory-pr. Also consulted review-comet-expression-pr during scope analysis. Read the existing reviews and all nine discussion threads. Two new P2 findings are detailed below.
Summary
- Prior state and problem: Text scans remained on Spark and could prevent a text lookup's broadcast join from running natively. This PR adds an opt-in native V2 text reader.
- Design approach: Follow the existing CSV scan integration, serialize Spark's text options and file partitions, and use a DataFusion
FileSourcewith conservative Spark fallbacks. - Correctness: Compared Spark's text options, scans, reader factories, and line-reader selection across 3.4.3, 3.5.9, 4.0.4, 4.1.3, and 4.2.0. Reproduced a custom-separator/BOM mismatch that drops or changes records. A separate JVM probe confirms that the new serializer loses per-read Hadoop settings. Whole-file mode, empty projections, line termination, and Arrow offset bounds were also inspected.
- Compatibility analysis: The version-specific compression shims address the earlier
.gzip/.zstdconcern. The Hadoop line-length guard addresses the earlier skipped-record concern. Compared the affected paths with latest release branchbranch-1.1ate9efd9f764ee0a59b7898ff028d6985d4a7a28e1: native text support is new and disabled by default. Invalid UTF-8 replacement remains explicitly documented. - Key design decisions: Require both V2 text routing and the native-text flag. Decline unsupported partition/metadata columns, split or compressed files, input-file expressions, and oversized files. The new file-size ceiling defaults to 64 MB.
- Implementation sketch:
CometScanRulechecks eligibility,CometTextNativeScanExecbuilds the protobuf, and the native planner createsTextSource/TextOpener. The opener reads, splits, decodes, and batches the file. Existing native-to-JVM Arrow export machinery is reused without changing its ownership rules. - Performance: The reader still buffers the whole file, its line descriptors, and all output batches before yielding. The ceiling limits input size but does not reserve those allocations against the memory pool. No exact-head throughput benchmark was run. The CSV/text
stringArgschanges avoid printing file-bearing protobufs in plans. - Design: Reusing the CSV/DataFusion scan structure keeps the feature understandable. The shared object-store eligibility helper removes duplicated Parquet checks, but its alias-only bucket policy does not fully address the existing multi-bucket concern for the new text path.
- Abstraction & complexity: The source/opener split and projection wrapper fit DataFusion's interfaces. No separate abstraction defect met the P1/P2 bar. Configuration should flow consistently from the scan's options through eligibility checking and serialization.
- Behavioral changes worth calling out: Eligible text reads can now participate in native pipelines and broadcast joins. The two findings introduce unintended behavior when that opt-in is enabled. The existing multi-bucket concern remains for ordinary
s3://ands3a://roots:aliasScanBucketsreturns empty without an alias, while the text planner selects the first file's object store and strips the bucket from subsequent keys. Files from another bucket can therefore be read from the first bucket. This existing concern is not repeated as a new inline finding. - Suggested improvements: Apply BOM handling after identifying the first record, preserving Hadoop's consumed-byte behavior. Serialize the text scan's case-preserved Hadoop options. Address the remaining ordinary multi-bucket case in the existing discussion, and add focused regression coverage for these triggers.
Exact-head CI: GitHub reports only the successful label check. No build or test verdict is available for this SHA, although run-all-spark-profiles is present.
Validation: Compiled the unchanged native splitting helpers into a disposable Rust probe and compared them with Hadoop's reader, Spark's reader, and Spark 4.1.3 V2 SQL execution. Ran a bounded Spark JVM configuration-propagation probe. git diff --check passed and the checkout remains unchanged. The focused Cargo test build failed in hdfs-sys because jni.h is unavailable. Full native/JVM suites, cloud reads, and Spark SQL CI suites were not run. The reproductions do not claim an integrated Comet execution test.
| // lineSep still strips, matching Hadoop). wholetext keeps the BOM, as Spark's | ||
| // WholeTextFileRecordReader does. | ||
| let at_file_start = range.as_ref().map(|r| r.start == 0).unwrap_or(true); | ||
| let content = strip_leading_bom(&buf, whole_text, at_file_start); |
There was a problem hiding this comment.
[P2] Apply BOM handling after splitting the first record. With native V2 text enabled, a UTF-8 file containing \uFEFFa\uFEFFb and .option("lineSep", "\uFEFF") should return ["", "a", "b"], as Spark does. This code removes the initial separator before splitting and returns ["a", "b"]. With separator \uFEFFx, it also changes the first value from a to xa. These inputs pass the eligibility checks and silently change counts, filters, and join results. Hadoop reads the first record before checking its value for a BOM. Follow its first-record BOM/consumed-byte rules, or fall back for conflicting separators.
Evidence: Compiled strip_leading_bom, split_lines, and their split helpers directly from the reviewed head. The Rust probe returned two rows for both examples. Hadoop LineRecordReader, Spark HadoopLineRecordReader, and Spark 4.1.3 V2 SQL returned three rows: ["", "a", "b"]. A file containing only U+FEFF with that separator returned zero native-helper rows versus one empty Spark row. Ordinary BOM and BOM-only default-separator controls agreed.
There was a problem hiding this comment.
I reproduced this end to end on 3.4, 3.5, 4.0, 4.1 and 4.2. With lineSep set to U+FEFF, BOM a BOM b gives three rows in Spark and two natively, and a file holding only the BOM gives one row against none. Splitting first and then stripping the BOM from the first record would match Hadoop, as long as a file that is exactly the BOM still gives no rows with the default separator. Could you add tests for both cases?
There was a problem hiding this comment.
Fixed. strip_leading_bom only strips with the default separator, so a custom lineSep equal to the BOM is left alone. Added tests: BOM a BOM b with lineSep=BOM → ["", "a", "b"], and a BOM-only file → no rows (default sep) / one empty row (lineSep=BOM).
| val objectStoreOptions = filePartitions.headOption | ||
| .flatMap { partitionFile => | ||
| val hadoopConf = sessionState | ||
| .newHadoopConfWithOptions(op.session.sparkContext.conf.getAll.toMap) |
There was a problem hiding this comment.
[P2] Preserve per-read Hadoop settings when serializing the text scan. For example, a text read using .option("fs.s3a.endpoint", "http://127.0.0.1:19000") expects that endpoint to be used. Spark's reader and the new eligibility gate incorporate read options, but this call rebuilds the configuration from SparkConf and discards them. extractObjectStoreOptions therefore sends the global/default endpoint to native execution, so enabling native text can break an otherwise working read against an S3-compatible service. Build this configuration from textScan.options.asCaseSensitiveMap.asScala.toMap, matching Spark's TextScan.createReaderFactory.
Evidence: A bounded Spark 4.1.3 JVM probe executed both configuration constructions with the endpoint supplied only in read options. Spark's options-aware configuration returned http://127.0.0.1:19000; the exact construction used at line 112 returned null. NativeConfig.extractObjectStoreOptions copies fs.s3a.* exclusively from that supplied configuration, and the native text planner passes the resulting map to object-store creation. No remote service or credentials were used in this reproduction.
There was a problem hiding this comment.
Agreed. The gate already builds its conf from the read options, so the serde should use the same one. CometCsvNativeScanExec has the same line. Could you fix both here, since this PR already touches the CSV node?
There was a problem hiding this comment.
Fixed both nodes. Each builds its Hadoop conf from the scan's own options with newHadoopConfWithOptions(...asCaseSensitiveMap...), so settings like fs.s3a.endpoint reach native.
andygrove
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround, Parth. I reran my probes on 23bba3e against Spark 3.4, 3.5, 4.0, 4.1 and 4.2, and the suite passes on all five. Both cases I blocked on are fixed. The compression check is now version aware, and line.maxlength falls back from every place it can be set. The size ceiling also bounds the memory, which was my other main concern.
Three things still return different results from Spark, or fail where Spark succeeds, so I'm keeping this at request changes. A plain s3a:// read across two buckets still reads every file from the first bucket, because the new check only covers alias schemes. sunchao's two findings also hold up. I reproduced the U+FEFF lineSep case end to end on all five profiles, and the serde drops per-read Hadoop options such as fs.s3a.endpoint. The details are in the threads.
The branch now conflicts with main in understanding-comet-plans.md, where #5634 changed the CometInMemoryTableScan row. While it conflicts, GitHub can't build the merge ref, so Comet CI hasn't run on this head and the run-all-spark-profiles label hasn't produced anything. Merging main with the next push will sort that out.
| // lineSep still strips, matching Hadoop). wholetext keeps the BOM, as Spark's | ||
| // WholeTextFileRecordReader does. | ||
| let at_file_start = range.as_ref().map(|r| r.start == 0).unwrap_or(true); | ||
| let content = strip_leading_bom(&buf, whole_text, at_file_start); |
There was a problem hiding this comment.
I reproduced this end to end on 3.4, 3.5, 4.0, 4.1 and 4.2. With lineSep set to U+FEFF, BOM a BOM b gives three rows in Spark and two natively, and a file holding only the BOM gives one row against none. Splitting first and then stripping the BOM from the first record would match Hadoop, as long as a file that is exactly the BOM still gives no rows with the default separator. Could you add tests for both cases?
| val objectStoreOptions = filePartitions.headOption | ||
| .flatMap { partitionFile => | ||
| val hadoopConf = sessionState | ||
| .newHadoopConfWithOptions(op.session.sparkContext.conf.getAll.toMap) |
There was a problem hiding this comment.
Agreed. The gate already builds its conf from the read options, so the serde should use the same one. CometCsvNativeScanExec has the same line. Could you fix both here, since this PR already touches the CSV node?
|
|
||
| Text files are a common source of ill-formed UTF-8. The native reader replaces invalid byte sequences with the | ||
| Unicode replacement character (U+FFFD), so byte-level comparisons and functions can differ from Spark on those rows; | ||
| see [Strings with non-UTF-8 bytes](compatibility/scans.md) in the compatibility guide. |
There was a problem hiding this comment.
The link text says Strings with non-UTF-8 bytes, but it points at compatibility/scans.md. That section is in compatibility/index.md, so could it link to compatibility/index.md#strings-with-non-utf-8-bytes?
There was a problem hiding this comment.
Fixed. The link now points to compatibility/index.md#strings-with-non-utf-8-bytes.
23bba3e to
49fc103
Compare
… multi-bucket text gate - text_scan.rs: strip a leading UTF-8 BOM only with the default separator (a custom lineSep leaves the BOM as data), matching Spark; update the unit test - CometTextNativeScanExec / CometCsvNativeScanExec: build the object-store Hadoop conf from the scan's read options (case preserved) so per-read settings like fs.s3a.endpoint reach native - CometScanRule: decline native Text scans whose roots span more than one scheme/bucket (RootPathIssues.crossLocations); Parquet gate behavior unchanged - tests: add .zstd fallback, BOM-vs-lineSep and BOM-only parity tests - docs: fix the non-UTF-8 compatibility link target
sunchao
left a comment
There was a problem hiding this comment.
Reviewed commit 8dac218a9def3b4dcf0d6bbd3fd9ed954215b324. New P1/P2 findings are detailed inline.
| val df = spark.read.text(TEST_TEXT_PATH).withColumn("f", input_file_name()) | ||
| checkSparkAnswerAndFallbackReason( | ||
| df, | ||
| "Native Text scan is not compatible with input_file_name") |
There was a problem hiding this comment.
[P2] Update this assertion to the shared V2 fallback reason. transformV2Scan handles input_file_name before reaching the text-specific branch, so the actual reason starts with Native V2 scan is not compatible with input_file_name. The expected Native Text scan... string cannot match. This new test fails on all five Spark profiles and blocks Required Checks, although the fallback itself works. Assert the shared V2 message while retaining the answer and fallback checks.
Evidence: Exact-head run 38305916651 reports this as the sole failed test in every Spark 3.4, 3.5, 4.0, 4.1, and 4.2 scan job. The Spark 4.1 job, https://github.com/apache/datafusion-comet/actions/runs/38305916651/job/115002925730, reports 737 successful tests and one failure: expected Native Text scan is not compatible with input_file_name, actual Native V2 scan is not compatible with input_file_name, input_file_block_start, or input_file_block_length.
There was a problem hiding this comment.
Fixed. Dropped the dead Text-specific guard; the test now asserts the shared V2 message, which the blanket gate at the top of transformV2Scan emits.
The Text branch had its own input_file_name/_block guard that emitted a Native Text scan ... message, but the blanket V2 gate at the top of transformV2Scan already handles these expressions and emits Native V2 scan ... first. The Text message was dead, and the test asserted it, so the scans suite failed on all five Spark profiles. Drop the redundant guard and assert the shared message.
Spark splits a custom lineSep with Hadoop LineReader.readCustomLine, which backtracks only one byte on a mismatch instead of following the separator's own prefix overlap. For a separator that overlaps itself (e.g. aab, whose aa prefix can restart a match) that reader can split differently from the native reader's exact substring scan. Rather than reproduce Hadoop's buffered matcher, fall back at plan time when the separator self-overlaps (KMP prefix function has a nonzero entry); overlap-free separators stay native. Guarded to line mode since wholetext ignores the separator. Added a test with lineSep=aab.
|
If we think the common use case here is broadcasted tables (i.e., small input) then why not start with adding a config that enables SparkToColumnar for this data source, a la edit: I guess that's similar to what #6786 is? |
Exactly. #6786 is essentially what you are suggesting. It is in fact more generic. Any unsupported feature (not just a txt file) that can cause the build side to fall back can be covered by inserting a |
# Conflicts: # docs/source/user-guide/latest/understanding-comet-plans.md
|
Heads up: #7090 merged the Parquet and Iceberg user docs into |
# Conflicts: # docs/source/user-guide/latest/compatibility/scans.md # native/core/src/execution/planner.rs # native/core/src/execution/planner/operator_registry.rs # spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala # spark/src/main/scala/org/apache/spark/sql/comet/CometCsvNativeScanExec.scala
|
@sunchao, @andygrove any more comments? |
sunchao
left a comment
There was a problem hiding this comment.
Reviewed commit 7e57fffa279f43685cf91c176f02ea869996d6e1. New P1/P2 findings are detailed inline.
| convertToComet(scan, CometCsvNativeScanExec).getOrElse(scan.wrapped) | ||
|
|
||
| case scan: CometBatchScanExec if scan.wrapped.scan.isInstanceOf[TextScan] => | ||
| convertToComet(scan, CometTextNativeScanExec).getOrElse(scan) |
There was a problem hiding this comment.
[P2] Restore the wrapped Spark scan when text conversion is declined. With native V2 text enabled, reading small uncompressed files from two ABFS containers passes the scan gate, but extractScanObjectStoreOptions rejects the layout and the text serializer returns None. This branch then retains CometBatchScanExec, which advertises columnar output although Spark’s text reader produces UnsafeRow. The query fails with ClassCastException instead of falling back and returning both files. Use getOrElse(scan.wrapped), as the adjacent CSV branch does, and cover a serializer-decline case.
Evidence: A bounded Spark 4.1.3 probe used the repository’s FakeAbfsFileSystem to read local files through two abfss container authorities. Spark returned alpha and beta. The unchanged exact-head extractScanObjectStoreOptions method returned Left for the two containers. Executing the exact-head CometBatchScanExec around that scan reproduced UnsafeRow cannot be cast to ColumnarBatch; executing its wrapped BatchScanExec returned alpha and beta. Source tracing connects that decline to this getOrElse branch. This was a component reproduction without Azure access or a full integrated Comet build.
There was a problem hiding this comment.
Fixed. A declined Text conversion now returns scan.wrapped, like the CSV branch, so the query falls back to Spark's row-producing scan instead of failing with the ClassCastException. Added Text tests for the two ABFS layouts (different accounts, different containers). (CometExecRule.scala:462; AbfsScanFallbackSuite.scala)
…is declined When the Text serializer declines a scan (for example files spread over two ABFS containers), CometExecRule kept the CometBatchScanExec, which advertises columnar output while Spark's text reader produces UnsafeRow, so the query failed with a ClassCastException. Return the wrapped scan like the CSV branch does, and add Text tests for the ABFS decline layouts.
# Conflicts: # native/core/src/execution/operators/mod.rs # spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
I didn't see responses to all the threads from my previous review yet. Were they also addressed? |
andygrove
left a comment
There was a problem hiding this comment.
Thanks for the quick work on the earlier rounds, Parth. I went back through every thread against 6f21c0dd39, and the compression shim, line.maxlength, the size ceiling, the per-read Hadoop conf, the ABFS decline and the new tests all hold up. CI is green on this head across all five Spark profiles. I also fuzzed the self-overlapping lineSep fallback against Hadoop's LineReader with 200k random inputs per separator and buffer sizes from 1 to 8 bytes. Separators without self-overlap matched an exact scan every time, and aab mismatched about 8,400 times, so the fallback covers the right class.
I'm keeping this at request changes for one case where the native scan returns different rows from Spark without an error.
Spark's HadoopLineRecordReader.skipUtfByteOrderMark strips a leading BOM from the first record whatever the separator is. The fix for the lineSep=BOM case now skips the strip for every custom separator (strip_leading_bom in text_scan.rs). I ran Hadoop 3.4.2's LineRecordReader on a few files to compare:
BOM a|bwithlineSep="|": Hadoop returnsa,b. The native scan returnsBOM+a,b.- A file holding only the BOM with
lineSep="|": Hadoop returns no rows. The native scan returns one row containing U+FEFF. BOM|xwithlineSep="|": Hadoop returns an empty row andx. The native scan returnsBOMandx.
These files pass every eligibility check, so counts, filters and joins over them would silently differ. I checked this against Hadoop's reader and Spark 4.1.3's source, not end to end through Comet.
Would it work to split first and then strip the BOM from the first record only, as Hadoop does? If that record is exactly the BOM with no terminator after it, it would produce no row. That also gives the right answer for lineSep=BOM and for the default separator. Could you add SQL tests for BOM a|b and for a BOM-only file, both with lineSep="|"? The existing BOM tests only cover the default separator and lineSep=BOM.
Two small notes on the description. The CSV node now builds its Hadoop conf from the read options and no longer prints the protobuf in stringArgs, which is the right change but isn't mentioned. And the multi-bucket reply mentions a crossLocations set that isn't in the diff. The behavior is still right because splitPartitionsByStore keys on the bucket, so I consider that thread resolved.
Once the BOM handling is fixed and tested I expect to approve.
…rators too Hadoop's LineRecordReader reads the first record and then drops a leading UTF-8 BOM from it, whatever the separator. The earlier fix skipped the strip for every custom separator, so a file like BOM a|b with lineSep=| returned BOM+a, and a BOM-only file returned one row instead of none. Split first, then strip the BOM from the first record only. A file that is only the BOM has no rows; BOM followed by a separator leaves an empty row; lineSep equal to the BOM is unaffected because the BOM is then the separator. Add Rust unit tests and Scala tests for BOM a|b, BOM|x, and a BOM-only file with lineSep=|.
Yes. I had missed replying on the "collect() only" thread and have answered it now. |
Fixed. The reader now splits first and strips the BOM from the first record only, like Hadoop's skipUtfByteOrderMark. A BOM-only file gives no rows, BOM|x gives "" and x, and lineSep=BOM is unchanged. Added Rust and Scala tests for BOM a|b, BOM|x and a BOM-only file with lineSep="|". Also, updated the description. |
sunchao
left a comment
There was a problem hiding this comment.
Reviewed commit b04673de2177499c1a61c8d3ed130acba25ded22. New P1/P2 findings are detailed inline.
| // Use the options-aware Hadoop conf (so a per-read codec option is honored) and the | ||
| // version-aware ShimFileFormat.isCompressedFile, which on Spark 4.1+ also catches the | ||
| // non-standard .gzip/.zstd extensions that CompressionCodecFactory alone misses. | ||
| val hadoopConf = session.sessionState.newHadoopConfWithOptions(scan.options.asScala.toMap) |
There was a problem hiding this comment.
[P2] Preserve Hadoop option casing in the eligibility configuration. With native V2 text enabled, set the Hadoop configuration key mapreduce.input.linerecordreader.line.maxlength to 5, then read ab\nabcdefghij\nxy with .option("mapreduce.input.linerecordreader.line.maxLength", "2147483647"). Spark preserves Hadoop key casing, so that differently cased option does not override the limit and Spark returns ab, xy. Here, scan.options.asScala lowercases the option name, making the gate see an unlimited line length and admit native execution. The native reader returns all three rows, silently changing counts, filters, and joins. Use scan.options.asCaseSensitiveMap.asScala.toMap, matching TextScan.createReaderFactory and the new serializer.
Evidence: A bounded Spark 4.1.3 V2 SQL probe reproduced Spark returning [[ab], [xy]]. Executing the two configuration constructions reported 2147483647 for this gate's lowercased map and 5 for Spark's case-preserving map. The unchanged splitting/BOM helpers compiled directly from this head returned [ab, abcdefghij, xy]. Source tracing confirms that the gate therefore skips its finite-line-length fallback and that native execution does not implement that limit. Spark's TextScan preserves option casing in all five inspected supported versions. This is component reproduction and source tracing, not a full integrated Comet run.
There was a problem hiding this comment.
Fixed. The eligibility check now builds its Hadoop conf from scan.options.asCaseSensitiveMap.asScala.toMap, like TextScan.createReaderFactory and the serializer, so a differently cased option such as ...line.maxLength no longer hides a finite limit set in the Hadoop conf. Added a test with the limit set to 5 in the Hadoop conf and a differently cased option set to 2147483647. It falls back and matches Spark.
…n map The Text eligibility gate built its Hadoop conf from scan.options.asScala, which lowercases keys. Hadoop keys are case sensitive and Spark's TextScan keeps their case, so a differently cased read option such as ...line.maxLength did not override the Hadoop conf limit in Spark, but the gate saw the option as the lowercase key, read an unlimited length, and admitted native execution, which returned the long line Spark skips. Use asCaseSensitiveMap, matching TextScan.createReaderFactory and the serializer, and add a test.
andygrove
left a comment
There was a problem hiding this comment.
Thanks @parthchandra. Splitting first and then stripping the BOM from the first record matches Hadoop for every file I tried, and the BOM a|b, BOM|x and BOM-only tests pin it.
sunchao's casing finding at CometScanRule.scala:483 holds up when I read it. The eligibility check builds its Hadoop conf from scan.options.asScala, which lowercases the keys, while TextScan.createReaderFactory and your serializer use asCaseSensitiveMap. A differently cased read option can then hide a finite line.maxlength from the check, and the native reader returns lines Spark skips. Could that call use scan.options.asCaseSensitiveMap.asScala.toMap, with a test for sunchao's case? That's the last thing I see, and I'll approve once it's in.
# Conflicts: # native/proto/src/proto/operator.proto
Done. The check now builds its Hadoop conf from scan.options.asCaseSensitiveMap.asScala.toMap, like TextScan.createReaderFactory and the serializer. Added a test for sunchao's case: line.maxlength=5 in the Hadoop conf plus a differently cased ...line.maxLength=2147483647 read option now falls back and matches Spark. |
# Conflicts: # docs/source/user-guide/latest/datasources.md
sunchao
left a comment
There was a problem hiding this comment.
Reviewed commit 462bc44773b61520763adae4b9e6446c08810b3f. New P1/P2 findings are detailed inline.
| val hadoopConf = session.sessionState.newHadoopConfWithOptions( | ||
| scan.options.asCaseSensitiveMap.asScala.toMap) | ||
| val hasCompressedFile = scan.fileIndex.inputFiles.exists { path => | ||
| ShimFileFormat.isCompressedFile(hadoopConf, new org.apache.hadoop.fs.Path(path)) |
There was a problem hiding this comment.
[P2] Resolve compression against the selected PartitionedFile.toPath. inputFiles returns URL-encoded strings. Constructing a Hadoop Path from that string can hide a registered codec suffix, admitting a compressed file whose payload is then returned as text.
Evidence: I registered a GzipCodec with extension .packed gz and wrote one\ntwo\n to small.packed gz. Spark 4.1.3 returned one and two. The gate checked .packed%20gz, and the executed native scan returned one row decoded from the gzip payload.
There was a problem hiding this comment.
Fixed. The compression check now uses each selected file's decoded path (new Path(file.pathUri)) instead of the URL-encoded strings from inputFiles. A name like small.packed gz keeps its codec suffix, and the scan falls back. Added a test with a codec registered for .packed gz.
| CometScanRule | ||
| .rootPathFallbackReason( | ||
| "Native Text scan", | ||
| scan.fileIndex.rootPaths.map(_.toUri), |
There was a problem hiding this comment.
[P2] Validate the selected file URIs before committing to native text. A directory root can pass this check while a child has a legal Hadoop filename that object_store rejects. The query then fails during native planning. Apply the backend’s path check to the listed files and fall back when one fails.
Evidence: I placed expected\n in a child whose name contained a literal tab and passed its parent directory to spark.read.text. Spark 4.1.3 returned expected. Enabling native text raised CometNativeException with Encountered illegal character sequence. A directly supplied filename containing a newline triggered the existing root fallback and read successfully.
There was a problem hiding this comment.
Fixed. The scheme and object_store path checks now run on the selected files instead of only the root paths, so a child file with a tab in its name falls back instead of failing during native planning. Added a test with a file named a<TAB>b.txt inside a directory.
| // mapreduce.input.linerecordreader.line.maxlength (default unlimited); the key can come | ||
| // from spark.hadoop.*, core-site.xml, or a read option. The native reader does not | ||
| // truncate, so fall back when a finite limit is set. | ||
| if (hadoopConf.getInt("mapreduce.input.linerecordreader.line.maxlength", Int.MaxValue) |
There was a problem hiding this comment.
[P2] Read line.maxlength only in line mode. Spark’s whole-file reader never consults this setting. The unconditional getInt can therefore fail an otherwise working wholetext=true query during planning. Move it under the !textOptions.wholeText guard.
Evidence: With Spark 4.1.3, wholetext=true, and mapreduce.input.linerecordreader.line.maxlength=64k, Spark returned the single value alpha\nbeta. Toggling only the native-text flag raised NumberFormatException. Both readers rejected 64k in line mode. With the valid integer 5, the whole-file query succeeded but unnecessarily fell back.
There was a problem hiding this comment.
Fixed. line.maxlength is now read only in line mode, so wholetext queries no longer fail or fall back because of it. If the value is not an integer, the scan falls back so Spark reports it. Added a test for wholetext with the limit set to 5 and to 64k.
| override def outputOrdering: Seq[SortOrder] = Nil | ||
|
|
||
| override protected def doCanonicalize(): SparkPlan = { | ||
| CometTextNativeScanExec(nativeOp, output, originalPlan, serializedPlanOpt) |
There was a problem hiding this comment.
[P2] Canonicalize the output attributes and wrapped BatchScanExec, and clear block serialization state in the canonical copy. Returning the original expression IDs makes equivalent text lookups compare unequal, so Spark repeats their scans and exchanges.
Evidence: With exchange reuse enabled and AQE disabled on Spark 4.1.3, joining one five-row text lookup twice executed two CometBroadcastExchange nodes, each emitting five rows. Spark and the native Parquet control each reused one broadcast. The native leaves reported sameResult=false while their wrapped scans reported true. A grouped UNION ALL likewise wrote 24 shuffle records in two map stages versus 12 in one.
There was a problem hiding this comment.
Fixed. The canonical copy now normalizes the output attributes, uses the wrapped scan's canonical form, and clears the serialization state, so repeated lookups compare equal and Spark can reuse their exchanges. Added a test that two scans of the same file are sameResult.
| // non-standard .gzip/.zstd extensions that CompressionCodecFactory alone misses. | ||
| val hadoopConf = session.sessionState.newHadoopConfWithOptions( | ||
| scan.options.asCaseSensitiveMap.asScala.toMap) | ||
| val hasCompressedFile = scan.fileIndex.inputFiles.exists { path => |
There was a problem hiding this comment.
[P2] Check compression on Spark’s selected file partitions. fileIndex.inputFiles includes files eliminated by partition pruning, so a compressed file the query never reads can disable the native lookup. The driver also constructs codec factories for those excluded files. Use scanExec.inputPartitions and reuse the version-appropriate codec resolver.
Evidence: A Spark 4.1.3 query selecting only p='hot' used CometTextNativeScan and returned alpha. Changing only the cold partition’s file to .gz switched the same query to Spark’s BatchScan. In a separate fixture with one selected and 256 pruned plain files, the driver constructed the counting codec 258 times with native text enabled and once with it disabled.
There was a problem hiding this comment.
Fixed along with the compression comment above. The check now uses inputPartitions, so a compressed file in a pruned partition no longer disables the native scan, and the driver no longer builds codec factories for files the query never reads. Added a test with a .gz file in a partition the filter excludes.
- Resolve the codec and check files on the selected partitions' decoded paths, not fileIndex.inputFiles (URL-encoded, and including pruned files). - Fall back for local files that have a Hadoop .crc checksum file. - Run the scheme and object_store path checks on the selected files, not only the roots. - Fall back for mapreduce.job.input.file.must.* options in line mode. - Read line.maxlength only in line mode, and fall back instead of failing when it is not an integer. - Decline CSV scans whose libhdfs scheme is set only by a read option. - Canonicalize the Text scan's output and wrapped scan so repeated lookups compare equal. - Report bytes_scanned from the native opener and expose it with output_rows so the task input metrics are filled in.
|
@andygrove @sunchao I believe all comments are addressed. |
| // files only. wholetext keeps the BOM. | ||
| let at_file_start = range.as_ref().map(|r| r.start == 0).unwrap_or(true); | ||
| let content: &[u8] = &buf; | ||
| let mut lines = split_lines(content, whole_text, line_sep.as_deref()); |
There was a problem hiding this comment.
A file consisting of newlines allocates one 16-byte slice descriptor per input byte before the first batch is returned. An isolated 8 MiB input used 128 MiB for the slice table alone. At the default 64 MiB file cap the table reaches 1 GiB, including for count(*). Could the reader split lines incrementally and retain only what the current output batch needs?
| // from the decoded path (`Path(URI)`), so a name like `a b.gz` keeps its suffix. The | ||
| // version-aware ShimFileFormat.isCompressedFile also catches the non-standard .gzip/.zstd | ||
| // extensions on Spark 4.1+ that CompressionCodecFactory alone misses. | ||
| val hasCompressedFile = selectedFiles.exists { file => |
There was a problem hiding this comment.
On Spark 3.4, 3.5 and 4.0, the shim constructs a Hadoop CompressionCodecFactory for each path. For a directory of uncompressed files, this loop rebuilds the same codec registry for every file on the driver. Could this plan check create the factory once and reuse it across the selected paths?
Which issue does this PR close?
Part of #6008.
Rationale for this change
Comet cannot natively scan Spark's
textfile format, reporting[COMET: Unsupported file format Text].This cascades on the build side of a broadcast join. When a small lookup table is read from a text file,
the build branch (text scan -> ... ->
BroadcastExchange) stays on Spark, the exchange never becomes aCometBroadcastExchange, and theBroadcastHashJoincannot run natively even when the large probe sideis fully native. A tiny text lookup can disqualify native acceleration of the whole join.
This PR implements option B from #6008: a native Text reader (single
value: stringcolumn,line-delimited), mirroring the existing native CSV V2 scan path, so a text leaf no longer blocks native
execution. Option A (a generic
SparkToColumnarbridge for any unsupported build side) is in #6786 andis complementary.
What changes are included in this PR?
New config
spark.comet.scan.text.v2.enabled(defaultfalse, experimental). As with the native CSVscan, only Spark's DataSource V2 text path is accelerated, so
textmust also be removed fromspark.sql.sources.useV1SourceList. A second config,spark.comet.scan.text.maxFileSize(default 64MB),caps the size of each file the native reader will take.
Native (Rust):
TextSource/TextOpener(native/core/src/execution/operators/text_scan.rs): a DataFusionFileSource/FileOpenerproducing a single nullablevalue: Utf8column. Line splitting matchesSpark/Hadoop
LineRecordReader: default universal newline (\n,\r\n,\r), a customlineSep(the exact charset-encoded
lineSeparatorInReadbytes), orwholetext=true(one row per file).skipUtfByteOrderMark. In line mode the reader splits first and thenstrips a leading UTF-8 BOM from the first record only, for any separator. A file that is only a BOM has
no rows, and a BOM followed by a separator leaves an empty first row.
lineSepequal to the BOM isunaffected, because the BOM is then the separator.
wholetextkeeps the BOM.decode_utf8_spark_lossy). Eachbatch's value buffer is bounded below Arrow's 2GB offset limit, returning a clean error on a single
oversized value rather than panicking.
CometScanRulefalls back for split files). A defensivebyte-range filter keeps results correct even if a split reached the reader.
TextScan/TextOptionsprotobuf messages, planner wiring (OpStruct::TextScan), andoperator-registry /
jni_apientries. Like the CSV arm, the planner builds the object store from thecurrent partition's first file, because partitions can differ in store.
JVM (Scala):
CometTextNativeScanExec(mirrorsCometCsvNativeScanExec), the configs, and dispatch inCometExecRule. A declined Text conversion falls back to the wrapped Spark scan, as CSV does.CometScanUtils.multiStoreFallbackReasonand splits a Spark partition that mixes stores throughsplitPartitionsByStore, so each native partition reads one store. This uses the same machinery as thenative CSV scan.
CometScanRuleclaims a V2TextScanonly when safe, otherwise falling back to Spark:input_file_name/input_file_block_start/input_file_block_length(theshared V2 gate)
TextScan.verifyReadSchema), partition columns, ormetadata columns
ShimFileFormat.isCompressedFile. Spark 4.1+ alsotreats
.gzipand.zstdas compressed (HadoopCodecStreams), earlier versions useCompressionCodecFactory.spark.comet.scan.text.maxFileSize, and oversizedwholetextfilesignoreCorruptFiles/ignoreMissingFiles, and a finitemapreduce.input.linerecordreader.line.maxlengthlineSep(for exampleaab). Hadoop'sreadCustomLinebacks up one byteon a mismatch instead of following the separator's own overlap, so it can split differently from an
exact scan, depending on where the separator falls in its input buffer. Separators with no
self-overlap (
\r\n, multi-byte CJK, and so on) stay native.CometScanRule.rootPathFallbackReason, used by both the native Parquet V1 gate and the Text gate.Parquet's fallback messages are unchanged.
Changes to existing code:
CometCsvNativeScanExecbuilds its Hadoop conf from the scan's read options (case preserved), matchingSpark's
CSVScan.createReaderFactory, so per-read settings such asfs.s3a.endpointreach nativeexecution. It also overrides
stringArgsso EXPLAIN, the SQL tab and the event log no longer print theserialized plan with every input path. The Text node does both too.
rootPathFallbackReasonhelper described above instead ofcarrying its own copy of the checks.
Docs: a Text subsection in the data sources guide, a
CometTextNativeScanrow inunderstanding-comet-plans.md, and the native text scan added to the non-UTF-8 handling note inparquet.md. The multi-store section of the data sources guide now names Text.How are these changes tested?
text_scan.rs: line splitting (universal newline, trailing terminator,empty/interior-empty lines, custom single- and multi-byte separators, wholetext), byte-range split
partitioning, first-record BOM stripping (default and custom separators, BOM-only file, BOM followed by
a separator,
lineSepequal to the BOM), invalid UTF-8 -> U+FFFD, and the count/empty-projection path.CometTextNativeReadSuitecompares Comet to Spark (checkSparkAnswerAndOperator) for basic read,count, filter, wholetext (+ count, directory), custom
lineSep(including a trailing separator and anon-ASCII multibyte separator), CRLF/CR line endings, invalid UTF-8, BOMs (default separator, custom
separator,
lineSepequal to the BOM, BOM-only files), multi-file directories, files in separatepartitions, and empty files. It also covers a broadcast join with a text build side running fully
native.
checkSparkAnswerAndFallbackReason) for split files,compressed files (
.gz, and.gzip/.zstdon Spark 4.1+),ignoreCorruptFiles/ignoreMissingFiles,line.maxlength, files over the size ceiling,input_file_name, large wholetext, a self-overlappinglineSep, partition columns, and native-exec-disabled.AbfsScanFallbackSuitecovers a Text scan over two ABFS accounts and over two containers of oneaccount. The serializer declines both layouts, and the query must fall back to Spark and return the
right rows rather than fail with a
ClassCastException.pr_build_linux.ymlandpr_build_macos.yml.version-specific compression shims.