From 66baa2c8229aa47c197c405c4f06e4110832d3a5 Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Wed, 20 Oct 2021 13:40:30 +0200 Subject: [PATCH] ISSUE-569: Allow usage of ISO duration notations --- pom.xml | 4 +- .../io/dapr/actors/runtime/AbstractActor.java | 629 +++++++++--------- .../actors/runtime/ActorObjectSerializer.java | 384 ++++++----- .../actors/runtime/ActorReminderParams.java | 47 ++ .../dapr/actors/runtime/DaprGrpcClient.java | 2 +- .../runtime/ActorReminderParamsTest.java | 18 + .../actors/runtime/DaprGrpcClientTest.java | 2 +- .../actors/runtime/DaprHttpClientTest.java | 16 + .../java/io/dapr/utils/DurationUtils.java | 334 ++++++---- .../java/io/dapr/utils/DurationUtilsTest.java | 18 + 10 files changed, 834 insertions(+), 620 deletions(-) diff --git a/pom.xml b/pom.xml index de742fc0a..22f47af44 100644 --- a/pom.xml +++ b/pom.xml @@ -152,8 +152,8 @@ UTF-8 true warning - true - true + false + false false 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 38b2b0d2e..8f38ac7d4 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 @@ -20,316 +20,337 @@ */ public abstract class AbstractActor { - private static final ActorObjectSerializer INTERNAL_SERIALIZER = new ActorObjectSerializer(); - - /** - * Type of tracing messages. - */ - private static final String TRACE_TYPE = "Actor"; - - /** - * Context for the Actor runtime. - */ - private final ActorRuntimeContext actorRuntimeContext; - - /** - * Actor identifier. - */ - private final ActorId id; - - /** - * Emits trace messages for Actors. - */ - private final ActorTrace actorTrace; - - /** - * Manager for the states in Actors. - */ - private final ActorStateManager actorStateManager; - - /** - * Internal control to assert method invocation on start and finish in this SDK. - */ - private boolean started; - - /** - * Instantiates a new Actor. - * - * @param runtimeContext Context for the runtime. - * @param id Actor identifier. - */ - protected AbstractActor(ActorRuntimeContext runtimeContext, ActorId id) { - this.actorRuntimeContext = runtimeContext; - this.id = id; - this.actorStateManager = new ActorStateManager( - runtimeContext.getStateProvider(), - runtimeContext.getActorTypeInformation().getName(), - id); - this.actorTrace = runtimeContext.getActorTrace(); - this.started = false; - } - - /** - * Returns the id of the actor. - * - * @return Actor id. - */ - protected ActorId getId() { - return this.id; - } - - /** - * Returns the actor's type. - * - * @return Actor type. - */ - String getType() { - return this.actorRuntimeContext.getActorTypeInformation().getName(); - } - - /** - * Returns the state store manager for this Actor. - * - * @return State store manager for this Actor - */ - protected ActorStateManager getActorStateManager() { - return this.actorStateManager; - } - - /** - * Registers a reminder for this Actor. - * - * @param reminderName Name of the reminder. - * @param state State to be send along with reminder triggers. - * @param dueTime Due time for the first trigger. - * @param period Frequency for the triggers. - * @param Type of the state object. - * @return Asynchronous void response. - */ - protected Mono registerReminder( - String reminderName, - T state, - Duration dueTime, - Duration period) { - try { - byte[] data = this.actorRuntimeContext.getObjectSerializer().serialize(state); - ActorReminderParams params = new ActorReminderParams(data, dueTime, period); - return this.actorRuntimeContext.getDaprClient().registerReminder( - this.actorRuntimeContext.getActorTypeInformation().getName(), - this.id.toString(), - reminderName, - params); - } catch (IOException e) { - return Mono.error(e); + private static final ActorObjectSerializer INTERNAL_SERIALIZER = new ActorObjectSerializer(); + + /** + * Type of tracing messages. + */ + private static final String TRACE_TYPE = "Actor"; + + /** + * Context for the Actor runtime. + */ + private final ActorRuntimeContext actorRuntimeContext; + + /** + * Actor identifier. + */ + private final ActorId id; + + /** + * Emits trace messages for Actors. + */ + private final ActorTrace actorTrace; + + /** + * Manager for the states in Actors. + */ + private final ActorStateManager actorStateManager; + + /** + * Internal control to assert method invocation on start and finish in this SDK. + */ + private boolean started; + + /** + * Instantiates a new Actor. + * + * @param runtimeContext Context for the runtime. + * @param id Actor identifier. + */ + protected AbstractActor(ActorRuntimeContext runtimeContext, ActorId id) { + this.actorRuntimeContext = runtimeContext; + this.id = id; + this.actorStateManager = new ActorStateManager( + runtimeContext.getStateProvider(), + runtimeContext.getActorTypeInformation().getName(), + id); + this.actorTrace = runtimeContext.getActorTrace(); + this.started = false; } - } - - /** - * Registers a Timer for the actor. A timer name is autogenerated by the runtime to keep track of it. - * - * @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 dueTime The amount of time to delay before the async callback is first invoked. - * Specify negative one (-1) milliseconds to prevent the timer from starting. - * Specify zero (0) to start the timer immediately. - * @param period The time interval between invocations of the async callback. - * Specify negative one (-1) milliseconds to disable periodic signaling. - * @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, - Duration dueTime, - Duration period) { - try { - if ((callback == null) || callback.isEmpty()) { - throw new IllegalArgumentException("Timer requires a callback function."); - } - - String name = timerName; - if ((timerName == null) || (timerName.isEmpty())) { - name = String.format("%s_Timer_%s", this.id.toString(), UUID.randomUUID().toString()); - } - - byte[] data = this.actorRuntimeContext.getObjectSerializer().serialize(state); - ActorTimerParams actorTimer = new ActorTimerParams(callback, data, dueTime, period); - - return this.actorRuntimeContext.getDaprClient().registerTimer( - this.actorRuntimeContext.getActorTypeInformation().getName(), - this.id.toString(), - name, - actorTimer).thenReturn(name); - } catch (Exception e) { - return Mono.error(e); + + /** + * Returns the id of the actor. + * + * @return Actor id. + */ + protected ActorId getId() { + return this.id; + } + + /** + * Returns the actor's type. + * + * @return Actor type. + */ + String getType() { + return this.actorRuntimeContext.getActorTypeInformation().getName(); } - } - - /** - * Unregisters an Actor timer. - * - * @param timerName Name of Timer to be unregistered. - * @return Asynchronous void response. - */ - protected Mono unregisterTimer(String timerName) { - return this.actorRuntimeContext.getDaprClient().unregisterTimer( + + /** + * Returns the state store manager for this Actor. + * + * @return State store manager for this Actor + */ + protected ActorStateManager getActorStateManager() { + return this.actorStateManager; + } + + /** + * Registers a reminder for this Actor. + * + * @param reminderName Name of the reminder. + * @param state State to be send along with reminder triggers. + * @param dueTime Due time for the first trigger. + * @param period Frequency for the triggers. + * @param Type of the state object. + * @return Asynchronous void response. + */ + protected Mono registerReminder( + String reminderName, + T state, + Duration dueTime, + Duration period) { + return this.registerReminder(reminderName, state, dueTime, period, null); + } + + /** + * Registers a reminder for this Actor. + * + * @param reminderName Name of the reminder. + * @param state State to be send along with reminder triggers. + * @param dueTime Due time for the first trigger. + * @param period Frequency for the triggers. + * @param repetitions Number of times the reminder should be invoked. + * @param Type of the state object. + * @return Asynchronous void response. + */ + protected Mono registerReminder( + String reminderName, + T state, + Duration dueTime, + Duration period, + Integer repetitions) { + try { + byte[] data = this.actorRuntimeContext.getObjectSerializer().serialize(state); + ActorReminderParams params = new ActorReminderParams(data, dueTime, period, repetitions); + return this.actorRuntimeContext.getDaprClient().registerReminder( + this.actorRuntimeContext.getActorTypeInformation().getName(), + this.id.toString(), + reminderName, + params); + } catch (IOException e) { + return Mono.error(e); + } + } + + + /** + * Registers a Timer for the actor. A timer name is autogenerated by the runtime to keep track of it. + * + * @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 dueTime The amount of time to delay before the async callback is first invoked. + * Specify negative one (-1) milliseconds to prevent the timer from starting. + * Specify zero (0) to start the timer immediately. + * @param period The time interval between invocations of the async callback. + * Specify negative one (-1) milliseconds to disable periodic signaling. + * @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, + Duration dueTime, + Duration period) { + try { + if ((callback == null) || callback.isEmpty()) { + throw new IllegalArgumentException("Timer requires a callback function."); + } + + String name = timerName; + if ((timerName == null) || (timerName.isEmpty())) { + name = String.format("%s_Timer_%s", this.id.toString(), UUID.randomUUID().toString()); + } + + byte[] data = this.actorRuntimeContext.getObjectSerializer().serialize(state); + ActorTimerParams actorTimer = new ActorTimerParams(callback, data, dueTime, period); + + return this.actorRuntimeContext.getDaprClient().registerTimer( + this.actorRuntimeContext.getActorTypeInformation().getName(), + this.id.toString(), + name, + actorTimer).thenReturn(name); + } catch (Exception e) { + return Mono.error(e); + } + } + + /** + * Unregisters an Actor timer. + * + * @param timerName Name of Timer to be unregistered. + * @return Asynchronous void response. + */ + protected Mono unregisterTimer(String timerName) { + return this.actorRuntimeContext.getDaprClient().unregisterTimer( this.actorRuntimeContext.getActorTypeInformation().getName(), this.id.toString(), timerName); - } - - /** - * Unregisters a Reminder. - * - * @param reminderName Name of Reminder to be unregistered. - * @return Asynchronous void response. - */ - protected Mono unregisterReminder(String reminderName) { - return this.actorRuntimeContext.getDaprClient().unregisterReminder( - this.actorRuntimeContext.getActorTypeInformation().getName(), - this.id.toString(), - reminderName); - } - - /** - * Callback function invoked after an Actor has been activated. - * - * @return Asynchronous void response. - */ - protected Mono onActivate() { - return Mono.empty(); - } - - /** - * Callback function invoked after an Actor has been deactivated. - * - * @return Asynchronous void response. - */ - protected Mono onDeactivate() { - return Mono.empty(); - } - - /** - * Callback function invoked before method is invoked. - * - * @param actorMethodContext Method context. - * @return Asynchronous void response. - */ - protected Mono onPreActorMethod(ActorMethodContext actorMethodContext) { - return Mono.empty(); - } - - /** - * Callback function invoked after method is invoked. - * - * @param actorMethodContext Method context. - * @return Asynchronous void response. - */ - protected Mono onPostActorMethod(ActorMethodContext actorMethodContext) { - return Mono.empty(); - } - - /** - * Saves the state of this Actor. - * - * @return Asynchronous void response. - */ - protected Mono saveState() { - return this.actorStateManager.save(); - } - - /** - * Resets the cached state of this Actor. - */ - void rollback() { - if (!this.started) { - throw new IllegalStateException("Cannot reset state before starting call."); } - this.resetState(); - this.started = false; - } - - /** - * Resets the cached state of this Actor. - */ - void resetState() { - this.actorStateManager.clear(); - } - - /** - * Internal callback when an Actor is activated. - * - * @return Asynchronous void response. - */ - Mono onActivateInternal() { - return Mono.fromRunnable(() -> { - this.actorTrace.writeInfo(TRACE_TYPE, this.id.toString(), "Activating ..."); - this.resetState(); - }).then(this.onActivate()) - .then(this.doWriteInfo(TRACE_TYPE, this.id.toString(), "Activated")) - .then(this.saveState()); - } - - /** - * Internal callback when an Actor is deactivated. - * - * @return Asynchronous void response. - */ - Mono onDeactivateInternal() { - this.actorTrace.writeInfo(TRACE_TYPE, this.id.toString(), "Deactivating ..."); - - return Mono.fromRunnable(() -> this.resetState()) - .then(this.onDeactivate()) - .then(this.doWriteInfo(TRACE_TYPE, this.id.toString(), "Deactivated")); - } - - /** - * Internal callback prior to method be invoked. - * - * @param actorMethodContext Method context. - * @return Asynchronous void response. - */ - Mono onPreActorMethodInternal(ActorMethodContext actorMethodContext) { - return Mono.fromRunnable(() -> { - if (this.started) { - throw new IllegalStateException("Cannot invoke a method before completing previous call."); - } - - this.started = true; - }).then(this.onPreActorMethod(actorMethodContext)); - } - - /** - * Internal callback after method is invoked. - * - * @param actorMethodContext Method context. - * @return Asynchronous void response. - */ - Mono onPostActorMethodInternal(ActorMethodContext actorMethodContext) { - return Mono.fromRunnable(() -> { - if (!this.started) { - throw new IllegalStateException("Cannot complete a method before starting a call."); - } - }).then(this.onPostActorMethod(actorMethodContext)) - .then(this.saveState()) - .then(Mono.fromRunnable(() -> { - this.started = false; - })); - } - - /** - * Internal method to emit a trace message. - * - * @param type Type of trace message. - * @param id Identifier of entity relevant for the trace message. - * @param message Message to be logged. - * @return Asynchronous void response. - */ - private Mono doWriteInfo(String type, String id, String message) { - return Mono.fromRunnable(() -> this.actorTrace.writeInfo(type, id, message)); - } + /** + * Unregisters a Reminder. + * + * @param reminderName Name of Reminder to be unregistered. + * @return Asynchronous void response. + */ + protected Mono unregisterReminder(String reminderName) { + return this.actorRuntimeContext.getDaprClient().unregisterReminder( + this.actorRuntimeContext.getActorTypeInformation().getName(), + this.id.toString(), + reminderName); + } + + /** + * Callback function invoked after an Actor has been activated. + * + * @return Asynchronous void response. + */ + protected Mono onActivate() { + return Mono.empty(); + } + + /** + * Callback function invoked after an Actor has been deactivated. + * + * @return Asynchronous void response. + */ + protected Mono onDeactivate() { + return Mono.empty(); + } + + /** + * Callback function invoked before method is invoked. + * + * @param actorMethodContext Method context. + * @return Asynchronous void response. + */ + protected Mono onPreActorMethod(ActorMethodContext actorMethodContext) { + return Mono.empty(); + } + + /** + * Callback function invoked after method is invoked. + * + * @param actorMethodContext Method context. + * @return Asynchronous void response. + */ + protected Mono onPostActorMethod(ActorMethodContext actorMethodContext) { + return Mono.empty(); + } + + /** + * Saves the state of this Actor. + * + * @return Asynchronous void response. + */ + protected Mono saveState() { + return this.actorStateManager.save(); + } + + /** + * Resets the cached state of this Actor. + */ + void rollback() { + if (!this.started) { + throw new IllegalStateException("Cannot reset state before starting call."); + } + + this.resetState(); + this.started = false; + } + + /** + * Resets the cached state of this Actor. + */ + void resetState() { + this.actorStateManager.clear(); + } + + /** + * Internal callback when an Actor is activated. + * + * @return Asynchronous void response. + */ + Mono onActivateInternal() { + return Mono.fromRunnable(() -> { + this.actorTrace.writeInfo(TRACE_TYPE, this.id.toString(), "Activating ..."); + this.resetState(); + }).then(this.onActivate()) + .then(this.doWriteInfo(TRACE_TYPE, this.id.toString(), "Activated")) + .then(this.saveState()); + } + + /** + * Internal callback when an Actor is deactivated. + * + * @return Asynchronous void response. + */ + Mono onDeactivateInternal() { + this.actorTrace.writeInfo(TRACE_TYPE, this.id.toString(), "Deactivating ..."); + + return Mono.fromRunnable(() -> this.resetState()) + .then(this.onDeactivate()) + .then(this.doWriteInfo(TRACE_TYPE, this.id.toString(), "Deactivated")); + } + + /** + * Internal callback prior to method be invoked. + * + * @param actorMethodContext Method context. + * @return Asynchronous void response. + */ + Mono onPreActorMethodInternal(ActorMethodContext actorMethodContext) { + return Mono.fromRunnable(() -> { + if (this.started) { + throw new IllegalStateException("Cannot invoke a method before completing previous call."); + } + + this.started = true; + }).then(this.onPreActorMethod(actorMethodContext)); + } + + /** + * Internal callback after method is invoked. + * + * @param actorMethodContext Method context. + * @return Asynchronous void response. + */ + Mono onPostActorMethodInternal(ActorMethodContext actorMethodContext) { + return Mono.fromRunnable(() -> { + if (!this.started) { + throw new IllegalStateException("Cannot complete a method before starting a call."); + } + }).then(this.onPostActorMethod(actorMethodContext)) + .then(this.saveState()) + .then(Mono.fromRunnable(() -> { + this.started = false; + })); + } + + /** + * Internal method to emit a trace message. + * + * @param type Type of trace message. + * @param id Identifier of entity relevant for the trace message. + * @param message Message to be logged. + * @return Asynchronous void response. + */ + private Mono doWriteInfo(String type, String id, String message) { + return Mono.fromRunnable(() -> this.actorTrace.writeInfo(type, id, message)); + } } 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 58a04f62e..fa4b76d56 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 @@ -20,204 +20,222 @@ */ public class ActorObjectSerializer extends ObjectSerializer { - /** - * Shared Json Factory as per Jackson's documentation. - */ - private static final JsonFactory JSON_FACTORY = new JsonFactory(); - - /** - * {@inheritDoc} - */ - @Override - public byte[] serialize(Object state) throws IOException { - if (state == null) { - return null; + /** + * Shared Json Factory as per Jackson's documentation. + */ + private static final JsonFactory JSON_FACTORY = new JsonFactory(); + + /** + * Extracts duration or null. + * + * @param node Node that contains the attribute. + * @param name Attribute name. + * @return Parsed duration or null. + */ + private static Duration extractDurationOrNull(JsonNode node, String name) { + JsonNode valueNode = node.get(name); + if (valueNode == null) { + return null; + } + + return DurationUtils.convertDurationWithRepetitionFromDapr(valueNode.asText()).getDuration(); } - if (state.getClass() == ActorTimerParams.class) { - // Special serializer for this internal classes. - return serialize((ActorTimerParams) state); + /** + * Extracts repetition or null. + * + * @param node Node that contains the attribute. + * @param name Attribute name. + * @return Parsed duration or null. + */ + private static Integer extractRepetitionOrNull(JsonNode node, String name) { + JsonNode valueNode = node.get(name); + if (valueNode == null) { + return null; + } + + return DurationUtils.convertDurationWithRepetitionFromDapr(valueNode.asText()).getRepetitions() + .orElse(null); } - if (state.getClass() == ActorReminderParams.class) { - // Special serializer for this internal classes. - return serialize((ActorReminderParams) state); + /** + * {@inheritDoc} + */ + @Override + public byte[] serialize(Object state) throws IOException { + if (state == null) { + return null; + } + + if (state.getClass() == ActorTimerParams.class) { + // Special serializer for this internal classes. + return serialize((ActorTimerParams) state); + } + + if (state.getClass() == ActorReminderParams.class) { + // Special serializer for this internal classes. + return serialize((ActorReminderParams) state); + } + + if (state.getClass() == ActorRuntimeConfig.class) { + // Special serializer for this internal classes. + return serialize((ActorRuntimeConfig) state); + } + + // Is not an special case. + return super.serialize(state); } - if (state.getClass() == ActorRuntimeConfig.class) { - // Special serializer for this internal classes. - return serialize((ActorRuntimeConfig) state); + /** + * Faster serialization for params of Actor's timer. + * + * @param timer Timer's to be serialized. + * @return JSON String. + * @throws IOException If cannot generate JSON. + */ + private byte[] serialize(ActorTimerParams timer) throws IOException { + if (timer == null) { + return null; + } + + 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())); + generator.writeStringField("callback", timer.getCallback()); + if (timer.getData() != null) { + generator.writeBinaryField("data", timer.getData()); + } + generator.writeEndObject(); + generator.close(); + writer.flush(); + return writer.toByteArray(); + } } - // Is not an special case. - return super.serialize(state); - } - - /** - * Faster serialization for params of Actor's timer. - * - * @param timer Timer's to be serialized. - * @return JSON String. - * @throws IOException If cannot generate JSON. - */ - private byte[] serialize(ActorTimerParams timer) throws IOException { - if (timer == null) { - return null; + /** + * Faster serialization for Actor's reminder. + * + * @param reminder Reminder to be serialized. + * @return JSON String. + * @throws IOException If cannot generate JSON. + */ + private byte[] serialize(ActorReminderParams reminder) throws IOException { + try (ByteArrayOutputStream writer = new ByteArrayOutputStream()) { + JsonGenerator generator = JSON_FACTORY.createGenerator(writer); + generator.writeStartObject(); + generator.writeStringField("dueTime", DurationUtils.convertDurationToDaprFormat(reminder.getDueTime())); + generator.writeStringField("period", DurationUtils.convertDurationWithRepetitionToISO8601Format(new DurationUtils.RepeatedDuration(reminder.getPeriod(), reminder.getRepetitions()))); + if (reminder.getData() != null) { + generator.writeBinaryField("data", reminder.getData()); + } + generator.writeEndObject(); + generator.close(); + writer.flush(); + return writer.toByteArray(); + } } - 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())); - generator.writeStringField("callback", timer.getCallback()); - if (timer.getData() != null) { - generator.writeBinaryField("data", timer.getData()); - } - generator.writeEndObject(); - generator.close(); - writer.flush(); - return writer.toByteArray(); - } - } - - /** - * Faster serialization for Actor's reminder. - * - * @param reminder Reminder to be serialized. - * @return JSON String. - * @throws IOException If cannot generate JSON. - */ - private byte[] serialize(ActorReminderParams reminder) throws IOException { - try (ByteArrayOutputStream writer = new ByteArrayOutputStream()) { - JsonGenerator generator = JSON_FACTORY.createGenerator(writer); - generator.writeStartObject(); - generator.writeStringField("dueTime", DurationUtils.convertDurationToDaprFormat(reminder.getDueTime())); - generator.writeStringField("period", DurationUtils.convertDurationToDaprFormat(reminder.getPeriod())); - if (reminder.getData() != null) { - generator.writeBinaryField("data", reminder.getData()); - } - generator.writeEndObject(); - generator.close(); - writer.flush(); - return writer.toByteArray(); - } - } - - /** - * Faster serialization for Actor's runtime configuration. - * - * @param config Configuration for Dapr's actor runtime. - * @return JSON String. - * @throws IOException If cannot generate JSON. - */ - private byte[] serialize(ActorRuntimeConfig config) throws IOException { - try (ByteArrayOutputStream writer = new ByteArrayOutputStream()) { - JsonGenerator generator = JSON_FACTORY.createGenerator(writer); - generator.writeStartObject(); - generator.writeArrayFieldStart("entities"); - for (String actorClass : config.getRegisteredActorTypes()) { - generator.writeString(actorClass); - } - generator.writeEndArray(); - if (config.getActorIdleTimeout() != null) { - generator.writeStringField("actorIdleTimeout", - DurationUtils.convertDurationToDaprFormat(config.getActorIdleTimeout())); - } - if (config.getActorScanInterval() != null) { - generator.writeStringField("actorScanInterval", - DurationUtils.convertDurationToDaprFormat(config.getActorScanInterval())); - } - if (config.getDrainOngoingCallTimeout() != null) { - generator.writeStringField("drainOngoingCallTimeout", - DurationUtils.convertDurationToDaprFormat(config.getDrainOngoingCallTimeout())); - } - if (config.getDrainBalancedActors() != null) { - generator.writeBooleanField("drainBalancedActors", config.getDrainBalancedActors()); - } - if (config.getRemindersStoragePartitions() != null) { - generator.writeNumberField("remindersStoragePartitions", config.getRemindersStoragePartitions()); - } - generator.writeEndObject(); - generator.close(); - writer.flush(); - return writer.toByteArray(); - } - } - - /** - * {@inheritDoc} - */ - @Override - public T deserialize(byte[] content, Class clazz) throws IOException { - if (clazz == ActorTimerParams.class) { - // Special serializer for this internal classes. - return (T) deserializeActorTimer(content); + /** + * Faster serialization for Actor's runtime configuration. + * + * @param config Configuration for Dapr's actor runtime. + * @return JSON String. + * @throws IOException If cannot generate JSON. + */ + private byte[] serialize(ActorRuntimeConfig config) throws IOException { + try (ByteArrayOutputStream writer = new ByteArrayOutputStream()) { + JsonGenerator generator = JSON_FACTORY.createGenerator(writer); + generator.writeStartObject(); + generator.writeArrayFieldStart("entities"); + for (String actorClass : config.getRegisteredActorTypes()) { + generator.writeString(actorClass); + } + generator.writeEndArray(); + if (config.getActorIdleTimeout() != null) { + generator.writeStringField("actorIdleTimeout", + DurationUtils.convertDurationToDaprFormat(config.getActorIdleTimeout())); + } + if (config.getActorScanInterval() != null) { + generator.writeStringField("actorScanInterval", + DurationUtils.convertDurationToDaprFormat(config.getActorScanInterval())); + } + if (config.getDrainOngoingCallTimeout() != null) { + generator.writeStringField("drainOngoingCallTimeout", + DurationUtils.convertDurationToDaprFormat(config.getDrainOngoingCallTimeout())); + } + if (config.getDrainBalancedActors() != null) { + generator.writeBooleanField("drainBalancedActors", config.getDrainBalancedActors()); + } + if (config.getRemindersStoragePartitions() != null) { + generator.writeNumberField("remindersStoragePartitions", config.getRemindersStoragePartitions()); + } + generator.writeEndObject(); + generator.close(); + writer.flush(); + return writer.toByteArray(); + } } - if (clazz == ActorReminderParams.class) { - // Special serializer for this internal classes. - return (T) deserializeActorReminder(content); + /** + * {@inheritDoc} + */ + @Override + public T deserialize(byte[] content, Class clazz) throws IOException { + if (clazz == ActorTimerParams.class) { + // Special serializer for this internal classes. + return (T) deserializeActorTimer(content); + } + + if (clazz == ActorReminderParams.class) { + // Special serializer for this internal classes. + return (T) deserializeActorReminder(content); + } + + // Is not one of the special cases. + return super.deserialize(content, clazz); } - // Is not one of the special cases. - return super.deserialize(content, clazz); - } - - /** - * Deserializes an Actor Timer. - * - * @param value Content to be deserialized. - * @return Actor Timer. - * @throws IOException If cannot parse JSON. - */ - private ActorTimerParams deserializeActorTimer(byte[] value) throws IOException { - if (value == null) { - return null; + /** + * Deserializes an Actor Timer. + * + * @param value Content to be deserialized. + * @return Actor Timer. + * @throws IOException If cannot parse JSON. + */ + private ActorTimerParams deserializeActorTimer(byte[] value) throws IOException { + if (value == null) { + return null; + } + + JsonNode node = OBJECT_MAPPER.readTree(value); + String callback = node.get("callback").asText(); + Duration dueTime = extractDurationOrNull(node, "dueTime"); + Duration period = extractDurationOrNull(node, "period"); + byte[] data = node.get("data") != null ? node.get("data").binaryValue() : null; + + return new ActorTimerParams(callback, data, dueTime, period); } - JsonNode node = OBJECT_MAPPER.readTree(value); - String callback = node.get("callback").asText(); - Duration dueTime = extractDurationOrNull(node, "dueTime"); - Duration period = extractDurationOrNull(node, "period"); - byte[] data = node.get("data") != null ? node.get("data").binaryValue() : null; - - return new ActorTimerParams(callback, data, dueTime, period); - } - - /** - * Deserializes an Actor Reminder. - * - * @param value Content to be deserialized. - * @return Actor Reminder. - * @throws IOException If cannot parse JSON. - */ - private ActorReminderParams deserializeActorReminder(byte[] value) throws IOException { - if (value == null) { - return null; + /** + * Deserializes an Actor Reminder. + * + * @param value Content to be deserialized. + * @return Actor Reminder. + * @throws IOException If cannot parse JSON. + */ + private ActorReminderParams deserializeActorReminder(byte[] value) throws IOException { + if (value == null) { + return null; + } + + JsonNode node = OBJECT_MAPPER.readTree(value); + Duration dueTime = extractDurationOrNull(node, "dueTime"); + Duration period = extractDurationOrNull(node, "period"); + Integer repetition = extractRepetitionOrNull(node, "period"); + byte[] data = node.get("data") != null ? node.get("data").binaryValue() : null; + + return new ActorReminderParams(data, dueTime, period, repetition); } - - JsonNode node = OBJECT_MAPPER.readTree(value); - Duration dueTime = extractDurationOrNull(node, "dueTime"); - Duration period = extractDurationOrNull(node, "period"); - byte[] data = node.get("data") != null ? node.get("data").binaryValue() : null; - - return new ActorReminderParams(data, dueTime, period); - } - - /** - * Extracts duration or null. - * - * @param node Node that contains the attribute. - * @param name Attribute name. - * @return Parsed duration or null. - */ - private static Duration extractDurationOrNull(JsonNode node, String name) { - JsonNode valueNode = node.get(name); - if (valueNode == null) { - return null; - } - - return DurationUtils.convertDurationFromDaprFormat(valueNode.asText()); - } } 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 523dfd8e0..194014b56 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 @@ -17,6 +17,11 @@ final class ActorReminderParams { */ private static final Duration MIN_TIME_PERIOD = Duration.ofMillis(-1); + /** + * Minimum repetitions + */ + private static final Integer MIN_REPETITIONS = 0; + /** * Data to be passed in as part of the reminder trigger. */ @@ -32,6 +37,11 @@ final class ActorReminderParams { */ private final Duration period; + /** + * Amount of times the reminder should be executed + */ + private final Integer repetitions; + /** * Instantiates a new instance for the params of a reminder. * @@ -40,11 +50,25 @@ final class ActorReminderParams { * @param period Interval between triggers. */ ActorReminderParams(byte[] data, Duration dueTime, Duration period) { + this(data, dueTime, period, null); + } + + /** + * 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. + * @param period Interval between triggers. + * @param repetitions Amount of times the reminder should be executed. + */ + ActorReminderParams(byte[] data, Duration dueTime, Duration period, Integer repetitions) { validateDueTime("DueTime", dueTime); validatePeriod("Period", period); + validateRepetitions("Repetitions", repetitions); this.data = data; this.dueTime = dueTime; this.period = period; + this.repetitions = repetitions; } /** @@ -74,6 +98,15 @@ byte[] getData() { return data; } + /** + * Gets the amount of times the reminder should be executed + * + * @return Amount of times the reminder should be executed + */ + public Integer getRepetitions() { + return repetitions; + } + /** * Validates due time is valid, throws {@link IllegalArgumentException}. * @@ -101,4 +134,18 @@ private static void validatePeriod(String argName, Duration value) throws Illega throw new IllegalArgumentException(message); } } + + /** + * Validates amount of repetitions is valid, throws {@link IllegalArgumentException}. + * + * @param argName Name of the argument passed in. + * @param value Vale being checked. + */ + private void validateRepetitions(String argName, Integer value) { + if (value != null && value < 0) { + String message = String.format( + "argName: %s - specified value must be greater than %s", argName, MIN_REPETITIONS); + throw new IllegalArgumentException(message); + } + } } diff --git a/sdk-actors/src/main/java/io/dapr/actors/runtime/DaprGrpcClient.java b/sdk-actors/src/main/java/io/dapr/actors/runtime/DaprGrpcClient.java index dcbfddf9a..d6c3fea1d 100644 --- a/sdk-actors/src/main/java/io/dapr/actors/runtime/DaprGrpcClient.java +++ b/sdk-actors/src/main/java/io/dapr/actors/runtime/DaprGrpcClient.java @@ -147,7 +147,7 @@ public Mono registerReminder( .setName(reminderName) .setData(ByteString.copyFrom(reminderParams.getData())) .setDueTime(DurationUtils.convertDurationToDaprFormat(reminderParams.getDueTime())) - .setPeriod(DurationUtils.convertDurationToDaprFormat(reminderParams.getPeriod())) + .setPeriod(DurationUtils.convertDurationWithRepetitionToISO8601Format(new DurationUtils.RepeatedDuration(reminderParams.getPeriod(), reminderParams.getRepetitions()))) .build(); ListenableFuture futureResponse = client.registerActorReminder(req); 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 0151563d9..71176a41b 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 @@ -14,6 +14,24 @@ public class ActorReminderParamsTest { private static final ActorObjectSerializer SERIALIZER = new ActorObjectSerializer(); + @Test + public void noRepetition() { + // this is ok + ActorReminderParams info = new ActorReminderParams(null, Duration.ZERO.plusMinutes(1), Duration.ZERO.plusMillis(-1), null); + } + + @Test + public void validRepetition() { + Integer validRepetition = 2; + ActorReminderParams info = new ActorReminderParams(null, Duration.ZERO.plusMinutes(1), Duration.ZERO.plusMillis(-1), validRepetition); + } + + @Test(expected = IllegalArgumentException.class) + public void outOfRangeRepetition() { + Integer invalidRepetition = -1; + ActorReminderParams info = new ActorReminderParams(null, Duration.ZERO.plusMinutes(1), Duration.ZERO.plusMinutes(-10), invalidRepetition); + } + @Test(expected = IllegalArgumentException.class) public void outOfRangeDueTime() { ActorReminderParams info = new ActorReminderParams(null, Duration.ZERO.plusSeconds(-10), Duration.ZERO.plusMinutes(1)); 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 bc5a067f0..c5d48099e 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 @@ -158,7 +158,7 @@ public void registerActorReminder() { assertEquals(ACTOR_ID, argument.getActorId()); assertEquals(reminderName, argument.getName()); assertEquals(DurationUtils.convertDurationToDaprFormat(params.getDueTime()), argument.getDueTime()); - assertEquals(DurationUtils.convertDurationToDaprFormat(params.getPeriod()), argument.getPeriod()); + assertEquals(params.getPeriod().toString(), argument.getPeriod()); return true; }))).thenReturn(settableFuture); Mono result = client.registerReminder(ACTOR_TYPE, ACTOR_ID, reminderName, params); diff --git a/sdk-actors/src/test/java/io/dapr/actors/runtime/DaprHttpClientTest.java b/sdk-actors/src/test/java/io/dapr/actors/runtime/DaprHttpClientTest.java index d1b553d7a..d65df4170 100644 --- a/sdk-actors/src/test/java/io/dapr/actors/runtime/DaprHttpClientTest.java +++ b/sdk-actors/src/test/java/io/dapr/actors/runtime/DaprHttpClientTest.java @@ -87,6 +87,22 @@ public void registerActorReminder() { assertNull(mono.block()); } + @Test + public void registerActorReminderWithRepetition() { + mockInterceptor.addRule() + .put("http://127.0.0.1:3000/v1.0/actors/DemoActor/1/reminders/reminder") + .respond(EXPECTED_RESULT); + DaprHttp daprHttp = new DaprHttpProxy(Properties.SIDECAR_IP.get(), 3000, okHttpClient); + DaprHttpClient = new DaprHttpClient(daprHttp); + Mono mono = + DaprHttpClient.registerReminder( + "DemoActor", + "1", + "reminder", + new ActorReminderParams("".getBytes(), Duration.ofSeconds(1), Duration.ofSeconds(2), 2)); + assertNull(mono.block()); + } + @Test public void unregisterActorReminder() { mockInterceptor.addRule() diff --git a/sdk/src/main/java/io/dapr/utils/DurationUtils.java b/sdk/src/main/java/io/dapr/utils/DurationUtils.java index fd6bc337d..4284ca9d0 100644 --- a/sdk/src/main/java/io/dapr/utils/DurationUtils.java +++ b/sdk/src/main/java/io/dapr/utils/DurationUtils.java @@ -6,138 +6,214 @@ package io.dapr.utils; import java.time.Duration; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class DurationUtils { - /** - * Converts time from the String format used by Dapr into a Duration. - * - * @param valueString A String representing time in the Dapr runtime's format (e.g. 4h15m50s60ms). - * @return A Duration - */ - public static Duration convertDurationFromDaprFormat(String valueString) { - // Convert the format returned by the Dapr runtime into Duration - // An example of the format is: 4h15m50s60ms. It does not include days. - int hourIndex = valueString.indexOf('h'); - int minuteIndex = valueString.indexOf('m'); - int secondIndex = valueString.indexOf('s'); - int milliIndex = valueString.indexOf("ms"); - - String hoursSpan = valueString.substring(0, hourIndex); - - int hours = Integer.parseInt(hoursSpan); - int days = hours / 24; - hours = hours % 24; - - String minutesSpan = valueString.substring(hourIndex + 1, minuteIndex); - int minutes = Integer.parseInt(minutesSpan); - - String secondsSpan = valueString.substring(minuteIndex + 1, secondIndex); - int seconds = Integer.parseInt(secondsSpan); - - String millisecondsSpan = valueString.substring(secondIndex + 1, milliIndex); - int milliseconds = Integer.parseInt(millisecondsSpan); - - return Duration.ZERO - .plusDays(days) - .plusHours(hours) - .plusMinutes(minutes) - .plusSeconds(seconds) - .plusMillis(milliseconds); - } - - /** - * Converts a Duration to the format used by the Dapr runtime. - * - * @param value Duration - * @return The Duration formatted as a String in the format the Dapr runtime uses (e.g. 4h15m50s60ms) - */ - public static String convertDurationToDaprFormat(Duration value) { - String stringValue = ""; - - // return empty string for anything negative, it'll only happen for reminder "periods", not dueTimes. A - // negative "period" means fire once only. - if (value == Duration.ZERO - || (value.compareTo(Duration.ZERO) == 1)) { - long hours = getDaysPart(value) * 24 + getHoursPart(value); - - StringBuilder sb = new StringBuilder(); - - sb.append(hours); - sb.append("h"); - - sb.append(getMinutesPart((value))); - sb.append("m"); - - sb.append(getSecondsPart((value))); - sb.append("s"); - - sb.append(getMilliSecondsPart((value))); - sb.append("ms"); - - return sb.toString(); + /** + * Converts time from the String format used by Dapr into a Duration. + * + * @param valueString A String representing time in the Dapr runtime's format (e.g. 4h15m50s60ms). + * @return A Duration + */ + public static Duration convertDurationFromDaprFormat(String valueString) { + // Convert the format returned by the Dapr runtime into Duration + // An example of the format is: 4h15m50s60ms. It does not include days. + int hourIndex = valueString.indexOf('h'); + int minuteIndex = valueString.indexOf('m'); + int secondIndex = valueString.indexOf('s'); + int milliIndex = valueString.indexOf("ms"); + + String hoursSpan = valueString.substring(0, hourIndex); + + int hours = Integer.parseInt(hoursSpan); + int days = hours / 24; + hours = hours % 24; + + String minutesSpan = valueString.substring(hourIndex + 1, minuteIndex); + int minutes = Integer.parseInt(minutesSpan); + + String secondsSpan = valueString.substring(minuteIndex + 1, secondIndex); + int seconds = Integer.parseInt(secondsSpan); + + String millisecondsSpan = valueString.substring(secondIndex + 1, milliIndex); + int milliseconds = Integer.parseInt(millisecondsSpan); + + return Duration.ZERO + .plusDays(days) + .plusHours(hours) + .plusMinutes(minutes) + .plusSeconds(seconds) + .plusMillis(milliseconds); } - return stringValue; - } - - /** - * Helper to get the "days" part of the Duration. For example if the duration is 26 hours, this returns 1. - * - * @param d Duration - * @return Number of days. - */ - static long getDaysPart(Duration d) { - long t = d.getSeconds() / 60 / 60 / 24; - return t; - } - - /** - * Helper to get the "hours" part of the Duration. - * For example if the duration is 26 hours, this is 1 day, 2 hours, so this returns 2. - * - * @param d The duration to parse - * @return the hour part of the duration - */ - static long getHoursPart(Duration d) { - long u = (d.getSeconds() / 60 / 60) % 24; - - return u; - } - - /** - * Helper to get the "minutes" part of the Duration. - * - * @param d The duration to parse - * @return the minutes part of the duration - */ - static long getMinutesPart(Duration d) { - long u = (d.getSeconds() / 60) % 60; - - return u; - } - - /** - * Helper to get the "seconds" part of the Duration. - * - * @param d The duration to parse - * @return the seconds part of the duration - */ - static long getSecondsPart(Duration d) { - long u = d.getSeconds() % 60; - - return u; - } - - /** - * Helper to get the "millis" part of the Duration. - * - * @param d The duration to parse - * @return the milliseconds part of the duration - */ - static long getMilliSecondsPart(Duration d) { - long u = d.toMillis() % 1000; - - return u; - } + /** + * Converts a Duration to the format used by the Dapr runtime. + * + * @param value Duration + * @return The Duration formatted as a String in the format the Dapr runtime uses (e.g. 4h15m50s60ms) + */ + public static String convertDurationToDaprFormat(Duration value) { + String stringValue = ""; + + // return empty string for anything negative, it'll only happen for reminder "periods", not dueTimes. A + // negative "period" means fire once only. + if (value == Duration.ZERO + || (value.compareTo(Duration.ZERO) == 1)) { + long hours = getDaysPart(value) * 24 + getHoursPart(value); + + StringBuilder sb = new StringBuilder(); + + sb.append(hours); + sb.append("h"); + + sb.append(getMinutesPart((value))); + sb.append("m"); + + sb.append(getSecondsPart((value))); + sb.append("s"); + + sb.append(getMilliSecondsPart((value))); + sb.append("ms"); + + return sb.toString(); + } + + return stringValue; + } + + /** + * Converts a Duration and an amount of repetitions to the format used by the Dapr runtime. + * The Dapr runtime supports the ISO 8601 interval specification format, which this method will return. + * ex. R5/PT10S: Repeat 5 times, every 10 seconds. + * We have chosen to leverage the Duration.toString() method which already returns a ISO 8601 compliant string, without the repetitions. + * + * @param repeatedDuration Duration + * @return The Duration and repetitions formatted as a String in the ISO 8601 format (e.g. R5/PT10S or PT10S) + */ + public static String convertDurationWithRepetitionToISO8601Format(RepeatedDuration repeatedDuration) { + StringBuilder builder = new StringBuilder(); + if (repeatedDuration.getRepetitions().isPresent()) { + builder.append(String.format("R%s/", repeatedDuration.getRepetitions().get())); + } + + builder.append(repeatedDuration.getDuration().toString()); + + return builder.toString(); + } + + public static RepeatedDuration convertDurationWithRepetitionFromDapr(String input) { + // We can read 2 formats, either ISO8601 or the classic one. + // This method takes care of both; + if (!(input.startsWith("R") || input.startsWith("P"))) { + return new RepeatedDuration(DurationUtils.convertDurationFromDaprFormat(input)); + } else { + return convertDurationWithRepetitionFromISO8601Format(input); + } + } + + public static RepeatedDuration convertDurationWithRepetitionFromISO8601Format(String input) { + final String[] parts = input.split("/"); + + Duration duration; + Integer repetitions = null; + if(parts.length == 1) { + // Format only contains Duration (e.g. PT10S) + duration = Duration.parse(parts[0]); + } else if(parts.length == 2) { + // Format only contains both Repetition & Duration (e.g. R5/PT10S) + repetitions = Integer.parseInt(parts[0].replace("R","")); + duration = Duration.parse(parts[1]); + } else { + throw new IllegalStateException("Input date '" + input + "' does not comply with ISO8601, so it could not be parsed."); + } + + return new RepeatedDuration(duration, repetitions); + } + + /** + * Helper to get the "days" part of the Duration. For example if the duration is 26 hours, this returns 1. + * + * @param d Duration + * @return Number of days. + */ + static long getDaysPart(Duration d) { + long t = d.getSeconds() / 60 / 60 / 24; + return t; + } + + /** + * Helper to get the "hours" part of the Duration. + * For example if the duration is 26 hours, this is 1 day, 2 hours, so this returns 2. + * + * @param d The duration to parse + * @return the hour part of the duration + */ + static long getHoursPart(Duration d) { + long u = (d.getSeconds() / 60 / 60) % 24; + + return u; + } + + /** + * Helper to get the "minutes" part of the Duration. + * + * @param d The duration to parse + * @return the minutes part of the duration + */ + static long getMinutesPart(Duration d) { + long u = (d.getSeconds() / 60) % 60; + + return u; + } + + /** + * Helper to get the "seconds" part of the Duration. + * + * @param d The duration to parse + * @return the seconds part of the duration + */ + static long getSecondsPart(Duration d) { + long u = d.getSeconds() % 60; + + return u; + } + + /** + * Helper to get the "millis" part of the Duration. + * + * @param d The duration to parse + * @return the milliseconds part of the duration + */ + static long getMilliSecondsPart(Duration d) { + long u = d.toMillis() % 1000; + + return u; + } + + public static class RepeatedDuration { + private final Duration duration; + private final Optional repetitions; + + public RepeatedDuration(Duration duration, Integer repetitions) { + this.duration = duration; + this.repetitions = Optional.ofNullable(repetitions); + } + + public RepeatedDuration(Duration duration) { + this(duration, null); + } + + public Duration getDuration() { + return duration; + } + + public Optional getRepetitions() { + return repetitions; + } + } + + } diff --git a/sdk/src/test/java/io/dapr/utils/DurationUtilsTest.java b/sdk/src/test/java/io/dapr/utils/DurationUtilsTest.java index 12b391ef7..486d4e463 100644 --- a/sdk/src/test/java/io/dapr/utils/DurationUtilsTest.java +++ b/sdk/src/test/java/io/dapr/utils/DurationUtilsTest.java @@ -31,6 +31,24 @@ public void largeHours() { Assert.assertEquals(s, t); } + @Test + public void convertRepeatedDurationBothWays() { + String s = "R10/PT10S"; + DurationUtils.RepeatedDuration d1 = DurationUtils.convertDurationWithRepetitionFromISO8601Format(s); + + String t = DurationUtils.convertDurationWithRepetitionToISO8601Format(d1); + Assert.assertEquals(s, t); + } + + @Test + public void convertRepeatedDurationWithoutRepetitionBothWays() { + String s = "PT10S"; + DurationUtils.RepeatedDuration d1 = DurationUtils.convertDurationWithRepetitionFromISO8601Format(s); + + String t = DurationUtils.convertDurationWithRepetitionToISO8601Format(d1); + Assert.assertEquals(s, t); + } + @Test public void negativeDuration() { Duration d = Duration.ofSeconds(-99);