Repository: kafka Updated Branches: refs/heads/trunk bf5aebadb -> 337f576f5
KAFKA-4576; Log segments close to max size break on fetch `FileChannel.read` may not fill the destination buffer even if there are enough bytes in the channel to do so. Add a couple of utility methods that ensure this and use them from all the relevant places. Author: huxi <[email protected]> Author: amethystic <[email protected]> Author: Ismael Juma <[email protected]> Reviewers: Jun Rao <[email protected]>, Jason Gustafson <[email protected]>, Ismael Juma <[email protected]> Closes #2304 from amethystic/kafka4576_FileChannel_read Project: http://git-wip-us.apache.org/repos/asf/kafka/repo Commit: http://git-wip-us.apache.org/repos/asf/kafka/commit/337f576f Tree: http://git-wip-us.apache.org/repos/asf/kafka/tree/337f576f Diff: http://git-wip-us.apache.org/repos/asf/kafka/diff/337f576f Branch: refs/heads/trunk Commit: 337f576f5979bf8924c5707b338cf4d3c76a53fd Parents: bf5aeba Author: huxi <[email protected]> Authored: Tue Jan 24 10:04:42 2017 +0000 Committer: Ismael Juma <[email protected]> Committed: Tue Jan 24 11:02:20 2017 +0000 ---------------------------------------------------------------------- .../kafka/common/record/FileLogInputStream.java | 13 +- .../apache/kafka/common/record/FileRecords.java | 9 +- .../org/apache/kafka/common/utils/Utils.java | 52 +++++++ .../apache/kafka/common/utils/UtilsTest.java | 140 ++++++++++++++++++- 4 files changed, 201 insertions(+), 13 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/kafka/blob/337f576f/clients/src/main/java/org/apache/kafka/common/record/FileLogInputStream.java ---------------------------------------------------------------------- diff --git a/clients/src/main/java/org/apache/kafka/common/record/FileLogInputStream.java b/clients/src/main/java/org/apache/kafka/common/record/FileLogInputStream.java index ae393b0..dd9cc84 100644 --- a/clients/src/main/java/org/apache/kafka/common/record/FileLogInputStream.java +++ b/clients/src/main/java/org/apache/kafka/common/record/FileLogInputStream.java @@ -18,6 +18,7 @@ package org.apache.kafka.common.record; import org.apache.kafka.common.KafkaException; import org.apache.kafka.common.errors.CorruptRecordException; +import org.apache.kafka.common.utils.Utils; import java.io.IOException; import java.nio.ByteBuffer; @@ -56,9 +57,7 @@ public class FileLogInputStream implements LogInputStream<FileLogInputStream.Fil return null; logHeaderBuffer.rewind(); - channel.read(logHeaderBuffer, position); - if (logHeaderBuffer.hasRemaining()) - return null; + Utils.readFullyOrFail(channel, logHeaderBuffer, position, "log header"); logHeaderBuffer.rewind(); long offset = logHeaderBuffer.getLong(); @@ -117,9 +116,7 @@ public class FileLogInputStream implements LogInputStream<FileLogInputStream.Fil try { byte[] magic = new byte[1]; ByteBuffer buf = ByteBuffer.wrap(magic); - channel.read(buf, position + Records.LOG_OVERHEAD + Record.MAGIC_OFFSET); - if (buf.hasRemaining()) - throw new KafkaException("Failed to read magic byte from FileChannel " + channel); + Utils.readFullyOrFail(channel, buf, position + Records.LOG_OVERHEAD + Record.MAGIC_OFFSET, "magic byte"); return magic[0]; } catch (IOException e) { throw new KafkaException(e); @@ -136,9 +133,7 @@ public class FileLogInputStream implements LogInputStream<FileLogInputStream.Fil return record; ByteBuffer recordBuffer = ByteBuffer.allocate(recordSize); - channel.read(recordBuffer, position + Records.LOG_OVERHEAD); - if (recordBuffer.hasRemaining()) - throw new IOException("Failed to read full record from channel " + channel); + Utils.readFullyOrFail(channel, recordBuffer, position + Records.LOG_OVERHEAD, "full record"); recordBuffer.rewind(); record = new Record(recordBuffer); http://git-wip-us.apache.org/repos/asf/kafka/blob/337f576f/clients/src/main/java/org/apache/kafka/common/record/FileRecords.java ---------------------------------------------------------------------- diff --git a/clients/src/main/java/org/apache/kafka/common/record/FileRecords.java b/clients/src/main/java/org/apache/kafka/common/record/FileRecords.java index 8a33dca..960b716 100644 --- a/clients/src/main/java/org/apache/kafka/common/record/FileRecords.java +++ b/clients/src/main/java/org/apache/kafka/common/record/FileRecords.java @@ -108,14 +108,17 @@ public class FileRecords extends AbstractRecords implements Closeable { } /** - * Read log entries into a given buffer. + * Read log entries into the given buffer until there are no bytes remaining in the buffer or the end of the file + * is reached. + * * @param buffer The buffer to write the entries to * @param position Position in the buffer to read from * @return The same buffer - * @throws IOException + * @throws IOException If an I/O error occurs, see {@link FileChannel#read(ByteBuffer, long)} for details on the + * possible exceptions */ public ByteBuffer readInto(ByteBuffer buffer, int position) throws IOException { - channel.read(buffer, position + this.start); + Utils.readFully(channel, buffer, position + this.start); buffer.flip(); return buffer; } http://git-wip-us.apache.org/repos/asf/kafka/blob/337f576f/clients/src/main/java/org/apache/kafka/common/utils/Utils.java ---------------------------------------------------------------------- diff --git a/clients/src/main/java/org/apache/kafka/common/utils/Utils.java b/clients/src/main/java/org/apache/kafka/common/utils/Utils.java index ac8d078..afa85bd 100755 --- a/clients/src/main/java/org/apache/kafka/common/utils/Utils.java +++ b/clients/src/main/java/org/apache/kafka/common/utils/Utils.java @@ -12,6 +12,7 @@ */ package org.apache.kafka.common.utils; +import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.io.Closeable; @@ -803,4 +804,55 @@ public class Utils { return Crc32.crc32(buffer.array(), buffer.arrayOffset() + start, size); } + /** + * Read data from the channel to the given byte buffer until there are no bytes remaining in the buffer. If the end + * of the file is reached while there are bytes remaining in the buffer, an EOFException is thrown. + * + * @param channel File channel containing the data to read from + * @param destinationBuffer The buffer into which bytes are to be transferred + * @param position The file position at which the transfer is to begin; it must be non-negative + * @param description A description of what is being read, this will be included in the EOFException if it is thrown + * + * @throws IllegalArgumentException If position is negative + * @throws EOFException If the end of the file is reached while there are remaining bytes in the destination buffer + * @throws IOException If an I/O error occurs, see {@link FileChannel#read(ByteBuffer, long)} for details on the + * possible exceptions + */ + public static void readFullyOrFail(FileChannel channel, ByteBuffer destinationBuffer, long position, + String description) throws IOException { + if (position < 0) { + throw new IllegalArgumentException("The file channel position cannot be negative, but it is " + position); + } + int expectedReadBytes = destinationBuffer.remaining(); + readFully(channel, destinationBuffer, position); + if (destinationBuffer.hasRemaining()) { + throw new EOFException(String.format("Failed to read `%s` from file channel `%s`. Expected to read %d bytes, " + + "but reached end of file after reading %d bytes. Started read from position %d.", + description, channel, expectedReadBytes, expectedReadBytes - destinationBuffer.remaining(), position)); + } + } + + /** + * Read data from the channel to the given byte buffer until there are no bytes remaining in the buffer or the end + * of the file has been reached. + * + * @param channel File channel containing the data to read from + * @param destinationBuffer The buffer into which bytes are to be transferred + * @param position The file position at which the transfer is to begin; it must be non-negative + * + * @throws IllegalArgumentException If position is negative + * @throws IOException If an I/O error occurs, see {@link FileChannel#read(ByteBuffer, long)} for details on the + * possible exceptions + */ + public static void readFully(FileChannel channel, ByteBuffer destinationBuffer, long position) throws IOException { + if (position < 0) { + throw new IllegalArgumentException("The file channel position cannot be negative, but it is " + position); + } + long currentPosition = position; + int bytesRead; + do { + bytesRead = channel.read(destinationBuffer, currentPosition); + currentPosition += bytesRead; + } while (bytesRead != -1 && destinationBuffer.hasRemaining()); + } } http://git-wip-us.apache.org/repos/asf/kafka/blob/337f576f/clients/src/test/java/org/apache/kafka/common/utils/UtilsTest.java ---------------------------------------------------------------------- diff --git a/clients/src/test/java/org/apache/kafka/common/utils/UtilsTest.java b/clients/src/test/java/org/apache/kafka/common/utils/UtilsTest.java index 46400b4..194cad6 100755 --- a/clients/src/test/java/org/apache/kafka/common/utils/UtilsTest.java +++ b/clients/src/test/java/org/apache/kafka/common/utils/UtilsTest.java @@ -16,18 +16,27 @@ */ package org.apache.kafka.common.utils; +import java.io.EOFException; +import java.nio.channels.FileChannel; +import java.nio.file.StandardOpenOption; import java.util.Arrays; import java.util.Collections; import java.io.Closeable; import java.io.IOException; import java.nio.ByteBuffer; +import java.util.Random; +import org.apache.kafka.test.TestUtils; +import org.easymock.EasyMock; +import org.easymock.IAnswer; import org.junit.Test; + +import static org.apache.kafka.common.utils.Utils.formatAddress; import static org.apache.kafka.common.utils.Utils.getHost; import static org.apache.kafka.common.utils.Utils.getPort; -import static org.apache.kafka.common.utils.Utils.formatAddress; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -158,6 +167,135 @@ public class UtilsTest { } } + @Test + public void testReadFullyOrFailWithRealFile() throws IOException { + try (FileChannel channel = FileChannel.open(TestUtils.tempFile().toPath(), StandardOpenOption.READ, StandardOpenOption.WRITE)) { + // prepare channel + String msg = "hello, world"; + channel.write(ByteBuffer.wrap(msg.getBytes()), 0); + channel.force(true); + assertEquals("Message should be written to the file channel", channel.size(), msg.length()); + + ByteBuffer perfectBuffer = ByteBuffer.allocate(msg.length()); + ByteBuffer smallBuffer = ByteBuffer.allocate(5); + ByteBuffer largeBuffer = ByteBuffer.allocate(msg.length() + 1); + // Scenario 1: test reading into a perfectly-sized buffer + Utils.readFullyOrFail(channel, perfectBuffer, 0, "perfect"); + assertFalse("Buffer should be filled up", perfectBuffer.hasRemaining()); + assertEquals("Buffer should be populated correctly", msg, new String(perfectBuffer.array())); + // Scenario 2: test reading into a smaller buffer + Utils.readFullyOrFail(channel, smallBuffer, 0, "small"); + assertFalse("Buffer should be filled", smallBuffer.hasRemaining()); + assertEquals("Buffer should be populated correctly", "hello", new String(smallBuffer.array())); + // Scenario 3: test reading starting from a non-zero position + smallBuffer.clear(); + Utils.readFullyOrFail(channel, smallBuffer, 7, "small"); + assertFalse("Buffer should be filled", smallBuffer.hasRemaining()); + assertEquals("Buffer should be populated correctly", "world", new String(smallBuffer.array())); + // Scenario 4: test end of stream is reached before buffer is filled up + try { + Utils.readFullyOrFail(channel, largeBuffer, 0, "large"); + fail("Expected EOFException to be raised"); + } catch (EOFException e) { + // expected + } + } + } + + /** + * Tests that `readFullyOrFail` behaves correctly if multiple `FileChannel.read` operations are required to fill + * the destination buffer. + */ + @Test + public void testReadFullyOrFailWithPartialFileChannelReads() throws IOException { + FileChannel channelMock = EasyMock.createMock(FileChannel.class); + final int bufferSize = 100; + ByteBuffer buffer = ByteBuffer.allocate(bufferSize); + StringBuilder expectedBufferContent = new StringBuilder(); + fileChannelMockExpectReadWithRandomBytes(channelMock, expectedBufferContent, bufferSize); + EasyMock.replay(channelMock); + Utils.readFullyOrFail(channelMock, buffer, 0L, "test"); + assertEquals("The buffer should be populated correctly", expectedBufferContent.toString(), + new String(buffer.array())); + assertFalse("The buffer should be filled", buffer.hasRemaining()); + EasyMock.verify(channelMock); + } + + /** + * Tests that `readFullyOrFail` behaves correctly if multiple `FileChannel.read` operations are required to fill + * the destination buffer. + */ + @Test + public void testReadFullyWithPartialFileChannelReads() throws IOException { + FileChannel channelMock = EasyMock.createMock(FileChannel.class); + final int bufferSize = 100; + StringBuilder expectedBufferContent = new StringBuilder(); + fileChannelMockExpectReadWithRandomBytes(channelMock, expectedBufferContent, bufferSize); + EasyMock.replay(channelMock); + ByteBuffer buffer = ByteBuffer.allocate(bufferSize); + Utils.readFully(channelMock, buffer, 0L); + assertEquals("The buffer should be populated correctly.", expectedBufferContent.toString(), + new String(buffer.array())); + assertFalse("The buffer should be filled", buffer.hasRemaining()); + EasyMock.verify(channelMock); + } + + @Test + public void testReadFullyIfEofIsReached() throws IOException { + final FileChannel channelMock = EasyMock.createMock(FileChannel.class); + final int bufferSize = 100; + final String fileChannelContent = "abcdefghkl"; + ByteBuffer buffer = ByteBuffer.allocate(bufferSize); + EasyMock.expect(channelMock.size()).andReturn((long) fileChannelContent.length()); + EasyMock.expect(channelMock.read(EasyMock.anyObject(ByteBuffer.class), EasyMock.anyInt())).andAnswer(new IAnswer<Integer>() { + @Override + public Integer answer() throws Throwable { + ByteBuffer buffer = (ByteBuffer) EasyMock.getCurrentArguments()[0]; + buffer.put(fileChannelContent.getBytes()); + return -1; + } + }); + EasyMock.replay(channelMock); + Utils.readFully(channelMock, buffer, 0L); + assertEquals("abcdefghkl", new String(buffer.array(), 0, buffer.position())); + assertEquals(buffer.position(), channelMock.size()); + assertTrue(buffer.hasRemaining()); + EasyMock.verify(channelMock); + } + + /** + * Expectation setter for multiple reads where each one reads random bytes to the buffer. + * + * @param channelMock The mocked FileChannel object + * @param expectedBufferContent buffer that will be updated to contain the expected buffer content after each + * `FileChannel.read` invocation + * @param bufferSize The buffer size + * @throws IOException If an I/O error occurs + */ + private void fileChannelMockExpectReadWithRandomBytes(final FileChannel channelMock, + final StringBuilder expectedBufferContent, + final int bufferSize) throws IOException { + final int step = 20; + final Random random = new Random(); + int remainingBytes = bufferSize; + while (remainingBytes > 0) { + final int mockedBytesRead = remainingBytes < step ? remainingBytes : random.nextInt(step); + final StringBuilder sb = new StringBuilder(); + EasyMock.expect(channelMock.read(EasyMock.anyObject(ByteBuffer.class), EasyMock.anyInt())).andAnswer(new IAnswer<Integer>() { + @Override + public Integer answer() throws Throwable { + ByteBuffer buffer = (ByteBuffer) EasyMock.getCurrentArguments()[0]; + for (int i = 0; i < mockedBytesRead; i++) + sb.append("a"); + buffer.put(sb.toString().getBytes()); + expectedBufferContent.append(sb); + return mockedBytesRead; + } + }); + remainingBytes -= mockedBytesRead; + } + } + private static class TestCloseable implements Closeable { private final int id; private final IOException closeException;
