Siyao Meng created HDDS-17668:
---------------------------------

             Summary: PutBlock reply handlers of a Ratis BlockOutputStream can 
run out of order, so close() can commit wrong data and OM can get an older bcsId
                 Key: HDDS-17668
                 URL: https://issues.apache.org/jira/browse/HDDS-17668
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: MC-2-handle-putblock-replies-in-order.patch, 
TestBugMC1BcsIdGoesBack.java, TestBugMC2OutOfOrderPutBlockHandlers.java

h3. Mechanism
{{BlockOutputStream.executePutBlock}} handles each PutBlock reply with 
{{thenApplyAsync(..., responseExecutor)}}, and so does 
{{writeChunkToContainer}} for a WriteChunk that carries a piggybacked PutBlock. 
Since HDDS-10384 (1.4.1) {{responseExecutor}} is the {{writeExecutor}} of 
{{RpcClient}}, shared by all streams of the client: a {{ThreadPoolExecutor}} 
with core size 1, no maximum and a {{SynchronousQueue}}, so a handler gets a 
new thread whenever no thread is idle. Before 1.4.1 each stream had its own 
single thread executor, which kept its handlers in order. The Ratis client 
completes the reply futures of one client in arrival order 
({{SlidingWindow.Client.receiveReply}}), but when the replies of PutBlock 1 and 
PutBlock 2 arrive close together the two handlers run on two threads and 
nothing orders them. Nothing else serializes them either: the handlers do not 
take the stream lock.

A handler does two things that assume it runs after the handlers of the earlier 
PutBlocks:
* {{handleSuccessfulPutBlock}} sets the block ID, including the bcsId, from the 
reply with {{blockID.set(responseBlockID)}}. If the handler of PutBlock 1 runs 
last, the bcsId goes back to the one of PutBlock 1. 
{{BlockOutputStreamEntry.close}} then sends that bcsId to OM with the full 
block length.
* It registers the PutBlock's buffers in {{CommitWatcher}} under its log index, 
and the watch chained on it releases every registered index up to the index the 
watch returns ({{AbstractCommitWatcher.adjustBuffers}}). If the handler and 
watch of PutBlock 2 run before the handler of PutBlock 1, only the buffers of 
PutBlock 2 are released and {{totalAckDataLength}} counts data that is not the 
start of the block. If the block now fails (for example {{CLOSED_CONTAINER_IO}} 
because SCM closed the container) before the handler of PutBlock 1 runs, that 
handler fails in {{validateResponse}} on the stored exception and never 
registers its buffers, although the datanode applied PutBlock 1. 
{{KeyOutputStream.handleExceptionInternal}} then records the acknowledged 
length (the size of PutBlock 2's data) as the length of the failed block and 
rewrites the buffers still allocated, which hold PutBlock 1's data, to a new 
block.

The TODO in {{CommitWatcher.releaseBuffers}} ("When there are concurrent 
watchForCommits, there's no guarantee of the order of execution") points at the 
same ordering gap.

h3. Trigger
Two PutBlocks of one block whose replies arrive close together, which is normal 
for asynchronous writes where several PutBlocks are in flight, and a thread 
schedule in which the handler of the later one runs first.
* Older bcsId at OM: nothing else is needed.
* Wrong data committed: in addition, a later request on the same block must 
fail, and its handler must run before the handler of PutBlock 1. That is most 
likely when the failing reply arrives in the same burst, for example when SCM 
closes the container between PutBlock 2 and the next request. This was only 
reproduced with the handler order controlled.

h3. Impact
* *close() succeeds and the key holds wrong data.* With 1 MB buffers and two 1 
MB writes ('A' then 'B'), the container closed before the PutBlock sent by 
{{close()}}, and the handler of PutBlock 1 started last: {{close()}} returns 
normally, OM commits two 1 MB blocks (the first in the old container, the 
second the rewritten 'A' buffer) and the key reads back as 'A' x 2097152. The 
'B' megabyte is lost. The in order control with the same container close reads 
back correctly. [^TestBugMC2OutOfOrderPutBlockHandlers.java]
* *OM records an older bcsId with the full length.* Same writes without a 
failure: OM gets bcsId 1 (PutBlock 1) instead of 2 for a block of 2 MB. 
[^TestBugMC1BcsIdGoesBack.java] A replica that has applied PutBlock 1 but not 
yet PutBlock 2 then passes {{BlockUtils.verifyBCSId}} and the block bcsId check 
in {{BlockManagerImpl.getBlock}} and serves the shorter block, where the 
correct bcsId would make it answer with an error ({{UNKNOWN_BCSID}} or 
{{BCSID_MISMATCH}}) so that the reader moves to the next replica. In a three 
datanode mini cluster run with a test only hook that holds PutBlock apply on 
one follower, stopping the leader made the read of the key fail with 
"Inconsistent read" while an up to date replica was alive; a control key in the 
same container read fine. The key had been committed with bcsId 2 while the 
container bcsId on the leader was 6. The hook only delays apply, not Raft 
commit, so it stands in for a slow follower. With the patch the same run 
commits bcsId 6, equal to the container bcsId on the leader, and the read moves 
on to the up to date replica and succeeds. This run needs a hook in production 
code, so it is not attached.
* By code reading, not run: data acknowledged by {{hsync()}} is exposed in the 
same way, because {{hsync()}} waits only for the latest PutBlock and its watch, 
not for the handlers of earlier PutBlocks.

h3. Reproduction
The two attached classes are self contained (target path and Maven command in 
each class comment). They drive the real {{OzoneClient}}, {{RpcClient}}, 
{{KeyOutputStream}}, {{RatisBlockOutputStream}} and {{CommitWatcher}} over the 
existing client test mocks ({{MockOmTransport}}, {{MockXceiverClientFactory}}), 
with a scripted datanode that gives PutBlocks increasing log indexes, completes 
their replies in log order and returns the log index as bcsId. The only timing 
control is an executor that replaces the private {{RpcClient.writeExecutor}} 
field by reflection: it runs each handler on its own thread and starts the 
handler of PutBlock 1 last. Everything else goes through the public client API. 
Each class has an in order control case. On unmodified source the out of order 
cases fail ({{expected: "A x 1048576, B x 1048576" but was: "A x 2097152"}} and 
{{bcsId committed to OM expected: 2L but was: 1L}}) and the controls pass; with 
the patch all cases pass. They show that the order is reachable and what 
follows from it, not how often it happens.

h3. Patch
[^MC-2-handle-putblock-replies-in-order.patch], against 
ec5ed5f7ea0e4b406aee53aae4906616819cba2b. It also applies to master at 
b683fd3f294.

Each PutBlock reply handler (including a WriteChunk with a piggybacked 
PutBlock) now runs with {{thenCombineAsync}} on a future that completes when 
the handler of the previous PutBlock of the stream has finished, successfully 
or not, so the handlers of one stream run in the order the PutBlocks were sent. 
The executor and its thread reuse stay as they are. This fixes both symptoms at 
the cause: the bcsId only moves forward, and the registered buffers are always 
the prefix of the PutBlocks handled so far, so the acknowledged length is 
always a prefix of the block. A WriteChunk reply handler also waits for the 
handler of the PutBlock sent before it, so a later error can no longer overtake 
the success of an earlier PutBlock. {{ECBlockOutputStream}} overrides 
{{executePutBlock}} and never piggybacks, so EC writes are unchanged. The 
piggybacked WriteChunk plus PutBlock path 
({{ozone.client.stream.putblock.piggybacking}}, off by default) gets the same 
change and is covered by code reading only; no test runs it. The replies of one 
stream arrive in order and the handlers do no I/O, so the added wait is at most 
the run time of the previous handler; no throughput change is expected, but 
this was not benchmarked. One behaviour change: {{hsync()}} and a write that 
waits for a full buffer now return only after the handlers of all earlier 
PutBlocks have run, not only the latest one.

A forward only bcsId update in {{handleSuccessfulPutBlock}} would fix only the 
bcsId symptom. Making {{CommitWatcher}} release only a contiguous prefix would 
need the expected log indexes before the replies arrive and would still leave 
the bcsId symptom.

Two cases in the existing {{TestBlockOutputStreamCorrectness}}, with an 
executor that holds the handlers and runs the most recent one first: 
{{testBcsIdOfLatestPutBlockWhenReplyHandlersRunLate}} and 
{{testUnackedBuffersFollowAckedLengthWhenReplyHandlersRunLate}}. Without the 
change they fail ("expected: 10L but was: 5L", and the unacknowledged buffers 
after 1048576 acknowledged bytes hold the first megabyte instead of the 
following data); with it they pass. The unit tests of {{hdds-client}} and 
{{ozone-client}} and the mini cluster suites {{TestBlockOutputStream}}, 
{{TestBlockOutputStreamWithFailures}}, {{TestCommitWatcher}}, 
{{TestWatchForCommit}}, {{TestFailureHandlingByClient}}, 
{{TestFailureHandlingByClientFlushDelay}}, 
{{TestOzoneClientRetriesOnExceptions}}, 
{{TestOzoneClientRetriesOnExceptionFlushDelay}}, 
{{TestMultiBlockWritesWithDnFailures}}, {{TestCloseContainerHandlingByClient}} 
and {{TestHSync}} pass, and checkstyle is clean.

Found by TLA+ model checking and code review of the Ozone client Ratis block 
write and failure handling paths under HDDS-15926, on commit 
ec5ed5f7ea0e4b406aee53aae4906616819cba2b. Checked against HDDS issues and 
apache/ozone pull requests for duplicates before filing. Related but not 
duplicates: HDDS-6139 (resolved, {{totalAckDataLength}} in the streaming write 
path), HDDS-16262 (resolved, cleanup races in {{AbstractCommitWatcher}}), 
HDDS-17648 / #11890 (open, a watch that never completes after the client is 
closed) and HDDS-15271 (open, the reader preferring replicas that cover the 
block bcsId). 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]

Reply via email to