Skip to content

Commit 045ec96

Browse files
authored
Introduce a job scheduler and experiment for sending notifications to idle devices
1 parent 4ebad2c commit 045ec96

13 files changed

+830
-44
lines changed

service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,12 @@
251251
import org.whispersystems.textsecuregcm.workers.CertificateCommand;
252252
import org.whispersystems.textsecuregcm.workers.CheckDynamicConfigurationCommand;
253253
import org.whispersystems.textsecuregcm.workers.DeleteUserCommand;
254+
import org.whispersystems.textsecuregcm.workers.DiscardPushNotificationExperimentSamplesCommand;
255+
import org.whispersystems.textsecuregcm.workers.FinishPushNotificationExperimentCommand;
256+
import org.whispersystems.textsecuregcm.workers.IdleDeviceNotificationSchedulerFactory;
254257
import org.whispersystems.textsecuregcm.workers.MessagePersisterServiceCommand;
258+
import org.whispersystems.textsecuregcm.workers.NotifyIdleDevicesWithoutMessagesExperimentFactory;
259+
import org.whispersystems.textsecuregcm.workers.ProcessScheduledJobsServiceCommand;
255260
import org.whispersystems.textsecuregcm.workers.RemoveExpiredAccountsCommand;
256261
import org.whispersystems.textsecuregcm.workers.RemoveExpiredBackupsCommand;
257262
import org.whispersystems.textsecuregcm.workers.RemoveExpiredLinkedDevicesCommand;
@@ -260,6 +265,7 @@
260265
import org.whispersystems.textsecuregcm.workers.ServerVersionCommand;
261266
import org.whispersystems.textsecuregcm.workers.SetRequestLoggingEnabledTask;
262267
import org.whispersystems.textsecuregcm.workers.SetUserDiscoverabilityCommand;
268+
import org.whispersystems.textsecuregcm.workers.StartPushNotificationExperimentCommand;
263269
import org.whispersystems.textsecuregcm.workers.UnlinkDeviceCommand;
264270
import org.whispersystems.textsecuregcm.workers.ZkParamsCommand;
265271
import org.whispersystems.websocket.WebSocketResourceProviderFactory;
@@ -313,6 +319,24 @@ public void initialize(final Bootstrap<WhisperServerConfiguration> bootstrap) {
313319
bootstrap.addCommand(new RemoveExpiredBackupsCommand(Clock.systemUTC()));
314320
bootstrap.addCommand(new BackupMetricsCommand(Clock.systemUTC()));
315321
bootstrap.addCommand(new RemoveExpiredLinkedDevicesCommand());
322+
bootstrap.addCommand(new ProcessScheduledJobsServiceCommand("process-idle-device-notification-jobs",
323+
"Processes scheduled jobs to send notifications to idle devices",
324+
new IdleDeviceNotificationSchedulerFactory()));
325+
326+
bootstrap.addCommand(
327+
new StartPushNotificationExperimentCommand<>("start-notify-idle-devices-without-messages-experiment",
328+
"Start an experiment to send push notifications to idle devices with empty message queues",
329+
new NotifyIdleDevicesWithoutMessagesExperimentFactory()));
330+
331+
bootstrap.addCommand(
332+
new FinishPushNotificationExperimentCommand<>("finish-notify-idle-devices-without-messages-experiment",
333+
"Finish an experiment to send push notifications to idle devices with empty message queues",
334+
new NotifyIdleDevicesWithoutMessagesExperimentFactory()));
335+
336+
bootstrap.addCommand(
337+
new DiscardPushNotificationExperimentSamplesCommand("discard-notify-idle-devices-without-messages-samples",
338+
"Discard samples from the \"notify idle devices without messages\" experiment",
339+
new NotifyIdleDevicesWithoutMessagesExperimentFactory()));
316340
}
317341

318342
@Override
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.whispersystems.textsecuregcm.experiment;
2+
3+
import javax.annotation.Nullable;
4+
5+
public record DeviceLastSeenState(boolean deviceExists,
6+
long createdAtMillis,
7+
boolean hasPushToken,
8+
long lastSeenMillis,
9+
@Nullable PushTokenType pushTokenType) {
10+
11+
public static DeviceLastSeenState MISSING_DEVICE_STATE = new DeviceLastSeenState(false, 0, false, 0, null);
12+
13+
public enum PushTokenType {
14+
APNS,
15+
FCM
16+
}
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package org.whispersystems.textsecuregcm.experiment;
2+
3+
import com.google.common.annotations.VisibleForTesting;
4+
import org.apache.commons.lang3.StringUtils;
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
import org.whispersystems.textsecuregcm.identity.IdentityType;
8+
import org.whispersystems.textsecuregcm.push.IdleDeviceNotificationScheduler;
9+
import org.whispersystems.textsecuregcm.storage.Account;
10+
import org.whispersystems.textsecuregcm.storage.Device;
11+
import org.whispersystems.textsecuregcm.storage.MessagesManager;
12+
import reactor.core.publisher.Flux;
13+
import javax.annotation.Nullable;
14+
import java.util.EnumMap;
15+
import java.util.Map;
16+
import java.time.LocalTime;
17+
import java.util.concurrent.CompletableFuture;
18+
19+
public class NotifyIdleDevicesWithoutMessagesPushNotificationExperiment implements PushNotificationExperiment<DeviceLastSeenState> {
20+
21+
private final MessagesManager messagesManager;
22+
private final IdleDeviceNotificationScheduler idleDeviceNotificationScheduler;
23+
24+
private static final LocalTime PREFERRED_NOTIFICATION_TIME = LocalTime.of(14, 0);
25+
26+
private static final Logger log = LoggerFactory.getLogger(NotifyIdleDevicesWithoutMessagesPushNotificationExperiment.class);
27+
28+
@VisibleForTesting
29+
enum Population {
30+
APNS_CONTROL,
31+
APNS_EXPERIMENT,
32+
FCM_CONTROL,
33+
FCM_EXPERIMENT
34+
}
35+
36+
@VisibleForTesting
37+
enum Outcome {
38+
DELETED,
39+
UNINSTALLED,
40+
REACTIVATED,
41+
UNCHANGED
42+
}
43+
44+
public NotifyIdleDevicesWithoutMessagesPushNotificationExperiment(final MessagesManager messagesManager,
45+
final IdleDeviceNotificationScheduler idleDeviceNotificationScheduler) {
46+
47+
this.messagesManager = messagesManager;
48+
this.idleDeviceNotificationScheduler = idleDeviceNotificationScheduler;
49+
}
50+
51+
@Override
52+
public String getExperimentName() {
53+
return "notify-idle-devices-without-messages";
54+
}
55+
56+
@Override
57+
public CompletableFuture<Boolean> isDeviceEligible(final Account account, final Device device) {
58+
59+
if (!hasPushToken(device)) {
60+
return CompletableFuture.completedFuture(false);
61+
}
62+
63+
if (!idleDeviceNotificationScheduler.isIdle(device)) {
64+
return CompletableFuture.completedFuture(false);
65+
}
66+
67+
return messagesManager.mayHaveMessages(account.getIdentifier(IdentityType.ACI), device)
68+
.thenApply(mayHaveMessages -> !mayHaveMessages);
69+
}
70+
71+
@VisibleForTesting
72+
static boolean hasPushToken(final Device device) {
73+
// Exclude VOIP tokens since they have their own, distinct delivery mechanism
74+
return !StringUtils.isAllBlank(device.getApnId(), device.getGcmId()) && StringUtils.isBlank(device.getVoipApnId());
75+
}
76+
77+
@Override
78+
public DeviceLastSeenState getState(@Nullable final Account account, @Nullable final Device device) {
79+
if (account != null && device != null) {
80+
final DeviceLastSeenState.PushTokenType pushTokenType = StringUtils.isNotBlank(device.getApnId())
81+
? DeviceLastSeenState.PushTokenType.APNS
82+
: DeviceLastSeenState.PushTokenType.FCM;
83+
84+
return new DeviceLastSeenState(true, device.getCreated(), hasPushToken(device), device.getLastSeen(), pushTokenType);
85+
} else {
86+
return DeviceLastSeenState.MISSING_DEVICE_STATE;
87+
}
88+
}
89+
90+
@Override
91+
public CompletableFuture<Void> applyExperimentTreatment(final Account account, final Device device) {
92+
return idleDeviceNotificationScheduler.scheduleNotification(account, device.getId(), PREFERRED_NOTIFICATION_TIME);
93+
}
94+
95+
@Override
96+
public void analyzeResults(final Flux<PushNotificationExperimentSample<DeviceLastSeenState>> samples) {
97+
final Map<Population, Map<Outcome, Integer>> contingencyTable = new EnumMap<>(Population.class);
98+
99+
for (final Population population : Population.values()) {
100+
final Map<Outcome, Integer> countsByOutcome = new EnumMap<>(Outcome.class);
101+
102+
for (final Outcome outcome : Outcome.values()) {
103+
countsByOutcome.put(outcome, 0);
104+
}
105+
106+
contingencyTable.put(population, countsByOutcome);
107+
}
108+
109+
samples.doOnNext(sample -> contingencyTable.get(getPopulation(sample)).merge(getOutcome(sample), 1, Integer::sum))
110+
.then()
111+
.block();
112+
113+
final StringBuilder reportBuilder = new StringBuilder("population,deleted,uninstalled,reactivated,unchanged\n");
114+
115+
for (final Population population : Population.values()) {
116+
final Map<Outcome, Integer> countsByOutcome = contingencyTable.get(population);
117+
118+
reportBuilder.append(population.name());
119+
reportBuilder.append(",");
120+
reportBuilder.append(countsByOutcome.getOrDefault(Outcome.DELETED, 0));
121+
reportBuilder.append(",");
122+
reportBuilder.append(countsByOutcome.getOrDefault(Outcome.UNINSTALLED, 0));
123+
reportBuilder.append(",");
124+
reportBuilder.append(countsByOutcome.getOrDefault(Outcome.REACTIVATED, 0));
125+
reportBuilder.append(",");
126+
reportBuilder.append(countsByOutcome.getOrDefault(Outcome.UNCHANGED, 0));
127+
reportBuilder.append("\n");
128+
}
129+
130+
log.info(reportBuilder.toString());
131+
}
132+
133+
@VisibleForTesting
134+
static Population getPopulation(final PushNotificationExperimentSample<DeviceLastSeenState> sample) {
135+
assert sample.initialState() != null && sample.initialState().pushTokenType() != null;
136+
137+
return switch (sample.initialState().pushTokenType()) {
138+
case APNS -> sample.inExperimentGroup() ? Population.APNS_EXPERIMENT : Population.APNS_CONTROL;
139+
case FCM -> sample.inExperimentGroup() ? Population.FCM_EXPERIMENT : Population.FCM_CONTROL;
140+
};
141+
}
142+
143+
@VisibleForTesting
144+
static Outcome getOutcome(final PushNotificationExperimentSample<DeviceLastSeenState> sample) {
145+
final Outcome outcome;
146+
147+
if (!sample.finalState().deviceExists() || sample.initialState().createdAtMillis() != sample.finalState().createdAtMillis()) {
148+
outcome = Outcome.DELETED;
149+
} else if (!sample.finalState().hasPushToken()) {
150+
outcome = Outcome.UNINSTALLED;
151+
} else if (sample.initialState().lastSeenMillis() != sample.finalState().lastSeenMillis()) {
152+
outcome = Outcome.REACTIVATED;
153+
} else {
154+
outcome = Outcome.UNCHANGED;
155+
}
156+
157+
return outcome;
158+
}
159+
}

service/src/main/java/org/whispersystems/textsecuregcm/experiment/PushNotificationExperiment.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import org.whispersystems.textsecuregcm.storage.Account;
44
import org.whispersystems.textsecuregcm.storage.Device;
5+
import reactor.core.publisher.Flux;
56
import javax.annotation.Nullable;
67
import java.util.concurrent.CompletableFuture;
78

@@ -65,4 +66,12 @@ default CompletableFuture<Void> applyControlTreatment(Account account, Device de
6566
* @return a future that completes when the experimental treatment has been applied for the given device
6667
*/
6768
CompletableFuture<Void> applyExperimentTreatment(Account account, Device device);
69+
70+
/**
71+
* Consumes a stream of finished samples and emits an analysis of the results via an implementation-specific channel
72+
* (e.g. a log message). Implementations must block until all samples have been consumed and analyzed.
73+
*
74+
* @param samples a stream of finished samples from this experiment
75+
*/
76+
void analyzeResults(Flux<PushNotificationExperimentSample<T>> samples);
6877
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package org.whispersystems.textsecuregcm.push;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.google.common.annotations.VisibleForTesting;
5+
import org.whispersystems.textsecuregcm.identity.IdentityType;
6+
import org.whispersystems.textsecuregcm.scheduler.JobScheduler;
7+
import org.whispersystems.textsecuregcm.scheduler.SchedulingUtil;
8+
import org.whispersystems.textsecuregcm.storage.Account;
9+
import org.whispersystems.textsecuregcm.storage.AccountsManager;
10+
import org.whispersystems.textsecuregcm.storage.Device;
11+
import org.whispersystems.textsecuregcm.util.SystemMapper;
12+
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
13+
import javax.annotation.Nullable;
14+
import java.io.IOException;
15+
import java.time.Clock;
16+
import java.time.Duration;
17+
import java.time.Instant;
18+
import java.time.LocalTime;
19+
import java.util.UUID;
20+
import java.util.concurrent.CompletableFuture;
21+
22+
public class IdleDeviceNotificationScheduler extends JobScheduler {
23+
24+
private final AccountsManager accountsManager;
25+
private final PushNotificationManager pushNotificationManager;
26+
private final Clock clock;
27+
28+
@VisibleForTesting
29+
static final Duration MIN_IDLE_DURATION = Duration.ofDays(14);
30+
31+
@VisibleForTesting
32+
record AccountAndDeviceIdentifier(UUID accountIdentifier, byte deviceId) {}
33+
34+
public IdleDeviceNotificationScheduler(final AccountsManager accountsManager,
35+
final PushNotificationManager pushNotificationManager,
36+
final DynamoDbAsyncClient dynamoDbAsyncClient,
37+
final String tableName,
38+
final Duration jobExpiration,
39+
final Clock clock) {
40+
41+
super(dynamoDbAsyncClient, tableName, jobExpiration, clock);
42+
43+
this.accountsManager = accountsManager;
44+
this.pushNotificationManager = pushNotificationManager;
45+
this.clock = clock;
46+
}
47+
48+
@Override
49+
public String getSchedulerName() {
50+
return "IdleDeviceNotification";
51+
}
52+
53+
@Override
54+
protected CompletableFuture<String> processJob(@Nullable final byte[] jobData) {
55+
final AccountAndDeviceIdentifier accountAndDeviceIdentifier;
56+
57+
try {
58+
accountAndDeviceIdentifier = SystemMapper.jsonMapper().readValue(jobData, AccountAndDeviceIdentifier.class);
59+
} catch (final IOException e) {
60+
return CompletableFuture.failedFuture(e);
61+
}
62+
63+
return accountsManager.getByAccountIdentifierAsync(accountAndDeviceIdentifier.accountIdentifier())
64+
.thenCompose(maybeAccount -> maybeAccount.map(account ->
65+
account.getDevice(accountAndDeviceIdentifier.deviceId()).map(device -> {
66+
if (!isIdle(device)) {
67+
return CompletableFuture.completedFuture("deviceSeenRecently");
68+
}
69+
70+
try {
71+
return pushNotificationManager
72+
.sendNewMessageNotification(account, accountAndDeviceIdentifier.deviceId(), true)
73+
.thenApply(ignored -> "sent");
74+
} catch (final NotPushRegisteredException e) {
75+
return CompletableFuture.completedFuture("deviceTokenDeleted");
76+
}
77+
})
78+
.orElse(CompletableFuture.completedFuture("deviceDeleted")))
79+
.orElse(CompletableFuture.completedFuture("accountDeleted")));
80+
}
81+
82+
public boolean isIdle(final Device device) {
83+
final Duration idleDuration = Duration.between(Instant.ofEpochMilli(device.getLastSeen()), clock.instant());
84+
85+
return idleDuration.compareTo(MIN_IDLE_DURATION) >= 0;
86+
}
87+
88+
public CompletableFuture<Void> scheduleNotification(final Account account, final byte deviceId, final LocalTime preferredDeliveryTime) {
89+
final Instant runAt = SchedulingUtil.getNextRecommendedNotificationTime(account, preferredDeliveryTime, clock);
90+
91+
try {
92+
return scheduleJob(runAt, SystemMapper.jsonMapper().writeValueAsBytes(
93+
new AccountAndDeviceIdentifier(account.getIdentifier(IdentityType.ACI), deviceId)));
94+
} catch (final JsonProcessingException e) {
95+
// This should never happen when serializing an `AccountAndDeviceIdentifier`
96+
throw new AssertionError(e);
97+
}
98+
}
99+
}

service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ record CommandDependencies(
8181
FaultTolerantRedisCluster pushSchedulerCluster,
8282
ClientResources.Builder redisClusterClientResourcesBuilder,
8383
BackupManager backupManager,
84-
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
84+
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
85+
DynamoDbAsyncClient dynamoDbAsyncClient) {
8586

8687
static CommandDependencies build(
8788
final String name,
@@ -271,7 +272,8 @@ static CommandDependencies build(
271272
pushSchedulerCluster,
272273
redisClientResourcesBuilder,
273274
backupManager,
274-
dynamicConfigurationManager
275+
dynamicConfigurationManager,
276+
dynamoDbAsyncClient
275277
);
276278
}
277279

0 commit comments

Comments
 (0)