mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-07-02 20:41:07 +00:00
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:
parent
debb8a20e6
commit
5ac06a5ccd
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = (
|
||||
"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user