Skip to content

Commit fe8b591

Browse files
authored
[format] Close the text stream when line-reader construction fails (#9579)
1 parent 5110d36 commit fe8b591

2 files changed

Lines changed: 152 additions & 3 deletions

File tree

paimon-format/src/main/java/org/apache/paimon/format/text/AbstractTextFileReader.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
import org.apache.paimon.data.InternalRow;
2222
import org.apache.paimon.fs.FileIO;
2323
import org.apache.paimon.fs.Path;
24+
import org.apache.paimon.fs.SeekableInputStream;
2425
import org.apache.paimon.reader.FileRecordIterator;
2526
import org.apache.paimon.reader.FileRecordReader;
2627
import org.apache.paimon.types.RowType;
28+
import org.apache.paimon.utils.IOUtils;
2729

2830
import javax.annotation.Nullable;
2931

@@ -55,9 +57,19 @@ protected AbstractTextFileReader(
5557
this.filePath = filePath;
5658
this.rowType = rowType;
5759
this.offset = offset;
58-
InputStream decompressedStream =
59-
createDecompressedInputStream(fileIO.newInputStream(filePath), filePath);
60-
this.lineReader = TextLineReader.create(decompressedStream, delimiter, offset, length);
60+
// The line reader takes over the stream only once it is constructed, and both
61+
// the decompression wrapper and the line reader itself can throw before that.
62+
SeekableInputStream inputStream = fileIO.newInputStream(filePath);
63+
InputStream stream = inputStream;
64+
try {
65+
stream = createDecompressedInputStream(inputStream, filePath);
66+
this.lineReader = TextLineReader.create(stream, delimiter, offset, length);
67+
} catch (Throwable t) {
68+
// Closing the decompression wrapper closes the stream underneath it, so this
69+
// is one close either way.
70+
IOUtils.closeQuietly(stream);
71+
throw t;
72+
}
6173
this.reader = new TextRecordIterator();
6274
}
6375

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package org.apache.paimon.format.text;
20+
21+
import org.apache.paimon.format.csv.CsvFileReader;
22+
import org.apache.paimon.format.csv.CsvOptions;
23+
import org.apache.paimon.fs.Path;
24+
import org.apache.paimon.fs.PositionOutputStream;
25+
import org.apache.paimon.fs.SeekableInputStream;
26+
import org.apache.paimon.fs.local.LocalFileIO;
27+
import org.apache.paimon.options.Options;
28+
import org.apache.paimon.types.DataTypes;
29+
import org.apache.paimon.types.RowType;
30+
31+
import org.junit.jupiter.api.Test;
32+
import org.junit.jupiter.api.io.TempDir;
33+
34+
import java.io.IOException;
35+
import java.nio.charset.StandardCharsets;
36+
import java.util.UUID;
37+
import java.util.concurrent.atomic.AtomicInteger;
38+
39+
import static org.assertj.core.api.Assertions.assertThat;
40+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
41+
42+
/** Tests that a failing text-reader construction closes the stream it opened, exactly once. */
43+
class TextReaderCtorLeakTest {
44+
45+
@TempDir java.nio.file.Path tempDir;
46+
47+
private final RowType rowType = RowType.of(DataTypes.STRING());
48+
49+
@Test
50+
void testCorruptGzipClosesStream() throws IOException {
51+
// A .gz suffix makes the reader wrap the stream in a gzip codec, and
52+
// StandardLineReader reads in its constructor, so the header check fails there.
53+
Path file = write("a.csv.gz", "this is not gzip".getBytes(StandardCharsets.UTF_8));
54+
CountingFileIO fileIO = new CountingFileIO();
55+
56+
assertThatThrownBy(() -> reader(fileIO, file, new Options(), 0L))
57+
.isInstanceOf(IOException.class);
58+
assertThat(fileIO.closed).hasValue(1);
59+
}
60+
61+
@Test
62+
void testCustomDelimiterSplitClosesStream() throws IOException {
63+
// Defensive path: SplitEnumerator refuses to split a file read with a custom line
64+
// delimiter, so only a direct reader call reaches this.
65+
Path file = write("a.csv", "a|b".getBytes(StandardCharsets.UTF_8));
66+
CountingFileIO fileIO = new CountingFileIO();
67+
Options options = new Options();
68+
options.set(CsvOptions.LINE_DELIMITER, "|");
69+
70+
assertThatThrownBy(() -> reader(fileIO, file, options, 1L))
71+
.isInstanceOf(UnsupportedOperationException.class);
72+
assertThat(fileIO.closed).hasValue(1);
73+
}
74+
75+
@Test
76+
void testSuccessfulReadClosesStreamOnce() throws IOException {
77+
Path file = write("a.csv", "hello".getBytes(StandardCharsets.UTF_8));
78+
CountingFileIO fileIO = new CountingFileIO();
79+
80+
try (CsvFileReader reader = reader(fileIO, file, new Options(), 0L)) {
81+
assertThat(reader.readBatch().next().getString(0).toString()).isEqualTo("hello");
82+
}
83+
assertThat(fileIO.closed).hasValue(1);
84+
}
85+
86+
private CsvFileReader reader(CountingFileIO fileIO, Path file, Options options, long offset)
87+
throws IOException {
88+
return new CsvFileReader(
89+
fileIO, file, rowType, rowType, new CsvOptions(options), offset, null);
90+
}
91+
92+
private Path write(String name, byte[] content) throws IOException {
93+
Path file = new Path(tempDir.toUri().toString(), UUID.randomUUID() + "_" + name);
94+
try (PositionOutputStream out = LocalFileIO.create().newOutputStream(file, false)) {
95+
out.write(content);
96+
}
97+
return file;
98+
}
99+
100+
private static class CountingFileIO extends LocalFileIO {
101+
102+
private final AtomicInteger closed = new AtomicInteger();
103+
104+
@Override
105+
public SeekableInputStream newInputStream(Path path) throws IOException {
106+
SeekableInputStream inner = super.newInputStream(path);
107+
return new SeekableInputStream() {
108+
109+
@Override
110+
public void seek(long desired) throws IOException {
111+
inner.seek(desired);
112+
}
113+
114+
@Override
115+
public long getPos() throws IOException {
116+
return inner.getPos();
117+
}
118+
119+
@Override
120+
public int read() throws IOException {
121+
return inner.read();
122+
}
123+
124+
@Override
125+
public int read(byte[] b, int off, int len) throws IOException {
126+
return inner.read(b, off, len);
127+
}
128+
129+
@Override
130+
public void close() throws IOException {
131+
closed.incrementAndGet();
132+
inner.close();
133+
}
134+
};
135+
}
136+
}
137+
}

0 commit comments

Comments
 (0)