Siyao Meng created HDDS-16811:
---------------------------------

             Summary: Reconciliation can restore a deleted block, or commit it 
without its file when block deletion runs during the repair
                 Key: HDDS-16811
                 URL: https://issues.apache.org/jira/browse/HDDS-16811
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: 
MC-2-do-not-restore-deleted-blocks-in-reconciliation.patch, 
TestBugMC2DeletedBlockRestoredByReconciliation.java, 
TestBugMC3BlockDeletedDuringReconciliation.java

h3. Mechanism
{{BlockDeletingTask.deleteTransactions}} handles a delete transaction for a 
block that has no metadata in the container by calling 
{{KeyValueHandler.deleteUnreferenced}}, which removes the block file if there 
is one, and then removes the transaction like any other. A block that has 
metadata is also recorded as deleted in the container merkle tree 
({{ContainerChecksumTreeManager.addDeletedBlocks}}). A block without metadata 
is not, so the tree keeps no trace of the deletion.

Reconciliation copies every block that is live in the peer's tree and has no 
entry in the local tree. A deleted entry in the local tree is the only thing 
that makes it skip a block. {{KeyValueHandler.reconcileChunksPerBlock}} then 
pulls the chunks, writes them and commits the block metadata with 
{{putBlockForClosedContainer}} without holding the container lock, while the 
block deleting service works under the container write lock. Nothing orders a 
deletion against the copy of the same block.

Two results, depending on when the block deleting service handles the 
transaction:
# Before reconciliation reads the local tree: the block is copied back from a 
peer that has not deleted it yet, and no transaction is left to delete it.
# While reconciliation copies the block: the block file is deleted and 
reconciliation still commits the block metadata, so the replica has block 
metadata without a block file.

The same code is in the tags ozone-2.1.0, ozone-2.2.0 and ozone-2.2.1 (read 
with git grep on the tags, not run there).

h3. Trigger
# A QUASI_CLOSED container has a replica that lacks a block the other replicas 
have, for example a replica that was quasi closed behind the others. This is 
the state reconciliation exists to repair.
# The key is deleted. SCM sends the block delete transaction to every replica, 
including the one without the block. {{DeletedBlockLogImpl.getTransactions}} 
sends it when the replication health of the container is HEALTHY, and a 
QUASI_CLOSED container whose replicas are all QUASI_CLOSED is HEALTHY whatever 
their sequence IDs are ({{RatisReplicationCheckHandler}} run in a unit test, 
{{DeletedBlockLogImpl}} read, not run).
# {{ozone admin container reconcile}} is run for the container.

The state of steps 1 and 2 is short lived by default (read in the code, not 
run). With replicas of all three origins present, the next replication manager 
pass ({{hdds.scm.replication.thread.interval}}, 300s) closes the replicas with 
the highest sequence ID ({{QuasiClosedContainerHandler}}). After that the 
lagging replica counts as unhealthy, the container is under replicated and its 
delete transactions are held back. A container that is stuck in QUASI_CLOSED 
with replicas of two origins is kept at five replicas by default 
({{hdds.scm.replication.quasi.closed.stuck.best.origin.copies}} 3, 
{{other.origin.copies}} 2), which the same health check rates as over 
replicated. So the transaction has to be sent between the quasi close and the 
next replication manager pass, or while a stuck container has exactly three 
replicas. A transaction that a datanode has already stored stays pending there 
and is executed as long as its replica is CLOSED or QUASI_CLOSED.

For the first result the lagging replica's block deleting service handles the 
transaction before a peer's does, and reconciliation with that peer runs in 
between. Each datanode runs the service on its own schedule. For the second 
result the transaction is still pending on the lagging replica and its block 
deleting service reaches it between the first chunk write and the commit of 
that block, which is the time it takes to pull the block's chunks from the 
peer. No fault is needed. How often either ordering occurs on a cluster was not 
measured.

h3. Impact
* Restored block: data of a deleted key stays on one datanode. SCM completes 
the transaction once every replica has acknowledged the command, which a 
datanode does when it stores the transaction, so it is not sent again (read, 
not run). A later reconciliation does not remove the block: once the peers have 
deleted it, their deleted entry carries the same checksum as the live block, 
all replicas report the same data checksum, and reconciling with both peers 
leaves the block in place (run). So no existing check shows the block, and a 
replication that uses this replica as its source copies it along, because the 
block files, the metadata and the tree file are all part of the container 
archive (read in {{TarContainerPacker}}, not run). The replica never becomes 
empty, and SCM deletes an empty container only when all its replicas report 
empty (read in {{EmptyContainerHandler}}, not run), so the container stays on 
all datanodes.
* Metadata without file: the scan that reconciliation starts reports 
{{MISSING_DATA_FILE}} and marks the replica UNHEALTHY (run). If the replica had 
the block with only its first chunk, the same ordering ends in 
{{MISSING_CHUNK}} and UNHEALTHY (run). SCM is then expected to replace the 
replica from a healthy one (argued, not run). No data of a live key is lost in 
either case.

h3. Reproduction
PASS for both on unmodified source at 1e528b97aed3eef1be6846f1f0767c1ef98a883a, 
deterministic, module {{hadoop-hdds/container-service}}. Each datanode is a 
real {{KeyValueHandler}} on its own volume with the real 
{{DeleteBlocksCommandHandler}}, {{BlockDeletingService}} and on demand scanner. 
There is no SCM and no network: the delete transaction is handed to the command 
handler, and the datanode to datanode calls of reconciliation are answered in 
process by the peer's handler. A passing test means the defect is present.

* [^TestBugMC2DeletedBlockRestoredByReconciliation.java]: three QUASI_CLOSED 
replicas, dn1 without block 1, the transaction stored on all three. After dn1's 
block deleting service ran: no metadata, no tree entry, no pending deletion. 
After dn1 reconciled with dn2, which has not deleted yet: metadata and block 
file present, tree entry live, no pending deletion. After dn2 and dn3 deleted 
the block and dn1 reconciled with both, ran its block deleting service and was 
scanned again: unchanged, and all three replicas report the same data checksum. 
After the remaining block is deleted, dn2 and dn3 are marked empty and dn1 is 
not.
* [^TestBugMC3BlockDeletedDuringReconciliation.java]: two replicas, the 
transaction pending on dn1. One pass of dn1's block deleting service runs at 
the moment the peer is asked for the second chunk of the block. It runs in the 
reconciliation thread, which is possible because reconciliation holds no 
container lock there. On a datanode it is a separate thread. Result: block 
metadata present, block file absent, {{MISSING_DATA_FILE}}, replica UNHEALTHY.

h3. Patch
[^MC-2-do-not-restore-deleted-blocks-in-reconciliation.patch]. It applies on 
top of the patch attached to HDDS-16808 (the data checksum reported to SCM and 
stored in RocksDB is not refreshed when block deletion changes the container 
checksum tree), not to the tree without it. Both patches change the same call 
in {{BlockDeletingTask.deleteTransactions}}, and the entries added here move 
the tree root on ordinary deletes, so the refresh from HDDS-16808 is needed for 
the reported data checksum to follow. Developed on 
1e528b97aed3eef1be6846f1f0767c1ef98a883a plus that patch. The two patches in 
that order also apply to master at 8ca44914771. Below, with the patch means 
with both patches.

Two parts, and the second relies on the first:
# {{BlockDeletingTask.deleteTransactions}} passes the IDs of the blocks it 
found no metadata for to {{ContainerChecksumTreeManager.addDeletedBlocks}}, 
which marks them as deleted in the tree write the pass already does. An entry 
that exists keeps its checksum, a new entry gets the checksum of a block 
without chunks. The call goes through the refresh that HDDS-16808 adds, so the 
data checksum in memory and in RocksDB follows the tree file. This is enough 
for the first result when the deletion is complete before reconciliation reads 
the local tree.
# {{KeyValueHandler.reconcileChunksPerBlock}} closes the block file handle 
before the commit instead of after it, as the client put block paths do, and 
then commits under the container read lock only if the tree on disk does not 
mark the block as deleted. The read lock orders the check and the commit 
against the block deleting service, which holds the write lock for its whole 
pass.

A block that is deleted during its repair leaves the patched code by one of two 
exits (both run):
* The block file was removed while its handle was cached and later chunks were 
still written, which is the ordering of the second reproduction. 
{{finishWriteChunks}} then fails with "Chunk file not found" before any 
metadata is written. Moving that call in front of the commit is what stops this 
ordering. As for every caller of {{finishWriteChunks}}, the failure also 
requests an asynchronous volume check, which is throttled 
({{hdds.datanode.disk.check.min.gap}}, 10m) and finds a healthy volume. The 
unpatched code makes the same request in this ordering, after the commit.
* A block file exists at commit time, because the deletion ran before the first 
chunk write or the block was partly present. The tree check finds the deleted 
entry, the pulled chunks are removed and the block fails.

Cost. The delete path gets no new lock and no additional file write, but the 
tree write it already does now changes the data checksum, see the side effect 
below. Reconciliation holds the read lock only for the commit of each repaired 
block, not while it pulls chunks. It reads and parses the tree file once per 
repaired block: measured 0.5 ms for a tree of 1,280 blocks, 1 ms for 10,000 and 
10 ms for 100,000 (3 MB file), one chunk per block, warm page cache. That is 
noise for the usual repair of a few blocks and quadratic for a replica that 
misses most blocks of a container with very many small blocks (about 16 minutes 
in total for 100,000). Skipping the read while the tree is unchanged, for 
example with a write counter in {{ContainerChecksumTreeManager}}, is a possible 
follow up. Holding the read lock for the whole repair of a block would also 
work, but it would block block deletion and delete commands for the container 
across network reads from the peer.

Side effect of the first part. Every delete of a block without metadata now 
adds a deleted entry, also for a block that no replica ever had. That is 
routine: the unused blocks of a committed key are deleted this way 
({{OMKeyRequest.wrapUncommittedBlocksAsPseudoKey}}, read). Each such entry 
changes the data checksum in the tree of the replica that executes the delete, 
and the value the replica reports follows it in the same pass (run), where the 
unpatched code leaves the tree alone. Replicas on the patched version are equal 
again once all have executed the transaction. A replica on an older version, or 
one that executed the transaction before the upgrade, has no such entry and 
differs until it is reconciled, which only {{ozone admin container reconcile}} 
starts. SCM does not act on the value, and Recon lists the container as 
REPLICA_MISMATCH while the reported values differ (read). Trees written before 
the patch are not backfilled, so a delete executed before the upgrade is not 
protected by the patch. For a block that only some replicas had, the entries 
differ in checksum until the replicas reconcile, as the missing entry does 
today. A stricter alternative, not taken here, is to write the new entries only 
once the datanode layout is finalized on a version that has them, which needs a 
new layout feature.

Covered by two new tests in the existing 
{{TestContainerReconciliationWithMockDatanodes}} (one per result, the second 
with three interleavings), an added assertion in 
{{TestBlockDeletingService.testWithUnrecordedBlocks}} and 
{{TestContainerMerkleTreeWriter.testMarkBlocksDeleted}}. One of the new tests 
also asserts that after a delete pass that records an entry for a block the 
replica never had, the data checksum in memory, in RocksDB and in the tree file 
agree. On HDDS-16808 alone the tests in the first two classes fail (8 
failures). With both patches 534 tests in 19 suites of 
{{hadoop-hdds/container-service}} and {{TestBlockDeletion}} on a mini cluster 
pass, checkstyle is clean. {{TestContainerCommandReconciliation}} was not run 
because its secure mini cluster did not start in the test environment. It is 
the existing suite most likely to notice the change to what block deletion 
writes to the tree, so it should pass in CI before the patch goes in. With both 
patches the two reproductions fail: the block is not restored, and in the 
second no metadata is committed and the replica stays QUASI_CLOSED. With 
HDDS-16808 alone they still pass.

Not covered by the patch: a replica that already holds a restored block is not 
cleaned up.

Found by TLA+ model checking and code review of the container reconciliation 
paths in the datanode and SCM under HDDS-15926, on commit 
1e528b97aed3eef1be6846f1f0767c1ef98a883a. Checked against HDDS issues and 
apache/ozone pull requests for duplicates before filing. Related but different: 
HDDS-11765 (apply block deletions that a replica missed, open), HDDS-13712 
(more tests for deleted block handling in reconciliation, open) and HDDS-10929 
(coordinate reconciliation with container deletion and replication, open). 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