Siyao Meng created HDDS-16787:
---------------------------------

             Summary: Part commit that fails on bucket quota leaves the part in 
the OM table cache, so a later part commit, abort or complete acts on it
                 Key: HDDS-16787
                 URL: https://issues.apache.org/jira/browse/HDDS-16787
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: 
MC-1-publish-multipart-part-to-cache-after-quota-check.patch, 
TestBugMC1AbortAfterQuotaFailedPartCommit.java, 
TestBugMC2OverwrittenPartBlockLeak.java, 
TestBugMC3CompleteWithQuotaFailedPart.java, 
TestBugMC4AbortDivergesAcrossOMs.java

h3. Mechanism
{{S3MultipartUploadCommitPartRequest.validateAndUpdateCache}} adds the new part 
to the {{multipartPartsTable}} cache (legacy layout: the updated 
{{multipartInfoTable}} entry that carries the part inline) and puts a delete 
marker on the part's open key in the open key table cache before it calls 
{{checkBucketQuotaInBytes}}. When the check throws {{QUOTA_EXCEEDED}}, 
{{S3MultipartUploadCommitPartResponse.checkAndUpdateDB}} writes nothing and 
nothing withdraws those cache entries. They stay visible until the double 
buffer has flushed that transaction and the epoch is evicted.

{{OzoneManagerStateMachine.applyTransaction}} does not wait for the flush, so 
the next request can be applied while the rejected part still looks committed. 
{{S3MultipartUploadAbortRequest}}, {{S3ExpiredMultipartUploadsAbortRequest}} 
and {{S3MultipartUploadCompleteRequest}} read the parts through the cache 
({{OMMultipartUploadUtils.scanParts}}, or the cached {{OmMultipartKeyInfo}} for 
the legacy layout) and act on what they see. A later commit of another part of 
the same upload reads the same cache. In the legacy layout it starts from the 
cached {{OmMultipartKeyInfo}}, which carries the rejected part inline, adds its 
own part, and its response writes the whole entry to RocksDB. From then on the 
rejected part is durable and no longer depends on the window.

HDDS-16161 made this request update the bucket usage on a copy and publish it 
to the bucket table cache after the quota check. The part, upload and open key 
cache writes stayed in front of it.

h3. Trigger
A bucket with a space quota that is almost used up. Opening a part only checks 
the quota, it charges nothing, so parts uploaded in parallel can all be opened 
and one of them then fails at commit with {{QUOTA_EXCEEDED}}. One more request 
for the same upload then has to be applied on an OM before that OM has flushed 
the failed commit:
* a commit of another part that still fits, typically a smaller last part 
(legacy layout). After that, no abort or complete has to fall inside the window.
* an abort, sent by the client or by {{MultipartUploadCleanupService}} if the 
upload is older than the expiry threshold, or a complete.

The window is one double buffer flush per OM. The reply to the client does not 
wait for the flush, so the window exists on the leader as well, and on 
followers it is independent of when the client received the error.

The cache writes precede the quota check in every release tag from ozone-1.4.0 
to ozone-2.2.1 as well (read from the tagged source, not run on a release). 
Those releases have only the legacy layout.

h3. Impact
Reproduced on unmodified source unless marked otherwise. The attached classes 
hold the flush with {{OzoneManagerDoubleBuffer.pause}}, which only changes 
timing.
* *OMs diverge.* Whether an OM still shows the rejected part when it applies 
the next request depends on its own flush, not on the Ratis log. On a three OM 
cluster with one follower's flush held, all OMs applied the same log and the 
held follower persisted {{usedBytes=-3145728}} and two deleted blocks while the 
other two persisted {{usedBytes=0}} and one. A client read 0, then -3145728 
after a leader transfer with no write in between. 
[^TestBugMC4AbortDivergesAcrossOMs.java]
* *Rejected part persisted (legacy layout).* With a 6 byte quota, part 1 (4 
bytes) committed, part 2 (4 bytes) rejected and part 3 (2 bytes) committed 
before the flush, {{multipartInfoTable}} in RocksDB lists parts 1, 2 and 3. An 
abort sent after the cache entry had been evicted left {{usedBytes}} at -4 in 
the cache and in RocksDB. Whether an OM persists the rejected part depends on 
its own flush in the same way as above (argued from the code, the three OM run 
covers the abort only). [^TestBugMC1AbortAfterQuotaFailedPartCommit.java]
* *Negative bucket usage.* An abort inside the window releases the bytes of the 
rejected part although they were never charged. With a 6 byte quota and two 4 
byte parts, {{usedBytes}} ends at -4 in the cache and in RocksDB, for a client 
abort and for the expired upload cleanup. The bucket then accepted 8 bytes of 
committed parts. A complete that lists only the accepted part releases the 
rejected part as an unused part in the same way: in a request level run (both 
layouts, not attached) {{usedBytes}} ended at 0 although the completed key 
holds the 200 byte part. [^TestBugMC1AbortAfterQuotaFailedPartCommit.java]
* *Leaked block.* If the rejected commit was a re-upload of an already 
committed part number, the abort sees only the rejected part. The committed 
part's block is in no OM table and not in {{deletedTable}}, and 
{{KeyDeletingService}} never asks SCM to delete it. Shown for the split parts 
table and for the legacy layout. A later accepted commit of the same part 
number has the same effect without an abort: in a request level run (both 
layouts, not attached) it queued the rejected part's blocks for deletion, not 
the committed part's block, and {{usedBytes}} ended at 0 with a 200 byte part 
committed. [^TestBugMC2OverwrittenPartBlockLeak.java]
* *Completed key whose block is sent for deletion.* A complete that lists the 
rejected part returns OK. The key references the part's block while the part's 
open key row is still in RocksDB. A retry of the part commit 
({{NO_SUCH_MULTIPART_UPLOAD_ERROR}} branch of 
{{S3MultipartUploadCommitPartResponse}}) or {{OpenKeyCleanupService}} then 
moves that block to {{deletedTable}} and {{KeyDeletingService}} asks SCM to 
delete it while the key is still in {{keyTable}}. This needs a client that 
completes with a part whose commit did not succeed. The test stops at the 
delete request to a testing SCM client. The datanode side delete and a failing 
read were not exercised. [^TestBugMC3CompleteWithQuotaFailedPart.java]

The wrong values are written to RocksDB and survive the eviction of the cache 
entries. The patch does not repair buckets and uploads that are already 
affected.

h3. Reproduction
The four attached classes are self contained and pass on unmodified source when 
the bug is present (target path and Maven command are in each class comment). 
Three run one real OM through {{OmTestManagers}} in 
{{hadoop-ozone/ozone-manager}}. They send every request through 
{{OzoneManagerProtocol}} or through one manual pass of the cleanup services, 
with a 4 byte block size and short expiry thresholds. The first runs a 
pre-finalized OM so that uploads use the legacy layout, the second runs both 
layouts. The fourth runs a three OM mini cluster in 
{{hadoop-ozone/integration-test}} through the public client. No state is 
injected. The only timing aid is the existing 
{{OzoneManagerDoubleBuffer.pause}} / {{unpause}} test hook, so these runs show 
that the window exists, not how often it is hit. Runs without the hook 
(concurrent clients) hit the window at low rates in the original investigation 
and were not repeated for this report.

h3. Patch
[^MC-1-publish-multipart-part-to-cache-after-quota-check.patch], against 
d067ba38e7aea917db7efd290b4af53987ec985a. It also applies to master at 
a6332fb5987.

The three cache writes ({{multipartPartsTable}}, {{multipartInfoTable}}, open 
key delete marker) move from before {{checkBucketQuotaInBytes}} to after it, 
next to the bucket table cache write that HDDS-16161 already placed there. 
Nothing between the old and the new position reads those tables, and for the 
legacy layout {{addPartKeyInfo}} works on the copy returned by {{Table.get}}, 
so no other change is needed. A commit that fails now leaves no cache state, 
for the default and the FSO request (the FSO class inherits the method), and 
the success path publishes the same entries as before. Withdrawing the entries 
on failure would also work but is more code for the same result.

One visible change besides the symptoms above: the open key of a rejected 
commit is no longer hidden while the delete marker is cached, so a retry of 
that commit with the same open key session is evaluated again on every OM. 
Before, by code reading, it failed with {{KEY_NOT_FOUND}} until the marker was 
evicted.

Covered by five cases in the existing 
{{TestS3MultipartUploadCommitPartRequest}}, inherited by 
{{TestS3MultipartUploadCommitPartRequestWithFSO}}: 
{{testQuotaExceededCommitIsNotVisibleToLaterRequests}}, 
{{testAbortAfterQuotaExceededCommitReleasesOnlyCommittedParts}}, 
{{testAbortAfterQuotaExceededOverwriteDeletesCommittedPart}} and 
{{testCompleteRejectsPartWhoseCommitExceededQuota}} run for both part layouts, 
{{testCommitAfterQuotaExceededCommitPersistsOnlyAcceptedPart}} for the legacy 
layout. Without the change all 18 runs fail (for example "expected: 0L but was: 
-200L", "expected: INVALID_PART but was: OK" and persisted parts 1 and 2 where 
only part 2 is expected). With it the multipart request and response unit 
suites and the two cleanup service suites pass (163 tests), as do the multipart 
cases of {{TestOzoneRpcClient}} and {{TestOzoneManagerHAWithStoppedNodes}} (56 
tests), checkstyle is clean, and the four attached reproductions no longer show 
the bug.

Found by TLA+ model checking and code review of the OM expired multipart upload 
cleanup paths under HDDS-15926, on commit 
d067ba38e7aea917db7efd290b4af53987ec985a. Checked against HDDS issues and 
apache/ozone pull requests for duplicates before filing. Related but not 
duplicates: HDDS-16763 (a failed FSO CompleteMultipartUpload leaves parent 
directories in the cache), HDDS-16765 (a rejected overwrite commit leaves a 
marker on another writer's cached open key entry), HDDS-16766 (an FSO directory 
delete depends on flush and cache eviction timing, so OMs apply it 
differently), HDDS-16785 (a part commit rejected with 
NO_SUCH_MULTIPART_UPLOAD_ERROR leaves its open key behind, a different exit of 
the same request) and HDDS-16161 (resolved, bucket usage 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