Siyao Meng created HDDS-17675:
---------------------------------

             Summary: Datanode acknowledges and commits a WriteChunk without 
writing its data when a truncated entry at the same log index still has a 
pending write
                 Key: HDDS-17675
                 URL: https://issues.apache.org/jira/browse/HDDS-17675
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: MC-2-bind-pending-write-future-to-entry-term.patch, 
TestBugMC2StaleWriteFutureAfterTruncate.java

h3. Mechanism
{{ContainerStateMachine.writeStateMachineData}} keeps the pending data write of 
each WriteChunk in {{writeChunkFutureMap}}, keyed by log index only. When an 
entry for that index is already present, it returns the existing future without 
checking the term or the request (added by HDDS-12469 as a precaution for a 
retried write of the same entry). A map entry is removed only when its chunk 
write task finishes; {{truncate}} and {{notifyNotLeader}} only touch 
{{stateMachineDataCache}}.

In Ratis 3.3.1, {{SegmentedRaftLog.appendImpl}} calls {{StateMachine.truncate}} 
and then {{StateMachine.write}} for each new entry while it builds the log 
tasks under the log write lock. Nothing waits for data writes that this 
datanode started for its own entries while it was leader, because those appends 
are not part of the follower append chain 
({{RaftServerImpl.appendEntriesAsync}}). So if such an entry is truncated while 
its chunk write is still queued, and the new leader puts a different WriteChunk 
at the same index, {{write}} returns the future of the truncated entry:
* the new chunk is never written;
* the old task's completion completes the new entry's write and {{flush}}, so 
the datanode acknowledges the entry;
* the COMMIT_DATA apply of the WriteChunk succeeds, because 
{{FilePerBlockStrategy.writeChunk}} does nothing in that stage;
* a following PutBlock succeeds and the container BCSID moves past the missing 
chunk.

h3. Trigger
# Datanode A is leader of a Ratis pipeline. Its data writes for new WriteChunk 
entries are queued behind a slow volume (the condition HDDS-12469 describes).
# A loses leadership before those entries commit (for example a network 
partition), and another datanode becomes leader in a higher term without them.
# A receives the new leader's entries. They truncate A's uncommitted entries, 
and a different WriteChunk lands on an index whose old data write is still 
pending. The old write must still be pending when the new leader's entries 
arrive (at least the time of an election) but younger than 
{{hdds.container.ratis.statemachine.write.wait.interval}} (default 10m), after 
which the next write marks the state machine unhealthy.
# In the case that was run, the swallowed chunk is the last chunk of a block 
before its PutBlock(eof), and the block file already exists from an earlier 
chunk, so the file check of PutBlock(eof) passes. Other placements of the 
swallowed chunk were not run.

Needs two faults at once (leadership loss and a slow volume on the old leader), 
so it is rare. No client action can cause it.

The same reuse can happen for entries the datanode received as a follower, but 
only in the short window between the completion of the old write future and its 
removal from the map, because Ratis 3.3.1 chains follower appends (append 
entries compose, enabled by default). Ozone 2.0.0 ships Ratis 3.1.2, which does 
not chain them, so there this window is likely wider. That variant is argued 
from the code, not run.

h3. Impact
On the affected replica the container stays OPEN and healthy, its BCSID and the 
block metadata (GetBlock length) include the chunk, but the block file is short 
and ReadChunk of the committed chunk fails ({{CONTAINER_INTERNAL_ERROR}}, 
"Unexpected read size"). Nothing on the write path repairs it. A reader served 
by this replica gets an error and has to use another replica, and the container 
data scanner skips OPEN containers, so the replica counts as good until the 
container is closed and scanned. The replica's acknowledgement also counts 
towards the commit of a chunk it does not hold; with one other replica lagging, 
the write could be committed with a single good copy (argued from the Ratis 
code, not run).

h3. Reproduction
[^TestBugMC2StaleWriteFutureAfterTruncate.java] 
({{hadoop-hdds/container-service}}) drives the real {{ContainerStateMachine}}, 
{{HddsDispatcher}}, {{KeyValueHandler}}, {{FilePerBlockStrategy}}, an on disk 
container and the real Ratis {{SegmentedRaftLog}}, which issues the write, 
truncate and flush calls itself. {{RaftServerImpl}} and the other peers are not 
run; the test calls the log in the order {{RaftServerImpl}} does. The slow 
volume is one of the two chunk executors kept busy. Deterministic, unmodified 
source. On an unpatched build {{tailChunkOfNewLeaderIsCommittedWithoutItsData}} 
passes, which means the defect is present:
{noformat}
dataWrites(block@offset)=[100@0, 10@0, 10@4096, 2@0, 11@0] 
applyResults=[SUCCESS, SUCCESS, SUCCESS] containerState=OPEN containerBCSID=5 
lastIndex=5 block2Length=8192 block2FileLength=4096 readChunk(block 2, chunk 
1)=CONTAINER_INTERNAL_ERROR readMatches=false
{noformat}
The data write of block 2 at offset 4096 never runs; the only write at that 
index is the truncated entry's chunk of block 11. {{controlWithoutSlowVolume}} 
runs the same sequence with the old writes finished before the truncation and 
reads the correct data.

h3. Patch
[^MC-2-bind-pending-write-future-to-entry-term.patch], against 
ec5ed5f7ea0e4b406aee53aae4906616819cba2b. It also applies to master at 
7294fc2da18.

{{WriteFutures}} records the term of its entry, and {{writeStateMachineData}} 
reuses a pending future only when the term matches, so a retried write of the 
same entry still gets the existing future (the HDDS-12469 intent), while a 
different entry at a truncated index starts its own write and replaces the map 
entry. The finishing task of the truncated entry then removes the map entry 
only if it is still its own, so that {{flush}} and the write timeout check keep 
tracking the new entry. {{truncate}} does not need to drop map entries: 
leftovers of truncated entries are no longer reused and leave the map when 
their task finishes, as today.

Covered by {{testWriteAfterTruncateDoesNotReuseFutureOfTruncatedEntry}} in the 
existing {{ContainerStateMachineTests}} (run as leader and follower). Without 
the change it fails because the new WriteChunk is never dispatched; with only 
the term check it fails because {{flush}} no longer waits for the new write. 
With the patch {{TestContainerStateMachineLeader}}, 
{{TestContainerStateMachineFollower}} and the mini cluster suites 
{{TestContainerStateMachine}}, {{TestContainerStateMachineFailures}}, 
{{TestContainerStateMachineFlushDelay}} and {{TestCSMMetrics}} pass, and the 
reproduction fails at its first assertion because block 2 is written in full 
and reads back correctly.

Found by TLA+ model checking and code review of the datanode container state 
machine apply and block commit paths under HDDS-15926, on commit 
ec5ed5f7ea0e4b406aee53aae4906616819cba2b. 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
(v10.3.26#10030026)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to