tags;
private Long expectedDataGeneration = null;
+ private Long versionId = null;
+ private boolean isDeleteMarker;
+ private boolean isNullVersion;
public Builder() {
this.acls = AclListBuilder.empty();
@@ -533,6 +580,9 @@ public Builder(OmKeyInfo obj) {
this.fileChecksum = obj.fileChecksum;
this.isFile = obj.isFile;
this.expectedDataGeneration = obj.expectedDataGeneration;
+ this.versionId = obj.versionId;
+ this.isDeleteMarker = obj.isDeleteMarker;
+ this.isNullVersion = obj.isNullVersion;
this.tags = MapBuilder.of(obj.tags);
obj.keyLocationVersions.forEach(keyLocationVersion ->
this.omKeyLocationInfoGroups.add(
@@ -704,6 +754,21 @@ public Builder setExpectedDataGeneration(Long existingGeneration) {
return this;
}
+ public Builder setVersionId(Long versionId) {
+ this.versionId = versionId;
+ return this;
+ }
+
+ public Builder setDeleteMarker(boolean deleteMarker) {
+ this.isDeleteMarker = deleteMarker;
+ return this;
+ }
+
+ public Builder setNullVersion(boolean nullVersion) {
+ this.isNullVersion = nullVersion;
+ return this;
+ }
+
@Override
protected void validate() {
super.validate();
@@ -855,6 +920,16 @@ private KeyInfo getProtobuf(boolean ignorePipeline, String fullKeyName,
if (ownerName != null) {
kb.setOwnerName(ownerName);
}
+ if (versionId != null) {
+ kb.setVersionId(versionId);
+ }
+ // only persisted when set, to keep records without versioning unchanged
+ if (isDeleteMarker) {
+ kb.setIsDeleteMarker(true);
+ }
+ if (isNullVersion) {
+ kb.setIsNullVersion(true);
+ }
return kb.build();
}
@@ -909,6 +984,15 @@ public static Builder builderFromProtobuf(KeyInfo keyInfo) {
if (keyInfo.hasOwnerName()) {
builder.setOwnerName(keyInfo.getOwnerName());
}
+ if (keyInfo.hasVersionId()) {
+ builder.setVersionId(keyInfo.getVersionId());
+ }
+ if (keyInfo.hasIsDeleteMarker()) {
+ builder.setDeleteMarker(keyInfo.getIsDeleteMarker());
+ }
+ if (keyInfo.hasIsNullVersion()) {
+ builder.setNullVersion(keyInfo.getIsNullVersion());
+ }
return builder;
}
@@ -946,7 +1030,10 @@ public boolean isKeyInfoSame(OmKeyInfo omKeyInfo, boolean checkPath,
Objects.equals(getMetadata(), omKeyInfo.getMetadata()) &&
Objects.equals(acls, omKeyInfo.acls) &&
Objects.equals(getTags(), omKeyInfo.getTags()) &&
- getObjectID() == omKeyInfo.getObjectID();
+ getObjectID() == omKeyInfo.getObjectID() &&
+ Objects.equals(versionId, omKeyInfo.versionId) &&
+ isDeleteMarker == omKeyInfo.isDeleteMarker &&
+ isNullVersion == omKeyInfo.isNullVersion;
if (isEqual && checkUpdateID) {
isEqual = getUpdateID() == omKeyInfo.getUpdateID();
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/PinnedFirstVersionIdGenerator.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/PinnedFirstVersionIdGenerator.java
new file mode 100644
index 000000000000..061c467651c9
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/PinnedFirstVersionIdGenerator.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om.helpers;
+
+import com.google.common.base.Preconditions;
+
+/**
+ * Pins the first version of every key to {@link #FIRST_VERSION_ID} and uses the
+ * committing transaction's index for every later version, exactly like
+ * {@link TransactionIndexVersionIdGenerator}. Clusters configured with this
+ * generator can reference the first version of a key without listing it first.
+ *
+ * Holds no allocator state either: a key is on its first version exactly
+ * when keyTable holds no current version for it, which the write path looks up
+ * anyway.
+ *
+ *
The sentinel is smaller than any transaction index, so the first version
+ * sorts at the old end of the key's version sequence, as the versionedKeyTable
+ * layout requires.
+ *
+ *
Known trade-off: once every version of a key is permanently deleted, a
+ * recreated key takes the sentinel again, so an external reference to the first
+ * version resolves to the new content. Later versions are transaction indices
+ * and are never reused.
+ */
+public class PinnedFirstVersionIdGenerator implements VersionIdGenerator {
+
+ @Override
+ public long generateVersionId(long transactionLogIndex, boolean hasCurrentVersion) {
+ if (!hasCurrentVersion) {
+ return FIRST_VERSION_ID;
+ }
+ Preconditions.checkArgument(transactionLogIndex > FIRST_VERSION_ID,
+ "Transaction index " + transactionLogIndex
+ + " is a reserved versionId, expected greater than " + FIRST_VERSION_ID);
+ return transactionLogIndex;
+ }
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/TransactionIndexVersionIdGenerator.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/TransactionIndexVersionIdGenerator.java
new file mode 100644
index 000000000000..c94a7679e042
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/TransactionIndexVersionIdGenerator.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om.helpers;
+
+import com.google.common.base.Preconditions;
+
+/**
+ * Uses the index of the committing transaction as the versionId, the default
+ * generator. Holds no allocator state, so a version costs no read or write
+ * beyond the commit itself.
+ */
+public class TransactionIndexVersionIdGenerator implements VersionIdGenerator {
+
+ @Override
+ public long generateVersionId(long transactionLogIndex, boolean hasCurrentVersion) {
+ Preconditions.checkArgument(transactionLogIndex > FIRST_VERSION_ID,
+ "Transaction index " + transactionLogIndex
+ + " is a reserved versionId, expected greater than " + FIRST_VERSION_ID);
+ return transactionLogIndex;
+ }
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/VersionIdGenerator.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/VersionIdGenerator.java
new file mode 100644
index 000000000000..083b423a6677
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/VersionIdGenerator.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om.helpers;
+
+import org.apache.hadoop.hdds.conf.ConfigurationSource;
+import org.apache.hadoop.ozone.om.OMConfigKeys;
+import org.apache.hadoop.util.ReflectionUtils;
+
+/**
+ * Assigns the versionId of an object version when the version is committed.
+ *
+ *
Deployments differ in whether they need a version identity that can be
+ * constructed without listing, so the implementation is chosen per cluster
+ * through {@link OMConfigKeys#OZONE_OM_VERSIONING_VERSION_ID_GENERATOR}.
+ * Implementations must be public, have a public no-argument constructor, and
+ * satisfy the constraints that the versionedKeyTable layout and version
+ * promotion rely on:
+ *
+ *
+ * - ids strictly increase within a key: for one generator, the id of a
+ * version created later is always greater than the id of every version of
+ * that key created before it, never equal and never smaller. The
+ * versionedKeyTable ordering and version promotion depend on this;
+ * - an id is assigned once when the version is created and never changes
+ * afterwards, so that external references stay valid;
+ * - {@link #UNSET_VERSION_ID} and {@link #FIRST_VERSION_ID} are reserved
+ * and are never generated.
+ *
+ *
+ * The first constraint binds one generator, not a sequence of them: the
+ * generator is cluster-wide and may be changed on a running cluster, and the
+ * new one knows nothing of the ids the old one handed out.
+ * {@code VersionIdAllocator} enforces the constraint at commit time and refuses
+ * a write whose id does not come after the key's current version, so a change
+ * of generator fails loudly on affected keys instead of corrupting their
+ * version order.
+ */
+public interface VersionIdGenerator {
+
+ /**
+ * Unset value of the optional versionId field, carried by records written
+ * before versioning existed. Reserved, and never returned by a generator.
+ *
+ *
This is not the id of the null version: a null version carries a
+ * normally generated id like any other version and is identified by the
+ * {@code isNullVersion} attribute instead. Pinning it to a fixed low value
+ * would misorder a null created between two versioned writes, which is the
+ * middle version of the key rather than its oldest.
+ */
+ long UNSET_VERSION_ID = 0L;
+
+ /**
+ * Reserved id of the pinned first version of a key. Only assigned by
+ * generators that pin the first version of a key; it is smaller than any
+ * transaction index, so such a version sorts at the old end of the key's
+ * version sequence.
+ */
+ long FIRST_VERSION_ID = 1L;
+
+ /**
+ * Generates the versionId to freeze on a version being committed.
+ *
+ * @param transactionLogIndex index of the committing OM Ratis transaction
+ * @param hasCurrentVersion whether keyTable already holds a current version
+ * of the key being committed. The write path looks the current version up
+ * anyway, so generators that treat the first version of a key specially
+ * need no read of their own.
+ * @return the versionId of the new version
+ */
+ long generateVersionId(long transactionLogIndex, boolean hasCurrentVersion);
+
+ /**
+ * Instantiates the generator configured for this cluster.
+ *
+ * @throws RuntimeException if the configured class cannot be instantiated
+ */
+ static VersionIdGenerator fromConfiguration(ConfigurationSource conf) {
+ Class extends VersionIdGenerator> generatorClass = conf.getClass(
+ OMConfigKeys.OZONE_OM_VERSIONING_VERSION_ID_GENERATOR,
+ OMConfigKeys.OZONE_OM_VERSIONING_VERSION_ID_GENERATOR_DEFAULT,
+ VersionIdGenerator.class);
+ return ReflectionUtils.newInstance(generatorClass, null);
+ }
+}
diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestOmBucketArgs.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestOmBucketArgs.java
index 147255b3b573..cb24e5cbb571 100644
--- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestOmBucketArgs.java
+++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestOmBucketArgs.java
@@ -65,6 +65,32 @@ public void testQuotaIsSetFlagsAreCorrectlySet() {
assertTrue(argsFromProto.hasQuotaInNamespace());
}
+ @Test
+ public void testVersioningStatusIsSetCorrectly() {
+ OmBucketArgs bucketArgs = OmBucketArgs.newBuilder()
+ .setBucketName("bucket")
+ .setVolumeName("volume")
+ .build();
+
+ OmBucketArgs argsFromProto = OmBucketArgs.getFromProtobuf(
+ bucketArgs.getProtobuf());
+
+ // absent means "not being changed"
+ assertNull(argsFromProto.getVersioningStatus());
+
+ bucketArgs = OmBucketArgs.newBuilder()
+ .setBucketName("bucket")
+ .setVolumeName("volume")
+ .setVersioningStatus(BucketVersioningStatus.SUSPENDED)
+ .build();
+
+ argsFromProto = OmBucketArgs.getFromProtobuf(
+ bucketArgs.getProtobuf());
+
+ assertEquals(BucketVersioningStatus.SUSPENDED,
+ argsFromProto.getVersioningStatus());
+ }
+
@Test
public void testDefaultReplicationConfigIsSetCorrectly() {
OmBucketArgs bucketArgs = OmBucketArgs.newBuilder()
diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestOmBucketInfo.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestOmBucketInfo.java
index 857103a20c0d..935cfb05fd55 100644
--- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestOmBucketInfo.java
+++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestOmBucketInfo.java
@@ -29,6 +29,7 @@
import org.apache.hadoop.hdds.client.ReplicationConfig;
import org.apache.hadoop.hdds.client.ReplicationType;
import org.apache.hadoop.hdds.protocol.StorageType;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos;
import org.apache.hadoop.ozone.OzoneAcl;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos;
import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer;
@@ -54,6 +55,91 @@ public void protobufConversion() {
OmBucketInfo.getFromProtobuf(bucket.getProtobuf()));
}
+ @Test
+ public void legacyFlagDoesNotImplyAnS3VersioningStatus() {
+ // Records written before the versioningStatus field existed deserialize
+ // unchanged and keep carrying the legacy flag alone. The legacy flag
+ // selects the in-record block version list, which is a different feature
+ // from S3 versioning, so it must not be promoted to a status.
+ OzoneManagerProtocolProtos.BucketInfo oldRecord =
+ OzoneManagerProtocolProtos.BucketInfo.newBuilder()
+ .setVolumeName("vol1")
+ .setBucketName("bucket")
+ .setIsVersionEnabled(false)
+ .setStorageType(HddsProtos.StorageTypeProto.DISK)
+ .build();
+ OmBucketInfo bucket = OmBucketInfo.getFromProtobuf(oldRecord);
+ assertFalse(bucket.hasVersioningStatus());
+ assertEquals(BucketVersioningStatus.UNVERSIONED,
+ bucket.getVersioningStatus());
+ assertFalse(bucket.getIsVersionEnabled());
+ assertEquals(bucket, OmBucketInfo.getFromProtobuf(bucket.getProtobuf()));
+
+ oldRecord = oldRecord.toBuilder().setIsVersionEnabled(true).build();
+ bucket = OmBucketInfo.getFromProtobuf(oldRecord);
+ assertFalse(bucket.hasVersioningStatus());
+ assertEquals(BucketVersioningStatus.UNVERSIONED,
+ bucket.getVersioningStatus());
+ assertTrue(bucket.getIsVersionEnabled());
+ // the absent status survives the round trip, so a re-serialized legacy
+ // record is still distinguishable from an S3-versioned one
+ assertFalse(bucket.getProtobuf().hasVersioningStatus());
+ assertEquals(bucket, OmBucketInfo.getFromProtobuf(bucket.getProtobuf()));
+ }
+
+ @Test
+ public void versioningStatusProtobufConversion() {
+ // SUSPENDED is not representable by the legacy flag alone, so it must
+ // survive a proto round trip via the new field.
+ OmBucketInfo bucket = OmBucketInfo.newBuilder()
+ .setBucketName("bucket")
+ .setVolumeName("vol1")
+ .setVersioningStatus(BucketVersioningStatus.SUSPENDED)
+ .build();
+ assertFalse(bucket.getIsVersionEnabled());
+
+ OmBucketInfo recovered = OmBucketInfo.getFromProtobuf(bucket.getProtobuf());
+ assertEquals(BucketVersioningStatus.SUSPENDED,
+ recovered.getVersioningStatus());
+ assertFalse(recovered.getIsVersionEnabled());
+ assertEquals(bucket, recovered);
+ }
+
+ @Test
+ public void builderKeepsVersioningStatusAndLegacyFlagInSync() {
+ OmBucketInfo.Builder builder = OmBucketInfo.newBuilder()
+ .setBucketName("bucket")
+ .setVolumeName("vol1");
+
+ // no status set at all
+ assertFalse(builder.build().hasVersioningStatus());
+ assertEquals(BucketVersioningStatus.UNVERSIONED,
+ builder.build().getVersioningStatus());
+
+ // the legacy flag sets only itself: no status is derived from it
+ builder.setIsVersionEnabled(true);
+ assertFalse(builder.build().hasVersioningStatus());
+ assertEquals(BucketVersioningStatus.UNVERSIONED,
+ builder.build().getVersioningStatus());
+ assertTrue(builder.build().getIsVersionEnabled());
+
+ // an explicit status is authoritative and drives the legacy flag
+ builder.setVersioningStatus(BucketVersioningStatus.SUSPENDED);
+ assertTrue(builder.build().hasVersioningStatus());
+ assertEquals(BucketVersioningStatus.SUSPENDED,
+ builder.build().getVersioningStatus());
+ assertFalse(builder.build().getIsVersionEnabled());
+
+ // ENABLED shows up as true to clients that only know the legacy flag
+ builder.setVersioningStatus(BucketVersioningStatus.ENABLED);
+ assertTrue(builder.build().getIsVersionEnabled());
+
+ // a null status is a no-op (records without the new field)
+ builder.setVersioningStatus(null);
+ assertEquals(BucketVersioningStatus.ENABLED,
+ builder.build().getVersioningStatus());
+ }
+
@Test
public void protobufConversionOfBucketLink() {
OmBucketInfo bucket = OmBucketInfo.newBuilder()
diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestOmKeyInfo.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestOmKeyInfo.java
index 285853a3a766..12846bece1c4 100644
--- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestOmKeyInfo.java
+++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestOmKeyInfo.java
@@ -25,6 +25,7 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
@@ -116,6 +117,40 @@ public void getProtobufMessageEC() throws IOException {
assertEquals(2, config.getParity());
}
+ @Test
+ public void protobufConversionWithVersioningFields() {
+ // records without versioning fields keep them unset after a round trip
+ OmKeyInfo key = createOmKeyInfo(
+ RatisReplicationConfig.getInstance(ReplicationFactor.THREE));
+ OzoneManagerProtocolProtos.KeyInfo proto = key.getProtobuf(ClientVersion.CURRENT_VERSION);
+ assertFalse(proto.hasVersionId());
+ assertFalse(proto.hasIsDeleteMarker());
+ assertFalse(proto.hasIsNullVersion());
+ OmKeyInfo recovered = OmKeyInfo.getFromProtobuf(proto);
+ assertNull(recovered.getVersionId());
+ assertFalse(recovered.isDeleteMarker());
+ assertFalse(recovered.isNullVersion());
+
+ // versioning fields survive a round trip and the copy constructor
+ key = createOmKeyInfo(RatisReplicationConfig.getInstance(ReplicationFactor.THREE))
+ .toBuilder()
+ .setVersionId(4242L)
+ .setDeleteMarker(true)
+ .setNullVersion(true)
+ .build();
+ recovered = OmKeyInfo.getFromProtobuf(key.getProtobuf(ClientVersion.CURRENT_VERSION));
+ assertEquals(4242L, recovered.getVersionId());
+ assertTrue(recovered.isDeleteMarker());
+ assertTrue(recovered.isNullVersion());
+
+ // records differing only in a versioning field must not compare equal
+ OmKeyInfo plain = createOmKeyInfo(
+ RatisReplicationConfig.getInstance(ReplicationFactor.THREE));
+ assertNotEquals(plain, plain.toBuilder().setVersionId(1L).build());
+ assertNotEquals(plain, plain.toBuilder().setDeleteMarker(true).build());
+ assertNotEquals(plain, plain.toBuilder().setNullVersion(true).build());
+ }
+
private OmKeyInfo createOmKeyInfo(ReplicationConfig replicationConfig) {
return new Builder()
.setKeyName("key1")
diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestVersionIdGenerator.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestVersionIdGenerator.java
new file mode 100644
index 000000000000..612b42872927
--- /dev/null
+++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestVersionIdGenerator.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om.helpers;
+
+import static org.apache.hadoop.ozone.om.helpers.VersionIdGenerator.FIRST_VERSION_ID;
+import static org.apache.hadoop.ozone.om.helpers.VersionIdGenerator.UNSET_VERSION_ID;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.stream.Stream;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.ozone.om.OMConfigKeys;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests the constraints every {@link VersionIdGenerator} has to satisfy, and
+ * how the cluster-wide generator is selected.
+ */
+public class TestVersionIdGenerator {
+
+ /** The first transaction index that is not a reserved versionId. */
+ private static final long FIRST_USABLE_INDEX = FIRST_VERSION_ID + 1;
+
+ /** Every generator shipped with Ozone; extended as generators are added. */
+ static Stream generators() {
+ return Stream.of(new TransactionIndexVersionIdGenerator(),
+ new PinnedFirstVersionIdGenerator());
+ }
+
+ @ParameterizedTest
+ @MethodSource("generators")
+ void generatedIdsStrictlyIncreaseWithinAKey(VersionIdGenerator generator) {
+ // The contract every generator owes VersionIdAllocator: over the life of a
+ // key, ids only ever go up, starting from the key's first version.
+ long previous = generator.generateVersionId(FIRST_USABLE_INDEX, false);
+ for (long index = FIRST_USABLE_INDEX + 1; index < 100; index++) {
+ long current = generator.generateVersionId(index, true);
+ assertTrue(previous < current,
+ "versionId " + current + " generated for transaction " + index
+ + " does not exceed " + previous);
+ previous = current;
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("generators")
+ void generatedIdsNeverCollideWithReservedIds(VersionIdGenerator generator) {
+ for (long index = FIRST_USABLE_INDEX; index < 100; index++) {
+ assertNotEquals(UNSET_VERSION_ID, generator.generateVersionId(index, true));
+ assertNotEquals(UNSET_VERSION_ID, generator.generateVersionId(index, false));
+ }
+ // A transaction index that lands on a reserved id is a misconfiguration of
+ // the Ratis log rather than something to silently work around.
+ assertThrows(IllegalArgumentException.class,
+ () -> generator.generateVersionId(UNSET_VERSION_ID, true));
+ assertThrows(IllegalArgumentException.class,
+ () -> generator.generateVersionId(FIRST_VERSION_ID, true));
+ }
+
+ @ParameterizedTest
+ @MethodSource("generators")
+ void generationIsDeterministic(VersionIdGenerator generator) {
+ assertEquals(generator.generateVersionId(4242, true),
+ generator.generateVersionId(4242, true));
+ assertEquals(generator.generateVersionId(4242, false),
+ generator.generateVersionId(4242, false));
+ }
+
+ @Test
+ void reservedIdsDoNotCollide() {
+ assertNotEquals(UNSET_VERSION_ID, FIRST_VERSION_ID);
+ }
+
+ @Test
+ void transactionIndexGeneratorIsTheDefault() {
+ assertInstanceOf(TransactionIndexVersionIdGenerator.class,
+ VersionIdGenerator.fromConfiguration(new OzoneConfiguration()));
+ }
+
+ @Test
+ void transactionIndexIgnoresWhetherTheKeyHasACurrentVersion() {
+ VersionIdGenerator generator = new TransactionIndexVersionIdGenerator();
+
+ assertEquals(7, generator.generateVersionId(7, false));
+ assertEquals(7, generator.generateVersionId(7, true));
+ }
+
+ @Test
+ void pinnedFirstPinsOnlyTheFirstVersionOfAKey() {
+ VersionIdGenerator generator = new PinnedFirstVersionIdGenerator();
+
+ assertEquals(FIRST_VERSION_ID, generator.generateVersionId(7, false));
+ assertEquals(7, generator.generateVersionId(7, true));
+ }
+
+ @Test
+ void pinnedFirstSentinelIsOlderThanEveryTransactionIndex() {
+ VersionIdGenerator generator = new PinnedFirstVersionIdGenerator();
+ long first = generator.generateVersionId(FIRST_USABLE_INDEX, false);
+
+ for (long index = FIRST_USABLE_INDEX; index < 100; index++) {
+ assertTrue(first < generator.generateVersionId(index, true),
+ "sentinel " + first + " is not older than the version at transaction " + index);
+ }
+ }
+
+ @Test
+ void pinnedFirstSentinelIsNotTheUnsetId() {
+ assertNotEquals(UNSET_VERSION_ID,
+ new PinnedFirstVersionIdGenerator().generateVersionId(7, false));
+ }
+
+ @Test
+ void pinnedFirstGeneratorIsSelectableByConfiguration() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ conf.set(OMConfigKeys.OZONE_OM_VERSIONING_VERSION_ID_GENERATOR,
+ PinnedFirstVersionIdGenerator.class.getName());
+
+ assertInstanceOf(PinnedFirstVersionIdGenerator.class,
+ VersionIdGenerator.fromConfiguration(conf));
+ }
+
+ @Test
+ void generatorClassIsReadFromConfiguration() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ conf.set(OMConfigKeys.OZONE_OM_VERSIONING_VERSION_ID_GENERATOR,
+ TransactionIndexVersionIdGenerator.class.getName());
+
+ assertInstanceOf(TransactionIndexVersionIdGenerator.class,
+ VersionIdGenerator.fromConfiguration(conf));
+ }
+
+ @Test
+ void unknownGeneratorClassIsRejected() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ conf.set(OMConfigKeys.OZONE_OM_VERSIONING_VERSION_ID_GENERATOR,
+ "org.apache.hadoop.ozone.om.helpers.NoSuchVersionIdGenerator");
+
+ assertThrows(RuntimeException.class, () -> VersionIdGenerator.fromConfiguration(conf));
+ }
+
+ @Test
+ void generatorClassNotImplementingTheInterfaceIsRejected() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ conf.set(OMConfigKeys.OZONE_OM_VERSIONING_VERSION_ID_GENERATOR,
+ String.class.getName());
+
+ assertThrows(RuntimeException.class, () -> VersionIdGenerator.fromConfiguration(conf));
+ }
+}
diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
index bb8f54c79c56..a135d0a8bf67 100644
--- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
+++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
@@ -598,6 +598,10 @@ enum Status {
ETAG_NOT_AVAILABLE = 100;
ATOMIC_WRITE_CONFLICT = 101;
+
+ // The addressed version exists but is a delete marker. Distinct from
+ // KEY_NOT_FOUND: the S3 Gateway maps it to 405, not 404.
+ KEY_IS_DELETE_MARKER = 102;
}
/**
@@ -808,6 +812,9 @@ message BucketInfo {
optional uint64 snapshotUsedNamespace = 22;
// TODO: S3 bucket tags persisted in OM DB; set by PutBucketTagging, read by GetBucketTagging.
repeated hadoop.hdds.KeyValue tags = 23;
+ // S3-compatible object versioning status. When absent, derived from
+ // isVersionEnabled (true -> VERSIONING_ENABLED, false -> UNVERSIONED).
+ optional BucketVersioningStatusProto versioningStatus = 24;
}
enum BucketLayoutProto {
@@ -816,6 +823,17 @@ enum BucketLayoutProto {
OBJECT_STORE = 3;
}
+/**
+ * S3-compatible bucket versioning state machine:
+ * UNVERSIONED -> VERSIONING_ENABLED <-> VERSIONING_SUSPENDED.
+ * Once enabled, a bucket can never return to UNVERSIONED.
+ */
+enum BucketVersioningStatusProto {
+ UNVERSIONED = 1;
+ VERSIONING_ENABLED = 2;
+ VERSIONING_SUSPENDED = 3;
+}
+
/**
* Cipher suite.
*/
@@ -883,6 +901,9 @@ message BucketArgs {
optional BucketEncryptionInfoProto bekInfo = 12;
// TODO: Tag payload for PutBucketTagging only.
repeated hadoop.hdds.KeyValue tags = 13;
+ // S3-compatible object versioning status. Takes precedence over
+ // isVersionEnabled when both are set.
+ optional BucketVersioningStatusProto versioningStatus = 14;
}
message PrefixInfo {
@@ -1124,6 +1145,14 @@ message KeyArgs {
// the given ETag for the operation to succeed. This is used for
// S3 conditional writes with the If-Match header.
optional string expectedETag = 24;
+
+ // S3-compatible object versioning: addresses one specific version of the key
+ // instead of its current version. nullVersion selects the key's null version
+ // slot, which cannot be addressed by id because a null version carries a
+ // normally generated versionId like any other version. At most one of the
+ // two may be set.
+ optional uint64 versionId = 25;
+ optional bool nullVersion = 26;
}
message KeyLocation {
@@ -1217,6 +1246,15 @@ message KeyInfo {
// This allows a key to be created an committed atomically if the original has not
// been modified.
optional uint64 expectedDataGeneration = 22;
+ // S3-compatible object versioning fields. versionId identifies an object
+ // version: assigned once from the committing transaction's index when the
+ // version is created, then frozen. A delete marker is a record with
+ // isDeleteMarker set and no data blocks. isNullVersion marks the single
+ // overwritable "null version" slot per key (writes while versioning is
+ // suspended, or objects that predate enabling versioning).
+ optional uint64 versionId = 23;
+ optional bool isDeleteMarker = 24;
+ optional bool isNullVersion = 25;
}
// KeyInfoProtoLight is a lightweight subset of KeyInfo message containing
diff --git a/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java b/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java
index be66ffc195b5..ad2450e8e75f 100644
--- a/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java
+++ b/hadoop-ozone/interface-storage/src/main/java/org/apache/hadoop/ozone/om/OMMetadataManager.java
@@ -172,6 +172,24 @@ public interface OMMetadataManager extends DBStoreHAManager, AutoCloseable {
*/
String getOzoneKey(String volume, String bucket, String key);
+ /**
+ * Given a volume, bucket, key and versionId, return the corresponding
+ * versionedKeyTable DB key: the versionId is appended as fixed-width hex of
+ * (Long.MAX_VALUE - versionId), so all versions of a key are adjacent and
+ * ordered newest first.
+ */
+ String getVersionedOzoneKey(String volume, String bucket, String key, long versionId);
+
+ /**
+ * Prefix under which all noncurrent versions of the given key are stored in
+ * the versionedKeyTable. The key name is separated from the versionId suffix
+ * by OM_VERSIONED_KEY_SEPARATOR rather than OM_KEY_PREFIX, so that a key's
+ * versions stay contiguous under this prefix and sort before any key nested
+ * under it: seeking this prefix yields exactly that key's versions, newest
+ * first.
+ */
+ String getVersionedOzoneKeyPrefix(String volume, String bucket, String key);
+
/**
* Get DB key for a key or prefix in an FSO bucket given existing
* volume and bucket names.
@@ -405,6 +423,14 @@ List getExpiredMultipartUploads(
Table getKeyTable(BucketLayout bucketLayout);
+ /**
+ * Returns the versionedKeyTable holding noncurrent object versions
+ * (including noncurrent delete markers) of versioning-enabled buckets.
+ *
+ * @return versionedKeyTable.
+ */
+ Table getVersionedKeyTable();
+
/**
* Returns the FileTable.
*
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/KeyManagerImpl.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/KeyManagerImpl.java
index 3232f9b1ff33..33b8c2c3a9fd 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/KeyManagerImpl.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/KeyManagerImpl.java
@@ -601,15 +601,31 @@ private OmKeyInfo readKeyInfo(OmKeyArgs args, BucketLayout bucketLayout)
bucketLayout);
if (bucketLayout.isFileSystemOptimized()) {
+ if (args.addressesVersion()) {
+ throw new OMException("Object versioning is only supported on "
+ + BucketLayout.OBJECT_STORE + " buckets",
+ ResultCodes.NOT_SUPPORTED_OPERATION);
+ }
value = getOmKeyInfoFSO(volumeName, bucketName, keyName);
} else {
value = getOmKeyInfo(volumeName, bucketName, keyName, bucketLayout);
+ if (args.addressesVersion()) {
+ value = getAddressedVersion(args, volumeName, bucketName, keyName,
+ value);
+ }
if (value != null) {
// For Legacy & OBS buckets, any key is a file by default. This is to
// keep getKeyInfo compatible with OFS clients.
value.setFile(true);
}
}
+ if (value != null && value.isDeleteMarker()) {
+ // Addressing a delete marker by version is answered with 405 by S3,
+ // while a request that lands on a current marker is a plain 404.
+ throw new OMException("Key: " + keyName + " is a delete marker",
+ args.addressesVersion()
+ ? ResultCodes.KEY_IS_DELETE_MARKER : ResultCodes.KEY_NOT_FOUND);
+ }
} catch (IOException ex) {
if (ex instanceof OMException) {
throw ex;
@@ -661,6 +677,48 @@ private OmKeyInfo getOmKeyInfo(String volumeName, String bucketName,
.get(keyBytes);
}
+ /**
+ * Resolves the version addressed by {@code args} for a key whose current
+ * version is {@code current}. The current version is checked first, so a
+ * request naming the current version costs no extra read; otherwise the
+ * version is looked up in the versionedKeyTable.
+ *
+ * @param current the key's current version, or null when the key has none
+ * @return the addressed version, or null when it does not exist
+ */
+ private OmKeyInfo getAddressedVersion(OmKeyArgs args, String volumeName,
+ String bucketName, String keyName, OmKeyInfo current) throws IOException {
+ if (args.isNullVersion()) {
+ if (current != null && current.isNullVersionRecord()) {
+ return current;
+ }
+ // The null version carries a normally generated versionId, so it can only
+ // be found by scanning the key's versions. The scan is bounded by the
+ // number of versions the key has and stops at the first match, since a key
+ // has at most one null version.
+ String prefix = metadataManager
+ .getVersionedOzoneKeyPrefix(volumeName, bucketName, keyName);
+ try (Table.KeyValueIterator versions =
+ metadataManager.getVersionedKeyTable().iterator(prefix)) {
+ while (versions.hasNext()) {
+ OmKeyInfo version = versions.next().getValue();
+ if (version.isNullVersionRecord()) {
+ return version;
+ }
+ }
+ }
+ return null;
+ }
+
+ long versionId = args.getVersionId();
+ if (current != null && current.getVersionId() != null
+ && current.getVersionId() == versionId) {
+ return current;
+ }
+ return metadataManager.getVersionedKeyTable().get(metadataManager
+ .getVersionedOzoneKey(volumeName, bucketName, keyName, versionId));
+ }
+
/**
* Look up will return only closed fileInfo. This will return null if the
* keyName is a directory or if the keyName is still open for writing.
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java
index 283bb4933580..18f213df0884 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataManagerImpl.java
@@ -22,6 +22,7 @@
import static org.apache.hadoop.ozone.OzoneConsts.OM_DB_NAME;
import static org.apache.hadoop.ozone.OzoneConsts.OM_KEY_PREFIX;
import static org.apache.hadoop.ozone.OzoneConsts.OM_SNAPSHOT_CHECKPOINT_DIR;
+import static org.apache.hadoop.ozone.OzoneConsts.OM_VERSIONED_KEY_SEPARATOR;
import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_OM_DB_MAX_OPEN_FILES;
import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_OM_DB_MAX_OPEN_FILES_DEFAULT;
import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_OM_SNAPSHOT_DB_MAX_OPEN_FILES;
@@ -159,6 +160,7 @@ public class OmMetadataManagerImpl implements OMMetadataManager,
private Table volumeTable;
private Table bucketTable;
private Table keyTable;
+ private Table versionedKeyTable;
private Table openKeyTable;
private Table multipartInfoTable;
@@ -376,6 +378,11 @@ public Table getKeyTable(BucketLayout bucketLayout) {
return keyTable;
}
+ @Override
+ public Table getVersionedKeyTable() {
+ return versionedKeyTable;
+ }
+
@Override
public Table getFileTable() {
return fileTable;
@@ -494,6 +501,7 @@ protected void initializeOmTables(CacheType cacheType,
volumeTable = initializer.get(OMDBDefinition.VOLUME_TABLE_DEF, cacheType);
bucketTable = initializer.get(OMDBDefinition.BUCKET_TABLE_DEF, cacheType);
keyTable = initializer.get(OMDBDefinition.KEY_TABLE_DEF);
+ versionedKeyTable = initializer.get(OMDBDefinition.VERSIONED_KEY_TABLE_DEF);
openKeyTable = initializer.get(OMDBDefinition.OPEN_KEY_TABLE_DEF);
multipartInfoTable = initializer.get(OMDBDefinition.MULTIPART_INFO_TABLE_DEF);
@@ -649,6 +657,17 @@ public String getOzoneKey(String volume, String bucket, String key) {
return builder.toString();
}
+ @Override
+ public String getVersionedOzoneKey(String volume, String bucket, String key, long versionId) {
+ return getVersionedOzoneKeyPrefix(volume, bucket, key)
+ + String.format("%016x", Long.MAX_VALUE - versionId);
+ }
+
+ @Override
+ public String getVersionedOzoneKeyPrefix(String volume, String bucket, String key) {
+ return getOzoneKey(volume, bucket, key) + OM_VERSIONED_KEY_SEPARATOR;
+ }
+
@Override
public String getOzoneKeyFSO(String volumeName,
String bucketName,
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
index 5d1e33f7cd84..b85802f26e88 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
@@ -416,6 +416,7 @@ public final class OzoneManager extends ServiceRuntimeInfoImpl
private BucketManager bucketManager;
private KeyManager keyManager;
private PrefixManagerImpl prefixManager;
+ private final VersionIdAllocator versionIdAllocator;
private final UpgradeFinalizer upgradeFinalizer;
private ExecutorService edekCacheLoader = null;
@@ -565,6 +566,7 @@ private OzoneManager(OzoneConfiguration conf, StartupOption startupOption)
versionManager = new OMLayoutVersionManager(omStorage.getLayoutVersion());
upgradeFinalizer = new OMUpgradeFinalizer(versionManager);
+ versionIdAllocator = new VersionIdAllocator(conf);
replicationConfigValidator =
conf.getObject(ReplicationConfigValidator.class);
@@ -2387,6 +2389,15 @@ public long getObjectIdFromTxId(long trxnId) {
trxnId);
}
+ /**
+ * Assigns the versionId of a version being committed, using the
+ * {@link org.apache.hadoop.ozone.om.helpers.VersionIdGenerator} configured
+ * for this cluster.
+ */
+ public VersionIdAllocator getVersionIdAllocator() {
+ return versionIdAllocator;
+ }
+
/**
*
* @return Gets the stored layout version from the DB meta table.
@@ -3060,6 +3071,11 @@ public OmBucketInfo getBucketInfo(String volume, String bucket)
.setDefaultReplicationConfig(
realBucket.getDefaultReplicationConfig())
.setIsVersionEnabled(realBucket.getIsVersionEnabled())
+ // Only when the real bucket actually carries a status: copying the
+ // value getVersioningStatus() derives for a legacy bucket would give
+ // the link an explicit status the real bucket does not have.
+ .setVersioningStatus(realBucket.hasVersioningStatus()
+ ? realBucket.getVersioningStatus() : null)
.setStorageType(realBucket.getStorageType())
.setQuotaInBytes(realBucket.getQuotaInBytes())
.setQuotaInNamespace(realBucket.getQuotaInNamespace())
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/VersionIdAllocator.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/VersionIdAllocator.java
new file mode 100644
index 000000000000..bc2f776254a2
--- /dev/null
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/VersionIdAllocator.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om;
+
+import java.io.IOException;
+import org.apache.hadoop.hdds.conf.ConfigurationSource;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
+import org.apache.hadoop.ozone.om.helpers.OmKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.VersionIdGenerator;
+
+/**
+ * Assigns the versionId of a version being committed, using the generator
+ * configured for this cluster.
+ *
+ * The generator is cluster-wide and can be changed by reconfiguring the OMs,
+ * so ids generated before and after a change are not guaranteed to be distinct.
+ * Rather than constrain the change, a commit whose versionId already exists on
+ * the key is rejected: the operator or the client deletes the existing version
+ * first, and the write can then be retried.
+ */
+public class VersionIdAllocator {
+
+ private final VersionIdGenerator generator;
+
+ public VersionIdAllocator(ConfigurationSource conf) {
+ this(VersionIdGenerator.fromConfiguration(conf));
+ }
+
+ public VersionIdAllocator(VersionIdGenerator generator) {
+ this.generator = generator;
+ }
+
+ public VersionIdGenerator getGenerator() {
+ return generator;
+ }
+
+ /**
+ * Returns the versionId to freeze on the version being committed.
+ *
+ *
The id must be greater than the one on the key's current version: a
+ * generator has to hand out increasing ids for a key, and the versionedKeyTable
+ * ordering and version promotion depend on it. An id that is not greater is
+ * refused rather than written, because it would either overwrite an existing
+ * version or sort into the wrong place. In practice this only happens after the
+ * cluster's generator is changed, or if the Ratis log index went backwards.
+ *
+ * @param currentVersion the key's current version, or null if the key has
+ * none. The write path holds it already, so no extra read is needed here.
+ * @throws OMException INVALID_REQUEST if the generated id does not exceed the
+ * current version's id, KEY_ALREADY_EXISTS if it is already taken
+ */
+ public long allocate(OMMetadataManager metadataManager, String volumeName,
+ String bucketName, String keyName, long transactionLogIndex,
+ OmKeyInfo currentVersion) throws IOException {
+
+ long versionId =
+ generator.generateVersionId(transactionLogIndex, currentVersion != null);
+
+ if (currentVersion == null) {
+ // No current version means the key has no versions at all, so nothing can
+ // be taken and nothing constrains the id.
+ return versionId;
+ }
+
+ Long currentVersionId = currentVersion.getVersionId();
+ if (currentVersionId == null) {
+ // A current version written before versioning was enabled carries no id,
+ // so there is nothing to order against; fall back to looking the id up.
+ if (isTaken(metadataManager, volumeName, bucketName, keyName, versionId)) {
+ throw alreadyExists(volumeName, bucketName, keyName, versionId);
+ }
+ return versionId;
+ }
+
+ if (versionId <= currentVersionId) {
+ throw new OMException("Version " + versionId + " of key /" + volumeName + "/"
+ + bucketName + "/" + keyName + " does not come after the current version "
+ + currentVersionId + ". " + generator.getClass().getName()
+ + " must generate increasing versionIds for a key; an id that goes backwards can "
+ + "happen after the cluster's " + OMConfigKeys.OZONE_OM_VERSIONING_VERSION_ID_GENERATOR
+ + " is changed. Delete the key's versions before writing with the new generator.",
+ OMException.ResultCodes.INVALID_REQUEST);
+ }
+
+ // Strictly greater than the largest id on the key, so no lookup is needed:
+ // every noncurrent version of the key has a smaller id than the current one.
+ return versionId;
+ }
+
+ private static OMException alreadyExists(String volumeName, String bucketName,
+ String keyName, long versionId) {
+ return new OMException("Version " + versionId + " of key /" + volumeName + "/"
+ + bucketName + "/" + keyName + " already exists. Delete that version before "
+ + "writing this one.", OMException.ResultCodes.KEY_ALREADY_EXISTS);
+ }
+
+ private boolean isTaken(OMMetadataManager metadataManager, String volumeName,
+ String bucketName, String keyName, long versionId) throws IOException {
+ return metadataManager.getVersionedKeyTable().isExist(
+ metadataManager.getVersionedOzoneKey(volumeName, bucketName, keyName, versionId));
+ }
+
+}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java
index 02e32edec464..49d24f9d6138 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/codec/OMDBDefinition.java
@@ -86,6 +86,7 @@
* | Column Family | Mapping |
* |----------------------------------------------------------------------------------|
* | keyTable | /volume/bucket/key :- KeyInfo |
+ * | versionedKeyTable | /volume/bucket/key\0revVersionId :- KeyInfo |
* | deletedTable | /volume/bucket/key :- RepeatedKeyInfo |
* | openKeyTable | /volume/bucket/key/id :- KeyInfo |
* | multipartInfoTable | /volume/bucket/key/uploadId :- parts |
@@ -210,6 +211,21 @@ public final class OMDBDefinition extends DBDefinition.WithMap {
StringCodec.get(),
OmKeyInfo.getKeyTableCodec());
+ public static final String VERSIONED_KEY_TABLE = "versionedKeyTable";
+ /**
+ * versionedKeyTable: /volume/bucket/key\0revVersionId :- KeyInfo.
+ * Noncurrent object versions (including noncurrent delete markers) of
+ * versioning-enabled buckets; the current version stays in keyTable.
+ * revVersionId is the fixed-width hex of (Long.MAX_VALUE - versionId), so
+ * versions of a key are adjacent and ordered newest first. The separator is
+ * OM_VERSIONED_KEY_SEPARATOR (0x00), not '/', because OBJECT_STORE key names
+ * contain '/' verbatim.
+ */
+ public static final DBColumnFamilyDefinition VERSIONED_KEY_TABLE_DEF
+ = new DBColumnFamilyDefinition<>(VERSIONED_KEY_TABLE,
+ StringCodec.get(),
+ OmKeyInfo.getKeyTableCodec());
+
public static final String DELETED_TABLE = "deletedTable";
/** deletedTable: /volume/bucket/key :- RepeatedKeyInfo (excludes fields only used in openKeyTable). */
public static final DBColumnFamilyDefinition DELETED_TABLE_DEF
@@ -353,6 +369,7 @@ public final class OMDBDefinition extends DBDefinition.WithMap {
TENANT_STATE_TABLE_DEF,
TRANSACTION_INFO_TABLE_DEF,
USER_TABLE_DEF,
+ VERSIONED_KEY_TABLE_DEF,
VOLUME_TABLE_DEF);
private static final OMDBDefinition INSTANCE = new OMDBDefinition();
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketCreateRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketCreateRequest.java
index 718f329aaaff..51bae769b8c0 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketCreateRequest.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketCreateRequest.java
@@ -101,6 +101,15 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException {
// FSO and LEGACY buckets are not strictly bound to S3 naming semantics.
OmUtils.validateBucketName(bucketInfo.getBucketName(), strict);
+ if (bucketInfo.hasVersioningStatus()
+ && bucketLayout != BucketLayout.OBJECT_STORE) {
+ throw new OMException("S3 object versioning is only supported on "
+ + BucketLayout.OBJECT_STORE + " buckets, but bucket "
+ + bucketInfo.getBucketName() + " is created with layout "
+ + bucketLayout + ".",
+ OMException.ResultCodes.NOT_SUPPORTED_OPERATION);
+ }
+
// ACL check during preExecute
if (ozoneManager.getAclsEnabled()) {
try {
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetPropertyRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetPropertyRequest.java
index a88e5fb73334..07fa9b95b7b8 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetPropertyRequest.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/bucket/OMBucketSetPropertyRequest.java
@@ -38,6 +38,8 @@
import org.apache.hadoop.ozone.om.exceptions.OMException;
import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext;
import org.apache.hadoop.ozone.om.helpers.BucketEncryptionKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
+import org.apache.hadoop.ozone.om.helpers.BucketVersioningStatus;
import org.apache.hadoop.ozone.om.helpers.KeyValueUtil;
import org.apache.hadoop.ozone.om.helpers.OmBucketArgs;
import org.apache.hadoop.ozone.om.helpers.OmBucketInfo;
@@ -174,10 +176,41 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
//Check Versioning to update
Boolean versioning = omBucketArgs.getIsVersionEnabled();
+ BucketVersioningStatus newVersioningStatus = omBucketArgs.getVersioningStatus();
if (versioning != null) {
+ // Apply the legacy flag on its own; setVersioningStatus below overrides
+ // it when a status is also being set.
bucketInfoBuilder.setIsVersionEnabled(versioning);
- LOG.debug("Updating bucket versioning for bucket: {} in volume: {}",
- bucketName, volumeName);
+ }
+ if (newVersioningStatus == null && versioning != null
+ && dbBucketInfo.hasVersioningStatus()) {
+ // Legacy flag from an older client against a bucket that already has an
+ // S3 versioning status: keep the two consistent. Disabling maps to
+ // SUSPENDED, since the S3 state machine forbids returning to
+ // UNVERSIONED once versioning has been enabled.
+ //
+ // On a bucket without a status the flag is left alone: it selects the
+ // legacy in-record block version list, and an old client must not be
+ // able to opt a bucket into S3 versioning semantics.
+ newVersioningStatus = versioning
+ ? BucketVersioningStatus.ENABLED : BucketVersioningStatus.SUSPENDED;
+ }
+ if (newVersioningStatus != null) {
+ if (dbBucketInfo.getBucketLayout() != BucketLayout.OBJECT_STORE) {
+ throw new OMException("S3 object versioning is only supported on "
+ + BucketLayout.OBJECT_STORE + " buckets, but bucket " + bucketName
+ + " has layout " + dbBucketInfo.getBucketLayout() + ".",
+ OMException.ResultCodes.NOT_SUPPORTED_OPERATION);
+ }
+ if (!dbBucketInfo.getVersioningStatus().canTransitionTo(newVersioningStatus)) {
+ throw new OMException("Bucket versioning cannot be changed from "
+ + dbBucketInfo.getVersioningStatus() + " to " + newVersioningStatus
+ + "; once enabled, versioning can only be suspended.",
+ OMException.ResultCodes.INVALID_REQUEST);
+ }
+ bucketInfoBuilder.setVersioningStatus(newVersioningStatus);
+ LOG.debug("Updating bucket versioning to {} for bucket: {} in volume: {}",
+ newVersioningStatus, bucketName, volumeName);
}
//Check quotaInBytes and quotaInNamespace to update
@@ -376,4 +409,27 @@ public static OMRequest disallowSetBucketPropertyWithECReplicationConfig(
}
return req;
}
+
+ @RequestFeatureValidator(
+ conditions = ValidationCondition.CLUSTER_NEEDS_FINALIZATION,
+ processingPhase = RequestProcessingPhase.PRE_PROCESS,
+ requestType = Type.SetBucketProperty
+ )
+ public static OMRequest disallowSetBucketPropertyWithVersioningStatus(
+ OMRequest req, ValidationContext ctx) throws OMException {
+ if (!ctx.versionManager()
+ .isAllowed(OMLayoutFeature.OBJECT_VERSIONING)) {
+ SetBucketPropertyRequest propReq =
+ req.getSetBucketPropertyRequest();
+ if (propReq.hasBucketArgs()
+ && propReq.getBucketArgs().hasVersioningStatus()) {
+ throw new OMException("Cluster does not have the object versioning"
+ + " feature finalized yet, but the request contains a bucket"
+ + " versioning status. Rejecting the request, please finalize the"
+ + " cluster upgrade and then try again.",
+ OMException.ResultCodes.NOT_SUPPORTED_OPERATION_PRIOR_FINALIZATION);
+ }
+ }
+ return req;
+ }
}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java
index 6c34443058b5..7d6f7a6abed4 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCommitRequest.java
@@ -34,6 +34,8 @@
import java.util.Map;
import java.util.Objects;
import org.apache.commons.lang3.tuple.Pair;
+import org.apache.hadoop.hdds.utils.db.cache.CacheKey;
+import org.apache.hadoop.hdds.utils.db.cache.CacheValue;
import org.apache.hadoop.ozone.OmUtils;
import org.apache.hadoop.ozone.OzoneConsts;
import org.apache.hadoop.ozone.OzoneManagerVersion;
@@ -52,6 +54,7 @@
import org.apache.hadoop.ozone.om.helpers.OzoneFSUtils;
import org.apache.hadoop.ozone.om.helpers.QuotaUtil;
import org.apache.hadoop.ozone.om.helpers.RepeatedOmKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.VersionIdGenerator;
import org.apache.hadoop.ozone.om.helpers.WithMetadata;
import org.apache.hadoop.ozone.om.request.util.OmKeyHSyncUtil;
import org.apache.hadoop.ozone.om.request.util.OmResponseUtil;
@@ -303,14 +306,36 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
}
validateAtomicRewrite(keyToDelete, omKeyInfo, auditMap);
+ final boolean versioningEnabled = omBucketInfo.isS3VersioningEnabled();
+ final boolean keepsVersions = omBucketInfo.hasEverBeenVersioned();
+ // A write while versioning is suspended creates no version of its own: it
+ // takes the key's null version slot, replacing whatever held it. The
+ // record still carries a generated versionId, so that it sorts among the
+ // key's versions by the time it was written.
+ final boolean suspendedWrite = omBucketInfo.isS3VersioningSuspended();
// Set the UpdateID to current transactionLogIndex
- omKeyInfo = omKeyInfo.toBuilder()
+ OmKeyInfo.Builder committedKeyBuilder = omKeyInfo.toBuilder()
.setExpectedDataGeneration(null)
.addAllMetadata(KeyValueUtil.getFromProtobuf(
commitKeyArgs.getMetadataList()))
.setUpdateID(trxnLogIndex)
- .setDataSize(commitKeyArgs.getDataSize())
- .build();
+ .setDataSize(commitKeyArgs.getDataSize());
+ if (keepsVersions) {
+ // The version identity is frozen when the version is created: an hsync
+ // re-commit keeps updating the same version, so it keeps its versionId.
+ committedKeyBuilder.setVersionId(isSameHsyncKey
+ ? keyToDelete.getVersionId()
+ : ozoneManager.getVersionIdAllocator().allocate(omMetadataManager,
+ volumeName, bucketName, keyName, trxnLogIndex, keyToDelete));
+ committedKeyBuilder.setNullVersion(suspendedWrite);
+ }
+ omKeyInfo = committedKeyBuilder.build();
+
+ // The version a write supersedes is kept as a noncurrent version, except
+ // for the null version, which a suspended write replaces outright.
+ final boolean supersededVersionRetained = keyToDelete != null
+ && keepsVersions
+ && (versioningEnabled || !keyToDelete.isNullVersionRecord());
// Update the block length for each block, return the allocated but
// uncommitted blocks
@@ -325,7 +350,11 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
correctedSpace -= keyToDelete.getReplicatedSize();
checkBucketQuotaInBytes(omMetadataManager, omBucketInfo,
correctedSpace);
- } else if (keyToDelete != null && !omBucketInfo.getIsVersionEnabled()) {
+ } else if (keyToDelete != null && !omBucketInfo.getIsVersionEnabled()
+ && !supersededVersionRetained) {
+ // A retained version keeps its blocks: it lives on in the
+ // versionedKeyTable. What reaches this branch under S3 versioning is
+ // the null version being replaced by a suspended write.
RepeatedOmKeyInfo oldVerKeyInfo = getOldVersionsToCleanUp(
keyToDelete, omBucketInfo.getObjectID(), trxnLogIndex);
// using pseudoObjId as objectId can be same in case of overwrite key
@@ -401,6 +430,47 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
dbOpenKey, newOpenKeyInfo, trxnLogIndex);
}
+ // With S3-compatible versioning the overwritten current version is kept
+ // as a noncurrent version instead of being reclaimed. A record written
+ // before versioning was enabled carries no versionId and becomes the
+ // key's null version.
+ String dbVersionedKey = null;
+ OmKeyInfo versionedKeyInfo = null;
+ if (supersededVersionRetained && !isSameHsyncKey) {
+ versionedKeyInfo = keyToDelete.getVersionId() != null ? keyToDelete
+ : keyToDelete.toBuilder()
+ .setVersionId(VersionIdGenerator.UNSET_VERSION_ID)
+ .setNullVersion(true)
+ .build();
+ dbVersionedKey = omMetadataManager.getVersionedOzoneKey(
+ volumeName, bucketName, keyName, versionedKeyInfo.getVersionId());
+ omMetadataManager.getVersionedKeyTable().addCacheEntry(
+ dbVersionedKey, versionedKeyInfo, trxnLogIndex);
+ }
+
+ // A suspended write replaces the key's null version wherever it is. It is
+ // the current version when the previous write was also suspended, and a
+ // noncurrent version when versioning was enabled in between - the version
+ // that demoted it is still current, and is retained above.
+ String replacedNullVersionKey = null;
+ if (suspendedWrite && !isSameHsyncKey && supersededVersionRetained) {
+ Pair nullVersion = getNoncurrentNullVersion(
+ omMetadataManager, volumeName, bucketName, keyName);
+ if (nullVersion != null) {
+ replacedNullVersionKey = nullVersion.getKey();
+ oldKeyVersionsToDeleteMap = addKeyInfoToDeleteMap(ozoneManager,
+ trxnLogIndex, dbOzoneKey, omBucketInfo.getObjectID(),
+ nullVersion.getValue().withCommittedKeyDeletedFlag(true),
+ oldKeyVersionsToDeleteMap);
+ omBucketInfo.decrUsedBytes(
+ sumBlockLengths(nullVersion.getValue()), true);
+ omBucketInfo.decrUsedNamespace(1L, true);
+ omMetadataManager.getVersionedKeyTable().addCacheEntry(
+ new CacheKey<>(replacedNullVersionKey),
+ CacheValue.get(trxnLogIndex));
+ }
+ }
+
omMetadataManager.getKeyTable(getBucketLayout()).addCacheEntry(
dbOzoneKey, omKeyInfo, trxnLogIndex);
@@ -408,7 +478,9 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
omClientResponse = new OMKeyCommitResponse(omResponse.build(),
omKeyInfo, dbOzoneKey, dbOpenKey, omBucketInfo.copyObject(),
- oldKeyVersionsToDeleteMap, isHSync, newOpenKeyInfo, dbOpenKeyToDeleteKey, openKeyToDelete);
+ oldKeyVersionsToDeleteMap, isHSync, newOpenKeyInfo, dbOpenKeyToDeleteKey, openKeyToDelete)
+ .withVersionedKey(dbVersionedKey, versionedKeyInfo)
+ .withReplacedNullVersion(replacedNullVersionKey);
result = Result.SUCCESS;
} catch (IOException | InvalidPathException ex) {
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyDeleteRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyDeleteRequest.java
index 26287ca66d26..5c9ee1848781 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyDeleteRequest.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyDeleteRequest.java
@@ -19,13 +19,20 @@
import static org.apache.hadoop.ozone.OzoneConsts.DELETED_HSYNC_KEY;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.KEY_NOT_FOUND;
+import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION;
import static org.apache.hadoop.ozone.om.lock.OzoneManagerLock.LeveledResource.BUCKET_LOCK;
import static org.apache.hadoop.ozone.util.MetricUtil.captureLatencyNs;
import java.io.IOException;
import java.nio.file.InvalidPathException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.hadoop.hdds.client.RatisReplicationConfig;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos.ReplicationFactor;
import org.apache.hadoop.hdds.utils.db.Table;
import org.apache.hadoop.hdds.utils.db.cache.CacheKey;
import org.apache.hadoop.hdds.utils.db.cache.CacheValue;
@@ -42,12 +49,17 @@
import org.apache.hadoop.ozone.om.helpers.BucketLayout;
import org.apache.hadoop.ozone.om.helpers.OmBucketInfo;
import org.apache.hadoop.ozone.om.helpers.OmKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.OmKeyLocationInfoGroup;
+import org.apache.hadoop.ozone.om.helpers.RepeatedOmKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.VersionIdGenerator;
import org.apache.hadoop.ozone.om.request.util.OmResponseUtil;
import org.apache.hadoop.ozone.om.request.validation.RequestFeatureValidator;
import org.apache.hadoop.ozone.om.request.validation.ValidationCondition;
import org.apache.hadoop.ozone.om.request.validation.ValidationContext;
import org.apache.hadoop.ozone.om.response.OMClientResponse;
+import org.apache.hadoop.ozone.om.response.key.OMKeyDeleteMarkerResponse;
import org.apache.hadoop.ozone.om.response.key.OMKeyDeleteResponse;
+import org.apache.hadoop.ozone.om.response.key.OMKeyVersionDeleteResponse;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.DeleteKeyRequest;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.DeleteKeyResponse;
@@ -128,6 +140,15 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
boolean acquiredLock = false;
OMClientResponse omClientResponse = null;
Result result = null;
+ // whether the request removed a key that was visible to plain reads; a
+ // delete marker supersedes the current version without removing anything
+ boolean visibleKeyRemoved = false;
+ // whether the request took the delete marker path, and so may have left
+ // versionedKeyTable entries in the table cache for this transaction
+ boolean insertingDeleteMarker = false;
+ // whether the request took the permanent version delete path, and so may
+ // have left versionedKeyTable entries in the table cache
+ boolean deletingVersion = false;
long startNanos = Time.monotonicNowNanos();
try {
String objectKey =
@@ -142,57 +163,88 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
OmKeyInfo omKeyInfo =
omMetadataManager.getKeyTable(getBucketLayout()).get(objectKey);
- if (omKeyInfo == null) {
- throw new OMException("Key not found", KEY_NOT_FOUND);
- }
-
- validateIfMatchETag(keyArgs, omKeyInfo);
-
- // Set the UpdateID to current transactionLogIndex
- omKeyInfo = omKeyInfo.toBuilder()
- .setUpdateID(trxnLogIndex)
- .build();
-
- // Update table cache. Put a tombstone entry
- omMetadataManager.getKeyTable(getBucketLayout()).addCacheEntry(
- new CacheKey<>(
- omMetadataManager.getOzoneKey(volumeName, bucketName, keyName)),
- CacheValue.get(trxnLogIndex));
OmBucketInfo omBucketInfo =
getBucketInfo(omMetadataManager, volumeName, bucketName);
- long quotaReleased = sumBlockLengths(omKeyInfo);
- // Empty entries won't be added to deleted table so this key shouldn't get added to snapshotUsed space.
- boolean isKeyNonEmpty = !OmKeyInfo.isKeyEmpty(omKeyInfo);
- omBucketInfo.decrUsedBytes(quotaReleased, isKeyNonEmpty);
- omBucketInfo.decrUsedNamespace(1L, isKeyNonEmpty);
- OmKeyInfo deletedOpenKeyInfo = null;
-
- // If omKeyInfo has hsync metadata, delete its corresponding open key as well
- String dbOpenKey = null;
- String hsyncClientId = omKeyInfo.getMetadata().get(OzoneConsts.HSYNC_CLIENT_ID);
- if (hsyncClientId != null) {
- Table openKeyTable = omMetadataManager.getOpenKeyTable(getBucketLayout());
- dbOpenKey = omMetadataManager.getOpenKey(volumeName, bucketName, keyName, hsyncClientId);
- OmKeyInfo openKeyInfo = openKeyTable.get(dbOpenKey);
- if (openKeyInfo != null) {
- openKeyInfo = openKeyInfo.withMetadataMutations(
- metadata -> metadata.put(DELETED_HSYNC_KEY, "true"));
- openKeyTable.addCacheEntry(dbOpenKey, openKeyInfo, trxnLogIndex);
- deletedOpenKeyInfo = openKeyInfo;
- } else {
- LOG.warn("Potentially inconsistent DB state: open key not found with dbOpenKey '{}'", dbOpenKey);
+ if (keyArgs.hasVersionId() || keyArgs.getNullVersion()) {
+ // DELETE ?versionId= permanently removes one version. It is the only
+ // delete that destroys data on a versioned bucket.
+ if (!omBucketInfo.hasEverBeenVersioned()) {
+ throw new OMException("Bucket " + bucketName
+ + " does not have S3 versioning enabled",
+ NOT_SUPPORTED_OPERATION);
+ }
+ deletingVersion = true;
+ omClientResponse = deleteVersion(omMetadataManager, omBucketInfo,
+ omKeyInfo, keyArgs, volumeName, bucketName, keyName, trxnLogIndex,
+ omResponse);
+ // Noncurrent versions are invisible to plain reads, so removing one
+ // does not change the visible key count.
+ } else if (omBucketInfo.hasEverBeenVersioned()) {
+ // A delete without a versionId removes no data: a delete marker
+ // becomes the current version and the version it supersedes moves to
+ // the versionedKeyTable. Like S3, the marker is inserted even when the
+ // key does not exist. While versioning is suspended the marker takes
+ // the key's null version slot instead of creating a version.
+ insertingDeleteMarker = true;
+ omClientResponse = insertDeleteMarker(ozoneManager, omMetadataManager,
+ omBucketInfo, omKeyInfo, objectKey, keyArgs, trxnLogIndex,
+ omResponse);
+ // The key stays in the keyTable as a marker, so nothing is released;
+ // only superseding a visible object is one fewer visible key.
+ visibleKeyRemoved = omKeyInfo != null && !omKeyInfo.isDeleteMarker();
+ } else {
+ if (omKeyInfo == null) {
+ throw new OMException("Key not found", KEY_NOT_FOUND);
}
- }
- omClientResponse = new OMKeyDeleteResponse(
- omResponse.setDeleteKeyResponse(DeleteKeyResponse.newBuilder())
- .build(), omKeyInfo,
- omBucketInfo.copyObject(), deletedOpenKeyInfo);
- if (omKeyInfo.isFile()) {
- auditMap.put(OzoneConsts.DATA_SIZE, String.valueOf(omKeyInfo.getDataSize()));
- auditMap.put(OzoneConsts.REPLICATION_CONFIG, omKeyInfo.getReplicationConfig().toString());
+ validateIfMatchETag(keyArgs, omKeyInfo);
+
+ // Set the UpdateID to current transactionLogIndex
+ omKeyInfo = omKeyInfo.toBuilder()
+ .setUpdateID(trxnLogIndex)
+ .build();
+
+ // Update table cache. Put a tombstone entry
+ omMetadataManager.getKeyTable(getBucketLayout()).addCacheEntry(
+ new CacheKey<>(
+ omMetadataManager.getOzoneKey(volumeName, bucketName, keyName)),
+ CacheValue.get(trxnLogIndex));
+
+ long quotaReleased = sumBlockLengths(omKeyInfo);
+ // Empty entries won't be added to deleted table so this key shouldn't get added to snapshotUsed space.
+ boolean isKeyNonEmpty = !OmKeyInfo.isKeyEmpty(omKeyInfo);
+ omBucketInfo.decrUsedBytes(quotaReleased, isKeyNonEmpty);
+ omBucketInfo.decrUsedNamespace(1L, isKeyNonEmpty);
+ OmKeyInfo deletedOpenKeyInfo = null;
+
+ // If omKeyInfo has hsync metadata, delete its corresponding open key as well
+ String dbOpenKey = null;
+ String hsyncClientId = omKeyInfo.getMetadata().get(OzoneConsts.HSYNC_CLIENT_ID);
+ if (hsyncClientId != null) {
+ Table openKeyTable = omMetadataManager.getOpenKeyTable(getBucketLayout());
+ dbOpenKey = omMetadataManager.getOpenKey(volumeName, bucketName, keyName, hsyncClientId);
+ OmKeyInfo openKeyInfo = openKeyTable.get(dbOpenKey);
+ if (openKeyInfo != null) {
+ openKeyInfo = openKeyInfo.withMetadataMutations(
+ metadata -> metadata.put(DELETED_HSYNC_KEY, "true"));
+ openKeyTable.addCacheEntry(dbOpenKey, openKeyInfo, trxnLogIndex);
+ deletedOpenKeyInfo = openKeyInfo;
+ } else {
+ LOG.warn("Potentially inconsistent DB state: open key not found with dbOpenKey '{}'", dbOpenKey);
+ }
+ }
+
+ omClientResponse = new OMKeyDeleteResponse(
+ omResponse.setDeleteKeyResponse(DeleteKeyResponse.newBuilder())
+ .build(), omKeyInfo,
+ omBucketInfo.copyObject(), deletedOpenKeyInfo);
+ if (omKeyInfo.isFile()) {
+ auditMap.put(OzoneConsts.DATA_SIZE, String.valueOf(omKeyInfo.getDataSize()));
+ auditMap.put(OzoneConsts.REPLICATION_CONFIG, omKeyInfo.getReplicationConfig().toString());
+ }
+ visibleKeyRemoved = true;
}
result = Result.SUCCESS;
@@ -201,9 +253,22 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
} catch (IOException | InvalidPathException ex) {
result = Result.FAILURE;
exception = ex;
- omClientResponse =
- new OMKeyDeleteResponse(createErrorOMResponse(omResponse, exception),
- getBucketLayout());
+ // The failure response has to declare the same tables as the successful
+ // one: the double buffer cleans up the table cache from the response's
+ // CleanupTableInfo, so a delete marker request that failed after
+ // touching the versionedKeyTable cache would otherwise leave an entry
+ // behind that is in no DB and is never cleaned up.
+ OMResponse errorResponse = createErrorOMResponse(omResponse, exception);
+ if (insertingDeleteMarker) {
+ omClientResponse =
+ new OMKeyDeleteMarkerResponse(errorResponse, getBucketLayout());
+ } else if (deletingVersion) {
+ omClientResponse =
+ new OMKeyVersionDeleteResponse(errorResponse, getBucketLayout());
+ } else {
+ omClientResponse =
+ new OMKeyDeleteResponse(errorResponse, getBucketLayout());
+ }
long endNanosDeleteKeyFailureLatencyNs = Time.monotonicNowNanos();
perfMetrics.setDeleteKeyFailureLatencyNs(endNanosDeleteKeyFailureLatencyNs - startNanos);
} finally {
@@ -222,7 +287,9 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
switch (result) {
case SUCCESS:
- omMetrics.decNumKeys();
+ if (visibleKeyRemoved) {
+ omMetrics.decNumKeys();
+ }
LOG.debug("Key deleted. Volume:{}, Bucket:{}, Key:{}", volumeName,
bucketName, keyName);
break;
@@ -239,6 +306,213 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
return omClientResponse;
}
+ /**
+ * Supersedes the key's current version with a delete marker: a record with
+ * no data blocks that makes plain reads of the key return KEY_NOT_FOUND
+ * while every existing version stays readable by versionId. The superseded
+ * current version becomes a noncurrent version; a record written before
+ * versioning was enabled carries no versionId and becomes the key's null
+ * version. When the key does not exist the marker is still inserted, as S3
+ * does.
+ */
+ /**
+ * Permanently removes the addressed version. Only noncurrent versions are
+ * handled here: removing the current version has to promote the next-newest
+ * one to keep the keyTable authoritative, which T4.3 adds.
+ *
+ * @param currentVersion the key's current version, or null if it has none
+ */
+ @SuppressWarnings("checkstyle:ParameterNumber")
+ private OMClientResponse deleteVersion(OMMetadataManager omMetadataManager,
+ OmBucketInfo omBucketInfo, OmKeyInfo currentVersion,
+ OzoneManagerProtocolProtos.KeyArgs keyArgs, String volumeName,
+ String bucketName, String keyName, long trxnLogIndex,
+ OMResponse.Builder omResponse) throws IOException {
+
+ boolean nullVersion = keyArgs.getNullVersion();
+ boolean deletingCurrent = currentVersion != null && (nullVersion
+ ? currentVersion.isNullVersionRecord()
+ : Long.valueOf(keyArgs.getVersionId()).equals(
+ currentVersion.getVersionId()));
+
+ // Everything that can fail runs before the first cache entry is added, so
+ // that a failed request leaves no versionedKeyTable cache entry behind.
+ String versionedKey;
+ OmKeyInfo version;
+ if (deletingCurrent) {
+ versionedKey = null;
+ version = currentVersion;
+ } else if (nullVersion) {
+ Pair nullSlot = getNoncurrentNullVersion(
+ omMetadataManager, volumeName, bucketName, keyName);
+ versionedKey = nullSlot == null ? null : nullSlot.getKey();
+ version = nullSlot == null ? null : nullSlot.getValue();
+ } else {
+ versionedKey = omMetadataManager.getVersionedOzoneKey(
+ volumeName, bucketName, keyName, keyArgs.getVersionId());
+ version = omMetadataManager.getVersionedKeyTable().get(versionedKey);
+ }
+ if (version == null) {
+ throw new OMException("Version not found for key " + keyName,
+ KEY_NOT_FOUND);
+ }
+
+ // Removing the current version leaves the key without one, so the newest
+ // noncurrent version is promoted to keep the invariant that keyTable holds
+ // the current version of every key that still has one. The record moves
+ // unchanged: promotion is positional, the version keeps its identity.
+ String promotedKey = null;
+ OmKeyInfo promoted = null;
+ if (deletingCurrent) {
+ Pair newest = getNewestNoncurrentVersion(
+ omMetadataManager, volumeName, bucketName, keyName);
+ if (newest != null) {
+ promotedKey = newest.getKey();
+ promoted = newest.getValue();
+ }
+ }
+
+ version = version.toBuilder().setUpdateID(trxnLogIndex).build();
+
+ String objectKey =
+ omMetadataManager.getOzoneKey(volumeName, bucketName, keyName);
+ if (deletingCurrent) {
+ if (promoted != null) {
+ omMetadataManager.getKeyTable(getBucketLayout())
+ .addCacheEntry(objectKey, promoted, trxnLogIndex);
+ omMetadataManager.getVersionedKeyTable().addCacheEntry(
+ new CacheKey<>(promotedKey), CacheValue.get(trxnLogIndex));
+ } else {
+ // no version survives, so the key disappears entirely
+ omMetadataManager.getKeyTable(getBucketLayout()).addCacheEntry(
+ new CacheKey<>(objectKey), CacheValue.get(trxnLogIndex));
+ }
+ } else {
+ omMetadataManager.getVersionedKeyTable().addCacheEntry(
+ new CacheKey<>(versionedKey), CacheValue.get(trxnLogIndex));
+ }
+
+ // A delete marker holds no blocks, so it releases namespace but no space.
+ long quotaReleased = sumBlockLengths(version);
+ boolean isVersionNonEmpty = !OmKeyInfo.isKeyEmpty(version);
+ omBucketInfo.decrUsedBytes(quotaReleased, isVersionNonEmpty);
+ omBucketInfo.decrUsedNamespace(1L, isVersionNonEmpty);
+
+ return new OMKeyVersionDeleteResponse(
+ omResponse.setDeleteKeyResponse(DeleteKeyResponse.newBuilder()).build(),
+ version, deletingCurrent ? objectKey : versionedKey, deletingCurrent,
+ promotedKey, promoted, omBucketInfo.copyObject());
+ }
+
+ @SuppressWarnings("checkstyle:ParameterNumber")
+ private OMClientResponse insertDeleteMarker(OzoneManager ozoneManager,
+ OMMetadataManager omMetadataManager, OmBucketInfo omBucketInfo,
+ OmKeyInfo currentVersion, String objectKey,
+ OzoneManagerProtocolProtos.KeyArgs keyArgs, long trxnLogIndex,
+ OMResponse.Builder omResponse) throws IOException {
+
+ String volumeName = omBucketInfo.getVolumeName();
+ String bucketName = omBucketInfo.getBucketName();
+ String keyName = keyArgs.getKeyName();
+ // While versioning is suspended the marker is the key's null version, so
+ // it replaces whatever held that slot instead of superseding it.
+ final boolean suspended = omBucketInfo.isS3VersioningSuspended();
+ final boolean replacesCurrent = suspended && currentVersion != null
+ && currentVersion.isNullVersionRecord();
+
+ // Everything that can fail runs before the first cache entry is added: a
+ // request that throws here is answered with an OMKeyDeleteResponse, whose
+ // cleanup does not cover the versionedKeyTable, so a cache entry left
+ // behind would never be removed and would outlive the failed request.
+ long markerVersionId = ozoneManager.getVersionIdAllocator().allocate(
+ omMetadataManager, volumeName, bucketName, keyName, trxnLogIndex,
+ currentVersion);
+ // the marker is a record of its own; it holds no blocks, so it consumes
+ // namespace but no space
+ checkBucketQuotaInNamespace(omBucketInfo, 1L);
+
+ OmKeyInfo.Builder markerBuilder;
+ if (currentVersion != null) {
+ markerBuilder = currentVersion.toBuilder()
+ .setMetadata(new HashMap<>())
+ .setTags(new HashMap<>())
+ .setFileChecksum(null);
+ } else {
+ markerBuilder = new OmKeyInfo.Builder()
+ .setVolumeName(volumeName)
+ .setBucketName(bucketName)
+ .setKeyName(keyName)
+ .setReplicationConfig(RatisReplicationConfig.getInstance(
+ ReplicationFactor.ONE))
+ .setObjectID(ozoneManager.getObjectIdFromTxId(trxnLogIndex))
+ .setOwnerName(omBucketInfo.getOwner())
+ .setFile(true);
+ }
+ OmKeyInfo deleteMarker = markerBuilder
+ .setOmKeyLocationInfos(Collections.singletonList(
+ new OmKeyLocationInfoGroup(0, new ArrayList<>())))
+ .setDataSize(0L)
+ .setCreationTime(keyArgs.getModificationTime())
+ .setModificationTime(keyArgs.getModificationTime())
+ .setUpdateID(trxnLogIndex)
+ .setVersionId(markerVersionId)
+ .setDeleteMarker(true)
+ .setNullVersion(suspended)
+ .build();
+
+ String movedVersionedKeyName = null;
+ OmKeyInfo movedVersionedKeyInfo = null;
+ if (currentVersion != null && !replacesCurrent) {
+ movedVersionedKeyInfo = currentVersion.getVersionId() != null
+ ? currentVersion
+ : currentVersion.toBuilder()
+ .setVersionId(VersionIdGenerator.UNSET_VERSION_ID)
+ .setNullVersion(true)
+ .build();
+ movedVersionedKeyName = omMetadataManager.getVersionedOzoneKey(
+ volumeName, bucketName, keyName,
+ movedVersionedKeyInfo.getVersionId());
+ omMetadataManager.getVersionedKeyTable().addCacheEntry(
+ movedVersionedKeyName, movedVersionedKeyInfo, trxnLogIndex);
+ }
+
+ // The null version the marker replaces is removed: the current one when
+ // the last write was also suspended, and a noncurrent one when versioning
+ // was enabled in between.
+ OmKeyInfo replacedNullVersion = replacesCurrent ? currentVersion : null;
+ String replacedNullVersionKey = null;
+ if (suspended && !replacesCurrent) {
+ Pair nullVersion = getNoncurrentNullVersion(
+ omMetadataManager, volumeName, bucketName, keyName);
+ if (nullVersion != null) {
+ replacedNullVersionKey = nullVersion.getKey();
+ replacedNullVersion = nullVersion.getValue();
+ omMetadataManager.getVersionedKeyTable().addCacheEntry(
+ new CacheKey<>(replacedNullVersionKey),
+ CacheValue.get(trxnLogIndex));
+ }
+ }
+ Map keysToDelete = null;
+ if (replacedNullVersion != null) {
+ keysToDelete = addKeyInfoToDeleteMap(ozoneManager, trxnLogIndex,
+ objectKey, omBucketInfo.getObjectID(),
+ replacedNullVersion.withCommittedKeyDeletedFlag(true), null);
+ omBucketInfo.decrUsedBytes(sumBlockLengths(replacedNullVersion), true);
+ omBucketInfo.decrUsedNamespace(1L, true);
+ }
+
+ omBucketInfo.incrUsedNamespace(1L);
+
+ omMetadataManager.getKeyTable(getBucketLayout()).addCacheEntry(
+ objectKey, deleteMarker, trxnLogIndex);
+
+ return new OMKeyDeleteMarkerResponse(
+ omResponse.setDeleteKeyResponse(DeleteKeyResponse.newBuilder()).build(),
+ deleteMarker, objectKey, movedVersionedKeyName, movedVersionedKeyInfo,
+ omBucketInfo.copyObject())
+ .withReplacedNullVersion(replacedNullVersionKey, keysToDelete);
+ }
+
/**
* Validates key delete requests.
* We do not want to allow older clients to delete keys in buckets which use
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyRequest.java
index d12b8fa05257..42eeeace25f6 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyRequest.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyRequest.java
@@ -49,6 +49,7 @@
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
+import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.hadoop.crypto.key.KeyProviderCryptoExtension.EncryptedKeyVersion;
@@ -63,6 +64,7 @@
import org.apache.hadoop.hdds.scm.container.common.helpers.ExcludeList;
import org.apache.hadoop.hdds.scm.exceptions.SCMException;
import org.apache.hadoop.hdds.security.token.OzoneBlockTokenIdentifier;
+import org.apache.hadoop.hdds.utils.db.Table;
import org.apache.hadoop.hdds.utils.db.cache.CacheKey;
import org.apache.hadoop.hdds.utils.db.cache.CacheValue;
import org.apache.hadoop.ipc_.Server;
@@ -896,6 +898,107 @@ public static long sumBlockLengths(OmKeyInfo omKeyInfo) {
return bytesUsed;
}
+ /**
+ * Returns the newest noncurrent version of the given key as a
+ * (dbKey, keyInfo) pair, or null if the key has no noncurrent version.
+ */
+ protected Pair getNewestNoncurrentVersion(
+ OMMetadataManager omMetadataManager, String volumeName,
+ String bucketName, String keyName) throws IOException {
+ return findNoncurrentVersion(omMetadataManager, volumeName, bucketName,
+ keyName, keyInfo -> true);
+ }
+
+ /**
+ * Returns the key's null version as a (dbKey, keyInfo) pair, or null if the
+ * key has no noncurrent null version. A key has at most one.
+ */
+ protected Pair getNoncurrentNullVersion(
+ OMMetadataManager omMetadataManager, String volumeName,
+ String bucketName, String keyName) throws IOException {
+ return findNoncurrentVersion(omMetadataManager, volumeName, bucketName,
+ keyName, OmKeyInfo::isNullVersionRecord);
+ }
+
+ /**
+ * Returns the newest noncurrent version of the key that satisfies
+ * {@code filter}, or null if there is none. Versions of a key are adjacent
+ * and ordered newest first, so the smallest matching dbKey wins.
+ *
+ * Both the table cache and the DB are searched, and neither can stand in
+ * for the other:
+ *
+ *
+ * - {@link Table#iterator} reads RocksDB directly and does not consult
+ * the cache, so a version written by a transaction that the double
+ * buffer has not flushed yet is invisible to it, and a version removed
+ * by such a transaction still appears in it;
+ * - the cache only holds the current flush window, so every older
+ * version of the key exists in the DB only.
+ *
+ *
+ * The search does not stop at the first cache match either. Versions are
+ * demoted into the versionedKeyTable in increasing versionId order, so in
+ * practice a cached version is newer than every flushed one, but that is a
+ * property of the write paths rather than something enforced here, and
+ * relying on it would silently promote the wrong version if a later write
+ * path broke it. The DB search costs one seek, since it stops at the first
+ * match.
+ */
+ private Pair findNoncurrentVersion(
+ OMMetadataManager omMetadataManager, String volumeName,
+ String bucketName, String keyName, Predicate filter)
+ throws IOException {
+ final String prefix = omMetadataManager.getVersionedOzoneKeyPrefix(
+ volumeName, bucketName, keyName);
+ final Table table =
+ omMetadataManager.getVersionedKeyTable();
+
+ String bestKey = null;
+ OmKeyInfo bestValue = null;
+
+ // Entries of transactions that are not flushed yet. The cache is not
+ // sorted, so it has to be scanned; it only holds this table's writes from
+ // the current flush window.
+ Iterator, CacheValue>> cacheIterator =
+ table.cacheIterator();
+ while (cacheIterator.hasNext()) {
+ Map.Entry, CacheValue> entry = cacheIterator.next();
+ String dbKey = entry.getKey().getCacheKey();
+ OmKeyInfo value = entry.getValue().getCacheValue();
+ // a null value is a tombstone: the version is deleted but not flushed
+ if (value == null || !dbKey.startsWith(prefix) || !filter.test(value)) {
+ continue;
+ }
+ if (bestKey == null || dbKey.compareTo(bestKey) < 0) {
+ bestKey = dbKey;
+ bestValue = value;
+ }
+ }
+
+ try (Table.KeyValueIterator versions = table.iterator(prefix)) {
+ while (versions.hasNext()) {
+ Table.KeyValue entry = versions.next();
+ String dbKey = entry.getKey();
+ CacheValue cached = table.getCacheValue(new CacheKey<>(dbKey));
+ if (cached != null && cached.getCacheValue() == null) {
+ continue;
+ }
+ OmKeyInfo value = cached != null ? cached.getCacheValue() : entry.getValue();
+ if (!filter.test(value)) {
+ continue;
+ }
+ if (bestKey == null || dbKey.compareTo(bestKey) < 0) {
+ bestKey = dbKey;
+ bestValue = value;
+ }
+ // DB entries ascend, so the first match is the smallest one in the DB
+ break;
+ }
+ }
+ return bestKey == null ? null : Pair.of(bestKey, bestValue);
+ }
+
/**
* Return bucket info for the specified bucket.
*/
@@ -959,11 +1062,15 @@ protected OmKeyInfo prepareFileInfo(
if (dbKeyInfo != null) {
// The key already exist, the new blocks will replace old ones
// as new versions unless the bucket does not have versioning
- // turned on.
- dbKeyInfo.addNewVersion(locations, false,
- omBucketInfo.getIsVersionEnabled());
+ // turned on. With S3-compatible versioning the previous current version
+ // is kept as its own record in the versionedKeyTable at commit time, so
+ // the in-record block version list is not used to accumulate object
+ // versions and always holds a single version.
+ boolean keepInRecordVersions = omBucketInfo.getIsVersionEnabled()
+ && !omBucketInfo.hasEverBeenVersioned();
+ dbKeyInfo.addNewVersion(locations, false, keepInRecordVersions);
long newSize = size;
- if (omBucketInfo.getIsVersionEnabled()) {
+ if (keepInRecordVersions) {
newSize += dbKeyInfo.getDataSize();
}
// The modification time is set in preExecute. Use the same
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadCompleteRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadCompleteRequest.java
index 841ced7dacce..0a6a224ea828 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadCompleteRequest.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartUploadCompleteRequest.java
@@ -34,6 +34,7 @@
import java.util.function.BiFunction;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.tuple.Pair;
import org.apache.hadoop.hdds.client.ReplicationConfig;
import org.apache.hadoop.hdds.utils.db.cache.CacheKey;
import org.apache.hadoop.hdds.utils.db.cache.CacheValue;
@@ -55,6 +56,7 @@
import org.apache.hadoop.ozone.om.helpers.OmMultipartPartInfo;
import org.apache.hadoop.ozone.om.helpers.OmMultipartPartKey;
import org.apache.hadoop.ozone.om.helpers.RepeatedOmKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.VersionIdGenerator;
import org.apache.hadoop.ozone.om.request.file.OMFileRequest;
import org.apache.hadoop.ozone.om.request.key.OMKeyRequest;
import org.apache.hadoop.ozone.om.request.util.OMMultipartUploadUtils;
@@ -331,7 +333,23 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
OmKeyInfo keyToDelete =
omMetadataManager.getKeyTable(getBucketLayout()).get(dbOzoneKey);
boolean isNamespaceUpdate = false;
- if (keyToDelete != null && !omBucketInfo.getIsVersionEnabled()) {
+ // Completing a multipart upload creates a version like any other
+ // write: the version it supersedes is kept instead of reclaimed,
+ // except for the null version, which a suspended write replaces.
+ final boolean supersededVersionRetained = keyToDelete != null
+ && omBucketInfo.hasEverBeenVersioned()
+ && (omBucketInfo.isS3VersioningEnabled()
+ || !keyToDelete.isNullVersionRecord());
+ if (omBucketInfo.hasEverBeenVersioned()) {
+ omKeyInfo = omKeyInfo.toBuilder()
+ .setVersionId(ozoneManager.getVersionIdAllocator().allocate(
+ omMetadataManager, volumeName, bucketName, keyName,
+ trxnLogIndex, keyToDelete))
+ .setNullVersion(omBucketInfo.isS3VersioningSuspended())
+ .build();
+ }
+ if (keyToDelete != null && !omBucketInfo.getIsVersionEnabled()
+ && !supersededVersionRetained) {
RepeatedOmKeyInfo oldKeyVersionsToDelete = getOldVersionsToCleanUp(
keyToDelete, omBucketInfo.getObjectID(), trxnLogIndex);
allKeyInfoToRemove.addAll(oldKeyVersionsToDelete.getOmKeyInfoList());
@@ -342,6 +360,41 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
isNamespaceUpdate = true;
}
+ // The superseded version becomes a noncurrent version. Without this the
+ // record would be dropped from the keyTable, kept out of the
+ // deletedTable by the check above, and leak.
+ String dbVersionedKey = null;
+ OmKeyInfo versionedKeyInfo = null;
+ if (supersededVersionRetained) {
+ versionedKeyInfo = keyToDelete.getVersionId() != null ? keyToDelete
+ : keyToDelete.toBuilder()
+ .setVersionId(VersionIdGenerator.UNSET_VERSION_ID)
+ .setNullVersion(true)
+ .build();
+ dbVersionedKey = omMetadataManager.getVersionedOzoneKey(
+ volumeName, bucketName, keyName, versionedKeyInfo.getVersionId());
+ omMetadataManager.getVersionedKeyTable().addCacheEntry(
+ dbVersionedKey, versionedKeyInfo, trxnLogIndex);
+ }
+
+ // A suspended write replaces the key's null version wherever it is.
+ String replacedNullVersionKey = null;
+ if (omBucketInfo.isS3VersioningSuspended()
+ && supersededVersionRetained) {
+ Pair nullVersion = getNoncurrentNullVersion(
+ omMetadataManager, volumeName, bucketName, keyName);
+ if (nullVersion != null) {
+ replacedNullVersionKey = nullVersion.getKey();
+ allKeyInfoToRemove.add(nullVersion.getValue()
+ .withCommittedKeyDeletedFlag(true));
+ usedBytesDiff -= nullVersion.getValue().getReplicatedSize();
+ omBucketInfo.decrUsedNamespace(1L, true);
+ omMetadataManager.getVersionedKeyTable().addCacheEntry(
+ new CacheKey<>(replacedNullVersionKey),
+ CacheValue.get(trxnLogIndex));
+ }
+ }
+
String dbBucketKey = omMetadataManager.getBucketKey(
omBucketInfo.getVolumeName(), omBucketInfo.getBucketName());
if (usedBytesDiff != 0) {
@@ -366,10 +419,12 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
long volumeId = omMetadataManager.getVolumeId(volumeName);
long bucketId = omMetadataManager.getBucketId(volumeName, bucketName);
omClientResponse =
- getOmClientResponse(multipartKey, omResponse, dbMultipartOpenKey,
+ ((S3MultipartUploadCompleteResponse) getOmClientResponse(multipartKey, omResponse, dbMultipartOpenKey,
omKeyInfo, allKeyInfoToRemove, omBucketInfo,
volumeId, bucketId, missingParentInfos, multipartKeyInfo,
- multipartPartKeysToDelete);
+ multipartPartKeysToDelete))
+ .withVersionedKey(dbVersionedKey, versionedKeyInfo,
+ replacedNullVersionKey);
result = Result.SUCCESS;
} else {
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/key/OMKeyCommitResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/key/OMKeyCommitResponse.java
index 425c4f63ac5e..ed17907b4fa0 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/key/OMKeyCommitResponse.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/key/OMKeyCommitResponse.java
@@ -21,6 +21,7 @@
import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.DELETED_TABLE;
import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.KEY_TABLE;
import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.OPEN_KEY_TABLE;
+import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.VERSIONED_KEY_TABLE;
import com.google.common.annotations.VisibleForTesting;
import jakarta.annotation.Nonnull;
@@ -39,7 +40,7 @@
* Response for CommitKey request.
*/
@CleanupTableInfo(cleanupTables = {OPEN_KEY_TABLE, KEY_TABLE, DELETED_TABLE,
- BUCKET_TABLE})
+ BUCKET_TABLE, VERSIONED_KEY_TABLE})
public class OMKeyCommitResponse extends OmKeyResponse {
private OmKeyInfo omKeyInfo;
@@ -51,6 +52,9 @@ public class OMKeyCommitResponse extends OmKeyResponse {
private OmKeyInfo newOpenKeyInfo;
private OmKeyInfo openKeyToUpdate;
private String openKeyNameToUpdate;
+ private String versionedKeyName;
+ private OmKeyInfo versionedKeyInfo;
+ private String replacedNullVersionKey;
@SuppressWarnings("checkstyle:ParameterNumber")
public OMKeyCommitResponse(
@@ -82,6 +86,27 @@ public OMKeyCommitResponse(@Nonnull OMResponse omResponse, @Nonnull
checkStatusNotOK();
}
+ /**
+ * The version this commit overwrote, to be kept in the versionedKeyTable as
+ * a noncurrent version. Null for buckets without S3-compatible versioning.
+ */
+ public OMKeyCommitResponse withVersionedKey(String dbVersionedKey,
+ OmKeyInfo keyInfo) {
+ this.versionedKeyName = dbVersionedKey;
+ this.versionedKeyInfo = keyInfo;
+ return this;
+ }
+
+ /**
+ * The noncurrent null version this commit replaced, to be removed from the
+ * versionedKeyTable. Null unless a suspended write replaced a null version
+ * that was not the current one.
+ */
+ public OMKeyCommitResponse withReplacedNullVersion(String dbVersionedKey) {
+ this.replacedNullVersionKey = dbVersionedKey;
+ return this;
+ }
+
@Override
public void addToDBBatch(OMMetadataManager omMetadataManager,
BatchOperation batchOperation) throws IOException {
@@ -98,6 +123,16 @@ public void addToDBBatch(OMMetadataManager omMetadataManager,
omMetadataManager.getKeyTable(getBucketLayout())
.putWithBatch(batchOperation, ozoneKeyName, omKeyInfo);
+ if (versionedKeyInfo != null) {
+ omMetadataManager.getVersionedKeyTable()
+ .putWithBatch(batchOperation, versionedKeyName, versionedKeyInfo);
+ }
+
+ if (replacedNullVersionKey != null) {
+ omMetadataManager.getVersionedKeyTable()
+ .deleteWithBatch(batchOperation, replacedNullVersionKey);
+ }
+
updateDeletedTable(omMetadataManager, batchOperation);
handleOpenKeyToUpdate(omMetadataManager, batchOperation);
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/key/OMKeyDeleteMarkerResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/key/OMKeyDeleteMarkerResponse.java
new file mode 100644
index 000000000000..50d4821b13e7
--- /dev/null
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/key/OMKeyDeleteMarkerResponse.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om.response.key;
+
+import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.BUCKET_TABLE;
+import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.DELETED_TABLE;
+import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.KEY_TABLE;
+import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.VERSIONED_KEY_TABLE;
+
+import com.google.common.annotations.VisibleForTesting;
+import jakarta.annotation.Nonnull;
+import java.io.IOException;
+import java.util.Map;
+import org.apache.hadoop.hdds.utils.db.BatchOperation;
+import org.apache.hadoop.ozone.om.OMMetadataManager;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
+import org.apache.hadoop.ozone.om.helpers.OmBucketInfo;
+import org.apache.hadoop.ozone.om.helpers.OmKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.RepeatedOmKeyInfo;
+import org.apache.hadoop.ozone.om.response.CleanupTableInfo;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse;
+
+/**
+ * Response for a DeleteKey request on a bucket with S3-compatible versioning
+ * enabled: no data is removed. A delete marker becomes the current version in
+ * the keyTable, and the version it supersedes (if the key existed) moves to
+ * the versionedKeyTable.
+ */
+@CleanupTableInfo(cleanupTables = {KEY_TABLE, VERSIONED_KEY_TABLE, BUCKET_TABLE,
+ DELETED_TABLE})
+public class OMKeyDeleteMarkerResponse extends OmKeyResponse {
+
+ private OmKeyInfo deleteMarker;
+ private String ozoneKeyName;
+ private String movedVersionedKeyName;
+ private OmKeyInfo movedVersionedKeyInfo;
+ private OmBucketInfo omBucketInfo;
+ private String replacedNullVersionKey;
+ private Map keysToDelete;
+
+ public OMKeyDeleteMarkerResponse(@Nonnull OMResponse omResponse,
+ @Nonnull OmKeyInfo deleteMarker, @Nonnull String ozoneKeyName,
+ String movedVersionedKeyName, OmKeyInfo movedVersionedKeyInfo,
+ @Nonnull OmBucketInfo omBucketInfo) {
+ super(omResponse, omBucketInfo.getBucketLayout());
+ this.deleteMarker = deleteMarker;
+ this.ozoneKeyName = ozoneKeyName;
+ this.movedVersionedKeyName = movedVersionedKeyName;
+ this.movedVersionedKeyInfo = movedVersionedKeyInfo;
+ this.omBucketInfo = omBucketInfo;
+ }
+
+ /**
+ * For when the request is not successful.
+ * For a successful request, the other constructor should be used.
+ */
+ public OMKeyDeleteMarkerResponse(@Nonnull OMResponse omResponse,
+ @Nonnull BucketLayout bucketLayout) {
+ super(omResponse, bucketLayout);
+ checkStatusNotOK();
+ }
+
+ /**
+ * The null version the marker replaced, when versioning is suspended: the
+ * versionedKeyTable entry to remove, if it had one, and its blocks to queue
+ * for reclamation.
+ */
+ public OMKeyDeleteMarkerResponse withReplacedNullVersion(
+ String dbVersionedKey, Map deleteMap) {
+ this.replacedNullVersionKey = dbVersionedKey;
+ this.keysToDelete = deleteMap;
+ return this;
+ }
+
+ @VisibleForTesting
+ public Map getKeysToDelete() {
+ return keysToDelete;
+ }
+
+ @Override
+ public void addToDBBatch(OMMetadataManager omMetadataManager,
+ BatchOperation batchOperation) throws IOException {
+ omMetadataManager.getKeyTable(getBucketLayout())
+ .putWithBatch(batchOperation, ozoneKeyName, deleteMarker);
+
+ if (movedVersionedKeyInfo != null) {
+ omMetadataManager.getVersionedKeyTable().putWithBatch(batchOperation,
+ movedVersionedKeyName, movedVersionedKeyInfo);
+ }
+
+ if (replacedNullVersionKey != null) {
+ omMetadataManager.getVersionedKeyTable()
+ .deleteWithBatch(batchOperation, replacedNullVersionKey);
+ }
+
+ if (keysToDelete != null) {
+ for (Map.Entry entry
+ : keysToDelete.entrySet()) {
+ omMetadataManager.getDeletedTable().putWithBatch(batchOperation,
+ entry.getKey(), entry.getValue());
+ }
+ }
+
+ omMetadataManager.getBucketTable().putWithBatch(batchOperation,
+ omMetadataManager.getBucketKey(omBucketInfo.getVolumeName(),
+ omBucketInfo.getBucketName()), omBucketInfo);
+ }
+}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/key/OMKeyVersionDeleteResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/key/OMKeyVersionDeleteResponse.java
new file mode 100644
index 000000000000..c76ef0aceaf4
--- /dev/null
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/key/OMKeyVersionDeleteResponse.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om.response.key;
+
+import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.BUCKET_TABLE;
+import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.DELETED_TABLE;
+import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.KEY_TABLE;
+import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.VERSIONED_KEY_TABLE;
+
+import jakarta.annotation.Nonnull;
+import java.io.IOException;
+import org.apache.hadoop.hdds.utils.db.BatchOperation;
+import org.apache.hadoop.ozone.om.OMMetadataManager;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
+import org.apache.hadoop.ozone.om.helpers.OmBucketInfo;
+import org.apache.hadoop.ozone.om.helpers.OmKeyInfo;
+import org.apache.hadoop.ozone.om.response.CleanupTableInfo;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse;
+
+/**
+ * Response for {@code DELETE ?versionId=}: the version leaves the table that
+ * held it and its blocks go to the deletedTable, which is the single path
+ * through which version blocks are reclaimed. Removing the current version
+ * promotes the newest remaining one into the keyTable in the same batch, so the
+ * key never lacks a current version; if none remains the key disappears.
+ */
+@CleanupTableInfo(cleanupTables = {KEY_TABLE, VERSIONED_KEY_TABLE, DELETED_TABLE, BUCKET_TABLE})
+public class OMKeyVersionDeleteResponse extends AbstractOMKeyDeleteResponse {
+
+ private final OmKeyInfo deletedVersion;
+ private final String deletedKeyName;
+ private final boolean deletedCurrent;
+ private final String promotedKeyName;
+ private final OmKeyInfo promoted;
+ private final OmBucketInfo omBucketInfo;
+
+ @SuppressWarnings("checkstyle:ParameterNumber")
+ public OMKeyVersionDeleteResponse(@Nonnull OMResponse omResponse,
+ @Nonnull OmKeyInfo deletedVersion, @Nonnull String deletedKeyName,
+ boolean deletedCurrent, String promotedKeyName, OmKeyInfo promoted,
+ @Nonnull OmBucketInfo omBucketInfo) {
+ super(omResponse, omBucketInfo.getBucketLayout());
+ this.deletedVersion = deletedVersion;
+ this.deletedKeyName = deletedKeyName;
+ this.deletedCurrent = deletedCurrent;
+ this.promotedKeyName = promotedKeyName;
+ this.promoted = promoted;
+ this.omBucketInfo = omBucketInfo;
+ }
+
+ /**
+ * For when the request is not successful.
+ * For a successful request, the other constructor should be used.
+ */
+ public OMKeyVersionDeleteResponse(@Nonnull OMResponse omResponse,
+ @Nonnull BucketLayout bucketLayout) {
+ super(omResponse, bucketLayout);
+ this.deletedVersion = null;
+ this.deletedKeyName = null;
+ this.deletedCurrent = false;
+ this.promotedKeyName = null;
+ this.promoted = null;
+ this.omBucketInfo = null;
+ checkStatusNotOK();
+ }
+
+ @Override
+ public void addToDBBatch(OMMetadataManager omMetadataManager,
+ BatchOperation batchOperation) throws IOException {
+ // The version leaves whichever table held it and its blocks go to the
+ // deletedTable; when it was the current version the successor takes its
+ // place in the keyTable, so the delete and the promotion land in one batch.
+ addDeletionToBatch(omMetadataManager, batchOperation,
+ deletedCurrent ? omMetadataManager.getKeyTable(getBucketLayout())
+ : omMetadataManager.getVersionedKeyTable(),
+ deletedKeyName, deletedVersion, omBucketInfo.getObjectID(), true);
+
+ if (promoted != null) {
+ omMetadataManager.getKeyTable(getBucketLayout())
+ .putWithBatch(batchOperation, deletedKeyName, promoted);
+ omMetadataManager.getVersionedKeyTable()
+ .deleteWithBatch(batchOperation, promotedKeyName);
+ }
+
+ omMetadataManager.getBucketTable().putWithBatch(batchOperation,
+ omMetadataManager.getBucketKey(omBucketInfo.getVolumeName(),
+ omBucketInfo.getBucketName()), omBucketInfo);
+ }
+}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/multipart/S3MultipartUploadCompleteResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/multipart/S3MultipartUploadCompleteResponse.java
index a1dfe4ed317d..b9da28f2bbb4 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/multipart/S3MultipartUploadCompleteResponse.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/multipart/S3MultipartUploadCompleteResponse.java
@@ -23,6 +23,7 @@
import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.MULTIPART_INFO_TABLE;
import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.MULTIPART_PARTS_TABLE;
import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.OPEN_KEY_TABLE;
+import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.VERSIONED_KEY_TABLE;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
@@ -48,7 +49,8 @@
* 3) Delete unused parts.
*/
@CleanupTableInfo(cleanupTables = {OPEN_KEY_TABLE, KEY_TABLE, DELETED_TABLE,
- MULTIPART_INFO_TABLE, MULTIPART_PARTS_TABLE, BUCKET_TABLE})
+ MULTIPART_INFO_TABLE, MULTIPART_PARTS_TABLE, BUCKET_TABLE,
+ VERSIONED_KEY_TABLE})
public class S3MultipartUploadCompleteResponse extends OmKeyResponse {
private String multipartKey;
private String multipartOpenKey;
@@ -57,6 +59,9 @@ public class S3MultipartUploadCompleteResponse extends OmKeyResponse {
private List multipartPartKeysToDelete;
private OmBucketInfo omBucketInfo;
private long bucketId;
+ private String versionedKeyName;
+ private OmKeyInfo versionedKeyInfo;
+ private String replacedNullVersionKey;
@SuppressWarnings("parameternumber")
public S3MultipartUploadCompleteResponse(
@@ -128,6 +133,19 @@ public void addToDBBatch(OMMetadataManager omMetadataManager,
}
}
+ /**
+ * The version this upload superseded, to be kept in the versionedKeyTable as
+ * a noncurrent version, and the null version it replaced, if any. Both null
+ * for buckets that have never been versioned.
+ */
+ public S3MultipartUploadCompleteResponse withVersionedKey(
+ String dbVersionedKey, OmKeyInfo keyInfo, String replacedNullVersion) {
+ this.versionedKeyName = dbVersionedKey;
+ this.versionedKeyInfo = keyInfo;
+ this.replacedNullVersionKey = replacedNullVersion;
+ return this;
+ }
+
protected String addToKeyTable(OMMetadataManager omMetadataManager,
BatchOperation batchOperation) throws IOException {
@@ -135,6 +153,15 @@ protected String addToKeyTable(OMMetadataManager omMetadataManager,
omKeyInfo.getBucketName(), omKeyInfo.getKeyName());
omMetadataManager.getKeyTable(getBucketLayout())
.putWithBatch(batchOperation, ozoneKey, omKeyInfo);
+
+ if (versionedKeyInfo != null) {
+ omMetadataManager.getVersionedKeyTable()
+ .putWithBatch(batchOperation, versionedKeyName, versionedKeyInfo);
+ }
+ if (replacedNullVersionKey != null) {
+ omMetadataManager.getVersionedKeyTable()
+ .deleteWithBatch(batchOperation, replacedNullVersionKey);
+ }
return ozoneKey;
}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/upgrade/OMLayoutFeature.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/upgrade/OMLayoutFeature.java
index ef99b453b7f0..b832d6efc9ec 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/upgrade/OMLayoutFeature.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/upgrade/OMLayoutFeature.java
@@ -44,7 +44,10 @@ public enum OMLayoutFeature implements LayoutFeature {
QUOTA(6, "Ozone quota re-calculate"),
HBASE_SUPPORT(7, "Full support of hsync, lease recovery and listOpenFiles APIs for HBase"),
DELEGATION_TOKEN_SYMMETRIC_SIGN(8, "Delegation token signed by symmetric key"),
- SNAPSHOT_DEFRAG(9, "Supporting defragmentation of snapshot");
+ SNAPSHOT_DEFRAG(9, "Supporting defragmentation of snapshot"),
+
+ OBJECT_VERSIONING(10, "S3-compatible object versioning: bucket versioning"
+ + " state machine and the versionedKeyTable for noncurrent versions");
/////////////////////////////// /////////////////////////////
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java
index 7359065986ab..0e6a589f66db 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java
@@ -645,6 +645,8 @@ private LookupKeyResponse lookupKey(LookupKeyRequest request,
.setLatestVersionLocation(keyArgs.getLatestVersionLocation())
.setSortDatanodesInPipeline(keyArgs.getSortDatanodes())
.setHeadOp(keyArgs.getHeadOp())
+ .setVersionId(keyArgs.hasVersionId() ? keyArgs.getVersionId() : null)
+ .setNullVersion(keyArgs.getNullVersion())
.build();
OmKeyInfo keyInfo = impl.lookupKey(omKeyArgs);
@@ -666,6 +668,8 @@ private GetKeyInfoResponse getKeyInfo(GetKeyInfoRequest request,
.setForceUpdateContainerCacheFromSCM(
keyArgs.getForceUpdateContainerCacheFromSCM())
.setMultipartUploadPartNumber(keyArgs.getMultipartNumber())
+ .setVersionId(keyArgs.hasVersionId() ? keyArgs.getVersionId() : null)
+ .setNullVersion(keyArgs.getNullVersion())
.build();
KeyInfoWithVolumeContext keyInfo = impl.getKeyInfo(omKeyArgs,
request.getAssumeS3Context());
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestKeyManagerUnit.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestKeyManagerUnit.java
index 9b1844212073..ace48d8ffea2 100644
--- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestKeyManagerUnit.java
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestKeyManagerUnit.java
@@ -23,6 +23,8 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.anySet;
import static org.mockito.Mockito.mock;
@@ -64,6 +66,7 @@
import org.apache.hadoop.hdds.utils.db.cache.CacheKey;
import org.apache.hadoop.hdds.utils.db.cache.CacheValue;
import org.apache.hadoop.ozone.OzoneConsts;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
import org.apache.hadoop.ozone.om.helpers.BucketLayout;
import org.apache.hadoop.ozone.om.helpers.OmBucketInfo;
import org.apache.hadoop.ozone.om.helpers.OmKeyArgs;
@@ -579,6 +582,234 @@ public void testGetKeyInfo() throws IOException {
.getContainerWithPipelineBatch(containerIDs);
}
+ private OmKeyInfo versionedKeyInfo(String volume, String bucket, String key,
+ long versionId, boolean nullVersion, boolean deleteMarker) {
+ return new OmKeyInfo.Builder()
+ .setVolumeName(volume)
+ .setBucketName(bucket)
+ .setKeyName(key)
+ .setOmKeyLocationInfos(Collections.emptyList())
+ .setCreationTime(Time.now())
+ .setModificationTime(Time.now())
+ .setDataSize(0)
+ .setReplicationConfig(RatisReplicationConfig.getInstance(ReplicationFactor.ONE))
+ .setVersionId(versionId)
+ .setNullVersion(nullVersion)
+ .setDeleteMarker(deleteMarker)
+ .build();
+ }
+
+ /**
+ * Enabling versioning does not rewrite the objects a bucket already holds:
+ * they keep no versionId at all, and S3 reports their version as "null".
+ */
+ @Test
+ public void testLookupPreVersioningKeyAsNullVersion() throws Exception {
+ String volume = "vol-legacy";
+ String bucket = "buck-legacy";
+ String key = "obj";
+ OMRequestTestUtils.addVolumeAndBucketToDB(volume, bucket, metadataManager,
+ BucketLayout.OBJECT_STORE);
+
+ // written before versioning was enabled: no versionId, no null flag
+ OmKeyInfo legacy = new OmKeyInfo.Builder()
+ .setVolumeName(volume)
+ .setBucketName(bucket)
+ .setKeyName(key)
+ .setOmKeyLocationInfos(Collections.emptyList())
+ .setCreationTime(Time.now())
+ .setModificationTime(Time.now())
+ .setDataSize(0)
+ .setReplicationConfig(
+ RatisReplicationConfig.getInstance(ReplicationFactor.ONE))
+ .build();
+ metadataManager.getKeyTable(BucketLayout.OBJECT_STORE).put(
+ metadataManager.getOzoneKey(volume, bucket, key), legacy);
+
+ OmKeyArgs.Builder base = new OmKeyArgs.Builder()
+ .setVolumeName(volume).setBucketName(bucket).setKeyName(key)
+ .setHeadOp(true);
+
+ // a plain read still returns it
+ OmKeyArgs args = base.build();
+ assertNull(keyManager.lookupKey(args, resolveBucket(args), null)
+ .getVersionId());
+
+ // and version "null" addresses it, without the record having been rewritten
+ args = base.setNullVersion(true).build();
+ OmKeyInfo nullVersion =
+ keyManager.lookupKey(args, resolveBucket(args), null);
+ assertNull(nullVersion.getVersionId());
+ assertTrue(nullVersion.isNullVersionRecord());
+ assertEquals(legacy, metadataManager
+ .getKeyTable(BucketLayout.OBJECT_STORE)
+ .get(metadataManager.getOzoneKey(volume, bucket, key)));
+ }
+
+ @Test
+ public void testLookupKeyByVersionId() throws Exception {
+ String volume = "vol-ver";
+ String bucket = "buck-ver";
+ String key = "obj";
+ OMRequestTestUtils.addVolumeAndBucketToDB(volume, bucket, metadataManager,
+ BucketLayout.OBJECT_STORE);
+
+ // current version 30, one noncurrent version 20, and a null version 10
+ metadataManager.getKeyTable(BucketLayout.OBJECT_STORE).put(
+ metadataManager.getOzoneKey(volume, bucket, key),
+ versionedKeyInfo(volume, bucket, key, 30L, false, false));
+ metadataManager.getVersionedKeyTable().put(
+ metadataManager.getVersionedOzoneKey(volume, bucket, key, 20L),
+ versionedKeyInfo(volume, bucket, key, 20L, false, false));
+ metadataManager.getVersionedKeyTable().put(
+ metadataManager.getVersionedOzoneKey(volume, bucket, key, 10L),
+ versionedKeyInfo(volume, bucket, key, 10L, true, false));
+
+ OmKeyArgs.Builder base = new OmKeyArgs.Builder()
+ .setVolumeName(volume).setBucketName(bucket).setKeyName(key)
+ .setHeadOp(true);
+
+ // no versionId addresses the current version
+ OmKeyArgs args = base.build();
+ assertEquals(30L, keyManager.lookupKey(args, resolveBucket(args), null)
+ .getVersionId());
+
+ // the current version can also be addressed by its id
+ args = base.setVersionId(30L).build();
+ assertEquals(30L, keyManager.lookupKey(args, resolveBucket(args), null)
+ .getVersionId());
+
+ // a noncurrent version resolves through the versionedKeyTable
+ args = base.setVersionId(20L).build();
+ assertEquals(20L, keyManager.lookupKey(args, resolveBucket(args), null)
+ .getVersionId());
+
+ // the null version slot is found by attribute, not by id
+ args = base.setNullVersion(true).build();
+ OmKeyInfo nullVersion =
+ keyManager.lookupKey(args, resolveBucket(args), null);
+ assertEquals(10L, nullVersion.getVersionId());
+ assertTrue(nullVersion.isNullVersion());
+
+ // an unknown versionId is a plain not-found
+ OmKeyArgs unknown = base.setVersionId(999L).build();
+ OMException ex = assertThrows(OMException.class,
+ () -> keyManager.lookupKey(unknown, resolveBucket(unknown), null));
+ assertEquals(OMException.ResultCodes.KEY_NOT_FOUND, ex.getResult());
+ }
+
+ @Test
+ public void testLookupOfDeleteMarkerIsDistinguishable() throws Exception {
+ String volume = "vol-marker";
+ String bucket = "buck-marker";
+ String key = "obj";
+ OMRequestTestUtils.addVolumeAndBucketToDB(volume, bucket, metadataManager,
+ BucketLayout.OBJECT_STORE);
+
+ // current version is a marker, with a readable version behind it
+ metadataManager.getKeyTable(BucketLayout.OBJECT_STORE).put(
+ metadataManager.getOzoneKey(volume, bucket, key),
+ versionedKeyInfo(volume, bucket, key, 40L, false, true));
+ metadataManager.getVersionedKeyTable().put(
+ metadataManager.getVersionedOzoneKey(volume, bucket, key, 20L),
+ versionedKeyInfo(volume, bucket, key, 20L, false, false));
+
+ OmKeyArgs.Builder base = new OmKeyArgs.Builder()
+ .setVolumeName(volume).setBucketName(bucket).setKeyName(key)
+ .setHeadOp(true);
+
+ // without a versionId a current marker reads as absent
+ OmKeyArgs current = base.build();
+ OMException ex = assertThrows(OMException.class,
+ () -> keyManager.lookupKey(current, resolveBucket(current), null));
+ assertEquals(OMException.ResultCodes.KEY_NOT_FOUND, ex.getResult());
+
+ // naming the marker's version is a different condition: S3 answers 405
+ OmKeyArgs marker = base.setVersionId(40L).build();
+ ex = assertThrows(OMException.class,
+ () -> keyManager.lookupKey(marker, resolveBucket(marker), null));
+ assertEquals(OMException.ResultCodes.KEY_IS_DELETE_MARKER, ex.getResult());
+
+ // the version behind the marker stays readable
+ OmKeyArgs behind = base.setVersionId(20L).build();
+ assertEquals(20L,
+ keyManager.lookupKey(behind, resolveBucket(behind), null).getVersionId());
+ }
+
+ @Test
+ public void testCurrentVersionIsResolvedWithoutReadingVersionedKeyTable()
+ throws Exception {
+ String volume = "vol-cur";
+ String bucket = "buck-cur";
+ String key = "obj";
+ OMRequestTestUtils.addVolumeAndBucketToDB(volume, bucket, metadataManager,
+ BucketLayout.OBJECT_STORE);
+
+ // The current version and a decoy stored under the dbKey that version would
+ // occupy in the versionedKeyTable. Resolving to the current record proves
+ // the lookup answers from keyTable and never falls through to the scan.
+ metadataManager.getKeyTable(BucketLayout.OBJECT_STORE).put(
+ metadataManager.getOzoneKey(volume, bucket, key),
+ versionedKeyInfo(volume, bucket, key, 30L, false, false));
+ metadataManager.getVersionedKeyTable().put(
+ metadataManager.getVersionedOzoneKey(volume, bucket, key, 30L),
+ versionedKeyInfo(volume, bucket, key, 30L, false, true));
+
+ OmKeyArgs args = new OmKeyArgs.Builder()
+ .setVolumeName(volume).setBucketName(bucket).setKeyName(key)
+ .setHeadOp(true).setVersionId(30L).build();
+
+ // the decoy is a delete marker, so reading it would have thrown
+ assertEquals(30L, keyManager.lookupKey(args, resolveBucket(args), null)
+ .getVersionId());
+ }
+
+ @Test
+ public void testNullVersionSlotCanBeTheCurrentVersion() throws Exception {
+ String volume = "vol-null";
+ String bucket = "buck-null";
+ String key = "obj";
+ OMRequestTestUtils.addVolumeAndBucketToDB(volume, bucket, metadataManager,
+ BucketLayout.OBJECT_STORE);
+
+ // A suspended PUT leaves the null version as the current one, with the
+ // versions accumulated while enabled behind it.
+ metadataManager.getKeyTable(BucketLayout.OBJECT_STORE).put(
+ metadataManager.getOzoneKey(volume, bucket, key),
+ versionedKeyInfo(volume, bucket, key, 50L, true, false));
+ metadataManager.getVersionedKeyTable().put(
+ metadataManager.getVersionedOzoneKey(volume, bucket, key, 20L),
+ versionedKeyInfo(volume, bucket, key, 20L, false, false));
+
+ OmKeyArgs args = new OmKeyArgs.Builder()
+ .setVolumeName(volume).setBucketName(bucket).setKeyName(key)
+ .setHeadOp(true).setNullVersion(true).build();
+
+ OmKeyInfo nullVersion = keyManager.lookupKey(args, resolveBucket(args), null);
+ assertEquals(50L, nullVersion.getVersionId());
+ assertTrue(nullVersion.isNullVersion());
+ }
+
+ @Test
+ public void testVersionIdRejectedOnFileSystemOptimizedBucket()
+ throws Exception {
+ String volume = "vol-fso";
+ String bucket = "buck-fso";
+ OMRequestTestUtils.addVolumeAndBucketToDB(volume, bucket, metadataManager,
+ BucketLayout.FILE_SYSTEM_OPTIMIZED);
+
+ OmKeyArgs args = new OmKeyArgs.Builder()
+ .setVolumeName(volume).setBucketName(bucket).setKeyName("obj")
+ .setHeadOp(true).setVersionId(1L).build();
+ ResolvedBucket fso = new ResolvedBucket(volume, bucket, volume, bucket, "",
+ BucketLayout.FILE_SYSTEM_OPTIMIZED);
+
+ OMException ex = assertThrows(OMException.class,
+ () -> keyManager.lookupKey(args, fso, null));
+ assertEquals(OMException.ResultCodes.NOT_SUPPORTED_OPERATION,
+ ex.getResult());
+ }
+
private ResolvedBucket resolveBucket(OmKeyArgs keyArgs) {
return new ResolvedBucket(keyArgs.getVolumeName(), keyArgs.getBucketName(),
keyArgs.getVolumeName(), keyArgs.getBucketName(), "",
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java
index ec241f9dcb3e..d321dff0a81d 100644
--- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOmMetadataManager.java
@@ -46,6 +46,7 @@
import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.TENANT_STATE_TABLE;
import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.TRANSACTION_INFO_TABLE;
import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.USER_TABLE;
+import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.VERSIONED_KEY_TABLE;
import static org.apache.hadoop.ozone.om.codec.OMDBDefinition.VOLUME_TABLE;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.BUCKET_NOT_FOUND;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.VOLUME_NOT_FOUND;
@@ -120,6 +121,7 @@ public class TestOmMetadataManager {
VOLUME_TABLE,
BUCKET_TABLE,
KEY_TABLE,
+ VERSIONED_KEY_TABLE,
DELETED_TABLE,
OPEN_KEY_TABLE,
MULTIPART_INFO_TABLE,
@@ -172,6 +174,42 @@ public void testTransactionTable() throws Exception {
assertEquals(250, transactionInfo.getTransactionIndex());
}
+ @Test
+ public void testVersionedOzoneKeyOrdering() {
+ String prefix = omMetadataManager.getVersionedOzoneKeyPrefix("vol", "buck", "key");
+ assertEquals("/vol/buck/key\0", prefix);
+
+ // newer versions (larger versionId) must sort before older ones, and all
+ // versioned keys must sort under the key's prefix
+ String v1 = omMetadataManager.getVersionedOzoneKey("vol", "buck", "key", 1L);
+ String v2 = omMetadataManager.getVersionedOzoneKey("vol", "buck", "key", 42L);
+ String v3 = omMetadataManager.getVersionedOzoneKey("vol", "buck", "key", Long.MAX_VALUE - 1);
+ assertThat(v3).startsWith(prefix).isLessThan(v2);
+ assertThat(v2).startsWith(prefix).isLessThan(v1);
+
+ // fixed-width suffix: identical length regardless of versionId magnitude
+ assertEquals(v1.length(), v3.length());
+ }
+
+ @Test
+ public void testVersionedOzoneKeyIsolatedFromNestedKeys() {
+ // OBJECT_STORE key names contain '/' verbatim, so "key" and "key/001" are two
+ // unrelated keys. Every version of "key" must sort under "key"'s prefix and
+ // ahead of anything belonging to "key/001", otherwise a prefix seek for the
+ // newest noncurrent version of "key" would land on a version of "key/001".
+ String prefix = omMetadataManager.getVersionedOzoneKeyPrefix("vol", "buck", "key");
+ String nestedPrefix = omMetadataManager.getVersionedOzoneKeyPrefix("vol", "buck", "key/001");
+ assertThat(nestedPrefix).doesNotStartWith(prefix);
+
+ String oldest = omMetadataManager.getVersionedOzoneKey("vol", "buck", "key", 1L);
+ String nestedNewest =
+ omMetadataManager.getVersionedOzoneKey("vol", "buck", "key/001", Long.MAX_VALUE - 1);
+ assertThat(oldest).isLessThan(nestedNewest);
+
+ // the nested key's own current entry in keyTable also sorts after all of them
+ assertThat(oldest).isLessThan(omMetadataManager.getOzoneKey("vol", "buck", "key/001"));
+ }
+
@Test
public void testListVolumes() throws Exception {
String ownerName = "owner";
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestVersionIdAllocator.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestVersionIdAllocator.java
new file mode 100644
index 000000000000..17f3b6e41b0e
--- /dev/null
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestVersionIdAllocator.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.HashSet;
+import java.util.Set;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.utils.db.Table;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
+import org.apache.hadoop.ozone.om.helpers.OmKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.TransactionIndexVersionIdGenerator;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link VersionIdAllocator}: which versionId a commit gets, and the
+ * rejection of ids that are already taken on the key.
+ */
+public class TestVersionIdAllocator {
+
+ private static final String VOLUME = "vol1";
+ private static final String BUCKET = "bucket1";
+ private static final String KEY = "key1";
+
+ private OMMetadataManager metadataManager;
+ private Set versionedKeys;
+ private int lookups;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ versionedKeys = new HashSet<>();
+ lookups = 0;
+
+ Table versionedKeyTable = mock(Table.class);
+ when(versionedKeyTable.isExist(anyString())).thenAnswer(invocation -> {
+ lookups++;
+ return versionedKeys.contains(invocation.getArgument(0));
+ });
+
+ metadataManager = mock(OMMetadataManager.class);
+ when(metadataManager.getVersionedKeyTable()).thenReturn(versionedKeyTable);
+ when(metadataManager.getVersionedOzoneKey(eq(VOLUME), eq(BUCKET), eq(KEY), anyLong()))
+ .thenAnswer(invocation -> dbKey(invocation.getArgument(3)));
+ }
+
+ private static String dbKey(long versionId) {
+ return "/" + VOLUME + "/" + BUCKET + "/" + KEY + "/" + versionId;
+ }
+
+ private VersionIdAllocator allocator() {
+ return new VersionIdAllocator(new TransactionIndexVersionIdGenerator());
+ }
+
+ private static OmKeyInfo keyWithVersionId(Long versionId) {
+ return new OmKeyInfo.Builder()
+ .setVolumeName(VOLUME)
+ .setBucketName(BUCKET)
+ .setKeyName(KEY)
+ .setVersionId(versionId)
+ .build();
+ }
+
+ @Test
+ void allocatesTheTransactionIndexForTheFirstVersion() throws Exception {
+ assertEquals(7, allocator().allocate(metadataManager, VOLUME, BUCKET, KEY, 7, null));
+ }
+
+ @Test
+ void allocatesTheTransactionIndexForALaterVersion() throws Exception {
+ assertEquals(9, allocator().allocate(metadataManager, VOLUME, BUCKET, KEY, 9,
+ keyWithVersionId(7L)));
+ }
+
+ @Test
+ void rejectsAnIdEqualToTheCurrentVersion() {
+ OMException e = assertThrows(OMException.class,
+ () -> allocator().allocate(metadataManager, VOLUME, BUCKET, KEY, 7,
+ keyWithVersionId(7L)));
+
+ assertEquals(OMException.ResultCodes.INVALID_REQUEST, e.getResult());
+ }
+
+ @Test
+ void rejectsAnIdOlderThanTheCurrentVersion() {
+ // Refused even though no version holds this id: writing it would sort the
+ // new version before versions that predate it.
+ OMException e = assertThrows(OMException.class,
+ () -> allocator().allocate(metadataManager, VOLUME, BUCKET, KEY, 5,
+ keyWithVersionId(9L)));
+
+ assertEquals(OMException.ResultCodes.INVALID_REQUEST, e.getResult());
+ }
+
+ @Test
+ void skipsTheLookupWhenTheKeyHasNoCurrentVersion() throws Exception {
+ assertEquals(7, allocator().allocate(metadataManager, VOLUME, BUCKET, KEY, 7, null));
+ assertEquals(0, lookups);
+ }
+
+ @Test
+ void skipsTheLookupWhenTheGeneratedIdIsNewerThanTheCurrentVersion() throws Exception {
+ // The steady-state path: the current version holds the key's largest id, so
+ // an id above it cannot be taken and costs no read.
+ assertEquals(9, allocator().allocate(metadataManager, VOLUME, BUCKET, KEY, 9,
+ keyWithVersionId(7L)));
+ assertEquals(0, lookups);
+ }
+
+ @Test
+ void skipsTheLookupWhenTheIdIsRefusedForGoingBackwards() {
+ assertThrows(OMException.class,
+ () -> allocator().allocate(metadataManager, VOLUME, BUCKET, KEY, 5,
+ keyWithVersionId(9L)));
+
+ assertEquals(0, lookups);
+ }
+
+ @Test
+ void looksUpTheTableForACurrentVersionPredatingVersioning() throws Exception {
+ // Keys written before versioning was enabled carry no versionId, so there
+ // is nothing to order against and the id has to be looked up.
+ assertEquals(7, allocator().allocate(metadataManager, VOLUME, BUCKET, KEY, 7,
+ keyWithVersionId(null)));
+
+ assertEquals(1, lookups);
+ }
+
+ @Test
+ void rejectsATakenIdForACurrentVersionPredatingVersioning() {
+ versionedKeys.add(dbKey(7));
+
+ OMException e = assertThrows(OMException.class,
+ () -> allocator().allocate(metadataManager, VOLUME, BUCKET, KEY, 7,
+ keyWithVersionId(null)));
+
+ assertEquals(OMException.ResultCodes.KEY_ALREADY_EXISTS, e.getResult());
+ }
+
+ @Test
+ void allowsAnIdHeldByAnotherKeysVersion() throws Exception {
+ // Ids are only unique within a key, so another key holding it is fine.
+ versionedKeys.add("/" + VOLUME + "/" + BUCKET + "/otherKey/7");
+
+ assertEquals(7, allocator().allocate(metadataManager, VOLUME, BUCKET, KEY, 7,
+ keyWithVersionId(null)));
+ }
+
+ @Test
+ void usesTheGeneratorConfiguredForTheCluster() {
+ assertInstanceOf(TransactionIndexVersionIdGenerator.class,
+ new VersionIdAllocator(new OzoneConfiguration()).getGenerator());
+ }
+
+}
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/bucket/TestOMBucketSetPropertyRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/bucket/TestOMBucketSetPropertyRequest.java
index 2e41d4c8b173..cdfe5453a56d 100644
--- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/bucket/TestOMBucketSetPropertyRequest.java
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/bucket/TestOMBucketSetPropertyRequest.java
@@ -24,19 +24,28 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
import java.util.UUID;
import org.apache.hadoop.hdds.client.DefaultReplicationConfig;
import org.apache.hadoop.hdds.client.ECReplicationConfig;
import org.apache.hadoop.hdds.utils.db.cache.CacheKey;
import org.apache.hadoop.hdds.utils.db.cache.CacheValue;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
import org.apache.hadoop.ozone.om.helpers.BucketEncryptionKeyInfo;
import org.apache.hadoop.ozone.om.helpers.BucketLayout;
+import org.apache.hadoop.ozone.om.helpers.BucketVersioningStatus;
import org.apache.hadoop.ozone.om.helpers.OmBucketArgs;
import org.apache.hadoop.ozone.om.helpers.OmBucketInfo;
import org.apache.hadoop.ozone.om.request.OMRequestTestUtils;
+import org.apache.hadoop.ozone.om.request.validation.ValidationContext;
import org.apache.hadoop.ozone.om.response.OMClientResponse;
+import org.apache.hadoop.ozone.om.upgrade.OMLayoutFeature;
+import org.apache.hadoop.ozone.om.upgrade.OMLayoutVersionManager;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.BucketArgs;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest;
@@ -422,6 +431,190 @@ public void testValidateAndUpdateCacheWithQuotaNamespaceUsed()
"is less than used namespaceQuota");
}
+ /**
+ * S3 object versioning is only defined for OBJECT_STORE buckets: combining
+ * it with the directory and rename semantics of the other layouts is out of
+ * scope, so the status cannot be set on them at all.
+ */
+ @Test
+ public void testVersioningStatusRejectedOnNonObjectStoreBucket()
+ throws Exception {
+ String volumeName = UUID.randomUUID().toString();
+ String bucketName = UUID.randomUUID().toString();
+ OMRequestTestUtils.addVolumeAndBucketToDB(volumeName, bucketName,
+ omMetadataManager, BucketLayout.FILE_SYSTEM_OPTIMIZED);
+
+ OMClientResponse response = new OMBucketSetPropertyRequest(
+ createSetVersioningStatusRequest(volumeName, bucketName,
+ BucketVersioningStatus.ENABLED)).validateAndUpdateCache(
+ ozoneManager, 1);
+
+ assertFalse(response.getOMResponse().getSuccess());
+ assertEquals(OzoneManagerProtocolProtos.Status.NOT_SUPPORTED_OPERATION,
+ response.getOMResponse().getStatus());
+ assertFalse(omMetadataManager.getBucketTable()
+ .get(omMetadataManager.getBucketKey(volumeName, bucketName))
+ .hasVersioningStatus());
+ }
+
+ @Test
+ public void testVersioningStatusTransitions() throws Exception {
+ String volumeName = UUID.randomUUID().toString();
+ String bucketName = UUID.randomUUID().toString();
+ OMRequestTestUtils.addVolumeAndBucketToDB(volumeName, bucketName,
+ omMetadataManager, BucketLayout.OBJECT_STORE);
+ String bucketKey = omMetadataManager.getBucketKey(volumeName, bucketName);
+
+ assertEquals(BucketVersioningStatus.UNVERSIONED,
+ omMetadataManager.getBucketTable().get(bucketKey).getVersioningStatus());
+
+ // UNVERSIONED -> ENABLED
+ OMClientResponse response = new OMBucketSetPropertyRequest(
+ createSetVersioningStatusRequest(volumeName, bucketName,
+ BucketVersioningStatus.ENABLED)).validateAndUpdateCache(ozoneManager, 1);
+ assertTrue(response.getOMResponse().getSuccess());
+ OmBucketInfo dbBucketInfo = omMetadataManager.getBucketTable().get(bucketKey);
+ assertEquals(BucketVersioningStatus.ENABLED, dbBucketInfo.getVersioningStatus());
+ assertTrue(dbBucketInfo.getIsVersionEnabled());
+
+ // ENABLED -> SUSPENDED
+ response = new OMBucketSetPropertyRequest(
+ createSetVersioningStatusRequest(volumeName, bucketName,
+ BucketVersioningStatus.SUSPENDED)).validateAndUpdateCache(ozoneManager, 2);
+ assertTrue(response.getOMResponse().getSuccess());
+ dbBucketInfo = omMetadataManager.getBucketTable().get(bucketKey);
+ assertEquals(BucketVersioningStatus.SUSPENDED, dbBucketInfo.getVersioningStatus());
+ assertFalse(dbBucketInfo.getIsVersionEnabled());
+
+ // SUSPENDED -> UNVERSIONED is rejected
+ response = new OMBucketSetPropertyRequest(
+ createSetVersioningStatusRequest(volumeName, bucketName,
+ BucketVersioningStatus.UNVERSIONED)).validateAndUpdateCache(ozoneManager, 3);
+ assertFalse(response.getOMResponse().getSuccess());
+ assertEquals(OzoneManagerProtocolProtos.Status.INVALID_REQUEST,
+ response.getOMResponse().getStatus());
+ assertThat(response.getOMResponse().getMessage())
+ .contains("once enabled, versioning can only be suspended");
+ assertEquals(BucketVersioningStatus.SUSPENDED,
+ omMetadataManager.getBucketTable().get(bucketKey).getVersioningStatus());
+
+ // SUSPENDED -> ENABLED
+ response = new OMBucketSetPropertyRequest(
+ createSetVersioningStatusRequest(volumeName, bucketName,
+ BucketVersioningStatus.ENABLED)).validateAndUpdateCache(ozoneManager, 4);
+ assertTrue(response.getOMResponse().getSuccess());
+ assertEquals(BucketVersioningStatus.ENABLED,
+ omMetadataManager.getBucketTable().get(bucketKey).getVersioningStatus());
+ }
+
+ @Test
+ public void testLegacyVersioningFlagMapsToStateMachine() throws Exception {
+ String volumeName = UUID.randomUUID().toString();
+ String bucketName = UUID.randomUUID().toString();
+ OMRequestTestUtils.addVolumeAndBucketToDB(volumeName, bucketName,
+ omMetadataManager, BucketLayout.OBJECT_STORE);
+ String bucketKey = omMetadataManager.getBucketKey(volumeName, bucketName);
+
+ // legacy false on a never-enabled bucket stays UNVERSIONED
+ OMClientResponse response = new OMBucketSetPropertyRequest(
+ createSetVersioningFlagRequest(volumeName, bucketName, false))
+ .validateAndUpdateCache(ozoneManager, 1);
+ assertTrue(response.getOMResponse().getSuccess());
+ assertEquals(BucketVersioningStatus.UNVERSIONED,
+ omMetadataManager.getBucketTable().get(bucketKey).getVersioningStatus());
+
+ // legacy true does NOT opt the bucket into S3 versioning: it only sets the
+ // legacy flag, which selects the in-record block version list
+ response = new OMBucketSetPropertyRequest(
+ createSetVersioningFlagRequest(volumeName, bucketName, true))
+ .validateAndUpdateCache(ozoneManager, 2);
+ assertTrue(response.getOMResponse().getSuccess());
+ OmBucketInfo dbBucketInfo = omMetadataManager.getBucketTable().get(bucketKey);
+ assertFalse(dbBucketInfo.hasVersioningStatus());
+ assertEquals(BucketVersioningStatus.UNVERSIONED,
+ dbBucketInfo.getVersioningStatus());
+ assertTrue(dbBucketInfo.getIsVersionEnabled());
+
+ // once a status exists, an old client's flag is kept consistent with it:
+ // disabling maps to SUSPENDED rather than back to UNVERSIONED
+ response = new OMBucketSetPropertyRequest(
+ createSetVersioningStatusRequest(volumeName, bucketName,
+ BucketVersioningStatus.ENABLED))
+ .validateAndUpdateCache(ozoneManager, 3);
+ assertTrue(response.getOMResponse().getSuccess());
+
+ response = new OMBucketSetPropertyRequest(
+ createSetVersioningFlagRequest(volumeName, bucketName, false))
+ .validateAndUpdateCache(ozoneManager, 4);
+ assertTrue(response.getOMResponse().getSuccess());
+ dbBucketInfo = omMetadataManager.getBucketTable().get(bucketKey);
+ assertEquals(BucketVersioningStatus.SUSPENDED, dbBucketInfo.getVersioningStatus());
+ assertFalse(dbBucketInfo.getIsVersionEnabled());
+ }
+
+ @Test
+ public void testVersioningStatusRejectedBeforeFinalization()
+ throws Exception {
+ OMRequest request = createSetVersioningStatusRequest(
+ UUID.randomUUID().toString(), UUID.randomUUID().toString(),
+ BucketVersioningStatus.ENABLED);
+
+ OMLayoutVersionManager preFinalizedVersionManager =
+ mock(OMLayoutVersionManager.class);
+ when(preFinalizedVersionManager
+ .isAllowed(OMLayoutFeature.OBJECT_VERSIONING)).thenReturn(false);
+ ValidationContext preFinalizedContext = ValidationContext.of(
+ preFinalizedVersionManager, omMetadataManager);
+
+ OMException omException = assertThrows(OMException.class,
+ () -> OMBucketSetPropertyRequest
+ .disallowSetBucketPropertyWithVersioningStatus(
+ request, preFinalizedContext));
+ assertEquals(OMException.ResultCodes
+ .NOT_SUPPORTED_OPERATION_PRIOR_FINALIZATION,
+ omException.getResult());
+
+ // requests without a versioningStatus pass through untouched
+ OMRequest legacyRequest = createSetVersioningFlagRequest(
+ UUID.randomUUID().toString(), UUID.randomUUID().toString(), true);
+ assertSame(legacyRequest, OMBucketSetPropertyRequest
+ .disallowSetBucketPropertyWithVersioningStatus(
+ legacyRequest, preFinalizedContext));
+
+ // after finalization the request passes through untouched
+ OMLayoutVersionManager finalizedVersionManager =
+ mock(OMLayoutVersionManager.class);
+ when(finalizedVersionManager
+ .isAllowed(OMLayoutFeature.OBJECT_VERSIONING)).thenReturn(true);
+ ValidationContext finalizedContext = ValidationContext.of(
+ finalizedVersionManager, omMetadataManager);
+ assertSame(request, OMBucketSetPropertyRequest
+ .disallowSetBucketPropertyWithVersioningStatus(
+ request, finalizedContext));
+ }
+
+ private OMRequest createSetVersioningStatusRequest(String volumeName,
+ String bucketName, BucketVersioningStatus status) {
+ return OMRequest.newBuilder().setSetBucketPropertyRequest(
+ SetBucketPropertyRequest.newBuilder().setBucketArgs(
+ BucketArgs.newBuilder().setBucketName(bucketName)
+ .setVolumeName(volumeName)
+ .setVersioningStatus(status.toProto()).build()))
+ .setCmdType(OzoneManagerProtocolProtos.Type.SetBucketProperty)
+ .setClientId(UUID.randomUUID().toString()).build();
+ }
+
+ private OMRequest createSetVersioningFlagRequest(String volumeName,
+ String bucketName, boolean isVersionEnabled) {
+ return OMRequest.newBuilder().setSetBucketPropertyRequest(
+ SetBucketPropertyRequest.newBuilder().setBucketArgs(
+ BucketArgs.newBuilder().setBucketName(bucketName)
+ .setVolumeName(volumeName)
+ .setIsVersionEnabled(isVersionEnabled).build()))
+ .setCmdType(OzoneManagerProtocolProtos.Type.SetBucketProperty)
+ .setClientId(UUID.randomUUID().toString()).build();
+ }
+
@Test
public void testSettingQuotaRetainsReplication() throws Exception {
String volumeName1 = UUID.randomUUID().toString();
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/OMKeyRequestTests.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/OMKeyRequestTests.java
index 167fbc354a3c..f219f23f4077 100644
--- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/OMKeyRequestTests.java
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/OMKeyRequestTests.java
@@ -74,6 +74,7 @@
import org.apache.hadoop.ozone.om.OzoneManagerPrepareState;
import org.apache.hadoop.ozone.om.ResolvedBucket;
import org.apache.hadoop.ozone.om.ScmClient;
+import org.apache.hadoop.ozone.om.VersionIdAllocator;
import org.apache.hadoop.ozone.om.helpers.BucketLayout;
import org.apache.hadoop.ozone.om.helpers.OmBucketInfo;
import org.apache.hadoop.ozone.om.helpers.OmKeyInfo;
@@ -154,6 +155,8 @@ public void setup() throws Exception {
when(ozoneManager.getMetadataManager()).thenReturn(omMetadataManager);
when(ozoneManager.getConfiguration()).thenReturn(ozoneConfiguration);
when(ozoneManager.getConfig()).thenReturn(ozoneConfiguration.getObject(OmConfig.class));
+ when(ozoneManager.getVersionIdAllocator())
+ .thenReturn(new VersionIdAllocator(ozoneConfiguration));
OMLayoutVersionManager lvm = mock(OMLayoutVersionManager.class);
when(lvm.isAllowed(anyString())).thenReturn(true);
when(ozoneManager.getVersionManager()).thenReturn(lvm);
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMKeyVersioningRequests.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMKeyVersioningRequests.java
new file mode 100644
index 000000000000..1cab35d136e6
--- /dev/null
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMKeyVersioningRequests.java
@@ -0,0 +1,857 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om.request.key;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.UUID;
+import org.apache.hadoop.hdds.utils.db.cache.CacheKey;
+import org.apache.hadoop.hdds.utils.db.cache.CacheValue;
+import org.apache.hadoop.ozone.OzoneConsts;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
+import org.apache.hadoop.ozone.om.helpers.BucketVersioningStatus;
+import org.apache.hadoop.ozone.om.helpers.OmBucketInfo;
+import org.apache.hadoop.ozone.om.helpers.OmKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.QuotaUtil;
+import org.apache.hadoop.ozone.om.helpers.VersionIdGenerator;
+import org.apache.hadoop.ozone.om.request.OMRequestTestUtils;
+import org.apache.hadoop.ozone.om.response.OMClientResponse;
+import org.apache.hadoop.ozone.om.response.key.OMKeyCommitResponse;
+import org.apache.hadoop.ozone.om.response.key.OMKeyDeleteMarkerResponse;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CommitKeyRequest;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.DeleteKeyRequest;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.KeyArgs;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest;
+import org.apache.hadoop.util.Time;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests the S3-compatible versioning behaviour of key writes on a
+ * versioning-enabled OBJECT_STORE bucket: a commit freezes a versionId on the
+ * new current version and keeps the version it overwrote as a noncurrent
+ * version in the versionedKeyTable instead of reclaiming it.
+ */
+public class TestOMKeyVersioningRequests extends OMKeyRequestTests {
+
+ @Override
+ public BucketLayout getBucketLayout() {
+ return BucketLayout.OBJECT_STORE;
+ }
+
+ private void setupVersionedBucket() throws Exception {
+ setupVersionedBucket(OzoneConsts.QUOTA_RESET, OzoneConsts.QUOTA_RESET);
+ }
+
+ private void setupVersionedBucket(long quotaInBytes, long quotaInNamespace)
+ throws Exception {
+ setupVersionedBucket(quotaInBytes, quotaInNamespace, 0L);
+ }
+
+ private void setupSuspendedBucket() throws Exception {
+ setupVersionedBucket(OzoneConsts.QUOTA_RESET, OzoneConsts.QUOTA_RESET, 0L,
+ BucketVersioningStatus.SUSPENDED);
+ }
+
+ private void setupVersionedBucket(long quotaInBytes, long quotaInNamespace,
+ long usedNamespace) throws Exception {
+ setupVersionedBucket(quotaInBytes, quotaInNamespace, usedNamespace,
+ BucketVersioningStatus.ENABLED);
+ }
+
+ private void setupVersionedBucket(long quotaInBytes, long quotaInNamespace,
+ long usedNamespace, BucketVersioningStatus status) throws Exception {
+ OMRequestTestUtils.addVolumeToDB(volumeName, omMetadataManager);
+ OmBucketInfo bucketInfo = OmBucketInfo.newBuilder()
+ .setVolumeName(volumeName)
+ .setBucketName(bucketName)
+ .setBucketLayout(BucketLayout.OBJECT_STORE)
+ .setVersioningStatus(status)
+ .setQuotaInBytes(quotaInBytes)
+ .setQuotaInNamespace(quotaInNamespace)
+ .setUsedNamespace(usedNamespace)
+ .setCreationTime(Time.now())
+ .build();
+ omMetadataManager.getBucketTable().addCacheEntry(
+ new CacheKey<>(omMetadataManager.getBucketKey(volumeName, bucketName)),
+ CacheValue.get(1L, bucketInfo));
+ }
+
+ /** Puts a current version into keyTable, as an earlier write would have. */
+ private String seedCurrentVersion(Long versionId) throws Exception {
+ return seedCurrentVersion(versionId, false);
+ }
+
+ private String seedCurrentVersion(Long versionId, boolean deleteMarker)
+ throws Exception {
+ return seedCurrentVersion(versionId, deleteMarker, false);
+ }
+
+ private String seedCurrentVersion(Long versionId, boolean deleteMarker,
+ boolean nullVersion) throws Exception {
+ return seedCurrentVersion(versionId, deleteMarker, nullVersion, false);
+ }
+
+ private String seedCurrentVersion(Long versionId, boolean deleteMarker,
+ boolean nullVersion, boolean withBlocks) throws Exception {
+ OmKeyInfo keyInfo = OMRequestTestUtils.createOmKeyInfo(
+ volumeName, bucketName, keyName, replicationConfig)
+ .setVersionId(versionId)
+ .setDeleteMarker(deleteMarker)
+ .setNullVersion(nullVersion)
+ .build();
+ if (withBlocks) {
+ OMRequestTestUtils.addKeyLocationInfo(keyInfo, 0L, 1000L);
+ }
+ String ozoneKey = omMetadataManager.getOzoneKey(
+ volumeName, bucketName, keyName);
+ omMetadataManager.getKeyTable(getBucketLayout()).put(ozoneKey, keyInfo);
+ return ozoneKey;
+ }
+
+ private OMRequest deleteRequest() {
+ KeyArgs keyArgs = KeyArgs.newBuilder()
+ .setVolumeName(volumeName)
+ .setBucketName(bucketName)
+ .setKeyName(keyName)
+ .setModificationTime(Time.now())
+ .build();
+ return OMRequest.newBuilder()
+ .setDeleteKeyRequest(DeleteKeyRequest.newBuilder().setKeyArgs(keyArgs))
+ .setCmdType(OzoneManagerProtocolProtos.Type.DeleteKey)
+ .setClientId(UUID.randomUUID().toString()).build();
+ }
+
+ private OMClientResponse deleteAt(long trxnLogIndex) throws Exception {
+ OMClientResponse response = new OMKeyDeleteRequest(deleteRequest(),
+ getBucketLayout()).validateAndUpdateCache(ozoneManager, trxnLogIndex);
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ response.getOMResponse().getStatus());
+ return response;
+ }
+
+ private OMRequest deleteVersionRequest(Long versionId, boolean nullVersion) {
+ KeyArgs.Builder keyArgs = KeyArgs.newBuilder()
+ .setVolumeName(volumeName)
+ .setBucketName(bucketName)
+ .setKeyName(keyName)
+ .setModificationTime(Time.now());
+ if (versionId != null) {
+ keyArgs.setVersionId(versionId);
+ }
+ if (nullVersion) {
+ keyArgs.setNullVersion(true);
+ }
+ return OMRequest.newBuilder()
+ .setDeleteKeyRequest(DeleteKeyRequest.newBuilder().setKeyArgs(keyArgs))
+ .setCmdType(OzoneManagerProtocolProtos.Type.DeleteKey)
+ .setClientId(UUID.randomUUID().toString()).build();
+ }
+
+ private OMClientResponse deleteVersionAt(Long versionId, boolean nullVersion,
+ long trxnLogIndex) throws Exception {
+ return new OMKeyDeleteRequest(deleteVersionRequest(versionId, nullVersion),
+ getBucketLayout()).validateAndUpdateCache(ozoneManager, trxnLogIndex);
+ }
+
+ private void seedNoncurrentVersion(long versionId, boolean nullVersion)
+ throws Exception {
+ OmKeyInfo version = OMRequestTestUtils.createOmKeyInfo(
+ volumeName, bucketName, keyName, replicationConfig)
+ .setVersionId(versionId)
+ .setNullVersion(nullVersion)
+ .build();
+ omMetadataManager.getVersionedKeyTable().put(
+ omMetadataManager.getVersionedOzoneKey(
+ volumeName, bucketName, keyName, versionId), version);
+ }
+
+ /** A noncurrent version that only exists in the table cache, not in the DB. */
+ private void cacheOnlyNoncurrentVersion(long versionId) throws Exception {
+ OmKeyInfo version = OMRequestTestUtils.createOmKeyInfo(
+ volumeName, bucketName, keyName, replicationConfig)
+ .setVersionId(versionId)
+ .build();
+ omMetadataManager.getVersionedKeyTable().addCacheEntry(
+ omMetadataManager.getVersionedOzoneKey(
+ volumeName, bucketName, keyName, versionId), version, 350L);
+ }
+
+ @Test
+ public void testPermanentDeleteRemovesOnlyTheAddressedVersion()
+ throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(300L);
+ seedNoncurrentVersion(100L, false);
+ seedNoncurrentVersion(200L, false);
+
+ OMClientResponse response = deleteVersionAt(100L, false, 400L);
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ response.getOMResponse().getStatus());
+
+ assertNull(noncurrentVersion(100L));
+ assertNotNull(noncurrentVersion(200L));
+ assertEquals(300L, currentVersion().getVersionId());
+ }
+
+ @Test
+ public void testPermanentDeleteReleasesQuota() throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(300L);
+ seedNoncurrentVersion(100L, false);
+
+ OmBucketInfo before = omMetadataManager.getBucketTable()
+ .get(omMetadataManager.getBucketKey(volumeName, bucketName));
+ long usedNamespaceBefore = before.getUsedNamespace();
+
+ deleteVersionAt(100L, false, 400L);
+
+ OmBucketInfo after = omMetadataManager.getBucketTable()
+ .get(omMetadataManager.getBucketKey(volumeName, bucketName));
+ assertEquals(usedNamespaceBefore - 1, after.getUsedNamespace());
+ }
+
+ @Test
+ public void testPermanentDeleteOfNullVersion() throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(300L);
+ seedNoncurrentVersion(100L, true);
+ seedNoncurrentVersion(200L, false);
+
+ OMClientResponse response = deleteVersionAt(null, true, 400L);
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ response.getOMResponse().getStatus());
+
+ assertNull(noncurrentVersion(100L));
+ assertNotNull(noncurrentVersion(200L));
+ }
+
+ @Test
+ public void testPermanentDeleteOfUnknownVersionIsNotFound() throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(300L);
+
+ OMClientResponse response = deleteVersionAt(999L, false, 400L);
+ assertEquals(OzoneManagerProtocolProtos.Status.KEY_NOT_FOUND,
+ response.getOMResponse().getStatus());
+ }
+
+ @Test
+ public void testDeletingCurrentVersionPromotesTheNextNewest()
+ throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(300L);
+ seedNoncurrentVersion(100L, false);
+ seedNoncurrentVersion(200L, false);
+
+ OMClientResponse response = deleteVersionAt(300L, false, 400L);
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ response.getOMResponse().getStatus());
+
+ // the newest remaining version takes over, unchanged
+ OmKeyInfo current = currentVersion();
+ assertNotNull(current);
+ assertEquals(200L, current.getVersionId());
+ assertFalse(current.isDeleteMarker());
+ // and no longer counts as noncurrent
+ assertNull(noncurrentVersion(200L));
+ assertNotNull(noncurrentVersion(100L));
+ }
+
+ /**
+ * A version written by a transaction that the double buffer has not flushed
+ * yet lives only in the table cache. Promotion has to see it, otherwise an
+ * older version takes over and the newest one is orphaned.
+ */
+ @Test
+ public void testPromotionSeesVersionsStillInCache() throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(300L);
+ seedNoncurrentVersion(100L, false);
+ cacheOnlyNoncurrentVersion(200L);
+
+ deleteVersionAt(300L, false, 400L);
+
+ assertEquals(200L, currentVersion().getVersionId());
+ assertNotNull(noncurrentVersion(100L));
+ }
+
+ /**
+ * The mirror case: a version removed by an unflushed transaction is a
+ * tombstone in the cache while the DB still holds it. Promotion must not
+ * bring it back.
+ */
+ @Test
+ public void testPromotionSkipsVersionsTombstonedInCache() throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(300L);
+ seedNoncurrentVersion(100L, false);
+ seedNoncurrentVersion(200L, false);
+ // 200 is deleted but not flushed yet
+ omMetadataManager.getVersionedKeyTable().addCacheEntry(
+ new CacheKey<>(omMetadataManager.getVersionedOzoneKey(
+ volumeName, bucketName, keyName, 200L)),
+ CacheValue.get(350L));
+
+ deleteVersionAt(300L, false, 400L);
+
+ assertEquals(100L, currentVersion().getVersionId());
+ }
+
+ @Test
+ public void testDeletingTheOnlyVersionRemovesTheKey() throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(300L);
+
+ OMClientResponse response = deleteVersionAt(300L, false, 400L);
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ response.getOMResponse().getStatus());
+
+ assertNull(currentVersion());
+ }
+
+ /** Deleting a current delete marker is how S3 restores an object. */
+ @Test
+ public void testDeletingCurrentMarkerRestoresTheObject() throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(300L, true);
+ seedNoncurrentVersion(100L, false);
+
+ OMClientResponse response = deleteVersionAt(300L, false, 400L);
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ response.getOMResponse().getStatus());
+
+ OmKeyInfo current = currentVersion();
+ assertNotNull(current);
+ assertEquals(100L, current.getVersionId());
+ assertFalse(current.isDeleteMarker());
+ }
+
+ private OmKeyInfo currentVersion() throws Exception {
+ return omMetadataManager.getKeyTable(getBucketLayout()).get(
+ omMetadataManager.getOzoneKey(volumeName, bucketName, keyName));
+ }
+
+ private OMRequest commitRequest(boolean isHsync, long dataSize,
+ long writerClientId) {
+ KeyArgs keyArgs = KeyArgs.newBuilder()
+ .setVolumeName(volumeName)
+ .setBucketName(bucketName)
+ .setKeyName(keyName)
+ .setModificationTime(Time.now())
+ .setDataSize(dataSize)
+ .build();
+ return OMRequest.newBuilder()
+ .setCommitKeyRequest(CommitKeyRequest.newBuilder()
+ .setKeyArgs(keyArgs)
+ .setClientID(writerClientId)
+ .setHsync(isHsync))
+ .setCmdType(OzoneManagerProtocolProtos.Type.CommitKey)
+ .setClientId(UUID.randomUUID().toString()).build();
+ }
+
+ private OMClientResponse commitAt(long trxnLogIndex) throws Exception {
+ return commitAt(trxnLogIndex, false);
+ }
+
+ private OMClientResponse commitAt(long trxnLogIndex, boolean isHsync)
+ throws Exception {
+ OMClientResponse response =
+ commitAt(trxnLogIndex, isHsync, 0L, clientID);
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ response.getOMResponse().getStatus());
+ return response;
+ }
+
+ /**
+ * Commits the key as the writer identified by {@code writerClientId}. A
+ * non-hsync commit tombstones its own open key, so a second write of the
+ * same key comes from a different client, as it would in practice.
+ */
+ private OMClientResponse commitAt(long trxnLogIndex, boolean isHsync,
+ long dataSize, long writerClientId) throws Exception {
+ OMRequestTestUtils.addKeyToTable(true, volumeName, bucketName, keyName,
+ writerClientId, replicationConfig, omMetadataManager);
+ return new OMKeyCommitRequest(
+ commitRequest(isHsync, dataSize, writerClientId),
+ getBucketLayout()).validateAndUpdateCache(ozoneManager, trxnLogIndex);
+ }
+
+ private OmKeyInfo noncurrentVersion(long versionId) throws Exception {
+ return omMetadataManager.getVersionedKeyTable().get(
+ omMetadataManager.getVersionedOzoneKey(
+ volumeName, bucketName, keyName, versionId));
+ }
+
+ @Test
+ public void testCommitOfNewKeyAssignsVersionIdAndHasNoNoncurrentVersion()
+ throws Exception {
+ setupVersionedBucket();
+
+ commitAt(500L);
+
+ OmKeyInfo current = omMetadataManager.getKeyTable(getBucketLayout()).get(
+ omMetadataManager.getOzoneKey(volumeName, bucketName, keyName));
+ assertNotNull(current);
+ assertEquals(500L, current.getVersionId());
+ assertFalse(current.isDeleteMarker());
+ assertFalse(current.isNullVersion());
+ assertNull(noncurrentVersion(500L));
+ }
+
+ @Test
+ public void testOverwriteKeepsPreviousVersionAsNoncurrent()
+ throws Exception {
+ setupVersionedBucket();
+ String ozoneKey = seedCurrentVersion(100L);
+
+ commitAt(500L);
+
+ OmKeyInfo current =
+ omMetadataManager.getKeyTable(getBucketLayout()).get(ozoneKey);
+ assertEquals(500L, current.getVersionId());
+
+ OmKeyInfo noncurrent = noncurrentVersion(100L);
+ assertNotNull(noncurrent);
+ assertEquals(100L, noncurrent.getVersionId());
+ assertFalse(noncurrent.isNullVersion());
+ }
+
+ /**
+ * A record written before versioning was enabled carries no versionId, so on
+ * the first overwrite it becomes the key's single null version.
+ */
+ @Test
+ public void testPreVersioningRecordBecomesNullVersion() throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(null);
+
+ commitAt(500L);
+
+ OmKeyInfo noncurrent =
+ noncurrentVersion(VersionIdGenerator.UNSET_VERSION_ID);
+ assertNotNull(noncurrent);
+ assertTrue(noncurrent.isNullVersion());
+ assertEquals(VersionIdGenerator.UNSET_VERSION_ID,
+ noncurrent.getVersionId());
+ }
+
+ /**
+ * The overwritten version keeps its blocks in the versionedKeyTable: they
+ * must not be queued for reclamation, and they must not be carried into the
+ * new current version's in-record block version list either.
+ */
+ @Test
+ public void testOverwriteDoesNotReclaimOrInheritPreviousBlocks()
+ throws Exception {
+ setupVersionedBucket();
+ String ozoneKey = seedCurrentVersion(100L);
+
+ commitAt(500L);
+
+ assertNull(omMetadataManager.getDeletedTable().get(ozoneKey));
+ OmKeyInfo current =
+ omMetadataManager.getKeyTable(getBucketLayout()).get(ozoneKey);
+ assertEquals(1, current.getKeyLocationVersions().size());
+ assertNotNull(noncurrentVersion(100L));
+ }
+
+ /**
+ * An hsync re-commit keeps updating the version it opened rather than
+ * creating a new one, so its versionId stays frozen and nothing moves to the
+ * versionedKeyTable.
+ */
+ @Test
+ public void testHsyncRecommitKeepsVersionIdFrozen() throws Exception {
+ setupVersionedBucket();
+
+ commitAt(500L, true);
+ OmKeyInfo firstCommit = omMetadataManager.getKeyTable(getBucketLayout())
+ .get(omMetadataManager.getOzoneKey(volumeName, bucketName, keyName));
+ assertEquals(500L, firstCommit.getVersionId());
+
+ commitAt(600L, true);
+ OmKeyInfo recommitted = omMetadataManager.getKeyTable(getBucketLayout())
+ .get(omMetadataManager.getOzoneKey(volumeName, bucketName, keyName));
+ assertEquals(500L, recommitted.getVersionId());
+ assertNull(noncurrentVersion(500L));
+ }
+
+ @Test
+ public void testDeleteInsertsMarkerAndKeepsPreviousVersion()
+ throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(100L);
+
+ deleteAt(200L);
+
+ OmKeyInfo current = currentVersion();
+ assertNotNull(current);
+ assertTrue(current.isDeleteMarker());
+ assertEquals(200L, current.getVersionId());
+ assertEquals(0L, current.getDataSize());
+ assertTrue(current.getLatestVersionLocations().getLocationList().isEmpty());
+
+ OmKeyInfo noncurrent = noncurrentVersion(100L);
+ assertNotNull(noncurrent);
+ assertFalse(noncurrent.isDeleteMarker());
+ }
+
+ /** S3 inserts a delete marker even for a key that does not exist. */
+ @Test
+ public void testDeleteOfMissingKeyStillInsertsMarker() throws Exception {
+ setupVersionedBucket();
+
+ deleteAt(200L);
+
+ OmKeyInfo current = currentVersion();
+ assertNotNull(current);
+ assertTrue(current.isDeleteMarker());
+ assertEquals(200L, current.getVersionId());
+ assertNull(noncurrentVersion(VersionIdGenerator.UNSET_VERSION_ID));
+ }
+
+ /** Deleting a key whose current version is already a marker stacks another. */
+ @Test
+ public void testDeleteStacksAnotherMarker() throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(100L, true);
+
+ deleteAt(200L);
+
+ OmKeyInfo current = currentVersion();
+ assertTrue(current.isDeleteMarker());
+ assertEquals(200L, current.getVersionId());
+
+ OmKeyInfo stacked = noncurrentVersion(100L);
+ assertNotNull(stacked);
+ assertTrue(stacked.isDeleteMarker());
+ }
+
+ @Test
+ public void testDeleteOfPreVersioningRecordMovesItToNullVersion()
+ throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(null);
+
+ deleteAt(200L);
+
+ OmKeyInfo noncurrent =
+ noncurrentVersion(VersionIdGenerator.UNSET_VERSION_ID);
+ assertNotNull(noncurrent);
+ assertTrue(noncurrent.isNullVersion());
+ assertFalse(noncurrent.isDeleteMarker());
+ }
+
+ /**
+ * Every version counts against the bucket's space quota: an overwrite adds
+ * the new version's usage without releasing the version it supersedes.
+ */
+ @Test
+ public void testEachVersionCountsAgainstUsedBytes() throws Exception {
+ setupVersionedBucket();
+ String bucketKey =
+ omMetadataManager.getBucketKey(volumeName, bucketName);
+
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ commitAt(500L, false, 100L, clientID).getOMResponse().getStatus());
+ long afterFirst = omMetadataManager.getBucketTable().get(bucketKey)
+ .getUsedBytes();
+ assertEquals(QuotaUtil.getReplicatedSize(100L, replicationConfig),
+ afterFirst);
+
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ commitAt(600L, false, 300L, clientID + 1).getOMResponse().getStatus());
+ long afterSecond = omMetadataManager.getBucketTable().get(bucketKey)
+ .getUsedBytes();
+ assertEquals(afterFirst
+ + QuotaUtil.getReplicatedSize(300L, replicationConfig),
+ afterSecond);
+ assertEquals(2, omMetadataManager.getBucketTable().get(bucketKey)
+ .getUsedNamespace());
+ }
+
+ @Test
+ public void testVersionedWriteRejectedWhenSpaceQuotaExceeded()
+ throws Exception {
+ long quota = QuotaUtil.getReplicatedSize(150L, replicationConfig);
+ setupVersionedBucket(quota, OzoneConsts.QUOTA_RESET);
+
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ commitAt(500L, false, 100L, clientID).getOMResponse().getStatus());
+ // the first version is not released, so the second one no longer fits
+ assertEquals(OzoneManagerProtocolProtos.Status.QUOTA_EXCEEDED,
+ commitAt(600L, false, 100L, clientID + 1).getOMResponse().getStatus());
+
+ OmKeyInfo current = currentVersion();
+ assertEquals(500L, current.getVersionId());
+ assertNull(noncurrentVersion(500L));
+ }
+
+ @Test
+ public void testVersionedWriteRejectedWhenNamespaceQuotaExceeded()
+ throws Exception {
+ setupVersionedBucket(OzoneConsts.QUOTA_RESET, 1L);
+
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ commitAt(500L, false, 0L, clientID).getOMResponse().getStatus());
+ // each version is a record of its own, so the second one needs namespace
+ assertEquals(OzoneManagerProtocolProtos.Status.QUOTA_EXCEEDED,
+ commitAt(600L, false, 0L, clientID + 1).getOMResponse().getStatus());
+ }
+
+ /** A delete marker is a record too, so it needs namespace quota. */
+ @Test
+ public void testDeleteMarkerRejectedWhenNamespaceQuotaExceeded()
+ throws Exception {
+ // the bucket already holds the one key its namespace quota allows
+ setupVersionedBucket(OzoneConsts.QUOTA_RESET, 1L, 1L);
+ seedCurrentVersion(100L);
+
+ OMClientResponse response = new OMKeyDeleteRequest(deleteRequest(),
+ getBucketLayout()).validateAndUpdateCache(ozoneManager, 200L);
+ assertEquals(OzoneManagerProtocolProtos.Status.QUOTA_EXCEEDED,
+ response.getOMResponse().getStatus());
+ assertFalse(currentVersion().isDeleteMarker());
+ // a rejected request must not leave the superseded version behind in the
+ // versionedKeyTable cache, and its response has to declare that table so
+ // that the double buffer cleans up whatever the request did touch
+ assertNull(noncurrentVersion(100L));
+ assertInstanceOf(OMKeyDeleteMarkerResponse.class, response);
+ }
+
+ /**
+ * A delete marker holds no blocks, so it consumes namespace but no space,
+ * and the superseded version keeps its own usage.
+ */
+ @Test
+ public void testDeleteMarkerConsumesNamespaceButNoSpace() throws Exception {
+ setupVersionedBucket();
+ seedCurrentVersion(100L);
+ String bucketKey =
+ omMetadataManager.getBucketKey(volumeName, bucketName);
+ OmBucketInfo before = omMetadataManager.getBucketTable().get(bucketKey);
+ long usedBytes = before.getUsedBytes();
+ long usedNamespace = before.getUsedNamespace();
+
+ deleteAt(200L);
+
+ OmBucketInfo after = omMetadataManager.getBucketTable().get(bucketKey);
+ assertEquals(usedBytes, after.getUsedBytes());
+ assertEquals(usedNamespace + 1, after.getUsedNamespace());
+ }
+
+ /**
+ * A write while versioning is suspended takes the key's null version slot
+ * instead of creating a version of its own.
+ */
+ @Test
+ public void testSuspendedWriteTakesTheNullVersionSlot() throws Exception {
+ setupSuspendedBucket();
+
+ commitAt(500L);
+
+ OmKeyInfo current = currentVersion();
+ assertNotNull(current);
+ assertTrue(current.isNullVersion());
+ assertEquals(500L, current.getVersionId());
+ }
+
+ /**
+ * Repeated suspended writes replace each other: versions do not accumulate,
+ * and the replaced record's blocks are queued for reclamation.
+ */
+ @Test
+ public void testSuspendedWriteReplacesTheCurrentNullVersion()
+ throws Exception {
+ setupSuspendedBucket();
+ seedCurrentVersion(100L, false, true, true);
+
+ OMKeyCommitResponse response =
+ (OMKeyCommitResponse) commitAt(500L);
+
+ assertTrue(currentVersion().isNullVersion());
+ assertEquals(500L, currentVersion().getVersionId());
+ // the replaced null version is not kept as a noncurrent version
+ assertNull(noncurrentVersion(100L));
+ // its blocks are queued for reclamation instead
+ assertReclaimed(response, 100L);
+ }
+
+ /** Asserts that the given version was queued for block reclamation. */
+ private void assertReclaimed(OMKeyCommitResponse response, long versionId) {
+ assertNotNull(response.getKeysToDelete());
+ assertTrue(response.getKeysToDelete().values().stream()
+ .flatMap(repeated -> repeated.getOmKeyInfoList().stream())
+ .anyMatch(info -> info.getVersionId() != null
+ && info.getVersionId() == versionId),
+ "version " + versionId + " was not queued for reclamation");
+ }
+
+ /**
+ * Versions created while versioning was enabled are not touched by a
+ * suspended write: the one it supersedes becomes noncurrent as usual.
+ */
+ @Test
+ public void testSuspendedWriteKeepsEnabledEraVersions() throws Exception {
+ setupSuspendedBucket();
+ seedCurrentVersion(300L);
+ seedNoncurrentVersion(100L, false);
+
+ commitAt(500L);
+
+ assertTrue(currentVersion().isNullVersion());
+ // the version it superseded is retained, as is the older one
+ assertNotNull(noncurrentVersion(300L));
+ assertFalse(noncurrentVersion(300L).isNullVersion());
+ assertNotNull(noncurrentVersion(100L));
+ }
+
+ /**
+ * The null version may be noncurrent, when versioning was enabled again
+ * after the write that created it. A suspended write still replaces it, and
+ * still becomes the current version.
+ */
+ @Test
+ public void testSuspendedWriteReplacesANoncurrentNullVersion()
+ throws Exception {
+ setupSuspendedBucket();
+ seedCurrentVersion(300L);
+ seedNoncurrentVersion(200L, true);
+ seedNoncurrentVersion(100L, false);
+
+ commitAt(500L);
+
+ OmKeyInfo current = currentVersion();
+ assertTrue(current.isNullVersion());
+ assertEquals(500L, current.getVersionId());
+ // the old null version is gone, everything else is retained
+ assertNull(noncurrentVersion(200L));
+ assertNotNull(noncurrentVersion(300L));
+ assertNotNull(noncurrentVersion(100L));
+ }
+
+ /**
+ * A delete while versioning is suspended writes a marker into the key's null
+ * version slot rather than creating a version.
+ */
+ @Test
+ public void testSuspendedDeleteWritesANullMarker() throws Exception {
+ setupSuspendedBucket();
+ seedCurrentVersion(300L);
+
+ deleteAt(500L);
+
+ OmKeyInfo current = currentVersion();
+ assertNotNull(current);
+ assertTrue(current.isDeleteMarker());
+ assertTrue(current.isNullVersion());
+ // the version it superseded is retained, as under an enabled bucket
+ assertNotNull(noncurrentVersion(300L));
+ }
+
+ /** The null marker replaces the null version that held the slot. */
+ @Test
+ public void testSuspendedDeleteReplacesTheCurrentNullVersion()
+ throws Exception {
+ setupSuspendedBucket();
+ seedCurrentVersion(100L, false, true, true);
+
+ OMKeyDeleteMarkerResponse response =
+ (OMKeyDeleteMarkerResponse) deleteAt(500L);
+
+ OmKeyInfo current = currentVersion();
+ assertTrue(current.isDeleteMarker());
+ assertTrue(current.isNullVersion());
+ // the replaced record is not kept as a noncurrent version
+ assertNull(noncurrentVersion(100L));
+ assertNotNull(response.getKeysToDelete());
+ }
+
+ /**
+ * Versions created while versioning was enabled stay readable and deletable
+ * by versionId after a suspended delete.
+ */
+ @Test
+ public void testSuspendedDeleteKeepsEnabledEraVersions() throws Exception {
+ setupSuspendedBucket();
+ seedCurrentVersion(300L);
+ seedNoncurrentVersion(200L, true);
+ seedNoncurrentVersion(100L, false);
+
+ deleteAt(500L);
+
+ assertTrue(currentVersion().isDeleteMarker());
+ // the superseded version and the older one are retained
+ assertNotNull(noncurrentVersion(300L));
+ assertNotNull(noncurrentVersion(100L));
+ // only the null version the marker replaced is gone
+ assertNull(noncurrentVersion(200L));
+
+ // and a retained version can still be deleted by versionId
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ deleteVersionAt(100L, false, 600L).getOMResponse().getStatus());
+ assertNull(noncurrentVersion(100L));
+ }
+
+ /**
+ * A key written before versioning was enabled carries no versionId and no
+ * null flag. It is the key's null version all the same, so version "null"
+ * addresses it - without the record ever having been rewritten.
+ */
+ @Test
+ public void testPreVersioningCurrentIsAddressableAsNullVersion()
+ throws Exception {
+ setupVersionedBucket();
+ String ozoneKey = seedCurrentVersion(null);
+ OmKeyInfo legacy = currentVersion();
+ assertNull(legacy.getVersionId());
+ assertFalse(legacy.isNullVersion());
+ assertTrue(legacy.isNullVersionRecord());
+
+ // deleting version "null" hits it even though it carries no flag
+ assertEquals(OzoneManagerProtocolProtos.Status.OK,
+ deleteVersionAt(null, true, 500L).getOMResponse().getStatus());
+ assertNull(omMetadataManager.getKeyTable(getBucketLayout()).get(ozoneKey));
+ }
+
+ /**
+ * A suspended write replaces a pre-versioning record, since that record is
+ * the key's null version.
+ */
+ @Test
+ public void testSuspendedWriteReplacesAPreVersioningRecord()
+ throws Exception {
+ setupSuspendedBucket();
+ seedCurrentVersion(null, false, false, true);
+
+ OMKeyCommitResponse response = (OMKeyCommitResponse) commitAt(500L);
+
+ OmKeyInfo current = currentVersion();
+ assertTrue(current.isNullVersion());
+ assertEquals(500L, current.getVersionId());
+ // it was replaced, not kept as a noncurrent version
+ assertNull(noncurrentVersion(VersionIdGenerator.UNSET_VERSION_ID));
+ assertNotNull(response.getKeysToDelete());
+ }
+}
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartRequestTests.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartRequestTests.java
index e23b84f52939..e621c0c74d6a 100644
--- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartRequestTests.java
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/multipart/S3MultipartRequestTests.java
@@ -45,6 +45,7 @@
import org.apache.hadoop.ozone.om.OmMetadataReader;
import org.apache.hadoop.ozone.om.OzoneManager;
import org.apache.hadoop.ozone.om.ResolvedBucket;
+import org.apache.hadoop.ozone.om.VersionIdAllocator;
import org.apache.hadoop.ozone.om.helpers.BucketLayout;
import org.apache.hadoop.ozone.om.helpers.KeyValueUtil;
import org.apache.hadoop.ozone.om.request.OMClientRequest;
@@ -112,6 +113,8 @@ public void setup() throws Exception {
when(lvm.getMetadataLayoutVersion()).thenReturn(0);
when(ozoneManager.getVersionManager()).thenReturn(lvm);
when(ozoneManager.getConfiguration()).thenReturn(ozoneConfiguration);
+ when(ozoneManager.getVersionIdAllocator())
+ .thenReturn(new VersionIdAllocator(ozoneConfiguration));
when(ozoneManager.getConfig()).thenReturn(ozoneConfiguration.getObject(OmConfig.class));
}
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/multipart/TestS3MultipartUploadCompleteRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/multipart/TestS3MultipartUploadCompleteRequest.java
index 819f4bf448d2..1e1cf5d18fe4 100644
--- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/multipart/TestS3MultipartUploadCompleteRequest.java
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/multipart/TestS3MultipartUploadCompleteRequest.java
@@ -20,9 +20,11 @@
import static org.apache.hadoop.hdds.protocol.proto.HddsProtos.ReplicationFactor.ONE;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeFalse;
import java.io.IOException;
import java.util.ArrayList;
@@ -35,6 +37,8 @@
import org.apache.hadoop.hdds.utils.db.Table;
import org.apache.hadoop.hdds.utils.db.cache.CacheKey;
import org.apache.hadoop.ozone.OzoneConsts;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
+import org.apache.hadoop.ozone.om.helpers.BucketVersioningStatus;
import org.apache.hadoop.ozone.om.helpers.OmBucketInfo;
import org.apache.hadoop.ozone.om.helpers.OmKeyInfo;
import org.apache.hadoop.ozone.om.helpers.RepeatedOmKeyInfo;
@@ -98,6 +102,51 @@ public void testValidateAndUpdateCacheSuccess() throws Exception {
checkDeleteTableCount(volumeName, bucketName, keyName, 1, uploadId);
}
+
+ /**
+ * Completing a multipart upload over an existing key on a versioned bucket
+ * creates a version like any other write: the version it supersedes has to
+ * be kept in the versionedKeyTable, not dropped from the keyTable and left
+ * out of the deletedTable.
+ */
+ @Test
+ public void testVersionedOverwriteKeepsPreviousVersion() throws Exception {
+ // versioning is only supported on OBJECT_STORE buckets
+ assumeFalse(getBucketLayout().isFileSystemOptimized());
+ String volumeName = UUID.randomUUID().toString();
+ String bucketName = UUID.randomUUID().toString();
+ String keyName = getKeyName();
+ OMRequestTestUtils.addVolumeAndBucketToDB(volumeName, omMetadataManager,
+ OmBucketInfo.newBuilder()
+ .setVolumeName(volumeName)
+ .setBucketName(bucketName)
+ // versioning is only allowed on OBJECT_STORE buckets; it shares
+ // the keyTable with LEGACY, so the request helpers still apply
+ .setBucketLayout(BucketLayout.OBJECT_STORE)
+ .setVersioningStatus(BucketVersioningStatus.ENABLED));
+
+ checkValidateAndUpdateCacheSuccess(volumeName, bucketName, keyName,
+ new HashMap<>(), new HashMap<>(), 0L, 1L);
+ OmKeyInfo firstVersion = omMetadataManager.getKeyTable(getBucketLayout())
+ .get(getOzoneDBKey(volumeName, bucketName, keyName));
+ assertNotNull(firstVersion.getVersionId());
+
+ checkValidateAndUpdateCacheSuccess(volumeName, bucketName, keyName,
+ new HashMap<>(), new HashMap<>(), 10L, 2L);
+
+ OmKeyInfo current = omMetadataManager.getKeyTable(getBucketLayout())
+ .get(getOzoneDBKey(volumeName, bucketName, keyName));
+ assertNotEquals(firstVersion.getVersionId(), current.getVersionId());
+
+ // the superseded version survives as a noncurrent version
+ OmKeyInfo noncurrent = omMetadataManager.getVersionedKeyTable().get(
+ omMetadataManager.getVersionedOzoneKey(volumeName, bucketName, keyName,
+ firstVersion.getVersionId()));
+ assertNotNull(noncurrent,
+ "the version the upload superseded was neither kept nor reclaimed");
+ assertEquals(firstVersion.getVersionId(), noncurrent.getVersionId());
+ }
+
public void checkDeleteTableCount(String volumeName,
String bucketName, String keyName, int count, String uploadId)
throws Exception {
@@ -122,6 +171,21 @@ public void checkDeleteTableCount(String volumeName,
private String checkValidateAndUpdateCacheSuccess(String volumeName,
String bucketName, String keyName, Map metadata, Map tags) throws Exception {
+ return checkValidateAndUpdateCacheSuccess(volumeName, bucketName, keyName,
+ metadata, tags, 0L, getNamespaceCount());
+ }
+
+ /**
+ * @param trxnBase offset added to the transaction indexes, so that a test can
+ * run the flow more than once: versionIds have to increase within a key.
+ * @param expectedNamespace the bucket's used namespace once the upload
+ * completes; a versioned overwrite adds a record rather than replacing
+ * one, so the count grows.
+ */
+ private String checkValidateAndUpdateCacheSuccess(String volumeName,
+ String bucketName, String keyName, Map metadata,
+ Map tags, long trxnBase, long expectedNamespace)
+ throws Exception {
OMRequest initiateMPURequest = doPreExecuteInitiateMPU(volumeName,
bucketName, keyName, metadata, tags);
@@ -130,7 +194,7 @@ private String checkValidateAndUpdateCacheSuccess(String volumeName,
getS3InitiateMultipartUploadReq(initiateMPURequest);
OMClientResponse omClientResponse =
- s3InitiateMultipartUploadRequest.validateAndUpdateCache(ozoneManager, 1L);
+ s3InitiateMultipartUploadRequest.validateAndUpdateCache(ozoneManager, trxnBase + 1L);
long clientID = Time.now();
String multipartUploadID = omClientResponse.getOMResponse()
@@ -145,7 +209,7 @@ private String checkValidateAndUpdateCacheSuccess(String volumeName,
// Add key to open key table.
addKeyToTable(volumeName, bucketName, keyName, clientID);
- s3MultipartUploadCommitPartRequest.validateAndUpdateCache(ozoneManager, 2L);
+ s3MultipartUploadCommitPartRequest.validateAndUpdateCache(ozoneManager, trxnBase + 2L);
List partList = new ArrayList<>();
@@ -166,7 +230,7 @@ private String checkValidateAndUpdateCacheSuccess(String volumeName,
getS3MultipartUploadCompleteReq(completeMultipartRequest);
omClientResponse =
- s3MultipartUploadCompleteRequest.validateAndUpdateCache(ozoneManager, 3L);
+ s3MultipartUploadCompleteRequest.validateAndUpdateCache(ozoneManager, trxnBase + 3L);
BatchOperation batchOperation
= omMetadataManager.getStore().initBatchOperation();
@@ -201,8 +265,7 @@ private String checkValidateAndUpdateCacheSuccess(String volumeName,
.getCacheValue(new CacheKey<>(
omMetadataManager.getBucketKey(volumeName, bucketName)))
.getCacheValue();
- assertEquals(getNamespaceCount(),
- omBucketInfo.getUsedNamespace());
+ assertEquals(expectedNamespace, omBucketInfo.getUsedNamespace());
return multipartUploadID;
}