Siyao Meng created HDDS-17003:
---------------------------------

             Summary: Interrupted EC reconstruction read returns while its cell 
reads still write into stripe buffers that close() gives back to the buffer pool
                 Key: HDDS-17003
                 URL: https://issues.apache.org/jira/browse/HDDS-17003
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: CR-3-wait-for-ec-cell-reads-on-interrupt.patch

h3. Mechanism
{{ECBlockReconstructedStripeInputStream.loadDataBuffersFromStream}} submits one 
cell read per selected index to the EC reconstruct executor and then waits for 
the futures in turn. When a wait is interrupted it catches the 
{{InterruptedException}}, sets the interrupt flag again and moves to the next 
future. With the flag set, {{Future.get()}} on every read that has not finished 
throws at once, so the loop ends and the method throws while those reads are 
still running. The comment at that catch says the intent is to handle all 
futures before throwing; setting the flag inside the loop defeats it. The code 
dates from HDDS-5952 and is the same in every release from 1.3.0 to 2.2.1.

The reads that are left running keep writing into the buffers they were given: 
the caller's stripe buffers ({{ECBlockReconstructedInputStream}} takes them 
from the client's {{ByteBufferPool}}) and the parity buffers the stripe reader 
takes from the same pool. {{ECBlockReconstructedInputStream.close()}} and 
{{unbuffer()}} put the stripe buffers back into the pool, and the stripe 
reader's {{close()}} and {{unbuffer()}} put back its parity buffers, without 
waiting for those reads. A read that has not yet opened its replica stream 
opens it through {{ECBlockInputStream.getOrOpenStream}} after the close, and 
that stream is not closed again (argued from code, not run).

{{BoundedElasticByteBufferPool.getBuffer}} hands out the same {{ByteBuffer}} 
object. The client has one pool, used by every EC reconstruction stream and by 
{{ECKeyOutputStream}}. A late cell read therefore writes into a buffer that is 
already in use by another stream of the same client, and it also moves that 
buffer's position.

h3. Trigger
# A key in an EC bucket is read through the reconstruction reader (a data 
replica is missing or has failed).
# The reading thread is interrupted while it waits for the cell reads of a 
stripe.
# The stream is closed or unbuffered before those reads have finished. 
{{ECBlockInputStreamProxy.readPositioned}} closes its per call reader in a try 
with resources block, so an interrupted positioned read takes this path by 
itself.

h3. Impact
* The late cell read overwrites a buffer that the pool has handed to another 
stream. If that stream is a reconstruction read that has just cleared the 
buffer, its own read of that cell finds no space left and the stripe is 
returned with the wrong cell, and a decoded cell computed from it is wrong too, 
without an error.
* Not run: the same applies when the buffer is taken by {{ECKeyOutputStream}}, 
which would then encode and write a cell with wrong content. The datanode 
offline reconstruction ({{ECReconstructionCoordinator}}) uses the same method 
with its own pool; there an interrupt comes only from a shutdown.
* Argued, not run: a read started after the interrupt on the same stream 
submits new cell reads for the same indexes and buffers while the old ones may 
still run.

h3. Reproduction
No standalone reproduction is attached. The regression tests 
{{testInterruptedReadWaitsForCellReadRunningOnPoolThread}} and 
{{testInterruptedReadRunsCellReadsNotStartedByExecutor}} in the attached patch 
fail on unmodified source (see Patch).

h3. Patch
[^CR-3-wait-for-ec-cell-reads-on-interrupt.patch], against 
1fb64c8e499161d1452ae6948ffd461fc9d2c302. It also applies to master at 
fd2f6092c6d.

{{loadDataBuffersFromStream}} now creates a {{FutureTask}} per cell read and 
hands it to the executor with {{execute}}. It then calls {{run()}} on each task 
in the calling thread and waits for it with 
{{Uninterruptibles.getUninterruptibly}} (Guava, already a dependency of the 
module) instead of {{Future.get()}}, and the catch that set the flag again is 
removed. {{FutureTask.run}} does nothing once a pool thread has started the 
task, so the calling thread only runs a read the executor has not started: one 
a pool thread has been handed but has not started yet (the calling thread then 
does that read itself and the pool thread skips it, so the reads of a stripe 
still run in parallel), one that {{CallerRunsPolicy}} dropped because the 
executor was shut down, or one the executor rejected. The calling thread runs 
such a read with its interrupt status cleared and restored afterwards, as a 
pool thread would, so that the datanode read does not fail because of the 
interrupt and mark a healthy replica as failed. Every read that is waited for 
therefore runs or has run, and the wait always ends. {{getUninterruptibly}} 
restores the interrupt status when it returns, so the existing check after the 
loop still throws {{InterruptedException}} and {{read}} still throws an 
{{IOException}} with the flag set, but only after every read of the stripe has 
completed or failed. No read writes into the stripe or parity buffers after the 
method returns, so {{close()}} and {{unbuffer()}} put back only idle buffers. 
Read failures seen while waiting are recorded as before. The same approach is 
used for the healthy reader in the open pull request for HDDS-16867.

Behaviour change: an interrupted reconstruction read returns only after the 
cell reads of the stripe that are in flight have completed or failed, which 
takes as long as an uninterrupted read of that stripe would (each datanode call 
has the {{ozone.client.read.timeout}} deadline, 30s by default, and a cell read 
can make several calls, including read retries and a switch to a spare 
replica), instead of at once. A read whose executor has been shut down no 
longer waits for a cell read that will never run: on unmodified source it waits 
until the thread is interrupted, with the patch the calling thread runs that 
cell read. Cancelling the futures was not used: {{cancel(true)}} would make the 
datanode reads fail and count as failed locations, and a cancelled future no 
longer tells when its read has stopped writing.

Covered by three tests in the existing {{TestECBlockReconstructedInputStream}}. 
In the first two, index 3 has no location, so a stripe is read by two data 
reads and one parity read, and {{read}} is called with the interrupt flag set.
* {{testInterruptedReadWaitsForCellReadRunningOnPoolThread}} lets the executor 
start the first cell read on a pool thread and holds that read until the 
reading thread waits for it. It asserts an {{IOException}} caused by 
{{InterruptedException}}, the flag still set, and all three cell reads, 
including the held one, done when {{read}} throws. On unmodified source it 
fails because {{read}} returns while the held read is not done.
* {{testInterruptedReadRunsCellReadsNotStartedByExecutor}} uses an executor 
that keeps the cell reads without running them, and replica streams whose read 
fails when its thread is interrupted, as a datanode read does. It asserts the 
same, plus that no replica was marked as failed, then takes the buffers back 
from the pool after {{close()}}, clears them, runs any read left over and 
asserts the buffers are untouched. On unmodified source it fails because 
{{read}} returns with the three reads not done; run after {{close()}}, those 
reads would write into three of the four buffers taken back from the pool, 
which the rest of the test checks.
* {{testInterruptedReadReturnsWhenExecutorIsShutDown}} reads with the interrupt 
flag set through a {{SynchronousQueue}} executor with {{CallerRunsPolicy}} that 
has been shut down, and asserts that the read throws within 10 seconds. It 
passes on unmodified source, where the interrupt ends the wait, and fails if 
the wait cannot end.

With the change the EC client stream suites (78 tests), {{TestOzoneECClient}} 
and {{TestKeyInputStreamEC}} (36 tests) pass, and checkstyle and PMD are clean.

The patch and the patch for HDDS-17001 both add static imports at the top of 
{{TestECBlockReconstructedInputStream}}, so whichever lands second needs a 
rebase of that import block; the main code changes do not overlap.

Found by code review of the Ozone client read retry and failover paths, as part 
of the TLA+ verification effort 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