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