Migrate profiles from a relational database to DynamoDB

This commit is contained in:
Jon Chambers
2021-11-24 14:48:41 -05:00
committed by GitHub
parent 3bb8e5bb00
commit 9e7010f185
18 changed files with 1021 additions and 114 deletions

View File

@@ -0,0 +1,193 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
class ProfilesDynamoDbTest extends ProfilesTest {
private static final String PROFILES_TABLE_NAME = "profiles_test";
@RegisterExtension
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
.tableName(PROFILES_TABLE_NAME)
.hashKey(ProfilesDynamoDb.KEY_ACCOUNT_UUID)
.rangeKey(ProfilesDynamoDb.ATTR_VERSION)
.attributeDefinition(AttributeDefinition.builder()
.attributeName(ProfilesDynamoDb.KEY_ACCOUNT_UUID)
.attributeType(ScalarAttributeType.B)
.build())
.attributeDefinition(AttributeDefinition.builder()
.attributeName(ProfilesDynamoDb.ATTR_VERSION)
.attributeType(ScalarAttributeType.S)
.build())
.build();
private ProfilesDynamoDb profiles;
@BeforeEach
void setUp() {
profiles = new ProfilesDynamoDb(dynamoDbExtension.getDynamoDbClient(),
dynamoDbExtension.getDynamoDbAsyncClient(),
PROFILES_TABLE_NAME);
}
@Override
protected ProfilesStore getProfilesStore() {
return profiles;
}
@ParameterizedTest
@MethodSource
void buildUpdateExpression(final VersionedProfile profile, final String expectedUpdateExpression) {
assertEquals(expectedUpdateExpression, ProfilesDynamoDb.buildUpdateExpression(profile));
}
private static Stream<Arguments> buildUpdateExpression() {
final byte[] commitment = "commitment".getBytes(StandardCharsets.UTF_8);
return Stream.of(
Arguments.of(
new VersionedProfile("version", "name", "avatar", "emoji", "about", "paymentAddress", commitment),
"SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar, #about = :about, #aboutEmoji = :aboutEmoji, #paymentAddress = :paymentAddress"),
Arguments.of(
new VersionedProfile("version", "name", "avatar", "emoji", "about", null, commitment),
"SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar, #about = :about, #aboutEmoji = :aboutEmoji REMOVE #paymentAddress"),
Arguments.of(
new VersionedProfile("version", "name", "avatar", "emoji", null, null, commitment),
"SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar, #aboutEmoji = :aboutEmoji REMOVE #about, #paymentAddress"),
Arguments.of(
new VersionedProfile("version", "name", "avatar", null, null, null, commitment),
"SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar REMOVE #about, #aboutEmoji, #paymentAddress"),
Arguments.of(
new VersionedProfile("version", "name", null, null, null, null, commitment),
"SET #commitment = if_not_exists(#commitment, :commitment), #name = :name REMOVE #avatar, #about, #aboutEmoji, #paymentAddress"),
Arguments.of(
new VersionedProfile("version", null, null, null, null, null, commitment),
"SET #commitment = if_not_exists(#commitment, :commitment) REMOVE #name, #avatar, #about, #aboutEmoji, #paymentAddress")
);
}
@ParameterizedTest
@MethodSource
void buildUpdateExpressionAttributeValues(final VersionedProfile profile, final Map<String, AttributeValue> expectedAttributeValues) {
assertEquals(expectedAttributeValues, ProfilesDynamoDb.buildUpdateExpressionAttributeValues(profile));
}
private static Stream<Arguments> buildUpdateExpressionAttributeValues() {
final byte[] commitment = "commitment".getBytes(StandardCharsets.UTF_8);
return Stream.of(
Arguments.of(
new VersionedProfile("version", "name", "avatar", "emoji", "about", "paymentAddress", commitment),
Map.of(
":commitment", AttributeValues.fromByteArray(commitment),
":name", AttributeValues.fromString("name"),
":avatar", AttributeValues.fromString("avatar"),
":aboutEmoji", AttributeValues.fromString("emoji"),
":about", AttributeValues.fromString("about"),
":paymentAddress", AttributeValues.fromString("paymentAddress"))),
Arguments.of(
new VersionedProfile("version", "name", "avatar", "emoji", "about", null, commitment),
Map.of(
":commitment", AttributeValues.fromByteArray(commitment),
":name", AttributeValues.fromString("name"),
":avatar", AttributeValues.fromString("avatar"),
":aboutEmoji", AttributeValues.fromString("emoji"),
":about", AttributeValues.fromString("about"))),
Arguments.of(
new VersionedProfile("version", "name", "avatar", "emoji", null, null, commitment),
Map.of(
":commitment", AttributeValues.fromByteArray(commitment),
":name", AttributeValues.fromString("name"),
":avatar", AttributeValues.fromString("avatar"),
":aboutEmoji", AttributeValues.fromString("emoji"))),
Arguments.of(
new VersionedProfile("version", "name", "avatar", null, null, null, commitment),
Map.of(
":commitment", AttributeValues.fromByteArray(commitment),
":name", AttributeValues.fromString("name"),
":avatar", AttributeValues.fromString("avatar"))),
Arguments.of(
new VersionedProfile("version", "name", null, null, null, null, commitment),
Map.of(
":commitment", AttributeValues.fromByteArray(commitment),
":name", AttributeValues.fromString("name"))),
Arguments.of(
new VersionedProfile("version", null, null, null, null, null, commitment),
Map.of(":commitment", AttributeValues.fromByteArray(commitment)))
);
}
@ParameterizedTest
@MethodSource
void migrate(final VersionedProfile profile) {
final UUID uuid = UUID.randomUUID();
assertTrue(assertDoesNotThrow(() -> profiles.migrate(uuid, profile).join()));
assertFalse(assertDoesNotThrow(() -> profiles.migrate(uuid, profile).join()));
assertEquals(Optional.of(profile), profiles.get(uuid, profile.getVersion()));
}
private static Stream<Arguments> migrate() {
return Stream.of(
Arguments.of(new VersionedProfile("version", "name", "avatar", "emoji", "about", "paymentAddress", "commitment".getBytes(StandardCharsets.UTF_8))),
Arguments.of(new VersionedProfile("version", null, "avatar", "emoji", "about", "paymentAddress", "commitment".getBytes(StandardCharsets.UTF_8))),
Arguments.of(new VersionedProfile("version", "name", null, "emoji", "about", "paymentAddress", "commitment".getBytes(StandardCharsets.UTF_8))),
Arguments.of(new VersionedProfile("version", "name", "avatar", null, "about", "paymentAddress", "commitment".getBytes(StandardCharsets.UTF_8))),
Arguments.of(new VersionedProfile("version", "name", "avatar", "emoji", null, "paymentAddress", "commitment".getBytes(StandardCharsets.UTF_8))),
Arguments.of(new VersionedProfile("version", "name", "avatar", "emoji", "about", null, "commitment".getBytes(StandardCharsets.UTF_8)))
);
}
@Test
void delete() {
final UUID uuid = UUID.randomUUID();
final VersionedProfile firstProfile =
new VersionedProfile("version1", "name", "avatar", "emoji", "about", "paymentAddress", "commitment".getBytes(StandardCharsets.UTF_8));
final VersionedProfile secondProfile =
new VersionedProfile("version2", "name", "avatar", "emoji", "about", "paymentAddress", "commitment".getBytes(StandardCharsets.UTF_8));
profiles.set(uuid, firstProfile);
profiles.set(uuid, secondProfile);
profiles.delete(uuid, firstProfile.getVersion()).join();
assertTrue(profiles.get(uuid, firstProfile.getVersion()).isEmpty());
assertTrue(profiles.get(uuid, secondProfile.getVersion()).isPresent());
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import com.google.common.collect.ImmutableList;
import com.opentable.db.postgres.embedded.LiquibasePreparer;
import com.opentable.db.postgres.junit5.EmbeddedPostgresExtension;
import com.opentable.db.postgres.junit5.PreparedDbExtension;
import org.jdbi.v3.core.Jdbi;
import org.jdbi.v3.core.result.ResultIterator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.util.Pair;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class ProfilesPostgresTest extends ProfilesTest {
@RegisterExtension
static PreparedDbExtension ACCOUNTS_POSTGRES_EXTENSION =
EmbeddedPostgresExtension.preparedDatabase(LiquibasePreparer.forClasspathLocation("accountsdb.xml"));
private Profiles profiles;
@BeforeEach
void setUp() {
final FaultTolerantDatabase faultTolerantDatabase = new FaultTolerantDatabase("profilesTest",
Jdbi.create(ACCOUNTS_POSTGRES_EXTENSION.getTestDatabase()),
new CircuitBreakerConfiguration());
profiles = new Profiles(faultTolerantDatabase);
}
@Override
protected ProfilesStore getProfilesStore() {
return profiles;
}
@Test
void testGetDeletedProfiles() {
profiles.purgeDeletedProfiles();
UUID uuid = UUID.randomUUID();
VersionedProfile profileOne = new VersionedProfile("123", "foo", "avatarLocation", null, null,
null, "aDigest".getBytes());
VersionedProfile profileTwo = new VersionedProfile("345", "bar", "baz", null, null, null, "boof".getBytes());
profiles.set(uuid, profileOne);
profiles.set(UUID.randomUUID(), profileTwo);
profiles.deleteAll(uuid);
try (final ResultIterator<Pair<UUID, String>> resultIterator = profiles.getDeletedProfiles(10)) {
assertEquals(List.of(new Pair<>(uuid, profileOne.getVersion())), ImmutableList.copyOf(resultIterator));
}
}
}

View File

@@ -1,47 +1,27 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.tests.storage;
package org.whispersystems.textsecuregcm.storage;
import com.opentable.db.postgres.embedded.LiquibasePreparer;
import com.opentable.db.postgres.junit.EmbeddedPostgresRules;
import com.opentable.db.postgres.junit.PreparedDbRule;
import org.jdbi.v3.core.Jdbi;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.storage.FaultTolerantDatabase;
import org.whispersystems.textsecuregcm.storage.Profiles;
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
public abstract class ProfilesTest {
public class ProfilesTest {
@Rule
public PreparedDbRule db = EmbeddedPostgresRules.preparedDatabase(LiquibasePreparer.forClasspathLocation("accountsdb.xml"));
private Profiles profiles;
@Before
public void setupProfilesDao() {
FaultTolerantDatabase faultTolerantDatabase = new FaultTolerantDatabase("profilesTest",
Jdbi.create(db.getTestDatabase()),
new CircuitBreakerConfiguration());
this.profiles = new Profiles(faultTolerantDatabase);
}
protected abstract ProfilesStore getProfilesStore();
@Test
public void testSetGet() {
UUID uuid = UUID.randomUUID();
VersionedProfile profile = new VersionedProfile("123", "foo", "avatarLocation", "emoji", "the very model of a modern major general",
void testSetGet() {
ProfilesStore profiles = getProfilesStore();
UUID uuid = UUID.randomUUID();
VersionedProfile profile = new VersionedProfile("123", "foo", "avatarLocation", "emoji",
"the very model of a modern major general",
null, "acommitment".getBytes());
profiles.set(uuid, profile);
@@ -56,8 +36,9 @@ public class ProfilesTest {
}
@Test
public void testSetGetNullOptionalFields() {
UUID uuid = UUID.randomUUID();
void testSetGetNullOptionalFields() {
ProfilesStore profiles = getProfilesStore();
UUID uuid = UUID.randomUUID();
VersionedProfile profile = new VersionedProfile("123", "foo", null, null, null, null,
"acommitment".getBytes());
profiles.set(uuid, profile);
@@ -73,10 +54,11 @@ public class ProfilesTest {
}
@Test
public void testSetReplace() {
UUID uuid = UUID.randomUUID();
void testSetReplace() {
ProfilesStore profiles = getProfilesStore();
UUID uuid = UUID.randomUUID();
VersionedProfile profile = new VersionedProfile("123", "foo", "avatarLocation", null, null,
null, "acommitment".getBytes());
"paymentAddress", "acommitment".getBytes());
profiles.set(uuid, profile);
Optional<VersionedProfile> retrieved = profiles.get(uuid, "123");
@@ -96,20 +78,22 @@ public class ProfilesTest {
assertThat(retrieved.isPresent()).isTrue();
assertThat(retrieved.get().getName()).isEqualTo(updated.getName());
assertThat(retrieved.get().getCommitment()).isEqualTo(profile.getCommitment());
assertThat(retrieved.get().getAbout()).isEqualTo(updated.getAbout());
assertThat(retrieved.get().getAboutEmoji()).isEqualTo(updated.getAboutEmoji());
assertThat(retrieved.get().getAvatar()).isEqualTo(updated.getAvatar());
// Commitment should be unchanged after an overwrite
assertThat(retrieved.get().getAvatar()).isEqualTo(updated.getAvatar());
assertThat(retrieved.get().getCommitment()).isEqualTo(profile.getCommitment());
}
@Test
public void testMultipleVersions() {
UUID uuid = UUID.randomUUID();
void testMultipleVersions() {
ProfilesStore profiles = getProfilesStore();
UUID uuid = UUID.randomUUID();
VersionedProfile profileOne = new VersionedProfile("123", "foo", "avatarLocation", null, null,
null, "acommitmnet".getBytes());
VersionedProfile profileTwo = new VersionedProfile("345", "bar", "baz", "emoji", "i keep typing emoju for some reason",
VersionedProfile profileTwo = new VersionedProfile("345", "bar", "baz", "emoji",
"i keep typing emoju for some reason",
null, "boof".getBytes());
profiles.set(uuid, profileOne);
@@ -135,8 +119,9 @@ public class ProfilesTest {
}
@Test
public void testMissing() {
UUID uuid = UUID.randomUUID();
void testMissing() {
ProfilesStore profiles = getProfilesStore();
UUID uuid = UUID.randomUUID();
VersionedProfile profile = new VersionedProfile("123", "foo", "avatarLocation", null, null,
null, "aDigest".getBytes());
profiles.set(uuid, profile);
@@ -147,8 +132,9 @@ public class ProfilesTest {
@Test
public void testDelete() {
UUID uuid = UUID.randomUUID();
void testDelete() {
ProfilesStore profiles = getProfilesStore();
UUID uuid = UUID.randomUUID();
VersionedProfile profileOne = new VersionedProfile("123", "foo", "avatarLocation", null, null,
null, "aDigest".getBytes());
VersionedProfile profileTwo = new VersionedProfile("345", "bar", "baz", null, null, null, "boof".getBytes());
@@ -166,6 +152,4 @@ public class ProfilesTest {
assertThat(retrieved.isPresent()).isFalse();
}
}

View File

@@ -5,10 +5,10 @@
package org.whispersystems.textsecuregcm.tests.storage;
import static junit.framework.TestCase.assertSame;
import static junit.framework.TestCase.assertTrue;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -22,59 +22,85 @@ import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
import java.util.Base64;
import java.util.Optional;
import java.util.UUID;
import org.junit.Test;
import java.util.concurrent.Executor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicProfileMigrationConfiguration;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.Profiles;
import org.whispersystems.textsecuregcm.storage.ProfilesDynamoDb;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
public class ProfilesManagerTest {
private Profiles profiles;
private RedisAdvancedClusterCommands<String, String> commands;
private ProfilesManager profilesManager;
@BeforeEach
void setUp() {
//noinspection unchecked
commands = mock(RedisAdvancedClusterCommands.class);
final FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.buildMockRedisCluster(commands);
profiles = mock(Profiles.class);
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =
mock(DynamicConfigurationManager.class);
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
final DynamicProfileMigrationConfiguration profileMigrationConfiguration =
mock(DynamicProfileMigrationConfiguration.class);
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
when(dynamicConfiguration.getProfileMigrationConfiguration()).thenReturn(profileMigrationConfiguration);
profilesManager = new ProfilesManager(profiles,
mock(ProfilesDynamoDb.class),
cacheCluster,
dynamicConfigurationManager,
mock(Executor.class));
}
@Test
public void testGetProfileInCache() {
RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.buildMockRedisCluster(commands);
Profiles profiles = mock(Profiles.class);
UUID uuid = UUID.randomUUID();
when(commands.hget(eq("profiles::" + uuid.toString()), eq("someversion"))).thenReturn("{\"version\": \"someversion\", \"name\": \"somename\", \"avatar\": \"someavatar\", \"commitment\":\"" + Base64.getEncoder().encodeToString("somecommitment".getBytes()) + "\"}");
when(commands.hget(eq("profiles::" + uuid), eq("someversion"))).thenReturn("{\"version\": \"someversion\", \"name\": \"somename\", \"avatar\": \"someavatar\", \"commitment\":\"" + Base64.getEncoder().encodeToString("somecommitment".getBytes()) + "\"}");
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
Optional<VersionedProfile> profile = profilesManager.get(uuid, "someversion");
Optional<VersionedProfile> profile = profilesManager.get(uuid, "someversion");
assertTrue(profile.isPresent());
assertEquals(profile.get().getName(), "somename");
assertEquals(profile.get().getAvatar(), "someavatar");
assertThat(profile.get().getCommitment()).isEqualTo("somecommitment".getBytes());
verify(commands, times(1)).hget(eq("profiles::" + uuid.toString()), eq("someversion"));
verify(commands, times(1)).hget(eq("profiles::" + uuid), eq("someversion"));
verifyNoMoreInteractions(commands);
verifyNoMoreInteractions(profiles);
}
@Test
public void testGetProfileNotInCache() {
RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.buildMockRedisCluster(commands);
Profiles profiles = mock(Profiles.class);
UUID uuid = UUID.randomUUID();
VersionedProfile profile = new VersionedProfile("someversion", "somename", "someavatar", null, null,
null, "somecommitment".getBytes());
when(commands.hget(eq("profiles::" + uuid.toString()), eq("someversion"))).thenReturn(null);
when(commands.hget(eq("profiles::" + uuid), eq("someversion"))).thenReturn(null);
when(profiles.get(eq(uuid), eq("someversion"))).thenReturn(Optional.of(profile));
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
Optional<VersionedProfile> retrieved = profilesManager.get(uuid, "someversion");
Optional<VersionedProfile> retrieved = profilesManager.get(uuid, "someversion");
assertTrue(retrieved.isPresent());
assertSame(retrieved.get(), profile);
verify(commands, times(1)).hget(eq("profiles::" + uuid.toString()), eq("someversion"));
verify(commands, times(1)).hset(eq("profiles::" + uuid.toString()), eq("someversion"), anyString());
verify(commands, times(1)).hget(eq("profiles::" + uuid), eq("someversion"));
verify(commands, times(1)).hset(eq("profiles::" + uuid), eq("someversion"), anyString());
verifyNoMoreInteractions(commands);
verify(profiles, times(1)).get(eq(uuid), eq("someversion"));
@@ -83,25 +109,20 @@ public class ProfilesManagerTest {
@Test
public void testGetProfileBrokenCache() {
RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.buildMockRedisCluster(commands);
Profiles profiles = mock(Profiles.class);
UUID uuid = UUID.randomUUID();
VersionedProfile profile = new VersionedProfile("someversion", "somename", "someavatar", null, null,
null, "somecommitment".getBytes());
when(commands.hget(eq("profiles::" + uuid.toString()), eq("someversion"))).thenThrow(new RedisException("Connection lost"));
when(commands.hget(eq("profiles::" + uuid), eq("someversion"))).thenThrow(new RedisException("Connection lost"));
when(profiles.get(eq(uuid), eq("someversion"))).thenReturn(Optional.of(profile));
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
Optional<VersionedProfile> retrieved = profilesManager.get(uuid, "someversion");
Optional<VersionedProfile> retrieved = profilesManager.get(uuid, "someversion");
assertTrue(retrieved.isPresent());
assertSame(retrieved.get(), profile);
verify(commands, times(1)).hget(eq("profiles::" + uuid.toString()), eq("someversion"));
verify(commands, times(1)).hset(eq("profiles::" + uuid.toString()), eq("someversion"), anyString());
verify(commands, times(1)).hget(eq("profiles::" + uuid), eq("someversion"));
verify(commands, times(1)).hset(eq("profiles::" + uuid), eq("someversion"), anyString());
verifyNoMoreInteractions(commands);
verify(profiles, times(1)).get(eq(uuid), eq("someversion"));