[ 
https://issues.apache.org/jira/browse/HDDS-17000?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Siyao Meng updated HDDS-17000:
------------------------------
    Attachment: MC-1-rewind-chunk-stream-on-failed-partial-read.patch

> 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
>            Priority: Critical
>         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