From 837065bfbde8b3abb4fb00100fd11022722b458a Mon Sep 17 00:00:00 2001 From: Jon Chambers Date: Fri, 3 Apr 2026 12:16:35 -0400 Subject: [PATCH] Retire commands for removing accounts/devices that do not support SPQR --- .../textsecuregcm/WhisperServerService.java | 4 - .../workers/RemoveNonSpqrAccountsCommand.java | 99 ---------------- .../RemoveNonSpqrLinkedDevicesCommand.java | 93 --------------- .../RemoveNonSpqrAccountsCommandTest.java | 107 ------------------ ...RemoveNonSpqrLinkedDevicesCommandTest.java | 98 ---------------- 5 files changed, 401 deletions(-) delete mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrAccountsCommand.java delete mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrLinkedDevicesCommand.java delete mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrAccountsCommandTest.java delete mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrLinkedDevicesCommandTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index b7ca28184..c97d94cab 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -294,8 +294,6 @@ import org.whispersystems.textsecuregcm.workers.RemoveExpiredAccountsCommand; import org.whispersystems.textsecuregcm.workers.RemoveExpiredBackupsCommand; import org.whispersystems.textsecuregcm.workers.RemoveExpiredLinkedDevicesCommand; import org.whispersystems.textsecuregcm.workers.RemoveExpiredUsernameHoldsCommand; -import org.whispersystems.textsecuregcm.workers.RemoveNonSpqrAccountsCommand; -import org.whispersystems.textsecuregcm.workers.RemoveNonSpqrLinkedDevicesCommand; import org.whispersystems.textsecuregcm.workers.RemoveOrphanedPreKeyPagesCommand; import org.whispersystems.textsecuregcm.workers.ScheduledApnPushNotificationSenderServiceCommand; import org.whispersystems.textsecuregcm.workers.ServerVersionCommand; @@ -358,8 +356,6 @@ public class WhisperServerService extends Application accounts) { - final int maxAccounts = getNamespace().getInt(MAX_ACCOUNTS_ARGUMENT); - final int maxConcurrency = getNamespace().getInt(MAX_CONCURRENCY_ARGUMENT); - final boolean dryRun = getNamespace().getBoolean(DRY_RUN_ARGUMENT); - - final AccountsManager accountsManager = getCommandDependencies().accountsManager(); - - final Counter removeAccountCounterName = - Metrics.counter(REMOVE_ACCOUNT_COUNTER_NAME, "dryRun", String.valueOf(dryRun)); - - accounts - .filter(account -> !account.getPrimaryDevice().hasCapability(DeviceCapability.SPARSE_POST_QUANTUM_RATCHET)) - .take(maxAccounts) - .flatMap(account -> { - final Mono removeAccountMono = dryRun - ? Mono.empty() - : Mono.fromRunnable(() -> accountsManager.delete(account, AccountsManager.DeletionReason.ADMIN_DELETED)) - .subscribeOn(Schedulers.boundedElastic()) - .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))) - .onErrorResume(throwable -> { - logger.warn("Failed to remove account: {}", - account.getIdentifier(IdentityType.ACI), - throwable); - - return Mono.empty(); - }) - .then(); - - return removeAccountMono - .doOnSuccess(_ -> removeAccountCounterName.increment()); - }, maxConcurrency) - .then() - .block(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrLinkedDevicesCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrLinkedDevicesCommand.java deleted file mode 100644 index 4ab740051..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrLinkedDevicesCommand.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Metrics; -import net.sourceforge.argparse4j.inf.Subparser; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.identity.IdentityType; -import org.whispersystems.textsecuregcm.metrics.MetricsUtil; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.DeviceCapability; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.util.function.Tuples; -import reactor.util.retry.Retry; -import java.time.Duration; - -public class RemoveNonSpqrLinkedDevicesCommand extends AbstractSinglePassCrawlAccountsCommand { - - static final String MAX_CONCURRENCY_ARGUMENT = "maxConcurrency"; - static final String DRY_RUN_ARGUMENT = "dryRun"; - - private static final String REMOVE_DEVICE_COUNTER_NAME = - MetricsUtil.name(RemoveNonSpqrLinkedDevicesCommand.class, "removeLinkedDevice"); - - private static final Logger logger = LoggerFactory.getLogger(RemoveNonSpqrLinkedDevicesCommand.class); - - public RemoveNonSpqrLinkedDevicesCommand() { - super("remove-non-spqr-linked-devices", "Removes linked devices that do not support SPQR"); - } - - @Override - public void configure(final Subparser subparser) { - super.configure(subparser); - - subparser.addArgument("--max-concurrency") - .type(Integer.class) - .dest(MAX_CONCURRENCY_ARGUMENT) - .required(false) - .setDefault(32) - .help("Max concurrency for DynamoDB operations"); - - subparser.addArgument("--dry-run") - .type(Boolean.class) - .dest(DRY_RUN_ARGUMENT) - .required(false) - .setDefault(true) - .help("If true, don't actually remove linked devices"); - } - - @Override - protected void crawlAccounts(final Flux accounts) { - final int maxConcurrency = getNamespace().getInt(MAX_CONCURRENCY_ARGUMENT); - final boolean dryRun = getNamespace().getBoolean(DRY_RUN_ARGUMENT); - - final AccountsManager accountsManager = getCommandDependencies().accountsManager(); - - final Counter removeDeviceCounterName = - Metrics.counter(REMOVE_DEVICE_COUNTER_NAME, "dryRun", String.valueOf(dryRun)); - - accounts - .flatMap(account -> Flux.fromIterable(account.getDevices()) - .filter(device -> !device.isPrimary()) - .filter(device -> !device.hasCapability(DeviceCapability.SPARSE_POST_QUANTUM_RATCHET)) - .map(device -> Tuples.of(account, device.getId()))) - .flatMap(accountAndDeviceId -> { - final Mono removeDeviceMono = dryRun - ? Mono.empty() - : Mono.fromRunnable(() -> accountsManager.removeDevice(accountAndDeviceId.getT1(), accountAndDeviceId.getT2())) - .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))) - .onErrorResume(throwable -> { - logger.warn("Failed to remove device: {}:{}", - accountAndDeviceId.getT1().getIdentifier(IdentityType.ACI), - accountAndDeviceId.getT2(), - throwable); - - return Mono.empty(); - }) - .then(); - - return removeDeviceMono - .doOnSuccess(_ -> removeDeviceCounterName.increment()); - }, maxConcurrency) - .then() - .block(); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrAccountsCommandTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrAccountsCommandTest.java deleted file mode 100644 index fd23395d9..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrAccountsCommandTest.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import net.sourceforge.argparse4j.inf.Namespace; -import org.junit.jupiter.api.RepeatedTest; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.DeviceCapability; -import reactor.core.publisher.Flux; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.eq; -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.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -class RemoveNonSpqrAccountsCommandTest { - - private static class TestRemoveNonSpqrAccountsCommand extends RemoveNonSpqrAccountsCommand { - - private final CommandDependencies commandDependencies; - private final Namespace namespace; - - public TestRemoveNonSpqrAccountsCommand(final int maxAccounts, final boolean isDryRun) { - - commandDependencies = mock(CommandDependencies.class); - when(commandDependencies.accountsManager()).thenReturn(mock(AccountsManager.class)); - - namespace = new Namespace(Map.of( - RemoveNonSpqrAccountsCommand.MAX_ACCOUNTS_ARGUMENT, maxAccounts, - RemoveNonSpqrAccountsCommand.DRY_RUN_ARGUMENT, isDryRun, - RemoveNonSpqrAccountsCommand.MAX_CONCURRENCY_ARGUMENT, 16)); - } - - @Override - protected CommandDependencies getCommandDependencies() { - return commandDependencies; - } - - @Override - protected Namespace getNamespace() { - return namespace; - } - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void crawlAccounts(final boolean dryRun) { - final int maxAccountsToRemove = 4; - - final List accounts = new ArrayList<>(); - - for (int i = 0; i < maxAccountsToRemove * 2; i++) { - accounts.add(buildMockAccount(true)); - } - - for (int i = 0; i < maxAccountsToRemove * 2; i++) { - accounts.add(buildMockAccount(false)); - } - - Collections.shuffle(accounts); - - final RemoveNonSpqrAccountsCommand removeNonSpqrAccountsCommand = - new TestRemoveNonSpqrAccountsCommand(maxAccountsToRemove, dryRun); - - removeNonSpqrAccountsCommand.crawlAccounts(Flux.fromIterable(accounts)); - - final AccountsManager accountsManager = removeNonSpqrAccountsCommand.getCommandDependencies().accountsManager(); - - if (dryRun) { - verify(accountsManager, never()).delete(any(), any()); - } else { - verify(accountsManager, times(maxAccountsToRemove)).delete( - argThat(account -> !account.getPrimaryDevice().hasCapability(DeviceCapability.SPARSE_POST_QUANTUM_RATCHET)), - eq(AccountsManager.DeletionReason.ADMIN_DELETED)); - - verifyNoMoreInteractions(accountsManager); - } - } - - private static Account buildMockAccount(final boolean supportsSpqr) { - final Device primaryDevice = mock(Device.class); - when(primaryDevice.hasCapability(DeviceCapability.SPARSE_POST_QUANTUM_RATCHET)).thenReturn(supportsSpqr); - - final Account account = mock(Account.class); - when(account.getPrimaryDevice()).thenReturn(primaryDevice); - - return account; - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrLinkedDevicesCommandTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrLinkedDevicesCommandTest.java deleted file mode 100644 index dd8cce385..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrLinkedDevicesCommandTest.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import java.util.List; -import java.util.Map; -import net.sourceforge.argparse4j.inf.Namespace; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.DeviceCapability; -import reactor.core.publisher.Flux; - -class RemoveNonSpqrLinkedDevicesCommandTest { - - private static class TestRemoveNonSpqrLinkedDevicesCommand extends RemoveNonSpqrLinkedDevicesCommand { - - private final CommandDependencies commandDependencies; - private final Namespace namespace; - - public TestRemoveNonSpqrLinkedDevicesCommand(final boolean isDryRun) { - - commandDependencies = mock(CommandDependencies.class); - when(commandDependencies.accountsManager()).thenReturn(mock(AccountsManager.class)); - - namespace = new Namespace(Map.of( - RemoveNonSpqrLinkedDevicesCommand.DRY_RUN_ARGUMENT, isDryRun, - RemoveNonSpqrLinkedDevicesCommand.MAX_CONCURRENCY_ARGUMENT, 16)); - } - - @Override - protected CommandDependencies getCommandDependencies() { - return commandDependencies; - } - - @Override - protected Namespace getNamespace() { - return namespace; - } - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void crawlAccounts(final boolean dryRun) { - final Device primaryDeviceWithSpqr = buildMockDevice(true, true); - final Device primaryDeviceWithoutSpqr = buildMockDevice(true, false); - final Device linkedDeviceWithSpqr = buildMockDevice(false, true); - final Device linkedDeviceWithoutSpqr = buildMockDevice(false, false); - - final Account accountWithNonSpqrPrimary = mock(Account.class); - when(accountWithNonSpqrPrimary.getDevices()) - .thenReturn(List.of(primaryDeviceWithoutSpqr)); - - final Account accountWithSpqrLinkedDevice = mock(Account.class); - when(accountWithSpqrLinkedDevice.getDevices()) - .thenReturn(List.of(primaryDeviceWithSpqr, linkedDeviceWithSpqr)); - - final Account accountWithNonSpqrLinkedDevice = mock(Account.class); - when(accountWithNonSpqrLinkedDevice.getDevices()) - .thenReturn(List.of(primaryDeviceWithSpqr, linkedDeviceWithoutSpqr)); - - final RemoveNonSpqrLinkedDevicesCommand removeNonSpqrLinkedDevicesCommand = - new TestRemoveNonSpqrLinkedDevicesCommand(dryRun); - - removeNonSpqrLinkedDevicesCommand.crawlAccounts(Flux.just( - accountWithNonSpqrPrimary, accountWithSpqrLinkedDevice, accountWithNonSpqrLinkedDevice)); - - final AccountsManager accountsManager = - removeNonSpqrLinkedDevicesCommand.getCommandDependencies().accountsManager(); - - if (dryRun) { - verifyNoInteractions(accountsManager); - } else { - verify(accountsManager).removeDevice(accountWithNonSpqrLinkedDevice, linkedDeviceWithoutSpqr.getId()); - verifyNoMoreInteractions(accountsManager); - } - } - - private Device buildMockDevice(final boolean isPrimary, final boolean supportsSpqr) { - final Device device = mock(Device.class); - when(device.isPrimary()).thenReturn(isPrimary); - when(device.getId()).thenReturn(isPrimary ? Device.PRIMARY_ID : Device.PRIMARY_ID + 1); - when(device.hasCapability(DeviceCapability.SPARSE_POST_QUANTUM_RATCHET)).thenReturn(supportsSpqr); - - return device; - } -}