Siyao Meng created HDDS-17002:
---------------------------------

             Summary: Streaming block read after unbuffer() requests nothing at 
the current position, times out and excludes a healthy datanode
                 Key: HDDS-17002
                 URL: https://issues.apache.org/jira/browse/HDDS-17002
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: 
MC-4-reset-stream-read-request-mark-on-reader-close.patch, 
TestBugMC4StreamReadUnbuffer.java

h3. Mechanism
{{StreamBlockInputStream.unbuffer}} calls {{releaseClient}}, which calls 
{{closeReader}}. That discards the {{StreamingReader}} together with every 
response still owed to it, but leaves {{requestedLength}} unchanged. On the 
next read a new reader is opened and {{StreamBlockInputStream.readBlock}} 
counts {{requestedLength - position}} as already requested: for a block shorter 
than the pre-read size {{readLength}} is clamped to 0, for a longer block the 
"already requested, above refill threshold" branch usually returns early. 
Either way nothing covering the current position is requested on the new stream 
(in the remaining case, a longer block whose stale window has drained just 
below the refill threshold, a ReadBlock starts at the old mark, past the 
position, and its responses are discarded; this case is from code reading). 
{{StreamingReader.poll}} then times out, and {{handleExceptions}} adds the 
healthy datanode to {{failedStreamingDatanodes}}, which is never cleared for 
that stream. After one such cycle per replica 
{{XceiverClientGrpc.initStreamRead}} has no datanode left and throws.

{{handleExceptions}} already resets {{requestedLength = position}} after 
{{releaseClient}}, so a new reader is expected to start from the current 
position; the unbuffer path skips that reset.

h3. Trigger
# {{ozone.client.stream.readblock.enable=true}}, a RATIS/THREE key, all 
datanodes healthy.
# Open the key and {{read()}}.
# {{unbuffer()}} (for example through {{OzoneFSInputStream}}, which implements 
{{CanUnbuffer}}), then {{read()}} again. Repeat.

No fault is needed. This is the usual pattern of {{CanUnbuffer}} callers that 
keep a stream open and release resources between reads. Only a sequential read 
that continues from the same position after {{unbuffer()}} is affected: 
positioned reads ({{readPositioned}}) do not use {{requestedLength}}, and a 
{{seek()}} to another position after {{unbuffer()}} resets it.

h3. Impact
* Every read after {{unbuffer()}} stalls for 
{{ozone.client.stream.read.timeout}} plus the read retry interval (4.1 s 
observed with a 3 s timeout, so about 11 s with the default 10 s timeout, not 
run) and excludes one more healthy datanode from that stream.
* After one unbuffer and read cycle per replica the read throws {{IOException: 
Failed to start streaming read to any available DataNodes}} on a healthy 
cluster. Reopening the key starts with an empty exclusion set.
* Only with streaming block reads enabled (off by default). No wrong data was 
observed: the bytes read after each stall were correct.

h3. Reproduction
PASS, deterministic, unmodified source. [^TestBugMC4StreamReadUnbuffer.java] 
({{hadoop-ozone/integration-test}}) starts a mini cluster with three datanodes, 
writes a 32 KiB RATIS/THREE key and reads it with a streaming read client (3 s 
stream read timeout), using only {{read()}} and {{unbuffer()}}; reflection is 
used only to inspect the exclusion set. A passing test means the defect is 
present. Output on the unmodified source:
{noformat}
MC-4 cycle 1: read after unbuffer took 4114 ms, excluded datanodes 
[a1638c9a-...]
MC-4 cycle 2: read after unbuffer took 4102 ms, excluded datanodes 
[a1638c9a-..., 8bbfd668-...]
MC-4 cycle 3: read after unbuffer threw java.io.IOException: Failed to start 
streaming read to any available DataNodes
{noformat}
Each stalled cycle logs {{Failed to poll a response, timed out PT3S}} followed 
by {{Excluding DataNode ... from streaming read retries}}. With the patch the 
test fails at cycle 1 (2 ms, nothing excluded).

h3. Patch
[^MC-4-reset-stream-read-request-mark-on-reader-close.patch], against 
1fb64c8e499161d1452ae6948ffd461fc9d2c302. It also applies to master at 
8664dc4fc3c.

{{closeReader}} sets {{requestedLength = position}} when it discards the 
reader, so whatever path discards a reader ({{unbuffer}}, {{close}}, the retry 
path, end of block), the next reader requests again from the current position. 
Behaviour of the other callers does not change: the retry path already reset, 
and at end of block both values are already the block length.

Covered by {{testStreamReadAfterUnbuffer}} in the existing 
{{TestStreamReadDatanodeFailover}}, which unbuffers before the first read and 
then between reads, once more than there are replicas, and checks the rest of 
the key. It runs with a pre-read window of 16 KiB and of 32 MiB on a 32 KiB 
key, so both the clamp and the refill threshold branch are exercised. Without 
the change both cases fail with {{IOException: Failed to start streaming read 
to any available DataNodes}}. With it {{TestStreamReadDatanodeFailover}} (5 
tests), {{TestStreamRead}} (4), the integration {{TestStreamBlockInputStream}} 
(3) and the unit {{TestStreamBlockInputStream}} (29) and 
{{TestMultipartInputStream}} (6) pass, checkstyle is clean.

Found by TLA+ model checking and code review of the Ozone client read retry and 
failover paths under HDDS-15926, on commit 
1fb64c8e499161d1452ae6948ffd461fc9d2c302. Checked against HDDS issues and 
apache/ozone pull requests for duplicates before filing. The attached patch is 
a proposal for review. Generated with Specula (Claude Opus 5.5).



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to