Siyao Meng created HDDS-16822:
---------------------------------

             Summary: Data stream hsync with PutBlock through Raft commits a 
block length whose last 1MB exists only in datanode memory
                 Key: HDDS-16822
                 URL: https://issues.apache.org/jira/browse/HDDS-16822
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: 
CR-8-write-buffered-stream-data-before-raft-putblock.patch, 
TestBugCR8DatastreamHsyncTailNotInBlockFile.java

h3. Mechanism
On the data stream write path the datanode's {{KeyValueStreamDataChannel}} 
keeps at least the last {{BlockDataStreamOutput.PUT_BLOCK_REQUEST_LENGTH_MAX}} 
(1MB) of the stream in memory ({{Buffers}}), because the end of the stream may 
be the PutBlock request that the client appends at close (HDDS-6500). Only the 
data before that is written to the block file.

{{BlockDataStreamOutput.hsync}} waits until all datanodes acknowledged the 
stream writes ({{waitFuturesComplete}}) and then, with 
{{ozone.client.datastream.putblock.without.raft.enabled=false}} (the default), 
sends a PutBlock through Raft ({{putBlockAsync}}). 
{{KeyValueHandler.handlePutBlock}} commits the block length. Nothing writes the 
buffered bytes, and the acknowledgement of a stream write only says that the 
datanode received it.

HDDS-16746 fixed this for a PutBlock sent as a stream command: 
{{LocalStream.onCommand}} calls {{KeyValueStreamDataChannel.drainBuffers}} 
first. A PutBlock through Raft does not pass through there. So after hsync 
returns, every replica has committed a length whose last 1MB (the whole block 
if it is smaller) is not in its block file. The bytes are written when the 
stream is closed.

h3. Trigger
# Datanodes with {{hdds.container.ratis.datastream.enabled=true}}, and hsync 
enabled ({{ozone.fs.hsync.enabled}} with {{ozone.hbase.enhancements.allowed}} 
on OM and {{ozone.client.hbase.enhancements.allowed}} on the client). All of 
these default to false.
# A client writes through a data stream: {{OzoneBucket.createStreamKey}}, or a 
file system client with {{ozone.fs.datastream.enabled=true}} that writes more 
than {{ozone.fs.datastream.auto.threshold}} (4MB by default) before its first 
flush, hflush or hsync.
# The client calls hsync and it returns.

That is enough for the unreadable state. No second client, fault or timing is 
involved. For the loss the writer additionally dies, or loses its connections, 
before it closes the stream.

h3. Impact
* While the writer is open, a reader fails in the last 1MB of the hsynced 
length with {{StorageContainerException: Unexpected read size. expected: N, 
actual: -1}}, although OM and all datanodes report that length.
* If the writer never closes the stream, those bytes are never written to the 
block file. In the run below the file was then closed at the hsynced length and 
stayed unreadable beyond the block file length, also after all datanodes were 
restarted. Hsync had acknowledged the bytes and no replica has them.
* By code reading, not run on a release: {{CapableOzoneFSDataStreamOutput}} 
advertises {{HSYNC}} and {{HFLUSH}} for a data stream since HDDS-11816 (2.0.0), 
and the buffering on the datanode and the PutBlock through Raft on hsync are 
the same in ozone-2.0.0 and ozone-2.2.1.

h3. Reproduction
PASS on unmodified source, nothing injected. 
[^TestBugCR8DatastreamHsyncTailNotInBlockFile.java] 
({{hadoop-ozone/integration-test}}) starts a mini cluster with three datanodes, 
hsync and the file system data stream enabled, and the default data stream 
sizes. It writes 7MB (the block size of the mini cluster is 4MB) and calls 
hsync. The datanodes are only read, for the committed length and the block file 
length of each replica of the last block. A passing test means the defect is 
present.

First test, writer still open: file length 7340032, last block 3145728, 
committed block length per replica [3145728, 3145728, 3145728], block file 
length per replica [2097152, 2097152, 2097152], and reading the file fails with 
{{Unexpected read size. expected: 262144, actual: -1}}. After the writer 
closes, the block files are 3145728 bytes and the content reads back.

Second test: after hsync the test closes the writer's datanode clients instead 
of the stream, then closes the file with {{recoverLease}} (the test sets 
{{ozone.om.lease.soft.limit}} to 0s; it returns true, file length 7340032). 
Block files are still 2097152 bytes and the read fails. After restarting all 
three datanodes the committed length is 3145728, the block files are 2097152 
bytes and the read still fails.

h3. Suggested fix
The attached patch writes the buffered bytes before a PutBlock is committed, 
for every PutBlock: {{KeyValueHandler.handlePutBlock}} asks the chunk manager 
to drain the open stream of the block up to the length that the PutBlock 
commits. Points for review:
* The drain is bounded by the block length of the PutBlock. The existing 
{{drainBuffers()}} writes everything, which is not safe here: at close the 
client sends the PutBlock at the end of the stream and also one through Raft, 
and a full drain during the second could write the first into the block file.
* {{FilePerBlockStrategy}} now keeps the open stream channel of each block in a 
map, removed on close and on cleanup.
* The Raft PutBlock is applied on another thread than the stream writes, so the 
methods of {{KeyValueStreamDataChannel}} that use the buffers are synchronized. 
The lock is not held while the PutBlock at the end of the stream is dispatched.
* The PutBlock that a data stream writer sends at each flush size boundary 
without hsync now also writes the buffered tail at that point. The same bytes 
are written, earlier.
* The drain is a buffered write, without a force to disk. That is what an 
ordinary hsync gets with the default {{hdds.container.chunk.write.sync=false}}. 
The stream channel does not look at that setting, before or after the patch: it 
always opens the block file for buffered writes and forces it only when the 
client asks for it ({{ozone.client.datastream.sync.size}}, 0 by default, which 
is never). By code reading.
* When hsync returns, the Raft leader has applied the PutBlock and so has 
written the bytes. A follower writes them when it applies the entry. In the run 
with the patch all three block files had the committed length right after 
hsync. A replica whose stream is gone when it applies the entry (it restarted 
before applying, or the stream was cleaned up after an error) has nothing to 
write and still commits the length, as before the patch. By code reading, not 
run.
* A stricter variant would be to reject a PutBlock whose length is beyond the 
block file. The patch does not do that.

h3. Patch
[^CR-8-write-buffered-stream-data-before-raft-putblock.patch], against 
ea69b4a7d9abd040e5259dbcbb7c6ce52eb5d199. It also applies to master at 
84f5594a6a2 (not built there). It touches lines that the open pull request for 
HDDS-16789 changes in {{KeyValueStreamDataChannel}} and its test, so whichever 
is merged second needs a rebase. That pull request removes the empty check from 
{{drainBuffers()}}. The one in {{drainBuffers(long)}} has to stay in a rebase, 
because it keeps the file position from being read after the file is closed.

Covered by {{TestContainerStateMachineStream.testReadAfterHsync}}, which is the 
existing {{testReadAfterHsyncWithPutBlockWithoutRaft}} run for both PutBlock 
modes. Without the change the Raft case fails with {{Unexpected read size. 
expected: 1000, actual: -1}}. 
{{TestKeyValueStreamDataChannel.testDrainBuffersUpToLength}} covers the bounded 
drain, and that the channel is released on close and on cleanup. With the 
change the unit tests of the datanode's key value container, dispatcher and 
container state machine (449 tests), {{TestBlockDataStreamOutput}}, 
{{TestHSync}} and {{TestOzoneFileSystemWithStreaming}} pass and checkstyle is 
clean. {{TestLeaseRecovery}} has one failing test, 
{{testGetCommittedBlockLengthWithException}}, with and without the change; it 
writes no data stream. With the patch both tests of the reproduction fail, 
because the block files have the committed length and the content reads back, 
with the writer open and after the writer was killed. The datanode restart was 
not run with the patch.

Found by code review of the lease recovery paths for hsynced files, as part of 
the TLA+ verification effort under HDDS-15926, on commit 
ea69b4a7d9abd040e5259dbcbb7c6ce52eb5d199. Checked against HDDS issues and 
apache/ozone pull requests for duplicates before filing. The nearest is 
HDDS-16746, which fixed the same gap for a PutBlock sent as a stream command 
only. 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