Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
Expand All @@ -41,9 +42,23 @@
* instance to enable slicing a range of the log records.
*/
public class FileRecords extends AbstractRecords implements Closeable {
// Size of the buffer used to re-read a region that failed to transfer, see readFailure
static final int REREAD_BUFFER_SIZE = 32 * 1024;

/**
* Notified when {@link #writeTo} fails, typically to find out with {@link #readFailure} whether the backing file
* could not be read. It is called on the thread that failed to write, a network thread, so it must not block.
*/
@FunctionalInterface
public interface TransferFailureHandler {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency, should we hook this up to SslTransportLayer.transferFrom() to handle disk IO error during read too? This could be done in a separate followup PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No separate hook is needed: SslTransportLayer.transferFrom reads the file within the call that FileRecords.writeTo makes, so a read error there reaches the same handler. What stops it today is KAFKA-21280: once part of the region has been sent, transferFrom swallows the error and sends a stale buffer instead. #23832 fixes that. On the cluster in the test notes, a TLS listener with #23832 offlined the directory and all 20000 records were consumed; without it the consumer failed on a corrupt record.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the explanation. Agreed.

Since SslTransportLayer.transferFrom() first reads from file channel and then sends the bytes to the network channel. We can categorize the disk IO error more accurately here. Perhaps we can add a new type of disk related IOException through TransferableChannel.transferFrom()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Now that #23832 rethrows the read error, wrapping it in a dedicated IOException at the fileChannel.read call would let LocalLog take the directory offline on TLS listeners without a re-read, and would also catch a media error that has cleared by the time of the re-read. I'd like to do it in a second PR for KAFKA-16304, after this PR and #23832 are merged:

Would that be ok with you, and does this order of the three PRs work for you: this PR and #23832 first, in either order, then the follow-up?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plan sounds good to me.

void onTransferFailure(FileRecords records, long position, int count, IOException transferFailure);
}

private final boolean isSlice;
private final int start;
private final int end;
// Notified when writing to a channel fails, or null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

channel => network channel?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

private final TransferFailureHandler transferFailureHandler;

private final Iterable<FileLogInputStream.FileChannelRecordBatch> batches;

Expand All @@ -66,6 +81,7 @@ public class FileRecords extends AbstractRecords implements Closeable {
this.start = 0;
this.end = end;
this.isSlice = false;
this.transferFailureHandler = null;

if (channel.size() > Integer.MAX_VALUE) {
throw new KafkaException(
Expand All @@ -92,13 +108,15 @@ private FileRecords(
File file,
FileChannel channel,
int start,
int end
int end,
TransferFailureHandler transferFailureHandler
) {
this.file = file;
this.channel = channel;
this.start = start;
this.end = end;
this.isSlice = true;
this.transferFailureHandler = transferFailureHandler;

// don't check the file size since this is just a slice view
this.size = new AtomicInteger(end - start);
Expand Down Expand Up @@ -146,7 +164,26 @@ public FileRecords slice(int position, int size) {
int availableBytes = availableBytes(position, size);
int startPosition = this.start + position;

return new FileRecords(file, channel, startPosition, startPosition + availableBytes);
return new FileRecords(file, channel, startPosition, startPosition + availableBytes, transferFailureHandler);
}

/**
* Return a copy of this slice whose {@link #writeTo} notifies the given handler when writing to a channel fails.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writing to a channel => writing to a network channel ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

* Slices taken from it with {@link #slice} notify the same handler. This instance is not modified.
*
* @param handler The handler to notify
* @return A copy of this slice that notifies the handler
* @throws IllegalStateException If this instance is not a slice
*/
public FileRecords withTransferFailureHandler(TransferFailureHandler handler) {
if (!isSlice)
throw new IllegalStateException("A transfer failure handler can only be set on a slice of " + file);
return new FileRecords(file, channel, start, end, Objects.requireNonNull(handler));
}

// Visible for testing
public TransferFailureHandler transferFailureHandler() {
return transferFailureHandler;
}

/**
Expand Down Expand Up @@ -278,17 +315,61 @@ public int truncateTo(int targetSize) throws IOException {

@Override
public int writeTo(TransferableChannel destChannel, int offset, int length) throws IOException {
long newSize = Math.min(channel.size(), end) - start;
int oldSize = sizeInBytes();
if (newSize < oldSize)
throw new KafkaException(String.format(
"Size of FileRecords %s has been truncated during write: old size %d, new size %d",
file.getAbsolutePath(), oldSize, newSize));

long position = start + offset;
int count = Math.min(length, oldSize - offset);
// safe to cast to int since `count` is an int
return (int) destChannel.transferFrom(channel, position, count);
try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude reported the following.

channel.size() is outside the new try (FileRecords.java:310). An IOException from it skips the handler. That's rare, but moving it inside the try costs nothing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

long newSize = Math.min(channel.size(), end) - start;
if (newSize < oldSize)
throw new KafkaException(String.format(
"Size of FileRecords %s has been truncated during write: old size %d, new size %d",
file.getAbsolutePath(), oldSize, newSize));
// safe to cast to int since `count` is an int
return (int) destChannel.transferFrom(channel, position, count);
} catch (IOException e) {
// The transfer reads the file and writes the socket in one call, so a storage failure and a socket
// failure both arrive here as a bare IOException. Let the handler find out which one it was. A closed
// channel means the file was not read, so there is nothing to find out.
if (transferFailureHandler != null && !(e instanceof ClosedChannelException))
transferFailureHandler.onTransferFailure(this, position, count, e);
throw e;
}
}

/**
* Read the given region of the backing file, after {@link #writeTo} failed on it, to tell a storage failure from

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude suggested the following optimzation.

1. Re-read only the first chunk (FileRecords.readFailure). With both transports, an IOException only escapes from a transferFrom call that sent nothing:

SSL: explicitly, through if (totalBytesWritten > 0) return totalBytesWritten;.
Plaintext: sendfile reports partial progress before an error.
So the bytes that failed to read are at position, within the first REREAD_BUFFER_SIZE bytes. Reading only that much:

cuts the extra disk reads caused by ordinary disconnects, which are most transfer failures;
shortens how long a slow or hung disk holds a scheduler thread;
keeps the per-directory slot free more often, so a real failure elsewhere in the directory is less likely to be skipped while a harmless check runs. That was the reviewer's finding 1. It's still best effort, but the client retrying the bad offset gives another chance.
If you do this, update the Javadoc, which says "the transfer may fail anywhere within it without reporting progress". I haven't checked every fallback path inside the JDK's transferTo. The buffered fallback reports partial progress too, and the mmap one fails with an InternalError rather than an IOException, so it never reaches this handler. A quick check before relying on this would be worthwhile.

Gemini seems to confirm the validity of this optimization.

When transferTo() throws an IOException due to a disk issue (such as a bad sector or read error), the physical defect is located within the very first OS page or disk block (typically 4KB to 8KB) starting at the position provided in that specific method call.

It does not mean the error occurred at the exact single byte of position, but it does mean the error is at the immediate start of the requested read chunk.

Here is how the underlying mechanics dictate this behavior:

1. Partial Transfers Prevent "Future" Exceptions
If you request a large transfer (e.g., 1GB) and the disk has a bad sector 500MB into the file, transferTo will not throw an exception on your initial call.

Instead, the underlying OS system call (like Linux sendfile or macOS copyfile) will successfully transfer the healthy data up to the error, and Java will return the number of successfully transferred bytes (e.g., 524288000).

You are then expected to loop and call transferTo again with the position updated to the new offset. Only on this subsequent call, where the bad sector is now sitting immediately at the new position, will the OS fail immediately and cause Java to throw the IOException.

2. Disk Block Resolution
When the exception is finally thrown, the exact corrupted byte might not be the very first byte at position because storage hardware and operating systems do not read files byte-by-byte. They read in blocks or memory pages (usually 4,096 bytes).

If position points to the start of a 4KB block, and the disk hardware encounters a cyclic redundancy check (CRC) failure at the 2,000th byte of that block, the hardware fails the read for the entire block. The OS returns an EIO (Input/Output Error) to Java, which throws the exception. The error is somewhere inside that first block.

State of the Transfer upon Exception
Because of how OS-level POSIX I/O operates, if a transferTo call throws an IOException due to a disk read error:

Previous calls: Any bytes counted in the return values of previous transferTo calls were successfully read and sent to the target.

Current call: The target WritableByteChannel received exactly zero bytes from the specific transferTo call that threw the exception.

Location: The disk failure is guaranteed to be within the first hardware block (usually within 4KB) of the position argument that caused the crash.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Jun. I'd rather keep re-reading the whole region. The premise no longer holds on TLS: with #23832, transferFrom rethrows after partial progress, and in the SSL run in the test notes the read failed at position 2613248 of a transfer of 8388608 bytes from position 0. On macOS, sendfile reads 64 pages (256 KiB) at a time and drops the progress of the chunk whose read fails, so the bad bytes can be up to 256 KiB past position.

Re-reading the whole region does not depend on where each platform fails the transfer, so Linux, macOS and Windows are handled the same way, for any read failure the JVM reports within the region, and it is cheap on all of them: a disconnect usually comes right after the send, so the region is in the page cache. In the test notes we measured 64 KiB, 1 MiB and 8 MiB regions on the test host, a VM on RAID 0 SATA SSDs under load: the re-read took 0.1 to 2.3 ms from the page cache and 0.9 to 29 ms (median) from disk, on the scheduler and at most one at a time per log directory. The numbers depend on the host and should not be taken literally, but their spread shows the order of the cost.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cheeeee : Thanks for the explanation. We can keep the change as it is.

* a socket failure. The whole region is read rather than its first byte because, depending on the platform, the
* transfer may fail anywhere within it without reporting progress. Only a failed read of an open channel counts
* as a storage failure: the end of the file may be reached because the log was truncated concurrently (e.g. when
* the replica becomes a follower), and the channel may have been closed concurrently, by a deletion or a
* shutdown; neither says anything about the medium.
*
* @param position The position of the region in the file

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably worth adding that this is an absolute file position.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, here and in TransferFailureHandler.onTransferFailure.

* @param count The size of the region
* @return The failure of the read, naming the file and the position, or empty if the region could be read or
* nothing can be concluded
*/
public Optional<IOException> readFailure(long position, int count) {
ByteBuffer buffer = ByteBuffer.allocate(Math.min(count, REREAD_BUFFER_SIZE));
long regionEnd = position + count;
long readPosition = position;
try {
while (readPosition < regionEnd) {
buffer.clear();
buffer.limit((int) Math.min(buffer.capacity(), regionEnd - readPosition));
int bytesRead = channel.read(buffer, readPosition);
if (bytesRead <= 0)
return Optional.empty();
readPosition += bytesRead;
}
return Optional.empty();
} catch (ClosedChannelException e) {
return Optional.empty();
} catch (IOException e) {
return Optional.of(new IOException("Failed to read " + file.getAbsolutePath() + " at position "
+ readPosition + " after failing to write " + count + " bytes from position " + position
+ " to a channel", e));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to a channel => to a network channel

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mockito;
import org.mockito.invocation.InvocationOnMock;

import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.FileChannel;
import java.util.ArrayList;
import java.util.Collections;
Expand All @@ -51,6 +53,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
Expand All @@ -72,6 +75,8 @@ public class FileRecordsTest {
"efgh".getBytes(),
"ijkl".getBytes()
};
private static final String LOG_FILE_PATH = "/logs/topic-0/00000000000000000000.log";

private FileRecords fileRecords;

@BeforeEach
Expand Down Expand Up @@ -519,6 +524,185 @@ public void testBytesLengthOfWriteTo() throws IOException {
verify(channel).transferFrom(any(), anyLong(), eq((long) size - firstWritten));
}

/**
* A failed write is reported to the handler with the region being written, and the exception is rethrown as it
* arrived: the handler finds out later whether the file could be read.
*/
@Test
public void testWriteToNotifiesHandlerAndRethrows() throws IOException {
List<List<Object>> notifications = new ArrayList<>();
FileRecords records = fileRecords.slice(1, fileRecords.sizeInBytes() - 1)
.withTransferFailureHandler(recordingHandler(notifications));
IOException socketFailure = new IOException("Broken pipe");

IOException thrown = assertThrows(IOException.class,
() -> records.writeTo(failingChannel(socketFailure), 2, records.sizeInBytes() - 2));
assertSame(socketFailure, thrown);
assertEquals(List.of(List.of(records, 3L, records.sizeInBytes() - 2, socketFailure)), notifications);
}

/**
* A closed channel means the file was not read, so there is nothing for the handler to find out.
*/
@Test
public void testWriteToDoesNotNotifyHandlerWhenChannelClosed() throws IOException {
List<List<Object>> notifications = new ArrayList<>();
FileRecords records = fileRecords.slice(0, fileRecords.sizeInBytes())
.withTransferFailureHandler(recordingHandler(notifications));
IOException closed = new ClosedChannelException();

IOException thrown = assertThrows(IOException.class,
() -> records.writeTo(failingChannel(closed), 0, records.sizeInBytes()));
assertSame(closed, thrown);
assertTrue(notifications.isEmpty());
}

/**
* Getting the size of the file before the transfer is part of writing to the channel, so its failure is
* reported too.
*/
@Test
public void testWriteToNotifiesHandlerWhenSizeFails() throws IOException {
FileChannel fileChannel = fileChannelOfSize(1024);
List<List<Object>> notifications = new ArrayList<>();
FileRecords records = new FileRecords(mock(File.class), fileChannel, 1024).slice(0, 1024)
.withTransferFailureHandler(recordingHandler(notifications));
IOException sizeFailure = new IOException("Input/output error");
when(fileChannel.size()).thenThrow(sizeFailure);
TransferableChannel channel = mock(TransferableChannel.class);

IOException thrown = assertThrows(IOException.class, () -> records.writeTo(channel, 0, 1024));
assertSame(sizeFailure, thrown);
assertEquals(List.of(List.of(records, 0L, 1024, sizeFailure)), notifications);
verify(channel, never()).transferFrom(any(), anyLong(), anyLong());
}

@Test
public void testTransferFailureHandlerIsKeptBySlices() {
FileRecords.TransferFailureHandler handler = (records, position, count, e) -> { };
FileRecords slice = fileRecords.slice(0, fileRecords.sizeInBytes());
FileRecords records = slice.withTransferFailureHandler(handler);

assertSame(handler, records.transferFailureHandler());
assertSame(handler, records.slice(1, records.sizeInBytes() - 1).transferFailureHandler());
assertEquals(slice.sizeInBytes(), records.sizeInBytes());
assertNull(slice.transferFailureHandler());
}

/**
* Only slices are written to a channel; the records of a whole segment keep growing.
*/
@Test
public void testTransferFailureHandlerCannotBeSetOnNonSlice() {
assertThrows(IllegalStateException.class,
() -> fileRecords.withTransferFailureHandler((records, position, count, e) -> { }));
}

@Test
public void testReadFailureIsEmptyWhenRegionIsReadable() {
assertEquals(Optional.empty(), fileRecords.readFailure(0, fileRecords.sizeInBytes()));
}

@Test
public void testReadFailureAtStartOfRegion() throws IOException {
FileChannel fileChannel = fileChannelOfSize(1024);
IOException readFailure = new IOException("Input/output error");
when(fileChannel.read(any(ByteBuffer.class), anyLong())).thenThrow(readFailure);

IOException failure = recordsOn(fileChannel, 1024).readFailure(0, 1024).orElseThrow();
assertSame(readFailure, failure.getCause());
assertTrue(failure.getMessage().contains(LOG_FILE_PATH + " at position 0"));
}

/**
* Depending on the platform, the transfer may fail anywhere within the region without reporting progress, so a
* failure past the first bytes of the region must be found as well.
*/
@Test
public void testReadFailureInsideRegion() throws IOException {
int size = 2 * FileRecords.REREAD_BUFFER_SIZE;
FileChannel fileChannel = fileChannelOfSize(size);
IOException readFailure = new IOException("Input/output error");
when(fileChannel.read(any(ByteBuffer.class), anyLong()))
.thenAnswer(FileRecordsTest::fillBuffer)
.thenThrow(readFailure);

IOException failure = recordsOn(fileChannel, size).readFailure(0, size).orElseThrow();
assertSame(readFailure, failure.getCause());
assertTrue(failure.getMessage().contains("at position " + FileRecords.REREAD_BUFFER_SIZE));
verify(fileChannel).read(any(ByteBuffer.class), eq(0L));
verify(fileChannel).read(any(ByteBuffer.class), eq((long) FileRecords.REREAD_BUFFER_SIZE));
}

/**
* The log can be truncated while the region is re-read, for instance when the replica becomes a follower while
* a fetch is being served. The end of the file says nothing about the medium.
*/
@Test
public void testReadFailureIsEmptyWhenTruncatedDuringRead() throws IOException {
int size = 2 * FileRecords.REREAD_BUFFER_SIZE;
FileChannel fileChannel = fileChannelOfSize(size);
when(fileChannel.read(any(ByteBuffer.class), anyLong()))
.thenAnswer(FileRecordsTest::fillBuffer)
.thenReturn(-1);

assertEquals(Optional.empty(), recordsOn(fileChannel, size).readFailure(0, size));
}

/**
* A channel closed while the region is re-read, by a deletion of the segment or a shutdown, says nothing about
* the medium.
*/
@Test
public void testReadFailureIsEmptyWhenChannelClosedDuringRead() throws IOException {
FileChannel fileChannel = fileChannelOfSize(1024);
when(fileChannel.read(any(ByteBuffer.class), anyLong())).thenThrow(new ClosedChannelException());

assertEquals(Optional.empty(), recordsOn(fileChannel, 1024).readFailure(0, 1024));
}

/**
* A read that makes no progress cannot conclude anything, and must not spin.
*/
@Test
public void testReadFailureIsEmptyWhenReadMakesNoProgress() throws IOException {
FileChannel fileChannel = fileChannelOfSize(1024);
when(fileChannel.read(any(ByteBuffer.class), anyLong())).thenReturn(0);

assertEquals(Optional.empty(), recordsOn(fileChannel, 1024).readFailure(0, 1024));
}

// Records each notification as a list of the handler's arguments
private static FileRecords.TransferFailureHandler recordingHandler(List<List<Object>> notifications) {
return (records, position, count, e) -> notifications.add(List.of(records, position, count, e));
}

private static FileChannel fileChannelOfSize(long size) throws IOException {
FileChannel fileChannel = mock(FileChannel.class);
when(fileChannel.size()).thenReturn(size);
return fileChannel;
}

private static FileRecords recordsOn(FileChannel fileChannel, int size) throws IOException {
File file = mock(File.class);
when(file.getAbsolutePath()).thenReturn(LOG_FILE_PATH);
return new FileRecords(file, fileChannel, size);
}

private static TransferableChannel failingChannel(IOException failure) throws IOException {
TransferableChannel channel = mock(TransferableChannel.class);
when(channel.transferFrom(any(), anyLong(), anyLong())).thenThrow(failure);
return channel;
}

// An answer to FileChannel.read(ByteBuffer, long) that fills the buffer
private static int fillBuffer(InvocationOnMock invocation) {
ByteBuffer buffer = invocation.getArgument(0);
int bytesRead = buffer.remaining();
buffer.position(buffer.limit());
return bytesRead;
}

/**
* Test two conditions:
* 1. If the target offset equals the base offset of the first batch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ public FetchDataInfo(LogOffsetMetadata fetchOffsetMetadata,
this.delayedRemoteStorageFetch = delayedRemoteStorageFetch;
}

/**
* @return A copy of this instance with the given records
*/
public FetchDataInfo withRecords(Records records) {
return new FetchDataInfo(fetchOffsetMetadata, records, firstEntryIncomplete, abortedTransactions, delayedRemoteStorageFetch);
}

public static FetchDataInfo empty(long fetchOffset) {
return new FetchDataInfo(new LogOffsetMetadata(fetchOffset), MemoryRecords.EMPTY);
}
Expand Down
Loading
Loading