Fix PollingTime parsing to support comma in RSQL (#2791)

The PollingTime now supports all RSQL filters that doesn't contain '->'
For duration HH:mm:ss and ISO-8601 is supported
For deviation 0-99% are suppported (as before)

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-11-03 16:05:22 +02:00
committed by GitHub
parent 3ee042447c
commit 5751ed504c
4 changed files with 68 additions and 49 deletions

View File

@@ -28,24 +28,28 @@ public class ControllerPollProperties implements Serializable {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**
* Maximum polling time that can be configured system-wide and by tenant in HH:mm:ss notation. * Minimum polling time that can be configured by a tenant in HH:mm:ss or ISO-8601 notation.
*/
private String maxPollingTime = "23:59:59";
/**
* Minimum polling time that can be configured by a tenant in HH:mm:ss notation.
*/ */
private String minPollingTime = "00:00:30"; private String minPollingTime = "00:00:30";
/** /**
* Controller polling time that can be configured system-wide and by tenant in HH:mm:ss(~\d{1,2}%)? notation, plus * Maximum polling time that can be configured system-wide and by tenant in HH:mm:ss or ISO-8601 notation.
*/
private String maxPollingTime = "23:59:59";
/**
* Controller polling time that can be configured system-wide and by tenant in (HH:mm:ss|ISO-8601)(~\d{1,2}%)? notation, plus
* followed (optionally and ordered) by a comma separated @lt;QL filter@gt; -@gt; polling time that overrides the * followed (optionally and ordered) by a comma separated @lt;QL filter@gt; -@gt; polling time that overrides the
* default polling time for the targets that match the filter. * default polling time for the targets that match the filter.
* <p/>
* If the computed polling time returned by DDI to device (i.e. poling time + deviation) is not less then a day then it will be
* sent to device in ISO-8601 format. This may brake backward compatibility for devices expecting polling time in HH:mm:ss format.
* In order to prevent this, for legacy devices, keep the maxPollingTime less than a day.
*/ */
private String pollingTime = "00:05:00"; private String pollingTime = "00:05:00";
/** /**
* Controller polling overdue time that can be configured system-wide and by tenant in HH:mm:ss notation. * Controller polling overdue time that can be configured system-wide and by tenant in HH:mm:ss or ISO-8601 notation.
*/ */
private String pollingOverdueTime = "00:05:00"; private String pollingOverdueTime = "00:05:00";

View File

@@ -18,39 +18,36 @@ import lombok.NoArgsConstructor;
/** /**
* This class is a helper for converting a duration into a string and for the other way. The string is in the format expected * This class is a helper for converting a duration into a string and for the other way. The string is in the format expected
* in configuration and database - in {@link Duration} default format or in custom format like "01:00:00" or "01:01:50:50" * in configuration and database - in {@link Duration} default format or in custom format like "01:00:00" or standard ISO-8601.
* (starting with seconds, minutes, hours, days from the end).
*/ */
@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) @NoArgsConstructor(access = lombok.AccessLevel.PRIVATE)
public final class DurationHelper { public final class DurationHelper {
private static final DateTimeFormatter DURATION_FORMATER = DateTimeFormatter.ofPattern("HH:mm:ss"); private static final DateTimeFormatter DURATION_FORMATER = DateTimeFormatter.ofPattern("HH:mm:ss");
private static final long SECONDS_PER_DAY = 24 * 60 * 60L; // 24 hours * 60 minutes * 60 seconds
private static final Duration DAY = Duration.ofDays(1); private static final Duration DAY = Duration.ofDays(1);
/** /**
* Converts a Duration into a formatted String * Converts a Duration into a formatted String
* *
* @param duration duration, which will be converted into a formatted String * @param duration duration, which will be converted into a formatted String
* @return String in the duration format, specified as HH:mm:ss or d+:HH:mm:ss * @return String in the duration format, specified as HH:mm:ss (of possible, i.e. less than a day) or ISO-8601 format
*/ */
public static String toString(final Duration duration) { public static String toString(final Duration duration) {
if (duration == null) { if (duration == null) {
return null; return null;
} }
if (duration.compareTo(DAY) < 0) { // backward compatible HH:mm:ss if (duration.compareTo(DAY) < 0) { // backward compatible HH:mm:ss (if possible, could be sent to devices via DDI)
return LocalTime.ofSecondOfDay(duration.toSeconds()).format(DURATION_FORMATER); return LocalTime.ofSecondOfDay(duration.toSeconds()).format(DURATION_FORMATER);
} else { // custom format d+:HH:mm:ss } else { // ISO-8601
return duration.toDays() + ":" + LocalTime.ofSecondOfDay(duration.toSeconds() % SECONDS_PER_DAY).format(DURATION_FORMATER); return duration.toString();
} }
} }
/** /**
* Converts a formatted String into a Duration object. * Converts a formatted String into a Duration object.
* *
* @param durationStr String in {@link Duration} default format or in custom format like "01:00:00" or "01:01:50:50" * @param durationStr String in {@link Duration} default format or in custom format like "01:00:00" or standard ISO-8601
* (starting with seconds, minutes, hours, days from the end)
* @return duration as a {@link Duration} object * @return duration as a {@link Duration} object
* @throws DateTimeParseException when String is in wrong format * @throws DateTimeParseException when String is in wrong format
*/ */
@@ -76,12 +73,6 @@ public final class DurationHelper {
.ofHours(Long.parseLong(split[0])) .ofHours(Long.parseLong(split[0]))
.plusMinutes(Long.parseLong(split[1])) .plusMinutes(Long.parseLong(split[1]))
.plusSeconds(Long.parseLong(split[2])); .plusSeconds(Long.parseLong(split[2]));
} else if (split.length == 4) { // d:HH:mm:ss
return Duration
.ofDays(Long.parseLong(split[0]))
.plusHours(Long.parseLong(split[1]))
.plusMinutes(Long.parseLong(split[2]))
.plusSeconds(Long.parseLong(split[3]));
} else { } else {
throw new IllegalArgumentException("No more then 4 chunks (split by ':') are allowed in duration"); throw new IllegalArgumentException("No more then 4 chunks (split by ':') are allowed in duration");
} }

View File

@@ -10,9 +10,7 @@
package org.eclipse.hawkbit.tenancy.configuration; package org.eclipse.hawkbit.tenancy.configuration;
import java.time.Duration; import java.time.Duration;
import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Random; import java.util.Random;
@@ -25,31 +23,35 @@ import org.eclipse.hawkbit.repository.exception.TenantConfigurationValidatorExce
@Value @Value
public class PollingTime { public class PollingTime {
private static final Pattern OVERRIDE_PATTERN = Pattern.compile(
"\\s{0,5},\\s{0,5}(?<qlStr>[^,]*)\\s{0,5}->\\s{0,5}(?<pollInterval>" + PollingInterval.POLLING_INTERVALE_REGEX + ")\\s{0,5}");
PollingInterval pollingInterval; PollingInterval pollingInterval;
List<Override> overrides; List<Override> overrides;
@SuppressWarnings("java:S127")
public PollingTime(final String pollingTime) { public PollingTime(final String pollingTime) {
final int indexOfComma = pollingTime.indexOf(','); final int indexOfComma = pollingTime.indexOf(',');
if (indexOfComma == -1) { // no overrides if (indexOfComma == -1) { // no overrides
pollingInterval = new PollingInterval(pollingTime); pollingInterval = new PollingInterval(pollingTime);
overrides = Collections.emptyList(); overrides = List.of();
} else { } else {
// Extract the main polling interval and overrides // Extract the main polling interval and overrides
final String pollingIntervalStr = pollingTime.substring(0, indexOfComma); final String pollingIntervalStr = pollingTime.substring(0, indexOfComma);
pollingInterval = new PollingInterval(pollingIntervalStr); pollingInterval = new PollingInterval(pollingIntervalStr);
overrides = new ArrayList<>(); overrides = new ArrayList<>();
final String overridesStr = pollingTime.substring(indexOfComma).trim(); // with initial comma final String overridesStr = pollingTime.substring(indexOfComma + 1).trim();
final Matcher overridesMatcher = OVERRIDE_PATTERN.matcher(overridesStr); for (int start = 0; ; ) {
for (int start = 0; start < overridesStr.length(); start = overridesMatcher.end()) { final int separatorIndex = overridesStr.indexOf("->", start);
if (overridesMatcher.find(start)) { if (separatorIndex == -1) {
overrides.add(new Override( throw new TenantConfigurationValidatorException("Invalid pollingTime override: '" + overridesStr.substring(start) + "'");
overridesMatcher.group("qlStr").trim(),
new PollingInterval(overridesMatcher.group("pollInterval").trim())));
} else { } else {
throw new TenantConfigurationValidatorException("Invalid pollingTime overrides: " + overridesStr); final String ql = overridesStr.substring(start, separatorIndex).trim();
final int nextCommaIndex = overridesStr.indexOf(',', separatorIndex);
if (nextCommaIndex == -1) { // last override
overrides.add(new Override(ql, new PollingInterval(overridesStr.substring(separatorIndex + 2).trim())));
break;
} else {
overrides.add(new Override(ql, new PollingInterval(overridesStr.substring(separatorIndex + 2, nextCommaIndex).trim())));
start = nextCommaIndex + 1;
}
} }
} }
} }
@@ -61,7 +63,7 @@ public class PollingTime {
@SuppressWarnings("java:S1068") // used for random delay only, no need of secure random @SuppressWarnings("java:S1068") // used for random delay only, no need of secure random
private static final Random RANDOM = new Random(); private static final Random RANDOM = new Random();
public static final String POLLING_INTERVALE_REGEX = "\\s{0,5}(?<pollingInterval>\\d{2}:[0-5]\\d:[0-5]\\d)\\s{0,5}(~(?<deviationPercent>\\d{1,2})%)?\\s{0,5}"; private static final String POLLING_INTERVALE_REGEX = "(?<pollingInterval>[^~]+)(~(?<deviationPercent>\\d{1,2})%)?\\s{0,5}";
private static final Pattern POLLING_INTERVAL_PATTERN = Pattern.compile(POLLING_INTERVALE_REGEX); private static final Pattern POLLING_INTERVAL_PATTERN = Pattern.compile(POLLING_INTERVALE_REGEX);
Duration interval; Duration interval;
@@ -69,16 +71,17 @@ public class PollingTime {
public PollingInterval(final String pollingInterval) { public PollingInterval(final String pollingInterval) {
final Matcher matcher = POLLING_INTERVAL_PATTERN.matcher(pollingInterval); final Matcher matcher = POLLING_INTERVAL_PATTERN.matcher(pollingInterval);
if (matcher.matches()) { try {
try { if (matcher.matches()) {
this.interval = DurationHelper.fromString(matcher.group("pollingInterval")); interval = DurationHelper.fromString(matcher.group("pollingInterval").trim());
} catch (final DateTimeParseException ex) { deviationPercent = Optional.ofNullable(matcher.group("deviationPercent"))
throw new TenantConfigurationValidatorException( .map(String::trim).map(Integer::parseInt).orElse(0);
"The given configuration value is expected as a string in the format HH:mm:ss(~\\d{1,2})?."); } else {
throw new IllegalArgumentException();
} }
this.deviationPercent = Optional.ofNullable(matcher.group("deviationPercent")).map(Integer::parseInt).orElse(0); } catch (final Exception e) {
} else { throw new TenantConfigurationValidatorException(
throw new TenantConfigurationValidatorException("Invalid pollingInterval: " + pollingInterval); "Invalid pollingInterval: '" + pollingInterval + "', expecting: (HH:mm:ss|ISO-8601)(~\\d{1,2}%)?");
} }
} }

View File

@@ -41,6 +41,7 @@ class PollingTimeTest {
@Test @Test
void testComplexWithOverrides() { void testComplexWithOverrides() {
assertExpectedComplexWithOverrides("01:00:00~10%, group == 'eu' -> 00:02:00~15%, status != in_sync -> 00:05:00"); assertExpectedComplexWithOverrides("01:00:00~10%, group == 'eu' -> 00:02:00~15%, status != in_sync -> 00:05:00");
assertExpectedComplexWithOverrides("PT1H~10%, group == 'eu' -> PT2M~15%, status != in_sync -> PT5M");
} }
@Test @Test
@@ -48,16 +49,36 @@ class PollingTimeTest {
assertExpectedComplexWithOverrides("01:00:00~10%, group == 'eu' -> 00:02:00~15%, status != in_sync ->00:05:00"); assertExpectedComplexWithOverrides("01:00:00~10%, group == 'eu' -> 00:02:00~15%, status != in_sync ->00:05:00");
assertExpectedComplexWithOverrides(" 01:00:00~10%, group == 'eu' -> 00:02:00~15%, status != in_sync ->00:05:00 "); assertExpectedComplexWithOverrides(" 01:00:00~10%, group == 'eu' -> 00:02:00~15%, status != in_sync ->00:05:00 ");
assertExpectedComplexWithOverrides(" 01:00:00~10% , group == 'eu' -> 00:02:00 ~15%, status != in_sync ->00:05:00 "); assertExpectedComplexWithOverrides(" 01:00:00~10% , group == 'eu' -> 00:02:00 ~15%, status != in_sync ->00:05:00 ");
assertExpectedComplexWithOverrides("PT1H~10%, group == 'eu' -> PT2M~15%, status != in_sync ->PT5M");
assertExpectedComplexWithOverrides(" PT1H~10%, group == 'eu' -> PT2M~15%, status != in_sync ->PT5M");
assertExpectedComplexWithOverrides(" PT1H~10% , group == 'eu' -> PT2M~15%, status != in_sync ->PT5M");
}
@Test
void testComplexWithOverridesWithCommaInQl() {
assertExpectedComplexWithOverrides(
"01:00:00~10%, group == 'eu' -> 00:02:00~15%, status =in= (in_sync,pending) -> 00:05:00",
"group == 'eu'", "status =in= (in_sync,pending)");
assertExpectedComplexWithOverrides(
"PT1H~10%, group == 'eu' -> PT2M~15%, status =in= (in_sync,pending) -> PT5M",
"group == 'eu'", "status =in= (in_sync,pending)");
assertExpectedComplexWithOverrides(
"01:00:00~10%, group == 'eu' -> 00:02:00~15%, status =in= (in_sync,pending) -> 00:05:00, region == us -> 00:10:00",
"group == 'eu'", "status =in= (in_sync,pending)", "region == us");
} }
private static void assertExpectedComplexWithOverrides(final String pollingTimeStr) { private static void assertExpectedComplexWithOverrides(final String pollingTimeStr) {
assertExpectedComplexWithOverrides(pollingTimeStr, "group == 'eu'", "status != in_sync");
}
private static void assertExpectedComplexWithOverrides(final String pollingTimeStr, final String... expectedQls) {
final PollingTime pollingTime = new PollingTime(pollingTimeStr); final PollingTime pollingTime = new PollingTime(pollingTimeStr);
assertThat(pollingTime.getPollingInterval().getInterval()).hasToString("PT1H"); assertThat(pollingTime.getPollingInterval().getInterval()).hasToString("PT1H");
assertThat(pollingTime.getPollingInterval().getDeviationPercent()).isEqualTo(10); assertThat(pollingTime.getPollingInterval().getDeviationPercent()).isEqualTo(10);
assertThat(pollingTime.getOverrides().get(0).qlStr()).isEqualTo("group == 'eu'"); assertThat(pollingTime.getOverrides().get(0).qlStr()).isEqualTo(expectedQls[0]);
assertThat(pollingTime.getOverrides().get(0).pollingInterval().getInterval()).hasToString("PT2M"); assertThat(pollingTime.getOverrides().get(0).pollingInterval().getInterval()).hasToString("PT2M");
assertThat(pollingTime.getOverrides().get(0).pollingInterval().getDeviationPercent()).isEqualTo(15); assertThat(pollingTime.getOverrides().get(0).pollingInterval().getDeviationPercent()).isEqualTo(15);
assertThat(pollingTime.getOverrides().get(1).qlStr()).isEqualTo("status != in_sync"); assertThat(pollingTime.getOverrides().get(1).qlStr()).isEqualTo(expectedQls[1]);
assertThat(pollingTime.getOverrides().get(1).pollingInterval().getInterval()).hasToString("PT5M"); assertThat(pollingTime.getOverrides().get(1).pollingInterval().getInterval()).hasToString("PT5M");
assertThat(pollingTime.getOverrides().get(1).pollingInterval().getDeviationPercent()).isZero(); assertThat(pollingTime.getOverrides().get(1).pollingInterval().getDeviationPercent()).isZero();
} }