Actor reminder deserialization bugfix (#1483)

Signed-off-by: Whit Waldo <whit.waldo@innovian.net>
This commit is contained in:
Whit Waldo 2025-03-11 13:47:17 -05:00 committed by GitHub
parent bb47132f98
commit c14fcea0d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 454 additions and 121 deletions

View File

@ -0,0 +1,124 @@
// ------------------------------------------------------------------------
// Copyright 2025 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.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
namespace Dapr.Actors.Extensions;
internal static class DurationExtensions
{
/// <summary>
/// Used to parse the duration string accompanying an @every expression.
/// </summary>
private static readonly Regex durationRegex = new(@"(?<value>\d+(\.\d+)?)(?<unit>ns|us|µs|ms|s|m|h)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>
/// A regular expression used to evaluate whether a given prefix period embodies an @every statement.
/// </summary>
private static readonly Regex isEveryExpression = new(@"^@every (\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$");
/// <summary>
/// The various acceptable duration values for a period expression.
/// </summary>
private static readonly string[] acceptablePeriodValues =
{
"yearly", "monthly", "weekly", "daily", "midnight", "hourly"
};
private const string YearlyPrefixPeriod = "@yearly";
private const string MonthlyPrefixPeriod = "@monthly";
private const string WeeklyPrefixPeriod = "@weekly";
private const string DailyPrefixPeriod = "@daily";
private const string MidnightPrefixPeriod = "@midnight";
private const string HourlyPrefixPeriod = "@hourly";
private const string EveryPrefixPeriod = "@every";
/// <summary>
/// Indicates that the schedule represents a prefixed period expression.
/// </summary>
/// <param name="expression"></param>
/// <returns></returns>
public static bool IsDurationExpression(this string expression) => expression.StartsWith('@') &&
(isEveryExpression.IsMatch(expression) ||
expression.EndsWithAny(acceptablePeriodValues, StringComparison.InvariantCulture));
/// <summary>
/// Creates a TimeSpan value from the prefixed period value.
/// </summary>
/// <param name="period">The prefixed period value to parse.</param>
/// <returns>A TimeSpan value matching the provided period.</returns>
public static TimeSpan FromPrefixedPeriod(this string period)
{
if (period.StartsWith(YearlyPrefixPeriod))
{
var dateTime = DateTime.UtcNow;
return dateTime.AddYears(1) - dateTime;
}
if (period.StartsWith(MonthlyPrefixPeriod))
{
var dateTime = DateTime.UtcNow;
return dateTime.AddMonths(1) - dateTime;
}
if (period.StartsWith(MidnightPrefixPeriod))
{
return new TimeSpan();
}
if (period.StartsWith(WeeklyPrefixPeriod))
{
return TimeSpan.FromDays(7);
}
if (period.StartsWith(DailyPrefixPeriod) || period.StartsWith(MidnightPrefixPeriod))
{
return TimeSpan.FromDays(1);
}
if (period.StartsWith(HourlyPrefixPeriod))
{
return TimeSpan.FromHours(1);
}
if (period.StartsWith(EveryPrefixPeriod))
{
//A sequence of decimal numbers each with an optional fraction and unit suffix
//Valid time units are: 'ns', 'us'/'µs', 'ms', 's', 'm', and 'h'
double totalMilliseconds = 0;
var durationString = period.Split(' ').Last().Trim();
foreach (Match match in durationRegex.Matches(durationString))
{
var value = double.Parse(match.Groups["value"].Value, CultureInfo.InvariantCulture);
var unit = match.Groups["unit"].Value.ToLower();
totalMilliseconds += unit switch
{
"ns" => value / 1_000_000,
"us" or "µs" => value / 1_000,
"ms" => value,
"s" => value * 1_000,
"m" => value * 1_000 * 60,
"h" => value * 1_000 * 60 * 60,
_ => throw new ArgumentException($"Unknown duration unit: {unit}")
};
}
return TimeSpan.FromMilliseconds(totalMilliseconds);
}
throw new ArgumentException($"Unknown prefix period expression: {period}");
}
}

View File

@ -0,0 +1,32 @@
// ------------------------------------------------------------------------
// Copyright 2025 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.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace Dapr.Actors.Extensions;
internal static class StringExtensions
{
/// <summary>
/// Extension method that validates a string against a list of possible matches.
/// </summary>
/// <param name="value">The string value to evaluate.</param>
/// <param name="possibleValues">The possible values to look for a match within.</param>
/// <param name="comparisonType">The type of string comparison to perform.</param>
/// <returns>True if the value ends with any of the possible values; otherwise false.</returns>
public static bool EndsWithAny(this string value, IReadOnlyList<string> possibleValues, StringComparison comparisonType )
=> possibleValues.Any(val => value.EndsWith(val, comparisonType));
}

View File

@ -11,15 +11,17 @@
// limitations under the License.
// ------------------------------------------------------------------------
namespace Dapr.Actors.Runtime
{
using Dapr.Actors.Extensions;
namespace Dapr.Actors.Runtime;
using System;
using System.Text;
using System.Text.RegularExpressions;
internal class ConverterUtils
internal static class ConverterUtils
{
private static Regex regex = new Regex("^(R(?<repetition>\\d+)/)?P((?<year>\\d+)Y)?((?<month>\\d+)M)?((?<week>\\d+)W)?((?<day>\\d+)D)?(T((?<hour>\\d+)H)?((?<minute>\\d+)M)?((?<second>\\d+)S)?)?$", RegexOptions.Compiled);
private static Regex regex = new("^(R(?<repetition>\\d+)/)?P((?<year>\\d+)Y)?((?<month>\\d+)M)?((?<week>\\d+)W)?((?<day>\\d+)D)?(T((?<hour>\\d+)H)?((?<minute>\\d+)M)?((?<second>\\d+)S)?)?$", RegexOptions.Compiled);
public static TimeSpan ConvertTimeSpanFromDaprFormat(string valueString)
{
if (string.IsNullOrEmpty(valueString))
@ -28,6 +30,11 @@ namespace Dapr.Actors.Runtime
return never;
}
if (valueString.IsDurationExpression())
{
return valueString.FromPrefixedPeriod();
}
// TimeSpan is a string. Format returned by Dapr is: 1h4m5s4ms4us4ns
// acceptable values are: m, s, ms, us(micro), ns
var spanOfValue = valueString.AsSpan();
@ -63,6 +70,9 @@ namespace Dapr.Actors.Runtime
{
// write in format expected by Dapr, it only accepts h, m, s, ms, us(micro), ns
var stringValue = string.Empty;
if (value is null)
return stringValue;
if (value.Value >= TimeSpan.Zero)
{
var hours = (value.Value.Days * 24) + value.Value.Hours;
@ -86,28 +96,28 @@ namespace Dapr.Actors.Runtime
throw new ArgumentException("The TimeSpan value, combined with repetition cannot be in milliseconds.", nameof(value));
}
builder.AppendFormat("R{0}/P", repetitions);
builder.Append($"R{repetitions}/P");
if(value.Days > 0)
{
builder.AppendFormat("{0}D", value.Days);
builder.Append($"{value.Days}D");
}
builder.Append("T");
if(value.Hours > 0)
{
builder.AppendFormat("{0}H", value.Hours);
builder.Append($"{value.Hours}H");
}
if(value.Minutes > 0)
{
builder.AppendFormat("{0}M", value.Minutes);
builder.Append($"{value.Minutes}M");
}
if(value.Seconds > 0)
{
builder.AppendFormat("{0}S", value.Seconds);
builder.Append($"{value.Seconds}S");
}
return builder.ToString();
}
@ -125,6 +135,7 @@ namespace Dapr.Actors.Runtime
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;
@ -145,4 +156,3 @@ namespace Dapr.Actors.Runtime
return (new TimeSpan(days, hour, minute, second), repetition);
}
}
}

View File

@ -12,6 +12,7 @@
// ------------------------------------------------------------------------
#nullable enable
namespace Dapr.Actors.Runtime;
using System;

View File

@ -0,0 +1,35 @@
// ------------------------------------------------------------------------
// Copyright 2025 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 Dapr.Actors.Runtime;
using Xunit;
namespace Dapr.Actors;
public class ConverterUtilsTests
{
[Fact]
public void Deserialize_Period_Duration1()
{
var result = ConverterUtils.ConvertTimeSpanValueFromISO8601Format("@every 15m");
Assert.Equal(TimeSpan.FromMinutes(15), result.Item1);
}
[Fact]
public void Deserialize_Period_Duration2()
{
var result = ConverterUtils.ConvertTimeSpanValueFromISO8601Format("@hourly");
Assert.Equal(TimeSpan.FromHours(1), result.Item1);
}
}

View File

@ -0,0 +1,90 @@
// ------------------------------------------------------------------------
// Copyright 2025 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 Xunit;
namespace Dapr.Actors.Extensions;
public sealed class DurationExtensionsTests
{
[Theory]
[InlineData("@yearly", 364, 0, 0, 0, 0)]
[InlineData("@monthly", 28, 0, 0, 0, 0 )]
[InlineData("@weekly", 7, 0, 0, 0, 0 )]
[InlineData("@daily", 1, 0, 0, 0, 0)]
[InlineData("@midnight", 0, 0, 0, 0, 0 )]
[InlineData("@hourly", 0, 1, 0, 0, 0)]
[InlineData("@every 1h", 0, 1, 0, 0, 0)]
[InlineData("@every 30m", 0, 0, 30, 0, 0)]
[InlineData("@every 45s", 0, 0, 0, 45, 0)]
[InlineData("@every 1.5h", 0, 1, 30, 0, 0)]
[InlineData("@every 1h30m", 0, 1, 30, 0, 0)]
[InlineData("@every 1h30m45s", 0, 1, 30, 45, 0)]
[InlineData("@every 1h30m45.3s", 0, 1, 30, 45, 300)]
[InlineData("@every 100ms", 0, 0, 0, 0, 100)]
[InlineData("@every 1s500ms", 0, 0, 0, 1, 500)]
[InlineData("@every 1m1s", 0, 0, 1, 1, 0)]
[InlineData("@every 1.1m", 0, 0, 1, 6, 0)]
[InlineData("@every 1.5h30m45s100ms", 0, 2, 0, 45, 100)]
public void ValidatePrefixedPeriodParsing(string input, int expectedDays, int expectedHours, int expectedMinutes, int expectedSeconds, int expectedMilliseconds)
{
var result = input.FromPrefixedPeriod();
if (input is "@yearly" or "@monthly")
{
Assert.True(result.Days >= expectedDays);
return;
}
Assert.Equal(expectedDays, result.Days);
Assert.Equal(expectedHours, result.Hours);
Assert.Equal(expectedMinutes, result.Minutes);
Assert.Equal(expectedSeconds, result.Seconds);
Assert.Equal(expectedMilliseconds, result.Milliseconds);
}
[Theory]
[InlineData("@yearly", true)]
[InlineData("@monthly", true)]
[InlineData("@weekly", true)]
[InlineData("@daily", true)]
[InlineData("@midnight", true)]
[InlineData("@hourly", true)]
[InlineData("@every 1h", true)]
[InlineData("@every 30m", true)]
[InlineData("@every 45s", true)]
[InlineData("@every 1.5h", true)]
[InlineData("@every 1h30m", true)]
[InlineData("@every 1h30m45s", true)]
[InlineData("@every 1h30m45.3s", true)]
[InlineData("@every 100ms", true)]
[InlineData("@every 1s500ms", true)]
[InlineData("@every 1m1s", true)]
[InlineData("@every 1.1m", true)]
[InlineData("@every 1.5h30m45s100ms", true)]
public void TestIsDurationExpression(string input, bool expectedResult)
{
var actualResult = input.IsDurationExpression();
Assert.Equal(expectedResult, actualResult);
}
[Fact]
public void ValidateExceptionForUnknownExpression()
{
Assert.Throws<ArgumentException>(() =>
{
var result = "every 100s".FromPrefixedPeriod();
});
}
}

View File

@ -0,0 +1,41 @@
// ------------------------------------------------------------------------
// Copyright 2025 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.Collections.Generic;
using Xunit;
namespace Dapr.Actors.Extensions;
public sealed class StringExtensionsTests
{
[Fact]
public void ValidateMatchesValue()
{
var matchingValues = new List<string> { "apples", "bananas", "cherries", };
const string value = "I have four cherries";
var result = value.EndsWithAny(matchingValues, StringComparison.InvariantCulture);
Assert.True(result);
}
[Fact]
public void ValidateDoesNotMatchValue()
{
var matchingValues = new List<string> { "apples", "bananas", "cherries", };
const string value = "I have four grapes";
var result = value.EndsWithAny(matchingValues, StringComparison.InvariantCulture);
Assert.False(result);
}
}