diff --git a/broker/src/main/java/org/apache/rocketmq/broker/lite/LiteEventDispatcher.java b/broker/src/main/java/org/apache/rocketmq/broker/lite/LiteEventDispatcher.java
index 2bd1f36186b..a6858a143eb 100644
--- a/broker/src/main/java/org/apache/rocketmq/broker/lite/LiteEventDispatcher.java
+++ b/broker/src/main/java/org/apache/rocketmq/broker/lite/LiteEventDispatcher.java
@@ -21,7 +21,6 @@
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import org.apache.commons.collections.CollectionUtils;
-import org.apache.commons.lang3.tuple.Triple;
import org.apache.rocketmq.broker.BrokerController;
import org.apache.rocketmq.broker.offset.ConsumerOffsetManager;
import org.apache.rocketmq.common.BrokerConfig;
@@ -47,7 +46,6 @@
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
-import java.util.function.Function;
public class LiteEventDispatcher extends ServiceThread {
@@ -96,6 +94,13 @@ public void dispatch(String group, String lmqName, int queueId, long offset, lon
if (queueId != 0 || !LiteUtil.isLiteTopicQueue(lmqName)) {
return;
}
+ // On the message-arriving path (group == null), lazily register this lmqName against any
+ // pattern-mode wildcard clients whose patterns match it, so the dispatch below can reach
+ // them even for a newly-created lite-topic. The periodic doFullDispatchForWildcardGroup
+ // re-expand remains as a backstop.
+ if (group == null) {
+ liteSubscriptionRegistry.registerArrivingLmqForPatternClients(lmqName);
+ }
doDispatch(group, lmqName, null);
}
@@ -253,41 +258,75 @@ public void doFullDispatchForClient(String clientId, String group) {
/**
* Perform a full dispatch for wildcard group which was previously marked for a delayed full dispatch.
- * It iterates through all LMQ topics in CQ table, so it may be a heavy work.
+ *
+ *
For legacy wildcard groups (receiving all lite-topics under the parent topic) it iterates
+ * the lite-topics under the parent topic via {@code collectByParentTopic} (O(M)) instead of
+ * scanning the entire LMQ table. For pattern-mode wildcard groups it first re-expands the
+ * client patterns (picking up lite-topics created since the initial subscription) and then
+ * delegates to {@link #doFullDispatchForClient(String, String)} to clear backlog.
*/
public void doFullDispatchForWildcardGroup(String group) {
String parentTopic = LiteMetadataUtil.getLiteBindTopic(group, brokerController);
if (null == parentTopic || !LiteMetadataUtil.isWildcardGroup(group, brokerController)) {
return;
}
- List clients = liteSubscriptionRegistry.getWildcardSubscriber(group, parentTopic).getClients();
- if (CollectionUtils.isEmpty(clients)) {
- return;
+
+ // Partition clients into pattern-mode (re-expand + per-client dispatch) and legacy
+ // (single shared dispatch over all parent-topic lite-topics).
+ List clientIds = liteSubscriptionRegistry.getAllClientIdByGroup(group);
+ List patternClientIds = new ArrayList<>();
+ boolean hasLegacyClient = false;
+ for (String clientId : clientIds) {
+ LiteSubscription subscription = liteSubscriptionRegistry.getLiteSubscription(clientId);
+ if (subscription != null && !subscription.getWildcardPatterns().isEmpty()) {
+ patternClientIds.add(clientId);
+ } else {
+ hasLegacyClient = true;
+ }
}
- AtomicInteger count = new AtomicInteger();
- Function, Boolean> function = triple -> {
- String lmqName = triple.getLeft();
- long maxOffset = triple.getMiddle();
- if (!LiteUtil.belongsTo(lmqName, parentTopic)) {
- return true;
+
+ int count = 0;
+ // Pattern-mode: re-expand patterns against current lite-topics, then dispatch backlog per client.
+ for (String clientId : patternClientIds) {
+ liteSubscriptionRegistry.reexpandWildcardPatterns(clientId);
+ doFullDispatchForClient(clientId, group);
+ }
+
+ // Legacy-mode: dispatch over all lite-topics under the parent topic to the shared client set.
+ if (hasLegacyClient) {
+ List clients = liteSubscriptionRegistry.getWildcardSubscriber(group, parentTopic).getClients();
+ if (CollectionUtils.isNotEmpty(clients)) {
+ count += dispatchLegacyWildcardGroup(group, parentTopic, clients);
}
+ }
+ LOGGER.info("doFullDispatchForWildcardGroup finish. {}, patternClients:{}, legacyDispatch:{}",
+ group, patternClientIds.size(), count);
+ }
+
+ /**
+ * Iterate lite-topics under {@code parentTopic} (O(M), not the full LMQ scan) and dispatch the
+ * ones with consumer lag to the shared legacy wildcard client set.
+ */
+ private int dispatchLegacyWildcardGroup(String group, String parentTopic, List clients) {
+ List lmqNames = liteLifecycleManager.collectByParentTopic(parentTopic);
+ AtomicInteger count = new AtomicInteger();
+ for (String lmqName : lmqNames) {
+ long maxOffset = liteLifecycleManager.getMaxOffsetInQueue(lmqName);
if (maxOffset <= 0) {
- return true;
+ continue;
}
long consumerOffset = consumerOffsetManager.queryOffset(group, lmqName, 0);
if (consumerOffset >= maxOffset) {
- return true;
+ continue;
}
if (selectAndDispatch(lmqName, clients, null)) {
count.incrementAndGet();
} else {
LOGGER.warn("doFullDispatchForWildcardGroup, wait another period. {}", group);
- return false;
+ break;
}
- return true;
- };
- liteLifecycleManager.forEachLiteTopic(function);
- LOGGER.info("doFullDispatchForWildcardGroup finish. {}, dispatch:{}", group, count);
+ }
+ return count.get();
}
/**
diff --git a/broker/src/main/java/org/apache/rocketmq/broker/lite/LiteSubscriptionRegistry.java b/broker/src/main/java/org/apache/rocketmq/broker/lite/LiteSubscriptionRegistry.java
index 965ed180fc6..6f20d74a831 100644
--- a/broker/src/main/java/org/apache/rocketmq/broker/lite/LiteSubscriptionRegistry.java
+++ b/broker/src/main/java/org/apache/rocketmq/broker/lite/LiteSubscriptionRegistry.java
@@ -38,6 +38,54 @@ public interface LiteSubscriptionRegistry {
void addCompleteSubscription(String clientId, String group, String topic, Set newLmqNameSet, long version);
+ /**
+ * Complete-add overload carrying wildcard patterns for a wildcard group in pattern mode.
+ *
+ * When {@code wildcardPatterns} is non-empty, the group is in pattern mode: the
+ * patterns are eagerly expanded against the existing lite-topics under {@code topic} and the
+ * matched lmqNames are registered (as a normal subscription) into {@code liteTopic2Group}.
+ * When empty, the call delegates to {@link #addCompleteSubscription(String, String, String,
+ * Set, long)} (legacy wildcard group receives all lite-topics under the parent topic).
+ *
+ * @param clientId the client id
+ * @param group the consumer group
+ * @param topic the parent (lite-bind) topic
+ * @param newLmqNameSet literal lmqNames (used only by the non-wildcard delegate path)
+ * @param wildcardPatterns wildcard patterns for pattern mode (empty for legacy mode)
+ * @param version the subscription version
+ */
+ void addCompleteSubscription(String clientId, String group, String topic, Set newLmqNameSet,
+ Set wildcardPatterns, long version);
+
+ /**
+ * Lazily re-expand a pattern-mode wildcard client's stored patterns against the current
+ * lite-topics under its parent topic, registering any newly-matched lmqNames into
+ * {@code liteTopic2Group}. This is the mechanism by which a pattern-mode client picks up
+ * lite-topics created after its initial {@code COMPLETE_ADD}.
+ *
+ * @param clientId the client id
+ * @return the number of newly-registered lmqNames (0 if the client is not in pattern mode or
+ * nothing new matched)
+ */
+ int reexpandWildcardPatterns(String clientId);
+
+ /**
+ * Lazily register the arriving lmqName against any pattern-mode wildcard clients whose stored
+ * patterns match it, so that the current message-arriving dispatch can reach them. This is the
+ * single-lmqName counterpart to {@link #reexpandWildcardPatterns(String)}: it matches only
+ * against the one arriving lmqName (avoiding the O(M) {@code collectByParentTopic} scan on the
+ * message-arriving hot path) and lets a pattern-mode client receive a newly-created matching
+ * lite-topic without waiting for the periodic full-dispatch re-expand.
+ *
+ * Only pattern-mode clients (non-empty {@code wildcardPatterns}) are considered; legacy
+ * wildcard clients are already reachable via the synthetic {@code topic@group} key in
+ * {@link #getAllSubscriber}. Registration is idempotent via {@code addTopicGroup}.
+ *
+ * @param lmqName the full lmqName of the arriving message
+ * @return the number of newly-registered (client, lmqName) pairs
+ */
+ int registerArrivingLmqForPatternClients(String lmqName);
+
void removeCompleteSubscription(String clientId);
void addListener(LiteCtlListener listener);
diff --git a/broker/src/main/java/org/apache/rocketmq/broker/lite/LiteSubscriptionRegistryImpl.java b/broker/src/main/java/org/apache/rocketmq/broker/lite/LiteSubscriptionRegistryImpl.java
index b487b8757f4..0fbb1ebb2eb 100644
--- a/broker/src/main/java/org/apache/rocketmq/broker/lite/LiteSubscriptionRegistryImpl.java
+++ b/broker/src/main/java/org/apache/rocketmq/broker/lite/LiteSubscriptionRegistryImpl.java
@@ -23,6 +23,7 @@
import io.netty.channel.Channel;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -39,6 +40,7 @@
import org.apache.rocketmq.common.ServiceThread;
import org.apache.rocketmq.common.constant.LoggerName;
import org.apache.rocketmq.common.entity.ClientGroup;
+import org.apache.rocketmq.common.lite.LitePatternMatcher;
import org.apache.rocketmq.common.lite.LiteSubscription;
import org.apache.rocketmq.common.lite.LiteUtil;
import org.apache.rocketmq.common.lite.OffsetOption;
@@ -124,10 +126,26 @@ public void removePartialSubscription(String clientId, String group, String topi
@Override
public void addCompleteSubscription(String clientId, String group, String topic, Set lmqNameAll, long version) {
+ addCompleteSubscription(clientId, group, topic, lmqNameAll, Collections.emptySet(), version);
+ }
+
+ @Override
+ public void addCompleteSubscription(String clientId, String group, String topic, Set lmqNameAll,
+ Set wildcardPatterns, long version) {
Set lmqNameNew;
- if (LiteMetadataUtil.isWildcardGroup(group, brokerController)) {
+ boolean isWildcardGroup = LiteMetadataUtil.isWildcardGroup(group, brokerController);
+ boolean isPatternMode = isWildcardGroup && wildcardPatterns != null && !wildcardPatterns.isEmpty();
+
+ // Compute the target lmqName set BEFORE any mutation. expandWildcardPatterns is read-only
+ // (it scans existing lite-topics via the lifecycle manager and matches patterns), so it is
+ // safe to call pre-flight. This lets us project the net reference delta and enforce the
+ // broker-wide quota BEFORE touching subscription state — a quota failure then leaves no
+ // patterns / wildcard-group marks behind that later re-expansion would mistake for a live
+ // (deferred-effect) subscription.
+ if (isPatternMode) {
+ lmqNameNew = expandWildcardPatterns(topic, wildcardPatterns);
+ } else if (isWildcardGroup) {
lmqNameNew = Collections.singleton(mockLmqNameForWildcardGroup(topic, group));
- markWildcardGroup(topic, group);
} else {
lmqNameNew = lmqNameAll.stream()
.filter(lmqName -> liteLifecycleManager.isSubscriptionActive(topic, lmqName))
@@ -136,6 +154,42 @@ public void addCompleteSubscription(String clientId, String group, String topic,
LiteSubscription thisSub = getOrCreateLiteSubscription(clientId, group, topic);
Set lmqNamePrev = thisSub.getLiteTopicSet();
+
+ // Pre-flight quota check: project the NET change in (client, lmqName) references this
+ // operation would register — lmqNames added that are not already held, MINUS lmqNames held
+ // that this operation will remove. Crediting removals means a set-replacing client at the
+ // limit whose net delta is zero (or negative) is not falsely rejected. A re-sync of an
+ // identical set has wouldAdd == 0 and passes trivially. The check runs before any mutation
+ // (setWildcardPatterns / markWildcardGroup / add-remove below), so a throw leaves only the
+ // harmless empty placeholder LiteSubscription created by getOrCreateLiteSubscription — no
+ // patterns, no wildcard-group mark, no active references. The throw maps to
+ // LITE_SUBSCRIPTION_QUOTA_EXCEEDED via the processor's existing catch.
+ int wouldAdd = Math.max(0,
+ (int) lmqNameNew.stream().filter(lmqName -> !lmqNamePrev.contains(lmqName)).count()
+ - (int) lmqNamePrev.stream().filter(lmqName -> !lmqNameNew.contains(lmqName)).count());
+ checkQuotaOrThrow(wouldAdd);
+
+ // Quota passed — now apply the authoritative intent and group marks.
+ if (isPatternMode) {
+ // Persist the patterns (authoritative intent) and register the eagerly-expanded
+ // lmqNames as a normal subscription; the topic@group synthetic key is NOT used for
+ // pattern-mode groups.
+ thisSub.setWildcardPatterns(wildcardPatterns);
+ markWildcardGroup(topic, group);
+ } else if (isWildcardGroup) {
+ // Legacy wildcard group: receive all lite-topics under the parent topic via the
+ // synthetic topic@group key. Clear any previously stored patterns so a client that
+ // transitions from pattern mode back to legacy mode is no longer classified as
+ // pattern-mode (doFullDispatchForWildcardGroup / reexpandWildcardPatterns key off a
+ // non-empty wildcardPatterns set).
+ thisSub.setWildcardPatterns(Collections.emptySet());
+ markWildcardGroup(topic, group);
+ } else {
+ // Non-wildcard group: a normal subscription. Clear stale patterns from any prior
+ // wildcard incarnation so the group is not misclassified downstream.
+ thisSub.setWildcardPatterns(Collections.emptySet());
+ }
+
// Find topics to remove (in current set but not in new set)
Set lmqNameRemove = lmqNamePrev.stream()
.filter(lmqName -> !lmqNameNew.contains(lmqName))
@@ -167,6 +221,154 @@ public void addCompleteSubscription(String clientId, String group, String topic,
}
}
+ /**
+ * Pre-flight quota check for the request/response registration paths. Throws
+ * {@link LiteQuotaException} (mapped to {@code LITE_SUBSCRIPTION_QUOTA_EXCEEDED} by the
+ * processor) if the projected active count would exceed the configured broker-wide limit.
+ *
+ * @param wouldAdd the NET number of (client, lmqName) references this operation would add
+ * (new additions minus pending removals, floored at 0); 0 for a pure
+ * "is the broker already at the limit" check
+ */
+ private void checkQuotaOrThrow(int wouldAdd) {
+ long maxCount = brokerController.getBrokerConfig().getMaxLiteSubscriptionCount();
+ if ((long) getActiveSubscriptionNum() + wouldAdd > maxCount) {
+ throw new LiteQuotaException("lite subscription quota exceeded " + maxCount
+ + ", current: " + getActiveSubscriptionNum() + ", would add: " + wouldAdd);
+ }
+ }
+
+ /**
+ * Eagerly expand wildcard patterns against the existing lite-topics under {@code parentTopic}.
+ * Returns the set of matched full lmqNames that this broker is responsible for
+ * (sharding-aware via {@link AbstractLiteLifecycleManager#isSubscriptionActive}).
+ */
+ private Set expandWildcardPatterns(String parentTopic, Set wildcardPatterns) {
+ List candidates = liteLifecycleManager.collectByParentTopic(parentTopic);
+ if (candidates.isEmpty()) {
+ return Collections.emptySet();
+ }
+ Set matchedLmqNames = new HashSet<>();
+ for (String lmqName : candidates) {
+ String child = LiteUtil.getLiteTopic(lmqName);
+ if (child == null) {
+ continue;
+ }
+ if (LitePatternMatcher.matchesAny(wildcardPatterns, child)
+ && liteLifecycleManager.isSubscriptionActive(parentTopic, lmqName)) {
+ matchedLmqNames.add(lmqName);
+ }
+ }
+ return matchedLmqNames;
+ }
+
+ @Override
+ public int reexpandWildcardPatterns(String clientId) {
+ LiteSubscription thisSub = client2Subscription.get(clientId);
+ if (thisSub == null || CollectionUtils.isEmpty(thisSub.getWildcardPatterns())) {
+ return 0;
+ }
+ String topic = thisSub.getTopic();
+ String group = thisSub.getGroup();
+ Set matched = expandWildcardPatterns(topic, thisSub.getWildcardPatterns());
+ Set current = thisSub.getLiteTopicSet();
+ // Register only the newly-matched lmqNames; existing ones are already subscribed.
+ Set toAdd = matched.stream().filter(lmqName -> !current.contains(lmqName)).collect(Collectors.toSet());
+ if (toAdd.isEmpty()) {
+ return 0;
+ }
+ ClientGroup clientGroup = new ClientGroup(clientId, group);
+ int added = 0;
+ // Cap-and-log, do not throw: this runs on the LiteEventDispatcher background thread with no
+ // client to respond to. Once the broker-wide quota is reached, stop auto-registering new
+ // matches (existing subscriptions keep serving); the periodic re-expand retries next cycle
+ // and picks up registration once room frees up. The guard precedes addLiteTopic so the
+ // subscription's LiteTopicSet stays consistent with liteTopic2Group on early exit.
+ long maxCount = brokerController.getBrokerConfig().getMaxLiteSubscriptionCount();
+ for (String lmqName : toAdd) {
+ if (getActiveSubscriptionNum() >= maxCount) {
+ LOGGER.warn("reexpandWildcardPatterns capped at lite subscription quota {}, clientId:{}, group:{}, "
+ + "topic:{}, added:{}, skipped:{}", maxCount, clientId, group, topic, added, toAdd.size() - added);
+ break;
+ }
+ thisSub.addLiteTopic(lmqName);
+ if (addTopicGroup(clientGroup, lmqName)) {
+ added++;
+ }
+ }
+ if (added > 0) {
+ LOGGER.info("reexpandWildcardPatterns, clientId:{}, group:{}, topic:{}, newly matched:{}",
+ clientId, group, topic, added);
+ }
+ return added;
+ }
+
+ /**
+ * Single-lmqName counterpart to {@link #reexpandWildcardPatterns(String)} for the
+ * message-arriving dispatch path. When a message arrives on a lite-topic that no pattern-mode
+ * client has been registered for yet (e.g. a newly-created lite-topic), enumerate the parent
+ * topic's pattern-mode wildcard clients and register the ones whose stored patterns match this
+ * lmqName's child. This avoids the O(M) {@code collectByParentTopic} scan that
+ * {@code reexpandWildcardPatterns} performs, matching only against the single arriving lmqName.
+ *
+ * Legacy wildcard clients are NOT touched here: they are already reachable via the synthetic
+ * {@code topic@group} key in {@link #getAllSubscriber}. The periodic
+ * {@code doFullDispatchForWildcardGroup} re-expand remains as a backstop.
+ */
+ @Override
+ public int registerArrivingLmqForPatternClients(String lmqName) {
+ String parentTopic = LiteUtil.getParentTopic(lmqName);
+ String child = LiteUtil.getLiteTopic(lmqName);
+ if (parentTopic == null || child == null) {
+ return 0;
+ }
+ Set wildcardGroups = wildcardGroupMap.get(parentTopic);
+ if (wildcardGroups == null || wildcardGroups.isEmpty()) {
+ return 0;
+ }
+ int added = 0;
+ // Cap-and-log, do not throw: this runs on the message-arriving dispatch hot path with no
+ // client to respond to (an exception here would abort per-message dispatch). Once the
+ // broker-wide quota is reached, stop auto-registering the arriving lmqName; the periodic
+ // doFullDispatchForWildcardGroup re-expand retries and picks it up once room frees up. The
+ // guard precedes addLiteTopic so the subscription's LiteTopicSet stays consistent with
+ // liteTopic2Group on early exit.
+ long maxCount = brokerController.getBrokerConfig().getMaxLiteSubscriptionCount();
+ for (String group : wildcardGroups) {
+ if (getActiveSubscriptionNum() >= maxCount) {
+ LOGGER.warn("registerArrivingLmqForPatternClients capped at lite subscription quota {}, "
+ + "topic:{}, lmqName:{}, added:{}", maxCount, parentTopic, lmqName, added);
+ break;
+ }
+ for (String clientId : getAllClientIdByGroup(group)) {
+ LiteSubscription thisSub = client2Subscription.get(clientId);
+ if (thisSub == null || CollectionUtils.isEmpty(thisSub.getWildcardPatterns())) {
+ continue; // legacy or absent client
+ }
+ if (!LitePatternMatcher.matchesAny(thisSub.getWildcardPatterns(), child)) {
+ continue;
+ }
+ if (!liteLifecycleManager.isSubscriptionActive(parentTopic, lmqName)) {
+ continue; // mirror expandWildcardPatterns sharding guard
+ }
+ if (getActiveSubscriptionNum() >= maxCount) {
+ LOGGER.warn("registerArrivingLmqForPatternClients capped at lite subscription quota {}, "
+ + "topic:{}, lmqName:{}, added:{}", maxCount, parentTopic, lmqName, added);
+ break;
+ }
+ thisSub.addLiteTopic(lmqName);
+ if (addTopicGroup(new ClientGroup(clientId, thisSub.getGroup()), lmqName)) {
+ added++;
+ }
+ }
+ }
+ if (added > 0) {
+ LOGGER.info("registerArrivingLmqForPatternClients, topic:{}, lmqName:{}, newly registered:{}",
+ parentTopic, lmqName, added);
+ }
+ return added;
+ }
+
@Override
public void removeCompleteSubscription(String clientId) {
clientChannels.remove(clientId);
@@ -183,6 +385,13 @@ public void removeCompleteSubscription(String clientId) {
thisSub.getLiteTopicSet().forEach(lmqName -> {
removeTopicGroup(clientGroup, lmqName, false);
});
+ // Pattern-mode wildcard groups are marked in wildcardGroupMap for fan-out enumeration but
+ // register real lmqNames (so unmarkWildcardGroupIfNecessary, which parses the synthetic
+ // topic@group key, never fires for them). Clean up explicitly when the last client leaves.
+ if (CollectionUtils.isNotEmpty(thisSub.getWildcardPatterns())
+ && getAllClientIdByGroup(thisSub.getGroup()).isEmpty()) {
+ unmarkWildcardGroup(thisSub.getTopic(), thisSub.getGroup());
+ }
for (LiteCtlListener listener : listeners) {
listener.onRemoveAll(clientId, thisSub.getGroup());
}
@@ -204,18 +413,28 @@ public void addListener(LiteCtlListener listener) {
@Override
public SubscriberWrapper getAllSubscriber(String group, String lmqName) {
String topic = LiteUtil.getParentTopic(lmqName);
+ boolean isWildcardGroup = group != null && LiteMetadataUtil.isWildcardGroup(group, brokerController);
if (group != null) {
- if (LiteMetadataUtil.isWildcardGroup(group, brokerController)) {
- return getWildcardSubscriber(group, topic);
- }
SubscriberWrapper.ListWrapper wrapper = new SubscriberWrapper.ListWrapper();
+ // Pattern-mode wildcard groups register real lmqNames into liteTopic2Group (same as a
+ // normal subscription), so the normal lookup finds their clients here.
Set subscribers = liteTopic2Group.get(lmqName);
if (subscribers != null) {
wrapper.getClients().addAll(subscribers.stream()
.filter(clientGroup -> group.equals(clientGroup.group))
.collect(Collectors.toSet()));
}
+ // For a wildcard group, always merge clients from the synthetic topic@group key. Legacy
+ // wildcard clients (receive-all) live there; pattern-mode clients never do (they register
+ // real lmqNames, already merged above). This keeps delivery correct in mixed groups where
+ // some clients use patterns and others are legacy — both sets are returned.
+ if (isWildcardGroup) {
+ List wildcardClients = getWildcardGroupClients(topic, group);
+ if (CollectionUtils.isNotEmpty(wildcardClients)) {
+ wrapper.getClients().addAll(wildcardClients);
+ }
+ }
return wrapper;
} else {
SubscriberWrapper.MapWrapper wrapper = new SubscriberWrapper.MapWrapper();
@@ -225,6 +444,10 @@ public SubscriberWrapper getAllSubscriber(String group, String lmqName) {
wrapper.getGroupMap().computeIfAbsent(clientGroup.group, k -> new ArrayList<>()).add(clientGroup);
}
}
+ // Fan out wildcard groups via the synthetic topic@group key. Legacy wildcard clients are
+ // only reachable there; pattern-mode wildcard clients are already in liteTopic2Group
+ // (merged above) and absent from the synthetic key, so they are not double-counted. A
+ // group may contain both kinds, so enumerate every wildcard group without skipping.
Set wildcardGroups = wildcardGroupMap.get(topic);
if (wildcardGroups != null) {
for (String wildcardGroup : wildcardGroups) {
@@ -269,7 +492,7 @@ public void cleanSubscription(String lmqName, boolean notifyClient) {
}
}
- protected void addTopicGroup(ClientGroup clientGroup, String lmqName) {
+ protected boolean addTopicGroup(ClientGroup clientGroup, String lmqName) {
Set topicGroupSet = liteTopic2Group
.computeIfAbsent(lmqName, k -> ConcurrentHashMap.newKeySet());
if (topicGroupSet.add(clientGroup)) {
@@ -278,7 +501,9 @@ protected void addTopicGroup(ClientGroup clientGroup, String lmqName) {
for (LiteCtlListener listener : listeners) {
listener.onRegister(clientGroup.clientId, clientGroup.group, lmqName);
}
+ return true;
}
+ return false;
}
protected void removeTopicGroup(ClientGroup clientGroup, String lmqName, boolean resetOffset) {
@@ -424,6 +649,13 @@ private void markWildcardGroup(String topic, String group) {
wildcardGroupMap.computeIfAbsent(topic, k -> ConcurrentHashMap.newKeySet()).add(group);
}
+ private void unmarkWildcardGroup(String topic, String group) {
+ wildcardGroupMap.computeIfPresent(topic, (k, v) -> {
+ v.remove(group);
+ return v.isEmpty() ? null : v;
+ });
+ }
+
private void unmarkWildcardGroupIfNecessary(String lmqName) {
if (!LiteUtil.isLiteTopicQueue(lmqName)) { // must be topic@group
String[] topicAtGroup = StringUtils.split(lmqName, "@");
diff --git a/broker/src/main/java/org/apache/rocketmq/broker/processor/LiteSubscriptionCtlProcessor.java b/broker/src/main/java/org/apache/rocketmq/broker/processor/LiteSubscriptionCtlProcessor.java
index bcf0df41270..cfe1dc1d056 100644
--- a/broker/src/main/java/org/apache/rocketmq/broker/processor/LiteSubscriptionCtlProcessor.java
+++ b/broker/src/main/java/org/apache/rocketmq/broker/processor/LiteSubscriptionCtlProcessor.java
@@ -28,6 +28,7 @@
import org.apache.rocketmq.broker.lite.LiteQuotaException;
import org.apache.rocketmq.broker.lite.LiteMetadataUtil;
import org.apache.rocketmq.common.constant.LoggerName;
+import org.apache.rocketmq.common.lite.LitePatternMatcher;
import org.apache.rocketmq.common.lite.LiteSubscriptionDTO;
import org.apache.rocketmq.common.lite.LiteUtil;
import org.apache.rocketmq.remoting.netty.NettyRequestProcessor;
@@ -94,8 +95,19 @@ public RemotingCommand processRequest(ChannelHandlerContext ctx, RemotingCommand
case COMPLETE_ADD:
checkConsumeEnable(group);
this.liteSubscriptionRegistry.updateClientChannel(clientId, ctx.channel());
- this.liteSubscriptionRegistry.addCompleteSubscription(clientId, group, topic, lmqNameSet,
- entry.getVersion());
+ if (LiteMetadataUtil.isWildcardGroup(group, brokerController)) {
+ // For a wildcard group, liteTopicSet carries pattern strings (not literal
+ // lmqNames); pass them through verbatim and let the registry expand them.
+ // lmqNameSet is intentionally empty: pattern-mode registration ignores it
+ // (lmqNames are derived by expanding the patterns), and passing a set of
+ // pattern-derived pseudo-lmqNames here would be misleading.
+ Set wildcardPatterns = toWildcardPatterns(entry);
+ this.liteSubscriptionRegistry.addCompleteSubscription(clientId, group, topic,
+ Collections.emptySet(), wildcardPatterns, entry.getVersion());
+ } else {
+ this.liteSubscriptionRegistry.addCompleteSubscription(clientId, group, topic, lmqNameSet,
+ entry.getVersion());
+ }
break;
case COMPLETE_REMOVE:
this.liteSubscriptionRegistry.removeCompleteSubscription(clientId);
@@ -128,4 +140,42 @@ private Set toLmqNameSet(LiteSubscriptionDTO liteSubscriptionDTO) {
.collect(Collectors.toSet());
}
+ /**
+ * Extract wildcard patterns from a wildcard group's {@code liteTopicSet}. Pattern strings are
+ * passed through verbatim (they contain {@code *} / {@code **} and must not be converted to
+ * lmqNames); entries that fail {@link LitePatternMatcher#validate(String)} are dropped with a
+ * warning rather than aborting the whole request.
+ *
+ * A genuinely empty {@code liteTopicSet} means the group runs in legacy wildcard mode
+ * (receive all lite-topics under the parent topic). But a non-empty set in which every
+ * pattern is invalid must NOT silently widen into legacy/full-wildcard consumption — that would
+ * turn a malformed restrictive subscription into receiving everything. Such input is rejected
+ * with an {@link IllegalStateException} (mapped to {@code ILLEGAL_OPERATION}).
+ *
+ * @return the validated patterns; empty only when the input {@code liteTopicSet} was empty
+ * @throws IllegalStateException if the input was non-empty but contained no valid patterns
+ */
+ private Set toWildcardPatterns(LiteSubscriptionDTO liteSubscriptionDTO) {
+ Set raw = liteSubscriptionDTO.getLiteTopicSet();
+ if (CollectionUtils.isEmpty(raw)) {
+ return Collections.emptySet();
+ }
+ Set patterns = raw.stream()
+ .filter(pattern -> {
+ if (LitePatternMatcher.validate(pattern)) {
+ return true;
+ }
+ log.warn("drop invalid wildcard pattern, group:{}, topic:{}, pattern:{}",
+ liteSubscriptionDTO.getGroup(), liteSubscriptionDTO.getTopic(), pattern);
+ return false;
+ })
+ .collect(Collectors.toSet());
+ if (patterns.isEmpty()) {
+ // Non-empty input but no valid pattern: reject rather than widen to receive-all.
+ throw new IllegalStateException("all wildcard patterns are invalid, group:"
+ + liteSubscriptionDTO.getGroup() + ", topic:" + liteSubscriptionDTO.getTopic());
+ }
+ return patterns;
+ }
+
}
diff --git a/broker/src/test/java/org/apache/rocketmq/broker/lite/LiteEventDispatcherTest.java b/broker/src/test/java/org/apache/rocketmq/broker/lite/LiteEventDispatcherTest.java
index 84aec24829f..5ae34cf1ca4 100644
--- a/broker/src/test/java/org/apache/rocketmq/broker/lite/LiteEventDispatcherTest.java
+++ b/broker/src/test/java/org/apache/rocketmq/broker/lite/LiteEventDispatcherTest.java
@@ -57,6 +57,7 @@
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -118,6 +119,33 @@ public void testDispatchCallsDoDispatch() {
verify(spyDispatcher).doDispatch("group", lmqName, null);
}
+ /**
+ * On the message-arriving path (group == null), dispatch lazily registers the arriving lmqName
+ * against pattern-mode wildcard clients before fan-out, so a newly-created matching lite-topic
+ * is delivered without waiting for the periodic full dispatch.
+ */
+ @Test
+ public void testDispatch_MessageArrival_RegistersArrivingLmqForPatternClients() {
+ String lmqName = LiteUtil.toLmqName("parentTopic", "child");
+ LiteEventDispatcher spyDispatcher = Mockito.spy(liteEventDispatcher);
+ spyDispatcher.dispatch(null, lmqName, 0, 0L, 0L);
+ verify(liteSubscriptionRegistry).registerArrivingLmqForPatternClients(lmqName);
+ verify(spyDispatcher).doDispatch(null, lmqName, null);
+ }
+
+ /**
+ * On a group-specific dispatch (e.g. ack re-dispatch), the pattern re-expand must NOT run —
+ * registration state is already settled for an explicit group.
+ */
+ @Test
+ public void testDispatch_GroupSpecified_DoesNotRegisterArrivingLmq() {
+ String lmqName = LiteUtil.toLmqName("parentTopic", "child");
+ LiteEventDispatcher spyDispatcher = Mockito.spy(liteEventDispatcher);
+ spyDispatcher.dispatch("someGroup", lmqName, 0, 0L, 0L);
+ verify(liteSubscriptionRegistry, never()).registerArrivingLmqForPatternClients(anyString());
+ verify(spyDispatcher).doDispatch("someGroup", lmqName, null);
+ }
+
@Test
public void testDoDispatchWhenWrapperIsNull() {
when(liteSubscriptionRegistry.getAllSubscriber("group", "lmqName")).thenReturn(null);
@@ -541,4 +569,77 @@ public void testScan_FullDispatch() {
liteEventDispatcher.scan();
assertTrue(liteEventDispatcher.fullDispatchSet.isEmpty());
}
+
+ // ==================== doFullDispatchForWildcardGroup ====================
+
+ private SubscriptionGroupConfig wildcardGroupConfig(String group, String bindTopic) {
+ SubscriptionGroupConfig config = new SubscriptionGroupConfig();
+ config.setGroupName(group);
+ config.setWildcardLiteGroup(true);
+ config.setLiteBindTopic(bindTopic);
+ return config;
+ }
+
+ /**
+ * Legacy wildcard group: dispatch iterates collectByParentTopic (O(M)) instead of the full
+ * forEachLiteTopic scan, dispatching lagged lite-topics to the shared client set.
+ */
+ @Test
+ public void testDoFullDispatchForWildcardGroup_LegacyMode_UsesCollectByParentTopic() {
+ String group = "wildcardGroup";
+ String parentTopic = "order_events";
+ String lmqName = LiteUtil.toLmqName(parentTopic, "pay__refund");
+ when(subscriptionGroupManager.findSubscriptionGroupConfig(group))
+ .thenReturn(wildcardGroupConfig(group, parentTopic));
+ when(liteLifecycleManager.collectByParentTopic(parentTopic)).thenReturn(Collections.singletonList(lmqName));
+ when(liteLifecycleManager.getMaxOffsetInQueue(lmqName)).thenReturn(100L);
+ when(consumerOffsetManager.queryOffset(group, lmqName, 0)).thenReturn(50L);
+ // no pattern-mode clients: getAllClientIdByGroup returns a legacy client id whose
+ // subscription has empty wildcardPatterns
+ when(liteSubscriptionRegistry.getAllClientIdByGroup(group)).thenReturn(Collections.singletonList("legacyClient"));
+ LiteSubscription legacySub = new LiteSubscription().setGroup(group).setTopic(parentTopic);
+ when(liteSubscriptionRegistry.getLiteSubscription("legacyClient")).thenReturn(legacySub);
+ List clients = Collections.singletonList(new ClientGroup("legacyClient", group));
+ SubscriberWrapper.ListWrapper wrapper = mock(SubscriberWrapper.ListWrapper.class);
+ when(wrapper.getClients()).thenReturn(clients);
+ when(liteSubscriptionRegistry.getWildcardSubscriber(group, parentTopic)).thenReturn(wrapper);
+
+ LiteEventDispatcher spyDispatcher = spy(liteEventDispatcher);
+ doReturn(true).when(spyDispatcher).selectAndDispatch(eq(lmqName), eq(clients), eq(null));
+
+ spyDispatcher.doFullDispatchForWildcardGroup(group);
+
+ // collectByParentTopic is used, not forEachLiteTopic
+ verify(liteLifecycleManager).collectByParentTopic(parentTopic);
+ verify(liteLifecycleManager, never()).forEachLiteTopic(any());
+ verify(spyDispatcher).selectAndDispatch(lmqName, clients, null);
+ }
+
+ /**
+ * Pattern-mode wildcard group: patterns are re-expanded and backlog is dispatched per client
+ * via doFullDispatchForClient.
+ */
+ @Test
+ public void testDoFullDispatchForWildcardGroup_PatternMode_ReexpandsAndDispatches() {
+ String group = "wildcardGroup";
+ String parentTopic = "order_events";
+ String clientId = "patternClient";
+ when(subscriptionGroupManager.findSubscriptionGroupConfig(group))
+ .thenReturn(wildcardGroupConfig(group, parentTopic));
+ when(liteSubscriptionRegistry.getAllClientIdByGroup(group)).thenReturn(Collections.singletonList(clientId));
+ LiteSubscription patternSub = new LiteSubscription().setGroup(group).setTopic(parentTopic);
+ patternSub.setWildcardPatterns(Collections.singleton("pay__*"));
+ when(liteSubscriptionRegistry.getLiteSubscription(clientId)).thenReturn(patternSub);
+
+ LiteEventDispatcher spyDispatcher = spy(liteEventDispatcher);
+ // re-expansion delegates to the registry; per-client backlog dispatch delegates to doFullDispatchForClient
+ doNothing().when(spyDispatcher).doFullDispatchForClient(clientId, group);
+
+ spyDispatcher.doFullDispatchForWildcardGroup(group);
+
+ verify(liteSubscriptionRegistry).reexpandWildcardPatterns(clientId);
+ verify(spyDispatcher).doFullDispatchForClient(clientId, group);
+ // legacy path (getWildcardSubscriber) must NOT be taken for a pure pattern-mode group
+ verify(liteSubscriptionRegistry, never()).getWildcardSubscriber(anyString(), anyString());
+ }
}
\ No newline at end of file
diff --git a/broker/src/test/java/org/apache/rocketmq/broker/lite/LiteSubscriptionRegistryImplTest.java b/broker/src/test/java/org/apache/rocketmq/broker/lite/LiteSubscriptionRegistryImplTest.java
index 7645a470962..c456e82404a 100644
--- a/broker/src/test/java/org/apache/rocketmq/broker/lite/LiteSubscriptionRegistryImplTest.java
+++ b/broker/src/test/java/org/apache/rocketmq/broker/lite/LiteSubscriptionRegistryImplTest.java
@@ -18,6 +18,7 @@
package org.apache.rocketmq.broker.lite;
import io.netty.channel.Channel;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
@@ -335,6 +336,515 @@ public void testRemoveCompleteSubscription_WildcardGroupMetadataCleanup() {
assertEquals(0, registry.getActiveSubscriptionNum());
}
+ // ==================== Pattern-mode Wildcard Group Tests ====================
+
+ private SubscriptionGroupConfig wildcardGroupConfig(String group) {
+ SubscriptionGroupConfig groupConfig = new SubscriptionGroupConfig();
+ groupConfig.setGroupName(group);
+ groupConfig.setWildcardLiteGroup(true);
+ return groupConfig;
+ }
+
+ /**
+ * Pattern-mode wildcard group: patterns are eagerly expanded against existing lite-topics
+ * under the parent topic, and matched lmqNames are registered as a normal subscription.
+ */
+ @Test
+ public void testAddCompleteSubscription_PatternWildcardGroup_ExpandsAgainstExistingLmq() {
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ // candidates returned by collectByParentTopic (full lmqNames)
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ String paySuccess = LiteUtil.toLmqName(topic, "pay__success");
+ String payRefundNotify = LiteUtil.toLmqName(topic, "pay__refund__notify");
+ String orderCreated = LiteUtil.toLmqName(topic, "order__created");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(
+ Arrays.asList(payRefund, paySuccess, payRefundNotify, orderCreated));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ Set patterns = new HashSet<>(Collections.singletonList("pay__*"));
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(), patterns, 1L);
+
+ LiteSubscription subscription = registry.getLiteSubscription(clientId);
+ assertNotNull(subscription);
+ assertEquals(patterns, subscription.getWildcardPatterns());
+ // pay__* matches pay__refund and pay__success only (single-level)
+ assertTrue(subscription.getLiteTopicSet().contains(payRefund));
+ assertTrue(subscription.getLiteTopicSet().contains(paySuccess));
+ assertFalse(subscription.getLiteTopicSet().contains(payRefundNotify));
+ assertFalse(subscription.getLiteTopicSet().contains(orderCreated));
+ // No synthetic topic@group key for pattern-mode groups
+ assertFalse(subscription.getLiteTopicSet().contains(topic + "@" + group));
+ // marked in wildcardGroupMap for fan-out enumeration
+ assertTrue(registry.wildcardGroupMap.containsKey(topic));
+ assertTrue(registry.wildcardGroupMap.get(topic).contains(group));
+ assertEquals(2, registry.getActiveSubscriptionNum());
+ }
+
+ /**
+ * Re-subscribing with changed patterns removes old matched lmqNames and adds new ones.
+ */
+ @Test
+ public void testAddCompleteSubscription_PatternWildcardGroup_DiffOnResubscribe() {
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ String notifyRefundSms = LiteUtil.toLmqName(topic, "notify__refund__sms");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Arrays.asList(payRefund, notifyRefundSms));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ // first subscribe: pay__*
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(),
+ new HashSet<>(Collections.singletonList("pay__*")), 1L);
+ LiteSubscription subscription = registry.getLiteSubscription(clientId);
+ assertTrue(subscription.getLiteTopicSet().contains(payRefund));
+ assertFalse(subscription.getLiteTopicSet().contains(notifyRefundSms));
+
+ // re-subscribe: notify__**
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(),
+ new HashSet<>(Collections.singletonList("notify__**")), 2L);
+ subscription = registry.getLiteSubscription(clientId);
+ assertFalse(subscription.getLiteTopicSet().contains(payRefund));
+ assertTrue(subscription.getLiteTopicSet().contains(notifyRefundSms));
+ assertEquals(1, registry.getActiveSubscriptionNum());
+ }
+
+ /**
+ * Re-subscribing from pattern mode back to legacy mode (empty patterns) must clear the stored
+ * patterns. Otherwise the client is misclassified as pattern-mode by
+ * doFullDispatchForWildcardGroup / reexpandWildcardPatterns, which key off a non-empty
+ * wildcardPatterns set, and legacy receive-all behavior is broken.
+ */
+ @Test
+ public void testAddCompleteSubscription_PatternToLegacyTransition_ClearsPatterns() {
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Collections.singletonList(payRefund));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ // first subscribe in pattern mode
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(),
+ new HashSet<>(Collections.singletonList("pay__*")), 1L);
+ LiteSubscription subscription = registry.getLiteSubscription(clientId);
+ assertFalse(subscription.getWildcardPatterns().isEmpty());
+ assertTrue(subscription.getLiteTopicSet().contains(payRefund));
+
+ // re-subscribe in legacy mode (empty patterns): must drop patterns and revert to the
+ // synthetic topic@group key.
+ registry.addCompleteSubscription(clientId, group, topic,
+ new HashSet<>(Collections.singletonList(payRefund)), Collections.emptySet(), 2L);
+ subscription = registry.getLiteSubscription(clientId);
+ assertTrue(subscription.getWildcardPatterns().isEmpty());
+ assertFalse(subscription.getLiteTopicSet().contains(payRefund));
+ assertTrue(subscription.getLiteTopicSet().contains(topic + "@" + group));
+ assertEquals(1, registry.getActiveSubscriptionNum());
+ }
+
+ /**
+ * getAllSubscriber for a pattern-mode wildcard group uses the normal liteTopic2Group path.
+ */
+ @Test
+ public void testGetAllSubscriber_PatternWildcardGroup_UsesNormalPath() {
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Collections.singletonList(payRefund));
+ when(mockLifecycleManager.isSubscriptionActive(topic, payRefund)).thenReturn(true);
+
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(),
+ new HashSet<>(Collections.singletonList("pay__*")), 1L);
+
+ SubscriberWrapper result = registry.getAllSubscriber(group, payRefund);
+ assertNotNull(result);
+ assertInstanceOf(SubscriberWrapper.ListWrapper.class, result);
+ SubscriberWrapper.ListWrapper listWrapper = (SubscriberWrapper.ListWrapper) result;
+ assertEquals(1, listWrapper.getClients().size());
+ assertEquals(clientId, listWrapper.getClients().get(0).clientId);
+ assertEquals(group, listWrapper.getClients().get(0).group);
+ }
+
+ /**
+ * removeCompleteSubscription cleans up wildcardGroupMap for pattern-mode groups when the last
+ * client leaves (the synthetic-key path does not fire for them).
+ */
+ @Test
+ public void testRemoveCompleteSubscription_PatternWildcardGroupMetadataCleanup() {
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Collections.singletonList(payRefund));
+ when(mockLifecycleManager.isSubscriptionActive(topic, payRefund)).thenReturn(true);
+
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(),
+ new HashSet<>(Collections.singletonList("pay__*")), 1L);
+ assertTrue(registry.wildcardGroupMap.containsKey(topic));
+
+ registry.removeCompleteSubscription(clientId);
+
+ assertFalse(registry.wildcardGroupMap.containsKey(topic));
+ assertNull(registry.getLiteSubscription(clientId));
+ assertEquals(0, registry.getActiveSubscriptionNum());
+ }
+
+ /**
+ * Backward compat: a legacy wildcard group (empty patterns) still uses the synthetic topic@group
+ * key and receives all lite-topics under the parent topic.
+ */
+ @Test
+ public void testAddCompleteSubscription_LegacyWildcardGroup_StillUsesSyntheticKey() {
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "testTopic";
+ Set lmqNameAll = new HashSet<>(Arrays.asList("lmq1", "lmq2"));
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+
+ // 5-arg overload delegates with empty patterns -> legacy mode
+ registry.addCompleteSubscription(clientId, group, topic, lmqNameAll, 1L);
+
+ assertTrue(registry.wildcardGroupMap.containsKey(topic));
+ LiteSubscription subscription = registry.getLiteSubscription(clientId);
+ assertNotNull(subscription);
+ assertTrue(subscription.getWildcardPatterns().isEmpty());
+ assertTrue(subscription.getLiteTopicSet().contains(topic + "@" + group));
+ assertEquals(1, registry.getActiveSubscriptionNum());
+ }
+
+ // ==================== Subscription Quota on All Registration Paths ====================
+
+ /**
+ * addCompleteSubscription (non-wildcard group) must respect maxLiteSubscriptionCount: once the
+ * broker is at the limit, a second client's complete-add is rejected with LiteQuotaException
+ * (mapped to LITE_SUBSCRIPTION_QUOTA_EXCEEDED by the processor) and mutates nothing.
+ */
+ @Test
+ public void testAddCompleteSubscription_NonWildcard_QuotaExceeded() {
+ when(mockBrokerConfig.getMaxLiteSubscriptionCount()).thenReturn(1L);
+ String group = "testGroup";
+ String topic = "testTopic";
+ SubscriptionGroupConfig groupConfig = new SubscriptionGroupConfig();
+ groupConfig.setGroupName(group);
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(groupConfig);
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ String clientA = "clientA";
+ String lmq1 = "lmq1";
+ registry.addCompleteSubscription(clientA, group, topic, new HashSet<>(Collections.singletonList(lmq1)), 1L);
+ assertEquals(1, registry.getActiveSubscriptionNum());
+ assertTrue(registry.getLiteSubscription(clientA).getLiteTopicSet().contains(lmq1));
+
+ // clientB's complete-add would push activeNum to 2 (> maxCount=1) -> rejected, no mutation.
+ // (addCompleteSubscription creates a placeholder LiteSubscription for clientB before the
+ // pre-flight check fires, but registers no lmqNames and never increments activeNum.)
+ String clientB = "clientB";
+ String lmq2 = "lmq2";
+ assertThrows(LiteQuotaException.class, () ->
+ registry.addCompleteSubscription(clientB, group, topic, new HashSet<>(Collections.singletonList(lmq2)), 1L));
+ assertEquals(1, registry.getActiveSubscriptionNum());
+ assertTrue(registry.getLiteSubscription(clientB).getLiteTopicSet().isEmpty());
+ // clientA's subscription is intact
+ assertTrue(registry.getLiteSubscription(clientA).getLiteTopicSet().contains(lmq1));
+ }
+
+ /**
+ * addCompleteSubscription (pattern-mode wildcard group) must respect maxLiteSubscriptionCount:
+ * when eager pattern expansion would register more lmqNames than the quota allows, the whole
+ * operation is rejected up front (pre-mutation) with LiteQuotaException and registers nothing.
+ */
+ @Test
+ public void testAddCompleteSubscription_PatternWildcard_QuotaExceeded() {
+ when(mockBrokerConfig.getMaxLiteSubscriptionCount()).thenReturn(1L);
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ String paySuccess = LiteUtil.toLmqName(topic, "pay__success");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Arrays.asList(payRefund, paySuccess));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ Set patterns = new HashSet<>(Collections.singletonList("pay__*"));
+ // Both pay__refund and pay__success match -> wouldAdd=2 > maxCount=1 -> rejected pre-mutation.
+ assertThrows(LiteQuotaException.class, () ->
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(), patterns, 1L));
+
+ assertEquals(0, registry.getActiveSubscriptionNum());
+ LiteSubscription subscription = registry.getLiteSubscription(clientId);
+ assertNotNull(subscription);
+ assertTrue(subscription.getLiteTopicSet().isEmpty());
+ // The quota check runs before any state mutation, so a rejected complete-add must NOT leave
+ // behind wildcard patterns or a wildcard-group mark that later re-expansion would mistake
+ // for a live (deferred-effect) subscription.
+ assertTrue(subscription.getWildcardPatterns().isEmpty());
+ assertFalse(registry.wildcardGroupMap.containsKey(topic));
+ }
+
+ /**
+ * reexpandWildcardPatterns caps at the quota instead of throwing: it registers newly-matched
+ * lmqNames up to the limit, then stops and logs. Runs on the dispatcher background thread with
+ * no client to respond to, so it must not throw. The cap guard precedes addLiteTopic so the
+ * subscription's LiteTopicSet stays consistent with liteTopic2Group on early exit.
+ */
+ @Test
+ public void testReexpandWildcardPatterns_CapsAtQuota() {
+ when(mockBrokerConfig.getMaxLiteSubscriptionCount()).thenReturn(2L);
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ String paySuccess = LiteUtil.toLmqName(topic, "pay__success");
+ String payCancelled = LiteUtil.toLmqName(topic, "pay__cancelled");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ // initial subscription: only pay__refund exists -> activeNum=1 (room for 1 more)
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Collections.singletonList(payRefund));
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(),
+ new HashSet<>(Collections.singletonList("pay__*")), 1L);
+ assertEquals(1, registry.getActiveSubscriptionNum());
+
+ // two new matching lite-topics now exist; re-expand has room for only one more
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(
+ Arrays.asList(payRefund, paySuccess, payCancelled));
+ int added = registry.reexpandWildcardPatterns(clientId);
+
+ assertEquals(1, added); // added paySuccess, then capped before payCancelled
+ assertEquals(2, registry.getActiveSubscriptionNum());
+ LiteSubscription subscription = registry.getLiteSubscription(clientId);
+ assertTrue(subscription.getLiteTopicSet().contains(payRefund));
+ assertTrue(subscription.getLiteTopicSet().contains(paySuccess));
+ assertFalse(subscription.getLiteTopicSet().contains(payCancelled));
+ }
+
+ /**
+ * registerArrivingLmqForPatternClients caps at the quota instead of throwing: when the broker is
+ * already at the limit on the message-arriving hot path, it registers nothing and logs rather than
+ * throwing (an exception would abort per-message dispatch). Existing subscriptions are unaffected.
+ */
+ @Test
+ public void testRegisterArrivingLmqForPatternClients_CapsAtQuota() {
+ when(mockBrokerConfig.getMaxLiteSubscriptionCount()).thenReturn(2L);
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ String paySuccess = LiteUtil.toLmqName(topic, "pay__success");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ // two pattern clients subscribed when only pay__refund exists -> activeNum=2 (at limit)
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Collections.singletonList(payRefund));
+ Set patterns = new HashSet<>(Collections.singletonList("pay__*"));
+ registry.addCompleteSubscription("clientA", group, topic, Collections.emptySet(), patterns, 1L);
+ registry.addCompleteSubscription("clientB", group, topic, Collections.emptySet(), patterns, 1L);
+ assertEquals(2, registry.getActiveSubscriptionNum());
+
+ // a new matching lite-topic arrives while at the limit -> nothing registered, no throw
+ int added = registry.registerArrivingLmqForPatternClients(paySuccess);
+
+ assertEquals(0, added);
+ assertEquals(2, registry.getActiveSubscriptionNum());
+ assertFalse(registry.getLiteSubscription("clientA").getLiteTopicSet().contains(paySuccess));
+ assertFalse(registry.getLiteSubscription("clientB").getLiteTopicSet().contains(paySuccess));
+ }
+
+ /**
+ * reexpandWildcardPatterns picks up lite-topics created after the initial subscription.
+ */
+ @Test
+ public void testReexpandWildcardPatterns_PicksUpNewlyCreatedLmq() {
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ String paySuccess = LiteUtil.toLmqName(topic, "pay__success");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ // initial subscription: only pay__refund exists
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Collections.singletonList(payRefund));
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(),
+ new HashSet<>(Collections.singletonList("pay__*")), 1L);
+ assertEquals(1, registry.getActiveSubscriptionNum());
+
+ // a new matching lite-topic pay__success is created
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Arrays.asList(payRefund, paySuccess));
+ int added = registry.reexpandWildcardPatterns(clientId);
+
+ assertEquals(1, added);
+ LiteSubscription subscription = registry.getLiteSubscription(clientId);
+ assertTrue(subscription.getLiteTopicSet().contains(payRefund));
+ assertTrue(subscription.getLiteTopicSet().contains(paySuccess));
+ assertEquals(2, registry.getActiveSubscriptionNum());
+ }
+
+ /**
+ * registerArrivingLmqForPatternClients is the dispatch-time single-lmqName counterpart to
+ * reexpandWildcardPatterns. When a message arrives on a newly-created lite-topic that a
+ * pattern-mode client matches but was not expanded to at subscribe time, it registers the
+ * (client, lmqName) pair so the dispatch can reach the client immediately — without the O(M)
+ * collectByParentTopic scan.
+ */
+ @Test
+ public void testRegisterArrivingLmqForPatternClients_RegistersNewlyCreatedMatchingLmq() {
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ String paySuccess = LiteUtil.toLmqName(topic, "pay__success"); // created after subscribe
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Collections.singletonList(payRefund));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ // initial subscription: only pay__refund exists at expand time
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(),
+ new HashSet<>(Collections.singletonList("pay__*")), 1L);
+ assertEquals(1, registry.getActiveSubscriptionNum());
+
+ // a new matching lite-topic pay__success arrives — single-lmqName match, no re-scan
+ int added = registry.registerArrivingLmqForPatternClients(paySuccess);
+
+ assertEquals(1, added);
+ LiteSubscription subscription = registry.getLiteSubscription(clientId);
+ assertTrue(subscription.getLiteTopicSet().contains(payRefund));
+ assertTrue(subscription.getLiteTopicSet().contains(paySuccess));
+ assertEquals(2, registry.getActiveSubscriptionNum());
+ // the new lmqName is now reachable via the normal message-arriving (group == null) path
+ SubscriberWrapper result = registry.getAllSubscriber(null, paySuccess);
+ assertNotNull(result);
+ assertInstanceOf(SubscriberWrapper.MapWrapper.class, result);
+ assertTrue(((SubscriberWrapper.MapWrapper) result).getGroupMap().containsKey(group));
+ }
+
+ /**
+ * registerArrivingLmqForPatternClients does not register a lmqName whose child does not match
+ * any of the pattern client's patterns.
+ */
+ @Test
+ public void testRegisterArrivingLmqForPatternClients_SkipsNonMatchingLmq() {
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ String orderCreated = LiteUtil.toLmqName(topic, "order__created"); // does not match pay__*
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Collections.singletonList(payRefund));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(),
+ new HashSet<>(Collections.singletonList("pay__*")), 1L);
+
+ int added = registry.registerArrivingLmqForPatternClients(orderCreated);
+
+ assertEquals(0, added);
+ assertFalse(registry.getLiteSubscription(clientId).getLiteTopicSet().contains(orderCreated));
+ assertEquals(1, registry.getActiveSubscriptionNum());
+ }
+
+ /**
+ * registerArrivingLmqForPatternClients is idempotent: re-arrival on an lmqName already
+ * registered at subscribe time registers nothing new.
+ */
+ @Test
+ public void testRegisterArrivingLmqForPatternClients_IdempotentOnRepeatArrival() {
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Collections.singletonList(payRefund));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(),
+ new HashSet<>(Collections.singletonList("pay__*")), 1L);
+
+ int added = registry.registerArrivingLmqForPatternClients(payRefund);
+ assertEquals(0, added); // already registered at subscribe time
+ assertEquals(1, registry.getActiveSubscriptionNum());
+ }
+
+ /**
+ * registerArrivingLmqForPatternClients ignores legacy wildcard clients (empty patterns); they
+ * are reachable via the synthetic topic@group key, not the pattern path, and must not be
+ * double-registered.
+ */
+ @Test
+ public void testRegisterArrivingLmqForPatternClients_IgnoresLegacyClients() {
+ String legacyClientId = "legacyClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ // legacy wildcard client (empty patterns -> synthetic key, receives all)
+ registry.addCompleteSubscription(legacyClientId, group, topic, Collections.emptySet(), 1L);
+
+ int added = registry.registerArrivingLmqForPatternClients(payRefund);
+ assertEquals(0, added); // legacy client has empty wildcardPatterns, skipped
+ // legacy client still reachable via the synthetic key, unaffected
+ assertEquals(1, registry.getActiveSubscriptionNum());
+ SubscriberWrapper result = registry.getAllSubscriber(group, payRefund);
+ assertNotNull(result);
+ assertInstanceOf(SubscriberWrapper.ListWrapper.class, result);
+ Set deliveredClientIds = new HashSet<>();
+ for (ClientGroup cg : ((SubscriberWrapper.ListWrapper) result).getClients()) {
+ deliveredClientIds.add(cg.clientId);
+ }
+ assertTrue(deliveredClientIds.contains(legacyClientId));
+ }
+
+ /**
+ * A mixed wildcard group (one pattern-mode client + one legacy client) must deliver to BOTH:
+ * the pattern-mode client (via its real lmqName in liteTopic2Group) and the legacy client (via
+ * the synthetic topic@group key). getAllSubscriber must merge the two sets.
+ */
+ @Test
+ public void testGetAllSubscriber_MixedWildcardGroup_DeliversToBothPatternAndLegacy() {
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ String unmatched = LiteUtil.toLmqName(topic, "order__created");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Arrays.asList(payRefund, unmatched));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ // pattern-mode client subscribes pay__*
+ registry.addCompleteSubscription("patternClient", group, topic, Collections.emptySet(),
+ new HashSet<>(Collections.singletonList("pay__*")), 1L);
+ // legacy client subscribes (empty patterns -> synthetic key, receives all)
+ registry.addCompleteSubscription("legacyClient", group, topic, Collections.emptySet(), 1L);
+
+ // For a topic the pattern client matched: both clients should be returned.
+ SubscriberWrapper result = registry.getAllSubscriber(group, payRefund);
+ assertNotNull(result);
+ assertInstanceOf(SubscriberWrapper.ListWrapper.class, result);
+ Set deliveredClientIds = new HashSet<>();
+ for (ClientGroup cg : ((SubscriberWrapper.ListWrapper) result).getClients()) {
+ deliveredClientIds.add(cg.clientId);
+ }
+ assertTrue("pattern-mode client must be delivered", deliveredClientIds.contains("patternClient"));
+ assertTrue("legacy client must be delivered", deliveredClientIds.contains("legacyClient"));
+
+ // For a topic the pattern client did NOT match: only the legacy client (receive-all).
+ SubscriberWrapper unmatchedResult = registry.getAllSubscriber(group, unmatched);
+ Set unmatchedIds = new HashSet<>();
+ for (ClientGroup cg : ((SubscriberWrapper.ListWrapper) unmatchedResult).getClients()) {
+ unmatchedIds.add(cg.clientId);
+ }
+ assertFalse("pattern client must not receive unmatched topic", unmatchedIds.contains("patternClient"));
+ assertTrue("legacy client must still receive unmatched topic", unmatchedIds.contains("legacyClient"));
+ }
+
/**
* Test addCompleteSubscription updates complete subscription
*/
@@ -924,4 +1434,79 @@ public void testExclusiveEviction_CompleteSyncReNotifiesForTombstonedLmq() {
verify(mockBroker2Client, org.mockito.Mockito.atLeast(2))
.notifyUnsubscribeLite(eq(clientAChannel), captor.capture());
}
+
+ // ==================== Quota projection: net delta & no state leak on failure ====================
+
+ /**
+ * wouldAdd must credit pending removals: a complete-add that removes one lmqName and adds a
+ * different one has a NET delta of zero, so it must NOT be rejected at the quota limit even
+ * though the gross "add" count is 1. Before crediting removals this net-zero replace was
+ * falsely rejected when the broker was full.
+ */
+ @Test
+ public void testAddCompleteSubscription_NetZeroReplaceAtQuotaNotRejected() {
+ when(mockBrokerConfig.getMaxLiteSubscriptionCount()).thenReturn(1L);
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "testTopic";
+ SubscriptionGroupConfig groupConfig = new SubscriptionGroupConfig();
+ groupConfig.setGroupName(group);
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(groupConfig);
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ // client holds lmq1 -> activeNum=1 (at the limit)
+ String lmq1 = "lmq1";
+ String lmq2 = "lmq2";
+ registry.addCompleteSubscription(clientId, group, topic, new HashSet<>(Collections.singletonList(lmq1)), 1L);
+ assertEquals(1, registry.getActiveSubscriptionNum());
+
+ // Full re-sync swaps lmq1 -> lmq2. Gross add = 1, but net delta = 0 (lmq1 removed). Must pass.
+ registry.addCompleteSubscription(clientId, group, topic, new HashSet<>(Collections.singletonList(lmq2)), 2L);
+
+ // Still at the limit, now holding lmq2 (lmq1 dropped).
+ assertEquals(1, registry.getActiveSubscriptionNum());
+ LiteSubscription subscription = registry.getLiteSubscription(clientId);
+ assertFalse(subscription.getLiteTopicSet().contains(lmq1));
+ assertTrue(subscription.getLiteTopicSet().contains(lmq2));
+ }
+
+ /**
+ * A rejected pattern-mode complete-add must leave NO live subscription state behind — not just
+ * an empty liteTopicSet, but empty wildcardPatterns and no wildcard-group mark — so the periodic
+ * reexpandWildcardPatterns / message-arriving registerArrivingLmqForPatternClients paths do not
+ * pick up the failed client and turn it into a deferred-effect subscription. This is the core of
+ * the review's "reexpand may turn a failed subscription into a delayed-effect subscription".
+ */
+ @Test
+ public void testAddCompleteSubscription_PatternQuotaFailure_NoDeferredState() {
+ when(mockBrokerConfig.getMaxLiteSubscriptionCount()).thenReturn(1L);
+ String clientId = "testClient";
+ String group = "testGroup";
+ String topic = "order_events";
+ String payRefund = LiteUtil.toLmqName(topic, "pay__refund");
+ String paySuccess = LiteUtil.toLmqName(topic, "pay__success");
+ when(mockSubscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(wildcardGroupConfig(group));
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Arrays.asList(payRefund, paySuccess));
+ when(mockLifecycleManager.isSubscriptionActive(eq(topic), anyString())).thenReturn(true);
+
+ Set patterns = new HashSet<>(Collections.singletonList("pay__*"));
+ // wouldAdd=2 > maxCount=1 -> rejected. Critically, this runs before setWildcardPatterns /
+ // markWildcardGroup, so no patterns or group mark are persisted.
+ assertThrows(LiteQuotaException.class, () ->
+ registry.addCompleteSubscription(clientId, group, topic, Collections.emptySet(), patterns, 1L));
+
+ // No active references, and no pattern/group state a later re-expand could act on.
+ assertEquals(0, registry.getActiveSubscriptionNum());
+ LiteSubscription subscription = registry.getLiteSubscription(clientId);
+ assertNotNull(subscription);
+ assertTrue(subscription.getWildcardPatterns().isEmpty());
+ assertFalse(registry.wildcardGroupMap.containsKey(topic));
+
+ // Consequence: a later re-expand of this client registers nothing (it short-circuits on
+ // empty wildcardPatterns), and an arriving matching lmqName does not pick the client up.
+ when(mockLifecycleManager.collectByParentTopic(topic)).thenReturn(Arrays.asList(payRefund, paySuccess));
+ assertEquals(0, registry.reexpandWildcardPatterns(clientId));
+ assertEquals(0, registry.registerArrivingLmqForPatternClients(paySuccess));
+ assertEquals(0, registry.getActiveSubscriptionNum());
+ }
}
diff --git a/broker/src/test/java/org/apache/rocketmq/broker/processor/LiteSubscriptionCtlProcessorTest.java b/broker/src/test/java/org/apache/rocketmq/broker/processor/LiteSubscriptionCtlProcessorTest.java
index cc4692955fa..29be4bbfe7e 100644
--- a/broker/src/test/java/org/apache/rocketmq/broker/processor/LiteSubscriptionCtlProcessorTest.java
+++ b/broker/src/test/java/org/apache/rocketmq/broker/processor/LiteSubscriptionCtlProcessorTest.java
@@ -159,6 +159,79 @@ public void testProcessRequest_ActionIsAllAdd() throws Exception {
verify(liteSubscriptionRegistry).addCompleteSubscription(eq(clientId), eq(group), eq(topic), anySet(), eq(1L));
}
+ @Test
+ public void testProcessRequest_WildcardCompleteAdd_RoutesToPatternOverload() throws Exception {
+ String clientId = "clientId";
+ String group = "group";
+ String topic = "topic";
+ Set patterns = new HashSet<>();
+ patterns.add("pay__*");
+ patterns.add("notify__**");
+
+ LiteSubscriptionDTO dto = new LiteSubscriptionDTO();
+ dto.setClientId(clientId);
+ dto.setGroup(group);
+ dto.setTopic(topic);
+ dto.setLiteTopicSet(patterns);
+ dto.setAction(LiteSubscriptionAction.COMPLETE_ADD);
+ dto.setVersion(1L);
+
+ LiteSubscriptionCtlRequestBody requestBody = new LiteSubscriptionCtlRequestBody();
+ requestBody.setSubscriptionSet(Collections.singleton(dto));
+ RemotingCommand request = RemotingCommand.createRequestCommand(0, null);
+ request.setBody(requestBody.encode());
+
+ when(ctx.channel()).thenReturn(channel);
+ when(brokerController.getSubscriptionGroupManager()).thenReturn(subscriptionGroupManager);
+ SubscriptionGroupConfig groupConfig = new SubscriptionGroupConfig();
+ groupConfig.setConsumeEnable(true);
+ groupConfig.setWildcardLiteGroup(true);
+ when(subscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(groupConfig);
+
+ RemotingCommand response = processor.processRequest(ctx, request);
+
+ assertEquals(ResponseCode.SUCCESS, response.getCode());
+ // wildcard group -> 6-arg overload carrying the validated patterns
+ verify(liteSubscriptionRegistry).addCompleteSubscription(eq(clientId), eq(group), eq(topic),
+ anySet(), anySet(), eq(1L));
+ }
+
+ @Test
+ public void testProcessRequest_WildcardCompleteAdd_AllInvalidPatternsRejected() throws Exception {
+ String clientId = "clientId";
+ String group = "group";
+ String topic = "topic";
+ // every pattern is invalid (mixed * in a segment / ** not at end)
+ Set patterns = new HashSet<>();
+ patterns.add("pay*");
+ patterns.add("**__pay");
+
+ LiteSubscriptionDTO dto = new LiteSubscriptionDTO();
+ dto.setClientId(clientId);
+ dto.setGroup(group);
+ dto.setTopic(topic);
+ dto.setLiteTopicSet(patterns);
+ dto.setAction(LiteSubscriptionAction.COMPLETE_ADD);
+ dto.setVersion(1L);
+
+ LiteSubscriptionCtlRequestBody requestBody = new LiteSubscriptionCtlRequestBody();
+ requestBody.setSubscriptionSet(Collections.singleton(dto));
+ RemotingCommand request = RemotingCommand.createRequestCommand(0, null);
+ request.setBody(requestBody.encode());
+
+ when(ctx.channel()).thenReturn(channel);
+ when(brokerController.getSubscriptionGroupManager()).thenReturn(subscriptionGroupManager);
+ SubscriptionGroupConfig groupConfig = new SubscriptionGroupConfig();
+ groupConfig.setConsumeEnable(true);
+ groupConfig.setWildcardLiteGroup(true);
+ when(subscriptionGroupManager.findSubscriptionGroupConfig(group)).thenReturn(groupConfig);
+
+ RemotingCommand response = processor.processRequest(ctx, request);
+
+ // Must be rejected, NOT silently widened to receive-all legacy wildcard mode.
+ assertEquals(ResponseCode.ILLEGAL_OPERATION, response.getCode());
+ }
+
@Test
public void testProcessRequest_ActionIsIncrementalRemove() throws Exception {
String clientId = "clientId";
diff --git a/common/src/main/java/org/apache/rocketmq/common/lite/LitePatternMatcher.java b/common/src/main/java/org/apache/rocketmq/common/lite/LitePatternMatcher.java
new file mode 100644
index 00000000000..fc672dd9a85
--- /dev/null
+++ b/common/src/main/java/org/apache/rocketmq/common/lite/LitePatternMatcher.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.common.lite;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import org.apache.rocketmq.common.constant.LoggerName;
+import org.apache.rocketmq.logging.org.slf4j.Logger;
+import org.apache.rocketmq.logging.org.slf4j.LoggerFactory;
+
+/**
+ * NATS-style wildcard pattern matcher for LiteTopic consumer subscriptions, operating in
+ * child lite-topic name space (i.e. the {@code $liteTopic} segment of {@code %LMQ%$parent$liteTopic}).
+ *
+ * Separator: {@code __} (double underscore). {@code $} is reserved as the LMQ separator
+ * (see {@link LiteUtil#SEPARATOR}) and {@code .} is used in topic names/paths, so {@code __}
+ * is used here to avoid conflicts.
+ *
+ *
Pattern tokens:
+ *
+ * - {@code *} — matches exactly one {@code __}-delimited segment (allowed anywhere).
+ * - {@code **} — matches one or more segments (must be the last segment, like NATS {@code >}).
+ *
+ *
+ * Examples (parent topic = {@code order_events}):
+ *
+ * pay__refund matches pay__refund
+ * pay__* matches pay__refund, pay__success
+ * *__refund matches pay__refund, notify__refund
+ * pay__*__notify matches pay__refund__notify, pay__success__notify
+ * ** matches all
+ * pay__** matches pay__refund, pay__refund__notify
+ *
+ *
+ * Matching is recursive segment-by-segment with no regex, so it is fast enough for the
+ * dispatch hot path.
+ */
+public final class LitePatternMatcher {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(LoggerName.ROCKETMQ_POP_LITE_LOGGER_NAME);
+
+ /**
+ * Segment separator for patterns and lite-topic names. Distinct from {@link LiteUtil#SEPARATOR}
+ * (which is the LMQ {@code $} separator and must not appear in lite-topic child names).
+ */
+ public static final String SEPARATOR = "__";
+
+ private static final String SINGLE = "*";
+ private static final String DOUBLE = "**";
+
+ private LitePatternMatcher() {
+ }
+
+ /**
+ * Validate the syntax of a pattern.
+ *
+ *
Rules:
+ *
+ * - non-null and non-empty;
+ * - no empty segment (rejects leading/trailing/duplicate {@code __}, e.g. {@code pay__},
+ * {@code __pay}, {@code pay____x});
+ * - {@code **} only allowed as the last segment;
+ * - {@code *} is a whole-segment token; a segment mixing {@code *} with other chars
+ * (e.g. {@code pay*}) is invalid.
+ *
+ *
+ * @param pattern the pattern to validate
+ * @return true if the pattern is syntactically valid
+ */
+ public static boolean validate(String pattern) {
+ if (pattern == null || pattern.isEmpty()) {
+ return false;
+ }
+ String[] segments = pattern.split(SEPARATOR, -1);
+ for (int i = 0; i < segments.length; i++) {
+ String segment = segments[i];
+ if (segment.isEmpty()) {
+ return false;
+ }
+ if (DOUBLE.equals(segment)) {
+ // ** only allowed as the last segment
+ if (i != segments.length - 1) {
+ return false;
+ }
+ } else if (SINGLE.equals(segment)) {
+ // * is allowed anywhere
+ } else if (segment.indexOf('*') >= 0) {
+ // a segment mixing * with other chars is invalid
+ return false;
+ }
+ // otherwise a literal segment, always valid
+ }
+ return true;
+ }
+
+ /**
+ * Test whether a lite-topic name matches a pattern. Both are split on {@code __}.
+ *
+ * @param pattern the pattern, should be {@link #validate(String)} valid
+ * @param liteTopic the candidate lite-topic child name
+ * @return true if the lite-topic matches the pattern
+ */
+ public static boolean matches(String pattern, String liteTopic) {
+ if (!validate(pattern) || liteTopic == null || liteTopic.isEmpty()) {
+ return false;
+ }
+ String[] p = pattern.split(SEPARATOR, -1);
+ String[] t = liteTopic.split(SEPARATOR, -1);
+ return matchSeg(p, 0, t, 0);
+ }
+
+ /**
+ * Recursive segment matcher.
+ *
+ * Invariants enforced by {@link #validate(String)}: no empty segments, {@code **} only at
+ * the last index, {@code *} is a whole segment.
+ */
+ private static boolean matchSeg(String[] p, int pi, String[] t, int ti) {
+ if (pi == p.length) {
+ // pattern exhausted: topic must also be exhausted
+ return ti == t.length;
+ }
+ String segment = p[pi];
+ if (DOUBLE.equals(segment)) {
+ // ** matches one or more remaining segments to the end (validated to be last)
+ return ti < t.length;
+ }
+ if (SINGLE.equals(segment)) {
+ // * matches exactly one segment
+ return ti < t.length && matchSeg(p, pi + 1, t, ti + 1);
+ }
+ // literal segment
+ return ti < t.length && segment.equals(t[ti]) && matchSeg(p, pi + 1, t, ti + 1);
+ }
+
+ /**
+ * Test whether a lite-topic name matches any of the given patterns. Invalid patterns are
+ * skipped. This is a convenience over {@link #matches(String, String)} for the common
+ * "does this candidate match any of the client's patterns" check on the dispatch hot path.
+ *
+ * @param patterns the patterns to test
+ * @param liteTopic the candidate lite-topic child name
+ * @return true if the lite-topic matches at least one valid pattern
+ */
+ public static boolean matchesAny(Collection patterns, String liteTopic) {
+ if (patterns == null || patterns.isEmpty() || liteTopic == null || liteTopic.isEmpty()) {
+ return false;
+ }
+ for (String pattern : patterns) {
+ if (matches(pattern, liteTopic)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Expand a set of patterns against a collection of candidate lite-topic names, returning the
+ * union of candidates matched by any valid pattern. Invalid patterns are skipped (with a debug
+ * log) rather than aborting the whole expansion, so one bad pattern does not kill a subscription.
+ *
+ * Patterns and candidates are each pre-split once and reused across the inner loop to keep
+ * the cost under the {@code <10ms / 1000 candidates} target.
+ *
+ * @param patterns the patterns to expand
+ * @param candidates the candidate lite-topic child names
+ * @return the union of matched candidate names (never null)
+ */
+ public static Set expand(Collection patterns, Collection candidates) {
+ Set matched = new HashSet<>();
+ if (patterns == null || patterns.isEmpty() || candidates == null || candidates.isEmpty()) {
+ return matched;
+ }
+ // Pre-split valid patterns once
+ int count = 0;
+ String[][] compiledPatterns = new String[patterns.size()][];
+ for (String pattern : patterns) {
+ if (!validate(pattern)) {
+ LOGGER.debug("skip invalid wildcard pattern during expand: {}", pattern);
+ continue;
+ }
+ compiledPatterns[count++] = pattern.split(SEPARATOR, -1);
+ }
+ if (count == 0) {
+ return matched;
+ }
+ // Pre-split each candidate once and test against all compiled patterns
+ for (String candidate : candidates) {
+ if (candidate == null || candidate.isEmpty()) {
+ continue;
+ }
+ String[] t = candidate.split(SEPARATOR, -1);
+ for (int i = 0; i < count; i++) {
+ if (matchSeg(compiledPatterns[i], 0, t, 0)) {
+ matched.add(candidate);
+ break;
+ }
+ }
+ }
+ return matched;
+ }
+}
diff --git a/common/src/main/java/org/apache/rocketmq/common/lite/LiteSubscription.java b/common/src/main/java/org/apache/rocketmq/common/lite/LiteSubscription.java
index abf7c9ee3af..0a54bde848c 100644
--- a/common/src/main/java/org/apache/rocketmq/common/lite/LiteSubscription.java
+++ b/common/src/main/java/org/apache/rocketmq/common/lite/LiteSubscription.java
@@ -25,6 +25,13 @@ public class LiteSubscription {
private String group;
private String topic;
private final Set liteTopicSet = ConcurrentHashMap.newKeySet();
+ /**
+ * Wildcard patterns for a wildcard group in pattern mode. Empty means the group is either a
+ * non-wildcard subscription or a legacy wildcard group (receives all lite-topics under the
+ * parent topic). When non-empty, {@link #liteTopicSet} holds the expanded (matched) lmqNames.
+ * Not serialized over the wire; rebuilt on client reconnect, same as {@link #liteTopicSet}.
+ */
+ private final Set wildcardPatterns = ConcurrentHashMap.newKeySet();
private volatile long updateTime = System.currentTimeMillis();
public boolean addLiteTopic(String liteTopic) {
@@ -74,6 +81,23 @@ public LiteSubscription setLiteTopicSet(Set liteTopicSet) {
return this;
}
+ public Set getWildcardPatterns() {
+ return wildcardPatterns;
+ }
+
+ /**
+ * Full replace of the wildcard patterns. Existing patterns not in the given collection are
+ * removed and the new ones are added.
+ */
+ public LiteSubscription setWildcardPatterns(Collection patterns) {
+ updateTime();
+ this.wildcardPatterns.clear();
+ if (patterns != null) {
+ this.wildcardPatterns.addAll(patterns);
+ }
+ return this;
+ }
+
public long getUpdateTime() {
return updateTime;
}
@@ -92,6 +116,7 @@ public String toString() {
"group='" + group + '\'' +
", topic='" + topic + '\'' +
", liteTopicSet=" + liteTopicSet +
+ ", wildcardPatterns=" + wildcardPatterns +
", updateTime=" + updateTime +
'}';
}
diff --git a/common/src/test/java/org/apache/rocketmq/common/lite/LitePatternMatcherTest.java b/common/src/test/java/org/apache/rocketmq/common/lite/LitePatternMatcherTest.java
new file mode 100644
index 00000000000..fe8443f25c9
--- /dev/null
+++ b/common/src/test/java/org/apache/rocketmq/common/lite/LitePatternMatcherTest.java
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.common.lite;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class LitePatternMatcherTest {
+
+ // ==================== validate ====================
+
+ @Test
+ public void validate_acceptsValidPatterns() {
+ assertTrue(LitePatternMatcher.validate("pay__refund"));
+ assertTrue(LitePatternMatcher.validate("pay__*"));
+ assertTrue(LitePatternMatcher.validate("*__refund"));
+ assertTrue(LitePatternMatcher.validate("pay__*__notify"));
+ assertTrue(LitePatternMatcher.validate("**"));
+ assertTrue(LitePatternMatcher.validate("pay__**"));
+ assertTrue(LitePatternMatcher.validate("*"));
+ assertTrue(LitePatternMatcher.validate("a__b__c"));
+ }
+
+ @Test
+ public void validate_rejectsInvalidPatterns() {
+ assertFalse(LitePatternMatcher.validate(null));
+ assertFalse(LitePatternMatcher.validate(""));
+ // empty segments
+ assertFalse(LitePatternMatcher.validate("pay__"));
+ assertFalse(LitePatternMatcher.validate("__pay"));
+ assertFalse(LitePatternMatcher.validate("pay____refund"));
+ // ** not at the end
+ assertFalse(LitePatternMatcher.validate("**__pay"));
+ assertFalse(LitePatternMatcher.validate("a__**__b"));
+ // * mixed with other chars in a segment
+ assertFalse(LitePatternMatcher.validate("pay*"));
+ assertFalse(LitePatternMatcher.validate("pa*y"));
+ }
+
+ // ==================== matches — issue example rows ====================
+
+ @Test
+ public void matches_literalExact() {
+ assertTrue(LitePatternMatcher.matches("pay__refund", "pay__refund"));
+ assertFalse(LitePatternMatcher.matches("pay__refund", "pay__refund__notify"));
+ assertFalse(LitePatternMatcher.matches("pay__refund", "notify__refund"));
+ }
+
+ @Test
+ public void matches_singleLevelWildcard() {
+ assertTrue(LitePatternMatcher.matches("pay__*", "pay__refund"));
+ assertTrue(LitePatternMatcher.matches("pay__*", "pay__success"));
+ assertFalse(LitePatternMatcher.matches("pay__*", "pay__refund__notify"));
+ }
+
+ @Test
+ public void matches_singleLevelWildcardPrefix() {
+ assertTrue(LitePatternMatcher.matches("*__refund", "pay__refund"));
+ assertTrue(LitePatternMatcher.matches("*__refund", "notify__refund"));
+ assertFalse(LitePatternMatcher.matches("*__refund", "pay__refund__notify"));
+ }
+
+ @Test
+ public void matches_wildcardInMiddle() {
+ assertTrue(LitePatternMatcher.matches("pay__*__notify", "pay__refund__notify"));
+ assertTrue(LitePatternMatcher.matches("pay__*__notify", "pay__success__notify"));
+ assertFalse(LitePatternMatcher.matches("pay__*__notify", "pay__refund"));
+ }
+
+ @Test
+ public void matches_multiLevelAll() {
+ assertTrue(LitePatternMatcher.matches("**", "pay"));
+ assertTrue(LitePatternMatcher.matches("**", "pay__refund"));
+ assertTrue(LitePatternMatcher.matches("**", "pay__refund__notify"));
+ assertFalse(LitePatternMatcher.matches("**", ""));
+ }
+
+ @Test
+ public void matches_multiLevelPrefix() {
+ assertTrue(LitePatternMatcher.matches("pay__**", "pay__refund"));
+ assertTrue(LitePatternMatcher.matches("pay__**", "pay__refund__notify"));
+ assertFalse(LitePatternMatcher.matches("pay__**", "notify__refund"));
+ // ** consumes one-or-more: pay__** must NOT match bare "pay"
+ assertFalse(LitePatternMatcher.matches("pay__**", "pay"));
+ }
+
+ // ==================== matches — edge cases ====================
+
+ @Test
+ public void matches_invalidPatternNeverMatches() {
+ assertFalse(LitePatternMatcher.matches("pay__", "pay__refund"));
+ assertFalse(LitePatternMatcher.matches("**__pay", "pay"));
+ assertFalse(LitePatternMatcher.matches("pay*", "payx"));
+ }
+
+ @Test
+ public void matches_nullAndEmpty() {
+ assertFalse(LitePatternMatcher.matches(null, "pay"));
+ assertFalse(LitePatternMatcher.matches("pay", null));
+ assertFalse(LitePatternMatcher.matches("pay", ""));
+ }
+
+ @Test
+ public void matches_singleSegmentLiteralAndWildcard() {
+ assertTrue(LitePatternMatcher.matches("pay", "pay"));
+ assertFalse(LitePatternMatcher.matches("pay", "refund"));
+ assertTrue(LitePatternMatcher.matches("*", "pay"));
+ assertFalse(LitePatternMatcher.matches("*", "pay__refund"));
+ }
+
+ // ==================== matchesAny ====================
+
+ @Test
+ public void matchesAny_unionSemantics() {
+ Set patterns = new HashSet<>(Arrays.asList("pay__*", "notify__**"));
+ assertTrue(LitePatternMatcher.matchesAny(patterns, "pay__refund"));
+ assertTrue(LitePatternMatcher.matchesAny(patterns, "notify__refund__sms"));
+ assertFalse(LitePatternMatcher.matchesAny(patterns, "order__created"));
+ }
+
+ @Test
+ public void matchesAny_emptyAndNull() {
+ assertFalse(LitePatternMatcher.matchesAny(null, "pay"));
+ assertFalse(LitePatternMatcher.matchesAny(Collections.emptySet(), "pay"));
+ assertFalse(LitePatternMatcher.matchesAny(Collections.singleton("pay__*"), null));
+ assertFalse(LitePatternMatcher.matchesAny(Collections.singleton("pay__*"), ""));
+ }
+
+ @Test
+ public void matchesAny_skipsInvalidPattern() {
+ // an invalid pattern among valid ones must not break matching
+ Set patterns = new HashSet<>(Arrays.asList("pay__", "pay__*"));
+ assertTrue(LitePatternMatcher.matchesAny(patterns, "pay__refund"));
+ assertFalse(LitePatternMatcher.matchesAny(patterns, "order__x"));
+ }
+
+ // ==================== expand ====================
+
+ @Test
+ public void expand_unionOfMatched() {
+ Set patterns = new HashSet<>(Arrays.asList("pay__*", "notify__**"));
+ Set candidates = new HashSet<>(Arrays.asList(
+ "pay__refund", "pay__success", "pay__refund__notify",
+ "notify__refund", "notify__refund__sms", "order__created"));
+ Set matched = LitePatternMatcher.expand(patterns, candidates);
+ // pay__* -> pay__refund, pay__success (NOT pay__refund__notify)
+ // notify__** -> notify__refund, notify__refund__sms
+ Set expected = new HashSet<>(Arrays.asList(
+ "pay__refund", "pay__success", "notify__refund", "notify__refund__sms"));
+ assertEquals(expected, matched);
+ }
+
+ @Test
+ public void expand_emptyInputs() {
+ assertEquals(Collections.emptySet(),
+ LitePatternMatcher.expand(Collections.emptySet(), Collections.singleton("pay__refund")));
+ assertEquals(Collections.emptySet(),
+ LitePatternMatcher.expand(Collections.singleton("pay__*"), Collections.emptySet()));
+ assertEquals(Collections.emptySet(), LitePatternMatcher.expand(null, null));
+ }
+
+ @Test
+ public void expand_skipsInvalidPattern() {
+ Set patterns = new HashSet<>(Arrays.asList("pay__", "pay__*"));
+ Set candidates = new HashSet<>(Arrays.asList("pay__refund", "order__x"));
+ Set matched = LitePatternMatcher.expand(patterns, candidates);
+ assertEquals(Collections.singleton("pay__refund"), matched);
+ }
+
+ @Test
+ public void expand_doubleStarMatchesAll() {
+ Set candidates = new HashSet<>(Arrays.asList("a", "a__b", "a__b__c"));
+ Set matched = LitePatternMatcher.expand(Collections.singleton("**"), candidates);
+ assertEquals(candidates, matched);
+ }
+}