From 6e77f12a39fcc5be1891ec6e52cf1a805b5fc697 Mon Sep 17 00:00:00 2001 From: Yash Nisar Date: Thu, 26 Jan 2023 22:13:42 -0600 Subject: [PATCH] Add support for repetition and ISO 8601 for reminders (#974) Signed-off-by: Yash Nisar Signed-off-by: Yash Nisar --- examples/Actor/ActorClient/Program.cs | 9 ++ examples/Actor/DemoActor/DemoActor.cs | 10 ++ examples/Actor/IDemoActor/IDemoActor.cs | 15 ++ src/Dapr.Actors/Resources/SR.Designer.cs | 10 ++ src/Dapr.Actors/Resources/SR.resx | 3 + src/Dapr.Actors/Runtime/Actor.cs | 78 +++++++++- src/Dapr.Actors/Runtime/ActorReminder.cs | 79 ++++++++++ .../Runtime/ActorReminderOptions.cs | 5 + src/Dapr.Actors/Runtime/ConverterUtils.cs | 76 +++++++++ .../Runtime/DefaultActorTimerManager.cs | 3 +- src/Dapr.Actors/Runtime/ReminderInfo.cs | 12 +- .../DaprFormatTimeSpanTests.cs | 67 ++++++++ .../Runtime/ActorReminderInfoTests.cs | 4 +- .../Runtime/DefaultActorTimerManagerTests.cs | 88 +++++++++++ test/Dapr.Actors.Test/TestDaprInteractor.cs | 145 ++++++++++++++++++ .../Reminders/IReminderActor.cs | 28 ++-- .../Dapr.E2E.Test.App/Actors/ReminderActor.cs | 26 ++++ .../Actors/E2ETests.ReminderTests.cs | 52 +++++++ 18 files changed, 691 insertions(+), 19 deletions(-) create mode 100644 test/Dapr.Actors.Test/Runtime/DefaultActorTimerManagerTests.cs create mode 100644 test/Dapr.Actors.Test/TestDaprInteractor.cs diff --git a/examples/Actor/ActorClient/Program.cs b/examples/Actor/ActorClient/Program.cs index e78d238a5..103aed6b9 100644 --- a/examples/Actor/ActorClient/Program.cs +++ b/examples/Actor/ActorClient/Program.cs @@ -100,6 +100,15 @@ public static async Task Main(string[] args) await proxy.UnregisterTimer(); Console.WriteLine("Deregistering reminder. Reminders are durable and would not stop until an explicit deregistration or the actor is deleted."); await proxy.UnregisterReminder(); + + Console.WriteLine("Registering reminder with repetitions - The reminder will repeat 3 times."); + await proxy.RegisterReminderWithRepetitions(3); + Console.WriteLine("Waiting so the reminder can be triggered"); + await Task.Delay(5000); + Console.WriteLine("Registering reminder with ttl and repetitions, i.e. reminder stops when either condition is met - The reminder will repeat 2 times."); + await proxy.RegisterReminderWithTtlAndRepetitions(TimeSpan.FromSeconds(5), 2); + Console.WriteLine("Deregistering reminder. Reminders are durable and would not stop until an explicit deregistration or the actor is deleted."); + await proxy.UnregisterReminder(); Console.WriteLine("Registering reminder and Timer with TTL - The reminder will self delete after 10 seconds."); await proxy.RegisterReminderWithTtl(TimeSpan.FromSeconds(10)); diff --git a/examples/Actor/DemoActor/DemoActor.cs b/examples/Actor/DemoActor/DemoActor.cs index 04a3ebc04..057b7df6d 100644 --- a/examples/Actor/DemoActor/DemoActor.cs +++ b/examples/Actor/DemoActor/DemoActor.cs @@ -74,6 +74,16 @@ public async Task RegisterReminderWithTtl(TimeSpan ttl) { await this.RegisterReminderAsync("TestReminder", null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), ttl); } + + public async Task RegisterReminderWithRepetitions(int repetitions) + { + await this.RegisterReminderAsync("TestReminder", null, TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1), repetitions); + } + + public async Task RegisterReminderWithTtlAndRepetitions(TimeSpan ttl, int repetitions) + { + await this.RegisterReminderAsync("TestReminder", null, TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1), repetitions, ttl); + } public Task UnregisterReminder() { diff --git a/examples/Actor/IDemoActor/IDemoActor.cs b/examples/Actor/IDemoActor/IDemoActor.cs index 398686288..3220dfdbd 100644 --- a/examples/Actor/IDemoActor/IDemoActor.cs +++ b/examples/Actor/IDemoActor/IDemoActor.cs @@ -78,6 +78,21 @@ public interface IDemoActor : IActor /// Optional TimeSpan that dictates when the timer expires. /// A task that represents the asynchronous save operation. Task RegisterTimerWithTtl(TimeSpan ttl); + + /// + /// Registers a reminder with repetitions. + /// + /// The number of repetitions for which the reminder should be invoked. + /// A task that represents the asynchronous save operation. + Task RegisterReminderWithRepetitions(int repetitions); + + /// + /// Registers a reminder with ttl and repetitions. + /// + /// TimeSpan that dictates when the timer expires. + /// The number of repetitions for which the reminder should be invoked. + /// A task that represents the asynchronous save operation. + Task RegisterReminderWithTtlAndRepetitions(TimeSpan ttl, int repetitions); /// /// Unregisters the registered timer. diff --git a/src/Dapr.Actors/Resources/SR.Designer.cs b/src/Dapr.Actors/Resources/SR.Designer.cs index 21c546d43..f507b596a 100644 --- a/src/Dapr.Actors/Resources/SR.Designer.cs +++ b/src/Dapr.Actors/Resources/SR.Designer.cs @@ -392,5 +392,15 @@ internal static string TimerArgumentOutOfRange { return ResourceManager.GetString("TimerArgumentOutOfRange", resourceCulture); } } + + /// + /// Looks up a localized string stating repetitions specified value must be a valid positive integer. + /// + internal static string RepetitionsArgumentOutOfRange + { + get { + return ResourceManager.GetString("RepetitionsArgumentOutOfRange", resourceCulture); + } + } } } diff --git a/src/Dapr.Actors/Resources/SR.resx b/src/Dapr.Actors/Resources/SR.resx index b19f3e4fa..7858857c0 100644 --- a/src/Dapr.Actors/Resources/SR.resx +++ b/src/Dapr.Actors/Resources/SR.resx @@ -223,4 +223,7 @@ TimeSpan TotalMilliseconds specified value must be between {0} and {1} + + The repetitions {0} specified must be a valid positive integer. + \ No newline at end of file diff --git a/src/Dapr.Actors/Runtime/Actor.cs b/src/Dapr.Actors/Runtime/Actor.cs index e838b1417..0f74513a1 100644 --- a/src/Dapr.Actors/Runtime/Actor.cs +++ b/src/Dapr.Actors/Runtime/Actor.cs @@ -15,7 +15,6 @@ namespace Dapr.Actors.Runtime { using System; using System.Reflection; - using System.Text.Json; using System.Threading.Tasks; using Dapr.Actors.Client; using Microsoft.Extensions.Logging; @@ -264,6 +263,83 @@ protected async Task RegisterReminderAsync( Ttl = ttl }); } + + /// + /// Registers a reminder with the actor. + /// + /// The name of the reminder to register. The name must be unique per actor. + /// User state passed to the reminder invocation. + /// The amount of time to delay before invoking the reminder for the first time. Specify negative one (-1) milliseconds to disable invocation. Specify zero (0) to invoke the reminder immediately after registration. + /// + /// The time interval between reminder invocations after the first invocation. + /// + /// The number of repetitions for which the reminder should be invoked. + /// The time interval after which the reminder will expire. + /// + /// A task that represents the asynchronous registration operation. The result of the task provides information about the registered reminder and is used to unregister the reminder using UnregisterReminderAsync. + /// + /// + /// + /// The class deriving from must implement to consume reminder invocations. Multiple reminders can be registered at any time, uniquely identified by . Existing reminders can also be updated by calling this method again. Reminder invocations are synchronized both with other reminders and other actor method callbacks. + /// + /// + protected async Task RegisterReminderAsync( + string reminderName, + byte[] state, + TimeSpan dueTime, + TimeSpan period, + int repetitions, + TimeSpan ttl) + { + return await RegisterReminderAsync(new ActorReminderOptions + { + ActorTypeName = this.actorTypeName, + Id = this.Id, + ReminderName = reminderName, + State = state, + DueTime = dueTime, + Period = period, + Repetitions = repetitions, + Ttl = ttl + }); + } + + /// + /// Registers a reminder with the actor. + /// + /// The name of the reminder to register. The name must be unique per actor. + /// User state passed to the reminder invocation. + /// The amount of time to delay before invoking the reminder for the first time. Specify negative one (-1) milliseconds to disable invocation. Specify zero (0) to invoke the reminder immediately after registration. + /// + /// The time interval between reminder invocations after the first invocation. + /// + /// The number of repetitions for which the reminder should be invoked. + /// + /// A task that represents the asynchronous registration operation. The result of the task provides information about the registered reminder and is used to unregister the reminder using UnregisterReminderAsync. + /// + /// + /// + /// The class deriving from must implement to consume reminder invocations. Multiple reminders can be registered at any time, uniquely identified by . Existing reminders can also be updated by calling this method again. Reminder invocations are synchronized both with other reminders and other actor method callbacks. + /// + /// + protected async Task RegisterReminderAsync( + string reminderName, + byte[] state, + TimeSpan dueTime, + TimeSpan period, + int repetitions) + { + return await RegisterReminderAsync(new ActorReminderOptions + { + ActorTypeName = this.actorTypeName, + Id = this.Id, + ReminderName = reminderName, + State = state, + DueTime = dueTime, + Period = period, + Repetitions = repetitions + }); + } /// /// Registers a reminder with the actor. diff --git a/src/Dapr.Actors/Runtime/ActorReminder.cs b/src/Dapr.Actors/Runtime/ActorReminder.cs index a39b5c769..930df29b8 100644 --- a/src/Dapr.Actors/Runtime/ActorReminder.cs +++ b/src/Dapr.Actors/Runtime/ActorReminder.cs @@ -85,6 +85,71 @@ public ActorReminder( { } + /// + /// Initializes a new instance of . + /// + /// The actor type. + /// The actor id. + /// The reminder name. + /// The state associated with the reminder. + /// The reminder due time. + /// The reminder period. + /// The number of times reminder should be invoked. + /// The reminder ttl. + public ActorReminder( + string actorType, + ActorId actorId, + string name, + byte[] state, + TimeSpan dueTime, + TimeSpan period, + int? repetitions, + TimeSpan? ttl) + : this(new ActorReminderOptions + { + ActorTypeName = actorType, + Id = actorId, + ReminderName = name, + State = state, + DueTime = dueTime, + Period = period, + Repetitions = repetitions, + Ttl = ttl + }) + { + } + + /// + /// Initializes a new instance of . + /// + /// The actor type. + /// The actor id. + /// The reminder name. + /// The state associated with the reminder. + /// The reminder due time. + /// The reminder period. + /// The number of times reminder should be invoked. + public ActorReminder( + string actorType, + ActorId actorId, + string name, + byte[] state, + TimeSpan dueTime, + TimeSpan period, + int? repetitions) + : this(new ActorReminderOptions + { + ActorTypeName = actorType, + Id = actorId, + ReminderName = name, + State = state, + DueTime = dueTime, + Period = period, + Repetitions = repetitions + }) + { + } + /// /// Initializes a new instance of . /// @@ -118,11 +183,20 @@ internal ActorReminder(ActorReminderOptions options) options.DueTime, TimeSpan.MaxValue.TotalMilliseconds)); } + + if (options.Repetitions != null && options.Repetitions <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options.Repetitions), string.Format( + CultureInfo.CurrentCulture, + SR.RepetitionsArgumentOutOfRange, + options.Repetitions)); + } this.State = options.State; this.DueTime = options.DueTime; this.Period = options.Period; this.Ttl = options.Ttl; + this.Repetitions = options.Repetitions; } /// @@ -144,5 +218,10 @@ internal ActorReminder(ActorReminderOptions options) /// The optional that states when the reminder will expire. /// public TimeSpan? Ttl { get; } + + /// + /// The optional property that gets the number of invocations of the reminder left. + /// + public int? Repetitions { get; } } } diff --git a/src/Dapr.Actors/Runtime/ActorReminderOptions.cs b/src/Dapr.Actors/Runtime/ActorReminderOptions.cs index 6c6b8aaa9..5d81d68fa 100644 --- a/src/Dapr.Actors/Runtime/ActorReminderOptions.cs +++ b/src/Dapr.Actors/Runtime/ActorReminderOptions.cs @@ -40,5 +40,10 @@ internal class ActorReminderOptions /// An optional that determines when the reminder will expire. /// public TimeSpan? Ttl { get; set; } + + /// + /// The number of repetitions for which the reminder should be invoked. + /// + public int? Repetitions { get; set; } } } diff --git a/src/Dapr.Actors/Runtime/ConverterUtils.cs b/src/Dapr.Actors/Runtime/ConverterUtils.cs index 5e46e5e53..80155cf37 100644 --- a/src/Dapr.Actors/Runtime/ConverterUtils.cs +++ b/src/Dapr.Actors/Runtime/ConverterUtils.cs @@ -14,9 +14,12 @@ namespace Dapr.Actors.Runtime { using System; + using System.Text; + using System.Text.RegularExpressions; internal class ConverterUtils { + private static Regex regex = new Regex("^(R(?\\d+)/)?P((?\\d+)Y)?((?\\d+)M)?((?\\d+)W)?((?\\d+)D)?(T((?\\d+)H)?((?\\d+)M)?((?\\d+)S)?)?$", RegexOptions.Compiled); public static TimeSpan ConvertTimeSpanFromDaprFormat(string valueString) { if (string.IsNullOrEmpty(valueString)) @@ -68,5 +71,78 @@ public static string ConvertTimeSpanValueInDaprFormat(TimeSpan? value) return stringValue; } + + public static string ConvertTimeSpanValueInISO8601Format(TimeSpan value, int? repetitions) + { + StringBuilder builder = new StringBuilder(); + + if (repetitions == null) + { + return ConvertTimeSpanValueInDaprFormat(value); + } + + if (value.Milliseconds > 0) + { + throw new ArgumentException("The TimeSpan value, combined with repetition cannot be in milliseconds.", nameof(value)); + } + + builder.AppendFormat("R{0}/P", repetitions); + + if(value.Days > 0) + { + builder.AppendFormat("{0}D", value.Days); + } + + builder.Append("T"); + + if(value.Hours > 0) + { + builder.AppendFormat("{0}H", value.Hours); + } + + if(value.Minutes > 0) + { + builder.AppendFormat("{0}M", value.Minutes); + } + + if(value.Seconds > 0) + { + builder.AppendFormat("{0}S", value.Seconds); + } + return builder.ToString(); + } + + public static (TimeSpan, int?) ConvertTimeSpanValueFromISO8601Format(string valueString) + { + // ISO 8601 format can be Rn/PaYbMcHTdHeMfS or PaYbMcHTdHeMfS so if it does + // not start with R or P then assuming it to default Dapr format without repetition + if (!(valueString.StartsWith('R') || valueString.StartsWith('P'))) + { + return (ConvertTimeSpanFromDaprFormat(valueString), -1); + } + + var matches = regex.Match(valueString); + + var repetition = matches.Groups["repetition"].Success ? int.Parse(matches.Groups["repetition"].Value) : (int?)null; + + var days = 0; + var year = matches.Groups["year"].Success ? int.Parse(matches.Groups["year"].Value) : 0; + days = year * 365; + + var month = matches.Groups["month"].Success ? int.Parse(matches.Groups["month"].Value) : 0; + days += month * 30; + + var week = matches.Groups["week"].Success ? int.Parse(matches.Groups["week"].Value) : 0; + days += week * 7; + + var day = matches.Groups["day"].Success ? int.Parse(matches.Groups["day"].Value) : 0; + days += day; + + var hour = matches.Groups["hour"].Success ? int.Parse(matches.Groups["hour"].Value) : 0; + var minute = matches.Groups["minute"].Success ? int.Parse(matches.Groups["minute"].Value) : 0; + var second = matches.Groups["second"].Success ? int.Parse(matches.Groups["second"].Value) : 0; + + return (new TimeSpan(days, hour, minute, second), repetition); + } } } diff --git a/src/Dapr.Actors/Runtime/DefaultActorTimerManager.cs b/src/Dapr.Actors/Runtime/DefaultActorTimerManager.cs index f80b97e70..d3378c962 100644 --- a/src/Dapr.Actors/Runtime/DefaultActorTimerManager.cs +++ b/src/Dapr.Actors/Runtime/DefaultActorTimerManager.cs @@ -73,7 +73,8 @@ public override async Task UnregisterTimerAsync(ActorTimerToken timer) private async ValueTask SerializeReminderAsync(ActorReminder reminder) { - var info = new ReminderInfo(reminder.State, reminder.DueTime, reminder.Period, reminder.Ttl); + var info = new ReminderInfo(reminder.State, reminder.DueTime, reminder.Period, reminder.Repetitions, + reminder.Ttl); return await info.SerializeAsync(); } } diff --git a/src/Dapr.Actors/Runtime/ReminderInfo.cs b/src/Dapr.Actors/Runtime/ReminderInfo.cs index 83ff37e32..84e56bbc7 100644 --- a/src/Dapr.Actors/Runtime/ReminderInfo.cs +++ b/src/Dapr.Actors/Runtime/ReminderInfo.cs @@ -26,12 +26,14 @@ public ReminderInfo( byte[] data, TimeSpan dueTime, TimeSpan period, + int? repetitions = null, TimeSpan? ttl = null) { this.Data = data; this.DueTime = dueTime; this.Period = period; this.Ttl = ttl; + this.Repetitions = repetitions; } public TimeSpan DueTime { get; private set; } @@ -41,6 +43,8 @@ public ReminderInfo( public byte[] Data { get; private set; } public TimeSpan? Ttl { get; private set; } + + public int? Repetitions { get; private set; } internal static async Task DeserializeAsync(Stream stream) { @@ -49,6 +53,7 @@ internal static async Task DeserializeAsync(Stream stream) var dueTime = default(TimeSpan); var period = default(TimeSpan); var data = default(byte[]); + int? repetition = null; TimeSpan? ttl = null; if (json.TryGetProperty("dueTime", out var dueTimeProperty)) @@ -60,7 +65,7 @@ internal static async Task DeserializeAsync(Stream stream) if (json.TryGetProperty("period", out var periodProperty)) { var periodString = periodProperty.GetString(); - period = ConverterUtils.ConvertTimeSpanFromDaprFormat(periodString); + (period, repetition) = ConverterUtils.ConvertTimeSpanValueFromISO8601Format(periodString); } if (json.TryGetProperty("data", out var dataProperty) && dataProperty.ValueKind != JsonValueKind.Null) @@ -74,7 +79,7 @@ internal static async Task DeserializeAsync(Stream stream) ttl = ConverterUtils.ConvertTimeSpanFromDaprFormat(ttlString); } - return new ReminderInfo(data, dueTime, period, ttl); + return new ReminderInfo(data, dueTime, period, repetition, ttl); } internal async ValueTask SerializeAsync() @@ -84,7 +89,8 @@ internal async ValueTask SerializeAsync() writer.WriteStartObject(); writer.WriteString("dueTime", ConverterUtils.ConvertTimeSpanValueInDaprFormat(this.DueTime)); - writer.WriteString("period", ConverterUtils.ConvertTimeSpanValueInDaprFormat(this.Period)); + writer.WriteString("period", ConverterUtils.ConvertTimeSpanValueInISO8601Format( + this.Period, this.Repetitions)); writer.WriteBase64String("data", this.Data); if (Ttl != null) diff --git a/test/Dapr.Actors.Test/DaprFormatTimeSpanTests.cs b/test/Dapr.Actors.Test/DaprFormatTimeSpanTests.cs index 08376875c..9d5710dfc 100644 --- a/test/Dapr.Actors.Test/DaprFormatTimeSpanTests.cs +++ b/test/Dapr.Actors.Test/DaprFormatTimeSpanTests.cs @@ -39,6 +39,56 @@ public class DaprFormatTimeSpanTests new TimeSpan(0, 0, 35, 10, 12), }, }; + + public static readonly IEnumerable DaprReminderISO8601FormatTimeSpanAndExpectedDeserializedValues = new List + { + new object[] + { + "R10/PT10S", + new TimeSpan(0, 0, 0, 10, 0), + 10 + }, + new object[] + { + "PT10H5M10S", + new TimeSpan(0, 10, 5, 10, 0), + null, + }, + new object[] + { + "P1Y1M1W1DT1H1M1S", + new TimeSpan(403, 1, 1, 1, 0), + null, + }, + new object[] + { + "R3/P2W3DT4H59M", + new TimeSpan(17, 4, 59, 0, 0), + 3, + } + }; + + public static readonly IEnumerable DaprReminderTimeSpanToDaprISO8601Format = new List + { + new object[] + { + new TimeSpan(10, 10, 10, 10), + 1, + "R1/P10DT10H10M10S" + }, + new object[] + { + new TimeSpan(17, 4, 59, 0, 0), + 3, + "R3/P17DT4H59M" + }, + new object[] + { + new TimeSpan(0, 7, 23, 12, 0), + null, + "7h23m12s0ms" + } + }; [Theory] [MemberData(nameof(DaprFormatTimeSpanJsonStringsAndExpectedDeserializedValues))] @@ -86,6 +136,23 @@ public void DaprFormatTimespanEmpty() Assert.Equal(never, convert(null)); Assert.Equal(never, convert(string.Empty)); } + + [Theory] + [MemberData(nameof(DaprReminderISO8601FormatTimeSpanAndExpectedDeserializedValues))] + public void DaprReminderFormat_TimeSpan_Parsing(string valueString, TimeSpan expectedDuration, int? expectedRepetition) + { + (TimeSpan duration, int? repetition) = ConverterUtils.ConvertTimeSpanValueFromISO8601Format(valueString); + Assert.Equal(expectedDuration, duration); + Assert.Equal(expectedRepetition, repetition); + } + + [Theory] + [MemberData(nameof(DaprReminderTimeSpanToDaprISO8601Format))] + public void DaprReminderFormat_ConvertFromTimeSpan_ToDaprFormat(TimeSpan period, int? repetitions, string expectedValue) + { + var actualValue = ConverterUtils.ConvertTimeSpanValueInISO8601Format(period, repetitions); + Assert.Equal(expectedValue, actualValue); + } } } #pragma warning restore 0618 diff --git a/test/Dapr.Actors.Test/Runtime/ActorReminderInfoTests.cs b/test/Dapr.Actors.Test/Runtime/ActorReminderInfoTests.cs index dc471eedd..9ff0ad2b1 100644 --- a/test/Dapr.Actors.Test/Runtime/ActorReminderInfoTests.cs +++ b/test/Dapr.Actors.Test/Runtime/ActorReminderInfoTests.cs @@ -11,7 +11,7 @@ public class ActorReminderInfoTests [Fact] public async Task TestActorReminderInfo_SerializeExcludesNullTtl() { - var info = new ReminderInfo(new byte[] { }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10)); + var info = new ReminderInfo(new byte[] { }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), 1); var serialized = await info.SerializeAsync(); Assert.DoesNotContain("ttl", serialized); @@ -22,7 +22,7 @@ public async Task TestActorReminderInfo_SerializeExcludesNullTtl() [Fact] public async Task TestActorReminderInfo_SerializeIncludesTtlWhenSet() { - var info = new ReminderInfo(new byte[] { }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(1)); + var info = new ReminderInfo(new byte[] { }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), 1, TimeSpan.FromSeconds(1)); var serialized = await info.SerializeAsync(); Assert.Contains("ttl", serialized); diff --git a/test/Dapr.Actors.Test/Runtime/DefaultActorTimerManagerTests.cs b/test/Dapr.Actors.Test/Runtime/DefaultActorTimerManagerTests.cs new file mode 100644 index 000000000..352b431db --- /dev/null +++ b/test/Dapr.Actors.Test/Runtime/DefaultActorTimerManagerTests.cs @@ -0,0 +1,88 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------ + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using JsonSerializer = System.Text.Json.JsonSerializer; + +namespace Dapr.Actors.Runtime +{ + public sealed class DefaultActorTimerManagerTests + { + /// + /// When register reminder is called, interactor is called with correct data. + /// + /// + [Fact] + public async Task RegisterReminderAsync_CallsInteractor_WithCorrectData() + { + var actorId = "123"; + var actorType = "abc"; + var interactor = new Mock(); + var defaultActorTimerManager = new DefaultActorTimerManager(interactor.Object); + var actorReminder = new ActorReminder(actorType, new ActorId(actorId), "remindername", new byte[] { }, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); + var actualData = string.Empty; + + interactor + .Setup(d => d.RegisterReminderAsync(actorType, actorId, "remindername", It.Is(data => !string.IsNullOrEmpty(data)), It.IsAny())) + .Callback((actorType, actorID, reminderName, data, token) => { + actualData = data; + }) + .Returns(Task.CompletedTask); + + await defaultActorTimerManager.RegisterReminderAsync(actorReminder); + + JsonElement json = JsonSerializer.Deserialize(actualData); + + var isPeriodSet = json.TryGetProperty("period", out var period); + var isdDueTimeSet = json.TryGetProperty("dueTime", out var dueTime); + + Assert.True(isPeriodSet); + Assert.True(isdDueTimeSet); + + Assert.Equal("0h1m0s0ms", period.GetString()); + Assert.Equal("0h1m0s0ms", dueTime.GetString()); + } + + /// + /// When register reminder is called with repetition, interactor is called with correct data. + /// + /// + [Fact] + public async Task RegisterReminderAsync_WithRepetition_CallsInteractor_WithCorrectData() + { + var actorId = "123"; + var actorType = "abc"; + var interactor = new Mock(); + var defaultActorTimerManager = new DefaultActorTimerManager(interactor.Object); + var actorReminder = new ActorReminder(actorType, new ActorId(actorId), "remindername", new byte[] { }, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), 10); + var actualData = string.Empty; + + interactor + .Setup(d => d.RegisterReminderAsync(actorType, actorId, "remindername", It.Is(data => !string.IsNullOrEmpty(data)), It.IsAny())) + .Callback((actorType, actorID, reminderName, data, token) => { + actualData = data; + }) + .Returns(Task.CompletedTask); + + await defaultActorTimerManager.RegisterReminderAsync(actorReminder); + + JsonElement json = JsonSerializer.Deserialize(actualData); + + var isPeriodSet = json.TryGetProperty("period", out var period); + var isdDueTimeSet = json.TryGetProperty("dueTime", out var dueTime); + + Assert.True(isPeriodSet); + Assert.True(isdDueTimeSet); + + Assert.Equal("R10/PT1M", period.GetString()); + Assert.Equal("0h1m0s0ms", dueTime.GetString()); + } + } +} diff --git a/test/Dapr.Actors.Test/TestDaprInteractor.cs b/test/Dapr.Actors.Test/TestDaprInteractor.cs new file mode 100644 index 000000000..1b382208d --- /dev/null +++ b/test/Dapr.Actors.Test/TestDaprInteractor.cs @@ -0,0 +1,145 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors.Communication; + +namespace Dapr.Actors +{ + /// + /// A Wrapper class for IDaprInteractor which is mainly created for testing. + /// + public class TestDaprInteractor : IDaprInteractor + { + private TestDaprInteractor _testDaprInteractor; + + /// + /// The TestDaprInteractor constructor. + /// + /// + public TestDaprInteractor(TestDaprInteractor testDaprInteractor) + { + _testDaprInteractor = testDaprInteractor; + } + + /// + /// The TestDaprInteractor constructor. + /// + public TestDaprInteractor() + { + + } + + /// + /// Register a reminder. + /// + /// Type of actor. + /// ActorId. + /// Name of reminder to register. + /// JSON reminder data as per the Dapr spec. + /// Cancels the operation. + /// A representing the result of the asynchronous operation. + public virtual async Task RegisterReminderAsync(string actorType, string actorId, string reminderName, string data, + CancellationToken cancellationToken = default) + { + await _testDaprInteractor.RegisterReminderAsync(actorType, actorId, reminderName, data); + } + + /// + /// Invokes an Actor method on Dapr without remoting. + /// + /// Type of actor. + /// ActorId. + /// Method name to invoke. + /// Serialized body. + /// Cancels the operation. + /// A task that represents the asynchronous operation. + public Task InvokeActorMethodWithoutRemotingAsync(string actorType, string actorId, string methodName, + string jsonPayload, CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + + /// + /// Saves state batch to Dapr. + /// + /// Type of actor. + /// ActorId. + /// JSON data with state changes as per the Dapr spec for transaction state update. + /// Cancels the operation. + /// A task that represents the asynchronous operation. + public Task SaveStateTransactionallyAsync(string actorType, string actorId, string data, + CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + + /// + /// Saves a state to Dapr. + /// + /// Type of actor. + /// ActorId. + /// Name of key to get value for. + /// Cancels the operation. + /// A task that represents the asynchronous operation. + public Task GetStateAsync(string actorType, string actorId, string keyName, CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + + /// + /// Invokes Actor method. + /// + /// Serializers manager for remoting calls. + /// Actor Request Message. + /// Cancels the operation. + /// A representing the result of the asynchronous operation. + Task IDaprInteractor.InvokeActorMethodWithRemotingAsync(ActorMessageSerializersManager serializersManager, + IActorRequestMessage remotingRequestRequestMessage, CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + + /// + /// Unregisters a reminder. + /// + /// Type of actor. + /// ActorId. + /// Name of reminder to unregister. + /// Cancels the operation. + /// A representing the result of the asynchronous operation. + public Task UnregisterReminderAsync(string actorType, string actorId, string reminderName, + CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + + /// + /// Registers a timer. + /// + /// Type of actor. + /// ActorId. + /// Name of timer to register. + /// JSON reminder data as per the Dapr spec. + /// Cancels the operation. + /// A representing the result of the asynchronous operation. + public Task RegisterTimerAsync(string actorType, string actorId, string timerName, string data, + CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + + /// + /// Unregisters a timer. + /// + /// Type of actor. + /// ActorId. + /// Name of timer to register. + /// Cancels the operation. + /// A representing the result of the asynchronous operation. + public Task UnregisterTimerAsync(string actorType, string actorId, string timerName, + CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/test/Dapr.E2E.Test.Actors/Reminders/IReminderActor.cs b/test/Dapr.E2E.Test.Actors/Reminders/IReminderActor.cs index 8ba468f48..33a5e0d05 100644 --- a/test/Dapr.E2E.Test.Actors/Reminders/IReminderActor.cs +++ b/test/Dapr.E2E.Test.Actors/Reminders/IReminderActor.cs @@ -1,15 +1,15 @@ -// ------------------------------------------------------------------------ -// Copyright 2021 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. -// ------------------------------------------------------------------------ +// ------------------------------------------------------------------------ +// Copyright 2021 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. +// ------------------------------------------------------------------------ using System; using System.Threading.Tasks; @@ -23,6 +23,10 @@ public interface IReminderActor : IPingActor, IActor Task StartReminderWithTtl(TimeSpan ttl); + Task StartReminderWithRepetitions(int repetitions); + + Task StartReminderWithTtlAndRepetitions(TimeSpan ttl, int repetitions); + Task GetState(); } } diff --git a/test/Dapr.E2E.Test.App/Actors/ReminderActor.cs b/test/Dapr.E2E.Test.App/Actors/ReminderActor.cs index 4aa2aa356..f9b9d7573 100644 --- a/test/Dapr.E2E.Test.App/Actors/ReminderActor.cs +++ b/test/Dapr.E2E.Test.App/Actors/ReminderActor.cs @@ -54,6 +54,32 @@ public async Task StartReminderWithTtl(TimeSpan ttl) await this.RegisterReminderAsync("test-reminder-ttl", bytes, dueTime: TimeSpan.Zero, period: TimeSpan.FromSeconds(1), ttl: ttl); await this.StateManager.SetStateAsync("reminder-state", new State() { IsReminderRunning = true, }); } + + public async Task StartReminderWithRepetitions(int repetitions) + { + var options = new StartReminderOptions() + { + Total = 100, + }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(options, this.Host.JsonSerializerOptions); + await this.RegisterReminderAsync("test-reminder-repetition", bytes, dueTime: TimeSpan.Zero, + period: TimeSpan.FromSeconds(1), repetitions: repetitions); + await this.StateManager.SetStateAsync("reminder-state", new State() + { IsReminderRunning = true, }); + } + + public async Task StartReminderWithTtlAndRepetitions(TimeSpan ttl, int repetitions) + { + var options = new StartReminderOptions() + { + Total = 100, + }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(options, this.Host.JsonSerializerOptions); + await this.RegisterReminderAsync("test-reminder-ttl-repetition", bytes, dueTime: TimeSpan.Zero, + period: TimeSpan.FromSeconds(1), repetitions: repetitions, ttl: ttl); + await this.StateManager.SetStateAsync("reminder-state", new State() + { IsReminderRunning = true, }); + } public async Task ReceiveReminderAsync(string reminderName, byte[] bytes, TimeSpan dueTime, TimeSpan period) { diff --git a/test/Dapr.E2E.Test/Actors/E2ETests.ReminderTests.cs b/test/Dapr.E2E.Test/Actors/E2ETests.ReminderTests.cs index 626de8c9f..50cd87219 100644 --- a/test/Dapr.E2E.Test/Actors/E2ETests.ReminderTests.cs +++ b/test/Dapr.E2E.Test/Actors/E2ETests.ReminderTests.cs @@ -49,6 +49,58 @@ public async Task ActorCanStartAndStopReminder() Assert.Equal(10, state.Count); } + [Fact] + public async Task ActorCanStartReminderWithRepetitions() + { + int repetitions = 5; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var proxy = this.ProxyFactory.CreateActorProxy(ActorId.CreateRandom(), "ReminderActor"); + + await WaitForActorRuntimeAsync(proxy, cts.Token); + + // Reminder that should fire 5 times (repetitions) at an interval of 1s + await proxy.StartReminderWithRepetitions(repetitions); + var start = DateTime.Now; + + await Task.Delay(TimeSpan.FromSeconds(7)); + + var state = await proxy.GetState(); + + // Make sure the reminder fired 5 times (repetitions) + Assert.Equal(repetitions, state.Count); + + // Make sure the reminder has fired and that it didn't fire within the past second since it should have expired. + Assert.True(state.Timestamp.Subtract(start) > TimeSpan.Zero, "Reminder may not have triggered."); + Assert.True(DateTime.Now.Subtract(state.Timestamp) > TimeSpan.FromSeconds(1), + $"Reminder triggered too recently. {DateTime.Now} - {state.Timestamp}"); + } + + [Fact] + public async Task ActorCanStartReminderWithTtlAndRepetitions() + { + int repetitions = 2; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var proxy = this.ProxyFactory.CreateActorProxy(ActorId.CreateRandom(), "ReminderActor"); + + await WaitForActorRuntimeAsync(proxy, cts.Token); + + // Reminder that should fire 2 times (repetitions) at an interval of 1s + await proxy.StartReminderWithTtlAndRepetitions(TimeSpan.FromSeconds(5), repetitions); + var start = DateTime.Now; + + await Task.Delay(TimeSpan.FromSeconds(5)); + + var state = await proxy.GetState(); + + // Make sure the reminder fired 2 times (repetitions) whereas the ttl was 5 seconds. + Assert.Equal(repetitions, state.Count); + + // Make sure the reminder has fired and that it didn't fire within the past second since it should have expired. + Assert.True(state.Timestamp.Subtract(start) > TimeSpan.Zero, "Reminder may not have triggered."); + Assert.True(DateTime.Now.Subtract(state.Timestamp) > TimeSpan.FromSeconds(1), + $"Reminder triggered too recently. {DateTime.Now} - {state.Timestamp}"); + } + [Fact] public async Task ActorCanStartReminderWithTtl() {