This is an automated email from the ASF dual-hosted git repository.
deniskuzZ pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hive.git
The following commit(s) were added to refs/heads/master by this push:
new 691b786dc46 HIVE-29785: SkippingTextInputFormat: ClassCastException on
skip.header.line.count files with lone-CR (\r) line endings (#6680)
691b786dc46 is described below
commit 691b786dc4675ee7e5df14258f70e358cf11d58c
Author: srbiswal <[email protected]>
AuthorDate: Thu Aug 20 22:47:26 2026 +0530
HIVE-29785: SkippingTextInputFormat: ClassCastException on
skip.header.line.count files with lone-CR (\r) line endings (#6680)
---
.../hadoop/hive/ql/io/SkippingTextInputFormat.java | 74 +++++----
.../hive/ql/io/TestSkippingTextInputFormat.java | 168 +++++++++++++++++++++
2 files changed, 209 insertions(+), 33 deletions(-)
diff --git
a/ql/src/java/org/apache/hadoop/hive/ql/io/SkippingTextInputFormat.java
b/ql/src/java/org/apache/hadoop/hive/ql/io/SkippingTextInputFormat.java
index 2e546117289..fe037b76f2c 100644
--- a/ql/src/java/org/apache/hadoop/hive/ql/io/SkippingTextInputFormat.java
+++ b/ql/src/java/org/apache/hadoop/hive/ql/io/SkippingTextInputFormat.java
@@ -22,12 +22,14 @@
import org.apache.hadoop.fs.FSDataInputStream;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
-import org.apache.hadoop.io.compress.CompressionCodecFactory;
+import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapred.FileSplit;
import org.apache.hadoop.mapred.JobConf;
import org.apache.hadoop.mapred.TextInputFormat;
+import org.apache.hadoop.util.LineReader;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.Map;
import java.util.Queue;
@@ -106,29 +108,35 @@ private long getCachedStartIndex(Path path) throws
IOException {
}
Long startIndexForFile = startIndexMap.get(path);
if (startIndexForFile == null) {
- FileSystem fileSystem;
- FSDataInputStream fis = null;
- fileSystem = path.getFileSystem(conf);
- try {
- fis = fileSystem.open(path);
- long currPos = fis.getPos();
+ FileSystem fileSystem = path.getFileSystem(conf);
+ String delimiter = conf.get("textinputformat.record.delimiter");
+ boolean hasDelimiter = delimiter != null && !delimiter.isEmpty();
+ try (FSDataInputStream fis = fileSystem.open(path)) {
+ // LineReader terminates on '\n', '\r\n' and lone '\r' and returns the
number of
+ // bytes it consumed, so offsets come from that count instead of the
+ // readLine()+getPos() idiom, which throws ClassCastException on
lone-CR files.
+ // Deliberately not closed: LineReader.close() would close fis, which
the
+ // try-with-resources already owns.
+ LineReader reader = new LineReader(fis, conf);
+ Text headerLine = new Text();
+ long currPos = 0;
int delimiterIdx = -1;
for (int j = 0; j < headerCount; j++) {
- String headerLine = fis.readLine();
- if (headerLine == null) {
+ int bytesRead = reader.readLine(headerLine);
+ if (bytesRead == 0) {
startIndexMap.put(path, Long.MAX_VALUE);
return Long.MAX_VALUE;
}
- if (j == headerCount-1) {
- String delimiter = conf.get("textinputformat.record.delimiter");
- // If record delimiter is defined
- if (delimiter != null && !delimiter.isEmpty()) {
- delimiterIdx = headerLine.indexOf(delimiter);
- } else {
- currPos = fis.getPos();
- }
+ if (j == headerCount - 1 && hasDelimiter) {
+ // Decode one char per byte so indexOf() yields a byte offset
comparable with
+ // currPos. This preserves the old DataInputStream.readLine(),
which
+ // zero-extended each byte to a char; Text.toString() would decode
UTF-8 and
+ // shift the index whenever the header holds multi-byte characters.
+ String lastHeader =
+ new String(headerLine.getBytes(), 0, headerLine.getLength(),
StandardCharsets.ISO_8859_1);
+ delimiterIdx = lastHeader.indexOf(delimiter);
} else {
- currPos = fis.getPos();
+ currPos += bytesRead;
}
}
// Readers skip the entire first row if the start index of the
@@ -137,10 +145,6 @@ private long getCachedStartIndex(Path path) throws
IOException {
// is discarded instead of the first valid input row.
// We consider record delimiters if they exist.
startIndexForFile = currPos + delimiterIdx;
- } finally {
- if (fis != null) {
- fis.close();
- }
}
startIndexMap.put(path, startIndexForFile);
}
@@ -150,7 +154,7 @@ private long getCachedStartIndex(Path path) throws
IOException {
private long getCachedEndIndex(Path path) throws IOException {
Long endIndexForFile = endIndexMap.get(path);
if (endIndexForFile == null) {
- final long bufferSectionSize = 5 * 1024;
+ final int bufferSectionSize = 5 * 1024;
FileSystem fileSystem = path.getFileSystem(conf);
long endOfFile = fileSystem.getFileStatus(path).getLen();
if (footerCount == 0) {
@@ -161,20 +165,28 @@ private long getCachedEndIndex(Path path) throws
IOException {
// we need 'footer count' lines and one space for EOF
LineBuffer buffer = new LineBuffer(footerCount + 1);
- FSDataInputStream fis = null;
- try {
- fis = fileSystem.open(path);
+ try (FSDataInputStream fis = fileSystem.open(path)) {
while (bufferSectionEnd > bufferSectionStart) {
fis.seek(bufferSectionStart);
- long pos = fis.getPos();
+ // A LineReader buffers read-ahead and is not seek-aware, so it is
valid only
+ // for the region following this seek and must not outlive the
section: a
+ // reader carried across the seek would keep serving bytes from
the previously
+ // scanned section while offsets were counted from the new start.
The single
+ // buffer of over-read past bufferSectionEnd is expected and
discarded by
+ // LineBuffer.consume(). Deliberately not closed: that would close
fis, which
+ // the remaining sections still need.
+ LineReader reader = new LineReader(fis, conf);
+ Text line = new Text();
+ long pos = bufferSectionStart;
while (pos < bufferSectionEnd) {
- if (fis.readLine() == null) {
+ int bytesRead = reader.readLine(line);
+ if (bytesRead == 0) {
// if there is not enough lines in this section, check the
previous
// section. If this is the beginning section, there are simply
not
// enough lines in the file.
break;
}
- pos = fis.getPos();
+ pos += bytesRead;
buffer.consume(pos, bufferSectionEnd);
}
if (buffer.getRemainingLineCount() == 0) {
@@ -194,10 +206,6 @@ private long getCachedEndIndex(Path path) throws
IOException {
// there were not enough lines in the file to consume all footer
rows.
endIndexForFile = Long.MIN_VALUE;
}
- } finally {
- if (fis != null) {
- fis.close();
- }
}
}
endIndexMap.put(path, endIndexForFile);
diff --git
a/ql/src/test/org/apache/hadoop/hive/ql/io/TestSkippingTextInputFormat.java
b/ql/src/test/org/apache/hadoop/hive/ql/io/TestSkippingTextInputFormat.java
index d53f5363347..272f48d6975 100644
--- a/ql/src/test/org/apache/hadoop/hive/ql/io/TestSkippingTextInputFormat.java
+++ b/ql/src/test/org/apache/hadoop/hive/ql/io/TestSkippingTextInputFormat.java
@@ -42,10 +42,12 @@
import java.io.File;
import java.io.IOException;
import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
+import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/**
@@ -195,6 +197,161 @@ public void testSkipCompressedFileSplits() throws
Exception {
}
}
+ /**
+ * Reproduces ClassCastException (PushbackInputStream cannot be cast to
Seekable)
+ * during split generation when a header line ends in a lone CR ('\r')
followed
+ * by a non-'\n' byte (classic-Mac line endings). readLine() swaps the
stream's
+ * inner input for a non-Seekable PushbackInputStream; the following
getPos() casts.
+ * With the byte-counting reader this now succeeds instead of throwing.
+ */
+ @Test
+ public void testSkipFileSplitsLoneCR() throws Exception {
+ FileInputFormat.setInputPaths(job, dataDir);
+ Path loneCrFile = new Path(dataDir, "data1_cr_only.csv");
+ // Exact bytes from HIVE-29785:
+ // printf "id;name;place;\n1;smruti;ctc;\n2;biswal;bbsr;\n3;'';NULL;\n"
| tr '\n' '\r'
+ writeTextFile(loneCrFile,
+ "id;name;place;\r" +
+ "1;smruti;ctc;\r" +
+ "2;biswal;bbsr;\r" +
+ "3;'';NULL;\r");
+
+ SkippingTextInputFormat inputFormat = new SkippingTextInputFormat();
+ inputFormat.configure(job, 1, 0); // skip.header.line.count = 1, no footer
+ FileInputFormat.setInputPaths(job, loneCrFile);
+
+ // On unmodified master this throws ClassCastException during split
generation.
+ InputSplit[] splits = inputFormat.getSplits(job, 1);
+ assertTrue(splits.length >= 1);
+
+ // Read every row back: the header must be skipped and no data row
truncated,
+ // i.e. SELECT COUNT(*) would return the 3 data rows.
+ List<String> received = new ArrayList<String>();
+ for (InputSplit split : splits) {
+ RecordReader<LongWritable, Text> reader =
+ inputFormat.getRecordReader(split, job, reporter);
+ LongWritable key = reader.createKey();
+ Text value = reader.createValue();
+ while (reader.next(key, value)) {
+ received.add(value.toString());
+ }
+ reader.close();
+ }
+ assertEquals(3, received.size());
+ assertEquals("1;smruti;ctc;", received.get(0));
+ assertEquals("2;biswal;bbsr;", received.get(1));
+ assertEquals("3;'';NULL;", received.get(2));
+ }
+
+ /**
+ * The lone-CR fix must not move split boundaries for the well-behaved cases.
+ * The same logical content is written with LF, lone-CR and CRLF terminators;
+ * LF and lone-CR share a one-byte terminator so their (start, length) must
be
+ * identical, while CRLF's two-byte terminator shifts the header boundary by
one.
+ */
+ @Test
+ public void testSkipHeaderSplitOffsetsAcrossLineEndings() throws Exception {
+ Path lf = new Path(dataDir, "lf.csv");
+ writeTextFile(lf,
+ "id;name;place;\n1;smruti;ctc;\n2;biswal;bbsr;\n3;'';NULL;\n");
+ Path cr = new Path(dataDir, "cr.csv");
+ writeTextFile(cr,
+ "id;name;place;\r1;smruti;ctc;\r2;biswal;bbsr;\r3;'';NULL;\r");
+ Path crlf = new Path(dataDir, "crlf.csv");
+ writeTextFile(crlf,
+ "id;name;place;\r\n1;smruti;ctc;\r\n2;biswal;bbsr;\r\n3;'';NULL;\r\n");
+
+ FileSplit lfSplit = singleHeaderSplit(lf);
+ FileSplit crSplit = singleHeaderSplit(cr);
+ FileSplit crlfSplit = singleHeaderSplit(crlf);
+
+ // LF and lone-CR: identical byte layout (1-byte terminator) => identical
split.
+ assertEquals(14, lfSplit.getStart());
+ assertEquals(41, lfSplit.getLength());
+ assertEquals(lfSplit.getStart(), crSplit.getStart());
+ assertEquals(lfSplit.getLength(), crSplit.getLength());
+
+ // CRLF: 2-byte terminator shifts the header boundary by one; file is 4
bytes longer.
+ assertEquals(15, crlfSplit.getStart());
+ assertEquals(44, crlfSplit.getLength());
+ }
+
+ /**
+ * Exercises the footer detection path (getCachedEndIndex) with lone-CR line
+ * endings, which throws the same ClassCastException on unmodified master.
+ * The header and footer rows must be skipped and the two data rows read
back.
+ */
+ @Test
+ public void testSkipFileSplitsLoneCRHeaderFooter() throws Exception {
+ FileInputFormat.setInputPaths(job, dataDir);
+ Path file = new Path(dataDir, "cr_header_footer.csv");
+ writeTextFile(file,
+ "dir1_header\r" +
+ "dir1_file1_line1\r" +
+ "dir1_file1_line2\r" +
+ "dir1_footer");
+
+ SkippingTextInputFormat inputFormat = new SkippingTextInputFormat();
+ inputFormat.configure(job, 1, 1); // skip one header and one footer line
+ FileInputFormat.setInputPaths(job, file);
+ InputSplit[] splits = inputFormat.getSplits(job, 2);
+
+ List<String> received = new ArrayList<String>();
+ for (int i = 0; i < splits.length; i++) {
+ RecordReader<LongWritable, Text> reader =
+ inputFormat.getRecordReader(splits[i], job, reporter);
+ LongWritable key = reader.createKey();
+ Text value = reader.createValue();
+ while (reader.next(key, value)) {
+ received.add(value.toString());
+ }
+ reader.close();
+ }
+ assertEquals(2, received.size());
+ assertTrue(!received.get(0).contains("header"));
+ assertTrue(!received.get(received.size() - 1).contains("footer"));
+ }
+
+ /**
+ * Header skipping with a custom textinputformat.record.delimiter must
locate the
+ * delimiter by BYTE offset. The last header line is decoded one char per
byte
+ * (ISO-8859-1) so indexOf() lines up with the byte-counting currPos;
decoding the
+ * header as UTF-8 (Text.toString()) would shrink the index by one for every
+ * multi-byte character preceding the delimiter and shift the split start.
+ */
+ @Test
+ public void testSkipHeaderMultiByteWithRecordDelimiter() throws Exception {
+ // 'é' is two UTF-8 bytes (0xC3 0xA9), so the '~' delimiter sits at byte
offset 7
+ // ("h", "é" [2 bytes], "a", "d", "e", "r", "~") but at char index 6 under
UTF-8.
+ Path file = new Path(dataDir, "multibyte_delim.csv");
+ writeUtf8File(file, "héader~data_row1~data_row2");
+
+ job.set("textinputformat.record.delimiter", "~");
+ SkippingTextInputFormat inputFormat = new SkippingTextInputFormat();
+ inputFormat.configure(job, 1, 0); // skip one header "line", no footer
+ FileInputFormat.setInputPaths(job, file);
+ InputSplit[] splits = inputFormat.getSplits(job, 1);
+
+ assertEquals(1, splits.length);
+ assertTrue(splits[0] instanceof FileSplit);
+ // Byte offset of the delimiter; char-based UTF-8 decoding would wrongly
yield 6.
+ assertEquals(7, ((FileSplit) splits[0]).getStart());
+ }
+
+ /**
+ * Generates the single (header-adjusted) split for the given file with
+ * skip.header.line.count=1 and no footer.
+ */
+ private FileSplit singleHeaderSplit(Path file) throws Exception {
+ SkippingTextInputFormat inputFormat = new SkippingTextInputFormat();
+ inputFormat.configure(job, 1, 0);
+ FileInputFormat.setInputPaths(job, file);
+ InputSplit[] splits = inputFormat.getSplits(job, 1);
+ assertEquals(1, splits.length);
+ assertTrue(splits[0] instanceof FileSplit);
+ return (FileSplit) splits[0];
+ }
+
/**
* Writes the given string to the given file.
*/
@@ -203,4 +360,15 @@ private void writeTextFile(Path file, String content)
throws IOException {
writer.write(content);
writer.close();
}
+
+ /**
+ * Writes the given string to the given file as UTF-8, so multi-byte
characters
+ * land as a deterministic byte sequence regardless of the platform default
charset.
+ */
+ private void writeUtf8File(Path file, String content) throws IOException {
+ try (OutputStreamWriter writer =
+ new OutputStreamWriter(fileSystem.create(file),
StandardCharsets.UTF_8)) {
+ writer.write(content);
+ }
+ }
}