You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@ozone.apache.org by "Siyao Meng (Jira)" <ji...@apache.org> on 2026/10/08 08:57:00 UTC
[jira] [Updated] (HDDS-16787) 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
[ https://issues.apache.org/jira/browse/HDDS-16787?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel ]
Siyao Meng updated HDDS-16787:
------------------------------
Attachment: MC-1-publish-multipart-part-to-cache-after-quota-check.patch
> 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
> Priority: Critical
> 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: issues-unsubscribe@ozone.apache.org
For additional commands, e-mail: issues-help@ozone.apache.org