Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed 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.thingsboard.mqtt.broker.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConfigurationProperties(prefix = "mqtt.rate-limits.application-persisted-messages")
@Data
public class ApplicationPersistedMsgsRateLimitsConfiguration {

private boolean enabled;
private String clientConfig;

}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ public interface RateLimitService {

boolean isDevicePersistedMsgsLimitEnabled();

/**
* Non-blocking token consume for outbound persisted messages to an APPLICATION client (throttling, not dropping)
*/
boolean tryConsumeApplicationPersistedMsgs(String clientId);

boolean isApplicationPersistedMsgsRateLimitEnabled();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sibling flags in this interface are isDevicePersistedMsgsLimitEnabled() and isTotalMsgsLimitEnabled(), but this one has an extra Rate: isApplicationPersistedMsgsRateLimitEnabled(). Minor, but for a set of methods a caller finds via autocomplete the odd-one-out is a small papercut — isApplicationPersistedMsgsLimitEnabled() would line up with the others.


long tryConsumeTotalMsgs(long limit);

boolean isTotalMsgsLimitEnabled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import org.thingsboard.mqtt.broker.common.data.ClientSessionInfo;
import org.thingsboard.mqtt.broker.common.data.SessionInfo;
import org.thingsboard.mqtt.broker.common.util.TbRateLimits;
import org.thingsboard.mqtt.broker.config.ApplicationPersistedMsgsRateLimitsConfiguration;
import org.thingsboard.mqtt.broker.config.ClientsLimitProperties;
import org.thingsboard.mqtt.broker.config.DevicePersistedMsgsRateLimitsConfiguration;
import org.thingsboard.mqtt.broker.config.IncomingRateLimitsConfiguration;
Expand All @@ -44,13 +45,16 @@ public class RateLimitServiceImpl implements RateLimitService {
private final OutgoingRateLimitsConfiguration outgoingRateLimitsConfiguration;
private final TotalMsgsRateLimitsConfiguration totalMsgsRateLimitsConfiguration;
private final DevicePersistedMsgsRateLimitsConfiguration devicePersistedMsgsRateLimitsConfiguration;
private final ApplicationPersistedMsgsRateLimitsConfiguration applicationPersistedMsgsRateLimitsConfiguration;
private final RateLimitCacheService rateLimitCacheService;
private final ClientsLimitProperties clientsLimitProperties;

@Getter
private ConcurrentMap<String, TbRateLimits> incomingPublishClientLimits;
@Getter
private ConcurrentMap<String, TbRateLimits> outgoingPublishClientLimits;
@Getter
private ConcurrentMap<String, TbRateLimits> applicationPersistedMsgClientLimits;

@PostConstruct
public void init() {
Expand All @@ -60,6 +64,9 @@ public void init() {
if (outgoingRateLimitsConfiguration.isEnabled()) {
outgoingPublishClientLimits = new ConcurrentHashMap<>();
}
if (applicationPersistedMsgsRateLimitsConfiguration.isEnabled()) {
applicationPersistedMsgClientLimits = new ConcurrentHashMap<>();
}
}

@Override
Expand Down Expand Up @@ -100,6 +107,9 @@ public void remove(String clientId) {
if (outgoingPublishClientLimits != null) {
outgoingPublishClientLimits.remove(clientId);
}
if (applicationPersistedMsgClientLimits != null) {
applicationPersistedMsgClientLimits.remove(clientId);
}
}
}

Expand Down Expand Up @@ -175,6 +185,21 @@ public boolean isDevicePersistedMsgsLimitEnabled() {
return devicePersistedMsgsRateLimitsConfiguration.isEnabled();
}

@Override
public boolean tryConsumeApplicationPersistedMsgs(String clientId) {
if (!applicationPersistedMsgsRateLimitsConfiguration.isEnabled()) {
return true;
}
TbRateLimits rateLimits = applicationPersistedMsgClientLimits.computeIfAbsent(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now the third copy of the per-client bucket idiom — checkIncomingLimits (line 77) and checkOutgoingLimits (line 93) both do the same map.computeIfAbsent(clientId, id -> new TbRateLimits(config.getClientConfig())).tryConsume(). Would it be worth extracting a small private helper like tryConsume(ConcurrentMap<String, TbRateLimits> map, String clientConfig, String clientId) and having the three call sites supply their own map/config? The surrounding logic differs (logging, the QoS-0 gate), so only the two-line core would move, but it keeps the bucket-creation contract in one place.

clientId, id -> new TbRateLimits(applicationPersistedMsgsRateLimitsConfiguration.getClientConfig()));
return rateLimits.tryConsume();
}

@Override
public boolean isApplicationPersistedMsgsRateLimitEnabled() {
return applicationPersistedMsgsRateLimitsConfiguration.isEnabled();
}

@Override
public long tryConsumeTotalMsgs(long limit) {
return rateLimitCacheService.tryConsumeTotalMsgs(limit);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import org.thingsboard.mqtt.broker.actors.client.messages.mqtt.MqttDisconnectMsg;
import org.thingsboard.mqtt.broker.actors.client.state.ClientActorStateInfo;
import org.thingsboard.mqtt.broker.adaptor.ProtoConverter;
import org.thingsboard.mqtt.broker.common.data.PersistedPacketType;
import org.thingsboard.mqtt.broker.common.data.mqtt.MsgExpiryResult;
import org.thingsboard.mqtt.broker.common.util.ThingsBoardExecutors;
import org.thingsboard.mqtt.broker.gen.queue.PublishMsgProto;
Expand All @@ -38,6 +39,7 @@
import org.thingsboard.mqtt.broker.queue.provider.ApplicationPersistenceMsgQueueFactory;
import org.thingsboard.mqtt.broker.service.analysis.ClientLogger;
import org.thingsboard.mqtt.broker.service.historical.stats.TbMessageStatsReportClient;
import org.thingsboard.mqtt.broker.service.limits.RateLimitService;
import org.thingsboard.mqtt.broker.service.mqtt.MqttMsgDeliveryService;
import org.thingsboard.mqtt.broker.service.mqtt.PublishMsg;
import org.thingsboard.mqtt.broker.service.mqtt.persistence.application.data.ApplicationMainProcessingState;
Expand Down Expand Up @@ -81,6 +83,7 @@
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
import java.util.stream.Collectors;

@Service
Expand Down Expand Up @@ -112,6 +115,7 @@ public class ApplicationPersistenceProcessorImpl implements ApplicationPersisten
private final ApplicationClientHelperService appClientHelperService;
private final AppMsgDeliveryStrategy appMsgDeliveryStrategy;
private final TbMessageStatsReportClient tbMessageStatsReportClient;
private final RateLimitService rateLimitService;
private final boolean isDebugEnabled = log.isDebugEnabled();

@Value("${queue.application-persisted-msg.poll-interval}")
Expand Down Expand Up @@ -453,6 +457,7 @@ private ApplicationPubRelMsgCtx processMainPack(ApplicationPubRelMsgCtx pubRelMs

List<PersistedMsg> messagesToDeliver = buildMessagesToDeliver(pubRelMsgCtx, clientSessionCtx, persistedMsgCtx, messages, null);
submitStrategy.init(messagesToDeliver);
throttleDelivery(clientId, messagesToDeliver, () -> isClientSessionActive(sessionId, clientState));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Message expiry is evaluated once in buildPublishMessagesToDeliver (against System.currentTimeMillis() at build time), but throttleDelivery can then hold the pack here for seconds — or much longer at low limits — before deliverMessages runs. A message that was still within its expiry window when the pack was built could cross the boundary during the throttle wait and then be delivered anyway. Is that acceptable for MQTT message-expiry semantics, or should expiry be re-checked after throttling / just before the send?


if (isDebugEnabled) {
log.debug("[{}] Starting main pack, {} messages to deliver", clientId, messagesToDeliver.size());
Expand Down Expand Up @@ -558,6 +563,7 @@ private ApplicationPubRelMsgCtx processSharedPack(ApplicationPubRelMsgCtx pubRel

List<PersistedMsg> messagesToDeliver = buildMessagesToDeliver(pubRelMsgCtx, clientSessionCtx, persistedMsgCtx, messages, subscription);
submitStrategy.init(messagesToDeliver);
throttleDelivery(clientId, messagesToDeliver, () -> isJobActive(job));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The four new unit tests exercise throttleDelivery directly, but nothing verifies the wiring — that processMainPack/processSharedPack actually invoke it before the retry loop with the right isActive supplier. The existing processMainPack tests run with the limit mocked disabled (so the gate is a no-op), and the shared path has no throttling test at all. A test that drives these methods with the limit enabled and asserts tryConsumeApplicationPersistedMsgs is called would lock in the placement — it's easy to move this call to the wrong spot and not notice.


if (log.isTraceEnabled()) {
log.trace("[{}] Starting shared subscription pack, {} messages to deliver", clientId, messagesToDeliver.size());
Expand Down Expand Up @@ -731,6 +737,46 @@ private void deliverMessages(ApplicationSubmitStrategy submitStrategy, ClientSes
appMsgDeliveryStrategy.process(submitStrategy, clientSessionCtx);
}

void throttleDelivery(String clientId, List<PersistedMsg> messagesToDeliver, BooleanSupplier isActive) {
if (!rateLimitService.isApplicationPersistedMsgsRateLimitEnabled()) {
return;
}
int remaining = countPublishMsgs(messagesToDeliver);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

throttleDelivery acquires tokens for the whole pack before deliverMessages runs, so the pack is still handed off as a single burst once enough tokens accrue — the loop paces token acquisition, not the actual sends. With e.g. 100:1,5000:60 and a large polled pack (main up to 200, shared up to 500), the client sees nothing for a few seconds and then receives the whole pack at once rather than a steady drip. The bucket does bound the long-run average, so this is a reasonable simplification — just want to confirm it's intentional given the PR describes the goal as replaying at a steady rate. Was interleaving the gate with per-message delivery considered?

boolean throttled = false;
while (remaining > 0) {
if (!isActive.getAsBoolean()) {
return;
}
if (rateLimitService.tryConsumeApplicationPersistedMsgs(clientId)) {
remaining--;
} else {
if (!throttled) {
throttled = true;
if (isDebugEnabled) {
log.debug("[{}] Outbound rate limit reached; pacing delivery of {} remaining message(s) in this pack", clientId, remaining);
}
}
// Reuse the Kafka poll interval as the throttle back-off granularity (intentionally no separate tunable).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reusing queue.application-persisted-msg.poll-interval as the back-off granularity couples the throttle to a value that's really tuned for Kafka polling. If someone later changes the poll interval for Kafka reasons, the throttle back-off silently changes with it (and vice versa). The comment shows it's deliberate, which helps — just flagging that the coupling isn't visible from the call sites, and a small dedicated default might age better.

try {
Thread.sleep(pollDuration);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
}

private static int countPublishMsgs(List<PersistedMsg> messagesToDeliver) {
int count = 0;
for (PersistedMsg msg : messagesToDeliver) {
if (PersistedPacketType.PUBLISH == msg.getPacketType()) {
count++;
}
}
return count;
}

private void processPubAckInSharedCtx(String clientId, int packetId, String format) {
Set<ApplicationSharedSubscriptionCtx> contexts = sharedPackProcessingCtxMap.get(clientId);
if (CollectionUtils.isEmpty(contexts)) {
Expand Down
13 changes: 13 additions & 0 deletions application/src/main/resources/thingsboard-mqtt-broker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,19 @@ mqtt:
# Limits the count of Device clients persisted messages per time interval (in s). Comma-separated list of limit:seconds pairs.
# Example: 100 messages per second or 1000 messages per minute.
config: "${MQTT_DEVICE_PERSISTED_MSGS_RATE_LIMITS_CONFIG:100:1,1000:60}"
application-persisted-messages:
# Enables or disables per-client outbound rate limits for persistent APPLICATION subscribers.
# Throttles (paces) delivery without dropping messages: the Kafka backlog is retained and
# replayed at the configured rate. Applies to the main and shared-subscription consumers.
enabled: "${MQTT_APPLICATION_PERSISTED_MSGS_RATE_LIMITS_ENABLED:false}"
# Limits the count of outgoing persisted messages per APPLICATION client per time interval (in s).
# Comma-separated list of limit:seconds pairs.
# Example: 100 messages per second or 5000 messages per minute.
# Note: for shared subscriptions this paces a shared Kafka consumer group. Avoid a rate so low that
# draining one polled pack (up to max.poll.records) takes longer than max.poll.interval.ms (default 300s),
# otherwise the shared-subscription consumer may be evicted and its group rebalanced. The per-client
# (non-shared) APPLICATION consumer uses manual partition assignment and is not affected.
client-config: "${MQTT_APPLICATION_PERSISTED_MSGS_RATE_LIMITS_CLIENT_CONFIG:100:1,5000:60}"
# Total limit of sessions (connected + disconnected) stored on the broker, applied collectively across the cluster, not per node.
# For example, when set to 1000, the entire cluster can store 1000 sessions in total. This is a soft limit, meaning slightly more sessions may be stored.
# Defaults to 0, which disables the limit.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import org.thingsboard.mqtt.broker.common.data.ClientType;
import org.thingsboard.mqtt.broker.common.data.SessionInfo;
import org.thingsboard.mqtt.broker.common.util.TbRateLimits;
import org.thingsboard.mqtt.broker.config.ApplicationPersistedMsgsRateLimitsConfiguration;
import org.thingsboard.mqtt.broker.config.ClientsLimitProperties;
import org.thingsboard.mqtt.broker.config.DevicePersistedMsgsRateLimitsConfiguration;
import org.thingsboard.mqtt.broker.config.IncomingRateLimitsConfiguration;
Expand All @@ -55,6 +56,8 @@ public class RateLimitServiceImplTest {
@MockitoBean
DevicePersistedMsgsRateLimitsConfiguration devicePersistedMsgsRateLimitsConfiguration;
@MockitoBean
ApplicationPersistedMsgsRateLimitsConfiguration applicationPersistedMsgsRateLimitsConfiguration;
@MockitoBean
TotalMsgsRateLimitsConfiguration totalMsgsRateLimitsConfiguration;
@MockitoBean
RateLimitCacheService rateLimitCacheService;
Expand All @@ -69,12 +72,14 @@ public void setUp() throws Exception {
when(incomingRateLimitsConfiguration.isEnabled()).thenReturn(true);
when(outgoingRateLimitsConfiguration.isEnabled()).thenReturn(true);
when(devicePersistedMsgsRateLimitsConfiguration.isEnabled()).thenReturn(true);
when(applicationPersistedMsgsRateLimitsConfiguration.isEnabled()).thenReturn(true);
when(totalMsgsRateLimitsConfiguration.isEnabled()).thenReturn(true);

rateLimitService.init();

rateLimitService.getIncomingPublishClientLimits().put(CLIENT_ID, new TbRateLimits("1:1")); // limit 1 per 1 second
rateLimitService.getOutgoingPublishClientLimits().put(CLIENT_ID, new TbRateLimits("1:1")); // limit 1 per 1 second
rateLimitService.getApplicationPersistedMsgClientLimits().put(CLIENT_ID, new TbRateLimits("1:1")); // limit 1 per 1 second
}

@After
Expand Down Expand Up @@ -157,13 +162,15 @@ public void givenOneClient_whenRemoveIt_thenSuccess() {
rateLimitService.remove(CLIENT_ID);
assertEquals(0, rateLimitService.getIncomingPublishClientLimits().size());
assertEquals(0, rateLimitService.getOutgoingPublishClientLimits().size());
assertEquals(0, rateLimitService.getApplicationPersistedMsgClientLimits().size());
}

@Test
public void givenOneClient_whenRemoveNull_thenSuccess() {
rateLimitService.remove(null);
assertEquals(1, rateLimitService.getIncomingPublishClientLimits().size());
assertEquals(1, rateLimitService.getOutgoingPublishClientLimits().size());
assertEquals(1, rateLimitService.getApplicationPersistedMsgClientLimits().size());
}

@Test
Expand Down Expand Up @@ -303,4 +310,38 @@ public void givenTokensAvailable_whenTryConsumeTotalMsgs_thenSuccess() {
long tokens = rateLimitService.tryConsumeTotalMsgs(10L);
assertEquals(10L, tokens);
}

@Test
public void givenAppPersistedMsgsRateLimitsDisabled_whenTryConsume_thenAlwaysTrue() {
when(applicationPersistedMsgsRateLimitsConfiguration.isEnabled()).thenReturn(false);

Assert.assertTrue(rateLimitService.tryConsumeApplicationPersistedMsgs(CLIENT_ID));
Assert.assertTrue(rateLimitService.tryConsumeApplicationPersistedMsgs(CLIENT_ID));
}

@Test
public void givenAppPersistedMsgsRateLimitsEnabled_whenTryConsume_thenGetExpectedResult() {
when(applicationPersistedMsgsRateLimitsConfiguration.isEnabled()).thenReturn(true);

Assert.assertTrue(rateLimitService.tryConsumeApplicationPersistedMsgs(CLIENT_ID));
Assert.assertFalse(rateLimitService.tryConsumeApplicationPersistedMsgs(CLIENT_ID));
Assert.assertFalse(rateLimitService.tryConsumeApplicationPersistedMsgs(CLIENT_ID));
}

@Test
public void givenTwoClients_whenTryConsume_thenBucketsAreIndependent() {
when(applicationPersistedMsgsRateLimitsConfiguration.isEnabled()).thenReturn(true);
rateLimitService.getApplicationPersistedMsgClientLimits().put("other", new TbRateLimits("1:1"));

Assert.assertTrue(rateLimitService.tryConsumeApplicationPersistedMsgs(CLIENT_ID));
Assert.assertTrue(rateLimitService.tryConsumeApplicationPersistedMsgs("other"));
Assert.assertFalse(rateLimitService.tryConsumeApplicationPersistedMsgs(CLIENT_ID));
Assert.assertFalse(rateLimitService.tryConsumeApplicationPersistedMsgs("other"));
}

@Test
public void givenEnabledConfig_whenIsApplicationPersistedMsgsRateLimitEnabled_thenTrue() {
when(applicationPersistedMsgsRateLimitsConfiguration.isEnabled()).thenReturn(true);
Assert.assertTrue(rateLimitService.isApplicationPersistedMsgsRateLimitEnabled());
}
}
Loading