Siyao Meng created HDDS-16830:
---------------------------------

             Summary: Lease recovery matches open key and fileTable blocks by 
count, so it can drop blocks, repeat the last block, never complete or commit a 
hole
                 Key: HDDS-16830
                 URL: https://issues.apache.org/jira/browse/HDDS-16830
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: 
MC-2-lease-recovery-match-open-key-blocks-by-identity.patch, 
TestBugCR3ForceRecoverySplicesBlockAfterStaleBlock.java, 
TestBugMC2RecoveryDropsBlocksAheadOfFileTable.java, 
TestBugMC3RecoveryRepeatsLastBlock.java, 
TestBugMC6RecoveryFailsOnNeverWrittenLastBlock.java

h3. Mechanism
{{LeaseRecoveryClientDNHandler.getOmKeyLocationInfos}} builds the block list of 
the recovery commit from the fileTable entry (the blocks as of the last hsync 
that reached OM) and the open key (every allocated block). It compares the two 
by block count and looks only at the open key's last two blocks: it appends the 
open key's last block when the open key has more blocks, then refreshes 
fileTable's last block only when that is the open key's penultimate block or 
the counts are equal. Only there is {{NO_SUCH_BLOCK}} or 
{{CONTAINER_NOT_FOUND}} accepted as "allocated but never written". With the 
force property any other failure is logged and the list is committed as it is.

{{BlockOutputStreamEntryPool.hsyncKey}} calls OM only when the last block ID 
changed, so a writer without hsync gets several blocks ahead of fileTable. A 
never written block is left out of the hsync list but stays in the open key, 
possibly in front of fileTable's last block.

OM does not catch the result: the recovery commit 
({{OMKeyCommitRequestWithFSO}}) accepts any subset of the open key's blocks and 
moves the others to {{deletedTable}}, drops a repeated block ID and stores the 
client's {{dataSize}} as is.

h3. Trigger
Only with hsync enabled, which is not the default: {{ozone.fs.hsync.enabled}} 
and {{ozone.hbase.enhancements.allowed}} 
({{ozone.client.hbase.enhancements.allowed}} on the client) are false. The 
writer of an hsynced file is gone and another client calls {{recoverLease}}.
# *Open key two or more blocks ahead*, fileTable {{[b1]}}, open key {{[b1, b2, 
b3]}}: the writer wrote more than a full block after its last hsync that 
reached OM. No fault needed. A never written block counts, so {{[b1, b2 never 
written, b3]}} also hits a writer that hsyncs often (read from the code, not 
run). The force property ({{OZONE.CLIENT.RECOVER.LEASE.FORCE}}, a client system 
property) makes no difference here or in shape 2 (read, not run with it set).
# *Never written block in front of the last block*, fileTable {{[b2]}}, open 
key {{[b1, b2]}}: the first write to b1 failed (for example its container was 
closed in between), the client moved on to b2 and called hsync there.
# *As shape 1, but the open key's last block was never written*: the writer 
stopped right after that block was allocated. No fault needed.
# *Open key one block ahead, the earlier block unreachable, force set*, 
fileTable {{[b1]}}, open key {{[b1, b2]}}: the writer wrote more into b1 after 
its hsync, and no replica of b1 can return its length while b2 answers.

The handler logic is the same in the release tags ozone-2.0.0 to ozone-2.2.1 
(compared in the tagged source, nothing run on a release).

h3. Impact
Reproduced on unmodified source (FSO bucket, 128 KB blocks, 16 KB chunks).
* *Shape 1, blocks dropped and hsynced bytes lost.* Two hsyncs acknowledged 
32768 bytes in b1, then the writer flushed on to 262244 bytes. {{recoverLease}} 
returns true and the file is closed as {{[b1:16384, b3:100]}}: the second hsync 
is not covered, b2 is in {{deletedTable}}, and the content is not a prefix of 
what was written. [^TestBugMC2RecoveryDropsBlocksAheadOfFileTable.java]
* *Shape 2, wrong file length.* A file of 100 bytes is closed with length 200 
and block list {{[b2:100]}}. {{readFully}} of 200 bytes throws {{EOFException}} 
and the bucket's {{usedBytes}} goes from 300 to 600. The 100 bytes stay 
readable. [^TestBugMC3RecoveryRepeatsLastBlock.java]
* *Shape 3, recovery cannot complete.* Every {{recoverLease}} throws 
{{StorageContainerException}}. The file stays open with the {{LEASE_RECOVERY}} 
mark, {{OmMetadataManagerImpl.getExpiredOpenKeys}} no longer selects it for 
cleanup and an hsync of the writer is rejected. Only a commit removes the mark 
(read), which leaves delete, overwrite or force. Force closes the file at the 
fileTable length, 16384 of 262144 bytes (seen in a run with the property set), 
dropping later hsyncs in that block too (read). 
[^TestBugMC6RecoveryFailsOnNeverWrittenLastBlock.java]
* *Shape 4, hole after a forced recovery.* After an hsync at 100 bytes the 
writer flushed 1000 more to b1 and 100 to b2. Forced recovery closes the file 
with 200 bytes, bytes 0 to 99 followed by bytes 1100 to 1199. Least severe of 
the four: it needs the force property, set by hand after a normal recovery 
failed, and every replica of b1 down while b2 answers. 
[^TestBugCR3ForceRecoverySplicesBlockAfterStaleBlock.java]

The wrong list and length are committed to the closed key, and the patch does 
not repair files already recovered this way.

h3. Reproduction
The four attached classes are self contained and pass on unmodified source 
while the bug is present. Each drives the file through the FileSystem API on a 
mini cluster in {{hadoop-ozone/integration-test}}. The fault setup of shapes 2 
and 4, the target path and the Maven command are in each class comment.

h3. Patch
[^MC-2-lease-recovery-match-open-key-blocks-by-identity.patch], against 
ea69b4a7d9abd040e5259dbcbb7c6ce52eb5d199, also applies to master at 
bdff9801bb2. Client only.

The handler now finds fileTable's last block in the open key by container and 
local ID, finalizes it, then finalizes every open key block listed after it, in 
order, and appends it. A block for which the datanodes answer {{NO_SUCH_BLOCK}} 
or {{CONTAINER_NOT_FOUND}} was never written and is left out. Blocks listed 
before fileTable's last block are never appended.

With the force property the result is the longest prefix whose lengths are 
known: fileTable's blocks, the last one at its datanode length if that could be 
read, then the following blocks up to the first whose length could not be read. 
If fileTable's last block cannot be read nothing is appended after it, even if 
its fileTable length was already final; the blocks left out hold no byte 
acknowledged by an hsync and OM moves them to {{deletedTable}} as uncommitted 
(both read).

Not covered by the patch:
* *Open key three or more blocks ahead.* {{OMRecoverLeaseRequest}} returns a 
pipeline only for fileTable's last block and the open key's last two blocks, so 
the client has no datanode to ask for a block in between. The patched client 
then fails before it finalizes anything, with an {{IOException}} naming the 
block and the force property, and drops nothing (unit test, not run on a 
cluster). The file stays open and marked as in shape 3; with force it is closed 
with the blocks before that block, fileTable's last block at its datanode 
length. The follow up is for OM to return the pipelines of all blocks after 
fileTable's last one in one batched SCM call, once that call no longer runs 
while the request is applied, see HDDS-16823, which moves the SCM call of 
{{RecoverLease}} out of the apply path. It is not part of this patch because it 
would add one SCM call per block to the apply path.
* *A block for which SCM knows no datanode.* Recovery still fails, now with an 
{{IOException}} that names the block in place of the 
{{IllegalArgumentException}} of {{XceiverClientManager.acquireClient}} (unit 
test, cluster state not reproduced).
* *A block the writer abandoned.* Like the existing code, the patch takes a 
datanode length as one the writer accepted. 
{{KeyOutputStream.handleExceptionInternal}} rewrites the unacknowledged data of 
a failed block into a new block while the datanodes may have applied the failed 
write, so such a block is appended as the datanodes have it and the next block 
repeats those bytes (read, not run).
* *An empty fileTable block list* (an hsync at offset 0, then flushed writes). 
The file is still closed empty. No acknowledged byte is involved (read, not 
run).

Tests: a new unit class {{TestLeaseRecoveryClientDNHandler}} (six tests, mock 
adapter) and two parameterized tests in {{TestLeaseRecovery}}, 
{{testRecoveryWithOpenKeyTwoBlocksAheadOfFileTable}} and 
{{testForceRecoveryKeepsHsyncedPrefixWhenEarlierBlockIsNotFinalized}}, all 
failing without the change. With it they pass, as do the whole 
{{TestLeaseRecovery}} class (19 tests) and the lease recovery cases of 
{{TestSecureOzoneRpcClient}}, checkstyle is clean, and none of the four 
reproductions passes any more.

Found by TLA+ model checking and code review of the lease recovery paths for 
hsynced files under HDDS-15926, on commit 
ea69b4a7d9abd040e5259dbcbb7c6ce52eb5d199. Checked against HDDS issues and 
apache/ozone pull requests for duplicates before filing. Related but not 
duplicates: HDDS-11550 (open, lease recovery fails with "Unable to find the 
block" for one file, no cause given, possibly the third shape), HDDS-10242 
(resolved, the open key one block ahead with a never written last block), 
HDDS-10632 (resolved, introduced the three cases), HDDS-11126 (open, asks for 
test coverage of this handler) and HDDS-16829 (the datanode side of a 
FinalizeBlock for a never written block). 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