Repository navigation
KAFKA-16304: Offline the log directory on a disk error in the zero-copy Fetch path #23445
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
435ed52
0bb8dfc
de4c0d1
4b1b646
f371064
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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 { | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. channel => network channel?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||
| private final TransferFailureHandler transferFailureHandler; | ||
|
|
||
| private final Iterable<FileLogInputStream.FileChannelRecordBatch> batches; | ||
|
|
||
|
|
@@ -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( | ||
|
|
@@ -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); | ||
|
|
@@ -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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. writing to a channel => writing to a network channel ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude reported the following.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude suggested the following optimzation. Gemini seems to confirm the validity of this optimization.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably worth adding that this is an absolute file position.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done, here and in |
||
| * @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)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to a channel => to a network channel
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.transferFromreads the file within the call thatFileRecords.writeTomakes, so a read error there reaches the same handler. What stops it today is KAFKA-21280: once part of the region has been sent,transferFromswallows 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.There was a problem hiding this comment.
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()?
There was a problem hiding this comment.
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
IOExceptionat thefileChannel.readcall would letLocalLogtake 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:SslTransportLayer.transferFromas KAFKA-21280: Do not send stale bytes after a failed file read in SslTransportLayer.transferFrom #23832, which fixes a bug present since 2.5.0 and could be backported on its own, so I'd rather keep that PR small;sendfilereads and sends in one call and reports a disk error and a socket error as the sameIOException, so there is no read to wrap: the re-read stays the way to tell them apart there, and the new type only lets the TLS path skip it.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?
There was a problem hiding this comment.
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.