Siyao Meng created HDDS-16733:
---------------------------------
Summary: Concurrent open of an absent key on a versioned bucket
drops the first committed version and leaks its blocks
Key: HDDS-16733
URL: https://issues.apache.org/jira/browse/HDDS-16733
Project: Apache Ozone
Issue Type: Bug
Reporter: Siyao Meng
Attachments: MC-2-versioned-overwrite-orphans-previous-blocks.patch
h3. Mechanism
Bucket versioning preserves an overwritten version only through block
inheritance done at OPEN time ({{OMKeyRequest.prepareFileInfo}}, the
{{dbKeyInfo != null}} branch at {{OMKeyRequest.java:1013}}), not through
anything commit does. When two writers open the same not-yet-existing key,
neither open key inherits anything.
The first commit publishes its record. The second commit finds {{keyToDelete !=
null}} but, versioning being enabled, skips the overwrite cleanup branch at
{{OMKeyCommitRequest.java:348}} and falls into the {{else}} at {{:391}}, which
only does quota accounting: no old version retention, no deletion enqueue, no
reference tracking. The committed record is built from the second writer's open
key, which never contained the first writer's blocks.
The first version is therefore silently discarded despite versioning being on,
and its blocks are referenced by nothing and queued for deletion by nothing.
The same branch also calls {{incrUsedNamespace(1L)}} for what is really an
overwrite.
h3. Trigger
Bucket {{v/b}} with versioning enabled (settable through the public native
client: {{OzoneBucket.setVersioning(true)}} to {{RpcClient.java:1224}} to
{{OMBucketSetPropertyRequest.java:175-181}}, no gate), key {{k}} absent:
# Writer A sends {{CreateKey v/b/k}}. {{dbKeyInfo == null}}, so A's open key
holds only A's blocks.
# Writer B sends {{CreateKey v/b/k}} before A commits. Still {{dbKeyInfo ==
null}}, so B's open key holds only B's blocks.
# A sends {{CommitKey}}. {{keyToDelete == null}}; key table gets A's record,
version 0, blocks {{{bA}}}.
# B sends {{CommitKey}}. {{keyToDelete}} is A's record, versioning on, so
control goes to the {{else}} at {{:391}}. Key table gets B's record, version 0,
blocks {{{bB}}}. A's version is gone and {{{bA}}} was never enqueued.
Both are plain unconditional PUTs, so {{validateAtomicRewrite}}
({{OMKeyRequest.java:1367}}) is a no-op on this path. Four ordinary requests,
no injected fault.
h3. Impact
The first committed version is unrecoverable and its blocks are leaked
permanently: nothing in the tree scans containers for unreferenced blocks, and
a block absent from {{deletedTable}} is never reclaimed. The bucket's
{{usedNamespace}} and {{usedBytes}} served to users are also inflated, which
{{ozone repair om quota}} can recompute later but which does not reclaim the
blocks.
h3. Developer intent
{{TestOmBlockVersioning}} enables versioning ({{:78}}) and asserts versions
accumulate 0, 1, 2 through the public client ({{:99-135}}), so version
retention on the native path is a promised property and losing a version is
incorrect. Blame on the {{else}} block shows only quota work; nothing discusses
old block ownership on the versioned path. The "versioning is not supported"
notes in the tree are about the S3 API and the MPU path, which is awareness
that versioning is incomplete rather than a report of this defect, and they do
not disable the native flag the commit path honours.
h3. Reproduction
PASS at escalation level 1.
{{repro/test_bugMC-2_versioned_concurrent_open_orphans_blocks.java}}, run as
{{TestSpeculaMC2VersionedConcurrentOpen extends OMKeyRequestTests}} in the
existing {{request/key}} suite, no new cluster.
Versioning ON, the bug lines are the last four:
{noformat}
[MC-2] bucket versioning enabled = true
[MC-2] after A commit, keyTable versions=[version:0 conID 1001 locID 101 || ]
[MC-2] after B commit, keyTable versions=[version:0 conID 1002 locID 102 || ]
[MC-2] blocks referenced by keyTable: [{blockID={conID: 1002 locID: 102}}]
[MC-2] blocks enqueued in deletedTable: []
[MC-2] bucket usedNamespace=2 usedBytes=2000
[MC-2] writer A block conID: 1001 locID: 101 referenced=false
queuedForDelete=false
{noformat}
Negative control, identical interleaving with versioning OFF, which passes:
{noformat}
[MC-2] bucket versioning enabled = false
[MC-2] blocks enqueued in deletedTable: [{blockID={conID: 1001 locID: 101}}]
[MC-2] bucket usedNamespace=1 usedBytes=1000
[MC-2] writer A block conID: 1001 locID: 101 referenced=false
queuedForDelete=true
{noformat}
Expected correct behaviour is the control's: A's blocks either retained as
version 0 of the versioned key, or enqueued in {{deletedTable}}. Flipping only
{{isVersionEnabled}} moves control from {{:348}} (enqueues, quota correct at 1
key and 1000 bytes) to {{:391}} (enqueues nothing, quota inflated to 2 and
2000), so the cause is isolated and a harness artifact is ruled out.
The OM apply path is single threaded by construction, so the interleaving is
chosen rather than raced. The only harness adjustment of note is that the
shared base class's {{allocateBlock}} stub returns one fixed block ID for every
call, which real SCM never does and which would have hidden the leak for a mock
only reason.
h3. Suggested fix
On the versioned commit path, treat {{keyToDelete != null}} as an overwrite
that must preserve or release the previous version's blocks, rather than
falling through to quota accounting only. Either retain the previous record's
version group in the committed record (which is what versioning promises) or
enqueue its blocks in {{deletedTable}} as the non versioned branch does. The
quota call on that branch should also stop counting an overwrite as a new
namespace entry.
Add coverage to the existing {{request/key}} suite: two writers opening the
same absent key on a versioned bucket, asserting after both commits that every
block from the first commit is either referenced by {{keyTable}} or present in
{{deletedTable}}, and that {{usedNamespace}} is 1.
h3. Patch
[^MC-2-versioned-overwrite-orphans-previous-blocks.patch], against
20e6a1c0f6039248373efd3eb1d7f2afcf7f1535.
Widens the overwrite cleanup branch in {{OMKeyCommitRequest}} and
{{OMKeyCommitRequestWithFSO}} from {{keyToDelete != null &&
!getIsVersionEnabled()}} to also cover the versioned case where the committed
record carries over none of the previous record's blocks, which is exactly the
concurrent open shape. A new shared {{hasBlocksNotCarriedOver}} in
{{OMKeyRequest}} is the predicate. An ordinary versioned overwrite, where the
open key did inherit the previous version, still takes the untouched {{else}}
branch, so version retention and its quota arithmetic are unchanged. On the
orphaning path, {{filterOutBlocksStillInUse}} keeps any block the new record
does share, and the branch's existing {{decrUsedNamespace}} plus
{{incrUsedNamespace}} arithmetic nets the inflated namespace count back to 1.
Scope note, for reviewers to decide: this makes the first version's blocks
reclaimable and fixes the namespace count. It does not add true version
retention, which would mean renumbering the committing writer's version group
and prepending the previous one, a change to version numbering semantics that
belongs in its own issue. After this patch the first version is still lost, as
it is today, but its blocks are no longer leaked.
True version retention is also in scope for the active S3 object versioning
work (HDDS-15728 and its T1 to T7 subtasks), so that half may belong there
rather than in a separate fix.
Covered by {{testVersionedConcurrentOpenDoesNotOrphanTheFirstVersionsBlocks}}
in the existing {{TestOMKeyCommitRequest}}, inherited by the FSO subclass. Two
writers commit the same absent key on a versioned bucket; the test asserts the
first writer's block is enqueued for deletion and that {{usedNamespace}} is 1.
Verified to fail without the change and pass with it on OBS and FSO. Checkstyle
clean. The 23 tests in each of those two classes 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]