diff --git a/sdk-actors/src/main/java/io/dapr/actors/runtime/AbstractActor.java b/sdk-actors/src/main/java/io/dapr/actors/runtime/AbstractActor.java index 1fe0deda42..11e1a941ec 100644 --- a/sdk-actors/src/main/java/io/dapr/actors/runtime/AbstractActor.java +++ b/sdk-actors/src/main/java/io/dapr/actors/runtime/AbstractActor.java @@ -118,9 +118,41 @@ protected Mono registerReminder( T state, Duration dueTime, Duration period) { + return this.registerReminder( + reminderName, + state, + ActorReminderOptions.builder().setDueTime(dueTime).setPeriod(period).build()); + } + + /** + * Registers a reminder for this Actor. + * + *

Unlike {@link #registerReminder(String, Object, Duration, Duration)}, every option is optional and the + * reminder can be limited by a number of repetitions and/or a TTL.

+ * + * @param reminderName Name of the reminder. + * @param state State to be send along with reminder triggers. + * @param options Schedule of the reminder (due time, period, repetitions and TTL). + * @param Type of the state object. + * @return Asynchronous void response. + */ + protected Mono registerReminder( + String reminderName, + T state, + ActorReminderOptions options) { + if (options == null) { + throw new IllegalArgumentException("Reminder options are required."); + } + try { byte[] data = this.actorRuntimeContext.getObjectSerializer().serialize(state); - ActorReminderParams params = new ActorReminderParams(data, dueTime, period); + ActorReminderParams params = new ActorReminderParams( + data, + options.getDueTime(), + options.getPeriod(), + options.getRepetitions(), + options.getTtl(), + null); return this.actorRuntimeContext.getDaprClient().registerReminder( this.actorRuntimeContext.getActorTypeInformation().getName(), this.id.toString(), @@ -151,6 +183,31 @@ protected Mono registerActorTimer( T state, Duration dueTime, Duration period) { + return this.registerActorTimer( + timerName, + callback, + state, + ActorTimerOptions.builder().setDueTime(dueTime).setPeriod(period).build()); + } + + /** + * Registers a Timer for the actor. A timer name is autogenerated by the runtime to keep track of it. + * + *

Unlike {@link #registerActorTimer(String, String, Object, Duration, Duration)}, every option is optional and + * the timer can be limited by a number of repetitions and/or a TTL.

+ * + * @param timerName Name of the timer, unique per Actor (auto-generated if null). + * @param callback Name of the method to be called. + * @param state State to be passed it to the method when timer triggers. + * @param options Schedule of the timer (due time, period, repetitions and TTL). + * @param Type for the state to be passed in to timer. + * @return Asynchronous result with timer's name. + */ + protected Mono registerActorTimer( + String timerName, + String callback, + T state, + ActorTimerOptions options) { try { if ((callback == null) || callback.isEmpty()) { throw new IllegalArgumentException("Timer requires a callback function."); @@ -161,8 +218,18 @@ protected Mono registerActorTimer( name = String.format("%s_Timer_%s", this.id.toString(), UUID.randomUUID().toString()); } + if (options == null) { + throw new IllegalArgumentException("Timer options are required."); + } + byte[] data = this.actorRuntimeContext.getObjectSerializer().serialize(state); - ActorTimerParams actorTimer = new ActorTimerParams(callback, data, dueTime, period); + ActorTimerParams actorTimer = new ActorTimerParams( + callback, + data, + options.getDueTime(), + options.getPeriod(), + options.getRepetitions(), + options.getTtl()); return this.actorRuntimeContext.getDaprClient().registerTimer( this.actorRuntimeContext.getActorTypeInformation().getName(), diff --git a/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorObjectSerializer.java b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorObjectSerializer.java index a9e517fa8f..cf78ea6810 100644 --- a/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorObjectSerializer.java +++ b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorObjectSerializer.java @@ -83,8 +83,7 @@ private byte[] serialize(ActorTimerParams timer) throws IOException { try (ByteArrayOutputStream writer = new ByteArrayOutputStream()) { JsonGenerator generator = JSON_FACTORY.createGenerator(writer); generator.writeStartObject(); - generator.writeStringField("dueTime", DurationUtils.convertDurationToDaprFormat(timer.getDueTime())); - generator.writeStringField("period", DurationUtils.convertDurationToDaprFormat(timer.getPeriod())); + writeSchedule(generator, timer.getDueTime(), timer.getPeriod(), timer.getRepetitions(), timer.getTtl()); generator.writeStringField("callback", timer.getCallback()); if (timer.getData() != null) { generator.writeBinaryField("data", timer.getData()); @@ -108,8 +107,8 @@ private byte[] serialize(ActorReminderParams reminder) throws IOException { JsonGenerator generator = JSON_FACTORY.createGenerator(writer); generator.setCodec(OBJECT_MAPPER); generator.writeStartObject(); - generator.writeStringField("dueTime", DurationUtils.convertDurationToDaprFormat(reminder.getDueTime())); - generator.writeStringField("period", DurationUtils.convertDurationToDaprFormat(reminder.getPeriod())); + writeSchedule( + generator, reminder.getDueTime(), reminder.getPeriod(), reminder.getRepetitions(), reminder.getTtl()); if (reminder.getData() != null) { generator.writeBinaryField("data", reminder.getData()); } @@ -236,9 +235,11 @@ private ActorTimerParams deserializeActorTimer(byte[] value) throws IOException String callback = node.get("callback").asText(); Duration dueTime = extractDurationOrNull(node, "dueTime"); Duration period = extractDurationOrNull(node, "period"); + Integer repetitions = extractRepetitionsOrNull(node); + Duration ttl = extractDurationOrNull(node, "ttl"); byte[] data = node.get("data") != null ? node.get("data").binaryValue() : null; - return new ActorTimerParams(callback, data, dueTime, period); + return new ActorTimerParams(callback, data, dueTime, period, repetitions, ttl); } /** @@ -256,6 +257,8 @@ private ActorReminderParams deserializeActorReminder(byte[] value) throws IOExce JsonNode node = OBJECT_MAPPER.readTree(value); Duration dueTime = extractDurationOrNull(node, "dueTime"); Duration period = extractDurationOrNull(node, "period"); + Integer repetitions = extractRepetitionsOrNull(node); + Duration ttl = extractDurationOrNull(node, "ttl"); byte[] data = node.get("data") != null ? node.get("data").binaryValue() : null; // Handle failure policy if present @@ -266,7 +269,7 @@ private ActorReminderParams deserializeActorReminder(byte[] value) throws IOExce } - return new ActorReminderParams(data, dueTime, period, failurePolicy); + return new ActorReminderParams(data, dueTime, period, repetitions, ttl, failurePolicy); } /** @@ -284,4 +287,46 @@ private static Duration extractDurationOrNull(JsonNode node, String name) { return DurationUtils.convertDurationFromDaprFormat(valueNode.asText()); } + + /** + * Extracts the repetitions from the period (e.g. R5/PT10S) or null. + * + * @param node Node that contains the period attribute. + * @return Repetitions or null. + */ + private static Integer extractRepetitionsOrNull(JsonNode node) { + JsonNode valueNode = node.get("period"); + if (valueNode == null) { + return null; + } + + return ActorScheduleUtils.parseRepetitions(valueNode.asText()); + } + + /** + * Writes the schedule fields of a timer or reminder, omitting the ones that are not set. + * + * @param generator JSON generator. + * @param dueTime Due time or null. + * @param period Period or null. + * @param repetitions Repetitions or null. + * @param ttl TTL or null. + * @throws IOException If cannot generate JSON. + */ + private static void writeSchedule( + JsonGenerator generator, + Duration dueTime, + Duration period, + Integer repetitions, + Duration ttl) throws IOException { + if (dueTime != null) { + generator.writeStringField("dueTime", ActorScheduleUtils.formatDuration(dueTime)); + } + if (period != null) { + generator.writeStringField("period", ActorScheduleUtils.formatPeriod(period, repetitions)); + } + if (ttl != null) { + generator.writeStringField("ttl", ActorScheduleUtils.formatDuration(ttl)); + } + } } diff --git a/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorReminderOptions.java b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorReminderOptions.java new file mode 100644 index 0000000000..c88797fda1 --- /dev/null +++ b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorReminderOptions.java @@ -0,0 +1,156 @@ +/* + * Copyright 2026 The Dapr 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 io.dapr.actors.runtime; + +import java.time.Duration; + +/** + * Options to register an actor reminder. + * + *

All fields are optional: without a due time the reminder fires immediately, without a period it fires only + * once, without repetitions it fires until unregistered (or until the TTL expires) and without a TTL it never + * expires. A TTL requires a positive period.

+ */ +public final class ActorReminderOptions { + + private final Duration dueTime; + + private final Duration period; + + private final Integer repetitions; + + private final Duration ttl; + + private ActorReminderOptions(Builder builder) { + this.dueTime = builder.dueTime; + this.period = builder.period; + this.repetitions = builder.repetitions; + this.ttl = builder.ttl; + } + + /** + * Creates a new builder. + * + * @return Builder for reminder options. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Gets the time the reminder is due for the 1st time. + * + * @return Due time or null. + */ + public Duration getDueTime() { + return dueTime; + } + + /** + * Gets the interval between triggers. + * + * @return Period or null. + */ + public Duration getPeriod() { + return period; + } + + /** + * Gets the number of times the reminder is triggered. + * + * @return Repetitions or null. + */ + public Integer getRepetitions() { + return repetitions; + } + + /** + * Gets the time after which the reminder expires. + * + * @return TTL or null. + */ + public Duration getTtl() { + return ttl; + } + + /** + * Builder for {@link ActorReminderOptions}. + */ + public static final class Builder { + + private Duration dueTime; + + private Duration period; + + private Integer repetitions; + + private Duration ttl; + + private Builder() { + } + + /** + * Sets the time the reminder is due for the 1st time. + * + * @param dueTime Due time. + * @return This builder. + */ + public Builder setDueTime(Duration dueTime) { + this.dueTime = dueTime; + return this; + } + + /** + * Sets the interval between triggers. + * + * @param period Period. + * @return This builder. + */ + public Builder setPeriod(Duration period) { + this.period = period; + return this; + } + + /** + * Sets the number of times the reminder is triggered, requires a period in whole seconds. + * + * @param repetitions Repetitions. + * @return This builder. + */ + public Builder setRepetitions(Integer repetitions) { + this.repetitions = repetitions; + return this; + } + + /** + * Sets the time after which the reminder expires, requires a positive period. + * + * @param ttl TTL. + * @return This builder. + */ + public Builder setTtl(Duration ttl) { + this.ttl = ttl; + return this; + } + + /** + * Builds the reminder options. + * + * @return Reminder options. + */ + public ActorReminderOptions build() { + return new ActorReminderOptions(this); + } + } +} diff --git a/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorReminderParams.java b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorReminderParams.java index 20c2555452..0c2f394d86 100644 --- a/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorReminderParams.java +++ b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorReminderParams.java @@ -42,10 +42,20 @@ final class ActorReminderParams { */ private final Duration period; + /** + * Number of times the reminder is triggered, null means unlimited. + */ + private final Integer repetitions; + + /** + * Time after which the reminder expires, null means it never expires. + */ + private final Duration ttl; + /** * Failure Policy. */ - private FailurePolicy failurePolicy; + private final FailurePolicy failurePolicy; /** * Instantiates a new instance for the params of a reminder. @@ -55,11 +65,7 @@ final class ActorReminderParams { * @param period Interval between triggers. */ ActorReminderParams(byte[] data, Duration dueTime, Duration period) { - validateDueTime("DueTime", dueTime); - validatePeriod("Period", period); - this.data = data; - this.dueTime = dueTime; - this.period = period; + this(data, dueTime, period, null, null, null); } /** @@ -70,7 +76,34 @@ final class ActorReminderParams { * @param period Interval between triggers. */ ActorReminderParams(byte[] data, Duration dueTime, Duration period, FailurePolicy failurePolicy) { - this(data, dueTime, period); + this(data, dueTime, period, null, null, failurePolicy); + } + + /** + * Instantiates a new instance for the params of a reminder. + * + * @param data Data to be passed in as part of the reminder trigger. + * @param dueTime Time the reminder is due for the 1st time, null means immediately. + * @param period Interval between triggers, null means it fires only once. + * @param repetitions Number of times the reminder is triggered, null means unlimited. + * @param ttl Time after which the reminder expires, null means it never expires. + * @param failurePolicy Failure policy, can be null. + */ + ActorReminderParams( + byte[] data, + Duration dueTime, + Duration period, + Integer repetitions, + Duration ttl, + FailurePolicy failurePolicy) { + validateDueTime("DueTime", dueTime); + validatePeriod("Period", period); + ActorScheduleUtils.validate(period, repetitions, ttl); + this.data = data; + this.dueTime = dueTime; + this.period = period; + this.repetitions = repetitions; + this.ttl = ttl; this.failurePolicy = failurePolicy; } @@ -92,6 +125,24 @@ Duration getPeriod() { return period; } + /** + * Gets the number of times the reminder is triggered. + * + * @return Repetitions, null means unlimited. + */ + Integer getRepetitions() { + return repetitions; + } + + /** + * Gets the time after which the reminder expires. + * + * @return TTL, null means it never expires. + */ + Duration getTtl() { + return ttl; + } + /** * Gets the data to be passed in as part of the reminder trigger. * @@ -108,7 +159,7 @@ byte[] getData() { * @param value Vale being checked. */ private static void validateDueTime(String argName, Duration value) { - if (value.compareTo(Duration.ZERO) < 0) { + if (value != null && value.compareTo(Duration.ZERO) < 0) { String message = String.format( "argName: %s - Duration toMillis() - specified value must be greater than %s", argName, Duration.ZERO); throw new IllegalArgumentException(message); @@ -122,7 +173,7 @@ private static void validateDueTime(String argName, Duration value) { * @param value Vale being checked. */ private static void validatePeriod(String argName, Duration value) throws IllegalArgumentException { - if (value.compareTo(MIN_TIME_PERIOD) < 0) { + if (value != null && value.compareTo(MIN_TIME_PERIOD) < 0) { String message = String.format( "argName: %s - Duration toMillis() - specified value must be greater than %s", argName, MIN_TIME_PERIOD); throw new IllegalArgumentException(message); diff --git a/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorScheduleUtils.java b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorScheduleUtils.java new file mode 100644 index 0000000000..f68002ebc8 --- /dev/null +++ b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorScheduleUtils.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026 The Dapr 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 io.dapr.actors.runtime; + +import io.dapr.utils.DurationUtils; + +import java.time.Duration; + +/** + * Helpers to validate and format the schedule (due time, period, repetitions and TTL) of timers and reminders. + */ +final class ActorScheduleUtils { + + private ActorScheduleUtils() { + } + + /** + * Validates the repetitions and TTL of a timer or reminder, throws {@link IllegalArgumentException}. + * + * @param period Interval between triggers. + * @param repetitions Number of times to trigger, or null for unlimited. + * @param ttl Time after which the timer or reminder expires, or null for no expiration. + */ + static void validate(Duration period, Integer repetitions, Duration ttl) { + if (repetitions != null) { + if (repetitions <= 0) { + throw new IllegalArgumentException("repetitions must be greater than zero"); + } + if (period == null || period.isZero() || period.isNegative()) { + throw new IllegalArgumentException("repetitions requires a positive period"); + } + if (period.getNano() != 0) { + throw new IllegalArgumentException("period must be in whole seconds when repetitions are set"); + } + } + if (ttl != null) { + if (ttl.isNegative()) { + throw new IllegalArgumentException("ttl must not be negative"); + } + // The Dapr scheduler rejects a TTL for jobs without a schedule. + if (period == null || period.isZero() || period.isNegative()) { + throw new IllegalArgumentException("ttl requires a positive period"); + } + } + } + + /** + * Formats a due time for the Dapr runtime. An unset due time is sent as zero so the timer or reminder fires + * right away; an empty value would make the Dapr scheduler wait one period before the first trigger. + * + * @param dueTime Duration or null. + * @return Formatted value. + */ + static String formatDueTime(Duration dueTime) { + return DurationUtils.convertDurationToDaprFormat(dueTime == null ? Duration.ZERO : dueTime); + } + + /** + * Formats a TTL or period for the Dapr runtime, an empty string means not set. + * + * @param value Duration or null. + * @return Formatted value. + */ + static String formatDuration(Duration value) { + return value == null ? "" : DurationUtils.convertDurationToDaprFormat(value); + } + + /** + * Formats a period for the Dapr runtime, using the ISO 8601 format (e.g. R5/PT10S) when repetitions are set. + * + * @param period Interval between triggers or null. + * @param repetitions Number of times to trigger, or null for unlimited. + * @return Formatted value. + */ + static String formatPeriod(Duration period, Integer repetitions) { + if (repetitions != null) { + return "R" + repetitions + "/" + DurationUtils.convertDurationToIso8601Format(period); + } + return formatDuration(period); + } + + /** + * Extracts the repetitions from a period in the ISO 8601 format (e.g. R5/PT10S). + * + * @param period Period as sent by the Dapr runtime. + * @return Repetitions or null if not set. + */ + static Integer parseRepetitions(String period) { + if (period == null || !period.startsWith("R")) { + return null; + } + int separator = period.indexOf('/'); + String value = separator == -1 ? period.substring(1) : period.substring(1, separator); + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorTimerOptions.java b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorTimerOptions.java new file mode 100644 index 0000000000..689a4b5e33 --- /dev/null +++ b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorTimerOptions.java @@ -0,0 +1,156 @@ +/* + * Copyright 2026 The Dapr 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 io.dapr.actors.runtime; + +import java.time.Duration; + +/** + * Options to register an actor timer. + * + *

All fields are optional: without a due time the timer fires immediately, without a period it fires only + * once, without repetitions it fires until unregistered (or until the TTL expires) and without a TTL it never + * expires. A TTL requires a positive period.

+ */ +public final class ActorTimerOptions { + + private final Duration dueTime; + + private final Duration period; + + private final Integer repetitions; + + private final Duration ttl; + + private ActorTimerOptions(Builder builder) { + this.dueTime = builder.dueTime; + this.period = builder.period; + this.repetitions = builder.repetitions; + this.ttl = builder.ttl; + } + + /** + * Creates a new builder. + * + * @return Builder for timer options. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Gets the time the timer is due for the 1st time. + * + * @return Due time or null. + */ + public Duration getDueTime() { + return dueTime; + } + + /** + * Gets the interval between triggers. + * + * @return Period or null. + */ + public Duration getPeriod() { + return period; + } + + /** + * Gets the number of times the timer is triggered. + * + * @return Repetitions or null. + */ + public Integer getRepetitions() { + return repetitions; + } + + /** + * Gets the time after which the timer expires. + * + * @return TTL or null. + */ + public Duration getTtl() { + return ttl; + } + + /** + * Builder for {@link ActorTimerOptions}. + */ + public static final class Builder { + + private Duration dueTime; + + private Duration period; + + private Integer repetitions; + + private Duration ttl; + + private Builder() { + } + + /** + * Sets the time the timer is due for the 1st time. + * + * @param dueTime Due time. + * @return This builder. + */ + public Builder setDueTime(Duration dueTime) { + this.dueTime = dueTime; + return this; + } + + /** + * Sets the interval between triggers. + * + * @param period Period. + * @return This builder. + */ + public Builder setPeriod(Duration period) { + this.period = period; + return this; + } + + /** + * Sets the number of times the timer is triggered, requires a period in whole seconds. + * + * @param repetitions Repetitions. + * @return This builder. + */ + public Builder setRepetitions(Integer repetitions) { + this.repetitions = repetitions; + return this; + } + + /** + * Sets the time after which the timer expires, requires a positive period. + * + * @param ttl TTL. + * @return This builder. + */ + public Builder setTtl(Duration ttl) { + this.ttl = ttl; + return this; + } + + /** + * Builds the timer options. + * + * @return Reminder options. + */ + public ActorTimerOptions build() { + return new ActorTimerOptions(this); + } + } +} diff --git a/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorTimerParams.java b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorTimerParams.java index a3d1bb755a..903677c1a3 100644 --- a/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorTimerParams.java +++ b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorTimerParams.java @@ -41,6 +41,16 @@ final class ActorTimerParams { */ private Duration period; + /** + * Number of times the timer is triggered, null means unlimited. + */ + private Integer repetitions; + + /** + * Time after which the timer expires, null means it never expires. + */ + private Duration ttl; + /** * Instantiates a new Actor Timer. * @@ -53,10 +63,32 @@ final class ActorTimerParams { byte[] data, Duration dueTime, Duration period) { + this(callback, data, dueTime, period, null, null); + } + + /** + * Instantiates a new Actor Timer. + * + * @param callback The name of the method to be called for this timer. + * @param data The state to be used by the callback method + * @param dueTime The time when timer is first due, null means immediately. + * @param period The periodic time when timer will be invoked, null means it fires only once. + * @param repetitions Number of times the timer is triggered, null means unlimited. + * @param ttl Time after which the timer expires, null means it never expires. + */ + ActorTimerParams(String callback, + byte[] data, + Duration dueTime, + Duration period, + Integer repetitions, + Duration ttl) { + ActorScheduleUtils.validate(period, repetitions, ttl); this.callback = callback; this.data = data; this.dueTime = dueTime; this.period = period; + this.repetitions = repetitions; + this.ttl = ttl; } /** @@ -86,6 +118,24 @@ public Duration getPeriod() { return this.period; } + /** + * Gets the number of times the timer is triggered. + * + * @return Repetitions, null means unlimited. + */ + public Integer getRepetitions() { + return this.repetitions; + } + + /** + * Gets the time after which the timer expires. + * + * @return TTL, null means it never expires. + */ + public Duration getTtl() { + return this.ttl; + } + /** * Gets state containing information to be used by the callback method, or null. * diff --git a/sdk-actors/src/main/java/io/dapr/actors/runtime/DaprClientImpl.java b/sdk-actors/src/main/java/io/dapr/actors/runtime/DaprClientImpl.java index 9b8f1d3f8c..bbe6a0e80a 100644 --- a/sdk-actors/src/main/java/io/dapr/actors/runtime/DaprClientImpl.java +++ b/sdk-actors/src/main/java/io/dapr/actors/runtime/DaprClientImpl.java @@ -19,7 +19,6 @@ import com.google.protobuf.Empty; import io.dapr.config.Properties; import io.dapr.exceptions.DaprException; -import io.dapr.utils.DurationUtils; import io.dapr.v1.DaprActorsProtos; import io.dapr.v1.DaprGrpc; import io.grpc.ManagedChannel; @@ -154,8 +153,10 @@ public Mono registerReminder( .setActorId(actorId) .setName(reminderName) .setData(ByteString.copyFrom(reminderParams.getData())) - .setDueTime(DurationUtils.convertDurationToDaprFormat(reminderParams.getDueTime())) - .setPeriod(DurationUtils.convertDurationToDaprFormat(reminderParams.getPeriod())); + .setDueTime(ActorScheduleUtils.formatDueTime(reminderParams.getDueTime())) + .setPeriod(ActorScheduleUtils.formatPeriod( + reminderParams.getPeriod(), reminderParams.getRepetitions())) + .setTtl(ActorScheduleUtils.formatDuration(reminderParams.getTtl())); if (reminderParams.getFailurePolicy() != null) { builder.setFailurePolicy(getJobFailurePolicy(reminderParams.getFailurePolicy())); @@ -196,8 +197,9 @@ public Mono registerTimer( .setName(timerName) .setCallback(timerParams.getCallback()) .setData(ByteString.copyFrom(timerParams.getData())) - .setDueTime(DurationUtils.convertDurationToDaprFormat(timerParams.getDueTime())) - .setPeriod(DurationUtils.convertDurationToDaprFormat(timerParams.getPeriod())) + .setDueTime(ActorScheduleUtils.formatDueTime(timerParams.getDueTime())) + .setPeriod(ActorScheduleUtils.formatPeriod(timerParams.getPeriod(), timerParams.getRepetitions())) + .setTtl(ActorScheduleUtils.formatDuration(timerParams.getTtl())) .build(); return Mono.create(it -> client.registerActorTimer(req, createStreamObserver(it))).then().then(); diff --git a/sdk-actors/src/test/java/io/dapr/actors/runtime/AbstractActorScheduleTest.java b/sdk-actors/src/test/java/io/dapr/actors/runtime/AbstractActorScheduleTest.java new file mode 100644 index 0000000000..ca449ff71b --- /dev/null +++ b/sdk-actors/src/test/java/io/dapr/actors/runtime/AbstractActorScheduleTest.java @@ -0,0 +1,190 @@ +/* + * Copyright 2026 The Dapr 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 io.dapr.actors.runtime; + +import io.dapr.actors.ActorId; +import io.dapr.serializer.DaprObjectSerializer; +import io.dapr.serializer.DefaultObjectSerializer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class AbstractActorScheduleTest { + + public interface ScheduleActor { + } + + public static class ScheduleActorImpl extends AbstractActor implements ScheduleActor { + public ScheduleActorImpl(ActorRuntimeContext runtimeContext, ActorId id) { + super(runtimeContext, id); + } + } + + private DaprClient daprClient; + + private ScheduleActorImpl actor; + + @BeforeEach + public void setUp() { + daprClient = mock(DaprClient.class); + when(daprClient.registerTimer(any(), any(), any(), any())).thenReturn(Mono.empty()); + when(daprClient.registerReminder(any(), any(), any(), any())).thenReturn(Mono.empty()); + + actor = createActor(new DefaultObjectSerializer()); + } + + private ScheduleActorImpl createActor(DaprObjectSerializer serializer) { + ActorRuntimeContext context = new ActorRuntimeContext<>( + mock(ActorRuntime.class), + serializer, + new DefaultActorFactory<>(), + ActorTypeInformation.create(ScheduleActorImpl.class), + daprClient, + mock(DaprStateAsyncProvider.class)); + return new ScheduleActorImpl(context, new ActorId("1")); + } + + @Test + public void registerReminderWithOptions() { + actor.registerReminder("myreminder", "state", ActorReminderOptions.builder() + .setPeriod(Duration.ofSeconds(10)) + .setRepetitions(5) + .setTtl(Duration.ofMinutes(1)) + .build()).block(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ActorReminderParams.class); + verify(daprClient).registerReminder(any(), eq("1"), eq("myreminder"), captor.capture()); + ActorReminderParams params = captor.getValue(); + assertNull(params.getDueTime()); + assertEquals(Duration.ofSeconds(10), params.getPeriod()); + assertEquals(5, params.getRepetitions()); + assertEquals(Duration.ofMinutes(1), params.getTtl()); + } + + @Test + public void registerReminderWithDurationsKeepsBehavior() { + actor.registerReminder("myreminder", "state", Duration.ofSeconds(1), Duration.ofSeconds(2)).block(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ActorReminderParams.class); + verify(daprClient).registerReminder(any(), eq("1"), eq("myreminder"), captor.capture()); + ActorReminderParams params = captor.getValue(); + assertEquals(Duration.ofSeconds(1), params.getDueTime()); + assertEquals(Duration.ofSeconds(2), params.getPeriod()); + assertNull(params.getRepetitions()); + assertNull(params.getTtl()); + } + + @Test + public void registerReminderWithInvalidRepetitions() { + assertThrows(IllegalArgumentException.class, () -> actor.registerReminder("myreminder", "state", + ActorReminderOptions.builder().setRepetitions(5).build())); + } + + @Test + public void registerReminderWithTtlWithoutPeriod() { + assertThrows(IllegalArgumentException.class, () -> actor.registerReminder("myreminder", "state", + ActorReminderOptions.builder().setTtl(Duration.ofMinutes(1)).build())); + + Mono timer = actor.registerActorTimer("mytimer", "callback", "state", + ActorTimerOptions.builder().setTtl(Duration.ofMinutes(1)).build()); + assertThrows(IllegalArgumentException.class, timer::block); + } + + @Test + public void registerReminderWithoutOptions() { + assertThrows(IllegalArgumentException.class, + () -> actor.registerReminder("myreminder", "state", (ActorReminderOptions) null)); + } + + @Test + public void registerTimerWithoutCallback() { + Mono result = actor.registerActorTimer("mytimer", "", "state", ActorTimerOptions.builder().build()); + assertThrows(IllegalArgumentException.class, result::block); + } + + @Test + public void registerReminderWithSerializationError() throws Exception { + DaprObjectSerializer serializer = mock(DaprObjectSerializer.class); + when(serializer.serialize(any())).thenThrow(new IOException("boom")); + ScheduleActorImpl failingActor = createActor(serializer); + + Mono result = failingActor.registerReminder("myreminder", "state", ActorReminderOptions.builder().build()); + assertThrows(IOException.class, () -> { + try { + result.block(); + } catch (RuntimeException e) { + throw e.getCause(); + } + }); + } + + @Test + public void registerTimerWithGeneratedName() { + String emptyName = actor.registerActorTimer("", "callback", "state", ActorTimerOptions.builder().build()).block(); + assertTrue(emptyName.startsWith("1_Timer_")); + + String name = actor.registerActorTimer(null, "callback", "state", ActorTimerOptions.builder().build()).block(); + + assertTrue(name.startsWith("1_Timer_")); + ArgumentCaptor captor = ArgumentCaptor.forClass(ActorTimerParams.class); + verify(daprClient).registerTimer(any(), eq("1"), eq(name), captor.capture()); + assertNotEquals(emptyName, name); + assertNull(captor.getValue().getDueTime()); + assertNull(captor.getValue().getPeriod()); + } + + @Test + public void registerTimerWithOptions() { + String name = actor.registerActorTimer("mytimer", "callback", "state", ActorTimerOptions.builder() + .setDueTime(Duration.ofSeconds(1)) + .setPeriod(Duration.ofSeconds(2)) + .setRepetitions(3) + .setTtl(Duration.ofSeconds(30)) + .build()).block(); + + assertEquals("mytimer", name); + ArgumentCaptor captor = ArgumentCaptor.forClass(ActorTimerParams.class); + verify(daprClient).registerTimer(any(), eq("1"), eq("mytimer"), captor.capture()); + ActorTimerParams params = captor.getValue(); + assertEquals("callback", params.getCallback()); + assertEquals(Duration.ofSeconds(1), params.getDueTime()); + assertEquals(Duration.ofSeconds(2), params.getPeriod()); + assertEquals(3, params.getRepetitions()); + assertEquals(Duration.ofSeconds(30), params.getTtl()); + } + + @Test + public void registerTimerWithInvalidOptions() { + Mono result = actor.registerActorTimer("mytimer", "callback", "state", + ActorTimerOptions.builder().setRepetitions(0).setPeriod(Duration.ofSeconds(1)).build()); + assertThrows(IllegalArgumentException.class, result::block); + assertThrows(IllegalArgumentException.class, + () -> actor.registerActorTimer("mytimer", "callback", "state", (ActorTimerOptions) null).block()); + } +} diff --git a/sdk-actors/src/test/java/io/dapr/actors/runtime/ActorReminderParamsTest.java b/sdk-actors/src/test/java/io/dapr/actors/runtime/ActorReminderParamsTest.java index 57c31c4385..271b853fff 100644 --- a/sdk-actors/src/test/java/io/dapr/actors/runtime/ActorReminderParamsTest.java +++ b/sdk-actors/src/test/java/io/dapr/actors/runtime/ActorReminderParamsTest.java @@ -13,6 +13,7 @@ package io.dapr.actors.runtime; +import com.fasterxml.jackson.databind.ObjectMapper; import io.dapr.client.domain.ConstantFailurePolicy; import io.dapr.client.domain.DropFailurePolicy; import org.junit.jupiter.api.Assertions; @@ -26,6 +27,8 @@ public class ActorReminderParamsTest { private static final ActorObjectSerializer SERIALIZER = new ActorObjectSerializer(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Test public void outOfRangeDueTime() { assertThrows(IllegalArgumentException.class, () -> new ActorReminderParams(null, Duration.ZERO.plusSeconds(-10), Duration.ZERO.plusMinutes(1))); @@ -142,4 +145,56 @@ public void withConstantIntervalFailurePolicy() { Assertions.assertEquals(original.getFailurePolicy().getFailurePolicyType(), recreated.getFailurePolicy().getFailurePolicyType()); Assertions.assertEquals(((ConstantFailurePolicy) original.getFailurePolicy()).getDurationBetweenRetries(), ((ConstantFailurePolicy) recreated.getFailurePolicy()).getDurationBetweenRetries()); } + + @Test + public void withRepetitionsAndTtl() throws Exception { + ActorReminderParams original = new ActorReminderParams("maru".getBytes(), + Duration.ofSeconds(2), + Duration.ofSeconds(10), + 5, + Duration.ofMinutes(1), + null); + + byte[] serialized = SERIALIZER.serialize(original); + String expected = "{\"dueTime\":\"0h0m2s0ms\",\"period\":\"R5/PT10S\",\"ttl\":\"0h1m0s0ms\"," + + "\"data\":\"bWFydQ==\"}"; + Assertions.assertEquals(OBJECT_MAPPER.readTree(expected), OBJECT_MAPPER.readTree(serialized)); + + ActorReminderParams recreated = SERIALIZER.deserialize(serialized, ActorReminderParams.class); + Assertions.assertArrayEquals(original.getData(), recreated.getData()); + Assertions.assertEquals(original.getDueTime(), recreated.getDueTime()); + Assertions.assertEquals(original.getPeriod(), recreated.getPeriod()); + Assertions.assertEquals(5, recreated.getRepetitions()); + Assertions.assertEquals(original.getTtl(), recreated.getTtl()); + } + + @Test + public void withoutDueTimeAndPeriod() throws Exception { + ActorReminderParams original = new ActorReminderParams(null, null, null); + + byte[] serialized = SERIALIZER.serialize(original); + Assertions.assertEquals(OBJECT_MAPPER.readTree("{}"), OBJECT_MAPPER.readTree(serialized)); + + ActorReminderParams recreated = SERIALIZER.deserialize(serialized, ActorReminderParams.class); + Assertions.assertNull(recreated.getDueTime()); + Assertions.assertNull(recreated.getPeriod()); + Assertions.assertNull(recreated.getRepetitions()); + Assertions.assertNull(recreated.getTtl()); + } + + @Test + public void invalidRepetitions() { + assertThrows(IllegalArgumentException.class, + () -> new ActorReminderParams(null, null, Duration.ofSeconds(10), 0, null, null)); + assertThrows(IllegalArgumentException.class, + () -> new ActorReminderParams(null, null, null, 5, null, null)); + assertThrows(IllegalArgumentException.class, + () -> new ActorReminderParams(null, null, Duration.ofMillis(1500), 5, null, null)); + } + + @Test + public void negativeTtl() { + assertThrows(IllegalArgumentException.class, + () -> new ActorReminderParams(null, null, Duration.ofSeconds(10), null, Duration.ofSeconds(-1), null)); + } } diff --git a/sdk-actors/src/test/java/io/dapr/actors/runtime/ActorScheduleUtilsTest.java b/sdk-actors/src/test/java/io/dapr/actors/runtime/ActorScheduleUtilsTest.java new file mode 100644 index 0000000000..b5010eb798 --- /dev/null +++ b/sdk-actors/src/test/java/io/dapr/actors/runtime/ActorScheduleUtilsTest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026 The Dapr 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 io.dapr.actors.runtime; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ActorScheduleUtilsTest { + + @Test + public void validateAcceptsUnsetValues() { + assertDoesNotThrow(() -> ActorScheduleUtils.validate(null, null, null)); + assertDoesNotThrow(() -> ActorScheduleUtils.validate(Duration.ofSeconds(10), 5, Duration.ZERO)); + } + + @Test + public void validateRejectsInvalidRepetitions() { + assertThrows(IllegalArgumentException.class, + () -> ActorScheduleUtils.validate(Duration.ofSeconds(10), 0, null)); + assertThrows(IllegalArgumentException.class, + () -> ActorScheduleUtils.validate(Duration.ofSeconds(10), -1, null)); + assertThrows(IllegalArgumentException.class, + () -> ActorScheduleUtils.validate(null, 5, null)); + assertThrows(IllegalArgumentException.class, + () -> ActorScheduleUtils.validate(Duration.ZERO, 5, null)); + assertThrows(IllegalArgumentException.class, + () -> ActorScheduleUtils.validate(Duration.ofSeconds(-10), 5, null)); + assertThrows(IllegalArgumentException.class, + () -> ActorScheduleUtils.validate(Duration.ofMillis(1500), 5, null)); + } + + @Test + public void validateRejectsNegativeTtl() { + assertThrows(IllegalArgumentException.class, + () -> ActorScheduleUtils.validate(Duration.ofSeconds(10), null, Duration.ofSeconds(-1))); + } + + @Test + public void validateRejectsTtlWithoutPeriod() { + assertThrows(IllegalArgumentException.class, + () -> ActorScheduleUtils.validate(null, null, Duration.ofMinutes(1))); + assertThrows(IllegalArgumentException.class, + () -> ActorScheduleUtils.validate(Duration.ZERO, null, Duration.ofMinutes(1))); + assertThrows(IllegalArgumentException.class, + () -> ActorScheduleUtils.validate(Duration.ofMillis(-1), null, Duration.ofMinutes(1))); + } + + @Test + public void formatDueTime() { + // An unset due time must fire right away, so it is sent as zero instead of an empty string. + assertEquals("0h0m0s0ms", ActorScheduleUtils.formatDueTime(null)); + assertEquals("0h0m1s0ms", ActorScheduleUtils.formatDueTime(Duration.ofSeconds(1))); + } + + @Test + public void formatDuration() { + assertEquals("", ActorScheduleUtils.formatDuration(null)); + assertEquals("0h0m10s0ms", ActorScheduleUtils.formatDuration(Duration.ofSeconds(10))); + } + + @Test + public void formatPeriod() { + assertEquals("", ActorScheduleUtils.formatPeriod(null, null)); + assertEquals("0h0m10s0ms", ActorScheduleUtils.formatPeriod(Duration.ofSeconds(10), null)); + assertEquals("R5/PT10S", ActorScheduleUtils.formatPeriod(Duration.ofSeconds(10), 5)); + } + + @Test + public void parseRepetitions() { + assertNull(ActorScheduleUtils.parseRepetitions(null)); + assertNull(ActorScheduleUtils.parseRepetitions("")); + assertNull(ActorScheduleUtils.parseRepetitions("0h0m10s0ms")); + assertNull(ActorScheduleUtils.parseRepetitions("PT10S")); + assertEquals(5, ActorScheduleUtils.parseRepetitions("R5/PT10S")); + assertEquals(5, ActorScheduleUtils.parseRepetitions("R5")); + assertNull(ActorScheduleUtils.parseRepetitions("R/PT10S")); + assertNull(ActorScheduleUtils.parseRepetitions("Rx/PT10S")); + } +} diff --git a/sdk-actors/src/test/java/io/dapr/actors/runtime/ActorTimerTest.java b/sdk-actors/src/test/java/io/dapr/actors/runtime/ActorTimerTest.java index 204996da0f..a90c9e3eee 100644 --- a/sdk-actors/src/test/java/io/dapr/actors/runtime/ActorTimerTest.java +++ b/sdk-actors/src/test/java/io/dapr/actors/runtime/ActorTimerTest.java @@ -69,4 +69,37 @@ public void serializeWithOneTimePeriod() throws IOException { // Deep comparison via JsonNode.equals method. Assertions.assertEquals(OBJECT_MAPPER.readTree(expected), OBJECT_MAPPER.readTree(s)); } + + @Test + public void serializeWithRepetitionsAndTtl() throws IOException { + ActorTimerParams timer = new ActorTimerParams( + "myfunction", + null, + null, + Duration.ofSeconds(10), + 3, + Duration.ofMinutes(5)); + byte[] s = new ActorObjectSerializer().serialize(timer); + + String expected = "{\"period\":\"R3/PT10S\",\"ttl\":\"0h5m0s0ms\", \"callback\": \"myfunction\"}"; + Assertions.assertEquals(OBJECT_MAPPER.readTree(expected), OBJECT_MAPPER.readTree(s)); + } + + @Test + public void deserializeWithRepetitions() throws IOException { + String payload = "{\"period\":\"R3/PT10S\",\"dueTime\":\"0h0m1s0ms\", \"callback\": \"myfunction\"}"; + ActorTimerParams timer = new ActorObjectSerializer().deserialize(payload.getBytes(), ActorTimerParams.class); + + Assertions.assertEquals("myfunction", timer.getCallback()); + Assertions.assertEquals(Duration.ofSeconds(1), timer.getDueTime()); + Assertions.assertEquals(Duration.ofSeconds(10), timer.getPeriod()); + Assertions.assertEquals(3, timer.getRepetitions()); + Assertions.assertNull(timer.getTtl()); + } + + @Test + public void invalidRepetitions() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ActorTimerParams("myfunction", null, null, null, 3, null)); + } } diff --git a/sdk-actors/src/test/java/io/dapr/actors/runtime/DaprGrpcClientTest.java b/sdk-actors/src/test/java/io/dapr/actors/runtime/DaprGrpcClientTest.java index 425ba64589..1e676f1e5d 100644 --- a/sdk-actors/src/test/java/io/dapr/actors/runtime/DaprGrpcClientTest.java +++ b/sdk-actors/src/test/java/io/dapr/actors/runtime/DaprGrpcClientTest.java @@ -59,6 +59,8 @@ public class DaprGrpcClientTest { private static final String TIMER_NAME = "timerName"; + private static final String SCHEDULE_OPTIONS_NAME = "withScheduleOptions"; + private static final byte[] RESPONSE_PAYLOAD = "\"hello world\"".getBytes(); private static final List OPERATIONS = Arrays.asList( @@ -170,6 +172,35 @@ public void registerActorTimer() { result.block(); } + @Test + public void registerActorReminderWithRepetitionsAndTtl() { + ActorReminderParams params = new ActorReminderParams( + "hello world".getBytes(), + null, + Duration.ofSeconds(2), + 5, + Duration.ofMinutes(1), + null + ); + Mono result = client.registerReminder(ACTOR_TYPE, ACTOR_ID, SCHEDULE_OPTIONS_NAME, params); + result.block(); + } + + @Test + public void registerActorTimerWithRepetitionsAndTtl() { + ActorTimerParams params = new ActorTimerParams( + "mymethod", + "hello world".getBytes(), + Duration.ofSeconds(1), + Duration.ofSeconds(2), + 3, + Duration.ofSeconds(30) + ); + + Mono result = client.registerTimer(ACTOR_TYPE, ACTOR_ID, SCHEDULE_OPTIONS_NAME, params); + result.block(); + } + @Test public void unregisterActorTimer() { Mono result = client.unregisterTimer(ACTOR_TYPE, ACTOR_ID, TIMER_NAME); @@ -284,9 +315,16 @@ public void executeActorStateTransaction(DaprActorsProtos.ExecuteActorStateTrans @Override public void registerActorReminder(DaprActorsProtos.RegisterActorReminderRequest request, io.grpc.stub.StreamObserver responseObserver) { - assertEquals(REMINDER_NAME, request.getName()); - assertEquals("0h0m1s0ms", request.getDueTime()); - assertEquals("0h0m2s0ms", request.getPeriod()); + if (SCHEDULE_OPTIONS_NAME.equals(request.getName())) { + assertEquals("0h0m0s0ms", request.getDueTime()); + assertEquals("R5/PT2S", request.getPeriod()); + assertEquals("0h1m0s0ms", request.getTtl()); + } else { + assertEquals(REMINDER_NAME, request.getName()); + assertEquals("0h0m1s0ms", request.getDueTime()); + assertEquals("0h0m2s0ms", request.getPeriod()); + assertEquals("", request.getTtl()); + } assertEquals(ACTOR_TYPE, request.getActorType()); assertEquals(ACTOR_ID, request.getActorId()); switch (request.getActorId()) { @@ -305,10 +343,17 @@ public void registerActorTimer(DaprActorsProtos.RegisterActorTimerRequest reques io.grpc.stub.StreamObserver responseObserver) { assertEquals(ACTOR_TYPE, request.getActorType()); assertEquals(ACTOR_ID, request.getActorId()); - assertEquals(TIMER_NAME, request.getName()); assertEquals("mymethod", request.getCallback()); - assertEquals("0h0m1s0ms", request.getDueTime()); - assertEquals("0h0m2s0ms", request.getPeriod()); + if (SCHEDULE_OPTIONS_NAME.equals(request.getName())) { + assertEquals("0h0m1s0ms", request.getDueTime()); + assertEquals("R3/PT2S", request.getPeriod()); + assertEquals("0h0m30s0ms", request.getTtl()); + } else { + assertEquals(TIMER_NAME, request.getName()); + assertEquals("0h0m1s0ms", request.getDueTime()); + assertEquals("0h0m2s0ms", request.getPeriod()); + assertEquals("", request.getTtl()); + } switch (request.getActorId()) { case ACTOR_ID: populateObserver(responseObserver, Empty.newBuilder().build()); diff --git a/sdk/src/main/java/io/dapr/utils/DurationUtils.java b/sdk/src/main/java/io/dapr/utils/DurationUtils.java index 01bbdb61e2..99846e924a 100644 --- a/sdk/src/main/java/io/dapr/utils/DurationUtils.java +++ b/sdk/src/main/java/io/dapr/utils/DurationUtils.java @@ -14,12 +14,16 @@ package io.dapr.utils; import java.time.Duration; +import java.time.format.DateTimeParseException; public class DurationUtils { /** * Converts time from the String format used by Dapr into a Duration. * + *

ISO 8601 durations (e.g. PT10S), optionally with repetitions (e.g. R5/PT10S), are also accepted, since + * the Dapr runtime echoes back the period exactly as it was registered. Repetitions are ignored.

+ * * @param valueString A String representing time in the Dapr runtime's format (e.g. 4h15m50s60ms). * @return A Duration */ @@ -29,6 +33,9 @@ public static Duration convertDurationFromDaprFormat(String valueString) { if (valueString == null) { throw new IllegalArgumentException("duration string cannot be null"); } + if (valueString.startsWith("R") || valueString.startsWith("P")) { + return convertDurationFromIso8601Format(valueString); + } Duration parsedDuration = Duration.ZERO; int hourIndex = valueString.indexOf('h'); // Get first occurrence of "m" @@ -112,6 +119,61 @@ public static String convertDurationToDaprFormat(Duration value) { return stringValue; } + /** + * Converts a Duration to the ISO 8601 duration format accepted by the Dapr runtime (e.g. PT1H30M10S). + * The runtime only accepts whole seconds in this format. + * + * @param value Duration, must be non-negative and have no sub-second part. + * @return The Duration formatted as an ISO 8601 duration. + */ + public static String convertDurationToIso8601Format(Duration value) { + if (value == null || value.isNegative()) { + throw new IllegalArgumentException("duration must be non-negative"); + } + if (value.getNano() != 0) { + throw new IllegalArgumentException("ISO 8601 durations only support whole seconds: " + value); + } + + long hours = getDaysPart(value) * 24 + getHoursPart(value); + long minutes = getMinutesPart(value); + long seconds = getSecondsPart(value); + + StringBuilder sb = new StringBuilder("PT"); + if (hours > 0) { + sb.append(hours).append('H'); + } + if (minutes > 0) { + sb.append(minutes).append('M'); + } + if (seconds > 0 || (hours == 0 && minutes == 0)) { + sb.append(seconds).append('S'); + } + return sb.toString(); + } + + /** + * Converts an ISO 8601 duration, optionally prefixed with repetitions (e.g. R5/PT10S), into a Duration. + * + * @param valueString ISO 8601 duration. + * @return A Duration + */ + private static Duration convertDurationFromIso8601Format(String valueString) { + String durationPart = valueString; + if (durationPart.startsWith("R")) { + int separator = durationPart.indexOf('/'); + durationPart = separator == -1 ? "" : durationPart.substring(separator + 1); + } + if (durationPart.isEmpty()) { + return Duration.ZERO; + } + + try { + return Duration.parse(durationPart); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("unsupported ISO 8601 duration: " + valueString, e); + } + } + /** * Helper to get the "days" part of the Duration. For example if the duration is 26 hours, this returns 1. * diff --git a/sdk/src/test/java/io/dapr/utils/DurationUtilsTest.java b/sdk/src/test/java/io/dapr/utils/DurationUtilsTest.java index 02aa5b1a70..b04173d366 100644 --- a/sdk/src/test/java/io/dapr/utils/DurationUtilsTest.java +++ b/sdk/src/test/java/io/dapr/utils/DurationUtilsTest.java @@ -184,4 +184,35 @@ public void testGetMillisecondsPart() { Duration d7 = Duration.ZERO.plusMillis(10000); assertEquals(0, DurationUtils.getMilliSecondsPart(d7)); } + + @Test + public void convertDurationToIso8601Format() { + assertEquals("PT0S", DurationUtils.convertDurationToIso8601Format(Duration.ZERO)); + assertEquals("PT10S", DurationUtils.convertDurationToIso8601Format(Duration.ofSeconds(10))); + assertEquals("PT1H30M", DurationUtils.convertDurationToIso8601Format(Duration.ofMinutes(90))); + assertEquals("PT5M", DurationUtils.convertDurationToIso8601Format(Duration.ofMinutes(5))); + assertEquals("PT2H", DurationUtils.convertDurationToIso8601Format(Duration.ofHours(2))); + assertEquals("PT1M5S", DurationUtils.convertDurationToIso8601Format(Duration.ofSeconds(65))); + assertEquals("PT26H5S", DurationUtils.convertDurationToIso8601Format(Duration.ofHours(26).plusSeconds(5))); + } + + @Test + public void convertDurationToIso8601FormatRejectsInvalidValues() { + assertThrows(IllegalArgumentException.class, + () -> DurationUtils.convertDurationToIso8601Format(Duration.ofMillis(1500))); + assertThrows(IllegalArgumentException.class, + () -> DurationUtils.convertDurationToIso8601Format(Duration.ofSeconds(-1))); + assertThrows(IllegalArgumentException.class, + () -> DurationUtils.convertDurationToIso8601Format(null)); + } + + @Test + public void convertDurationFromIso8601Format() { + assertEquals(Duration.ofSeconds(10), DurationUtils.convertDurationFromDaprFormat("PT10S")); + assertEquals(Duration.ofSeconds(10), DurationUtils.convertDurationFromDaprFormat("R5/PT10S")); + assertEquals(Duration.ofDays(1).plusHours(2), DurationUtils.convertDurationFromDaprFormat("P1DT2H")); + assertEquals(Duration.ZERO, DurationUtils.convertDurationFromDaprFormat("R5/")); + assertEquals(Duration.ZERO, DurationUtils.convertDurationFromDaprFormat("R5")); + assertThrows(IllegalArgumentException.class, () -> DurationUtils.convertDurationFromDaprFormat("P1M")); + } }