Use reactive streams for WebSocket message queue
Initially, uses `ExperimentEnrollmentManager` to do a safe rollout.
This commit is contained in:
parent
4252284405
commit
c10fda8363
9
pom.xml
9
pom.xml
|
@ -57,7 +57,7 @@
|
||||||
<jedis.version>2.9.0</jedis.version>
|
<jedis.version>2.9.0</jedis.version>
|
||||||
<kotlin.version>1.7.10</kotlin.version>
|
<kotlin.version>1.7.10</kotlin.version>
|
||||||
<kotlinx-serialization.version>1.4.0</kotlinx-serialization.version>
|
<kotlinx-serialization.version>1.4.0</kotlinx-serialization.version>
|
||||||
<lettuce.version>6.1.9.RELEASE</lettuce.version>
|
<lettuce.version>6.2.0.RELEASE</lettuce.version>
|
||||||
<libphonenumber.version>8.12.54</libphonenumber.version>
|
<libphonenumber.version>8.12.54</libphonenumber.version>
|
||||||
<logstash.logback.version>7.0.1</logstash.logback.version>
|
<logstash.logback.version>7.0.1</logstash.logback.version>
|
||||||
<micrometer.version>1.9.3</micrometer.version>
|
<micrometer.version>1.9.3</micrometer.version>
|
||||||
|
@ -151,6 +151,13 @@
|
||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.projectreactor</groupId>
|
||||||
|
<artifactId>reactor-bom</artifactId>
|
||||||
|
<version>2020.0.23</version> <!-- 3.4.x, see https://github.com/reactor/reactor#bom-versioning-scheme -->
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.eatthepath</groupId>
|
<groupId>com.eatthepath</groupId>
|
||||||
<artifactId>pushy</artifactId>
|
<artifactId>pushy</artifactId>
|
||||||
|
|
|
@ -228,6 +228,10 @@
|
||||||
<groupId>io.github.resilience4j</groupId>
|
<groupId>io.github.resilience4j</groupId>
|
||||||
<artifactId>resilience4j-retry</artifactId>
|
<artifactId>resilience4j-retry</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.github.resilience4j</groupId>
|
||||||
|
<artifactId>resilience4j-reactor</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.grpc</groupId>
|
<groupId>io.grpc</groupId>
|
||||||
|
@ -407,7 +411,6 @@
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.projectreactor</groupId>
|
<groupId>io.projectreactor</groupId>
|
||||||
<artifactId>reactor-core</artifactId>
|
<artifactId>reactor-core</artifactId>
|
||||||
<version>3.3.22.RELEASE</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.vavr</groupId>
|
<groupId>io.vavr</groupId>
|
||||||
|
@ -420,6 +423,11 @@
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.projectreactor</groupId>
|
||||||
|
<artifactId>reactor-test</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.signal</groupId>
|
<groupId>org.signal</groupId>
|
||||||
<artifactId>embedded-redis</artifactId>
|
<artifactId>embedded-redis</artifactId>
|
||||||
|
|
|
@ -116,7 +116,6 @@ import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;
|
||||||
import org.whispersystems.textsecuregcm.limits.DynamicRateLimiters;
|
import org.whispersystems.textsecuregcm.limits.DynamicRateLimiters;
|
||||||
import org.whispersystems.textsecuregcm.limits.PushChallengeManager;
|
import org.whispersystems.textsecuregcm.limits.PushChallengeManager;
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
|
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeOptionManager;
|
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||||
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
|
||||||
|
@ -330,6 +329,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
config.getAppConfig().getConfigurationName(),
|
config.getAppConfig().getConfigurationName(),
|
||||||
DynamicConfiguration.class);
|
DynamicConfiguration.class);
|
||||||
|
|
||||||
|
BlockingQueue<Runnable> messageDeletionQueue = new ArrayBlockingQueue<>(10_000);
|
||||||
|
Metrics.gaugeCollectionSize(name(getClass(), "messageDeletionQueueSize"), Collections.emptyList(),
|
||||||
|
messageDeletionQueue);
|
||||||
|
ExecutorService messageDeletionAsyncExecutor = environment.lifecycle()
|
||||||
|
.executorService(name(getClass(), "messageDeletionAsyncExecutor-%d")).maxThreads(16)
|
||||||
|
.workQueue(messageDeletionQueue).build();
|
||||||
|
|
||||||
Accounts accounts = new Accounts(dynamicConfigurationManager,
|
Accounts accounts = new Accounts(dynamicConfigurationManager,
|
||||||
dynamoDbClient,
|
dynamoDbClient,
|
||||||
dynamoDbAsyncClient,
|
dynamoDbAsyncClient,
|
||||||
|
@ -345,9 +351,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
||||||
config.getDynamoDbTables().getProfiles().getTableName());
|
config.getDynamoDbTables().getProfiles().getTableName());
|
||||||
Keys keys = new Keys(dynamoDbClient, config.getDynamoDbTables().getKeys().getTableName());
|
Keys keys = new Keys(dynamoDbClient, config.getDynamoDbTables().getKeys().getTableName());
|
||||||
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient,
|
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
|
||||||
config.getDynamoDbTables().getMessages().getTableName(),
|
config.getDynamoDbTables().getMessages().getTableName(),
|
||||||
config.getDynamoDbTables().getMessages().getExpiration());
|
config.getDynamoDbTables().getMessages().getExpiration(),
|
||||||
|
messageDeletionAsyncExecutor);
|
||||||
RemoteConfigs remoteConfigs = new RemoteConfigs(dynamoDbClient,
|
RemoteConfigs remoteConfigs = new RemoteConfigs(dynamoDbClient,
|
||||||
config.getDynamoDbTables().getRemoteConfig().getTableName());
|
config.getDynamoDbTables().getRemoteConfig().getTableName());
|
||||||
PushChallengeDynamoDb pushChallengeDynamoDb = new PushChallengeDynamoDb(dynamoDbClient,
|
PushChallengeDynamoDb pushChallengeDynamoDb = new PushChallengeDynamoDb(dynamoDbClient,
|
||||||
|
@ -453,7 +460,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||||
StoredVerificationCodeManager pendingDevicesManager = new StoredVerificationCodeManager(pendingDevices);
|
StoredVerificationCodeManager pendingDevicesManager = new StoredVerificationCodeManager(pendingDevices);
|
||||||
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
||||||
MessagesCache messagesCache = new MessagesCache(messagesCluster, messagesCluster, keyspaceNotificationDispatchExecutor);
|
MessagesCache messagesCache = new MessagesCache(messagesCluster, messagesCluster, Clock.systemUTC(),
|
||||||
|
keyspaceNotificationDispatchExecutor, messageDeletionAsyncExecutor);
|
||||||
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
|
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
|
||||||
ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster, config.getReportMessageConfiguration().getCounterTtl());
|
ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster, config.getReportMessageConfiguration().getCounterTtl());
|
||||||
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager);
|
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager);
|
||||||
|
@ -503,8 +511,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager, pushChallengeDynamoDb);
|
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager, pushChallengeDynamoDb);
|
||||||
RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
|
RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
|
||||||
recaptchaClient, dynamicRateLimiters);
|
recaptchaClient, dynamicRateLimiters);
|
||||||
RateLimitChallengeOptionManager rateLimitChallengeOptionManager =
|
|
||||||
new RateLimitChallengeOptionManager(dynamicRateLimiters, dynamicConfigurationManager);
|
|
||||||
|
|
||||||
MessagePersister messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager, dynamicConfigurationManager, Duration.ofMinutes(config.getMessageCacheConfiguration().getPersistDelayMinutes()));
|
MessagePersister messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager, dynamicConfigurationManager, Duration.ofMinutes(config.getMessageCacheConfiguration().getPersistDelayMinutes()));
|
||||||
ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager);
|
ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager);
|
||||||
|
@ -628,8 +634,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator));
|
webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator));
|
||||||
webSocketEnvironment.setConnectListener(
|
webSocketEnvironment.setConnectListener(
|
||||||
new AuthenticatedConnectListener(receiptSender, messagesManager, pushNotificationManager,
|
new AuthenticatedConnectListener(receiptSender, messagesManager, pushNotificationManager,
|
||||||
clientPresenceManager, websocketScheduledExecutor));
|
clientPresenceManager, websocketScheduledExecutor, experimentEnrollmentManager));
|
||||||
webSocketEnvironment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager));
|
webSocketEnvironment.jersey()
|
||||||
|
.register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager));
|
||||||
webSocketEnvironment.jersey().register(new ContentLengthFilter(TrafficSource.WEBSOCKET));
|
webSocketEnvironment.jersey().register(new ContentLengthFilter(TrafficSource.WEBSOCKET));
|
||||||
webSocketEnvironment.jersey().register(MultiRecipientMessageProvider.class);
|
webSocketEnvironment.jersey().register(MultiRecipientMessageProvider.class);
|
||||||
webSocketEnvironment.jersey().register(new MetricsApplicationEventListener(TrafficSource.WEBSOCKET));
|
webSocketEnvironment.jersey().register(new MetricsApplicationEventListener(TrafficSource.WEBSOCKET));
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright 2013-2021 Signal Messenger, LLC
|
* Copyright 2013-2022 Signal Messenger, LLC
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
package org.whispersystems.textsecuregcm.controllers;
|
package org.whispersystems.textsecuregcm.controllers;
|
||||||
|
@ -30,6 +30,7 @@ import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
@ -538,14 +539,17 @@ public class MessageController {
|
||||||
@Timed
|
@Timed
|
||||||
@DELETE
|
@DELETE
|
||||||
@Path("/uuid/{uuid}")
|
@Path("/uuid/{uuid}")
|
||||||
public void removePendingMessage(@Auth AuthenticatedAccount auth, @PathParam("uuid") UUID uuid) {
|
public CompletableFuture<Void> removePendingMessage(@Auth AuthenticatedAccount auth, @PathParam("uuid") UUID uuid) {
|
||||||
messagesManager.delete(
|
return messagesManager.delete(
|
||||||
auth.getAccount().getUuid(),
|
auth.getAccount().getUuid(),
|
||||||
auth.getAuthenticatedDevice().getId(),
|
auth.getAuthenticatedDevice().getId(),
|
||||||
uuid,
|
uuid,
|
||||||
null).ifPresent(deletedMessage -> {
|
null)
|
||||||
|
.thenAccept(maybeDeletedMessage -> {
|
||||||
|
maybeDeletedMessage.ifPresent(deletedMessage -> {
|
||||||
|
|
||||||
WebSocketConnection.recordMessageDeliveryDuration(deletedMessage.getTimestamp(), auth.getAuthenticatedDevice());
|
WebSocketConnection.recordMessageDeliveryDuration(deletedMessage.getTimestamp(),
|
||||||
|
auth.getAuthenticatedDevice());
|
||||||
|
|
||||||
if (deletedMessage.hasSourceUuid() && deletedMessage.getType() != Type.SERVER_DELIVERY_RECEIPT) {
|
if (deletedMessage.hasSourceUuid() && deletedMessage.getType() != Type.SERVER_DELIVERY_RECEIPT) {
|
||||||
try {
|
try {
|
||||||
|
@ -557,6 +561,7 @@ public class MessageController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Timed
|
@Timed
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
package org.whispersystems.textsecuregcm.redis;
|
package org.whispersystems.textsecuregcm.redis;
|
||||||
|
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
|
import io.lettuce.core.RedisException;
|
||||||
import io.lettuce.core.RedisNoScriptException;
|
import io.lettuce.core.RedisNoScriptException;
|
||||||
import io.lettuce.core.ScriptOutputType;
|
import io.lettuce.core.ScriptOutputType;
|
||||||
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
|
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
|
||||||
|
@ -15,9 +16,12 @@ import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import org.apache.commons.codec.binary.Hex;
|
import org.apache.commons.codec.binary.Hex;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
public class ClusterLuaScript {
|
public class ClusterLuaScript {
|
||||||
|
|
||||||
|
@ -73,11 +77,31 @@ public class ClusterLuaScript {
|
||||||
execute(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY)));
|
execute(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Object> executeAsync(final List<String> keys, final List<String> args) {
|
||||||
|
return redisCluster.withCluster(connection ->
|
||||||
|
executeAsync(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<Object> executeReactive(final List<String> keys, final List<String> args) {
|
||||||
|
return redisCluster.withCluster(connection ->
|
||||||
|
executeReactive(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY)));
|
||||||
|
}
|
||||||
|
|
||||||
public Object executeBinary(final List<byte[]> keys, final List<byte[]> args) {
|
public Object executeBinary(final List<byte[]> keys, final List<byte[]> args) {
|
||||||
return redisCluster.withBinaryCluster(connection ->
|
return redisCluster.withBinaryCluster(connection ->
|
||||||
execute(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY)));
|
execute(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Object> executeBinaryAsync(final List<byte[]> keys, final List<byte[]> args) {
|
||||||
|
return redisCluster.withBinaryCluster(connection ->
|
||||||
|
executeAsync(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<Object> executeBinaryReactive(final List<byte[]> keys, final List<byte[]> args) {
|
||||||
|
return redisCluster.withBinaryCluster(connection ->
|
||||||
|
executeReactive(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY)));
|
||||||
|
}
|
||||||
|
|
||||||
private <T> Object execute(final StatefulRedisClusterConnection<T, T> connection, final T[] keys, final T[] args) {
|
private <T> Object execute(final StatefulRedisClusterConnection<T, T> connection, final T[] keys, final T[] args) {
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
|
@ -90,4 +114,32 @@ public class ClusterLuaScript {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private <T> CompletableFuture<Object> executeAsync(final StatefulRedisClusterConnection<T, T> connection,
|
||||||
|
final T[] keys, final T[] args) {
|
||||||
|
|
||||||
|
return connection.async().evalsha(sha, scriptOutputType, keys, args)
|
||||||
|
.exceptionallyCompose(throwable -> {
|
||||||
|
if (throwable instanceof RedisNoScriptException) {
|
||||||
|
return connection.async().eval(script, scriptOutputType, keys, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("Failed to execute script", throwable);
|
||||||
|
throw new RedisException(throwable);
|
||||||
|
}).toCompletableFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> Flux<Object> executeReactive(final StatefulRedisClusterConnection<T, T> connection,
|
||||||
|
final T[] keys, final T[] args) {
|
||||||
|
|
||||||
|
return connection.reactive().evalsha(sha, scriptOutputType, keys, args)
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
if (e instanceof RedisNoScriptException) {
|
||||||
|
return connection.reactive().eval(script, scriptOutputType, keys, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("Failed to execute script", e);
|
||||||
|
return Mono.error(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,8 @@ package org.whispersystems.textsecuregcm.redis;
|
||||||
import com.codahale.metrics.SharedMetricRegistries;
|
import com.codahale.metrics.SharedMetricRegistries;
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||||
|
import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator;
|
||||||
|
import io.github.resilience4j.reactor.retry.RetryOperator;
|
||||||
import io.github.resilience4j.retry.Retry;
|
import io.github.resilience4j.retry.Retry;
|
||||||
import io.lettuce.core.ClientOptions.DisconnectedBehavior;
|
import io.lettuce.core.ClientOptions.DisconnectedBehavior;
|
||||||
import io.lettuce.core.RedisCommandTimeoutException;
|
import io.lettuce.core.RedisCommandTimeoutException;
|
||||||
|
@ -24,11 +26,13 @@ import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.util.CircuitBreakerUtil;
|
import org.whispersystems.textsecuregcm.util.CircuitBreakerUtil;
|
||||||
import org.whispersystems.textsecuregcm.util.Constants;
|
import org.whispersystems.textsecuregcm.util.Constants;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fault-tolerant access manager for a Redis cluster. A fault-tolerant Redis cluster provides managed,
|
* A fault-tolerant access manager for a Redis cluster. A fault-tolerant Redis cluster provides managed,
|
||||||
|
@ -111,7 +115,13 @@ public class FaultTolerantRedisCluster {
|
||||||
return withConnection(binaryConnection, function);
|
return withConnection(binaryConnection, function);
|
||||||
}
|
}
|
||||||
|
|
||||||
private <K, V> void useConnection(final StatefulRedisClusterConnection<K, V> connection, final Consumer<StatefulRedisClusterConnection<K, V>> consumer) {
|
public <T> Publisher<T> withBinaryClusterReactive(
|
||||||
|
final Function<StatefulRedisClusterConnection<byte[], byte[]>, Publisher<T>> function) {
|
||||||
|
return withConnectionReactive(binaryConnection, function);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <K, V> void useConnection(final StatefulRedisClusterConnection<K, V> connection,
|
||||||
|
final Consumer<StatefulRedisClusterConnection<K, V>> consumer) {
|
||||||
try {
|
try {
|
||||||
circuitBreaker.executeCheckedRunnable(() -> retry.executeRunnable(() -> consumer.accept(connection)));
|
circuitBreaker.executeCheckedRunnable(() -> retry.executeRunnable(() -> consumer.accept(connection)));
|
||||||
} catch (final Throwable t) {
|
} catch (final Throwable t) {
|
||||||
|
@ -123,7 +133,8 @@ public class FaultTolerantRedisCluster {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private <T, K, V> T withConnection(final StatefulRedisClusterConnection<K, V> connection, final Function<StatefulRedisClusterConnection<K, V>, T> function) {
|
private <T, K, V> T withConnection(final StatefulRedisClusterConnection<K, V> connection,
|
||||||
|
final Function<StatefulRedisClusterConnection<K, V>, T> function) {
|
||||||
try {
|
try {
|
||||||
return circuitBreaker.executeCheckedSupplier(() -> retry.executeCallable(() -> function.apply(connection)));
|
return circuitBreaker.executeCheckedSupplier(() -> retry.executeCallable(() -> function.apply(connection)));
|
||||||
} catch (final Throwable t) {
|
} catch (final Throwable t) {
|
||||||
|
@ -135,6 +146,14 @@ public class FaultTolerantRedisCluster {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private <T, K, V> Publisher<T> withConnectionReactive(final StatefulRedisClusterConnection<K, V> connection,
|
||||||
|
final Function<StatefulRedisClusterConnection<K, V>, Publisher<T>> function) {
|
||||||
|
|
||||||
|
return Flux.from(function.apply(connection))
|
||||||
|
.transformDeferred(RetryOperator.of(retry))
|
||||||
|
.transformDeferred(CircuitBreakerOperator.of(circuitBreaker));
|
||||||
|
}
|
||||||
|
|
||||||
public FaultTolerantPubSubConnection<String, String> createPubSubConnection() {
|
public FaultTolerantPubSubConnection<String, String> createPubSubConnection() {
|
||||||
final StatefulRedisClusterPubSubConnection<String, String> pubSubConnection = clusterClient.connectPubSub();
|
final StatefulRedisClusterPubSubConnection<String, String> pubSubConnection = clusterClient.connectPubSub();
|
||||||
pubSubConnections.add(pubSubConnection);
|
pubSubConnections.add(pubSubConnection);
|
||||||
|
|
|
@ -26,7 +26,7 @@ import software.amazon.awssdk.services.dynamodb.model.BatchWriteItemResponse;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
|
import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.WriteRequest;
|
import software.amazon.awssdk.services.dynamodb.model.WriteRequest;
|
||||||
|
|
||||||
public class AbstractDynamoDbStore {
|
public abstract class AbstractDynamoDbStore {
|
||||||
|
|
||||||
private final DynamoDbClient dynamoDbClient;
|
private final DynamoDbClient dynamoDbClient;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright 2013-2021 Signal Messenger, LLC
|
* Copyright 2013-2022 Signal Messenger, LLC
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -22,6 +22,7 @@ import io.micrometer.core.instrument.Metrics;
|
||||||
import io.micrometer.core.instrument.Timer;
|
import io.micrometer.core.instrument.Timer;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
@ -34,23 +35,32 @@ import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.function.Predicate;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||||
import org.whispersystems.textsecuregcm.redis.ClusterLuaScript;
|
import org.whispersystems.textsecuregcm.redis.ClusterLuaScript;
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubConnection;
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubConnection;
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||||
|
import org.whispersystems.textsecuregcm.util.Pair;
|
||||||
import org.whispersystems.textsecuregcm.util.RedisClusterUtil;
|
import org.whispersystems.textsecuregcm.util.RedisClusterUtil;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
public class MessagesCache extends RedisClusterPubSubAdapter<String, String> implements Managed {
|
public class MessagesCache extends RedisClusterPubSubAdapter<String, String> implements Managed {
|
||||||
|
|
||||||
private final FaultTolerantRedisCluster readDeleteCluster;
|
private final FaultTolerantRedisCluster readDeleteCluster;
|
||||||
private final FaultTolerantPubSubConnection<String, String> pubSubConnection;
|
private final FaultTolerantPubSubConnection<String, String> pubSubConnection;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
private final ExecutorService notificationExecutorService;
|
private final ExecutorService notificationExecutorService;
|
||||||
|
private final ExecutorService messageDeletionExecutorService;
|
||||||
|
|
||||||
private final ClusterLuaScript insertScript;
|
private final ClusterLuaScript insertScript;
|
||||||
private final ClusterLuaScript removeByGuidScript;
|
private final ClusterLuaScript removeByGuidScript;
|
||||||
|
@ -79,22 +89,23 @@ public class MessagesCache extends RedisClusterPubSubAdapter<String, String> imp
|
||||||
private static final String QUEUE_KEYSPACE_PREFIX = "__keyspace@0__:user_queue::";
|
private static final String QUEUE_KEYSPACE_PREFIX = "__keyspace@0__:user_queue::";
|
||||||
private static final String PERSISTING_KEYSPACE_PREFIX = "__keyspace@0__:user_queue_persisting::";
|
private static final String PERSISTING_KEYSPACE_PREFIX = "__keyspace@0__:user_queue_persisting::";
|
||||||
|
|
||||||
private static final Duration MAX_EPHEMERAL_MESSAGE_DELAY = Duration.ofSeconds(10);
|
@VisibleForTesting
|
||||||
|
static final Duration MAX_EPHEMERAL_MESSAGE_DELAY = Duration.ofSeconds(10);
|
||||||
|
|
||||||
private static final String REMOVE_TIMER_NAME = name(MessagesCache.class, "remove");
|
private static final int PAGE_SIZE = 100;
|
||||||
|
|
||||||
private static final String REMOVE_METHOD_TAG = "method";
|
|
||||||
private static final String REMOVE_METHOD_UUID = "uuid";
|
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(MessagesCache.class);
|
private static final Logger logger = LoggerFactory.getLogger(MessagesCache.class);
|
||||||
|
|
||||||
public MessagesCache(final FaultTolerantRedisCluster insertCluster, final FaultTolerantRedisCluster readDeleteCluster,
|
public MessagesCache(final FaultTolerantRedisCluster insertCluster, final FaultTolerantRedisCluster readDeleteCluster,
|
||||||
final ExecutorService notificationExecutorService) throws IOException {
|
final Clock clock, final ExecutorService notificationExecutorService,
|
||||||
|
final ExecutorService messageDeletionExecutorService) throws IOException {
|
||||||
|
|
||||||
this.readDeleteCluster = readDeleteCluster;
|
this.readDeleteCluster = readDeleteCluster;
|
||||||
this.pubSubConnection = readDeleteCluster.createPubSubConnection();
|
this.pubSubConnection = readDeleteCluster.createPubSubConnection();
|
||||||
|
this.clock = clock;
|
||||||
|
|
||||||
this.notificationExecutorService = notificationExecutorService;
|
this.notificationExecutorService = notificationExecutorService;
|
||||||
|
this.messageDeletionExecutorService = messageDeletionExecutorService;
|
||||||
|
|
||||||
this.insertScript = ClusterLuaScript.fromResource(insertCluster, "lua/insert_item.lua", ScriptOutputType.INTEGER);
|
this.insertScript = ClusterLuaScript.fromResource(insertCluster, "lua/insert_item.lua", ScriptOutputType.INTEGER);
|
||||||
this.removeByGuidScript = ClusterLuaScript.fromResource(readDeleteCluster, "lua/remove_item_by_guid.lua",
|
this.removeByGuidScript = ClusterLuaScript.fromResource(readDeleteCluster, "lua/remove_item_by_guid.lua",
|
||||||
|
@ -147,21 +158,26 @@ public class MessagesCache extends RedisClusterPubSubAdapter<String, String> imp
|
||||||
guid.toString().getBytes(StandardCharsets.UTF_8))));
|
guid.toString().getBytes(StandardCharsets.UTF_8))));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<MessageProtos.Envelope> remove(final UUID destinationUuid, final long destinationDevice,
|
public CompletableFuture<Optional<MessageProtos.Envelope>> remove(final UUID destinationUuid,
|
||||||
|
final long destinationDevice,
|
||||||
final UUID messageGuid) {
|
final UUID messageGuid) {
|
||||||
return remove(destinationUuid, destinationDevice, List.of(messageGuid)).stream().findFirst();
|
|
||||||
|
return remove(destinationUuid, destinationDevice, List.of(messageGuid))
|
||||||
|
.thenApply(removed -> removed.isEmpty() ? Optional.empty() : Optional.of(removed.get(0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public List<MessageProtos.Envelope> remove(final UUID destinationUuid, final long destinationDevice,
|
public CompletableFuture<List<MessageProtos.Envelope>> remove(final UUID destinationUuid,
|
||||||
|
final long destinationDevice,
|
||||||
final List<UUID> messageGuids) {
|
final List<UUID> messageGuids) {
|
||||||
final List<byte[]> serialized = (List<byte[]>) Metrics.timer(REMOVE_TIMER_NAME, REMOVE_METHOD_TAG,
|
|
||||||
REMOVE_METHOD_UUID).record(() ->
|
return removeByGuidScript.executeBinaryAsync(List.of(getMessageQueueKey(destinationUuid, destinationDevice),
|
||||||
removeByGuidScript.executeBinary(List.of(getMessageQueueKey(destinationUuid, destinationDevice),
|
|
||||||
getMessageQueueMetadataKey(destinationUuid, destinationDevice),
|
getMessageQueueMetadataKey(destinationUuid, destinationDevice),
|
||||||
getQueueIndexKey(destinationUuid, destinationDevice)),
|
getQueueIndexKey(destinationUuid, destinationDevice)),
|
||||||
messageGuids.stream().map(guid -> guid.toString().getBytes(StandardCharsets.UTF_8))
|
messageGuids.stream().map(guid -> guid.toString().getBytes(StandardCharsets.UTF_8))
|
||||||
.collect(Collectors.toList())));
|
.collect(Collectors.toList()))
|
||||||
|
.thenApplyAsync(result -> {
|
||||||
|
List<byte[]> serialized = (List<byte[]>) result;
|
||||||
|
|
||||||
final List<MessageProtos.Envelope> removedMessages = new ArrayList<>(serialized.size());
|
final List<MessageProtos.Envelope> removedMessages = new ArrayList<>(serialized.size());
|
||||||
|
|
||||||
|
@ -174,6 +190,7 @@ public class MessagesCache extends RedisClusterPubSubAdapter<String, String> imp
|
||||||
}
|
}
|
||||||
|
|
||||||
return removedMessages;
|
return removedMessages;
|
||||||
|
}, messageDeletionExecutorService);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasMessages(final UUID destinationUuid, final long destinationDevice) {
|
public boolean hasMessages(final UUID destinationUuid, final long destinationDevice) {
|
||||||
|
@ -181,49 +198,109 @@ public class MessagesCache extends RedisClusterPubSubAdapter<String, String> imp
|
||||||
connection -> connection.sync().zcard(getMessageQueueKey(destinationUuid, destinationDevice)) > 0);
|
connection -> connection.sync().zcard(getMessageQueueKey(destinationUuid, destinationDevice)) > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
public Publisher<MessageProtos.Envelope> get(final UUID destinationUuid, final long destinationDevice) {
|
||||||
public List<MessageProtos.Envelope> get(final UUID destinationUuid, final long destinationDevice, final int limit) {
|
|
||||||
return getMessagesTimer.record(() -> {
|
|
||||||
final List<byte[]> queueItems = (List<byte[]>) getItemsScript.executeBinary(
|
|
||||||
List.of(getMessageQueueKey(destinationUuid, destinationDevice),
|
|
||||||
getPersistInProgressKey(destinationUuid, destinationDevice)),
|
|
||||||
List.of(String.valueOf(limit).getBytes(StandardCharsets.UTF_8)));
|
|
||||||
|
|
||||||
final long earliestAllowableEphemeralTimestamp =
|
final long earliestAllowableEphemeralTimestamp =
|
||||||
System.currentTimeMillis() - MAX_EPHEMERAL_MESSAGE_DELAY.toMillis();
|
clock.millis() - MAX_EPHEMERAL_MESSAGE_DELAY.toMillis();
|
||||||
|
|
||||||
final List<MessageProtos.Envelope> messageEntities;
|
final Flux<MessageProtos.Envelope> allMessages = getAllMessages(destinationUuid, destinationDevice)
|
||||||
final List<UUID> staleEphemeralMessageGuids = new ArrayList<>();
|
.publish()
|
||||||
|
// We expect exactly two subscribers to this base flux:
|
||||||
|
// 1. the websocket that delivers messages to clients
|
||||||
|
// 2. an internal process to discard stale ephemeral messages
|
||||||
|
// The discard subscriber will subscribe immediately, but we don’t want to do any work if the
|
||||||
|
// websocket never subscribes.
|
||||||
|
.autoConnect(2);
|
||||||
|
|
||||||
if (queueItems.size() % 2 == 0) {
|
final Flux<MessageProtos.Envelope> messagesToPublish = allMessages
|
||||||
messageEntities = new ArrayList<>(queueItems.size() / 2);
|
.filter(Predicate.not(envelope -> isStaleEphemeralMessage(envelope, earliestAllowableEphemeralTimestamp)));
|
||||||
|
|
||||||
|
final Flux<MessageProtos.Envelope> staleEphemeralMessages = allMessages
|
||||||
|
.filter(envelope -> isStaleEphemeralMessage(envelope, earliestAllowableEphemeralTimestamp));
|
||||||
|
|
||||||
|
discardStaleEphemeralMessages(destinationUuid, destinationDevice, staleEphemeralMessages);
|
||||||
|
|
||||||
|
return messagesToPublish;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isStaleEphemeralMessage(final MessageProtos.Envelope message,
|
||||||
|
long earliestAllowableTimestamp) {
|
||||||
|
return message.hasEphemeral() && message.getEphemeral() && message.getTimestamp() < earliestAllowableTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void discardStaleEphemeralMessages(final UUID destinationUuid, final long destinationDevice,
|
||||||
|
Flux<MessageProtos.Envelope> staleEphemeralMessages) {
|
||||||
|
staleEphemeralMessages
|
||||||
|
.map(e -> UUID.fromString(e.getServerGuid()))
|
||||||
|
.buffer(PAGE_SIZE)
|
||||||
|
.subscribeOn(Schedulers.boundedElastic())
|
||||||
|
.subscribe(staleEphemeralMessageGuids ->
|
||||||
|
remove(destinationUuid, destinationDevice, staleEphemeralMessageGuids)
|
||||||
|
.thenAccept(removedMessages -> staleEphemeralMessagesCounter.increment(removedMessages.size())),
|
||||||
|
e -> logger.warn("Could not remove stale ephemeral messages from cache", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
Flux<MessageProtos.Envelope> getAllMessages(final UUID destinationUuid, final long destinationDevice) {
|
||||||
|
|
||||||
|
// fetch messages by page
|
||||||
|
return getNextMessagePage(destinationUuid, destinationDevice, -1)
|
||||||
|
.expand(queueItemsAndLastMessageId -> {
|
||||||
|
// expand() is breadth-first, so each page will be published in order
|
||||||
|
if (queueItemsAndLastMessageId.first().isEmpty()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return getNextMessagePage(destinationUuid, destinationDevice, queueItemsAndLastMessageId.second());
|
||||||
|
})
|
||||||
|
.limitRate(1)
|
||||||
|
// we want to ensure we don’t accidentally block the Lettuce/netty i/o executors
|
||||||
|
.publishOn(Schedulers.boundedElastic())
|
||||||
|
.map(Pair::first)
|
||||||
|
.flatMapIterable(queueItems -> {
|
||||||
|
final List<MessageProtos.Envelope> envelopes = new ArrayList<>(queueItems.size() / 2);
|
||||||
|
|
||||||
for (int i = 0; i < queueItems.size() - 1; i += 2) {
|
for (int i = 0; i < queueItems.size() - 1; i += 2) {
|
||||||
try {
|
try {
|
||||||
final MessageProtos.Envelope message = MessageProtos.Envelope.parseFrom(queueItems.get(i));
|
final MessageProtos.Envelope message = MessageProtos.Envelope.parseFrom(queueItems.get(i));
|
||||||
if (message.getEphemeral() && message.getTimestamp() < earliestAllowableEphemeralTimestamp) {
|
|
||||||
staleEphemeralMessageGuids.add(UUID.fromString(message.getServerGuid()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
messageEntities.add(message);
|
envelopes.add(message);
|
||||||
} catch (InvalidProtocolBufferException e) {
|
} catch (InvalidProtocolBufferException e) {
|
||||||
logger.warn("Failed to parse envelope", e);
|
logger.warn("Failed to parse envelope", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
return envelopes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Flux<Pair<List<byte[]>, Long>> getNextMessagePage(final UUID destinationUuid, final long destinationDevice,
|
||||||
|
long messageId) {
|
||||||
|
|
||||||
|
return getItemsScript.executeBinaryReactive(
|
||||||
|
List.of(getMessageQueueKey(destinationUuid, destinationDevice),
|
||||||
|
getPersistInProgressKey(destinationUuid, destinationDevice)),
|
||||||
|
List.of(String.valueOf(PAGE_SIZE).getBytes(StandardCharsets.UTF_8),
|
||||||
|
String.valueOf(messageId).getBytes(StandardCharsets.UTF_8)))
|
||||||
|
.map(result -> {
|
||||||
|
logger.trace("Processing page: {}", messageId);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<byte[]> queueItems = (List<byte[]>) result;
|
||||||
|
|
||||||
|
if (queueItems.isEmpty()) {
|
||||||
|
return new Pair<>(Collections.emptyList(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queueItems.size() % 2 != 0) {
|
||||||
logger.error("\"Get messages\" operation returned a list with a non-even number of elements.");
|
logger.error("\"Get messages\" operation returned a list with a non-even number of elements.");
|
||||||
messageEntities = Collections.emptyList();
|
return new Pair<>(Collections.emptyList(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
final long lastMessageId = Long.parseLong(
|
||||||
remove(destinationUuid, destinationDevice, staleEphemeralMessageGuids);
|
new String(queueItems.get(queueItems.size() - 1), StandardCharsets.UTF_8));
|
||||||
staleEphemeralMessagesCounter.increment(staleEphemeralMessageGuids.size());
|
|
||||||
} catch (final Throwable e) {
|
|
||||||
logger.warn("Could not remove stale ephemeral messages from cache", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageEntities;
|
return new Pair<>(queueItems, lastMessageId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright 2021 Signal Messenger, LLC
|
* Copyright 2021-2022 Signal Messenger, LLC
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -17,19 +17,24 @@ import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import javax.annotation.Nonnull;
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||||
import org.whispersystems.textsecuregcm.util.AttributeValues;
|
import org.whispersystems.textsecuregcm.util.AttributeValues;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
import software.amazon.awssdk.core.SdkBytes;
|
import software.amazon.awssdk.core.SdkBytes;
|
||||||
|
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
|
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.DeleteItemResponse;
|
|
||||||
import software.amazon.awssdk.services.dynamodb.model.DeleteRequest;
|
import software.amazon.awssdk.services.dynamodb.model.DeleteRequest;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.PutRequest;
|
import software.amazon.awssdk.services.dynamodb.model.PutRequest;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
|
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
|
||||||
|
@ -48,22 +53,25 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
|
||||||
private static final String KEY_ENVELOPE_BYTES = "EB";
|
private static final String KEY_ENVELOPE_BYTES = "EB";
|
||||||
|
|
||||||
private final Timer storeTimer = timer(name(getClass(), "store"));
|
private final Timer storeTimer = timer(name(getClass(), "store"));
|
||||||
private final Timer loadTimer = timer(name(getClass(), "load"));
|
|
||||||
private final Timer deleteByGuid = timer(name(getClass(), "delete", "guid"));
|
|
||||||
private final Timer deleteByKey = timer(name(getClass(), "delete", "key"));
|
|
||||||
private final Timer deleteByAccount = timer(name(getClass(), "delete", "account"));
|
private final Timer deleteByAccount = timer(name(getClass(), "delete", "account"));
|
||||||
private final Timer deleteByDevice = timer(name(getClass(), "delete", "device"));
|
private final Timer deleteByDevice = timer(name(getClass(), "delete", "device"));
|
||||||
|
|
||||||
|
private final DynamoDbAsyncClient dbAsyncClient;
|
||||||
private final String tableName;
|
private final String tableName;
|
||||||
private final Duration timeToLive;
|
private final Duration timeToLive;
|
||||||
|
private final ExecutorService messageDeletionExecutor;
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(MessagesDynamoDb.class);
|
private static final Logger logger = LoggerFactory.getLogger(MessagesDynamoDb.class);
|
||||||
|
|
||||||
public MessagesDynamoDb(DynamoDbClient dynamoDb, String tableName, Duration timeToLive) {
|
public MessagesDynamoDb(DynamoDbClient dynamoDb, DynamoDbAsyncClient dynamoDbAsyncClient, String tableName,
|
||||||
|
Duration timeToLive, ExecutorService messageDeletionExecutor) {
|
||||||
super(dynamoDb);
|
super(dynamoDb);
|
||||||
|
|
||||||
|
this.dbAsyncClient = dynamoDbAsyncClient;
|
||||||
this.tableName = tableName;
|
this.tableName = tableName;
|
||||||
this.timeToLive = timeToLive;
|
this.timeToLive = timeToLive;
|
||||||
|
|
||||||
|
this.messageDeletionExecutor = messageDeletionExecutor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void store(final List<MessageProtos.Envelope> messages, final UUID destinationAccountUuid, final long destinationDeviceId) {
|
public void store(final List<MessageProtos.Envelope> messages, final UUID destinationAccountUuid, final long destinationDeviceId) {
|
||||||
|
@ -95,11 +103,11 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
|
||||||
executeTableWriteItemsUntilComplete(Map.of(tableName, writeItems));
|
executeTableWriteItemsUntilComplete(Map.of(tableName, writeItems));
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<MessageProtos.Envelope> load(final UUID destinationAccountUuid, final long destinationDeviceId, final int requestedNumberOfMessagesToFetch) {
|
public Publisher<MessageProtos.Envelope> load(final UUID destinationAccountUuid, final long destinationDeviceId,
|
||||||
return loadTimer.record(() -> {
|
final Integer limit) {
|
||||||
final int numberOfMessagesToFetch = Math.min(requestedNumberOfMessagesToFetch, RESULT_SET_CHUNK_SIZE);
|
|
||||||
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
||||||
final QueryRequest queryRequest = QueryRequest.builder()
|
final QueryRequest.Builder queryRequestBuilder = QueryRequest.builder()
|
||||||
.tableName(tableName)
|
.tableName(tableName)
|
||||||
.consistentRead(true)
|
.consistentRead(true)
|
||||||
.keyConditionExpression("#part = :part AND begins_with ( #sort , :sortprefix )")
|
.keyConditionExpression("#part = :part AND begins_with ( #sort , :sortprefix )")
|
||||||
|
@ -108,30 +116,31 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
|
||||||
"#sort", KEY_SORT))
|
"#sort", KEY_SORT))
|
||||||
.expressionAttributeValues(Map.of(
|
.expressionAttributeValues(Map.of(
|
||||||
":part", partitionKey,
|
":part", partitionKey,
|
||||||
":sortprefix", convertDestinationDeviceIdToSortKeyPrefix(destinationDeviceId)))
|
":sortprefix", convertDestinationDeviceIdToSortKeyPrefix(destinationDeviceId)));
|
||||||
.limit(numberOfMessagesToFetch)
|
|
||||||
.build();
|
if (limit != null) {
|
||||||
List<MessageProtos.Envelope> messageEntities = new ArrayList<>(numberOfMessagesToFetch);
|
// some callers don’t take advantage of reactive streams, so we want to support limiting the fetch size. Otherwise,
|
||||||
for (Map<String, AttributeValue> message : db().queryPaginator(queryRequest).items()) {
|
// we could fetch up to 1 MB (likely >1,000 messages) and discard 90% of them
|
||||||
|
queryRequestBuilder.limit(Math.min(RESULT_SET_CHUNK_SIZE, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
final QueryRequest queryRequest = queryRequestBuilder.build();
|
||||||
|
|
||||||
|
return dbAsyncClient.queryPaginator(queryRequest).items()
|
||||||
|
.map(message -> {
|
||||||
try {
|
try {
|
||||||
messageEntities.add(convertItemToEnvelope(message));
|
return convertItemToEnvelope(message);
|
||||||
} catch (final InvalidProtocolBufferException e) {
|
} catch (final InvalidProtocolBufferException e) {
|
||||||
logger.error("Failed to parse envelope", e);
|
logger.error("Failed to parse envelope", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Predicate.not(Objects::isNull));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageEntities.size() == numberOfMessagesToFetch) {
|
public CompletableFuture<Optional<MessageProtos.Envelope>> deleteMessageByDestinationAndGuid(
|
||||||
// queryPaginator() uses limit() as the page size, not as an absolute limit
|
final UUID destinationAccountUuid, final UUID messageUuid) {
|
||||||
// …but a page might be smaller than limit, because a page is capped at 1 MB
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return messageEntities;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<MessageProtos.Envelope> deleteMessageByDestinationAndGuid(final UUID destinationAccountUuid,
|
|
||||||
final UUID messageUuid) {
|
|
||||||
return deleteByGuid.record(() -> {
|
|
||||||
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
||||||
final QueryRequest queryRequest = QueryRequest.builder()
|
final QueryRequest queryRequest = QueryRequest.builder()
|
||||||
.tableName(tableName)
|
.tableName(tableName)
|
||||||
|
@ -146,54 +155,53 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
|
||||||
":part", partitionKey,
|
":part", partitionKey,
|
||||||
":uuid", convertLocalIndexMessageUuidSortKey(messageUuid)))
|
":uuid", convertLocalIndexMessageUuidSortKey(messageUuid)))
|
||||||
.build();
|
.build();
|
||||||
return deleteItemsMatchingQueryAndReturnFirstOneActuallyDeleted(partitionKey, queryRequest);
|
|
||||||
});
|
// because we are filtering on message UUID, this query should return at most one item,
|
||||||
|
// but it’s simpler to handle the full stream and return the “last” item
|
||||||
|
return Flux.from(dbAsyncClient.queryPaginator(queryRequest).items())
|
||||||
|
.flatMap(item -> Mono.fromCompletionStage(dbAsyncClient.deleteItem(DeleteItemRequest.builder()
|
||||||
|
.tableName(tableName)
|
||||||
|
.key(Map.of(KEY_PARTITION, partitionKey, KEY_SORT,
|
||||||
|
AttributeValues.fromByteArray(item.get(KEY_SORT).b().asByteArray())))
|
||||||
|
.returnValues(ReturnValue.ALL_OLD)
|
||||||
|
.build())))
|
||||||
|
.mapNotNull(deleteItemResponse -> {
|
||||||
|
try {
|
||||||
|
if (deleteItemResponse.attributes() != null && deleteItemResponse.attributes().containsKey(KEY_PARTITION)) {
|
||||||
|
return convertItemToEnvelope(deleteItemResponse.attributes());
|
||||||
|
}
|
||||||
|
} catch (final InvalidProtocolBufferException e) {
|
||||||
|
logger.error("Failed to parse envelope", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.last()
|
||||||
|
.toFuture()
|
||||||
|
.thenApply(Optional::ofNullable);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<MessageProtos.Envelope> deleteMessage(final UUID destinationAccountUuid,
|
public CompletableFuture<Optional<MessageProtos.Envelope>> deleteMessage(final UUID destinationAccountUuid,
|
||||||
final long destinationDeviceId, final UUID messageUuid, final long serverTimestamp) {
|
final long destinationDeviceId, final UUID messageUuid, final long serverTimestamp) {
|
||||||
return deleteByKey.record(() -> {
|
|
||||||
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
||||||
final AttributeValue sortKey = convertSortKey(destinationDeviceId, serverTimestamp, messageUuid);
|
final AttributeValue sortKey = convertSortKey(destinationDeviceId, serverTimestamp, messageUuid);
|
||||||
DeleteItemRequest.Builder deleteItemRequest = DeleteItemRequest.builder()
|
DeleteItemRequest.Builder deleteItemRequest = DeleteItemRequest.builder()
|
||||||
.tableName(tableName)
|
.tableName(tableName)
|
||||||
.key(Map.of(KEY_PARTITION, partitionKey, KEY_SORT, sortKey))
|
.key(Map.of(KEY_PARTITION, partitionKey, KEY_SORT, sortKey))
|
||||||
.returnValues(ReturnValue.ALL_OLD);
|
.returnValues(ReturnValue.ALL_OLD);
|
||||||
final DeleteItemResponse deleteItemResponse = db().deleteItem(deleteItemRequest.build());
|
|
||||||
|
return dbAsyncClient.deleteItem(deleteItemRequest.build())
|
||||||
|
.thenApplyAsync(deleteItemResponse -> {
|
||||||
if (deleteItemResponse.attributes() != null && deleteItemResponse.attributes().containsKey(KEY_PARTITION)) {
|
if (deleteItemResponse.attributes() != null && deleteItemResponse.attributes().containsKey(KEY_PARTITION)) {
|
||||||
try {
|
try {
|
||||||
return Optional.of(convertItemToEnvelope(deleteItemResponse.attributes()));
|
return Optional.of(convertItemToEnvelope(deleteItemResponse.attributes()));
|
||||||
} catch (final InvalidProtocolBufferException e) {
|
} catch (final InvalidProtocolBufferException e) {
|
||||||
logger.error("Failed to parse envelope", e);
|
logger.error("Failed to parse envelope", e);
|
||||||
return Optional.empty();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
});
|
}, messageDeletionExecutor);
|
||||||
}
|
|
||||||
|
|
||||||
@Nonnull
|
|
||||||
private Optional<MessageProtos.Envelope> deleteItemsMatchingQueryAndReturnFirstOneActuallyDeleted(AttributeValue partitionKey, QueryRequest queryRequest) {
|
|
||||||
Optional<MessageProtos.Envelope> result = Optional.empty();
|
|
||||||
for (Map<String, AttributeValue> item : db().queryPaginator(queryRequest).items()) {
|
|
||||||
final byte[] rangeKeyValue = item.get(KEY_SORT).b().asByteArray();
|
|
||||||
DeleteItemRequest.Builder deleteItemRequest = DeleteItemRequest.builder()
|
|
||||||
.tableName(tableName)
|
|
||||||
.key(Map.of(KEY_PARTITION, partitionKey, KEY_SORT, AttributeValues.fromByteArray(rangeKeyValue)));
|
|
||||||
if (result.isEmpty()) {
|
|
||||||
deleteItemRequest.returnValues(ReturnValue.ALL_OLD);
|
|
||||||
}
|
|
||||||
final DeleteItemResponse deleteItemResponse = db().deleteItem(deleteItemRequest.build());
|
|
||||||
if (deleteItemResponse.attributes() != null && deleteItemResponse.attributes().containsKey(KEY_PARTITION)) {
|
|
||||||
try {
|
|
||||||
result = Optional.of(convertItemToEnvelope(deleteItemResponse.attributes()));
|
|
||||||
} catch (final InvalidProtocolBufferException e) {
|
|
||||||
logger.error("Failed to parse envelope", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteAllMessagesForAccount(final UUID destinationAccountUuid) {
|
public void deleteAllMessagesForAccount(final UUID destinationAccountUuid) {
|
||||||
|
@ -248,7 +256,7 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
|
||||||
KEY_PARTITION, partitionKey,
|
KEY_PARTITION, partitionKey,
|
||||||
KEY_SORT, item.get(KEY_SORT))).build())
|
KEY_SORT, item.get(KEY_SORT))).build())
|
||||||
.build())
|
.build())
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
executeTableWriteItemsUntilComplete(Map.of(tableName, deletes));
|
executeTableWriteItemsUntilComplete(Map.of(tableName, deletes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright 2013-2021 Signal Messenger, LLC
|
* Copyright 2013-2022 Signal Messenger, LLC
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
package org.whispersystems.textsecuregcm.storage;
|
package org.whispersystems.textsecuregcm.storage;
|
||||||
|
@ -9,19 +9,30 @@ import static com.codahale.metrics.MetricRegistry.name;
|
||||||
import com.codahale.metrics.Meter;
|
import com.codahale.metrics.Meter;
|
||||||
import com.codahale.metrics.MetricRegistry;
|
import com.codahale.metrics.MetricRegistry;
|
||||||
import com.codahale.metrics.SharedMetricRegistries;
|
import com.codahale.metrics.SharedMetricRegistries;
|
||||||
import java.util.ArrayList;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
|
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
|
||||||
import org.whispersystems.textsecuregcm.util.Constants;
|
import org.whispersystems.textsecuregcm.util.Constants;
|
||||||
import org.whispersystems.textsecuregcm.util.Pair;
|
import org.whispersystems.textsecuregcm.util.Pair;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
public class MessagesManager {
|
public class MessagesManager {
|
||||||
|
|
||||||
private static final int RESULT_SET_CHUNK_SIZE = 100;
|
private static final int RESULT_SET_CHUNK_SIZE = 100;
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(MessagesManager.class);
|
||||||
|
|
||||||
private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
|
private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
|
||||||
private static final Meter cacheHitByGuidMeter = metricRegistry.meter(name(MessagesManager.class, "cacheHitByGuid"));
|
private static final Meter cacheHitByGuidMeter = metricRegistry.meter(name(MessagesManager.class, "cacheHitByGuid"));
|
||||||
private static final Meter cacheMissByGuidMeter = metricRegistry.meter(
|
private static final Meter cacheMissByGuidMeter = metricRegistry.meter(
|
||||||
|
@ -55,18 +66,32 @@ public class MessagesManager {
|
||||||
return messagesCache.hasMessages(destinationUuid, destinationDevice);
|
return messagesCache.hasMessages(destinationUuid, destinationDevice);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Pair<List<Envelope>, Boolean> getMessagesForDevice(UUID destinationUuid, long destinationDevice, final boolean cachedMessagesOnly) {
|
public Pair<List<Envelope>, Boolean> getMessagesForDevice(UUID destinationUuid, long destinationDevice,
|
||||||
List<Envelope> messageList = new ArrayList<>();
|
boolean cachedMessagesOnly) {
|
||||||
|
|
||||||
if (!cachedMessagesOnly) {
|
final List<Envelope> envelopes = Flux.from(
|
||||||
messageList.addAll(messagesDynamoDb.load(destinationUuid, destinationDevice, RESULT_SET_CHUNK_SIZE));
|
getMessagesForDevice(destinationUuid, destinationDevice, RESULT_SET_CHUNK_SIZE, cachedMessagesOnly))
|
||||||
|
.take(RESULT_SET_CHUNK_SIZE, true)
|
||||||
|
.collectList()
|
||||||
|
.blockOptional().orElse(Collections.emptyList());
|
||||||
|
|
||||||
|
return new Pair<>(envelopes, envelopes.size() >= RESULT_SET_CHUNK_SIZE);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageList.size() < RESULT_SET_CHUNK_SIZE) {
|
public Publisher<Envelope> getMessagesForDeviceReactive(UUID destinationUuid, long destinationDevice,
|
||||||
messageList.addAll(messagesCache.get(destinationUuid, destinationDevice, RESULT_SET_CHUNK_SIZE - messageList.size()));
|
final boolean cachedMessagesOnly) {
|
||||||
|
|
||||||
|
return getMessagesForDevice(destinationUuid, destinationDevice, null, cachedMessagesOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Pair<>(messageList, messageList.size() >= RESULT_SET_CHUNK_SIZE);
|
private Publisher<Envelope> getMessagesForDevice(UUID destinationUuid, long destinationDevice,
|
||||||
|
@Nullable Integer limit, final boolean cachedMessagesOnly) {
|
||||||
|
|
||||||
|
final Publisher<Envelope> dynamoPublisher =
|
||||||
|
cachedMessagesOnly ? Flux.empty() : messagesDynamoDb.load(destinationUuid, destinationDevice, limit);
|
||||||
|
final Publisher<Envelope> cachePublisher = messagesCache.get(destinationUuid, destinationDevice);
|
||||||
|
|
||||||
|
return Flux.concat(dynamoPublisher, cachePublisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void clear(UUID destinationUuid) {
|
public void clear(UUID destinationUuid) {
|
||||||
|
@ -79,21 +104,25 @@ public class MessagesManager {
|
||||||
messagesDynamoDb.deleteAllMessagesForDevice(destinationUuid, deviceId);
|
messagesDynamoDb.deleteAllMessagesForDevice(destinationUuid, deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<Envelope> delete(UUID destinationUuid, long destinationDeviceId, UUID guid, Long serverTimestamp) {
|
public CompletableFuture<Optional<Envelope>> delete(UUID destinationUuid, long destinationDeviceId, UUID guid,
|
||||||
Optional<Envelope> removed = messagesCache.remove(destinationUuid, destinationDeviceId, guid);
|
@Nullable Long serverTimestamp) {
|
||||||
|
return messagesCache.remove(destinationUuid, destinationDeviceId, guid)
|
||||||
|
.thenCompose(removed -> {
|
||||||
|
|
||||||
if (removed.isEmpty()) {
|
if (removed.isPresent()) {
|
||||||
if (serverTimestamp == null) {
|
|
||||||
removed = messagesDynamoDb.deleteMessageByDestinationAndGuid(destinationUuid, guid);
|
|
||||||
} else {
|
|
||||||
removed = messagesDynamoDb.deleteMessage(destinationUuid, destinationDeviceId, guid, serverTimestamp);
|
|
||||||
}
|
|
||||||
cacheMissByGuidMeter.mark();
|
|
||||||
} else {
|
|
||||||
cacheHitByGuidMeter.mark();
|
cacheHitByGuidMeter.mark();
|
||||||
|
return CompletableFuture.completedFuture(removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
return removed;
|
cacheMissByGuidMeter.mark();
|
||||||
|
|
||||||
|
if (serverTimestamp == null) {
|
||||||
|
return messagesDynamoDb.deleteMessageByDestinationAndGuid(destinationUuid, guid);
|
||||||
|
} else {
|
||||||
|
return messagesDynamoDb.deleteMessage(destinationUuid, destinationDeviceId, guid, serverTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -112,10 +141,15 @@ public class MessagesManager {
|
||||||
|
|
||||||
final List<UUID> messageGuids = messages.stream().map(message -> UUID.fromString(message.getServerGuid()))
|
final List<UUID> messageGuids = messages.stream().map(message -> UUID.fromString(message.getServerGuid()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
int messagesRemovedFromCache = messagesCache.remove(destinationUuid, destinationDeviceId, messageGuids).size();
|
int messagesRemovedFromCache = 0;
|
||||||
|
try {
|
||||||
|
messagesRemovedFromCache = messagesCache.remove(destinationUuid, destinationDeviceId, messageGuids)
|
||||||
|
.get(30, TimeUnit.SECONDS).size();
|
||||||
persistMessageMeter.mark(nonEphemeralMessages.size());
|
persistMessageMeter.mark(nonEphemeralMessages.size());
|
||||||
|
|
||||||
|
} catch (InterruptedException | ExecutionException | TimeoutException e) {
|
||||||
|
logger.warn("Failed to remove messages from cache", e);
|
||||||
|
}
|
||||||
return messagesRemovedFromCache;
|
return messagesRemovedFromCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,4 +163,5 @@ public class MessagesManager {
|
||||||
public void removeMessageAvailabilityListener(final MessageAvailabilityListener listener) {
|
public void removeMessageAvailabilityListener(final MessageAvailabilityListener listener) {
|
||||||
messagesCache.removeMessageAvailabilityListener(listener);
|
messagesCache.removeMessageAvailabilityListener(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright 2013-2021 Signal Messenger, LLC
|
* Copyright 2013-2022 Signal Messenger, LLC
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -11,14 +11,19 @@ import com.codahale.metrics.Counter;
|
||||||
import com.codahale.metrics.MetricRegistry;
|
import com.codahale.metrics.MetricRegistry;
|
||||||
import com.codahale.metrics.SharedMetricRegistries;
|
import com.codahale.metrics.SharedMetricRegistries;
|
||||||
import com.codahale.metrics.Timer;
|
import com.codahale.metrics.Timer;
|
||||||
|
import io.micrometer.core.instrument.Metrics;
|
||||||
|
import io.micrometer.core.instrument.Tags;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.ScheduledFuture;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import org.eclipse.jetty.websocket.api.UpgradeResponse;
|
import org.eclipse.jetty.websocket.api.UpgradeResponse;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||||
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
|
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
|
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
|
||||||
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||||
|
@ -33,12 +38,20 @@ import org.whispersystems.websocket.setup.WebSocketConnectListener;
|
||||||
public class AuthenticatedConnectListener implements WebSocketConnectListener {
|
public class AuthenticatedConnectListener implements WebSocketConnectListener {
|
||||||
|
|
||||||
private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
|
private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
|
||||||
private static final Timer durationTimer = metricRegistry.timer(name(WebSocketConnection.class, "connected_duration" ));
|
private static final Timer durationTimer = metricRegistry.timer(
|
||||||
private static final Timer unauthenticatedDurationTimer = metricRegistry.timer(name(WebSocketConnection.class, "unauthenticated_connection_duration"));
|
name(WebSocketConnection.class, "connected_duration"));
|
||||||
private static final Counter openWebsocketCounter = metricRegistry.counter(name(WebSocketConnection.class, "open_websockets"));
|
private static final Timer unauthenticatedDurationTimer = metricRegistry.timer(
|
||||||
|
name(WebSocketConnection.class, "unauthenticated_connection_duration"));
|
||||||
|
private static final Counter openWebsocketCounter = metricRegistry.counter(
|
||||||
|
name(WebSocketConnection.class, "open_websockets"));
|
||||||
|
|
||||||
|
private static final String OPEN_WEBSOCKET_COUNTER_NAME = MetricsUtil.name(WebSocketConnection.class,
|
||||||
|
"openWebsockets");
|
||||||
|
|
||||||
private static final long RENEW_PRESENCE_INTERVAL_MINUTES = 5;
|
private static final long RENEW_PRESENCE_INTERVAL_MINUTES = 5;
|
||||||
|
|
||||||
|
private static final String REACTIVE_MESSAGE_QUEUE_EXPERIMENT_NAME = "reactive_message_queue_v1";
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(AuthenticatedConnectListener.class);
|
private static final Logger log = LoggerFactory.getLogger(AuthenticatedConnectListener.class);
|
||||||
|
|
||||||
private final ReceiptSender receiptSender;
|
private final ReceiptSender receiptSender;
|
||||||
|
@ -46,18 +59,26 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
|
||||||
private final PushNotificationManager pushNotificationManager;
|
private final PushNotificationManager pushNotificationManager;
|
||||||
private final ClientPresenceManager clientPresenceManager;
|
private final ClientPresenceManager clientPresenceManager;
|
||||||
private final ScheduledExecutorService scheduledExecutorService;
|
private final ScheduledExecutorService scheduledExecutorService;
|
||||||
|
private final ExperimentEnrollmentManager experimentEnrollmentManager;
|
||||||
|
|
||||||
|
private final AtomicInteger openReactiveWebSockets = new AtomicInteger(0);
|
||||||
|
private final AtomicInteger openStandardWebSockets = new AtomicInteger(0);
|
||||||
|
|
||||||
public AuthenticatedConnectListener(ReceiptSender receiptSender,
|
public AuthenticatedConnectListener(ReceiptSender receiptSender,
|
||||||
MessagesManager messagesManager,
|
MessagesManager messagesManager,
|
||||||
PushNotificationManager pushNotificationManager,
|
PushNotificationManager pushNotificationManager,
|
||||||
ClientPresenceManager clientPresenceManager,
|
ClientPresenceManager clientPresenceManager,
|
||||||
ScheduledExecutorService scheduledExecutorService)
|
ScheduledExecutorService scheduledExecutorService,
|
||||||
{
|
ExperimentEnrollmentManager experimentEnrollmentManager) {
|
||||||
this.receiptSender = receiptSender;
|
this.receiptSender = receiptSender;
|
||||||
this.messagesManager = messagesManager;
|
this.messagesManager = messagesManager;
|
||||||
this.pushNotificationManager = pushNotificationManager;
|
this.pushNotificationManager = pushNotificationManager;
|
||||||
this.clientPresenceManager = clientPresenceManager;
|
this.clientPresenceManager = clientPresenceManager;
|
||||||
this.scheduledExecutorService = scheduledExecutorService;
|
this.scheduledExecutorService = scheduledExecutorService;
|
||||||
|
this.experimentEnrollmentManager = experimentEnrollmentManager;
|
||||||
|
|
||||||
|
Metrics.gauge(OPEN_WEBSOCKET_COUNTER_NAME, Tags.of("reactive", String.valueOf(true)), openReactiveWebSockets);
|
||||||
|
Metrics.gauge(OPEN_WEBSOCKET_COUNTER_NAME, Tags.of("reactive", String.valueOf(false)), openStandardWebSockets);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -66,20 +87,34 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
|
||||||
final AuthenticatedAccount auth = context.getAuthenticated(AuthenticatedAccount.class);
|
final AuthenticatedAccount auth = context.getAuthenticated(AuthenticatedAccount.class);
|
||||||
final Device device = auth.getAuthenticatedDevice();
|
final Device device = auth.getAuthenticatedDevice();
|
||||||
final Timer.Context timer = durationTimer.time();
|
final Timer.Context timer = durationTimer.time();
|
||||||
|
final boolean enrolledInReactiveMessageQueue = experimentEnrollmentManager.isEnrolled(
|
||||||
|
auth.getAccount().getUuid(),
|
||||||
|
REACTIVE_MESSAGE_QUEUE_EXPERIMENT_NAME);
|
||||||
final WebSocketConnection connection = new WebSocketConnection(receiptSender,
|
final WebSocketConnection connection = new WebSocketConnection(receiptSender,
|
||||||
messagesManager, auth, device,
|
messagesManager, auth, device,
|
||||||
context.getClient(),
|
context.getClient(),
|
||||||
scheduledExecutorService);
|
scheduledExecutorService,
|
||||||
|
enrolledInReactiveMessageQueue);
|
||||||
|
|
||||||
openWebsocketCounter.inc();
|
openWebsocketCounter.inc();
|
||||||
|
if (enrolledInReactiveMessageQueue) {
|
||||||
|
openReactiveWebSockets.incrementAndGet();
|
||||||
|
} else {
|
||||||
|
openStandardWebSockets.incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), device, context.getClient().getUserAgent());
|
pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), device, context.getClient().getUserAgent());
|
||||||
|
|
||||||
final AtomicReference<ScheduledFuture<?>> renewPresenceFutureReference = new AtomicReference<>();
|
final AtomicReference<ScheduledFuture<?>> renewPresenceFutureReference = new AtomicReference<>();
|
||||||
|
|
||||||
context.addListener(new WebSocketSessionContext.WebSocketEventListener() {
|
context.addListener((closingContext, statusCode, reason) -> {
|
||||||
@Override
|
|
||||||
public void onWebSocketClose(WebSocketSessionContext context, int statusCode, String reason) {
|
|
||||||
openWebsocketCounter.dec();
|
openWebsocketCounter.dec();
|
||||||
|
if (enrolledInReactiveMessageQueue) {
|
||||||
|
openReactiveWebSockets.decrementAndGet();
|
||||||
|
} else {
|
||||||
|
openStandardWebSockets.decrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
timer.stop();
|
timer.stop();
|
||||||
|
|
||||||
final ScheduledFuture<?> renewPresenceFuture = renewPresenceFutureReference.get();
|
final ScheduledFuture<?> renewPresenceFuture = renewPresenceFutureReference.get();
|
||||||
|
@ -102,7 +137,6 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
/*
|
/*
|
||||||
* Copyright 2013-2021 Signal Messenger, LLC
|
* Copyright 2013-2022 Signal Messenger, LLC
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.websocket;
|
package org.whispersystems.textsecuregcm.websocket;
|
||||||
|
|
||||||
import static com.codahale.metrics.MetricRegistry.name;
|
import static com.codahale.metrics.MetricRegistry.name;
|
||||||
import static org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
|
|
||||||
|
|
||||||
import com.codahale.metrics.Histogram;
|
import com.codahale.metrics.Histogram;
|
||||||
import com.codahale.metrics.Meter;
|
import com.codahale.metrics.Meter;
|
||||||
|
@ -34,11 +33,13 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.concurrent.atomic.LongAdder;
|
import java.util.concurrent.atomic.LongAdder;
|
||||||
import javax.ws.rs.WebApplicationException;
|
import javax.ws.rs.WebApplicationException;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||||
import org.whispersystems.textsecuregcm.controllers.MessageController;
|
import org.whispersystems.textsecuregcm.controllers.MessageController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.NoSuchUserException;
|
import org.whispersystems.textsecuregcm.controllers.NoSuchUserException;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
|
||||||
import org.whispersystems.textsecuregcm.metrics.MessageMetrics;
|
import org.whispersystems.textsecuregcm.metrics.MessageMetrics;
|
||||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||||
import org.whispersystems.textsecuregcm.push.DisplacedPresenceListener;
|
import org.whispersystems.textsecuregcm.push.DisplacedPresenceListener;
|
||||||
|
@ -49,13 +50,14 @@ import org.whispersystems.textsecuregcm.storage.MessagesManager;
|
||||||
import org.whispersystems.textsecuregcm.util.Constants;
|
import org.whispersystems.textsecuregcm.util.Constants;
|
||||||
import org.whispersystems.textsecuregcm.util.Pair;
|
import org.whispersystems.textsecuregcm.util.Pair;
|
||||||
import org.whispersystems.textsecuregcm.util.TimestampHeaderUtil;
|
import org.whispersystems.textsecuregcm.util.TimestampHeaderUtil;
|
||||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
|
||||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
|
||||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
|
||||||
import org.whispersystems.websocket.WebSocketClient;
|
import org.whispersystems.websocket.WebSocketClient;
|
||||||
import org.whispersystems.websocket.messages.WebSocketResponseMessage;
|
import org.whispersystems.websocket.messages.WebSocketResponseMessage;
|
||||||
|
import reactor.core.Disposable;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Scheduler;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
|
||||||
public class WebSocketConnection implements MessageAvailabilityListener, DisplacedPresenceListener {
|
public class WebSocketConnection implements MessageAvailabilityListener, DisplacedPresenceListener {
|
||||||
|
|
||||||
private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
|
private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
|
||||||
|
@ -70,8 +72,6 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
name(WebSocketConnection.class, "messagesPersisted"));
|
name(WebSocketConnection.class, "messagesPersisted"));
|
||||||
private static final Meter bytesSentMeter = metricRegistry.meter(name(WebSocketConnection.class, "bytes_sent"));
|
private static final Meter bytesSentMeter = metricRegistry.meter(name(WebSocketConnection.class, "bytes_sent"));
|
||||||
private static final Meter sendFailuresMeter = metricRegistry.meter(name(WebSocketConnection.class, "send_failures"));
|
private static final Meter sendFailuresMeter = metricRegistry.meter(name(WebSocketConnection.class, "send_failures"));
|
||||||
private static final Meter discardedMessagesMeter = metricRegistry.meter(
|
|
||||||
name(WebSocketConnection.class, "discardedMessages"));
|
|
||||||
|
|
||||||
private static final String INITIAL_QUEUE_LENGTH_DISTRIBUTION_NAME = name(WebSocketConnection.class,
|
private static final String INITIAL_QUEUE_LENGTH_DISTRIBUTION_NAME = name(WebSocketConnection.class,
|
||||||
"initialQueueLength");
|
"initialQueueLength");
|
||||||
|
@ -85,11 +85,12 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
"messageAvailableAfterClientClosed");
|
"messageAvailableAfterClientClosed");
|
||||||
private static final String STATUS_CODE_TAG = "status";
|
private static final String STATUS_CODE_TAG = "status";
|
||||||
private static final String STATUS_MESSAGE_TAG = "message";
|
private static final String STATUS_MESSAGE_TAG = "message";
|
||||||
|
private static final String REACTIVE_TAG = "reactive";
|
||||||
|
|
||||||
private static final long SLOW_DRAIN_THRESHOLD = 10_000;
|
private static final long SLOW_DRAIN_THRESHOLD = 10_000;
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
static final int MAX_DESKTOP_MESSAGE_SIZE = 1024 * 1024;
|
static final int MESSAGE_PUBLISHER_LIMIT_RATE = 100;
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
static final int MAX_CONSECUTIVE_RETRIES = 5;
|
static final int MAX_CONSECUTIVE_RETRIES = 5;
|
||||||
|
@ -111,8 +112,6 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
|
|
||||||
private final ScheduledExecutorService scheduledExecutorService;
|
private final ScheduledExecutorService scheduledExecutorService;
|
||||||
|
|
||||||
private final boolean isDesktopClient;
|
|
||||||
|
|
||||||
private final Semaphore processStoredMessagesSemaphore = new Semaphore(1);
|
private final Semaphore processStoredMessagesSemaphore = new Semaphore(1);
|
||||||
private final AtomicReference<StoredMessageState> storedMessageState = new AtomicReference<>(
|
private final AtomicReference<StoredMessageState> storedMessageState = new AtomicReference<>(
|
||||||
StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE);
|
StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE);
|
||||||
|
@ -121,8 +120,11 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
private final AtomicLong queueDrainStartTime = new AtomicLong();
|
private final AtomicLong queueDrainStartTime = new AtomicLong();
|
||||||
private final AtomicInteger consecutiveRetries = new AtomicInteger();
|
private final AtomicInteger consecutiveRetries = new AtomicInteger();
|
||||||
private final AtomicReference<ScheduledFuture<?>> retryFuture = new AtomicReference<>();
|
private final AtomicReference<ScheduledFuture<?>> retryFuture = new AtomicReference<>();
|
||||||
|
private final AtomicReference<Disposable> messageSubscription = new AtomicReference<>();
|
||||||
|
|
||||||
private final Random random = new Random();
|
private final Random random = new Random();
|
||||||
|
private final boolean useReactive;
|
||||||
|
private Scheduler reactiveScheduler;
|
||||||
|
|
||||||
private enum StoredMessageState {
|
private enum StoredMessageState {
|
||||||
EMPTY,
|
EMPTY,
|
||||||
|
@ -135,7 +137,28 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
AuthenticatedAccount auth,
|
AuthenticatedAccount auth,
|
||||||
Device device,
|
Device device,
|
||||||
WebSocketClient client,
|
WebSocketClient client,
|
||||||
ScheduledExecutorService scheduledExecutorService) {
|
ScheduledExecutorService scheduledExecutorService,
|
||||||
|
boolean useReactive) {
|
||||||
|
|
||||||
|
this(receiptSender,
|
||||||
|
messagesManager,
|
||||||
|
auth,
|
||||||
|
device,
|
||||||
|
client,
|
||||||
|
scheduledExecutorService,
|
||||||
|
useReactive,
|
||||||
|
Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
WebSocketConnection(ReceiptSender receiptSender,
|
||||||
|
MessagesManager messagesManager,
|
||||||
|
AuthenticatedAccount auth,
|
||||||
|
Device device,
|
||||||
|
WebSocketClient client,
|
||||||
|
ScheduledExecutorService scheduledExecutorService,
|
||||||
|
boolean useReactive,
|
||||||
|
Scheduler reactiveScheduler) {
|
||||||
|
|
||||||
this(receiptSender,
|
this(receiptSender,
|
||||||
messagesManager,
|
messagesManager,
|
||||||
|
@ -143,7 +166,9 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
device,
|
device,
|
||||||
client,
|
client,
|
||||||
DEFAULT_SEND_FUTURES_TIMEOUT_MILLIS,
|
DEFAULT_SEND_FUTURES_TIMEOUT_MILLIS,
|
||||||
scheduledExecutorService);
|
scheduledExecutorService,
|
||||||
|
useReactive,
|
||||||
|
reactiveScheduler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
|
@ -153,7 +178,9 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
Device device,
|
Device device,
|
||||||
WebSocketClient client,
|
WebSocketClient client,
|
||||||
int sendFuturesTimeoutMillis,
|
int sendFuturesTimeoutMillis,
|
||||||
ScheduledExecutorService scheduledExecutorService) {
|
ScheduledExecutorService scheduledExecutorService,
|
||||||
|
boolean useReactive,
|
||||||
|
Scheduler reactiveScheduler) {
|
||||||
|
|
||||||
this.receiptSender = receiptSender;
|
this.receiptSender = receiptSender;
|
||||||
this.messagesManager = messagesManager;
|
this.messagesManager = messagesManager;
|
||||||
|
@ -162,16 +189,8 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.sendFuturesTimeoutMillis = sendFuturesTimeoutMillis;
|
this.sendFuturesTimeoutMillis = sendFuturesTimeoutMillis;
|
||||||
this.scheduledExecutorService = scheduledExecutorService;
|
this.scheduledExecutorService = scheduledExecutorService;
|
||||||
|
this.useReactive = useReactive;
|
||||||
Optional<ClientPlatform> maybePlatform;
|
this.reactiveScheduler = reactiveScheduler;
|
||||||
|
|
||||||
try {
|
|
||||||
maybePlatform = Optional.of(UserAgentUtil.parseUserAgentString(client.getUserAgent()).getPlatform());
|
|
||||||
} catch (final UnrecognizedUserAgentException e) {
|
|
||||||
maybePlatform = Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isDesktopClient = maybePlatform.map(platform -> platform == ClientPlatform.DESKTOP).orElse(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void start() {
|
public void start() {
|
||||||
|
@ -186,10 +205,15 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
future.cancel(false);
|
future.cancel(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Disposable subscription = messageSubscription.get();
|
||||||
|
if (subscription != null) {
|
||||||
|
subscription.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
client.close(1000, "OK");
|
client.close(1000, "OK");
|
||||||
}
|
}
|
||||||
|
|
||||||
private CompletableFuture<WebSocketResponseMessage> sendMessage(final Envelope message, final Optional<StoredMessageInfo> storedMessageInfo) {
|
private CompletableFuture<?> sendMessage(final Envelope message, StoredMessageInfo storedMessageInfo) {
|
||||||
// clear ephemeral field from the envelope
|
// clear ephemeral field from the envelope
|
||||||
final Optional<byte[]> body = Optional.ofNullable(message.toBuilder().clearEphemeral().build().toByteArray());
|
final Optional<byte[]> body = Optional.ofNullable(message.toBuilder().clearEphemeral().build().toByteArray());
|
||||||
|
|
||||||
|
@ -199,12 +223,18 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
MessageMetrics.measureAccountEnvelopeUuidMismatches(auth.getAccount(), message);
|
MessageMetrics.measureAccountEnvelopeUuidMismatches(auth.getAccount(), message);
|
||||||
|
|
||||||
// X-Signal-Key: false must be sent until Android stops assuming it missing means true
|
// X-Signal-Key: false must be sent until Android stops assuming it missing means true
|
||||||
return client.sendRequest("PUT", "/api/v1/message", List.of("X-Signal-Key: false", TimestampHeaderUtil.getTimestampHeader()), body).whenComplete((response, throwable) -> {
|
return client.sendRequest("PUT", "/api/v1/message",
|
||||||
if (throwable == null) {
|
List.of("X-Signal-Key: false", TimestampHeaderUtil.getTimestampHeader()), body)
|
||||||
if (isSuccessResponse(response)) {
|
.whenComplete((ignored, throwable) -> {
|
||||||
if (storedMessageInfo.isPresent()) {
|
if (throwable != null) {
|
||||||
messagesManager.delete(auth.getAccount().getUuid(), device.getId(), storedMessageInfo.get().getGuid(), storedMessageInfo.get().getServerTimestamp());
|
sendFailuresMeter.mark();
|
||||||
}
|
}
|
||||||
|
}).thenCompose(response -> {
|
||||||
|
final CompletableFuture<?> result;
|
||||||
|
if (isSuccessResponse(response)) {
|
||||||
|
|
||||||
|
result = messagesManager.delete(auth.getAccount().getUuid(), device.getId(),
|
||||||
|
storedMessageInfo.guid(), storedMessageInfo.serverTimestamp());
|
||||||
|
|
||||||
if (message.getType() != Envelope.Type.SERVER_DELIVERY_RECEIPT) {
|
if (message.getType() != Envelope.Type.SERVER_DELIVERY_RECEIPT) {
|
||||||
recordMessageDeliveryDuration(message.getTimestamp(), device);
|
recordMessageDeliveryDuration(message.getTimestamp(), device);
|
||||||
|
@ -212,8 +242,11 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final List<Tag> tags = new ArrayList<>(
|
final List<Tag> tags = new ArrayList<>(
|
||||||
List.of(Tag.of(STATUS_CODE_TAG, String.valueOf(response.getStatus())),
|
List.of(
|
||||||
UserAgentTagUtil.getPlatformTag(client.getUserAgent())));
|
Tag.of(STATUS_CODE_TAG, String.valueOf(response.getStatus())),
|
||||||
|
UserAgentTagUtil.getPlatformTag(client.getUserAgent()),
|
||||||
|
Tag.of(REACTIVE_TAG, String.valueOf(useReactive))
|
||||||
|
));
|
||||||
|
|
||||||
// TODO Remove this once we've identified the cause of message rejections from desktop clients
|
// TODO Remove this once we've identified the cause of message rejections from desktop clients
|
||||||
if (StringUtils.isNotBlank(response.getMessage())) {
|
if (StringUtils.isNotBlank(response.getMessage())) {
|
||||||
|
@ -221,10 +254,11 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
}
|
}
|
||||||
|
|
||||||
Metrics.counter(NON_SUCCESS_RESPONSE_COUNTER_NAME, tags).increment();
|
Metrics.counter(NON_SUCCESS_RESPONSE_COUNTER_NAME, tags).increment();
|
||||||
|
|
||||||
|
result = CompletableFuture.completedFuture(null);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
sendFailuresMeter.mark();
|
return result;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -260,18 +294,37 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
void processStoredMessages() {
|
void processStoredMessages() {
|
||||||
|
if (useReactive) {
|
||||||
|
processStoredMessages_reactive();
|
||||||
|
} else {
|
||||||
|
processStoredMessage_paged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processStoredMessage_paged() {
|
||||||
|
assert !useReactive;
|
||||||
|
|
||||||
if (processStoredMessagesSemaphore.tryAcquire()) {
|
if (processStoredMessagesSemaphore.tryAcquire()) {
|
||||||
final StoredMessageState state = storedMessageState.getAndSet(StoredMessageState.EMPTY);
|
final StoredMessageState state = storedMessageState.getAndSet(StoredMessageState.EMPTY);
|
||||||
final CompletableFuture<Void> queueClearedFuture = new CompletableFuture<>();
|
final CompletableFuture<Void> queueCleared = new CompletableFuture<>();
|
||||||
|
|
||||||
sendNextMessagePage(state != StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE, queueClearedFuture);
|
sendNextMessagePage(state != StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE, queueCleared);
|
||||||
|
|
||||||
queueClearedFuture.whenComplete((v, cause) -> {
|
setQueueClearedHandler(state, queueCleared);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setQueueClearedHandler(final StoredMessageState state, final CompletableFuture<Void> queueCleared) {
|
||||||
|
|
||||||
|
queueCleared.whenComplete((v, cause) -> {
|
||||||
if (cause == null) {
|
if (cause == null) {
|
||||||
consecutiveRetries.set(0);
|
consecutiveRetries.set(0);
|
||||||
|
|
||||||
if (sentInitialQueueEmptyMessage.compareAndSet(false, true)) {
|
if (sentInitialQueueEmptyMessage.compareAndSet(false, true)) {
|
||||||
final List<Tag> tags = List.of(UserAgentTagUtil.getPlatformTag(client.getUserAgent()));
|
final List<Tag> tags = List.of(
|
||||||
|
UserAgentTagUtil.getPlatformTag(client.getUserAgent()),
|
||||||
|
Tag.of(REACTIVE_TAG, String.valueOf(useReactive))
|
||||||
|
);
|
||||||
final long drainDuration = System.currentTimeMillis() - queueDrainStartTime.get();
|
final long drainDuration = System.currentTimeMillis() - queueDrainStartTime.get();
|
||||||
|
|
||||||
Metrics.summary(INITIAL_QUEUE_LENGTH_DISTRIBUTION_NAME, tags).record(sentMessageCounter.sum());
|
Metrics.summary(INITIAL_QUEUE_LENGTH_DISTRIBUTION_NAME, tags).record(sentMessageCounter.sum());
|
||||||
|
@ -316,9 +369,21 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void processStoredMessages_reactive() {
|
||||||
|
assert useReactive;
|
||||||
|
|
||||||
|
if (processStoredMessagesSemaphore.tryAcquire()) {
|
||||||
|
final StoredMessageState state = storedMessageState.getAndSet(StoredMessageState.EMPTY);
|
||||||
|
final CompletableFuture<Void> queueCleared = new CompletableFuture<>();
|
||||||
|
|
||||||
|
sendMessagesReactive(state != StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE, queueCleared);
|
||||||
|
|
||||||
|
setQueueClearedHandler(state, queueCleared);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendNextMessagePage(final boolean cachedMessagesOnly, final CompletableFuture<Void> queueClearedFuture) {
|
private void sendNextMessagePage(final boolean cachedMessagesOnly, final CompletableFuture<Void> queueCleared) {
|
||||||
try {
|
try {
|
||||||
final Pair<List<Envelope>, Boolean> messagesAndHasMore = messagesManager.getMessagesForDevice(
|
final Pair<List<Envelope>, Boolean> messagesAndHasMore = messagesManager.getMessagesForDevice(
|
||||||
auth.getAccount().getUuid(), device.getId(), cachedMessagesOnly);
|
auth.getAccount().getUuid(), device.getId(), cachedMessagesOnly);
|
||||||
|
@ -330,25 +395,7 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
|
|
||||||
for (int i = 0; i < messages.size(); i++) {
|
for (int i = 0; i < messages.size(); i++) {
|
||||||
final Envelope envelope = messages.get(i);
|
final Envelope envelope = messages.get(i);
|
||||||
final UUID messageGuid = UUID.fromString(envelope.getServerGuid());
|
sendFutures[i] = sendMessage(envelope);
|
||||||
|
|
||||||
final boolean discard;
|
|
||||||
if (isDesktopClient && envelope.getSerializedSize() > MAX_DESKTOP_MESSAGE_SIZE) {
|
|
||||||
discard = true;
|
|
||||||
} else if (envelope.getStory() && !client.shouldDeliverStories()) {
|
|
||||||
discard = true;
|
|
||||||
} else {
|
|
||||||
discard = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discard) {
|
|
||||||
messagesManager.delete(auth.getAccount().getUuid(), device.getId(), messageGuid, envelope.getServerTimestamp());
|
|
||||||
discardedMessagesMeter.mark();
|
|
||||||
|
|
||||||
sendFutures[i] = CompletableFuture.completedFuture(null);
|
|
||||||
} else {
|
|
||||||
sendFutures[i] = sendMessage(envelope, Optional.of(new StoredMessageInfo(messageGuid, envelope.getServerTimestamp())));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set a large, non-zero timeout, to prevent any failure to acknowledge receipt from blocking indefinitely
|
// Set a large, non-zero timeout, to prevent any failure to acknowledge receipt from blocking indefinitely
|
||||||
|
@ -357,16 +404,45 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
.whenComplete((v, cause) -> {
|
.whenComplete((v, cause) -> {
|
||||||
if (cause == null) {
|
if (cause == null) {
|
||||||
if (hasMore) {
|
if (hasMore) {
|
||||||
sendNextMessagePage(cachedMessagesOnly, queueClearedFuture);
|
sendNextMessagePage(cachedMessagesOnly, queueCleared);
|
||||||
} else {
|
} else {
|
||||||
queueClearedFuture.complete(null);
|
queueCleared.complete(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
queueClearedFuture.completeExceptionally(cause);
|
queueCleared.completeExceptionally(cause);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
queueClearedFuture.completeExceptionally(e);
|
queueCleared.completeExceptionally(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendMessagesReactive(final boolean cachedMessagesOnly, final CompletableFuture<Void> queueCleared) {
|
||||||
|
|
||||||
|
final Publisher<Envelope> messages =
|
||||||
|
messagesManager.getMessagesForDeviceReactive(auth.getAccount().getUuid(), device.getId(), cachedMessagesOnly);
|
||||||
|
|
||||||
|
final Disposable subscription = Flux.from(messages)
|
||||||
|
.limitRate(MESSAGE_PUBLISHER_LIMIT_RATE)
|
||||||
|
.flatMapSequential(envelope ->
|
||||||
|
Mono.fromFuture(sendMessage(envelope).orTimeout(sendFuturesTimeoutMillis, TimeUnit.MILLISECONDS)))
|
||||||
|
.doOnError(queueCleared::completeExceptionally)
|
||||||
|
.doOnComplete(() -> queueCleared.complete(null))
|
||||||
|
.subscribeOn(reactiveScheduler)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
messageSubscription.set(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<?> sendMessage(Envelope envelope) {
|
||||||
|
final UUID messageGuid = UUID.fromString(envelope.getServerGuid());
|
||||||
|
|
||||||
|
if (envelope.getStory() && !client.shouldDeliverStories()) {
|
||||||
|
messagesManager.delete(auth.getAccount().getUuid(), device.getId(), messageGuid, envelope.getServerTimestamp());
|
||||||
|
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
} else {
|
||||||
|
return sendMessage(envelope, new StoredMessageInfo(messageGuid, envelope.getServerTimestamp()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -381,6 +457,7 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
messageAvailableMeter.mark();
|
messageAvailableMeter.mark();
|
||||||
|
|
||||||
storedMessageState.compareAndSet(StoredMessageState.EMPTY, StoredMessageState.CACHED_NEW_MESSAGES_AVAILABLE);
|
storedMessageState.compareAndSet(StoredMessageState.EMPTY, StoredMessageState.CACHED_NEW_MESSAGES_AVAILABLE);
|
||||||
|
|
||||||
processStoredMessages();
|
processStoredMessages();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -396,6 +473,7 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
messagesPersistedMeter.mark();
|
messagesPersistedMeter.mark();
|
||||||
|
|
||||||
storedMessageState.set(StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE);
|
storedMessageState.set(StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE);
|
||||||
|
|
||||||
processStoredMessages();
|
processStoredMessages();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -405,7 +483,8 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
public void handleDisplacement(final boolean connectedElsewhere) {
|
public void handleDisplacement(final boolean connectedElsewhere) {
|
||||||
final Tags tags = Tags.of(
|
final Tags tags = Tags.of(
|
||||||
UserAgentTagUtil.getPlatformTag(client.getUserAgent()),
|
UserAgentTagUtil.getPlatformTag(client.getUserAgent()),
|
||||||
Tag.of("connectedElsewhere", String.valueOf(connectedElsewhere)));
|
Tag.of("connectedElsewhere", String.valueOf(connectedElsewhere)),
|
||||||
|
Tag.of(REACTIVE_TAG, String.valueOf(useReactive)));
|
||||||
|
|
||||||
Metrics.counter(DISPLACEMENT_COUNTER_NAME, tags).increment();
|
Metrics.counter(DISPLACEMENT_COUNTER_NAME, tags).increment();
|
||||||
|
|
||||||
|
@ -429,21 +508,7 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class StoredMessageInfo {
|
private record StoredMessageInfo(UUID guid, long serverTimestamp) {
|
||||||
private final UUID guid;
|
|
||||||
private final long serverTimestamp;
|
|
||||||
|
|
||||||
public StoredMessageInfo(UUID guid, long serverTimestamp) {
|
|
||||||
this.guid = guid;
|
|
||||||
this.serverTimestamp = serverTimestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
public UUID getGuid() {
|
|
||||||
return guid;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getServerTimestamp() {
|
|
||||||
return serverTimestamp;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,6 @@ import net.sourceforge.argparse4j.inf.Subparser;
|
||||||
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
|
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
|
||||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||||
|
@ -45,9 +44,9 @@ import org.whispersystems.textsecuregcm.storage.MessagesManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
||||||
import org.whispersystems.textsecuregcm.storage.Profiles;
|
import org.whispersystems.textsecuregcm.storage.Profiles;
|
||||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
|
||||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||||
|
@ -97,6 +96,8 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
|
||||||
|
|
||||||
ExecutorService keyspaceNotificationDispatchExecutor = environment.lifecycle()
|
ExecutorService keyspaceNotificationDispatchExecutor = environment.lifecycle()
|
||||||
.executorService(name(getClass(), "keyspaceNotification-%d")).maxThreads(4).build();
|
.executorService(name(getClass(), "keyspaceNotification-%d")).maxThreads(4).build();
|
||||||
|
ExecutorService messageDeletionExecutor = environment.lifecycle()
|
||||||
|
.executorService(name(getClass(), "messageDeletion-%d")).maxThreads(4).build();
|
||||||
ExecutorService backupServiceExecutor = environment.lifecycle()
|
ExecutorService backupServiceExecutor = environment.lifecycle()
|
||||||
.executorService(name(getClass(), "backupService-%d")).maxThreads(8).minThreads(1).build();
|
.executorService(name(getClass(), "backupService-%d")).maxThreads(8).minThreads(1).build();
|
||||||
ExecutorService storageServiceExecutor = environment.lifecycle()
|
ExecutorService storageServiceExecutor = environment.lifecycle()
|
||||||
|
@ -156,15 +157,14 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
|
||||||
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
|
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
|
||||||
Keys keys = new Keys(dynamoDbClient,
|
Keys keys = new Keys(dynamoDbClient,
|
||||||
configuration.getDynamoDbTables().getKeys().getTableName());
|
configuration.getDynamoDbTables().getKeys().getTableName());
|
||||||
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient,
|
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
|
||||||
configuration.getDynamoDbTables().getMessages().getTableName(),
|
configuration.getDynamoDbTables().getMessages().getTableName(),
|
||||||
configuration.getDynamoDbTables().getMessages().getExpiration());
|
configuration.getDynamoDbTables().getMessages().getExpiration(),
|
||||||
|
messageDeletionExecutor);
|
||||||
FaultTolerantRedisCluster messageInsertCacheCluster = new FaultTolerantRedisCluster("message_insert_cluster",
|
FaultTolerantRedisCluster messageInsertCacheCluster = new FaultTolerantRedisCluster("message_insert_cluster",
|
||||||
configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources);
|
configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources);
|
||||||
FaultTolerantRedisCluster messageReadDeleteCluster = new FaultTolerantRedisCluster("message_read_delete_cluster",
|
FaultTolerantRedisCluster messageReadDeleteCluster = new FaultTolerantRedisCluster("message_read_delete_cluster",
|
||||||
configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources);
|
configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources);
|
||||||
FaultTolerantRedisCluster metricsCluster = new FaultTolerantRedisCluster("metrics_cluster",
|
|
||||||
configuration.getMetricsClusterConfiguration(), redisClusterClientResources);
|
|
||||||
FaultTolerantRedisCluster clientPresenceCluster = new FaultTolerantRedisCluster("client_presence_cluster",
|
FaultTolerantRedisCluster clientPresenceCluster = new FaultTolerantRedisCluster("client_presence_cluster",
|
||||||
configuration.getClientPresenceClusterConfiguration(), redisClusterClientResources);
|
configuration.getClientPresenceClusterConfiguration(), redisClusterClientResources);
|
||||||
FaultTolerantRedisCluster rateLimitersCluster = new FaultTolerantRedisCluster("rate_limiters",
|
FaultTolerantRedisCluster rateLimitersCluster = new FaultTolerantRedisCluster("rate_limiters",
|
||||||
|
@ -176,8 +176,7 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
|
||||||
ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster,
|
ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster,
|
||||||
Executors.newSingleThreadScheduledExecutor(), keyspaceNotificationDispatchExecutor);
|
Executors.newSingleThreadScheduledExecutor(), keyspaceNotificationDispatchExecutor);
|
||||||
MessagesCache messagesCache = new MessagesCache(messageInsertCacheCluster, messageReadDeleteCluster,
|
MessagesCache messagesCache = new MessagesCache(messageInsertCacheCluster, messageReadDeleteCluster,
|
||||||
keyspaceNotificationDispatchExecutor);
|
Clock.systemUTC(), keyspaceNotificationDispatchExecutor, messageDeletionExecutor);
|
||||||
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
|
|
||||||
DirectoryQueue directoryQueue = new DirectoryQueue(
|
DirectoryQueue directoryQueue = new DirectoryQueue(
|
||||||
configuration.getDirectoryConfiguration().getSqsConfiguration());
|
configuration.getDirectoryConfiguration().getSqsConfiguration());
|
||||||
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
||||||
|
|
|
@ -27,7 +27,6 @@ import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
|
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
|
||||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||||
|
@ -48,9 +47,9 @@ import org.whispersystems.textsecuregcm.storage.MessagesManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
||||||
import org.whispersystems.textsecuregcm.storage.Profiles;
|
import org.whispersystems.textsecuregcm.storage.Profiles;
|
||||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
|
||||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||||
|
@ -99,6 +98,8 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
|
||||||
|
|
||||||
ExecutorService keyspaceNotificationDispatchExecutor = environment.lifecycle()
|
ExecutorService keyspaceNotificationDispatchExecutor = environment.lifecycle()
|
||||||
.executorService(name(getClass(), "keyspaceNotification-%d")).maxThreads(4).build();
|
.executorService(name(getClass(), "keyspaceNotification-%d")).maxThreads(4).build();
|
||||||
|
ExecutorService messageDeletionExecutor = environment.lifecycle()
|
||||||
|
.executorService(name(getClass(), "messageDeletion-%d")).maxThreads(4).build();
|
||||||
ExecutorService backupServiceExecutor = environment.lifecycle()
|
ExecutorService backupServiceExecutor = environment.lifecycle()
|
||||||
.executorService(name(getClass(), "backupService-%d")).maxThreads(8).minThreads(1).build();
|
.executorService(name(getClass(), "backupService-%d")).maxThreads(8).minThreads(1).build();
|
||||||
ExecutorService storageServiceExecutor = environment.lifecycle()
|
ExecutorService storageServiceExecutor = environment.lifecycle()
|
||||||
|
@ -158,15 +159,14 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
|
||||||
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
|
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
|
||||||
Keys keys = new Keys(dynamoDbClient,
|
Keys keys = new Keys(dynamoDbClient,
|
||||||
configuration.getDynamoDbTables().getKeys().getTableName());
|
configuration.getDynamoDbTables().getKeys().getTableName());
|
||||||
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient,
|
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
|
||||||
configuration.getDynamoDbTables().getMessages().getTableName(),
|
configuration.getDynamoDbTables().getMessages().getTableName(),
|
||||||
configuration.getDynamoDbTables().getMessages().getExpiration());
|
configuration.getDynamoDbTables().getMessages().getExpiration(),
|
||||||
|
messageDeletionExecutor);
|
||||||
FaultTolerantRedisCluster messageInsertCacheCluster = new FaultTolerantRedisCluster("message_insert_cluster",
|
FaultTolerantRedisCluster messageInsertCacheCluster = new FaultTolerantRedisCluster("message_insert_cluster",
|
||||||
configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources);
|
configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources);
|
||||||
FaultTolerantRedisCluster messageReadDeleteCluster = new FaultTolerantRedisCluster("message_read_delete_cluster",
|
FaultTolerantRedisCluster messageReadDeleteCluster = new FaultTolerantRedisCluster("message_read_delete_cluster",
|
||||||
configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources);
|
configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources);
|
||||||
FaultTolerantRedisCluster metricsCluster = new FaultTolerantRedisCluster("metrics_cluster",
|
|
||||||
configuration.getMetricsClusterConfiguration(), redisClusterClientResources);
|
|
||||||
FaultTolerantRedisCluster clientPresenceCluster = new FaultTolerantRedisCluster("client_presence_cluster",
|
FaultTolerantRedisCluster clientPresenceCluster = new FaultTolerantRedisCluster("client_presence_cluster",
|
||||||
configuration.getClientPresenceClusterConfiguration(), redisClusterClientResources);
|
configuration.getClientPresenceClusterConfiguration(), redisClusterClientResources);
|
||||||
FaultTolerantRedisCluster rateLimitersCluster = new FaultTolerantRedisCluster("rate_limiters",
|
FaultTolerantRedisCluster rateLimitersCluster = new FaultTolerantRedisCluster("rate_limiters",
|
||||||
|
@ -178,8 +178,7 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
|
||||||
ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster,
|
ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster,
|
||||||
Executors.newSingleThreadScheduledExecutor(), keyspaceNotificationDispatchExecutor);
|
Executors.newSingleThreadScheduledExecutor(), keyspaceNotificationDispatchExecutor);
|
||||||
MessagesCache messagesCache = new MessagesCache(messageInsertCacheCluster, messageReadDeleteCluster,
|
MessagesCache messagesCache = new MessagesCache(messageInsertCacheCluster, messageReadDeleteCluster,
|
||||||
keyspaceNotificationDispatchExecutor);
|
Clock.systemUTC(), keyspaceNotificationDispatchExecutor, messageDeletionExecutor);
|
||||||
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
|
|
||||||
DirectoryQueue directoryQueue = new DirectoryQueue(
|
DirectoryQueue directoryQueue = new DirectoryQueue(
|
||||||
configuration.getDirectoryConfiguration().getSqsConfiguration());
|
configuration.getDirectoryConfiguration().getSqsConfiguration());
|
||||||
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
||||||
|
|
|
@ -26,7 +26,6 @@ import net.sourceforge.argparse4j.inf.Subparser;
|
||||||
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
|
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
|
||||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||||
|
@ -46,9 +45,9 @@ import org.whispersystems.textsecuregcm.storage.MessagesManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
||||||
import org.whispersystems.textsecuregcm.storage.Profiles;
|
import org.whispersystems.textsecuregcm.storage.Profiles;
|
||||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
|
||||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||||
|
@ -102,6 +101,8 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
|
||||||
|
|
||||||
ExecutorService keyspaceNotificationDispatchExecutor = environment.lifecycle()
|
ExecutorService keyspaceNotificationDispatchExecutor = environment.lifecycle()
|
||||||
.executorService(name(getClass(), "keyspaceNotification-%d")).maxThreads(4).build();
|
.executorService(name(getClass(), "keyspaceNotification-%d")).maxThreads(4).build();
|
||||||
|
ExecutorService messageDeletionExecutor = environment.lifecycle()
|
||||||
|
.executorService(name(getClass(), "messageDeletion-%d")).maxThreads(4).build();
|
||||||
ExecutorService backupServiceExecutor = environment.lifecycle()
|
ExecutorService backupServiceExecutor = environment.lifecycle()
|
||||||
.executorService(name(getClass(), "backupService-%d")).maxThreads(8).minThreads(1).build();
|
.executorService(name(getClass(), "backupService-%d")).maxThreads(8).minThreads(1).build();
|
||||||
ExecutorService storageServiceExecutor = environment.lifecycle()
|
ExecutorService storageServiceExecutor = environment.lifecycle()
|
||||||
|
@ -161,15 +162,14 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
|
||||||
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
|
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
|
||||||
Keys keys = new Keys(dynamoDbClient,
|
Keys keys = new Keys(dynamoDbClient,
|
||||||
configuration.getDynamoDbTables().getKeys().getTableName());
|
configuration.getDynamoDbTables().getKeys().getTableName());
|
||||||
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient,
|
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
|
||||||
configuration.getDynamoDbTables().getMessages().getTableName(),
|
configuration.getDynamoDbTables().getMessages().getTableName(),
|
||||||
configuration.getDynamoDbTables().getMessages().getExpiration());
|
configuration.getDynamoDbTables().getMessages().getExpiration(),
|
||||||
|
messageDeletionExecutor);
|
||||||
FaultTolerantRedisCluster messageInsertCacheCluster = new FaultTolerantRedisCluster("message_insert_cluster",
|
FaultTolerantRedisCluster messageInsertCacheCluster = new FaultTolerantRedisCluster("message_insert_cluster",
|
||||||
configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources);
|
configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources);
|
||||||
FaultTolerantRedisCluster messageReadDeleteCluster = new FaultTolerantRedisCluster("message_read_delete_cluster",
|
FaultTolerantRedisCluster messageReadDeleteCluster = new FaultTolerantRedisCluster("message_read_delete_cluster",
|
||||||
configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources);
|
configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources);
|
||||||
FaultTolerantRedisCluster metricsCluster = new FaultTolerantRedisCluster("metrics_cluster",
|
|
||||||
configuration.getMetricsClusterConfiguration(), redisClusterClientResources);
|
|
||||||
FaultTolerantRedisCluster clientPresenceCluster = new FaultTolerantRedisCluster("client_presence",
|
FaultTolerantRedisCluster clientPresenceCluster = new FaultTolerantRedisCluster("client_presence",
|
||||||
configuration.getClientPresenceClusterConfiguration(), redisClusterClientResources);
|
configuration.getClientPresenceClusterConfiguration(), redisClusterClientResources);
|
||||||
SecureBackupClient secureBackupClient = new SecureBackupClient(backupCredentialsGenerator, backupServiceExecutor,
|
SecureBackupClient secureBackupClient = new SecureBackupClient(backupCredentialsGenerator, backupServiceExecutor,
|
||||||
|
@ -179,8 +179,7 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
|
||||||
ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster,
|
ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster,
|
||||||
Executors.newSingleThreadScheduledExecutor(), keyspaceNotificationDispatchExecutor);
|
Executors.newSingleThreadScheduledExecutor(), keyspaceNotificationDispatchExecutor);
|
||||||
MessagesCache messagesCache = new MessagesCache(messageInsertCacheCluster, messageReadDeleteCluster,
|
MessagesCache messagesCache = new MessagesCache(messageInsertCacheCluster, messageReadDeleteCluster,
|
||||||
keyspaceNotificationDispatchExecutor);
|
Clock.systemUTC(), keyspaceNotificationDispatchExecutor, messageDeletionExecutor);
|
||||||
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
|
|
||||||
DirectoryQueue directoryQueue = new DirectoryQueue(
|
DirectoryQueue directoryQueue = new DirectoryQueue(
|
||||||
configuration.getDirectoryConfiguration().getSqsConfiguration());
|
configuration.getDirectoryConfiguration().getSqsConfiguration());
|
||||||
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
local queueKey = KEYS[1]
|
local queueKey = KEYS[1]
|
||||||
local queueLockKey = KEYS[2]
|
local queueLockKey = KEYS[2]
|
||||||
local limit = ARGV[1]
|
local limit = ARGV[1]
|
||||||
|
local afterMessageId = ARGV[2]
|
||||||
|
|
||||||
local locked = redis.call("GET", queueLockKey)
|
local locked = redis.call("GET", queueLockKey)
|
||||||
|
|
||||||
|
@ -8,7 +9,8 @@ if locked then
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The range is inclusive
|
if afterMessageId == "null" then
|
||||||
|
-- An index range is inclusive
|
||||||
local min = 0
|
local min = 0
|
||||||
local max = limit - 1
|
local max = limit - 1
|
||||||
|
|
||||||
|
@ -17,3 +19,7 @@ if max < 0 then
|
||||||
end
|
end
|
||||||
|
|
||||||
return redis.call("ZRANGE", queueKey, min, max, "WITHSCORES")
|
return redis.call("ZRANGE", queueKey, min, max, "WITHSCORES")
|
||||||
|
else
|
||||||
|
-- note: this is deprecated in Redis 6.2, and should be migrated to zrange after the cluster is updated
|
||||||
|
return redis.call("ZRANGEBYSCORE", queueKey, "("..afterMessageId, "+inf", "WITHSCORES", "LIMIT", 0, limit)
|
||||||
|
end
|
||||||
|
|
|
@ -46,6 +46,7 @@ import java.util.Optional;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
@ -533,17 +534,25 @@ class MessageControllerTest {
|
||||||
UUID sourceUuid = UUID.randomUUID();
|
UUID sourceUuid = UUID.randomUUID();
|
||||||
|
|
||||||
UUID uuid1 = UUID.randomUUID();
|
UUID uuid1 = UUID.randomUUID();
|
||||||
when(messagesManager.delete(AuthHelper.VALID_UUID, 1, uuid1, null)).thenReturn(Optional.of(generateEnvelope(
|
when(messagesManager.delete(AuthHelper.VALID_UUID, 1, uuid1, null))
|
||||||
uuid1, Envelope.Type.CIPHERTEXT_VALUE,
|
.thenReturn(
|
||||||
timestamp, sourceUuid, 1, AuthHelper.VALID_UUID, null, "hi".getBytes(), 0)));
|
CompletableFuture.completedFuture(Optional.of(generateEnvelope(uuid1, Envelope.Type.CIPHERTEXT_VALUE,
|
||||||
|
timestamp, sourceUuid, 1, AuthHelper.VALID_UUID, null, "hi".getBytes(), 0))));
|
||||||
|
|
||||||
UUID uuid2 = UUID.randomUUID();
|
UUID uuid2 = UUID.randomUUID();
|
||||||
when(messagesManager.delete(AuthHelper.VALID_UUID, 1, uuid2, null)).thenReturn(Optional.of(generateEnvelope(
|
when(messagesManager.delete(AuthHelper.VALID_UUID, 1, uuid2, null))
|
||||||
|
.thenReturn(
|
||||||
|
CompletableFuture.completedFuture(Optional.of(generateEnvelope(
|
||||||
uuid2, Envelope.Type.SERVER_DELIVERY_RECEIPT_VALUE,
|
uuid2, Envelope.Type.SERVER_DELIVERY_RECEIPT_VALUE,
|
||||||
System.currentTimeMillis(), sourceUuid, 1, AuthHelper.VALID_UUID, null, null, 0)));
|
System.currentTimeMillis(), sourceUuid, 1, AuthHelper.VALID_UUID, null, null, 0))));
|
||||||
|
|
||||||
UUID uuid3 = UUID.randomUUID();
|
UUID uuid3 = UUID.randomUUID();
|
||||||
when(messagesManager.delete(AuthHelper.VALID_UUID, 1, uuid3, null)).thenReturn(Optional.empty());
|
when(messagesManager.delete(AuthHelper.VALID_UUID, 1, uuid3, null))
|
||||||
|
.thenReturn(CompletableFuture.completedFuture(Optional.empty()));
|
||||||
|
|
||||||
|
UUID uuid4 = UUID.randomUUID();
|
||||||
|
when(messagesManager.delete(AuthHelper.VALID_UUID, 1, uuid4, null))
|
||||||
|
.thenReturn(CompletableFuture.failedFuture(new RuntimeException("Oh No")));
|
||||||
|
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
.target(String.format("/v1/messages/uuid/%s", uuid1))
|
.target(String.format("/v1/messages/uuid/%s", uuid1))
|
||||||
|
@ -573,6 +582,15 @@ class MessageControllerTest {
|
||||||
assertThat("Good Response Code", response.getStatus(), is(equalTo(204)));
|
assertThat("Good Response Code", response.getStatus(), is(equalTo(204)));
|
||||||
verifyNoMoreInteractions(receiptSender);
|
verifyNoMoreInteractions(receiptSender);
|
||||||
|
|
||||||
|
response = resources.getJerseyTest()
|
||||||
|
.target(String.format("/v1/messages/uuid/%s", uuid4))
|
||||||
|
.request()
|
||||||
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
assertThat("Bad Response Code", response.getStatus(), is(equalTo(500)));
|
||||||
|
verifyNoMoreInteractions(receiptSender);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -700,7 +718,7 @@ class MessageControllerTest {
|
||||||
.target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID))
|
.target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID))
|
||||||
.request()
|
.request()
|
||||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||||
.header("User-Agent", "FIXME")
|
.header("User-Agent", "Test-UA")
|
||||||
.put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture(payloadFilename), IncomingMessageList.class),
|
.put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture(payloadFilename), IncomingMessageList.class),
|
||||||
MediaType.APPLICATION_JSON_TYPE));
|
MediaType.APPLICATION_JSON_TYPE));
|
||||||
|
|
||||||
|
|
|
@ -13,18 +13,29 @@ import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import io.lettuce.core.FlushMode;
|
||||||
|
import io.lettuce.core.RedisFuture;
|
||||||
import io.lettuce.core.RedisNoScriptException;
|
import io.lettuce.core.RedisNoScriptException;
|
||||||
import io.lettuce.core.ScriptOutputType;
|
import io.lettuce.core.ScriptOutputType;
|
||||||
|
import io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands;
|
||||||
|
import io.lettuce.core.cluster.api.reactive.RedisAdvancedClusterReactiveCommands;
|
||||||
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
||||||
|
import io.lettuce.core.protocol.AsyncCommand;
|
||||||
|
import io.lettuce.core.protocol.RedisCommand;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource;
|
||||||
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
|
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
public class ClusterLuaScriptTest {
|
class ClusterLuaScriptTest {
|
||||||
|
|
||||||
@RegisterExtension
|
@RegisterExtension
|
||||||
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
||||||
|
@ -32,7 +43,7 @@ public class ClusterLuaScriptTest {
|
||||||
@Test
|
@Test
|
||||||
void testExecute() {
|
void testExecute() {
|
||||||
final RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
|
final RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
|
||||||
final FaultTolerantRedisCluster mockCluster = RedisClusterHelper.buildMockRedisCluster(commands);
|
final FaultTolerantRedisCluster mockCluster = RedisClusterHelper.builder().stringCommands(commands).build();
|
||||||
|
|
||||||
final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])";
|
final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])";
|
||||||
final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;
|
final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;
|
||||||
|
@ -51,7 +62,7 @@ public class ClusterLuaScriptTest {
|
||||||
@Test
|
@Test
|
||||||
void testExecuteScriptNotLoaded() {
|
void testExecuteScriptNotLoaded() {
|
||||||
final RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
|
final RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
|
||||||
final FaultTolerantRedisCluster mockCluster = RedisClusterHelper.buildMockRedisCluster(commands);
|
final FaultTolerantRedisCluster mockCluster = RedisClusterHelper.builder().stringCommands(commands).build();
|
||||||
|
|
||||||
final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])";
|
final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])";
|
||||||
final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;
|
final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;
|
||||||
|
@ -71,8 +82,10 @@ public class ClusterLuaScriptTest {
|
||||||
void testExecuteBinaryScriptNotLoaded() {
|
void testExecuteBinaryScriptNotLoaded() {
|
||||||
final RedisAdvancedClusterCommands<String, String> stringCommands = mock(RedisAdvancedClusterCommands.class);
|
final RedisAdvancedClusterCommands<String, String> stringCommands = mock(RedisAdvancedClusterCommands.class);
|
||||||
final RedisAdvancedClusterCommands<byte[], byte[]> binaryCommands = mock(RedisAdvancedClusterCommands.class);
|
final RedisAdvancedClusterCommands<byte[], byte[]> binaryCommands = mock(RedisAdvancedClusterCommands.class);
|
||||||
final FaultTolerantRedisCluster mockCluster =
|
final FaultTolerantRedisCluster mockCluster = RedisClusterHelper.builder()
|
||||||
RedisClusterHelper.buildMockRedisCluster(stringCommands, binaryCommands);
|
.stringCommands(stringCommands)
|
||||||
|
.binaryCommands(binaryCommands)
|
||||||
|
.build();
|
||||||
|
|
||||||
final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])";
|
final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])";
|
||||||
final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;
|
final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;
|
||||||
|
@ -85,17 +98,85 @@ public class ClusterLuaScriptTest {
|
||||||
luaScript.executeBinary(keys, values);
|
luaScript.executeBinary(keys, values);
|
||||||
|
|
||||||
verify(binaryCommands).eval(script, scriptOutputType, keys.toArray(new byte[0][]), values.toArray(new byte[0][]));
|
verify(binaryCommands).eval(script, scriptOutputType, keys.toArray(new byte[0][]), values.toArray(new byte[0][]));
|
||||||
verify(binaryCommands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new byte[0][]), values.toArray(new byte[0][]));
|
verify(binaryCommands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new byte[0][]),
|
||||||
|
values.toArray(new byte[0][]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testExecuteRealCluster() {
|
void testExecuteBinaryAsyncScriptNotLoaded() throws Exception {
|
||||||
|
final RedisAdvancedClusterAsyncCommands<byte[], byte[]> binaryAsyncCommands =
|
||||||
|
mock(RedisAdvancedClusterAsyncCommands.class);
|
||||||
|
final FaultTolerantRedisCluster mockCluster =
|
||||||
|
RedisClusterHelper.builder().binaryAsyncCommands(binaryAsyncCommands).build();
|
||||||
|
|
||||||
|
final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])";
|
||||||
|
final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;
|
||||||
|
final List<byte[]> keys = List.of("key".getBytes(StandardCharsets.UTF_8));
|
||||||
|
final List<byte[]> values = List.of("value".getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
final AsyncCommand<?, ?, ?> evalShaFailure = new AsyncCommand<>(mock(RedisCommand.class));
|
||||||
|
evalShaFailure.completeExceptionally(new RedisNoScriptException("OH NO"));
|
||||||
|
|
||||||
|
final AsyncCommand<?, ?, ?> evalSuccess = new AsyncCommand<>(mock(RedisCommand.class));
|
||||||
|
evalSuccess.complete();
|
||||||
|
|
||||||
|
when(binaryAsyncCommands.evalsha(any(), any(), any(), any())).thenReturn((RedisFuture<Object>) evalShaFailure);
|
||||||
|
when(binaryAsyncCommands.eval(anyString(), any(), any(), any())).thenReturn((RedisFuture<Object>) evalSuccess);
|
||||||
|
|
||||||
|
final ClusterLuaScript luaScript = new ClusterLuaScript(mockCluster, script, scriptOutputType);
|
||||||
|
luaScript.executeBinaryAsync(keys, values).get(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
verify(binaryAsyncCommands).eval(script, scriptOutputType, keys.toArray(new byte[0][]),
|
||||||
|
values.toArray(new byte[0][]));
|
||||||
|
verify(binaryAsyncCommands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new byte[0][]),
|
||||||
|
values.toArray(new byte[0][]));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testExecuteBinaryReactiveScriptNotLoaded() {
|
||||||
|
final RedisAdvancedClusterReactiveCommands<byte[], byte[]> binaryReactiveCommands =
|
||||||
|
mock(RedisAdvancedClusterReactiveCommands.class);
|
||||||
|
final FaultTolerantRedisCluster mockCluster = RedisClusterHelper.builder()
|
||||||
|
.binaryReactiveCommands(binaryReactiveCommands).build();
|
||||||
|
|
||||||
|
final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])";
|
||||||
|
final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;
|
||||||
|
final List<byte[]> keys = List.of("key".getBytes(StandardCharsets.UTF_8));
|
||||||
|
final List<byte[]> values = List.of("value".getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
when(binaryReactiveCommands.evalsha(any(), any(), any(), any()))
|
||||||
|
.thenReturn(Flux.error(new RedisNoScriptException("OH NO")));
|
||||||
|
when(binaryReactiveCommands.eval(anyString(), any(), any(), any())).thenReturn(Flux.just("ok"));
|
||||||
|
|
||||||
|
final ClusterLuaScript luaScript = new ClusterLuaScript(mockCluster, script, scriptOutputType);
|
||||||
|
luaScript.executeBinaryReactive(keys, values).blockLast(Duration.ofSeconds(5));
|
||||||
|
|
||||||
|
verify(binaryReactiveCommands).eval(script, scriptOutputType, keys.toArray(new byte[0][]),
|
||||||
|
values.toArray(new byte[0][]));
|
||||||
|
verify(binaryReactiveCommands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new byte[0][]),
|
||||||
|
values.toArray(new byte[0][]));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(ExecuteMode.class)
|
||||||
|
void testExecuteRealCluster(final ExecuteMode mode) throws Exception {
|
||||||
|
REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(c -> c.sync().scriptFlush(FlushMode.SYNC));
|
||||||
|
REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(c -> c.sync().configResetstat());
|
||||||
|
|
||||||
final ClusterLuaScript script = new ClusterLuaScript(REDIS_CLUSTER_EXTENSION.getRedisCluster(),
|
final ClusterLuaScript script = new ClusterLuaScript(REDIS_CLUSTER_EXTENSION.getRedisCluster(),
|
||||||
"return 2;",
|
"return 2;",
|
||||||
ScriptOutputType.INTEGER);
|
ScriptOutputType.INTEGER);
|
||||||
|
|
||||||
for (int i = 0; i < 7; i++) {
|
for (int i = 0; i < 7; i++) {
|
||||||
assertEquals(2L, script.execute(Collections.emptyList(), Collections.emptyList()));
|
final long actual = switch (mode) {
|
||||||
|
case SYNC -> (long) script.execute(Collections.emptyList(), Collections.emptyList());
|
||||||
|
case ASYNC ->
|
||||||
|
(long) script.executeAsync(Collections.emptyList(), Collections.emptyList()).get(5, TimeUnit.SECONDS);
|
||||||
|
case REACTIVE -> (long) script.executeReactive(Collections.emptyList(), Collections.emptyList())
|
||||||
|
.blockLast(Duration.ofSeconds(5));
|
||||||
|
};
|
||||||
|
|
||||||
|
assertEquals(2L, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
final int evalCount = REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(connection -> {
|
final int evalCount = REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(connection -> {
|
||||||
|
@ -120,4 +201,11 @@ public class ClusterLuaScriptTest {
|
||||||
|
|
||||||
assertEquals(1, evalCount);
|
assertEquals(1, evalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum ExecuteMode {
|
||||||
|
SYNC,
|
||||||
|
ASYNC,
|
||||||
|
REACTIVE
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -155,7 +155,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
|
||||||
accountsManager = new AccountsManager(
|
accountsManager = new AccountsManager(
|
||||||
accounts,
|
accounts,
|
||||||
phoneNumberIdentifiers,
|
phoneNumberIdentifiers,
|
||||||
RedisClusterHelper.buildMockRedisCluster(commands),
|
RedisClusterHelper.builder().stringCommands(commands).build(),
|
||||||
deletedAccountsManager,
|
deletedAccountsManager,
|
||||||
mock(DirectoryQueue.class),
|
mock(DirectoryQueue.class),
|
||||||
mock(Keys.class),
|
mock(Keys.class),
|
||||||
|
|
|
@ -147,7 +147,7 @@ class AccountsManagerTest {
|
||||||
accountsManager = new AccountsManager(
|
accountsManager = new AccountsManager(
|
||||||
accounts,
|
accounts,
|
||||||
phoneNumberIdentifiers,
|
phoneNumberIdentifiers,
|
||||||
RedisClusterHelper.buildMockRedisCluster(commands),
|
RedisClusterHelper.builder().stringCommands(commands).build(),
|
||||||
deletedAccountsManager,
|
deletedAccountsManager,
|
||||||
directoryQueue,
|
directoryQueue,
|
||||||
keys,
|
keys,
|
||||||
|
|
|
@ -78,7 +78,14 @@ public class DynamoDbExtension implements BeforeEachCallback, AfterEachCallback
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void afterEach(ExtensionContext context) throws Exception {
|
public void afterEach(ExtensionContext context) {
|
||||||
|
stopServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For use in integration tests that want to test resiliency/error handling
|
||||||
|
*/
|
||||||
|
public void stopServer() {
|
||||||
try {
|
try {
|
||||||
server.stop();
|
server.stop();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
|
@ -15,6 +15,7 @@ import com.google.protobuf.ByteString;
|
||||||
import com.google.protobuf.InvalidProtocolBufferException;
|
import com.google.protobuf.InvalidProtocolBufferException;
|
||||||
import io.lettuce.core.cluster.SlotHash;
|
import io.lettuce.core.cluster.SlotHash;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
@ -32,7 +33,6 @@ import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||||
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
|
||||||
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
|
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
|
||||||
import org.whispersystems.textsecuregcm.tests.util.MessagesDynamoDbExtension;
|
import org.whispersystems.textsecuregcm.tests.util.MessagesDynamoDbExtension;
|
||||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||||
|
@ -47,6 +47,7 @@ class MessagePersisterIntegrationTest {
|
||||||
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
||||||
|
|
||||||
private ExecutorService notificationExecutorService;
|
private ExecutorService notificationExecutorService;
|
||||||
|
private ExecutorService messageDeletionExecutorService;
|
||||||
private MessagesCache messagesCache;
|
private MessagesCache messagesCache;
|
||||||
private MessagesManager messagesManager;
|
private MessagesManager messagesManager;
|
||||||
private MessagePersister messagePersister;
|
private MessagePersister messagePersister;
|
||||||
|
@ -66,13 +67,16 @@ class MessagePersisterIntegrationTest {
|
||||||
|
|
||||||
when(dynamicConfigurationManager.getConfiguration()).thenReturn(new DynamicConfiguration());
|
when(dynamicConfigurationManager.getConfiguration()).thenReturn(new DynamicConfiguration());
|
||||||
|
|
||||||
|
messageDeletionExecutorService = Executors.newSingleThreadExecutor();
|
||||||
final MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbExtension.getDynamoDbClient(),
|
final MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbExtension.getDynamoDbClient(),
|
||||||
MessagesDynamoDbExtension.TABLE_NAME, Duration.ofDays(14));
|
dynamoDbExtension.getDynamoDbAsyncClient(), MessagesDynamoDbExtension.TABLE_NAME, Duration.ofDays(14),
|
||||||
|
messageDeletionExecutorService);
|
||||||
final AccountsManager accountsManager = mock(AccountsManager.class);
|
final AccountsManager accountsManager = mock(AccountsManager.class);
|
||||||
|
|
||||||
notificationExecutorService = Executors.newSingleThreadExecutor();
|
notificationExecutorService = Executors.newSingleThreadExecutor();
|
||||||
messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),
|
messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),
|
||||||
REDIS_CLUSTER_EXTENSION.getRedisCluster(), notificationExecutorService);
|
REDIS_CLUSTER_EXTENSION.getRedisCluster(), Clock.systemUTC(), notificationExecutorService,
|
||||||
|
messageDeletionExecutorService);
|
||||||
messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, mock(ReportMessageManager.class));
|
messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, mock(ReportMessageManager.class));
|
||||||
messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager,
|
messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager,
|
||||||
dynamicConfigurationManager, PERSIST_DELAY);
|
dynamicConfigurationManager, PERSIST_DELAY);
|
||||||
|
@ -94,6 +98,9 @@ class MessagePersisterIntegrationTest {
|
||||||
void tearDown() throws Exception {
|
void tearDown() throws Exception {
|
||||||
notificationExecutorService.shutdown();
|
notificationExecutorService.shutdown();
|
||||||
notificationExecutorService.awaitTermination(15, TimeUnit.SECONDS);
|
notificationExecutorService.awaitTermination(15, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
messageDeletionExecutorService.shutdown();
|
||||||
|
messageDeletionExecutorService.awaitTermination(15, TimeUnit.SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
@ -22,6 +22,7 @@ import static org.mockito.Mockito.when;
|
||||||
import com.google.protobuf.ByteString;
|
import com.google.protobuf.ByteString;
|
||||||
import io.lettuce.core.cluster.SlotHash;
|
import io.lettuce.core.cluster.SlotHash;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -46,7 +47,7 @@ class MessagePersisterTest {
|
||||||
@RegisterExtension
|
@RegisterExtension
|
||||||
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
||||||
|
|
||||||
private ExecutorService notificationExecutorService;
|
private ExecutorService sharedExecutorService;
|
||||||
private MessagesCache messagesCache;
|
private MessagesCache messagesCache;
|
||||||
private MessagesDynamoDb messagesDynamoDb;
|
private MessagesDynamoDb messagesDynamoDb;
|
||||||
private MessagePersister messagePersister;
|
private MessagePersister messagePersister;
|
||||||
|
@ -74,9 +75,9 @@ class MessagePersisterTest {
|
||||||
when(account.getNumber()).thenReturn(DESTINATION_ACCOUNT_NUMBER);
|
when(account.getNumber()).thenReturn(DESTINATION_ACCOUNT_NUMBER);
|
||||||
when(dynamicConfigurationManager.getConfiguration()).thenReturn(new DynamicConfiguration());
|
when(dynamicConfigurationManager.getConfiguration()).thenReturn(new DynamicConfiguration());
|
||||||
|
|
||||||
notificationExecutorService = Executors.newSingleThreadExecutor();
|
sharedExecutorService = Executors.newSingleThreadExecutor();
|
||||||
messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),
|
messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),
|
||||||
REDIS_CLUSTER_EXTENSION.getRedisCluster(), notificationExecutorService);
|
REDIS_CLUSTER_EXTENSION.getRedisCluster(), Clock.systemUTC(), sharedExecutorService, sharedExecutorService);
|
||||||
messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager,
|
messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager,
|
||||||
dynamicConfigurationManager, PERSIST_DELAY);
|
dynamicConfigurationManager, PERSIST_DELAY);
|
||||||
|
|
||||||
|
@ -88,7 +89,7 @@ class MessagePersisterTest {
|
||||||
messagesDynamoDb.store(messages, destinationUuid, destinationDeviceId);
|
messagesDynamoDb.store(messages, destinationUuid, destinationDeviceId);
|
||||||
|
|
||||||
for (final MessageProtos.Envelope message : messages) {
|
for (final MessageProtos.Envelope message : messages) {
|
||||||
messagesCache.remove(destinationUuid, destinationDeviceId, UUID.fromString(message.getServerGuid()));
|
messagesCache.remove(destinationUuid, destinationDeviceId, UUID.fromString(message.getServerGuid())).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
@ -97,8 +98,8 @@ class MessagePersisterTest {
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void tearDown() throws Exception {
|
void tearDown() throws Exception {
|
||||||
notificationExecutorService.shutdown();
|
sharedExecutorService.shutdown();
|
||||||
notificationExecutorService.awaitTermination(1, TimeUnit.SECONDS);
|
sharedExecutorService.awaitTermination(1, TimeUnit.SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright 2013-2021 Signal Messenger, LLC
|
* Copyright 2013-2022 Signal Messenger, LLC
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -7,16 +7,34 @@ package org.whispersystems.textsecuregcm.storage;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
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.assertTimeoutPreemptively;
|
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.atLeast;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import com.google.protobuf.ByteString;
|
import com.google.protobuf.ByteString;
|
||||||
|
import io.lettuce.core.RedisFuture;
|
||||||
import io.lettuce.core.cluster.SlotHash;
|
import io.lettuce.core.cluster.SlotHash;
|
||||||
|
import io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands;
|
||||||
|
import io.lettuce.core.cluster.api.reactive.RedisAdvancedClusterReactiveCommands;
|
||||||
|
import io.lettuce.core.protocol.AsyncCommand;
|
||||||
|
import io.lettuce.core.protocol.RedisCommand;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Deque;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
|
@ -26,29 +44,41 @@ import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import org.apache.commons.lang3.RandomStringUtils;
|
import org.apache.commons.lang3.RandomStringUtils;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||||
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||||
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
|
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
|
||||||
|
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.FluxSink;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
class MessagesCacheTest {
|
class MessagesCacheTest {
|
||||||
|
|
||||||
|
private final Random random = new Random();
|
||||||
|
private long serialTimestamp = 0;
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class WithRealCluster {
|
||||||
|
|
||||||
@RegisterExtension
|
@RegisterExtension
|
||||||
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
||||||
|
|
||||||
private ExecutorService notificationExecutorService;
|
private ExecutorService sharedExecutorService;
|
||||||
private MessagesCache messagesCache;
|
private MessagesCache messagesCache;
|
||||||
|
|
||||||
private final Random random = new Random();
|
|
||||||
private long serialTimestamp = 0;
|
|
||||||
|
|
||||||
private static final UUID DESTINATION_UUID = UUID.randomUUID();
|
private static final UUID DESTINATION_UUID = UUID.randomUUID();
|
||||||
private static final int DESTINATION_DEVICE_ID = 7;
|
private static final int DESTINATION_DEVICE_ID = 7;
|
||||||
|
|
||||||
|
@ -60,8 +90,10 @@ class MessagesCacheTest {
|
||||||
connection.sync().upstream().commands().configSet("notify-keyspace-events", "K$glz");
|
connection.sync().upstream().commands().configSet("notify-keyspace-events", "K$glz");
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationExecutorService = Executors.newSingleThreadExecutor();
|
sharedExecutorService = Executors.newSingleThreadExecutor();
|
||||||
messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(), REDIS_CLUSTER_EXTENSION.getRedisCluster(), notificationExecutorService);
|
messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),
|
||||||
|
REDIS_CLUSTER_EXTENSION.getRedisCluster(), Clock.systemUTC(), sharedExecutorService,
|
||||||
|
sharedExecutorService);
|
||||||
|
|
||||||
messagesCache.start();
|
messagesCache.start();
|
||||||
}
|
}
|
||||||
|
@ -70,8 +102,8 @@ class MessagesCacheTest {
|
||||||
void tearDown() throws Exception {
|
void tearDown() throws Exception {
|
||||||
messagesCache.stop();
|
messagesCache.stop();
|
||||||
|
|
||||||
notificationExecutorService.shutdown();
|
sharedExecutorService.shutdown();
|
||||||
notificationExecutorService.awaitTermination(1, TimeUnit.SECONDS);
|
sharedExecutorService.awaitTermination(1, TimeUnit.SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
|
@ -87,7 +119,8 @@ class MessagesCacheTest {
|
||||||
final UUID duplicateGuid = UUID.randomUUID();
|
final UUID duplicateGuid = UUID.randomUUID();
|
||||||
final MessageProtos.Envelope duplicateMessage = generateRandomMessage(duplicateGuid, false);
|
final MessageProtos.Envelope duplicateMessage = generateRandomMessage(duplicateGuid, false);
|
||||||
|
|
||||||
final long firstId = messagesCache.insert(duplicateGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, duplicateMessage);
|
final long firstId = messagesCache.insert(duplicateGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
||||||
|
duplicateMessage);
|
||||||
final long secondId = messagesCache.insert(duplicateGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
final long secondId = messagesCache.insert(duplicateGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
||||||
duplicateMessage);
|
duplicateMessage);
|
||||||
|
|
||||||
|
@ -96,23 +129,24 @@ class MessagesCacheTest {
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(booleans = {true, false})
|
@ValueSource(booleans = {true, false})
|
||||||
void testRemoveByUUID(final boolean sealedSender) {
|
void testRemoveByUUID(final boolean sealedSender) throws Exception {
|
||||||
final UUID messageGuid = UUID.randomUUID();
|
final UUID messageGuid = UUID.randomUUID();
|
||||||
|
|
||||||
assertEquals(Optional.empty(), messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageGuid));
|
assertEquals(Optional.empty(),
|
||||||
|
messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageGuid).get(5, TimeUnit.SECONDS));
|
||||||
|
|
||||||
final MessageProtos.Envelope message = generateRandomMessage(messageGuid, sealedSender);
|
final MessageProtos.Envelope message = generateRandomMessage(messageGuid, sealedSender);
|
||||||
|
|
||||||
messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message);
|
messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message);
|
||||||
final Optional<MessageProtos.Envelope> maybeRemovedMessage = messagesCache.remove(DESTINATION_UUID,
|
final Optional<MessageProtos.Envelope> maybeRemovedMessage = messagesCache.remove(DESTINATION_UUID,
|
||||||
DESTINATION_DEVICE_ID, messageGuid);
|
DESTINATION_DEVICE_ID, messageGuid).get(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
assertEquals(Optional.of(message), maybeRemovedMessage);
|
assertEquals(Optional.of(message), maybeRemovedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(booleans = {true, false})
|
@ValueSource(booleans = {true, false})
|
||||||
void testRemoveBatchByUUID(final boolean sealedSender) {
|
void testRemoveBatchByUUID(final boolean sealedSender) throws Exception {
|
||||||
final int messageCount = 10;
|
final int messageCount = 10;
|
||||||
|
|
||||||
final List<MessageProtos.Envelope> messagesToRemove = new ArrayList<>(messageCount);
|
final List<MessageProtos.Envelope> messagesToRemove = new ArrayList<>(messageCount);
|
||||||
|
@ -125,19 +159,21 @@ class MessagesCacheTest {
|
||||||
|
|
||||||
assertEquals(Collections.emptyList(), messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
assertEquals(Collections.emptyList(), messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
||||||
messagesToRemove.stream().map(message -> UUID.fromString(message.getServerGuid()))
|
messagesToRemove.stream().map(message -> UUID.fromString(message.getServerGuid()))
|
||||||
.collect(Collectors.toList())));
|
.collect(Collectors.toList())).get(5, TimeUnit.SECONDS));
|
||||||
|
|
||||||
for (final MessageProtos.Envelope message : messagesToRemove) {
|
for (final MessageProtos.Envelope message : messagesToRemove) {
|
||||||
messagesCache.insert(UUID.fromString(message.getServerGuid()), DESTINATION_UUID, DESTINATION_DEVICE_ID, message);
|
messagesCache.insert(UUID.fromString(message.getServerGuid()), DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
||||||
|
message);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final MessageProtos.Envelope message : messagesToPreserve) {
|
for (final MessageProtos.Envelope message : messagesToPreserve) {
|
||||||
messagesCache.insert(UUID.fromString(message.getServerGuid()), DESTINATION_UUID, DESTINATION_DEVICE_ID, message);
|
messagesCache.insert(UUID.fromString(message.getServerGuid()), DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
||||||
|
message);
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<MessageProtos.Envelope> removedMessages = messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
final List<MessageProtos.Envelope> removedMessages = messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
||||||
messagesToRemove.stream().map(message -> UUID.fromString(message.getServerGuid()))
|
messagesToRemove.stream().map(message -> UUID.fromString(message.getServerGuid()))
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList())).get(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
assertEquals(messagesToRemove, removedMessages);
|
assertEquals(messagesToRemove, removedMessages);
|
||||||
assertEquals(messagesToPreserve,
|
assertEquals(messagesToPreserve,
|
||||||
|
@ -157,7 +193,7 @@ class MessagesCacheTest {
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(booleans = {true, false})
|
@ValueSource(booleans = {true, false})
|
||||||
void testGetMessages(final boolean sealedSender) {
|
void testGetMessages(final boolean sealedSender) throws Exception {
|
||||||
final int messageCount = 100;
|
final int messageCount = 100;
|
||||||
|
|
||||||
final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(messageCount);
|
final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(messageCount);
|
||||||
|
@ -170,7 +206,97 @@ class MessagesCacheTest {
|
||||||
expectedMessages.add(message);
|
expectedMessages.add(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
assertEquals(expectedMessages, messagesCache.get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount));
|
assertEquals(expectedMessages, get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount));
|
||||||
|
|
||||||
|
messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
||||||
|
expectedMessages.stream()
|
||||||
|
.map(MessageProtos.Envelope::getServerGuid)
|
||||||
|
.map(UUID::fromString)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
|
||||||
|
final UUID message1Guid = UUID.randomUUID();
|
||||||
|
final MessageProtos.Envelope message1 = generateRandomMessage(message1Guid, sealedSender);
|
||||||
|
messagesCache.insert(message1Guid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message1);
|
||||||
|
final List<MessageProtos.Envelope> get1 = get(DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
||||||
|
1);
|
||||||
|
assertEquals(List.of(message1), get1);
|
||||||
|
|
||||||
|
messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID, message1Guid).get(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
final UUID message2Guid = UUID.randomUUID();
|
||||||
|
final MessageProtos.Envelope message2 = generateRandomMessage(message2Guid, sealedSender);
|
||||||
|
|
||||||
|
messagesCache.insert(message2Guid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message2);
|
||||||
|
|
||||||
|
assertEquals(List.of(message2), get(DESTINATION_UUID, DESTINATION_DEVICE_ID, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = {true, false})
|
||||||
|
void testGetMessagesPublisher(final boolean expectStale) throws Exception {
|
||||||
|
final int messageCount = 214;
|
||||||
|
|
||||||
|
final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(messageCount);
|
||||||
|
|
||||||
|
for (int i = 0; i < messageCount; i++) {
|
||||||
|
final UUID messageGuid = UUID.randomUUID();
|
||||||
|
final MessageProtos.Envelope message = generateRandomMessage(messageGuid, true);
|
||||||
|
messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message);
|
||||||
|
|
||||||
|
expectedMessages.add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
final UUID ephemeralMessageGuid = UUID.randomUUID();
|
||||||
|
final MessageProtos.Envelope ephemeralMessage = generateRandomMessage(ephemeralMessageGuid, true)
|
||||||
|
.toBuilder().setEphemeral(true).build();
|
||||||
|
messagesCache.insert(ephemeralMessageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, ephemeralMessage);
|
||||||
|
|
||||||
|
final Clock cacheClock;
|
||||||
|
if (expectStale) {
|
||||||
|
cacheClock = Clock.fixed(Instant.ofEpochMilli(serialTimestamp + 1),
|
||||||
|
ZoneId.of("Etc/UTC"));
|
||||||
|
} else {
|
||||||
|
cacheClock = Clock.fixed(
|
||||||
|
Instant.ofEpochMilli(serialTimestamp + 1).plus(MessagesCache.MAX_EPHEMERAL_MESSAGE_DELAY),
|
||||||
|
ZoneId.of("Etc/UTC"));
|
||||||
|
}
|
||||||
|
|
||||||
|
final MessagesCache messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),
|
||||||
|
REDIS_CLUSTER_EXTENSION.getRedisCluster(),
|
||||||
|
cacheClock,
|
||||||
|
sharedExecutorService,
|
||||||
|
sharedExecutorService);
|
||||||
|
|
||||||
|
final List<MessageProtos.Envelope> actualMessages = Flux.from(
|
||||||
|
messagesCache.get(DESTINATION_UUID, DESTINATION_DEVICE_ID))
|
||||||
|
.collectList()
|
||||||
|
.block(Duration.ofSeconds(5));
|
||||||
|
|
||||||
|
if (expectStale) {
|
||||||
|
final List<MessageProtos.Envelope> expectedAllMessages = new ArrayList<>() {{
|
||||||
|
addAll(expectedMessages);
|
||||||
|
add(ephemeralMessage);
|
||||||
|
}};
|
||||||
|
|
||||||
|
assertEquals(expectedAllMessages, actualMessages);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
assertEquals(expectedMessages, actualMessages);
|
||||||
|
|
||||||
|
// delete all of these messages and call `getAll()`, to confirm that ephemeral messages have been discarded
|
||||||
|
CompletableFuture.allOf(actualMessages.stream()
|
||||||
|
.map(message -> messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
||||||
|
UUID.fromString(message.getServerGuid())))
|
||||||
|
.toArray(CompletableFuture<?>[]::new))
|
||||||
|
.get(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
final List<MessageProtos.Envelope> messages = messagesCache.getAllMessages(DESTINATION_UUID,
|
||||||
|
DESTINATION_DEVICE_ID)
|
||||||
|
.collectList()
|
||||||
|
.toFuture().get(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
assertTrue(messages.isEmpty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
|
@ -189,8 +315,8 @@ class MessagesCacheTest {
|
||||||
|
|
||||||
messagesCache.clear(DESTINATION_UUID, DESTINATION_DEVICE_ID);
|
messagesCache.clear(DESTINATION_UUID, DESTINATION_DEVICE_ID);
|
||||||
|
|
||||||
assertEquals(Collections.emptyList(), messagesCache.get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount));
|
assertEquals(Collections.emptyList(), get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount));
|
||||||
assertEquals(messageCount, messagesCache.get(DESTINATION_UUID, DESTINATION_DEVICE_ID + 1, messageCount).size());
|
assertEquals(messageCount, get(DESTINATION_UUID, DESTINATION_DEVICE_ID + 1, messageCount).size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
|
@ -209,30 +335,8 @@ class MessagesCacheTest {
|
||||||
|
|
||||||
messagesCache.clear(DESTINATION_UUID);
|
messagesCache.clear(DESTINATION_UUID);
|
||||||
|
|
||||||
assertEquals(Collections.emptyList(), messagesCache.get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount));
|
assertEquals(Collections.emptyList(), get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount));
|
||||||
assertEquals(Collections.emptyList(), messagesCache.get(DESTINATION_UUID, DESTINATION_DEVICE_ID + 1, messageCount));
|
assertEquals(Collections.emptyList(), get(DESTINATION_UUID, DESTINATION_DEVICE_ID + 1, messageCount));
|
||||||
}
|
|
||||||
|
|
||||||
private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid, final boolean sealedSender) {
|
|
||||||
return generateRandomMessage(messageGuid, sealedSender, serialTimestamp++);
|
|
||||||
}
|
|
||||||
|
|
||||||
private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid, final boolean sealedSender,
|
|
||||||
final long timestamp) {
|
|
||||||
final MessageProtos.Envelope.Builder envelopeBuilder = MessageProtos.Envelope.newBuilder()
|
|
||||||
.setTimestamp(timestamp)
|
|
||||||
.setServerTimestamp(timestamp)
|
|
||||||
.setContent(ByteString.copyFromUtf8(RandomStringUtils.randomAlphanumeric(256)))
|
|
||||||
.setType(MessageProtos.Envelope.Type.CIPHERTEXT)
|
|
||||||
.setServerGuid(messageGuid.toString())
|
|
||||||
.setDestinationUuid(UUID.randomUUID().toString());
|
|
||||||
|
|
||||||
if (!sealedSender) {
|
|
||||||
envelopeBuilder.setSourceDevice(random.nextInt(256))
|
|
||||||
.setSourceUuid(UUID.randomUUID().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
return envelopeBuilder.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -359,7 +463,8 @@ class MessagesCacheTest {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper class that implements {@link MessageAvailabilityListener#handleNewMessagesAvailable()} by always returning
|
* Helper class that implements {@link MessageAvailabilityListener#handleNewMessagesAvailable()} by always returning
|
||||||
* {@code false}. Its {@code counter} field tracks how many times {@code handleNewMessagesAvailable} has been called.
|
* {@code false}. Its {@code counter} field tracks how many times {@code handleNewMessagesAvailable} has been
|
||||||
|
* called.
|
||||||
* <p>
|
* <p>
|
||||||
* It uses a {@link CompletableFuture} to signal that it has received a “messages available” callback for the first
|
* It uses a {@link CompletableFuture} to signal that it has received a “messages available” callback for the first
|
||||||
* time.
|
* time.
|
||||||
|
@ -409,7 +514,7 @@ class MessagesCacheTest {
|
||||||
// Avoid a race condition by blocking on the message handled future *and* the current notification executor task—
|
// Avoid a race condition by blocking on the message handled future *and* the current notification executor task—
|
||||||
// the notification executor task includes unsubscribing `listener1`, and, if we don’t wait, sometimes
|
// the notification executor task includes unsubscribing `listener1`, and, if we don’t wait, sometimes
|
||||||
// `listener2` will get subscribed before `listener1` is cleaned up
|
// `listener2` will get subscribed before `listener1` is cleaned up
|
||||||
notificationExecutorService.submit(() -> listener1.firstMessageHandled.get()).get();
|
sharedExecutorService.submit(() -> listener1.firstMessageHandled.get()).get();
|
||||||
|
|
||||||
final UUID messageGuid2 = UUID.randomUUID();
|
final UUID messageGuid2 = UUID.randomUUID();
|
||||||
messagesCache.insert(messageGuid2, DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
messagesCache.insert(messageGuid2, DESTINATION_UUID, DESTINATION_DEVICE_ID,
|
||||||
|
@ -424,4 +529,228 @@ class MessagesCacheTest {
|
||||||
listener2.firstMessageHandled.get();
|
listener2.firstMessageHandled.get();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<MessageProtos.Envelope> get(final UUID destinationUuid, final long destinationDeviceId,
|
||||||
|
final int messageCount) {
|
||||||
|
return Flux.from(messagesCache.get(destinationUuid, destinationDeviceId))
|
||||||
|
.take(messageCount, true)
|
||||||
|
.collectList()
|
||||||
|
.block();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class WithMockCluster {
|
||||||
|
|
||||||
|
private MessagesCache messagesCache;
|
||||||
|
private RedisAdvancedClusterReactiveCommands<byte[], byte[]> reactiveCommands;
|
||||||
|
private RedisAdvancedClusterAsyncCommands<byte[], byte[]> asyncCommands;
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@BeforeEach
|
||||||
|
void setup() throws Exception {
|
||||||
|
reactiveCommands = mock(RedisAdvancedClusterReactiveCommands.class);
|
||||||
|
asyncCommands = mock(RedisAdvancedClusterAsyncCommands.class);
|
||||||
|
|
||||||
|
final FaultTolerantRedisCluster mockCluster = RedisClusterHelper.builder()
|
||||||
|
.binaryReactiveCommands(reactiveCommands)
|
||||||
|
.binaryAsyncCommands(asyncCommands)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
messagesCache = new MessagesCache(mockCluster, mockCluster, Clock.systemUTC(), mock(ExecutorService.class),
|
||||||
|
mock(ExecutorService.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void teardown() {
|
||||||
|
StepVerifier.resetDefaultTimeout();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetAllMessagesLimitsAndBackpressure() {
|
||||||
|
// this test makes sure that we don’t fetch and buffer all messages from the cache when the publisher
|
||||||
|
// is subscribed. Rather, we should be fetching in pages to satisfy downstream requests, so that memory usage
|
||||||
|
// is limited to few pages of messages
|
||||||
|
|
||||||
|
// we use a combination of Flux.just() and Sinks to control when data is “fetched” from the cache. The initial
|
||||||
|
// Flux.just()s are pages that are readily available, on demand. By design, there are more of these pages than
|
||||||
|
// the initial prefetch. The sinks allow us to create extra demand but defer producing values to satisfy the demand
|
||||||
|
// until later on.
|
||||||
|
|
||||||
|
final AtomicReference<FluxSink<Object>> page4Sink = new AtomicReference<>();
|
||||||
|
final AtomicReference<FluxSink<Object>> page56Sink = new AtomicReference<>();
|
||||||
|
final AtomicReference<FluxSink<Object>> emptyFinalPageSink = new AtomicReference<>();
|
||||||
|
|
||||||
|
final Deque<List<byte[]>> pages = new ArrayDeque<>();
|
||||||
|
pages.add(generatePage());
|
||||||
|
pages.add(generatePage());
|
||||||
|
pages.add(generatePage());
|
||||||
|
pages.add(generatePage());
|
||||||
|
// make sure that stale ephemeral messages are also produced by calls to getAllMessages()
|
||||||
|
pages.add(generateStaleEphemeralPage());
|
||||||
|
pages.add(generatePage());
|
||||||
|
|
||||||
|
when(reactiveCommands.evalsha(any(), any(), any(), any()))
|
||||||
|
.thenReturn(Flux.just(pages.pop()))
|
||||||
|
.thenReturn(Flux.just(pages.pop()))
|
||||||
|
.thenReturn(Flux.just(pages.pop()))
|
||||||
|
.thenReturn(Flux.create(sink -> page4Sink.compareAndSet(null, sink)))
|
||||||
|
.thenReturn(Flux.create(sink -> page56Sink.compareAndSet(null, sink)))
|
||||||
|
.thenReturn(Flux.create(sink -> emptyFinalPageSink.compareAndSet(null, sink)))
|
||||||
|
.thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
final Flux<?> allMessages = messagesCache.getAllMessages(UUID.randomUUID(), 1L);
|
||||||
|
|
||||||
|
// Why initialValue = 3?
|
||||||
|
// 1. messagesCache.getAllMessages() above produces the first call
|
||||||
|
// 2. when we subscribe, the prefetch of 1 results in `expand()`, which produces a second call
|
||||||
|
// 3. there is an implicit “low tide mark” of 1, meaning there will be an extra call to replenish when there is
|
||||||
|
// 1 value remaining
|
||||||
|
final AtomicInteger expectedReactiveCommandInvocations = new AtomicInteger(3);
|
||||||
|
|
||||||
|
StepVerifier.setDefaultTimeout(Duration.ofSeconds(5));
|
||||||
|
|
||||||
|
final int page = 100;
|
||||||
|
final int halfPage = page / 2;
|
||||||
|
|
||||||
|
// in order to fully control demand and separate the prefetch mechanics, initially subscribe with a request of 0
|
||||||
|
StepVerifier.create(allMessages, 0)
|
||||||
|
.expectSubscription()
|
||||||
|
.then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.get())).evalsha(any(), any(),
|
||||||
|
any(), any()))
|
||||||
|
.thenRequest(halfPage) // page 0.5 requested
|
||||||
|
.expectNextCount(halfPage) // page 0.5 produced
|
||||||
|
// page 0.5 produced, 1.5 remain, so no additional interactions with the cache cluster
|
||||||
|
.then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.get())).evalsha(any(),
|
||||||
|
any(), any(), any()))
|
||||||
|
.then(() -> assertNull(page4Sink.get(), "page 4 should not have been fetched yet"))
|
||||||
|
.thenRequest(page) // page 1.5 requested
|
||||||
|
.expectNextCount(page) // page 1.5 produced
|
||||||
|
|
||||||
|
// we now have produced 1.5 pages, have 0.5 buffered, and two more have been prefetched.
|
||||||
|
// after producing more than a full page, we’ll need to replenish from the cache.
|
||||||
|
// future requests will depend on sink emitters.
|
||||||
|
// also NB: times() checks cumulative calls, hence addAndGet
|
||||||
|
.then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.addAndGet(1))).evalsha(any(),
|
||||||
|
any(), any(), any()))
|
||||||
|
.then(() -> assertNotNull(page4Sink.get(), "page 4 should have been fetched"))
|
||||||
|
.thenRequest(page + halfPage) // page 3 requested
|
||||||
|
.expectNextCount(page + halfPage) // page 1.5–3 produced
|
||||||
|
|
||||||
|
.thenRequest(halfPage) // page 3.5 requested
|
||||||
|
.then(() -> assertNull(page56Sink.get(), "page 5 should not have been fetched yet"))
|
||||||
|
.then(() -> page4Sink.get().next(pages.pop()).complete())
|
||||||
|
.expectNextCount(halfPage) // page 3.5 produced
|
||||||
|
.then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.addAndGet(1))).evalsha(any(),
|
||||||
|
any(), any(), any()))
|
||||||
|
.then(() -> assertNotNull(page56Sink.get(), "page 5 should have been fetched"))
|
||||||
|
|
||||||
|
.thenRequest(page) // page 4.5 requested
|
||||||
|
.expectNextCount(halfPage) // page 4 produced
|
||||||
|
|
||||||
|
.thenRequest(page * 4) // request more demand than we will ultimately satisfy
|
||||||
|
|
||||||
|
.then(() -> page56Sink.get().next(pages.pop()).next(pages.pop()).complete())
|
||||||
|
.expectNextCount(page + page) // page 5 and 6 produced
|
||||||
|
.then(() -> emptyFinalPageSink.get().complete())
|
||||||
|
// confirm that cache calls increased by 2: one for page 5-and-6 (we got a two-fer in next(pop()).next(pop()),
|
||||||
|
// and one for the final, empty page
|
||||||
|
.then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.addAndGet(2))).evalsha(any(),
|
||||||
|
any(), any(),
|
||||||
|
any()))
|
||||||
|
.expectComplete()
|
||||||
|
.log()
|
||||||
|
.verify();
|
||||||
|
|
||||||
|
// make sure that we consumed all the pages, especially in case of refactoring
|
||||||
|
assertTrue(pages.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDiscardsEphemeralMessages() {
|
||||||
|
final Deque<List<byte[]>> pages = new ArrayDeque<>();
|
||||||
|
pages.add(generatePage());
|
||||||
|
pages.add(generatePage());
|
||||||
|
pages.add(generateStaleEphemeralPage());
|
||||||
|
|
||||||
|
when(reactiveCommands.evalsha(any(), any(), any(), any()))
|
||||||
|
.thenReturn(Flux.just(pages.pop()))
|
||||||
|
.thenReturn(Flux.just(pages.pop()))
|
||||||
|
.thenReturn(Flux.just(pages.pop()))
|
||||||
|
.thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
final AsyncCommand<?, ?, ?> removeSuccess = new AsyncCommand<>(mock(RedisCommand.class));
|
||||||
|
removeSuccess.complete();
|
||||||
|
|
||||||
|
when(asyncCommands.evalsha(any(), any(), any(), any()))
|
||||||
|
.thenReturn((RedisFuture) removeSuccess);
|
||||||
|
|
||||||
|
final Publisher<?> allMessages = messagesCache.get(UUID.randomUUID(), 1L);
|
||||||
|
|
||||||
|
StepVerifier.setDefaultTimeout(Duration.ofSeconds(5));
|
||||||
|
|
||||||
|
// async commands are used for remove(), and nothing should happen until we are subscribed
|
||||||
|
verify(asyncCommands, never()).evalsha(any(), any(), any(byte[][].class), any(byte[].class));
|
||||||
|
// the reactive commands will be called once, to prep the first page fetch (but no remote request would actually be sent)
|
||||||
|
verify(reactiveCommands, times(1)).evalsha(any(), any(), any(byte[][].class), any(byte[].class));
|
||||||
|
|
||||||
|
StepVerifier.create(allMessages)
|
||||||
|
.expectSubscription()
|
||||||
|
.expectNextCount(200)
|
||||||
|
.expectComplete()
|
||||||
|
.log()
|
||||||
|
.verify();
|
||||||
|
|
||||||
|
assertTrue(pages.isEmpty());
|
||||||
|
verify(asyncCommands, atLeast(1)).evalsha(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<byte[]> generatePage() {
|
||||||
|
final List<byte[]> messagesAndIds = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < 100; i++) {
|
||||||
|
final MessageProtos.Envelope envelope = generateRandomMessage(UUID.randomUUID(), true);
|
||||||
|
messagesAndIds.add(envelope.toByteArray());
|
||||||
|
messagesAndIds.add(String.valueOf(serialTimestamp).getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
return messagesAndIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<byte[]> generateStaleEphemeralPage() {
|
||||||
|
final List<byte[]> messagesAndIds = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < 100; i++) {
|
||||||
|
final MessageProtos.Envelope envelope = generateRandomMessage(UUID.randomUUID(), true)
|
||||||
|
.toBuilder().setEphemeral(true).build();
|
||||||
|
messagesAndIds.add(envelope.toByteArray());
|
||||||
|
messagesAndIds.add(String.valueOf(serialTimestamp).getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
return messagesAndIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid, final boolean sealedSender) {
|
||||||
|
return generateRandomMessage(messageGuid, sealedSender, serialTimestamp++);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid, final boolean sealedSender,
|
||||||
|
final long timestamp) {
|
||||||
|
final MessageProtos.Envelope.Builder envelopeBuilder = MessageProtos.Envelope.newBuilder()
|
||||||
|
.setTimestamp(timestamp)
|
||||||
|
.setServerTimestamp(timestamp)
|
||||||
|
.setContent(ByteString.copyFromUtf8(RandomStringUtils.randomAlphanumeric(256)))
|
||||||
|
.setType(MessageProtos.Envelope.Type.CIPHERTEXT)
|
||||||
|
.setServerGuid(messageGuid.toString())
|
||||||
|
.setDestinationUuid(UUID.randomUUID().toString());
|
||||||
|
|
||||||
|
if (!sealedSender) {
|
||||||
|
envelopeBuilder.setSourceDevice(random.nextInt(256))
|
||||||
|
.setSourceUuid(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelopeBuilder.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,14 +9,26 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
import com.google.protobuf.ByteString;
|
import com.google.protobuf.ByteString;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||||
|
import org.whispersystems.textsecuregcm.tests.util.MessageHelper;
|
||||||
import org.whispersystems.textsecuregcm.tests.util.MessagesDynamoDbExtension;
|
import org.whispersystems.textsecuregcm.tests.util.MessagesDynamoDbExtension;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
class MessagesDynamoDbTest {
|
class MessagesDynamoDbTest {
|
||||||
|
|
||||||
|
@ -59,6 +71,7 @@ class MessagesDynamoDbTest {
|
||||||
MESSAGE3 = builder.build();
|
MESSAGE3 = builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ExecutorService messageDeletionExecutorService;
|
||||||
private MessagesDynamoDb messagesDynamoDb;
|
private MessagesDynamoDb messagesDynamoDb;
|
||||||
|
|
||||||
|
|
||||||
|
@ -67,8 +80,18 @@ class MessagesDynamoDbTest {
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setup() {
|
void setup() {
|
||||||
messagesDynamoDb = new MessagesDynamoDb(dynamoDbExtension.getDynamoDbClient(), MessagesDynamoDbExtension.TABLE_NAME,
|
messageDeletionExecutorService = Executors.newSingleThreadExecutor();
|
||||||
Duration.ofDays(14));
|
messagesDynamoDb = new MessagesDynamoDb(dynamoDbExtension.getDynamoDbClient(),
|
||||||
|
dynamoDbExtension.getDynamoDbAsyncClient(), MessagesDynamoDbExtension.TABLE_NAME, Duration.ofDays(14),
|
||||||
|
messageDeletionExecutorService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void teardown() throws Exception {
|
||||||
|
messageDeletionExecutorService.shutdown();
|
||||||
|
messageDeletionExecutorService.awaitTermination(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
StepVerifier.resetDefaultTimeout();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -77,7 +100,7 @@ class MessagesDynamoDbTest {
|
||||||
final int destinationDeviceId = random.nextInt(255) + 1;
|
final int destinationDeviceId = random.nextInt(255) + 1;
|
||||||
messagesDynamoDb.store(List.of(MESSAGE1, MESSAGE2, MESSAGE3), destinationUuid, destinationDeviceId);
|
messagesDynamoDb.store(List.of(MESSAGE1, MESSAGE2, MESSAGE3), destinationUuid, destinationDeviceId);
|
||||||
|
|
||||||
final List<MessageProtos.Envelope> messagesStored = messagesDynamoDb.load(destinationUuid, destinationDeviceId,
|
final List<MessageProtos.Envelope> messagesStored = load(destinationUuid, destinationDeviceId,
|
||||||
MessagesDynamoDb.RESULT_SET_CHUNK_SIZE);
|
MessagesDynamoDb.RESULT_SET_CHUNK_SIZE);
|
||||||
assertThat(messagesStored).isNotNull().hasSize(3);
|
assertThat(messagesStored).isNotNull().hasSize(3);
|
||||||
final MessageProtos.Envelope firstMessage =
|
final MessageProtos.Envelope firstMessage =
|
||||||
|
@ -88,6 +111,73 @@ class MessagesDynamoDbTest {
|
||||||
assertThat(messagesStored).element(2).isEqualTo(MESSAGE2);
|
assertThat(messagesStored).element(2).isEqualTo(MESSAGE2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(ints = {10, 100, 100, 1_000, 3_000})
|
||||||
|
void testLoadManyAfterInsert(final int messageCount) {
|
||||||
|
final UUID destinationUuid = UUID.randomUUID();
|
||||||
|
final int destinationDeviceId = random.nextInt(255) + 1;
|
||||||
|
|
||||||
|
final List<MessageProtos.Envelope> messages = new ArrayList<>(messageCount);
|
||||||
|
for (int i = 0; i < messageCount; i++) {
|
||||||
|
messages.add(MessageHelper.createMessage(UUID.randomUUID(), 1, destinationUuid, (i + 1L) * 1000, "message " + i));
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesDynamoDb.store(messages, destinationUuid, destinationDeviceId);
|
||||||
|
|
||||||
|
final Publisher<?> fetchedMessages = messagesDynamoDb.load(destinationUuid, destinationDeviceId, null);
|
||||||
|
|
||||||
|
final long firstRequest = Math.min(10, messageCount);
|
||||||
|
StepVerifier.setDefaultTimeout(Duration.ofSeconds(15));
|
||||||
|
|
||||||
|
StepVerifier.Step<?> step = StepVerifier.create(fetchedMessages, 0)
|
||||||
|
.expectSubscription()
|
||||||
|
.thenRequest(firstRequest)
|
||||||
|
.expectNextCount(firstRequest);
|
||||||
|
|
||||||
|
if (messageCount > firstRequest) {
|
||||||
|
step = step.thenRequest(messageCount)
|
||||||
|
.expectNextCount(messageCount - firstRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
step.thenCancel()
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLimitedLoad() {
|
||||||
|
final int messageCount = 200;
|
||||||
|
final UUID destinationUuid = UUID.randomUUID();
|
||||||
|
final int destinationDeviceId = random.nextInt(255) + 1;
|
||||||
|
|
||||||
|
final List<MessageProtos.Envelope> messages = new ArrayList<>(messageCount);
|
||||||
|
for (int i = 0; i < messageCount; i++) {
|
||||||
|
messages.add(MessageHelper.createMessage(UUID.randomUUID(), 1, destinationUuid, (i + 1L) * 1000, "message " + i));
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesDynamoDb.store(messages, destinationUuid, destinationDeviceId);
|
||||||
|
|
||||||
|
final int messageLoadLimit = 100;
|
||||||
|
final int halfOfMessageLoadLimit = messageLoadLimit / 2;
|
||||||
|
final Publisher<?> fetchedMessages = messagesDynamoDb.load(destinationUuid, destinationDeviceId, messageLoadLimit);
|
||||||
|
|
||||||
|
StepVerifier.setDefaultTimeout(Duration.ofSeconds(10));
|
||||||
|
|
||||||
|
final AtomicInteger messagesRemaining = new AtomicInteger(messageLoadLimit);
|
||||||
|
|
||||||
|
StepVerifier.create(fetchedMessages, 0)
|
||||||
|
.expectSubscription()
|
||||||
|
.thenRequest(halfOfMessageLoadLimit)
|
||||||
|
.expectNextCount(halfOfMessageLoadLimit)
|
||||||
|
// the first 100 should be fetched and buffered, but further requests should fail
|
||||||
|
.then(() -> dynamoDbExtension.stopServer())
|
||||||
|
.thenRequest(halfOfMessageLoadLimit)
|
||||||
|
.expectNextCount(halfOfMessageLoadLimit)
|
||||||
|
// we’ve consumed all the buffered messages, so a single request will fail
|
||||||
|
.thenRequest(1)
|
||||||
|
.expectError()
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteForDestination() {
|
void testDeleteForDestination() {
|
||||||
final UUID destinationUuid = UUID.randomUUID();
|
final UUID destinationUuid = UUID.randomUUID();
|
||||||
|
@ -96,18 +186,18 @@ class MessagesDynamoDbTest {
|
||||||
messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, 1);
|
messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, 1);
|
||||||
messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, 2);
|
messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, 2);
|
||||||
|
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE1);
|
.element(0).isEqualTo(MESSAGE1);
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE3);
|
.element(0).isEqualTo(MESSAGE3);
|
||||||
assertThat(messagesDynamoDb.load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
||||||
.hasSize(1).element(0).isEqualTo(MESSAGE2);
|
.hasSize(1).element(0).isEqualTo(MESSAGE2);
|
||||||
|
|
||||||
messagesDynamoDb.deleteAllMessagesForAccount(destinationUuid);
|
messagesDynamoDb.deleteAllMessagesForAccount(destinationUuid);
|
||||||
|
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().isEmpty();
|
assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().isEmpty();
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().isEmpty();
|
assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().isEmpty();
|
||||||
assertThat(messagesDynamoDb.load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
||||||
.hasSize(1).element(0).isEqualTo(MESSAGE2);
|
.hasSize(1).element(0).isEqualTo(MESSAGE2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,71 +209,79 @@ class MessagesDynamoDbTest {
|
||||||
messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, 1);
|
messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, 1);
|
||||||
messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, 2);
|
messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, 2);
|
||||||
|
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE1);
|
.element(0).isEqualTo(MESSAGE1);
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE3);
|
.element(0).isEqualTo(MESSAGE3);
|
||||||
assertThat(messagesDynamoDb.load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
||||||
.hasSize(1).element(0).isEqualTo(MESSAGE2);
|
.hasSize(1).element(0).isEqualTo(MESSAGE2);
|
||||||
|
|
||||||
messagesDynamoDb.deleteAllMessagesForDevice(destinationUuid, 2);
|
messagesDynamoDb.deleteAllMessagesForDevice(destinationUuid, 2);
|
||||||
|
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE1);
|
.element(0).isEqualTo(MESSAGE1);
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().isEmpty();
|
assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().isEmpty();
|
||||||
assertThat(messagesDynamoDb.load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
||||||
.hasSize(1).element(0).isEqualTo(MESSAGE2);
|
.hasSize(1).element(0).isEqualTo(MESSAGE2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteMessageByDestinationAndGuid() {
|
void testDeleteMessageByDestinationAndGuid() throws Exception {
|
||||||
final UUID destinationUuid = UUID.randomUUID();
|
final UUID destinationUuid = UUID.randomUUID();
|
||||||
final UUID secondDestinationUuid = UUID.randomUUID();
|
final UUID secondDestinationUuid = UUID.randomUUID();
|
||||||
messagesDynamoDb.store(List.of(MESSAGE1), destinationUuid, 1);
|
messagesDynamoDb.store(List.of(MESSAGE1), destinationUuid, 1);
|
||||||
messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, 1);
|
messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, 1);
|
||||||
messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, 2);
|
messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, 2);
|
||||||
|
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE1);
|
.element(0).isEqualTo(MESSAGE1);
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE3);
|
.element(0).isEqualTo(MESSAGE3);
|
||||||
assertThat(messagesDynamoDb.load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
||||||
.hasSize(1).element(0).isEqualTo(MESSAGE2);
|
.hasSize(1).element(0).isEqualTo(MESSAGE2);
|
||||||
|
|
||||||
messagesDynamoDb.deleteMessageByDestinationAndGuid(secondDestinationUuid,
|
messagesDynamoDb.deleteMessageByDestinationAndGuid(secondDestinationUuid,
|
||||||
UUID.fromString(MESSAGE2.getServerGuid()));
|
UUID.fromString(MESSAGE2.getServerGuid())).get(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE1);
|
.element(0).isEqualTo(MESSAGE1);
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE3);
|
.element(0).isEqualTo(MESSAGE3);
|
||||||
assertThat(messagesDynamoDb.load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
||||||
.isEmpty();
|
.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteSingleMessage() {
|
void testDeleteSingleMessage() throws Exception {
|
||||||
final UUID destinationUuid = UUID.randomUUID();
|
final UUID destinationUuid = UUID.randomUUID();
|
||||||
final UUID secondDestinationUuid = UUID.randomUUID();
|
final UUID secondDestinationUuid = UUID.randomUUID();
|
||||||
messagesDynamoDb.store(List.of(MESSAGE1), destinationUuid, 1);
|
messagesDynamoDb.store(List.of(MESSAGE1), destinationUuid, 1);
|
||||||
messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, 1);
|
messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, 1);
|
||||||
messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, 2);
|
messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, 2);
|
||||||
|
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE1);
|
.element(0).isEqualTo(MESSAGE1);
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE3);
|
.element(0).isEqualTo(MESSAGE3);
|
||||||
assertThat(messagesDynamoDb.load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
||||||
.hasSize(1).element(0).isEqualTo(MESSAGE2);
|
.hasSize(1).element(0).isEqualTo(MESSAGE2);
|
||||||
|
|
||||||
messagesDynamoDb.deleteMessage(secondDestinationUuid, 1,
|
messagesDynamoDb.deleteMessage(secondDestinationUuid, 1,
|
||||||
UUID.fromString(MESSAGE2.getServerGuid()), MESSAGE2.getServerTimestamp());
|
UUID.fromString(MESSAGE2.getServerGuid()), MESSAGE2.getServerTimestamp()).get(1, TimeUnit.SECONDS);
|
||||||
|
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE1);
|
.element(0).isEqualTo(MESSAGE1);
|
||||||
assertThat(messagesDynamoDb.load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)
|
||||||
.element(0).isEqualTo(MESSAGE3);
|
.element(0).isEqualTo(MESSAGE3);
|
||||||
assertThat(messagesDynamoDb.load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()
|
||||||
.isEmpty();
|
.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<MessageProtos.Envelope> load(final UUID destinationUuid, final long destinationDeviceId,
|
||||||
|
final int count) {
|
||||||
|
return Flux.from(messagesDynamoDb.load(destinationUuid, destinationDeviceId, count))
|
||||||
|
.take(count, true)
|
||||||
|
.collectList()
|
||||||
|
.block();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,13 +14,11 @@ import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
|
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
|
||||||
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
|
||||||
|
|
||||||
class MessagesManagerTest {
|
class MessagesManagerTest {
|
||||||
|
|
||||||
private final MessagesDynamoDb messagesDynamoDb = mock(MessagesDynamoDb.class);
|
private final MessagesDynamoDb messagesDynamoDb = mock(MessagesDynamoDb.class);
|
||||||
private final MessagesCache messagesCache = mock(MessagesCache.class);
|
private final MessagesCache messagesCache = mock(MessagesCache.class);
|
||||||
private final PushLatencyManager pushLatencyManager = mock(PushLatencyManager.class);
|
|
||||||
private final ReportMessageManager reportMessageManager = mock(ReportMessageManager.class);
|
private final ReportMessageManager reportMessageManager = mock(ReportMessageManager.class);
|
||||||
|
|
||||||
private final MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache,
|
private final MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache,
|
||||||
|
|
|
@ -41,7 +41,7 @@ public class ProfilesManagerTest {
|
||||||
void setUp() {
|
void setUp() {
|
||||||
//noinspection unchecked
|
//noinspection unchecked
|
||||||
commands = mock(RedisAdvancedClusterCommands.class);
|
commands = mock(RedisAdvancedClusterCommands.class);
|
||||||
final FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.buildMockRedisCluster(commands);
|
final FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.builder().stringCommands(commands).build();
|
||||||
|
|
||||||
profiles = mock(Profiles.class);
|
profiles = mock(Profiles.class);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2022 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.tests.util;
|
||||||
|
|
||||||
|
import com.google.protobuf.ByteString;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||||
|
|
||||||
|
public class MessageHelper {
|
||||||
|
|
||||||
|
public static MessageProtos.Envelope createMessage(UUID senderUuid, final int senderDeviceId, UUID destinationUuid,
|
||||||
|
long timestamp, String content) {
|
||||||
|
return MessageProtos.Envelope.newBuilder()
|
||||||
|
.setServerGuid(UUID.randomUUID().toString())
|
||||||
|
.setType(MessageProtos.Envelope.Type.CIPHERTEXT)
|
||||||
|
.setTimestamp(timestamp)
|
||||||
|
.setServerTimestamp(0)
|
||||||
|
.setSourceUuid(senderUuid.toString())
|
||||||
|
.setSourceDevice(senderDeviceId)
|
||||||
|
.setDestinationUuid(destinationUuid.toString())
|
||||||
|
.setContent(ByteString.copyFrom(content.getBytes(StandardCharsets.UTF_8)))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,33 +5,39 @@
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.tests.util;
|
package org.whispersystems.textsecuregcm.tests.util;
|
||||||
|
|
||||||
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
|
|
||||||
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
|
||||||
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
import java.util.function.Function;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.doAnswer;
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
|
||||||
|
import io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands;
|
||||||
|
import io.lettuce.core.cluster.api.reactive.RedisAdvancedClusterReactiveCommands;
|
||||||
|
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||||
|
|
||||||
public class RedisClusterHelper {
|
public class RedisClusterHelper {
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
public static RedisClusterHelper.Builder builder() {
|
||||||
public static FaultTolerantRedisCluster buildMockRedisCluster(final RedisAdvancedClusterCommands<String, String> stringCommands) {
|
return new Builder();
|
||||||
return buildMockRedisCluster(stringCommands, mock(RedisAdvancedClusterCommands.class));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public static FaultTolerantRedisCluster buildMockRedisCluster(final RedisAdvancedClusterCommands<String, String> stringCommands, final RedisAdvancedClusterCommands<byte[], byte[]> binaryCommands) {
|
private static FaultTolerantRedisCluster buildMockRedisCluster(
|
||||||
|
final RedisAdvancedClusterCommands<String, String> stringCommands,
|
||||||
|
final RedisAdvancedClusterCommands<byte[], byte[]> binaryCommands,
|
||||||
|
final RedisAdvancedClusterAsyncCommands<byte[], byte[]> binaryAsyncCommands,
|
||||||
|
final RedisAdvancedClusterReactiveCommands<byte[], byte[]> binaryReactiveCommands) {
|
||||||
final FaultTolerantRedisCluster cluster = mock(FaultTolerantRedisCluster.class);
|
final FaultTolerantRedisCluster cluster = mock(FaultTolerantRedisCluster.class);
|
||||||
final StatefulRedisClusterConnection<String, String> stringConnection = mock(StatefulRedisClusterConnection.class);
|
final StatefulRedisClusterConnection<String, String> stringConnection = mock(StatefulRedisClusterConnection.class);
|
||||||
final StatefulRedisClusterConnection<byte[], byte[]> binaryConnection = mock(StatefulRedisClusterConnection.class);
|
final StatefulRedisClusterConnection<byte[], byte[]> binaryConnection = mock(StatefulRedisClusterConnection.class);
|
||||||
|
|
||||||
when(stringConnection.sync()).thenReturn(stringCommands);
|
when(stringConnection.sync()).thenReturn(stringCommands);
|
||||||
when(binaryConnection.sync()).thenReturn(binaryCommands);
|
when(binaryConnection.sync()).thenReturn(binaryCommands);
|
||||||
|
when(binaryConnection.async()).thenReturn(binaryAsyncCommands);
|
||||||
|
when(binaryConnection.reactive()).thenReturn(binaryReactiveCommands);
|
||||||
|
|
||||||
when(cluster.withCluster(any(Function.class))).thenAnswer(invocation -> {
|
when(cluster.withCluster(any(Function.class))).thenAnswer(invocation -> {
|
||||||
return invocation.getArgument(0, Function.class).apply(stringConnection);
|
return invocation.getArgument(0, Function.class).apply(stringConnection);
|
||||||
|
@ -71,4 +77,46 @@ public class RedisClusterHelper {
|
||||||
|
|
||||||
return cluster;
|
return cluster;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public static class Builder {
|
||||||
|
|
||||||
|
private RedisAdvancedClusterCommands<String, String> stringCommands = mock(RedisAdvancedClusterCommands.class);
|
||||||
|
private RedisAdvancedClusterCommands<byte[], byte[]> binaryCommands = mock(RedisAdvancedClusterCommands.class);
|
||||||
|
private RedisAdvancedClusterAsyncCommands<byte[], byte[]> binaryAsyncCommands = mock(
|
||||||
|
RedisAdvancedClusterAsyncCommands.class);
|
||||||
|
private RedisAdvancedClusterReactiveCommands<byte[], byte[]> binaryReactiveCommands = mock(
|
||||||
|
RedisAdvancedClusterReactiveCommands.class);
|
||||||
|
|
||||||
|
private Builder() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder stringCommands(final RedisAdvancedClusterCommands<String, String> stringCommands) {
|
||||||
|
this.stringCommands = stringCommands;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder binaryCommands(final RedisAdvancedClusterCommands<byte[], byte[]> binaryCommands) {
|
||||||
|
this.binaryCommands = binaryCommands;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder binaryAsyncCommands(final RedisAdvancedClusterAsyncCommands<byte[], byte[]> binaryAsyncCommands) {
|
||||||
|
this.binaryAsyncCommands = binaryAsyncCommands;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder binaryReactiveCommands(
|
||||||
|
final RedisAdvancedClusterReactiveCommands<byte[], byte[]> binaryReactiveCommands) {
|
||||||
|
this.binaryReactiveCommands = binaryReactiveCommands;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FaultTolerantRedisCluster build() {
|
||||||
|
return RedisClusterHelper.buildMockRedisCluster(stringCommands, binaryCommands, binaryAsyncCommands,
|
||||||
|
binaryReactiveCommands);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright 2013-2021 Signal Messenger, LLC
|
* Copyright 2013-2022 Signal Messenger, LLC
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -22,6 +22,7 @@ import static org.mockito.Mockito.when;
|
||||||
import com.google.protobuf.ByteString;
|
import com.google.protobuf.ByteString;
|
||||||
import com.google.protobuf.InvalidProtocolBufferException;
|
import com.google.protobuf.InvalidProtocolBufferException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -36,8 +37,10 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import org.apache.commons.lang3.RandomStringUtils;
|
import org.apache.commons.lang3.RandomStringUtils;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.stubbing.Answer;
|
import org.mockito.stubbing.Answer;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||||
|
@ -56,6 +59,7 @@ import org.whispersystems.textsecuregcm.tests.util.MessagesDynamoDbExtension;
|
||||||
import org.whispersystems.textsecuregcm.util.Pair;
|
import org.whispersystems.textsecuregcm.util.Pair;
|
||||||
import org.whispersystems.websocket.WebSocketClient;
|
import org.whispersystems.websocket.WebSocketClient;
|
||||||
import org.whispersystems.websocket.messages.WebSocketResponseMessage;
|
import org.whispersystems.websocket.messages.WebSocketResponseMessage;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
class WebSocketConnectionIntegrationTest {
|
class WebSocketConnectionIntegrationTest {
|
||||||
|
|
||||||
|
@ -65,16 +69,13 @@ class WebSocketConnectionIntegrationTest {
|
||||||
@RegisterExtension
|
@RegisterExtension
|
||||||
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
||||||
|
|
||||||
private static final int SEND_FUTURES_TIMEOUT_MILLIS = 100;
|
private ExecutorService sharedExecutorService;
|
||||||
|
|
||||||
private ExecutorService executorService;
|
|
||||||
private MessagesDynamoDb messagesDynamoDb;
|
private MessagesDynamoDb messagesDynamoDb;
|
||||||
private MessagesCache messagesCache;
|
private MessagesCache messagesCache;
|
||||||
private ReportMessageManager reportMessageManager;
|
private ReportMessageManager reportMessageManager;
|
||||||
private Account account;
|
private Account account;
|
||||||
private Device device;
|
private Device device;
|
||||||
private WebSocketClient webSocketClient;
|
private WebSocketClient webSocketClient;
|
||||||
private WebSocketConnection webSocketConnection;
|
|
||||||
private ScheduledExecutorService retrySchedulingExecutor;
|
private ScheduledExecutorService retrySchedulingExecutor;
|
||||||
|
|
||||||
private long serialTimestamp = System.currentTimeMillis();
|
private long serialTimestamp = System.currentTimeMillis();
|
||||||
|
@ -82,11 +83,12 @@ class WebSocketConnectionIntegrationTest {
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() throws Exception {
|
void setUp() throws Exception {
|
||||||
|
|
||||||
executorService = Executors.newSingleThreadExecutor();
|
sharedExecutorService = Executors.newSingleThreadExecutor();
|
||||||
messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),
|
messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),
|
||||||
REDIS_CLUSTER_EXTENSION.getRedisCluster(), executorService);
|
REDIS_CLUSTER_EXTENSION.getRedisCluster(), Clock.systemUTC(), sharedExecutorService, sharedExecutorService);
|
||||||
messagesDynamoDb = new MessagesDynamoDb(dynamoDbExtension.getDynamoDbClient(), MessagesDynamoDbExtension.TABLE_NAME,
|
messagesDynamoDb = new MessagesDynamoDb(dynamoDbExtension.getDynamoDbClient(),
|
||||||
Duration.ofDays(7));
|
dynamoDbExtension.getDynamoDbAsyncClient(), MessagesDynamoDbExtension.TABLE_NAME, Duration.ofDays(7),
|
||||||
|
sharedExecutorService);
|
||||||
reportMessageManager = mock(ReportMessageManager.class);
|
reportMessageManager = mock(ReportMessageManager.class);
|
||||||
account = mock(Account.class);
|
account = mock(Account.class);
|
||||||
device = mock(Device.class);
|
device = mock(Device.class);
|
||||||
|
@ -96,30 +98,36 @@ class WebSocketConnectionIntegrationTest {
|
||||||
when(account.getNumber()).thenReturn("+18005551234");
|
when(account.getNumber()).thenReturn("+18005551234");
|
||||||
when(account.getUuid()).thenReturn(UUID.randomUUID());
|
when(account.getUuid()).thenReturn(UUID.randomUUID());
|
||||||
when(device.getId()).thenReturn(1L);
|
when(device.getId()).thenReturn(1L);
|
||||||
|
|
||||||
webSocketConnection = new WebSocketConnection(
|
|
||||||
mock(ReceiptSender.class),
|
|
||||||
new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager),
|
|
||||||
new AuthenticatedAccount(() -> new Pair<>(account, device)),
|
|
||||||
device,
|
|
||||||
webSocketClient,
|
|
||||||
SEND_FUTURES_TIMEOUT_MILLIS,
|
|
||||||
retrySchedulingExecutor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void tearDown() throws Exception {
|
void tearDown() throws Exception {
|
||||||
executorService.shutdown();
|
sharedExecutorService.shutdown();
|
||||||
executorService.awaitTermination(2, TimeUnit.SECONDS);
|
sharedExecutorService.awaitTermination(2, TimeUnit.SECONDS);
|
||||||
|
|
||||||
retrySchedulingExecutor.shutdown();
|
retrySchedulingExecutor.shutdown();
|
||||||
retrySchedulingExecutor.awaitTermination(2, TimeUnit.SECONDS);
|
retrySchedulingExecutor.awaitTermination(2, TimeUnit.SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testProcessStoredMessages() {
|
@CsvSource({
|
||||||
final int persistedMessageCount = 207;
|
"207, 173, true",
|
||||||
final int cachedMessageCount = 173;
|
"207, 173, false",
|
||||||
|
"323, 0, true",
|
||||||
|
"323, 0, false",
|
||||||
|
"0, 221, true",
|
||||||
|
"0, 221, false",
|
||||||
|
})
|
||||||
|
void testProcessStoredMessages(final int persistedMessageCount, final int cachedMessageCount,
|
||||||
|
final boolean useReactive) {
|
||||||
|
final WebSocketConnection webSocketConnection = new WebSocketConnection(
|
||||||
|
mock(ReceiptSender.class),
|
||||||
|
new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager),
|
||||||
|
new AuthenticatedAccount(() -> new Pair<>(account, device)),
|
||||||
|
device,
|
||||||
|
webSocketClient,
|
||||||
|
retrySchedulingExecutor,
|
||||||
|
useReactive);
|
||||||
|
|
||||||
final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(persistedMessageCount + cachedMessageCount);
|
final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(persistedMessageCount + cachedMessageCount);
|
||||||
|
|
||||||
|
@ -150,8 +158,8 @@ class WebSocketConnectionIntegrationTest {
|
||||||
final AtomicBoolean queueCleared = new AtomicBoolean(false);
|
final AtomicBoolean queueCleared = new AtomicBoolean(false);
|
||||||
|
|
||||||
when(successResponse.getStatus()).thenReturn(200);
|
when(successResponse.getStatus()).thenReturn(200);
|
||||||
when(webSocketClient.sendRequest(eq("PUT"), eq("/api/v1/message"), anyList(), any())).thenReturn(
|
when(webSocketClient.sendRequest(eq("PUT"), eq("/api/v1/message"), anyList(), any()))
|
||||||
CompletableFuture.completedFuture(successResponse));
|
.thenReturn(CompletableFuture.completedFuture(successResponse));
|
||||||
|
|
||||||
when(webSocketClient.sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), anyList(), any())).thenAnswer(
|
when(webSocketClient.sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), anyList(), any())).thenAnswer(
|
||||||
(Answer<CompletableFuture<WebSocketResponseMessage>>) invocation -> {
|
(Answer<CompletableFuture<WebSocketResponseMessage>>) invocation -> {
|
||||||
|
@ -194,8 +202,18 @@ class WebSocketConnectionIntegrationTest {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testProcessStoredMessagesClientClosed() {
|
@ValueSource(booleans = {true, false})
|
||||||
|
void testProcessStoredMessagesClientClosed(final boolean useReactive) {
|
||||||
|
final WebSocketConnection webSocketConnection = new WebSocketConnection(
|
||||||
|
mock(ReceiptSender.class),
|
||||||
|
new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager),
|
||||||
|
new AuthenticatedAccount(() -> new Pair<>(account, device)),
|
||||||
|
device,
|
||||||
|
webSocketClient,
|
||||||
|
retrySchedulingExecutor,
|
||||||
|
useReactive);
|
||||||
|
|
||||||
final int persistedMessageCount = 207;
|
final int persistedMessageCount = 207;
|
||||||
final int cachedMessageCount = 173;
|
final int cachedMessageCount = 173;
|
||||||
|
|
||||||
|
@ -250,8 +268,20 @@ class WebSocketConnectionIntegrationTest {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testProcessStoredMessagesSendFutureTimeout() {
|
@ValueSource(booleans = {true, false})
|
||||||
|
void testProcessStoredMessagesSendFutureTimeout(final boolean useReactive) {
|
||||||
|
final WebSocketConnection webSocketConnection = new WebSocketConnection(
|
||||||
|
mock(ReceiptSender.class),
|
||||||
|
new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager),
|
||||||
|
new AuthenticatedAccount(() -> new Pair<>(account, device)),
|
||||||
|
device,
|
||||||
|
webSocketClient,
|
||||||
|
100, // use a very short timeout, so that this test completes quickly
|
||||||
|
retrySchedulingExecutor,
|
||||||
|
useReactive,
|
||||||
|
Schedulers.boundedElastic());
|
||||||
|
|
||||||
final int persistedMessageCount = 207;
|
final int persistedMessageCount = 207;
|
||||||
final int cachedMessageCount = 173;
|
final int cachedMessageCount = 173;
|
||||||
|
|
||||||
|
@ -346,4 +376,5 @@ class WebSocketConnectionIntegrationTest {
|
||||||
.setDestinationUuid(UUID.randomUUID().toString())
|
.setDestinationUuid(UUID.randomUUID().toString())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright 2013-2021 Signal Messenger, LLC
|
* Copyright 2013-2022 Signal Messenger, LLC
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
import static org.mockito.Mockito.any;
|
import static org.mockito.Mockito.any;
|
||||||
import static org.mockito.Mockito.anyInt;
|
import static org.mockito.Mockito.anyInt;
|
||||||
import static org.mockito.Mockito.anyLong;
|
import static org.mockito.Mockito.anyLong;
|
||||||
|
@ -42,17 +43,20 @@ import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.ScheduledFuture;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import org.apache.commons.lang3.RandomStringUtils;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import org.eclipse.jetty.websocket.api.UpgradeRequest;
|
import org.eclipse.jetty.websocket.api.UpgradeRequest;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Disabled;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentMatchers;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.mockito.invocation.InvocationOnMock;
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
import org.mockito.stubbing.Answer;
|
import org.mockito.stubbing.Answer;
|
||||||
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
|
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||||
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ApnPushNotificationScheduler;
|
|
||||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ReceiptSender;
|
import org.whispersystems.textsecuregcm.push.ReceiptSender;
|
||||||
|
@ -65,6 +69,10 @@ import org.whispersystems.websocket.WebSocketClient;
|
||||||
import org.whispersystems.websocket.auth.WebSocketAuthenticator.AuthenticationResult;
|
import org.whispersystems.websocket.auth.WebSocketAuthenticator.AuthenticationResult;
|
||||||
import org.whispersystems.websocket.messages.WebSocketResponseMessage;
|
import org.whispersystems.websocket.messages.WebSocketResponseMessage;
|
||||||
import org.whispersystems.websocket.session.WebSocketSessionContext;
|
import org.whispersystems.websocket.session.WebSocketSessionContext;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.FluxSink;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
class WebSocketConnectionTest {
|
class WebSocketConnectionTest {
|
||||||
|
|
||||||
|
@ -83,7 +91,6 @@ class WebSocketConnectionTest {
|
||||||
private AuthenticatedAccount auth;
|
private AuthenticatedAccount auth;
|
||||||
private UpgradeRequest upgradeRequest;
|
private UpgradeRequest upgradeRequest;
|
||||||
private ReceiptSender receiptSender;
|
private ReceiptSender receiptSender;
|
||||||
private PushNotificationManager pushNotificationManager;
|
|
||||||
private ScheduledExecutorService retrySchedulingExecutor;
|
private ScheduledExecutorService retrySchedulingExecutor;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
|
@ -95,17 +102,21 @@ class WebSocketConnectionTest {
|
||||||
auth = new AuthenticatedAccount(() -> new Pair<>(account, device));
|
auth = new AuthenticatedAccount(() -> new Pair<>(account, device));
|
||||||
upgradeRequest = mock(UpgradeRequest.class);
|
upgradeRequest = mock(UpgradeRequest.class);
|
||||||
receiptSender = mock(ReceiptSender.class);
|
receiptSender = mock(ReceiptSender.class);
|
||||||
pushNotificationManager = mock(PushNotificationManager.class);
|
|
||||||
retrySchedulingExecutor = mock(ScheduledExecutorService.class);
|
retrySchedulingExecutor = mock(ScheduledExecutorService.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void teardown() {
|
||||||
|
StepVerifier.resetDefaultTimeout();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testCredentials() {
|
void testCredentials() {
|
||||||
MessagesManager storedMessages = mock(MessagesManager.class);
|
MessagesManager storedMessages = mock(MessagesManager.class);
|
||||||
WebSocketAccountAuthenticator webSocketAuthenticator = new WebSocketAccountAuthenticator(accountAuthenticator);
|
WebSocketAccountAuthenticator webSocketAuthenticator = new WebSocketAccountAuthenticator(accountAuthenticator);
|
||||||
AuthenticatedConnectListener connectListener = new AuthenticatedConnectListener(receiptSender, storedMessages,
|
AuthenticatedConnectListener connectListener = new AuthenticatedConnectListener(receiptSender, storedMessages,
|
||||||
mock(PushNotificationManager.class), mock(ClientPresenceManager.class),
|
mock(PushNotificationManager.class), mock(ClientPresenceManager.class),
|
||||||
retrySchedulingExecutor);
|
retrySchedulingExecutor, mock(ExperimentEnrollmentManager.class));
|
||||||
WebSocketSessionContext sessionContext = mock(WebSocketSessionContext.class);
|
WebSocketSessionContext sessionContext = mock(WebSocketSessionContext.class);
|
||||||
|
|
||||||
when(accountAuthenticator.authenticate(eq(new BasicCredentials(VALID_USER, VALID_PASSWORD))))
|
when(accountAuthenticator.authenticate(eq(new BasicCredentials(VALID_USER, VALID_PASSWORD))))
|
||||||
|
@ -114,7 +125,6 @@ class WebSocketConnectionTest {
|
||||||
when(accountAuthenticator.authenticate(eq(new BasicCredentials(INVALID_USER, INVALID_PASSWORD))))
|
when(accountAuthenticator.authenticate(eq(new BasicCredentials(INVALID_USER, INVALID_PASSWORD))))
|
||||||
.thenReturn(Optional.empty());
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
|
||||||
when(upgradeRequest.getParameterMap()).thenReturn(Map.of(
|
when(upgradeRequest.getParameterMap()).thenReturn(Map.of(
|
||||||
"login", List.of(VALID_USER),
|
"login", List.of(VALID_USER),
|
||||||
"password", List.of(VALID_PASSWORD)));
|
"password", List.of(VALID_PASSWORD)));
|
||||||
|
@ -136,8 +146,9 @@ class WebSocketConnectionTest {
|
||||||
assertTrue(account.isRequired());
|
assertTrue(account.isRequired());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testOpen() throws Exception {
|
@ValueSource(booleans = {true, false})
|
||||||
|
void testOpen(final boolean useReactive) throws Exception {
|
||||||
MessagesManager storedMessages = mock(MessagesManager.class);
|
MessagesManager storedMessages = mock(MessagesManager.class);
|
||||||
|
|
||||||
UUID accountUuid = UUID.randomUUID();
|
UUID accountUuid = UUID.randomUUID();
|
||||||
|
@ -166,29 +177,31 @@ class WebSocketConnectionTest {
|
||||||
|
|
||||||
String userAgent = "user-agent";
|
String userAgent = "user-agent";
|
||||||
|
|
||||||
|
if (useReactive) {
|
||||||
|
when(storedMessages.getMessagesForDeviceReactive(account.getUuid(), device.getId(), false))
|
||||||
|
.thenReturn(Flux.fromIterable(outgoingMessages));
|
||||||
|
} else {
|
||||||
when(storedMessages.getMessagesForDevice(account.getUuid(), device.getId(), false))
|
when(storedMessages.getMessagesForDevice(account.getUuid(), device.getId(), false))
|
||||||
.thenReturn(new Pair<>(outgoingMessages, false));
|
.thenReturn(new Pair<>(outgoingMessages, false));
|
||||||
|
}
|
||||||
|
|
||||||
final List<CompletableFuture<WebSocketResponseMessage>> futures = new LinkedList<>();
|
final List<CompletableFuture<WebSocketResponseMessage>> futures = new LinkedList<>();
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
|
|
||||||
when(client.getUserAgent()).thenReturn(userAgent);
|
when(client.getUserAgent()).thenReturn(userAgent);
|
||||||
when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), ArgumentMatchers.nullable(List.class), ArgumentMatchers.<Optional<byte[]>>any()))
|
when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), nullable(List.class), any()))
|
||||||
.thenAnswer(new Answer<CompletableFuture<WebSocketResponseMessage>>() {
|
.thenAnswer(invocation -> {
|
||||||
@Override
|
|
||||||
public CompletableFuture<WebSocketResponseMessage> answer(InvocationOnMock invocationOnMock) {
|
|
||||||
CompletableFuture<WebSocketResponseMessage> future = new CompletableFuture<>();
|
CompletableFuture<WebSocketResponseMessage> future = new CompletableFuture<>();
|
||||||
futures.add(future);
|
futures.add(future);
|
||||||
return future;
|
return future;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages,
|
WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages,
|
||||||
auth, device, client, retrySchedulingExecutor);
|
auth, device, client, retrySchedulingExecutor, useReactive, Schedulers.immediate());
|
||||||
|
|
||||||
connection.start();
|
connection.start();
|
||||||
verify(client, times(3)).sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class),
|
verify(client, times(3)).sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class),
|
||||||
ArgumentMatchers.<Optional<byte[]>>any());
|
any());
|
||||||
|
|
||||||
assertEquals(3, futures.size());
|
assertEquals(3, futures.size());
|
||||||
|
|
||||||
|
@ -208,12 +221,13 @@ class WebSocketConnectionTest {
|
||||||
verify(client).close(anyInt(), anyString());
|
verify(client).close(anyInt(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
public void testOnlineSend() {
|
@ValueSource(booleans = {true, false})
|
||||||
|
public void testOnlineSend(final boolean useReactive) {
|
||||||
final MessagesManager messagesManager = mock(MessagesManager.class);
|
final MessagesManager messagesManager = mock(MessagesManager.class);
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
||||||
retrySchedulingExecutor);
|
retrySchedulingExecutor, useReactive, Schedulers.immediate());
|
||||||
|
|
||||||
final UUID accountUuid = UUID.randomUUID();
|
final UUID accountUuid = UUID.randomUUID();
|
||||||
|
|
||||||
|
@ -222,17 +236,29 @@ class WebSocketConnectionTest {
|
||||||
when(device.getId()).thenReturn(1L);
|
when(device.getId()).thenReturn(1L);
|
||||||
when(client.isOpen()).thenReturn(true);
|
when(client.isOpen()).thenReturn(true);
|
||||||
|
|
||||||
|
if (useReactive) {
|
||||||
|
when(messagesManager.getMessagesForDeviceReactive(eq(accountUuid), eq(1L), anyBoolean()))
|
||||||
|
.thenReturn(Flux.empty())
|
||||||
|
.thenReturn(Flux.just(createMessage(UUID.randomUUID(), UUID.randomUUID(), 1111, "first")))
|
||||||
|
.thenReturn(Flux.just(createMessage(UUID.randomUUID(), UUID.randomUUID(), 2222, "second")))
|
||||||
|
.thenReturn(Flux.empty());
|
||||||
|
} else {
|
||||||
when(messagesManager.getMessagesForDevice(eq(accountUuid), eq(1L), anyBoolean()))
|
when(messagesManager.getMessagesForDevice(eq(accountUuid), eq(1L), anyBoolean()))
|
||||||
.thenReturn(new Pair<>(Collections.emptyList(), false))
|
.thenReturn(new Pair<>(Collections.emptyList(), false))
|
||||||
.thenReturn(new Pair<>(List.of(createMessage(UUID.randomUUID(), UUID.randomUUID(), 1111, "first")), false))
|
.thenReturn(new Pair<>(List.of(createMessage(UUID.randomUUID(), UUID.randomUUID(), 1111, "first")),
|
||||||
.thenReturn(new Pair<>(List.of(createMessage(UUID.randomUUID(), UUID.randomUUID(), 2222, "second")), false));
|
false))
|
||||||
|
.thenReturn(new Pair<>(List.of(createMessage(UUID.randomUUID(), UUID.randomUUID(), 2222, "second")),
|
||||||
|
false))
|
||||||
|
.thenReturn(new Pair<>(Collections.emptyList(), false));
|
||||||
|
}
|
||||||
|
|
||||||
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
||||||
when(successResponse.getStatus()).thenReturn(200);
|
when(successResponse.getStatus()).thenReturn(200);
|
||||||
|
|
||||||
final AtomicInteger sendCounter = new AtomicInteger(0);
|
final AtomicInteger sendCounter = new AtomicInteger(0);
|
||||||
|
|
||||||
when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class))).thenAnswer((Answer<CompletableFuture<WebSocketResponseMessage>>)invocation -> {
|
when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
synchronized (sendCounter) {
|
synchronized (sendCounter) {
|
||||||
sendCounter.incrementAndGet();
|
sendCounter.incrementAndGet();
|
||||||
sendCounter.notifyAll();
|
sendCounter.notifyAll();
|
||||||
|
@ -269,8 +295,9 @@ class WebSocketConnectionTest {
|
||||||
verify(client, times(2)).sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class));
|
verify(client, times(2)).sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testPendingSend() throws Exception {
|
@ValueSource(booleans = {true, false})
|
||||||
|
void testPendingSend(final boolean useReactive) throws Exception {
|
||||||
MessagesManager storedMessages = mock(MessagesManager.class);
|
MessagesManager storedMessages = mock(MessagesManager.class);
|
||||||
|
|
||||||
final UUID accountUuid = UUID.randomUUID();
|
final UUID accountUuid = UUID.randomUUID();
|
||||||
|
@ -311,12 +338,17 @@ class WebSocketConnectionTest {
|
||||||
when(sender1.getDevices()).thenReturn(sender1devices);
|
when(sender1.getDevices()).thenReturn(sender1devices);
|
||||||
|
|
||||||
when(accountsManager.getByE164("sender1")).thenReturn(Optional.of(sender1));
|
when(accountsManager.getByE164("sender1")).thenReturn(Optional.of(sender1));
|
||||||
when(accountsManager.getByE164("sender2")).thenReturn(Optional.<Account>empty());
|
when(accountsManager.getByE164("sender2")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
String userAgent = "user-agent";
|
String userAgent = "user-agent";
|
||||||
|
|
||||||
|
if (useReactive) {
|
||||||
|
when(storedMessages.getMessagesForDeviceReactive(account.getUuid(), device.getId(), false))
|
||||||
|
.thenReturn(Flux.fromIterable(pendingMessages));
|
||||||
|
} else {
|
||||||
when(storedMessages.getMessagesForDevice(account.getUuid(), device.getId(), false))
|
when(storedMessages.getMessagesForDevice(account.getUuid(), device.getId(), false))
|
||||||
.thenReturn(new Pair<>(pendingMessages, false));
|
.thenReturn(new Pair<>(pendingMessages, false));
|
||||||
|
}
|
||||||
|
|
||||||
final List<CompletableFuture<WebSocketResponseMessage>> futures = new LinkedList<>();
|
final List<CompletableFuture<WebSocketResponseMessage>> futures = new LinkedList<>();
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
|
@ -330,7 +362,7 @@ class WebSocketConnectionTest {
|
||||||
});
|
});
|
||||||
|
|
||||||
WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages,
|
WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages,
|
||||||
auth, device, client, retrySchedulingExecutor);
|
auth, device, client, retrySchedulingExecutor, useReactive, Schedulers.immediate());
|
||||||
|
|
||||||
connection.start();
|
connection.start();
|
||||||
|
|
||||||
|
@ -350,12 +382,13 @@ class WebSocketConnectionTest {
|
||||||
verify(client).close(anyInt(), anyString());
|
verify(client).close(anyInt(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testProcessStoredMessageConcurrency() throws InterruptedException {
|
@ValueSource(booleans = {true, false})
|
||||||
|
void testProcessStoredMessageConcurrency(final boolean useReactive) {
|
||||||
final MessagesManager messagesManager = mock(MessagesManager.class);
|
final MessagesManager messagesManager = mock(MessagesManager.class);
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
||||||
retrySchedulingExecutor);
|
retrySchedulingExecutor, useReactive, Schedulers.immediate());
|
||||||
|
|
||||||
when(account.getNumber()).thenReturn("+18005551234");
|
when(account.getNumber()).thenReturn("+18005551234");
|
||||||
when(account.getUuid()).thenReturn(UUID.randomUUID());
|
when(account.getUuid()).thenReturn(UUID.randomUUID());
|
||||||
|
@ -365,8 +398,10 @@ class WebSocketConnectionTest {
|
||||||
final AtomicBoolean threadWaiting = new AtomicBoolean(false);
|
final AtomicBoolean threadWaiting = new AtomicBoolean(false);
|
||||||
final AtomicBoolean returnMessageList = new AtomicBoolean(false);
|
final AtomicBoolean returnMessageList = new AtomicBoolean(false);
|
||||||
|
|
||||||
when(messagesManager.getMessagesForDevice(account.getUuid(), 1L, false)).thenAnswer(
|
if (useReactive) {
|
||||||
(Answer<OutgoingMessageEntityList>) invocation -> {
|
when(
|
||||||
|
messagesManager.getMessagesForDeviceReactive(account.getUuid(), 1L, false))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
synchronized (threadWaiting) {
|
synchronized (threadWaiting) {
|
||||||
threadWaiting.set(true);
|
threadWaiting.set(true);
|
||||||
threadWaiting.notifyAll();
|
threadWaiting.notifyAll();
|
||||||
|
@ -378,13 +413,30 @@ class WebSocketConnectionTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OutgoingMessageEntityList(Collections.emptyList(), false);
|
return Flux.empty();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
when(
|
||||||
|
messagesManager.getMessagesForDevice(account.getUuid(), 1L, false))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
synchronized (threadWaiting) {
|
||||||
|
threadWaiting.set(true);
|
||||||
|
threadWaiting.notifyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized (returnMessageList) {
|
||||||
|
while (!returnMessageList.get()) {
|
||||||
|
returnMessageList.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Pair<>(Collections.emptyList(), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
final Thread[] threads = new Thread[10];
|
final Thread[] threads = new Thread[10];
|
||||||
final CountDownLatch unblockedThreadsLatch = new CountDownLatch(threads.length - 1);
|
final CountDownLatch unblockedThreadsLatch = new CountDownLatch(threads.length - 1);
|
||||||
|
|
||||||
|
|
||||||
assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
|
assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
|
||||||
for (int i = 0; i < threads.length; i++) {
|
for (int i = 0; i < threads.length; i++) {
|
||||||
threads[i] = new Thread(() -> {
|
threads[i] = new Thread(() -> {
|
||||||
|
@ -413,18 +465,24 @@ class WebSocketConnectionTest {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (useReactive) {
|
||||||
|
verify(messagesManager).getMessagesForDeviceReactive(any(UUID.class), anyLong(), eq(false));
|
||||||
|
} else {
|
||||||
verify(messagesManager).getMessagesForDevice(any(UUID.class), anyLong(), eq(false));
|
verify(messagesManager).getMessagesForDevice(any(UUID.class), anyLong(), eq(false));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testProcessStoredMessagesMultiplePages() {
|
@ValueSource(booleans = {true, false})
|
||||||
|
void testProcessStoredMessagesMultiplePages(final boolean useReactive) {
|
||||||
final MessagesManager messagesManager = mock(MessagesManager.class);
|
final MessagesManager messagesManager = mock(MessagesManager.class);
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
||||||
retrySchedulingExecutor);
|
retrySchedulingExecutor, useReactive, Schedulers.immediate());
|
||||||
|
|
||||||
when(account.getNumber()).thenReturn("+18005551234");
|
when(account.getNumber()).thenReturn("+18005551234");
|
||||||
when(account.getUuid()).thenReturn(UUID.randomUUID());
|
final UUID accountUuid = UUID.randomUUID();
|
||||||
|
when(account.getUuid()).thenReturn(accountUuid);
|
||||||
when(device.getId()).thenReturn(1L);
|
when(device.getId()).thenReturn(1L);
|
||||||
when(client.isOpen()).thenReturn(true);
|
when(client.isOpen()).thenReturn(true);
|
||||||
|
|
||||||
|
@ -435,39 +493,56 @@ class WebSocketConnectionTest {
|
||||||
final List<Envelope> secondPageMessages =
|
final List<Envelope> secondPageMessages =
|
||||||
List.of(createMessage(UUID.randomUUID(), UUID.randomUUID(), 3333, "third"));
|
List.of(createMessage(UUID.randomUUID(), UUID.randomUUID(), 3333, "third"));
|
||||||
|
|
||||||
when(messagesManager.getMessagesForDevice(account.getUuid(), 1L, false))
|
if (useReactive) {
|
||||||
|
when(messagesManager.getMessagesForDeviceReactive(eq(accountUuid), eq(1L), eq(false)))
|
||||||
|
.thenReturn(Flux.fromStream(Stream.concat(firstPageMessages.stream(), secondPageMessages.stream())));
|
||||||
|
} else {
|
||||||
|
when(messagesManager.getMessagesForDevice(eq(accountUuid), eq(1L), eq(false)))
|
||||||
.thenReturn(new Pair<>(firstPageMessages, true))
|
.thenReturn(new Pair<>(firstPageMessages, true))
|
||||||
.thenReturn(new Pair<>(secondPageMessages, false));
|
.thenReturn(new Pair<>(secondPageMessages, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
when(messagesManager.delete(eq(accountUuid), eq(1L), any(), any()))
|
||||||
|
.thenReturn(CompletableFuture.completedFuture(null));
|
||||||
|
|
||||||
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
||||||
when(successResponse.getStatus()).thenReturn(200);
|
when(successResponse.getStatus()).thenReturn(200);
|
||||||
|
|
||||||
final CountDownLatch sendLatch = new CountDownLatch(firstPageMessages.size() + secondPageMessages.size());
|
final CountDownLatch queueEmptyLatch = new CountDownLatch(1);
|
||||||
|
|
||||||
when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class))).thenAnswer((Answer<CompletableFuture<WebSocketResponseMessage>>)invocation -> {
|
when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class)))
|
||||||
sendLatch.countDown();
|
.thenAnswer(invocation -> {
|
||||||
|
return CompletableFuture.completedFuture(successResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
when(client.sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty())))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
queueEmptyLatch.countDown();
|
||||||
return CompletableFuture.completedFuture(successResponse);
|
return CompletableFuture.completedFuture(successResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
|
assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
|
||||||
connection.processStoredMessages();
|
connection.processStoredMessages();
|
||||||
|
|
||||||
sendLatch.await();
|
queueEmptyLatch.await();
|
||||||
});
|
});
|
||||||
|
|
||||||
verify(client, times(firstPageMessages.size() + secondPageMessages.size())).sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class));
|
verify(client, times(firstPageMessages.size() + secondPageMessages.size())).sendRequest(eq("PUT"),
|
||||||
|
eq("/api/v1/message"), any(List.class), any(Optional.class));
|
||||||
verify(client).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty()));
|
verify(client).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testProcessStoredMessagesContainsSenderUuid() {
|
@ValueSource(booleans = {true, false})
|
||||||
|
void testProcessStoredMessagesContainsSenderUuid(final boolean useReactive) {
|
||||||
final MessagesManager messagesManager = mock(MessagesManager.class);
|
final MessagesManager messagesManager = mock(MessagesManager.class);
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
||||||
retrySchedulingExecutor);
|
retrySchedulingExecutor, useReactive, Schedulers.immediate());
|
||||||
|
|
||||||
when(account.getNumber()).thenReturn("+18005551234");
|
when(account.getNumber()).thenReturn("+18005551234");
|
||||||
when(account.getUuid()).thenReturn(UUID.randomUUID());
|
final UUID accountUuid = UUID.randomUUID();
|
||||||
|
when(account.getUuid()).thenReturn(accountUuid);
|
||||||
when(device.getId()).thenReturn(1L);
|
when(device.getId()).thenReturn(1L);
|
||||||
when(client.isOpen()).thenReturn(true);
|
when(client.isOpen()).thenReturn(true);
|
||||||
|
|
||||||
|
@ -475,26 +550,40 @@ class WebSocketConnectionTest {
|
||||||
final List<Envelope> messages = List.of(
|
final List<Envelope> messages = List.of(
|
||||||
createMessage(senderUuid, UUID.randomUUID(), 1111L, "message the first"));
|
createMessage(senderUuid, UUID.randomUUID(), 1111L, "message the first"));
|
||||||
|
|
||||||
|
if (useReactive) {
|
||||||
|
when(messagesManager.getMessagesForDeviceReactive(account.getUuid(), 1L, false))
|
||||||
|
.thenReturn(Flux.fromIterable(messages))
|
||||||
|
.thenReturn(Flux.empty());
|
||||||
|
} else {
|
||||||
when(messagesManager.getMessagesForDevice(account.getUuid(), 1L, false))
|
when(messagesManager.getMessagesForDevice(account.getUuid(), 1L, false))
|
||||||
.thenReturn(new Pair<>(messages, false));
|
.thenReturn(new Pair<>(messages, false))
|
||||||
|
.thenReturn(new Pair<>(Collections.emptyList(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
when(messagesManager.delete(eq(accountUuid), eq(1L), any(UUID.class), any()))
|
||||||
|
.thenReturn(CompletableFuture.completedFuture(null));
|
||||||
|
|
||||||
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
||||||
when(successResponse.getStatus()).thenReturn(200);
|
when(successResponse.getStatus()).thenReturn(200);
|
||||||
|
|
||||||
final CountDownLatch sendLatch = new CountDownLatch(messages.size());
|
final CountDownLatch queueEmptyLatch = new CountDownLatch(1);
|
||||||
|
|
||||||
when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class))).thenAnswer(invocation -> {
|
when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class))).thenAnswer(
|
||||||
sendLatch.countDown();
|
invocation -> CompletableFuture.completedFuture(successResponse));
|
||||||
|
|
||||||
|
when(client.sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty())))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
queueEmptyLatch.countDown();
|
||||||
return CompletableFuture.completedFuture(successResponse);
|
return CompletableFuture.completedFuture(successResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
|
assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
|
||||||
connection.processStoredMessages();
|
connection.processStoredMessages();
|
||||||
|
queueEmptyLatch.await();
|
||||||
sendLatch.await();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
verify(client, times(messages.size())).sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), argThat(argument -> {
|
verify(client, times(messages.size())).sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class),
|
||||||
|
argThat(argument -> {
|
||||||
if (argument.isEmpty()) {
|
if (argument.isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -513,12 +602,13 @@ class WebSocketConnectionTest {
|
||||||
verify(client).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty()));
|
verify(client).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testProcessStoredMessagesSingleEmptyCall() {
|
@ValueSource(booleans = {true, false})
|
||||||
|
void testProcessStoredMessagesSingleEmptyCall(final boolean useReactive) {
|
||||||
final MessagesManager messagesManager = mock(MessagesManager.class);
|
final MessagesManager messagesManager = mock(MessagesManager.class);
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
||||||
retrySchedulingExecutor);
|
retrySchedulingExecutor, useReactive, Schedulers.immediate());
|
||||||
|
|
||||||
final UUID accountUuid = UUID.randomUUID();
|
final UUID accountUuid = UUID.randomUUID();
|
||||||
|
|
||||||
|
@ -527,8 +617,13 @@ class WebSocketConnectionTest {
|
||||||
when(device.getId()).thenReturn(1L);
|
when(device.getId()).thenReturn(1L);
|
||||||
when(client.isOpen()).thenReturn(true);
|
when(client.isOpen()).thenReturn(true);
|
||||||
|
|
||||||
|
if (useReactive) {
|
||||||
|
when(messagesManager.getMessagesForDeviceReactive(eq(accountUuid), eq(1L), anyBoolean()))
|
||||||
|
.thenReturn(Flux.empty());
|
||||||
|
} else {
|
||||||
when(messagesManager.getMessagesForDevice(eq(accountUuid), eq(1L), anyBoolean()))
|
when(messagesManager.getMessagesForDevice(eq(accountUuid), eq(1L), anyBoolean()))
|
||||||
.thenReturn(new Pair<>(Collections.emptyList(), false));
|
.thenReturn(new Pair<>(Collections.emptyList(), false));
|
||||||
|
}
|
||||||
|
|
||||||
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
||||||
when(successResponse.getStatus()).thenReturn(200);
|
when(successResponse.getStatus()).thenReturn(200);
|
||||||
|
@ -543,12 +638,13 @@ class WebSocketConnectionTest {
|
||||||
verify(client, times(1)).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty()));
|
verify(client, times(1)).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
public void testRequeryOnStateMismatch() {
|
@ValueSource(booleans = {true, false})
|
||||||
|
public void testRequeryOnStateMismatch(final boolean useReactive) {
|
||||||
final MessagesManager messagesManager = mock(MessagesManager.class);
|
final MessagesManager messagesManager = mock(MessagesManager.class);
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
||||||
retrySchedulingExecutor);
|
retrySchedulingExecutor, useReactive, Schedulers.immediate());
|
||||||
final UUID accountUuid = UUID.randomUUID();
|
final UUID accountUuid = UUID.randomUUID();
|
||||||
|
|
||||||
when(account.getNumber()).thenReturn("+18005551234");
|
when(account.getNumber()).thenReturn("+18005551234");
|
||||||
|
@ -563,39 +659,57 @@ class WebSocketConnectionTest {
|
||||||
final List<Envelope> secondPageMessages =
|
final List<Envelope> secondPageMessages =
|
||||||
List.of(createMessage(UUID.randomUUID(), UUID.randomUUID(), 3333, "third"));
|
List.of(createMessage(UUID.randomUUID(), UUID.randomUUID(), 3333, "third"));
|
||||||
|
|
||||||
|
if (useReactive) {
|
||||||
|
when(messagesManager.getMessagesForDeviceReactive(eq(accountUuid), eq(1L), anyBoolean()))
|
||||||
|
.thenReturn(Flux.fromIterable(firstPageMessages))
|
||||||
|
.thenReturn(Flux.fromIterable(secondPageMessages))
|
||||||
|
.thenReturn(Flux.empty());
|
||||||
|
} else {
|
||||||
when(messagesManager.getMessagesForDevice(eq(accountUuid), eq(1L), anyBoolean()))
|
when(messagesManager.getMessagesForDevice(eq(accountUuid), eq(1L), anyBoolean()))
|
||||||
.thenReturn(new Pair<>(firstPageMessages, false))
|
.thenReturn(new Pair<>(firstPageMessages, false))
|
||||||
.thenReturn(new Pair<>(secondPageMessages, false))
|
.thenReturn(new Pair<>(secondPageMessages, false))
|
||||||
.thenReturn(new Pair<>(Collections.emptyList(), false));
|
.thenReturn(new Pair<>(Collections.emptyList(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
when(messagesManager.delete(eq(accountUuid), eq(1L), any(), any()))
|
||||||
|
.thenReturn(CompletableFuture.completedFuture(null));
|
||||||
|
|
||||||
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
||||||
when(successResponse.getStatus()).thenReturn(200);
|
when(successResponse.getStatus()).thenReturn(200);
|
||||||
|
|
||||||
final CountDownLatch sendLatch = new CountDownLatch(firstPageMessages.size() + secondPageMessages.size());
|
final CountDownLatch queueEmptyLatch = new CountDownLatch(1);
|
||||||
|
|
||||||
when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class))).thenAnswer((Answer<CompletableFuture<WebSocketResponseMessage>>)invocation -> {
|
when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
connection.handleNewMessagesAvailable();
|
connection.handleNewMessagesAvailable();
|
||||||
sendLatch.countDown();
|
|
||||||
|
|
||||||
return CompletableFuture.completedFuture(successResponse);
|
return CompletableFuture.completedFuture(successResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
when(client.sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty())))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
queueEmptyLatch.countDown();
|
||||||
|
return CompletableFuture.completedFuture(successResponse);
|
||||||
|
});
|
||||||
|
|
||||||
assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
|
assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
|
||||||
connection.processStoredMessages();
|
connection.processStoredMessages();
|
||||||
|
|
||||||
sendLatch.await();
|
queueEmptyLatch.await();
|
||||||
});
|
});
|
||||||
|
|
||||||
verify(client, times(firstPageMessages.size() + secondPageMessages.size())).sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class));
|
verify(client, times(firstPageMessages.size() + secondPageMessages.size())).sendRequest(eq("PUT"),
|
||||||
|
eq("/api/v1/message"), any(List.class), any(Optional.class));
|
||||||
verify(client).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty()));
|
verify(client).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testProcessCachedMessagesOnly() {
|
@ValueSource(booleans = {true, false})
|
||||||
|
void testProcessCachedMessagesOnly(final boolean useReactive) {
|
||||||
final MessagesManager messagesManager = mock(MessagesManager.class);
|
final MessagesManager messagesManager = mock(MessagesManager.class);
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
||||||
retrySchedulingExecutor);
|
retrySchedulingExecutor, useReactive, Schedulers.immediate());
|
||||||
|
|
||||||
final UUID accountUuid = UUID.randomUUID();
|
final UUID accountUuid = UUID.randomUUID();
|
||||||
|
|
||||||
|
@ -604,8 +718,13 @@ class WebSocketConnectionTest {
|
||||||
when(device.getId()).thenReturn(1L);
|
when(device.getId()).thenReturn(1L);
|
||||||
when(client.isOpen()).thenReturn(true);
|
when(client.isOpen()).thenReturn(true);
|
||||||
|
|
||||||
|
if (useReactive) {
|
||||||
|
when(messagesManager.getMessagesForDeviceReactive(eq(accountUuid), eq(1L), anyBoolean()))
|
||||||
|
.thenReturn(Flux.empty());
|
||||||
|
} else {
|
||||||
when(messagesManager.getMessagesForDevice(eq(accountUuid), eq(1L), anyBoolean()))
|
when(messagesManager.getMessagesForDevice(eq(accountUuid), eq(1L), anyBoolean()))
|
||||||
.thenReturn(new Pair<>(Collections.emptyList(), false));
|
.thenReturn(new Pair<>(Collections.emptyList(), false));
|
||||||
|
}
|
||||||
|
|
||||||
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
||||||
when(successResponse.getStatus()).thenReturn(200);
|
when(successResponse.getStatus()).thenReturn(200);
|
||||||
|
@ -616,19 +735,28 @@ class WebSocketConnectionTest {
|
||||||
// anything.
|
// anything.
|
||||||
connection.processStoredMessages();
|
connection.processStoredMessages();
|
||||||
|
|
||||||
|
if (useReactive) {
|
||||||
|
verify(messagesManager).getMessagesForDeviceReactive(account.getUuid(), device.getId(), false);
|
||||||
|
} else {
|
||||||
verify(messagesManager).getMessagesForDevice(account.getUuid(), device.getId(), false);
|
verify(messagesManager).getMessagesForDevice(account.getUuid(), device.getId(), false);
|
||||||
|
}
|
||||||
|
|
||||||
connection.handleNewMessagesAvailable();
|
connection.handleNewMessagesAvailable();
|
||||||
|
|
||||||
|
if (useReactive) {
|
||||||
|
verify(messagesManager).getMessagesForDeviceReactive(account.getUuid(), device.getId(), true);
|
||||||
|
} else {
|
||||||
verify(messagesManager).getMessagesForDevice(account.getUuid(), device.getId(), true);
|
verify(messagesManager).getMessagesForDevice(account.getUuid(), device.getId(), true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testProcessDatabaseMessagesAfterPersist() {
|
@ValueSource(booleans = {true, false})
|
||||||
|
void testProcessDatabaseMessagesAfterPersist(final boolean useReactive) {
|
||||||
final MessagesManager messagesManager = mock(MessagesManager.class);
|
final MessagesManager messagesManager = mock(MessagesManager.class);
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client,
|
||||||
retrySchedulingExecutor);
|
retrySchedulingExecutor, useReactive, Schedulers.immediate());
|
||||||
|
|
||||||
final UUID accountUuid = UUID.randomUUID();
|
final UUID accountUuid = UUID.randomUUID();
|
||||||
|
|
||||||
|
@ -637,8 +765,13 @@ class WebSocketConnectionTest {
|
||||||
when(device.getId()).thenReturn(1L);
|
when(device.getId()).thenReturn(1L);
|
||||||
when(client.isOpen()).thenReturn(true);
|
when(client.isOpen()).thenReturn(true);
|
||||||
|
|
||||||
|
if (useReactive) {
|
||||||
|
when(messagesManager.getMessagesForDeviceReactive(eq(accountUuid), eq(1L), anyBoolean()))
|
||||||
|
.thenReturn(Flux.empty());
|
||||||
|
} else {
|
||||||
when(messagesManager.getMessagesForDevice(eq(accountUuid), eq(1L), anyBoolean()))
|
when(messagesManager.getMessagesForDevice(eq(accountUuid), eq(1L), anyBoolean()))
|
||||||
.thenReturn(new Pair<>(Collections.emptyList(), false));
|
.thenReturn(new Pair<>(Collections.emptyList(), false));
|
||||||
|
}
|
||||||
|
|
||||||
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
||||||
when(successResponse.getStatus()).thenReturn(200);
|
when(successResponse.getStatus()).thenReturn(200);
|
||||||
|
@ -650,151 +783,16 @@ class WebSocketConnectionTest {
|
||||||
connection.processStoredMessages();
|
connection.processStoredMessages();
|
||||||
connection.handleMessagesPersisted();
|
connection.handleMessagesPersisted();
|
||||||
|
|
||||||
|
if (useReactive) {
|
||||||
|
verify(messagesManager, times(2)).getMessagesForDeviceReactive(account.getUuid(), device.getId(), false);
|
||||||
|
} else {
|
||||||
verify(messagesManager, times(2)).getMessagesForDevice(account.getUuid(), device.getId(), false);
|
verify(messagesManager, times(2)).getMessagesForDevice(account.getUuid(), device.getId(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void testDiscardOversizedMessagesForDesktop() {
|
|
||||||
MessagesManager storedMessages = mock(MessagesManager.class);
|
|
||||||
|
|
||||||
UUID accountUuid = UUID.randomUUID();
|
|
||||||
UUID senderOneUuid = UUID.randomUUID();
|
|
||||||
UUID senderTwoUuid = UUID.randomUUID();
|
|
||||||
|
|
||||||
List<Envelope> outgoingMessages = List.of(
|
|
||||||
createMessage(senderOneUuid, UUID.randomUUID(), 1111, "first"),
|
|
||||||
createMessage(senderOneUuid, UUID.randomUUID(), 2222,
|
|
||||||
RandomStringUtils.randomAlphanumeric(WebSocketConnection.MAX_DESKTOP_MESSAGE_SIZE + 1)),
|
|
||||||
createMessage(senderTwoUuid, UUID.randomUUID(), 3333, "third"));
|
|
||||||
|
|
||||||
when(device.getId()).thenReturn(2L);
|
|
||||||
|
|
||||||
when(account.getNumber()).thenReturn("+14152222222");
|
|
||||||
when(account.getUuid()).thenReturn(accountUuid);
|
|
||||||
|
|
||||||
final Device sender1device = mock(Device.class);
|
|
||||||
|
|
||||||
List<Device> sender1devices = List.of(sender1device);
|
|
||||||
|
|
||||||
Account sender1 = mock(Account.class);
|
|
||||||
when(sender1.getDevices()).thenReturn(sender1devices);
|
|
||||||
|
|
||||||
when(accountsManager.getByE164("sender1")).thenReturn(Optional.of(sender1));
|
|
||||||
when(accountsManager.getByE164("sender2")).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
String userAgent = "Signal-Desktop/1.2.3";
|
|
||||||
|
|
||||||
when(storedMessages.getMessagesForDevice(account.getUuid(), device.getId(), false))
|
|
||||||
.thenReturn(new Pair<>(outgoingMessages, false));
|
|
||||||
|
|
||||||
final List<CompletableFuture<WebSocketResponseMessage>> futures = new LinkedList<>();
|
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
|
||||||
|
|
||||||
when(client.getUserAgent()).thenReturn(userAgent);
|
|
||||||
when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), ArgumentMatchers.nullable(List.class),
|
|
||||||
ArgumentMatchers.<Optional<byte[]>>any()))
|
|
||||||
.thenAnswer(new Answer<CompletableFuture<WebSocketResponseMessage>>() {
|
|
||||||
@Override
|
|
||||||
public CompletableFuture<WebSocketResponseMessage> answer(InvocationOnMock invocationOnMock) {
|
|
||||||
CompletableFuture<WebSocketResponseMessage> future = new CompletableFuture<>();
|
|
||||||
futures.add(future);
|
|
||||||
return future;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, auth, device, client,
|
|
||||||
retrySchedulingExecutor);
|
|
||||||
|
|
||||||
connection.start();
|
|
||||||
verify(client, times(2)).sendRequest(eq("PUT"), eq("/api/v1/message"), ArgumentMatchers.nullable(List.class),
|
|
||||||
ArgumentMatchers.<Optional<byte[]>>any());
|
|
||||||
|
|
||||||
assertEquals(2, futures.size());
|
|
||||||
|
|
||||||
WebSocketResponseMessage response = mock(WebSocketResponseMessage.class);
|
|
||||||
when(response.getStatus()).thenReturn(200);
|
|
||||||
futures.get(0).complete(response);
|
|
||||||
futures.get(1).complete(response);
|
|
||||||
|
|
||||||
// We should delete all three messages even though we only sent two; one got discarded because it was too big for
|
|
||||||
// desktop clients.
|
|
||||||
verify(storedMessages, times(3)).delete(eq(accountUuid), eq(2L), any(UUID.class), any(Long.class));
|
|
||||||
|
|
||||||
connection.stop();
|
|
||||||
verify(client).close(anyInt(), anyString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testSendOversizedMessagesForNonDesktop() {
|
@ValueSource(booleans = {true, false})
|
||||||
MessagesManager storedMessages = mock(MessagesManager.class);
|
void testRetrieveMessageException(final boolean useReactive) {
|
||||||
|
|
||||||
UUID accountUuid = UUID.randomUUID();
|
|
||||||
UUID senderOneUuid = UUID.randomUUID();
|
|
||||||
UUID senderTwoUuid = UUID.randomUUID();
|
|
||||||
|
|
||||||
List<Envelope> outgoingMessages = List.of(createMessage(senderOneUuid, UUID.randomUUID(), 1111, "first"),
|
|
||||||
createMessage(senderOneUuid, UUID.randomUUID(), 2222,
|
|
||||||
RandomStringUtils.randomAlphanumeric(WebSocketConnection.MAX_DESKTOP_MESSAGE_SIZE + 1)),
|
|
||||||
createMessage(senderTwoUuid, UUID.randomUUID(), 3333, "third"));
|
|
||||||
|
|
||||||
when(device.getId()).thenReturn(2L);
|
|
||||||
|
|
||||||
when(account.getNumber()).thenReturn("+14152222222");
|
|
||||||
when(account.getUuid()).thenReturn(accountUuid);
|
|
||||||
|
|
||||||
final Device sender1device = mock(Device.class);
|
|
||||||
|
|
||||||
List<Device> sender1devices = List.of(sender1device);
|
|
||||||
|
|
||||||
Account sender1 = mock(Account.class);
|
|
||||||
when(sender1.getDevices()).thenReturn(sender1devices);
|
|
||||||
|
|
||||||
when(accountsManager.getByE164("sender1")).thenReturn(Optional.of(sender1));
|
|
||||||
when(accountsManager.getByE164("sender2")).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
String userAgent = "Signal-Android/4.68.3";
|
|
||||||
|
|
||||||
when(storedMessages.getMessagesForDevice(account.getUuid(), device.getId(), false))
|
|
||||||
.thenReturn(new Pair<>(outgoingMessages, false));
|
|
||||||
|
|
||||||
final List<CompletableFuture<WebSocketResponseMessage>> futures = new LinkedList<>();
|
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
|
||||||
|
|
||||||
when(client.getUserAgent()).thenReturn(userAgent);
|
|
||||||
when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), ArgumentMatchers.nullable(List.class),
|
|
||||||
ArgumentMatchers.<Optional<byte[]>>any()))
|
|
||||||
.thenAnswer(new Answer<CompletableFuture<WebSocketResponseMessage>>() {
|
|
||||||
@Override
|
|
||||||
public CompletableFuture<WebSocketResponseMessage> answer(InvocationOnMock invocationOnMock) {
|
|
||||||
CompletableFuture<WebSocketResponseMessage> future = new CompletableFuture<>();
|
|
||||||
futures.add(future);
|
|
||||||
return future;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, auth, device, client,
|
|
||||||
retrySchedulingExecutor);
|
|
||||||
|
|
||||||
connection.start();
|
|
||||||
verify(client, times(3)).sendRequest(eq("PUT"), eq("/api/v1/message"), ArgumentMatchers.nullable(List.class),
|
|
||||||
ArgumentMatchers.<Optional<byte[]>>any());
|
|
||||||
|
|
||||||
assertEquals(3, futures.size());
|
|
||||||
|
|
||||||
WebSocketResponseMessage response = mock(WebSocketResponseMessage.class);
|
|
||||||
when(response.getStatus()).thenReturn(200);
|
|
||||||
futures.get(0).complete(response);
|
|
||||||
futures.get(1).complete(response);
|
|
||||||
futures.get(2).complete(response);
|
|
||||||
|
|
||||||
verify(storedMessages, times(3)).delete(eq(accountUuid), eq(2L), any(UUID.class), any(Long.class));
|
|
||||||
|
|
||||||
connection.stop();
|
|
||||||
verify(client).close(anyInt(), anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testRetrieveMessageException() {
|
|
||||||
MessagesManager storedMessages = mock(MessagesManager.class);
|
MessagesManager storedMessages = mock(MessagesManager.class);
|
||||||
|
|
||||||
UUID accountUuid = UUID.randomUUID();
|
UUID accountUuid = UUID.randomUUID();
|
||||||
|
@ -804,10 +802,13 @@ class WebSocketConnectionTest {
|
||||||
when(account.getNumber()).thenReturn("+14152222222");
|
when(account.getNumber()).thenReturn("+14152222222");
|
||||||
when(account.getUuid()).thenReturn(accountUuid);
|
when(account.getUuid()).thenReturn(accountUuid);
|
||||||
|
|
||||||
String userAgent = "Signal-Android/4.68.3";
|
if (useReactive) {
|
||||||
|
when(storedMessages.getMessagesForDeviceReactive(account.getUuid(), device.getId(), false))
|
||||||
|
.thenReturn(Flux.error(new RedisException("OH NO")));
|
||||||
|
} else {
|
||||||
when(storedMessages.getMessagesForDevice(account.getUuid(), device.getId(), false))
|
when(storedMessages.getMessagesForDevice(account.getUuid(), device.getId(), false))
|
||||||
.thenThrow(new RedisException("OH NO"));
|
.thenThrow(new RedisException("OH NO"));
|
||||||
|
}
|
||||||
|
|
||||||
when(retrySchedulingExecutor.schedule(any(Runnable.class), anyLong(), any())).thenAnswer(
|
when(retrySchedulingExecutor.schedule(any(Runnable.class), anyLong(), any())).thenAnswer(
|
||||||
(Answer<ScheduledFuture<?>>) invocation -> {
|
(Answer<ScheduledFuture<?>>) invocation -> {
|
||||||
|
@ -819,7 +820,7 @@ class WebSocketConnectionTest {
|
||||||
when(client.isOpen()).thenReturn(true);
|
when(client.isOpen()).thenReturn(true);
|
||||||
|
|
||||||
WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, auth, device, client,
|
WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, auth, device, client,
|
||||||
retrySchedulingExecutor);
|
retrySchedulingExecutor, useReactive, Schedulers.immediate());
|
||||||
connection.start();
|
connection.start();
|
||||||
|
|
||||||
verify(retrySchedulingExecutor, times(WebSocketConnection.MAX_CONSECUTIVE_RETRIES)).schedule(any(Runnable.class),
|
verify(retrySchedulingExecutor, times(WebSocketConnection.MAX_CONSECUTIVE_RETRIES)).schedule(any(Runnable.class),
|
||||||
|
@ -827,8 +828,9 @@ class WebSocketConnectionTest {
|
||||||
verify(client).close(eq(1011), anyString());
|
verify(client).close(eq(1011), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testRetrieveMessageExceptionClientDisconnected() {
|
@ValueSource(booleans = {true, false})
|
||||||
|
void testRetrieveMessageExceptionClientDisconnected(final boolean useReactive) {
|
||||||
MessagesManager storedMessages = mock(MessagesManager.class);
|
MessagesManager storedMessages = mock(MessagesManager.class);
|
||||||
|
|
||||||
UUID accountUuid = UUID.randomUUID();
|
UUID accountUuid = UUID.randomUUID();
|
||||||
|
@ -838,22 +840,143 @@ class WebSocketConnectionTest {
|
||||||
when(account.getNumber()).thenReturn("+14152222222");
|
when(account.getNumber()).thenReturn("+14152222222");
|
||||||
when(account.getUuid()).thenReturn(accountUuid);
|
when(account.getUuid()).thenReturn(accountUuid);
|
||||||
|
|
||||||
String userAgent = "Signal-Android/4.68.3";
|
if (useReactive) {
|
||||||
|
when(storedMessages.getMessagesForDeviceReactive(account.getUuid(), device.getId(), false))
|
||||||
|
.thenReturn(Flux.error(new RedisException("OH NO")));
|
||||||
|
} else {
|
||||||
when(storedMessages.getMessagesForDevice(account.getUuid(), device.getId(), false))
|
when(storedMessages.getMessagesForDevice(account.getUuid(), device.getId(), false))
|
||||||
.thenThrow(new RedisException("OH NO"));
|
.thenThrow(new RedisException("OH NO"));
|
||||||
|
}
|
||||||
|
|
||||||
final WebSocketClient client = mock(WebSocketClient.class);
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
when(client.isOpen()).thenReturn(false);
|
when(client.isOpen()).thenReturn(false);
|
||||||
|
|
||||||
WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, auth, device, client,
|
WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, auth, device, client,
|
||||||
retrySchedulingExecutor);
|
retrySchedulingExecutor, useReactive, Schedulers.immediate());
|
||||||
connection.start();
|
connection.start();
|
||||||
|
|
||||||
verify(retrySchedulingExecutor, never()).schedule(any(Runnable.class), anyLong(), any());
|
verify(retrySchedulingExecutor, never()).schedule(any(Runnable.class), anyLong(), any());
|
||||||
verify(client, never()).close(anyInt(), anyString());
|
verify(client, never()).close(anyInt(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Disabled("This test is flaky")
|
||||||
|
void testReactivePublisherLimitRate() {
|
||||||
|
MessagesManager storedMessages = mock(MessagesManager.class);
|
||||||
|
|
||||||
|
final UUID accountUuid = UUID.randomUUID();
|
||||||
|
|
||||||
|
final long deviceId = 2L;
|
||||||
|
when(device.getId()).thenReturn(deviceId);
|
||||||
|
|
||||||
|
when(account.getNumber()).thenReturn("+14152222222");
|
||||||
|
when(account.getUuid()).thenReturn(accountUuid);
|
||||||
|
|
||||||
|
final int totalMessages = 10;
|
||||||
|
final AtomicReference<FluxSink<Envelope>> sink = new AtomicReference<>();
|
||||||
|
|
||||||
|
final AtomicLong maxRequest = new AtomicLong(-1);
|
||||||
|
final Flux<Envelope> flux = Flux.create(s -> {
|
||||||
|
sink.set(s);
|
||||||
|
s.onRequest(n -> {
|
||||||
|
if (maxRequest.get() < n) {
|
||||||
|
maxRequest.set(n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
when(storedMessages.getMessagesForDeviceReactive(eq(accountUuid), eq(deviceId), anyBoolean()))
|
||||||
|
.thenReturn(flux);
|
||||||
|
|
||||||
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
|
when(client.isOpen()).thenReturn(true);
|
||||||
|
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
||||||
|
when(successResponse.getStatus()).thenReturn(200);
|
||||||
|
when(client.sendRequest(any(), any(), any(), any())).thenReturn(CompletableFuture.completedFuture(successResponse));
|
||||||
|
when(storedMessages.delete(any(), anyLong(), any(), any())).thenReturn(
|
||||||
|
CompletableFuture.completedFuture(Optional.empty()));
|
||||||
|
|
||||||
|
WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, auth, device, client,
|
||||||
|
retrySchedulingExecutor, true);
|
||||||
|
|
||||||
|
connection.start();
|
||||||
|
|
||||||
|
StepVerifier.setDefaultTimeout(Duration.ofSeconds(5));
|
||||||
|
|
||||||
|
StepVerifier.create(flux, 0)
|
||||||
|
.expectSubscription()
|
||||||
|
.thenRequest(totalMessages * 2)
|
||||||
|
.then(() -> {
|
||||||
|
for (long i = 0; i < totalMessages; i++) {
|
||||||
|
sink.get().next(createMessage(UUID.randomUUID(), accountUuid, 1111 * i + 1, "message " + i));
|
||||||
|
}
|
||||||
|
sink.get().complete();
|
||||||
|
})
|
||||||
|
.expectNextCount(totalMessages)
|
||||||
|
.expectComplete()
|
||||||
|
.log()
|
||||||
|
.verify();
|
||||||
|
|
||||||
|
assertEquals(WebSocketConnection.MESSAGE_PUBLISHER_LIMIT_RATE, maxRequest.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testReactivePublisherDisposedWhenConnectionStopped() {
|
||||||
|
MessagesManager storedMessages = mock(MessagesManager.class);
|
||||||
|
|
||||||
|
final UUID accountUuid = UUID.randomUUID();
|
||||||
|
|
||||||
|
final long deviceId = 2L;
|
||||||
|
when(device.getId()).thenReturn(deviceId);
|
||||||
|
|
||||||
|
when(account.getNumber()).thenReturn("+14152222222");
|
||||||
|
when(account.getUuid()).thenReturn(accountUuid);
|
||||||
|
|
||||||
|
final AtomicBoolean canceled = new AtomicBoolean();
|
||||||
|
|
||||||
|
final Flux<Envelope> flux = Flux.create(s -> {
|
||||||
|
s.onRequest(n -> {
|
||||||
|
// the subscriber should request more than 1 message, but we will only send one, so that
|
||||||
|
// we are sure the subscriber is waiting for more when we stop the connection
|
||||||
|
assert n > 1;
|
||||||
|
s.next(createMessage(UUID.randomUUID(), UUID.randomUUID(), 1111, "first"));
|
||||||
|
});
|
||||||
|
|
||||||
|
s.onCancel(() -> canceled.set(true));
|
||||||
|
});
|
||||||
|
when(storedMessages.getMessagesForDeviceReactive(eq(accountUuid), eq(deviceId), anyBoolean()))
|
||||||
|
.thenReturn(flux);
|
||||||
|
|
||||||
|
final WebSocketClient client = mock(WebSocketClient.class);
|
||||||
|
when(client.isOpen()).thenReturn(true);
|
||||||
|
final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);
|
||||||
|
when(successResponse.getStatus()).thenReturn(200);
|
||||||
|
when(client.sendRequest(any(), any(), any(), any())).thenReturn(CompletableFuture.completedFuture(successResponse));
|
||||||
|
when(storedMessages.delete(any(), anyLong(), any(), any())).thenReturn(
|
||||||
|
CompletableFuture.completedFuture(Optional.empty()));
|
||||||
|
|
||||||
|
WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, auth, device, client,
|
||||||
|
retrySchedulingExecutor, true, Schedulers.immediate());
|
||||||
|
|
||||||
|
connection.start();
|
||||||
|
|
||||||
|
verify(client).sendRequest(any(), any(), any(), any());
|
||||||
|
|
||||||
|
// close the connection before the publisher completes
|
||||||
|
connection.stop();
|
||||||
|
|
||||||
|
StepVerifier.setDefaultTimeout(Duration.ofSeconds(2));
|
||||||
|
|
||||||
|
StepVerifier.create(flux)
|
||||||
|
.expectSubscription()
|
||||||
|
.expectNextCount(1)
|
||||||
|
.then(() -> assertTrue(canceled.get()))
|
||||||
|
// this is not entirely intuitive, but expecting a timeout is the recommendation for verifying cancellation
|
||||||
|
.expectTimeout(Duration.ofMillis(100))
|
||||||
|
.log()
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
private Envelope createMessage(UUID senderUuid, UUID destinationUuid, long timestamp, String content) {
|
private Envelope createMessage(UUID senderUuid, UUID destinationUuid, long timestamp, String content) {
|
||||||
return Envelope.newBuilder()
|
return Envelope.newBuilder()
|
||||||
.setServerGuid(UUID.randomUUID().toString())
|
.setServerGuid(UUID.randomUUID().toString())
|
||||||
|
|
|
@ -8,4 +8,7 @@
|
||||||
<root level="warn">
|
<root level="warn">
|
||||||
<appender-ref ref="STDOUT"/>
|
<appender-ref ref="STDOUT"/>
|
||||||
</root>
|
</root>
|
||||||
|
|
||||||
|
<!-- uncomment and combine with .log() in StepVerifier for more insight into reactor operations -->
|
||||||
|
<!-- <logger name="reactor" level="debug"/> -->
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|
Loading…
Reference in New Issue