diff --git a/CHANGELOG.md b/CHANGELOG.md index 399cadd9..0ab8b90f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Fixed + +- Sending to large groups is no longer slowed down by members that are already known to be unregistered; they are skipped instead of being retried via the legacy 1:1 send path on every send. + ## [0.14.5] - 2026-06-11 ### Changed diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java index d4f54945..87e67b7a 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java @@ -507,9 +507,32 @@ public class SendHelper { ) throws IOException { long startTime = System.currentTimeMillis(); - final var addressesMap = recipientIds.stream() + // Recipients that are already known to be unregistered are skipped here. + // Otherwise every group send re-attempts them via the slow legacy 1:1 + // fan-out, which on large groups can take tens of seconds (and time out). + // The unregistered flag is maintained independently by profile/CDS + // discovery, which clears it once a recipient registers again, so they + // are re-included automatically. An unregisteredFailure result is still + // returned for each skipped recipient, so callers see them unchanged. + final var skippedResults = new ArrayList(); + final Set targetRecipientIds; + final var unregisteredRecipientIds = account.getRecipientStore().getUnregisteredRecipientIds(recipientIds); + if (unregisteredRecipientIds.isEmpty()) { + targetRecipientIds = recipientIds; + } else { + logger.debug("Skipping {} known-unregistered recipient(s) in group send.", + unregisteredRecipientIds.size()); + targetRecipientIds = new HashSet<>(recipientIds); + targetRecipientIds.removeAll(unregisteredRecipientIds); + for (final var recipientId : unregisteredRecipientIds) { + skippedResults.add(SendMessageResult.unregisteredFailure(context.getRecipientHelper() + .resolveSignalServiceAddress(recipientId))); + } + } + + final var addressesMap = targetRecipientIds.stream() .collect(Collectors.toMap(id -> id, context.getRecipientHelper()::resolveSignalServiceAddress)); - final var unidentifiedAccessesMap = context.getUnidentifiedAccessHelper().getAccessFor(recipientIds); + final var unidentifiedAccessesMap = context.getUnidentifiedAccessHelper().getAccessFor(targetRecipientIds); final var groupSendEndorsementsResult = getGroupSendEndorsements(groupInfo); final var groupSecretParams = groupInfo instanceof GroupInfoV2 gv2 ? GroupSecretParams.deriveFromMasterKey((gv2.getMasterKey())) @@ -523,7 +546,7 @@ public class SendHelper { : groupSendEndorsementsResult.first(); Set senderKeyTargets = groupInfo.getDistributionId() == null || groupSendEndorsements == null ? Set.of() - : recipientIds.stream() + : targetRecipientIds.stream() .filter(s -> this.isSenderKeyCapable(s, addressesMap.get(s), unidentifiedAccessesMap.get(s), @@ -533,7 +556,7 @@ public class SendHelper { logger.debug("Too few sender-key-capable users ({}). Doing all legacy sends.", senderKeyTargets.size()); senderKeyTargets = Set.of(); } else { - logger.debug("Can use sender key for {}/{} recipients.", senderKeyTargets.size(), recipientIds.size()); + logger.debug("Can use sender key for {}/{} recipients.", senderKeyTargets.size(), targetRecipientIds.size()); } final var allResults = new ArrayList(recipientIds.size()); @@ -569,9 +592,9 @@ public class SendHelper { } } - final var legacyTargets = new HashSet<>(recipientIds); + final var legacyTargets = new HashSet<>(targetRecipientIds); legacyTargets.removeAll(senderKeyTargets); - final boolean onlyTargetIsSelfWithLinkedDevice = recipientIds.isEmpty() && account.isMultiDevice(); + final boolean onlyTargetIsSelfWithLinkedDevice = targetRecipientIds.isEmpty() && account.isMultiDevice(); if (!legacyTargets.isEmpty() || onlyTargetIsSelfWithLinkedDevice) { if (!legacyTargets.isEmpty()) { @@ -605,6 +628,7 @@ public class SendHelper { isRecipientUpdate || !allResults.isEmpty()); allResults.addAll(results); } + allResults.addAll(skippedResults); final var duration = Duration.ofMillis(System.currentTimeMillis() - startTime); logger.debug("Sending took {}", duration.toString()); return allResults; diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java index c80b3454..90ea1baa 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java @@ -463,6 +463,40 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re } } + /** + * Returns the subset of the given recipients that are currently known to be + * unregistered (i.e. have an unregistered timestamp set). + *

+ * These can be skipped when sending group messages; otherwise every send + * re-attempts them via the slow legacy 1:1 fan-out. The unregistered flag is + * maintained independently by profile/CDS discovery and cleared again once a + * recipient registers, so they are re-included automatically. + */ + public Set getUnregisteredRecipientIds(final Set recipientIds) { + if (recipientIds.isEmpty()) { + return Set.of(); + } + final var recipientIdsCommaSeparated = recipientIds.stream() + .map(recipientId -> String.valueOf(recipientId.id())) + .collect(Collectors.joining(",")); + final var sql = ( + """ + SELECT r._id + FROM %s r + WHERE r.unregistered_timestamp IS NOT NULL AND r._id IN (%s) + """ + ).formatted(TABLE_RECIPIENT, recipientIdsCommaSeparated); + try (final var connection = database.getConnection()) { + try (final var statement = connection.prepareStatement(sql)) { + try (var result = Utils.executeQueryForStream(statement, this::getRecipientIdFromResultSet)) { + return result.collect(Collectors.toSet()); + } + } + } catch (SQLException e) { + throw new RuntimeException("Failed read from recipient store", e); + } + } + public Set getAllNumbers() { final var sql = ( """