Siyao Meng created HDDS-16732:
---------------------------------

             Summary: Open key reaper enqueues a live key's inherited blocks 
for deletion on a versioned bucket
                 Key: HDDS-16732
                 URL: https://issues.apache.org/jira/browse/HDDS-16732
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: MC-1-open-key-reaper-retains-live-blocks.patch

h3. Mechanism
With bucket versioning enabled, {{OMKeyRequest.prepareFileInfo}} calls 
{{OmKeyInfo.addNewVersion(locations, false, 
omBucketInfo.getIsVersionEnabled())}} ({{OMKeyRequest.java:1017}}), and 
{{addNewVersion}} clears {{keyLocationVersions}} only when {{keepOldVersions}} 
is false ({{OmKeyInfo.java:417}}). An open key created over an existing key 
therefore keeps the live key's block version groups, and that whole record is 
what {{OMKeyCreateRequest}} writes to {{openKeyTable}}.

If that open key is abandoned, {{OpenKeyCleanupService}} submits a 
{{DeleteOpenKeysRequest}}. {{OMOpenKeysDeleteRequest.updateOpenKeyTableCache}} 
({{OMOpenKeysDeleteRequest.java:186}}) checks only that the key is still in 
{{openKeyTable}}, never {{keyTable}}, and 
{{OMOpenKeysDeleteResponse.addToDBBatch}} 
({{OMOpenKeysDeleteResponse.java:74}}) hands the entire record to 
{{AbstractOMKeyDeleteResponse.addDeletionToBatch}}, which puts every version 
group into {{deletedTable}} with no reference check.

{{filterOutBlocksStillInUse}} ({{OMKeyRequest.java:1248}}), the guard written 
for exactly this hazard, is wired into {{OMKeyCommitRequest.java:364}}, 
{{OMKeyCommitRequestWithFSO.java:277}} and 
{{S3MultipartUploadCompleteRequest.java:354}}, but not into the reaper path. 
{{ReclaimableKeyFilter.isReclaimable}} ({{ReclaimableKeyFilter.java:87}}) 
decides purely from previous snapshots and returns true immediately when none 
exist, so nothing downstream catches it either.

h3. Trigger
No conditional write, no fault injection, no timing race.

# Bucket has versioning enabled.
# Client A writes key {{k}}; {{keyTable[k]}} references block {{b1}}.
# Client B calls {{createKey(k)}} to overwrite. The {{openKeyTable}} entry now 
holds two version groups, {{[{b1}, {b2}]}}.
# Client B never commits (process killed, partition, abandoned upload). The key 
is not hsync'd, so {{getExpiredOpenKeys}} routes it to the delete branch.
# After {{ozone.om.open.key.expire.threshold}} (default 5 min), 
{{OpenKeyCleanupService}} submits {{DeleteOpenKeysRequest}}.
# {{deletedTable}} now holds an entry whose blocks include {{b1}}, while 
{{keyTable[k]}} still references {{b1}}.
# {{KeyDeletingService}} ships {{b1}} to SCM 
({{KeyDeletingService.java:171-172}}). Client A's committed key is destroyed.

h3. Impact
A committed key's blocks are enqueued for physical deletion while the key is 
still live and readable. The state is durable in RocksDB, nothing re-checks it, 
and {{deletedTable}}'s only consumer acts on it. Default configuration, no 
cluster faults required.

h3. Developer intent
{{git log -S filterOutBlocksStillInUse}} returns exactly two commits, both 
fixing this hazard class at other sites. The first introduced the helper for 
key commit and states the intent plainly: "Remove any block from oldVerKeyInfo 
that share the same container ID and local ID with omKeyInfo blocks'. 
Otherwise, it causes data loss once those shared blocks are added to 
deletedTable and processed by KeyDeletingService for deletion." The second 
extended it to S3 MPU complete three weeks before this head. Neither touches 
the open key reaper. {{TestOMOpenKeysDeleteRequest}} asserts only table 
membership, metrics and audit, never the blocks that reach {{deletedTable}}, so 
nothing encodes the current behaviour as intended.

h3. Reproduction
PASS at escalation level 0, pure black box. Every step is a real OM write 
request driven through {{preExecute}} plus {{validateAndUpdateCache}} plus 
{{addToDBBatch}} with the RocksDB batch committed, exactly as the Ratis state 
machine drives them. No state injection, no product code patch, no fault 
injection.

{{repro/test_bugMC-1_run.sh}}, which runs {{mvn -B -o -pl 
hadoop-ozone/ozone-manager test 
-Dtest=TestSpeculaBugMC1OpenKeyReaperBlockLoss}}.

{noformat}
[repro] live keyTable blocks      = [conID: 5001 locID: 1]
[repro] abandoned openKey blocks  = [conID: 5001 locID: 1, conID: 5002 locID: 
1] (versionGroups=2)
[repro] deletedTable blocks       = [conID: 5001 locID: 1, conID: 5002 locID: 1]
[repro] blocks KeyDeletingService would send to SCM = [conID: 5001 locID: 1, 
conID: 5002 locID: 1]
[repro] LIVE block conID: 5001 locID: 1 in deletedTable? true ; handed to SCM? 
true
[control-commit] deletedTable = []
[control-nover] deletedTable = [conID: 5002 locID: 1]
{noformat}

Block 5001 is the live key's committed block. After the reaper runs it is in 
{{deletedTable}} and in the list {{keyManager.getPendingDeletionKeys(...)}} 
returns, which is what {{KeyDeletingService.java:171-172}} submits to 
{{ScmBlockLocationProtocol.deleteKeyBlocks}}. Correct behaviour would have put 
only 5002 there.

Two negative controls pin the mechanism. {{[control-commit]}}: same scenario 
but client B commits, which enqueues nothing, so the defect is the reaper path 
rather than the inheritance itself. {{[control-nover]}}: same reaper scenario 
on a non versioned bucket, where {{keyLocationVersions.clear()}} runs and only 
5002 reaches {{deletedTable}}, pinning the trigger to {{getIsVersionEnabled()}}.

Stated limitation: the final DataNode level block removal was not executed, 
since a request level unit test has no SCM or DataNode. What is demonstrated is 
that OM durably enqueues a live key's block and hands it to the SCM delete path.

h3. Suggested fix
Call {{filterOutBlocksStillInUse}} on the open key reaper path as the commit 
and MPU complete paths already do: in 
{{OMOpenKeysDeleteRequest.updateOpenKeyTableCache}}, look the key up in 
{{keyTable}} under the bucket lock already held, strip from the expiring record 
every block the live key still references, and skip the {{deletedTable}} put 
entirely when nothing is left.

A root cause alternative is to stop the {{openKeyTable}} record carrying the 
live key's version groups at all, since the open key only needs its own newly 
allocated blocks and the old versions are re-read from {{keyTable}} at commit. 
That removes the hazard from every reaper like consumer at once but touches 
versioning semantics, so the targeted filter is the safer first patch.

Either way, add the missing negative assertion to 
{{TestOMOpenKeysDeleteRequest}}: on a versioned bucket with a pre-existing live 
key, the reaped open key must contribute no block that {{keyTable}} still 
references. The reproduction is written against {{OMKeyRequestTests}}, the 
shared base for that package, so it can be folded in as a test method rather 
than a new class.

h3. Patch
[^MC-1-open-key-reaper-retains-live-blocks.patch], against 
20e6a1c0f6039248373efd3eb1d7f2afcf7f1535.

Routes the record the reaper enqueues through a new {{withLiveBlocksRetained}}, 
which resolves the live key from the open key name (dropping the trailing 
client id segment inverts both {{getOpenKey}} and {{getOpenFileName}}, so it is 
layout agnostic) and runs the existing {{filterOutBlocksStillInUse}} over a 
copy of the expiring record. A record left with no blocks is still removed from 
openKeyTable and simply contributes nothing to deletedTable, because 
{{AbstractOMKeyDeleteResponse.addDeletionToBatch}} already guards that put with 
{{isKeyEmpty}}, so no response class changes.

Covered by {{testReapRetainsBlocksTheCommittedKeyStillReferences}} in the 
existing {{TestOMOpenKeysDeleteRequest}}, parameterized over both bucket 
layouts. It asserts both directions: the inherited key must not reach 
deletedTable, and a key whose block no committed key holds must still be 
reclaimed. Verified to fail without the change and pass with it on OBS and FSO. 
Checkstyle clean. The 14 tests in that class pass.

Found by TLA+ model checking and code review of the OM conditional create, 
overwrite and delete paths under HDDS-15926, on commit 
20e6a1c0f6039248373efd3eb1d7f2afcf7f1535. Checked against open HDDS issues for 
duplicates before filing, in addition to the repository's merged git history. 
Generated with Specula (Claude Opus 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