15 changed files with 407 additions and 37 deletions
@ -0,0 +1,34 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.server.common.data.limit; |
|||
|
|||
public record LimitedApiEntry(long capacity, long durationSeconds) { |
|||
|
|||
public static LimitedApiEntry parse(String s) { |
|||
String[] parts = s.split(":"); |
|||
return new LimitedApiEntry(Long.parseLong(parts[0]), Long.parseLong(parts[1])); |
|||
} |
|||
|
|||
public double rps() { |
|||
return (double) capacity / durationSeconds; |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return capacity + ":" + durationSeconds; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,67 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.server.common.data.limit; |
|||
|
|||
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.Arrays; |
|||
import java.util.Collections; |
|||
import java.util.HashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.function.Function; |
|||
import java.util.stream.Collectors; |
|||
|
|||
public class LimitedApiUtil { |
|||
|
|||
public static List<LimitedApiEntry> parseConfig(String config) { |
|||
if (config == null || config.isEmpty()) { |
|||
return Collections.emptyList(); |
|||
} |
|||
return Arrays.stream(config.split(",")) |
|||
.map(LimitedApiEntry::parse) |
|||
.toList(); |
|||
} |
|||
|
|||
public static Function<DefaultTenantProfileConfiguration, String> merge( |
|||
Function<DefaultTenantProfileConfiguration, String> configExtractor1, |
|||
Function<DefaultTenantProfileConfiguration, String> configExtractor2) { |
|||
return config -> { |
|||
String config1 = configExtractor1.apply(config); |
|||
String config2 = configExtractor2.apply(config); |
|||
return LimitedApiUtil.mergeStrConfigs(config1, config2); // merges the configs
|
|||
}; |
|||
} |
|||
|
|||
private static String mergeStrConfigs(String firstConfig, String secondConfig) { |
|||
List<LimitedApiEntry> all = new ArrayList<>(); |
|||
all.addAll(parseConfig(firstConfig)); |
|||
all.addAll(parseConfig(secondConfig)); |
|||
|
|||
Map<Long, Long> merged = new HashMap<>(); |
|||
|
|||
for (LimitedApiEntry entry : all) { |
|||
merged.merge(entry.durationSeconds(), entry.capacity(), Long::sum); |
|||
} |
|||
|
|||
return merged.entrySet().stream() |
|||
.sorted(Map.Entry.comparingByKey()) // optional: sort by duration
|
|||
.map(e -> e.getValue() + ":" + e.getKey()) |
|||
.collect(Collectors.joining(",")); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,113 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.server.common.data.limit; |
|||
|
|||
import org.junit.jupiter.api.DisplayName; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; |
|||
|
|||
import java.util.List; |
|||
import java.util.function.Function; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
class LimitedApiUtilTest { |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil should parse single entry correctly") |
|||
void testParseSingleEntry() { |
|||
List<LimitedApiEntry> entries = LimitedApiUtil.parseConfig("100:60"); |
|||
|
|||
assertThat(entries).hasSize(1); |
|||
assertThat(entries.get(0).capacity()).isEqualTo(100); |
|||
assertThat(entries.get(0).durationSeconds()).isEqualTo(60); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil should parse multiple entries correctly") |
|||
void testParseMultipleEntries() { |
|||
List<LimitedApiEntry> entries = LimitedApiUtil.parseConfig("100:60,200:30"); |
|||
|
|||
assertThat(entries).hasSize(2); |
|||
assertThat(entries.get(0).capacity()).isEqualTo(100); |
|||
assertThat(entries.get(0).durationSeconds()).isEqualTo(60); |
|||
assertThat(entries.get(1).capacity()).isEqualTo(200); |
|||
assertThat(entries.get(1).durationSeconds()).isEqualTo(30); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil should return empty list for null or empty config") |
|||
void testParseEmptyConfig() { |
|||
assertThat(LimitedApiUtil.parseConfig(null)).isEmpty(); |
|||
assertThat(LimitedApiUtil.parseConfig("")).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil should merge two configs by summing capacities with same durations") |
|||
void testMergeStrConfigs() { |
|||
Function<DefaultTenantProfileConfiguration, String> extractor1 = cfg -> "100:60,50:30"; |
|||
Function<DefaultTenantProfileConfiguration, String> extractor2 = cfg -> "200:60,25:10"; |
|||
|
|||
// Fake config instance (not used directly in lambda logic)
|
|||
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); |
|||
|
|||
String result = LimitedApiUtil.merge(extractor1, extractor2).apply(config); |
|||
|
|||
// Should be: 300:60 (100+200), 50:30, 25:10
|
|||
assertThat(result).isEqualTo("25:10,50:30,300:60"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil should merge configs when one is empty") |
|||
void testMergeWithEmptyOne() { |
|||
Function<DefaultTenantProfileConfiguration, String> extractor1 = cfg -> "100:60"; |
|||
Function<DefaultTenantProfileConfiguration, String> extractor2 = cfg -> ""; |
|||
|
|||
// Fake config instance (not used directly in lambda logic)
|
|||
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); |
|||
String result = LimitedApiUtil.merge(extractor1, extractor2).apply(config); |
|||
|
|||
assertThat(result).isEqualTo("100:60"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil should merge configs when both have distinct durations") |
|||
void testMergeWithDistinctDurations() { |
|||
Function<DefaultTenantProfileConfiguration, String> extractor1 = cfg -> "100:60"; |
|||
Function<DefaultTenantProfileConfiguration, String> extractor2 = cfg -> "200:10"; |
|||
|
|||
// Fake config instance (not used directly in lambda logic)
|
|||
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); |
|||
String result = LimitedApiUtil.merge(extractor1, extractor2).apply(config); |
|||
|
|||
assertThat(result).isEqualTo("200:10,100:60"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil shouldn't have duplicate durations in the same config!") |
|||
void testMergeHandlesDuplicatesInSingleConfig() { |
|||
Function<DefaultTenantProfileConfiguration, String> extractor1 = cfg -> "100:60,200:60"; |
|||
Function<DefaultTenantProfileConfiguration, String> extractor2 = cfg -> ""; |
|||
|
|||
// Fake config instance (not used directly in lambda logic)
|
|||
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); |
|||
String result = LimitedApiUtil.merge(extractor1, extractor2).apply(config); |
|||
|
|||
// 100+200 = 300 for duration 60. Currently possible to save the same "per seconds" config from the UI.
|
|||
// This must be fixed, so we will merge only two different rate limits.
|
|||
assertThat(result).isEqualTo("300:60"); |
|||
} |
|||
} |
|||
@ -0,0 +1,63 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.server.common.msg.tools; |
|||
|
|||
import org.junit.jupiter.api.DisplayName; |
|||
import org.junit.jupiter.api.Test; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
class TbRateLimitsTest { |
|||
|
|||
@Test |
|||
@DisplayName("TbRateLimits should construct with single rate limit") |
|||
void testSingleLimitConstructor() { |
|||
TbRateLimits limits = new TbRateLimits("10:1", false); |
|||
assertThat(limits.getConfiguration()).isEqualTo("10:1"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("TbRateLimits should construct with multiple rate limits") |
|||
void testMultipleLimitConstructor() { |
|||
String config = "10:1,100:10"; |
|||
TbRateLimits limits = new TbRateLimits(config, false); |
|||
assertThat(limits.getConfiguration()).isEqualTo(config); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("TbRateLimits should throw IllegalArgumentException on empty string") |
|||
void testEmptyConfigThrows() { |
|||
assertThatThrownBy(() -> new TbRateLimits("", false)) |
|||
.isInstanceOf(IllegalArgumentException.class) |
|||
.hasMessage("Failed to parse rate limits configuration: "); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("TbRateLimits should throw NumberFormatException on malformed value") |
|||
void testMalformedConfigThrows() { |
|||
assertThatThrownBy(() -> new TbRateLimits("not_a_number:second", false)) |
|||
.isInstanceOf(NumberFormatException.class); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("TbRateLimits should throw ArrayIndexOutOfBoundsException on missing colon") |
|||
void testColonMissingThrows() { |
|||
assertThatThrownBy(() -> new TbRateLimits("100", false)) |
|||
.isInstanceOf(ArrayIndexOutOfBoundsException.class); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.server.dao.util; |
|||
|
|||
import lombok.Getter; |
|||
import org.apache.commons.lang3.StringUtils; |
|||
import org.thingsboard.server.common.data.limit.LimitedApi; |
|||
|
|||
@Getter |
|||
public enum BufferedRateExecutorType { |
|||
|
|||
READ(LimitedApi.CASSANDRA_READ_QUERIES_CORE, LimitedApi.CASSANDRA_READ_QUERIES_RULE_ENGINE, LimitedApi.CASSANDRA_READ_QUERIES_MONOLITH), |
|||
WRITE(LimitedApi.CASSANDRA_WRITE_QUERIES_CORE, LimitedApi.CASSANDRA_WRITE_QUERIES_RULE_ENGINE, LimitedApi.CASSANDRA_WRITE_QUERIES_MONOLITH); |
|||
|
|||
private final LimitedApi coreLimitedApi; |
|||
private final LimitedApi ruleEngineLimitedApi; |
|||
private final LimitedApi monolithLimitedApi; |
|||
|
|||
private final String displayName = StringUtils.capitalize(name().toLowerCase()); |
|||
|
|||
BufferedRateExecutorType(LimitedApi coreLimitedApi, LimitedApi ruleEngineLimitedApi, LimitedApi monolithLimitedApi) { |
|||
this.coreLimitedApi = coreLimitedApi; |
|||
this.ruleEngineLimitedApi = ruleEngineLimitedApi; |
|||
this.monolithLimitedApi = monolithLimitedApi; |
|||
} |
|||
|
|||
} |
|||
Loading…
Reference in new issue