Siyao Meng created HDDS-17441:
---------------------------------
Summary: Interrupted short-circuit GetBlock fails with
NullPointerException and leaves the received block file stream open in
XceiverClientShortCircuit
Key: HDDS-17441
URL: https://issues.apache.org/jira/browse/HDDS-17441
Project: Apache Ozone
Issue Type: Bug
Reporter: Siyao Meng
Attachments:
CR-6-release-short-circuit-stream-of-abandoned-request.patch,
TestBugCR6InterruptedReadLeaksBlockStream.java
h3. Mechanism
In {{XceiverClientShortCircuit.sendCommandWithTraceID}} an
{{InterruptedException}} from the wait for the response is caught and the
interrupt flag is restored, but neither a response nor an exception is
recorded, so {{Objects.requireNonNull(ioException)}} throws a
{{NullPointerException}}. {{BlockInputStream.getBlockDataUsingClient}} falls
back to gRPC only on {{IOException}}, so the NPE ends the read.
The request stays registered, the datanode still answers it, and
{{ReceiveResponseTask}} puts the block file stream it receives with the
response into {{blockStreamCache}} under the request's call ID. Only
{{BlockInputStream.getBlockDataUsingSCClient}} takes streams out of the cache
({{getFileInputStream}}), and only after {{getBlock}} returned normally, so
this stream is never claimed. {{close()}} does not touch the cache, and a
{{FileInputStream}} built from a received descriptor registers no cleaner, so
the descriptor stays open for the life of the process.
The same leak happens when a validator rejects a successful GetBlock response
after it arrived. The validators {{BlockInputStream}} uses do not reject such a
response for a normally written block (code reading).
h3. Trigger
A thread that reads a key through short-circuit read is interrupted while it
waits for the GetBlock response, for example a task cancelled with
{{Future.cancel(true)}} or an executor shut down with {{shutdownNow}}.
Short-circuit read must be enabled ({{ozone.client.read.short-circuit}}, off by
default).
h3. Impact
* The read fails with a {{NullPointerException}} instead of an {{IOException}},
so the gRPC fallback and the read retry handling are skipped.
* Each such read leaves one open descriptor of the block file in the client
process, also after {{OzoneClient.close()}}. Repeated cancellations accumulate
them toward the process descriptor limit, and they keep the space of deleted
block files in use (code reading for these two consequences).
h3. Reproduction
PASS on unmodified source. [^TestBugCR6InterruptedReadLeaksBlockStream.java]
({{hadoop-ozone/integration-test}}, needs the hadoop native library) starts a
one datanode MiniOzoneCluster with short-circuit read, writes one key, reads it
from a thread whose interrupt flag is set, closes the {{OzoneClient}} and looks
into the short-circuit client's stream cache by reflection. On unmodified
source the read fails with {{java.lang.NullPointerException}}, and after the
client is closed one stream with a valid descriptor is still in its cache. A
passing test means the defect is present. The reader sets its interrupt before
the read starts; an interrupt that arrives during the wait goes through the
same catch block (code reading). Run on macOS only.
h3. Patch
[^CR-6-release-short-circuit-stream-of-abandoned-request.patch], against
69d77beff60b9106e2517c36a7a1b2cc726af568. It also applies to master at
1acf315d546.
On an interrupt the client now records an {{InterruptedIOException}}, as
{{sendCommand}} already does for its own wait, so the call fails with an
{{IOException}} and the gRPC fallback runs (on the interrupted thread the
fallback also fails with an {{IOException}}). When the caller gives up on a
GetBlock (interrupted, or a validator rejects the response) it completes the
request's future exceptionally and then removes and closes any stream already
cached for the request. {{ReceiveResponseTask}} closes the stream itself when
completing the future fails because the caller already gave up. The receiver
caches the stream before it completes the future and the caller completes the
future before it looks in the cache, so one of the two always finds the stream,
and {{ConcurrentHashMap.remove}} hands it to only one of them, so it is closed
exactly once. Successful reads are unchanged.
{{TestXceiverServerDomainSocket.testClientReleasesStreamOfAbandonedRequest}}
covers both ways of giving up and a normal GetBlock on the same connection. It
records every stream the client receives (through a recording stream cache set
by reflection) and checks that the streams of the abandoned requests were
closed, not only removed from the cache. Without the change it fails because
the interrupted call throws a {{java.lang.NullPointerException}} instead of a
{{java.io.InterruptedIOException}}, and with that check relaxed it fails
because the abandoned request's stream is still in the cache. With the cache
removal kept but the two closes left out, it fails because the abandoned
streams are still open. With the change it passes, together with the rest of
{{TestXceiverServerDomainSocket}}, {{TestXceiverClientManagerSC}},
{{TestLocalChunkInputStream}}, {{TestDomainSocketFactory}},
{{TestBlockInputStream}} and {{TestClientThreadShutdown}}, and checkstyle on
{{hadoop-hdds/client}} and {{hadoop-ozone/integration-test}} is clean. With the
change the reproduction above fails at its first assertion: the interrupted
read fails with an {{IOException}} after the gRPC fallback, which sees the same
interrupt, was attempted.
The test also sets {{DefaultMetricsSystem.setMiniClusterMode(true)}} in the
suite setup, because the short-circuit client starts the metrics system, after
which a later {{ContainerMetrics.create}} in the same suite would fail with a
duplicate source name. The new test imports conflict textually with the patches
of HDDS-17436 and HDDS-17437 (adjacent AssertJ import lines); a rebase resolves
it.
Found by code review of the datanode short-circuit read paths, as part of the
TLA+ verification effort under HDDS-15926, on commit
69d77beff60b9106e2517c36a7a1b2cc726af568. Checked against HDDS issues and
apache/ozone pull requests for duplicates before filing. Related but not
duplicates: HDDS-16517 (open pull request apache/ozone#11294, which simplifies
the client and also turns the interrupt into an {{InterruptedIOException}},
without releasing the stream), HDDS-16335 (open pull request apache/ozone#11225
on concurrent access in the same class, which changes
{{sendCommandWithTraceID}}, {{sendRequest}} and {{ReceiveResponseTask}}, so it
conflicts with this patch too), HDDS-16334 (changed the cache key type).
Merging with #11294 means: its interrupt handling replaces the first part of
this patch; it moves the validator loop from {{sendCommandWithTraceID}} into
{{sendCommand}}, so the release of the stream on a rejected response moves
there with it; and it makes {{sendCommandInternal}} private, so the new test
sends its delaying Echo another way. #11294 does not change
{{blockStreamCache}}. The attached patch is a proposal for review. Generated
with Specula (Claude Opus 5.5).
--
This message was sent by Atlassian Jira
(v10.3.26#10030026)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]