Skip known-unregistered recipients when sending to groups (#2077)

Group sends built the recipient list from the full group membership, so
members already known to be unregistered were retried on every send via
the legacy 1:1 fan-out. On large groups this made a single send take
tens of seconds and could time out, leaving the message undelivered.

Filter out recipients whose unregistered timestamp is set before
sending, returning an unregisteredFailure result for each (so callers
and CLI output are unchanged) without the network attempt. The flag is
maintained independently by profile/CDS discovery and is cleared when a
recipient registers again, so skipped recipients are re-included
automatically.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
KritBlade 2026-07-01 16:56:22 +01:00 committed by GitHub
parent debb8a20e6
commit 5ac06a5ccd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 70 additions and 6 deletions

View File

@ -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

View File

@ -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<SendMessageResult>();
final Set<RecipientId> 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<RecipientId> 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<SendMessageResult>(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;

View File

@ -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).
* <p>
* 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<RecipientId> getUnregisteredRecipientIds(final Set<RecipientId> 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<String> getAllNumbers() {
final var sql = (
"""