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

Siyao Meng updated HDDS-16828:
------------------------------
    Attachment: TestBugMC4HsyncAckedBeyondRecoveredLength.java

> [hsync] Datanode accepts a PutBlock on a finalized block if it is logged 
> after FinalizeBlock or sent through the data stream
> ----------------------------------------------------------------------------------------------------------------------------
>
>                 Key: HDDS-16828
>                 URL: https://issues.apache.org/jira/browse/HDDS-16828
>             Project: Apache Ozone
>          Issue Type: Bug
>            Reporter: Siyao Meng
>            Priority: Major
>         Attachments: MC-4-reject-putblock-on-finalized-block-at-apply.patch, 
> TestBugCR6DatastreamHsyncAfterLeaseRecovery.java, 
> TestBugMC4HsyncAckedBeyondRecoveredLength.java
>
>
> h3. Mechanism
> Lease recovery finalizes the last block on the datanodes and closes the file 
> at the block length that the FinalizeBlock reply carries 
> ({{KeyValueHandler.handleFinalizeBlock}} reads it when the request is 
> applied, {{BasicRootedOzoneClientAdapterImpl.finalizeBlock}} passes it on to 
> OM). A writer that hsyncs inside its current block does not call OM, so the 
> only thing that stops a writer whose lease was recovered is the datanode's 
> finalized block check ({{BLOCK_ALREADY_FINALIZED}}).
> That check exists in one place, {{ContainerStateMachine.startTransaction}}, 
> which runs on the Ratis leader for requests submitted through the Raft log. 
> Where a PutBlock is executed, {{KeyValueHandler.handlePutBlock}} and the 
> PutBlock carried by a WriteChunk in {{handleWriteChunk}} only check that the 
> container is open. Two kinds of PutBlock therefore get past the check:
> * *Logged after FinalizeBlock.* Ratis calls {{startTransaction}} before it 
> appends the request to the log, and outside the server lock that orders the 
> append ({{RaftServerImpl.writeAsyncImpl}}, then {{appendTransaction}}, Ratis 
> 3.3.1). A writer's PutBlock that passed the check can be appended, and 
> applied, after the FinalizeBlock of a concurrent lease recovery.
> * *Sent through the data stream.* A PutBlock sent as a stream command 
> ({{ContainerStateMachine.streamCommand}}, HDDS-16008) or at stream close 
> ({{streamPutBlock}}, commit "HDDS-15757. Streaming Write also commit PutBlock 
> at the time of closing", pull request 10694) is dispatched to the handler on 
> each datanode directly. It never passes {{startTransaction}}.
> In both cases the PutBlock extends the block after FinalizeBlock has reported 
> its length, and the writer gets a success reply.
> h3. Trigger
> hsync has to be enabled: {{ozone.fs.hsync.enabled=true}}, which is only 
> honoured with {{ozone.hbase.enhancements.allowed=true}} (both false by 
> default).
> * *Raft path, any client settings that enable hsync.* A writer's hsync races 
> with {{recoverLease}} from another client while the writer is still alive. 
> The window is on the datanode leader, between {{startTransaction}} returning 
> and the log append. It is short: the original investigation did not hit it in 
> 160 free running attempts (not repeated here), and the attached reproduction 
> holds the writer's request in the window with a Ratis test hook. The check in 
> {{startTransaction}} (HDDS-10044) and FinalizeBlock (HDDS-9915) are in every 
> release tag from ozone-2.0.0 to ozone-2.2.1, so this window is in released 
> code (read from the tags, not run on a release).
> * *Data stream path.* The writer has {{ozone.fs.datastream.enabled=true}} and 
> {{ozone.client.datastream.putblock.without.raft.enabled=true}} (both false by 
> default) and the datanodes have 
> {{hdds.container.ratis.datastream.enabled=true}} (false by default). Through 
> the FileSystem API the stream writer is selected only once more than 
> {{ozone.fs.datastream.auto.threshold}} (4MB by default) has been written 
> before the first hsync, hflush, flush or close. The reproduction lowers it to 
> 1KB. No timing is needed: every hsync after the recovery is accepted. With 
> the data stream enabled but PutBlock still sent through the Raft log (the 
> default), the writer is rejected by {{startTransaction}} as on the Raft path. 
> The two stream PutBlock paths are on master only, they are in no release tag 
> up to ozone-2.2.1.
> h3. Impact
> The writer's {{hsync()}} returns success for bytes that are not in the closed 
> file. Reproduced on unmodified source:
> * Raft path: the writer hsyncs 100 bytes, then writes 100 more and hsyncs 
> while {{recoverLease}} runs. The second hsync returns normally, the file is 
> closed with length 100 and 100 bytes are readable.
> * Data stream path: the writer hsyncs 40960 bytes and {{recoverLease}} closes 
> the file at 40960. Two more hsyncs of 30720 bytes each return normally. The 
> committed block length is 102400 on all three datanodes, the file stays at 
> 40960.
> The writer only learns that it lost the file when it allocates the next block 
> or closes ({{KEY_NOT_FOUND}}). On the data stream path the loss is limited to 
> what the writer hsyncs inside the block that was current at recovery. On the 
> Raft path it is only the request caught in the window, the next hsync is 
> rejected on the leader as before. As a side effect the finalized block's 
> committed length on the datanodes is larger than the length of the key in OM.
> h3. Reproduction
> Two attached classes for {{hadoop-ozone/integration-test}}, each self 
> contained with its own three datanode mini cluster and driven through the 
> FileSystem API. Target path and Maven command are in each class comment. Both 
> pass on unmodified source when the bug is present.
> * [^TestBugMC4HsyncAckedBeyondRecoveredLength.java]: the Raft path. The 
> writer uses incremental chunk list and PutBlock piggybacking (the settings of 
> {{TestHSync}}) so that one hsync is one Ratis request. The only timing aid is 
> Ratis' {{CodeInjectionForTesting}} at {{RaftServerImpl.appendTransaction}}, 
> which holds the writer's request after {{startTransaction}} returned until 
> {{recoverLease}} has returned. No state is injected and FinalizeBlock is not 
> delayed, so the run shows that the window exists, not how often it is hit. 
> The test also asserts that the recovery took its length from the 
> FinalizeBlock reply and not from the fallback read.
> * [^TestBugCR6DatastreamHsyncAfterLeaseRecovery.java]: the data stream path, 
> with the client settings above and no timing aid. It reads the committed 
> block length and the finalized state from the datanodes for its assertions.
> h3. Patch
> [^MC-4-reject-putblock-on-finalized-block-at-apply.patch], against 
> ea69b4a7d9abd040e5259dbcbb7c6ce52eb5d199. It also applies to master at 
> 53179f8f8b6.
> * {{KeyValueHandler}} checks the finalized block set where a PutBlock is 
> executed ({{handlePutBlock}} and the PutBlock carried by a WriteChunk), so 
> the order in the Raft log decides and the stream paths are covered by the 
> same check. The check and the PutBlock run under the container read lock and 
> {{handleFinalizeBlock}} under the write lock, which orders a stream PutBlock 
> (not in the Raft log) against FinalizeBlock on each datanode. A log entry 
> that is replayed after a restart and that 
> {{BlockManagerImpl.persistPutBlock}} skips by its BCSID is not checked, so 
> replay behaves as before.
> * {{BLOCK_ALREADY_FINALIZED}} is added to the results that 
> {{HddsDispatcher.canIgnoreException}} and 
> {{ContainerStateMachine.applyTransaction}} do not treat as a failure of the 
> replica. Without this the rejection would mark the container unhealthy and 
> close the pipeline on all three datanodes.
> * {{startTransaction}} keeps its check as an early rejection, but no longer 
> adds the block to the leader's finalized set when it sees the FinalizeBlock 
> request. The set is now filled only when FinalizeBlock is applied (and from 
> the {{FINALIZE_BLOCKS}} table at restart), so leader and followers reach the 
> same decision for the same log.
> The writer sees the same error as for a rejection in {{startTransaction}} 
> today: hsync throws an {{IOException}} ({{KEY_NOT_FOUND}} from OM when the 
> client retries on a new block). A plain WriteChunk logged after FinalizeBlock 
> is still written to the block file, as its data is written before the entry 
> is applied, but it does not change the committed length (by code reading).
> Compatibility (by code reading, no mixed version run): the patch changes what 
> a datanode does when it applies a PutBlock that is behind the FinalizeBlock 
> of its block in the log, which is the window of this bug. For such an entry a 
> datanode without the patch updates the block metadata (chunk list, size, 
> block BCSID) and the container BCSID, a datanode with the patch does not. The 
> block file is the same on both, because the data is written before the entry 
> is applied. FinalizeBlock is ahead of the PutBlock in the log on every 
> replica, so the key length in OM is the length before that PutBlock 
> everywhere: no replica is shorter than the key and readers are not affected, 
> and the replicas without the patch are in the state all replicas are in 
> today. What can be observed if both versions apply such an entry: when it was 
> the last PutBlock applied to the container, the replicas report different 
> BCSIDs at close, and the container data checksums differ. No new request, 
> field or persisted state is involved. A layout feature would be the strict 
> alternative.
> Not covered, data stream path only (argued from the code, not run):
> * A stream PutBlock that races with FinalizeBlock is decided on each datanode 
> separately. The writer is acknowledged only if all datanodes accepted it, and 
> each of them then completed it before its own FinalizeBlock read the length, 
> so an acknowledged hsync is always inside the recovered length. But if the 
> datanode whose FinalizeBlock reply the recovery uses accepted it and another 
> datanode rejected it, that replica's committed length stays below the length 
> of the key in OM, and a read that is served by that replica fails 
> ("Inconsistent read for blockID", the client does not fall back to another 
> replica for this). Without the patch every datanode accepts the PutBlock, so 
> this state is new with the patch. Closing it needs the stream PutBlock to be 
> ordered with FinalizeBlock through the Raft log.
> * The close of a container clears its finalized blocks before it takes the 
> container lock, so a stream PutBlock that races with the close of the 
> container can still pass the check.
> * {{streamPutBlock}} ignores the result of the PutBlock, so a rejected 
> PutBlock at stream close is not reported to the client (for a recovered 
> writer the commit to OM then fails).
> Tests: {{TestLeaseRecovery.testHsyncAfterRecoveryIsRejected}} (Raft path as 
> control, data stream path) and 
> {{testHsyncLoggedAfterFinalizeBlockIsRejected}} (same Ratis hook as the 
> reproduction), 
> {{TestHddsDispatcher.testPutBlockOnFinalizedBlockIsRejectedWithoutMarkingContainerUnhealthy}}
>  (rejection with and without a log index, replay, container stays healthy) 
> and 
> {{ContainerStateMachineTests.testApplyTransactionOnFinalizedBlockKeepsContainerHealthy}}
>  for leader and follower. Without the change the data stream case fails with 
> "Expected java.io.IOException to be thrown, but nothing was thrown", the hook 
> case with a missing hsync failure, the dispatcher case with "expected: 
> BLOCK_ALREADY_FINALIZED but was: SUCCESS" and the state machine cases with a 
> {{StorageContainerException}}. With the change these pass, together with 383 
> container-service unit tests around the handler, dispatcher and state machine 
> and 77 integration tests in the hsync, FinalizeBlock, container state machine 
> and data stream suites (those ran before the replay condition was added to 
> the patch). Checkstyle is clean. The two attached reproductions no longer 
> show the bug: the hsync fails and every replica stays at the recovered length.
> Found by TLA+ model checking and code review of the lease recovery paths for 
> hsynced files under HDDS-15926, on commit 
> ea69b4a7d9abd040e5259dbcbb7c6ce52eb5d199. The Raft path came from model 
> checking, the data stream path from code review. Checked against HDDS issues 
> and apache/ozone pull requests for duplicates before filing. Related but not 
> duplicates: HDDS-8439 (resolved, write after lease recovery does not fail, 
> the reason FinalizeBlock exists), HDDS-10563 (a writer rejected by the check, 
> the opposite symptom), HDDS-15723 (rejecting writes after a PutBlock with the 
> end of block flag, a different condition), HDDS-16730 (last chunk merge at 
> FinalizeBlock) and HDDS-16731 (scanner and reconciliation ignore the last 
> chunk rows). 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