diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java b/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java index 85f828973a..bf20a0841f 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java @@ -35,6 +35,7 @@ import java.util.concurrent.ConcurrentHashMap; public abstract class BaseApiUsageState { private final Map currentCycleValues = new ConcurrentHashMap<>(); private final Map currentHourValues = new ConcurrentHashMap<>(); + private final Map currentDayValues = new ConcurrentHashMap<>(); private final Map> lastGaugesByServiceId = new HashMap<>(); private final Map gaugesReportCycles = new HashMap<>(); @@ -48,6 +49,8 @@ public abstract class BaseApiUsageState { private volatile long nextCycleTs; @Getter private volatile long currentHourTs; + @Getter + private volatile long currentDayTs; @Setter private long gaugeReportInterval; @@ -57,30 +60,36 @@ public abstract class BaseApiUsageState { this.currentCycleTs = SchedulerUtils.getStartOfCurrentMonth(); this.nextCycleTs = SchedulerUtils.getStartOfNextMonth(); this.currentHourTs = SchedulerUtils.getStartOfCurrentHour(); + this.currentDayTs = SchedulerUtils.getStartOfCurrentDay(); } public StatsCalculationResult calculate(ApiUsageRecordKey key, long value, String serviceId) { long currentValue = get(key); long currentHourlyValue = getHourly(key); + long currentDailyValue = getDaily(key); StatsCalculationResult result; if (key.isCounter()) { result = StatsCalculationResult.builder() .newValue(currentValue + value).valueChanged(true) .newHourlyValue(currentHourlyValue + value).hourlyValueChanged(true) + .newDailyValue(currentDailyValue + value).dailyValueChanged(true) .build(); } else { Long newGaugeValue = calculateGauge(key, value, serviceId); long newValue = newGaugeValue != null ? newGaugeValue : currentValue; long newHourlyValue = newGaugeValue != null ? Math.max(newGaugeValue, currentHourlyValue) : currentHourlyValue; + long newDailyValue = newGaugeValue != null ? Math.max(newGaugeValue, currentDailyValue) : currentDailyValue; result = StatsCalculationResult.builder() .newValue(newValue).valueChanged(newValue != currentValue || !currentCycleValues.containsKey(key)) .newHourlyValue(newHourlyValue).hourlyValueChanged(newHourlyValue != currentHourlyValue || !currentHourValues.containsKey(key)) + .newDailyValue(newDailyValue).dailyValueChanged(newDailyValue != currentDailyValue || !currentDayValues.containsKey(key)) .build(); } set(key, result.getNewValue()); setHourly(key, result.getNewHourlyValue()); + setDaily(key, result.getNewDailyValue()); return result; } @@ -118,6 +127,14 @@ public abstract class BaseApiUsageState { return currentHourValues.getOrDefault(key, 0L); } + public void setDaily(ApiUsageRecordKey key, Long value) { + currentDayValues.put(key, value); + } + + public long getDaily(ApiUsageRecordKey key) { + return currentDayValues.getOrDefault(key, 0L); + } + public void setHour(long currentHourTs) { this.currentHourTs = currentHourTs; currentHourValues.clear(); @@ -125,6 +142,11 @@ public abstract class BaseApiUsageState { gaugesReportCycles.clear(); } + public void setDay(long currentDayTs) { + this.currentDayTs = currentDayTs; + currentDayValues.clear(); + } + public void setCycles(long currentCycleTs, long nextCycleTs) { this.currentCycleTs = currentCycleTs; this.nextCycleTs = nextCycleTs; @@ -207,6 +229,7 @@ public abstract class BaseApiUsageState { ", currentCycleTs=" + currentCycleTs + ", nextCycleTs=" + nextCycleTs + ", currentHourTs=" + currentHourTs + + ", currentDayTs=" + currentDayTs + '}'; } @@ -217,6 +240,8 @@ public abstract class BaseApiUsageState { private final boolean valueChanged; private final long newHourlyValue; private final boolean hourlyValueChanged; + private final long newDailyValue; + private final boolean dailyValueChanged; } } diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java index 0f16610c6f..3040ab0ac9 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java @@ -92,6 +92,7 @@ import java.util.stream.Collectors; public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService implements TbApiUsageStateService { public static final String HOURLY = "Hourly"; + public static final String DAILY = "Daily"; private final PartitionService partitionService; private final TenantService tenantService; @@ -199,8 +200,13 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService if (newHourTs != hourTs) { usageState.setHour(newHourTs); } + long dayTs = usageState.getCurrentDayTs(); + long newDayTs = SchedulerUtils.getStartOfCurrentDay(); + if (newDayTs != dayTs) { + usageState.setDay(newDayTs); + } if (log.isTraceEnabled()) { - log.trace("[{}][{}] Processing usage stats from {} (currentCycleTs={}, currentHourTs={}): {}", tenantId, ownerId, serviceId, ts, newHourTs, values); + log.trace("[{}][{}] Processing usage stats from {} (currentCycleTs={}, currentHourTs={}, currentDayTs={}): {}", tenantId, ownerId, serviceId, ts, newHourTs, newDayTs, values); } updatedEntries = new ArrayList<>(ApiUsageRecordKey.values().length); Set apiFeatures = new HashSet<>(); @@ -223,6 +229,10 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService long newHourlyValue = calculationResult.getNewHourlyValue(); updatedEntries.add(new BasicTsKvEntry(newHourTs, new LongDataEntry(recordKey.getApiCountKey() + HOURLY, newHourlyValue))); } + if (calculationResult.isDailyValueChanged()) { + long newDailyValue = calculationResult.getNewDailyValue(); + updatedEntries.add(new BasicTsKvEntry(newDayTs, new LongDataEntry(recordKey.getApiCountKey() + DAILY, newDailyValue))); + } if (recordKey.getApiFeature() != null) { apiFeatures.add(recordKey.getApiFeature()); } @@ -511,6 +521,7 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { boolean cycleEntryFound = false; boolean hourlyEntryFound = false; + boolean dailyEntryFound = false; for (TsKvEntry tsKvEntry : dbValues) { if (tsKvEntry.getKey().equals(key.getApiCountKey())) { cycleEntryFound = true; @@ -524,8 +535,11 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService } else if (tsKvEntry.getKey().equals(key.getApiCountKey() + HOURLY)) { hourlyEntryFound = true; state.setHourly(key, tsKvEntry.getTs() == state.getCurrentHourTs() ? tsKvEntry.getLongValue().get() : 0L); + } else if (tsKvEntry.getKey().equals(key.getApiCountKey() + DAILY)) { + dailyEntryFound = true; + state.setDaily(key, tsKvEntry.getTs() == state.getCurrentDayTs() ? tsKvEntry.getLongValue().get() : 0L); } - if (cycleEntryFound && hourlyEntryFound) { + if (cycleEntryFound && hourlyEntryFound && dailyEntryFound) { break; } } diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/TenantApiUsageState.java b/application/src/main/java/org/thingsboard/server/service/apiusage/TenantApiUsageState.java index 5ac5ed6600..e5e484b671 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/TenantApiUsageState.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/TenantApiUsageState.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import java.util.Arrays; @@ -32,6 +33,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.concurrent.TimeUnit; public class TenantApiUsageState extends BaseApiUsageState { @Getter @@ -63,21 +65,46 @@ public class TenantApiUsageState extends BaseApiUsageState { return tenantProfileData.getConfiguration().getWarnThreshold(key); } + /** + * Daily quota = monthlyQuota * peakDays / daysInBillingMonth. + * Returns 0 (unlimited) when the monthly quota is unlimited. + */ + public long getDailyThreshold(ApiUsageRecordKey key) { + long monthly = getProfileThreshold(key); + if (monthly == 0) return 0; + int peakDays = ((DefaultTenantProfileConfiguration) tenantProfileData.getConfiguration()).getDailyPeakDays(); + long monthMs = getNextCycleTs() - getCurrentCycleTs(); + if (monthMs <= 0) return monthly; + return Math.max(1L, monthly * peakDays * TimeUnit.DAYS.toMillis(1) / monthMs); + } + + public long getDailyWarnThreshold(ApiUsageRecordKey key) { + long daily = getDailyThreshold(key); + if (daily == 0) return 0; + double warnFraction = ((DefaultTenantProfileConfiguration) tenantProfileData.getConfiguration()).getWarnThreshold(); + return (long) (daily * (warnFraction > 0.0 ? warnFraction : 0.8)); + } + private Pair checkStateUpdatedDueToThreshold(ApiFeature feature) { ApiUsageStateValue featureValue = ApiUsageStateValue.ENABLED; for (ApiUsageRecordKey recordKey : ApiUsageRecordKey.getKeys(feature)) { long value = get(recordKey); + long dailyValue = getDaily(recordKey); boolean featureEnabled = getProfileFeatureEnabled(recordKey); ApiUsageStateValue tmpValue; if (featureEnabled) { long threshold = getProfileThreshold(recordKey); long warnThreshold = getProfileWarnThreshold(recordKey); - if (threshold == 0 || value == 0 || value < warnThreshold) { + long dailyThreshold = getDailyThreshold(recordKey); + long dailyWarnThreshold = getDailyWarnThreshold(recordKey); + if (threshold == 0) { tmpValue = ApiUsageStateValue.ENABLED; - } else if (value < threshold) { + } else if (value >= threshold || (dailyThreshold > 0 && dailyValue >= dailyThreshold)) { + tmpValue = ApiUsageStateValue.DISABLED; + } else if (value >= warnThreshold || (dailyThreshold > 0 && dailyValue >= dailyWarnThreshold)) { tmpValue = ApiUsageStateValue.WARNING; } else { - tmpValue = ApiUsageStateValue.DISABLED; + tmpValue = ApiUsageStateValue.ENABLED; } } else { tmpValue = ApiUsageStateValue.DISABLED; diff --git a/application/src/test/java/org/thingsboard/server/service/apiusage/TenantApiUsageStateDailyTest.java b/application/src/test/java/org/thingsboard/server/service/apiusage/TenantApiUsageStateDailyTest.java new file mode 100644 index 0000000000..9a29f1f33b --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/apiusage/TenantApiUsageStateDailyTest.java @@ -0,0 +1,136 @@ +/** + * 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.server.service.apiusage; + +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.ApiUsageStateId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.common.msg.tools.SchedulerUtils; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +public class TenantApiUsageStateDailyTest { + + private static final long MONTHLY = 10_000_000L; + private static final double WARN_FRACTION = 0.8; + private static final int PEAK_DAYS = 3; + + private TenantProfile tenantProfile; + private long currentCycleTs; + private long nextCycleTs; + + @Before + public void setUp() { + currentCycleTs = SchedulerUtils.getStartOfCurrentMonth(); + nextCycleTs = SchedulerUtils.getStartOfNextMonth(); + + DefaultTenantProfileConfiguration config = DefaultTenantProfileConfiguration.builder() + .maxTransportMessages(MONTHLY) + .warnThreshold(WARN_FRACTION) + .dailyPeakDays(PEAK_DAYS) + .build(); + TenantProfileData profileData = new TenantProfileData(); + profileData.setConfiguration(config); + tenantProfile = new TenantProfile(); + tenantProfile.setProfileData(profileData); + } + + private TenantApiUsageState createState() { + ApiUsageState apiUsageState = new ApiUsageState(new ApiUsageStateId(UUID.randomUUID())); + apiUsageState.setTenantId(TenantId.fromUUID(UUID.randomUUID())); + apiUsageState.setEntityId(TenantId.fromUUID(UUID.randomUUID())); + return new TenantApiUsageState(tenantProfile, apiUsageState); + } + + @Test + public void testDailyThresholdIsProportionalToMonthly() { + TenantApiUsageState state = createState(); + + long daily = state.getDailyThreshold(ApiUsageRecordKey.TRANSPORT_MSG_COUNT); + long monthMs = nextCycleTs - currentCycleTs; + long expected = MONTHLY * PEAK_DAYS * TimeUnit.DAYS.toMillis(1) / monthMs; + + assertThat(daily).isEqualTo(expected); + // Roughly 3/30 of monthly for a 30-day month + double ratio = (double) daily / MONTHLY; + assertThat(ratio).isCloseTo((double) PEAK_DAYS / 30, within(0.01)); + } + + @Test + public void testDailyThresholdIsLessThanMonthly() { + TenantApiUsageState state = createState(); + assertThat(state.getDailyThreshold(ApiUsageRecordKey.TRANSPORT_MSG_COUNT)) + .isLessThan(MONTHLY); + } + + @Test + public void testUnlimitedMonthlyGivesUnlimitedDaily() { + DefaultTenantProfileConfiguration config = DefaultTenantProfileConfiguration.builder() + .maxTransportMessages(0L) + .build(); + TenantProfileData profileData = new TenantProfileData(); + profileData.setConfiguration(config); + tenantProfile.setProfileData(profileData); + + TenantApiUsageState state = createState(); + assertThat(state.getDailyThreshold(ApiUsageRecordKey.TRANSPORT_MSG_COUNT)).isZero(); + } + + @Test + public void testDailyWarnThresholdAppliesWarnFraction() { + TenantApiUsageState state = createState(); + + long daily = state.getDailyThreshold(ApiUsageRecordKey.TRANSPORT_MSG_COUNT); + long dailyWarn = state.getDailyWarnThreshold(ApiUsageRecordKey.TRANSPORT_MSG_COUNT); + + assertThat((double) dailyWarn / daily).isCloseTo(WARN_FRACTION, within(0.001)); + } + + @Test + public void testMonthlyThresholdUnchanged() { + TenantApiUsageState state = createState(); + assertThat(state.getProfileThreshold(ApiUsageRecordKey.TRANSPORT_MSG_COUNT)).isEqualTo(MONTHLY); + } + + @Test + public void testDailyThresholdRespectsPeakDaysConfig() { + // peakDays=1 → daily ≈ 1/30 monthly; peakDays=6 → daily ≈ 6/30 monthly + for (int days : new int[]{1, 3, 6}) { + DefaultTenantProfileConfiguration config = DefaultTenantProfileConfiguration.builder() + .maxTransportMessages(MONTHLY) + .warnThreshold(WARN_FRACTION) + .dailyPeakDays(days) + .build(); + TenantProfileData profileData = new TenantProfileData(); + profileData.setConfiguration(config); + tenantProfile.setProfileData(profileData); + + TenantApiUsageState state = createState(); + double ratio = (double) state.getDailyThreshold(ApiUsageRecordKey.TRANSPORT_MSG_COUNT) / MONTHLY; + assertThat(ratio).isCloseTo((double) days / 30, within(0.01)); + } + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index a04816365b..539dac7fd3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -168,6 +168,12 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private double warnThreshold; + // Number of "peak days" used to compute the daily quota. + // Daily quota = monthlyQuota * dailyPeakDays / daysInBillingMonth. + // Default of 3 means a tenant may use up to 3× the daily fair-share per day. + @Builder.Default + private int dailyPeakDays = 3; + @Schema(example = "5") private long maxCalculatedFieldsPerEntity = 5; @Schema(example = "10") diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java index cdb1e71e7a..5c7de5c5bf 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java @@ -36,6 +36,14 @@ public class SchedulerUtils { return tzMap.computeIfAbsent(tz == null || tz.isEmpty() ? "UTC" : tz, ZoneId::of); } + public static long getStartOfCurrentDay() { + return getStartOfCurrentDay(UTC); + } + + public static long getStartOfCurrentDay(ZoneId zoneId) { + return LocalDate.now(UTC).atStartOfDay(zoneId).toInstant().toEpochMilli(); + } + public static long getStartOfCurrentHour() { return getStartOfCurrentHour(UTC); }