Skip to content

KAFKA-21280: Do not send stale bytes after a failed file read in SslTransportLayer.transferFrom - #23832

Merged
junrao merged 3 commits into
apache:trunkfrom
cheeeee:KAFKA-21280-ssl-stale-buffer
Oct 20, 2026
Merged

junrao merged 3 commits into
apache:trunkfrom
cheeeee:KAFKA-21280-ssl-stale-buffer

Conversation

@cheeeee

@cheeeee cheeeee commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

SslTransportLayer.transferFrom clears fileChannelBuffer before
reading the next chunk of the file. When the read threw, or returned no
bytes, the buffer was left cleared rather than flipped, so it still
looked full of the previous chunk. If bytes had already been written in
the same call the IOException was swallowed, and the next call sent
the stale buffer as the contents of the position that could not be read:
a read error on the segment never surfaced, and the client received a
corrupt batch.

This flips the buffer whether or not the read succeeds, so that it holds
exactly what was read: nothing after a failed read or at the end of the
file. It also rethrows the IOException when bytes were already written
in the same call: the connection is closed on it anyway, and returning
the bytes written only delayed the error to the next call.

Generated-by: Claude (Anthropic)

Reviewers: Jun Rao junrao@gmail.com

…ransportLayer.transferFrom

transferFrom clears fileChannelBuffer before reading the next chunk of
the file. When the read threw, or returned no bytes, the buffer was left
cleared rather than flipped, so it still looked full of the previous
chunk; when bytes had already been written in the same call the
IOException was swallowed, and the next call sent the stale buffer as
the contents of the position that could not be read. A read error on
the segment never surfaced and the client received a corrupt batch.

Flip the buffer whether or not the read succeeds, so that it holds
exactly what was read: nothing after a failed read. The next call then
reads again and the original IOException propagates.

Generated-by: Claude (Anthropic)
@cheeeee

cheeeee commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Updated for 854779b. 868d825 rethrows a failed read even when part of the transfer was already written, and
854779b documents that the channel must not be used after a failed transferFrom and checks what was sent after
the first call in both tests. The runs below are on 868d825; on 854779b SslTransportLayerTest passes.

Two tests in SslTransportLayerTest set up a TLS connection, then call transferFrom with a mocked file channel
whose first 32 KiB are readable, recording what transferFrom writes instead of sending it. In
testTransferFromDoesNotSendStaleBytesAfterFailedRead reads past that fail: the call must throw after writing the
readable part, and so must a call from the failed position. In testTransferFromDoesNotSendStaleBytesAtEndOfFile the
file ends there, and a call from that position must return 0. On trunk both fail for TLSv1.2 and TLSv1.3: the first
test's first call returns the bytes written instead of throwing, and in the second test the second call sends the
first chunk again.

End to end, this PR at 868d825: one controller and three brokers in Docker on a Linux 6.8 host, the leader's log
directory on dm-dust, the tail of a 10 MiB segment made unreadable, a consumer reading the partition from the
beginning over the SSL listener. Apart from this change, SslTransportLayer is the same in all three builds:

trunk (03e6606) trunk (b9b0c90) + this PR #23445 (4b1b646) + this PR
pread64 -EIO 169 518 2 (1 on the network thread, 1 the re-read on the scheduler)
read error logged by the broker no 518 x disconnected with IOException: Input/output error ERROR from LogDirFailureChannel
corrupt batch sent to the consumer yes no no
log directory / leader afterwards online / 1 online / 1 offline / 2
records consumed 5456, then Record is corrupt 0 of 20000, the fetch fails 20000 of 20000

With this PR alone the read error surfaces as it does over PLAINTEXT (each failed send logs it and closes the
connection) instead of turning into a corrupt batch. Offlining the directory on it is #23445; with both, the SSL run
behaves like the PLAINTEXT one: the directory goes offline, leadership moves and the consumer reads everything.

Ran the full clients, storage, raft, server and core test suites on 868d825, as a non-root user, on the
same host under a median load average of 72 on 70 cores: 3 test classes failed, ReassignReplicaShrinkTest in
storage and ReplicationQuotasTest and RequestQuotaTest in core. The two quota tests pass when re-run;
ReassignReplicaShrinkTest, a tiered storage test over PLAINTEXT that failed again in the re-run (4 segment fetches
from remote storage instead of 3), passes 5 times out of 5 on this PR and on trunk (b9b0c90) when run alone.
Checkstyle and spotless pass for clients.

@junrao junrao left a comment

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 PR. Left a comment.

int bytesRead;
try {
bytesRead = fileChannel.read(fileChannelBuffer, pos);
} finally {

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 following code seems to be copied from the JDK implementation. In Kafka's usage, we typically close a connection on IOException, it seems that we can just rethrow on IOException without returning totalBytesWritten.

        } catch (IOException e) {
            if (totalBytesWritten > 0)
                return totalBytesWritten;
            throw e;
        }

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: transferFrom now rethrows. I kept the flip, since a read that returns -1, e.g. after a concurrent truncation, throws nothing, and without the flip the buffer would still look full of the previous read on the next call. FileRecords.writeTo fails that next call with its truncation check unless the file has grown back past the slice by then, but transferFrom should not rely on its caller for that; testTransferFromDoesNotSendStaleBytesAtEndOfFile covers it.

An IOException from transferFrom closes the connection, so returning the
bytes already written only delayed the error to the next call. Rethrow it
instead. The buffer is still flipped after a failed read, and after a read
at the end of the file, which throws nothing.

Generated-by: Claude (Anthropic)
@cheeeee

cheeeee commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, Jun. Addressed in 868d825; the description and the test notes are updated.

@github-actions github-actions Bot removed the triage PRs from the community label Oct 17, 2026

@junrao junrao left a comment

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 PR. A couple of minor comments.

assertThrows(IOException.class, () -> layer.transferFrom(fileChannel, 0, 3 * TRANSFER_SIZE));
// A transfer from the unreadable position must fail as well instead of sending what was read before
assertThrows(IOException.class, () -> layer.transferFrom(fileChannel, TRANSFER_SIZE, 2 * TRANSFER_SIZE));
assertArrayEquals(fileContents(TRANSFER_SIZE), sent.toByteArray());

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.

Should we add this check after the first transferFrom() too? Ditto in the test below.

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, in both tests.

int bytesRead = fileChannel.read(fileChannelBuffer, pos);
if (bytesRead <= 0)
break;
while (totalBytesWritten < totalBytesToWrite) {

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.

Could we add a javadoc that transferFrom() throws IOException, after which, the channel shouldn't be used?

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.

… failed transferFrom

Also check what was sent after the first transferFrom call in both tests.

Generated-by: Claude (Anthropic)
@cheeeee

cheeeee commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, Jun. Addressed in 854779b; the test notes are updated.

junrao pushed a commit that referenced this pull request Oct 20, 2026
…py Fetch path (#23445)

The Fetch response is sent zero copy: the records are read from the
segment by `FileRecords.writeTo`, on the network thread, while the
response is written to the socket. A failed read of the medium and a
client that went away both arrive there as a bare `IOException`, and
`Selector` treats every one of them as a disconnect. A disk error on
this path therefore never offlines the log directory, unlike an I/O
error in the `LocalLog` operations.

This change offlines it, with the same split as elsewhere in the log:
the layer that sees the `IOException` reports it, and storage decides.

* `LocalLog.read` gives the `FileRecords` slices it returns a transfer
  failure handler.
* When the transfer fails, `FileRecords.writeTo` passes the region it
  was writing to the handler and rethrows the original exception
  unchanged. A `ClosedChannelException` is not passed on: the file was
  not read.
* The handler re-reads the region on the log's scheduler, at most once
  at a time per log directory, and calls
  `LogDirFailureChannel.maybeAddOfflineLogDir` only if the read fails,
  with the failed transfer attached. As the log cleaner does, the
  directory is offlined and the error is not rethrown.

The network thread does no extra I/O on a failed send. The send itself
is logged as a disconnect, at DEBUG; the ERROR comes from
`LogDirFailureChannel`.

The classification is as accurate as what the JVM reports, and no more.
Nothing below the JVM is second-guessed: a transient media error that
has cleared by the time of the re-read is attributed to the socket, and
a read that fails for a reason other than the medium is treated as a
storage failure, the criterion `LocalLog` already applies. At the JVM
level, the re-read of the region:

* reads completely: the socket was at fault;
* reaches the end of the file: the log was truncated concurrently (e.g.
  the replica became a follower);
* makes no progress: nothing can be concluded;
* fails with `ClosedChannelException` or a subclass: the segment was
  closed or deleted concurrently, which says nothing about the medium
  (the reasoning of KAFKA-19571);
* fails with any other `IOException`: the log directory is offlined.

The whole region is re-read rather than its first byte because,
depending on the platform, the transfer can fail anywhere within it
without reporting progress: on macOS both `sendfile(2)` and `read(2)`
drop the partial progress of a chunk whose read fails, and on Windows
the JDK does not use `TransmitFile` for a non-blocking socket and falls
back to a mapped buffer or `ReadFile`. On Linux the transfer fails at
the first unreadable page. The re-read reads at most what the failed
call was about to send. The message text of the exception is not
inspected: it is platform specific.

Once the directory is offline its segments are closed, and the next
Fetch gets `KAFKA_STORAGE_ERROR`.

Consequences worth stating:

* A broker whose only log directory, or whose metadata log directory,
  fails on this path now shuts down, as it does today for any other I/O
  error in that directory.
* The KRaft metadata log is read through `LocalLog` too. A read failure
  while the leader sends it marks the raft log's directory offline, the
  next append on the leader fails, and the raft I/O thread's fatal fault
  handler stops the node, as it does today for an I/O error in
  `LocalLog.read` on the metadata log.
* The checks run on the broker's shared background scheduler (the raft
  manager's own scheduler for the metadata log).
* KRaft snapshots are sent through `UnalignedFileRecords`, which this
  change does not cover; I will follow up separately.
* On TLS listeners a read error does not reach `FileRecords` today:
  `SslTransportLayer.transferFrom` swallows it and sends a stale buffer
  instead (KAFKA-21280, #23832). This change takes effect on TLS
  listeners once that is fixed.

Generated-by: Claude (Anthropic)

Reviewers: Jun Rao <junrao@gmail.com>

@junrao junrao left a comment

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 updated PR. LGTM

@junrao
junrao merged commit 46dc4b5 into apache:trunk Oct 20, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants