Siyao Meng created HDDS-17000:
---------------------------------

             Summary: Sequential read retried after a failed ReadChunk silently 
skips the bytes it had already copied from the chunk buffer
                 Key: HDDS-17000
                 URL: https://issues.apache.org/jira/browse/HDDS-17000
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: MC-1-rewind-chunk-stream-on-failed-partial-read.patch, 
TestBugMC1PartialChunkReadRetry.java

h3. Mechanism
{{ChunkInputStream.read(byte[], int, int)}} and 
{{ChunkInputStream.read(ByteBuffer)}} can do two things in one call. They first 
copy the bytes still buffered from the previous ReadChunk into the caller's 
buffer, which moves the chunk position past them, and then send another 
ReadChunk for the rest. If that ReadChunk throws, the count of copied bytes is 
lost with the exception, but the chunk position stays moved.

{{BlockInputStream.readWithStrategy}} then refreshes the pipeline and token and 
retries. The retry computes its length from the moved position, while the 
{{ByteArrayReader}} offset never advanced, so the retry writes the following 
bytes over the start of the caller's buffer and reports success. The 
{{numBytesRead != readLength}} check in {{BlockInputStream}} and 
{{KeyInputStream.checkPartBytesRead}} both pass. With {{ByteBufferReader}} the 
buffer position did advance, so the data lands in the right place but the count 
is short and the read fails with "Inconsistent read" instead of being retried.

The positioned read path in the same class already resets the destination on 
retry ({{dst.position(startPosition)}} in {{BlockInputStream.readChunkAt}}); 
the sequential path has no equivalent.

h3. Trigger
# Checksum verification is on ({{ozone.client.verify.checksum}}, default true), 
so a ReadChunk fetches whole checksum ranges (the bytes per checksum the key 
was written with, {{ozone.client.bytes.per.checksum}} of the writer, default 
16KB) and a read that ends inside a range leaves the rest of it buffered.
# A sequential read of a replicated key, with the default 
{{ozone.client.stream.readblock.enable}}=false, starts inside such a buffered 
range and extends past it within the same chunk, for example a read size that 
does not divide 16KB, or reads after a seek to an unaligned offset.
# The ReadChunk for the part after the buffered range fails on every datanode 
of the pipeline with any {{IOException}} other than {{SCMSecurityException}} 
(which is rethrown without a retry), and a later retry succeeds. Examples: a 
short network outage on the client side on any cluster, a restart of the only 
datanode of a one replica key, or an expired block token on a secure cluster, 
which every datanode rejects with {{BLOCK_TOKEN_VERIFICATION_FAILED}} once a 
stream has been open longer than {{hdds.block.token.expiry.time}} (default 1d) 
and which the client is designed to refresh and retry. Retries are bounded by 
{{ozone.client.read.max.retries}} (default 3) with 
{{ozone.client.read.retry.interval}} (default 1s) between them.

Not affected: positioned reads ({{readPositioned}}, {{read(long, ...)}}) and 
the streaming read path ({{StreamBlockInputStream}}, which does not use 
{{ChunkInputStream}}). EC keys are not affected by the silent variant because 
{{ECBlockInputStream}} reads its block streams through {{ByteBuffer}}; there 
the loud variant becomes a {{BadDataLocationException}}, so a plain token 
expiry makes the reader switch to a spare replica or to reconstruction (code 
reading only).

Affected versions: the read loop and the retry are the same in every release 
tag I checked from 1.3.0 through 2.2.1 (1.3.0, 1.4.0, 1.4.1, 2.0.0, 2.1.0, 
2.2.0, 2.2.1). Token expiry is a silent trigger since 1.4.0, where the retry 
uses the refreshed token (HDDS-9734). 1.2.0 has the same loop but retries only 
{{StorageContainerException}}.

h3. Impact
The {{byte[]}} read returns success with the wrong bytes: the bytes that were 
buffered (up to bytes per checksum minus one, so up to 16383 bytes with the 
defaults) are replaced by the following bytes, and the stream position is ahead 
by the same amount, so the caller reaches EOF that many bytes early. Only the 
usual WARN and INFO lines of a retried read are logged; nothing reports the 
skipped bytes and nothing re-checks the data afterwards. If the read range plus 
the skipped bytes reaches the end of the block, the retried read comes back 
short and {{KeyInputStream.checkPartBytesRead}} throws "Inconsistent read" 
instead. If all retries fail, the exception leaves the stream ahead by the 
copied bytes, so an application that catches it and keeps reading also skips 
them. Data stored on the datanodes is not affected. Through the {{ByteBuffer}} 
API the same trigger fails the read with an "Inconsistent read" {{IOException}} 
although the retry itself would have succeeded.

h3. Reproduction
[^TestBugMC1PartialChunkReadRetry.java] ({{hadoop-ozone/client}}) uses the real 
{{KeyInputStream}}, {{BlockInputStream}}, {{ChunkInputStream}}, 
{{ContainerProtocolCalls}} and response validators with the default client 
config except a zero retry interval. The {{XceiverClientSpi}} is replaced by a 
simulated datanode at {{sendCommandAsync}} (so the gRPC client's own loop over 
the datanodes is not exercised): it serves GetBlock and ReadChunk with real 
CRC32 checksums from a key of two chunks, {1,2,3,4} and {5,6}, with 2 bytes per 
checksum, and rejects the old token with {{BLOCK_TOKEN_VERIFICATION_FAILED}} 
once it is marked expired. The token refresh goes through the normal refresh 
function. After {{read(1)}} returns [1], the token expires and {{read(3)}} 
returns 3 with [3, 4, 5]; the position is 5 and the rest of the key reads as 
[6]. Without the expiry the same reads return [2, 3, 4] and then [5, 6]. The 
{{ByteBuffer}} variant fails with "Inconsistent read". The tests assert the 
defect, so a passing run means it is present. Not run on a real cluster.

h3. Patch
[^MC-1-rewind-chunk-stream-on-failed-partial-read.patch], against 
1fb64c8e499161d1452ae6948ffd461fc9d2c302. It also applies to master at 
8664dc4fc3c.

When the ReadChunk inside {{ChunkInputStream.read(byte[], ...)}} or 
{{ChunkInputStream.read(ByteBuffer)}} fails after the call has already copied 
buffered bytes, the stream seeks back by that many bytes (and the 
{{ByteBuffer}} variant also resets the buffer position) before rethrowing. A 
failed {{ChunkInputStream}} read then leaves the chunk stream and the buffer 
where the call started, so the retry in {{BlockInputStream}} resumes from 
there, for both APIs. A read that fails before copying anything is unchanged, 
as is every read that succeeds. One visible change: when the retries are 
exhausted, the stream is now left at the start of the failed call instead of 
after the copied bytes, so a caller that catches the exception and keeps 
reading gets those bytes again instead of skipping them. Bytes from earlier 
chunks that a {{BlockInputStream}} read had already completed before the 
failure stay consumed, as before. {{LocalChunkInputStream}} (short circuit) 
inherits both methods. Returning the partial count instead of throwing was 
considered and rejected: {{BlockInputStream}} treats a short count as a non 
retried "Inconsistent read", so a token expiry would become a read failure.

Covered in the existing {{TestBlockInputStream}}, which uses a real 
{{ChunkInputStream}} with only the ReadChunk call stubbed to fail with 
{{BLOCK_TOKEN_VERIFICATION_FAILED}}. {{retriedReadReturnsContiguousData}} fails 
it once, for {{byte[]}} and {{ByteBuffer}}, each after an unaligned first read 
(bytes left in the buffer) and an aligned one (nothing left). 
{{failedReadLeavesPositionUnchanged}} keeps it failing until the retries are 
exhausted and checks that the position and the buffer are where the call 
started and that the next read returns the right bytes. Without the change the 
unaligned {{byte[]}} case fails with the bytes from offset 20 in place of 
offset 1, the unaligned {{ByteBuffer}} case with "Inconsistent read ... 
readLength= 30 numBytesRead=11", and both exhausted retry cases with "expected: 
<1> but was: <20>" for the position. With the patch all six cases pass, as do 
the {{hdds-client}} storage and client io unit suites (168 tests, including the 
EC input stream tests) and the mini cluster suites {{TestChunkInputStream}} and 
{{TestKeyInputStream}}, and checkstyle is clean. With the patch the {{byte[]}} 
and {{ByteBuffer}} tests of the reproduction fail because the read returns [2, 
3, 4].

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. Related but not 
duplicates: HDDS-10536 and HDDS-10497 (block token expiry on long running HBase 
reads, which is one trigger here, fixed by refreshing the token), HDDS-9734 
(the chunk stream uses the new token after the refresh, which is the retry 
where the bytes are lost), HDDS-8024 (retry other datanodes on ReadChunk 
failure) and HDDS-10632 ("Inconsistent read" with numBytesRead=-1 on hsync'ed 
files, a different cause). 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