Siyao Meng created HDDS-17431:
---------------------------------

             Summary: With PutBlock without Raft, a failed data stream close is 
reported as success, so OM commits a block that no replica or only one replica 
has
                 Key: HDDS-17431
                 URL: https://issues.apache.org/jira/browse/HDDS-17431
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: MC-1-fail-stream-close-on-rejected-putblock.patch, 
TestBugCR5StreamCloseFailureIgnored.java, 
TestBugMC1ClosedContainerStreamClose.java

h3. Mechanism
With {{ozone.client.datastream.putblock.without.raft.enabled=true}} (client) 
and {{hdds.container.ratis.datastream.enabled=true}} (datanode), both false by 
default, the final PutBlock of a streaming write is appended to the data stream 
and committed by each datanode when the stream closes 
({{StreamInitWithPutBlock}}). The close reply from Ratis is the only signal 
that this commit happened, and two places lose it:
* Datanode: {{ContainerStateMachine.streamPutBlock}} ignores the response of 
{{dispatchCommand}}. When the dispatcher rejects the PutBlock (for example 
{{CLOSED_CONTAINER_IO}}, "Requested operation not allowed as ContainerState is 
CLOSED"), {{KeyValueStreamDataChannel.closeWithStreamPutBlock}} still calls 
{{setLinked()}}, the channel close succeeds and Ratis replies success to the 
close. The mid stream PutBlock ({{ContainerStateMachine.streamCommand}}) 
already throws on a result other than SUCCESS; the close path does not.
* Client: {{BlockDataStreamOutput.close()}} waits for the close reply but never 
checks {{isSuccess()}}. A close that fails on the pipeline (Ratis replies 
success=false when the local close throws or a remote close fails) is handled 
only by a {{whenComplete}} callback in {{executePutBlock}} that calls 
{{executePutBlock(true, false)}} again. That call cannot send anything: 
{{buffersForPutBlock}} is already null (NullPointerException) or the stream is 
already closed (IOException), and the exception is thrown inside a future 
nobody reads. So {{close()}} returns, {{KeyDataStreamOutput}} never retries, 
and OM commits the key on the original block.

The Raft path (the default) is not affected in the same way, because there the 
separate Raft PutBlock is the commit and its failure is reported.

h3. Trigger
Datanode streaming enabled, a client that writes through the streaming API with 
PutBlock without Raft enabled, and all datanodes of the pipeline at the version 
that supports it. Then either:
* the container is closed while the stream is open (container full, pipeline 
close, or an admin close), so every datanode rejects the close PutBlock, or
* the followers of the stream pipeline fail after the last data packet was 
acknowledged and before the close, so the close fails on the pipeline while the 
primary has already committed the PutBlock locally.

h3. Impact
Reproduced on unmodified source with a 5 datanode mini cluster through the 
public client API:
* *Container closed before the stream close, nothing committed yet.* 50 bytes 
written, container closed through SCM, then {{key.close()}}. The close returned 
normally, OM committed 50 bytes on the original block, the replicas logged 
{{op=PUT_BLOCK ... ret=FAILURE}} and have no such block, and reading the key 
fails with "Unable to find the block". With PutBlock through Raft the client 
rewrites the data on a new block instead. 
[^TestBugMC1ClosedContainerStreamClose.java]
* *Container closed before the stream close, part of the block committed.* 500 
bytes written with a PutBlock in the middle of the stream at 400 bytes, which 
all replicas committed. After the container close and {{key.close()}}, OM has 
500 bytes on the original block, every replica has 400, and reading the key 
fails with "Inconsistent read". [^TestBugMC1ClosedContainerStreamClose.java]
* *Followers lost before the stream close.* 100 bytes written and acknowledged, 
both followers shut down, then {{key.close()}}. The client logged {{Failed 
executePutBlockClose, reply=...,FAILED}}, {{close()}} returned normally, OM 
committed the original block, the primary has it with length 100 and both 
followers still lack it after restart. The key is readable only while the 
primary lives. [^TestBugCR5StreamCloseFailureIgnored.java]

In all cases the writer is told the data is stored. Nothing repairs it 
automatically: SCM replication does not see a difference between the replicas. 
Admin triggered container reconciliation was not tried.

PutBlock without Raft is part of HDDS-15463 (Streaming Write Pipeline without 
Raft), is off by default and is not in any release yet. It came with the 
commits "Streaming Write also commit PutBlock at the time of closing" 
(HDDS-15792), "Commit PutBlock without Raft in Client" (HDDS-15758) and 
"PutBlocks from Flushes also go without Raft" (HDDS-16008). The callback that 
cannot retry dates from HDDS-6500 and is in released versions, but there it is 
harmless because the Raft PutBlock is the commit.

h3. Reproduction
The two attached classes are self contained JUnit tests for 
{{hadoop-ozone/integration-test}} (target path and Maven command in each class 
comment). Each starts its own mini cluster and uses only the public client API, 
{{OzoneTestHelper.waitForContainerClose}}, datanode shutdown and restart, and 
reads of the datanode's block metadata, with no injected state and no sleeps. 
They pass on unmodified source when the bug is present and fail with the patch.

h3. Patch
[^MC-1-fail-stream-close-on-rejected-putblock.patch], against 
7212465c0bb19615708c144238317db51c2a696d. It also applies to master at 
ae71e2dd50c.

* {{ContainerStateMachine.streamPutBlock}} throws a 
{{StorageContainerException}} when the result is not SUCCESS, as 
{{streamCommand}} does. {{closeWithStreamPutBlock}} then does not mark the 
channel linked, the close fails, Ratis replies success=false and the client 
does not send the link request.
* With PutBlock without Raft, {{BlockDataStreamOutput}} handles the close reply 
like the reply to a mid stream PutBlock command: on success=false it fails the 
stream, so {{KeyDataStreamOutput}} excludes the pipeline and writes the 
unacknowledged bytes again on a new block; on success it registers the buffers 
the close PutBlock covered, so that they are released like those of the Raft 
PutBlock. {{executePutBlock}} no longer registers the callback that cannot 
retry on this path.

The release is needed for the retry to be correct. All blocks of a key share 
one buffer list, and before this patch a successful close without Raft never 
released its buffers. A retry on a later block then wrote the previous block's 
tail into the new block as well, and the key commit failed with an 
{{IllegalArgumentException}} ({{BlockDataStreamOutputEntryPool.commitKey}}, 
offset does not match the block lengths). That already happened before this 
patch when a PutBlock in the middle of a later block failed; with the close 
failures now surfaced it would also hit a key whose last close fails, including 
a key whose data was all hsynced before its container closed. With the patch 
these keys commit and read back correctly (checked on a mini cluster with 1000 
byte blocks: a failed close of the second block, a failed mid stream PutBlock 
of the second block, and a hsynced second block whose container closes before 
the close).

Both datanode and client changes are needed for the container close case: with 
only the client change the close reply is still success, with only the datanode 
change the client ignores the failed reply (both checked with the new 
integration test). The followers case needs only the client change; there the 
rewrite, like the one after a failed Raft PutBlock, succeeds or fails loudly 
depending on whether SCM still hands out pipelines with a stopped datanode, and 
while it does the client can keep retrying for minutes (about two minutes in 
one run); that retry behaviour predates this patch. Nothing changes when 
PutBlock goes through Raft, and there is no protocol or configuration change; a 
client and its datanodes both need the patch for the container close case.

Tests:
* {{TestBlockDataStreamOutput#closeFailsIfStreamCloseCommittingPutBlockFails}} 
and {{#closeReleasesBuffersIfStreamCloseCommittingPutBlockSucceeds}} (mocked 
pipeline, close replied with success=false or success).
* {{TestKeyDataStreamOutput#failedStreamCloseOfSecondBlockRewritesOnlyItsData}} 
(mocked pipelines, two block key whose second close fails).
* {{ContainerStateMachineTests#testStreamPutBlockFailsIfDispatchFails}} and 
{{#testStreamPutBlockSucceedsIfDispatchSucceeds}}.
* 
{{TestContainerStateMachineStream#testContainerClosedBeforeStreamCloseWithPutBlockWithoutRaft}}
 (shared non HA cluster: write, close the container, close the key, read it 
back).

All of them except {{#testStreamPutBlockSucceedsIfDispatchSucceeds}} fail 
without the change; that one guards the success path. With the change they 
pass, as do {{TestBlockDataStreamOutput}}, {{TestClientThreadShutdown}}, 
{{TestContainerStateMachineLeader}}, {{TestContainerStateMachineFollower}}, 
{{TestKeyValueStreamDataChannel}}, {{TestKeyDataStreamOutput}}, 
{{TestContainerStateMachineStream}}, the integration 
{{TestBlockDataStreamOutput}} and {{TestOzoneFileSystemWithStreaming}}, and 
checkstyle is clean.

Found by TLA+ model checking and code review of the datanode streaming PutBlock 
paths under HDDS-15926, on commit 7212465c0bb19615708c144238317db51c2a696d. 
Checked against HDDS issues and apache/ozone pull requests for duplicates 
before filing. Related but not duplicates: HDDS-16265 (the same callback, 
described as an unbounded retry whose failure is non critical because the Raft 
PutBlock does the commit; without Raft the close PutBlock is the commit, and on 
either path the re-entry fails before sending anything, so after this patch 
HDDS-16265 concerns only the Raft path), HDDS-16828 (a stream PutBlock accepted 
on a finalized block), HDDS-16822 (hsync with PutBlock through Raft) and PR 
#11384 (HDDS-16191, refactors the same method). This should be fixed before 
PutBlock without Raft (HDDS-15463) is enabled. 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