diff --git a/Orleans.slnx b/Orleans.slnx index ec2dd21f42..178450a979 100644 --- a/Orleans.slnx +++ b/Orleans.slnx @@ -33,6 +33,7 @@ + @@ -65,6 +66,7 @@ + @@ -72,6 +74,7 @@ + @@ -79,6 +82,8 @@ + + @@ -98,6 +103,7 @@ + diff --git a/src/AWS/Orleans.AdvancedReminders.DynamoDB/DynamoDBRemindersProviderBuilder.cs b/src/AWS/Orleans.AdvancedReminders.DynamoDB/DynamoDBRemindersProviderBuilder.cs new file mode 100644 index 0000000000..806304bfdf --- /dev/null +++ b/src/AWS/Orleans.AdvancedReminders.DynamoDB/DynamoDBRemindersProviderBuilder.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.Configuration; +using Orleans; +using Orleans.Hosting; +using Orleans.Providers; +using Orleans.AdvancedReminders.DynamoDB; + +[assembly: RegisterProvider("DynamoDB", "AdvancedReminders", "Silo", typeof(AdvancedDynamoDBRemindersProviderBuilder))] + +namespace Orleans.Hosting; + +internal sealed class AdvancedDynamoDBRemindersProviderBuilder : IProviderBuilder +{ + public void Configure(ISiloBuilder builder, string name, IConfigurationSection configurationSection) + { + builder.UseDynamoDBAdvancedReminderService(options => + { + var accessKey = configurationSection[nameof(options.AccessKey)]; + if (!string.IsNullOrEmpty(accessKey)) + { + options.AccessKey = accessKey; + } + + var secretKey = configurationSection[nameof(options.SecretKey)]; + if (!string.IsNullOrEmpty(secretKey)) + { + options.SecretKey = secretKey; + } + + var region = configurationSection[nameof(options.Service)] ?? configurationSection["Region"]; + if (!string.IsNullOrEmpty(region)) + { + options.Service = region; + } + + var token = configurationSection[nameof(options.Token)]; + if (!string.IsNullOrEmpty(token)) + { + options.Token = token; + } + + var profileName = configurationSection[nameof(options.ProfileName)]; + if (!string.IsNullOrEmpty(profileName)) + { + options.ProfileName = profileName; + } + + var tableName = configurationSection[nameof(options.TableName)]; + if (!string.IsNullOrEmpty(tableName)) + { + options.TableName = tableName; + } + + if (int.TryParse(configurationSection[nameof(options.ReadCapacityUnits)], out var rcu)) + { + options.ReadCapacityUnits = rcu; + } + + if (int.TryParse(configurationSection[nameof(options.WriteCapacityUnits)], out var wcu)) + { + options.WriteCapacityUnits = wcu; + } + + if (bool.TryParse(configurationSection[nameof(options.UseProvisionedThroughput)], out var upt)) + { + options.UseProvisionedThroughput = upt; + } + + if (bool.TryParse(configurationSection[nameof(options.CreateIfNotExists)], out var cine)) + { + options.CreateIfNotExists = cine; + } + + if (bool.TryParse(configurationSection[nameof(options.UpdateIfExists)], out var uie)) + { + options.UpdateIfExists = uie; + } + }); + } +} diff --git a/src/AWS/Orleans.AdvancedReminders.DynamoDB/DynamoDBServiceCollectionReminderExtensions.cs b/src/AWS/Orleans.AdvancedReminders.DynamoDB/DynamoDBServiceCollectionReminderExtensions.cs new file mode 100644 index 0000000000..896cab56d4 --- /dev/null +++ b/src/AWS/Orleans.AdvancedReminders.DynamoDB/DynamoDBServiceCollectionReminderExtensions.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.DependencyInjection; +using Orleans.Configuration; +using Orleans.Configuration.Internal; +using Orleans.AdvancedReminders.DynamoDB; +using System; + +namespace Orleans.Hosting +{ + /// + /// extensions. + /// + public static class DynamoDBServiceCollectionReminderExtensions + { + /// + /// Adds reminder storage backed by Amazon DynamoDB. + /// + /// + /// The service collection. + /// + /// + /// The delegate used to configure the reminder store. + /// + /// + /// The provided , for chaining. + /// + public static IServiceCollection UseDynamoDBAdvancedReminderService(this IServiceCollection services, Action configure) + { + services.AddAdvancedReminders(); + services.AddSingleton(); + services.Configure(configure); + services.ConfigureFormatter(); + return services; + } + } +} diff --git a/src/AWS/Orleans.AdvancedReminders.DynamoDB/DynamoDBSiloBuilderReminderExtensions.cs b/src/AWS/Orleans.AdvancedReminders.DynamoDB/DynamoDBSiloBuilderReminderExtensions.cs new file mode 100644 index 0000000000..5c7b9a7c2d --- /dev/null +++ b/src/AWS/Orleans.AdvancedReminders.DynamoDB/DynamoDBSiloBuilderReminderExtensions.cs @@ -0,0 +1,29 @@ +using System; +using Orleans.AdvancedReminders.DynamoDB; + +namespace Orleans.Hosting +{ + /// + /// Silo host builder extensions. + /// + public static class DynamoDBSiloBuilderReminderExtensions + { + /// + /// Adds reminder storage backed by Amazon DynamoDB. + /// + /// + /// The builder. + /// + /// + /// The delegate used to configure the reminder store. + /// + /// + /// The provided , for chaining. + /// + public static ISiloBuilder UseDynamoDBAdvancedReminderService(this ISiloBuilder builder, Action configure) + { + builder.ConfigureServices(services => services.UseDynamoDBAdvancedReminderService(configure)); + return builder; + } + } +} diff --git a/src/AWS/Orleans.AdvancedReminders.DynamoDB/GlobalUsings.cs b/src/AWS/Orleans.AdvancedReminders.DynamoDB/GlobalUsings.cs new file mode 100644 index 0000000000..cc91a7365d --- /dev/null +++ b/src/AWS/Orleans.AdvancedReminders.DynamoDB/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Orleans.AdvancedReminders.Runtime; +global using Orleans.AdvancedReminders.DynamoDB; diff --git a/src/AWS/Orleans.AdvancedReminders.DynamoDB/Orleans.AdvancedReminders.DynamoDB.csproj b/src/AWS/Orleans.AdvancedReminders.DynamoDB/Orleans.AdvancedReminders.DynamoDB.csproj new file mode 100644 index 0000000000..817e672d8f --- /dev/null +++ b/src/AWS/Orleans.AdvancedReminders.DynamoDB/Orleans.AdvancedReminders.DynamoDB.csproj @@ -0,0 +1,39 @@ + + + README.md + Microsoft.Orleans.AdvancedReminders.DynamoDB + Microsoft Orleans Advanced Reminders for DynamoDB + AWS DynamoDB provider for Microsoft Orleans Advanced Reminders. + $(PackageTags) AWS DynamoDB + $(DefaultTargetFrameworks) + true + + + + Orleans.AdvancedReminders.DynamoDB + Orleans.AdvancedReminders.DynamoDB + $(DefineConstants);ADVANCED_REMINDERS_DYNAMODB + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AWS/Orleans.AdvancedReminders.DynamoDB/README.md b/src/AWS/Orleans.AdvancedReminders.DynamoDB/README.md new file mode 100644 index 0000000000..6538cb0b24 --- /dev/null +++ b/src/AWS/Orleans.AdvancedReminders.DynamoDB/README.md @@ -0,0 +1,119 @@ +# Microsoft Orleans Advanced Reminders for DynamoDB + +## Introduction +Microsoft Orleans Advanced Reminders for DynamoDB stores reminder definitions in Amazon DynamoDB. + +This package does not include a DynamoDB-backed durable jobs implementation. You must also configure a durable jobs backend, for example `UseInMemoryDurableJobs()` for local development or `UseAzureBlobDurableJobs(...)` for persisted execution. + +## Getting Started +To use this package, install it via NuGet: + +```shell +dotnet add package Microsoft.Orleans.AdvancedReminders.DynamoDB +``` + +## Example - Configuring DynamoDB Advanced Reminders +```csharp +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Configuration; +using Orleans.AdvancedReminders; +using Orleans.Hosting; +using Orleans.DurableJobs; + +var builder = Host.CreateApplicationBuilder(args) + .UseOrleans(siloBuilder => + { + siloBuilder + .UseLocalhostClustering() + .UseInMemoryDurableJobs() + // Configure DynamoDB for reminder definitions + .UseDynamoDBAdvancedReminderService(options => + { + options.AccessKey = "YOUR_AWS_ACCESS_KEY"; + options.SecretKey = "YOUR_AWS_SECRET_KEY"; + options.Service = "us-east-1"; + options.TableName = "OrleansAdvancedReminders"; + options.CreateIfNotExists = true; + }); + }); + +// Run the host +var host = builder.Build(); +await host.StartAsync(); + +// Get a reference to the grain +var reminderGrain = host.Services.GetRequiredService() + .GetGrain("my-reminder-grain"); + +// Start the reminder +await reminderGrain.StartReminder("ExampleReminder"); +Console.WriteLine("Reminder started!"); + +// Keep the host running until the application is shut down +await host.WaitForShutdownAsync(); +``` + +## Example - Using Reminders in a Grain +```csharp +using System; +using System.Threading.Tasks; +using Orleans; +using Orleans.AdvancedReminders; +using Orleans.AdvancedReminders.Runtime; + +namespace ReminderExample; + +public interface IReminderGrain : IGrainWithStringKey +{ + Task StartReminder(string reminderName); + Task StopReminder(); +} + +public class ReminderGrain : Grain, IReminderGrain, IRemindable +{ + private string _reminderName = "MyReminder"; + + public async Task StartReminder(string reminderName) + { + _reminderName = reminderName; + + // Register a persistent reminder + await RegisterOrUpdateReminder( + reminderName, + TimeSpan.FromMinutes(2), // Time to delay before the first tick (must be > 1 minute) + TimeSpan.FromMinutes(5)); // Period of the reminder (must be > 1 minute) + } + + public async Task StopReminder() + { + // Find and unregister the reminder + var reminder = await GetReminder(_reminderName); + if (reminder != null) + { + await UnregisterReminder(reminder); + } + } + + public Task ReceiveReminder(string reminderName, TickStatus status) + { + // This method is called when the reminder ticks + Console.WriteLine($"Reminder {reminderName} triggered at {DateTime.UtcNow}. Status: {status}"); + return Task.CompletedTask; + } +} +``` + +## Documentation +For more comprehensive documentation, please refer to: +- [Microsoft Orleans Documentation](https://learn.microsoft.com/dotnet/orleans/) +- [Reminders and Timers](https://learn.microsoft.com/en-us/dotnet/orleans/grains/timers-and-reminders) +- [Reminder Services](https://learn.microsoft.com/en-us/dotnet/orleans/implementation/reminder-services) +- [AWS SDK for .NET Documentation](https://docs.aws.amazon.com/sdk-for-net/index.html) + +## Feedback & Contributing +- If you have any issues or would like to provide feedback, please [open an issue on GitHub](https://github.com/dotnet/orleans/issues) +- Join our community on [Discord](https://aka.ms/orleans-discord) +- Follow the [@msftorleans](https://twitter.com/msftorleans) Twitter account for Orleans announcements +- Contributions are welcome! Please review our [contribution guidelines](https://github.com/dotnet/orleans/blob/main/CONTRIBUTING.md) +- This project is licensed under the [MIT license](https://github.com/dotnet/orleans/blob/main/LICENSE) diff --git a/src/AWS/Orleans.AdvancedReminders.DynamoDB/Reminders/DynamoDBReminderTable.cs b/src/AWS/Orleans.AdvancedReminders.DynamoDB/Reminders/DynamoDBReminderTable.cs new file mode 100644 index 0000000000..b5eb507079 --- /dev/null +++ b/src/AWS/Orleans.AdvancedReminders.DynamoDB/Reminders/DynamoDBReminderTable.cs @@ -0,0 +1,484 @@ +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.Runtime; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; + +namespace Orleans.AdvancedReminders.DynamoDB +{ + /// + /// Implementation for IReminderTable using DynamoDB as underlying storage. + /// + internal sealed partial class DynamoDBReminderTable : IReminderTable + { + private const string GRAIN_REFERENCE_PROPERTY_NAME = "GrainReference"; + private const string REMINDER_NAME_PROPERTY_NAME = "ReminderName"; + private const string SERVICE_ID_PROPERTY_NAME = "ServiceId"; + private const string START_TIME_PROPERTY_NAME = "StartTime"; + private const string PERIOD_PROPERTY_NAME = "Period"; + private const string CRON_EXPRESSION_PROPERTY_NAME = "CronExpression"; + private const string CRON_TIME_ZONE_ID_PROPERTY_NAME = "CronTimeZoneId"; + private const string NEXT_DUE_UTC_PROPERTY_NAME = "NextDueUtc"; + private const string LAST_FIRE_UTC_PROPERTY_NAME = "LastFireUtc"; + private const string PRIORITY_PROPERTY_NAME = "Priority"; + private const string ACTION_PROPERTY_NAME = "Action"; + private const string GRAIN_HASH_PROPERTY_NAME = "GrainHash"; + private const string REMINDER_ID_PROPERTY_NAME = "ReminderId"; + private const string ETAG_PROPERTY_NAME = "ETag"; + private const string CURRENT_ETAG_ALIAS = ":currentETag"; + private const string SERVICE_ID_GRAIN_HASH_INDEX = "ServiceIdIndex"; + private const string SERVICE_ID_GRAIN_REFERENCE_INDEX = "ServiceIdGrainReferenceIndex"; + + private readonly ILogger logger; + private readonly DynamoDBReminderStorageOptions options; + private readonly string serviceId; + + private DynamoDBStorage storage; + + /// Initializes a new instance of the class. + /// logger factory to use + /// + /// + public DynamoDBReminderTable( + ILoggerFactory loggerFactory, + IOptions clusterOptions, + IOptions storageOptions) + { + this.logger = loggerFactory.CreateLogger(); + this.serviceId = clusterOptions.Value.ServiceId; + this.options = storageOptions.Value; + } + + /// Initialize current instance with specific global configuration and logger + public Task Init() + { + this.storage = new DynamoDBStorage( + this.logger, + this.options.Service, + this.options.AccessKey, + this.options.SecretKey, + this.options.Token, + this.options.ProfileName, + this.options.ReadCapacityUnits, + this.options.WriteCapacityUnits, + this.options.UseProvisionedThroughput, + this.options.CreateIfNotExists, + this.options.UpdateIfExists); + + LogInformationInitializingDynamoDBRemindersTable(logger); + + var serviceIdGrainHashGlobalSecondaryIndex = new GlobalSecondaryIndex + { + IndexName = SERVICE_ID_GRAIN_HASH_INDEX, + Projection = new Projection { ProjectionType = ProjectionType.ALL }, + KeySchema = new List + { + new KeySchemaElement { AttributeName = SERVICE_ID_PROPERTY_NAME, KeyType = KeyType.HASH}, + new KeySchemaElement { AttributeName = GRAIN_HASH_PROPERTY_NAME, KeyType = KeyType.RANGE } + } + }; + + var serviceIdGrainReferenceGlobalSecondaryIndex = new GlobalSecondaryIndex + { + IndexName = SERVICE_ID_GRAIN_REFERENCE_INDEX, + Projection = new Projection { ProjectionType = ProjectionType.ALL }, + KeySchema = new List + { + new KeySchemaElement { AttributeName = SERVICE_ID_PROPERTY_NAME, KeyType = KeyType.HASH}, + new KeySchemaElement { AttributeName = GRAIN_REFERENCE_PROPERTY_NAME, KeyType = KeyType.RANGE } + } + }; + + return this.storage.InitializeTable(this.options.TableName, + new List + { + new KeySchemaElement { AttributeName = REMINDER_ID_PROPERTY_NAME, KeyType = KeyType.HASH }, + new KeySchemaElement { AttributeName = GRAIN_HASH_PROPERTY_NAME, KeyType = KeyType.RANGE } + }, + new List + { + new AttributeDefinition { AttributeName = REMINDER_ID_PROPERTY_NAME, AttributeType = ScalarAttributeType.S }, + new AttributeDefinition { AttributeName = GRAIN_HASH_PROPERTY_NAME, AttributeType = ScalarAttributeType.N }, + new AttributeDefinition { AttributeName = SERVICE_ID_PROPERTY_NAME, AttributeType = ScalarAttributeType.S }, + new AttributeDefinition { AttributeName = GRAIN_REFERENCE_PROPERTY_NAME, AttributeType = ScalarAttributeType.S } + }, + new List { serviceIdGrainHashGlobalSecondaryIndex, serviceIdGrainReferenceGlobalSecondaryIndex }); + } + + /// + /// Reads a reminder for a grain reference by reminder name. + /// Read a row from the reminder table + /// + /// grain ref to locate the row + /// reminder name to locate the row + /// Return the ReminderTableData if the rows were read successfully + public async Task ReadRow(GrainId grainId, string reminderName) + { + var reminderId = ConstructReminderId(this.serviceId, grainId, reminderName); + + var keys = new Dictionary + { + { $"{REMINDER_ID_PROPERTY_NAME}", new AttributeValue(reminderId) }, + { $"{GRAIN_HASH_PROPERTY_NAME}", new AttributeValue { N = grainId.GetUniformHashCode().ToString() } } + }; + + try + { + return await this.storage.ReadSingleEntryAsync(this.options.TableName, keys, this.Resolve).ConfigureAwait(false); + } + catch (Exception exc) + { + LogWarningReadReminderEntry(logger, exc, new(keys), this.options.TableName); + throw; + } + } + + /// + /// Read one row from the reminder table + /// + /// grain ref to locate the row + /// Return the ReminderTableData if the rows were read successfully + public async Task ReadRows(GrainId grainId) + { + var expressionValues = new Dictionary + { + { $":{SERVICE_ID_PROPERTY_NAME}", new AttributeValue(this.serviceId) }, + { $":{GRAIN_REFERENCE_PROPERTY_NAME}", new AttributeValue(grainId.ToString()) } + }; + + try + { + var expression = $"{SERVICE_ID_PROPERTY_NAME} = :{SERVICE_ID_PROPERTY_NAME} AND {GRAIN_REFERENCE_PROPERTY_NAME} = :{GRAIN_REFERENCE_PROPERTY_NAME}"; + var records = await this.storage.QueryAllAsync(this.options.TableName, expressionValues, expression, this.Resolve, SERVICE_ID_GRAIN_REFERENCE_INDEX, consistentRead: false).ConfigureAwait(false); + + return new ReminderTableData(records); + } + catch (Exception exc) + { + LogWarningReadReminderEntries(logger, exc, new(expressionValues), this.options.TableName); + throw; + } + } + + /// + /// Reads reminder table data for a given hash range. + /// + /// + /// + /// Return the RemiderTableData if the rows were read successfully + public async Task ReadRows(uint begin, uint end) + { + Dictionary expressionValues = null; + + try + { + string expression = string.Empty; + List records; + + if (begin < end) + { + expressionValues = new Dictionary + { + { $":{SERVICE_ID_PROPERTY_NAME}", new AttributeValue(this.serviceId) }, + { $":Begin{GRAIN_HASH_PROPERTY_NAME}", new AttributeValue { N = (begin + 1).ToString() } }, + { $":End{GRAIN_HASH_PROPERTY_NAME}", new AttributeValue { N = end.ToString() } } + }; + expression = $"{SERVICE_ID_PROPERTY_NAME} = :{SERVICE_ID_PROPERTY_NAME} AND {GRAIN_HASH_PROPERTY_NAME} BETWEEN :Begin{GRAIN_HASH_PROPERTY_NAME} AND :End{GRAIN_HASH_PROPERTY_NAME}"; + records = await this.storage.QueryAllAsync(this.options.TableName, expressionValues, expression, this.Resolve, SERVICE_ID_GRAIN_HASH_INDEX, consistentRead: false).ConfigureAwait(false); + } + else + { + expressionValues = new Dictionary + { + { $":{SERVICE_ID_PROPERTY_NAME}", new AttributeValue(this.serviceId) }, + { $":End{GRAIN_HASH_PROPERTY_NAME}", new AttributeValue { N = end.ToString() } } + }; + expression = $"{SERVICE_ID_PROPERTY_NAME} = :{SERVICE_ID_PROPERTY_NAME} AND {GRAIN_HASH_PROPERTY_NAME} <= :End{GRAIN_HASH_PROPERTY_NAME}"; + records = await this.storage.QueryAllAsync(this.options.TableName, expressionValues, expression, this.Resolve, SERVICE_ID_GRAIN_HASH_INDEX, consistentRead: false).ConfigureAwait(false); + + expressionValues = new Dictionary + { + { $":{SERVICE_ID_PROPERTY_NAME}", new AttributeValue(this.serviceId) }, + { $":Begin{GRAIN_HASH_PROPERTY_NAME}", new AttributeValue { N = begin.ToString() } } + }; + expression = $"{SERVICE_ID_PROPERTY_NAME} = :{SERVICE_ID_PROPERTY_NAME} AND {GRAIN_HASH_PROPERTY_NAME} > :Begin{GRAIN_HASH_PROPERTY_NAME}"; + records.AddRange(await this.storage.QueryAllAsync(this.options.TableName, expressionValues, expression, this.Resolve, SERVICE_ID_GRAIN_HASH_INDEX, consistentRead: false).ConfigureAwait(false)); + + } + + return new ReminderTableData(records); + } + catch (Exception exc) + { + LogWarningReadReminderEntryRange(logger, exc, new(expressionValues), this.options.TableName); + throw; + } + } + + private ReminderEntry Resolve(Dictionary item) + { + return new ReminderEntry + { + ETag = item[ETAG_PROPERTY_NAME].N, + GrainId = GrainId.Parse(item[GRAIN_REFERENCE_PROPERTY_NAME].S), + Period = TimeSpan.Parse(item[PERIOD_PROPERTY_NAME].S, CultureInfo.InvariantCulture), + CronExpression = ReadOptionalString(item, CRON_EXPRESSION_PROPERTY_NAME), + CronTimeZoneId = ReadOptionalString(item, CRON_TIME_ZONE_ID_PROPERTY_NAME), + NextDueUtc = ReadOptionalDateTime(item, NEXT_DUE_UTC_PROPERTY_NAME), + LastFireUtc = ReadOptionalDateTime(item, LAST_FIRE_UTC_PROPERTY_NAME), + Priority = ReadPriority(item), + Action = ReadAction(item), + ReminderName = item[REMINDER_NAME_PROPERTY_NAME].S, + StartAt = DateTime.Parse(item[START_TIME_PROPERTY_NAME].S, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + }; + } + + private static string ReadOptionalString(Dictionary item, string propertyName) + => item.TryGetValue(propertyName, out var value) ? value.S : null; + + private static DateTime? ReadOptionalDateTime(Dictionary item, string propertyName) + { + if (!item.TryGetValue(propertyName, out var value) || string.IsNullOrWhiteSpace(value.S)) + { + return null; + } + + return DateTime.Parse(value.S, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + } + + private static ReminderPriority ReadPriority(Dictionary item) + { + if (!TryReadInt32(item, PRIORITY_PROPERTY_NAME, out var value)) + { + return ReminderPriority.Normal; + } + + return ParsePriority(value); + } + + private static MissedReminderAction ReadAction(Dictionary item) + { + if (!TryReadInt32(item, ACTION_PROPERTY_NAME, out var value)) + { + return MissedReminderAction.Skip; + } + + return ParseAction(value); + } + + private static bool TryReadInt32(Dictionary item, string propertyName, out int value) + { + value = default; + return item.TryGetValue(propertyName, out var attributeValue) + && !string.IsNullOrWhiteSpace(attributeValue.N) + && int.TryParse(attributeValue.N, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + } + + private static ReminderPriority ParsePriority(int value) => value switch + { + (int)ReminderPriority.High => ReminderPriority.High, + (int)ReminderPriority.Normal => ReminderPriority.Normal, + _ => ReminderPriority.Normal, + }; + + private static MissedReminderAction ParseAction(int value) => value switch + { + (int)MissedReminderAction.FireImmediately => MissedReminderAction.FireImmediately, + (int)MissedReminderAction.Skip => MissedReminderAction.Skip, + (int)MissedReminderAction.Notify => MissedReminderAction.Notify, + _ => MissedReminderAction.Skip, + }; + + /// + /// Remove one row from the reminder table + /// + /// specific grain ref to locate the row + /// reminder name to locate the row + /// e tag + /// Return true if the row was removed + public async Task RemoveRow(GrainId grainId, string reminderName, string eTag) + { + var reminderId = ConstructReminderId(this.serviceId, grainId, reminderName); + + var keys = new Dictionary + { + { $"{REMINDER_ID_PROPERTY_NAME}", new AttributeValue(reminderId) }, + { $"{GRAIN_HASH_PROPERTY_NAME}", new AttributeValue { N = grainId.GetUniformHashCode().ToString() } } + }; + + try + { + var conditionalValues = new Dictionary { { CURRENT_ETAG_ALIAS, new AttributeValue { N = eTag } } }; + var expression = $"{ETAG_PROPERTY_NAME} = {CURRENT_ETAG_ALIAS}"; + + await this.storage.DeleteEntryAsync(this.options.TableName, keys, expression, conditionalValues).ConfigureAwait(false); + return true; + } + catch (ConditionalCheckFailedException) + { + return false; + } + } + + /// + /// Test hook to clear reminder table data. + /// + /// + public async Task TestOnlyClearTable() + { + var expressionValues = new Dictionary + { + { $":{SERVICE_ID_PROPERTY_NAME}", new AttributeValue(this.serviceId) } + }; + + try + { + var expression = $"{SERVICE_ID_PROPERTY_NAME} = :{SERVICE_ID_PROPERTY_NAME}"; + var records = await this.storage.ScanAsync(this.options.TableName, expressionValues, expression, + item => new Dictionary + { + { REMINDER_ID_PROPERTY_NAME, item[REMINDER_ID_PROPERTY_NAME] }, + { GRAIN_HASH_PROPERTY_NAME, item[GRAIN_HASH_PROPERTY_NAME] } + }).ConfigureAwait(false); + + if (records.Count <= 25) + { + await this.storage.DeleteEntriesAsync(this.options.TableName, records); + } + else + { + List tasks = new List(); + foreach (var batch in records.BatchIEnumerable(25)) + { + tasks.Add(this.storage.DeleteEntriesAsync(this.options.TableName, batch)); + } + await Task.WhenAll(tasks); + } + } + catch (Exception exc) + { + LogWarningRemoveReminderEntries(logger, exc, new(expressionValues), this.options.TableName); + throw; + } + } + + /// + /// Async method to put an entry into the reminder table + /// + /// The entry to put + /// Return the entry ETag if entry was upsert successfully + public async Task UpsertRow(ReminderEntry entry) + { + var reminderId = ConstructReminderId(this.serviceId, entry.GrainId, entry.ReminderName); + + var fields = new Dictionary + { + { REMINDER_ID_PROPERTY_NAME, new AttributeValue(reminderId) }, + { GRAIN_HASH_PROPERTY_NAME, new AttributeValue { N = entry.GrainId.GetUniformHashCode().ToString() } }, + { SERVICE_ID_PROPERTY_NAME, new AttributeValue(this.serviceId) }, + { GRAIN_REFERENCE_PROPERTY_NAME, new AttributeValue( entry.GrainId.ToString()) }, + { PERIOD_PROPERTY_NAME, new AttributeValue(entry.Period.ToString("c", CultureInfo.InvariantCulture)) }, + { START_TIME_PROPERTY_NAME, new AttributeValue(entry.StartAt.ToString("O", CultureInfo.InvariantCulture)) }, + { REMINDER_NAME_PROPERTY_NAME, new AttributeValue(entry.ReminderName) }, + { PRIORITY_PROPERTY_NAME, new AttributeValue { N = ((int)entry.Priority).ToString(CultureInfo.InvariantCulture) } }, + { ACTION_PROPERTY_NAME, new AttributeValue { N = ((int)entry.Action).ToString(CultureInfo.InvariantCulture) } }, + { ETAG_PROPERTY_NAME, new AttributeValue { N = Random.Shared.Next().ToString(CultureInfo.InvariantCulture) } } + }; + + if (!string.IsNullOrWhiteSpace(entry.CronExpression)) + { + fields[CRON_EXPRESSION_PROPERTY_NAME] = new AttributeValue(entry.CronExpression); + } + + if (!string.IsNullOrWhiteSpace(entry.CronTimeZoneId)) + { + fields[CRON_TIME_ZONE_ID_PROPERTY_NAME] = new AttributeValue(entry.CronTimeZoneId); + } + + if (entry.NextDueUtc is { } nextDueUtc) + { + fields[NEXT_DUE_UTC_PROPERTY_NAME] = new AttributeValue(nextDueUtc.ToString("O")); + } + + if (entry.LastFireUtc is { } lastFireUtc) + { + fields[LAST_FIRE_UTC_PROPERTY_NAME] = new AttributeValue(lastFireUtc.ToString("O")); + } + + try + { + LogDebugUpsertRow(logger, entry, entry.ETag); + + await this.storage.PutEntryAsync(this.options.TableName, fields); + + entry.ETag = fields[ETAG_PROPERTY_NAME].N; + return entry.ETag; + } + catch (Exception exc) + { + LogWarningUpdateReminderEntry(logger, exc, entry, options.TableName); + throw; + } + } + + private static string ConstructReminderId(string serviceId, GrainId grainId, string reminderName) => $"{serviceId}_{grainId}_{reminderName}"; + + [LoggerMessage( + EventId = (int)ErrorCode.ReminderServiceBase, + Level = LogLevel.Information, + Message = "Initializing AWS DynamoDB Reminders Table" + )] + private static partial void LogInformationInitializingDynamoDBRemindersTable(ILogger logger); + + private readonly struct DictionaryLogRecord(Dictionary keys) + { + public override string ToString() => Utils.DictionaryToString(keys); + } + + [LoggerMessage( + EventId = (int)ErrorCode.ReminderServiceBase, + Level = LogLevel.Warning, + Message = "Intermediate error reading reminder entry {Keys} from table {TableName}." + )] + private static partial void LogWarningReadReminderEntry(ILogger logger, Exception exception, DictionaryLogRecord keys, string tableName); + + [LoggerMessage( + EventId = (int)ErrorCode.ReminderServiceBase, + Level = LogLevel.Warning, + Message = "Intermediate error reading reminder entry {Entries} from table {TableName}." + )] + private static partial void LogWarningReadReminderEntries(ILogger logger, Exception exception, DictionaryLogRecord entries, string tableName); + + [LoggerMessage( + EventId = (int)ErrorCode.ReminderServiceBase, + Level = LogLevel.Warning, + Message = "Intermediate error reading reminder entry {ExpressionValues} from table {TableName}." + )] + private static partial void LogWarningReadReminderEntryRange(ILogger logger, Exception exception, DictionaryLogRecord expressionValues, string tableName); + + [LoggerMessage( + EventId = (int)ErrorCode.ReminderServiceBase, + Level = LogLevel.Warning, + Message = "Intermediate error removing reminder entries {Entries} from table {TableName}." + )] + private static partial void LogWarningRemoveReminderEntries(ILogger logger, Exception exception, DictionaryLogRecord entries, string tableName); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "UpsertRow entry = {Entry}, etag = {ETag}" + )] + private static partial void LogDebugUpsertRow(ILogger logger, ReminderEntry entry, string eTag); + + [LoggerMessage( + EventId = (int)ErrorCode.ReminderServiceBase, + Level = LogLevel.Warning, + Message = "Intermediate error updating entry {Entry} to the table {TableName}." + )] + private static partial void LogWarningUpdateReminderEntry(ILogger logger, Exception exception, ReminderEntry entry, string tableName); + } +} diff --git a/src/AWS/Orleans.AdvancedReminders.DynamoDB/Reminders/DynamoDbReminderServiceOptions.cs b/src/AWS/Orleans.AdvancedReminders.DynamoDB/Reminders/DynamoDbReminderServiceOptions.cs new file mode 100644 index 0000000000..7b19820064 --- /dev/null +++ b/src/AWS/Orleans.AdvancedReminders.DynamoDB/Reminders/DynamoDbReminderServiceOptions.cs @@ -0,0 +1,14 @@ +namespace Orleans.AdvancedReminders.DynamoDB +{ + /// + /// Configuration for Amazon DynamoDB reminder storage. + /// + public class DynamoDBReminderTableOptions + { + /// + /// Gets or sets the connection string. + /// + [RedactConnectionString] + public string ConnectionString { get; set; } + } +} diff --git a/src/AWS/Orleans.AdvancedReminders.DynamoDB/Reminders/DynamoDbReminderStorageOptions.cs b/src/AWS/Orleans.AdvancedReminders.DynamoDB/Reminders/DynamoDbReminderStorageOptions.cs new file mode 100644 index 0000000000..a4782f97fd --- /dev/null +++ b/src/AWS/Orleans.AdvancedReminders.DynamoDB/Reminders/DynamoDbReminderStorageOptions.cs @@ -0,0 +1,41 @@ +using Orleans.AdvancedReminders.DynamoDB; + +namespace Orleans.AdvancedReminders.DynamoDB +{ + /// + /// Configuration for Amazon DynamoDB reminder storage. + /// + public class DynamoDBReminderStorageOptions : DynamoDBClientOptions + { + /// + /// Read capacity unit for DynamoDB storage + /// + public int ReadCapacityUnits { get; set; } = DynamoDBStorage.DefaultReadCapacityUnits; + + /// + /// Write capacity unit for DynamoDB storage + /// + public int WriteCapacityUnits { get; set; } = DynamoDBStorage.DefaultWriteCapacityUnits; + + /// + /// Use Provisioned Throughput for tables + /// + public bool UseProvisionedThroughput { get; set; } = true; + + /// + /// Create the table if it doesn't exist + /// + public bool CreateIfNotExists { get; set; } = true; + + /// + /// Update the table if it exists + /// + public bool UpdateIfExists { get; set; } = true; + + /// + /// DynamoDB table name. + /// Defaults to 'OrleansAdvancedReminders'. + /// + public string TableName { get; set; } = "OrleansAdvancedReminders"; + } +} diff --git a/src/AWS/Orleans.AdvancedReminders.DynamoDB/Reminders/DynamoDbReminderStorageOptionsExtensions.cs b/src/AWS/Orleans.AdvancedReminders.DynamoDB/Reminders/DynamoDbReminderStorageOptionsExtensions.cs new file mode 100644 index 0000000000..5cb52b980f --- /dev/null +++ b/src/AWS/Orleans.AdvancedReminders.DynamoDB/Reminders/DynamoDbReminderStorageOptionsExtensions.cs @@ -0,0 +1,92 @@ +using System; +using System.Linq; + +namespace Orleans.AdvancedReminders.DynamoDB +{ + /// + /// Configuration for Amazon DynamoDB reminder storage. + /// + public static class DynamoDBReminderStorageOptionsExtensions + { + private const string AccessKeyPropertyName = "AccessKey"; + private const string SecretKeyPropertyName = "SecretKey"; + private const string ServicePropertyName = "Service"; + private const string ReadCapacityUnitsPropertyName = "ReadCapacityUnits"; + private const string WriteCapacityUnitsPropertyName = "WriteCapacityUnits"; + private const string UseProvisionedThroughputPropertyName = "UseProvisionedThroughput"; + private const string CreateIfNotExistsPropertyName = "CreateIfNotExists"; + private const string UpdateIfExistsPropertyName = "UpdateIfExists"; + + /// + /// Configures this instance using the provided connection string. + /// + public static void ParseConnectionString(this DynamoDBReminderStorageOptions options, string connectionString) + { + var parameters = connectionString.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + + var serviceConfig = Array.Find(parameters, p => p.Contains(ServicePropertyName)); + if (!string.IsNullOrWhiteSpace(serviceConfig)) + { + var value = serviceConfig.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries); + if (value.Length == 2 && !string.IsNullOrWhiteSpace(value[1])) + options.Service = value[1]; + } + + var secretKeyConfig = Array.Find(parameters, p => p.Contains(SecretKeyPropertyName)); + if (!string.IsNullOrWhiteSpace(secretKeyConfig)) + { + var value = secretKeyConfig.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries); + if (value.Length == 2 && !string.IsNullOrWhiteSpace(value[1])) + options.SecretKey = value[1]; + } + + var accessKeyConfig = Array.Find(parameters, p => p.Contains(AccessKeyPropertyName)); + if (!string.IsNullOrWhiteSpace(accessKeyConfig)) + { + var value = accessKeyConfig.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries); + if (value.Length == 2 && !string.IsNullOrWhiteSpace(value[1])) + options.AccessKey = value[1]; + } + + var readCapacityUnitsConfig = Array.Find(parameters, p => p.Contains(ReadCapacityUnitsPropertyName)); + if (!string.IsNullOrWhiteSpace(readCapacityUnitsConfig)) + { + var value = readCapacityUnitsConfig.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries); + if (value.Length == 2 && !string.IsNullOrWhiteSpace(value[1])) + options.ReadCapacityUnits = int.Parse(value[1]); + } + + var writeCapacityUnitsConfig = Array.Find(parameters, p => p.Contains(WriteCapacityUnitsPropertyName)); + if (!string.IsNullOrWhiteSpace(writeCapacityUnitsConfig)) + { + var value = writeCapacityUnitsConfig.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries); + if (value.Length == 2 && !string.IsNullOrWhiteSpace(value[1])) + options.WriteCapacityUnits = int.Parse(value[1]); + } + + var useProvisionedThroughputConfig = Array.Find(parameters, p => p.Contains(UseProvisionedThroughputPropertyName)); + if (!string.IsNullOrWhiteSpace(useProvisionedThroughputConfig)) + { + var value = useProvisionedThroughputConfig.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries); + if (value.Length == 2 && !string.IsNullOrWhiteSpace(value[1])) + options.UseProvisionedThroughput = bool.Parse(value[1]); + } + + var createIfNotExistsPropertyNameConfig = Array.Find(parameters, p => p.Contains(CreateIfNotExistsPropertyName)); + if (!string.IsNullOrWhiteSpace(createIfNotExistsPropertyNameConfig)) + { + var value = createIfNotExistsPropertyNameConfig.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries); + if (value.Length == 2 && !string.IsNullOrWhiteSpace(value[1])) + options.CreateIfNotExists = bool.Parse(value[1]); + } + + var updateIfExistsPropertyNameConfig = Array.Find(parameters, p => p.Contains(UpdateIfExistsPropertyName)); + if (!string.IsNullOrWhiteSpace(updateIfExistsPropertyNameConfig)) + { + var value = updateIfExistsPropertyNameConfig.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries); + if (value.Length == 2 && !string.IsNullOrWhiteSpace(value[1])) + options.UpdateIfExists = bool.Parse(value[1]); + } + } + } +} diff --git a/src/AWS/Shared/AWSUtils.cs b/src/AWS/Shared/AWSUtils.cs index 269935ce81..16398db8fe 100644 --- a/src/AWS/Shared/AWSUtils.cs +++ b/src/AWS/Shared/AWSUtils.cs @@ -3,6 +3,8 @@ #if CLUSTERING_DYNAMODB namespace Orleans.Clustering.DynamoDB +#elif ADVANCED_REMINDERS_DYNAMODB +namespace Orleans.AdvancedReminders.DynamoDB #elif PERSISTENCE_DYNAMODB namespace Orleans.Persistence.DynamoDB #elif REMINDERS_DYNAMODB diff --git a/src/AWS/Shared/Storage/DynamoDBClientOptions.cs b/src/AWS/Shared/Storage/DynamoDBClientOptions.cs index ddec8065ea..389460af4d 100644 --- a/src/AWS/Shared/Storage/DynamoDBClientOptions.cs +++ b/src/AWS/Shared/Storage/DynamoDBClientOptions.cs @@ -1,5 +1,7 @@ #if CLUSTERING_DYNAMODB namespace Orleans.Clustering.DynamoDB +#elif ADVANCED_REMINDERS_DYNAMODB +namespace Orleans.AdvancedReminders.DynamoDB #elif PERSISTENCE_DYNAMODB namespace Orleans.Persistence.DynamoDB #elif REMINDERS_DYNAMODB @@ -37,4 +39,4 @@ public class DynamoDBClientOptions /// public string ProfileName { get; set; } } -} \ No newline at end of file +} diff --git a/src/AWS/Shared/Storage/DynamoDBStorage.cs b/src/AWS/Shared/Storage/DynamoDBStorage.cs index 593a9f4e5c..6b9373c387 100755 --- a/src/AWS/Shared/Storage/DynamoDBStorage.cs +++ b/src/AWS/Shared/Storage/DynamoDBStorage.cs @@ -13,6 +13,8 @@ #if CLUSTERING_DYNAMODB namespace Orleans.Clustering.DynamoDB +#elif ADVANCED_REMINDERS_DYNAMODB +namespace Orleans.AdvancedReminders.DynamoDB #elif PERSISTENCE_DYNAMODB namespace Orleans.Persistence.DynamoDB #elif REMINDERS_DYNAMODB diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/AdoNetRemindersProviderBuilder.cs b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/AdoNetRemindersProviderBuilder.cs new file mode 100644 index 0000000000..104dfb577c --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/AdoNetRemindersProviderBuilder.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Orleans; +using Orleans.Hosting; +using Orleans.Providers; +using Orleans.AdvancedReminders.AdoNet; + +[assembly: RegisterProvider("AdoNet", "AdvancedReminders", "Silo", typeof(AdvancedAdoNetRemindersProviderBuilder))] + +namespace Orleans.Hosting; + +internal sealed class AdvancedAdoNetRemindersProviderBuilder : IProviderBuilder +{ + public void Configure(ISiloBuilder builder, string name, IConfigurationSection configurationSection) + { + builder.UseAdoNetAdvancedReminderService((OptionsBuilder optionsBuilder) => optionsBuilder.Configure((options, services) => + { + var invariant = configurationSection[nameof(options.Invariant)]; + if (!string.IsNullOrEmpty(invariant)) + { + options.Invariant = invariant; + } + + var connectionString = configurationSection[nameof(options.ConnectionString)]; + var connectionName = configurationSection["ConnectionName"]; + if (string.IsNullOrEmpty(connectionString) && !string.IsNullOrEmpty(connectionName)) + { + connectionString = services.GetRequiredService().GetConnectionString(connectionName); + } + + if (!string.IsNullOrEmpty(connectionString)) + { + options.ConnectionString = connectionString; + } + })); + } +} diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/GlobalUsings.cs b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/GlobalUsings.cs new file mode 100644 index 0000000000..d59a9bde38 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/GlobalUsings.cs @@ -0,0 +1 @@ +global using Orleans.AdvancedReminders.Runtime; diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/MySQL-Reminders-10.0.0.sql b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/MySQL-Reminders-10.0.0.sql new file mode 100644 index 0000000000..36f98241a1 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/MySQL-Reminders-10.0.0.sql @@ -0,0 +1,150 @@ +-- Run this migration for upgrading MySQL reminder tables created before 10.0.0. + +ALTER TABLE OrleansAdvancedRemindersTable + ADD COLUMN CronExpression NVARCHAR(200) NULL, + ADD COLUMN CronTimeZoneId NVARCHAR(200) NULL, + ADD COLUMN NextDueUtc DATETIME NULL, + ADD COLUMN LastFireUtc DATETIME NULL, + ADD COLUMN Priority TINYINT NOT NULL DEFAULT 0, + ADD COLUMN Action TINYINT NOT NULL DEFAULT 0; + +CREATE INDEX IX_RemindersTable_NextDueUtc_Priority ON OrleansAdvancedRemindersTable(ServiceId, NextDueUtc, Priority); + +UPDATE OrleansQuery +SET QueryText = ' + INSERT INTO OrleansAdvancedRemindersTable + ( + ServiceId, + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + GrainHash, + Version + ) + VALUES + ( + @ServiceId, + @GrainId, + @ReminderName, + @StartTime, + @Period, + @CronExpression, + @CronTimeZoneId, + @NextDueUtc, + @LastFireUtc, + @Priority, + @Action, + @GrainHash, + last_insert_id(0) + ) + ON DUPLICATE KEY + UPDATE + StartTime = @StartTime, + Period = @Period, + CronExpression = @CronExpression, + CronTimeZoneId = @CronTimeZoneId, + NextDueUtc = @NextDueUtc, + LastFireUtc = @LastFireUtc, + Priority = @Priority, + Action = @Action, + GrainHash = @GrainHash, + Version = last_insert_id(Version+1); + + + SELECT last_insert_id() AS Version; +' +WHERE QueryKey = 'UpsertReminderRowKey'; + +UPDATE OrleansQuery +SET QueryText = ' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL; +' +WHERE QueryKey = 'ReadReminderRowsKey'; + +UPDATE OrleansQuery +SET QueryText = ' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL + AND ReminderName = @ReminderName AND @ReminderName IS NOT NULL; +' +WHERE QueryKey = 'ReadReminderRowKey'; + +UPDATE OrleansQuery +SET QueryText = ' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainHash > @BeginHash AND @BeginHash IS NOT NULL + AND GrainHash <= @EndHash AND @EndHash IS NOT NULL; +' +WHERE QueryKey = 'ReadRangeRows1Key'; + +UPDATE OrleansQuery +SET QueryText = ' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND ((GrainHash > @BeginHash AND @BeginHash IS NOT NULL) + OR (GrainHash <= @EndHash AND @EndHash IS NOT NULL)); +' +WHERE QueryKey = 'ReadRangeRows2Key'; diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/Oracle-Reminders-10.0.0.sql b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/Oracle-Reminders-10.0.0.sql new file mode 100644 index 0000000000..19ef4c1763 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/Oracle-Reminders-10.0.0.sql @@ -0,0 +1,126 @@ +-- Run this migration for upgrading Oracle reminder tables created before 10.0.0. + +ALTER TABLE OrleansAdvancedRemindersTable ADD (CronExpression NVARCHAR2(200) NULL); +/ +ALTER TABLE OrleansAdvancedRemindersTable ADD (CronTimeZoneId NVARCHAR2(200) NULL); +/ +ALTER TABLE OrleansAdvancedRemindersTable ADD (NextDueUtc TIMESTAMP(6) NULL); +/ +ALTER TABLE OrleansAdvancedRemindersTable ADD (LastFireUtc TIMESTAMP(6) NULL); +/ +ALTER TABLE OrleansAdvancedRemindersTable ADD (Priority NUMBER(3,0) DEFAULT 0 NOT NULL); +/ +ALTER TABLE OrleansAdvancedRemindersTable ADD (Action NUMBER(3,0) DEFAULT 0 NOT NULL); +/ + +CREATE INDEX IX_REMINDERS_NEXTDUE_PRIORITY ON OrleansAdvancedRemindersTable(SERVICEID, NEXTDUEUTC, PRIORITY); +/ + +CREATE OR REPLACE FUNCTION UpsertReminderRow(PARAM_SERVICEID IN NVARCHAR2, PARAM_GRAINHASH IN INT, PARAM_GRAINID IN VARCHAR2, PARAM_REMINDERNAME IN NVARCHAR2, + PARAM_STARTTIME IN TIMESTAMP, PARAM_PERIOD IN NUMBER, PARAM_CRONEXPRESSION IN NVARCHAR2, + PARAM_CRONTIMEZONEID IN NVARCHAR2, PARAM_NEXTDUEUTC IN TIMESTAMP, PARAM_LASTFIREUTC IN TIMESTAMP, PARAM_PRIORITY IN NUMBER, PARAM_ACTION IN NUMBER) +RETURN NUMBER IS + rowcount NUMBER; + currentVersion NUMBER := 0; + PRAGMA AUTONOMOUS_TRANSACTION; + BEGIN + MERGE INTO OrleansAdvancedRemindersTable ort + USING ( + SELECT PARAM_SERVICEID as SERVICEID, + PARAM_GRAINID as GRAINID, + PARAM_REMINDERNAME as REMINDERNAME, + PARAM_STARTTIME as STARTTIME, + PARAM_PERIOD as PERIOD, + PARAM_CRONEXPRESSION as CRONEXPRESSION, + PARAM_CRONTIMEZONEID as CRONTIMEZONEID, + PARAM_NEXTDUEUTC as NEXTDUEUTC, + PARAM_LASTFIREUTC as LASTFIREUTC, + PARAM_PRIORITY as PRIORITY, + PARAM_ACTION as ACTION, + PARAM_GRAINHASH GRAINHASH + FROM dual + ) n_ort + ON (ort.ServiceId = n_ort.SERVICEID AND + ort.GrainId = n_ort.GRAINID AND + ort.ReminderName = n_ort.REMINDERNAME + ) + WHEN MATCHED THEN + UPDATE SET + ort.StartTime = n_ort.STARTTIME, + ort.Period = n_ort.PERIOD, + ort.CronExpression = n_ort.CRONEXPRESSION, + ort.CronTimeZoneId = n_ort.CRONTIMEZONEID, + ort.NextDueUtc = n_ort.NEXTDUEUTC, + ort.LastFireUtc = n_ort.LASTFIREUTC, + ort.Priority = n_ort.PRIORITY, + ort.Action = n_ort.ACTION, + ort.GrainHash = n_ort.GRAINHASH, + ort.Version = ort.Version+1 + WHEN NOT MATCHED THEN + INSERT (ort.ServiceId, ort.GrainId, ort.ReminderName, ort.StartTime, ort.Period, ort.CronExpression, ort.CronTimeZoneId, ort.NextDueUtc, ort.LastFireUtc, ort.Priority, ort.Action, ort.GrainHash, ort.Version) + VALUES (n_ort.SERVICEID, n_ort.GRAINID, n_ort.REMINDERNAME, n_ort.STARTTIME, n_ort.PERIOD, n_ort.CRONEXPRESSION, n_ort.CRONTIMEZONEID, n_ort.NEXTDUEUTC, n_ort.LASTFIREUTC, n_ort.PRIORITY, n_ort.ACTION, n_ort.GRAINHASH, 0); + + SELECT Version INTO currentVersion FROM OrleansAdvancedRemindersTable + WHERE ServiceId = PARAM_SERVICEID AND PARAM_SERVICEID IS NOT NULL + AND GrainId = PARAM_GRAINID AND PARAM_GRAINID IS NOT NULL + AND ReminderName = PARAM_REMINDERNAME AND PARAM_REMINDERNAME IS NOT NULL; + COMMIT; + RETURN(currentVersion); + END; +/ + +UPDATE OrleansQuery +SET QueryText = ' + SELECT UpsertReminderRow(:ServiceId, :GrainHash, :GrainId, :ReminderName, :StartTime, :Period, :CronExpression, :CronTimeZoneId, :NextDueUtc, :LastFireUtc, :Priority, :Action) AS Version FROM DUAL +' +WHERE QueryKey = 'UpsertReminderRowKey'; +/ + +UPDATE OrleansQuery +SET QueryText = ' + SELECT GrainId, ReminderName, StartTime, Period, CronExpression, CronTimeZoneId, NextDueUtc, LastFireUtc, Priority, Action, Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = :ServiceId AND :ServiceId IS NOT NULL + AND GrainId = :GrainId AND :GrainId IS NOT NULL +' +WHERE QueryKey = 'ReadReminderRowsKey'; +/ + +UPDATE OrleansQuery +SET QueryText = ' + SELECT GrainId, ReminderName, StartTime, Period, CronExpression, CronTimeZoneId, NextDueUtc, LastFireUtc, Priority, Action, Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = :ServiceId AND :ServiceId IS NOT NULL + AND GrainId = :GrainId AND :GrainId IS NOT NULL + AND ReminderName = :ReminderName AND :ReminderName IS NOT NULL +' +WHERE QueryKey = 'ReadReminderRowKey'; +/ + +UPDATE OrleansQuery +SET QueryText = ' + SELECT GrainId, ReminderName, StartTime, Period, CronExpression, CronTimeZoneId, NextDueUtc, LastFireUtc, Priority, Action, Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = :ServiceId AND :ServiceId IS NOT NULL + AND GrainHash > :BeginHash AND :BeginHash IS NOT NULL + AND GrainHash <= :EndHash AND :EndHash IS NOT NULL +' +WHERE QueryKey = 'ReadRangeRows1Key'; +/ + +UPDATE OrleansQuery +SET QueryText = ' + SELECT GrainId, ReminderName, StartTime, Period, CronExpression, CronTimeZoneId, NextDueUtc, LastFireUtc, Priority, Action, Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = :ServiceId AND :ServiceId IS NOT NULL + AND ((GrainHash > :BeginHash AND :BeginHash IS NOT NULL) + OR (GrainHash <= :EndHash AND :EndHash IS NOT NULL)) +' +WHERE QueryKey = 'ReadRangeRows2Key'; +/ + +COMMIT; diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/PostgreSQL-Reminders-10.0.0.sql b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/PostgreSQL-Reminders-10.0.0.sql new file mode 100644 index 0000000000..51caf30658 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/PostgreSQL-Reminders-10.0.0.sql @@ -0,0 +1,188 @@ +-- Run this migration for upgrading PostgreSQL reminder tables created before 10.0.0. + +ALTER TABLE OrleansAdvancedRemindersTable + ADD COLUMN CronExpression varchar(200), + ADD COLUMN CronTimeZoneId varchar(200), + ADD COLUMN NextDueUtc timestamptz(3), + ADD COLUMN LastFireUtc timestamptz(3), + ADD COLUMN Priority smallint NOT NULL DEFAULT 0, + ADD COLUMN Action smallint NOT NULL DEFAULT 0; + +CREATE INDEX IX_RemindersTable_NextDueUtc_Priority + ON OrleansAdvancedRemindersTable(ServiceId, NextDueUtc, Priority); + +CREATE OR REPLACE FUNCTION upsert_reminder_row( + ServiceIdArg OrleansAdvancedRemindersTable.ServiceId%TYPE, + GrainIdArg OrleansAdvancedRemindersTable.GrainId%TYPE, + ReminderNameArg OrleansAdvancedRemindersTable.ReminderName%TYPE, + StartTimeArg OrleansAdvancedRemindersTable.StartTime%TYPE, + PeriodArg OrleansAdvancedRemindersTable.Period%TYPE, + CronExpressionArg OrleansAdvancedRemindersTable.CronExpression%TYPE, + CronTimeZoneIdArg OrleansAdvancedRemindersTable.CronTimeZoneId%TYPE, + NextDueUtcArg OrleansAdvancedRemindersTable.NextDueUtc%TYPE, + LastFireUtcArg OrleansAdvancedRemindersTable.LastFireUtc%TYPE, + PriorityArg OrleansAdvancedRemindersTable.Priority%TYPE, + ActionArg OrleansAdvancedRemindersTable.Action%TYPE, + GrainHashArg OrleansAdvancedRemindersTable.GrainHash%TYPE + ) + RETURNS TABLE(version integer) AS +$func$ +DECLARE + VersionVar int := 0; +BEGIN + + INSERT INTO OrleansAdvancedRemindersTable + ( + ServiceId, + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + GrainHash, + Version + ) + SELECT + ServiceIdArg, + GrainIdArg, + ReminderNameArg, + StartTimeArg, + PeriodArg, + CronExpressionArg, + CronTimeZoneIdArg, + NextDueUtcArg, + LastFireUtcArg, + PriorityArg, + ActionArg, + GrainHashArg, + 0 + ON CONFLICT (ServiceId, GrainId, ReminderName) + DO UPDATE SET + StartTime = excluded.StartTime, + Period = excluded.Period, + CronExpression = excluded.CronExpression, + CronTimeZoneId = excluded.CronTimeZoneId, + NextDueUtc = excluded.NextDueUtc, + LastFireUtc = excluded.LastFireUtc, + Priority = excluded.Priority, + Action = excluded.Action, + GrainHash = excluded.GrainHash, + Version = OrleansAdvancedRemindersTable.Version + 1 + RETURNING + OrleansAdvancedRemindersTable.Version INTO STRICT VersionVar; + + RETURN QUERY SELECT VersionVar AS version; + +END +$func$ LANGUAGE plpgsql; + +UPDATE OrleansQuery +SET QueryText = ' + SELECT * FROM upsert_reminder_row( + @ServiceId, + @GrainId, + @ReminderName, + @StartTime, + @Period::bigint, + @CronExpression, + @CronTimeZoneId, + @NextDueUtc, + @LastFireUtc, + @Priority::smallint, + @Action::smallint, + @GrainHash + ); +' +WHERE QueryKey = 'UpsertReminderRowKey'; + +UPDATE OrleansQuery +SET QueryText = ' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL; +' +WHERE QueryKey = 'ReadReminderRowsKey'; + +UPDATE OrleansQuery +SET QueryText = ' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL + AND ReminderName = @ReminderName AND @ReminderName IS NOT NULL; +' +WHERE QueryKey = 'ReadReminderRowKey'; + +UPDATE OrleansQuery +SET QueryText = ' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainHash > @BeginHash AND @BeginHash IS NOT NULL + AND GrainHash <= @EndHash AND @EndHash IS NOT NULL; +' +WHERE QueryKey = 'ReadRangeRows1Key'; + +UPDATE OrleansQuery +SET QueryText = ' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND ((GrainHash > @BeginHash AND @BeginHash IS NOT NULL) + OR (GrainHash <= @EndHash AND @EndHash IS NOT NULL)); +' +WHERE QueryKey = 'ReadRangeRows2Key'; diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/PostgreSQL-Reminders-3.6.0.sql b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/PostgreSQL-Reminders-3.6.0.sql new file mode 100644 index 0000000000..176f2da4b6 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/PostgreSQL-Reminders-3.6.0.sql @@ -0,0 +1,58 @@ +-- Run this migration for upgrading the PostgreSQL reminder table and routines for deployments created before 3.6.0 + +BEGIN; + +-- Change date type + +ALTER TABLE OrleansAdvancedRemindersTable +ALTER COLUMN StartTime TYPE TIMESTAMPTZ(3) USING StartTime AT TIME ZONE 'UTC'; + +-- Recreate routines + +CREATE OR REPLACE FUNCTION upsert_reminder_row( + ServiceIdArg OrleansAdvancedRemindersTable.ServiceId%TYPE, + GrainIdArg OrleansAdvancedRemindersTable.GrainId%TYPE, + ReminderNameArg OrleansAdvancedRemindersTable.ReminderName%TYPE, + StartTimeArg OrleansAdvancedRemindersTable.StartTime%TYPE, + PeriodArg OrleansAdvancedRemindersTable.Period%TYPE, + GrainHashArg OrleansAdvancedRemindersTable.GrainHash%TYPE + ) + RETURNS TABLE(version integer) AS +$func$ +DECLARE + VersionVar int := 0; +BEGIN + + INSERT INTO OrleansAdvancedRemindersTable + ( + ServiceId, + GrainId, + ReminderName, + StartTime, + Period, + GrainHash, + Version + ) + SELECT + ServiceIdArg, + GrainIdArg, + ReminderNameArg, + StartTimeArg, + PeriodArg, + GrainHashArg, + 0 + ON CONFLICT (ServiceId, GrainId, ReminderName) + DO UPDATE SET + StartTime = excluded.StartTime, + Period = excluded.Period, + GrainHash = excluded.GrainHash, + Version = OrleansAdvancedRemindersTable.Version + 1 + RETURNING + OrleansAdvancedRemindersTable.Version INTO STRICT VersionVar; + + RETURN QUERY SELECT VersionVar AS versionr; + +END +$func$ LANGUAGE plpgsql; + +COMMIT; diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/SQLServer-Reminders-10.0.0.sql b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/SQLServer-Reminders-10.0.0.sql new file mode 100644 index 0000000000..e9ba84614f --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Migrations/SQLServer-Reminders-10.0.0.sql @@ -0,0 +1,152 @@ +-- Run this migration for upgrading SQL Server reminder tables created before 10.0.0. + +ALTER TABLE OrleansAdvancedRemindersTable ADD CronExpression NVARCHAR(200) NULL; +ALTER TABLE OrleansAdvancedRemindersTable ADD CronTimeZoneId NVARCHAR(200) NULL; +ALTER TABLE OrleansAdvancedRemindersTable ADD NextDueUtc DATETIME2(3) NULL; +ALTER TABLE OrleansAdvancedRemindersTable ADD LastFireUtc DATETIME2(3) NULL; +ALTER TABLE OrleansAdvancedRemindersTable ADD Priority TINYINT NOT NULL CONSTRAINT DF_OrleansAdvancedRemindersTable_Priority DEFAULT (0); +ALTER TABLE OrleansAdvancedRemindersTable ADD Action TINYINT NOT NULL CONSTRAINT DF_OrleansAdvancedRemindersTable_Action DEFAULT (0); + +CREATE INDEX IX_RemindersTable_NextDueUtc_Priority +ON OrleansAdvancedRemindersTable(ServiceId, NextDueUtc, Priority); + +UPDATE OrleansQuery +SET QueryText = 'DECLARE @Version AS INT = 0; + SET XACT_ABORT, NOCOUNT ON; + BEGIN TRANSACTION; + UPDATE OrleansAdvancedRemindersTable WITH(UPDLOCK, ROWLOCK, HOLDLOCK) + SET + StartTime = @StartTime, + Period = @Period, + CronExpression = @CronExpression, + CronTimeZoneId = @CronTimeZoneId, + NextDueUtc = @NextDueUtc, + LastFireUtc = @LastFireUtc, + Priority = @Priority, + Action = @Action, + GrainHash = @GrainHash, + @Version = Version = Version + 1 + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL + AND ReminderName = @ReminderName AND @ReminderName IS NOT NULL; + + INSERT INTO OrleansAdvancedRemindersTable + ( + ServiceId, + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + GrainHash, + Version + ) + SELECT + @ServiceId, + @GrainId, + @ReminderName, + @StartTime, + @Period, + @CronExpression, + @CronTimeZoneId, + @NextDueUtc, + @LastFireUtc, + @Priority, + @Action, + @GrainHash, + 0 + WHERE + @@ROWCOUNT=0; + SELECT @Version AS Version; + COMMIT TRANSACTION; + ' +WHERE QueryKey = 'UpsertReminderRowKey'; + +UPDATE OrleansQuery +SET QueryText = 'SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL; + ' +WHERE QueryKey = 'ReadReminderRowsKey'; + +UPDATE OrleansQuery +SET QueryText = 'SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL + AND ReminderName = @ReminderName AND @ReminderName IS NOT NULL; + ' +WHERE QueryKey = 'ReadReminderRowKey'; + +UPDATE OrleansQuery +SET QueryText = 'SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainHash > @BeginHash AND @BeginHash IS NOT NULL + AND GrainHash <= @EndHash AND @EndHash IS NOT NULL; + ' +WHERE QueryKey = 'ReadRangeRows1Key'; + +UPDATE OrleansQuery +SET QueryText = 'SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND ((GrainHash > @BeginHash AND @BeginHash IS NOT NULL) + OR (GrainHash <= @EndHash AND @EndHash IS NOT NULL)); + ' +WHERE QueryKey = 'ReadRangeRows2Key'; diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/MySQL-Reminders.sql b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/MySQL-Reminders.sql new file mode 100644 index 0000000000..12ea9841a4 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/MySQL-Reminders.sql @@ -0,0 +1,188 @@ +-- Orleans Reminders table - https://learn.microsoft.com/dotnet/orleans/grains/timers-and-reminders +CREATE TABLE OrleansAdvancedRemindersTable +( + ServiceId NVARCHAR(150) NOT NULL, + GrainId VARCHAR(150) NOT NULL, + ReminderName NVARCHAR(150) NOT NULL, + StartTime DATETIME NOT NULL, + Period BIGINT NOT NULL, + CronExpression NVARCHAR(200) NULL, + CronTimeZoneId NVARCHAR(200) NULL, + NextDueUtc DATETIME NULL, + LastFireUtc DATETIME NULL, + Priority TINYINT NOT NULL DEFAULT 0, + Action TINYINT NOT NULL DEFAULT 0, + GrainHash INT NOT NULL, + Version INT NOT NULL, + + CONSTRAINT PK_RemindersTable_ServiceId_GrainId_ReminderName PRIMARY KEY(ServiceId, GrainId, ReminderName) +); + +CREATE INDEX IX_RemindersTable_NextDueUtc_Priority +ON OrleansAdvancedRemindersTable(ServiceId, NextDueUtc, Priority); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'UpsertReminderRowKey',' + INSERT INTO OrleansAdvancedRemindersTable + ( + ServiceId, + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + GrainHash, + Version + ) + VALUES + ( + @ServiceId, + @GrainId, + @ReminderName, + @StartTime, + @Period, + @CronExpression, + @CronTimeZoneId, + @NextDueUtc, + @LastFireUtc, + @Priority, + @Action, + @GrainHash, + last_insert_id(0) + ) + ON DUPLICATE KEY + UPDATE + StartTime = @StartTime, + Period = @Period, + CronExpression = @CronExpression, + CronTimeZoneId = @CronTimeZoneId, + NextDueUtc = @NextDueUtc, + LastFireUtc = @LastFireUtc, + Priority = @Priority, + Action = @Action, + GrainHash = @GrainHash, + Version = last_insert_id(Version+1); + + + SELECT last_insert_id() AS Version; +'); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'ReadReminderRowsKey',' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL; +'); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'ReadReminderRowKey',' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL + AND ReminderName = @ReminderName AND @ReminderName IS NOT NULL; +'); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'ReadRangeRows1Key',' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainHash > @BeginHash AND @BeginHash IS NOT NULL + AND GrainHash <= @EndHash AND @EndHash IS NOT NULL; +'); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'ReadRangeRows2Key',' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND ((GrainHash > @BeginHash AND @BeginHash IS NOT NULL) + OR (GrainHash <= @EndHash AND @EndHash IS NOT NULL)); +'); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'DeleteReminderRowKey',' + DELETE FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL + AND ReminderName = @ReminderName AND @ReminderName IS NOT NULL + AND Version = @Version AND @Version IS NOT NULL; + SELECT ROW_COUNT(); +'); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'DeleteReminderRowsKey',' + DELETE FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL; +'); diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Oracle-Reminders.sql b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Oracle-Reminders.sql new file mode 100644 index 0000000000..662a195cd3 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Oracle-Reminders.sql @@ -0,0 +1,172 @@ +-- Orleans Reminders table - https://learn.microsoft.com/dotnet/orleans/grains/timers-and-reminders +CREATE TABLE "ORLEANSREMINDERSTABLE" +( + "SERVICEID" NVARCHAR2(150) NOT NULL ENABLE, + "GRAINID" VARCHAR2(150) NOT NULL, + "REMINDERNAME" NVARCHAR2(150) NOT NULL, + "STARTTIME" TIMESTAMP(6) NOT NULL ENABLE, + "PERIOD" NUMBER(19,0) NULL, + "CRONEXPRESSION" NVARCHAR2(200) NULL, + "CRONTIMEZONEID" NVARCHAR2(200) NULL, + "NEXTDUEUTC" TIMESTAMP(6) NULL, + "LASTFIREUTC" TIMESTAMP(6) NULL, + "PRIORITY" NUMBER(3,0) DEFAULT 0 NOT NULL, + "ACTION" NUMBER(3,0) DEFAULT 0 NOT NULL, + "GRAINHASH" INT NOT NULL, + "VERSION" INT NOT NULL, + + CONSTRAINT PK_REMINDERSTABLE PRIMARY KEY(SERVICEID, GRAINID, REMINDERNAME) +); +/ +CREATE INDEX IX_REMINDERS_NEXTDUE_PRIORITY ON OrleansAdvancedRemindersTable(SERVICEID, NEXTDUEUTC, PRIORITY); +/ + +CREATE OR REPLACE FUNCTION UpsertReminderRow(PARAM_SERVICEID IN NVARCHAR2, PARAM_GRAINHASH IN INT, PARAM_GRAINID IN VARCHAR2, PARAM_REMINDERNAME IN NVARCHAR2, + PARAM_STARTTIME IN TIMESTAMP, PARAM_PERIOD IN NUMBER, PARAM_CRONEXPRESSION IN NVARCHAR2, + PARAM_CRONTIMEZONEID IN NVARCHAR2, PARAM_NEXTDUEUTC IN TIMESTAMP, PARAM_LASTFIREUTC IN TIMESTAMP, PARAM_PRIORITY IN NUMBER, PARAM_ACTION IN NUMBER) +RETURN NUMBER IS + rowcount NUMBER; + currentVersion NUMBER := 0; + PRAGMA AUTONOMOUS_TRANSACTION; + BEGIN + MERGE INTO OrleansAdvancedRemindersTable ort + USING ( + SELECT PARAM_SERVICEID as SERVICEID, + PARAM_GRAINID as GRAINID, + PARAM_REMINDERNAME as REMINDERNAME, + PARAM_STARTTIME as STARTTIME, + PARAM_PERIOD as PERIOD, + PARAM_CRONEXPRESSION as CRONEXPRESSION, + PARAM_CRONTIMEZONEID as CRONTIMEZONEID, + PARAM_NEXTDUEUTC as NEXTDUEUTC, + PARAM_LASTFIREUTC as LASTFIREUTC, + PARAM_PRIORITY as PRIORITY, + PARAM_ACTION as ACTION, + PARAM_GRAINHASH GRAINHASH + FROM dual + ) n_ort + ON (ort.ServiceId = n_ort.SERVICEID AND + ort.GrainId = n_ort.GRAINID AND + ort.ReminderName = n_ort.REMINDERNAME + ) + WHEN MATCHED THEN + UPDATE SET + ort.StartTime = n_ort.STARTTIME, + ort.Period = n_ort.PERIOD, + ort.CronExpression = n_ort.CRONEXPRESSION, + ort.CronTimeZoneId = n_ort.CRONTIMEZONEID, + ort.NextDueUtc = n_ort.NEXTDUEUTC, + ort.LastFireUtc = n_ort.LASTFIREUTC, + ort.Priority = n_ort.PRIORITY, + ort.Action = n_ort.ACTION, + ort.GrainHash = n_ort.GRAINHASH, + ort.Version = ort.Version+1 + WHEN NOT MATCHED THEN + INSERT (ort.ServiceId, ort.GrainId, ort.ReminderName, ort.StartTime, ort.Period, ort.CronExpression, ort.CronTimeZoneId, ort.NextDueUtc, ort.LastFireUtc, ort.Priority, ort.Action, ort.GrainHash, ort.Version) + VALUES (n_ort.SERVICEID, n_ort.GRAINID, n_ort.REMINDERNAME, n_ort.STARTTIME, n_ort.PERIOD, n_ort.CRONEXPRESSION, n_ort.CRONTIMEZONEID, n_ort.NEXTDUEUTC, n_ort.LASTFIREUTC, n_ort.PRIORITY, n_ort.ACTION, n_ort.GRAINHASH, 0); + + SELECT Version INTO currentVersion FROM OrleansAdvancedRemindersTable + WHERE ServiceId = PARAM_SERVICEID AND PARAM_SERVICEID IS NOT NULL + AND GrainId = PARAM_GRAINID AND PARAM_GRAINID IS NOT NULL + AND ReminderName = PARAM_REMINDERNAME AND PARAM_REMINDERNAME IS NOT NULL; + COMMIT; + RETURN(currentVersion); + END; +/ + +CREATE OR REPLACE FUNCTION DeleteReminderRow(PARAM_SERVICEID IN NVARCHAR2, PARAM_GRAINID IN VARCHAR2, PARAM_REMINDERNAME IN NVARCHAR2, + PARAM_VERSION IN NUMBER) +RETURN NUMBER IS + rowcount NUMBER; + PRAGMA AUTONOMOUS_TRANSACTION; + BEGIN + DELETE FROM OrleansAdvancedRemindersTable + WHERE ServiceId = PARAM_SERVICEID AND PARAM_SERVICEID IS NOT NULL + AND GrainId = PARAM_GRAINID AND PARAM_GRAINID IS NOT NULL + AND ReminderName = PARAM_REMINDERNAME AND PARAM_REMINDERNAME IS NOT NULL + AND Version = PARAM_VERSION AND PARAM_VERSION IS NOT NULL; + + rowcount := SQL%ROWCOUNT; + + COMMIT; + RETURN(rowcount); + END; +/ + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'UpsertReminderRowKey',' + SELECT UpsertReminderRow(:ServiceId, :GrainHash, :GrainId, :ReminderName, :StartTime, :Period, :CronExpression, :CronTimeZoneId, :NextDueUtc, :LastFireUtc, :Priority, :Action) AS Version FROM DUAL +'); +/ + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'ReadReminderRowsKey',' + SELECT GrainId, ReminderName, StartTime, Period, CronExpression, CronTimeZoneId, NextDueUtc, LastFireUtc, Priority, Action, Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = :ServiceId AND :ServiceId IS NOT NULL + AND GrainId = :GrainId AND :GrainId IS NOT NULL +'); +/ + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'ReadReminderRowKey',' + SELECT GrainId, ReminderName, StartTime, Period, CronExpression, CronTimeZoneId, NextDueUtc, LastFireUtc, Priority, Action, Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = :ServiceId AND :ServiceId IS NOT NULL + AND GrainId = :GrainId AND :GrainId IS NOT NULL + AND ReminderName = :ReminderName AND :ReminderName IS NOT NULL +'); +/ + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'ReadRangeRows1Key',' + SELECT GrainId, ReminderName, StartTime, Period, CronExpression, CronTimeZoneId, NextDueUtc, LastFireUtc, Priority, Action, Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = :ServiceId AND :ServiceId IS NOT NULL + AND GrainHash > :BeginHash AND :BeginHash IS NOT NULL + AND GrainHash <= :EndHash AND :EndHash IS NOT NULL +'); +/ + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'ReadRangeRows2Key',' + SELECT GrainId, ReminderName, StartTime, Period, CronExpression, CronTimeZoneId, NextDueUtc, LastFireUtc, Priority, Action, Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = :ServiceId AND :ServiceId IS NOT NULL + AND ((GrainHash > :BeginHash AND :BeginHash IS NOT NULL) + OR (GrainHash <= :EndHash AND :EndHash IS NOT NULL)) +'); +/ + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'DeleteReminderRowKey',' + SELECT DeleteReminderRow(:ServiceId, :GrainId, :ReminderName, :Version) AS RESULT FROM DUAL +'); +/ + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'DeleteReminderRowsKey',' + DELETE FROM OrleansAdvancedRemindersTable + WHERE ServiceId = :ServiceId AND :ServiceId IS NOT NULL +'); +/ + +COMMIT; diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Orleans.AdvancedReminders.AdoNet.csproj b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Orleans.AdvancedReminders.AdoNet.csproj new file mode 100644 index 0000000000..18f7f74a9a --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/Orleans.AdvancedReminders.AdoNet.csproj @@ -0,0 +1,34 @@ + + + README.md + Microsoft.Orleans.AdvancedReminders.AdoNet + Microsoft Orleans Advanced Reminders for ADO.NET + ADO.NET provider for Microsoft Orleans Advanced Reminders. + $(PackageTags) ADO.NET SQL MySQL PostgreSQL Oracle + $(DefaultTargetFrameworks) + true + + + + Orleans.AdvancedReminders.AdoNet + Orleans.AdvancedReminders.AdoNet + $(DefineConstants);REMINDERS_ADONET;ADVANCED_REMINDERS_ADONET + + + + + + + + + + + + + + + + + + + diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/PostgreSQL-Reminders.sql b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/PostgreSQL-Reminders.sql new file mode 100644 index 0000000000..15e2c6d76c --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/PostgreSQL-Reminders.sql @@ -0,0 +1,251 @@ +-- Orleans Reminders table - https://learn.microsoft.com/dotnet/orleans/grains/timers-and-reminders +CREATE TABLE OrleansAdvancedRemindersTable +( + ServiceId varchar(150) NOT NULL, + GrainId varchar(150) NOT NULL, + ReminderName varchar(150) NOT NULL, + StartTime timestamptz(3) NOT NULL, + Period bigint NOT NULL, + CronExpression varchar(200) NULL, + CronTimeZoneId varchar(200) NULL, + NextDueUtc timestamptz(3) NULL, + LastFireUtc timestamptz(3) NULL, + Priority smallint NOT NULL DEFAULT 0, + Action smallint NOT NULL DEFAULT 0, + GrainHash integer NOT NULL, + Version integer NOT NULL, + + CONSTRAINT PK_RemindersTable_ServiceId_GrainId_ReminderName PRIMARY KEY(ServiceId, GrainId, ReminderName) +); + +CREATE INDEX IX_RemindersTable_NextDueUtc_Priority + ON OrleansAdvancedRemindersTable(ServiceId, NextDueUtc, Priority); + +CREATE FUNCTION upsert_reminder_row( + ServiceIdArg OrleansAdvancedRemindersTable.ServiceId%TYPE, + GrainIdArg OrleansAdvancedRemindersTable.GrainId%TYPE, + ReminderNameArg OrleansAdvancedRemindersTable.ReminderName%TYPE, + StartTimeArg OrleansAdvancedRemindersTable.StartTime%TYPE, + PeriodArg OrleansAdvancedRemindersTable.Period%TYPE, + CronExpressionArg OrleansAdvancedRemindersTable.CronExpression%TYPE, + CronTimeZoneIdArg OrleansAdvancedRemindersTable.CronTimeZoneId%TYPE, + NextDueUtcArg OrleansAdvancedRemindersTable.NextDueUtc%TYPE, + LastFireUtcArg OrleansAdvancedRemindersTable.LastFireUtc%TYPE, + PriorityArg OrleansAdvancedRemindersTable.Priority%TYPE, + ActionArg OrleansAdvancedRemindersTable.Action%TYPE, + GrainHashArg OrleansAdvancedRemindersTable.GrainHash%TYPE + ) + RETURNS TABLE(version integer) AS +$func$ +DECLARE + VersionVar int := 0; +BEGIN + + INSERT INTO OrleansAdvancedRemindersTable + ( + ServiceId, + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + GrainHash, + Version + ) + SELECT + ServiceIdArg, + GrainIdArg, + ReminderNameArg, + StartTimeArg, + PeriodArg, + CronExpressionArg, + CronTimeZoneIdArg, + NextDueUtcArg, + LastFireUtcArg, + PriorityArg, + ActionArg, + GrainHashArg, + 0 + ON CONFLICT (ServiceId, GrainId, ReminderName) + DO UPDATE SET + StartTime = excluded.StartTime, + Period = excluded.Period, + CronExpression = excluded.CronExpression, + CronTimeZoneId = excluded.CronTimeZoneId, + NextDueUtc = excluded.NextDueUtc, + LastFireUtc = excluded.LastFireUtc, + Priority = excluded.Priority, + Action = excluded.Action, + GrainHash = excluded.GrainHash, + Version = OrleansAdvancedRemindersTable.Version + 1 + RETURNING + OrleansAdvancedRemindersTable.Version INTO STRICT VersionVar; + + RETURN QUERY SELECT VersionVar AS version; + +END +$func$ LANGUAGE plpgsql; + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'UpsertReminderRowKey',' + SELECT * FROM upsert_reminder_row( + @ServiceId, + @GrainId, + @ReminderName, + @StartTime, + @Period::bigint, + @CronExpression, + @CronTimeZoneId, + @NextDueUtc, + @LastFireUtc, + @Priority::smallint, + @Action::smallint, + @GrainHash + ); +'); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'ReadReminderRowsKey',' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL; +'); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'ReadReminderRowKey',' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL + AND ReminderName = @ReminderName AND @ReminderName IS NOT NULL; +'); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'ReadRangeRows1Key',' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainHash > @BeginHash AND @BeginHash IS NOT NULL + AND GrainHash <= @EndHash AND @EndHash IS NOT NULL; +'); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'ReadRangeRows2Key',' + SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND ((GrainHash > @BeginHash AND @BeginHash IS NOT NULL) + OR (GrainHash <= @EndHash AND @EndHash IS NOT NULL)); +'); + +CREATE FUNCTION delete_reminder_row( + ServiceIdArg OrleansAdvancedRemindersTable.ServiceId%TYPE, + GrainIdArg OrleansAdvancedRemindersTable.GrainId%TYPE, + ReminderNameArg OrleansAdvancedRemindersTable.ReminderName%TYPE, + VersionArg OrleansAdvancedRemindersTable.Version%TYPE +) + RETURNS TABLE(row_count integer) AS +$func$ +DECLARE + RowCountVar int := 0; +BEGIN + + + DELETE FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = ServiceIdArg AND ServiceIdArg IS NOT NULL + AND GrainId = GrainIdArg AND GrainIdArg IS NOT NULL + AND ReminderName = ReminderNameArg AND ReminderNameArg IS NOT NULL + AND Version = VersionArg AND VersionArg IS NOT NULL; + + GET DIAGNOSTICS RowCountVar = ROW_COUNT; + + RETURN QUERY SELECT RowCountVar; + +END +$func$ LANGUAGE plpgsql; + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'DeleteReminderRowKey',' + SELECT * FROM delete_reminder_row( + @ServiceId, + @GrainId, + @ReminderName, + @Version + ); +'); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +VALUES +( + 'DeleteReminderRowsKey',' + DELETE FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL; +'); diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/README.md b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/README.md new file mode 100644 index 0000000000..2b85effce7 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/README.md @@ -0,0 +1,128 @@ +# Microsoft Orleans Advanced Reminders for ADO.NET + +## Introduction +Microsoft Orleans Advanced Reminders for ADO.NET stores reminder definitions in ADO.NET-compatible databases. + +This package does not include an ADO.NET-backed durable jobs implementation. You must also configure a durable jobs backend, for example `UseInMemoryDurableJobs()` for local development or `UseAzureBlobDurableJobs(...)` for persisted execution. + +## Getting Started +To use this package, install it via NuGet: + +```shell +dotnet add package Microsoft.Orleans.AdvancedReminders.AdoNet +``` + +You will also need to install the appropriate ADO.NET provider for your database: + +```shell +# For SQL Server +dotnet add package Microsoft.Data.SqlClient + +# For MySQL +dotnet add package MySql.Data + +# For PostgreSQL +dotnet add package Npgsql +``` + +## Example - Configuring ADO.NET Advanced Reminders +```csharp +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Configuration; +using Orleans.AdvancedReminders; +using Orleans.Hosting; +using Orleans.DurableJobs; +using Example; + +// Create a host builder +var builder = Host.CreateApplicationBuilder(args); +builder.UseOrleans(siloBuilder => +{ + siloBuilder + .UseLocalhostClustering() + .UseInMemoryDurableJobs() + // Configure ADO.NET for reminder definitions + .UseAdoNetAdvancedReminderService(options => + { + options.Invariant = "Microsoft.Data.SqlClient"; // For SQL Server + options.ConnectionString = "Server=localhost;Database=OrleansAdvancedReminders;User ID=orleans;******;"; + }); +}); + +// Build and start the host +var host = builder.Build(); +await host.StartAsync(); + +// Get a grain reference and use it +var grain = host.Services.GetRequiredService().GetGrain("my-reminder-grain"); +await grain.StartReminder("DailyReport"); +Console.WriteLine("Reminder started successfully!"); + +// Keep the host running until the application is shut down +await host.WaitForShutdownAsync(); +``` + +## Example - Using Reminders in a Grain +```csharp +using System; +using System.Threading.Tasks; +using Orleans; +using Orleans.AdvancedReminders; +using Orleans.AdvancedReminders.Runtime; + +namespace Example; + +public interface IReminderGrain : IGrainWithStringKey +{ + Task StartReminder(string reminderName); + Task StopReminder(); +} + +public class ReminderGrain : Grain, IReminderGrain, IRemindable +{ + private string _reminderName = "MyReminder"; + + public async Task StartReminder(string reminderName) + { + _reminderName = reminderName; + + // Register a persistent reminder + await RegisterOrUpdateReminder( + reminderName, + TimeSpan.FromMinutes(2), // Time to delay before the first tick (must be > 1 minute) + TimeSpan.FromMinutes(5)); // Period of the reminder (must be > 1 minute) + } + + public async Task StopReminder() + { + // Find and unregister the reminder + var reminder = await GetReminder(_reminderName); + if (reminder != null) + { + await UnregisterReminder(reminder); + } + } + + public Task ReceiveReminder(string reminderName, TickStatus status) + { + // This method is called when the reminder ticks + Console.WriteLine($"Reminder {reminderName} triggered at {DateTime.UtcNow}. Status: {status}"); + return Task.CompletedTask; + } +} +``` + +## Documentation +For more comprehensive documentation, please refer to: +- [Microsoft Orleans Documentation](https://learn.microsoft.com/dotnet/orleans/) +- [Reminders and Timers](https://learn.microsoft.com/en-us/dotnet/orleans/grains/timers-and-reminders) +- [Reminder Services](https://learn.microsoft.com/en-us/dotnet/orleans/implementation/reminder-services) +- [ADO.NET Database Setup](https://learn.microsoft.com/en-us/dotnet/orleans/host/configuration-guide/adonet-configuration) + +## Feedback & Contributing +- If you have any issues or would like to provide feedback, please [open an issue on GitHub](https://github.com/dotnet/orleans/issues) +- Join our community on [Discord](https://aka.ms/orleans-discord) +- Follow the [@msftorleans](https://twitter.com/msftorleans) Twitter account for Orleans announcements +- Contributions are welcome! Please review our [contribution guidelines](https://github.com/dotnet/orleans/blob/main/CONTRIBUTING.md) +- This project is licensed under the [MIT license](https://github.com/dotnet/orleans/blob/main/LICENSE) diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/ReminderService/AdoNetReminderTable.cs b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/ReminderService/AdoNetReminderTable.cs new file mode 100644 index 0000000000..0619a1d461 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/ReminderService/AdoNetReminderTable.cs @@ -0,0 +1,75 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.AdvancedReminders.AdoNet; +using Orleans.Reminders.AdoNet.Storage; + +namespace Orleans.AdvancedReminders.Runtime.ReminderService +{ + internal sealed class AdoNetReminderTable : IReminderTable + { + private readonly AdoNetReminderTableOptions options; + private readonly string serviceId; + private RelationalOrleansQueries orleansQueries; + + public AdoNetReminderTable( + IOptions clusterOptions, + IOptions storageOptions) + { + this.serviceId = clusterOptions.Value.ServiceId; + this.options = storageOptions.Value; + } + + public async Task Init() + { + this.orleansQueries = await RelationalOrleansQueries.CreateInstance(this.options.Invariant, this.options.ConnectionString); + } + + public Task ReadRows(GrainId grainId) + { + return this.orleansQueries.ReadReminderRowsAsync(this.serviceId, grainId); + } + + public Task ReadRows(uint beginHash, uint endHash) + { + return this.orleansQueries.ReadReminderRowsAsync(this.serviceId, beginHash, endHash); + } + + public Task ReadRow(GrainId grainId, string reminderName) + { + return this.orleansQueries.ReadReminderRowAsync(this.serviceId, grainId, reminderName); + } + + public Task UpsertRow(ReminderEntry entry) + { + if (entry.StartAt.Kind is DateTimeKind.Unspecified) + { + entry.StartAt = new DateTime(entry.StartAt.Ticks, DateTimeKind.Utc); + } + + return this.orleansQueries.UpsertReminderRowAsync( + this.serviceId, + entry.GrainId, + entry.ReminderName, + entry.StartAt, + entry.Period, + entry.CronExpression, + entry.CronTimeZoneId, + entry.NextDueUtc, + entry.LastFireUtc, + entry.Priority, + entry.Action); + } + + public Task RemoveRow(GrainId grainId, string reminderName, string eTag) + { + return this.orleansQueries.DeleteReminderRowAsync(this.serviceId, grainId, reminderName, eTag); + } + + public Task TestOnlyClearTable() + { + return this.orleansQueries.DeleteReminderRowsAsync(this.serviceId); + } + } +} diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/ReminderService/AdoNetReminderTableOptions.cs b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/ReminderService/AdoNetReminderTableOptions.cs new file mode 100644 index 0000000000..24627e06c2 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/ReminderService/AdoNetReminderTableOptions.cs @@ -0,0 +1,19 @@ +namespace Orleans.AdvancedReminders.AdoNet +{ + /// + /// Options for ADO.NET reminder storage. + /// + public class AdoNetReminderTableOptions + { + /// + /// Gets or sets the ADO.NET invariant. + /// + public string Invariant { get; set; } + + /// + /// Gets or sets the connection string. + /// + [Redact] + public string ConnectionString { get; set; } + } +} diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/ReminderService/AdoNetReminderTableOptionsValidator.cs b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/ReminderService/AdoNetReminderTableOptionsValidator.cs new file mode 100644 index 0000000000..7cdcec37d2 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/ReminderService/AdoNetReminderTableOptionsValidator.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Options; +using Orleans.Runtime; +using Orleans.AdvancedReminders.Runtime.ReminderService; + +namespace Orleans.AdvancedReminders.AdoNet +{ + /// + /// Validates configuration. + /// + public class AdoNetReminderTableOptionsValidator : IConfigurationValidator + { + private readonly AdoNetReminderTableOptions options; + + public AdoNetReminderTableOptionsValidator(IOptions options) + { + this.options = options.Value; + } + + /// + public void ValidateConfiguration() + { + if (string.IsNullOrWhiteSpace(this.options.Invariant)) + { + throw new OrleansConfigurationException($"Invalid {nameof(AdoNetReminderTableOptions)} values for {nameof(AdoNetReminderTable)}. {nameof(options.Invariant)} is required."); + } + + if (string.IsNullOrWhiteSpace(this.options.ConnectionString)) + { + throw new OrleansConfigurationException($"Invalid {nameof(AdoNetReminderTableOptions)} values for {nameof(AdoNetReminderTable)}. {nameof(options.ConnectionString)} is required."); + } + } + } +} diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/SQLServer-Reminders.sql b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/SQLServer-Reminders.sql new file mode 100644 index 0000000000..b581f50e87 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/SQLServer-Reminders.sql @@ -0,0 +1,239 @@ +-- Orleans Reminders table - https://learn.microsoft.com/dotnet/orleans/grains/timers-and-reminders +IF OBJECT_ID(N'[OrleansAdvancedRemindersTable]', 'U') IS NULL +CREATE TABLE OrleansAdvancedRemindersTable +( + ServiceId NVARCHAR(150) NOT NULL, + GrainId VARCHAR(150) NOT NULL, + ReminderName NVARCHAR(150) NOT NULL, + StartTime DATETIME2(3) NOT NULL, + Period BIGINT NOT NULL, + CronExpression NVARCHAR(200) NULL, + CronTimeZoneId NVARCHAR(200) NULL, + NextDueUtc DATETIME2(3) NULL, + LastFireUtc DATETIME2(3) NULL, + Priority TINYINT NOT NULL CONSTRAINT DF_OrleansAdvancedRemindersTable_Priority DEFAULT (0), + Action TINYINT NOT NULL CONSTRAINT DF_OrleansAdvancedRemindersTable_Action DEFAULT (0), + GrainHash INT NOT NULL, + Version INT NOT NULL, + + CONSTRAINT PK_RemindersTable_ServiceId_GrainId_ReminderName PRIMARY KEY(ServiceId, GrainId, ReminderName) +); + +IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE name = 'IX_RemindersTable_NextDueUtc_Priority' + AND object_id = OBJECT_ID('OrleansAdvancedRemindersTable') +) +BEGIN + CREATE INDEX IX_RemindersTable_NextDueUtc_Priority + ON OrleansAdvancedRemindersTable(ServiceId, NextDueUtc, Priority); +END; + +INSERT INTO OrleansQuery(QueryKey, QueryText) +SELECT + 'UpsertReminderRowKey', + 'DECLARE @Version AS INT = 0; + SET XACT_ABORT, NOCOUNT ON; + BEGIN TRANSACTION; + UPDATE OrleansAdvancedRemindersTable WITH(UPDLOCK, ROWLOCK, HOLDLOCK) + SET + StartTime = @StartTime, + Period = @Period, + CronExpression = @CronExpression, + CronTimeZoneId = @CronTimeZoneId, + NextDueUtc = @NextDueUtc, + LastFireUtc = @LastFireUtc, + Priority = @Priority, + Action = @Action, + GrainHash = @GrainHash, + @Version = Version = Version + 1 + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL + AND ReminderName = @ReminderName AND @ReminderName IS NOT NULL; + + INSERT INTO OrleansAdvancedRemindersTable + ( + ServiceId, + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + GrainHash, + Version + ) + SELECT + @ServiceId, + @GrainId, + @ReminderName, + @StartTime, + @Period, + @CronExpression, + @CronTimeZoneId, + @NextDueUtc, + @LastFireUtc, + @Priority, + @Action, + @GrainHash, + 0 + WHERE + @@ROWCOUNT=0; + SELECT @Version AS Version; + COMMIT TRANSACTION; + ' +WHERE NOT EXISTS +( + SELECT 1 + FROM OrleansQuery oqt + WHERE oqt.[QueryKey] = 'UpsertReminderRowKey' +); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +SELECT + 'ReadReminderRowsKey', + 'SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL; + ' +WHERE NOT EXISTS +( + SELECT 1 + FROM OrleansQuery oqt + WHERE oqt.[QueryKey] = 'ReadReminderRowsKey' +); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +SELECT + 'ReadReminderRowKey', + 'SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL + AND ReminderName = @ReminderName AND @ReminderName IS NOT NULL; + ' +WHERE NOT EXISTS +( + SELECT 1 + FROM OrleansQuery oqt + WHERE oqt.[QueryKey] = 'ReadReminderRowKey' +); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +SELECT + 'ReadRangeRows1Key', + 'SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainHash > @BeginHash AND @BeginHash IS NOT NULL + AND GrainHash <= @EndHash AND @EndHash IS NOT NULL; + ' +WHERE NOT EXISTS +( + SELECT 1 + FROM OrleansQuery oqt + WHERE oqt.[QueryKey] = 'ReadRangeRows1Key' +); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +SELECT + 'ReadRangeRows2Key', + 'SELECT + GrainId, + ReminderName, + StartTime, + Period, + CronExpression, + CronTimeZoneId, + NextDueUtc, + LastFireUtc, + Priority, + Action, + Version + FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND ((GrainHash > @BeginHash AND @BeginHash IS NOT NULL) + OR (GrainHash <= @EndHash AND @EndHash IS NOT NULL)); + ' +WHERE NOT EXISTS +( + SELECT 1 + FROM OrleansQuery oqt + WHERE oqt.[QueryKey] = 'ReadRangeRows2Key' +); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +SELECT + 'DeleteReminderRowKey', + 'DELETE FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL + AND GrainId = @GrainId AND @GrainId IS NOT NULL + AND ReminderName = @ReminderName AND @ReminderName IS NOT NULL + AND Version = @Version AND @Version IS NOT NULL; + SELECT @@ROWCOUNT; + ' +WHERE NOT EXISTS +( + SELECT 1 + FROM OrleansQuery oqt + WHERE oqt.[QueryKey] = 'DeleteReminderRowKey' +); + +INSERT INTO OrleansQuery(QueryKey, QueryText) +SELECT + 'DeleteReminderRowsKey', + 'DELETE FROM OrleansAdvancedRemindersTable + WHERE + ServiceId = @ServiceId AND @ServiceId IS NOT NULL; + ' +WHERE NOT EXISTS +( + SELECT 1 + FROM OrleansQuery oqt + WHERE oqt.[QueryKey] = 'DeleteReminderRowsKey' +); diff --git a/src/AdoNet/Orleans.AdvancedReminders.AdoNet/SiloBuilderReminderExtensions.cs b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/SiloBuilderReminderExtensions.cs new file mode 100644 index 0000000000..0fa23ada51 --- /dev/null +++ b/src/AdoNet/Orleans.AdvancedReminders.AdoNet/SiloBuilderReminderExtensions.cs @@ -0,0 +1,63 @@ +using System; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.Configuration.Internal; +using Orleans.Hosting; +using Orleans.AdvancedReminders.Runtime.ReminderService; +using Orleans.AdvancedReminders.AdoNet; + +namespace Orleans.Hosting +{ + /// + /// Silo host builder extensions. + /// + public static class SiloBuilderReminderExtensions + { + /// Adds reminder storage using ADO.NET. Instructions on configuring your database are available at . + /// The builder. + /// Configuration delegate. + /// The provided , for chaining. + /// + /// Instructions on configuring your database are available at . + /// + public static ISiloBuilder UseAdoNetAdvancedReminderService( + this ISiloBuilder builder, + Action configureOptions) + { + return builder.UseAdoNetAdvancedReminderService(ob => ob.Configure(configureOptions)); + } + + /// Adds reminder storage using ADO.NET. Instructions on configuring your database are available at . + /// The builder. + /// Configuration delegate. + /// The provided , for chaining. + /// + /// Instructions on configuring your database are available at . + /// + public static ISiloBuilder UseAdoNetAdvancedReminderService( + this ISiloBuilder builder, + Action> configureOptions) + { + return builder.ConfigureServices(services => services.UseAdoNetAdvancedReminderService(configureOptions)); + } + + /// Adds reminder storage using ADO.NET. Instructions on configuring your database are available at . + /// The service collection. + /// Configuration delegate. + /// The provided , for chaining. + /// + /// Instructions on configuring your database are available at . + /// + public static IServiceCollection UseAdoNetAdvancedReminderService(this IServiceCollection services, Action> configureOptions) + { + services.AddAdvancedReminders(); + services.AddSingleton(); + services.ConfigureFormatter(); + services.AddSingleton(); + configureOptions(services.AddOptions()); + return services; + } + } +} diff --git a/src/AdoNet/Shared/Storage/DbExtensions.cs b/src/AdoNet/Shared/Storage/DbExtensions.cs index 48c615dc2a..52a6cd17d2 100644 --- a/src/AdoNet/Shared/Storage/DbExtensions.cs +++ b/src/AdoNet/Shared/Storage/DbExtensions.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Data; using System.Data.Common; +using System.Globalization; using System.Threading; using System.Threading.Tasks; @@ -273,7 +274,7 @@ public static int GetInt32(this IDataRecord record, string fieldName) try { var ordinal = record.GetOrdinal(fieldName); - return record.GetInt32(ordinal); + return Convert.ToInt32(record.GetValue(ordinal), CultureInfo.InvariantCulture); } catch (IndexOutOfRangeException e) { @@ -318,7 +319,7 @@ public static long GetInt64(this IDataRecord record, string fieldName) if (value == DBNull.Value) return null; - return Convert.ToInt32(value); + return Convert.ToInt32(value, CultureInfo.InvariantCulture); } catch (IndexOutOfRangeException e) { diff --git a/src/AdoNet/Shared/Storage/DbStoredQueries.cs b/src/AdoNet/Shared/Storage/DbStoredQueries.cs index f1c9de17e1..63bcb56296 100644 --- a/src/AdoNet/Shared/Storage/DbStoredQueries.cs +++ b/src/AdoNet/Shared/Storage/DbStoredQueries.cs @@ -265,7 +265,7 @@ private static string TryGetSiloName(IDataRecord record) internal static int GetVersion(IDataRecord record) { - return Convert.ToInt32(record.GetValue(nameof(Version))); + return record.GetInt32(nameof(Version)); } internal static Uri GetGatewayUri(IDataRecord record) @@ -458,6 +458,36 @@ internal string ReminderName set { Add(nameof(ReminderName), value); } } + internal string CronExpression + { + set { Add(nameof(CronExpression), value, dbType: DbType.String); } + } + + internal string CronTimeZoneId + { + set { Add(nameof(CronTimeZoneId), value, dbType: DbType.String); } + } + + internal DateTime? NextDueUtc + { + set { Add(nameof(NextDueUtc), value); } + } + + internal DateTime? LastFireUtc + { + set { Add(nameof(LastFireUtc), value); } + } + + internal int Priority + { + set { Add(nameof(Priority), value); } + } + + internal int Action + { + set { Add(nameof(Action), value); } + } + internal TimeSpan Period { set diff --git a/src/AdoNet/Shared/Storage/RelationalOrleansQueries.cs b/src/AdoNet/Shared/Storage/RelationalOrleansQueries.cs index 2a0d136cb6..fd0b9e6941 100644 --- a/src/AdoNet/Shared/Storage/RelationalOrleansQueries.cs +++ b/src/AdoNet/Shared/Storage/RelationalOrleansQueries.cs @@ -6,6 +6,13 @@ using System.Threading.Tasks; using Orleans.Runtime; +#if ADVANCED_REMINDERS_ADONET +using ReminderEntry = Orleans.AdvancedReminders.ReminderEntry; +using ReminderTableData = Orleans.AdvancedReminders.ReminderTableData; +using ReminderPriority = Orleans.AdvancedReminders.Runtime.ReminderPriority; +using MissedReminderAction = Orleans.AdvancedReminders.Runtime.MissedReminderAction; +#endif + #nullable disable #if CLUSTERING_ADONET @@ -99,11 +106,23 @@ private async Task ReadAsync(string query, /// The service ID. /// The grain reference (ID). /// Reminder table data. - internal Task ReadReminderRowsAsync(string serviceId, GrainId grainId) + internal Task< +#if ADVANCED_REMINDERS_ADONET + Orleans.AdvancedReminders.ReminderTableData +#else + ReminderTableData +#endif + > ReadReminderRowsAsync(string serviceId, GrainId grainId) { return ReadAsync(dbStoredQueries.ReadReminderRowsKey, GetReminderEntry, command => new DbStoredQueries.Columns(command) { ServiceId = serviceId, GrainId = grainId.ToString() }, - ret => new ReminderTableData(ret.ToList())); + ret => new +#if ADVANCED_REMINDERS_ADONET + Orleans.AdvancedReminders.ReminderTableData +#else + ReminderTableData +#endif + (ret.ToList())); } /// @@ -113,13 +132,25 @@ internal Task ReadReminderRowsAsync(string serviceId, GrainId /// The begin hash. /// The end hash. /// Reminder table data. - internal Task ReadReminderRowsAsync(string serviceId, uint beginHash, uint endHash) + internal Task< +#if ADVANCED_REMINDERS_ADONET + Orleans.AdvancedReminders.ReminderTableData +#else + ReminderTableData +#endif + > ReadReminderRowsAsync(string serviceId, uint beginHash, uint endHash) { var query = (int)beginHash < (int)endHash ? dbStoredQueries.ReadRangeRows1Key : dbStoredQueries.ReadRangeRows2Key; return ReadAsync(query, GetReminderEntry, command => new DbStoredQueries.Columns(command) { ServiceId = serviceId, BeginHash = beginHash, EndHash = endHash }, - ret => new ReminderTableData(ret.ToList())); + ret => new +#if ADVANCED_REMINDERS_ADONET + Orleans.AdvancedReminders.ReminderTableData +#else + ReminderTableData +#endif + (ret.ToList())); } internal static KeyValuePair GetQueryKeyAndValue(IDataRecord record) @@ -128,13 +159,29 @@ internal static KeyValuePair GetQueryKeyAndValue(IDataRecord rec record.GetValue("QueryText")); } - internal static ReminderEntry GetReminderEntry(IDataRecord record) + internal static +#if ADVANCED_REMINDERS_ADONET + Orleans.AdvancedReminders.ReminderEntry +#else + ReminderEntry +#endif + GetReminderEntry(IDataRecord record) { //Having non-null field, GrainId, means with the query filter options, an entry was found. string grainId = record.GetValueOrDefault(nameof(DbStoredQueries.Columns.GrainId)); if (grainId != null) { - return new ReminderEntry +#if ADVANCED_REMINDERS_ADONET + var cronExpression = record.GetValueOrDefault(nameof(DbStoredQueries.Columns.CronExpression)); + var cronTimeZoneId = record.GetValueOrDefault(nameof(DbStoredQueries.Columns.CronTimeZoneId)); +#endif + + return new +#if ADVANCED_REMINDERS_ADONET + Orleans.AdvancedReminders.ReminderEntry +#else + ReminderEntry +#endif { GrainId = GrainId.Parse(grainId), ReminderName = record.GetValue(nameof(DbStoredQueries.Columns.ReminderName)), @@ -143,6 +190,14 @@ internal static ReminderEntry GetReminderEntry(IDataRecord record) //Use the GetInt64 method instead of the generic GetValue version to retrieve the value from the data record //GetValue causes an InvalidCastException with oracle data provider. See https://github.com/dotnet/orleans/issues/3561 Period = TimeSpan.FromMilliseconds(record.GetInt64(nameof(DbStoredQueries.Columns.Period))), +#if ADVANCED_REMINDERS_ADONET + CronExpression = cronExpression, + CronTimeZoneId = cronTimeZoneId, + NextDueUtc = record.GetDateTimeValueOrDefault(nameof(DbStoredQueries.Columns.NextDueUtc)), + LastFireUtc = record.GetDateTimeValueOrDefault(nameof(DbStoredQueries.Columns.LastFireUtc)), + Priority = ParsePriority(record.GetInt32(nameof(DbStoredQueries.Columns.Priority))), + Action = ParseAction(record.GetInt32(nameof(DbStoredQueries.Columns.Action))), +#endif ETag = DbStoredQueries.Converters.GetVersion(record).ToString() }; } @@ -155,7 +210,13 @@ internal static ReminderEntry GetReminderEntry(IDataRecord record) /// The grain reference (ID). /// The reminder name to retrieve. /// A remainder entry. - internal Task ReadReminderRowAsync(string serviceId, GrainId grainId, + internal Task< +#if ADVANCED_REMINDERS_ADONET + Orleans.AdvancedReminders.ReminderEntry +#else + ReminderEntry +#endif + > ReadReminderRowAsync(string serviceId, GrainId grainId, string reminderName) { return ReadAsync(dbStoredQueries.ReadReminderRowKey, GetReminderEntry, command => @@ -176,6 +237,50 @@ internal Task ReadReminderRowAsync(string serviceId, GrainId grai /// Start time of the reminder. /// Period of the reminder. /// The new etag of the either or updated or inserted reminder row. +#if ADVANCED_REMINDERS_ADONET + internal Task UpsertReminderRowAsync( + string serviceId, + GrainId grainId, + string reminderName, + DateTime startTime, + TimeSpan period, + string cronExpression, + string cronTimeZoneId, + DateTime? nextDueUtc, + DateTime? lastFireUtc, + +#if ADVANCED_REMINDERS_ADONET + Orleans.AdvancedReminders.Runtime.ReminderPriority +#else + ReminderPriority +#endif + priority, +#if ADVANCED_REMINDERS_ADONET + Orleans.AdvancedReminders.Runtime.MissedReminderAction +#else + MissedReminderAction +#endif + action) + { + return ReadAsync(dbStoredQueries.UpsertReminderRowKey, DbStoredQueries.Converters.GetVersion, command => + new DbStoredQueries.Columns(command) + { + ServiceId = serviceId, + GrainHash = grainId.GetUniformHashCode(), + GrainId = grainId.ToString(), + ReminderName = reminderName, + StartTime = startTime, + Period = period, + CronExpression = cronExpression, + CronTimeZoneId = cronTimeZoneId, + NextDueUtc = nextDueUtc, + LastFireUtc = lastFireUtc, + Priority = (int)priority, + Action = (int)action + }, + ret => ret.First().ToString()); + } +#else internal Task UpsertReminderRowAsync(string serviceId, GrainId grainId, string reminderName, DateTime startTime, TimeSpan period) { @@ -188,8 +293,55 @@ internal Task UpsertReminderRowAsync(string serviceId, GrainId grainId, ReminderName = reminderName, StartTime = startTime, Period = period - }, ret => ret.First().ToString()); + }, + ret => ret.First().ToString()); } +#endif + +#if ADVANCED_REMINDERS_ADONET + private static + Orleans.AdvancedReminders.Runtime.ReminderPriority + ParsePriority(int value) => value switch + { + (int) + Orleans.AdvancedReminders.Runtime.ReminderPriority + .High => + Orleans.AdvancedReminders.Runtime.ReminderPriority + .High, + (int) + Orleans.AdvancedReminders.Runtime.ReminderPriority + .Normal => + Orleans.AdvancedReminders.Runtime.ReminderPriority + .Normal, + _ => + Orleans.AdvancedReminders.Runtime.ReminderPriority + .Normal, + }; + + private static + Orleans.AdvancedReminders.Runtime.MissedReminderAction + ParseAction(int value) => value switch + { + (int) + Orleans.AdvancedReminders.Runtime.MissedReminderAction + .FireImmediately => + Orleans.AdvancedReminders.Runtime.MissedReminderAction + .FireImmediately, + (int) + Orleans.AdvancedReminders.Runtime.MissedReminderAction + .Skip => + Orleans.AdvancedReminders.Runtime.MissedReminderAction + .Skip, + (int) + Orleans.AdvancedReminders.Runtime.MissedReminderAction + .Notify => + Orleans.AdvancedReminders.Runtime.MissedReminderAction + .Notify, + _ => + Orleans.AdvancedReminders.Runtime.MissedReminderAction + .Skip, + }; +#endif /// /// Deletes a reminder diff --git a/src/Azure/Orleans.AdvancedReminders.AzureStorage/AzureStorageReminderServiceCollectionExtensions.cs b/src/Azure/Orleans.AdvancedReminders.AzureStorage/AzureStorageReminderServiceCollectionExtensions.cs new file mode 100644 index 0000000000..bdff008c63 --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.AzureStorage/AzureStorageReminderServiceCollectionExtensions.cs @@ -0,0 +1,118 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.Configuration.Internal; +using Orleans.AdvancedReminders.AzureStorage; +namespace Orleans.Hosting +{ + /// + /// extensions. + /// + public static class AzureStorageReminderServiceCollectionExtensions + { + /// + /// Adds reminder storage backed by Azure Table Storage. + /// + /// + /// The service collection. + /// + /// + /// The delegate used to configure the reminder store. + /// + /// + /// The provided , for chaining. + /// + public static IServiceCollection UseAzureTableAdvancedReminderService(this IServiceCollection services, Action configure) + { + services.AddAdvancedReminders(); + services.UseAzureBlobDurableJobs(options => + { + options.Configure>((jobOptions, storageOptions) => + { + jobOptions.BlobServiceClient = storageOptions.Value.BlobServiceClient; + jobOptions.ContainerName = storageOptions.Value.JobContainerName; + }); + }); + services.AddSingleton(); + services.Configure(configure); + services.ConfigureFormatter(); + return services; + } + + /// + /// Adds reminder storage backed by Azure Table Storage. + /// + /// + /// The service collection. + /// + /// + /// The configuration delegate. + /// + /// + /// The provided , for chaining. + /// + public static IServiceCollection UseAzureTableAdvancedReminderService(this IServiceCollection services, Action> configureOptions) + { + services.AddAdvancedReminders(); + services.UseAzureBlobDurableJobs(options => + { + options.Configure>((jobOptions, storageOptions) => + { + jobOptions.BlobServiceClient = storageOptions.Value.BlobServiceClient; + jobOptions.ContainerName = storageOptions.Value.JobContainerName; + }); + }); + services.AddSingleton(); + configureOptions?.Invoke(services.AddOptions()); + services.ConfigureFormatter(); + services.AddTransient(sp => new AzureTableReminderStorageOptionsValidator(sp.GetRequiredService>().Get(Options.DefaultName), Options.DefaultName)); + return services; + } + + /// + /// Adds reminder storage backed by Azure Table Storage. + /// + /// + /// The service collection. + /// + /// + /// The storage connection string. + /// + /// + /// The provided , for chaining. + /// + public static IServiceCollection UseAzureTableAdvancedReminderService(this IServiceCollection services, string connectionString) + { + services.UseAzureTableAdvancedReminderService(options => + { + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + options.TableServiceClient = new(uri); + options.BlobServiceClient = new(CreateBlobServiceUri(uri)); + } + else + { + options.TableServiceClient = new(connectionString); + options.BlobServiceClient = new(connectionString); + } + }); + return services; + } + + private static Uri CreateBlobServiceUri(Uri serviceUri) + { + if (serviceUri.Host.Contains(".table.", StringComparison.OrdinalIgnoreCase)) + { + var builder = new UriBuilder(serviceUri) + { + Host = serviceUri.Host.Replace(".table.", ".blob.", StringComparison.OrdinalIgnoreCase), + }; + + return builder.Uri; + } + + return serviceUri; + } + } +} diff --git a/src/Azure/Orleans.AdvancedReminders.AzureStorage/AzureStorageReminderSiloBuilderReminderExtensions.cs b/src/Azure/Orleans.AdvancedReminders.AzureStorage/AzureStorageReminderSiloBuilderReminderExtensions.cs new file mode 100644 index 0000000000..77719e2ca0 --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.AzureStorage/AzureStorageReminderSiloBuilderReminderExtensions.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.Extensions.Options; +using Orleans.AdvancedReminders.AzureStorage; + +namespace Orleans.Hosting +{ + /// + /// Silo host builder extensions. + /// + public static class AzureStorageReminderSiloBuilderExtensions + { + /// + /// Adds reminder storage backed by Azure Table Storage. + /// + /// + /// The builder. + /// + /// + /// The delegate used to configure the reminder store. + /// + /// + /// The provided , for chaining. + /// + public static ISiloBuilder UseAzureTableAdvancedReminderService(this ISiloBuilder builder, Action configure) + { + builder.ConfigureServices(services => services.UseAzureTableAdvancedReminderService(configure)); + return builder; + } + + /// + /// Adds reminder storage backed by Azure Table Storage. + /// + /// + /// The builder. + /// + /// + /// The configuration delegate. + /// + /// + /// The provided , for chaining. + /// + public static ISiloBuilder UseAzureTableAdvancedReminderService(this ISiloBuilder builder, Action> configureOptions) + { + builder.ConfigureServices(services => services.UseAzureTableAdvancedReminderService(configureOptions)); + return builder; + } + + /// + /// Adds reminder storage backed by Azure Table Storage. + /// + /// + /// The builder. + /// + /// + /// The storage connection string. + /// + /// + /// The provided , for chaining. + /// + public static ISiloBuilder UseAzureTableAdvancedReminderService(this ISiloBuilder builder, string connectionString) + { + builder.UseAzureTableAdvancedReminderService(options => + { + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + options.TableServiceClient = new(uri); + } + else + { + options.TableServiceClient = new(connectionString); + } + }); + return builder; + } + } +} diff --git a/src/Azure/Orleans.AdvancedReminders.AzureStorage/AzureTableStorageRemindersProviderBuilder.cs b/src/Azure/Orleans.AdvancedReminders.AzureStorage/AzureTableStorageRemindersProviderBuilder.cs new file mode 100644 index 0000000000..35de84cdb3 --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.AzureStorage/AzureTableStorageRemindersProviderBuilder.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading.Tasks; +using Azure.Data.Tables; +using Azure.Storage.Blobs; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Orleans; +using Orleans.Configuration; +using Orleans.Hosting; +using Orleans.Providers; +using Orleans.AdvancedReminders.AzureStorage; + +[assembly: RegisterProvider("AzureTableStorage", "AdvancedReminders", "Silo", typeof(AdvancedAzureTableStorageRemindersProviderBuilder))] + +namespace Orleans.Hosting; + +internal sealed class AdvancedAzureTableStorageRemindersProviderBuilder : IProviderBuilder +{ + public void Configure(ISiloBuilder builder, string name, IConfigurationSection configurationSection) + { + builder.UseAzureTableAdvancedReminderService((OptionsBuilder optionsBuilder) => + optionsBuilder.Configure((options, services) => + { + var tableName = configurationSection["TableName"]; + if (!string.IsNullOrEmpty(tableName)) + { + options.TableName = tableName; + } + + var serviceKey = configurationSection["ServiceKey"]; + if (!string.IsNullOrEmpty(serviceKey)) + { + // Get a client by name. + options.TableServiceClient = services.GetRequiredKeyedService(serviceKey); + options.BlobServiceClient = services.GetRequiredKeyedService(serviceKey); + } + else + { + // Construct clients from a connection string. + var connectionName = configurationSection["ConnectionName"]; + var connectionString = configurationSection["ConnectionString"]; + if (!string.IsNullOrEmpty(connectionName) && string.IsNullOrEmpty(connectionString)) + { + var rootConfiguration = services.GetRequiredService(); + connectionString = rootConfiguration.GetConnectionString(connectionName); + } + + if (!string.IsNullOrEmpty(connectionString)) + { + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + options.TableServiceClient = new(uri); + options.BlobServiceClient = new(CreateBlobServiceUri(uri)); + } + else + { + options.TableServiceClient = new(connectionString); + options.BlobServiceClient = new(connectionString); + } + } + } + })); + } + + private static Uri CreateBlobServiceUri(Uri serviceUri) + { + if (serviceUri.Host.Contains(".table.", StringComparison.OrdinalIgnoreCase)) + { + var builder = new UriBuilder(serviceUri) + { + Host = serviceUri.Host.Replace(".table.", ".blob.", StringComparison.OrdinalIgnoreCase), + }; + + return builder.Uri; + } + + return serviceUri; + } +} diff --git a/src/Azure/Orleans.AdvancedReminders.AzureStorage/GlobalUsings.cs b/src/Azure/Orleans.AdvancedReminders.AzureStorage/GlobalUsings.cs new file mode 100644 index 0000000000..bdbd9249a2 --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.AzureStorage/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Orleans.AdvancedReminders.Runtime; +global using Orleans.AdvancedReminders.AzureStorage; diff --git a/src/Azure/Orleans.AdvancedReminders.AzureStorage/Orleans.AdvancedReminders.AzureStorage.csproj b/src/Azure/Orleans.AdvancedReminders.AzureStorage/Orleans.AdvancedReminders.AzureStorage.csproj new file mode 100644 index 0000000000..fb5dfd3b46 --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.AzureStorage/Orleans.AdvancedReminders.AzureStorage.csproj @@ -0,0 +1,39 @@ + + + README.md + Microsoft.Orleans.AdvancedReminders.AzureStorage + Microsoft Orleans Advanced Reminders for Azure Table Storage + Azure Table Storage provider for Microsoft Orleans Advanced Reminders. + $(PackageTags) Azure Table Storage + $(DefaultTargetFrameworks) + Orleans.AdvancedReminders.AzureStorage + Orleans.AdvancedReminders.AzureStorage + true + $(DefineConstants);ADVANCED_REMINDERS_AZURE + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Azure/Orleans.AdvancedReminders.AzureStorage/README.md b/src/Azure/Orleans.AdvancedReminders.AzureStorage/README.md new file mode 100644 index 0000000000..3c973c77d8 --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.AzureStorage/README.md @@ -0,0 +1,100 @@ +# Microsoft Orleans Advanced Reminders for Azure Storage + +## Introduction +Microsoft Orleans Advanced Reminders for Azure Storage stores reminder definitions in Azure Table Storage and schedules reminder delivery using Azure Blob Storage durable jobs. + +## Getting Started +To use this package, install it via NuGet: + +```shell +dotnet add package Microsoft.Orleans.AdvancedReminders.AzureStorage +``` + +## Example - Configuring Azure Storage Advanced Reminders +```csharp +using Azure.Data.Tables; +using Azure.Storage.Blobs; +using Microsoft.Extensions.Hosting; +using Orleans.AdvancedReminders; +using Orleans.Configuration; +using Orleans.Hosting; + +var builder = Host.CreateApplicationBuilder(args) + .UseOrleans(siloBuilder => + { + siloBuilder + .UseLocalhostClustering() + // Configure Azure Table Storage for definitions and Azure Blob Storage for durable jobs + .UseAzureTableAdvancedReminderService(options => + { + options.TableServiceClient = new TableServiceClient("YOUR_AZURE_STORAGE_CONNECTION_STRING"); + options.BlobServiceClient = new BlobServiceClient("YOUR_AZURE_STORAGE_CONNECTION_STRING"); + options.TableName = "OrleansAdvancedReminders"; + options.JobContainerName = "orleans-advanced-reminder-jobs"; + }); + }); + +// Run the host +await builder.RunAsync(); +``` + +## Example - Using Reminders in a Grain +```csharp +using Orleans; +using Orleans.AdvancedReminders; +using Orleans.AdvancedReminders.Runtime; + +public interface IReminderGrain +{ + Task StartReminder(string reminderName); + Task StopReminder(); +} + +public class ReminderGrain : Grain, IReminderGrain, IRemindable +{ + private string _reminderName = "MyReminder"; + + public async Task StartReminder(string reminderName) + { + _reminderName = reminderName; + + // Register a persistent reminder + await RegisterOrUpdateReminder( + reminderName, + TimeSpan.FromMinutes(2), // Time to delay before the first tick (must be > 1 minute) + TimeSpan.FromMinutes(5)); // Period of the reminder (must be > 1 minute) + } + + public async Task StopReminder() + { + // Find and unregister the reminder + var reminder = await GetReminder(_reminderName); + if (reminder != null) + { + await UnregisterReminder(reminder); + } + } + + public Task ReceiveReminder(string reminderName, TickStatus status) + { + // This method is called when the reminder ticks + Console.WriteLine($"Reminder {reminderName} triggered at {DateTime.UtcNow}. Status: {status}"); + return Task.CompletedTask; + } +} +``` + +## Documentation +For more comprehensive documentation, please refer to: +- [Microsoft Orleans Documentation](https://learn.microsoft.com/dotnet/orleans/) +- [Reminders and Timers](https://learn.microsoft.com/en-us/dotnet/orleans/grains/timers-and-reminders) +- [Reminder Services](https://learn.microsoft.com/en-us/dotnet/orleans/implementation/reminder-services) + +The `UseAzureTableAdvancedReminderService(string connectionString)` overload configures both the table client and the blob durable-jobs backend from the same storage account connection string. + +## Feedback & Contributing +- If you have any issues or would like to provide feedback, please [open an issue on GitHub](https://github.com/dotnet/orleans/issues) +- Join our community on [Discord](https://aka.ms/orleans-discord) +- Follow the [@msftorleans](https://twitter.com/msftorleans) Twitter account for Orleans announcements +- Contributions are welcome! Please review our [contribution guidelines](https://github.com/dotnet/orleans/blob/main/CONTRIBUTING.md) +- This project is licensed under the [MIT license](https://github.com/dotnet/orleans/blob/main/LICENSE) diff --git a/src/Azure/Orleans.AdvancedReminders.AzureStorage/Storage/AzureBasedReminderTable.cs b/src/Azure/Orleans.AdvancedReminders.AzureStorage/Storage/AzureBasedReminderTable.cs new file mode 100644 index 0000000000..96b484bfe6 --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.AzureStorage/Storage/AzureBasedReminderTable.cs @@ -0,0 +1,425 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.AzureUtils.Utilities; +using Orleans.Configuration; +using Orleans.AdvancedReminders.AzureStorage; + +namespace Orleans.AdvancedReminders.AzureStorage +{ + public sealed partial class AzureBasedReminderTable : IReminderTable + { + private readonly ILogger logger; + private readonly ILoggerFactory loggerFactory; + private readonly ClusterOptions clusterOptions; + private readonly AzureTableReminderStorageOptions storageOptions; + private readonly RemindersTableManager remTableManager; + private readonly TaskCompletionSource _initializationTask = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public AzureBasedReminderTable( + ILoggerFactory loggerFactory, + IOptions clusterOptions, + IOptions storageOptions) + { + this.logger = loggerFactory.CreateLogger(); + this.loggerFactory = loggerFactory; + this.clusterOptions = clusterOptions.Value; + this.storageOptions = storageOptions.Value; + this.remTableManager = new RemindersTableManager( + this.clusterOptions.ServiceId, + this.clusterOptions.ClusterId, + this.storageOptions, + this.loggerFactory); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + try + { + while (true) + { + try + { + await remTableManager.InitTableAsync(); + _initializationTask.TrySetResult(); + return; + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + LogErrorCreatingAzureTable(ex); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + } + } + catch (OperationCanceledException ex) + { + LogErrorReminderTableInitializationCanceled(ex); + _initializationTask.TrySetCanceled(ex.CancellationToken); + throw; + } + catch (Exception ex) + { + LogErrorInitializingReminderTable(ex); + _initializationTask.TrySetException(ex); + throw; + } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _initializationTask.TrySetCanceled(CancellationToken.None); + return Task.CompletedTask; + } + + private ReminderTableData ConvertFromTableEntryList(List<(ReminderTableEntry Entity, string ETag)> entries) + { + var remEntries = new List(); + foreach (var entry in entries) + { + try + { + ReminderEntry converted = ConvertFromTableEntry(entry.Entity, entry.ETag); + remEntries.Add(converted); + } + catch (OrleansException) + { + // Ignore reminders that are not valid for this silo/service. + } + catch (FormatException) + { + // Ignore malformed persisted values. + } + catch (OverflowException) + { + // Ignore malformed persisted values. + } + } + return new ReminderTableData(remEntries); + } + + private ReminderEntry ConvertFromTableEntry(ReminderTableEntry tableEntry, string eTag) + { + try + { + return new ReminderEntry + { + GrainId = GrainId.Parse(tableEntry.GrainReference), + ReminderName = tableEntry.ReminderName, + StartAt = LogFormatter.ParseDate(tableEntry.StartAt), + Period = TimeSpan.Parse(tableEntry.Period, CultureInfo.InvariantCulture), + CronExpression = tableEntry.CronExpression, + CronTimeZoneId = tableEntry.CronTimeZoneId, + NextDueUtc = ParseOptionalUtcDateTime(tableEntry.NextDueUtc), + LastFireUtc = ParseOptionalUtcDateTime(tableEntry.LastFireUtc), + Priority = ParsePriority(tableEntry.Priority), + Action = ParseAction(tableEntry.Action), + ETag = eTag, + }; + } + catch (Exception exc) + { + LogErrorParsingReminderEntry(exc, tableEntry); + throw; + } + finally + { + string serviceIdStr = this.clusterOptions.ServiceId; + if (!tableEntry.ServiceId.Equals(serviceIdStr)) + { + LogWarningAzureTable_ReadWrongReminder(tableEntry, serviceIdStr); + throw new OrleansException($"Read a reminder entry for wrong Service id. Read {tableEntry}, but my service id is {serviceIdStr}. Going to discard it."); + } + } + } + + private static DateTime? ParseOptionalUtcDateTime(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + } + + private static ReminderPriority ParsePriority(int value) => value switch + { + (int)ReminderPriority.High => ReminderPriority.High, + (int)ReminderPriority.Normal => ReminderPriority.Normal, + _ => ReminderPriority.Normal, + }; + + private static MissedReminderAction ParseAction(int value) => value switch + { + (int)MissedReminderAction.FireImmediately => MissedReminderAction.FireImmediately, + (int)MissedReminderAction.Skip => MissedReminderAction.Skip, + (int)MissedReminderAction.Notify => MissedReminderAction.Notify, + _ => MissedReminderAction.Skip, + }; + + private static ReminderTableEntry ConvertToTableEntry(ReminderEntry remEntry, string serviceId, string deploymentId) + { + string partitionKey = ReminderTableEntry.ConstructPartitionKey(serviceId, remEntry.GrainId); + string rowKey = ReminderTableEntry.ConstructRowKey(remEntry.GrainId, remEntry.ReminderName); + + var consistentHash = remEntry.GrainId.GetUniformHashCode(); + + return new ReminderTableEntry + { + PartitionKey = partitionKey, + RowKey = rowKey, + + ServiceId = serviceId, + DeploymentId = deploymentId, + GrainReference = remEntry.GrainId.ToString(), + ReminderName = remEntry.ReminderName, + + StartAt = LogFormatter.PrintDate(remEntry.StartAt), + Period = remEntry.Period.ToString("c", CultureInfo.InvariantCulture), + CronExpression = remEntry.CronExpression, + CronTimeZoneId = remEntry.CronTimeZoneId, + NextDueUtc = remEntry.NextDueUtc?.ToString("O", CultureInfo.InvariantCulture), + LastFireUtc = remEntry.LastFireUtc?.ToString("O", CultureInfo.InvariantCulture), + Priority = (int)remEntry.Priority, + Action = (int)remEntry.Action, + + GrainRefConsistentHash = consistentHash.ToString("X8"), + ETag = new ETag(remEntry.ETag), + }; + } + + public async Task TestOnlyClearTable() + { + await _initializationTask.Task; + + await this.remTableManager.DeleteTableEntries(); + } + + public async Task ReadRows(GrainId grainId) + { + try + { + await _initializationTask.Task; + + var entries = await this.remTableManager.FindReminderEntries(grainId); + ReminderTableData data = ConvertFromTableEntryList(entries); + LogTraceReadForGrain(grainId, data); + return data; + } + catch (Exception exc) + { + LogWarningReadingReminders(exc, grainId, this.remTableManager.TableName); + throw; + } + } + + public async Task ReadRows(uint begin, uint end) + { + try + { + await _initializationTask.Task; + + var entries = await this.remTableManager.FindReminderEntries(begin, end); + ReminderTableData data = ConvertFromTableEntryList(entries); + LogTraceReadInRange(new(begin, end), data); + return data; + } + catch (Exception exc) + { + LogWarningReadingReminderRange(exc, new(begin, end), this.remTableManager.TableName); + throw; + } + } + + public async Task ReadRow(GrainId grainId, string reminderName) + { + try + { + await _initializationTask.Task; + + LogDebugReadRow(grainId, reminderName); + var result = await this.remTableManager.FindReminderEntry(grainId, reminderName); + return result.Entity is null ? null : ConvertFromTableEntry(result.Entity, result.ETag); + } + catch (Exception exc) + { + LogWarningReadingReminderRow(exc, grainId, reminderName, this.remTableManager.TableName); + throw; + } + } + + public async Task UpsertRow(ReminderEntry entry) + { + try + { + await _initializationTask.Task; + + LogDebugUpsertRow(entry); + ReminderTableEntry remTableEntry = ConvertToTableEntry(entry, this.clusterOptions.ServiceId, this.clusterOptions.ClusterId); + + string result = await this.remTableManager.UpsertRow(remTableEntry); + if (result == null) + { + LogWarningReminderUpsertFailed(entry); + } + return result; + } + catch (Exception exc) + { + LogWarningUpsertReminderEntry(exc, entry, this.remTableManager.TableName); + throw; + } + } + + public async Task RemoveRow(GrainId grainId, string reminderName, string eTag) + { + var entry = new ReminderTableEntry + { + PartitionKey = ReminderTableEntry.ConstructPartitionKey(this.clusterOptions.ServiceId, grainId), + RowKey = ReminderTableEntry.ConstructRowKey(grainId, reminderName), + ETag = new ETag(eTag), + }; + + try + { + await _initializationTask.Task; + + LogTraceRemoveRow(entry); + + bool result = await this.remTableManager.DeleteReminderEntryConditionally(entry, eTag); + if (result == false) + { + LogWarningOnReminderDeleteRetry(entry); + } + return result; + } + catch (Exception exc) + { + LogWarningWhenDeletingReminder(exc, entry, this.remTableManager.TableName); + throw; + } + } + + private readonly struct RingRangeLogValue(uint Begin, uint End) + { + public override string ToString() => RangeFactory.CreateRange(Begin, End).ToString(); + } + + [LoggerMessage( + Level = LogLevel.Error, + EventId = (int)AzureReminderErrorCode.AzureTable_39, + Message = "Exception trying to create or connect to the Azure table" + )] + private partial void LogErrorCreatingAzureTable(Exception ex); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Reminder table initialization canceled." + )] + private partial void LogErrorReminderTableInitializationCanceled(Exception ex); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Error initializing reminder table." + )] + private partial void LogErrorInitializingReminderTable(Exception ex); + + [LoggerMessage( + Level = LogLevel.Error, + EventId = (int)AzureReminderErrorCode.AzureTable_49, + Message = "Failed to parse ReminderTableEntry: {TableEntry}. This entry is corrupt, going to ignore it." + )] + private partial void LogErrorParsingReminderEntry(Exception ex, object tableEntry); + + [LoggerMessage( + Level = LogLevel.Warning, + EventId = (int)AzureReminderErrorCode.AzureTable_ReadWrongReminder, + Message = "Read a reminder entry for wrong Service id. Read {TableEntry}, but my service id is {ServiceId}. Going to discard it." + )] + private partial void LogWarningAzureTable_ReadWrongReminder(ReminderTableEntry tableEntry, string serviceId); + + [LoggerMessage( + Level = LogLevel.Trace, + Message = "Read for grain {GrainId} Table={Data}" + )] + private partial void LogTraceReadForGrain(GrainId grainId, ReminderTableData data); + + [LoggerMessage( + Level = LogLevel.Warning, + EventId = (int)AzureReminderErrorCode.AzureTable_47, + Message = "Intermediate error reading reminders for grain {GrainId} in table {TableName}." + )] + private partial void LogWarningReadingReminders(Exception ex, GrainId grainId, string tableName); + + [LoggerMessage( + Level = LogLevel.Trace, + Message = "Read in {RingRange} Table={Data}" + )] + private partial void LogTraceReadInRange(RingRangeLogValue ringRange, ReminderTableData data); + + [LoggerMessage( + Level = LogLevel.Warning, + EventId = (int)AzureReminderErrorCode.AzureTable_40, + Message = "Intermediate error reading reminders in range {RingRange} for table {TableName}." + )] + private partial void LogWarningReadingReminderRange(Exception ex, RingRangeLogValue ringRange, string tableName); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "ReadRow grainRef = {GrainId} reminderName = {ReminderName}" + )] + private partial void LogDebugReadRow(GrainId grainId, string reminderName); + + [LoggerMessage( + Level = LogLevel.Warning, + EventId = (int)AzureReminderErrorCode.AzureTable_46, + Message = "Intermediate error reading row with grainId = {GrainId} reminderName = {ReminderName} from table {TableName}." + )] + private partial void LogWarningReadingReminderRow(Exception ex, GrainId grainId, string reminderName, string tableName); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "UpsertRow entry = {Data}" + )] + private partial void LogDebugUpsertRow(ReminderEntry data); + + [LoggerMessage( + Level = LogLevel.Warning, + EventId = (int)AzureReminderErrorCode.AzureTable_45, + Message = "Upsert failed on the reminder table. Will retry. Entry = {Data}" + )] + private partial void LogWarningReminderUpsertFailed(ReminderEntry data); + + [LoggerMessage( + Level = LogLevel.Warning, + EventId = (int)AzureReminderErrorCode.AzureTable_42, + Message = "Intermediate error upserting reminder entry {Data} to the table {TableName}." + )] + private partial void LogWarningUpsertReminderEntry(Exception ex, ReminderEntry data, string tableName); + + [LoggerMessage( + Level = LogLevel.Trace, + Message = "RemoveRow entry = {Data}" + )] + private partial void LogTraceRemoveRow(ReminderTableEntry data); + + [LoggerMessage( + Level = LogLevel.Warning, + EventId = (int)AzureReminderErrorCode.AzureTable_43, + Message = "Delete failed on the reminder table. Will retry. Entry = {Data}" + )] + private partial void LogWarningOnReminderDeleteRetry(ReminderTableEntry data); + + [LoggerMessage( + Level = LogLevel.Warning, + EventId = (int)AzureReminderErrorCode.AzureTable_44, + Message = "Intermediate error when deleting reminder entry {Data} to the table {TableName}." + )] + private partial void LogWarningWhenDeletingReminder(Exception ex, ReminderTableEntry data, string tableName); + } +} diff --git a/src/Azure/Orleans.AdvancedReminders.AzureStorage/Storage/AzureTableReminderStorageOptions.cs b/src/Azure/Orleans.AdvancedReminders.AzureStorage/Storage/AzureTableReminderStorageOptions.cs new file mode 100644 index 0000000000..347c5fe76b --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.AzureStorage/Storage/AzureTableReminderStorageOptions.cs @@ -0,0 +1,39 @@ +using Azure.Storage.Blobs; + +namespace Orleans.AdvancedReminders.AzureStorage +{ + /// Options for Azure Table based reminder table. + public class AzureTableReminderStorageOptions : AzureStorageOperationOptions + { + /// + /// Table name for Azure Storage + /// + public override string TableName { get; set; } = DEFAULT_TABLE_NAME; + public const string DEFAULT_TABLE_NAME = "OrleansAdvancedReminders"; + + /// + /// Gets or sets the instance used to store advanced reminder jobs. + /// + public BlobServiceClient BlobServiceClient { get; set; } = null!; + + /// + /// Gets or sets the container name used to store advanced reminder durable jobs. + /// + public string JobContainerName { get; set; } = "advanced-reminder-jobs"; + } + + /// + /// Configuration validator for . + /// + public class AzureTableReminderStorageOptionsValidator : AzureStorageOperationOptionsValidator + { + /// + /// Initializes a new instance of the class. + /// + /// The option to be validated. + /// The option name to be validated. + public AzureTableReminderStorageOptionsValidator(AzureTableReminderStorageOptions options, string name) : base(options, name) + { + } + } +} diff --git a/src/Azure/Orleans.AdvancedReminders.AzureStorage/Storage/RemindersTableManager.cs b/src/Azure/Orleans.AdvancedReminders.AzureStorage/Storage/RemindersTableManager.cs new file mode 100644 index 0000000000..5e377b0dfb --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.AzureStorage/Storage/RemindersTableManager.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Azure; +using Azure.Data.Tables; +using Microsoft.Extensions.Logging; +using Orleans.AdvancedReminders.AzureStorage; + +namespace Orleans.AdvancedReminders.AzureStorage +{ + internal sealed class ReminderTableEntry : ITableEntity + { + public string GrainReference { get; set; } // Part of RowKey + public string ReminderName { get; set; } // Part of RowKey + public string ServiceId { get; set; } // Part of PartitionKey + public string DeploymentId { get; set; } + public string StartAt { get; set; } + public string Period { get; set; } + public string CronExpression { get; set; } + public string CronTimeZoneId { get; set; } + public string NextDueUtc { get; set; } + public string LastFireUtc { get; set; } + public int Priority { get; set; } = (int)ReminderPriority.Normal; + public int Action { get; set; } = (int)MissedReminderAction.Skip; + public string GrainRefConsistentHash { get; set; } // Part of PartitionKey + + public string PartitionKey { get; set; } + public string RowKey { get; set; } + public DateTimeOffset? Timestamp { get; set; } + public ETag ETag { get; set; } + + public static string ConstructRowKey(GrainId grainId, string reminderName) + => AzureTableUtils.SanitizeTableProperty($"{grainId}-{reminderName}"); + + public static (string LowerBound, string UpperBound) ConstructRowKeyBounds(GrainId grainId) + { + var baseKey = AzureTableUtils.SanitizeTableProperty(grainId.ToString()); + return (baseKey + '-', baseKey + (char)('-' + 1)); + } + + public static string ConstructPartitionKey(string serviceId, GrainId grainId) + => ConstructPartitionKey(serviceId, grainId.GetUniformHashCode()); + + public static string ConstructPartitionKey(string serviceId, uint number) + { + // IMPORTANT NOTE: Other code using this return data is very sensitive to format changes, + // so take great care when making any changes here!!! + + // this format of partition key makes sure that the comparisons in FindReminderEntries(begin, end) work correctly + // the idea is that when converting to string, negative numbers start with 0, and positive start with 1. Now, + // when comparisons will be done on strings, this will ensure that positive numbers are always greater than negative + // string grainHash = number < 0 ? string.Format("0{0}", number.ToString("X")) : string.Format("1{0:d16}", number); + + return AzureTableUtils.SanitizeTableProperty($"{serviceId}_{number:X8}"); + } + + public static (string LowerBound, string UpperBound) ConstructPartitionKeyBounds(string serviceId) + { + var baseKey = AzureTableUtils.SanitizeTableProperty(serviceId); + return (baseKey + '_', baseKey + (char)('_' + 1)); + } + + public override string ToString() => $"Reminder [PartitionKey={PartitionKey} RowKey={RowKey} GrainId={GrainReference} ReminderName={ReminderName} Deployment={DeploymentId} ServiceId={ServiceId} StartAt={StartAt} Period={Period} CronExpression={CronExpression} CronTimeZoneId={CronTimeZoneId} NextDueUtc={NextDueUtc} LastFireUtc={LastFireUtc} Priority={Priority} Action={Action} GrainRefConsistentHash={GrainRefConsistentHash}]"; + } + + internal sealed partial class RemindersTableManager : AzureTableDataManager + { + private readonly string _serviceId; + private readonly string _clusterId; + + public RemindersTableManager( + string serviceId, + string clusterId, + AzureStorageOperationOptions options, + ILoggerFactory loggerFactory) + : base(options, loggerFactory.CreateLogger()) + { + _clusterId = clusterId; + _serviceId = serviceId; + } + + internal async Task> FindReminderEntries(uint begin, uint end) + { + string sBegin = ReminderTableEntry.ConstructPartitionKey(_serviceId, begin); + string sEnd = ReminderTableEntry.ConstructPartitionKey(_serviceId, end); + string query; + if (begin < end) + { + // Query between the specified lower and upper bounds. + // Note that the lower bound is exclusive and the upper bound is inclusive in the below query. + query = TableClient.CreateQueryFilter($"(PartitionKey gt {sBegin}) and (PartitionKey le {sEnd})"); + } + else + { + var (partitionKeyLowerBound, partitionKeyUpperBound) = ReminderTableEntry.ConstructPartitionKeyBounds(_serviceId); + if (begin == end) + { + // Query the entire range + query = TableClient.CreateQueryFilter($"(PartitionKey gt {partitionKeyLowerBound}) and (PartitionKey lt {partitionKeyUpperBound})"); + } + else + { + // (begin > end) + // Query wraps around the ends of the range, so the query is the union of two disjunct queries + // Include everything outside of the (begin, end] range, which wraps around to become: + // [partitionKeyLowerBound, end] OR (begin, partitionKeyUpperBound] + Debug.Assert(begin > end); + query = TableClient.CreateQueryFilter($"((PartitionKey gt {partitionKeyLowerBound}) and (PartitionKey le {sEnd})) or ((PartitionKey gt {sBegin}) and (PartitionKey lt {partitionKeyUpperBound}))"); + } + } + + return await ReadTableEntriesAndEtagsAsync(query); + } + + internal async Task> FindReminderEntries(GrainId grainId) + { + var partitionKey = ReminderTableEntry.ConstructPartitionKey(_serviceId, grainId); + var (rowKeyLowerBound, rowKeyUpperBound) = ReminderTableEntry.ConstructRowKeyBounds(grainId); + var query = TableClient.CreateQueryFilter($"(PartitionKey eq {partitionKey}) and ((RowKey gt {rowKeyLowerBound}) and (RowKey le {rowKeyUpperBound}))"); + return await ReadTableEntriesAndEtagsAsync(query); + } + + internal async Task<(ReminderTableEntry Entity, string ETag)> FindReminderEntry(GrainId grainId, string reminderName) + { + string partitionKey = ReminderTableEntry.ConstructPartitionKey(_serviceId, grainId); + string rowKey = ReminderTableEntry.ConstructRowKey(grainId, reminderName); + + return await ReadSingleTableEntryAsync(partitionKey, rowKey); + } + + private Task> FindAllReminderEntries() + { + return FindReminderEntries(0, 0); + } + + internal async Task UpsertRow(ReminderTableEntry reminderEntry) + { + try + { + return await UpsertTableEntryAsync(reminderEntry, TableUpdateMode.Replace); + } + catch(Exception exc) + { + if (AzureTableUtils.EvaluateException(exc, out var httpStatusCode, out var restStatus)) + { + LogTraceUpsertRowFailed(Logger, httpStatusCode, restStatus); + if (AzureTableUtils.IsContentionError(httpStatusCode)) return null; // false; + } + throw; + } + } + + + internal async Task DeleteReminderEntryConditionally(ReminderTableEntry reminderEntry, string eTag) + { + try + { + await DeleteTableEntryAsync(reminderEntry, eTag); + return true; + } + catch(Exception exc) + { + if (AzureTableUtils.EvaluateException(exc, out var httpStatusCode, out var restStatus)) + { + LogTraceDeleteReminderEntryConditionallyFailed(Logger, httpStatusCode, restStatus); + if (AzureTableUtils.IsContentionError(httpStatusCode)) return false; + } + throw; + } + } + + internal async Task DeleteTableEntries() + { + List<(ReminderTableEntry Entity, string ETag)> entries = await FindAllReminderEntries(); + // return manager.DeleteTableEntries(entries); // this doesnt work as entries can be across partitions, which is not allowed + // group by grain hashcode so each query goes to different partition + var tasks = new List(); + var groupedByHash = entries + .Where(tuple => tuple.Entity.ServiceId.Equals(_serviceId)) + .Where(tuple => tuple.Entity.DeploymentId.Equals(_clusterId)) // delete only entries that belong to our DeploymentId. + .GroupBy(x => x.Entity.GrainRefConsistentHash).ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var entriesPerPartition in groupedByHash.Values) + { + foreach (var batch in entriesPerPartition.BatchIEnumerable(this.StoragePolicyOptions.MaxBulkUpdateRows)) + { + tasks.Add(DeleteTableEntriesAsync(batch)); + } + } + + await Task.WhenAll(tasks); + } + + [LoggerMessage( + Level = LogLevel.Trace, + Message = "UpsertRow failed with HTTP status code: {HttpStatusCode}, REST status: {RestStatus}" + )] + private static partial void LogTraceUpsertRowFailed(ILogger logger, HttpStatusCode httpStatusCode, string restStatus); + + [LoggerMessage( + Level = LogLevel.Trace, + Message = "DeleteReminderEntryConditionally failed with HTTP status code: {HttpStatusCode}, REST status: {RestStatus}" + )] + private static partial void LogTraceDeleteReminderEntryConditionallyFailed(ILogger logger, HttpStatusCode httpStatusCode, string restStatus); + } +} diff --git a/src/Azure/Orleans.AdvancedReminders.AzureStorage/Utilities/AzureReminderErrorCode.cs b/src/Azure/Orleans.AdvancedReminders.AzureStorage/Utilities/AzureReminderErrorCode.cs new file mode 100644 index 0000000000..6168eba852 --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.AzureStorage/Utilities/AzureReminderErrorCode.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Orleans.AzureUtils.Utilities +{ + [SuppressMessage("ReSharper", "InconsistentNaming")] + internal enum AzureReminderErrorCode + { + Runtime = 100000, + AzureTableBase = Runtime + 800, + + // reminders related + AzureTable_38 = AzureTableBase + 38, + AzureTable_39 = AzureTableBase + 39, + AzureTable_40 = AzureTableBase + 40, + AzureTable_42 = AzureTableBase + 42, + AzureTable_43 = AzureTableBase + 43, + AzureTable_44 = AzureTableBase + 44, + AzureTable_45 = AzureTableBase + 45, + AzureTable_46 = AzureTableBase + 46, + AzureTable_47 = AzureTableBase + 47, + AzureTable_49 = AzureTableBase + 49, + + AzureTable_ReadWrongReminder = AzureTableBase + 64 + } +} diff --git a/src/Azure/Orleans.AdvancedReminders.Cosmos/CosmosReminderTable.cs b/src/Azure/Orleans.AdvancedReminders.Cosmos/CosmosReminderTable.cs new file mode 100644 index 0000000000..55bd805d95 --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.Cosmos/CosmosReminderTable.cs @@ -0,0 +1,467 @@ +using System.Net; +using System.Diagnostics; +using Orleans.Runtime; +using Orleans.AdvancedReminders.Cosmos.Models; + +namespace Orleans.AdvancedReminders.Cosmos; + +internal partial class CosmosReminderTable : IReminderTable +{ + private const HttpStatusCode TooManyRequests = (HttpStatusCode)429; + private const string PARTITION_KEY_PATH = "/PartitionKey"; + private readonly CosmosReminderTableOptions _options; + private readonly ClusterOptions _clusterOptions; + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly Func _convertEntityToEntry; + private readonly ICosmosOperationExecutor _executor; + private CosmosClient _client = default!; + private Container _container = default!; + + public CosmosReminderTable( + ILoggerFactory loggerFactory, + IServiceProvider serviceProvider, + IOptions options, + IOptions clusterOptions) + { + _logger = loggerFactory.CreateLogger(); + _serviceProvider = serviceProvider; + _options = options.Value; + _clusterOptions = clusterOptions.Value; + _convertEntityToEntry = FromEntity; + _executor = options.Value.OperationExecutor; + } + + public async Task Init() + { + var stopWatch = Stopwatch.StartNew(); + + try + { + LogDebugInitializingCosmosReminderTable(_clusterOptions.ServiceId, _options.ContainerName); + + await InitializeCosmosClient(); + + if (_options.IsResourceCreationEnabled) + { + if (_options.CleanResourcesOnInitialization) + { + await TryDeleteDatabase(); + } + + await TryCreateCosmosResources(); + } + + _container = _client.GetContainer(_options.DatabaseName, _options.ContainerName); + + stopWatch.Stop(); + + LogTraceInitializingCosmosReminderTableTook(stopWatch.ElapsedMilliseconds); + } + catch (Exception exc) + { + stopWatch.Stop(); + LogErrorInitializationFailedForProviderCosmosReminderTable(exc, stopWatch.ElapsedMilliseconds); + WrappedException.CreateAndRethrow(exc); + throw; + } + } + + public async Task ReadRows(GrainId grainId) + { + try + { + var pk = new PartitionKey(ReminderEntity.ConstructPartitionKey(_clusterOptions.ServiceId, grainId)); + var requestOptions = new QueryRequestOptions { PartitionKey = pk }; + var response = await _executor.ExecuteOperation(static async args => + { + var (self, grainId, requestOptions) = args; + var query = self._container.GetItemLinqQueryable(requestOptions: requestOptions).ToFeedIterator(); + + var reminders = new List(); + do + { + var queryResponse = await query.ReadNextAsync().ConfigureAwait(false); + if (queryResponse != null && queryResponse.Count > 0) + { + reminders.AddRange(queryResponse); + } + else + { + break; + } + } while (query.HasMoreResults); + + return reminders; + }, + (this, grainId, requestOptions)).ConfigureAwait(false); + + return new ReminderTableData(response.Select(_convertEntityToEntry)); + } + catch (Exception exc) + { + LogErrorFailureReadingRemindersForGrain(exc, grainId, _container.Id); + WrappedException.CreateAndRethrow(exc); + throw; + } + } + + public async Task ReadRows(uint begin, uint end) + { + try + { + var response = await _executor.ExecuteOperation(static async args => + { + var (self, begin, end) = args; + var query = self._container.GetItemLinqQueryable() + .Where(entity => entity.ServiceId == self._clusterOptions.ServiceId); + + query = begin < end + ? query.Where(r => r.GrainHash > begin && r.GrainHash <= end) + : query.Where(r => r.GrainHash > begin || r.GrainHash <= end); + + var iterator = query.ToFeedIterator(); + var reminders = new List(); + do + { + var queryResponse = await iterator.ReadNextAsync().ConfigureAwait(false); + if (queryResponse != null && queryResponse.Count > 0) + { + reminders.AddRange(queryResponse); + } + else + { + break; + } + } while (iterator.HasMoreResults); + + return reminders; + }, + (this, begin, end)).ConfigureAwait(false); + + return new ReminderTableData(response.Select(_convertEntityToEntry)); + } + catch (Exception exc) + { + LogErrorFailureReadingRemindersForService(exc, _clusterOptions.ServiceId, new(begin), new(end)); + WrappedException.CreateAndRethrow(exc); + throw; + } + } + + public async Task ReadRow(GrainId grainId, string reminderName) + { + try + { + var pk = new PartitionKey(ReminderEntity.ConstructPartitionKey(_clusterOptions.ServiceId, grainId)); + var id = ReminderEntity.ConstructId(grainId, reminderName); + var response = await _executor.ExecuteOperation(async args => + { + try + { + var (self, id, pk) = args; + var result = await self._container.ReadItemAsync(id, pk).ConfigureAwait(false); + return result.Resource; + } + catch (CosmosException ce) when (ce.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + }, + (this, id, pk)).ConfigureAwait(false); + + return response != null ? FromEntity(response)! : default!; + } + catch (Exception exc) + { + LogErrorFailureReadingReminder(exc, reminderName, _clusterOptions.ServiceId, grainId); + WrappedException.CreateAndRethrow(exc); + throw; + } + } + + public async Task UpsertRow(ReminderEntry entry) + { + try + { + var entity = ToEntity(entry); + var pk = new PartitionKey(ReminderEntity.ConstructPartitionKey(_clusterOptions.ServiceId, entry.GrainId)); + var options = new ItemRequestOptions { IfMatchEtag = entity.ETag }; + + var response = await _executor.ExecuteOperation(static async args => + { + var (self, pk, entity, options) = args; + var result = await self._container.UpsertItemAsync(entity, pk, options).ConfigureAwait(false); + return result.Resource; + }, + (this, pk, entity, options)).ConfigureAwait(false); + + return response.ETag; + } + catch (Exception exc) + { + LogErrorFailureToUpsertReminder(exc, _clusterOptions.ServiceId); + WrappedException.CreateAndRethrow(exc); + throw; + } + } + + public async Task RemoveRow(GrainId grainId, string reminderName, string eTag) + { + try + { + var id = ReminderEntity.ConstructId(grainId, reminderName); + var options = new ItemRequestOptions { IfMatchEtag = eTag, }; + var pk = new PartitionKey(ReminderEntity.ConstructPartitionKey(_clusterOptions.ServiceId, grainId)); + await _executor.ExecuteOperation(static args => + { + var (self, id, pk, options) = args; + return self._container.DeleteItemAsync(id, pk, options); + }, + (this, id, pk, options)).ConfigureAwait(false); + + return true; + } + catch (CosmosException dce) when (dce.StatusCode is HttpStatusCode.PreconditionFailed) + { + return false; + } + catch (Exception exc) + { + LogErrorFailureRemovingReminders(exc, _clusterOptions.ServiceId, grainId, reminderName); + WrappedException.CreateAndRethrow(exc); + throw; + } + } + + public async Task TestOnlyClearTable() + { + try + { + var entities = await _executor.ExecuteOperation(static async self => + { + var query = self._container.GetItemLinqQueryable() + .Where(entity => entity.ServiceId == self._clusterOptions.ServiceId) + .ToFeedIterator(); + var reminders = new List(); + do + { + var queryResponse = await query.ReadNextAsync().ConfigureAwait(false); + if (queryResponse != null && queryResponse.Count > 0) + { + reminders.AddRange(queryResponse); + } + else + { + break; + } + } while (query.HasMoreResults); + + return reminders; + }, this).ConfigureAwait(false); + + var deleteTasks = new List(); + foreach (var entity in entities) + { + deleteTasks.Add(_executor.ExecuteOperation( + static args => + { + var (self, id, pk) = args; + return self._container.DeleteItemAsync(id, pk); + }, + (this, entity.Id, new PartitionKey(entity.PartitionKey)))); + } + await Task.WhenAll(deleteTasks).ConfigureAwait(false); + } + catch (Exception exc) + { + LogErrorFailureToClearReminders(exc, _clusterOptions.ServiceId); + WrappedException.CreateAndRethrow(exc); + throw; + } + } + + private async Task InitializeCosmosClient() + { + try + { + _client = await _options.CreateClient!(_serviceProvider).ConfigureAwait(false); + } + catch (Exception ex) + { + LogErrorInitializingAzureCosmosDbClient(ex); + WrappedException.CreateAndRethrow(ex); + throw; + } + } + + private async Task TryDeleteDatabase() + { + try + { + await _client.GetDatabase(_options.DatabaseName).DeleteAsync().ConfigureAwait(false); + } + catch (CosmosException dce) when (dce.StatusCode == HttpStatusCode.NotFound) + { + return; + } + catch (Exception ex) + { + LogErrorDeletingAzureCosmosDBDatabase(ex); + WrappedException.CreateAndRethrow(ex); + throw; + } + } + + private async Task TryCreateCosmosResources() + { + var dbResponse = await _client.CreateDatabaseIfNotExistsAsync(_options.DatabaseName, _options.DatabaseThroughput).ConfigureAwait(false); + var db = dbResponse.Database; + + var remindersCollection = new ContainerProperties(_options.ContainerName, PARTITION_KEY_PATH); + + remindersCollection.IndexingPolicy.IndexingMode = IndexingMode.Consistent; + remindersCollection.IndexingPolicy.IncludedPaths.Add(new IncludedPath { Path = "/*" }); + remindersCollection.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/StartAt/*" }); + remindersCollection.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/Period/*" }); + remindersCollection.IndexingPolicy.IndexingMode = IndexingMode.Consistent; + + const int maxRetries = 3; + for (var retry = 0; retry <= maxRetries; ++retry) + { + var collResponse = await db.CreateContainerIfNotExistsAsync(remindersCollection, _options.ContainerThroughputProperties).ConfigureAwait(false); + + if (retry == maxRetries || dbResponse.StatusCode != HttpStatusCode.Created || collResponse.StatusCode == HttpStatusCode.Created) + { + break; // Apparently some throttling logic returns HttpStatusCode.OK (not 429) when the collection wasn't created in a new DB. + } + + await Task.Delay(1000); + } + } + + private ReminderEntry FromEntity(ReminderEntity entity) + { + return new ReminderEntry + { + GrainId = GrainId.Parse(entity.GrainId), + ReminderName = entity.Name, + Period = entity.Period, + StartAt = entity.StartAt.UtcDateTime, + CronExpression = entity.CronExpression ?? string.Empty, + CronTimeZoneId = entity.CronTimeZoneId ?? string.Empty, + NextDueUtc = entity.NextDueUtc?.UtcDateTime, + LastFireUtc = entity.LastFireUtc?.UtcDateTime, + Priority = ParsePriority(entity.Priority), + Action = ParseAction(entity.Action), + ETag = entity.ETag + }; + } + + private ReminderEntity ToEntity(ReminderEntry entry) + { + return new ReminderEntity + { + Id = ReminderEntity.ConstructId(entry.GrainId, entry.ReminderName), + PartitionKey = ReminderEntity.ConstructPartitionKey(_clusterOptions.ServiceId, entry.GrainId), + ServiceId = _clusterOptions.ServiceId, + GrainHash = entry.GrainId.GetUniformHashCode(), + GrainId = entry.GrainId.ToString(), + Name = entry.ReminderName, + StartAt = entry.StartAt, + Period = entry.Period, + CronExpression = entry.CronExpression, + CronTimeZoneId = entry.CronTimeZoneId, + NextDueUtc = entry.NextDueUtc, + LastFireUtc = entry.LastFireUtc, + Priority = (int)entry.Priority, + Action = (int)entry.Action, + }; + } + + private static ReminderPriority ParsePriority(int value) => value switch + { + (int)ReminderPriority.High => ReminderPriority.High, + (int)ReminderPriority.Normal => ReminderPriority.Normal, + _ => ReminderPriority.Normal, + }; + + private static MissedReminderAction ParseAction(int value) => value switch + { + (int)MissedReminderAction.FireImmediately => MissedReminderAction.FireImmediately, + (int)MissedReminderAction.Skip => MissedReminderAction.Skip, + (int)MissedReminderAction.Notify => MissedReminderAction.Notify, + _ => MissedReminderAction.Skip, + }; + + private readonly struct UIntLogValue(uint value) + { + public override string ToString() => value.ToString("X"); + } + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Azure Cosmos DB Reminder Storage CosmosReminderTable is initializing: Name=CosmosReminderTable ServiceId={ServiceId} Collection={Container}" + )] + private partial void LogDebugInitializingCosmosReminderTable(string serviceId, string container); + + [LoggerMessage( + Level = LogLevel.Trace, + Message = "Initializing CosmosReminderTable took {Elapsed} milliseconds" + )] + private partial void LogTraceInitializingCosmosReminderTableTook(long elapsed); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Initialization failed for provider CosmosReminderTable in {Elapsed} milliseconds" + )] + private partial void LogErrorInitializationFailedForProviderCosmosReminderTable(Exception ex, long elapsed); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Failure reading reminders for grain {GrainId} in container {Container}" + )] + private partial void LogErrorFailureReadingRemindersForGrain(Exception ex, GrainId grainId, string container); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Failure reading reminders for service {Service} for range {Begin} to {End}" + )] + private partial void LogErrorFailureReadingRemindersForService(Exception ex, string service, UIntLogValue begin, UIntLogValue end); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Failure reading reminder {Name} for service {ServiceId} and grain {GrainId}" + )] + private partial void LogErrorFailureReadingReminder(Exception ex, string name, string serviceId, GrainId grainId); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Failure to upsert reminder for service {ServiceId}" + )] + private partial void LogErrorFailureToUpsertReminder(Exception ex, string serviceId); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Failure removing reminders for service {ServiceId} with GrainId {GrainId} and name {ReminderName}" + )] + private partial void LogErrorFailureRemovingReminders(Exception ex, string serviceId, GrainId grainId, string reminderName); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Failure to clear reminders for service {ServiceId}" + )] + private partial void LogErrorFailureToClearReminders(Exception ex, string serviceId); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Error initializing Azure Cosmos DB client for membership table provider" + )] + private partial void LogErrorInitializingAzureCosmosDbClient(Exception ex); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Error deleting Azure Cosmos DB database" + )] + private partial void LogErrorDeletingAzureCosmosDBDatabase(Exception ex); +} diff --git a/src/Azure/Orleans.AdvancedReminders.Cosmos/CosmosReminderTableOptions.cs b/src/Azure/Orleans.AdvancedReminders.Cosmos/CosmosReminderTableOptions.cs new file mode 100644 index 0000000000..846796ee2e --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.Cosmos/CosmosReminderTableOptions.cs @@ -0,0 +1,17 @@ +namespace Orleans.AdvancedReminders.Cosmos; + +/// +/// Options for Azure Cosmos DB Reminder Storage. +/// +public class CosmosReminderTableOptions : CosmosOptions +{ + private const string ADVANCED_REMINDERS_CONTAINER = "OrleansAdvancedReminders"; + + /// + /// Initializes a new instance. + /// + public CosmosReminderTableOptions() + { + ContainerName = ADVANCED_REMINDERS_CONTAINER; + } +} diff --git a/src/Azure/Orleans.AdvancedReminders.Cosmos/GlobalUsings.cs b/src/Azure/Orleans.AdvancedReminders.Cosmos/GlobalUsings.cs new file mode 100644 index 0000000000..b2e678f28a --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.Cosmos/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Orleans.AdvancedReminders.Runtime; +global using Orleans.AdvancedReminders.Cosmos; diff --git a/src/Azure/Orleans.AdvancedReminders.Cosmos/HostingExtensions.cs b/src/Azure/Orleans.AdvancedReminders.Cosmos/HostingExtensions.cs new file mode 100644 index 0000000000..94e1b5ca0a --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.Cosmos/HostingExtensions.cs @@ -0,0 +1,87 @@ +using Microsoft.Extensions.DependencyInjection; +using Orleans.Hosting; +using Orleans.AdvancedReminders.Cosmos; + +namespace Orleans.Hosting; + +/// +/// Extension methods for configuring the Azure Cosmos DB reminder table provider. +/// +public static class HostingExtensions +{ + /// + /// Adds reminder storage backed by Azure Cosmos DB. + /// + /// + /// The builder. + /// + /// + /// The delegate used to configure the reminder store. + /// + /// + /// The provided , for chaining. + /// + public static ISiloBuilder UseCosmosAdvancedReminderService(this ISiloBuilder builder, Action configure) + { + builder.Services.UseCosmosAdvancedReminderService(configure); + return builder; + } + + /// + /// Adds reminder storage backed by Azure Cosmos DB. + /// + /// + /// The builder. + /// + /// + /// The delegate used to configure the reminder store. + /// + /// + /// The provided , for chaining. + /// + public static ISiloBuilder UseCosmosAdvancedReminderService(this ISiloBuilder builder, Action> configure) + { + builder.Services.UseCosmosAdvancedReminderService(configure); + return builder; + } + + /// + /// Adds reminder storage backed by Azure Cosmos DB. + /// + /// + /// The service collection. + /// + /// + /// The delegate used to configure the reminder store. + /// + /// + /// The provided , for chaining. + /// + public static IServiceCollection UseCosmosAdvancedReminderService(this IServiceCollection services, Action configure) + => services.UseCosmosAdvancedReminderService(optionsBuilder => optionsBuilder.Configure(configure)); + + /// + /// Adds reminder storage backed by Azure Cosmos DB. + /// + /// + /// The service collection. + /// + /// + /// The delegate used to configure the reminder store. + /// + /// + /// The provided , for chaining. + /// + public static IServiceCollection UseCosmosAdvancedReminderService(this IServiceCollection services, Action> configure) + { + services.AddAdvancedReminders(); + services.AddSingleton(); + configure(services.AddOptions()); + services.ConfigureFormatter(); + services.AddTransient(sp => + new CosmosOptionsValidator( + sp.GetRequiredService>().CurrentValue, + nameof(CosmosReminderTableOptions))); + return services; + } +} diff --git a/src/Azure/Orleans.AdvancedReminders.Cosmos/Models/ReminderEntity.cs b/src/Azure/Orleans.AdvancedReminders.Cosmos/Models/ReminderEntity.cs new file mode 100644 index 0000000000..92720c2e50 --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.Cosmos/Models/ReminderEntity.cs @@ -0,0 +1,74 @@ +using Newtonsoft.Json; +using static Orleans.AdvancedReminders.Cosmos.CosmosIdSanitizer; + +namespace Orleans.AdvancedReminders.Cosmos.Models; + +internal class ReminderEntity : BaseEntity +{ + [JsonProperty(nameof(PartitionKey))] + [JsonPropertyName(nameof(PartitionKey))] + public string PartitionKey { get; set; } = default!; + + [JsonProperty(nameof(ServiceId))] + [JsonPropertyName(nameof(ServiceId))] + public string ServiceId { get; set; } = default!; + + [JsonProperty(nameof(GrainId))] + [JsonPropertyName(nameof(GrainId))] + public string GrainId { get; set; } = default!; + + [JsonProperty(nameof(Name))] + [JsonPropertyName(nameof(Name))] + public string Name { get; set; } = default!; + + [JsonProperty(nameof(StartAt))] + [JsonPropertyName(nameof(StartAt))] + public DateTimeOffset StartAt { get; set; } + + [JsonProperty(nameof(Period))] + [JsonPropertyName(nameof(Period))] + public TimeSpan Period { get; set; } + + [JsonProperty(nameof(CronExpression))] + [JsonPropertyName(nameof(CronExpression))] + public string? CronExpression { get; set; } + + [JsonProperty(nameof(CronTimeZoneId))] + [JsonPropertyName(nameof(CronTimeZoneId))] + public string? CronTimeZoneId { get; set; } + + [JsonProperty(nameof(NextDueUtc))] + [JsonPropertyName(nameof(NextDueUtc))] + public DateTimeOffset? NextDueUtc { get; set; } + + [JsonProperty(nameof(LastFireUtc))] + [JsonPropertyName(nameof(LastFireUtc))] + public DateTimeOffset? LastFireUtc { get; set; } + + [JsonProperty(nameof(Priority))] + [JsonPropertyName(nameof(Priority))] + public int Priority { get; set; } = (int)Runtime.ReminderPriority.Normal; + + [JsonProperty(nameof(Action))] + [JsonPropertyName(nameof(Action))] + public int Action { get; set; } = (int)Runtime.MissedReminderAction.Skip; + + [JsonProperty(nameof(GrainHash))] + [JsonPropertyName(nameof(GrainHash))] + public uint GrainHash { get; set; } + + public static string ConstructId(GrainId grainId, string reminderName) + { + var grainType = grainId.Type.ToString(); + var grainKey = grainId.Key.ToString(); + + if (grainType is null || grainKey is null) + { + throw new ArgumentNullException(nameof(grainId)); + } + + return $"{Sanitize(grainType)}{SeparatorChar}{Sanitize(grainKey)}{SeparatorChar}{Sanitize(reminderName)}"; + } + + public static string ConstructPartitionKey(string serviceId, GrainId grainId) => $"{serviceId}_{grainId.GetUniformHashCode():X}"; +} diff --git a/src/Azure/Orleans.AdvancedReminders.Cosmos/Orleans.AdvancedReminders.Cosmos.csproj b/src/Azure/Orleans.AdvancedReminders.Cosmos/Orleans.AdvancedReminders.Cosmos.csproj new file mode 100644 index 0000000000..98eb4e4f8d --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.Cosmos/Orleans.AdvancedReminders.Cosmos.csproj @@ -0,0 +1,39 @@ + + + Microsoft.Orleans.AdvancedReminders.Cosmos + Microsoft Orleans Advanced Reminders for Azure Cosmos DB + Azure Cosmos DB provider for Microsoft Orleans Advanced Reminders. + $(PackageTags) Azure Cosmos DB + $(DefaultTargetFrameworks) + Orleans.AdvancedReminders.Cosmos + Orleans.AdvancedReminders.Cosmos + true + $(DefineConstants);ADVANCED_REMINDERS_COSMOS + enable + README.md + + + + + + + + + + + + + + + + + + + <_Parameter1>Orleans.Cosmos.Tests + + + + + + + diff --git a/src/Azure/Orleans.AdvancedReminders.Cosmos/README.md b/src/Azure/Orleans.AdvancedReminders.Cosmos/README.md new file mode 100644 index 0000000000..258d9365cd --- /dev/null +++ b/src/Azure/Orleans.AdvancedReminders.Cosmos/README.md @@ -0,0 +1,100 @@ +# Microsoft Orleans Advanced Reminders for Azure Cosmos DB + +## Introduction +Microsoft Orleans Advanced Reminders for Azure Cosmos DB stores reminder definitions in Azure Cosmos DB. + +This package does not include a Cosmos-backed durable jobs implementation. You must also configure a durable jobs backend, for example `UseInMemoryDurableJobs()` for local development or `UseAzureBlobDurableJobs(...)` for persisted execution. + +## Getting Started +To use this package, install it via NuGet: + +```shell +dotnet add package Microsoft.Orleans.AdvancedReminders.Cosmos +``` + +## Example - Configuring Azure Cosmos DB Advanced Reminders +```csharp +using Microsoft.Extensions.Hosting; +using Orleans.AdvancedReminders; +using Orleans.Configuration; +using Orleans.Hosting; +using Orleans.DurableJobs; + +var builder = Host.CreateApplicationBuilder(args) + .UseOrleans(siloBuilder => + { + siloBuilder + .UseLocalhostClustering() + .UseInMemoryDurableJobs() + // Configure Azure Cosmos DB for reminder definitions + .UseCosmosAdvancedReminderService(options => + { + options.ConfigureCosmosClient("AccountEndpoint=https://YOUR_COSMOS_ENDPOINT/;AccountKey=YOUR_COSMOS_KEY;"); + options.DatabaseName = "YOUR_DATABASE_NAME"; + options.ContainerName = "OrleansAdvancedReminders"; + options.IsResourceCreationEnabled = true; + }); + }); + +// Run the host +await builder.RunAsync(); +``` + +## Example - Using Reminders in a Grain +```csharp +using Orleans; +using Orleans.AdvancedReminders; +using Orleans.AdvancedReminders.Runtime; + +public interface IReminderGrain +{ + Task StartReminder(string reminderName); + Task StopReminder(); +} + +public class ReminderGrain : Grain, IReminderGrain, IRemindable +{ + private string _reminderName = "MyReminder"; + + public async Task StartReminder(string reminderName) + { + _reminderName = reminderName; + + // Register a persistent reminder + await RegisterOrUpdateReminder( + reminderName, + TimeSpan.FromMinutes(2), // Time to delay before the first tick (must be > 1 minute) + TimeSpan.FromMinutes(5)); // Period of the reminder (must be > 1 minute) + } + + public async Task StopReminder() + { + // Find and unregister the reminder + var reminder = await GetReminder(_reminderName); + if (reminder != null) + { + await UnregisterReminder(reminder); + } + } + + public Task ReceiveReminder(string reminderName, TickStatus status) + { + // This method is called when the reminder ticks + Console.WriteLine($"Reminder {reminderName} triggered at {DateTime.UtcNow}. Status: {status}"); + return Task.CompletedTask; + } +} +``` + +## Documentation +For more comprehensive documentation, please refer to: +- [Microsoft Orleans Documentation](https://learn.microsoft.com/dotnet/orleans/) +- [Reminders and Timers](https://learn.microsoft.com/en-us/dotnet/orleans/grains/timers-and-reminders) +- [Reminder Services](https://learn.microsoft.com/en-us/dotnet/orleans/implementation/reminder-services) + +## Feedback & Contributing +- If you have any issues or would like to provide feedback, please [open an issue on GitHub](https://github.com/dotnet/orleans/issues) +- Join our community on [Discord](https://aka.ms/orleans-discord) +- Follow the [@msftorleans](https://twitter.com/msftorleans) Twitter account for Orleans announcements +- Contributions are welcome! Please review our [contribution guidelines](https://github.com/dotnet/orleans/blob/main/CONTRIBUTING.md) +- This project is licensed under the [MIT license](https://github.com/dotnet/orleans/blob/main/LICENSE) diff --git a/src/Azure/Shared/Cosmos/BaseEntity.cs b/src/Azure/Shared/Cosmos/BaseEntity.cs index d176fcf136..6ca4117afe 100644 --- a/src/Azure/Shared/Cosmos/BaseEntity.cs +++ b/src/Azure/Shared/Cosmos/BaseEntity.cs @@ -2,6 +2,8 @@ #if ORLEANS_CLUSTERING namespace Orleans.Clustering.Cosmos; +#elif ADVANCED_REMINDERS_COSMOS +namespace Orleans.AdvancedReminders.Cosmos; #elif ORLEANS_PERSISTENCE namespace Orleans.Persistence.Cosmos; #elif ORLEANS_REMINDERS @@ -26,4 +28,4 @@ internal abstract class BaseEntity [JsonProperty(ETAG_FIELD)] [JsonPropertyName(ETAG_FIELD)] public string ETag { get; set; } = default!; -} \ No newline at end of file +} diff --git a/src/Azure/Shared/Cosmos/CosmosIdSanitizer.cs b/src/Azure/Shared/Cosmos/CosmosIdSanitizer.cs index 6ee1070c88..8433f727a7 100644 --- a/src/Azure/Shared/Cosmos/CosmosIdSanitizer.cs +++ b/src/Azure/Shared/Cosmos/CosmosIdSanitizer.cs @@ -1,5 +1,7 @@ #if ORLEANS_CLUSTERING namespace Orleans.Clustering.Cosmos; +#elif ADVANCED_REMINDERS_COSMOS +namespace Orleans.AdvancedReminders.Cosmos; #elif ORLEANS_PERSISTENCE namespace Orleans.Persistence.Cosmos; #elif ORLEANS_REMINDERS @@ -99,4 +101,4 @@ public static string Unsanitize(string input) } }); } -} \ No newline at end of file +} diff --git a/src/Azure/Shared/Cosmos/CosmosOptions.cs b/src/Azure/Shared/Cosmos/CosmosOptions.cs index b40998cc31..5802bb35e7 100644 --- a/src/Azure/Shared/Cosmos/CosmosOptions.cs +++ b/src/Azure/Shared/Cosmos/CosmosOptions.cs @@ -4,6 +4,8 @@ #if ORLEANS_CLUSTERING namespace Orleans.Clustering.Cosmos; +#elif ADVANCED_REMINDERS_COSMOS +namespace Orleans.AdvancedReminders.Cosmos; #elif ORLEANS_PERSISTENCE namespace Orleans.Persistence.Cosmos; #elif ORLEANS_REMINDERS diff --git a/src/Azure/Shared/Cosmos/CosmosOptionsValidator.cs b/src/Azure/Shared/Cosmos/CosmosOptionsValidator.cs index 6fb9fa0bc2..c0911e6386 100644 --- a/src/Azure/Shared/Cosmos/CosmosOptionsValidator.cs +++ b/src/Azure/Shared/Cosmos/CosmosOptionsValidator.cs @@ -1,5 +1,7 @@ #if ORLEANS_CLUSTERING namespace Orleans.Clustering.Cosmos; +#elif ADVANCED_REMINDERS_COSMOS +namespace Orleans.AdvancedReminders.Cosmos; #elif ORLEANS_PERSISTENCE namespace Orleans.Persistence.Cosmos; #elif ORLEANS_REMINDERS @@ -49,4 +51,4 @@ public void ValidateConfiguration() $"Configuration for Azure Cosmos DB provider {_name} is invalid. You must call {nameof(_options.ConfigureCosmosClient)} to configure access to Azure Cosmos DB."); } } -} \ No newline at end of file +} diff --git a/src/Azure/Shared/Storage/AzureStorageOperationOptions.cs b/src/Azure/Shared/Storage/AzureStorageOperationOptions.cs index 9ed8262e4a..7b1593e449 100644 --- a/src/Azure/Shared/Storage/AzureStorageOperationOptions.cs +++ b/src/Azure/Shared/Storage/AzureStorageOperationOptions.cs @@ -7,6 +7,8 @@ #if ORLEANS_CLUSTERING namespace Orleans.Clustering.AzureStorage +#elif ADVANCED_REMINDERS_AZURE +namespace Orleans.AdvancedReminders.AzureStorage #elif ORLEANS_PERSISTENCE namespace Orleans.Persistence.AzureStorage #elif ORLEANS_REMINDERS diff --git a/src/Azure/Shared/Storage/AzureStoragePolicyOptions.cs b/src/Azure/Shared/Storage/AzureStoragePolicyOptions.cs index 03d1cc3147..de95a2044c 100644 --- a/src/Azure/Shared/Storage/AzureStoragePolicyOptions.cs +++ b/src/Azure/Shared/Storage/AzureStoragePolicyOptions.cs @@ -3,6 +3,8 @@ #if ORLEANS_CLUSTERING namespace Orleans.Clustering.AzureStorage +#elif ADVANCED_REMINDERS_AZURE +namespace Orleans.AdvancedReminders.AzureStorage #elif ORLEANS_PERSISTENCE namespace Orleans.Persistence.AzureStorage #elif ORLEANS_REMINDERS diff --git a/src/Azure/Shared/Storage/AzureTableDataManager.cs b/src/Azure/Shared/Storage/AzureTableDataManager.cs index 212ea9abd7..5749484e31 100644 --- a/src/Azure/Shared/Storage/AzureTableDataManager.cs +++ b/src/Azure/Shared/Storage/AzureTableDataManager.cs @@ -16,6 +16,8 @@ #if ORLEANS_CLUSTERING namespace Orleans.Clustering.AzureStorage +#elif ADVANCED_REMINDERS_AZURE +namespace Orleans.AdvancedReminders.AzureStorage #elif ORLEANS_PERSISTENCE namespace Orleans.Persistence.AzureStorage #elif ORLEANS_REMINDERS @@ -182,7 +184,7 @@ public async Task CreateTableEntryAsync(T data) /// /// Data to be inserted or replaced in the table. /// Value promise with new Etag for this data entry after completing this storage operation. - public async Task UpsertTableEntryAsync(T data) + public async Task UpsertTableEntryAsync(T data, TableUpdateMode updateMode = TableUpdateMode.Merge) { const string operation = "UpsertTableEntry"; var startTime = DateTime.UtcNow; @@ -191,7 +193,7 @@ public async Task UpsertTableEntryAsync(T data) { try { - var opResult = await Table.UpsertEntityAsync(data); + var opResult = await Table.UpsertEntityAsync(data, updateMode); return opResult.Headers.ETag.GetValueOrDefault().ToString(); } catch (Exception exc) diff --git a/src/Azure/Shared/Storage/AzureTableDefaultPolicies.cs b/src/Azure/Shared/Storage/AzureTableDefaultPolicies.cs index 6bdc56dec7..74ac1b1818 100644 --- a/src/Azure/Shared/Storage/AzureTableDefaultPolicies.cs +++ b/src/Azure/Shared/Storage/AzureTableDefaultPolicies.cs @@ -8,6 +8,8 @@ #if ORLEANS_CLUSTERING namespace Orleans.Clustering.AzureStorage +#elif ADVANCED_REMINDERS_AZURE +namespace Orleans.AdvancedReminders.AzureStorage #elif ORLEANS_PERSISTENCE namespace Orleans.Persistence.AzureStorage #elif ORLEANS_REMINDERS diff --git a/src/Azure/Shared/Storage/AzureTableUtils.cs b/src/Azure/Shared/Storage/AzureTableUtils.cs index 5ac65163f1..40ef64ea79 100644 --- a/src/Azure/Shared/Storage/AzureTableUtils.cs +++ b/src/Azure/Shared/Storage/AzureTableUtils.cs @@ -13,6 +13,8 @@ #if ORLEANS_CLUSTERING namespace Orleans.Clustering.AzureStorage +#elif ADVANCED_REMINDERS_AZURE +namespace Orleans.AdvancedReminders.AzureStorage #elif ORLEANS_PERSISTENCE namespace Orleans.Persistence.AzureStorage #elif ORLEANS_REMINDERS diff --git a/src/Azure/Shared/Utilities/ErrorCode.cs b/src/Azure/Shared/Utilities/ErrorCode.cs index eeceb91289..7bb487fdcb 100644 --- a/src/Azure/Shared/Utilities/ErrorCode.cs +++ b/src/Azure/Shared/Utilities/ErrorCode.cs @@ -6,6 +6,8 @@ #if ORLEANS_CLUSTERING namespace Orleans.Clustering.AzureStorage.Utilities +#elif ADVANCED_REMINDERS_AZURE +namespace Orleans.AdvancedReminders.AzureStorage.Utilities #elif ORLEANS_PERSISTENCE namespace Orleans.Persistence.AzureStorage.Utilities #elif ORLEANS_REMINDERS_PROVIDER diff --git a/src/Orleans.AdvancedReminders/Constants/ReminderOptionsDefaults.cs b/src/Orleans.AdvancedReminders/Constants/ReminderOptionsDefaults.cs new file mode 100644 index 0000000000..c4f29d70aa --- /dev/null +++ b/src/Orleans.AdvancedReminders/Constants/ReminderOptionsDefaults.cs @@ -0,0 +1,22 @@ +using Orleans.Hosting; + +namespace Orleans.AdvancedReminders; + +internal static class ReminderOptionsDefaults +{ + /// + /// Minimum period for registering a reminder ... we want to enforce a lower bound . + /// + /// Increase this period, reminders are supposed to be less frequent ... we use 2 seconds just to reduce the running time of the unit tests + public const uint MinimumReminderPeriodMinutes = 1; + + /// + /// The maximum amount of time (in minutes) to attempt to initialize reminders giving up . + /// + public const uint InitializationTimeoutMinutes = 5; + + /// + /// Grace period in seconds before a reminder is considered missed. + /// + public const uint MissedReminderGracePeriodSeconds = 30; +} diff --git a/src/Orleans.AdvancedReminders/Cron/Internal/CalendarHelper.cs b/src/Orleans.AdvancedReminders/Cron/Internal/CalendarHelper.cs new file mode 100644 index 0000000000..811ce617cd --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/Internal/CalendarHelper.cs @@ -0,0 +1,82 @@ +#nullable enable +using System; + +namespace Orleans.AdvancedReminders.Cron.Internal +{ + internal static class CalendarHelper + { + private const int DaysPerWeekCount = 7; + + public static bool IsGreaterThan(int year1, int month1, int day1, int year2, int month2, int day2) + { + if (year1 != year2) return year1 > year2; + if (month1 != month2) return month1 > month2; + return day1 > day2; + } + + public static long DateTimeToTicks(int year, int month, int day, int hour, int minute, int second) + { + return new DateTime(year, month, day, hour, minute, second, DateTimeKind.Utc).Ticks; + } + + public static void FillDateTimeParts( + long ticks, + out int second, + out int minute, + out int hour, + out int day, + out int month, + out int year) + { + var value = new DateTime(ticks, DateTimeKind.Utc); + + second = value.Second; + if (ticks % TimeSpan.TicksPerSecond != 0) + { + // Preserve scheduler semantics: non-round timestamps move to the next second. + second++; + } + + minute = value.Minute; + hour = value.Hour; + (year, month, day) = value; + } + + public static DayOfWeek GetDayOfWeek(int year, int month, int day) + { + return new DateTime(year, month, day).DayOfWeek; + } + + public static int GetDaysInMonth(int year, int month) + { + return DateTime.DaysInMonth(year, month); + } + + public static int MoveToNearestWeekDay(int year, int month, int day) + { + var dayOfWeek = GetDayOfWeek(year, month, day); + if (dayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) + { + return day; + } + + if (dayOfWeek == DayOfWeek.Sunday) + { + return day == GetDaysInMonth(year, month) ? day - 2 : day + 1; + } + + return day == CronField.DaysOfMonth.First ? day + 2 : day - 1; + } + + public static bool IsNthDayOfWeek(int day, int n) + { + return day - DaysPerWeekCount * n < CronField.DaysOfMonth.First + && day - DaysPerWeekCount * (n - 1) >= CronField.DaysOfMonth.First; + } + + public static bool IsLastDayOfWeek(int year, int month, int day) + { + return day + DaysPerWeekCount > GetDaysInMonth(year, month); + } + } +} diff --git a/src/Orleans.AdvancedReminders/Cron/Internal/CronExpression.cs b/src/Orleans.AdvancedReminders/Cron/Internal/CronExpression.cs new file mode 100644 index 0000000000..79e3bdc1c3 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/Internal/CronExpression.cs @@ -0,0 +1,598 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Numerics; +using System.Text; + +namespace Orleans.AdvancedReminders.Cron.Internal +{ + /// + /// Provides a parser and scheduler for cron expressions. + /// + internal sealed class CronExpression: IEquatable + { + private const long NotFound = -1; + + /// + /// Represents a cron expression that fires on Jan 1st every year at midnight. + /// Equals to "0 0 1 1 *". + /// + public static readonly CronExpression Yearly = Parse("0 0 1 1 *", CronFormat.Standard); + + /// + /// Represents a cron expression that fires every Sunday at midnight. + /// Equals to "0 0 * * 0". + /// + public static readonly CronExpression Weekly = Parse("0 0 * * 0", CronFormat.Standard); + + /// + /// Represents a cron expression that fires on 1st day of every month at midnight. + /// Equals to "0 0 1 * *". + /// + public static readonly CronExpression Monthly = Parse("0 0 1 * *", CronFormat.Standard); + + /// + /// Represents a cron expression that fires every day at midnight. + /// Equals to "0 0 * * *". + /// + public static readonly CronExpression Daily = Parse("0 0 * * *", CronFormat.Standard); + + /// + /// Represents a cron expression that fires every hour at the beginning of the hour. + /// Equals to "0 * * * *". + /// + public static readonly CronExpression Hourly = Parse("0 * * * *", CronFormat.Standard); + + /// + /// Represents a cron expression that fires every minute. + /// Equals to "* * * * *". + /// + public static readonly CronExpression EveryMinute = Parse("* * * * *", CronFormat.Standard); + + /// + /// Represents a cron expression that fires every second. + /// Equals to "* * * * * *". + /// + public static readonly CronExpression EverySecond = Parse("* * * * * *", CronFormat.IncludeSeconds); + + private static readonly TimeZoneInfo UtcTimeZone = TimeZoneInfo.Utc; + + private readonly ulong _second; // 60 bits -> from 0 bit to 59 bit + private readonly ulong _minute; // 60 bits -> from 0 bit to 59 bit + private readonly uint _hour; // 24 bits -> from 0 bit to 23 bit + private readonly uint _dayOfMonth; // 31 bits -> from 1 bit to 31 bit + private readonly ushort _month; // 12 bits -> from 1 bit to 12 bit + private readonly byte _dayOfWeek; // 8 bits -> from 0 bit to 7 bit + + private readonly byte _nthDayOfWeek; + private readonly byte _lastMonthOffset; + + private readonly CronExpressionFlag _flags; + + internal CronExpression( + ulong second, + ulong minute, + uint hour, + uint dayOfMonth, + ushort month, + byte dayOfWeek, + byte nthDayOfWeek, + byte lastMonthOffset, + CronExpressionFlag flags) + { + _second = second; + _minute = minute; + _hour = hour; + _dayOfMonth = dayOfMonth; + _month = month; + _dayOfWeek = dayOfWeek; + _nthDayOfWeek = nthDayOfWeek; + _lastMonthOffset = lastMonthOffset; + _flags = flags; + } + + /// + /// Constructs a new based on the specified + /// cron expression. It's supported expressions consisting of 5 fields: + /// minute, hour, day of month, month, day of week. + /// If you want to parse non-standard cron expressions use with specified CronFields argument. + /// + public static CronExpression Parse(string expression) + { + return Parse(expression, CronFormat.Standard); + } + + /// + /// Constructs a new based on the specified + /// cron expression. It's supported expressions consisting of 5 or 6 fields: + /// second (optional), minute, hour, day of month, month, day of week. + /// + public static CronExpression Parse(string expression, CronFormat format) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(expression); +#else + if (expression == null) throw new ArgumentNullException(nameof(expression)); +#endif + + return CronParser.Parse(expression, format); + } + + /// + /// Constructs a new based on the specified cron expression with the + /// format. + /// A return value indicates whether the operation succeeded. + /// + public static bool TryParse(string expression, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression) + { + return TryParse(expression, CronFormat.Standard, out cronExpression); + } + + /// + /// Constructs a new based on the specified cron expression with the specified + /// . + /// A return value indicates whether the operation succeeded. + /// + public static bool TryParse(string expression, CronFormat format, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(expression); +#else + if (expression == null) throw new ArgumentNullException(nameof(expression)); +#endif + + try + { + cronExpression = Parse(expression, format); + return true; + } + catch (CronFormatException) + { + cronExpression = null; + return false; + } + } + + /// + /// Calculates next occurrence starting with (optionally ) in UTC time zone. + /// + /// + public DateTime? GetNextOccurrence(DateTime fromUtc, bool inclusive = false) + { + if (fromUtc.Kind != DateTimeKind.Utc) ThrowWrongDateTimeKindException(nameof(fromUtc)); + + var found = FindOccurrence(fromUtc.Ticks, inclusive); + if (found == NotFound) return null; + + return new DateTime(found, DateTimeKind.Utc); + } + + /// + /// Calculates next occurrence starting with (optionally ) in given + /// + /// + public DateTime? GetNextOccurrence(DateTime fromUtc, TimeZoneInfo zone, bool inclusive = false) + { + if (fromUtc.Kind != DateTimeKind.Utc) ThrowWrongDateTimeKindException(nameof(fromUtc)); + if (ReferenceEquals(zone, null)) ThrowArgumentNullException(nameof(zone)); + + if (ReferenceEquals(zone, UtcTimeZone)) + { + var found = FindOccurrence(fromUtc.Ticks, inclusive); + if (found == NotFound) return null; + + return new DateTime(found, DateTimeKind.Utc); + } + +#pragma warning disable CA1062 + var occurrence = GetOccurrenceConsideringTimeZone(fromUtc, zone, inclusive); +#pragma warning restore CA1062 + + return occurrence; + } + + /// + /// Returns the list of next occurrences within the given date/time range, + /// including and excluding + /// by default, and UTC time zone. When none of the occurrences found, an + /// empty list is returned. + /// + /// + public IEnumerable GetOccurrences( + DateTime fromUtc, + DateTime toUtc, + bool fromInclusive = true, + bool toInclusive = false) + { + if (fromUtc > toUtc) ThrowFromShouldBeLessThanToException(nameof(fromUtc), nameof(toUtc)); + + for (var occurrence = GetNextOccurrence(fromUtc, fromInclusive); + occurrence < toUtc || occurrence == toUtc && toInclusive; + // ReSharper disable once RedundantArgumentDefaultValue + // ReSharper disable once ArgumentsStyleLiteral + occurrence = GetNextOccurrence(occurrence.Value, inclusive: false)) + { + yield return occurrence.Value; + } + } + + /// + /// Returns the list of next occurrences within the given date/time range, including + /// and excluding by default, and + /// specified time zone. When none of the occurrences found, an empty list is returned. + /// + /// + public IEnumerable GetOccurrences( + DateTime fromUtc, + DateTime toUtc, + TimeZoneInfo zone, + bool fromInclusive = true, + bool toInclusive = false) + { + if (fromUtc > toUtc) ThrowFromShouldBeLessThanToException(nameof(fromUtc), nameof(toUtc)); + + for (var occurrence = GetNextOccurrence(fromUtc, zone, fromInclusive); + occurrence < toUtc || occurrence == toUtc && toInclusive; + // ReSharper disable once RedundantArgumentDefaultValue + // ReSharper disable once ArgumentsStyleLiteral + occurrence = GetNextOccurrence(occurrence.Value, zone, inclusive: false)) + { + yield return occurrence.Value; + } + } + + /// + public override string ToString() + { + var expressionBuilder = new StringBuilder(); + + if (_second != 1UL) + { + AppendFieldValue(expressionBuilder, CronField.Seconds, _second).Append(' '); + } + + AppendFieldValue(expressionBuilder, CronField.Minutes, _minute).Append(' '); + AppendFieldValue(expressionBuilder, CronField.Hours, _hour).Append(' '); + AppendDayOfMonth(expressionBuilder, _dayOfMonth).Append(' '); + AppendFieldValue(expressionBuilder, CronField.Months, _month).Append(' '); + AppendDayOfWeek(expressionBuilder, _dayOfWeek); + + return expressionBuilder.ToString(); + } + + /// + /// Determines whether the specified is equal to the current . + /// + /// The to compare with the current . + /// + /// true if the specified is equal to the current ; otherwise, false. + /// + public bool Equals(CronExpression? other) + { + if (ReferenceEquals(other, null)) return false; + + return _second == other._second && + _minute == other._minute && + _hour == other._hour && + _dayOfMonth == other._dayOfMonth && + _month == other._month && + _dayOfWeek == other._dayOfWeek && + _nthDayOfWeek == other._nthDayOfWeek && + _lastMonthOffset == other._lastMonthOffset && + _flags == other._flags; + } + + /// + /// Determines whether the specified is equal to this instance. + /// + /// The to compare with this instance. + /// + /// true if the specified is equal to this instance; + /// otherwise, false. + /// + public override bool Equals(object? obj) => Equals(obj as CronExpression); + + /// + /// Returns a hash code for this instance. + /// + /// + /// A hash code for this instance, suitable for use in hashing algorithms and data + /// structures like a hash table. + /// + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] + public override int GetHashCode() + { + unchecked + { + var hashCode = _second.GetHashCode(); + hashCode = (hashCode * 397) ^ _minute.GetHashCode(); + hashCode = (hashCode * 397) ^ (int)_hour; + hashCode = (hashCode * 397) ^ (int)_dayOfMonth; + hashCode = (hashCode * 397) ^ (int)_month; + hashCode = (hashCode * 397) ^ (int)_dayOfWeek; + hashCode = (hashCode * 397) ^ (int)_nthDayOfWeek; + hashCode = (hashCode * 397) ^ _lastMonthOffset; + hashCode = (hashCode * 397) ^ (int)_flags; + + return hashCode; + } + } + + /// + /// Implements the operator ==. + /// + public static bool operator ==(CronExpression? left, CronExpression? right) => Equals(left, right); + + /// + /// Implements the operator !=. + /// + public static bool operator !=(CronExpression? left, CronExpression? right) => !Equals(left, right); + + private DateTime? GetOccurrenceConsideringTimeZone(DateTime fromUtc, TimeZoneInfo zone, bool inclusive) + { + if (!DateTimeHelper.IsRound(fromUtc)) + { + // Rarely, if fromUtc is very close to DST transition, `TimeZoneInfo.ConvertTime` may not convert it correctly on Windows. + // E.g., In Jordan Time DST started 2017-03-31 00:00 local time. Clocks jump forward from `2017-03-31 00:00 +02:00` to `2017-03-31 01:00 +3:00`. + // But `2017-03-30 23:59:59.9999000 +02:00` will be converted to `2017-03-31 00:59:59.9999000 +03:00` instead of `2017-03-30 23:59:59.9999000 +02:00` on Windows. + // It can lead to skipped occurrences. To avoid such errors we floor fromUtc to seconds: + // `2017-03-30 23:59:59.9999000 +02:00` will be floored to `2017-03-30 23:59:59.0000000 +02:00` and will be converted to `2017-03-30 23:59:59.0000000 +02:00`. + fromUtc = DateTimeHelper.FloorToSeconds(fromUtc); + inclusive = false; + } + + var fromLocal = TimeZoneInfo.ConvertTimeFromUtc(fromUtc, zone); + + if (TimeZoneHelper.IsAmbiguousTime(zone, fromLocal)) + { + var currentOffset = zone.GetUtcOffset(fromUtc); + var standardOffset = zone.GetUtcOffset(fromLocal); + + if (standardOffset != currentOffset) + { + var daylightOffset = TimeZoneHelper.GetDaylightOffset(zone, fromLocal); + var daylightTimeLocalEnd = TimeZoneHelper.GetDaylightTimeEnd(zone, fromLocal, daylightOffset).DateTime; + + // Early period, try to find anything here. + var foundInDaylightOffset = FindOccurrence(fromLocal.Ticks, daylightTimeLocalEnd.Ticks, inclusive); + if (foundInDaylightOffset != NotFound) return new DateTime(foundInDaylightOffset - daylightOffset.Ticks, DateTimeKind.Utc); + + fromLocal = TimeZoneHelper.GetStandardTimeStart(zone, fromLocal, daylightOffset).DateTime; + inclusive = true; + } + + // Skip late ambiguous interval. + var ambiguousIntervalLocalEnd = TimeZoneHelper.GetAmbiguousIntervalEnd(zone, fromLocal).DateTime; + + if (HasFlag(CronExpressionFlag.Interval)) + { + var foundInStandardOffset = FindOccurrence(fromLocal.Ticks, ambiguousIntervalLocalEnd.Ticks - 1, inclusive); + if (foundInStandardOffset != NotFound) return new DateTime(foundInStandardOffset - standardOffset.Ticks, DateTimeKind.Utc); + } + + fromLocal = ambiguousIntervalLocalEnd; + inclusive = true; + } + + var occurrenceTicks = FindOccurrence(fromLocal.Ticks, inclusive); + if (occurrenceTicks == NotFound) return null; + + var occurrence = new DateTime(occurrenceTicks, DateTimeKind.Unspecified); + + if (zone.IsInvalidTime(occurrence)) + { + var nextValidTime = TimeZoneHelper.GetDaylightTimeStart(zone, occurrence); + return nextValidTime.UtcDateTime; + } + + if (TimeZoneHelper.IsAmbiguousTime(zone, occurrence)) + { + var daylightOffset = TimeZoneHelper.GetDaylightOffset(zone, occurrence); + return new DateTime(occurrenceTicks - daylightOffset.Ticks, DateTimeKind.Utc); + } + + return new DateTime(occurrenceTicks - zone.GetUtcOffset(occurrence).Ticks, DateTimeKind.Utc); + } + + private long FindOccurrence(long startTimeTicks, long endTimeTicks, bool startInclusive) + { + var found = FindOccurrence(startTimeTicks, startInclusive); + + if (found == NotFound || found > endTimeTicks) return NotFound; + return found; + } + + private long FindOccurrence(long ticks, bool startInclusive) + { + if (!startInclusive) ticks++; + + CalendarHelper.FillDateTimeParts( + ticks, + out int startSecond, + out int startMinute, + out int startHour, + out int startDay, + out int startMonth, + out int startYear); + + var minMatchedDay = GetFirstSet(_dayOfMonth); + + var second = startSecond; + var minute = startMinute; + var hour = startHour; + var day = startDay; + var month = startMonth; + var year = startYear; + + if (!GetBit(_second, second) && !Move(_second, ref second)) minute++; + if (!GetBit(_minute, minute) && !Move(_minute, ref minute)) hour++; + if (!GetBit(_hour, hour) && !Move(_hour, ref hour)) day++; + + // If NearestWeekday flag is set it's possible forward shift. + if (HasFlag(CronExpressionFlag.NearestWeekday)) day = CronField.DaysOfMonth.First; + + if (!GetBit(_dayOfMonth, day) && !Move(_dayOfMonth, ref day)) goto RetryMonth; + if (!GetBit(_month, month)) goto RetryMonth; + + Retry: + + if (day > GetLastDayOfMonth(year, month)) goto RetryMonth; + + if (HasFlag(CronExpressionFlag.DayOfMonthLast)) day = GetLastDayOfMonth(year, month); + + var lastCheckedDay = day; + + if (HasFlag(CronExpressionFlag.NearestWeekday)) day = CalendarHelper.MoveToNearestWeekDay(year, month, day); + + if (IsDayOfWeekMatch(year, month, day)) + { + if (CalendarHelper.IsGreaterThan(year, month, day, startYear, startMonth, startDay)) goto RolloverDay; + if (hour > startHour) goto RolloverHour; + if (minute > startMinute) goto RolloverMinute; + goto ReturnResult; + + RolloverDay: hour = GetFirstSet(_hour); + RolloverHour: minute = GetFirstSet(_minute); + RolloverMinute: second = GetFirstSet(_second); + + ReturnResult: + + var found = CalendarHelper.DateTimeToTicks(year, month, day, hour, minute, second); + if (found >= ticks) return found; + } + + day = lastCheckedDay; + if (Move(_dayOfMonth, ref day)) goto Retry; + + RetryMonth: + + if (!Move(_month, ref month)) + { + year++; + if (year > DateTime.MaxValue.Year) + { + return NotFound; + } + } + + day = minMatchedDay; + + goto Retry; + } + + private static bool Move(ulong fieldBits, ref int fieldValue) + { + if (fieldBits >> ++fieldValue == 0) + { + fieldValue = GetFirstSet(fieldBits); + return false; + } + + fieldValue += GetFirstSet(fieldBits >> fieldValue); + return true; + } + + private int GetLastDayOfMonth(int year, int month) + { + return CalendarHelper.GetDaysInMonth(year, month) - _lastMonthOffset; + } + + private bool IsDayOfWeekMatch(int year, int month, int day) + { + if (HasFlag(CronExpressionFlag.DayOfWeekLast) && !CalendarHelper.IsLastDayOfWeek(year, month, day) || + HasFlag(CronExpressionFlag.NthDayOfWeek) && !CalendarHelper.IsNthDayOfWeek(day, _nthDayOfWeek)) + { + return false; + } + + if (_dayOfWeek == CronField.DaysOfWeek.AllBits) return true; + + var dayOfWeek = CalendarHelper.GetDayOfWeek(year, month, day); + + return ((_dayOfWeek >> (int)dayOfWeek) & 1) != 0; + } + + private static int GetFirstSet(ulong value) + { + if (value == 0) return 0; + return BitOperations.TrailingZeroCount(value); + } + + private bool HasFlag(CronExpressionFlag value) + { + return (_flags & value) != 0; + } + + private static StringBuilder AppendFieldValue(StringBuilder expressionBuilder, CronField field, ulong fieldValue) + { + if (field.AllBits == fieldValue) return expressionBuilder.Append('*'); + + // Unset 7 bit for Day of week field because both 0 and 7 stand for Sunday. + if (field == CronField.DaysOfWeek) fieldValue &= ~(1U << field.Last); + + var i = GetFirstSet(fieldValue); + while (true) + { + expressionBuilder.Append(i); + + var nextBitStart = i + 1; + if (fieldValue >> nextBitStart == 0) break; + + expressionBuilder.Append(','); + i = nextBitStart + GetFirstSet(fieldValue >> nextBitStart); + } + + return expressionBuilder; + } + + private StringBuilder AppendDayOfMonth(StringBuilder expressionBuilder, uint domValue) + { + if (HasFlag(CronExpressionFlag.DayOfMonthLast)) + { + expressionBuilder.Append('L'); + if (_lastMonthOffset != 0) expressionBuilder.Append(String.Format(CultureInfo.InvariantCulture, "-{0}", _lastMonthOffset)); + } + else + { + AppendFieldValue(expressionBuilder, CronField.DaysOfMonth, (uint)domValue); + } + + if (HasFlag(CronExpressionFlag.NearestWeekday)) expressionBuilder.Append('W'); + + return expressionBuilder; + } + + private void AppendDayOfWeek(StringBuilder expressionBuilder, uint dowValue) + { + AppendFieldValue(expressionBuilder, CronField.DaysOfWeek, dowValue); + + if (HasFlag(CronExpressionFlag.DayOfWeekLast)) expressionBuilder.Append('L'); + else if (HasFlag(CronExpressionFlag.NthDayOfWeek)) expressionBuilder.Append(String.Format(CultureInfo.InvariantCulture, "#{0}", _nthDayOfWeek)); + } + + [DoesNotReturn] + private static void ThrowFromShouldBeLessThanToException(string fromName, string toName) + { + throw new ArgumentException($"The value of the {fromName} argument should be less than the value of the {toName} argument.", fromName); + } + + [DoesNotReturn] + private static void ThrowWrongDateTimeKindException(string paramName) + { + throw new ArgumentException("The supplied DateTime must have the Kind property set to Utc", paramName); + } + + [DoesNotReturn] + private static void ThrowArgumentNullException(string paramName) + { + throw new ArgumentNullException(paramName); + } + + private static bool GetBit(ulong value, int index) + { + return (value & (1UL << index)) != 0; + } + } +} diff --git a/src/Orleans.AdvancedReminders/Cron/Internal/CronExpressionFlag.cs b/src/Orleans.AdvancedReminders/Cron/Internal/CronExpressionFlag.cs new file mode 100644 index 0000000000..833a8fd9ab --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/Internal/CronExpressionFlag.cs @@ -0,0 +1,15 @@ +#nullable enable +using System; + +namespace Orleans.AdvancedReminders.Cron.Internal +{ + [Flags] + internal enum CronExpressionFlag : byte + { + DayOfMonthLast = 0b00001, + DayOfWeekLast = 0b00010, + Interval = 0b00100, + NearestWeekday = 0b01000, + NthDayOfWeek = 0b10000 + } +} diff --git a/src/Orleans.AdvancedReminders/Cron/Internal/CronField.cs b/src/Orleans.AdvancedReminders/Cron/Internal/CronField.cs new file mode 100644 index 0000000000..eec5e927b3 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/Internal/CronField.cs @@ -0,0 +1,57 @@ +#nullable enable +using System; + +namespace Orleans.AdvancedReminders.Cron.Internal +{ + internal sealed class CronField + { + private static readonly string[] MonthNames = + { + String.Empty, "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" + }; + + private static readonly string[] DayOfWeekNames = + { + "SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN" + }; + + private static readonly int[] MonthNamesArray = Array.ConvertAll(MonthNames, static name => + name == String.Empty ? 0 : name[0] | (name[1] << 8) | (name[2] << 16)); + private static readonly int[] DayOfWeekNamesArray = Array.ConvertAll(DayOfWeekNames, static name => + name[0] | (name[1] << 8) | (name[2] << 16)); + + // 0 and 7 are both Sunday, for compatibility reasons. + public static readonly CronField DaysOfWeek = new CronField("Days of week", 0, 7, DayOfWeekNamesArray, false); + + public static readonly CronField Months = new CronField("Months", 1, 12, MonthNamesArray, false); + public static readonly CronField DaysOfMonth = new CronField("Days of month", 1, 31, null, false); + public static readonly CronField Hours = new CronField("Hours", 0, 23, null, true); + public static readonly CronField Minutes = new CronField("Minutes", 0, 59, null, true); + public static readonly CronField Seconds = new CronField("Seconds", 0, 59, null, true); + + public readonly string Name; + public readonly int First; + public readonly int Last; + public readonly int[]? Names; + public readonly bool CanDefineInterval; + public readonly ulong AllBits; + + private CronField(string name, int first, int last, int[]? names, bool canDefineInterval) + { + Name = name; + First = first; + Last = last; + Names = names; + CanDefineInterval = canDefineInterval; + for (int i = First; i <= Last; i++) + { + AllBits = AllBits | (1UL << i); + } + } + + public override string ToString() + { + return Name; + } + } +} diff --git a/src/Orleans.AdvancedReminders/Cron/Internal/CronFormat.cs b/src/Orleans.AdvancedReminders/Cron/Internal/CronFormat.cs new file mode 100644 index 0000000000..93a362d0f5 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/Internal/CronFormat.cs @@ -0,0 +1,20 @@ +#nullable enable + +namespace Orleans.AdvancedReminders.Cron.Internal +{ + /// + /// Defines the cron format options that customize string parsing for . + /// + internal enum CronFormat + { + /// + /// Parsing string must contain only 5 fields: minute, hour, day of month, month, day of week. + /// + Standard = 0, + + /// + /// Second field must be specified in parsing string. + /// + IncludeSeconds = 1 + } +} diff --git a/src/Orleans.AdvancedReminders/Cron/Internal/CronFormatException.cs b/src/Orleans.AdvancedReminders/Cron/Internal/CronFormatException.cs new file mode 100644 index 0000000000..1d035a14c0 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/Internal/CronFormatException.cs @@ -0,0 +1,53 @@ +#nullable enable +using System; +#if !NETSTANDARD1_0 +using System.Runtime.Serialization; +#endif + +namespace Orleans.AdvancedReminders.Cron.Internal +{ + /// + /// Represents an exception that's thrown, when invalid Cron expression is given. + /// +#if !NETSTANDARD1_0 + [Serializable] +#endif + internal class CronFormatException : FormatException + { + internal const string BaseMessage = "The given cron expression has an invalid format."; + + /// + /// Initializes a new instance of the class. + /// + public CronFormatException() : this(BaseMessage) + { + } + + /// + /// Initializes a new instance of the class with + /// a specified error message. + /// + public CronFormatException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with + /// a specified error message and a reference to the inner exception that is the + /// cause of this exception. + /// + public CronFormatException(string message, Exception innerException) + : base(message, innerException) + { + } + +#if !NETSTANDARD1_0 + /// +#pragma warning disable SYSLIB0051 + protected CronFormatException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } +#pragma warning restore SYSLIB0051 +#endif + } +} diff --git a/src/Orleans.AdvancedReminders/Cron/Internal/CronParser.cs b/src/Orleans.AdvancedReminders/Cron/Internal/CronParser.cs new file mode 100644 index 0000000000..47ce1e2133 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/Internal/CronParser.cs @@ -0,0 +1,493 @@ +#nullable enable +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Orleans.AdvancedReminders.Cron.Internal +{ + internal static class CronParser + { + private const int MinNthDayOfWeek = 1; + private const int MaxNthDayOfWeek = 5; + private const int SundayBits = 0b1000_0001; + + public static unsafe CronExpression Parse(string expression, CronFormat format) + { + fixed (char* value = expression) + { + var pointer = value; + + SkipWhiteSpaces(ref pointer); + + if (Accept(ref pointer, '@')) + { + var cronExpression = ParseMacro(ref pointer); + SkipWhiteSpaces(ref pointer); + + if (ReferenceEquals(cronExpression, null) || !IsEndOfString(*pointer)) ThrowFormatException("Macro: Unexpected character '{0}' on position {1}.", *pointer, pointer - value); + return cronExpression; + } + + ulong second = default; + byte nthDayOfWeek = default; + byte lastMonthOffset = default; + + CronExpressionFlag flags = default; + + if (format == CronFormat.IncludeSeconds) + { + second = ParseField(CronField.Seconds, ref pointer, ref flags); + ParseWhiteSpace(CronField.Seconds, ref pointer); + } + else + { + SetBit(ref second, CronField.Seconds.First); + } + + var minute = ParseField(CronField.Minutes, ref pointer, ref flags); + ParseWhiteSpace(CronField.Minutes, ref pointer); + + var hour = (uint)ParseField(CronField.Hours, ref pointer, ref flags); + ParseWhiteSpace(CronField.Hours, ref pointer); + + var dayOfMonth = (uint)ParseDayOfMonth(ref pointer, ref flags, ref lastMonthOffset); + + ParseWhiteSpace(CronField.DaysOfMonth, ref pointer); + + var month = (ushort)ParseField(CronField.Months, ref pointer, ref flags); + ParseWhiteSpace(CronField.Months, ref pointer); + + var dayOfWeek = (byte)ParseDayOfWeek(ref pointer, ref flags, ref nthDayOfWeek); + ParseEndOfString(ref pointer); + + // Make sundays equivalent. + if ((dayOfWeek & SundayBits) != 0) + { + dayOfWeek |= SundayBits; + } + + return new CronExpression( + second, + minute, + hour, + dayOfMonth, + month, + dayOfWeek, + nthDayOfWeek, + lastMonthOffset, + flags); + } + } + + private static unsafe void SkipWhiteSpaces(ref char* pointer) + { + while (IsWhiteSpace(*pointer)) { pointer++; } + } + + private static unsafe void ParseWhiteSpace(CronField prevField, ref char* pointer) + { + if (!IsWhiteSpace(*pointer)) ThrowFormatException(prevField, "Unexpected character '{0}'.", *pointer); + SkipWhiteSpaces(ref pointer); + } + + private static unsafe void ParseEndOfString(ref char* pointer) + { + if (!IsWhiteSpace(*pointer) && !IsEndOfString(*pointer)) ThrowFormatException(CronField.DaysOfWeek, "Unexpected character '{0}'.", *pointer); + + SkipWhiteSpaces(ref pointer); + if (!IsEndOfString(*pointer)) ThrowFormatException("Unexpected character '{0}'.", *pointer); + } + + [SuppressMessage("SonarLint", "S1764:IdenticalExpressionsShouldNotBeUsedOnBothSidesOfOperators", Justification = "Expected, as the AcceptCharacter method produces side effects.")] + private static unsafe CronExpression? ParseMacro(ref char* pointer) + { + switch (ToUpper(*pointer++)) + { + case 'A': + if (AcceptCharacter(ref pointer, 'N') && + AcceptCharacter(ref pointer, 'N') && + AcceptCharacter(ref pointer, 'U') && + AcceptCharacter(ref pointer, 'A') && + AcceptCharacter(ref pointer, 'L') && + AcceptCharacter(ref pointer, 'L') && + AcceptCharacter(ref pointer, 'Y')) + return CronExpression.Yearly; + return null; + case 'D': + if (AcceptCharacter(ref pointer, 'A') && + AcceptCharacter(ref pointer, 'I') && + AcceptCharacter(ref pointer, 'L') && + AcceptCharacter(ref pointer, 'Y')) + return CronExpression.Daily; + return null; + case 'E': + if (AcceptCharacter(ref pointer, 'V') && + AcceptCharacter(ref pointer, 'E') && + AcceptCharacter(ref pointer, 'R') && + AcceptCharacter(ref pointer, 'Y') && + Accept(ref pointer, '_')) + { + if (AcceptCharacter(ref pointer, 'M') && + AcceptCharacter(ref pointer, 'I') && + AcceptCharacter(ref pointer, 'N') && + AcceptCharacter(ref pointer, 'U') && + AcceptCharacter(ref pointer, 'T') && + AcceptCharacter(ref pointer, 'E')) + return CronExpression.EveryMinute; + + if (*(pointer - 1) != '_') return null; + + if (AcceptCharacter(ref pointer, 'S') && + AcceptCharacter(ref pointer, 'E') && + AcceptCharacter(ref pointer, 'C') && + AcceptCharacter(ref pointer, 'O') && + AcceptCharacter(ref pointer, 'N') && + AcceptCharacter(ref pointer, 'D')) + return CronExpression.EverySecond; + } + + return null; + case 'H': + if (AcceptCharacter(ref pointer, 'O') && + AcceptCharacter(ref pointer, 'U') && + AcceptCharacter(ref pointer, 'R') && + AcceptCharacter(ref pointer, 'L') && + AcceptCharacter(ref pointer, 'Y')) + return CronExpression.Hourly; + return null; + case 'M': + if (AcceptCharacter(ref pointer, 'O') && + AcceptCharacter(ref pointer, 'N') && + AcceptCharacter(ref pointer, 'T') && + AcceptCharacter(ref pointer, 'H') && + AcceptCharacter(ref pointer, 'L') && + AcceptCharacter(ref pointer, 'Y')) + return CronExpression.Monthly; + + if (ToUpper(*(pointer - 1)) == 'M' && + AcceptCharacter(ref pointer, 'I') && + AcceptCharacter(ref pointer, 'D') && + AcceptCharacter(ref pointer, 'N') && + AcceptCharacter(ref pointer, 'I') && + AcceptCharacter(ref pointer, 'G') && + AcceptCharacter(ref pointer, 'H') && + AcceptCharacter(ref pointer, 'T')) + return CronExpression.Daily; + + return null; + case 'W': + if (AcceptCharacter(ref pointer, 'E') && + AcceptCharacter(ref pointer, 'E') && + AcceptCharacter(ref pointer, 'K') && + AcceptCharacter(ref pointer, 'L') && + AcceptCharacter(ref pointer, 'Y')) + return CronExpression.Weekly; + return null; + case 'Y': + if (AcceptCharacter(ref pointer, 'E') && + AcceptCharacter(ref pointer, 'A') && + AcceptCharacter(ref pointer, 'R') && + AcceptCharacter(ref pointer, 'L') && + AcceptCharacter(ref pointer, 'Y')) + return CronExpression.Yearly; + return null; + default: + pointer--; + return null; + } + } + + private static unsafe ulong ParseField(CronField field, ref char* pointer, ref CronExpressionFlag flags) + { + if (Accept(ref pointer, '*') || Accept(ref pointer, '?')) + { + if (field.CanDefineInterval) flags |= CronExpressionFlag.Interval; + return ParseStar(field, ref pointer); + } + + var num = ParseValue(field, ref pointer); + + var bits = ParseRange(field, ref pointer, num, ref flags); + if (Accept(ref pointer, ',')) bits |= ParseList(field, ref pointer, ref flags); + + return bits; + } + + private static unsafe ulong ParseDayOfMonth(ref char* pointer, ref CronExpressionFlag flags, ref byte lastDayOffset) + { + var field = CronField.DaysOfMonth; + + if (Accept(ref pointer, '*') || Accept(ref pointer, '?')) return ParseStar(field, ref pointer); + + if (AcceptCharacter(ref pointer, 'L')) return ParseLastDayOfMonth(field, ref pointer, ref flags, ref lastDayOffset); + + var dayOfMonth = ParseValue(field, ref pointer); + + if (AcceptCharacter(ref pointer, 'W')) + { + flags |= CronExpressionFlag.NearestWeekday; + return GetBit(dayOfMonth); + } + + var bits = ParseRange(field, ref pointer, dayOfMonth, ref flags); + if (Accept(ref pointer, ',')) bits |= ParseList(field, ref pointer, ref flags); + + return bits; + } + + private static unsafe ulong ParseDayOfWeek(ref char* pointer, ref CronExpressionFlag flags, ref byte nthWeekDay) + { + var field = CronField.DaysOfWeek; + if (Accept(ref pointer, '*') || Accept(ref pointer, '?')) return ParseStar(field, ref pointer); + + var dayOfWeek = ParseValue(field, ref pointer); + + if (AcceptCharacter(ref pointer, 'L')) return ParseLastWeekDay(dayOfWeek, ref flags); + if (Accept(ref pointer, '#')) return ParseNthWeekDay(field, ref pointer, dayOfWeek, ref flags, out nthWeekDay); + + var bits = ParseRange(field, ref pointer, dayOfWeek, ref flags); + if (Accept(ref pointer, ',')) bits |= ParseList(field, ref pointer, ref flags); + + return bits; + } + + private static unsafe ulong ParseStar(CronField field, ref char* pointer) + { + return Accept(ref pointer, '/') + ? ParseStep(field, ref pointer, field.First, field.Last) + : field.AllBits; + } + + private static unsafe ulong ParseList(CronField field, ref char* pointer, ref CronExpressionFlag flags) + { + var bits = 0UL; + + do + { + var num = ParseValue(field, ref pointer); + bits |= ParseRange(field, ref pointer, num, ref flags); + } while (Accept(ref pointer, ',')); + + return bits; + } + + private static unsafe ulong ParseRange(CronField field, ref char* pointer, int low, ref CronExpressionFlag flags) + { + if (!Accept(ref pointer, '-')) + { + if (!Accept(ref pointer, '/')) return GetBit(low); + + if (field.CanDefineInterval) flags |= CronExpressionFlag.Interval; + return ParseStep(field, ref pointer, low, field.Last); + } + + if (field.CanDefineInterval) flags |= CronExpressionFlag.Interval; + + var high = ParseValue(field, ref pointer); + if (Accept(ref pointer, '/')) return ParseStep(field, ref pointer, low, high); + return GetBits(field, low, high, 1); + } + + private static unsafe ulong ParseStep(CronField field, ref char* pointer, int low, int high) + { + // Get the step size -- note: we don't pass the + // names here, because the number is not an + // element id, it's a step size. 'low' is + // sent as a 0 since there is no offset either. + var step = ParseNumber(field, ref pointer, 1, field.Last); + return GetBits(field, low, high, step); + } + + private static unsafe ulong ParseLastDayOfMonth(CronField field, ref char* pointer, ref CronExpressionFlag flags, ref byte lastMonthOffset) + { + flags |= CronExpressionFlag.DayOfMonthLast; + + if (Accept(ref pointer, '-')) lastMonthOffset = (byte)ParseNumber(field, ref pointer, 0, field.Last - 1); + if (AcceptCharacter(ref pointer, 'W')) flags |= CronExpressionFlag.NearestWeekday; + return field.AllBits; + } + + private static unsafe ulong ParseNthWeekDay(CronField field, ref char* pointer, int dayOfWeek, ref CronExpressionFlag flags, out byte nthDayOfWeek) + { + nthDayOfWeek = (byte)ParseNumber(field, ref pointer, MinNthDayOfWeek, MaxNthDayOfWeek); + flags |= CronExpressionFlag.NthDayOfWeek; + return GetBit(dayOfWeek); + } + + private static ulong ParseLastWeekDay(int dayOfWeek, ref CronExpressionFlag flags) + { + flags |= CronExpressionFlag.DayOfWeekLast; + return GetBit(dayOfWeek); + } + + private static unsafe bool Accept(ref char* pointer, char character) + { + if (*pointer == character) + { + pointer++; + return true; + } + + return false; + } + + private static unsafe bool AcceptCharacter(ref char* pointer, char character) + { + if (ToUpper(*pointer) == character) + { + pointer++; + return true; + } + + return false; + } + + private static unsafe int ParseNumber(CronField field, ref char* pointer, int low, int high) + { + var num = GetNumber(ref pointer, null); + if (num == -1 || num < low || num > high) + { + ThrowFormatException(field, "Value must be a number between {0} and {1} (all inclusive).", low, high); + } + return num; + } + + private static unsafe int ParseValue(CronField field, ref char* pointer) + { + var num = GetNumber(ref pointer, field.Names); + if (num == -1 || num < field.First || num > field.Last) + { + ThrowFormatException(field, "Value must be a number between {0} and {1} (all inclusive).", field.First, field.Last); + } + return num; + } + + private static ulong GetBits(CronField field, int num1, int num2, int step) + { + if (num2 < num1) return GetReversedRangeBits(field, num1, num2, step); + if (step == 1) return (1UL << (num2 + 1)) - (1UL << num1); + + return GetRangeBits(num1, num2, step); + } + + private static ulong GetRangeBits(int low, int high, int step) + { + var bits = 0UL; + for (var i = low; i <= high; i += step) + { + SetBit(ref bits, i); + } + return bits; + } + + private static ulong GetReversedRangeBits(CronField field, int num1, int num2, int step) + { + var high = field.Last; + // Skip one of sundays. + if (field == CronField.DaysOfWeek) high--; + + var bits = GetRangeBits(num1, high, step); + + num1 = field.First + step - (high - num1) % step - 1; + return bits | GetRangeBits(num1, num2, step); + } + + private static ulong GetBit(int num1) + { + return 1UL << num1; + } + + private static unsafe int GetNumber(ref char* pointer, int[]? names) + { + if (IsDigit(*pointer)) + { + var num = GetNumeric(*pointer++); + + if (!IsDigit(*pointer)) return num; + + num = num * 10 + GetNumeric(*pointer++); + + if (!IsDigit(*pointer)) return num; + return -1; + } + + if (names == null) return -1; + + if (!IsLetter(*pointer)) return -1; + var buffer = ToUpper(*pointer++); + + if (!IsLetter(*pointer)) return -1; + buffer |= ToUpper(*pointer++) << 8; + + if (!IsLetter(*pointer)) return -1; + buffer |= ToUpper(*pointer++) << 16; + + var length = names.Length; + + for (var i = 0; i < length; i++) + { + if (buffer == names[i]) + { + return i; + } + } + + return -1; + } + + private static void SetBit(ref ulong value, int index) + { + value |= 1UL << index; + } + + private static bool IsEndOfString(int code) + { + return code == '\0'; + } + + private static bool IsWhiteSpace(int code) + { + return code == '\t' || code == ' '; + } + + private static bool IsDigit(int code) + { + return code >= 48 && code <= 57; + } + + private static bool IsLetter(int code) + { + return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); + } + + private static int GetNumeric(int code) + { + return code - 48; + } + + private static uint ToUpper(uint code) + { + if (code >= 97 && code <= 122) + { + return code - 32; + } + + return code; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + [DoesNotReturn] + private static void ThrowFormatException(CronField field, string format, params object[] args) + { + throw new CronFormatException($"{CronFormatException.BaseMessage} {field}: {String.Format(CultureInfo.CurrentCulture, format, args)}"); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + [DoesNotReturn] + private static void ThrowFormatException(string format, params object[] args) + { + throw new CronFormatException($"{CronFormatException.BaseMessage} {String.Format(CultureInfo.CurrentCulture, format, args)}"); + } + } +} diff --git a/src/Orleans.AdvancedReminders/Cron/Internal/DateTimeHelper.cs b/src/Orleans.AdvancedReminders/Cron/Internal/DateTimeHelper.cs new file mode 100644 index 0000000000..7c20f4611d --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/Internal/DateTimeHelper.cs @@ -0,0 +1,25 @@ +#nullable enable +using System; + +namespace Orleans.AdvancedReminders.Cron.Internal +{ + internal static class DateTimeHelper + { + private static readonly TimeSpan OneSecond = TimeSpan.FromSeconds(1); + + public static DateTime FloorToSeconds(DateTime dateTime) + { + return dateTime.AddTicks(-GetExtraTicks(dateTime.Ticks)); + } + + public static bool IsRound(DateTime dateTime) + { + return GetExtraTicks(dateTime.Ticks) == 0; + } + + private static long GetExtraTicks(long ticks) + { + return ticks % OneSecond.Ticks; + } + } +} diff --git a/src/Orleans.AdvancedReminders/Cron/Internal/ReminderCronParser.cs b/src/Orleans.AdvancedReminders/Cron/Internal/ReminderCronParser.cs new file mode 100644 index 0000000000..97aebc3f9d --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/Internal/ReminderCronParser.cs @@ -0,0 +1,46 @@ +#nullable enable +using System; + +namespace Orleans.AdvancedReminders.Cron.Internal; + +internal static class ReminderCronParser +{ + public static CronExpression Parse(string expression) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expression); + return CronExpression.Parse(expression, DetectFormat(expression)); + } + + public static CronFormat DetectFormat(string expression) + { + var trimmed = expression.AsSpan().Trim(); + if (trimmed is ['@', ..]) + { + return CronFormat.Standard; + } + + var fieldCount = 0; + var inToken = false; + foreach (var ch in trimmed) + { + if (char.IsWhiteSpace(ch)) + { + inToken = false; + continue; + } + + if (!inToken) + { + fieldCount++; + inToken = true; + } + } + + return fieldCount switch + { + 5 => CronFormat.Standard, + 6 => CronFormat.IncludeSeconds, + _ => throw new CronFormatException($"The given cron expression has an invalid format. Expected 5 or 6 fields, but got {fieldCount}.") + }; + } +} diff --git a/src/Orleans.AdvancedReminders/Cron/Internal/ReminderCronSchedule.cs b/src/Orleans.AdvancedReminders/Cron/Internal/ReminderCronSchedule.cs new file mode 100644 index 0000000000..165c96e2aa --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/Internal/ReminderCronSchedule.cs @@ -0,0 +1,126 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace Orleans.AdvancedReminders.Cron.Internal; + +internal sealed class ReminderCronSchedule +{ + private static readonly ConcurrentDictionary Cache = new(); + + private ReminderCronSchedule(ReminderCronExpression expression, TimeZoneInfo timeZone, string? timeZoneId) + { + Expression = expression; + TimeZone = timeZone; + TimeZoneId = timeZoneId; + } + + public ReminderCronExpression Expression { get; } + + public TimeZoneInfo TimeZone { get; } + + public string? TimeZoneId { get; } + + public static ReminderCronSchedule Parse(string expressionText, string? timeZoneId = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expressionText); + + var key = new CacheKey(expressionText.Trim(), NormalizeInputTimeZoneId(timeZoneId)); + return Cache.GetOrAdd( + key, + static cacheKey => + { + var expression = ReminderCronExpression.Parse(cacheKey.ExpressionText); + var zone = ResolveTimeZoneOrDefault(cacheKey.TimeZoneId); + return new ReminderCronSchedule(expression, zone, NormalizeTimeZoneIdForStorage(zone)); + }); + } + + public static ReminderCronSchedule Parse(ReminderCronExpression expression, TimeZoneInfo? timeZone = null) + { + ArgumentNullException.ThrowIfNull(expression); + var zone = timeZone ?? TimeZoneInfo.Utc; + return new ReminderCronSchedule(expression, zone, NormalizeTimeZoneIdForStorage(zone)); + } + + public static string? NormalizeTimeZoneIdForStorage(TimeZoneInfo? timeZone) + { + if (timeZone is null || IsUtc(timeZone)) + { + return null; + } + + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(timeZone.Id, out var ianaId)) + { + return ianaId; + } + + return timeZone.Id; + } + + private static TimeZoneInfo ResolveTimeZoneOrDefault(string? timeZoneId) + { + if (string.IsNullOrWhiteSpace(timeZoneId)) + { + return TimeZoneInfo.Utc; + } + + try + { + return ResolveTimeZone(timeZoneId.Trim()); + } + catch (Exception exception) when (exception is TimeZoneNotFoundException or InvalidTimeZoneException) + { + throw new CronFormatException($"Unknown time zone id '{timeZoneId}'.", exception); + } + } + + public DateTime? GetNextOccurrence(DateTime fromUtc, bool inclusive = false) + { + return IsUtc(TimeZone) + ? Expression.GetNextOccurrence(fromUtc, inclusive) + : Expression.GetNextOccurrence(fromUtc, TimeZone, inclusive); + } + + public IEnumerable GetOccurrences( + DateTime fromUtc, + DateTime toUtc, + bool fromInclusive = true, + bool toInclusive = false) + { + return IsUtc(TimeZone) + ? Expression.GetOccurrences(fromUtc, toUtc, fromInclusive, toInclusive) + : Expression.GetOccurrences(fromUtc, toUtc, TimeZone, fromInclusive, toInclusive); + } + + private static bool IsUtc(TimeZoneInfo zone) + => string.Equals(zone.Id, TimeZoneInfo.Utc.Id, StringComparison.Ordinal); + + private static TimeZoneInfo ResolveTimeZone(string timeZoneId) + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + } + catch (TimeZoneNotFoundException) + { + if (TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZoneId, out var windowsId)) + { + return TimeZoneInfo.FindSystemTimeZoneById(windowsId); + } + + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(timeZoneId, out var ianaId)) + { + return TimeZoneInfo.FindSystemTimeZoneById(ianaId); + } + + throw; + } + } + + private static string? NormalizeInputTimeZoneId(string? timeZoneId) + => string.IsNullOrWhiteSpace(timeZoneId) ? null : timeZoneId.Trim(); + + private readonly record struct CacheKey(string ExpressionText, string? TimeZoneId); +} diff --git a/src/Orleans.AdvancedReminders/Cron/Internal/TimeZoneHelper.cs b/src/Orleans.AdvancedReminders/Cron/Internal/TimeZoneHelper.cs new file mode 100644 index 0000000000..bbc405c974 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/Internal/TimeZoneHelper.cs @@ -0,0 +1,79 @@ +#nullable enable +using System; + +namespace Orleans.AdvancedReminders.Cron.Internal +{ + internal static class TimeZoneHelper + { + // Use platform-native DST ambiguity logic from TimeZoneInfo. + public static bool IsAmbiguousTime(TimeZoneInfo zone, DateTime ambiguousTime) + { + return zone.IsAmbiguousTime(ambiguousTime); + } + + public static TimeSpan GetDaylightOffset(TimeZoneInfo zone, DateTime ambiguousDateTime) + { + var offsets = GetAmbiguousOffsets(zone, ambiguousDateTime); + var baseOffset = zone.GetUtcOffset(ambiguousDateTime); + + if (offsets[0] != baseOffset) return offsets[0]; + return offsets[1]; + } + + public static DateTimeOffset GetDaylightTimeStart(TimeZoneInfo zone, DateTime invalidDateTime) + { + var dstTransitionDateTime = new DateTime(invalidDateTime.Year, invalidDateTime.Month, invalidDateTime.Day, + invalidDateTime.Hour, invalidDateTime.Minute, 0, 0, invalidDateTime.Kind); + + while (zone.IsInvalidTime(dstTransitionDateTime)) + { + dstTransitionDateTime = dstTransitionDateTime.AddMinutes(1); + } + + var dstOffset = zone.GetUtcOffset(dstTransitionDateTime); + + return new DateTimeOffset(dstTransitionDateTime, dstOffset); + } + + public static DateTimeOffset GetStandardTimeStart(TimeZoneInfo zone, DateTime ambiguousTime, TimeSpan daylightOffset) + { + var dstTransitionEnd = GetDstTransitionEndDateTime(zone, ambiguousTime); + var baseOffset = zone.GetUtcOffset(ambiguousTime); + + return new DateTimeOffset(dstTransitionEnd, daylightOffset).ToOffset(baseOffset); + } + + public static DateTimeOffset GetAmbiguousIntervalEnd(TimeZoneInfo zone, DateTime ambiguousTime) + { + var dstTransitionEnd = GetDstTransitionEndDateTime(zone, ambiguousTime); + var baseOffset = zone.GetUtcOffset(ambiguousTime); + + return new DateTimeOffset(dstTransitionEnd, baseOffset); + } + + public static DateTimeOffset GetDaylightTimeEnd(TimeZoneInfo zone, DateTime ambiguousTime, TimeSpan daylightOffset) + { + var daylightTransitionEnd = GetDstTransitionEndDateTime(zone, ambiguousTime); + + return new DateTimeOffset(daylightTransitionEnd.AddTicks(-1), daylightOffset); + } + + private static TimeSpan[] GetAmbiguousOffsets(TimeZoneInfo zone, DateTime ambiguousTime) + { + return zone.GetAmbiguousTimeOffsets(ambiguousTime); + } + + private static DateTime GetDstTransitionEndDateTime(TimeZoneInfo zone, DateTime ambiguousDateTime) + { + var dstTransitionDateTime = new DateTime(ambiguousDateTime.Year, ambiguousDateTime.Month, ambiguousDateTime.Day, + ambiguousDateTime.Hour, ambiguousDateTime.Minute, 0, 0, ambiguousDateTime.Kind); + + while (zone.IsAmbiguousTime(dstTransitionDateTime)) + { + dstTransitionDateTime = dstTransitionDateTime.AddMinutes(1); + } + + return dstTransitionDateTime; + } + } +} diff --git a/src/Orleans.AdvancedReminders/Cron/ReminderCronBuilder.cs b/src/Orleans.AdvancedReminders/Cron/ReminderCronBuilder.cs new file mode 100644 index 0000000000..fd5b22ad5a --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/ReminderCronBuilder.cs @@ -0,0 +1,585 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Orleans.AdvancedReminders; + +/// +/// Provides typed helpers for building reminder cron expressions. +/// +public sealed class ReminderCronBuilder +{ + private readonly string _expression; + private readonly TimeZoneInfo _timeZone; + + private ReminderCronBuilder(string expression, TimeZoneInfo timeZone) + { + _expression = expression; + _timeZone = timeZone; + } + + /// + /// Uses a raw cron expression string. + /// + public static ReminderCronBuilder FromExpression(string expression) + => FromExpression(expression, TimeZoneInfo.Utc); + + /// + /// Uses a raw cron expression string and scheduling time zone. + /// + public static ReminderCronBuilder FromExpression(string expression, TimeZoneInfo? timeZone) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expression); + return new ReminderCronBuilder(expression.Trim(), timeZone ?? TimeZoneInfo.Utc); + } + + /// + /// Every minute. + /// + public static ReminderCronBuilder EveryMinute() => new("* * * * *", TimeZoneInfo.Utc); + + /// + /// At the specified minute of every hour. + /// + public static ReminderCronBuilder HourlyAt(int minute) => CreateHourly(minute, second: 0); + + /// + /// At the specified minute and second of every hour. + /// + public static ReminderCronBuilder HourlyAt(int minute, int second) => CreateHourly(minute, second); + + /// + /// At the specified offset within every hour. + /// + public static ReminderCronBuilder HourlyAt(TimeSpan offset) + { + var (minute, second) = GetHourlyOffsetParts(offset, nameof(offset)); + return CreateHourly(minute, second); + } + + /// + /// At the specified time every day. + /// + public static ReminderCronBuilder DailyAt(int hour, int minute) => CreateSchedule(hour, minute, "*", "*", "*"); + + /// + /// At the specified time every day. + /// + public static ReminderCronBuilder DailyAt(int hour, int minute, int second) => CreateSchedule(hour, minute, "*", "*", "*", second); + + /// + /// At the specified time every day. + /// + public static ReminderCronBuilder DailyAt(TimeOnly time) + => CreateSchedule(time, "*", "*", "*", nameof(time)); + + /// + /// At the specified time every day. + /// + public static ReminderCronBuilder DailyAt(TimeSpan timeOfDay) + => CreateSchedule(timeOfDay, "*", "*", "*", nameof(timeOfDay)); + + /// + /// At the specified time Monday through Friday. + /// + public static ReminderCronBuilder WeekdaysAt(int hour, int minute) => CreateSchedule(hour, minute, "*", "*", "MON-FRI"); + + /// + /// At the specified time Monday through Friday. + /// + public static ReminderCronBuilder WeekdaysAt(int hour, int minute, int second) => CreateSchedule(hour, minute, "*", "*", "MON-FRI", second); + + /// + /// At the specified time Monday through Friday. + /// + public static ReminderCronBuilder WeekdaysAt(TimeOnly time) + => CreateSchedule(time, "*", "*", "MON-FRI", nameof(time)); + + /// + /// At the specified time Monday through Friday. + /// + public static ReminderCronBuilder WeekdaysAt(TimeSpan timeOfDay) + => CreateSchedule(timeOfDay, "*", "*", "MON-FRI", nameof(timeOfDay)); + + /// + /// At the specified time on Saturday and Sunday. + /// + public static ReminderCronBuilder WeekendsAt(int hour, int minute) => CreateSchedule(hour, minute, "*", "*", "SAT,SUN"); + + /// + /// At the specified time on Saturday and Sunday. + /// + public static ReminderCronBuilder WeekendsAt(int hour, int minute, int second) => CreateSchedule(hour, minute, "*", "*", "SAT,SUN", second); + + /// + /// At the specified time on Saturday and Sunday. + /// + public static ReminderCronBuilder WeekendsAt(TimeOnly time) + => CreateSchedule(time, "*", "*", "SAT,SUN", nameof(time)); + + /// + /// At the specified time on Saturday and Sunday. + /// + public static ReminderCronBuilder WeekendsAt(TimeSpan timeOfDay) + => CreateSchedule(timeOfDay, "*", "*", "SAT,SUN", nameof(timeOfDay)); + + /// + /// At the specified time on the given day of week. + /// + public static ReminderCronBuilder WeeklyOn(DayOfWeek dayOfWeek, int hour, int minute) + => CreateSchedule(hour, minute, "*", "*", ToCronDay(dayOfWeek).ToString(CultureInfo.InvariantCulture)); + + /// + /// At the specified time on the given day of week. + /// + public static ReminderCronBuilder WeeklyOn(DayOfWeek dayOfWeek, int hour, int minute, int second) + => CreateSchedule(hour, minute, "*", "*", ToCronDay(dayOfWeek).ToString(CultureInfo.InvariantCulture), second); + + /// + /// At the specified time on the given day of week. + /// + public static ReminderCronBuilder WeeklyOn(DayOfWeek dayOfWeek, TimeOnly time) + => CreateSchedule(time, "*", "*", ToCronDay(dayOfWeek).ToString(CultureInfo.InvariantCulture), nameof(time)); + + /// + /// At the specified time on the given day of week. + /// + public static ReminderCronBuilder WeeklyOn(DayOfWeek dayOfWeek, TimeSpan timeOfDay) + => CreateSchedule(timeOfDay, "*", "*", ToCronDay(dayOfWeek).ToString(CultureInfo.InvariantCulture), nameof(timeOfDay)); + + /// + /// At the specified time on the given day of month. + /// + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, int hour, int minute) + { + ValidateDayOfMonth(dayOfMonth); + return CreateSchedule(hour, minute, dayOfMonth.ToString(CultureInfo.InvariantCulture), "*", "*"); + } + + /// + /// At the specified time on the given day of month. + /// + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, int hour, int minute, int second) + { + ValidateDayOfMonth(dayOfMonth); + return CreateSchedule(hour, minute, dayOfMonth.ToString(CultureInfo.InvariantCulture), "*", "*", second); + } + + /// + /// At the specified time on the given day of month. + /// + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, TimeOnly time) + { + ValidateDayOfMonth(dayOfMonth); + return CreateSchedule(time, dayOfMonth.ToString(CultureInfo.InvariantCulture), "*", "*", nameof(time)); + } + + /// + /// At the specified time on the given day of month. + /// + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, TimeSpan timeOfDay) + { + ValidateDayOfMonth(dayOfMonth); + return CreateSchedule(timeOfDay, dayOfMonth.ToString(CultureInfo.InvariantCulture), "*", "*", nameof(timeOfDay)); + } + + /// + /// At the specified time on the last day of each month. + /// + public static ReminderCronBuilder MonthlyOnLastDay(int hour, int minute) => CreateSchedule(hour, minute, "L", "*", "*"); + + /// + /// At the specified time on the last day of each month. + /// + public static ReminderCronBuilder MonthlyOnLastDay(int hour, int minute, int second) => CreateSchedule(hour, minute, "L", "*", "*", second); + + /// + /// At the specified time on the last day of each month. + /// + public static ReminderCronBuilder MonthlyOnLastDay(TimeOnly time) + => CreateSchedule(time, "L", "*", "*", nameof(time)); + + /// + /// At the specified time on the last day of each month. + /// + public static ReminderCronBuilder MonthlyOnLastDay(TimeSpan timeOfDay) + => CreateSchedule(timeOfDay, "L", "*", "*", nameof(timeOfDay)); + + /// + /// At the specified time on the given month/day every year. + /// + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, int hour, int minute) + { + ValidateMonth(month); + ValidateDayOfMonth(dayOfMonth, month); + return CreateSchedule(hour, minute, dayOfMonth.ToString(CultureInfo.InvariantCulture), month.ToString(CultureInfo.InvariantCulture), "*"); + } + + /// + /// At the specified time on the given month/day every year. + /// + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, int hour, int minute, int second) + { + ValidateMonth(month); + ValidateDayOfMonth(dayOfMonth, month); + return CreateSchedule(hour, minute, dayOfMonth.ToString(CultureInfo.InvariantCulture), month.ToString(CultureInfo.InvariantCulture), "*", second); + } + + /// + /// At the specified time on the given month/day every year. + /// + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, TimeOnly time) + { + ValidateMonth(month); + ValidateDayOfMonth(dayOfMonth, month); + return CreateSchedule( + time, + dayOfMonth.ToString(CultureInfo.InvariantCulture), + month.ToString(CultureInfo.InvariantCulture), + "*", + nameof(time)); + } + + /// + /// At the specified time on the given month/day every year. + /// + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, TimeSpan timeOfDay) + { + ValidateMonth(month); + ValidateDayOfMonth(dayOfMonth, month); + return CreateSchedule( + timeOfDay, + dayOfMonth.ToString(CultureInfo.InvariantCulture), + month.ToString(CultureInfo.InvariantCulture), + "*", + nameof(timeOfDay)); + } + + /// + /// At the specified time on the given date's month/day every year. The year component is ignored. + /// + public static ReminderCronBuilder YearlyOn(DateOnly date, int hour, int minute) => YearlyOn(date.Month, date.Day, hour, minute); + + /// + /// At the specified time on the given date's month/day every year. The year component is ignored. + /// + public static ReminderCronBuilder YearlyOn(DateOnly date, int hour, int minute, int second) => YearlyOn(date.Month, date.Day, hour, minute, second); + + /// + /// At the specified time on the given date's month/day every year. The year component is ignored. + /// + public static ReminderCronBuilder YearlyOn(DateOnly date, TimeOnly time) => YearlyOn(date.Month, date.Day, time); + + /// + /// At the specified time on the given date's month/day every year. The year component is ignored. + /// + public static ReminderCronBuilder YearlyOn(DateOnly date, TimeSpan timeOfDay) => YearlyOn(date.Month, date.Day, timeOfDay); + + /// + /// Convenience overloads that apply a strongly typed scheduling time zone at creation time. + /// + public static ReminderCronBuilder EveryMinute(TimeZoneInfo timeZone) => EveryMinute().InTimeZone(timeZone); + + public static ReminderCronBuilder HourlyAt(int minute, TimeZoneInfo timeZone) => HourlyAt(minute).InTimeZone(timeZone); + + public static ReminderCronBuilder HourlyAt(int minute, int second, TimeZoneInfo timeZone) => HourlyAt(minute, second).InTimeZone(timeZone); + + public static ReminderCronBuilder HourlyAt(TimeSpan offset, TimeZoneInfo timeZone) => HourlyAt(offset).InTimeZone(timeZone); + + public static ReminderCronBuilder DailyAt(int hour, int minute, TimeZoneInfo timeZone) => DailyAt(hour, minute).InTimeZone(timeZone); + + public static ReminderCronBuilder DailyAt(int hour, int minute, int second, TimeZoneInfo timeZone) => DailyAt(hour, minute, second).InTimeZone(timeZone); + + public static ReminderCronBuilder DailyAt(TimeOnly time, TimeZoneInfo timeZone) => DailyAt(time).InTimeZone(timeZone); + + public static ReminderCronBuilder DailyAt(TimeSpan timeOfDay, TimeZoneInfo timeZone) => DailyAt(timeOfDay).InTimeZone(timeZone); + + public static ReminderCronBuilder WeekdaysAt(int hour, int minute, TimeZoneInfo timeZone) => WeekdaysAt(hour, minute).InTimeZone(timeZone); + + public static ReminderCronBuilder WeekdaysAt(int hour, int minute, int second, TimeZoneInfo timeZone) => WeekdaysAt(hour, minute, second).InTimeZone(timeZone); + + public static ReminderCronBuilder WeekdaysAt(TimeOnly time, TimeZoneInfo timeZone) => WeekdaysAt(time).InTimeZone(timeZone); + + public static ReminderCronBuilder WeekdaysAt(TimeSpan timeOfDay, TimeZoneInfo timeZone) => WeekdaysAt(timeOfDay).InTimeZone(timeZone); + + public static ReminderCronBuilder WeekendsAt(int hour, int minute, TimeZoneInfo timeZone) => WeekendsAt(hour, minute).InTimeZone(timeZone); + + public static ReminderCronBuilder WeekendsAt(int hour, int minute, int second, TimeZoneInfo timeZone) => WeekendsAt(hour, minute, second).InTimeZone(timeZone); + + public static ReminderCronBuilder WeekendsAt(TimeOnly time, TimeZoneInfo timeZone) => WeekendsAt(time).InTimeZone(timeZone); + + public static ReminderCronBuilder WeekendsAt(TimeSpan timeOfDay, TimeZoneInfo timeZone) => WeekendsAt(timeOfDay).InTimeZone(timeZone); + + public static ReminderCronBuilder WeeklyOn(DayOfWeek dayOfWeek, int hour, int minute, TimeZoneInfo timeZone) => WeeklyOn(dayOfWeek, hour, minute).InTimeZone(timeZone); + + public static ReminderCronBuilder WeeklyOn(DayOfWeek dayOfWeek, int hour, int minute, int second, TimeZoneInfo timeZone) => WeeklyOn(dayOfWeek, hour, minute, second).InTimeZone(timeZone); + + public static ReminderCronBuilder WeeklyOn(DayOfWeek dayOfWeek, TimeOnly time, TimeZoneInfo timeZone) => WeeklyOn(dayOfWeek, time).InTimeZone(timeZone); + + public static ReminderCronBuilder WeeklyOn(DayOfWeek dayOfWeek, TimeSpan timeOfDay, TimeZoneInfo timeZone) => WeeklyOn(dayOfWeek, timeOfDay).InTimeZone(timeZone); + + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, int hour, int minute, TimeZoneInfo timeZone) => MonthlyOn(dayOfMonth, hour, minute).InTimeZone(timeZone); + + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, int hour, int minute, int second, TimeZoneInfo timeZone) => MonthlyOn(dayOfMonth, hour, minute, second).InTimeZone(timeZone); + + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, TimeOnly time, TimeZoneInfo timeZone) => MonthlyOn(dayOfMonth, time).InTimeZone(timeZone); + + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, TimeSpan timeOfDay, TimeZoneInfo timeZone) => MonthlyOn(dayOfMonth, timeOfDay).InTimeZone(timeZone); + + public static ReminderCronBuilder MonthlyOnLastDay(int hour, int minute, TimeZoneInfo timeZone) => MonthlyOnLastDay(hour, minute).InTimeZone(timeZone); + + public static ReminderCronBuilder MonthlyOnLastDay(int hour, int minute, int second, TimeZoneInfo timeZone) => MonthlyOnLastDay(hour, minute, second).InTimeZone(timeZone); + + public static ReminderCronBuilder MonthlyOnLastDay(TimeOnly time, TimeZoneInfo timeZone) => MonthlyOnLastDay(time).InTimeZone(timeZone); + + public static ReminderCronBuilder MonthlyOnLastDay(TimeSpan timeOfDay, TimeZoneInfo timeZone) => MonthlyOnLastDay(timeOfDay).InTimeZone(timeZone); + + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, int hour, int minute, TimeZoneInfo timeZone) => YearlyOn(month, dayOfMonth, hour, minute).InTimeZone(timeZone); + + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, int hour, int minute, int second, TimeZoneInfo timeZone) => YearlyOn(month, dayOfMonth, hour, minute, second).InTimeZone(timeZone); + + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, TimeOnly time, TimeZoneInfo timeZone) => YearlyOn(month, dayOfMonth, time).InTimeZone(timeZone); + + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, TimeSpan timeOfDay, TimeZoneInfo timeZone) => YearlyOn(month, dayOfMonth, timeOfDay).InTimeZone(timeZone); + + public static ReminderCronBuilder YearlyOn(DateOnly date, int hour, int minute, TimeZoneInfo timeZone) => YearlyOn(date, hour, minute).InTimeZone(timeZone); + + public static ReminderCronBuilder YearlyOn(DateOnly date, int hour, int minute, int second, TimeZoneInfo timeZone) => YearlyOn(date, hour, minute, second).InTimeZone(timeZone); + + public static ReminderCronBuilder YearlyOn(DateOnly date, TimeOnly time, TimeZoneInfo timeZone) => YearlyOn(date, time).InTimeZone(timeZone); + + public static ReminderCronBuilder YearlyOn(DateOnly date, TimeSpan timeOfDay, TimeZoneInfo timeZone) => YearlyOn(date, timeOfDay).InTimeZone(timeZone); + + /// + /// Returns a copy of this builder configured to evaluate occurrences in the provided time zone. + /// + public ReminderCronBuilder InTimeZone(TimeZoneInfo timeZone) + { + ArgumentNullException.ThrowIfNull(timeZone); + return new ReminderCronBuilder(_expression, timeZone); + } + + /// + /// Returns a copy of this builder configured to evaluate occurrences in the provided time zone id. + /// + public ReminderCronBuilder InTimeZone(string timeZoneId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(timeZoneId); + var zone = ResolveTimeZone(timeZoneId); + return InTimeZone(zone); + } + + /// + /// Gets the time zone used by this builder for occurrence calculations. + /// + public TimeZoneInfo TimeZone => _timeZone; + + /// + /// Gets the next occurrence in UTC, evaluated using this builder's time zone. + /// + public DateTime? GetNextOccurrence(DateTime fromUtc, bool inclusive = false) + { + var expression = ReminderCronExpression.Parse(_expression); + return IsUtcTimeZone(_timeZone) + ? expression.GetNextOccurrence(fromUtc, inclusive) + : expression.GetNextOccurrence(fromUtc, _timeZone, inclusive); + } + + /// + /// Gets all occurrences in UTC for the provided range, evaluated using this builder's time zone. + /// + public IEnumerable GetOccurrences( + DateTime fromUtc, + DateTime toUtc, + bool fromInclusive = true, + bool toInclusive = false) + { + var expression = ReminderCronExpression.Parse(_expression); + return IsUtcTimeZone(_timeZone) + ? expression.GetOccurrences(fromUtc, toUtc, fromInclusive, toInclusive) + : expression.GetOccurrences(fromUtc, toUtc, _timeZone, fromInclusive, toInclusive); + } + + /// + /// Returns the resulting cron expression string. + /// + public string ToExpressionString() => _expression; + + /// + /// Parses and validates the builder output as a typed cron expression. + /// + public ReminderCronExpression ToCronExpression() => ReminderCronExpression.Parse(_expression); + + /// + /// Alias for . + /// + public ReminderCronExpression Build() => ToCronExpression(); + + private static int ToCronDay(DayOfWeek dayOfWeek) + { + // Unix cron mapping: 0 or 7 = Sunday, 1 = Monday, ..., 6 = Saturday + return dayOfWeek switch + { + DayOfWeek.Sunday => 0, + DayOfWeek.Monday => 1, + DayOfWeek.Tuesday => 2, + DayOfWeek.Wednesday => 3, + DayOfWeek.Thursday => 4, + DayOfWeek.Friday => 5, + DayOfWeek.Saturday => 6, + _ => throw new ArgumentOutOfRangeException(nameof(dayOfWeek), dayOfWeek, null) + }; + } + + private static TimeZoneInfo ResolveTimeZone(string timeZoneId) + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + } + catch (TimeZoneNotFoundException) + { + if (TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZoneId, out var windowsId)) + { + return TimeZoneInfo.FindSystemTimeZoneById(windowsId); + } + + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(timeZoneId, out var ianaId)) + { + return TimeZoneInfo.FindSystemTimeZoneById(ianaId); + } + + throw; + } + } + + private static bool IsUtcTimeZone(TimeZoneInfo zone) + => string.Equals(zone.Id, TimeZoneInfo.Utc.Id, StringComparison.Ordinal); + + private static ReminderCronBuilder CreateHourly(int minute, int second) + { + ValidateMinute(minute); + ValidateSecond(second); + return CreateSchedule( + minute.ToString(CultureInfo.InvariantCulture), + "*", + "*", + "*", + "*", + second); + } + + private static ReminderCronBuilder CreateSchedule(int hour, int minute, string dayOfMonth, string month, string dayOfWeek, int second = 0) + { + ValidateHour(hour); + ValidateMinute(minute); + ValidateSecond(second); + return CreateSchedule( + minute.ToString(CultureInfo.InvariantCulture), + hour.ToString(CultureInfo.InvariantCulture), + dayOfMonth, + month, + dayOfWeek, + second); + } + + private static ReminderCronBuilder CreateSchedule(TimeOnly time, string dayOfMonth, string month, string dayOfWeek, string paramName) + { + ValidateWholeSeconds(time.Ticks, paramName); + return CreateSchedule(time.Hour, time.Minute, dayOfMonth, month, dayOfWeek, time.Second); + } + + private static ReminderCronBuilder CreateSchedule(TimeSpan timeOfDay, string dayOfMonth, string month, string dayOfWeek, string paramName) + { + var (hour, minute, second) = GetTimeOfDayParts(timeOfDay, paramName); + return CreateSchedule(hour, minute, dayOfMonth, month, dayOfWeek, second); + } + + private static ReminderCronBuilder CreateSchedule(string minute, string hour, string dayOfMonth, string month, string dayOfWeek, int second) + { + var expression = second == 0 + ? $"{minute} {hour} {dayOfMonth} {month} {dayOfWeek}" + : $"{second.ToString(CultureInfo.InvariantCulture)} {minute} {hour} {dayOfMonth} {month} {dayOfWeek}"; + return new ReminderCronBuilder(expression, TimeZoneInfo.Utc); + } + + private static (int Hour, int Minute, int Second) GetTimeOfDayParts(TimeSpan timeOfDay, string paramName) + { + if (timeOfDay < TimeSpan.Zero || timeOfDay >= TimeSpan.FromDays(1)) + { + throw new ArgumentOutOfRangeException(paramName, timeOfDay, "Time of day must be in [00:00:00, 24:00:00)."); + } + + ValidateWholeSeconds(timeOfDay.Ticks, paramName); + return ((int)timeOfDay.TotalHours, timeOfDay.Minutes, timeOfDay.Seconds); + } + + private static (int Minute, int Second) GetHourlyOffsetParts(TimeSpan offset, string paramName) + { + if (offset < TimeSpan.Zero || offset >= TimeSpan.FromHours(1)) + { + throw new ArgumentOutOfRangeException(paramName, offset, "Hourly offset must be in [00:00:00, 01:00:00)."); + } + + ValidateWholeSeconds(offset.Ticks, paramName); + return (offset.Minutes, offset.Seconds); + } + + private static void ValidateWholeSeconds(long ticks, string paramName) + { + if (ticks % TimeSpan.TicksPerSecond != 0) + { + throw new ArgumentOutOfRangeException(paramName, "Sub-second precision is not supported."); + } + } + + private static void ValidateMinute(int minute) + { + if (minute is < 0 or > 59) + { + throw new ArgumentOutOfRangeException(nameof(minute), minute, "Minute must be in [0, 59]."); + } + } + + private static void ValidateHour(int hour) + { + if (hour is < 0 or > 23) + { + throw new ArgumentOutOfRangeException(nameof(hour), hour, "Hour must be in [0, 23]."); + } + } + + private static void ValidateDayOfMonth(int dayOfMonth) + { + if (dayOfMonth is < 1 or > 31) + { + throw new ArgumentOutOfRangeException(nameof(dayOfMonth), dayOfMonth, "Day of month must be in [1, 31]."); + } + } + + private static void ValidateDayOfMonth(int dayOfMonth, int month) + { + ValidateDayOfMonth(dayOfMonth); + if (dayOfMonth > DateTime.DaysInMonth(2024, month)) + { + throw new ArgumentOutOfRangeException(nameof(dayOfMonth), dayOfMonth, $"Day of month must be valid for month {month}."); + } + } + + private static void ValidateMonth(int month) + { + if (month is < 1 or > 12) + { + throw new ArgumentOutOfRangeException(nameof(month), month, "Month must be in [1, 12]."); + } + } + + private static void ValidateSecond(int second) + { + if (second is < 0 or > 59) + { + throw new ArgumentOutOfRangeException(nameof(second), second, "Second must be in [0, 59]."); + } + } +} diff --git a/src/Orleans.AdvancedReminders/Cron/ReminderCronExpression.cs b/src/Orleans.AdvancedReminders/Cron/ReminderCronExpression.cs new file mode 100644 index 0000000000..3759703331 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Cron/ReminderCronExpression.cs @@ -0,0 +1,138 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Orleans.AdvancedReminders.Cron.Internal; + +namespace Orleans.AdvancedReminders; + +/// +/// Represents a validated cron schedule for Orleans reminders. +/// +public sealed class ReminderCronExpression : IEquatable +{ + private readonly CronExpression _expression; + + private ReminderCronExpression(string expressionText, CronExpression expression) + { + ExpressionText = expressionText; + _expression = expression; + } + + /// + /// Gets the original cron expression text. + /// + public string ExpressionText { get; } + + /// + /// Parses a cron expression in 5-field or 6-field (with seconds) format. + /// + public static ReminderCronExpression Parse(string expression) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expression); + + var format = DetectFormat(expression); + var parsed = CronExpression.Parse(expression, format); + return new ReminderCronExpression(expression.Trim(), parsed); + } + + /// + /// Attempts to parse a cron expression in 5-field or 6-field (with seconds) format. + /// + public static bool TryParse(string expression, out ReminderCronExpression? cronExpression) + { + if (string.IsNullOrWhiteSpace(expression)) + { + cronExpression = null; + return false; + } + + try + { + cronExpression = Parse(expression); + return true; + } + catch (CronFormatException) + { + cronExpression = null; + return false; + } + catch (ArgumentException) + { + cronExpression = null; + return false; + } + } + + /// + /// Gets the next occurrence in UTC. + /// + public DateTime? GetNextOccurrence(DateTime fromUtc, bool inclusive = false) + { + EnsureUtc(fromUtc, nameof(fromUtc)); + return _expression.GetNextOccurrence(fromUtc, inclusive); + } + + /// + /// Gets the next occurrence in UTC using the provided scheduling time zone. + /// + internal DateTime? GetNextOccurrence(DateTime fromUtc, TimeZoneInfo zone, bool inclusive = false) + { + EnsureUtc(fromUtc, nameof(fromUtc)); + ArgumentNullException.ThrowIfNull(zone); + return _expression.GetNextOccurrence(fromUtc, zone, inclusive); + } + + /// + /// Gets all occurrences in the specified UTC range. + /// + public IEnumerable GetOccurrences(DateTime fromUtc, DateTime toUtc, bool fromInclusive = true, bool toInclusive = false) + { + EnsureUtc(fromUtc, nameof(fromUtc)); + EnsureUtc(toUtc, nameof(toUtc)); + return _expression.GetOccurrences(fromUtc, toUtc, fromInclusive, toInclusive); + } + + /// + /// Gets all occurrences in the specified UTC range using the provided scheduling time zone. + /// + internal IEnumerable GetOccurrences( + DateTime fromUtc, + DateTime toUtc, + TimeZoneInfo zone, + bool fromInclusive = true, + bool toInclusive = false) + { + EnsureUtc(fromUtc, nameof(fromUtc)); + EnsureUtc(toUtc, nameof(toUtc)); + ArgumentNullException.ThrowIfNull(zone); + return _expression.GetOccurrences(fromUtc, toUtc, zone, fromInclusive, toInclusive); + } + + internal static ReminderCronExpression FromValidatedString(string expression) + { + var format = DetectFormat(expression); + var parsed = CronExpression.Parse(expression, format); + return new ReminderCronExpression(expression, parsed); + } + + public string ToExpressionString() => ExpressionText; + + public override string ToString() => ExpressionText; + + public bool Equals(ReminderCronExpression? other) + => other is not null && string.Equals(ExpressionText, other.ExpressionText, StringComparison.Ordinal); + + public override bool Equals(object? obj) => obj is ReminderCronExpression other && Equals(other); + + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(ExpressionText); + + private static CronFormat DetectFormat(string expression) => ReminderCronParser.DetectFormat(expression); + + private static void EnsureUtc(DateTime value, string argumentName) + { + if (value.Kind != DateTimeKind.Utc) + { + throw new ArgumentException("DateTime must use DateTimeKind.Utc.", argumentName); + } + } +} diff --git a/src/Orleans.AdvancedReminders/ErrorCodes.cs b/src/Orleans.AdvancedReminders/ErrorCodes.cs new file mode 100644 index 0000000000..eaaf94605a --- /dev/null +++ b/src/Orleans.AdvancedReminders/ErrorCodes.cs @@ -0,0 +1,44 @@ +// ReSharper disable InconsistentNaming +namespace Orleans.AdvancedReminders; + +/// +/// The set of error codes used by the Orleans runtime libraries for logging errors. For Reminders. +/// +public enum RSErrorCode +{ + ReminderServiceBase = /* Runtime */ 100000 + 2900, + RS_Register_TableError = ReminderServiceBase + 5, + RS_Register_AlreadyRegistered = ReminderServiceBase + 7, + RS_Register_InvalidPeriod = ReminderServiceBase + 8, + RS_Register_NotRemindable = ReminderServiceBase + 9, + RS_NotResponsible = ReminderServiceBase + 10, + RS_Unregister_NotFoundLocally = ReminderServiceBase + 11, + RS_Unregister_TableError = ReminderServiceBase + 12, + RS_Table_Insert = ReminderServiceBase + 13, + RS_Table_Remove = ReminderServiceBase + 14, + RS_Tick_Delivery_Error = ReminderServiceBase + 15, + RS_Not_Started = ReminderServiceBase + 16, + RS_UnregisterGrain_TableError = ReminderServiceBase + 17, + RS_GrainBasedTable1 = ReminderServiceBase + 18, + RS_Factory1 = ReminderServiceBase + 19, + RS_FailedToReadTableAndStartTimer = ReminderServiceBase + 20, + RS_TableGrainInit1 = ReminderServiceBase + 21, + RS_TableGrainInit2 = ReminderServiceBase + 22, + RS_TableGrainInit3 = ReminderServiceBase + 23, + RS_GrainBasedTable2 = ReminderServiceBase + 24, + RS_ServiceStarting = ReminderServiceBase + 25, + RS_ServiceStarted = ReminderServiceBase + 26, + RS_ServiceStopping = ReminderServiceBase + 27, + RS_RegisterOrUpdate = ReminderServiceBase + 28, + RS_Unregister = ReminderServiceBase + 29, + RS_Stop = ReminderServiceBase + 30, + RS_RemoveFromTable = ReminderServiceBase + 31, + RS_GetReminder = ReminderServiceBase + 32, + RS_GetReminders = ReminderServiceBase + 33, + RS_RangeChanged = ReminderServiceBase + 34, + RS_LocalStop = ReminderServiceBase + 35, + RS_Started = ReminderServiceBase + 36, + RS_ServiceInitialLoadFailing = ReminderServiceBase + 37, + RS_ServiceInitialLoadFailed = ReminderServiceBase + 38, + RS_FastReminderInterval = ReminderServiceBase + 39, +} diff --git a/src/Orleans.AdvancedReminders/GlobalUsings.cs b/src/Orleans.AdvancedReminders/GlobalUsings.cs new file mode 100644 index 0000000000..d59a9bde38 --- /dev/null +++ b/src/Orleans.AdvancedReminders/GlobalUsings.cs @@ -0,0 +1 @@ +global using Orleans.AdvancedReminders.Runtime; diff --git a/src/Orleans.AdvancedReminders/GrainReminderCronExtensions.cs b/src/Orleans.AdvancedReminders/GrainReminderCronExtensions.cs new file mode 100644 index 0000000000..fc621e83f5 --- /dev/null +++ b/src/Orleans.AdvancedReminders/GrainReminderCronExtensions.cs @@ -0,0 +1,348 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Orleans.AdvancedReminders.Cron.Internal; +using Orleans.Runtime; +using Orleans.AdvancedReminders.Timers; + +#nullable enable +namespace Orleans.AdvancedReminders; + +/// +/// Extension methods for registering cron-based Orleans reminders. +/// +public static class GrainReminderCronExtensions +{ + /// + /// Registers or updates a persistent cron reminder. + /// + public static Task RegisterOrUpdateReminder(this Grain grain, string reminderName, string cronExpression) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, cronExpression); + + /// + /// Registers or updates a persistent cron reminder. + /// + public static Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, string cronExpression) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, cronExpression); + + /// + /// Registers or updates a persistent cron reminder. + /// + public static Task RegisterOrUpdateReminder(this Grain grain, string reminderName, ReminderCronExpression cronExpression) + => RegisterOrUpdateReminder(grain, reminderName, cronExpression, timeZone: null); + + /// + /// Registers or updates a persistent cron reminder. + /// + public static Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, ReminderCronExpression cronExpression) + => RegisterOrUpdateReminder(grain, reminderName, cronExpression, timeZone: null); + + /// + /// Registers or updates a persistent cron reminder. + /// + public static Task RegisterOrUpdateReminder( + this Grain grain, + string reminderName, + ReminderCronExpression cronExpression, + TimeZoneInfo? timeZone) + => RegisterOrUpdateReminder( + IsRemindable(grain), + grain?.GrainContext, + reminderName, + cronExpression?.ToExpressionString(), + GetCronTimeZoneId(timeZone)); + + /// + /// Registers or updates a persistent cron reminder. + /// + public static Task RegisterOrUpdateReminder( + this IGrainBase grain, + string reminderName, + ReminderCronExpression cronExpression, + TimeZoneInfo? timeZone) + => RegisterOrUpdateReminder( + IsRemindable(grain), + grain?.GrainContext, + reminderName, + cronExpression?.ToExpressionString(), + GetCronTimeZoneId(timeZone)); + + /// + /// Registers or updates a persistent cron reminder. + /// + public static Task RegisterOrUpdateReminder(this Grain grain, string reminderName, ReminderCronBuilder cronBuilder) + => RegisterOrUpdateReminder( + IsRemindable(grain), + grain?.GrainContext, + reminderName, + cronBuilder?.ToExpressionString(), + GetCronTimeZoneId(cronBuilder?.TimeZone)); + + /// + /// Registers or updates a persistent cron reminder. + /// + public static Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, ReminderCronBuilder cronBuilder) + => RegisterOrUpdateReminder( + IsRemindable(grain), + grain?.GrainContext, + reminderName, + cronBuilder?.ToExpressionString(), + GetCronTimeZoneId(cronBuilder?.TimeZone)); + + /// + /// Registers or updates a persistent cron reminder using the provided time zone. + /// + public static Task RegisterOrUpdateReminder( + this Grain grain, + string reminderName, + string cronExpression, + TimeZoneInfo? timeZone) + => RegisterOrUpdateReminder( + IsRemindable(grain), + grain?.GrainContext, + reminderName, + cronExpression, + GetCronTimeZoneId(timeZone)); + + /// + /// Registers or updates a persistent cron reminder using the provided time zone. + /// + public static Task RegisterOrUpdateReminder( + this IGrainBase grain, + string reminderName, + string cronExpression, + TimeZoneInfo? timeZone) + => RegisterOrUpdateReminder( + IsRemindable(grain), + grain?.GrainContext, + reminderName, + cronExpression, + GetCronTimeZoneId(timeZone)); + + /// + /// Registers or updates a persistent cron reminder with adaptive delivery options. + /// + public static Task RegisterOrUpdateReminder( + this Grain grain, + string reminderName, + string cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(grain, reminderName, cronExpression, priority, action, timeZone: null); + + /// + /// Registers or updates a persistent cron reminder with adaptive delivery options. + /// + public static Task RegisterOrUpdateReminder( + this IGrainBase grain, + string reminderName, + string cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(grain, reminderName, cronExpression, priority, action, timeZone: null); + + /// + /// Registers or updates a persistent cron reminder with adaptive delivery options. + /// + public static Task RegisterOrUpdateReminder( + this Grain grain, + string reminderName, + string cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action, + TimeZoneInfo? timeZone) + => RegisterOrUpdateReminder( + IsRemindable(grain), + grain?.GrainContext, + reminderName, + cronExpression, + GetCronTimeZoneId(timeZone), + priority, + action); + + /// + /// Registers or updates a persistent cron reminder with adaptive delivery options. + /// + public static Task RegisterOrUpdateReminder( + this IGrainBase grain, + string reminderName, + string cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action, + TimeZoneInfo? timeZone) + => RegisterOrUpdateReminder( + IsRemindable(grain), + grain?.GrainContext, + reminderName, + cronExpression, + GetCronTimeZoneId(timeZone), + priority, + action); + + /// + /// Registers or updates a persistent cron reminder with adaptive delivery options. + /// + public static Task RegisterOrUpdateReminder( + this Grain grain, + string reminderName, + ReminderCronExpression cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(grain, reminderName, cronExpression, priority, action, timeZone: null); + + /// + /// Registers or updates a persistent cron reminder with adaptive delivery options. + /// + public static Task RegisterOrUpdateReminder( + this IGrainBase grain, + string reminderName, + ReminderCronExpression cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(grain, reminderName, cronExpression, priority, action, timeZone: null); + + /// + /// Registers or updates a persistent cron reminder with adaptive delivery options. + /// + public static Task RegisterOrUpdateReminder( + this Grain grain, + string reminderName, + ReminderCronExpression cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action, + TimeZoneInfo? timeZone) + => RegisterOrUpdateReminder( + IsRemindable(grain), + grain?.GrainContext, + reminderName, + cronExpression?.ToExpressionString(), + GetCronTimeZoneId(timeZone), + priority, + action); + + /// + /// Registers or updates a persistent cron reminder with adaptive delivery options. + /// + public static Task RegisterOrUpdateReminder( + this IGrainBase grain, + string reminderName, + ReminderCronExpression cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action, + TimeZoneInfo? timeZone) + => RegisterOrUpdateReminder( + IsRemindable(grain), + grain?.GrainContext, + reminderName, + cronExpression?.ToExpressionString(), + GetCronTimeZoneId(timeZone), + priority, + action); + + /// + /// Registers or updates a persistent cron reminder with adaptive delivery options. + /// + public static Task RegisterOrUpdateReminder( + this Grain grain, + string reminderName, + ReminderCronBuilder cronBuilder, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder( + IsRemindable(grain), + grain?.GrainContext, + reminderName, + cronBuilder?.ToExpressionString(), + GetCronTimeZoneId(cronBuilder?.TimeZone), + priority, + action); + + /// + /// Registers or updates a persistent cron reminder with adaptive delivery options. + /// + public static Task RegisterOrUpdateReminder( + this IGrainBase grain, + string reminderName, + ReminderCronBuilder cronBuilder, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder( + IsRemindable(grain), + grain?.GrainContext, + reminderName, + cronBuilder?.ToExpressionString(), + GetCronTimeZoneId(cronBuilder?.TimeZone), + priority, + action); + + /// + /// Registers or updates a persistent cron reminder using the provided time zone and adaptive delivery options. + /// + public static Task RegisterOrUpdateReminder( + this Grain grain, + string reminderName, + string cronExpression, + TimeZoneInfo? timeZone, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(grain, reminderName, cronExpression, priority, action, timeZone); + + /// + /// Registers or updates a persistent cron reminder using the provided time zone and adaptive delivery options. + /// + public static Task RegisterOrUpdateReminder( + this IGrainBase grain, + string reminderName, + string cronExpression, + TimeZoneInfo? timeZone, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(grain, reminderName, cronExpression, priority, action, timeZone); + + private static Task RegisterOrUpdateReminder( + bool remindable, + IGrainContext? grainContext, + string reminderName, + string? cronExpression, + string? cronTimeZoneId = null) + => RegisterOrUpdateReminder( + remindable, + grainContext, + reminderName, + cronExpression, + cronTimeZoneId, + Runtime.ReminderPriority.Normal, + Runtime.MissedReminderAction.Skip); + + private static Task RegisterOrUpdateReminder( + bool remindable, + IGrainContext? grainContext, + string reminderName, + string? cronExpression, + string? cronTimeZoneId, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + { + ArgumentNullException.ThrowIfNull(grainContext, "grain"); + if (string.IsNullOrWhiteSpace(reminderName)) throw new ArgumentNullException(nameof(reminderName)); + if (string.IsNullOrWhiteSpace(cronExpression)) throw new ArgumentNullException(nameof(cronExpression)); + if (!remindable) + { + throw new InvalidOperationException( + $"Grain {grainContext.GrainId} is not '{typeof(IRemindable).FullName}'. A grain should implement {typeof(IRemindable).FullName} to use the advanced reminder service."); + } + + return grainContext.ActivationServices.GetRequiredService() + .RegisterOrUpdateReminder( + grainContext.GrainId, + reminderName, + ReminderSchedule.Cron(cronExpression, cronTimeZoneId), + priority, + action); + } + + private static string? GetCronTimeZoneId(TimeZoneInfo? timeZone) + => ReminderCronSchedule.NormalizeTimeZoneIdForStorage(timeZone); + + private static bool IsRemindable(object? grain) => grain is IRemindable; +} diff --git a/src/Orleans.AdvancedReminders/GrainReminderExtensions.cs b/src/Orleans.AdvancedReminders/GrainReminderExtensions.cs new file mode 100644 index 0000000000..7a271c1fce --- /dev/null +++ b/src/Orleans.AdvancedReminders/GrainReminderExtensions.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Runtime; +using Orleans.AdvancedReminders.Timers; + +#nullable enable +namespace Orleans.AdvancedReminders; + +/// +/// Extension methods for accessing reminders from a or implementation. +/// +public static class GrainReminderExtensions +{ + /// + /// Registers a persistent, reliable reminder to send regular notifications (reminders) to the grain. + /// The grain must implement the Orleans.AdvancedReminders.IRemindable interface, and reminders for this grain will be sent to the ReceiveReminder callback method. + /// If the current grain is deactivated when the timer fires, a new activation of this grain will be created to receive this reminder. + /// If an existing reminder with the same name already exists, that reminder will be overwritten with this new reminder. + /// Reminders will always be received by one activation of this grain, even if multiple activations exist for this grain. + /// + /// The grain instance. + /// Name of this reminder + /// Due time for this reminder + /// Frequency period for this reminder + /// Promise for Reminder handle. + public static Task RegisterOrUpdateReminder(this Grain grain, string reminderName, TimeSpan dueTime, TimeSpan period) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, dueTime, period); + + /// + /// Registers a persistent, reliable reminder to send regular notifications (reminders) to the grain. + /// The grain must implement the Orleans.AdvancedReminders.IRemindable interface, and reminders for this grain will be sent to the ReceiveReminder callback method. + /// If the current grain is deactivated when the timer fires, a new activation of this grain will be created to receive this reminder. + /// If an existing reminder with the same name already exists, that reminder will be overwritten with this new reminder. + /// Reminders will always be received by one activation of this grain, even if multiple activations exist for this grain. + /// + /// The grain instance. + /// Name of this reminder + /// Due time for this reminder + /// Frequency period for this reminder + /// Promise for Reminder handle. + public static Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, TimeSpan dueTime, TimeSpan period) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, dueTime, period); + + /// + /// Registers a persistent, reliable reminder to send regular notifications (reminders) to the grain using an absolute UTC due timestamp. + /// The grain must implement the Orleans.AdvancedReminders.IRemindable interface, and reminders for this grain will be sent to the ReceiveReminder callback method. + /// If the current grain is deactivated when the timer fires, a new activation of this grain will be created to receive this reminder. + /// If an existing reminder with the same name already exists, that reminder will be overwritten with this new reminder. + /// Reminders will always be received by one activation of this grain, even if multiple activations exist for this grain. + /// + /// The grain instance. + /// Name of this reminder + /// UTC timestamp for this reminder's first tick. + /// Frequency period for this reminder + /// Promise for Reminder handle. + public static Task RegisterOrUpdateReminder(this Grain grain, string reminderName, DateTime dueAtUtc, TimeSpan period) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, dueAtUtc, period); + + /// + /// Registers a persistent, reliable reminder to send regular notifications (reminders) to the grain using an absolute UTC due timestamp. + /// The grain must implement the Orleans.AdvancedReminders.IRemindable interface, and reminders for this grain will be sent to the ReceiveReminder callback method. + /// If the current grain is deactivated when the timer fires, a new activation of this grain will be created to receive this reminder. + /// If an existing reminder with the same name already exists, that reminder will be overwritten with this new reminder. + /// Reminders will always be received by one activation of this grain, even if multiple activations exist for this grain. + /// + /// The grain instance. + /// Name of this reminder + /// UTC timestamp for this reminder's first tick. + /// Frequency period for this reminder + /// Promise for Reminder handle. + public static Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, DateTime dueAtUtc, TimeSpan period) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, dueAtUtc, period); + + /// + /// Registers a persistent, reliable reminder using the provided schedule. + /// + /// The grain instance. + /// Name of this reminder. + /// Reminder schedule. + /// Promise for Reminder handle. + public static Task RegisterOrUpdateReminder(this Grain grain, string reminderName, ReminderSchedule schedule) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, schedule, Runtime.ReminderPriority.Normal, Runtime.MissedReminderAction.Skip); + + /// + /// Registers a persistent, reliable reminder using the provided schedule. + /// + /// The grain instance. + /// Name of this reminder. + /// Reminder schedule. + /// Promise for Reminder handle. + public static Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, ReminderSchedule schedule) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, schedule, Runtime.ReminderPriority.Normal, Runtime.MissedReminderAction.Skip); + + /// + /// Registers a persistent, reliable reminder to send regular notifications (reminders) to the grain with adaptive delivery options. + /// + /// The grain instance. + /// Name of this reminder. + /// Due time for this reminder. + /// Frequency period for this reminder. + /// Reminder priority. + /// Missed reminder action. + /// Promise for Reminder handle. + public static Task RegisterOrUpdateReminder( + this Grain grain, + string reminderName, + TimeSpan dueTime, + TimeSpan period, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, dueTime, period, priority, action); + + /// + /// Registers a persistent, reliable reminder to send regular notifications (reminders) to the grain with adaptive delivery options. + /// + /// The grain instance. + /// Name of this reminder. + /// Due time for this reminder. + /// Frequency period for this reminder. + /// Reminder priority. + /// Missed reminder action. + /// Promise for Reminder handle. + public static Task RegisterOrUpdateReminder( + this IGrainBase grain, + string reminderName, + TimeSpan dueTime, + TimeSpan period, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, dueTime, period, priority, action); + + /// + /// Registers a persistent, reliable reminder to send regular notifications (reminders) to the grain using an absolute UTC due timestamp with adaptive delivery options. + /// + /// The grain instance. + /// Name of this reminder. + /// UTC timestamp for this reminder's first tick. + /// Frequency period for this reminder. + /// Reminder priority. + /// Missed reminder action. + /// Promise for Reminder handle. + public static Task RegisterOrUpdateReminder( + this Grain grain, + string reminderName, + DateTime dueAtUtc, + TimeSpan period, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, dueAtUtc, period, priority, action); + + /// + /// Registers a persistent, reliable reminder to send regular notifications (reminders) to the grain using an absolute UTC due timestamp with adaptive delivery options. + /// + /// The grain instance. + /// Name of this reminder. + /// UTC timestamp for this reminder's first tick. + /// Frequency period for this reminder. + /// Reminder priority. + /// Missed reminder action. + /// Promise for Reminder handle. + public static Task RegisterOrUpdateReminder( + this IGrainBase grain, + string reminderName, + DateTime dueAtUtc, + TimeSpan period, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, dueAtUtc, period, priority, action); + + /// + /// Registers a persistent, reliable reminder using the provided schedule with adaptive delivery options. + /// + /// The grain instance. + /// Name of this reminder. + /// Reminder schedule. + /// Reminder priority. + /// Missed reminder action. + /// Promise for Reminder handle. + public static Task RegisterOrUpdateReminder( + this Grain grain, + string reminderName, + ReminderSchedule schedule, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, schedule, priority, action); + + /// + /// Registers a persistent, reliable reminder using the provided schedule with adaptive delivery options. + /// + /// The grain instance. + /// Name of this reminder. + /// Reminder schedule. + /// Reminder priority. + /// Missed reminder action. + /// Promise for Reminder handle. + public static Task RegisterOrUpdateReminder( + this IGrainBase grain, + string reminderName, + ReminderSchedule schedule, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(IsRemindable(grain), grain?.GrainContext, reminderName, schedule, priority, action); + + private static Task RegisterOrUpdateReminder(bool remindable, IGrainContext? grainContext, string reminderName, TimeSpan dueTime, TimeSpan period) + => RegisterOrUpdateReminder( + remindable, + grainContext, + reminderName, + ReminderSchedule.Interval(dueTime, period), + Runtime.ReminderPriority.Normal, + Runtime.MissedReminderAction.Skip); + + private static Task RegisterOrUpdateReminder(bool remindable, IGrainContext? grainContext, string reminderName, DateTime dueAtUtc, TimeSpan period) + => RegisterOrUpdateReminder( + remindable, + grainContext, + reminderName, + ReminderSchedule.Interval(dueAtUtc, period), + Runtime.ReminderPriority.Normal, + Runtime.MissedReminderAction.Skip); + + private static Task RegisterOrUpdateReminder( + bool remindable, + IGrainContext? grainContext, + string reminderName, + TimeSpan dueTime, + TimeSpan period, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder( + remindable, + grainContext, + reminderName, + ReminderSchedule.Interval(dueTime, period), + priority, + action); + + private static Task RegisterOrUpdateReminder( + bool remindable, + IGrainContext? grainContext, + string reminderName, + DateTime dueAtUtc, + TimeSpan period, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder( + remindable, + grainContext, + reminderName, + ReminderSchedule.Interval(dueAtUtc, period), + priority, + action); + + private static Task RegisterOrUpdateReminder( + bool remindable, + IGrainContext? grainContext, + string reminderName, + ReminderSchedule schedule, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + { + ArgumentNullException.ThrowIfNull(grainContext, "grain"); + ArgumentNullException.ThrowIfNull(schedule); + if (string.IsNullOrWhiteSpace(reminderName)) throw new ArgumentNullException(nameof(reminderName)); + if (!remindable) throw new InvalidOperationException($"Grain {grainContext.GrainId} is not '{typeof(IRemindable).FullName}'. A grain should implement {typeof(IRemindable).FullName} to use the advanced reminder service."); + + return GetReminderRegistry(grainContext).RegisterOrUpdateReminder(grainContext.GrainId, reminderName, schedule, priority, action); + } + + /// + /// Unregisters a previously registered reminder. + /// + /// The grain instance. + /// Reminder to unregister. + /// Completion promise for this operation. + public static Task UnregisterReminder(this Grain grain, IGrainReminder reminder) => UnregisterReminder(grain?.GrainContext, reminder); + + /// + /// Unregisters a previously registered reminder. + /// + /// The grain instance. + /// Reminder to unregister. + /// Completion promise for this operation. + public static Task UnregisterReminder(this IGrainBase grain, IGrainReminder reminder) => UnregisterReminder(grain?.GrainContext, reminder); + + private static Task UnregisterReminder(IGrainContext? grainContext, IGrainReminder reminder) + { + ArgumentNullException.ThrowIfNull(grainContext, "grain"); + return GetReminderRegistry(grainContext).UnregisterReminder(grainContext.GrainId, reminder); + } + + /// + /// Returns a previously registered reminder. + /// + /// The grain instance. + /// Reminder to return + /// Promise for Reminder handle. + public static Task GetReminder(this Grain grain, string reminderName) => GetReminder(grain?.GrainContext, reminderName); + + /// + /// Returns a previously registered reminder. + /// + /// A grain. + /// Reminder to return + /// Promise for Reminder handle. + public static Task GetReminder(this IGrainBase grain, string reminderName) => GetReminder(grain?.GrainContext, reminderName); + + private static Task GetReminder(IGrainContext? grainContext, string reminderName) + { + ArgumentNullException.ThrowIfNull(grainContext, "grain"); + if (string.IsNullOrWhiteSpace(reminderName)) throw new ArgumentNullException(nameof(reminderName)); + + return GetReminderRegistry(grainContext).GetReminder(grainContext.GrainId, reminderName); + } + + /// + /// Returns a list of all reminders registered by the grain. + /// + /// Promise for list of Reminders registered for this grain. + public static Task> GetReminders(this Grain grain) => GetReminders(grain?.GrainContext); + + /// + /// Returns a list of all reminders registered by the grain. + /// + /// Promise for list of Reminders registered for this grain. + public static Task> GetReminders(this IGrainBase grain) => GetReminders(grain?.GrainContext); + + private static Task> GetReminders(IGrainContext? grainContext) + { + ArgumentNullException.ThrowIfNull(grainContext, "grain"); + return GetReminderRegistry(grainContext).GetReminders(grainContext.GrainId); + } + + /// + /// Gets the . + /// + private static IReminderRegistry GetReminderRegistry(IGrainContext grainContext) + { + return grainContext.ActivationServices.GetRequiredService(); + } + + private static bool IsRemindable(object? grain) => grain is IRemindable; +} diff --git a/src/Orleans.AdvancedReminders/Hosting/AdvancedReminderJobBackendValidator.cs b/src/Orleans.AdvancedReminders/Hosting/AdvancedReminderJobBackendValidator.cs new file mode 100644 index 0000000000..198ed31b15 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Hosting/AdvancedReminderJobBackendValidator.cs @@ -0,0 +1,18 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Configuration; +using Orleans.DurableJobs; + +namespace Orleans.AdvancedReminders.Runtime.Hosting; + +internal sealed class AdvancedReminderJobBackendValidator(IServiceProvider serviceProvider) : IConfigurationValidator +{ + public void ValidateConfiguration() + { + if (serviceProvider.GetService() is null) + { + throw new OrleansConfigurationException( + "AdvancedReminders requires a durable jobs backend. Configure UseInMemoryDurableJobs() or a storage-backed durable jobs provider before starting the silo."); + } + } +} diff --git a/src/Orleans.AdvancedReminders/Hosting/MemoryReminderTableBuilder.cs b/src/Orleans.AdvancedReminders/Hosting/MemoryReminderTableBuilder.cs new file mode 100644 index 0000000000..f086003ac6 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Hosting/MemoryReminderTableBuilder.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Configuration; +using Orleans; +using Orleans.Hosting; +using Orleans.Providers; +using Orleans.AdvancedReminders.Runtime.Hosting.ProviderConfiguration; + +[assembly: RegisterProvider("Memory", "AdvancedReminders", "Silo", typeof(AdvancedMemoryReminderTableBuilder))] + +namespace Orleans.AdvancedReminders.Runtime.Hosting.ProviderConfiguration; + +internal sealed class AdvancedMemoryReminderTableBuilder : IProviderBuilder +{ + public void Configure(ISiloBuilder builder, string? name, IConfigurationSection configurationSection) + { + builder.UseInMemoryAdvancedReminderService(); + } +} diff --git a/src/Orleans.AdvancedReminders/Hosting/SiloBuilderReminderExtensions.cs b/src/Orleans.AdvancedReminders/Hosting/SiloBuilderReminderExtensions.cs new file mode 100644 index 0000000000..08a4728999 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Hosting/SiloBuilderReminderExtensions.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Orleans.Configuration.Internal; +using Orleans.DurableJobs; +using Orleans.AdvancedReminders; +using Orleans.AdvancedReminders.Runtime.Hosting; +using Orleans.AdvancedReminders.Runtime.ReminderService; +using Orleans.Runtime; + +namespace Orleans.Hosting; + +public static class SiloBuilderReminderExtensions +{ + public static ISiloBuilder AddAdvancedReminders(this ISiloBuilder builder) + => builder.ConfigureServices(static services => AddAdvancedReminders(services)); + + public static ISiloBuilder AddAdvancedReminders(this ISiloBuilder builder, Action configureOptions) + => builder.ConfigureServices(services => AddAdvancedReminders(services, configureOptions)); + + public static void AddAdvancedReminders(this IServiceCollection services) + { + if (services.Any(service => service.ServiceType == typeof(AdvancedReminderService))) + { + return; + } + + services.AddDurableJobs(); + services.TryAddSingleton(TimeProvider.System); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddFromExisting(); + services.AddFromExisting, AdvancedReminderService>(); + services.AddSingleton(); + services.AddSingleton(); + } + + public static void AddAdvancedReminders(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(configureOptions); + AddAdvancedReminders(services); + services.Configure(configureOptions); + } +} diff --git a/src/Orleans.AdvancedReminders/Hosting/SiloBuilderReminderMemoryExtensions.cs b/src/Orleans.AdvancedReminders/Hosting/SiloBuilderReminderMemoryExtensions.cs new file mode 100644 index 0000000000..8ab0622af0 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Hosting/SiloBuilderReminderMemoryExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; +using Orleans.Configuration.Internal; +using Orleans.AdvancedReminders; +using Orleans.AdvancedReminders.Runtime.ReminderService; +using Orleans.Runtime; + +namespace Orleans.Hosting; + +/// +/// Extensions to for configuring the in-memory advanced reminder provider. +/// +public static class SiloBuilderReminderMemoryExtensions +{ + /// + /// Configures advanced reminder storage using an in-memory, non-persistent store. + /// + public static ISiloBuilder UseInMemoryAdvancedReminderService(this ISiloBuilder builder) + { + builder.AddAdvancedReminders(); + builder.UseInMemoryDurableJobs(); + builder.ConfigureServices(static services => + { + services.AddSingleton(); + services.AddFromExisting(); + services.AddFromExisting, InMemoryReminderTable>(); + }); + return builder; + } +} diff --git a/src/Orleans.AdvancedReminders/IGrainReminder.cs b/src/Orleans.AdvancedReminders/IGrainReminder.cs new file mode 100644 index 0000000000..b571a9a9c1 --- /dev/null +++ b/src/Orleans.AdvancedReminders/IGrainReminder.cs @@ -0,0 +1,17 @@ +namespace Orleans.AdvancedReminders; + +/// +/// Handle for a persistent advanced reminder. +/// +public interface IGrainReminder +{ + string ReminderName { get; } + + string CronExpression { get; } + + string CronTimeZone { get; } + + Runtime.ReminderPriority Priority { get; } + + Runtime.MissedReminderAction Action { get; } +} diff --git a/src/Orleans.AdvancedReminders/IRemindable.cs b/src/Orleans.AdvancedReminders/IRemindable.cs new file mode 100644 index 0000000000..3f7f0c5119 --- /dev/null +++ b/src/Orleans.AdvancedReminders/IRemindable.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; + +namespace Orleans.AdvancedReminders; + +/// +/// Callback interface that grains must implement in order to register and receive advanced reminders. +/// +public interface IRemindable : IGrain +{ + /// + /// Receives a new advanced reminder tick. + /// + Task ReceiveReminder(string reminderName, Runtime.TickStatus status); +} diff --git a/src/Orleans.AdvancedReminders/Options/ReminderOptions.cs b/src/Orleans.AdvancedReminders/Options/ReminderOptions.cs new file mode 100644 index 0000000000..3788c87f88 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Options/ReminderOptions.cs @@ -0,0 +1,86 @@ +using System; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using Orleans.Runtime; + +namespace Orleans.AdvancedReminders; + +/// +/// Options for the reminder service. +/// +public sealed class ReminderOptions +{ + /// + /// Gets or sets the minimum period for reminders. + /// + /// + /// High-frequency reminders are dangerous for production systems. + /// + public TimeSpan MinimumReminderPeriod { get; set; } = TimeSpan.FromMinutes(ReminderOptionsDefaults.MinimumReminderPeriodMinutes); + + /// + /// Gets or sets the maximum amount of time to attempt to initialize reminders before giving up. + /// + /// Attempt to initialize for 5 minutes before giving up by default. + public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromMinutes(ReminderOptionsDefaults.InitializationTimeoutMinutes); + + /// + /// Gets or sets the grace period after a scheduled fire time before a reminder is considered missed. + /// + public TimeSpan MissedReminderGracePeriod { get; set; } = TimeSpan.FromSeconds(ReminderOptionsDefaults.MissedReminderGracePeriodSeconds); +} + +/// +/// Validator for . +/// +internal sealed partial class ReminderOptionsValidator : IConfigurationValidator +{ + private readonly ILogger logger; + private readonly IOptions options; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The logger. + /// + /// + /// The reminder options. + /// + public ReminderOptionsValidator(ILogger logger, IOptions reminderOptions) + { + this.logger = logger; + options = reminderOptions; + } + + /// + public void ValidateConfiguration() + { + if (options.Value.MinimumReminderPeriod < TimeSpan.Zero) + { + throw new OrleansConfigurationException($"{nameof(ReminderOptions)}.{nameof(ReminderOptions.MinimumReminderPeriod)} must not be less than {TimeSpan.Zero}"); + } + + if (options.Value.MinimumReminderPeriod.TotalMinutes < ReminderOptionsDefaults.MinimumReminderPeriodMinutes) + { + LogWarnFastReminderInterval(options.Value.MinimumReminderPeriod, ReminderOptionsDefaults.MinimumReminderPeriodMinutes); + } + + if (options.Value.InitializationTimeout <= TimeSpan.Zero) + { + throw new OrleansConfigurationException($"{nameof(ReminderOptions)}.{nameof(ReminderOptions.InitializationTimeout)} must be greater than {TimeSpan.Zero}"); + } + + if (options.Value.MissedReminderGracePeriod <= TimeSpan.Zero) + { + throw new OrleansConfigurationException($"{nameof(ReminderOptions)}.{nameof(ReminderOptions.MissedReminderGracePeriod)} must be greater than {TimeSpan.Zero}"); + } + } + + [LoggerMessage( + Level = LogLevel.Warning, + EventId = (int)RSErrorCode.RS_FastReminderInterval, + Message = $"{nameof(ReminderOptions)}.{nameof(ReminderOptions.MinimumReminderPeriod)} is {{MinimumReminderPeriod}} (default {{MinimumReminderPeriodMinutes}}). High-Frequency reminders are unsuitable for production use." + )] + private partial void LogWarnFastReminderInterval(TimeSpan minimumReminderPeriod, uint minimumReminderPeriodMinutes); +} diff --git a/src/Orleans.AdvancedReminders/Orleans.AdvancedReminders.csproj b/src/Orleans.AdvancedReminders/Orleans.AdvancedReminders.csproj new file mode 100644 index 0000000000..43164a63b2 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Orleans.AdvancedReminders.csproj @@ -0,0 +1,31 @@ + + + Microsoft.Orleans.AdvancedReminders + Microsoft Orleans Advanced Reminders + Advanced reminders for Microsoft Orleans with schedule-based APIs and job-backed delivery. + $(DefaultTargetFrameworks) + true + true + false + $(DefineConstants);ORLEANS_REMINDERS_PROVIDER + enable + + + + + + + + + + + + + + + + + + + + diff --git a/src/Orleans.AdvancedReminders/RegisterReminderAttribute.cs b/src/Orleans.AdvancedReminders/RegisterReminderAttribute.cs new file mode 100644 index 0000000000..f97e046320 --- /dev/null +++ b/src/Orleans.AdvancedReminders/RegisterReminderAttribute.cs @@ -0,0 +1,127 @@ +#nullable enable +using Orleans.Runtime; + +namespace Orleans.AdvancedReminders; + +/// +/// Registers reminders for a grain type on activation if missing. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class RegisterReminderAttribute : Attribute +{ + /// + /// Initializes a new interval-based reminder attribute. + /// + public RegisterReminderAttribute( + string name, + double dueSeconds, + double periodSeconds, + Runtime.ReminderPriority priority = Runtime.ReminderPriority.Normal, + Runtime.MissedReminderAction action = Runtime.MissedReminderAction.Skip) + { + ValidateName(name); + ValidatePriorityAndAction(priority, action); + ValidateNonNegativeFinite(dueSeconds, nameof(dueSeconds)); + ValidatePositiveFinite(periodSeconds, nameof(periodSeconds)); + + Name = name; + Due = TimeSpan.FromSeconds(dueSeconds); + Period = TimeSpan.FromSeconds(periodSeconds); + Priority = priority; + Action = action; + } + + /// + /// Initializes a new cron-based reminder attribute. + /// + public RegisterReminderAttribute( + string name, + string cron, + Runtime.ReminderPriority priority = Runtime.ReminderPriority.Normal, + Runtime.MissedReminderAction action = Runtime.MissedReminderAction.Skip) + { + ValidateName(name); + ValidatePriorityAndAction(priority, action); + ValidateCron(cron); + + Name = name; + Cron = cron; + Priority = priority; + Action = action; + } + + /// + /// Gets the reminder name. + /// + public string Name { get; } + + /// + /// Gets the interval due time. + /// + public TimeSpan? Due { get; } + + /// + /// Gets the interval period. + /// + public TimeSpan? Period { get; } + + /// + /// Gets the cron expression. + /// + public string? Cron { get; } + + /// + /// Gets the reminder priority. + /// + public Runtime.ReminderPriority Priority { get; } + + /// + /// Gets the missed reminder action. + /// + public Runtime.MissedReminderAction Action { get; } + + private static void ValidateName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Reminder name must be non-empty.", nameof(name)); + } + } + + private static void ValidateCron(string cron) + { + if (string.IsNullOrWhiteSpace(cron)) + { + throw new ArgumentException("Cron expression must be non-empty.", nameof(cron)); + } + } + + private static void ValidatePriorityAndAction(Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) + { + if (!Enum.IsDefined(priority)) + { + throw new ArgumentOutOfRangeException(nameof(priority), priority, "Invalid reminder priority."); + } + + if (!Enum.IsDefined(action)) + { + throw new ArgumentOutOfRangeException(nameof(action), action, "Invalid missed reminder action."); + } + } + + private static void ValidateNonNegativeFinite(double value, string argumentName) + { + if (double.IsNaN(value) || double.IsInfinity(value) || value < 0) + { + throw new ArgumentOutOfRangeException(argumentName); + } + } + + private static void ValidatePositiveFinite(double value, string argumentName) + { + if (double.IsNaN(value) || double.IsInfinity(value) || value <= 0) + { + throw new ArgumentOutOfRangeException(argumentName); + } + } +} diff --git a/src/Orleans.AdvancedReminders/ReminderCronRegistrationExtensions.cs b/src/Orleans.AdvancedReminders/ReminderCronRegistrationExtensions.cs new file mode 100644 index 0000000000..2e2b72f0a0 --- /dev/null +++ b/src/Orleans.AdvancedReminders/ReminderCronRegistrationExtensions.cs @@ -0,0 +1,411 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using Orleans.AdvancedReminders.Cron.Internal; +using Orleans.Runtime; +using Orleans.AdvancedReminders.Timers; + +namespace Orleans.AdvancedReminders; + +/// +/// Convenience overloads for cron registration APIs using typed cron objects. +/// +public static class ReminderCronRegistrationExtensions +{ + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + string cronExpression) + => RegisterCronReminder(registry, callingGrainId, reminderName, cronExpression, cronTimeZoneId: null, Runtime.ReminderPriority.Normal, Runtime.MissedReminderAction.Skip); + + /// + /// Registers or updates a cron reminder via using a typed cron expression. + /// + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + ReminderCronExpression cronExpression) + => RegisterOrUpdateReminder(registry, callingGrainId, reminderName, cronExpression, timeZone: null); + + /// + /// Registers or updates a cron reminder via using a typed cron expression. + /// + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + ReminderCronExpression cronExpression, + TimeZoneInfo? timeZone) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(cronExpression); + return RegisterCronReminder( + registry, + callingGrainId, + reminderName, + cronExpression.ToExpressionString(), + NormalizeTimeZoneId(timeZone), + Runtime.ReminderPriority.Normal, + Runtime.MissedReminderAction.Skip); + } + + /// + /// Registers or updates a cron reminder via using a cron builder. + /// + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + ReminderCronBuilder cronBuilder) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(cronBuilder); + return RegisterOrUpdateReminder( + registry, + callingGrainId, + reminderName, + cronBuilder.ToExpressionString(), + cronBuilder.TimeZone); + } + + /// + /// Registers or updates a cron reminder via using an expression and time zone. + /// + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + string cronExpression, + TimeZoneInfo? timeZone) + { + ArgumentNullException.ThrowIfNull(registry); + return RegisterCronReminder( + registry, + callingGrainId, + reminderName, + cronExpression, + NormalizeTimeZoneId(timeZone), + Runtime.ReminderPriority.Normal, + Runtime.MissedReminderAction.Skip); + } + + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + string cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterCronReminder(registry, callingGrainId, reminderName, cronExpression, cronTimeZoneId: null, priority, action); + + /// + /// Registers or updates a cron reminder via using a typed cron expression. + /// + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + ReminderCronExpression cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(registry, callingGrainId, reminderName, cronExpression, priority, action, timeZone: null); + + /// + /// Registers or updates a cron reminder via using a typed cron expression. + /// + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + ReminderCronExpression cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action, + TimeZoneInfo? timeZone) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(cronExpression); + return registry.RegisterOrUpdateReminder( + callingGrainId, + reminderName, + cronExpression.ToExpressionString(), + priority, + action, + NormalizeTimeZoneId(timeZone)); + } + + /// + /// Registers or updates a cron reminder via using a cron builder. + /// + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + ReminderCronBuilder cronBuilder, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(cronBuilder); + return RegisterOrUpdateReminder( + registry, + callingGrainId, + reminderName, + cronBuilder.ToExpressionString(), + cronBuilder.TimeZone, + priority, + action); + } + + /// + /// Registers or updates a cron reminder via using an expression and time zone. + /// + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + string cronExpression, + TimeZoneInfo? timeZone, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + { + ArgumentNullException.ThrowIfNull(registry); + return RegisterCronReminder( + registry, + callingGrainId, + reminderName, + cronExpression, + NormalizeTimeZoneId(timeZone), + priority, + action); + } + + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + string cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action, + string? cronTimeZoneId) + => RegisterCronReminder(registry, callingGrainId, reminderName, cronExpression, cronTimeZoneId, priority, action); + + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + string cronExpression) + => RegisterCronReminder(service, grainId, reminderName, cronExpression, cronTimeZoneId: null, Runtime.ReminderPriority.Normal, Runtime.MissedReminderAction.Skip); + + /// + /// Registers or updates a cron reminder via using a typed cron expression. + /// + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + ReminderCronExpression cronExpression) + => RegisterOrUpdateReminder(service, grainId, reminderName, cronExpression, timeZone: null); + + /// + /// Registers or updates a cron reminder via using a typed cron expression. + /// + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + ReminderCronExpression cronExpression, + TimeZoneInfo? timeZone) + { + ArgumentNullException.ThrowIfNull(service); + ArgumentNullException.ThrowIfNull(cronExpression); + return RegisterCronReminder( + service, + grainId, + reminderName, + cronExpression.ToExpressionString(), + NormalizeTimeZoneId(timeZone), + Runtime.ReminderPriority.Normal, + Runtime.MissedReminderAction.Skip); + } + + /// + /// Registers or updates a cron reminder via using a cron builder. + /// + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + ReminderCronBuilder cronBuilder) + { + ArgumentNullException.ThrowIfNull(service); + ArgumentNullException.ThrowIfNull(cronBuilder); + return RegisterOrUpdateReminder( + service, + grainId, + reminderName, + cronBuilder.ToExpressionString(), + cronBuilder.TimeZone); + } + + /// + /// Registers or updates a cron reminder via using an expression and time zone. + /// + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + string cronExpression, + TimeZoneInfo? timeZone) + { + ArgumentNullException.ThrowIfNull(service); + return RegisterCronReminder( + service, + grainId, + reminderName, + cronExpression, + NormalizeTimeZoneId(timeZone), + Runtime.ReminderPriority.Normal, + Runtime.MissedReminderAction.Skip); + } + + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + string cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterCronReminder(service, grainId, reminderName, cronExpression, cronTimeZoneId: null, priority, action); + + /// + /// Registers or updates a cron reminder via using a typed cron expression. + /// + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + ReminderCronExpression cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => RegisterOrUpdateReminder(service, grainId, reminderName, cronExpression, priority, action, timeZone: null); + + /// + /// Registers or updates a cron reminder via using a typed cron expression. + /// + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + ReminderCronExpression cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action, + TimeZoneInfo? timeZone) + { + ArgumentNullException.ThrowIfNull(service); + ArgumentNullException.ThrowIfNull(cronExpression); + return service.RegisterOrUpdateReminder( + grainId, + reminderName, + cronExpression.ToExpressionString(), + priority, + action, + NormalizeTimeZoneId(timeZone)); + } + + /// + /// Registers or updates a cron reminder via using a cron builder. + /// + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + ReminderCronBuilder cronBuilder, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + { + ArgumentNullException.ThrowIfNull(service); + ArgumentNullException.ThrowIfNull(cronBuilder); + return RegisterOrUpdateReminder( + service, + grainId, + reminderName, + cronBuilder.ToExpressionString(), + cronBuilder.TimeZone, + priority, + action); + } + + /// + /// Registers or updates a cron reminder via using an expression and time zone. + /// + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + string cronExpression, + TimeZoneInfo? timeZone, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + { + ArgumentNullException.ThrowIfNull(service); + return RegisterCronReminder( + service, + grainId, + reminderName, + cronExpression, + NormalizeTimeZoneId(timeZone), + priority, + action); + } + + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + string cronExpression, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action, + string? cronTimeZoneId) + => RegisterCronReminder(service, grainId, reminderName, cronExpression, cronTimeZoneId, priority, action); + + private static string? NormalizeTimeZoneId(TimeZoneInfo? timeZone) + => ReminderCronSchedule.NormalizeTimeZoneIdForStorage(timeZone); + + private static Task RegisterCronReminder( + IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + string cronExpression, + string? cronTimeZoneId, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + { + ArgumentNullException.ThrowIfNull(registry); + return registry.RegisterOrUpdateReminder( + callingGrainId, + reminderName, + ReminderSchedule.Cron(cronExpression, cronTimeZoneId), + priority, + action); + } + + private static Task RegisterCronReminder( + IReminderService service, + GrainId grainId, + string reminderName, + string cronExpression, + string? cronTimeZoneId, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + { + ArgumentNullException.ThrowIfNull(service); + return service.RegisterOrUpdateReminder( + grainId, + reminderName, + ReminderSchedule.Cron(cronExpression, cronTimeZoneId), + priority, + action); + } +} diff --git a/src/Orleans.AdvancedReminders/ReminderRegistrationExtensions.cs b/src/Orleans.AdvancedReminders/ReminderRegistrationExtensions.cs new file mode 100644 index 0000000000..c182b58804 --- /dev/null +++ b/src/Orleans.AdvancedReminders/ReminderRegistrationExtensions.cs @@ -0,0 +1,85 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using Orleans.AdvancedReminders.Timers; +using Orleans.Runtime; + +namespace Orleans.AdvancedReminders; + +/// +/// Convenience overloads for interval registration APIs built on top of . +/// +public static class ReminderRegistrationExtensions +{ + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + TimeSpan dueTime, + TimeSpan period) + => registry.RegisterOrUpdateReminder(callingGrainId, reminderName, ReminderSchedule.Interval(dueTime, period), Runtime.ReminderPriority.Normal, Runtime.MissedReminderAction.Skip); + + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + DateTime dueAtUtc, + TimeSpan period) + => registry.RegisterOrUpdateReminder(callingGrainId, reminderName, ReminderSchedule.Interval(dueAtUtc, period), Runtime.ReminderPriority.Normal, Runtime.MissedReminderAction.Skip); + + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + TimeSpan dueTime, + TimeSpan period, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => registry.RegisterOrUpdateReminder(callingGrainId, reminderName, ReminderSchedule.Interval(dueTime, period), priority, action); + + public static Task RegisterOrUpdateReminder( + this IReminderRegistry registry, + GrainId callingGrainId, + string reminderName, + DateTime dueAtUtc, + TimeSpan period, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => registry.RegisterOrUpdateReminder(callingGrainId, reminderName, ReminderSchedule.Interval(dueAtUtc, period), priority, action); + + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + TimeSpan dueTime, + TimeSpan period) + => service.RegisterOrUpdateReminder(grainId, reminderName, ReminderSchedule.Interval(dueTime, period), Runtime.ReminderPriority.Normal, Runtime.MissedReminderAction.Skip); + + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + DateTime dueAtUtc, + TimeSpan period) + => service.RegisterOrUpdateReminder(grainId, reminderName, ReminderSchedule.Interval(dueAtUtc, period), Runtime.ReminderPriority.Normal, Runtime.MissedReminderAction.Skip); + + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + TimeSpan dueTime, + TimeSpan period, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => service.RegisterOrUpdateReminder(grainId, reminderName, ReminderSchedule.Interval(dueTime, period), priority, action); + + public static Task RegisterOrUpdateReminder( + this IReminderService service, + GrainId grainId, + string reminderName, + DateTime dueAtUtc, + TimeSpan period, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + => service.RegisterOrUpdateReminder(grainId, reminderName, ReminderSchedule.Interval(dueAtUtc, period), priority, action); +} diff --git a/src/Orleans.AdvancedReminders/ReminderSchedule.cs b/src/Orleans.AdvancedReminders/ReminderSchedule.cs new file mode 100644 index 0000000000..41d2006166 --- /dev/null +++ b/src/Orleans.AdvancedReminders/ReminderSchedule.cs @@ -0,0 +1,48 @@ +using System; + +namespace Orleans.AdvancedReminders; + +/// +/// Describes the schedule of an advanced reminder. +/// +public sealed class ReminderSchedule +{ + private ReminderSchedule( + Runtime.ReminderScheduleKind kind, + TimeSpan? dueTime, + DateTime? dueAtUtc, + TimeSpan? period, + string? cronExpression, + string? cronTimeZoneId) + { + Kind = kind; + DueTime = dueTime; + DueAtUtc = dueAtUtc; + Period = period; + CronExpression = cronExpression; + CronTimeZoneId = cronTimeZoneId; + } + + public Runtime.ReminderScheduleKind Kind { get; } + + public TimeSpan? DueTime { get; } + + public DateTime? DueAtUtc { get; } + + public TimeSpan? Period { get; } + + public string? CronExpression { get; } + + public string? CronTimeZoneId { get; } + + public bool UsesAbsoluteDueTime => DueAtUtc.HasValue; + + public static ReminderSchedule Interval(TimeSpan dueTime, TimeSpan period) + => new(Runtime.ReminderScheduleKind.Interval, dueTime, null, period, null, null); + + public static ReminderSchedule Interval(DateTime dueAtUtc, TimeSpan period) + => new(Runtime.ReminderScheduleKind.Interval, null, dueAtUtc, period, null, null); + + public static ReminderSchedule Cron(string cronExpression, string? cronTimeZoneId = null) + => new(Runtime.ReminderScheduleKind.Cron, null, null, null, cronExpression, cronTimeZoneId); +} diff --git a/src/Orleans.AdvancedReminders/ReminderService/AdvancedReminderDispatcherGrain.cs b/src/Orleans.AdvancedReminders/ReminderService/AdvancedReminderDispatcherGrain.cs new file mode 100644 index 0000000000..96fdbd2b74 --- /dev/null +++ b/src/Orleans.AdvancedReminders/ReminderService/AdvancedReminderDispatcherGrain.cs @@ -0,0 +1,25 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Orleans.DurableJobs; + +namespace Orleans.AdvancedReminders.Runtime.ReminderService; + +internal interface IAdvancedReminderDispatcherGrain : IGrainWithStringKey, IDurableJobHandler +{ +} + +internal sealed class AdvancedReminderDispatcherGrain(IReminderService reminderService) : Grain, IAdvancedReminderDispatcherGrain +{ + private readonly IReminderService _reminderService = reminderService; + + public async Task ExecuteJobAsync(IJobRunContext context, CancellationToken cancellationToken) + { + if (!AdvancedReminderService.TryGetReminderMetadata(context.Job.Metadata, out var grainId, out var reminderName, out var eTag)) + { + return; + } + + await _reminderService.ProcessDueReminderAsync(grainId, reminderName, eTag, cancellationToken); + } +} diff --git a/src/Orleans.AdvancedReminders/ReminderService/AdvancedReminderService.cs b/src/Orleans.AdvancedReminders/ReminderService/AdvancedReminderService.cs new file mode 100644 index 0000000000..c846e719d0 --- /dev/null +++ b/src/Orleans.AdvancedReminders/ReminderService/AdvancedReminderService.cs @@ -0,0 +1,362 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.DurableJobs; +using Orleans.AdvancedReminders.Cron.Internal; +using Orleans.Runtime; + +namespace Orleans.AdvancedReminders.Runtime.ReminderService; + +internal sealed class AdvancedReminderService : IReminderService, ILifecycleParticipant +{ + private const string GrainIdMetadataKey = "grain-id"; + private const string ReminderNameMetadataKey = "reminder-name"; + private const string ETagMetadataKey = "etag"; + private const string JobNamePrefix = "advanced-reminder:"; + + private readonly IReminderTable _reminderTable; + private readonly ILocalDurableJobManager _jobManager; + private readonly JobShardManager _jobShardManager; + private readonly IGrainFactory _grainFactory; + private readonly ILogger _logger; + private readonly ReminderOptions _options; + private readonly TimeProvider _timeProvider; + + public AdvancedReminderService( + IReminderTable reminderTable, + ILocalDurableJobManager jobManager, + JobShardManager jobShardManager, + IGrainFactory grainFactory, + IOptions options, + ILogger logger, + TimeProvider timeProvider) + { + _reminderTable = reminderTable; + _jobManager = jobManager; + _jobShardManager = jobShardManager; + _grainFactory = grainFactory; + _logger = logger; + _options = options.Value; + _timeProvider = timeProvider; + } + + public void Participate(ISiloLifecycle lifecycle) + { + lifecycle.Subscribe( + nameof(AdvancedReminderService), + ServiceLifecycleStage.ApplicationServices, + StartAsync, + StopAsync); + } + + public async Task RegisterOrUpdateReminder( + GrainId grainId, + string reminderName, + ReminderSchedule schedule, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + { + ArgumentNullException.ThrowIfNull(schedule); + + ReminderEntry entry = schedule.Kind switch + { + Runtime.ReminderScheduleKind.Interval => CreateIntervalEntry(grainId, reminderName, schedule, priority, action), + Runtime.ReminderScheduleKind.Cron => CreateCronEntry(grainId, reminderName, schedule, priority, action), + _ => throw new ArgumentOutOfRangeException(nameof(schedule), schedule.Kind, "Unsupported reminder schedule kind."), + }; + return await UpsertAndScheduleAsync(entry, CancellationToken.None); + } + + public async Task UnregisterReminder(IGrainReminder reminder) + { + if (reminder is not ReminderData data) + { + throw new ArgumentException("Reminder handle was not created by Orleans.AdvancedReminders.", nameof(reminder)); + } + + if (await _reminderTable.RemoveRow(data.GrainId, data.ReminderName, data.ETag)) + { + return; + } + + var latest = await _reminderTable.ReadRow(data.GrainId, data.ReminderName); + if (latest is null) + { + return; + } + + if (!await _reminderTable.RemoveRow(data.GrainId, data.ReminderName, latest.ETag)) + { + throw new Runtime.ReminderException($"Could not unregister reminder {reminder} due to ETag mismatch."); + } + } + + public async Task GetReminder(GrainId grainId, string reminderName) + => (await _reminderTable.ReadRow(grainId, reminderName))?.ToIGrainReminder(); + + public async Task> GetReminders(GrainId grainId) + { + var data = await _reminderTable.ReadRows(grainId); + var result = new List(data.Reminders.Count); + foreach (var entry in data.Reminders) + { + result.Add(entry.ToIGrainReminder()); + } + + return result; + } + + public async Task ProcessDueReminderAsync(GrainId grainId, string reminderName, string? expectedETag, CancellationToken cancellationToken) + { + var entry = await _reminderTable.ReadRow(grainId, reminderName); + if (entry is null) + { + return; + } + + var currentETag = entry.ETag; + if (!string.IsNullOrEmpty(expectedETag) && !string.Equals(currentETag, expectedETag, StringComparison.Ordinal)) + { + return; + } + + var now = GetUtcNow(); + var due = entry.NextDueUtc ?? entry.StartAt; + var overdueBy = now > due ? now - due : TimeSpan.Zero; + var isMissed = overdueBy > _options.MissedReminderGracePeriod; + + var shouldFire = true; + if (isMissed && entry.Action != Runtime.MissedReminderAction.FireImmediately) + { + shouldFire = false; + if (entry.Action == Runtime.MissedReminderAction.Notify) + { + _logger.LogWarning( + "Reminder {ReminderName} for grain {GrainId} missed due window at {Due}. Current time {Now}.", + reminderName, + grainId, + due, + now); + } + } + + if (shouldFire) + { + var remindable = _grainFactory.GetGrain(grainId); + var status = new Runtime.TickStatus( + entry.StartAt, + string.IsNullOrWhiteSpace(entry.CronExpression) ? entry.Period : TimeSpan.Zero, + now); + await remindable.ReceiveReminder(entry.ReminderName, status); + entry.LastFireUtc = now; + } + + if (!await IsCurrentEntryAsync(entry.GrainId, entry.ReminderName, currentETag)) + { + return; + } + + var nextDue = CalculateNextDue(entry, now); + if (nextDue is null) + { + if (!string.IsNullOrEmpty(currentETag)) + { + await _reminderTable.RemoveRow(entry.GrainId, entry.ReminderName, currentETag); + } + + return; + } + + entry.NextDueUtc = nextDue; + entry.ETag = await _reminderTable.UpsertRow(entry); + await ScheduleReminderAsync(entry, cancellationToken); + } + + private async Task UpsertAndScheduleAsync(ReminderEntry entry, CancellationToken cancellationToken) + { + await UpsertAndScheduleEntryAsync(entry, cancellationToken); + return entry.ToIGrainReminder(); + } + + internal async Task UpsertAndScheduleEntryAsync(ReminderEntry entry, CancellationToken cancellationToken) + { + entry.ETag = await _reminderTable.UpsertRow(entry); + if (entry.NextDueUtc is not null) + { + await ScheduleReminderAsync(entry, cancellationToken); + } + + return entry.ETag; + } + + private async Task ScheduleReminderAsync(ReminderEntry entry, CancellationToken cancellationToken) + { + var due = entry.NextDueUtc ?? entry.StartAt; + var now = GetUtcNow(); + var dueTime = new DateTimeOffset(due <= now ? now.AddMilliseconds(1) : due, TimeSpan.Zero); + var grainIdText = entry.GrainId.ToString(); + var dispatcher = _grainFactory.GetGrain(grainIdText); + await _jobManager.ScheduleJobAsync( + new ScheduleJobRequest + { + Target = dispatcher.GetGrainId(), + JobName = string.Concat(JobNamePrefix, entry.ReminderName), + DueTime = dueTime, + Metadata = new Dictionary(capacity: 3, comparer: StringComparer.Ordinal) + { + [GrainIdMetadataKey] = grainIdText, + [ReminderNameMetadataKey] = entry.ReminderName, + [ETagMetadataKey] = entry.ETag, + }, + }, + cancellationToken); + } + + private ReminderEntry CreateIntervalEntry( + GrainId grainId, + string reminderName, + ReminderSchedule schedule, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + { + if (schedule.Period is not { } period) + { + throw new ArgumentException("Interval reminder schedule must define a period.", nameof(schedule)); + } + + var dueAtUtc = schedule.DueAtUtc ?? GetUtcNow().Add(schedule.DueTime ?? throw new ArgumentException("Interval reminder schedule must define dueTime or dueAtUtc.", nameof(schedule))); + return new ReminderEntry + { + GrainId = grainId, + ReminderName = reminderName, + StartAt = dueAtUtc, + Period = period, + Priority = priority, + Action = action, + NextDueUtc = dueAtUtc, + LastFireUtc = null, + }; + } + + private ReminderEntry CreateCronEntry( + GrainId grainId, + string reminderName, + ReminderSchedule schedule, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + { + var cronExpression = schedule.CronExpression ?? throw new ArgumentException("Cron reminder schedule must define a cron expression.", nameof(schedule)); + var cronSchedule = ReminderCronSchedule.Parse(cronExpression, schedule.CronTimeZoneId); + var now = GetUtcNow(); + var nextDue = cronSchedule.GetNextOccurrence(now, inclusive: true) + ?? throw new Runtime.ReminderException($"Reminder '{reminderName}' has no future cron occurrences."); + + return new ReminderEntry + { + GrainId = grainId, + ReminderName = reminderName, + StartAt = nextDue, + Period = TimeSpan.Zero, + CronExpression = cronSchedule.Expression.ToExpressionString(), + CronTimeZoneId = cronSchedule.TimeZoneId ?? string.Empty, + Priority = priority, + Action = action, + NextDueUtc = nextDue, + LastFireUtc = null, + }; + } + + private static DateTime? CalculateNextDue(ReminderEntry entry, DateTime now) + { + if (!string.IsNullOrWhiteSpace(entry.CronExpression)) + { + var cronSchedule = ReminderCronSchedule.Parse(entry.CronExpression, entry.CronTimeZoneId); + return cronSchedule.GetNextOccurrence(now); + } + + if (entry.Period <= TimeSpan.Zero) + { + return null; + } + + var next = entry.NextDueUtc ?? entry.StartAt; + if (next <= now) + { + var ticksBehind = now.Ticks - next.Ticks; + var periodsBehind = ticksBehind / entry.Period.Ticks + 1; + next = next.AddTicks(periodsBehind * entry.Period.Ticks); + } + + return next; + } + + private async Task IsCurrentEntryAsync(GrainId grainId, string reminderName, string? expectedETag) + { + if (string.IsNullOrEmpty(expectedETag)) + { + return true; + } + + var latest = await _reminderTable.ReadRow(grainId, reminderName); + return latest is not null && string.Equals(latest.ETag, expectedETag, StringComparison.Ordinal); + } + + private async Task StartAsync(CancellationToken cancellationToken) + { + await _reminderTable.StartAsync(cancellationToken); + if (!UsesInMemoryDurableJobs()) + { + return; + } + + var all = await _reminderTable.ReadRows(0, 0); + await Parallel.ForEachAsync( + all.Reminders, + new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, 8), + }, + async (entry, ct) => + { + if (entry.NextDueUtc is null && entry.Period <= TimeSpan.Zero && string.IsNullOrWhiteSpace(entry.CronExpression)) + { + return; + } + + await ScheduleReminderAsync(entry, ct); + }); + } + + private Task StopAsync(CancellationToken cancellationToken) => _reminderTable.StopAsync(cancellationToken); + + private DateTime GetUtcNow() => _timeProvider.GetUtcNow().UtcDateTime; + + private bool UsesInMemoryDurableJobs() => string.Equals(_jobShardManager.GetType().Name, "InMemoryJobShardManager", StringComparison.Ordinal); + + internal static bool TryGetReminderMetadata( + IReadOnlyDictionary? metadata, + out GrainId grainId, + out string reminderName, + out string? eTag) + { + grainId = default; + reminderName = string.Empty; + eTag = null; + + if (metadata is null + || !metadata.TryGetValue(GrainIdMetadataKey, out var grainIdText) + || !metadata.TryGetValue(ReminderNameMetadataKey, out var rawReminderName)) + { + return false; + } + + reminderName = rawReminderName; + grainId = GrainId.Parse(grainIdText); + metadata.TryGetValue(ETagMetadataKey, out eTag); + return !string.IsNullOrWhiteSpace(reminderName); + } +} diff --git a/src/Orleans.AdvancedReminders/ReminderService/InMemoryReminderTable.cs b/src/Orleans.AdvancedReminders/ReminderService/InMemoryReminderTable.cs new file mode 100644 index 0000000000..1e6f330c4e --- /dev/null +++ b/src/Orleans.AdvancedReminders/ReminderService/InMemoryReminderTable.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Orleans.AdvancedReminders.Runtime.ReminderService +{ + internal sealed class InMemoryReminderTable : IReminderTable, ILifecycleParticipant + { + internal const long ReminderTableGrainId = 12345; + private readonly IReminderTableGrain reminderTableGrain; + private bool isAvailable; + + public InMemoryReminderTable(IGrainFactory grainFactory) + { + this.reminderTableGrain = grainFactory.GetGrain(ReminderTableGrainId); + } + + public Task Init() => Task.CompletedTask; + + public Task ReadRow(GrainId grainId, string reminderName) + { + this.ThrowIfNotAvailable(); + return this.reminderTableGrain.ReadRow(grainId, reminderName); + } + + public Task ReadRows(GrainId grainId) + { + this.ThrowIfNotAvailable(); + return this.reminderTableGrain.ReadRows(grainId); + } + + public Task ReadRows(uint begin, uint end) + { + return this.isAvailable ? this.reminderTableGrain.ReadRows(begin, end) : Task.FromResult(new ReminderTableData()); + } + + public Task RemoveRow(GrainId grainId, string reminderName, string eTag) + { + this.ThrowIfNotAvailable(); + return this.reminderTableGrain.RemoveRow(grainId, reminderName, eTag); + } + + public Task TestOnlyClearTable() + { + this.ThrowIfNotAvailable(); + return this.reminderTableGrain.TestOnlyClearTable(); + } + + public Task UpsertRow(ReminderEntry entry) + { + this.ThrowIfNotAvailable(); + return this.reminderTableGrain.UpsertRow(entry); + } + + private void ThrowIfNotAvailable() + { + if (!this.isAvailable) throw new InvalidOperationException("The reminder service is not currently available."); + } + + void ILifecycleParticipant.Participate(ISiloLifecycle lifecycle) + { + Task OnApplicationServicesStart(CancellationToken ct) + { + this.isAvailable = true; + return Task.CompletedTask; + } + + Task OnApplicationServicesStop(CancellationToken ct) + { + this.isAvailable = false; + return Task.CompletedTask; + } + + lifecycle.Subscribe( + nameof(InMemoryReminderTable), + ServiceLifecycleStage.ApplicationServices, + OnApplicationServicesStart, + OnApplicationServicesStop); + } + } +} diff --git a/src/Orleans.AdvancedReminders/ReminderService/RegisterReminderActivationConfiguratorProvider.cs b/src/Orleans.AdvancedReminders/ReminderService/RegisterReminderActivationConfiguratorProvider.cs new file mode 100644 index 0000000000..4ddfd47fda --- /dev/null +++ b/src/Orleans.AdvancedReminders/ReminderService/RegisterReminderActivationConfiguratorProvider.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Orleans.Metadata; +using Orleans.Runtime; + +#nullable enable +namespace Orleans.AdvancedReminders.Runtime.ReminderService; + +internal sealed class RegisterReminderActivationConfiguratorProvider : IConfigureGrainContextProvider +{ + private readonly ConcurrentDictionary cache = new(); + private readonly Func grainTypeResolver; + private readonly ILoggerFactory loggerFactory; + + public RegisterReminderActivationConfiguratorProvider(GrainClassMap grainClassMap, ILoggerFactory loggerFactory) + : this( + loggerFactory, + grainType => grainClassMap.TryGetGrainClass(grainType, out var grainClass) ? grainClass : null) + { + } + + internal RegisterReminderActivationConfiguratorProvider( + ILoggerFactory loggerFactory, + Func grainTypeResolver) + { + this.loggerFactory = loggerFactory; + this.grainTypeResolver = grainTypeResolver; + } + + public bool TryGetConfigurator(GrainType grainType, GrainProperties properties, out IConfigureGrainContext configurator) + { + var result = cache.GetOrAdd(grainType, ResolveConfigurator); + if (result is not null) + { + configurator = result; + return true; + } + + configurator = default!; + return false; + } + + private RegisterReminderActivationConfigurator? ResolveConfigurator(GrainType grainType) + { + var grainClass = grainTypeResolver(grainType); + if (grainClass is null) + { + return null; + } + + var registrations = grainClass + .GetCustomAttributes(typeof(RegisterReminderAttribute), inherit: false) + .Cast() + .ToArray(); + if (registrations.Length == 0) + { + return null; + } + + var logger = loggerFactory.CreateLogger(); + if (!typeof(global::Orleans.AdvancedReminders.IRemindable).IsAssignableFrom(grainClass)) + { + logger.LogWarning( + "Ignoring [RegisterReminder] declarations for grain type {GrainType} because it does not implement {Remindable}.", + grainClass.FullName, + typeof(global::Orleans.AdvancedReminders.IRemindable).FullName); + return null; + } + + return new RegisterReminderActivationConfigurator(registrations, logger); + } + + private sealed class RegisterReminderActivationConfigurator( + RegisterReminderAttribute[] registrations, + ILogger logger) : IConfigureGrainContext + { + public void Configure(IGrainContext context) + { + try + { + context.ObservableLifecycle.Subscribe( + observerName: nameof(RegisterReminderAttribute), + stage: GrainLifecycleStage.Activate, + observer: new RegisterReminderActivationLifecycleObserver(context, registrations, logger)); + } + catch (NotSupportedException) + { + logger.LogWarning( + "Skipping [RegisterReminder] for grain {GrainId}: lifecycle hooks are not supported for this grain context.", + context.GrainId); + } + catch (NotImplementedException) + { + logger.LogWarning( + "Skipping [RegisterReminder] for grain {GrainId}: lifecycle hooks are not implemented for this grain context.", + context.GrainId); + } + } + } + + private sealed class RegisterReminderActivationLifecycleObserver( + IGrainContext grainContext, + RegisterReminderAttribute[] registrations, + ILogger logger) : ILifecycleObserver + { + public async Task OnStart(CancellationToken cancellationToken = default) + { + var reminderService = grainContext.ActivationServices.GetService(); + if (reminderService is null) + { + logger.LogWarning( + "Skipping [RegisterReminder] activation registration for grain {GrainId} because {Service} is not configured.", + grainContext.GrainId, + nameof(IReminderService)); + return; + } + + foreach (var registration in registrations) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var existingReminder = await reminderService.GetReminder(grainContext.GrainId, registration.Name); + if (existingReminder is not null) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(registration.Cron)) + { + await reminderService.RegisterOrUpdateReminder( + grainContext.GrainId, + registration.Name, + ReminderSchedule.Cron(registration.Cron), + registration.Priority, + registration.Action); + } + else if (registration.Due is { } due && registration.Period is { } period) + { + await reminderService.RegisterOrUpdateReminder( + grainContext.GrainId, + registration.Name, + ReminderSchedule.Interval(due, period), + registration.Priority, + registration.Action); + } + else + { + logger.LogWarning( + "Skipping [RegisterReminder] for grain {GrainId}, reminder {ReminderName}: missing cron or due/period.", + grainContext.GrainId, + registration.Name); + } + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed [RegisterReminder] activation registration for grain {GrainId}, reminder {ReminderName}.", + grainContext.GrainId, + registration.Name); + } + } + } + + public Task OnStop(CancellationToken cancellationToken = default) => Task.CompletedTask; + } +} diff --git a/src/Orleans.AdvancedReminders/ReminderService/ReminderManagementGrain.cs b/src/Orleans.AdvancedReminders/ReminderService/ReminderManagementGrain.cs new file mode 100644 index 0000000000..589faded71 --- /dev/null +++ b/src/Orleans.AdvancedReminders/ReminderService/ReminderManagementGrain.cs @@ -0,0 +1,391 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Orleans.AdvancedReminders.Cron.Internal; +using Orleans.Runtime; + +namespace Orleans.AdvancedReminders; + +/// +/// Administrative management API for advanced reminders. +/// +public sealed class ReminderManagementGrain(IReminderTable reminderTable) : Grain, IReminderManagementGrain +{ + private readonly IReminderTable _reminderTable = reminderTable; + private readonly TimeProvider _timeProvider = TimeProvider.System; + + internal ReminderManagementGrain(IReminderTable reminderTable, IServiceProvider? serviceProvider, TimeProvider? timeProvider = null) + : this(reminderTable) + { + _serviceProvider = serviceProvider; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + private readonly IServiceProvider? _serviceProvider; + + public Task ListAllAsync(int pageSize = 256, string? continuationToken = null) + => ListFilteredAsync(new ReminderQueryFilter(), pageSize, continuationToken); + + public Task ListOverdueAsync(TimeSpan overdueBy, int pageSize = 256, string? continuationToken = null) + => ListFilteredAsync( + new ReminderQueryFilter + { + Status = ReminderQueryStatus.Overdue, + OverdueBy = overdueBy, + }, + pageSize, + continuationToken); + + public Task ListDueInRangeAsync( + DateTime fromUtcInclusive, + DateTime toUtcInclusive, + int pageSize = 256, + string? continuationToken = null) + => ListFilteredAsync( + new ReminderQueryFilter + { + DueFromUtcInclusive = fromUtcInclusive, + DueToUtcInclusive = toUtcInclusive, + }, + pageSize, + continuationToken); + + public async Task ListFilteredAsync(ReminderQueryFilter filter, int pageSize = 256, string? continuationToken = null) + { + ArgumentNullException.ThrowIfNull(filter); + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } + + var cursor = ReminderCursor.Parse(continuationToken); + var now = GetUtcNow(); + var candidates = await SelectPageAsync(filter, cursor, pageSize + 1, now); + var hasMore = candidates.Count > pageSize; + if (hasMore) + { + candidates.RemoveRange(pageSize, candidates.Count - pageSize); + } + + return new ReminderManagementPage + { + Reminders = candidates, + ContinuationToken = hasMore && candidates.Count > 0 ? ReminderCursor.Create(candidates[^1]) : null, + }; + } + + public async Task> UpcomingAsync(TimeSpan horizon) + { + if (horizon < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(horizon)); + } + + var upper = GetUtcNow().Add(horizon); + return (await GetAllAsync()) + .Where(reminder => GetDueTime(reminder) <= upper) + .OrderBy(reminder => reminder, ReminderEntryComparer.Instance) + .ToList(); + } + + public async Task> ListForGrainAsync(GrainId grainId) + => (await _reminderTable.ReadRows(grainId)).Reminders.OrderBy(reminder => reminder, ReminderEntryComparer.Instance).ToList(); + + public async Task CountAllAsync() => (await _reminderTable.ReadRows(0, 0)).Reminders.Count; + + public async Task SetPriorityAsync(GrainId grainId, string name, Runtime.ReminderPriority priority) + { + var entry = await GetEntryAsync(grainId, name); + entry.Priority = priority; + await PersistMutationAsync(entry); + } + + public async Task SetActionAsync(GrainId grainId, string name, Runtime.MissedReminderAction action) + { + var entry = await GetEntryAsync(grainId, name); + entry.Action = action; + await PersistMutationAsync(entry); + } + + public async Task RepairAsync(GrainId grainId, string name) + { + var entry = await GetEntryAsync(grainId, name); + entry.NextDueUtc = CalculateNextDue(entry, GetUtcNow()); + await PersistMutationAsync(entry); + } + + public async Task DeleteAsync(GrainId grainId, string name) + { + var entry = await GetEntryAsync(grainId, name); + await _reminderTable.RemoveRow(grainId, name, entry.ETag); + } + + private async Task> GetAllAsync() + => (await _reminderTable.ReadRows(0, 0)).Reminders.ToList(); + + private async Task GetEntryAsync(GrainId grainId, string name) + => await _reminderTable.ReadRow(grainId, name) ?? throw new Runtime.ReminderException($"Reminder '{name}' for grain '{grainId}' was not found."); + + private async Task PersistMutationAsync(ReminderEntry entry) + { + if (entry.NextDueUtc is null) + { + entry.ETag = await _reminderTable.UpsertRow(entry); + return; + } + + var serviceProvider = _serviceProvider ?? ServiceProvider; + var reminderService = serviceProvider?.GetService(typeof(Runtime.ReminderService.AdvancedReminderService)) as Runtime.ReminderService.AdvancedReminderService; + if (reminderService is null) + { + entry.ETag = await _reminderTable.UpsertRow(entry); + return; + } + + await reminderService.UpsertAndScheduleEntryAsync(entry, CancellationToken.None); + } + + private async Task> SelectPageAsync(ReminderQueryFilter filter, ReminderCursor? cursor, int take, DateTime now) + { + var queue = new PriorityQueue(ReverseReminderEntryComparer.Instance); + foreach (var reminder in await GetAllAsync()) + { + if (!MatchesFilter(reminder, filter, now) || !IsAfterCursor(reminder, cursor)) + { + continue; + } + + queue.Enqueue(reminder, reminder); + if (queue.Count > take) + { + _ = queue.Dequeue(); + } + } + + var result = new List(queue.Count); + while (queue.Count > 0) + { + result.Add(queue.Dequeue()); + } + + result.Sort(ReminderEntryComparer.Instance); + return result; + } + + private bool MatchesFilter(ReminderEntry reminder, ReminderQueryFilter filter, DateTime now) + { + var due = GetDueTime(reminder); + + if (filter.DueFromUtcInclusive is { } from && due < from) + { + return false; + } + + if (filter.DueToUtcInclusive is { } to && due > to) + { + return false; + } + + if (filter.Priority is { } priority && reminder.Priority != priority) + { + return false; + } + + if (filter.Action is { } action && reminder.Action != action) + { + return false; + } + + if (filter.ScheduleKind is { } scheduleKind && GetScheduleKind(reminder) != scheduleKind) + { + return false; + } + + if (filter.Status == ReminderQueryStatus.Any) + { + return true; + } + + var matched = false; + if ((filter.Status & ReminderQueryStatus.Due) != 0 && due <= now) + { + matched = true; + } + + if ((filter.Status & ReminderQueryStatus.Upcoming) != 0 && due > now) + { + matched = true; + } + + if ((filter.Status & ReminderQueryStatus.Overdue) != 0 && due <= now - filter.OverdueBy) + { + matched = true; + } + + if ((filter.Status & ReminderQueryStatus.Missed) != 0 + && due <= now - filter.MissedBy + && (reminder.LastFireUtc is null || reminder.LastFireUtc < due)) + { + matched = true; + } + + return matched; + } + + private static DateTime? CalculateNextDue(ReminderEntry entry, DateTime now) + { + if (!string.IsNullOrWhiteSpace(entry.CronExpression)) + { + return ReminderCronSchedule.Parse(entry.CronExpression, entry.CronTimeZoneId).GetNextOccurrence(now); + } + + if (entry.Period <= TimeSpan.Zero) + { + return null; + } + + var next = entry.NextDueUtc ?? entry.StartAt; + if (next <= now) + { + var ticksBehind = now.Ticks - next.Ticks; + var periodsBehind = ticksBehind / entry.Period.Ticks + 1; + next = next.AddTicks(periodsBehind * entry.Period.Ticks); + } + + return next; + } + + private DateTime GetUtcNow() => _timeProvider.GetUtcNow().UtcDateTime; + + private static bool IsAfterCursor(ReminderEntry reminder, ReminderCursor? cursor) + => cursor is null || ReminderCursor.Compare(reminder, cursor) > 0; + + private static DateTime GetDueTime(ReminderEntry reminder) => reminder.NextDueUtc ?? reminder.StartAt; + + private static Runtime.ReminderScheduleKind GetScheduleKind(ReminderEntry reminder) + => string.IsNullOrWhiteSpace(reminder.CronExpression) + ? Runtime.ReminderScheduleKind.Interval + : Runtime.ReminderScheduleKind.Cron; + + private sealed class ReminderCursor + { + private ReminderCursor(DateTime dueUtc, GrainId grainId, string reminderName) + { + DueUtc = dueUtc; + GrainId = grainId; + ReminderName = reminderName; + } + + public DateTime DueUtc { get; } + + public GrainId GrainId { get; } + + public string ReminderName { get; } + + public static string Create(ReminderEntry entry) + { + var payload = string.Create( + CultureInfo.InvariantCulture, + $"{GetDueTime(entry).Ticks}\n{entry.GrainId}\n{entry.ReminderName}"); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)); + } + + public static ReminderCursor? Parse(string? continuationToken) + { + if (string.IsNullOrWhiteSpace(continuationToken)) + { + return null; + } + + try + { + var payload = Encoding.UTF8.GetString(Convert.FromBase64String(continuationToken)); + var firstSeparator = payload.IndexOf('\n'); + var secondSeparator = firstSeparator >= 0 ? payload.IndexOf('\n', firstSeparator + 1) : -1; + if (firstSeparator <= 0 || secondSeparator <= firstSeparator + 1 || secondSeparator >= payload.Length - 1) + { + throw new FormatException("Continuation token payload is incomplete."); + } + + var ticksSpan = payload.AsSpan(0, firstSeparator); + if (!long.TryParse(ticksSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dueTicks)) + { + throw new FormatException("Continuation token due timestamp is invalid."); + } + + var grainIdText = payload.Substring(firstSeparator + 1, secondSeparator - firstSeparator - 1); + var reminderName = payload[(secondSeparator + 1)..]; + return new ReminderCursor(new DateTime(dueTicks, DateTimeKind.Utc), GrainId.Parse(grainIdText), reminderName); + } + catch (Exception exception) when (exception is FormatException or ArgumentException) + { + throw new ArgumentException("Invalid continuation token.", nameof(continuationToken), exception); + } + } + + public static int Compare(ReminderEntry reminder, ReminderCursor cursor) + { + var dueCompare = GetDueTime(reminder).CompareTo(cursor.DueUtc); + if (dueCompare != 0) + { + return dueCompare; + } + + var grainCompare = reminder.GrainId.CompareTo(cursor.GrainId); + if (grainCompare != 0) + { + return grainCompare; + } + + return string.CompareOrdinal(reminder.ReminderName, cursor.ReminderName); + } + } + + private sealed class ReminderEntryComparer : IComparer + { + public static ReminderEntryComparer Instance { get; } = new(); + + public int Compare(ReminderEntry? x, ReminderEntry? y) + { + if (ReferenceEquals(x, y)) + { + return 0; + } + + if (x is null) + { + return -1; + } + + if (y is null) + { + return 1; + } + + var dueCompare = GetDueTime(x).CompareTo(GetDueTime(y)); + if (dueCompare != 0) + { + return dueCompare; + } + + var grainCompare = x.GrainId.CompareTo(y.GrainId); + if (grainCompare != 0) + { + return grainCompare; + } + + return string.CompareOrdinal(x.ReminderName, y.ReminderName); + } + } + + private sealed class ReverseReminderEntryComparer : IComparer + { + public static ReverseReminderEntryComparer Instance { get; } = new(); + + public int Compare(ReminderEntry? x, ReminderEntry? y) => ReminderEntryComparer.Instance.Compare(y, x); + } +} diff --git a/src/Orleans.AdvancedReminders/ReminderService/ReminderRegistry.cs b/src/Orleans.AdvancedReminders/ReminderService/ReminderRegistry.cs new file mode 100644 index 0000000000..4043721bf3 --- /dev/null +++ b/src/Orleans.AdvancedReminders/ReminderService/ReminderRegistry.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Orleans.AdvancedReminders.Cron.Internal; +using Orleans.AdvancedReminders.Timers; +using Orleans.Runtime; + +namespace Orleans.AdvancedReminders.Runtime.ReminderService; + +internal sealed class ReminderRegistry(IServiceProvider serviceProvider, IOptions options) : IReminderRegistry +{ + private readonly IServiceProvider _serviceProvider = serviceProvider; + private readonly ReminderOptions _options = options.Value; + + public Task RegisterOrUpdateReminder( + GrainId callingGrainId, + string reminderName, + ReminderSchedule schedule, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action) + { + ArgumentNullException.ThrowIfNull(schedule); + ValidateSchedule(reminderName, schedule, priority, action); + return GetReminderService().RegisterOrUpdateReminder(callingGrainId, reminderName, schedule, priority, action); + } + + public Task UnregisterReminder(GrainId callingGrainId, IGrainReminder reminder) + { + ArgumentNullException.ThrowIfNull(reminder); + return GetReminderService().UnregisterReminder(reminder); + } + + public Task GetReminder(GrainId callingGrainId, string reminderName) + { + if (string.IsNullOrWhiteSpace(reminderName)) + { + throw new ArgumentException("Cannot use null or empty name for the reminder", nameof(reminderName)); + } + + return GetReminderService().GetReminder(callingGrainId, reminderName); + } + + public Task> GetReminders(GrainId callingGrainId) => GetReminderService().GetReminders(callingGrainId); + + private IReminderService GetReminderService() + => _serviceProvider.GetRequiredService(); + + private void ValidateSchedule(string reminderName, ReminderSchedule schedule, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) + { + if (string.IsNullOrWhiteSpace(reminderName)) + { + throw new ArgumentException("Cannot use null or empty name for the reminder", nameof(reminderName)); + } + + ValidatePriorityAndAction(priority, action); + + switch (schedule.Kind) + { + case Runtime.ReminderScheduleKind.Interval: + ValidateIntervalSchedule(schedule, reminderName); + break; + case Runtime.ReminderScheduleKind.Cron: + ValidateCronSchedule(schedule); + break; + default: + throw new ArgumentOutOfRangeException(nameof(schedule), schedule.Kind, "Unsupported reminder schedule kind."); + } + } + + private void ValidateIntervalSchedule(ReminderSchedule schedule, string reminderName) + { + if (schedule.Period is not { } period) + { + throw new ArgumentException("Interval reminder schedule must define a period.", nameof(schedule)); + } + + if (schedule.DueTime is { } dueTime) + { + if (dueTime == Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(schedule), "Cannot use InfiniteTimeSpan dueTime to create a reminder"); + } + + if (dueTime.Ticks < 0) + { + throw new ArgumentOutOfRangeException(nameof(schedule), "Cannot use negative dueTime to create a reminder"); + } + } + else if (schedule.DueAtUtc is { } dueAtUtc) + { + if (dueAtUtc.Kind != DateTimeKind.Utc) + { + throw new ArgumentException("Due timestamp must use DateTimeKind.Utc.", nameof(schedule)); + } + } + else + { + throw new ArgumentException("Interval reminder schedule must define dueTime or dueAtUtc.", nameof(schedule)); + } + + if (period == Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(schedule), "Cannot use InfiniteTimeSpan period to create a reminder"); + } + + if (period.Ticks < 0) + { + throw new ArgumentOutOfRangeException(nameof(schedule), "Cannot use negative period to create a reminder"); + } + + if (period < _options.MinimumReminderPeriod) + { + throw new ArgumentException( + $"Cannot register reminder {reminderName} as requested period ({period}) is less than minimum allowed reminder period ({_options.MinimumReminderPeriod})"); + } + } + + private static void ValidateCronSchedule(ReminderSchedule schedule) + { + if (string.IsNullOrWhiteSpace(schedule.CronExpression)) + { + throw new ArgumentException("Cannot use null or empty cron expression for the reminder", nameof(schedule)); + } + + _ = ReminderCronSchedule.Parse(schedule.CronExpression, schedule.CronTimeZoneId); + } + + private static void ValidatePriorityAndAction(Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) + { + if (!Enum.IsDefined(priority)) + { + throw new ArgumentOutOfRangeException(nameof(priority), priority, "Invalid reminder priority."); + } + + if (!Enum.IsDefined(action)) + { + throw new ArgumentOutOfRangeException(nameof(action), action, "Invalid missed reminder action."); + } + } +} diff --git a/src/Orleans.AdvancedReminders/Runtime/ReminderRuntimeTypes.cs b/src/Orleans.AdvancedReminders/Runtime/ReminderRuntimeTypes.cs new file mode 100644 index 0000000000..e5f83da122 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Runtime/ReminderRuntimeTypes.cs @@ -0,0 +1,51 @@ +using System; +using System.Runtime.Serialization; +using Orleans.Runtime; + +namespace Orleans.AdvancedReminders.Runtime; + +/// +/// Represents the schedule type of an advanced reminder. +/// +public enum ReminderScheduleKind : byte +{ + Interval = 0, + Cron = 1, +} + +/// +/// Priority of reminder processing. +/// +public enum ReminderPriority : byte +{ + Normal = 0, + High = 1, +} + +/// +/// Action to apply when a reminder tick was missed. +/// +public enum MissedReminderAction : byte +{ + Skip = 0, + FireImmediately = 1, + Notify = 2, +} + +/// +/// Exception related to Orleans advanced reminder functions or reminder service. +/// +[Serializable] +[GenerateSerializer] +public sealed class ReminderException : OrleansException +{ + public ReminderException(string message) : base(message) + { + } + + [Obsolete] + public ReminderException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } +} diff --git a/src/Orleans.AdvancedReminders/Runtime/TickStatus.cs b/src/Orleans.AdvancedReminders/Runtime/TickStatus.cs new file mode 100644 index 0000000000..578b84adc8 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Runtime/TickStatus.cs @@ -0,0 +1,30 @@ +using System; + +namespace Orleans.AdvancedReminders.Runtime; + +/// +/// The status of an advanced reminder tick when it is delivered to a grain. +/// +[Serializable] +[GenerateSerializer] +[Immutable] +public readonly struct TickStatus +{ + [Id(0)] + public DateTime FirstTickTime { get; } + + [Id(1)] + public TimeSpan Period { get; } + + [Id(2)] + public DateTime CurrentTickTime { get; } + + public TickStatus(DateTime firstTickTime, TimeSpan period, DateTime currentTickTime) + { + FirstTickTime = firstTickTime; + Period = period; + CurrentTickTime = currentTickTime; + } + + public override string ToString() => $"<{FirstTickTime}, {Period}, {CurrentTickTime}>"; +} diff --git a/src/Orleans.AdvancedReminders/SystemTargetInterfaces/IReminderService.cs b/src/Orleans.AdvancedReminders/SystemTargetInterfaces/IReminderService.cs new file mode 100644 index 0000000000..a8c2e637a5 --- /dev/null +++ b/src/Orleans.AdvancedReminders/SystemTargetInterfaces/IReminderService.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Orleans.Runtime; + +namespace Orleans.AdvancedReminders; + +/// +/// Functionality for managing advanced reminders. +/// +public interface IReminderService +{ + Task RegisterOrUpdateReminder( + GrainId grainId, + string reminderName, + ReminderSchedule schedule, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action); + + Task UnregisterReminder(IGrainReminder reminder); + + Task GetReminder(GrainId grainId, string reminderName); + + Task> GetReminders(GrainId grainId); + + Task ProcessDueReminderAsync( + GrainId grainId, + string reminderName, + string? expectedETag, + CancellationToken cancellationToken); +} diff --git a/src/Orleans.AdvancedReminders/SystemTargetInterfaces/IReminderTable.cs b/src/Orleans.AdvancedReminders/SystemTargetInterfaces/IReminderTable.cs new file mode 100644 index 0000000000..fd8df391c4 --- /dev/null +++ b/src/Orleans.AdvancedReminders/SystemTargetInterfaces/IReminderTable.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Orleans.Concurrency; +using Orleans.Runtime; + +namespace Orleans.AdvancedReminders +{ + /// + /// Interface for implementations of the underlying storage for reminder data: + /// Azure Table, SQL, development emulator grain, and a mock implementation. + /// Defined as a grain interface for the development emulator grain case. + /// + public interface IReminderTable + { + /// + /// Initializes this instance. + /// + /// A representing the work performed. + Task StartAsync(CancellationToken cancellationToken = default) +#pragma warning disable CS0618 // Type or member is obsolete + => Init(); +#pragma warning restore CS0618 // Type or member is obsolete + + /// + /// Initializes this instance. + /// + /// A representing the work performed. + [Obsolete("Implement and use StartAsync instead")] + Task Init() => Task.CompletedTask; + + /// + /// Reads the reminder table entries associated with the specified grain. + /// + /// The grain ID. + /// The reminder table entries associated with the specified grain. + Task ReadRows(GrainId grainId); + + /// + /// Returns all rows that have their in the range (begin, end]. + /// If begin is greater or equal to end, returns all entries with hash greater begin or hash less or equal to end. + /// + /// The exclusive lower bound. + /// The inclusive upper bound. + /// The reminder table entries which fall within the specified range. + Task ReadRows(uint begin, uint end); + + /// + /// Reads the specified entry. + /// + /// The grain ID. + /// Name of the reminder. + /// The reminder table entry. + Task ReadRow(GrainId grainId, string reminderName); + + /// + /// Upserts the specified entry. + /// + /// The entry. + /// The row's new ETag. + Task UpsertRow(ReminderEntry entry); + + /// + /// Removes a row from the table. + /// + /// The grain ID. + /// The reminder name. + /// /// The ETag. + /// true if a row with and existed and was removed successfully, false otherwise + Task RemoveRow(GrainId grainId, string reminderName, string eTag); + + /// + /// Clears the table. + /// + /// A representing the work performed. + Task TestOnlyClearTable(); + + /// + /// Stops the reminder table. + /// + /// The cancellation token. + /// A representing the work performed. + Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + /// + /// Reminder table interface for grain based implementation. + /// + internal interface IReminderTableGrain : IGrainWithIntegerKey + { + Task ReadRows(GrainId grainId); + + Task ReadRows(uint begin, uint end); + + Task ReadRow(GrainId grainId, string reminderName); + + Task UpsertRow(ReminderEntry entry); + + Task RemoveRow(GrainId grainId, string reminderName, string eTag); + + Task TestOnlyClearTable(); + } + + /// + /// Represents a collection of reminder table entries. + /// + [Serializable] + [GenerateSerializer] + public sealed class ReminderTableData + { + /// + /// Initializes a new instance of the class. + /// + /// The entries. + public ReminderTableData(IEnumerable list) + { + Reminders = new List(list); + } + + /// + /// Initializes a new instance of the class. + /// + /// The entry. + public ReminderTableData(ReminderEntry entry) + { + Reminders = new[] { entry }; + } + + /// + /// Initializes a new instance of the class. + /// + public ReminderTableData() + { + Reminders = Array.Empty(); + } + + /// + /// Gets the reminders. + /// + /// The reminders. + [Id(0)] + public IList Reminders { get; private set; } + + /// + /// Returns a that represents this instance. + /// + /// A that represents this instance. + public override string ToString() => $"[{Reminders.Count} reminders: {Utils.EnumerableToString(Reminders)}."; + } + + /// + /// Represents a reminder table entry. + /// + [Serializable] + [GenerateSerializer] + public sealed class ReminderEntry + { + /// + /// Gets or sets the grain ID of the grain that created the reminder. Forms the reminder + /// primary key together with . + /// + [Id(0)] + public GrainId GrainId { get; set; } + + /// + /// Gets or sets the name of the reminder. Forms the reminder primary key together with + /// . + /// + [Id(1)] + public string ReminderName { get; set; } = string.Empty; + + /// + /// Gets or sets the time when the reminder was supposed to tick in the first time + /// + [Id(2)] + public DateTime StartAt { get; set; } + + /// + /// Gets or sets the time period for the reminder + /// + [Id(3)] + public TimeSpan Period { get; set; } + + /// + /// Gets or sets the ETag. + /// + /// The ETag. + [Id(4)] + public string ETag { get; set; } = string.Empty; + + /// + /// Gets or sets the cron expression for this reminder. + /// If null or empty, the reminder uses and interval semantics. + /// + [Id(5)] + public string CronExpression { get; set; } = string.Empty; + + /// + /// Gets or sets the time zone id used to evaluate . + /// Null or empty indicates UTC scheduling. + /// + [Id(10)] + public string CronTimeZoneId { get; set; } = string.Empty; + + /// + /// Gets or sets the next due timestamp for this reminder in UTC. + /// + [Id(6)] + public DateTime? NextDueUtc { get; set; } + + /// + /// Gets or sets the timestamp when this reminder was last fired in UTC. + /// + [Id(7)] + public DateTime? LastFireUtc { get; set; } + + /// + /// Gets or sets the reminder priority. + /// + [Id(8)] + public Runtime.ReminderPriority Priority { get; set; } = Runtime.ReminderPriority.Normal; + + /// + /// Gets or sets the missed reminder action. + /// + [Id(9)] + public Runtime.MissedReminderAction Action { get; set; } = Runtime.MissedReminderAction.Skip; + + /// + public override string ToString() + => $""; + + /// + /// Returns an representing the data in this instance. + /// + /// The . + internal IGrainReminder ToIGrainReminder() => new ReminderData(GrainId, ReminderName, ETag, CronExpression, Priority, Action, CronTimeZoneId); + } + + [Serializable, GenerateSerializer, Immutable] + internal sealed class ReminderData : IGrainReminder + { + [Id(0)] + public readonly GrainId GrainId; + [Id(1)] + public string ReminderName { get; } + [Id(2)] + public readonly string ETag; + [Id(3)] + public string CronExpression { get; } + [Id(4)] + public Runtime.ReminderPriority Priority { get; } + [Id(5)] + public Runtime.MissedReminderAction Action { get; } + [Id(6)] + public string CronTimeZone { get; } + + internal ReminderData( + GrainId grainId, + string reminderName, + string eTag, + string cronExpression = "", + Runtime.ReminderPriority priority = Runtime.ReminderPriority.Normal, + Runtime.MissedReminderAction action = Runtime.MissedReminderAction.Skip, + string cronTimeZoneId = "") + { + GrainId = grainId; + ReminderName = reminderName; + ETag = eTag; + CronExpression = cronExpression ?? string.Empty; + Priority = priority; + Action = action; + CronTimeZone = cronTimeZoneId ?? string.Empty; + } + + public override string ToString() => $""; + } +} diff --git a/src/Orleans.AdvancedReminders/Timers/IReminderIterator.cs b/src/Orleans.AdvancedReminders/Timers/IReminderIterator.cs new file mode 100644 index 0000000000..63c55a7c0a --- /dev/null +++ b/src/Orleans.AdvancedReminders/Timers/IReminderIterator.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +#nullable enable +namespace Orleans.AdvancedReminders; + +/// +/// Streams reminders page-by-page from without materializing all pages at once. +/// +public interface IReminderIterator +{ + /// + /// Enumerates all reminders. + /// + IAsyncEnumerable EnumerateAllAsync(int pageSize = 256, CancellationToken cancellationToken = default); + + /// + /// Enumerates reminders overdue by at least . + /// + IAsyncEnumerable EnumerateOverdueAsync(TimeSpan overdueBy, int pageSize = 256, CancellationToken cancellationToken = default); + + /// + /// Enumerates reminders due in the provided UTC range. + /// + IAsyncEnumerable EnumerateDueInRangeAsync( + DateTime fromUtcInclusive, + DateTime toUtcInclusive, + int pageSize = 256, + CancellationToken cancellationToken = default); + + /// + /// Enumerates reminders matching the provided filter. + /// + IAsyncEnumerable EnumerateFilteredAsync( + ReminderQueryFilter filter, + int pageSize = 256, + CancellationToken cancellationToken = default); +} diff --git a/src/Orleans.AdvancedReminders/Timers/IReminderManagementGrain.cs b/src/Orleans.AdvancedReminders/Timers/IReminderManagementGrain.cs new file mode 100644 index 0000000000..2f05241893 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Timers/IReminderManagementGrain.cs @@ -0,0 +1,86 @@ +using Orleans.Runtime; + +#nullable enable +namespace Orleans.AdvancedReminders; + +/// +/// Administrative management API for advanced reminders. +/// +public interface IReminderManagementGrain : IGrainWithGuidKey +{ + /// + /// Returns a page of reminders across the whole reminder table. + /// + /// The maximum number of reminders to return. + /// An opaque continuation token from a previous page, or to start from the beginning. + Task ListAllAsync(int pageSize = 256, string? continuationToken = null); + + /// + /// Returns a page of overdue reminders across the whole reminder table. + /// A reminder is considered overdue when NextDueUtc (or StartAt if missing) is less than or equal to UtcNow - overdueBy. + /// + /// The minimum overdue duration to match. + /// The maximum number of reminders to return. + /// An opaque continuation token from a previous page, or to start from the beginning. + Task ListOverdueAsync(TimeSpan overdueBy, int pageSize = 256, string? continuationToken = null); + + /// + /// Returns a page of reminders due within the provided UTC range. + /// A reminder due value is NextDueUtc when present, otherwise StartAt. + /// + /// Range start (inclusive), must be UTC. + /// Range end (inclusive), must be UTC. + /// The maximum number of reminders to return. + /// An opaque continuation token from a previous page, or to start from the beginning. + Task ListDueInRangeAsync( + DateTime fromUtcInclusive, + DateTime toUtcInclusive, + int pageSize = 256, + string? continuationToken = null); + + /// + /// Returns a page of reminders matching the provided server-side filter. + /// + /// Filter criteria. Date values must be UTC when provided. + /// The maximum number of reminders to return. + /// An opaque continuation token from a previous page, or to start from the beginning. + Task ListFilteredAsync( + ReminderQueryFilter filter, + int pageSize = 256, + string? continuationToken = null); + + /// + /// Returns reminders due within the specified horizon. + /// + Task> UpcomingAsync(TimeSpan horizon); + + /// + /// Returns all reminders for the specified grain. + /// + Task> ListForGrainAsync(GrainId grainId); + + /// + /// Returns the total reminder count. + /// + Task CountAllAsync(); + + /// + /// Sets reminder priority. + /// + Task SetPriorityAsync(GrainId grainId, string name, Runtime.ReminderPriority priority); + + /// + /// Sets missed reminder action. + /// + Task SetActionAsync(GrainId grainId, string name, Runtime.MissedReminderAction action); + + /// + /// Repairs a reminder by recalculating its next due time. + /// + Task RepairAsync(GrainId grainId, string name); + + /// + /// Deletes the specified reminder. + /// + Task DeleteAsync(GrainId grainId, string name); +} diff --git a/src/Orleans.AdvancedReminders/Timers/IReminderRegistry.cs b/src/Orleans.AdvancedReminders/Timers/IReminderRegistry.cs new file mode 100644 index 0000000000..85a4a8e4ff --- /dev/null +++ b/src/Orleans.AdvancedReminders/Timers/IReminderRegistry.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Orleans.Runtime; + +namespace Orleans.AdvancedReminders.Timers; + +/// +/// Functionality for managing advanced reminders from grain code. +/// +public interface IReminderRegistry +{ + Task RegisterOrUpdateReminder( + GrainId callingGrainId, + string reminderName, + ReminderSchedule schedule, + Runtime.ReminderPriority priority, + Runtime.MissedReminderAction action); + + Task UnregisterReminder(GrainId callingGrainId, IGrainReminder reminder); + + Task GetReminder(GrainId callingGrainId, string reminderName); + + Task> GetReminders(GrainId callingGrainId); +} diff --git a/src/Orleans.AdvancedReminders/Timers/ReminderIterator.cs b/src/Orleans.AdvancedReminders/Timers/ReminderIterator.cs new file mode 100644 index 0000000000..0f8ce35988 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Timers/ReminderIterator.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable +namespace Orleans.AdvancedReminders; + +/// +/// Default implementation of backed by paging APIs. +/// +public sealed class ReminderIterator(IReminderManagementGrain managementGrain) : IReminderIterator +{ + private readonly IReminderManagementGrain _managementGrain = managementGrain ?? throw new ArgumentNullException(nameof(managementGrain)); + + public async IAsyncEnumerable EnumerateAllAsync( + int pageSize = 256, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string? continuationToken = null; + do + { + cancellationToken.ThrowIfCancellationRequested(); + var page = await _managementGrain.ListAllAsync(pageSize, continuationToken).WaitAsync(cancellationToken); + + foreach (var reminder in page.Reminders) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return reminder; + } + + continuationToken = page.ContinuationToken; + } + while (!string.IsNullOrEmpty(continuationToken)); + } + + public async IAsyncEnumerable EnumerateOverdueAsync( + TimeSpan overdueBy, + int pageSize = 256, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string? continuationToken = null; + do + { + cancellationToken.ThrowIfCancellationRequested(); + var page = await _managementGrain.ListOverdueAsync(overdueBy, pageSize, continuationToken).WaitAsync(cancellationToken); + + foreach (var reminder in page.Reminders) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return reminder; + } + + continuationToken = page.ContinuationToken; + } + while (!string.IsNullOrEmpty(continuationToken)); + } + + public async IAsyncEnumerable EnumerateDueInRangeAsync( + DateTime fromUtcInclusive, + DateTime toUtcInclusive, + int pageSize = 256, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string? continuationToken = null; + do + { + cancellationToken.ThrowIfCancellationRequested(); + var page = await _managementGrain + .ListDueInRangeAsync(fromUtcInclusive, toUtcInclusive, pageSize, continuationToken) + .WaitAsync(cancellationToken); + + foreach (var reminder in page.Reminders) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return reminder; + } + + continuationToken = page.ContinuationToken; + } + while (!string.IsNullOrEmpty(continuationToken)); + } + + public async IAsyncEnumerable EnumerateFilteredAsync( + ReminderQueryFilter filter, + int pageSize = 256, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(filter); + + string? continuationToken = null; + do + { + cancellationToken.ThrowIfCancellationRequested(); + var page = await _managementGrain + .ListFilteredAsync(filter, pageSize, continuationToken) + .WaitAsync(cancellationToken); + + foreach (var reminder in page.Reminders) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return reminder; + } + + continuationToken = page.ContinuationToken; + } + while (!string.IsNullOrEmpty(continuationToken)); + } +} diff --git a/src/Orleans.AdvancedReminders/Timers/ReminderManagementGrainExtensions.cs b/src/Orleans.AdvancedReminders/Timers/ReminderManagementGrainExtensions.cs new file mode 100644 index 0000000000..d1d2550a61 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Timers/ReminderManagementGrainExtensions.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +#nullable enable +namespace Orleans.AdvancedReminders; + +/// +/// Helper methods for iterating reminder management queries page-by-page. +/// +public static class ReminderManagementGrainExtensions +{ + /// + /// Creates an iterator facade for reminder management paging APIs. + /// + public static IReminderIterator CreateIterator(this IReminderManagementGrain managementGrain) + { + ArgumentNullException.ThrowIfNull(managementGrain); + return new ReminderIterator(managementGrain); + } + + /// + /// Iterates all reminders across the reminder table using server-side paging. + /// + public static IAsyncEnumerable EnumerateAllAsync( + this IReminderManagementGrain managementGrain, + int pageSize = 256, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(managementGrain); + return managementGrain.CreateIterator().EnumerateAllAsync(pageSize, cancellationToken); + } + + /// + /// Iterates overdue reminders across the reminder table using server-side paging. + /// + public static IAsyncEnumerable EnumerateOverdueAsync( + this IReminderManagementGrain managementGrain, + TimeSpan overdueBy, + int pageSize = 256, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(managementGrain); + return managementGrain.CreateIterator().EnumerateOverdueAsync(overdueBy, pageSize, cancellationToken); + } + + /// + /// Iterates reminders due in the provided UTC range using server-side paging. + /// + public static IAsyncEnumerable EnumerateDueInRangeAsync( + this IReminderManagementGrain managementGrain, + DateTime fromUtcInclusive, + DateTime toUtcInclusive, + int pageSize = 256, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(managementGrain); + return managementGrain.CreateIterator().EnumerateDueInRangeAsync(fromUtcInclusive, toUtcInclusive, pageSize, cancellationToken); + } + + /// + /// Iterates reminders matching the provided server-side filter using paging. + /// + public static IAsyncEnumerable EnumerateFilteredAsync( + this IReminderManagementGrain managementGrain, + ReminderQueryFilter filter, + int pageSize = 256, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(managementGrain); + ArgumentNullException.ThrowIfNull(filter); + return managementGrain.CreateIterator().EnumerateFilteredAsync(filter, pageSize, cancellationToken); + } +} diff --git a/src/Orleans.AdvancedReminders/Timers/ReminderManagementPage.cs b/src/Orleans.AdvancedReminders/Timers/ReminderManagementPage.cs new file mode 100644 index 0000000000..d91598cfa8 --- /dev/null +++ b/src/Orleans.AdvancedReminders/Timers/ReminderManagementPage.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; + +#nullable enable +namespace Orleans.AdvancedReminders; + +/// +/// Represents a page of reminders returned from . +/// +[GenerateSerializer] +public sealed class ReminderManagementPage +{ + /// + /// Gets the reminders in this page. + /// + [Id(0)] + public List Reminders { get; init; } = []; + + /// + /// Gets the opaque continuation token for fetching the next page, or when there are no more pages. + /// + [Id(1)] + public string? ContinuationToken { get; init; } +} diff --git a/src/Orleans.AdvancedReminders/Timers/ReminderQueryFilter.cs b/src/Orleans.AdvancedReminders/Timers/ReminderQueryFilter.cs new file mode 100644 index 0000000000..bbc485023d --- /dev/null +++ b/src/Orleans.AdvancedReminders/Timers/ReminderQueryFilter.cs @@ -0,0 +1,92 @@ +using System; + +#nullable enable +namespace Orleans.AdvancedReminders; + +/// +/// Status categories used by for due-state filtering. +/// +[Flags] +public enum ReminderQueryStatus : byte +{ + /// + /// No status filtering. + /// + Any = 0, + + /// + /// Matches reminders whose due time is less than or equal to now. + /// + Due = 1 << 0, + + /// + /// Matches reminders overdue by at least . + /// + Overdue = 1 << 1, + + /// + /// Matches reminders considered missed: due time older than + /// and a last-fire timestamp earlier than that due time (or missing). + /// + Missed = 1 << 2, + + /// + /// Matches reminders due strictly after now. + /// + Upcoming = 1 << 3, +} + +/// +/// Server-side filter options for reminder management paging queries. +/// +[GenerateSerializer] +public sealed class ReminderQueryFilter +{ + /// + /// Gets the inclusive due lower bound in UTC. Null means no lower bound. + /// + [Id(0)] + public DateTime? DueFromUtcInclusive { get; init; } + + /// + /// Gets the inclusive due upper bound in UTC. Null means no upper bound. + /// + [Id(1)] + public DateTime? DueToUtcInclusive { get; init; } + + /// + /// Gets the optional reminder priority to match. + /// + [Id(2)] + public Runtime.ReminderPriority? Priority { get; init; } + + /// + /// Gets the optional missed-reminder action to match. + /// + [Id(3)] + public Runtime.MissedReminderAction? Action { get; init; } + + /// + /// Gets the optional schedule kind to match. + /// + [Id(4)] + public Runtime.ReminderScheduleKind? ScheduleKind { get; init; } + + /// + /// Gets the due-state status filter mask. + /// + [Id(5)] + public ReminderQueryStatus Status { get; init; } = ReminderQueryStatus.Any; + + /// + /// Gets the overdue threshold used when is set. + /// + [Id(6)] + public TimeSpan OverdueBy { get; init; } = TimeSpan.Zero; + + /// + /// Gets the missed threshold used when is set. + /// + [Id(7)] + public TimeSpan MissedBy { get; init; } = TimeSpan.Zero; +} diff --git a/src/Orleans.DurableJobs/Hosting/DurableJobsExtensions.cs b/src/Orleans.DurableJobs/Hosting/DurableJobsExtensions.cs index f0cd24844a..4d92aba4f2 100644 --- a/src/Orleans.DurableJobs/Hosting/DurableJobsExtensions.cs +++ b/src/Orleans.DurableJobs/Hosting/DurableJobsExtensions.cs @@ -67,7 +67,7 @@ public static ISiloBuilder UseInMemoryDurableJobs(this ISiloBuilder builder) /// /// The service collection. /// The provided , for chaining. - internal static IServiceCollection UseInMemoryDurableJobs(this IServiceCollection services) + public static IServiceCollection UseInMemoryDurableJobs(this IServiceCollection services) { services.AddSingleton(sp => { diff --git a/src/Orleans.Runtime/Timers/AsyncTimer.cs b/src/Orleans.Runtime/Timers/AsyncTimer.cs index 955314ee19..e2e7249618 100644 --- a/src/Orleans.Runtime/Timers/AsyncTimer.cs +++ b/src/Orleans.Runtime/Timers/AsyncTimer.cs @@ -17,14 +17,16 @@ internal partial class AsyncTimer : IAsyncTimer private readonly TimeSpan period; private readonly string name; private readonly ILogger log; + private readonly TimeProvider timeProvider; private DateTime lastFired = DateTime.MinValue; private DateTime expected; - public AsyncTimer(TimeSpan period, string name, ILogger log) + public AsyncTimer(TimeSpan period, string name, ILogger log, TimeProvider timeProvider) { this.log = log; this.period = period; this.name = name; + this.timeProvider = timeProvider; } /// @@ -36,7 +38,7 @@ public async Task NextTick(TimeSpan? overrideDelay = default) { if (cancellation.IsCancellationRequested) return false; - var start = DateTime.UtcNow; + var start = this.timeProvider.GetUtcNow().UtcDateTime; var delay = overrideDelay switch { { } value => value, @@ -55,7 +57,7 @@ public async Task NextTick(TimeSpan? overrideDelay = default) while (delay > maxDelay) { delay -= maxDelay; - var task2 = await Task.WhenAny(Task.Delay(maxDelay, cancellation.Token)).ConfigureAwait(false); + var task2 = await Task.WhenAny(Task.Delay(maxDelay, this.timeProvider, cancellation.Token)).ConfigureAwait(false); if (task2.IsCanceled) { await Task.Yield(); @@ -64,7 +66,7 @@ public async Task NextTick(TimeSpan? overrideDelay = default) } } - var task = await Task.WhenAny(Task.Delay(delay, cancellation.Token)).ConfigureAwait(false); + var task = await Task.WhenAny(Task.Delay(delay, this.timeProvider, cancellation.Token)).ConfigureAwait(false); if (task.IsCanceled) { await Task.Yield(); @@ -73,7 +75,7 @@ public async Task NextTick(TimeSpan? overrideDelay = default) } } - var now = this.lastFired = DateTime.UtcNow; + var now = this.lastFired = this.timeProvider.GetUtcNow().UtcDateTime; var overshoot = GetOvershootDelay(now, dueTime); if (overshoot > TimeSpan.Zero) { @@ -100,7 +102,7 @@ private static TimeSpan GetOvershootDelay(DateTime now, DateTime dueTime) public bool CheckHealth(DateTime lastCheckTime, out string reason) { - var now = DateTime.UtcNow; + var now = this.timeProvider.GetUtcNow().UtcDateTime; var due = this.expected; var overshoot = GetOvershootDelay(now, due); if (overshoot > TimeSpan.Zero && !Debugger.IsAttached) diff --git a/src/Orleans.Runtime/Timers/AsyncTimerFactory.cs b/src/Orleans.Runtime/Timers/AsyncTimerFactory.cs index b3f9623fa4..e46ce332a6 100644 --- a/src/Orleans.Runtime/Timers/AsyncTimerFactory.cs +++ b/src/Orleans.Runtime/Timers/AsyncTimerFactory.cs @@ -6,15 +6,23 @@ namespace Orleans.Runtime internal class AsyncTimerFactory : IAsyncTimerFactory { private readonly ILoggerFactory loggerFactory; + private readonly TimeProvider timeProvider; + public AsyncTimerFactory(ILoggerFactory loggerFactory) + : this(loggerFactory, TimeProvider.System) + { + } + + public AsyncTimerFactory(ILoggerFactory loggerFactory, TimeProvider timeProvider) { this.loggerFactory = loggerFactory; + this.timeProvider = timeProvider; } public IAsyncTimer Create(TimeSpan period, string name) { var log = this.loggerFactory.CreateLogger($"{typeof(AsyncTimer).FullName}.{name}"); - return new AsyncTimer(period, name, log); + return new AsyncTimer(period, name, log, this.timeProvider); } } } diff --git a/src/Redis/Orleans.AdvancedReminders.Redis/GlobalUsings.cs b/src/Redis/Orleans.AdvancedReminders.Redis/GlobalUsings.cs new file mode 100644 index 0000000000..d59a9bde38 --- /dev/null +++ b/src/Redis/Orleans.AdvancedReminders.Redis/GlobalUsings.cs @@ -0,0 +1 @@ +global using Orleans.AdvancedReminders.Runtime; diff --git a/src/Redis/Orleans.AdvancedReminders.Redis/Hosting/RedisRemindersProviderBuilder.cs b/src/Redis/Orleans.AdvancedReminders.Redis/Hosting/RedisRemindersProviderBuilder.cs new file mode 100644 index 0000000000..72f191bebb --- /dev/null +++ b/src/Redis/Orleans.AdvancedReminders.Redis/Hosting/RedisRemindersProviderBuilder.cs @@ -0,0 +1,50 @@ +using Orleans.Providers; +using Microsoft.Extensions.Configuration; +using Orleans; +using Orleans.Hosting; +using Orleans.AdvancedReminders.Redis; +using StackExchange.Redis; +using System; +using Microsoft.Extensions.DependencyInjection; +using System.Threading.Tasks; + +[assembly: RegisterProvider("Redis", "AdvancedReminders", "Silo", typeof(AdvancedRedisRemindersProviderBuilder))] +[assembly: RegisterProvider("AzureRedisCache", "AdvancedReminders", "Silo", typeof(AdvancedRedisRemindersProviderBuilder))] + +namespace Orleans.Hosting; + +internal sealed class AdvancedRedisRemindersProviderBuilder : IProviderBuilder +{ + public void Configure(ISiloBuilder builder, string name, IConfigurationSection configurationSection) + { + builder.UseRedisAdvancedReminderService(_ => { }); + builder.Services.AddOptions() + .Configure((options, services) => + { + var serviceKey = configurationSection["ServiceKey"]; + if (!string.IsNullOrEmpty(serviceKey)) + { + // Get a connection multiplexer instance by name. + var multiplexer = services.GetRequiredKeyedService(serviceKey); + options.CreateMultiplexer = _ => Task.FromResult(multiplexer); + options.ConfigurationOptions = new ConfigurationOptions(); + } + else + { + // Construct a connection multiplexer from a connection string. + var connectionName = configurationSection["ConnectionName"]; + var connectionString = configurationSection["ConnectionString"]; + if (!string.IsNullOrEmpty(connectionName) && string.IsNullOrEmpty(connectionString)) + { + var rootConfiguration = services.GetRequiredService(); + connectionString = rootConfiguration.GetConnectionString(connectionName); + } + + if (!string.IsNullOrEmpty(connectionString)) + { + options.ConfigurationOptions = ConfigurationOptions.Parse(connectionString); + } + } + }); + } +} diff --git a/src/Redis/Orleans.AdvancedReminders.Redis/Hosting/SiloBuilderReminderExtensions.cs b/src/Redis/Orleans.AdvancedReminders.Redis/Hosting/SiloBuilderReminderExtensions.cs new file mode 100644 index 0000000000..c2d98103a5 --- /dev/null +++ b/src/Redis/Orleans.AdvancedReminders.Redis/Hosting/SiloBuilderReminderExtensions.cs @@ -0,0 +1,56 @@ +using System; + +using Microsoft.Extensions.DependencyInjection; +using Orleans.Configuration; +using Orleans.Configuration.Internal; +using Orleans.Hosting; +using Orleans.AdvancedReminders.Redis; + +namespace Orleans.Hosting +{ + /// + /// Silo host builder extensions. + /// + public static class SiloBuilderReminderExtensions + { + /// + /// Adds reminder storage backed by Redis. + /// + /// + /// The builder. + /// + /// + /// The delegate used to configure the reminder store. + /// + /// + /// The provided , for chaining. + /// + public static ISiloBuilder UseRedisAdvancedReminderService(this ISiloBuilder builder, Action configure) + { + builder.ConfigureServices(services => services.UseRedisAdvancedReminderService(configure)); + return builder; + } + + /// + /// Adds reminder storage backed by Redis. + /// + /// + /// The service collection. + /// + /// + /// The delegate used to configure the reminder store. + /// + /// + /// The provided , for chaining. + /// + public static IServiceCollection UseRedisAdvancedReminderService(this IServiceCollection services, Action configure) + { + services.AddAdvancedReminders(); + services.AddSingleton(); + services.Configure(configure); + services.AddSingleton(); + services.ConfigureFormatter(); + return services; + } + } +} diff --git a/src/Redis/Orleans.AdvancedReminders.Redis/Orleans.AdvancedReminders.Redis.csproj b/src/Redis/Orleans.AdvancedReminders.Redis/Orleans.AdvancedReminders.Redis.csproj new file mode 100644 index 0000000000..20fbdb6af3 --- /dev/null +++ b/src/Redis/Orleans.AdvancedReminders.Redis/Orleans.AdvancedReminders.Redis.csproj @@ -0,0 +1,31 @@ + + + + README.md + Microsoft.Orleans.AdvancedReminders.Redis + Microsoft Orleans Advanced Reminders for Redis + Redis provider for Microsoft Orleans Advanced Reminders. + $(PackageTags) Redis Reminders + $(DefaultTargetFrameworks) + true + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Redis/Orleans.AdvancedReminders.Redis/Providers/RedisReminderTableOptions.cs b/src/Redis/Orleans.AdvancedReminders.Redis/Providers/RedisReminderTableOptions.cs new file mode 100644 index 0000000000..533f41c2aa --- /dev/null +++ b/src/Redis/Orleans.AdvancedReminders.Redis/Providers/RedisReminderTableOptions.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Orleans.Runtime; +using StackExchange.Redis; + +namespace Orleans.AdvancedReminders.Redis +{ + /// + /// Redis reminder options. + /// + public class RedisReminderTableOptions + { + /// + /// Gets or sets the Redis client options. + /// + [RedactRedisConfigurationOptions] + public ConfigurationOptions ConfigurationOptions { get; set; } + + /// + /// The delegate used to create a Redis connection multiplexer. + /// + public Func> CreateMultiplexer { get; set; } = DefaultCreateMultiplexer; + + /// + /// Entry expiry, null by default. A value should be set ONLY for ephemeral environments (like in tests). + /// Setting a value different from null will cause reminder entries to be deleted after some period of time. + /// + public TimeSpan? EntryExpiry { get; set; } = null; + + /// + /// The default multiplexer creation delegate. + /// + public static async Task DefaultCreateMultiplexer(RedisReminderTableOptions options) => await ConnectionMultiplexer.ConnectAsync(options.ConfigurationOptions); + } + + internal class RedactRedisConfigurationOptions : RedactAttribute + { + public override string Redact(object value) => value is ConfigurationOptions cfg ? cfg.ToString(includePassword: false) : base.Redact(value); + } + + /// + /// Configuration validator for . + /// + public class RedisReminderTableOptionsValidator : IConfigurationValidator + { + private readonly RedisReminderTableOptions _options; + + public RedisReminderTableOptionsValidator(IOptions options) + { + _options = options.Value; + } + + public void ValidateConfiguration() + { + if (_options.ConfigurationOptions == null) + { + throw new OrleansConfigurationException($"Invalid configuration for {nameof(RedisReminderTable)}. {nameof(RedisReminderTableOptions)}.{nameof(_options.ConfigurationOptions)} is required."); + } + } + } +} diff --git a/src/Redis/Orleans.AdvancedReminders.Redis/README.md b/src/Redis/Orleans.AdvancedReminders.Redis/README.md new file mode 100644 index 0000000000..38e4e167e8 --- /dev/null +++ b/src/Redis/Orleans.AdvancedReminders.Redis/README.md @@ -0,0 +1,171 @@ +# Microsoft Orleans Advanced Reminders for Redis + +## Introduction +Microsoft Orleans Advanced Reminders for Redis stores reminder definitions in Redis. + +This package does not include a Redis-backed durable jobs implementation. You must also configure a durable jobs backend, for example `UseInMemoryDurableJobs()` for local development or `UseAzureBlobDurableJobs(...)` for persisted execution. + +## Getting Started +To use this package, install it via NuGet: + +```shell +dotnet add package Microsoft.Orleans.AdvancedReminders.Redis +``` + +## Example - Configuring Redis Advanced Reminders +```csharp +using Microsoft.Extensions.Hosting; +using Orleans.AdvancedReminders; +using Orleans.Configuration; +using Orleans.DurableJobs; +using Orleans.Hosting; +using StackExchange.Redis; + +var builder = Host.CreateApplicationBuilder(args) + .UseOrleans(siloBuilder => + { + siloBuilder + .UseLocalhostClustering() + .UseInMemoryDurableJobs() + // Configure Redis for reminder definitions + .UseRedisAdvancedReminderService(options => + { + options.ConfigurationOptions = ConfigurationOptions.Parse("localhost:6379"); + options.ConfigurationOptions.DefaultDatabase = 0; + }); + }); + +// Run the host +await builder.RunAsync(); +``` + +## Example - Using Reminders in a Grain +```csharp +using Orleans; +using Orleans.AdvancedReminders; +using Orleans.AdvancedReminders.Runtime; + +public class ReminderGrain : Grain, IReminderGrain, IRemindable +{ + private string _reminderName = "MyReminder"; + + public async Task StartReminder(string reminderName) + { + _reminderName = reminderName; + + // Register a persistent reminder + await RegisterOrUpdateReminder( + reminderName, + TimeSpan.FromMinutes(2), // Time to delay before the first tick (must be > 1 minute) + TimeSpan.FromMinutes(5)); // Period of the reminder (must be > 1 minute) + } + + public async Task StopReminder() + { + // Find and unregister the reminder + var reminder = await GetReminder(_reminderName); + if (reminder != null) + { + await UnregisterReminder(reminder); + } + } + + public Task ReceiveReminder(string reminderName, TickStatus status) + { + // This method is called when the reminder ticks + Console.WriteLine($"Reminder {reminderName} triggered at {DateTime.UtcNow}. Status: {status}"); + return Task.CompletedTask; + } +} +``` + +## Configuration via Microsoft.Extensions.Configuration + +You can configure Orleans Redis advanced reminders using `Microsoft.Extensions.Configuration` (such as `appsettings.json`) instead of configuring it in code. When using this approach, Orleans will automatically read the configuration from the `Orleans` section. You still need to configure a durable jobs backend separately. + +> **Note**: You can use either `"ProviderType": "Redis"` or `"ProviderType": "AzureRedisCache"` - both are supported and functionally equivalent. + +### Example - appsettings.json +```json +{ + "ConnectionStrings": { + "redis": "localhost:6379" + }, + "Orleans": { + "ClusterId": "my-cluster", + "ServiceId": "MyOrleansService", + "AdvancedReminders": { + "ProviderType": "Redis", + "ServiceKey": "redis", + "Database": 0, + "KeyPrefix": "reminder-" + } + } +} +``` + +### .NET Aspire Integration + +For applications using .NET Aspire, consider using the [.NET Aspire Redis integration](https://learn.microsoft.com/en-us/dotnet/aspire/caching/stackexchange-redis-integration) which provides simplified Redis configuration, automatic service discovery, health checks, and telemetry. The Aspire integration automatically configures connection strings that Orleans can consume via the configuration system. + +#### Example - Program.cs with Aspire Redis Integration +```csharp +using Microsoft.Extensions.Hosting; +using Orleans.Hosting; +using Microsoft.Extensions.DependencyInjection; + +var builder = Host.CreateApplicationBuilder(args); + +// Add service defaults (Aspire configurations) +builder.AddServiceDefaults(); + +// Add Redis via Aspire client integration +builder.AddKeyedRedisClient("redis"); + +// Add Orleans +builder.UseOrleans(siloBuilder => +{ + siloBuilder.UseInMemoryDurableJobs(); +}); + +var host = builder.Build(); +await host.StartAsync(); + +// Get a reference to a grain and call it +var client = host.Services.GetRequiredService(); +var grain = client.GetGrain("user123"); +await grain.StartReminder("AspireReminder"); + +Console.WriteLine("Reminder started with Aspire Redis!"); +await host.WaitForShutdownAsync(); +``` + +This example assumes your AppHost project has configured Redis like this: +```csharp +// In your AppHost/Program.cs +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("redis"); + +var orleans = builder.AddOrleans("orleans") + .WithReminders(redis); + +builder.AddProject("orleans-app") + .WithReference(orleans); + +builder.Build().Run(); +``` + +## Documentation +For more comprehensive documentation, please refer to: +- [Microsoft Orleans Documentation](https://learn.microsoft.com/dotnet/orleans/) +- [Reminders and Timers](https://learn.microsoft.com/en-us/dotnet/orleans/grains/timers-and-reminders) +- [Reminder Services](https://learn.microsoft.com/en-us/dotnet/orleans/implementation/reminder-services) +- [Redis Documentation](https://redis.io/documentation) + +## Feedback & Contributing +- If you have any issues or would like to provide feedback, please [open an issue on GitHub](https://github.com/dotnet/orleans/issues) +- Join our community on [Discord](https://aka.ms/orleans-discord) +- Follow the [@msftorleans](https://twitter.com/msftorleans) Twitter account for Orleans announcements +- Contributions are welcome! Please review our [contribution guidelines](https://github.com/dotnet/orleans/blob/main/CONTRIBUTING.md) +- This project is licensed under the [MIT license](https://github.com/dotnet/orleans/blob/main/LICENSE) diff --git a/src/Redis/Orleans.AdvancedReminders.Redis/Storage/RedisReminderTable.cs b/src/Redis/Orleans.AdvancedReminders.Redis/Storage/RedisReminderTable.cs new file mode 100644 index 0000000000..5b18f9ba95 --- /dev/null +++ b/src/Redis/Orleans.AdvancedReminders.Redis/Storage/RedisReminderTable.cs @@ -0,0 +1,403 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Orleans.Configuration; +using Orleans.Runtime; + +using StackExchange.Redis; +using static System.FormattableString; + +namespace Orleans.AdvancedReminders.Redis +{ + internal partial class RedisReminderTable : IReminderTable + { + private readonly RedisKey _hashSetKey; + private readonly RedisReminderTableOptions _redisOptions; + private readonly ClusterOptions _clusterOptions; + private readonly ILogger _logger; + private IConnectionMultiplexer _muxer; + private IDatabase _db; + + public RedisReminderTable( + ILogger logger, + IOptions clusterOptions, + IOptions redisOptions) + { + _redisOptions = redisOptions.Value; + _clusterOptions = clusterOptions.Value; + _logger = logger; + + _hashSetKey = Encoding.UTF8.GetBytes($"{_clusterOptions.ServiceId}/advanced-reminders"); + } + + public async Task Init() + { + try + { + _muxer = await _redisOptions.CreateMultiplexer(_redisOptions); + _db = _muxer.GetDatabase(); + + if (_redisOptions.EntryExpiry is { } expiry) + { + await _db.KeyExpireAsync(_hashSetKey, expiry); + } + } + catch (Exception exception) + { + throw new RedisRemindersException(Invariant($"{exception.GetType()}: {exception.Message}")); + } + } + + public async Task ReadRow(GrainId grainId, string reminderName) + { + try + { + var (from, to) = GetFilter(grainId, reminderName); + RedisValue[] values = await _db.SortedSetRangeByValueAsync(_hashSetKey, from, to); + if (values.Length == 0) + { + return null; + } + else + { + return ConvertToEntry(values.SingleOrDefault()); + } + } + catch (Exception exception) + { + throw new RedisRemindersException(Invariant($"{exception.GetType()}: {exception.Message}")); + } + } + + public async Task ReadRows(GrainId grainId) + { + try + { + var (from, to) = GetFilter(grainId); + RedisValue[] values = await _db.SortedSetRangeByValueAsync(_hashSetKey, from, to); + IEnumerable records = values.Select(static v => ConvertToEntry(v)); + return new ReminderTableData(records); + } + catch (Exception exception) + { + throw new RedisRemindersException(Invariant($"{exception.GetType()}: {exception.Message}")); + } + } + + public async Task ReadRows(uint begin, uint end) + { + try + { + var (_, from) = GetFilter(begin); + var (_, to) = GetFilter(end); + IEnumerable values; + if (begin < end) + { + // -----begin******end----- + values = await _db.SortedSetRangeByValueAsync(_hashSetKey, from, to); + } + else + { + // *****end------begin***** + RedisValue[] values1 = await _db.SortedSetRangeByValueAsync(_hashSetKey, from, "\"FFFFFFFF\",#"); + RedisValue[] values2 = await _db.SortedSetRangeByValueAsync(_hashSetKey, "\"00000000\",\"", to); + values = values1.Concat(values2); + } + + IEnumerable records = values.Select(static v => ConvertToEntry(v)); + return new ReminderTableData(records); + } + catch (Exception exception) + { + throw new RedisRemindersException(Invariant($"{exception.GetType()}: {exception.Message}")); + } + } + + public async Task RemoveRow(GrainId grainId, string reminderName, string eTag) + { + try + { + var (from, to) = GetFilter(grainId, reminderName, eTag); + long removed = await _db.SortedSetRemoveRangeByValueAsync(_hashSetKey, from, to); + return removed > 0; + } + catch (Exception exception) + { + throw new RedisRemindersException(Invariant($"{exception.GetType()}: {exception.Message}")); + } + } + + public async Task TestOnlyClearTable() + { + try + { + await _db.KeyDeleteAsync(_hashSetKey); + } + catch (Exception exception) + { + throw new RedisRemindersException(Invariant($"{exception.GetType()}: {exception.Message}")); + } + } + + public async Task UpsertRow(ReminderEntry entry) + { + const string UpsertScript = + """ + local key = KEYS[1] + local from = '[' .. ARGV[1] -- start of the conditional (with etag) key range + local to = '[' .. ARGV[2] -- end of the conditional (with etag) key range + local value = ARGV[3] + + -- Remove all entries for this reminder + local remRes = redis.call('ZREMRANGEBYLEX', key, from, to); + + -- Add the new reminder entry + local addRes = redis.call('ZADD', key, 0, value); + return { key, from, to, value, remRes, addRes } + """; + + try + { + LogDebugUpsertRow(new(entry), entry.ETag); + + var (newETag, value) = ConvertFromEntry(entry); + var (from, to) = GetFilter(entry.GrainId, entry.ReminderName); + var res = await _db.ScriptEvaluateAsync(UpsertScript, keys: new[] { _hashSetKey }, values: new[] { from, to, value }); + return newETag; + } + catch (Exception exception) when (exception is not Runtime.ReminderException) + { + throw new RedisRemindersException(Invariant($"{exception.GetType()}: {exception.Message}")); + } + } + + private static ReminderEntry ConvertToEntry(string reminderValue) + { + using var document = JsonDocument.Parse(CreatePayloadBuffer(reminderValue)); + var segments = document.RootElement; + + return new ReminderEntry + { + GrainId = GrainId.Parse(ReadRequiredString(segments, 1)), + ReminderName = ReadRequiredString(segments, 2), + ETag = ReadRequiredString(segments, 3), + StartAt = DateTime.Parse(ReadRequiredString(segments, 4), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + Period = TimeSpan.Parse(ReadRequiredString(segments, 5), CultureInfo.InvariantCulture), + CronExpression = ReadNullableString(segments, 6), + NextDueUtc = ReadNullableDateTime(segments, 7), + LastFireUtc = ReadNullableDateTime(segments, 8), + Priority = ReadReminderPriority(segments, 9), + Action = ReadMissedReminderAction(segments, 10), + CronTimeZoneId = ReadNullableString(segments, 11), + }; + } + + private static byte[] CreatePayloadBuffer(string reminderValue) + => Encoding.UTF8.GetBytes(string.Concat("[", reminderValue, "]")); + + private static string ReadRequiredString(JsonElement segments, int index) + { + if (segments.GetArrayLength() <= index) + { + throw new FormatException($"Reminder payload is missing segment {index}."); + } + + var segment = segments[index]; + return segment.ValueKind switch + { + JsonValueKind.String => segment.GetString() ?? throw new FormatException($"Reminder payload segment {index} is null."), + JsonValueKind.Null => throw new FormatException($"Reminder payload segment {index} is null."), + _ => segment.ToString(), + }; + } + + private static string ReadNullableString(JsonElement segments, int index) + { + if (segments.GetArrayLength() <= index) + { + return null; + } + + var segment = segments[index]; + if (segment.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return null; + } + + var value = segment.ValueKind is JsonValueKind.String + ? segment.GetString() + : segment.ToString(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static DateTime? ReadNullableDateTime(JsonElement segments, int index) + { + var value = ReadNullableString(segments, index); + return value is null ? null : DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + } + + private static ReminderPriority ReadReminderPriority(JsonElement segments, int index) + { + if (!TryReadInt32(segments, index, out var value)) + { + return ReminderPriority.Normal; + } + + return ParsePriority(value); + } + + private static MissedReminderAction ReadMissedReminderAction(JsonElement segments, int index) + { + if (!TryReadInt32(segments, index, out var value)) + { + return MissedReminderAction.Skip; + } + + return ParseAction(value); + } + + private static bool TryReadInt32(JsonElement segments, int index, out int value) + { + value = default; + if (segments.GetArrayLength() <= index) + { + return false; + } + + var token = segments[index]; + if (token.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return false; + } + + if (token.ValueKind is JsonValueKind.Number) + { + return token.TryGetInt32(out value); + } + + var text = token.ValueKind is JsonValueKind.String + ? token.GetString() + : token.ToString(); + return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + } + + private static ReminderPriority ParsePriority(int value) => value switch + { + (int)ReminderPriority.High => ReminderPriority.High, + (int)ReminderPriority.Normal => ReminderPriority.Normal, + _ => ReminderPriority.Normal, + }; + + private static MissedReminderAction ParseAction(int value) => value switch + { + (int)MissedReminderAction.FireImmediately => MissedReminderAction.FireImmediately, + (int)MissedReminderAction.Skip => MissedReminderAction.Skip, + (int)MissedReminderAction.Notify => MissedReminderAction.Notify, + _ => MissedReminderAction.Skip, + }; + + private (RedisValue from, RedisValue to) GetFilter(uint grainHash) + { + return GetFilter(grainHash.ToString("X8")); + } + + private (RedisValue from, RedisValue to) GetFilter(GrainId grainId) + { + return GetFilter(grainId.GetUniformHashCode().ToString("X8"), grainId.ToString()); + } + + private (RedisValue from, RedisValue to) GetFilter(GrainId grainId, string reminderName) + { + return GetFilter(grainId.GetUniformHashCode().ToString("X8"), grainId.ToString(), reminderName); + } + + private (RedisValue from, RedisValue to) GetFilter(GrainId grainId, string reminderName, string eTag) + { + return GetFilter(grainId.GetUniformHashCode().ToString("X8"), grainId.ToString(), reminderName, eTag); + } + + private (RedisValue from, RedisValue to) GetFilter(params string[] segments) + { + string prefix = SerializeSegments(segments); + return ($"{prefix},\"", $"{prefix},#"); + } + + private (RedisValue eTag, RedisValue value) ConvertFromEntry(ReminderEntry entry) + { + string grainHash = entry.GrainId.GetUniformHashCode().ToString("X8"); + string eTag = Guid.NewGuid().ToString(); + return (eTag, SerializeEntry(entry, grainHash, eTag)); + } + + private static string SerializeSegments(IReadOnlyList segments) + { + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartArray(); + for (var i = 0; i < segments.Count; i++) + { + writer.WriteStringValue(segments[i]); + } + + writer.WriteEndArray(); + } + + return TrimArrayDelimiters(buffer.WrittenSpan); + } + + private static string SerializeEntry(ReminderEntry entry, string grainHash, string eTag) + { + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartArray(); + writer.WriteStringValue(grainHash); + writer.WriteStringValue(entry.GrainId.ToString()); + writer.WriteStringValue(entry.ReminderName); + writer.WriteStringValue(eTag); + writer.WriteStringValue(entry.StartAt.ToString("O", CultureInfo.InvariantCulture)); + writer.WriteStringValue(entry.Period.ToString("c", CultureInfo.InvariantCulture)); + writer.WriteStringValue(entry.CronExpression ?? string.Empty); + writer.WriteStringValue(entry.NextDueUtc?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty); + writer.WriteStringValue(entry.LastFireUtc?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty); + writer.WriteNumberValue((int)entry.Priority); + writer.WriteNumberValue((int)entry.Action); + writer.WriteStringValue(entry.CronTimeZoneId ?? string.Empty); + writer.WriteEndArray(); + } + + return TrimArrayDelimiters(buffer.WrittenSpan); + } + + private static string TrimArrayDelimiters(ReadOnlySpan json) + { + if (json.Length < 2 || json[0] != (byte)'[' || json[^1] != (byte)']') + { + throw new FormatException("Reminder payload is not a JSON array."); + } + + return Encoding.UTF8.GetString(json[1..^1]); + } + + private readonly struct ReminderEntryLogValue(ReminderEntry entry) + { + public override string ToString() => entry.ToString(); + } + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "UpsertRow entry = {Entry}, ETag = {ETag}" + )] + private partial void LogDebugUpsertRow(ReminderEntryLogValue entry, string eTag); + } +} diff --git a/src/Redis/Orleans.AdvancedReminders.Redis/Storage/RedisRemindersException.cs b/src/Redis/Orleans.AdvancedReminders.Redis/Storage/RedisRemindersException.cs new file mode 100644 index 0000000000..e501d9ad80 --- /dev/null +++ b/src/Redis/Orleans.AdvancedReminders.Redis/Storage/RedisRemindersException.cs @@ -0,0 +1,44 @@ +using System; +using System.Runtime.Serialization; + +namespace Orleans.AdvancedReminders.Redis +{ + /// + /// Exception thrown from . + /// + [GenerateSerializer] + public class RedisRemindersException : Exception + { + /// + /// Initializes a new instance of . + /// + public RedisRemindersException() + { + } + + /// + /// Initializes a new instance of . + /// + /// The error message that explains the reason for the exception. + public RedisRemindersException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of . + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if no inner exception is specified. + public RedisRemindersException(string message, Exception inner) : base(message, inner) + { + } + + /// + [Obsolete] + protected RedisRemindersException( + SerializationInfo info, + StreamingContext context) : base(info, context) + { + } + } +} \ No newline at end of file diff --git a/src/api/AWS/Orleans.AdvancedReminders.DynamoDB/Orleans.AdvancedReminders.DynamoDB.cs b/src/api/AWS/Orleans.AdvancedReminders.DynamoDB/Orleans.AdvancedReminders.DynamoDB.cs new file mode 100644 index 0000000000..4a60a7dc0a --- /dev/null +++ b/src/api/AWS/Orleans.AdvancedReminders.DynamoDB/Orleans.AdvancedReminders.DynamoDB.cs @@ -0,0 +1,64 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Orleans.AdvancedReminders.DynamoDB +{ + public partial class DynamoDBClientOptions + { + [Redact] + public string AccessKey { get { throw null; } set { } } + + public string ProfileName { get { throw null; } set { } } + + [Redact] + public string SecretKey { get { throw null; } set { } } + + public string Service { get { throw null; } set { } } + + public string Token { get { throw null; } set { } } + } + + public partial class DynamoDBReminderStorageOptions : DynamoDBClientOptions + { + public bool CreateIfNotExists { get { throw null; } set { } } + + public int ReadCapacityUnits { get { throw null; } set { } } + + public string TableName { get { throw null; } set { } } + + public bool UpdateIfExists { get { throw null; } set { } } + + public bool UseProvisionedThroughput { get { throw null; } set { } } + + public int WriteCapacityUnits { get { throw null; } set { } } + } + + public static partial class DynamoDBReminderStorageOptionsExtensions + { + public static void ParseConnectionString(this DynamoDBReminderStorageOptions options, string connectionString) { } + } + + public partial class DynamoDBReminderTableOptions + { + [RedactConnectionString] + public string ConnectionString { get { throw null; } set { } } + } +} + +namespace Orleans.Hosting +{ + public static partial class DynamoDBServiceCollectionReminderExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection UseDynamoDBAdvancedReminderService(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action configure) { throw null; } + } + + public static partial class DynamoDBSiloBuilderReminderExtensions + { + public static ISiloBuilder UseDynamoDBAdvancedReminderService(this ISiloBuilder builder, System.Action configure) { throw null; } + } +} \ No newline at end of file diff --git a/src/api/AdoNet/Orleans.AdvancedReminders.AdoNet/Orleans.AdvancedReminders.AdoNet.cs b/src/api/AdoNet/Orleans.AdvancedReminders.AdoNet/Orleans.AdvancedReminders.AdoNet.cs new file mode 100644 index 0000000000..17e6486489 --- /dev/null +++ b/src/api/AdoNet/Orleans.AdvancedReminders.AdoNet/Orleans.AdvancedReminders.AdoNet.cs @@ -0,0 +1,37 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Orleans.AdvancedReminders.AdoNet +{ + public partial class AdoNetReminderTableOptions + { + [Redact] + public string ConnectionString { get { throw null; } set { } } + + public string Invariant { get { throw null; } set { } } + } + + public partial class AdoNetReminderTableOptionsValidator : IConfigurationValidator + { + public AdoNetReminderTableOptionsValidator(Microsoft.Extensions.Options.IOptions options) { } + + public void ValidateConfiguration() { } + } +} + +namespace Orleans.Hosting +{ + public static partial class SiloBuilderReminderExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection UseAdoNetAdvancedReminderService(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action> configureOptions) { throw null; } + + public static ISiloBuilder UseAdoNetAdvancedReminderService(this ISiloBuilder builder, System.Action> configureOptions) { throw null; } + + public static ISiloBuilder UseAdoNetAdvancedReminderService(this ISiloBuilder builder, System.Action configureOptions) { throw null; } + } +} \ No newline at end of file diff --git a/src/api/Azure/Orleans.AdvancedReminders.AzureStorage/Orleans.AdvancedReminders.AzureStorage.cs b/src/api/Azure/Orleans.AdvancedReminders.AzureStorage/Orleans.AdvancedReminders.AzureStorage.cs new file mode 100644 index 0000000000..81517ccd8d --- /dev/null +++ b/src/api/Azure/Orleans.AdvancedReminders.AzureStorage/Orleans.AdvancedReminders.AzureStorage.cs @@ -0,0 +1,131 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Orleans.AdvancedReminders.AzureStorage +{ + public sealed partial class AzureBasedReminderTable : IReminderTable + { + public AzureBasedReminderTable(Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, Microsoft.Extensions.Options.IOptions clusterOptions, Microsoft.Extensions.Options.IOptions storageOptions) { } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task ReadRow(Orleans.Runtime.GrainId grainId, string reminderName) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task ReadRows(Orleans.Runtime.GrainId grainId) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task ReadRows(uint begin, uint end) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task RemoveRow(Orleans.Runtime.GrainId grainId, string reminderName, string eTag) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task StartAsync(System.Threading.CancellationToken cancellationToken) { throw null; } + + public System.Threading.Tasks.Task StopAsync(System.Threading.CancellationToken cancellationToken) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task TestOnlyClearTable() { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task UpsertRow(ReminderEntry entry) { throw null; } + } + + public partial class AzureStorageOperationOptions + { + public Azure.Data.Tables.TableClientOptions ClientOptions { get { throw null; } set { } } + + public AzureStoragePolicyOptions StoragePolicyOptions { get { throw null; } } + + public virtual string TableName { get { throw null; } set { } } + + public Azure.Data.Tables.TableServiceClient TableServiceClient { get { throw null; } set { } } + + [System.Obsolete("Set the TableServiceClient property directly.")] + public void ConfigureTableServiceClient(System.Func> createClientCallback) { } + + [System.Obsolete("Set the TableServiceClient property directly.")] + public void ConfigureTableServiceClient(string connectionString) { } + + [System.Obsolete("Set the TableServiceClient property directly.")] + public void ConfigureTableServiceClient(System.Uri serviceUri, Azure.AzureSasCredential azureSasCredential) { } + + [System.Obsolete("Set the TableServiceClient property directly.")] + public void ConfigureTableServiceClient(System.Uri serviceUri, Azure.Core.TokenCredential tokenCredential) { } + + [System.Obsolete("Set the TableServiceClient property directly.")] + public void ConfigureTableServiceClient(System.Uri serviceUri, Azure.Data.Tables.TableSharedKeyCredential sharedKeyCredential) { } + + [System.Obsolete("Set the TableServiceClient property directly.")] + public void ConfigureTableServiceClient(System.Uri serviceUri) { } + } + + public partial class AzureStorageOperationOptionsValidator : IConfigurationValidator where TOptions : AzureStorageOperationOptions + { + public AzureStorageOperationOptionsValidator(TOptions options, string name = null) { } + + public string Name { get { throw null; } } + + public TOptions Options { get { throw null; } } + + public virtual void ValidateConfiguration() { } + } + + public partial class AzureStoragePolicyOptions + { + public System.TimeSpan CreationTimeout { get { throw null; } set { } } + + public int MaxBulkUpdateRows { get { throw null; } set { } } + + public int MaxCreationRetries { get { throw null; } set { } } + + public int MaxOperationRetries { get { throw null; } set { } } + + public System.TimeSpan OperationTimeout { get { throw null; } set { } } + + public System.TimeSpan PauseBetweenCreationRetries { get { throw null; } set { } } + + public System.TimeSpan PauseBetweenOperationRetries { get { throw null; } set { } } + } + + public partial class AzureTableReminderStorageOptions : AzureStorageOperationOptions + { + public const string DEFAULT_TABLE_NAME = "OrleansAdvancedReminders"; + public Azure.Storage.Blobs.BlobServiceClient BlobServiceClient { get { throw null; } set { } } + + public string JobContainerName { get { throw null; } set { } } + + public override string TableName { get { throw null; } set { } } + } + + public partial class AzureTableReminderStorageOptionsValidator : AzureStorageOperationOptionsValidator + { + public AzureTableReminderStorageOptionsValidator(AzureTableReminderStorageOptions options, string name) : base(default!, default!) { } + } +} + +namespace Orleans.Hosting +{ + public static partial class AzureStorageReminderServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection UseAzureTableAdvancedReminderService(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action> configureOptions) { throw null; } + + public static Microsoft.Extensions.DependencyInjection.IServiceCollection UseAzureTableAdvancedReminderService(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action configure) { throw null; } + + public static Microsoft.Extensions.DependencyInjection.IServiceCollection UseAzureTableAdvancedReminderService(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, string connectionString) { throw null; } + } + + public static partial class AzureStorageReminderSiloBuilderExtensions + { + public static ISiloBuilder UseAzureTableAdvancedReminderService(this ISiloBuilder builder, System.Action> configureOptions) { throw null; } + + public static ISiloBuilder UseAzureTableAdvancedReminderService(this ISiloBuilder builder, System.Action configure) { throw null; } + + public static ISiloBuilder UseAzureTableAdvancedReminderService(this ISiloBuilder builder, string connectionString) { throw null; } + } +} \ No newline at end of file diff --git a/src/api/Azure/Orleans.AdvancedReminders.Cosmos/Orleans.AdvancedReminders.Cosmos.cs b/src/api/Azure/Orleans.AdvancedReminders.Cosmos/Orleans.AdvancedReminders.Cosmos.cs new file mode 100644 index 0000000000..3b67f6eee4 --- /dev/null +++ b/src/api/Azure/Orleans.AdvancedReminders.Cosmos/Orleans.AdvancedReminders.Cosmos.cs @@ -0,0 +1,69 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Orleans.AdvancedReminders.Cosmos +{ + public abstract partial class CosmosOptions + { + public bool CleanResourcesOnInitialization { get { throw null; } set { } } + + public Microsoft.Azure.Cosmos.CosmosClientOptions ClientOptions { get { throw null; } set { } } + + public string ContainerName { get { throw null; } set { } } + + public Microsoft.Azure.Cosmos.ThroughputProperties? ContainerThroughputProperties { get { throw null; } set { } } + + public string DatabaseName { get { throw null; } set { } } + + public int? DatabaseThroughput { get { throw null; } set { } } + + public bool IsResourceCreationEnabled { get { throw null; } set { } } + + public ICosmosOperationExecutor OperationExecutor { get { throw null; } set { } } + + public void ConfigureCosmosClient(System.Func> createClient) { } + + public void ConfigureCosmosClient(string accountEndpoint, Azure.AzureKeyCredential authKeyOrResourceTokenCredential) { } + + public void ConfigureCosmosClient(string accountEndpoint, Azure.Core.TokenCredential tokenCredential) { } + + public void ConfigureCosmosClient(string accountEndpoint, string authKeyOrResourceToken) { } + + public void ConfigureCosmosClient(string connectionString) { } + } + + public partial class CosmosOptionsValidator : IConfigurationValidator where TOptions : CosmosOptions + { + public CosmosOptionsValidator(TOptions options, string name) { } + + public void ValidateConfiguration() { } + } + + public partial class CosmosReminderTableOptions : CosmosOptions + { + } + + public partial interface ICosmosOperationExecutor + { + System.Threading.Tasks.Task ExecuteOperation(System.Func> func, TArg arg); + } +} + +namespace Orleans.Hosting +{ + public static partial class HostingExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection UseCosmosAdvancedReminderService(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action> configure) { throw null; } + + public static Microsoft.Extensions.DependencyInjection.IServiceCollection UseCosmosAdvancedReminderService(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action configure) { throw null; } + + public static ISiloBuilder UseCosmosAdvancedReminderService(this ISiloBuilder builder, System.Action> configure) { throw null; } + + public static ISiloBuilder UseCosmosAdvancedReminderService(this ISiloBuilder builder, System.Action configure) { throw null; } + } +} \ No newline at end of file diff --git a/src/api/Orleans.AdvancedReminders/Orleans.AdvancedReminders.cs b/src/api/Orleans.AdvancedReminders/Orleans.AdvancedReminders.cs new file mode 100644 index 0000000000..88a7502aa9 --- /dev/null +++ b/src/api/Orleans.AdvancedReminders/Orleans.AdvancedReminders.cs @@ -0,0 +1,1678 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Orleans.AdvancedReminders +{ + public static partial class GrainReminderCronExtensions + { + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, ReminderCronBuilder cronBuilder, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, ReminderCronBuilder cronBuilder) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, ReminderCronExpression cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, ReminderCronExpression cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, ReminderCronExpression cronExpression, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, ReminderCronExpression cronExpression) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, string cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, string cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, string cronExpression, System.TimeZoneInfo? timeZone, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, string cronExpression, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, string cronExpression) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, ReminderCronBuilder cronBuilder, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, ReminderCronBuilder cronBuilder) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, ReminderCronExpression cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, ReminderCronExpression cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, ReminderCronExpression cronExpression, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, ReminderCronExpression cronExpression) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, string cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, string cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, string cronExpression, System.TimeZoneInfo? timeZone, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, string cronExpression, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, string cronExpression) { throw null; } + } + + public static partial class GrainReminderExtensions + { + public static System.Threading.Tasks.Task GetReminder(this Grain grain, string reminderName) { throw null; } + + public static System.Threading.Tasks.Task GetReminder(this IGrainBase grain, string reminderName) { throw null; } + + public static System.Threading.Tasks.Task> GetReminders(this Grain grain) { throw null; } + + public static System.Threading.Tasks.Task> GetReminders(this IGrainBase grain) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, System.DateTime dueAtUtc, System.TimeSpan period, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, System.DateTime dueAtUtc, System.TimeSpan period) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, ReminderSchedule schedule, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, ReminderSchedule schedule) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, System.TimeSpan dueTime, System.TimeSpan period, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Grain grain, string reminderName, System.TimeSpan dueTime, System.TimeSpan period) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, System.DateTime dueAtUtc, System.TimeSpan period, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, System.DateTime dueAtUtc, System.TimeSpan period) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, ReminderSchedule schedule, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, ReminderSchedule schedule) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, System.TimeSpan dueTime, System.TimeSpan period, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IGrainBase grain, string reminderName, System.TimeSpan dueTime, System.TimeSpan period) { throw null; } + + public static System.Threading.Tasks.Task UnregisterReminder(this Grain grain, IGrainReminder reminder) { throw null; } + + public static System.Threading.Tasks.Task UnregisterReminder(this IGrainBase grain, IGrainReminder reminder) { throw null; } + } + + public partial interface IGrainReminder + { + Runtime.MissedReminderAction Action { get; } + + string CronExpression { get; } + + string CronTimeZone { get; } + + Runtime.ReminderPriority Priority { get; } + + string ReminderName { get; } + } + + public partial interface IRemindable : IGrain, Orleans.Runtime.IAddressable + { + System.Threading.Tasks.Task ReceiveReminder(string reminderName, Runtime.TickStatus status); + } + + public partial interface IReminderIterator + { + System.Collections.Generic.IAsyncEnumerable EnumerateAllAsync(int pageSize = 256, System.Threading.CancellationToken cancellationToken = default); + System.Collections.Generic.IAsyncEnumerable EnumerateDueInRangeAsync(System.DateTime fromUtcInclusive, System.DateTime toUtcInclusive, int pageSize = 256, System.Threading.CancellationToken cancellationToken = default); + System.Collections.Generic.IAsyncEnumerable EnumerateFilteredAsync(ReminderQueryFilter filter, int pageSize = 256, System.Threading.CancellationToken cancellationToken = default); + System.Collections.Generic.IAsyncEnumerable EnumerateOverdueAsync(System.TimeSpan overdueBy, int pageSize = 256, System.Threading.CancellationToken cancellationToken = default); + } + + public partial interface IReminderManagementGrain : IGrainWithGuidKey, IGrain, Orleans.Runtime.IAddressable + { + System.Threading.Tasks.Task CountAllAsync(); + System.Threading.Tasks.Task DeleteAsync(Orleans.Runtime.GrainId grainId, string name); + System.Threading.Tasks.Task ListAllAsync(int pageSize = 256, string? continuationToken = null); + System.Threading.Tasks.Task ListDueInRangeAsync(System.DateTime fromUtcInclusive, System.DateTime toUtcInclusive, int pageSize = 256, string? continuationToken = null); + System.Threading.Tasks.Task ListFilteredAsync(ReminderQueryFilter filter, int pageSize = 256, string? continuationToken = null); + System.Threading.Tasks.Task> ListForGrainAsync(Orleans.Runtime.GrainId grainId); + System.Threading.Tasks.Task ListOverdueAsync(System.TimeSpan overdueBy, int pageSize = 256, string? continuationToken = null); + System.Threading.Tasks.Task RepairAsync(Orleans.Runtime.GrainId grainId, string name); + System.Threading.Tasks.Task SetActionAsync(Orleans.Runtime.GrainId grainId, string name, Runtime.MissedReminderAction action); + System.Threading.Tasks.Task SetPriorityAsync(Orleans.Runtime.GrainId grainId, string name, Runtime.ReminderPriority priority); + System.Threading.Tasks.Task> UpcomingAsync(System.TimeSpan horizon); + } + + public partial interface IReminderService + { + System.Threading.Tasks.Task GetReminder(Orleans.Runtime.GrainId grainId, string reminderName); + System.Threading.Tasks.Task> GetReminders(Orleans.Runtime.GrainId grainId); + System.Threading.Tasks.Task ProcessDueReminderAsync(Orleans.Runtime.GrainId grainId, string reminderName, string? expectedETag, System.Threading.CancellationToken cancellationToken); + System.Threading.Tasks.Task RegisterOrUpdateReminder(Orleans.Runtime.GrainId grainId, string reminderName, ReminderSchedule schedule, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action); + System.Threading.Tasks.Task UnregisterReminder(IGrainReminder reminder); + } + + public partial interface IReminderTable + { + [System.Obsolete("Implement and use StartAsync instead")] + System.Threading.Tasks.Task Init(); + System.Threading.Tasks.Task ReadRow(Orleans.Runtime.GrainId grainId, string reminderName); + System.Threading.Tasks.Task ReadRows(Orleans.Runtime.GrainId grainId); + System.Threading.Tasks.Task ReadRows(uint begin, uint end); + System.Threading.Tasks.Task RemoveRow(Orleans.Runtime.GrainId grainId, string reminderName, string eTag); + System.Threading.Tasks.Task StartAsync(System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task StopAsync(System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task TestOnlyClearTable(); + System.Threading.Tasks.Task UpsertRow(ReminderEntry entry); + } + + [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple = true, Inherited = false)] + public sealed partial class RegisterReminderAttribute : System.Attribute + { + public RegisterReminderAttribute(string name, double dueSeconds, double periodSeconds, Runtime.ReminderPriority priority = Runtime.ReminderPriority.Normal, Runtime.MissedReminderAction action = Runtime.MissedReminderAction.Skip) { } + + public RegisterReminderAttribute(string name, string cron, Runtime.ReminderPriority priority = Runtime.ReminderPriority.Normal, Runtime.MissedReminderAction action = Runtime.MissedReminderAction.Skip) { } + + public Runtime.MissedReminderAction Action { get { throw null; } } + + public string? Cron { get { throw null; } } + + public System.TimeSpan? Due { get { throw null; } } + + public string Name { get { throw null; } } + + public System.TimeSpan? Period { get { throw null; } } + + public Runtime.ReminderPriority Priority { get { throw null; } } + } + + public sealed partial class ReminderCronBuilder + { + internal ReminderCronBuilder() { } + + public System.TimeZoneInfo TimeZone { get { throw null; } } + + public ReminderCronExpression Build() { throw null; } + + public static ReminderCronBuilder DailyAt(int hour, int minute) { throw null; } + + public static ReminderCronBuilder DailyAt(int hour, int minute, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder DailyAt(int hour, int minute, int second) { throw null; } + + public static ReminderCronBuilder DailyAt(int hour, int minute, int second, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder DailyAt(System.TimeOnly time) { throw null; } + + public static ReminderCronBuilder DailyAt(System.TimeOnly time, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder DailyAt(System.TimeSpan timeOfDay) { throw null; } + + public static ReminderCronBuilder DailyAt(System.TimeSpan timeOfDay, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder EveryMinute() { throw null; } + + public static ReminderCronBuilder EveryMinute(System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder FromExpression(string expression, System.TimeZoneInfo? timeZone) { throw null; } + + public static ReminderCronBuilder FromExpression(string expression) { throw null; } + + public System.DateTime? GetNextOccurrence(System.DateTime fromUtc, bool inclusive = false) { throw null; } + + public System.Collections.Generic.IEnumerable GetOccurrences(System.DateTime fromUtc, System.DateTime toUtc, bool fromInclusive = true, bool toInclusive = false) { throw null; } + + public static ReminderCronBuilder HourlyAt(int minute) { throw null; } + + public static ReminderCronBuilder HourlyAt(int minute, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder HourlyAt(int minute, int second) { throw null; } + + public static ReminderCronBuilder HourlyAt(int minute, int second, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder HourlyAt(System.TimeSpan offset) { throw null; } + + public static ReminderCronBuilder HourlyAt(System.TimeSpan offset, System.TimeZoneInfo timeZone) { throw null; } + + public ReminderCronBuilder InTimeZone(string timeZoneId) { throw null; } + + public ReminderCronBuilder InTimeZone(System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, int hour, int minute) { throw null; } + + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, int hour, int minute, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, int hour, int minute, int second) { throw null; } + + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, int hour, int minute, int second, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, System.TimeOnly time) { throw null; } + + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, System.TimeOnly time, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, System.TimeSpan timeOfDay) { throw null; } + + public static ReminderCronBuilder MonthlyOn(int dayOfMonth, System.TimeSpan timeOfDay, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder MonthlyOnLastDay(int hour, int minute) { throw null; } + + public static ReminderCronBuilder MonthlyOnLastDay(int hour, int minute, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder MonthlyOnLastDay(int hour, int minute, int second) { throw null; } + + public static ReminderCronBuilder MonthlyOnLastDay(int hour, int minute, int second, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder MonthlyOnLastDay(System.TimeOnly time) { throw null; } + + public static ReminderCronBuilder MonthlyOnLastDay(System.TimeOnly time, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder MonthlyOnLastDay(System.TimeSpan timeOfDay) { throw null; } + + public static ReminderCronBuilder MonthlyOnLastDay(System.TimeSpan timeOfDay, System.TimeZoneInfo timeZone) { throw null; } + + public ReminderCronExpression ToCronExpression() { throw null; } + + public string ToExpressionString() { throw null; } + + public static ReminderCronBuilder WeekdaysAt(int hour, int minute) { throw null; } + + public static ReminderCronBuilder WeekdaysAt(int hour, int minute, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder WeekdaysAt(int hour, int minute, int second) { throw null; } + + public static ReminderCronBuilder WeekdaysAt(int hour, int minute, int second, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder WeekdaysAt(System.TimeOnly time) { throw null; } + + public static ReminderCronBuilder WeekdaysAt(System.TimeOnly time, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder WeekdaysAt(System.TimeSpan timeOfDay) { throw null; } + + public static ReminderCronBuilder WeekdaysAt(System.TimeSpan timeOfDay, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder WeekendsAt(int hour, int minute) { throw null; } + + public static ReminderCronBuilder WeekendsAt(int hour, int minute, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder WeekendsAt(int hour, int minute, int second) { throw null; } + + public static ReminderCronBuilder WeekendsAt(int hour, int minute, int second, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder WeekendsAt(System.TimeOnly time) { throw null; } + + public static ReminderCronBuilder WeekendsAt(System.TimeOnly time, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder WeekendsAt(System.TimeSpan timeOfDay) { throw null; } + + public static ReminderCronBuilder WeekendsAt(System.TimeSpan timeOfDay, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder WeeklyOn(System.DayOfWeek dayOfWeek, int hour, int minute) { throw null; } + + public static ReminderCronBuilder WeeklyOn(System.DayOfWeek dayOfWeek, int hour, int minute, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder WeeklyOn(System.DayOfWeek dayOfWeek, int hour, int minute, int second) { throw null; } + + public static ReminderCronBuilder WeeklyOn(System.DayOfWeek dayOfWeek, int hour, int minute, int second, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder WeeklyOn(System.DayOfWeek dayOfWeek, System.TimeOnly time) { throw null; } + + public static ReminderCronBuilder WeeklyOn(System.DayOfWeek dayOfWeek, System.TimeOnly time, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder WeeklyOn(System.DayOfWeek dayOfWeek, System.TimeSpan timeOfDay) { throw null; } + + public static ReminderCronBuilder WeeklyOn(System.DayOfWeek dayOfWeek, System.TimeSpan timeOfDay, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, int hour, int minute) { throw null; } + + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, int hour, int minute, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, int hour, int minute, int second) { throw null; } + + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, int hour, int minute, int second, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, System.TimeOnly time) { throw null; } + + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, System.TimeOnly time, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, System.TimeSpan timeOfDay) { throw null; } + + public static ReminderCronBuilder YearlyOn(int month, int dayOfMonth, System.TimeSpan timeOfDay, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder YearlyOn(System.DateOnly date, int hour, int minute) { throw null; } + + public static ReminderCronBuilder YearlyOn(System.DateOnly date, int hour, int minute, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder YearlyOn(System.DateOnly date, int hour, int minute, int second) { throw null; } + + public static ReminderCronBuilder YearlyOn(System.DateOnly date, int hour, int minute, int second, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder YearlyOn(System.DateOnly date, System.TimeOnly time) { throw null; } + + public static ReminderCronBuilder YearlyOn(System.DateOnly date, System.TimeOnly time, System.TimeZoneInfo timeZone) { throw null; } + + public static ReminderCronBuilder YearlyOn(System.DateOnly date, System.TimeSpan timeOfDay) { throw null; } + + public static ReminderCronBuilder YearlyOn(System.DateOnly date, System.TimeSpan timeOfDay, System.TimeZoneInfo timeZone) { throw null; } + } + + public sealed partial class ReminderCronExpression : System.IEquatable + { + internal ReminderCronExpression() { } + + public string ExpressionText { get { throw null; } } + + public bool Equals(ReminderCronExpression? other) { throw null; } + + public override bool Equals(object? obj) { throw null; } + + public override int GetHashCode() { throw null; } + + public System.DateTime? GetNextOccurrence(System.DateTime fromUtc, bool inclusive = false) { throw null; } + + public System.Collections.Generic.IEnumerable GetOccurrences(System.DateTime fromUtc, System.DateTime toUtc, bool fromInclusive = true, bool toInclusive = false) { throw null; } + + public static ReminderCronExpression Parse(string expression) { throw null; } + + public string ToExpressionString() { throw null; } + + public override string ToString() { throw null; } + + public static bool TryParse(string expression, out ReminderCronExpression? cronExpression) { throw null; } + } + + public static partial class ReminderCronRegistrationExtensions + { + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, ReminderCronBuilder cronBuilder, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, ReminderCronBuilder cronBuilder) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, ReminderCronExpression cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, ReminderCronExpression cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, ReminderCronExpression cronExpression, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, ReminderCronExpression cronExpression) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, string cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action, string? cronTimeZoneId) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, string cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, string cronExpression, System.TimeZoneInfo? timeZone, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, string cronExpression, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, string cronExpression) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, ReminderCronBuilder cronBuilder, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, ReminderCronBuilder cronBuilder) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, ReminderCronExpression cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, ReminderCronExpression cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, ReminderCronExpression cronExpression, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, ReminderCronExpression cronExpression) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, string cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action, string? cronTimeZoneId) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, string cronExpression, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, string cronExpression, System.TimeZoneInfo? timeZone, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, string cronExpression, System.TimeZoneInfo? timeZone) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, string cronExpression) { throw null; } + } + + [GenerateSerializer] + public sealed partial class ReminderEntry + { + [Id(9)] + public Runtime.MissedReminderAction Action { get { throw null; } set { } } + + [Id(5)] + public string CronExpression { get { throw null; } set { } } + + [Id(10)] + public string CronTimeZoneId { get { throw null; } set { } } + + [Id(4)] + public string ETag { get { throw null; } set { } } + + [Id(0)] + public Orleans.Runtime.GrainId GrainId { get { throw null; } set { } } + + [Id(7)] + public System.DateTime? LastFireUtc { get { throw null; } set { } } + + [Id(6)] + public System.DateTime? NextDueUtc { get { throw null; } set { } } + + [Id(3)] + public System.TimeSpan Period { get { throw null; } set { } } + + [Id(8)] + public Runtime.ReminderPriority Priority { get { throw null; } set { } } + + [Id(1)] + public string ReminderName { get { throw null; } set { } } + + [Id(2)] + public System.DateTime StartAt { get { throw null; } set { } } + + public override string ToString() { throw null; } + } + + public sealed partial class ReminderIterator : IReminderIterator + { + public ReminderIterator(IReminderManagementGrain managementGrain) { } + + public System.Collections.Generic.IAsyncEnumerable EnumerateAllAsync(int pageSize = 256, System.Threading.CancellationToken cancellationToken = default) { throw null; } + + public System.Collections.Generic.IAsyncEnumerable EnumerateDueInRangeAsync(System.DateTime fromUtcInclusive, System.DateTime toUtcInclusive, int pageSize = 256, System.Threading.CancellationToken cancellationToken = default) { throw null; } + + public System.Collections.Generic.IAsyncEnumerable EnumerateFilteredAsync(ReminderQueryFilter filter, int pageSize = 256, System.Threading.CancellationToken cancellationToken = default) { throw null; } + + public System.Collections.Generic.IAsyncEnumerable EnumerateOverdueAsync(System.TimeSpan overdueBy, int pageSize = 256, System.Threading.CancellationToken cancellationToken = default) { throw null; } + } + + public sealed partial class ReminderManagementGrain : Grain, IReminderManagementGrain, IGrainWithGuidKey, IGrain, Orleans.Runtime.IAddressable + { + public ReminderManagementGrain(IReminderTable reminderTable) { } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task CountAllAsync() { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task DeleteAsync(Orleans.Runtime.GrainId grainId, string name) { throw null; } + + public System.Threading.Tasks.Task ListAllAsync(int pageSize = 256, string? continuationToken = null) { throw null; } + + public System.Threading.Tasks.Task ListDueInRangeAsync(System.DateTime fromUtcInclusive, System.DateTime toUtcInclusive, int pageSize = 256, string? continuationToken = null) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task ListFilteredAsync(ReminderQueryFilter filter, int pageSize = 256, string? continuationToken = null) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task> ListForGrainAsync(Orleans.Runtime.GrainId grainId) { throw null; } + + public System.Threading.Tasks.Task ListOverdueAsync(System.TimeSpan overdueBy, int pageSize = 256, string? continuationToken = null) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task RepairAsync(Orleans.Runtime.GrainId grainId, string name) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task SetActionAsync(Orleans.Runtime.GrainId grainId, string name, Runtime.MissedReminderAction action) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task SetPriorityAsync(Orleans.Runtime.GrainId grainId, string name, Runtime.ReminderPriority priority) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task> UpcomingAsync(System.TimeSpan horizon) { throw null; } + } + + public static partial class ReminderManagementGrainExtensions + { + public static IReminderIterator CreateIterator(this IReminderManagementGrain managementGrain) { throw null; } + + public static System.Collections.Generic.IAsyncEnumerable EnumerateAllAsync(this IReminderManagementGrain managementGrain, int pageSize = 256, System.Threading.CancellationToken cancellationToken = default) { throw null; } + + public static System.Collections.Generic.IAsyncEnumerable EnumerateDueInRangeAsync(this IReminderManagementGrain managementGrain, System.DateTime fromUtcInclusive, System.DateTime toUtcInclusive, int pageSize = 256, System.Threading.CancellationToken cancellationToken = default) { throw null; } + + public static System.Collections.Generic.IAsyncEnumerable EnumerateFilteredAsync(this IReminderManagementGrain managementGrain, ReminderQueryFilter filter, int pageSize = 256, System.Threading.CancellationToken cancellationToken = default) { throw null; } + + public static System.Collections.Generic.IAsyncEnumerable EnumerateOverdueAsync(this IReminderManagementGrain managementGrain, System.TimeSpan overdueBy, int pageSize = 256, System.Threading.CancellationToken cancellationToken = default) { throw null; } + } + + [GenerateSerializer] + public sealed partial class ReminderManagementPage + { + [Id(1)] + public string? ContinuationToken { get { throw null; } init { } } + + [Id(0)] + public System.Collections.Generic.List Reminders { get { throw null; } init { } } + } + + public sealed partial class ReminderOptions + { + public System.TimeSpan InitializationTimeout { get { throw null; } set { } } + + public System.TimeSpan MinimumReminderPeriod { get { throw null; } set { } } + + public System.TimeSpan MissedReminderGracePeriod { get { throw null; } set { } } + } + + [GenerateSerializer] + public sealed partial class ReminderQueryFilter + { + [Id(3)] + public Runtime.MissedReminderAction? Action { get { throw null; } init { } } + + [Id(0)] + public System.DateTime? DueFromUtcInclusive { get { throw null; } init { } } + + [Id(1)] + public System.DateTime? DueToUtcInclusive { get { throw null; } init { } } + + [Id(7)] + public System.TimeSpan MissedBy { get { throw null; } init { } } + + [Id(6)] + public System.TimeSpan OverdueBy { get { throw null; } init { } } + + [Id(2)] + public Runtime.ReminderPriority? Priority { get { throw null; } init { } } + + [Id(4)] + public Runtime.ReminderScheduleKind? ScheduleKind { get { throw null; } init { } } + + [Id(5)] + public ReminderQueryStatus Status { get { throw null; } init { } } + } + + [System.Flags] + public enum ReminderQueryStatus : byte + { + Any = 0, + Due = 1, + Overdue = 2, + Missed = 4, + Upcoming = 8 + } + + public static partial class ReminderRegistrationExtensions + { + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, System.DateTime dueAtUtc, System.TimeSpan period, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, System.DateTime dueAtUtc, System.TimeSpan period) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, System.TimeSpan dueTime, System.TimeSpan period, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this IReminderService service, Orleans.Runtime.GrainId grainId, string reminderName, System.TimeSpan dueTime, System.TimeSpan period) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, System.DateTime dueAtUtc, System.TimeSpan period, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, System.DateTime dueAtUtc, System.TimeSpan period) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, System.TimeSpan dueTime, System.TimeSpan period, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action) { throw null; } + + public static System.Threading.Tasks.Task RegisterOrUpdateReminder(this Timers.IReminderRegistry registry, Orleans.Runtime.GrainId callingGrainId, string reminderName, System.TimeSpan dueTime, System.TimeSpan period) { throw null; } + } + + public sealed partial class ReminderSchedule + { + internal ReminderSchedule() { } + + public string? CronExpression { get { throw null; } } + + public string? CronTimeZoneId { get { throw null; } } + + public System.DateTime? DueAtUtc { get { throw null; } } + + public System.TimeSpan? DueTime { get { throw null; } } + + public Runtime.ReminderScheduleKind Kind { get { throw null; } } + + public System.TimeSpan? Period { get { throw null; } } + + public bool UsesAbsoluteDueTime { get { throw null; } } + + public static ReminderSchedule Cron(string cronExpression, string? cronTimeZoneId = null) { throw null; } + + public static ReminderSchedule Interval(System.DateTime dueAtUtc, System.TimeSpan period) { throw null; } + + public static ReminderSchedule Interval(System.TimeSpan dueTime, System.TimeSpan period) { throw null; } + } + + [GenerateSerializer] + public sealed partial class ReminderTableData + { + public ReminderTableData() { } + + public ReminderTableData(ReminderEntry entry) { } + + public ReminderTableData(System.Collections.Generic.IEnumerable list) { } + + [Id(0)] + public System.Collections.Generic.IList Reminders { get { throw null; } } + + public override string ToString() { throw null; } + } + + public enum RSErrorCode + { + ReminderServiceBase = 102900, + RS_Register_TableError = 102905, + RS_Register_AlreadyRegistered = 102907, + RS_Register_InvalidPeriod = 102908, + RS_Register_NotRemindable = 102909, + RS_NotResponsible = 102910, + RS_Unregister_NotFoundLocally = 102911, + RS_Unregister_TableError = 102912, + RS_Table_Insert = 102913, + RS_Table_Remove = 102914, + RS_Tick_Delivery_Error = 102915, + RS_Not_Started = 102916, + RS_UnregisterGrain_TableError = 102917, + RS_GrainBasedTable1 = 102918, + RS_Factory1 = 102919, + RS_FailedToReadTableAndStartTimer = 102920, + RS_TableGrainInit1 = 102921, + RS_TableGrainInit2 = 102922, + RS_TableGrainInit3 = 102923, + RS_GrainBasedTable2 = 102924, + RS_ServiceStarting = 102925, + RS_ServiceStarted = 102926, + RS_ServiceStopping = 102927, + RS_RegisterOrUpdate = 102928, + RS_Unregister = 102929, + RS_Stop = 102930, + RS_RemoveFromTable = 102931, + RS_GetReminder = 102932, + RS_GetReminders = 102933, + RS_RangeChanged = 102934, + RS_LocalStop = 102935, + RS_Started = 102936, + RS_ServiceInitialLoadFailing = 102937, + RS_ServiceInitialLoadFailed = 102938, + RS_FastReminderInterval = 102939 + } +} + +namespace Orleans.AdvancedReminders.Runtime +{ + public enum MissedReminderAction : byte + { + Skip = 0, + FireImmediately = 1, + Notify = 2 + } + + [GenerateSerializer] + public sealed partial class ReminderException : Orleans.Runtime.OrleansException + { + [System.Obsolete] + public ReminderException(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) { } + + public ReminderException(string message) { } + } + + public enum ReminderPriority : byte + { + Normal = 0, + High = 1 + } + + public enum ReminderScheduleKind : byte + { + Interval = 0, + Cron = 1 + } + + [GenerateSerializer] + [Immutable] + public readonly partial struct TickStatus + { + private readonly int _dummyPrimitive; + public TickStatus(System.DateTime firstTickTime, System.TimeSpan period, System.DateTime currentTickTime) { } + + [Id(2)] + public System.DateTime CurrentTickTime { get { throw null; } } + + [Id(0)] + public System.DateTime FirstTickTime { get { throw null; } } + + [Id(1)] + public System.TimeSpan Period { get { throw null; } } + + public override readonly string ToString() { throw null; } + } +} + +namespace Orleans.AdvancedReminders.Timers +{ + public partial interface IReminderRegistry + { + System.Threading.Tasks.Task GetReminder(Orleans.Runtime.GrainId callingGrainId, string reminderName); + System.Threading.Tasks.Task> GetReminders(Orleans.Runtime.GrainId callingGrainId); + System.Threading.Tasks.Task RegisterOrUpdateReminder(Orleans.Runtime.GrainId callingGrainId, string reminderName, ReminderSchedule schedule, Runtime.ReminderPriority priority, Runtime.MissedReminderAction action); + System.Threading.Tasks.Task UnregisterReminder(Orleans.Runtime.GrainId callingGrainId, IGrainReminder reminder); + } +} + +namespace Orleans.Hosting +{ + public static partial class SiloBuilderReminderExtensions + { + public static void AddAdvancedReminders(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action configureOptions) { } + + public static void AddAdvancedReminders(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { } + + public static ISiloBuilder AddAdvancedReminders(this ISiloBuilder builder, System.Action configureOptions) { throw null; } + + public static ISiloBuilder AddAdvancedReminders(this ISiloBuilder builder) { throw null; } + } + + public static partial class SiloBuilderReminderMemoryExtensions + { + public static ISiloBuilder UseInMemoryAdvancedReminderService(this ISiloBuilder builder) { throw null; } + } +} + +namespace OrleansCodeGen.Orleans.AdvancedReminders +{ + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IRemindable_GrainReference_0373FAF7 : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_Invokable_IRemindable_GrainReference_0373FAF7(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IRemindable_GrainReference_0373FAF7 instance) { } + + public Invokable_IRemindable_GrainReference_0373FAF7 ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IRemindable_GrainReference_0373FAF7 instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IRemindable_GrainReference_0373FAF7 value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IReminderManagementGrain_GrainReference_05D0A66A : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IReminderManagementGrain_GrainReference_05D0A66A instance) { } + + public Invokable_IReminderManagementGrain_GrainReference_05D0A66A ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IReminderManagementGrain_GrainReference_05D0A66A instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IReminderManagementGrain_GrainReference_05D0A66A value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IReminderManagementGrain_GrainReference_254ED994 : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_Invokable_IReminderManagementGrain_GrainReference_254ED994(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IReminderManagementGrain_GrainReference_254ED994 instance) { } + + public Invokable_IReminderManagementGrain_GrainReference_254ED994 ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IReminderManagementGrain_GrainReference_254ED994 instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IReminderManagementGrain_GrainReference_254ED994 value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IReminderManagementGrain_GrainReference_29E7F2CE : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IReminderManagementGrain_GrainReference_29E7F2CE instance) { } + + public Invokable_IReminderManagementGrain_GrainReference_29E7F2CE ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IReminderManagementGrain_GrainReference_29E7F2CE instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IReminderManagementGrain_GrainReference_29E7F2CE value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IReminderManagementGrain_GrainReference_2FA3CE1A : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IReminderManagementGrain_GrainReference_2FA3CE1A instance) { } + + public Invokable_IReminderManagementGrain_GrainReference_2FA3CE1A ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IReminderManagementGrain_GrainReference_2FA3CE1A instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IReminderManagementGrain_GrainReference_2FA3CE1A value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IReminderManagementGrain_GrainReference_63932B7E : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_Invokable_IReminderManagementGrain_GrainReference_63932B7E(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IReminderManagementGrain_GrainReference_63932B7E instance) { } + + public Invokable_IReminderManagementGrain_GrainReference_63932B7E ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IReminderManagementGrain_GrainReference_63932B7E instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IReminderManagementGrain_GrainReference_63932B7E value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IReminderManagementGrain_GrainReference_74FE06B8 : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IReminderManagementGrain_GrainReference_74FE06B8 instance) { } + + public Invokable_IReminderManagementGrain_GrainReference_74FE06B8 ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IReminderManagementGrain_GrainReference_74FE06B8 instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IReminderManagementGrain_GrainReference_74FE06B8 value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IReminderManagementGrain_GrainReference_7B2FBCF0 : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_Invokable_IReminderManagementGrain_GrainReference_7B2FBCF0(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IReminderManagementGrain_GrainReference_7B2FBCF0 instance) { } + + public Invokable_IReminderManagementGrain_GrainReference_7B2FBCF0 ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IReminderManagementGrain_GrainReference_7B2FBCF0 instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IReminderManagementGrain_GrainReference_7B2FBCF0 value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IReminderManagementGrain_GrainReference_7B5551DA : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_Invokable_IReminderManagementGrain_GrainReference_7B5551DA(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IReminderManagementGrain_GrainReference_7B5551DA instance) { } + + public Invokable_IReminderManagementGrain_GrainReference_7B5551DA ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IReminderManagementGrain_GrainReference_7B5551DA instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IReminderManagementGrain_GrainReference_7B5551DA value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IReminderManagementGrain_GrainReference_7EAEA0B6 : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_Invokable_IReminderManagementGrain_GrainReference_7EAEA0B6(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IReminderManagementGrain_GrainReference_7EAEA0B6 instance) { } + + public Invokable_IReminderManagementGrain_GrainReference_7EAEA0B6 ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IReminderManagementGrain_GrainReference_7EAEA0B6 instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IReminderManagementGrain_GrainReference_7EAEA0B6 value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IReminderManagementGrain_GrainReference_8FC471F6 : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_Invokable_IReminderManagementGrain_GrainReference_8FC471F6(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IReminderManagementGrain_GrainReference_8FC471F6 instance) { } + + public Invokable_IReminderManagementGrain_GrainReference_8FC471F6 ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IReminderManagementGrain_GrainReference_8FC471F6 instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IReminderManagementGrain_GrainReference_8FC471F6 value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IReminderManagementGrain_GrainReference_91B34C93 : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IReminderManagementGrain_GrainReference_91B34C93 instance) { } + + public Invokable_IReminderManagementGrain_GrainReference_91B34C93 ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IReminderManagementGrain_GrainReference_91B34C93 instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IReminderManagementGrain_GrainReference_91B34C93 value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_ReminderEntry : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_ReminderEntry(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.AdvancedReminders.ReminderEntry instance) { } + + public global::Orleans.AdvancedReminders.ReminderEntry ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, global::Orleans.AdvancedReminders.ReminderEntry instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, global::Orleans.AdvancedReminders.ReminderEntry value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_ReminderManagementPage : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_ReminderManagementPage(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.AdvancedReminders.ReminderManagementPage instance) { } + + public global::Orleans.AdvancedReminders.ReminderManagementPage ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, global::Orleans.AdvancedReminders.ReminderManagementPage instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, global::Orleans.AdvancedReminders.ReminderManagementPage value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_ReminderQueryFilter : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_ReminderQueryFilter(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.AdvancedReminders.ReminderQueryFilter instance) { } + + public global::Orleans.AdvancedReminders.ReminderQueryFilter ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, global::Orleans.AdvancedReminders.ReminderQueryFilter instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, global::Orleans.AdvancedReminders.ReminderQueryFilter value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_ReminderTableData : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_ReminderTableData(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.AdvancedReminders.ReminderTableData instance) { } + + public global::Orleans.AdvancedReminders.ReminderTableData ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, global::Orleans.AdvancedReminders.ReminderTableData instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, global::Orleans.AdvancedReminders.ReminderTableData value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IRemindable_GrainReference_0373FAF7 : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Invokable_IRemindable_GrainReference_0373FAF7 DeepCopy(Invokable_IRemindable_GrainReference_0373FAF7 original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IReminderManagementGrain_GrainReference_05D0A66A : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Invokable_IReminderManagementGrain_GrainReference_05D0A66A DeepCopy(Invokable_IReminderManagementGrain_GrainReference_05D0A66A original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IReminderManagementGrain_GrainReference_254ED994 : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Invokable_IReminderManagementGrain_GrainReference_254ED994 DeepCopy(Invokable_IReminderManagementGrain_GrainReference_254ED994 original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IReminderManagementGrain_GrainReference_29E7F2CE : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Invokable_IReminderManagementGrain_GrainReference_29E7F2CE DeepCopy(Invokable_IReminderManagementGrain_GrainReference_29E7F2CE original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IReminderManagementGrain_GrainReference_2FA3CE1A : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Invokable_IReminderManagementGrain_GrainReference_2FA3CE1A DeepCopy(Invokable_IReminderManagementGrain_GrainReference_2FA3CE1A original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IReminderManagementGrain_GrainReference_63932B7E : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Invokable_IReminderManagementGrain_GrainReference_63932B7E DeepCopy(Invokable_IReminderManagementGrain_GrainReference_63932B7E original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IReminderManagementGrain_GrainReference_74FE06B8 : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Invokable_IReminderManagementGrain_GrainReference_74FE06B8 DeepCopy(Invokable_IReminderManagementGrain_GrainReference_74FE06B8 original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IReminderManagementGrain_GrainReference_7B2FBCF0 : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Invokable_IReminderManagementGrain_GrainReference_7B2FBCF0 DeepCopy(Invokable_IReminderManagementGrain_GrainReference_7B2FBCF0 original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IReminderManagementGrain_GrainReference_7B5551DA : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Invokable_IReminderManagementGrain_GrainReference_7B5551DA DeepCopy(Invokable_IReminderManagementGrain_GrainReference_7B5551DA original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IReminderManagementGrain_GrainReference_7EAEA0B6 : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Copier_Invokable_IReminderManagementGrain_GrainReference_7EAEA0B6(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public Invokable_IReminderManagementGrain_GrainReference_7EAEA0B6 DeepCopy(Invokable_IReminderManagementGrain_GrainReference_7EAEA0B6 original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IReminderManagementGrain_GrainReference_8FC471F6 : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Invokable_IReminderManagementGrain_GrainReference_8FC471F6 DeepCopy(Invokable_IReminderManagementGrain_GrainReference_8FC471F6 original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IReminderManagementGrain_GrainReference_91B34C93 : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Invokable_IReminderManagementGrain_GrainReference_91B34C93 DeepCopy(Invokable_IReminderManagementGrain_GrainReference_91B34C93 original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_ReminderEntry : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public global::Orleans.AdvancedReminders.ReminderEntry DeepCopy(global::Orleans.AdvancedReminders.ReminderEntry original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_ReminderManagementPage : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Copier_ReminderManagementPage(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public global::Orleans.AdvancedReminders.ReminderManagementPage DeepCopy(global::Orleans.AdvancedReminders.ReminderManagementPage original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_ReminderQueryFilter : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public global::Orleans.AdvancedReminders.ReminderQueryFilter DeepCopy(global::Orleans.AdvancedReminders.ReminderQueryFilter original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_ReminderTableData : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Copier_ReminderTableData(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public global::Orleans.AdvancedReminders.ReminderTableData DeepCopy(global::Orleans.AdvancedReminders.ReminderTableData original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.AdvancedReminders.IRemindable), "0373FAF7" })] + public sealed partial class Invokable_IRemindable_GrainReference_0373FAF7 : global::Orleans.Runtime.TaskRequest + { + public string arg0; + public global::Orleans.AdvancedReminders.Runtime.TickStatus arg1; + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override object GetArgument(int index) { throw null; } + + public override int GetArgumentCount() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task InvokeInner() { throw null; } + + public override void SetArgument(int index, object value) { } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.AdvancedReminders.IReminderManagementGrain), "05D0A66A" })] + public sealed partial class Invokable_IReminderManagementGrain_GrainReference_05D0A66A : global::Orleans.Runtime.TaskRequest + { + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task InvokeInner() { throw null; } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.AdvancedReminders.IReminderManagementGrain), "254ED994" })] + public sealed partial class Invokable_IReminderManagementGrain_GrainReference_254ED994 : global::Orleans.Runtime.TaskRequest + { + public global::Orleans.Runtime.GrainId arg0; + public string arg1; + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override object GetArgument(int index) { throw null; } + + public override int GetArgumentCount() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task InvokeInner() { throw null; } + + public override void SetArgument(int index, object value) { } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.AdvancedReminders.IReminderManagementGrain), "29E7F2CE" })] + public sealed partial class Invokable_IReminderManagementGrain_GrainReference_29E7F2CE : global::Orleans.Runtime.TaskRequest + { + public int arg0; + public string arg1; + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override object GetArgument(int index) { throw null; } + + public override int GetArgumentCount() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task InvokeInner() { throw null; } + + public override void SetArgument(int index, object value) { } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.AdvancedReminders.IReminderManagementGrain), "2FA3CE1A" })] + public sealed partial class Invokable_IReminderManagementGrain_GrainReference_2FA3CE1A : global::Orleans.Runtime.TaskRequest + { + public System.DateTime arg0; + public System.DateTime arg1; + public int arg2; + public string arg3; + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override object GetArgument(int index) { throw null; } + + public override int GetArgumentCount() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task InvokeInner() { throw null; } + + public override void SetArgument(int index, object value) { } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.AdvancedReminders.IReminderManagementGrain), "63932B7E" })] + public sealed partial class Invokable_IReminderManagementGrain_GrainReference_63932B7E : global::Orleans.Runtime.TaskRequest + { + public global::Orleans.Runtime.GrainId arg0; + public string arg1; + public global::Orleans.AdvancedReminders.Runtime.ReminderPriority arg2; + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override object GetArgument(int index) { throw null; } + + public override int GetArgumentCount() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task InvokeInner() { throw null; } + + public override void SetArgument(int index, object value) { } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.AdvancedReminders.IReminderManagementGrain), "74FE06B8" })] + public sealed partial class Invokable_IReminderManagementGrain_GrainReference_74FE06B8 : global::Orleans.Runtime.TaskRequest + { + public System.TimeSpan arg0; + public int arg1; + public string arg2; + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override object GetArgument(int index) { throw null; } + + public override int GetArgumentCount() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task InvokeInner() { throw null; } + + public override void SetArgument(int index, object value) { } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.AdvancedReminders.IReminderManagementGrain), "7B2FBCF0" })] + public sealed partial class Invokable_IReminderManagementGrain_GrainReference_7B2FBCF0 : global::Orleans.Runtime.TaskRequest + { + public global::Orleans.Runtime.GrainId arg0; + public string arg1; + public global::Orleans.AdvancedReminders.Runtime.MissedReminderAction arg2; + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override object GetArgument(int index) { throw null; } + + public override int GetArgumentCount() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task InvokeInner() { throw null; } + + public override void SetArgument(int index, object value) { } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.AdvancedReminders.IReminderManagementGrain), "7B5551DA" })] + public sealed partial class Invokable_IReminderManagementGrain_GrainReference_7B5551DA : global::Orleans.Runtime.TaskRequest> + { + public global::Orleans.Runtime.GrainId arg0; + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override object GetArgument(int index) { throw null; } + + public override int GetArgumentCount() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task> InvokeInner() { throw null; } + + public override void SetArgument(int index, object value) { } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.AdvancedReminders.IReminderManagementGrain), "7EAEA0B6" })] + public sealed partial class Invokable_IReminderManagementGrain_GrainReference_7EAEA0B6 : global::Orleans.Runtime.TaskRequest + { + public global::Orleans.AdvancedReminders.ReminderQueryFilter arg0; + public int arg1; + public string arg2; + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override object GetArgument(int index) { throw null; } + + public override int GetArgumentCount() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task InvokeInner() { throw null; } + + public override void SetArgument(int index, object value) { } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.AdvancedReminders.IReminderManagementGrain), "8FC471F6" })] + public sealed partial class Invokable_IReminderManagementGrain_GrainReference_8FC471F6 : global::Orleans.Runtime.TaskRequest + { + public global::Orleans.Runtime.GrainId arg0; + public string arg1; + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override object GetArgument(int index) { throw null; } + + public override int GetArgumentCount() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task InvokeInner() { throw null; } + + public override void SetArgument(int index, object value) { } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.AdvancedReminders.IReminderManagementGrain), "91B34C93" })] + public sealed partial class Invokable_IReminderManagementGrain_GrainReference_91B34C93 : global::Orleans.Runtime.TaskRequest> + { + public System.TimeSpan arg0; + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override object GetArgument(int index) { throw null; } + + public override int GetArgumentCount() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task> InvokeInner() { throw null; } + + public override void SetArgument(int index, object value) { } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + } +} + +namespace OrleansCodeGen.Orleans.AdvancedReminders.Runtime +{ + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_ReminderException : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_ReminderException(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider, global::Orleans.Serialization.Activators.IActivator _activator) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.AdvancedReminders.Runtime.ReminderException instance) { } + + public global::Orleans.AdvancedReminders.Runtime.ReminderException ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, global::Orleans.AdvancedReminders.Runtime.ReminderException instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, global::Orleans.AdvancedReminders.Runtime.ReminderException value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_TickStatus : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Serializers.IValueSerializer, global::Orleans.Serialization.Serializers.IValueSerializer + { + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, scoped ref global::Orleans.AdvancedReminders.Runtime.TickStatus instance) { } + + public global::Orleans.AdvancedReminders.Runtime.TickStatus ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, scoped ref global::Orleans.AdvancedReminders.Runtime.TickStatus instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, global::Orleans.AdvancedReminders.Runtime.TickStatus value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_ReminderException : global::Orleans.Serialization.GeneratedCodeHelpers.OrleansGeneratedCodeHelper.ExceptionCopier + { + public Copier_ReminderException(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) : base(default(Serialization.Serializers.ICodecProvider)!) { } + } +} + +namespace OrleansCodeGen.Orleans.DurableJobs +{ + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_Invokable_IDurableJobHandler_GrainReference_C5FF5E5E : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_Invokable_IDurableJobHandler_GrainReference_C5FF5E5E(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, Invokable_IDurableJobHandler_GrainReference_C5FF5E5E instance) { } + + public Invokable_IDurableJobHandler_GrainReference_C5FF5E5E ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, Invokable_IDurableJobHandler_GrainReference_C5FF5E5E instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, Invokable_IDurableJobHandler_GrainReference_C5FF5E5E value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_Invokable_IDurableJobHandler_GrainReference_C5FF5E5E : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Copier_Invokable_IDurableJobHandler_GrainReference_C5FF5E5E(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public Invokable_IDurableJobHandler_GrainReference_C5FF5E5E DeepCopy(Invokable_IDurableJobHandler_GrainReference_C5FF5E5E original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::Orleans.CompoundTypeAlias(new[] { "inv", typeof(global::Orleans.Runtime.GrainReference), typeof(global::Orleans.DurableJobs.IDurableJobHandler), "C5FF5E5E" })] + public sealed partial class Invokable_IDurableJobHandler_GrainReference_C5FF5E5E : global::Orleans.Runtime.TaskRequest + { + public global::Orleans.DurableJobs.IJobRunContext arg0; + public System.Threading.CancellationToken arg1; + public override bool IsCancellable { get { throw null; } } + + public override void Dispose() { } + + public override string GetActivityName() { throw null; } + + public override object GetArgument(int index) { throw null; } + + public override int GetArgumentCount() { throw null; } + + public override System.Threading.CancellationToken GetCancellationToken() { throw null; } + + public override string GetInterfaceName() { throw null; } + + public override System.Type GetInterfaceType() { throw null; } + + public override System.Reflection.MethodInfo GetMethod() { throw null; } + + public override string GetMethodName() { throw null; } + + public override object GetTarget() { throw null; } + + protected override System.Threading.Tasks.Task InvokeInner() { throw null; } + + public override void SetArgument(int index, object value) { } + + public override void SetTarget(global::Orleans.Serialization.Invocation.ITargetHolder holder) { } + + public override bool TryCancel() { throw null; } + } +} diff --git a/src/api/Orleans.DurableJobs/Orleans.DurableJobs.cs b/src/api/Orleans.DurableJobs/Orleans.DurableJobs.cs new file mode 100644 index 0000000000..1fe3f5646c --- /dev/null +++ b/src/api/Orleans.DurableJobs/Orleans.DurableJobs.cs @@ -0,0 +1,269 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Orleans.DurableJobs +{ + [GenerateSerializer] + [Alias("Orleans.DurableJobs.DurableJob")] + public sealed partial class DurableJob + { + [Id(2)] + public System.DateTimeOffset DueTime { get { throw null; } init { } } + + [Id(0)] + public required string Id { get { throw null; } init { } } + + [Id(5)] + public System.Collections.Generic.IReadOnlyDictionary? Metadata { get { throw null; } init { } } + + [Id(1)] + public required string Name { get { throw null; } init { } } + + [Id(4)] + public required string ShardId { get { throw null; } init { } } + + [Id(3)] + public Runtime.GrainId TargetGrainId { get { throw null; } init { } } + } + + [GenerateSerializer] + public sealed partial class DurableJobRunResult + { + internal DurableJobRunResult() { } + + public static DurableJobRunResult Completed { get { throw null; } } + + [Id(2)] + public System.Exception? Exception { get { throw null; } } + + [System.Diagnostics.CodeAnalysis.MemberNotNullWhen(true, "Exception")] + public bool IsFailed { get { throw null; } } + + [System.Diagnostics.CodeAnalysis.MemberNotNullWhen(true, "PollAfterDelay")] + public bool IsPending { get { throw null; } } + + [Id(1)] + public System.TimeSpan? PollAfterDelay { get { throw null; } } + + [Id(0)] + public DurableJobRunStatus Status { get { throw null; } } + + public static DurableJobRunResult Failed(System.Exception exception) { throw null; } + + public static DurableJobRunResult PollAfter(System.TimeSpan delay) { throw null; } + } + + public enum DurableJobRunStatus + { + Completed = 0, + PollAfter = 1, + Failed = 2 + } + + public partial interface IDurableJobHandler + { + System.Threading.Tasks.Task ExecuteJobAsync(IJobRunContext context, System.Threading.CancellationToken cancellationToken); + } + + public partial interface IJobRunContext + { + int DequeueCount { get; } + + DurableJob Job { get; } + + string RunId { get; } + } + + public partial interface IJobShard : System.IAsyncDisposable + { + System.DateTimeOffset EndTime { get; } + + string Id { get; } + + bool IsAddingCompleted { get; } + + System.Collections.Generic.IDictionary? Metadata { get; } + + System.DateTimeOffset StartTime { get; } + + System.Collections.Generic.IAsyncEnumerable ConsumeDurableJobsAsync(); + System.Threading.Tasks.ValueTask GetJobCountAsync(); + System.Threading.Tasks.Task MarkAsCompleteAsync(System.Threading.CancellationToken cancellationToken); + System.Threading.Tasks.Task RemoveJobAsync(string jobId, System.Threading.CancellationToken cancellationToken); + System.Threading.Tasks.Task RetryJobLaterAsync(IJobRunContext jobContext, System.DateTimeOffset newDueTime, System.Threading.CancellationToken cancellationToken); + System.Threading.Tasks.Task TryScheduleJobAsync(ScheduleJobRequest request, System.Threading.CancellationToken cancellationToken); + } + + public partial interface ILocalDurableJobManager + { + System.Threading.Tasks.Task ScheduleJobAsync(ScheduleJobRequest request, System.Threading.CancellationToken cancellationToken); + System.Threading.Tasks.Task TryCancelDurableJobAsync(DurableJob job, System.Threading.CancellationToken cancellationToken); + } + + public abstract partial class JobShard : IJobShard, System.IAsyncDisposable + { + protected JobShard(string id, System.DateTimeOffset startTime, System.DateTimeOffset endTime) { } + + public System.DateTimeOffset EndTime { get { throw null; } protected set { } } + + public string Id { get { throw null; } protected set { } } + + public bool IsAddingCompleted { get { throw null; } protected set { } } + + public System.Collections.Generic.IDictionary? Metadata { get { throw null; } protected set { } } + + public System.DateTimeOffset StartTime { get { throw null; } protected set { } } + + public System.Collections.Generic.IAsyncEnumerable ConsumeDurableJobsAsync() { throw null; } + + public virtual System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } + + protected void EnqueueJob(DurableJob job, int dequeueCount) { } + + public System.Threading.Tasks.ValueTask GetJobCountAsync() { throw null; } + + public System.Threading.Tasks.Task MarkAsCompleteAsync(System.Threading.CancellationToken cancellationToken) { throw null; } + + protected abstract System.Threading.Tasks.Task PersistAddJobAsync(string jobId, string jobName, System.DateTimeOffset dueTime, Runtime.GrainId target, System.Collections.Generic.IReadOnlyDictionary? metadata, System.Threading.CancellationToken cancellationToken); + protected abstract System.Threading.Tasks.Task PersistRemoveJobAsync(string jobId, System.Threading.CancellationToken cancellationToken); + protected abstract System.Threading.Tasks.Task PersistRetryJobAsync(string jobId, System.DateTimeOffset newDueTime, System.Threading.CancellationToken cancellationToken); + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task RemoveJobAsync(string jobId, System.Threading.CancellationToken cancellationToken) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task RetryJobLaterAsync(IJobRunContext jobContext, System.DateTimeOffset newDueTime, System.Threading.CancellationToken cancellationToken) { throw null; } + + [System.Diagnostics.DebuggerStepThrough] + public System.Threading.Tasks.Task TryScheduleJobAsync(ScheduleJobRequest request, System.Threading.CancellationToken cancellationToken) { throw null; } + } + + public abstract partial class JobShardManager + { + protected JobShardManager(Runtime.SiloAddress siloAddress) { } + + protected Runtime.SiloAddress SiloAddress { get { throw null; } } + + public abstract System.Threading.Tasks.Task> AssignJobShardsAsync(System.DateTimeOffset maxDueTime, System.Threading.CancellationToken cancellationToken); + public abstract System.Threading.Tasks.Task CreateShardAsync(System.DateTimeOffset minDueTime, System.DateTimeOffset maxDueTime, System.Collections.Generic.IDictionary metadata, System.Threading.CancellationToken cancellationToken); + public abstract System.Threading.Tasks.Task UnregisterShardAsync(IJobShard shard, System.Threading.CancellationToken cancellationToken); + } + + public readonly partial struct ScheduleJobRequest + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public required System.DateTimeOffset DueTime { get { throw null; } init { } } + + public required string JobName { get { throw null; } init { } } + + public System.Collections.Generic.IReadOnlyDictionary? Metadata { get { throw null; } init { } } + + public required Runtime.GrainId Target { get { throw null; } init { } } + } +} + +namespace Orleans.Hosting +{ + public static partial class DurableJobsExtensions + { + public static void AddDurableJobs(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { } + + public static ISiloBuilder AddDurableJobs(this ISiloBuilder builder) { throw null; } + + public static Microsoft.Extensions.DependencyInjection.IServiceCollection UseInMemoryDurableJobs(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; } + + public static ISiloBuilder UseInMemoryDurableJobs(this ISiloBuilder builder) { throw null; } + } + + public sealed partial class DurableJobsOptions + { + public bool ConcurrencySlowStartEnabled { get { throw null; } set { } } + + public int MaxAdoptedCount { get { throw null; } set { } } + + public int MaxConcurrentJobsPerSilo { get { throw null; } set { } } + + public System.TimeSpan OverloadBackoffDelay { get { throw null; } set { } } + + public System.TimeSpan ShardActivationBufferPeriod { get { throw null; } set { } } + + public System.TimeSpan ShardDuration { get { throw null; } set { } } + + public System.Func ShouldRetry { get { throw null; } set { } } + + public int SlowStartInitialConcurrency { get { throw null; } set { } } + + public System.TimeSpan SlowStartInterval { get { throw null; } set { } } + } + + public sealed partial class DurableJobsOptionsValidator : IConfigurationValidator + { + public DurableJobsOptionsValidator(Microsoft.Extensions.Logging.ILogger logger, Microsoft.Extensions.Options.IOptions options) { } + + public void ValidateConfiguration() { } + } +} + +namespace OrleansCodeGen.Orleans.DurableJobs +{ + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_DurableJob : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_DurableJob(global::Orleans.Serialization.Activators.IActivator _activator, global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.DurableJobs.DurableJob instance) { } + + public global::Orleans.DurableJobs.DurableJob ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, global::Orleans.DurableJobs.DurableJob instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, global::Orleans.DurableJobs.DurableJob value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_DurableJobRunResult : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec + { + public Codec_DurableJobRunResult(global::Orleans.Serialization.Activators.IActivator _activator, global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.DurableJobs.DurableJobRunResult instance) { } + + public global::Orleans.DurableJobs.DurableJobRunResult ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, global::Orleans.DurableJobs.DurableJobRunResult instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, global::Orleans.DurableJobs.DurableJobRunResult value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_DurableJob : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Copier_DurableJob(global::Orleans.Serialization.Activators.IActivator _activator, global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { } + + public global::Orleans.DurableJobs.DurableJob DeepCopy(global::Orleans.DurableJobs.DurableJob original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_DurableJobRunResult : global::Orleans.Serialization.Cloning.IDeepCopier, global::Orleans.Serialization.Cloning.IDeepCopier + { + public Copier_DurableJobRunResult(global::Orleans.Serialization.Activators.IActivator _activator) { } + + public global::Orleans.DurableJobs.DurableJobRunResult DeepCopy(global::Orleans.DurableJobs.DurableJobRunResult original, global::Orleans.Serialization.Cloning.CopyContext context) { throw null; } + } +} \ No newline at end of file diff --git a/src/api/Redis/Orleans.AdvancedReminders.Redis/Orleans.AdvancedReminders.Redis.cs b/src/api/Redis/Orleans.AdvancedReminders.Redis/Orleans.AdvancedReminders.Redis.cs new file mode 100644 index 0000000000..a463a64a2d --- /dev/null +++ b/src/api/Redis/Orleans.AdvancedReminders.Redis/Orleans.AdvancedReminders.Redis.cs @@ -0,0 +1,81 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Orleans.AdvancedReminders.Redis +{ + [GenerateSerializer] + public partial class RedisRemindersException : System.Exception + { + public RedisRemindersException() { } + + [System.Obsolete] + protected RedisRemindersException(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) { } + + public RedisRemindersException(string message, System.Exception inner) { } + + public RedisRemindersException(string message) { } + } + + public partial class RedisReminderTableOptions + { + public StackExchange.Redis.ConfigurationOptions ConfigurationOptions { get { throw null; } set { } } + + public System.Func> CreateMultiplexer { get { throw null; } set { } } + + public System.TimeSpan? EntryExpiry { get { throw null; } set { } } + + [System.Diagnostics.DebuggerStepThrough] + public static System.Threading.Tasks.Task DefaultCreateMultiplexer(RedisReminderTableOptions options) { throw null; } + } + + public partial class RedisReminderTableOptionsValidator : IConfigurationValidator + { + public RedisReminderTableOptionsValidator(Microsoft.Extensions.Options.IOptions options) { } + + public void ValidateConfiguration() { } + } +} + +namespace Orleans.Hosting +{ + public static partial class SiloBuilderReminderExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection UseRedisAdvancedReminderService(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action configure) { throw null; } + + public static ISiloBuilder UseRedisAdvancedReminderService(this ISiloBuilder builder, System.Action configure) { throw null; } + } +} + +namespace OrleansCodeGen.Orleans.AdvancedReminders.Redis +{ + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Codec_RedisRemindersException : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Serializers.IBaseCodec, global::Orleans.Serialization.Serializers.IBaseCodec + { + public Codec_RedisRemindersException(global::Orleans.Serialization.Serializers.IBaseCodec _baseTypeSerializer) { } + + public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.AdvancedReminders.Redis.RedisRemindersException instance) { } + + public global::Orleans.AdvancedReminders.Redis.RedisRemindersException ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } + + public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, global::Orleans.AdvancedReminders.Redis.RedisRemindersException instance) + where TBufferWriter : System.Buffers.IBufferWriter { } + + public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, global::Orleans.AdvancedReminders.Redis.RedisRemindersException value) + where TBufferWriter : System.Buffers.IBufferWriter { } + } + + [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed partial class Copier_RedisRemindersException : global::Orleans.Serialization.GeneratedCodeHelpers.OrleansGeneratedCodeHelper.ExceptionCopier + { + public Copier_RedisRemindersException(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) : base(default(Serialization.Serializers.ICodecProvider)!) { } + } +} \ No newline at end of file diff --git a/test/Extensions/Orleans.AWS.Tests/AdvancedReminders/DynamoDBAdvancedRemindersTableTests.cs b/test/Extensions/Orleans.AWS.Tests/AdvancedReminders/DynamoDBAdvancedRemindersTableTests.cs new file mode 100644 index 0000000000..788376c12f --- /dev/null +++ b/test/Extensions/Orleans.AWS.Tests/AdvancedReminders/DynamoDBAdvancedRemindersTableTests.cs @@ -0,0 +1,51 @@ +#nullable enable +using AWSUtils.Tests.StorageTests; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.AdvancedReminders.DynamoDB; +using TestExtensions; +using UnitTests; +using UnitTests.AdvancedRemindersTest; +using Xunit; +using ClusterOptions = Orleans.Configuration.ClusterOptions; +using LoggerFilterOptions = Microsoft.Extensions.Logging.LoggerFilterOptions; + +namespace AWSUtils.Tests.AdvancedReminders; + +[TestCategory("Reminders"), TestCategory("AWS"), TestCategory("DynamoDb")] +[Collection(TestEnvironmentFixture.DefaultCollection)] +public class DynamoDBAdvancedRemindersTableTests : AdvancedReminderTableTestsBase, IClassFixture +{ + public DynamoDBAdvancedRemindersTableTests(ConnectionStringFixture fixture, TestEnvironmentFixture environment) + : base(fixture, environment, new LoggerFilterOptions()) + { + } + + protected override Orleans.AdvancedReminders.IReminderTable CreateRemindersTable() + { + if (!AWSTestConstants.IsDynamoDbAvailable) + { + throw new SkipException("Unable to connect to AWS DynamoDB simulator"); + } + + var options = new DynamoDBReminderStorageOptions(); + DynamoDBReminderStorageOptionsExtensions.ParseConnectionString(options, connectionStringFixture.ConnectionString); + + return new DynamoDBReminderTable( + loggerFactory, + clusterOptions, + Options.Create(options)); + } + + protected override Task GetConnectionString() + => Task.FromResult(AWSTestConstants.IsDynamoDbAvailable ? $"Service={AWSTestConstants.DynamoDbService}" : null!); + + [SkippableFact] + public async Task RemindersTable_AWS_DurableCronRoundTrip() => await ReminderCronRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_AWS_DurableAdaptiveFieldsRoundTrip() => await ReminderAdaptiveFieldsRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_AWS_DurableCronTimeZoneRoundTrip() => await ReminderCronTimeZoneRoundTrip(); +} diff --git a/test/Extensions/Orleans.AWS.Tests/AdvancedReminders/DynamoDBReminderTableEnumParsingTests.cs b/test/Extensions/Orleans.AWS.Tests/AdvancedReminders/DynamoDBReminderTableEnumParsingTests.cs new file mode 100644 index 0000000000..867fc60f94 --- /dev/null +++ b/test/Extensions/Orleans.AWS.Tests/AdvancedReminders/DynamoDBReminderTableEnumParsingTests.cs @@ -0,0 +1,101 @@ +#nullable enable +using System.Collections.Generic; +using System.Reflection; +using Amazon.DynamoDBv2.Model; +using Orleans.AdvancedReminders.DynamoDB; +using Xunit; +using ReminderPriority = Orleans.AdvancedReminders.Runtime.ReminderPriority; +using MissedReminderAction = Orleans.AdvancedReminders.Runtime.MissedReminderAction; + +namespace AWSUtils.Tests.AdvancedReminders; + +[TestCategory("Reminders"), TestCategory("AWS"), TestCategory("DynamoDb")] +public class DynamoDBReminderTableEnumParsingTests +{ + [Fact] + public void ReadPriority_ReturnsNormal_WhenMissing() + { + var value = InvokeReadPriority(new Dictionary()); + Assert.Equal(ReminderPriority.Normal, value); + } + + [Fact] + public void ReadAction_ReturnsSkip_WhenMissing() + { + var value = InvokeReadAction(new Dictionary()); + Assert.Equal(MissedReminderAction.Skip, value); + } + + [Fact] + public void ReadPriority_ReturnsNormal_WhenInvalid() + { + var item = new Dictionary + { + ["Priority"] = new AttributeValue { N = "999" }, + }; + + var value = InvokeReadPriority(item); + Assert.Equal(ReminderPriority.Normal, value); + } + + [Fact] + public void ReadAction_ReturnsSkip_WhenInvalid() + { + var item = new Dictionary + { + ["Action"] = new AttributeValue { N = "-3" }, + }; + + var value = InvokeReadAction(item); + Assert.Equal(MissedReminderAction.Skip, value); + } + + [Theory] + [InlineData((int)ReminderPriority.High, ReminderPriority.High)] + [InlineData((int)ReminderPriority.Normal, ReminderPriority.Normal)] + public void ReadPriority_ReturnsExpectedValue_WhenValid(int rawValue, ReminderPriority expected) + { + var item = new Dictionary + { + ["Priority"] = new AttributeValue { N = rawValue.ToString() }, + }; + + var value = InvokeReadPriority(item); + Assert.Equal(expected, value); + } + + [Theory] + [InlineData((int)MissedReminderAction.FireImmediately, MissedReminderAction.FireImmediately)] + [InlineData((int)MissedReminderAction.Skip, MissedReminderAction.Skip)] + [InlineData((int)MissedReminderAction.Notify, MissedReminderAction.Notify)] + public void ReadAction_ReturnsExpectedValue_WhenValid(int rawValue, MissedReminderAction expected) + { + var item = new Dictionary + { + ["Action"] = new AttributeValue { N = rawValue.ToString() }, + }; + + var value = InvokeReadAction(item); + Assert.Equal(expected, value); + } + + private static ReminderPriority InvokeReadPriority(Dictionary item) + { + var method = typeof(DynamoDBReminderTable).GetMethod("ReadPriority", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + var result = method!.Invoke(null, [item]); + Assert.NotNull(result); + return (ReminderPriority)result!; + } + + private static MissedReminderAction InvokeReadAction(Dictionary item) + { + var method = typeof(DynamoDBReminderTable).GetMethod("ReadAction", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + var result = method!.Invoke(null, [item]); + Assert.NotNull(result); + return (MissedReminderAction)result!; + } +} diff --git a/test/Extensions/Orleans.AWS.Tests/AdvancedReminders/DynamoDBRemindersProviderBuilderTests.cs b/test/Extensions/Orleans.AWS.Tests/AdvancedReminders/DynamoDBRemindersProviderBuilderTests.cs new file mode 100644 index 0000000000..db3789d049 --- /dev/null +++ b/test/Extensions/Orleans.AWS.Tests/AdvancedReminders/DynamoDBRemindersProviderBuilderTests.cs @@ -0,0 +1,60 @@ +using System.IO; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Orleans.AdvancedReminders.DynamoDB; +using Orleans.Hosting; +using Xunit; + +namespace AWSUtils.Tests.AdvancedReminders; + +[TestCategory("Reminders"), TestCategory("AWS"), TestCategory("DynamoDb")] +public class DynamoDBRemindersProviderBuilderTests +{ + [Fact] + public void Configure_BindsTokenAndProfileName() + { + const string json = """ + { + "Orleans": { + "AdvancedReminders": { + "DynamoDB": { + "ProviderType": "DynamoDB", + "AccessKey": "access", + "SecretKey": "secret", + "Service": "eu-west-1", + "Token": "session-token", + "ProfileName": "dev-profile", + "TableName": "AdvancedReminders" + } + } + } + } + """; + + var siloBuilder = new TestSiloBuilder(json); + var providerBuilder = new AdvancedDynamoDBRemindersProviderBuilder(); + + providerBuilder.Configure(siloBuilder, "DynamoDB", siloBuilder.Configuration.GetSection("Orleans:AdvancedReminders:DynamoDB")); + + var options = siloBuilder.Services.BuildServiceProvider() + .GetRequiredService>() + .Value; + + Assert.Equal("access", options.AccessKey); + Assert.Equal("secret", options.SecretKey); + Assert.Equal("eu-west-1", options.Service); + Assert.Equal("session-token", options.Token); + Assert.Equal("dev-profile", options.ProfileName); + Assert.Equal("AdvancedReminders", options.TableName); + } + + private sealed class TestSiloBuilder(string json) : ISiloBuilder + { + public IServiceCollection Services { get; } = new ServiceCollection(); + + public IConfiguration Configuration { get; } = new ConfigurationBuilder() + .AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json))) + .Build(); + } +} diff --git a/test/Extensions/Orleans.AWS.Tests/Orleans.AWS.Tests.csproj b/test/Extensions/Orleans.AWS.Tests/Orleans.AWS.Tests.csproj index 40bb755f71..d6812e3f49 100644 --- a/test/Extensions/Orleans.AWS.Tests/Orleans.AWS.Tests.csproj +++ b/test/Extensions/Orleans.AWS.Tests/Orleans.AWS.Tests.csproj @@ -16,10 +16,11 @@ + - \ No newline at end of file + diff --git a/test/Extensions/Orleans.AdoNet.Tests/AdvancedReminders/MySqlAdvancedRemindersTableTests.cs b/test/Extensions/Orleans.AdoNet.Tests/AdvancedReminders/MySqlAdvancedRemindersTableTests.cs new file mode 100644 index 0000000000..e11d807ba4 --- /dev/null +++ b/test/Extensions/Orleans.AdoNet.Tests/AdvancedReminders/MySqlAdvancedRemindersTableTests.cs @@ -0,0 +1,57 @@ +#nullable enable +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.AdvancedReminders.AdoNet; +using Orleans.AdvancedReminders.Runtime.ReminderService; +using Orleans.Tests.SqlUtils; +using TestExtensions; +using UnitTests.General; +using Xunit; +using ClusterOptions = Orleans.Configuration.ClusterOptions; +using LoggerFilterOptions = Microsoft.Extensions.Logging.LoggerFilterOptions; + +namespace UnitTests.AdvancedRemindersTest; + +[TestCategory("Functional"), TestCategory("Reminders"), TestCategory("AdoNet"), TestCategory("MySql")] +public class MySqlAdvancedRemindersTableTests : AdvancedReminderTableTestsBase +{ + public MySqlAdvancedRemindersTableTests(ConnectionStringFixture fixture, TestEnvironmentFixture environment) + : base(fixture, environment, CreateFilters()) + { + } + + private static LoggerFilterOptions CreateFilters() + { + var filters = new LoggerFilterOptions(); + filters.AddFilter(nameof(MySqlAdvancedRemindersTableTests), LogLevel.Trace); + return filters; + } + + protected override Orleans.AdvancedReminders.IReminderTable CreateRemindersTable() + { + var options = new AdoNetReminderTableOptions + { + Invariant = GetAdoInvariant(), + ConnectionString = connectionStringFixture.ConnectionString, + }; + + return new AdoNetReminderTable(clusterOptions, Options.Create(options)); + } + + protected override string GetAdoInvariant() => AdoNetInvariants.InvariantNameMySql; + + protected override async Task GetConnectionString() + { + var instance = await RelationalStorageForTesting.SetupInstance(GetAdoInvariant()!, testDatabaseName); + return instance.CurrentConnectionString; + } + + [SkippableFact] + public async Task RemindersTable_MySql_DurableCronRoundTrip() => await ReminderCronRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_MySql_DurableAdaptiveFieldsRoundTrip() => await ReminderAdaptiveFieldsRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_MySql_DurableCronTimeZoneRoundTrip() => await ReminderCronTimeZoneRoundTrip(); +} diff --git a/test/Extensions/Orleans.AdoNet.Tests/AdvancedReminders/PostgreSqlAdvancedRemindersTableTests.cs b/test/Extensions/Orleans.AdoNet.Tests/AdvancedReminders/PostgreSqlAdvancedRemindersTableTests.cs new file mode 100644 index 0000000000..a782564d35 --- /dev/null +++ b/test/Extensions/Orleans.AdoNet.Tests/AdvancedReminders/PostgreSqlAdvancedRemindersTableTests.cs @@ -0,0 +1,57 @@ +#nullable enable +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.AdvancedReminders.AdoNet; +using Orleans.AdvancedReminders.Runtime.ReminderService; +using Orleans.Tests.SqlUtils; +using TestExtensions; +using UnitTests.General; +using Xunit; +using ClusterOptions = Orleans.Configuration.ClusterOptions; +using LoggerFilterOptions = Microsoft.Extensions.Logging.LoggerFilterOptions; + +namespace UnitTests.AdvancedRemindersTest; + +[TestCategory("Functional"), TestCategory("Reminders"), TestCategory("AdoNet"), TestCategory("PostgreSql")] +public class PostgreSqlAdvancedRemindersTableTests : AdvancedReminderTableTestsBase +{ + public PostgreSqlAdvancedRemindersTableTests(ConnectionStringFixture fixture, TestEnvironmentFixture environment) + : base(fixture, environment, CreateFilters()) + { + } + + private static LoggerFilterOptions CreateFilters() + { + var filters = new LoggerFilterOptions(); + filters.AddFilter(nameof(PostgreSqlAdvancedRemindersTableTests), LogLevel.Trace); + return filters; + } + + protected override Orleans.AdvancedReminders.IReminderTable CreateRemindersTable() + { + var options = new AdoNetReminderTableOptions + { + Invariant = GetAdoInvariant(), + ConnectionString = connectionStringFixture.ConnectionString, + }; + + return new AdoNetReminderTable(clusterOptions, Options.Create(options)); + } + + protected override string GetAdoInvariant() => AdoNetInvariants.InvariantNamePostgreSql; + + protected override async Task GetConnectionString() + { + var instance = await RelationalStorageForTesting.SetupInstance(GetAdoInvariant()!, testDatabaseName); + return instance.CurrentConnectionString; + } + + [SkippableFact] + public async Task RemindersTable_PostgreSql_DurableCronRoundTrip() => await ReminderCronRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_PostgreSql_DurableAdaptiveFieldsRoundTrip() => await ReminderAdaptiveFieldsRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_PostgreSql_DurableCronTimeZoneRoundTrip() => await ReminderCronTimeZoneRoundTrip(); +} diff --git a/test/Extensions/Orleans.AdoNet.Tests/AdvancedReminders/SqlServerAdvancedRemindersTableTests.cs b/test/Extensions/Orleans.AdoNet.Tests/AdvancedReminders/SqlServerAdvancedRemindersTableTests.cs new file mode 100644 index 0000000000..ea6ba22835 --- /dev/null +++ b/test/Extensions/Orleans.AdoNet.Tests/AdvancedReminders/SqlServerAdvancedRemindersTableTests.cs @@ -0,0 +1,58 @@ +#nullable enable +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.AdvancedReminders.AdoNet; +using Orleans.AdvancedReminders.Runtime.ReminderService; +using Orleans.Tests.SqlUtils; +using TestExtensions; +using UnitTests.AdvancedRemindersTest; +using UnitTests.General; +using Xunit; +using ClusterOptions = Orleans.Configuration.ClusterOptions; +using LoggerFilterOptions = Microsoft.Extensions.Logging.LoggerFilterOptions; + +namespace UnitTests.AdvancedRemindersTest; + +[TestCategory("Functional"), TestCategory("Reminders"), TestCategory("AdoNet"), TestCategory("SqlServer")] +public class SqlServerAdvancedRemindersTableTests : AdvancedReminderTableTestsBase +{ + public SqlServerAdvancedRemindersTableTests(ConnectionStringFixture fixture, TestEnvironmentFixture environment) + : base(fixture, environment, CreateFilters()) + { + } + + private static LoggerFilterOptions CreateFilters() + { + var filters = new LoggerFilterOptions(); + filters.AddFilter(nameof(SqlServerAdvancedRemindersTableTests), LogLevel.Trace); + return filters; + } + + protected override Orleans.AdvancedReminders.IReminderTable CreateRemindersTable() + { + var options = new AdoNetReminderTableOptions + { + Invariant = GetAdoInvariant(), + ConnectionString = connectionStringFixture.ConnectionString, + }; + + return new AdoNetReminderTable(clusterOptions, Options.Create(options)); + } + + protected override string GetAdoInvariant() => AdoNetInvariants.InvariantNameSqlServer; + + protected override async Task GetConnectionString() + { + var instance = await RelationalStorageForTesting.SetupInstance(GetAdoInvariant()!, testDatabaseName); + return instance.CurrentConnectionString; + } + + [SkippableFact] + public async Task RemindersTable_SqlServer_DurableCronRoundTrip() => await ReminderCronRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_SqlServer_DurableAdaptiveFieldsRoundTrip() => await ReminderAdaptiveFieldsRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_SqlServer_DurableCronTimeZoneRoundTrip() => await ReminderCronTimeZoneRoundTrip(); +} diff --git a/test/Extensions/Orleans.AdoNet.Tests/Orleans.AdoNet.Tests.csproj b/test/Extensions/Orleans.AdoNet.Tests/Orleans.AdoNet.Tests.csproj index 344363744c..2f650410b3 100644 --- a/test/Extensions/Orleans.AdoNet.Tests/Orleans.AdoNet.Tests.csproj +++ b/test/Extensions/Orleans.AdoNet.Tests/Orleans.AdoNet.Tests.csproj @@ -32,6 +32,7 @@ + diff --git a/test/Extensions/Orleans.AdoNet.Tests/StorageTests/DbExtensionsInt32ConversionTests.cs b/test/Extensions/Orleans.AdoNet.Tests/StorageTests/DbExtensionsInt32ConversionTests.cs new file mode 100644 index 0000000000..4915f56476 --- /dev/null +++ b/test/Extensions/Orleans.AdoNet.Tests/StorageTests/DbExtensionsInt32ConversionTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Data; +using Orleans.Tests.SqlUtils; +using Xunit; + +namespace UnitTests.StorageTests; + +[TestCategory("AdoNet"), TestCategory("Storage")] +public class DbExtensionsInt32ConversionTests +{ + [Fact] + public void GetInt32_ConvertsByte() + { + using var context = CreateReader(typeof(byte), (byte)7); + Assert.Equal(7, DbExtensions.GetInt32(context.Reader, "Value")); + } + + [Fact] + public void GetInt32_ConvertsInt16() + { + using var context = CreateReader(typeof(short), (short)1234); + Assert.Equal(1234, DbExtensions.GetInt32(context.Reader, "Value")); + } + + [Fact] + public void GetInt32_ConvertsInt64() + { + using var context = CreateReader(typeof(long), 1024L); + Assert.Equal(1024, DbExtensions.GetInt32(context.Reader, "Value")); + } + + [Fact] + public void GetInt32_ConvertsDecimal() + { + using var context = CreateReader(typeof(decimal), 42m); + Assert.Equal(42, DbExtensions.GetInt32(context.Reader, "Value")); + } + + [Fact] + public void GetInt32_ThrowsOnOverflow() + { + using var context = CreateReader(typeof(long), (long)int.MaxValue + 1); + Assert.Throws(() => DbExtensions.GetInt32(context.Reader, "Value")); + } + + [Fact] + public void GetNullableInt32_ReturnsNullForDbNull() + { + using var context = CreateReader(typeof(int), DBNull.Value); + Assert.Null(DbExtensions.GetNullableInt32(context.Reader, "Value")); + } + + private static ReaderContext CreateReader(Type valueType, object value) + { + return new ReaderContext(valueType, value); + } + + private sealed class ReaderContext : IDisposable + { + public ReaderContext(Type valueType, object value) + { + Table = new DataTable(); + Table.Columns.Add("Value", valueType); + Table.Rows.Add(value); + Reader = Table.CreateDataReader(); + Assert.True(Reader.Read()); + } + + public DataTable Table { get; } + + public DataTableReader Reader { get; } + + public void Dispose() + { + Reader.Dispose(); + Table.Dispose(); + } + } +} diff --git a/test/Extensions/Orleans.Azure.Tests/AdvancedReminders/AzureAdvancedRemindersTableTests.cs b/test/Extensions/Orleans.Azure.Tests/AdvancedReminders/AzureAdvancedRemindersTableTests.cs new file mode 100644 index 0000000000..afb3fd5e7d --- /dev/null +++ b/test/Extensions/Orleans.Azure.Tests/AdvancedReminders/AzureAdvancedRemindersTableTests.cs @@ -0,0 +1,52 @@ +#nullable enable +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.AdvancedReminders.AzureStorage; +using Tester; +using Tester.AzureUtils; +using TestExtensions; +using Xunit; + +namespace UnitTests.AdvancedRemindersTest; + +[TestCategory("Reminders"), TestCategory("AzureStorage")] +public class AzureAdvancedRemindersTableTests : AdvancedReminderTableTestsBase +{ + public AzureAdvancedRemindersTableTests(ConnectionStringFixture fixture, TestEnvironmentFixture environment) + : base(fixture, environment, CreateFilters()) + { + TestUtils.CheckForAzureStorage(); + } + + private static LoggerFilterOptions CreateFilters() + { + var filters = new LoggerFilterOptions(); + filters.AddFilter("AzureTableDataManager", LogLevel.Trace); + filters.AddFilter("OrleansSiloInstanceManager", LogLevel.Trace); + filters.AddFilter("Storage", LogLevel.Trace); + return filters; + } + + protected override Orleans.AdvancedReminders.IReminderTable CreateRemindersTable() + { + TestUtils.CheckForAzureStorage(); + var options = Options.Create(new AzureTableReminderStorageOptions()); + options.Value.TableServiceClient = AzureStorageOperationOptionsExtensions.GetTableServiceClient(); + return new AzureBasedReminderTable(loggerFactory, clusterOptions, options); + } + + protected override Task GetConnectionString() + { + TestUtils.CheckForAzureStorage(); + return Task.FromResult("not used"); + } + + [SkippableFact] + public async Task RemindersTable_Azure_DurableCronRoundTrip() => await ReminderCronRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_Azure_DurableAdaptiveFieldsRoundTrip() => await ReminderAdaptiveFieldsRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_Azure_DurableCronTimeZoneRoundTrip() => await ReminderCronTimeZoneRoundTrip(); +} diff --git a/test/Extensions/Orleans.Azure.Tests/Orleans.Azure.Tests.csproj b/test/Extensions/Orleans.Azure.Tests/Orleans.Azure.Tests.csproj index 20dc4997cd..41b6898d2a 100644 --- a/test/Extensions/Orleans.Azure.Tests/Orleans.Azure.Tests.csproj +++ b/test/Extensions/Orleans.Azure.Tests/Orleans.Azure.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/test/Extensions/Orleans.Cosmos.Tests/AdvancedReminders/CosmosAdvancedRemindersTableTests.cs b/test/Extensions/Orleans.Cosmos.Tests/AdvancedReminders/CosmosAdvancedRemindersTableTests.cs new file mode 100644 index 0000000000..46b79a0956 --- /dev/null +++ b/test/Extensions/Orleans.Cosmos.Tests/AdvancedReminders/CosmosAdvancedRemindersTableTests.cs @@ -0,0 +1,88 @@ +#nullable enable +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.AdvancedReminders.Cosmos; +using TestExtensions; +using Tester.Cosmos; +using UnitTests.AdvancedRemindersTest; +using Xunit; + +namespace UnitTests.AdvancedRemindersTest; + +[TestCategory("Reminders"), TestCategory("Cosmos")] +public class CosmosAdvancedRemindersTableTests : AdvancedReminderTableTestsBase +{ + public CosmosAdvancedRemindersTableTests(ConnectionStringFixture fixture, TestEnvironmentFixture environment) + : base(fixture, environment, CreateFilters()) + { + CosmosTestUtils.CheckCosmosStorage(); + } + + private static LoggerFilterOptions CreateFilters() + { + var filters = new LoggerFilterOptions(); + filters.AddFilter(nameof(CosmosAdvancedRemindersTableTests), LogLevel.Trace); + filters.AddFilter("CosmosReminderTable", LogLevel.Trace); + return filters; + } + + protected override Orleans.AdvancedReminders.IReminderTable CreateRemindersTable() + { + CosmosTestUtils.CheckCosmosStorage(); + var options = Options.Create(new CosmosReminderTableOptions()); + ConfigureTestDefaults(options.Value); + return new CosmosReminderTable(loggerFactory, ClusterFixture.Services, options, clusterOptions); + } + + protected override Task GetConnectionString() + { + CosmosTestUtils.CheckCosmosStorage(); + return Task.FromResult(TestDefaultConfiguration.CosmosDBAccountEndpoint); + } + + [SkippableFact] + public async Task RemindersTable_Cosmos_DurableCronRoundTrip() => await ReminderCronRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_Cosmos_DurableAdaptiveFieldsRoundTrip() => await ReminderAdaptiveFieldsRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_Cosmos_DurableCronTimeZoneRoundTrip() => await ReminderCronTimeZoneRoundTrip(); + + private static void ConfigureTestDefaults(CosmosReminderTableOptions options) + { + if (TestDefaultConfiguration.UseAadAuthentication) + { + options.ConfigureCosmosClient(TestDefaultConfiguration.CosmosDBAccountEndpoint, TestDefaultConfiguration.TokenCredential); + } + else + { + options.ConfigureCosmosClient(_ => new ValueTask(CreateCosmosClientUsingAccountKey())); + } + + options.IsResourceCreationEnabled = true; + } + + private static CosmosClient CreateCosmosClientUsingAccountKey() + { + var cosmosClientOptions = new CosmosClientOptions + { + HttpClientFactory = static () => + { + HttpMessageHandler httpMessageHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }; + + return new HttpClient(httpMessageHandler); + }, + ConnectionMode = ConnectionMode.Gateway, + }; + + return new CosmosClient( + TestDefaultConfiguration.CosmosDBAccountEndpoint, + TestDefaultConfiguration.CosmosDBAccountKey, + cosmosClientOptions); + } +} diff --git a/test/Extensions/Orleans.Cosmos.Tests/Orleans.Cosmos.Tests.csproj b/test/Extensions/Orleans.Cosmos.Tests/Orleans.Cosmos.Tests.csproj index 5d10657930..be822cd966 100644 --- a/test/Extensions/Orleans.Cosmos.Tests/Orleans.Cosmos.Tests.csproj +++ b/test/Extensions/Orleans.Cosmos.Tests/Orleans.Cosmos.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/test/Extensions/Orleans.Redis.Tests/AdvancedReminders/RedisAdvancedRemindersTableTests.cs b/test/Extensions/Orleans.Redis.Tests/AdvancedReminders/RedisAdvancedRemindersTableTests.cs new file mode 100644 index 0000000000..650249702f --- /dev/null +++ b/test/Extensions/Orleans.Redis.Tests/AdvancedReminders/RedisAdvancedRemindersTableTests.cs @@ -0,0 +1,55 @@ +#nullable enable +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.AdvancedReminders.Redis; +using StackExchange.Redis; +using TestExtensions; +using UnitTests; +using UnitTests.AdvancedRemindersTest; +using Xunit; +using ClusterOptions = Orleans.Configuration.ClusterOptions; +using LoggerFilterOptions = Microsoft.Extensions.Logging.LoggerFilterOptions; + +namespace Tester.Redis.AdvancedReminders; + +[TestCategory("Redis"), TestCategory("Reminders"), TestCategory("Functional")] +[Collection(TestEnvironmentFixture.DefaultCollection)] +public class RedisAdvancedRemindersTableTests : AdvancedReminderTableTestsBase +{ + public RedisAdvancedRemindersTableTests(ConnectionStringFixture fixture, CommonFixture clusterFixture) + : base(fixture, clusterFixture, CreateFilters()) + { + TestUtils.CheckForRedis(); + } + + private static LoggerFilterOptions CreateFilters() + { + var filters = new LoggerFilterOptions(); + filters.AddFilter(nameof(RedisAdvancedRemindersTableTests), LogLevel.Trace); + return filters; + } + + protected override Orleans.AdvancedReminders.IReminderTable CreateRemindersTable() + { + TestUtils.CheckForRedis(); + return new RedisReminderTable( + loggerFactory.CreateLogger(), + clusterOptions, + Options.Create(new RedisReminderTableOptions + { + ConfigurationOptions = ConfigurationOptions.Parse(GetConnectionString().Result), + EntryExpiry = TimeSpan.FromHours(1), + })); + } + + protected override Task GetConnectionString() => Task.FromResult(TestDefaultConfiguration.RedisConnectionString); + + [SkippableFact] + public async Task RemindersTable_Redis_DurableCronRoundTrip() => await ReminderCronRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_Redis_DurableAdaptiveFieldsRoundTrip() => await ReminderAdaptiveFieldsRoundTrip(); + + [SkippableFact] + public async Task RemindersTable_Redis_DurableCronTimeZoneRoundTrip() => await ReminderCronTimeZoneRoundTrip(); +} diff --git a/test/Extensions/Orleans.Redis.Tests/AdvancedReminders/RedisReminderTableSerializationTests.cs b/test/Extensions/Orleans.Redis.Tests/AdvancedReminders/RedisReminderTableSerializationTests.cs new file mode 100644 index 0000000000..f8c203890a --- /dev/null +++ b/test/Extensions/Orleans.Redis.Tests/AdvancedReminders/RedisReminderTableSerializationTests.cs @@ -0,0 +1,244 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Reflection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Orleans.AdvancedReminders.Redis; +using Orleans.Configuration; +using Orleans.Runtime; +using StackExchange.Redis; +using Xunit; +using ReminderEntry = Orleans.AdvancedReminders.ReminderEntry; +using ReminderPriority = Orleans.AdvancedReminders.Runtime.ReminderPriority; +using MissedReminderAction = Orleans.AdvancedReminders.Runtime.MissedReminderAction; +using AdvancedRedisReminderTableOptions = Orleans.AdvancedReminders.Redis.RedisReminderTableOptions; + +namespace Tester.Redis.AdvancedReminders; + +[TestCategory("Redis"), TestCategory("Reminders")] +public class RedisReminderTableSerializationTests +{ + [Fact] + public void ConvertFromEntry_WritesPriorityAndActionAsNumbers() + { + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "redis-serialization"), + ReminderName = "r", + StartAt = DateTime.UtcNow, + Period = TimeSpan.FromSeconds(30), + CronExpression = "*/5 * * * * *", + NextDueUtc = DateTime.UtcNow.AddSeconds(5), + LastFireUtc = DateTime.UtcNow, + Priority = ReminderPriority.Normal, + Action = MissedReminderAction.Notify, + }; + + var (_, payload) = InvokeConvertFromEntry(entry); + var segments = ParseSegments(payload); + + Assert.Equal(JTokenType.Integer, segments[9]!.Type); + Assert.Equal((int)ReminderPriority.Normal, segments[9]!.Value()); + Assert.Equal(JTokenType.Integer, segments[10]!.Type); + Assert.Equal((int)MissedReminderAction.Notify, segments[10]!.Value()); + } + + [Fact] + public void ConvertFromEntry_WritesInvariantTemporalFormats() + { + var startAt = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc); + var period = TimeSpan.FromMinutes(5); + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "redis-temporal-format"), + ReminderName = "r", + StartAt = startAt, + Period = period, + NextDueUtc = startAt.AddMinutes(5), + LastFireUtc = startAt.AddMinutes(-1), + }; + + var (_, payload) = InvokeConvertFromEntry(entry); + var segments = ParseSegments(payload); + + Assert.Equal(startAt.ToString("O", CultureInfo.InvariantCulture), segments[4]!.Value()); + Assert.Equal(period.ToString("c", CultureInfo.InvariantCulture), segments[5]!.Value()); + Assert.Equal(entry.NextDueUtc?.ToString("O", CultureInfo.InvariantCulture), segments[7]!.Value()); + Assert.Equal(entry.LastFireUtc?.ToString("O", CultureInfo.InvariantCulture), segments[8]!.Value()); + } + + [Fact] + public void ConvertFromEntry_WritesCronTimeZoneAtTailSegment() + { + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "redis-timezone-tail"), + ReminderName = "r", + StartAt = DateTime.UtcNow, + Period = TimeSpan.FromMinutes(1), + CronExpression = "0 9 * * *", + CronTimeZoneId = "America/New_York", + }; + + var (_, payload) = InvokeConvertFromEntry(entry); + var segments = ParseSegments(payload); + + Assert.Equal(entry.CronTimeZoneId, segments[11]!.Value()); + } + + [Fact] + public void ConvertToEntry_ParsesNumericPriorityAndAction() + { + var grainId = GrainId.Create("test", "redis-parse-numeric"); + var payload = BuildPayload(grainId, ReminderPriority.High, MissedReminderAction.FireImmediately, numericEnums: true); + + var entry = InvokeConvertToEntry(payload); + + Assert.Equal(grainId, entry.GrainId); + Assert.Equal(ReminderPriority.High, entry.Priority); + Assert.Equal(MissedReminderAction.FireImmediately, entry.Action); + } + + [Fact] + public void ConvertToEntry_ParsesCronTimeZoneFromCurrentLayout() + { + var grainId = GrainId.Create("test", "redis-parse-timezone-current"); + var payload = BuildPayloadWithAppendedTimeZone(grainId, "Europe/Kyiv"); + + var entry = InvokeConvertToEntry(payload); + + Assert.Equal("Europe/Kyiv", entry.CronTimeZoneId); + } + + [Fact] + public void ConvertToEntry_RejectsNonCanonicalTimeZoneSegmentOrder() + { + var grainId = GrainId.Create("test", "redis-parse-timezone-wrong-order"); + var payload = BuildPayloadWithInsertedTimeZone(grainId, "Europe/Kyiv"); + + var exception = Assert.Throws(() => InvokeConvertToEntry(payload)); + Assert.IsType(exception.InnerException); + } + + [Fact] + public void ConvertToEntry_DefaultsPriorityAndActionWhenValuesAreInvalid() + { + var grainId = GrainId.Create("test", "redis-default-invalid"); + var payload = BuildPayloadWithCustomEnums(grainId, priorityToken: "999", actionToken: "-3"); + + var entry = InvokeConvertToEntry(payload); + + Assert.Equal(ReminderPriority.Normal, entry.Priority); + Assert.Equal(MissedReminderAction.Skip, entry.Action); + } + + private static string BuildPayload(GrainId grainId, ReminderPriority priority, MissedReminderAction action, bool numericEnums) + { + var startAt = DateTime.UtcNow; + var nextDueUtc = startAt.AddSeconds(1); + var lastFireUtc = startAt; + var grainHash = grainId.GetUniformHashCode().ToString("X8", CultureInfo.InvariantCulture); + object priorityToken = numericEnums ? (int)priority : ((int)priority).ToString(CultureInfo.InvariantCulture); + object actionToken = numericEnums ? (int)action : ((int)action).ToString(CultureInfo.InvariantCulture); + + var segments = new object[] + { + grainHash, + grainId.ToString(), + "reminder", + "etag", + startAt.ToString("O", CultureInfo.InvariantCulture), + TimeSpan.FromSeconds(10).ToString("c", CultureInfo.InvariantCulture), + "*/5 * * * * *", + nextDueUtc.ToString("O", CultureInfo.InvariantCulture), + lastFireUtc.ToString("O", CultureInfo.InvariantCulture), + priorityToken, + actionToken, + }; + + return JsonConvert.SerializeObject(segments)[1..^1]; + } + + private static string BuildPayloadWithCustomEnums(GrainId grainId, object priorityToken, object actionToken) + { + var startAt = DateTime.UtcNow; + var nextDueUtc = startAt.AddSeconds(1); + var lastFireUtc = startAt; + var grainHash = grainId.GetUniformHashCode().ToString("X8", CultureInfo.InvariantCulture); + var segments = new object[] + { + grainHash, + grainId.ToString(), + "reminder", + "etag", + startAt.ToString("O", CultureInfo.InvariantCulture), + TimeSpan.FromSeconds(10).ToString("c", CultureInfo.InvariantCulture), + "*/5 * * * * *", + nextDueUtc.ToString("O", CultureInfo.InvariantCulture), + lastFireUtc.ToString("O", CultureInfo.InvariantCulture), + priorityToken, + actionToken, + }; + + return JsonConvert.SerializeObject(segments)[1..^1]; + } + + private static string BuildPayloadWithAppendedTimeZone(GrainId grainId, string timeZoneId) + { + var payload = BuildPayload(grainId, ReminderPriority.Normal, MissedReminderAction.Skip, numericEnums: true); + var segments = ParseSegments(payload); + segments.Add(timeZoneId); + return JsonConvert.SerializeObject(segments)[1..^1]; + } + + private static string BuildPayloadWithInsertedTimeZone(GrainId grainId, string timeZoneId) + { + var payload = BuildPayload(grainId, ReminderPriority.Normal, MissedReminderAction.Skip, numericEnums: true); + var segments = ParseSegments(payload); + segments.Insert(7, timeZoneId); + return JsonConvert.SerializeObject(segments)[1..^1]; + } + + private static ReminderEntry InvokeConvertToEntry(string payload) + { + var method = typeof(RedisReminderTable).GetMethod("ConvertToEntry", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + var result = method!.Invoke(null, [payload]); + Assert.NotNull(result); + return (ReminderEntry)result!; + } + + private static (string ETag, string Payload) InvokeConvertFromEntry(ReminderEntry entry) + { + var table = new RedisReminderTable( + NullLogger.Instance, + Options.Create(new ClusterOptions { ClusterId = "cluster", ServiceId = "service" }), + Options.Create(new AdvancedRedisReminderTableOptions())); + + var method = typeof(RedisReminderTable).GetMethod("ConvertFromEntry", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(method); + + var result = method!.Invoke(table, [entry]); + Assert.NotNull(result); + + var pair = ((RedisValue, RedisValue))result!; + return ((string)pair.Item1!, (string)pair.Item2!); + } + + private static JArray ParseSegments(string payload) + { + using var stringReader = new StringReader($"[{payload}]"); + using var jsonReader = new JsonTextReader(stringReader) + { + DateParseHandling = DateParseHandling.None, + }; + + return JArray.Load(jsonReader); + } +} diff --git a/test/Extensions/Orleans.Redis.Tests/Orleans.Redis.Tests.csproj b/test/Extensions/Orleans.Redis.Tests/Orleans.Redis.Tests.csproj index 1b7119506c..d2660ca5ad 100644 --- a/test/Extensions/Orleans.Redis.Tests/Orleans.Redis.Tests.csproj +++ b/test/Extensions/Orleans.Redis.Tests/Orleans.Redis.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/test/Orleans.Core.Tests/AdvancedReminders/AdvancedReminderCronTests.cs b/test/Orleans.Core.Tests/AdvancedReminders/AdvancedReminderCronTests.cs new file mode 100644 index 0000000000..ca1f9e031e --- /dev/null +++ b/test/Orleans.Core.Tests/AdvancedReminders/AdvancedReminderCronTests.cs @@ -0,0 +1,1195 @@ +#nullable enable +using System; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using NSubstitute; +using Orleans; +using Orleans.AdvancedReminders; +using Orleans.AdvancedReminders.Cron.Internal; +using Orleans.AdvancedReminders.Runtime; +using Orleans.AdvancedReminders.Timers; +using Orleans.Runtime; +using Xunit; +using AdvancedReminderServiceInterface = Orleans.AdvancedReminders.IReminderService; +using IGrainReminder = Orleans.AdvancedReminders.IGrainReminder; +using ReminderEntry = Orleans.AdvancedReminders.ReminderEntry; + +namespace UnitTests.AdvancedReminders; + +internal static class AdvancedReminderTimeZoneTestHelper +{ + public static TimeZoneInfo GetDubaiTimeZone() + => ResolveTimeZone("Asia/Dubai", "Arabian Standard Time"); + + public static TimeZoneInfo GetUsEasternTimeZone() + => ResolveTimeZone("America/New_York", "Eastern Standard Time"); + + public static TimeZoneInfo GetCentralEuropeanTimeZone() + => ResolveTimeZone("Europe/Berlin", "W. Europe Standard Time"); + + public static TimeZoneInfo GetParisTimeZone() + => ResolveTimeZone("Europe/Paris", "Romance Standard Time", "W. Europe Standard Time"); + + public static TimeZoneInfo GetKyivTimeZone() + => ResolveTimeZone("Europe/Kyiv", "FLE Standard Time", "E. Europe Standard Time"); + + public static TimeZoneInfo GetIndiaTimeZone() + => ResolveTimeZone("Asia/Kolkata", "India Standard Time"); + + public static TimeZoneInfo GetNepalTimeZone() + => ResolveTimeZone("Asia/Kathmandu", "Nepal Standard Time"); + + public static TimeZoneInfo GetLordHoweTimeZone() + => ResolveTimeZone("Australia/Lord_Howe", "Lord Howe Standard Time"); + + public static DateTime ToUtc(TimeZoneInfo zone, int year, int month, int day, int hour, int minute, int second) + => TimeZoneInfo.ConvertTimeToUtc(new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified), zone); + + public static string GetCentralEuropeanAlternateTimeZoneId() + { + var zone = GetCentralEuropeanTimeZone(); + return string.Equals(zone.Id, "Europe/Berlin", StringComparison.Ordinal) + ? "W. Europe Standard Time" + : "Europe/Berlin"; + } + + private static TimeZoneInfo ResolveTimeZone(params string[] ids) + { + foreach (var id in ids) + { + if (TryFindTimeZoneById(id, out var zone)) + { + return zone; + } + } + + throw new InvalidOperationException($"Could not resolve any of the requested time zones: {string.Join(", ", ids)}."); + } + + private static bool TryFindTimeZoneById(string id, out TimeZoneInfo zone) + { + try + { + zone = TimeZoneInfo.FindSystemTimeZoneById(id); + return true; + } + catch (TimeZoneNotFoundException) + { + if (TimeZoneInfo.TryConvertIanaIdToWindowsId(id, out var windowsId)) + { + return TryFindTimeZoneById(windowsId, out zone); + } + + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(id, out var ianaId)) + { + return TryFindTimeZoneById(ianaId, out zone); + } + + zone = null!; + return false; + } + catch (InvalidTimeZoneException) + { + zone = null!; + return false; + } + } +} + +[TestCategory("Reminders")] +public class ReminderCronTests +{ + [Theory] + [InlineData("@yearly", 2027, 1, 1, 0, 0, 0)] + [InlineData("@monthly", 2026, 2, 1, 0, 0, 0)] + [InlineData("@daily", 2026, 1, 16, 0, 0, 0)] + [InlineData("@hourly", 2026, 1, 15, 11, 0, 0)] + public void Parse_Macros_ComputeExpectedNextOccurrence( + string macro, + int year, + int month, + int day, + int hour, + int minute, + int second) + { + var expression = ReminderCronExpression.Parse(macro); + var fromUtc = new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc); + + var next = expression.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(year, month, day, hour, minute, second, DateTimeKind.Utc), next); + } + + [Fact] + public void Parse_AdvancedSyntax_ComputesExpectedNextOccurrence() + { + var expression = ReminderCronExpression.Parse("0 9 15W * *"); + var fromUtc = new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc); + + var next = expression.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2026, 2, 16, 9, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void Parse_ReversedRanges_ComputesExpectedNextOccurrence() + { + var expression = ReminderCronExpression.Parse("55-5/5 * * * * *"); + var fromUtc = new DateTime(2026, 1, 1, 10, 0, 56, DateTimeKind.Utc); + + var next = expression.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2026, 1, 1, 10, 1, 0, DateTimeKind.Utc), next); + } + + [Theory] + [InlineData("not-a-cron")] + [InlineData("* * * *")] + [InlineData("* * * * * * *")] + [InlineData("60 * * * *")] + [InlineData("@unknown")] + public void Parse_InvalidExpression_ThrowsFormatException(string expression) + { + Assert.ThrowsAny(() => ReminderCronExpression.Parse(expression)); + } + + [Fact] + public void Parse_LargeMinuteList_ComputesExpectedNextOccurrence() + { + var minuteField = string.Join(",", Enumerable.Repeat("0", 4_096)); + var expression = ReminderCronExpression.Parse($"{minuteField} * * * *"); + var fromUtc = new DateTime(2026, 1, 1, 10, 0, 30, DateTimeKind.Utc); + + var next = expression.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2026, 1, 1, 11, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void TryParse_InvalidExpression_ReturnsFalse() + { + var result = ReminderCronExpression.TryParse("not-a-cron", out var expression); + + Assert.False(result); + Assert.Null(expression); + } + + [Fact] + public void GetOccurrences_ReturnsExpectedRange() + { + var expression = ReminderCronExpression.Parse("0 9 * * *"); + var fromUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var toUtc = new DateTime(2026, 1, 4, 0, 0, 0, DateTimeKind.Utc); + + var occurrences = expression.GetOccurrences(fromUtc, toUtc).ToArray(); + + Assert.Equal(3, occurrences.Length); + Assert.Equal(new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc), occurrences[0]); + Assert.Equal(new DateTime(2026, 1, 2, 9, 0, 0, DateTimeKind.Utc), occurrences[1]); + Assert.Equal(new DateTime(2026, 1, 3, 9, 0, 0, DateTimeKind.Utc), occurrences[2]); + } +} + +[TestCategory("Reminders")] +public class ReminderCronBuilderTimeZoneTests +{ + [Fact] + public void Builder_DefaultTimeZone_IsUtc() + { + var builder = ReminderCronBuilder.DailyAt(9, 0); + + Assert.Equal(TimeZoneInfo.Utc.Id, builder.TimeZone.Id); + } + + [Fact] + public void Builder_TimeZoneOverloads_ApplyTypedZone_ForCoreHelpers() + { + var zone = AdvancedReminderTimeZoneTestHelper.GetDubaiTimeZone(); + + AssertTypedTimeZoneBuilder(ReminderCronBuilder.EveryMinute(zone), "* * * * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.HourlyAt(15, zone), "15 * * * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.HourlyAt(15, 10, zone), "10 15 * * * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.HourlyAt(TimeSpan.FromMinutes(15), zone), "15 * * * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.DailyAt(9, 30, zone), "30 9 * * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.DailyAt(9, 30, 15, zone), "15 30 9 * * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.DailyAt(new TimeOnly(9, 30), zone), "30 9 * * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.DailyAt(new TimeSpan(9, 30, 15), zone), "15 30 9 * * *", zone); + } + + [Fact] + public void Builder_TimeZoneOverloads_ApplyTypedZone_ForCalendarHelpers() + { + var zone = AdvancedReminderTimeZoneTestHelper.GetKyivTimeZone(); + + AssertTypedTimeZoneBuilder(ReminderCronBuilder.WeekdaysAt(9, 30, zone), "30 9 * * MON-FRI", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.WeekdaysAt(9, 30, 15, zone), "15 30 9 * * MON-FRI", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.WeekdaysAt(new TimeOnly(9, 30), zone), "30 9 * * MON-FRI", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.WeekdaysAt(new TimeSpan(9, 30, 15), zone), "15 30 9 * * MON-FRI", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.WeekendsAt(9, 30, zone), "30 9 * * SAT,SUN", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.WeekendsAt(9, 30, 15, zone), "15 30 9 * * SAT,SUN", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.WeekendsAt(new TimeOnly(9, 30), zone), "30 9 * * SAT,SUN", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.WeekendsAt(new TimeSpan(9, 30, 15), zone), "15 30 9 * * SAT,SUN", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.WeeklyOn(DayOfWeek.Monday, 4, 5, zone), "5 4 * * 1", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.WeeklyOn(DayOfWeek.Monday, 4, 5, 6, zone), "6 5 4 * * 1", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.WeeklyOn(DayOfWeek.Tuesday, new TimeOnly(4, 5, 6), zone), "6 5 4 * * 2", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.WeeklyOn(DayOfWeek.Tuesday, new TimeSpan(4, 5, 6), zone), "6 5 4 * * 2", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.MonthlyOn(31, 23, 59, zone), "59 23 31 * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.MonthlyOn(31, 23, 59, 58, zone), "58 59 23 31 * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.MonthlyOn(31, new TimeOnly(23, 59, 58), zone), "58 59 23 31 * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.MonthlyOn(31, new TimeSpan(23, 59, 58), zone), "58 59 23 31 * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.MonthlyOnLastDay(23, 59, zone), "59 23 L * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.MonthlyOnLastDay(23, 59, 58, zone), "58 59 23 L * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.MonthlyOnLastDay(new TimeOnly(23, 59, 58), zone), "58 59 23 L * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.MonthlyOnLastDay(new TimeSpan(23, 59, 58), zone), "58 59 23 L * *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.YearlyOn(3, 15, 6, 45, zone), "45 6 15 3 *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.YearlyOn(3, 15, 6, 45, 30, zone), "30 45 6 15 3 *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.YearlyOn(3, 15, new TimeOnly(6, 45, 30), zone), "30 45 6 15 3 *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.YearlyOn(3, 15, new TimeSpan(6, 45, 30), zone), "30 45 6 15 3 *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.YearlyOn(new DateOnly(2024, 2, 29), 12, 34, zone), "34 12 29 2 *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.YearlyOn(new DateOnly(2024, 2, 29), 12, 34, 56, zone), "56 34 12 29 2 *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.YearlyOn(new DateOnly(2024, 2, 29), new TimeOnly(12, 34, 56), zone), "56 34 12 29 2 *", zone); + AssertTypedTimeZoneBuilder(ReminderCronBuilder.YearlyOn(new DateOnly(2024, 2, 29), new TimeSpan(12, 34, 56), zone), "56 34 12 29 2 *", zone); + } + + [Fact] + public void Builder_InTimeZone_WithTimeZoneInfo_UsesLocalScheduleAndReturnsUtc() + { + var builder = ReminderCronBuilder.DailyAt(9, 0).InTimeZone(AdvancedReminderTimeZoneTestHelper.GetCentralEuropeanTimeZone()); + var fromUtc = new DateTime(2026, 1, 1, 6, 30, 0, DateTimeKind.Utc); + + var next = builder.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void Builder_InTimeZone_WithUsEasternAcrossSpringForward_PreservesNineAmLocal() + { + var builder = ReminderCronBuilder.DailyAt(9, 0).InTimeZone(AdvancedReminderTimeZoneTestHelper.GetUsEasternTimeZone()); + var fromUtc = new DateTime(2025, 3, 7, 0, 0, 0, DateTimeKind.Utc); + var toUtc = new DateTime(2025, 3, 12, 0, 0, 0, DateTimeKind.Utc); + + var occurrences = builder.GetOccurrences(fromUtc, toUtc).ToArray(); + + Assert.Equal( + [ + new DateTime(2025, 3, 7, 14, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 3, 8, 14, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 3, 9, 13, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 3, 10, 13, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 3, 11, 13, 0, 0, DateTimeKind.Utc), + ], + occurrences); + } + + [Fact] + public void Builder_InTimeZone_WithAlternatePlatformId_UsesEquivalentZone() + { + var alternateZoneId = AdvancedReminderTimeZoneTestHelper.GetCentralEuropeanAlternateTimeZoneId(); + var builder = ReminderCronBuilder.DailyAt(9, 0).InTimeZone(alternateZoneId); + var fromUtc = new DateTime(2026, 1, 1, 7, 30, 0, DateTimeKind.Utc); + + var next = builder.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public async Task RegistryRegistrationExtensions_WithNonUtcBuilder_DelegatesEncodedSchedule() + { + var registry = Substitute.For(); + var grainId = GrainId.Create("test", "non-utc-builder-registry"); + var reminder = Substitute.For(); + var zone = AdvancedReminderTimeZoneTestHelper.GetCentralEuropeanTimeZone(); + var builder = ReminderCronBuilder.DailyAt(9, 0).InTimeZone(zone); + var expectedTimeZoneId = ReminderCronSchedule.NormalizeTimeZoneIdForStorage(zone); + registry.RegisterOrUpdateReminder( + grainId, + "r", + Arg.Any(), + ReminderPriority.Normal, + MissedReminderAction.Skip) + .Returns(Task.FromResult(reminder)); + + var result = await registry.RegisterOrUpdateReminder(grainId, "r", builder); + + Assert.Same(reminder, result); + _ = registry.Received(1).RegisterOrUpdateReminder( + grainId, + "r", + Arg.Is(schedule => + schedule.Kind == ReminderScheduleKind.Cron + && schedule.CronExpression == "0 9 * * *" + && schedule.CronTimeZoneId == expectedTimeZoneId), + ReminderPriority.Normal, + MissedReminderAction.Skip); + } + + [Fact] + public async Task ServiceRegistrationExtensions_WithNonUtcBuilder_DelegatesEncodedSchedule() + { + var service = Substitute.For(); + var grainId = GrainId.Create("test", "non-utc-builder-service"); + var reminder = Substitute.For(); + var zone = AdvancedReminderTimeZoneTestHelper.GetCentralEuropeanTimeZone(); + var builder = ReminderCronBuilder.DailyAt(9, 0).InTimeZone(zone); + var expectedTimeZoneId = ReminderCronSchedule.NormalizeTimeZoneIdForStorage(zone); + service.RegisterOrUpdateReminder( + grainId, + "r", + Arg.Any(), + ReminderPriority.Normal, + MissedReminderAction.Skip) + .Returns(Task.FromResult(reminder)); + + var result = await service.RegisterOrUpdateReminder(grainId, "r", builder); + + Assert.Same(reminder, result); + _ = service.Received(1).RegisterOrUpdateReminder( + grainId, + "r", + Arg.Is(schedule => + schedule.Kind == ReminderScheduleKind.Cron + && schedule.CronExpression == "0 9 * * *" + && schedule.CronTimeZoneId == expectedTimeZoneId), + ReminderPriority.Normal, + MissedReminderAction.Skip); + } + + private static void AssertTypedTimeZoneBuilder(ReminderCronBuilder builder, string expectedExpression, TimeZoneInfo expectedZone) + { + Assert.Equal(expectedExpression, builder.ToExpressionString()); + Assert.Equal(expectedZone.Id, builder.TimeZone.Id); + } +} + +[TestCategory("Reminders")] +public class ReminderCronExpressionTimeZoneTests +{ + [Fact] + public void GetNextOccurrence_WithTimeZone_UsesLocalScheduleAndReturnsUtc() + { + var expression = ReminderCronExpression.Parse("0 9 * * *"); + var fromUtc = new DateTime(2026, 1, 1, 6, 30, 0, DateTimeKind.Utc); + var zone = AdvancedReminderTimeZoneTestHelper.GetCentralEuropeanTimeZone(); + + var next = expression.GetNextOccurrence(fromUtc, zone); + + Assert.Equal(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void GetOccurrences_WithUsEasternAcrossFallBack_PreservesNineAmLocal() + { + var expression = ReminderCronExpression.Parse("0 9 * * *"); + var zone = AdvancedReminderTimeZoneTestHelper.GetUsEasternTimeZone(); + var fromUtc = new DateTime(2025, 10, 31, 0, 0, 0, DateTimeKind.Utc); + var toUtc = new DateTime(2025, 11, 4, 0, 0, 0, DateTimeKind.Utc); + + var occurrences = expression.GetOccurrences(fromUtc, toUtc, zone).ToArray(); + + Assert.Equal( + [ + new DateTime(2025, 10, 31, 13, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 11, 1, 13, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 11, 2, 14, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 11, 3, 14, 0, 0, DateTimeKind.Utc), + ], + occurrences); + } + + [Fact] + public void GetNextOccurrence_WithUsEastern_WhenLocalTimeIsInvalid_MovesToNextValidInstant() + { + var expression = ReminderCronExpression.Parse("30 2 * * *"); + var zone = AdvancedReminderTimeZoneTestHelper.GetUsEasternTimeZone(); + var fromUtc = new DateTime(2025, 3, 8, 13, 0, 0, DateTimeKind.Utc); + + var next = expression.GetNextOccurrence(fromUtc, zone); + + Assert.Equal(new DateTime(2025, 3, 9, 7, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void GetNextOccurrence_WithLeapDaySchedule_SkipsToNextLeapYear() + { + var expression = ReminderCronExpression.Parse("0 9 29 2 *"); + var fromUtc = new DateTime(2025, 3, 1, 0, 0, 0, DateTimeKind.Utc); + + var next = expression.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2028, 2, 29, 9, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void GetNextOccurrence_WithTimeZone_ThrowsOnNullZone() + { + var expression = ReminderCronExpression.Parse("0 9 * * *"); + var fromUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + Assert.Throws(() => expression.GetNextOccurrence(fromUtc, zone: null!)); + } +} + +[TestCategory("Reminders")] +public class ReminderCronExpressionFuzzTests +{ + [Fact] + public void Fuzz_InternalCanonicalRoundTrip_PreservesSchedule() + { + var random = new Random(138_931); + + for (var i = 0; i < 300; i++) + { + var expressionText = + $"{GenerateTimeField(random, 0, 59)} {GenerateTimeField(random, 0, 59)} {GenerateTimeField(random, 0, 23)} * * ?"; + + var original = ReminderCronExpression.Parse(expressionText); + var canonicalText = GetInternalCronExpressionText(original); + var canonical = ReminderCronExpression.Parse(canonicalText); + + for (var j = 0; j < 10; j++) + { + var fromUtc = GenerateUtcInstant(random); + var inclusive = random.Next(2) == 0; + + var expected = original.GetNextOccurrence(fromUtc, inclusive); + var actual = canonical.GetNextOccurrence(fromUtc, inclusive); + + Assert.Equal(expected, actual); + } + } + } + + private static string GenerateTimeField(Random random, int min, int max) + { + var mode = random.Next(6); + return mode switch + { + 0 => "*", + 1 => random.Next(min, max + 1).ToString(CultureInfo.InvariantCulture), + 2 => GenerateListField(random, min, max), + 3 => GenerateRangeField(random, min, max), + 4 => $"*/{random.Next(1, Math.Min(max - min + 1, 12) + 1).ToString(CultureInfo.InvariantCulture)}", + _ => GenerateSteppedRangeField(random, min, max), + }; + } + + private static string GenerateListField(Random random, int min, int max) + => string.Join(",", Enumerable.Range(0, random.Next(2, 7)).Select(_ => random.Next(min, max + 1).ToString(CultureInfo.InvariantCulture))); + + private static string GenerateRangeField(Random random, int min, int max) + { + var left = random.Next(min, max + 1); + var right = random.Next(min, max + 1); + return $"{left.ToString(CultureInfo.InvariantCulture)}-{right.ToString(CultureInfo.InvariantCulture)}"; + } + + private static string GenerateSteppedRangeField(Random random, int min, int max) + { + var left = random.Next(min, max + 1); + var right = random.Next(min, max + 1); + var step = random.Next(1, Math.Min(max - min + 1, 12) + 1); + return $"{left.ToString(CultureInfo.InvariantCulture)}-{right.ToString(CultureInfo.InvariantCulture)}/{step.ToString(CultureInfo.InvariantCulture)}"; + } + + private static DateTime GenerateUtcInstant(Random random) + { + var year = random.Next(2024, 2028); + var month = random.Next(1, 13); + var day = random.Next(1, DateTime.DaysInMonth(year, month) + 1); + var hour = random.Next(0, 24); + var minute = random.Next(0, 60); + var second = random.Next(0, 60); + + return new DateTime(year, month, day, hour, minute, second, DateTimeKind.Utc); + } + + private static string GetInternalCronExpressionText(ReminderCronExpression expression) + { + var field = typeof(ReminderCronExpression).GetField("_expression", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + + var internalExpression = field!.GetValue(expression); + Assert.NotNull(internalExpression); + + return internalExpression!.ToString()!; + } +} + +[TestCategory("Reminders")] +public class ReminderEntryConversionTests +{ + [Fact] + public void ReminderEntry_ToIGrainReminder_ExposesCronTimeZone() + { + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "key"), + ReminderName = "rem", + ETag = "etag", + CronExpression = "0 */5 * * * *", + CronTimeZoneId = "America/New_York", + Priority = ReminderPriority.High, + Action = MissedReminderAction.FireImmediately, + }; + + var reminder = entry.ToIGrainReminder(); + + Assert.Equal(entry.ReminderName, reminder.ReminderName); + Assert.Equal(entry.CronExpression, reminder.CronExpression); + Assert.Equal(entry.CronTimeZoneId, reminder.CronTimeZone); + Assert.Equal(entry.Priority, reminder.Priority); + Assert.Equal(entry.Action, reminder.Action); + } + + [Fact] + public void ReminderEntry_ToIGrainReminder_NormalizesNullCronFields() + { + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "key"), + ReminderName = "rem", + ETag = "etag", + CronExpression = null!, + CronTimeZoneId = null!, + }; + + var reminder = entry.ToIGrainReminder(); + + Assert.Equal(string.Empty, reminder.CronExpression); + Assert.Equal(string.Empty, reminder.CronTimeZone); + } +} + +[TestCategory("Reminders")] +public class ReminderCronComplexPatternTests +{ + [Theory] + [InlineData("@weekly", 2026, 1, 18, 0, 0, 0)] + [InlineData("@midnight", 2026, 1, 16, 0, 0, 0)] + [InlineData("@every_second", 2026, 1, 15, 10, 0, 1)] + [InlineData("@annually", 2027, 1, 1, 0, 0, 0)] + public void Parse_AdditionalMacros_ComputeExpectedNextOccurrence( + string macro, + int year, + int month, + int day, + int hour, + int minute, + int second) + { + var expression = ReminderCronExpression.Parse(macro); + var fromUtc = new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc); + + var next = expression.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(year, month, day, hour, minute, second, DateTimeKind.Utc), next); + } + + [Fact] + public void Parse_LastDayOffset_ComputesExpectedNextOccurrence() + { + var expression = ReminderCronExpression.Parse("0 9 L-3 * *"); + var fromUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var next = expression.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2026, 1, 28, 9, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void Parse_LastNamedWeekday_ComputesExpectedNextOccurrence() + { + var expression = ReminderCronExpression.Parse("0 9 ? * FRIL"); + var fromUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var next = expression.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2026, 1, 30, 9, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void Parse_NthNamedWeekday_ComputesExpectedNextOccurrence() + { + var expression = ReminderCronExpression.Parse("0 9 ? * MON#2"); + var fromUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var next = expression.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2026, 1, 12, 9, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void Parse_NearestWeekday_ComputesExpectedNextOccurrence() + { + var expression = ReminderCronExpression.Parse("0 9 1W 6 *"); + var fromUtc = new DateTime(2025, 5, 31, 23, 0, 0, DateTimeKind.Utc); + + var next = expression.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2025, 6, 2, 9, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void Parse_SecondsMonthListAndWeekdayRange_ComputesExpectedNextOccurrence() + { + var expression = ReminderCronExpression.Parse("15 30 9 ? JAN,MAR MON-FRI"); + var fromUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var next = expression.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2026, 1, 1, 9, 30, 15, DateTimeKind.Utc), next); + } +} + +[TestCategory("Reminders")] +public class ReminderCronBuilderTests +{ + [Fact] + public void Builder_FactoryHelpers_EmitExpectedExpressions() + { + Assert.Equal("* * * * *", ReminderCronBuilder.EveryMinute().ToExpressionString()); + Assert.Equal("15 * * * *", ReminderCronBuilder.HourlyAt(15).ToExpressionString()); + Assert.Equal("10 15 * * * *", ReminderCronBuilder.HourlyAt(15, 10).ToExpressionString()); + Assert.Equal("0 9 * * *", ReminderCronBuilder.DailyAt(9, 0).ToExpressionString()); + Assert.Equal("15 30 9 * * *", ReminderCronBuilder.DailyAt(9, 30, 15).ToExpressionString()); + Assert.Equal("30 9 * * MON-FRI", ReminderCronBuilder.WeekdaysAt(9, 30).ToExpressionString()); + Assert.Equal("15 30 9 * * MON-FRI", ReminderCronBuilder.WeekdaysAt(9, 30, 15).ToExpressionString()); + Assert.Equal("30 9 * * SAT,SUN", ReminderCronBuilder.WeekendsAt(9, 30).ToExpressionString()); + Assert.Equal("5 4 * * 1", ReminderCronBuilder.WeeklyOn(DayOfWeek.Monday, 4, 5).ToExpressionString()); + Assert.Equal("6 5 4 * * 1", ReminderCronBuilder.WeeklyOn(DayOfWeek.Monday, 4, 5, 6).ToExpressionString()); + Assert.Equal("59 23 31 * *", ReminderCronBuilder.MonthlyOn(31, 23, 59).ToExpressionString()); + Assert.Equal("58 59 23 31 * *", ReminderCronBuilder.MonthlyOn(31, 23, 59, 58).ToExpressionString()); + Assert.Equal("59 23 L * *", ReminderCronBuilder.MonthlyOnLastDay(23, 59).ToExpressionString()); + Assert.Equal("58 59 23 L * *", ReminderCronBuilder.MonthlyOnLastDay(23, 59, 58).ToExpressionString()); + Assert.Equal("45 6 15 3 *", ReminderCronBuilder.YearlyOn(3, 15, 6, 45).ToExpressionString()); + Assert.Equal("30 45 6 15 3 *", ReminderCronBuilder.YearlyOn(3, 15, 6, 45, 30).ToExpressionString()); + } + + [Fact] + public void Builder_TimeOnlyAndTimeSpanHelpers_EmitExpectedExpressions() + { + Assert.Equal("15 * * * *", ReminderCronBuilder.HourlyAt(TimeSpan.FromMinutes(15)).ToExpressionString()); + Assert.Equal("30 9 * * *", ReminderCronBuilder.DailyAt(new TimeOnly(9, 30)).ToExpressionString()); + Assert.Equal("15 30 9 * * MON-FRI", ReminderCronBuilder.WeekdaysAt(new TimeSpan(9, 30, 15)).ToExpressionString()); + Assert.Equal("15 30 9 * * SAT,SUN", ReminderCronBuilder.WeekendsAt(new TimeSpan(9, 30, 15)).ToExpressionString()); + Assert.Equal("6 5 4 * * 2", ReminderCronBuilder.WeeklyOn(DayOfWeek.Tuesday, new TimeOnly(4, 5, 6)).ToExpressionString()); + Assert.Equal("59 23 31 * *", ReminderCronBuilder.MonthlyOn(31, TimeSpan.FromHours(23) + TimeSpan.FromMinutes(59)).ToExpressionString()); + Assert.Equal("58 59 23 L * *", ReminderCronBuilder.MonthlyOnLastDay(new TimeOnly(23, 59, 58)).ToExpressionString()); + Assert.Equal("34 12 29 2 *", ReminderCronBuilder.YearlyOn(new DateOnly(2024, 2, 29), new TimeOnly(12, 34)).ToExpressionString()); + Assert.Equal("34 12 29 2 *", ReminderCronBuilder.YearlyOn(new DateOnly(2024, 2, 29), 12, 34).ToExpressionString()); + Assert.Equal("56 34 12 29 2 *", ReminderCronBuilder.YearlyOn(new DateOnly(2024, 2, 29), 12, 34, 56).ToExpressionString()); + } + + [Fact] + public void Builder_YearlyOn_DateOnly_IgnoresYear() + { + var first = ReminderCronBuilder.YearlyOn(new DateOnly(2024, 2, 29), new TimeOnly(12, 34)); + var second = ReminderCronBuilder.YearlyOn(new DateOnly(2032, 2, 29), new TimeOnly(12, 34)); + + Assert.Equal(first.ToExpressionString(), second.ToExpressionString()); + } + + [Theory] + [InlineData(DayOfWeek.Sunday, 0)] + [InlineData(DayOfWeek.Monday, 1)] + [InlineData(DayOfWeek.Tuesday, 2)] + [InlineData(DayOfWeek.Wednesday, 3)] + [InlineData(DayOfWeek.Thursday, 4)] + [InlineData(DayOfWeek.Friday, 5)] + [InlineData(DayOfWeek.Saturday, 6)] + public void Builder_WeeklyOn_MapsDayOfWeekToCronValue(DayOfWeek dayOfWeek, int expectedCronDay) + { + var builder = ReminderCronBuilder.WeeklyOn(dayOfWeek, 4, 5); + + Assert.Equal($"5 4 * * {expectedCronDay}", builder.ToExpressionString()); + } + + [Fact] + public void Builder_FromExpression_TrimsAndSupportsBuildAliases() + { + var builder = ReminderCronBuilder.FromExpression(" 0 9 * * * "); + + Assert.Equal("0 9 * * *", builder.ToExpressionString()); + Assert.Equal(TimeZoneInfo.Utc.Id, builder.TimeZone.Id); + Assert.Equal("0 9 * * *", builder.ToCronExpression().ToExpressionString()); + Assert.Equal("0 9 * * *", builder.Build().ToExpressionString()); + } + + [Fact] + public void Builder_FromExpression_WithUtcZone_UsesUtcBranchForNextOccurrence() + { + var builder = ReminderCronBuilder.FromExpression("0 9 * * *", TimeZoneInfo.Utc); + var fromUtc = new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc); + + var next = builder.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void Builder_FromExpression_WithUtcZone_UsesUtcBranchForOccurrences() + { + var builder = ReminderCronBuilder.FromExpression("0 9 * * *", TimeZoneInfo.Utc); + var fromUtc = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc); + var toUtc = new DateTime(2026, 1, 3, 9, 0, 0, DateTimeKind.Utc); + + var occurrences = builder.GetOccurrences(fromUtc, toUtc, fromInclusive: false, toInclusive: true).ToArray(); + + Assert.Equal( + [ + new DateTime(2026, 1, 2, 9, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 1, 3, 9, 0, 0, DateTimeKind.Utc), + ], + occurrences); + } + + [Theory] + [InlineData(-1)] + [InlineData(60)] + public void Builder_HourlyAt_InvalidMinute_Throws(int minute) + { + Assert.Throws(() => ReminderCronBuilder.HourlyAt(minute)); + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(24, 0)] + [InlineData(0, -1)] + [InlineData(0, 60)] + public void Builder_DailyAt_InvalidClockValues_Throws(int hour, int minute) + { + Assert.Throws(() => ReminderCronBuilder.DailyAt(hour, minute)); + } + + [Fact] + public void Builder_HourlyAt_InvalidOffset_Throws() + { + Assert.Throws(() => ReminderCronBuilder.HourlyAt(TimeSpan.FromHours(1))); + Assert.Throws(() => ReminderCronBuilder.HourlyAt(TimeSpan.FromMilliseconds(1))); + } + + [Theory] + [InlineData(-1)] + [InlineData(60)] + public void Builder_SecondBasedHelpers_InvalidSecond_Throws(int second) + { + Assert.Throws(() => ReminderCronBuilder.HourlyAt(0, second)); + Assert.Throws(() => ReminderCronBuilder.DailyAt(0, 0, second)); + Assert.Throws(() => ReminderCronBuilder.WeekdaysAt(0, 0, second)); + Assert.Throws(() => ReminderCronBuilder.WeekendsAt(0, 0, second)); + Assert.Throws(() => ReminderCronBuilder.WeeklyOn(DayOfWeek.Monday, 0, 0, second)); + Assert.Throws(() => ReminderCronBuilder.MonthlyOn(1, 0, 0, second)); + Assert.Throws(() => ReminderCronBuilder.MonthlyOnLastDay(0, 0, second)); + Assert.Throws(() => ReminderCronBuilder.YearlyOn(1, 1, 0, 0, second)); + Assert.Throws(() => ReminderCronBuilder.YearlyOn(new DateOnly(2024, 1, 1), 0, 0, second)); + } + + [Fact] + public void Builder_DailyAt_InvalidTimeOnlyOrTimeSpan_Throws() + { + Assert.Throws(() => ReminderCronBuilder.DailyAt(new TimeOnly(9, 0, 0, 1))); + Assert.Throws(() => ReminderCronBuilder.DailyAt(TimeSpan.FromDays(1))); + Assert.Throws(() => ReminderCronBuilder.DailyAt(TimeSpan.FromMilliseconds(1))); + } + + [Theory] + [InlineData(0)] + [InlineData(32)] + public void Builder_MonthlyOn_InvalidDayOfMonth_Throws(int dayOfMonth) + { + Assert.Throws(() => ReminderCronBuilder.MonthlyOn(dayOfMonth, 0, 0)); + } + + [Fact] + public void Builder_WeeklyOn_InvalidDayOfWeek_Throws() + { + Assert.Throws(() => ReminderCronBuilder.WeeklyOn((DayOfWeek)99, 0, 0)); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(13, 1)] + [InlineData(4, 31)] + public void Builder_YearlyOn_InvalidMonthOrDay_Throws(int month, int dayOfMonth) + { + Assert.Throws(() => ReminderCronBuilder.YearlyOn(month, dayOfMonth, 0, 0)); + } + + [Fact] + public void Builder_InTimeZone_WithUnknownId_Throws() + { + var builder = ReminderCronBuilder.DailyAt(9, 0); + + Assert.Throws(() => builder.InTimeZone("Definitely/Not-A-TimeZone")); + } +} + +[TestCategory("Reminders")] +public class ReminderCronExpressionBehaviorTests +{ + [Fact] + public void TryParse_BlankExpression_ReturnsFalse() + { + var result = ReminderCronExpression.TryParse(" ", out var expression); + + Assert.False(result); + Assert.Null(expression); + } + + [Fact] + public void FromValidatedString_PreservesExpressionText() + { + var expression = ReminderCronExpression.FromValidatedString("0 9 * * *"); + + Assert.Equal("0 9 * * *", expression.ExpressionText); + Assert.Equal("0 9 * * *", expression.ToExpressionString()); + Assert.Equal("0 9 * * *", expression.ToString()); + } + + [Fact] + public void Equality_UsesOrdinalExpressionText() + { + var first = ReminderCronExpression.Parse("0 9 * * *"); + var second = ReminderCronExpression.Parse("0 9 * * *"); + var different = ReminderCronExpression.Parse("0 10 * * *"); + + Assert.True(first.Equals(second)); + Assert.True(first.Equals((object)second)); + Assert.False(first.Equals(different)); + Assert.False(first.Equals((object)"0 9 * * *")); + Assert.False(first.Equals(null)); + Assert.Equal(first.GetHashCode(), second.GetHashCode()); + } + + [Fact] + public void GetNextOccurrence_WithNonUtcDateTime_Throws() + { + var expression = ReminderCronExpression.Parse("0 9 * * *"); + var local = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Local); + + Assert.Throws(() => expression.GetNextOccurrence(local)); + } + + [Fact] + public void GetOccurrences_WithNonUtcRange_Throws() + { + var expression = ReminderCronExpression.Parse("0 9 * * *"); + var from = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var to = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Local); + + Assert.Throws(() => expression.GetOccurrences(from, to).ToArray()); + } +} + +[TestCategory("Reminders")] +public class ReminderCronScheduleTests +{ + [Fact] + public void Schedule_Parse_WithoutTimeZone_DefaultsToUtc() + { + var schedule = ReminderCronSchedule.Parse("0 9 * * *"); + var next = schedule.GetNextOccurrence(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)); + + Assert.Equal(TimeZoneInfo.Utc.Id, schedule.TimeZone.Id); + Assert.Null(schedule.TimeZoneId); + Assert.Equal(new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void Schedule_Parse_WithExpressionAndZone_NormalizesStorageIdAndUsesLocalSchedule() + { + var zone = AdvancedReminderTimeZoneTestHelper.GetNepalTimeZone(); + var expression = ReminderCronExpression.Parse("0 9 * * *"); + var schedule = ReminderCronSchedule.Parse(expression, zone); + var fromUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var next = schedule.GetNextOccurrence(fromUtc); + var occurrences = schedule.GetOccurrences(fromUtc, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc)).ToArray(); + + Assert.Equal(ReminderCronSchedule.NormalizeTimeZoneIdForStorage(zone), schedule.TimeZoneId); + Assert.Equal(AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2026, 1, 1, 9, 0, 0), next); + Assert.Equal( + [ + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2026, 1, 1, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2026, 1, 2, 9, 0, 0), + ], + occurrences); + } + + [Fact] + public void Schedule_NormalizeTimeZoneIdForStorage_ReturnsNullForUtcAndNull() + { + Assert.Null(ReminderCronSchedule.NormalizeTimeZoneIdForStorage(null)); + Assert.Null(ReminderCronSchedule.NormalizeTimeZoneIdForStorage(TimeZoneInfo.Utc)); + } + + [Fact] + public void Schedule_Parse_WithUnknownTimeZone_ThrowsCronFormatException() + { + var exception = Assert.Throws(() => ReminderCronSchedule.Parse("0 9 * * *", "Definitely/Not-A-TimeZone")); + + Assert.Contains("Unknown time zone id", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Schedule_Parse_WithAlternatePlatformTimeZoneId_UsesEquivalentZone() + { + var zoneId = AdvancedReminderTimeZoneTestHelper.GetCentralEuropeanAlternateTimeZoneId(); + var expectedZoneId = ReminderCronSchedule.NormalizeTimeZoneIdForStorage(AdvancedReminderTimeZoneTestHelper.GetCentralEuropeanTimeZone()); + var schedule = ReminderCronSchedule.Parse("0 9 * * *", zoneId); + + Assert.Equal(expectedZoneId, schedule.TimeZoneId); + } + + [Fact] + public void Schedule_Parse_ReusesCachedScheduleForEquivalentInputs() + { + var first = ReminderCronSchedule.Parse(" 0 9 * * * ", " Europe/Berlin "); + var second = ReminderCronSchedule.Parse("0 9 * * *", "Europe/Berlin"); + + Assert.Same(first, second); + } +} + +[TestCategory("Reminders")] +public class ReminderCronTimeZoneEdgeCaseTests +{ + [Fact] + public void Builder_WithNepalTimeZone_PreservesQuarterHourOffset() + { + var zone = AdvancedReminderTimeZoneTestHelper.GetNepalTimeZone(); + var builder = ReminderCronBuilder.DailyAt(9, 0).InTimeZone(zone); + var fromUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var next = builder.GetNextOccurrence(fromUtc); + + Assert.Equal(AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2026, 1, 1, 9, 0, 0), next); + } + + [Fact] + public void Builder_WithKyivTimeZoneOverload_AcrossEuropeanSpringForward_PreservesNineAmLocal() + { + var zone = AdvancedReminderTimeZoneTestHelper.GetKyivTimeZone(); + var builder = ReminderCronBuilder.DailyAt(9, 0, zone); + var fromUtc = new DateTime(2025, 3, 28, 0, 0, 0, DateTimeKind.Utc); + var toUtc = new DateTime(2025, 4, 2, 0, 0, 0, DateTimeKind.Utc); + + var occurrences = builder.GetOccurrences(fromUtc, toUtc).ToArray(); + + Assert.Equal( + [ + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 3, 28, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 3, 29, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 3, 30, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 3, 31, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 4, 1, 9, 0, 0), + ], + occurrences); + } + + [Fact] + public void Builder_WithLordHoweAcrossDstTransition_PreservesNineAmLocal() + { + var zone = AdvancedReminderTimeZoneTestHelper.GetLordHoweTimeZone(); + var builder = ReminderCronBuilder.DailyAt(9, 0).InTimeZone(zone); + var fromUtc = new DateTime(2025, 4, 4, 0, 0, 0, DateTimeKind.Utc); + var toUtc = new DateTime(2025, 4, 8, 0, 0, 0, DateTimeKind.Utc); + + var occurrences = builder.GetOccurrences(fromUtc, toUtc).ToArray(); + + Assert.Equal( + [ + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 4, 5, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 4, 6, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 4, 7, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 4, 8, 9, 0, 0), + ], + occurrences); + } + + [Fact] + public void Builder_WithIndiaTimeZone_AcrossNewYear_PreservesLocalMidnightSchedule() + { + var zone = AdvancedReminderTimeZoneTestHelper.GetIndiaTimeZone(); + var builder = ReminderCronBuilder.DailyAt(0, 15).InTimeZone(zone); + var fromUtc = new DateTime(2025, 12, 31, 18, 0, 0, DateTimeKind.Utc); + + var next = builder.GetNextOccurrence(fromUtc); + + Assert.Equal(AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2026, 1, 1, 0, 15, 0), next); + } + + [Fact] + public void Builder_WithDubaiTimeZoneOverload_DoesNotShiftAcrossDstWindows() + { + var zone = AdvancedReminderTimeZoneTestHelper.GetDubaiTimeZone(); + var builder = ReminderCronBuilder.DailyAt(9, 0, zone); + var fromUtc = new DateTime(2025, 3, 7, 0, 0, 0, DateTimeKind.Utc); + var toUtc = new DateTime(2025, 3, 12, 0, 0, 0, DateTimeKind.Utc); + + var occurrences = builder.GetOccurrences(fromUtc, toUtc).ToArray(); + + Assert.Equal( + [ + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 3, 7, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 3, 8, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 3, 9, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 3, 10, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 3, 11, 9, 0, 0), + ], + occurrences); + } + + [Fact] + public void Builder_WithKyivAndNewYork_WhenUsAlreadyOnDst_ReturnExpectedUtcOffsets() + { + var kyiv = AdvancedReminderTimeZoneTestHelper.GetKyivTimeZone(); + var newYork = AdvancedReminderTimeZoneTestHelper.GetUsEasternTimeZone(); + var kyivBuilder = ReminderCronBuilder.DailyAt(9, 0, kyiv); + var newYorkBuilder = ReminderCronBuilder.DailyAt(9, 0, newYork); + var fromUtc = new DateTime(2025, 3, 10, 0, 0, 0, DateTimeKind.Utc); + + var kyivNext = kyivBuilder.GetNextOccurrence(fromUtc); + var newYorkNext = newYorkBuilder.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2025, 3, 10, 7, 0, 0, DateTimeKind.Utc), kyivNext); + Assert.Equal(new DateTime(2025, 3, 10, 13, 0, 0, DateTimeKind.Utc), newYorkNext); + Assert.Equal(AdvancedReminderTimeZoneTestHelper.ToUtc(kyiv, 2025, 3, 10, 9, 0, 0), kyivNext); + Assert.Equal(AdvancedReminderTimeZoneTestHelper.ToUtc(newYork, 2025, 3, 10, 9, 0, 0), newYorkNext); + } + + [Fact] + public void Builder_WithParisAndNewYork_WhenEuropeAlreadyStandardButUsStillOnDst_ReturnExpectedUtcOffsets() + { + var paris = AdvancedReminderTimeZoneTestHelper.GetParisTimeZone(); + var newYork = AdvancedReminderTimeZoneTestHelper.GetUsEasternTimeZone(); + var parisBuilder = ReminderCronBuilder.DailyAt(9, 0, paris); + var newYorkBuilder = ReminderCronBuilder.DailyAt(9, 0, newYork); + var fromUtc = new DateTime(2025, 10, 27, 0, 0, 0, DateTimeKind.Utc); + + var parisNext = parisBuilder.GetNextOccurrence(fromUtc); + var newYorkNext = newYorkBuilder.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2025, 10, 27, 8, 0, 0, DateTimeKind.Utc), parisNext); + Assert.Equal(new DateTime(2025, 10, 27, 13, 0, 0, DateTimeKind.Utc), newYorkNext); + Assert.Equal(AdvancedReminderTimeZoneTestHelper.ToUtc(paris, 2025, 10, 27, 9, 0, 0), parisNext); + Assert.Equal(AdvancedReminderTimeZoneTestHelper.ToUtc(newYork, 2025, 10, 27, 9, 0, 0), newYorkNext); + } + + [Fact] + public void Builder_WithDubaiAndNewYork_WhenUsAlreadyOnDst_DubaiRemainsFixed() + { + var dubai = AdvancedReminderTimeZoneTestHelper.GetDubaiTimeZone(); + var newYork = AdvancedReminderTimeZoneTestHelper.GetUsEasternTimeZone(); + var dubaiBuilder = ReminderCronBuilder.DailyAt(9, 0, dubai); + var newYorkBuilder = ReminderCronBuilder.DailyAt(9, 0, newYork); + var fromUtc = new DateTime(2025, 3, 10, 0, 0, 0, DateTimeKind.Utc); + + var dubaiNext = dubaiBuilder.GetNextOccurrence(fromUtc); + var newYorkNext = newYorkBuilder.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2025, 3, 10, 5, 0, 0, DateTimeKind.Utc), dubaiNext); + Assert.Equal(new DateTime(2025, 3, 10, 13, 0, 0, DateTimeKind.Utc), newYorkNext); + Assert.Equal(AdvancedReminderTimeZoneTestHelper.ToUtc(dubai, 2025, 3, 10, 9, 0, 0), dubaiNext); + Assert.Equal(AdvancedReminderTimeZoneTestHelper.ToUtc(newYork, 2025, 3, 10, 9, 0, 0), newYorkNext); + } + + [Fact] + public void Builder_WithIndiaAndParis_WhenEuropeAlreadyStandard_IndiaRemainsFixed() + { + var india = AdvancedReminderTimeZoneTestHelper.GetIndiaTimeZone(); + var paris = AdvancedReminderTimeZoneTestHelper.GetParisTimeZone(); + var indiaBuilder = ReminderCronBuilder.DailyAt(9, 0, india); + var parisBuilder = ReminderCronBuilder.DailyAt(9, 0, paris); + var fromUtc = new DateTime(2025, 10, 27, 0, 0, 0, DateTimeKind.Utc); + + var indiaNext = indiaBuilder.GetNextOccurrence(fromUtc); + var parisNext = parisBuilder.GetNextOccurrence(fromUtc); + + Assert.Equal(new DateTime(2025, 10, 27, 3, 30, 0, DateTimeKind.Utc), indiaNext); + Assert.Equal(new DateTime(2025, 10, 27, 8, 0, 0, DateTimeKind.Utc), parisNext); + Assert.Equal(AdvancedReminderTimeZoneTestHelper.ToUtc(india, 2025, 10, 27, 9, 0, 0), indiaNext); + Assert.Equal(AdvancedReminderTimeZoneTestHelper.ToUtc(paris, 2025, 10, 27, 9, 0, 0), parisNext); + } + + [Fact] + public void Schedule_WithNepalTimeZone_AcrossNewYear_PreservesQuarterHourOffset() + { + var zone = AdvancedReminderTimeZoneTestHelper.GetNepalTimeZone(); + var schedule = ReminderCronSchedule.Parse("0 9 * * *", ReminderCronSchedule.NormalizeTimeZoneIdForStorage(zone)); + var fromUtc = new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc); + var toUtc = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + + var occurrences = schedule.GetOccurrences(fromUtc, toUtc).ToArray(); + + Assert.Equal( + [ + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2025, 12, 31, 9, 0, 0), + AdvancedReminderTimeZoneTestHelper.ToUtc(zone, 2026, 1, 1, 9, 0, 0), + ], + occurrences); + } + + [Fact] + public void TimeZoneHelper_InvalidAndAmbiguousTransitions_ReturnExpectedBoundaries() + { + var zone = AdvancedReminderTimeZoneTestHelper.GetUsEasternTimeZone(); + var invalidLocal = new DateTime(2025, 3, 9, 2, 30, 0, DateTimeKind.Unspecified); + var ambiguousLocal = new DateTime(2025, 11, 2, 1, 30, 0, DateTimeKind.Unspecified); + + var daylightStart = TimeZoneHelper.GetDaylightTimeStart(zone, invalidLocal); + var daylightOffset = TimeZoneHelper.GetDaylightOffset(zone, ambiguousLocal); + var daylightEnd = TimeZoneHelper.GetDaylightTimeEnd(zone, ambiguousLocal, daylightOffset); + var standardStart = TimeZoneHelper.GetStandardTimeStart(zone, ambiguousLocal, daylightOffset); + var intervalEnd = TimeZoneHelper.GetAmbiguousIntervalEnd(zone, ambiguousLocal); + + Assert.True(TimeZoneHelper.IsAmbiguousTime(zone, ambiguousLocal)); + Assert.Equal(zone.GetAmbiguousTimeOffsets(ambiguousLocal).Max(), daylightOffset); + Assert.Equal(new DateTime(2025, 3, 9, 7, 0, 0, DateTimeKind.Utc), daylightStart.UtcDateTime); + Assert.Equal(new DateTime(2025, 11, 2, 5, 59, 59, DateTimeKind.Utc).AddTicks(9999999), daylightEnd.UtcDateTime); + Assert.Equal(new DateTime(2025, 11, 2, 6, 0, 0, DateTimeKind.Utc), standardStart.UtcDateTime); + Assert.Equal(new DateTime(2025, 11, 2, 7, 0, 0, DateTimeKind.Utc), intervalEnd.UtcDateTime); + } +} + +[TestCategory("Reminders")] +public class CalendarHelperTests +{ + [Fact] + public void MoveToNearestWeekDay_HandlesWeekendEdgesAndWeekdays() + { + Assert.Equal(2, CalendarHelper.MoveToNearestWeekDay(2025, 6, 1)); + Assert.Equal(29, CalendarHelper.MoveToNearestWeekDay(2024, 3, 31)); + Assert.Equal(3, CalendarHelper.MoveToNearestWeekDay(2025, 2, 1)); + Assert.Equal(14, CalendarHelper.MoveToNearestWeekDay(2025, 1, 14)); + } + + [Fact] + public void CalendarHelper_FillDateTimeParts_AndWeekdayPredicates_WorkAsExpected() + { + var ticks = new DateTime(2026, 1, 1, 10, 11, 12, DateTimeKind.Utc).AddTicks(42).Ticks; + + CalendarHelper.FillDateTimeParts(ticks, out var second, out var minute, out var hour, out var day, out var month, out var year); + + Assert.Equal(13, second); + Assert.Equal(11, minute); + Assert.Equal(10, hour); + Assert.Equal(1, day); + Assert.Equal(1, month); + Assert.Equal(2026, year); + Assert.True(CalendarHelper.IsNthDayOfWeek(8, 2)); + Assert.False(CalendarHelper.IsNthDayOfWeek(15, 2)); + Assert.True(CalendarHelper.IsLastDayOfWeek(2025, 1, 31)); + Assert.False(CalendarHelper.IsLastDayOfWeek(2025, 1, 24)); + Assert.Equal(DayOfWeek.Thursday, CalendarHelper.GetDayOfWeek(2026, 1, 1)); + Assert.Equal(29, CalendarHelper.GetDaysInMonth(2024, 2)); + Assert.True(CalendarHelper.IsGreaterThan(2026, 1, 2, 2026, 1, 1)); + Assert.False(CalendarHelper.IsGreaterThan(2026, 1, 1, 2026, 1, 2)); + Assert.Equal(new DateTime(2026, 1, 1, 10, 11, 12, DateTimeKind.Utc).Ticks, CalendarHelper.DateTimeToTicks(2026, 1, 1, 10, 11, 12)); + } +} diff --git a/test/Orleans.Core.Tests/AdvancedReminders/AdvancedReminderManagementTests.cs b/test/Orleans.Core.Tests/AdvancedReminders/AdvancedReminderManagementTests.cs new file mode 100644 index 0000000000..f6faaf357d --- /dev/null +++ b/test/Orleans.Core.Tests/AdvancedReminders/AdvancedReminderManagementTests.cs @@ -0,0 +1,997 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Orleans; +using Orleans.AdvancedReminders; +using Orleans.AdvancedReminders.Cron.Internal; +using Orleans.AdvancedReminders.Runtime; +using Orleans.AdvancedReminders.Runtime.ReminderService; +using Orleans.DurableJobs; +using Orleans.Runtime; +using Xunit; +using ReminderEntry = Orleans.AdvancedReminders.ReminderEntry; +using ReminderTableData = Orleans.AdvancedReminders.ReminderTableData; +using AdvancedReminderException = Orleans.AdvancedReminders.Runtime.ReminderException; + +namespace UnitTests.AdvancedReminders; + +[TestCategory("Reminders")] +public class ReminderIteratorTests +{ + [Fact] + public async Task EnumerateAllAsync_ReadsAllPages() + { + var managementGrain = Substitute.For(); + managementGrain.ListAllAsync(2, null).Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r1"), CreateReminder("r2")], + ContinuationToken = "next", + })); + managementGrain.ListAllAsync(2, "next").Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r3")], + ContinuationToken = null, + })); + + var iterator = new ReminderIterator(managementGrain); + var names = new List(); + await foreach (var reminder in iterator.EnumerateAllAsync(pageSize: 2)) + { + names.Add(reminder.ReminderName); + } + + Assert.Equal(["r1", "r2", "r3"], names); + } + + [Fact] + public async Task EnumerateFilteredAsync_ReadsAllPages() + { + var filter = new ReminderQueryFilter + { + Status = ReminderQueryStatus.Overdue | ReminderQueryStatus.Missed, + OverdueBy = TimeSpan.FromMinutes(2), + MissedBy = TimeSpan.FromMinutes(1), + Priority = ReminderPriority.High, + }; + + var managementGrain = Substitute.For(); + managementGrain.ListFilteredAsync(filter, 2, null).Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r1")], + ContinuationToken = "next", + })); + managementGrain.ListFilteredAsync(filter, 2, "next").Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r2")], + ContinuationToken = null, + })); + + var iterator = new ReminderIterator(managementGrain); + var names = new List(); + await foreach (var reminder in iterator.EnumerateFilteredAsync(filter, pageSize: 2)) + { + names.Add(reminder.ReminderName); + } + + Assert.Equal(["r1", "r2"], names); + } + + [Fact] + public async Task EnumerateOverdueAsync_ReadsAllPages() + { + var managementGrain = Substitute.For(); + managementGrain.ListOverdueAsync(TimeSpan.FromMinutes(3), 2, null).Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r1")], + ContinuationToken = "next", + })); + managementGrain.ListOverdueAsync(TimeSpan.FromMinutes(3), 2, "next").Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r2")], + ContinuationToken = null, + })); + + var iterator = new ReminderIterator(managementGrain); + var names = new List(); + await foreach (var reminder in iterator.EnumerateOverdueAsync(TimeSpan.FromMinutes(3), pageSize: 2)) + { + names.Add(reminder.ReminderName); + } + + Assert.Equal(["r1", "r2"], names); + } + + [Fact] + public async Task EnumerateDueInRangeAsync_ReadsAllPages() + { + var from = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var to = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + var managementGrain = Substitute.For(); + managementGrain.ListDueInRangeAsync(from, to, 2, null).Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r1")], + ContinuationToken = "next", + })); + managementGrain.ListDueInRangeAsync(from, to, 2, "next").Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r2")], + ContinuationToken = null, + })); + + var iterator = new ReminderIterator(managementGrain); + var names = new List(); + await foreach (var reminder in iterator.EnumerateDueInRangeAsync(from, to, pageSize: 2)) + { + names.Add(reminder.ReminderName); + } + + Assert.Equal(["r1", "r2"], names); + } + + [Fact] + public void Ctor_NullManagementGrain_Throws() + { + Assert.Throws(() => _ = new ReminderIterator(null!)); + } + + private static ReminderEntry CreateReminder(string reminderName) + => new() + { + GrainId = GrainId.Create("test", reminderName), + ReminderName = reminderName, + StartAt = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc), + Period = TimeSpan.FromMinutes(1), + }; +} + +[TestCategory("Reminders")] +public class ReminderManagementGrainExtensionsTests +{ + [Fact] + public async Task EnumerateAllAsync_ReadsAllPages() + { + var managementGrain = Substitute.For(); + managementGrain.ListAllAsync(2, null).Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r1"), CreateReminder("r2")], + ContinuationToken = "next", + })); + managementGrain.ListAllAsync(2, "next").Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r3")], + ContinuationToken = null, + })); + + var names = new List(); + await foreach (var reminder in managementGrain.EnumerateAllAsync(pageSize: 2)) + { + names.Add(reminder.ReminderName); + } + + Assert.Equal(["r1", "r2", "r3"], names); + await managementGrain.Received(1).ListAllAsync(2, null); + await managementGrain.Received(1).ListAllAsync(2, "next"); + } + + [Fact] + public void CreateIterator_ReturnsIteratorFacade() + { + var managementGrain = Substitute.For(); + + var iterator = managementGrain.CreateIterator(); + + Assert.NotNull(iterator); + Assert.IsType(iterator); + } + + [Fact] + public async Task EnumerateOverdueAsync_Extension_ReadsAllPages() + { + var managementGrain = Substitute.For(); + managementGrain.ListOverdueAsync(TimeSpan.FromMinutes(2), 2, null).Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r1")], + ContinuationToken = "next", + })); + managementGrain.ListOverdueAsync(TimeSpan.FromMinutes(2), 2, "next").Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r2")], + ContinuationToken = null, + })); + + var names = new List(); + await foreach (var reminder in managementGrain.EnumerateOverdueAsync(TimeSpan.FromMinutes(2), pageSize: 2)) + { + names.Add(reminder.ReminderName); + } + + Assert.Equal(["r1", "r2"], names); + } + + [Fact] + public async Task EnumerateDueInRangeAsync_Extension_ReadsAllPages() + { + var from = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var to = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + var managementGrain = Substitute.For(); + managementGrain.ListDueInRangeAsync(from, to, 2, null).Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r1")], + ContinuationToken = "next", + })); + managementGrain.ListDueInRangeAsync(from, to, 2, "next").Returns(Task.FromResult(new ReminderManagementPage + { + Reminders = [CreateReminder("r2")], + ContinuationToken = null, + })); + + var names = new List(); + await foreach (var reminder in managementGrain.EnumerateDueInRangeAsync(from, to, pageSize: 2)) + { + names.Add(reminder.ReminderName); + } + + Assert.Equal(["r1", "r2"], names); + } + + private static ReminderEntry CreateReminder(string reminderName) + => new() + { + GrainId = GrainId.Create("test", reminderName), + ReminderName = reminderName, + StartAt = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc), + Period = TimeSpan.FromMinutes(1), + }; +} + +[TestCategory("Reminders")] +public class ReminderManagementGrainTests +{ + [Fact] + public async Task ListAllAsync_ReturnsSortedPagesWithContinuationToken() + { + var due = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc); + var table = new InMemoryManagementReminderTable( + new ReminderEntry + { + GrainId = GrainId.Create("test", "g2"), + ReminderName = "r2", + StartAt = due, + NextDueUtc = due, + Period = TimeSpan.FromMinutes(1), + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "g1"), + ReminderName = "rB", + StartAt = due, + NextDueUtc = due, + Period = TimeSpan.FromMinutes(1), + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "g1"), + ReminderName = "rA", + StartAt = due, + NextDueUtc = due, + Period = TimeSpan.FromMinutes(1), + }); + + var grain = new ReminderManagementGrain(table); + + var first = await grain.ListAllAsync(pageSize: 2); + Assert.Equal(["rA", "rB"], first.Reminders.Select(reminder => reminder.ReminderName).ToArray()); + Assert.False(string.IsNullOrWhiteSpace(first.ContinuationToken)); + + var second = await grain.ListAllAsync(pageSize: 2, continuationToken: first.ContinuationToken); + Assert.Single(second.Reminders); + Assert.Equal("r2", second.Reminders[0].ReminderName); + Assert.Null(second.ContinuationToken); + } + + [Fact] + public async Task ListFilteredAsync_AppliesPriorityScheduleAndStatus() + { + var now = DateTime.UtcNow; + var table = new InMemoryManagementReminderTable( + new ReminderEntry + { + GrainId = GrainId.Create("test", "match"), + ReminderName = "match", + StartAt = now.AddMinutes(-20), + Period = TimeSpan.Zero, + CronExpression = "*/5 * * * * *", + NextDueUtc = now.AddMinutes(-10), + LastFireUtc = now.AddMinutes(-20), + Priority = ReminderPriority.High, + Action = MissedReminderAction.FireImmediately, + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "wrong-priority"), + ReminderName = "wrong-priority", + StartAt = now.AddMinutes(-20), + Period = TimeSpan.Zero, + CronExpression = "*/5 * * * * *", + NextDueUtc = now.AddMinutes(-10), + LastFireUtc = null, + Priority = ReminderPriority.Normal, + Action = MissedReminderAction.FireImmediately, + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "upcoming"), + ReminderName = "upcoming", + StartAt = now.AddMinutes(2), + Period = TimeSpan.FromMinutes(1), + NextDueUtc = now.AddMinutes(2), + Priority = ReminderPriority.High, + Action = MissedReminderAction.FireImmediately, + }); + + var grain = new ReminderManagementGrain(table); + var filter = new ReminderQueryFilter + { + Status = ReminderQueryStatus.Overdue | ReminderQueryStatus.Missed, + OverdueBy = TimeSpan.FromMinutes(2), + MissedBy = TimeSpan.FromMinutes(1), + Priority = ReminderPriority.High, + Action = MissedReminderAction.FireImmediately, + ScheduleKind = ReminderScheduleKind.Cron, + }; + + var page = await grain.ListFilteredAsync(filter, pageSize: 10); + + Assert.Single(page.Reminders); + Assert.Equal("match", page.Reminders[0].ReminderName); + } + + [Fact] + public async Task ListFilteredAsync_StatusFilters_DistinguishDueUpcomingOverdueAndMissed() + { + var now = DateTime.UtcNow; + var table = new InMemoryManagementReminderTable( + new ReminderEntry + { + GrainId = GrainId.Create("test", "due"), + ReminderName = "due", + StartAt = now.AddMinutes(-1), + NextDueUtc = now.AddSeconds(-10), + Period = TimeSpan.FromMinutes(1), + LastFireUtc = now, + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "upcoming"), + ReminderName = "upcoming", + StartAt = now.AddMinutes(10), + NextDueUtc = now.AddMinutes(10), + Period = TimeSpan.FromMinutes(1), + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "overdue"), + ReminderName = "overdue", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-6), + Period = TimeSpan.FromMinutes(1), + LastFireUtc = now.AddMinutes(-1), + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "missed"), + ReminderName = "missed", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-6), + Period = TimeSpan.FromMinutes(1), + LastFireUtc = null, + }); + var grain = new ReminderManagementGrain(table); + + var due = await grain.ListFilteredAsync(new ReminderQueryFilter { Status = ReminderQueryStatus.Due }, pageSize: 10); + var upcoming = await grain.ListFilteredAsync(new ReminderQueryFilter { Status = ReminderQueryStatus.Upcoming }, pageSize: 10); + var overdue = await grain.ListFilteredAsync(new ReminderQueryFilter { Status = ReminderQueryStatus.Overdue, OverdueBy = TimeSpan.FromMinutes(5) }, pageSize: 10); + var missed = await grain.ListFilteredAsync(new ReminderQueryFilter { Status = ReminderQueryStatus.Missed, MissedBy = TimeSpan.FromMinutes(5) }, pageSize: 10); + + Assert.Equal(["missed", "overdue", "due"], due.Reminders.Select(x => x.ReminderName).ToArray()); + Assert.Equal(["upcoming"], upcoming.Reminders.Select(x => x.ReminderName).ToArray()); + Assert.Equal(["missed", "overdue"], overdue.Reminders.Select(x => x.ReminderName).ToArray()); + Assert.Equal(["missed"], missed.Reminders.Select(x => x.ReminderName).ToArray()); + } + + [Fact] + public async Task ListFilteredAsync_MissedStatus_ExcludesReminderAlreadyFiredAfterDueTime() + { + var now = DateTime.UtcNow; + var table = new InMemoryManagementReminderTable( + new ReminderEntry + { + GrainId = GrainId.Create("test", "missed"), + ReminderName = "missed", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-6), + Period = TimeSpan.FromMinutes(1), + LastFireUtc = null, + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "already-fired"), + ReminderName = "already-fired", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-6), + Period = TimeSpan.FromMinutes(1), + LastFireUtc = now.AddMinutes(-5), + }); + var grain = new ReminderManagementGrain(table); + + var missed = await grain.ListFilteredAsync( + new ReminderQueryFilter + { + Status = ReminderQueryStatus.Missed, + MissedBy = TimeSpan.FromMinutes(5), + }, + pageSize: 10); + + Assert.Equal(["missed"], missed.Reminders.Select(x => x.ReminderName).ToArray()); + } + + [Fact] + public async Task ListOverdueAsync_UsesThreshold() + { + var now = DateTime.UtcNow; + var table = new InMemoryManagementReminderTable( + new ReminderEntry + { + GrainId = GrainId.Create("test", "old"), + ReminderName = "old", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-8), + Period = TimeSpan.FromMinutes(1), + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "recent"), + ReminderName = "recent", + StartAt = now.AddMinutes(-1), + NextDueUtc = now.AddMinutes(-1), + Period = TimeSpan.FromMinutes(1), + }); + var grain = new ReminderManagementGrain(table); + + var page = await grain.ListOverdueAsync(TimeSpan.FromMinutes(5), pageSize: 10); + + Assert.Equal(["old"], page.Reminders.Select(x => x.ReminderName).ToArray()); + } + + [Fact] + public async Task ListDueInRangeAsync_UsesInclusiveBounds() + { + var lower = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc); + var upper = new DateTime(2026, 1, 1, 11, 0, 0, DateTimeKind.Utc); + var table = new InMemoryManagementReminderTable( + new ReminderEntry + { + GrainId = GrainId.Create("test", "lower"), + ReminderName = "lower", + StartAt = lower, + NextDueUtc = lower, + Period = TimeSpan.FromMinutes(1), + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "middle"), + ReminderName = "middle", + StartAt = lower.AddMinutes(30), + NextDueUtc = lower.AddMinutes(30), + Period = TimeSpan.FromMinutes(1), + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "upper"), + ReminderName = "upper", + StartAt = upper, + NextDueUtc = upper, + Period = TimeSpan.FromMinutes(1), + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "outside"), + ReminderName = "outside", + StartAt = upper.AddSeconds(1), + NextDueUtc = upper.AddSeconds(1), + Period = TimeSpan.FromMinutes(1), + }); + var grain = new ReminderManagementGrain(table); + + var page = await grain.ListDueInRangeAsync(lower, upper, pageSize: 10); + + Assert.Equal(["lower", "middle", "upper"], page.Reminders.Select(x => x.ReminderName).ToArray()); + } + + [Fact] + public async Task MutationApis_UpdateBackingStore() + { + var originalDue = DateTime.UtcNow.AddMinutes(-15); + var grainId = GrainId.Create("test", "mutations"); + var entry = new ReminderEntry + { + GrainId = grainId, + ReminderName = "r", + StartAt = originalDue, + NextDueUtc = originalDue, + Period = TimeSpan.FromMinutes(1), + Priority = ReminderPriority.Normal, + Action = MissedReminderAction.Skip, + ETag = "etag-1", + }; + var table = new InMemoryManagementReminderTable(entry); + var grain = new ReminderManagementGrain(table); + + await grain.SetPriorityAsync(grainId, "r", ReminderPriority.High); + await grain.SetActionAsync(grainId, "r", MissedReminderAction.Notify); + await grain.RepairAsync(grainId, "r"); + + var repaired = await table.ReadRow(grainId, "r"); + Assert.Equal(ReminderPriority.High, repaired.Priority); + Assert.Equal(MissedReminderAction.Notify, repaired.Action); + Assert.True(repaired.NextDueUtc > DateTime.UtcNow); + + await grain.DeleteAsync(grainId, "r"); + Assert.Null(await table.ReadRow(grainId, "r")); + } + + [Fact] + public async Task MutationApis_WithReminderService_RescheduleReminderChain() + { + var due = DateTime.UtcNow.AddMinutes(2); + var grainId = GrainId.Create("test", "mutation-reschedule"); + var entry = new ReminderEntry + { + GrainId = grainId, + ReminderName = "r", + StartAt = due, + NextDueUtc = due, + Period = TimeSpan.FromMinutes(5), + Priority = ReminderPriority.Normal, + Action = MissedReminderAction.Skip, + ETag = "etag-1", + }; + + var reminderTable = Substitute.For(); + reminderTable.ReadRow(grainId, "r").Returns( + Task.FromResult(CloneEntry(entry)), + Task.FromResult(CloneEntry(entry)), + Task.FromResult(CloneEntry(entry))); + reminderTable.UpsertRow(Arg.Any()).Returns("etag-2", "etag-3", "etag-4"); + + var grainFactory = Substitute.For(); + var dispatcher = new TestManagementDispatcherGrain(GrainId.Create("sys", "dispatcher")); + grainFactory.GetGrain(grainId.ToString(), null).Returns(dispatcher); + + var jobManager = Substitute.For(); + var service = new AdvancedReminderService( + reminderTable, + jobManager, + new TestJobShardManager(), + grainFactory, + Options.Create(new Orleans.AdvancedReminders.ReminderOptions()), + NullLogger.Instance, + TimeProvider.System); + var grain = new ReminderManagementGrain( + reminderTable, + new ServiceCollection() + .AddSingleton(service) + .BuildServiceProvider()); + + await grain.SetPriorityAsync(grainId, "r", ReminderPriority.High); + await grain.SetActionAsync(grainId, "r", MissedReminderAction.Notify); + await grain.RepairAsync(grainId, "r"); + + await jobManager.Received(3).ScheduleJobAsync( + Arg.Is(request => + request.JobName == "advanced-reminder:r" + && request.Target == dispatcher.GetGrainId() + && request.Metadata != null + && request.Metadata["grain-id"] == grainId.ToString() + && request.Metadata["reminder-name"] == "r"), + Arg.Any()); + } + + [Fact] + public async Task UpcomingAsync_UsesHorizon() + { + var now = DateTime.UtcNow; + var table = new InMemoryManagementReminderTable( + new ReminderEntry + { + GrainId = GrainId.Create("test", "in"), + ReminderName = "in", + StartAt = now.AddMinutes(2), + NextDueUtc = now.AddMinutes(2), + Period = TimeSpan.FromMinutes(1), + }, + new ReminderEntry + { + GrainId = GrainId.Create("test", "out"), + ReminderName = "out", + StartAt = now.AddMinutes(20), + NextDueUtc = now.AddMinutes(20), + Period = TimeSpan.FromMinutes(1), + }); + + var grain = new ReminderManagementGrain(table); + var reminders = (await grain.UpcomingAsync(TimeSpan.FromMinutes(5))).ToArray(); + + Assert.Single(reminders); + Assert.Equal("in", reminders[0].ReminderName); + } + + [Fact] + public async Task UpcomingAsync_NegativeHorizon_Throws() + { + var grain = new ReminderManagementGrain(new InMemoryManagementReminderTable()); + + var exception = await Assert.ThrowsAsync(() => grain.UpcomingAsync(TimeSpan.FromMinutes(-1))); + + Assert.Equal("horizon", exception.ParamName); + } + + [Fact] + public async Task ListFilteredAsync_WithNonPositivePageSize_Throws() + { + var grain = new ReminderManagementGrain(new InMemoryManagementReminderTable()); + + var exception = await Assert.ThrowsAsync( + () => grain.ListFilteredAsync(new ReminderQueryFilter(), pageSize: 0)); + + Assert.Equal("pageSize", exception.ParamName); + } + + [Fact] + public async Task ListFilteredAsync_WithMalformedContinuationToken_Throws() + { + var grain = new ReminderManagementGrain( + new InMemoryManagementReminderTable( + new ReminderEntry + { + GrainId = GrainId.Create("test", "invalid-token"), + ReminderName = "r1", + StartAt = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc), + NextDueUtc = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc), + Period = TimeSpan.FromMinutes(1), + })); + + var exception = await Assert.ThrowsAsync( + () => grain.ListFilteredAsync(new ReminderQueryFilter(), pageSize: 1, continuationToken: "definitely-not-base64")); + + Assert.Equal("continuationToken", exception.ParamName); + } + + [Fact] + public async Task MutationApis_WhenReminderIsMissing_ThrowReminderException() + { + var grainId = GrainId.Create("test", "missing-mutation"); + var grain = new ReminderManagementGrain(new InMemoryManagementReminderTable()); + + await Assert.ThrowsAsync(() => grain.SetPriorityAsync(grainId, "missing", ReminderPriority.High)); + await Assert.ThrowsAsync(() => grain.SetActionAsync(grainId, "missing", MissedReminderAction.Notify)); + await Assert.ThrowsAsync(() => grain.RepairAsync(grainId, "missing")); + await Assert.ThrowsAsync(() => grain.DeleteAsync(grainId, "missing")); + } + + [Fact] + public async Task RepairAsync_ForCronReminder_RecomputesNextOccurrenceUsingStoredTimeZone() + { + var grainId = GrainId.Create("test", "cron-repair"); + var timeZone = AdvancedReminderTimeZoneTestHelper.GetCentralEuropeanTimeZone(); + var normalizedTimeZoneId = ReminderCronSchedule.NormalizeTimeZoneIdForStorage(timeZone) ?? timeZone.Id; + var builder = ReminderCronBuilder.DailyAt(9, 0).InTimeZone(timeZone); + var beforeRepair = DateTime.UtcNow; + var entry = new ReminderEntry + { + GrainId = grainId, + ReminderName = "cron", + StartAt = beforeRepair.AddDays(-2), + NextDueUtc = beforeRepair.AddDays(-1), + Period = TimeSpan.Zero, + CronExpression = builder.ToExpressionString(), + CronTimeZoneId = normalizedTimeZoneId, + LastFireUtc = beforeRepair.AddDays(-2), + ETag = "etag-1", + }; + var table = new InMemoryManagementReminderTable(entry); + var grain = new ReminderManagementGrain(table); + + await grain.RepairAsync(grainId, "cron"); + + var afterRepair = DateTime.UtcNow; + var repaired = await table.ReadRow(grainId, "cron"); + var expectedLowerBound = builder.GetNextOccurrence(beforeRepair); + var expectedUpperBound = builder.GetNextOccurrence(afterRepair); + + Assert.NotNull(repaired.NextDueUtc); + Assert.NotNull(expectedLowerBound); + Assert.NotNull(expectedUpperBound); + Assert.InRange(repaired.NextDueUtc!.Value, expectedLowerBound!.Value, expectedUpperBound!.Value); + Assert.Equal(builder.ToExpressionString(), repaired.CronExpression); + Assert.Equal(normalizedTimeZoneId, repaired.CronTimeZoneId); + } + + private static ReminderEntry CloneEntry(ReminderEntry entry) + => new() + { + GrainId = entry.GrainId, + ReminderName = entry.ReminderName, + StartAt = entry.StartAt, + Period = entry.Period, + ETag = entry.ETag, + CronExpression = entry.CronExpression, + CronTimeZoneId = entry.CronTimeZoneId, + NextDueUtc = entry.NextDueUtc, + LastFireUtc = entry.LastFireUtc, + Priority = entry.Priority, + Action = entry.Action, + }; + + private sealed class InMemoryManagementReminderTable(params ReminderEntry[] reminders) : Orleans.AdvancedReminders.IReminderTable + { + private readonly Dictionary<(GrainId GrainId, string ReminderName), ReminderEntry> _entries = + reminders.ToDictionary( + reminder => (reminder.GrainId, reminder.ReminderName), + reminder => CloneEntry(reminder)); + + public Task ReadRows(GrainId grainId) + => Task.FromResult(new ReminderTableData(_entries.Values.Where(entry => entry.GrainId.Equals(grainId)).Select(CloneEntry).ToList())); + + public Task ReadRows(uint begin, uint end) + => Task.FromResult(new ReminderTableData(_entries.Values.Select(CloneEntry).ToList())); + + public Task ReadRow(GrainId grainId, string reminderName) + { + _entries.TryGetValue((grainId, reminderName), out var entry); + return Task.FromResult(entry is null ? null! : CloneEntry(entry)); + } + + public Task UpsertRow(ReminderEntry entry) + { + var copy = CloneEntry(entry); + copy.ETag = string.IsNullOrWhiteSpace(copy.ETag) + ? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture) + : copy.ETag + "-next"; + _entries[(copy.GrainId, copy.ReminderName)] = copy; + return Task.FromResult(copy.ETag); + } + + public Task RemoveRow(GrainId grainId, string reminderName, string eTag) + => Task.FromResult(_entries.Remove((grainId, reminderName))); + + public Task TestOnlyClearTable() + { + _entries.Clear(); + return Task.CompletedTask; + } + } + + private sealed class TestManagementDispatcherGrain : IAdvancedReminderDispatcherGrain, IGrainBase + { + public TestManagementDispatcherGrain(GrainId grainId) + { + var context = Substitute.For(); + context.GrainId.Returns(grainId); + GrainContext = context; + } + + public IGrainContext GrainContext { get; } + + public Task ExecuteJobAsync(IJobRunContext context, CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class TestJobShardManager() : JobShardManager(SiloAddress.Zero) + { + public override Task> AssignJobShardsAsync(DateTimeOffset maxDueTime, CancellationToken cancellationToken) + => Task.FromResult(new List()); + + public override Task CreateShardAsync(DateTimeOffset minDueTime, DateTimeOffset maxDueTime, IDictionary metadata, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public override Task UnregisterShardAsync(IJobShard shard, CancellationToken cancellationToken) + => Task.CompletedTask; + } +} + +[TestCategory("Reminders")] +[TestCategory("Stress")] +public class ReminderStressTests +{ + [Fact] + public async Task ListFilteredAsync_HighLoad_200K_Reminders_PagesCorrectly() + { + const int totalReminders = 200_000; + const int pageSize = 2_048; + + var now = DateTime.UtcNow; + var reminders = CreateSyntheticReminders(totalReminders, now); + var table = new SingleSegmentReminderTable(reminders); + var grain = new ReminderManagementGrain(table); + + var filter = new ReminderQueryFilter + { + Priority = ReminderPriority.High, + ScheduleKind = ReminderScheduleKind.Cron, + Status = ReminderQueryStatus.Upcoming, + }; + + var expectedCount = reminders.Count(reminder => + reminder.Priority == ReminderPriority.High + && !string.IsNullOrWhiteSpace(reminder.CronExpression) + && (reminder.NextDueUtc ?? reminder.StartAt) > now); + + var observedCount = 0; + var pageRequests = 0; + string? continuationToken = null; + + do + { + pageRequests++; + var page = await grain.ListFilteredAsync(filter, pageSize, continuationToken); + Assert.InRange(page.Reminders.Count, 0, pageSize); + observedCount += page.Reminders.Count; + continuationToken = page.ContinuationToken; + } + while (!string.IsNullOrEmpty(continuationToken)); + + Assert.Equal(expectedCount, observedCount); + Assert.InRange(table.RangeReadCallCount, 1, pageRequests); + } + + [Fact] + public async Task Iterator_HighLoad_StreamsOneMillionReminders() + { + const int totalReminders = 1_000_000; + const int pageSize = 4_096; + + var management = new SyntheticPagedReminderManagementGrain(totalReminders); + var iterator = new ReminderIterator(management); + + long observed = 0; + await foreach (var _ in iterator.EnumerateAllAsync(pageSize)) + { + observed++; + } + + Assert.Equal(totalReminders, observed); + Assert.True(management.ListAllCallCount > 1); + } + + private static List CreateSyntheticReminders(int count, DateTime nowUtc) + { + var result = new List(count); + var sharedGrainId = GrainId.Create("stress", "scan"); + + for (var i = 0; i < count; i++) + { + var due = nowUtc.AddSeconds((i % 120) - 60); + result.Add(new ReminderEntry + { + GrainId = sharedGrainId, + ReminderName = $"r-{i.ToString(CultureInfo.InvariantCulture)}", + StartAt = due, + NextDueUtc = due, + LastFireUtc = due.AddSeconds(-1), + Period = TimeSpan.FromMinutes(1), + CronExpression = i % 2 == 0 ? "*/5 * * * * *" : null!, + Priority = i % 3 == 0 ? ReminderPriority.High : ReminderPriority.Normal, + Action = (i % 3) switch + { + 0 => MissedReminderAction.FireImmediately, + 1 => MissedReminderAction.Skip, + _ => MissedReminderAction.Notify, + }, + }); + } + + return result; + } + + private sealed class SingleSegmentReminderTable(List reminders) : Orleans.AdvancedReminders.IReminderTable + { + public int RangeReadCallCount { get; private set; } + + public Task ReadRows(GrainId grainId) => throw new NotSupportedException(); + + public Task ReadRows(uint begin, uint end) + { + RangeReadCallCount++; + return Task.FromResult(new ReminderTableData(reminders)); + } + + public Task ReadRow(GrainId grainId, string reminderName) => throw new NotSupportedException(); + + public Task UpsertRow(ReminderEntry entry) => throw new NotSupportedException(); + + public Task RemoveRow(GrainId grainId, string reminderName, string eTag) => throw new NotSupportedException(); + + public Task TestOnlyClearTable() => throw new NotSupportedException(); + } + + private sealed class SyntheticPagedReminderManagementGrain(int totalReminders) : IReminderManagementGrain + { + private const string TokenPrefix = "offset:"; + private readonly GrainId _sharedGrainId = GrainId.Create("stress", "iterator"); + + public int ListAllCallCount { get; private set; } + + public Task ListAllAsync(int pageSize = 256, string? continuationToken = null) + { + ListAllCallCount++; + var offset = ParseOffset(continuationToken); + if (offset >= totalReminders) + { + return Task.FromResult(new ReminderManagementPage { Reminders = [], ContinuationToken = null }); + } + + var take = Math.Min(pageSize, totalReminders - offset); + var reminders = new List(take); + for (var i = 0; i < take; i++) + { + reminders.Add(new ReminderEntry + { + GrainId = _sharedGrainId, + ReminderName = $"bulk-{offset + i:0000000}", + StartAt = DateTime.UnixEpoch, + NextDueUtc = DateTime.UnixEpoch, + Period = TimeSpan.FromMinutes(1), + Priority = ReminderPriority.Normal, + Action = MissedReminderAction.Skip, + }); + } + + var nextOffset = offset + take; + return Task.FromResult(new ReminderManagementPage + { + Reminders = reminders, + ContinuationToken = nextOffset < totalReminders ? TokenPrefix + nextOffset.ToString(CultureInfo.InvariantCulture) : null, + }); + } + + public Task ListOverdueAsync(TimeSpan overdueBy, int pageSize = 256, string? continuationToken = null) => throw new NotSupportedException(); + public Task ListDueInRangeAsync(DateTime fromUtcInclusive, DateTime toUtcInclusive, int pageSize = 256, string? continuationToken = null) => throw new NotSupportedException(); + public Task ListFilteredAsync(ReminderQueryFilter filter, int pageSize = 256, string? continuationToken = null) => throw new NotSupportedException(); + public Task> UpcomingAsync(TimeSpan horizon) => throw new NotSupportedException(); + public Task> ListForGrainAsync(GrainId grainId) => throw new NotSupportedException(); + public Task CountAllAsync() => throw new NotSupportedException(); + public Task SetPriorityAsync(GrainId grainId, string name, ReminderPriority priority) => throw new NotSupportedException(); + public Task SetActionAsync(GrainId grainId, string name, MissedReminderAction action) => throw new NotSupportedException(); + public Task RepairAsync(GrainId grainId, string name) => throw new NotSupportedException(); + public Task DeleteAsync(GrainId grainId, string name) => throw new NotSupportedException(); + + private static int ParseOffset(string? continuationToken) + { + if (string.IsNullOrWhiteSpace(continuationToken)) + { + return 0; + } + + if (!continuationToken.StartsWith(TokenPrefix, StringComparison.Ordinal)) + { + throw new ArgumentException("Invalid continuation token format.", nameof(continuationToken)); + } + + var payload = continuationToken.AsSpan(TokenPrefix.Length); + if (!int.TryParse(payload, NumberStyles.None, CultureInfo.InvariantCulture, out var offset) || offset < 0) + { + throw new ArgumentException("Invalid continuation token payload.", nameof(continuationToken)); + } + + return offset; + } + } +} diff --git a/test/Orleans.Core.Tests/AdvancedReminders/AdvancedReminderRegistrationTests.cs b/test/Orleans.Core.Tests/AdvancedReminders/AdvancedReminderRegistrationTests.cs new file mode 100644 index 0000000000..dd95a4bba5 --- /dev/null +++ b/test/Orleans.Core.Tests/AdvancedReminders/AdvancedReminderRegistrationTests.cs @@ -0,0 +1,1935 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Orleans; +using Orleans.Configuration.Internal; +using Orleans.DurableJobs; +using Orleans.AdvancedReminders; +using Orleans.AdvancedReminders.Cron.Internal; +using Orleans.AdvancedReminders.Runtime; +using Orleans.AdvancedReminders.Runtime.ReminderService; +using Orleans.AdvancedReminders.Timers; +using Orleans.Hosting; +using Orleans.Metadata; +using Orleans.Runtime; +using Xunit; +using AdvancedReminderOptions = Orleans.AdvancedReminders.ReminderOptions; +using AdvancedReminderOptionsValidator = Orleans.AdvancedReminders.ReminderOptionsValidator; +using AdvancedReminderServiceInterface = Orleans.AdvancedReminders.IReminderService; +using AdvancedRemindable = Orleans.AdvancedReminders.IRemindable; +using AdvancedTickStatus = Orleans.AdvancedReminders.Runtime.TickStatus; +using IGrainReminder = Orleans.AdvancedReminders.IGrainReminder; +using ReminderEntry = Orleans.AdvancedReminders.ReminderEntry; +using ReminderTableData = Orleans.AdvancedReminders.ReminderTableData; + +namespace UnitTests.AdvancedReminders; + +internal interface IActivationIntervalRegistrationTestGrain : IGrainWithGuidKey; + +[RegisterReminder("interval-activation-registration", dueSeconds: 5, periodSeconds: 30)] +internal sealed class ActivationIntervalRegistrationTestGrain : Grain, IActivationIntervalRegistrationTestGrain, AdvancedRemindable +{ + public Task ReceiveReminder(string reminderName, AdvancedTickStatus status) => Task.CompletedTask; +} + +internal interface IActivationCronRegistrationTestGrain : IGrainWithGuidKey; + +[RegisterReminder( + "cron-activation-registration", + "0 9 * * MON-FRI", + priority: ReminderPriority.High, + action: MissedReminderAction.FireImmediately)] +internal sealed class ActivationCronRegistrationTestGrain : Grain, IActivationCronRegistrationTestGrain, AdvancedRemindable +{ + public Task ReceiveReminder(string reminderName, AdvancedTickStatus status) => Task.CompletedTask; +} + +internal interface IActivationNoAttributeTestGrain : IGrainWithGuidKey; + +internal sealed class ActivationNoAttributeTestGrain : Grain, IActivationNoAttributeTestGrain, AdvancedRemindable +{ + public Task ReceiveReminder(string reminderName, AdvancedTickStatus status) => Task.CompletedTask; +} + +internal interface IActivationNonRemindableTestGrain : IGrainWithGuidKey; + +[RegisterReminder("non-remindable", dueSeconds: 1, periodSeconds: 5)] +internal sealed class ActivationNonRemindableTestGrain : Grain, IActivationNonRemindableTestGrain; + +[TestCategory("Reminders")] +public class RegisterReminderAttributeTests +{ + [Fact] + public void IntervalCtor_SetsExpectedValues() + { + var attribute = new RegisterReminderAttribute( + "interval-reminder", + dueSeconds: 15, + periodSeconds: 60, + priority: ReminderPriority.High, + action: MissedReminderAction.FireImmediately); + + Assert.Equal("interval-reminder", attribute.Name); + Assert.Equal(TimeSpan.FromSeconds(15), attribute.Due); + Assert.Equal(TimeSpan.FromSeconds(60), attribute.Period); + Assert.Null(attribute.Cron); + Assert.Equal(ReminderPriority.High, attribute.Priority); + Assert.Equal(MissedReminderAction.FireImmediately, attribute.Action); + } + + [Fact] + public void IntervalCtor_RejectsInvalidInputs() + { + Assert.Throws(() => new RegisterReminderAttribute("", 1, 1)); + Assert.Throws(() => new RegisterReminderAttribute("r", -1, 1)); + Assert.Throws(() => new RegisterReminderAttribute("r", 1, 0)); + Assert.Throws(() => new RegisterReminderAttribute("r", 1, 1, (ReminderPriority)255, MissedReminderAction.Skip)); + Assert.Throws(() => new RegisterReminderAttribute("r", 1, 1, ReminderPriority.Normal, (MissedReminderAction)255)); + } + + [Fact] + public void CronCtor_SetsExpectedValues() + { + var attribute = new RegisterReminderAttribute( + "cron-reminder", + "0 9 * * MON-FRI", + priority: ReminderPriority.Normal, + action: MissedReminderAction.Notify); + + Assert.Equal("cron-reminder", attribute.Name); + Assert.Equal("0 9 * * MON-FRI", attribute.Cron); + Assert.Null(attribute.Due); + Assert.Null(attribute.Period); + Assert.Equal(ReminderPriority.Normal, attribute.Priority); + Assert.Equal(MissedReminderAction.Notify, attribute.Action); + } + + [Fact] + public void CronCtor_RejectsInvalidInputs() + { + Assert.Throws(() => new RegisterReminderAttribute("", "* * * * *")); + Assert.Throws(() => new RegisterReminderAttribute("r", " ")); + Assert.Throws(() => new RegisterReminderAttribute("r", "* * * * *", (ReminderPriority)255, MissedReminderAction.Skip)); + Assert.Throws(() => new RegisterReminderAttribute("r", "* * * * *", ReminderPriority.Normal, (MissedReminderAction)255)); + } +} + +[TestCategory("Reminders")] +public class RegisterReminderActivationConfiguratorProviderTests +{ + private static readonly GrainProperties EmptyGrainProperties = new(ImmutableDictionary.Empty); + + [Fact] + public void TryGetConfigurator_ReturnsFalse_WhenNoRegisterReminderAttribute() + { + var provider = CreateProvider(typeof(ActivationNoAttributeTestGrain)); + + var found = provider.TryGetConfigurator(GrainType.Create("test"), EmptyGrainProperties, out _); + + Assert.False(found); + } + + [Fact] + public void TryGetConfigurator_ReturnsFalse_WhenGrainIsNotRemindable() + { + var provider = CreateProvider(typeof(ActivationNonRemindableTestGrain)); + + var found = provider.TryGetConfigurator(GrainType.Create("test"), EmptyGrainProperties, out _); + + Assert.False(found); + } + + [Fact] + public async Task OnStart_DoesNotOverwriteExistingReminder() + { + var provider = CreateProvider(typeof(ActivationIntervalRegistrationTestGrain)); + Assert.True(provider.TryGetConfigurator(GrainType.Create("test"), EmptyGrainProperties, out var configurator)); + + var reminderService = Substitute.For(); + var existingReminder = Substitute.For(); + reminderService.GetReminder(Arg.Any(), "interval-activation-registration") + .Returns(Task.FromResult(existingReminder)); + + var (grainId, observer) = ConfigureAndCaptureObserver(configurator, reminderService); + + await observer.OnStart(CancellationToken.None); + + _ = reminderService.DidNotReceive().RegisterOrUpdateReminder( + grainId, + "interval-activation-registration", + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task OnStart_RegistersMissingIntervalReminder() + { + var provider = CreateProvider(typeof(ActivationIntervalRegistrationTestGrain)); + Assert.True(provider.TryGetConfigurator(GrainType.Create("test"), EmptyGrainProperties, out var configurator)); + + var reminderService = Substitute.For(); + reminderService.GetReminder(Arg.Any(), "interval-activation-registration") + .Returns(Task.FromResult(null)); + reminderService.RegisterOrUpdateReminder( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(Substitute.For())); + + var (grainId, observer) = ConfigureAndCaptureObserver(configurator, reminderService); + + await observer.OnStart(CancellationToken.None); + + _ = reminderService.Received(1).RegisterOrUpdateReminder( + grainId, + "interval-activation-registration", + Arg.Is(schedule => + schedule.Kind == ReminderScheduleKind.Interval + && schedule.DueTime == TimeSpan.FromSeconds(5) + && schedule.DueAtUtc == null + && schedule.Period == TimeSpan.FromSeconds(30) + && schedule.CronExpression == null + && schedule.CronTimeZoneId == null), + ReminderPriority.Normal, + MissedReminderAction.Skip); + } + + [Fact] + public async Task OnStart_RegistersMissingCronReminder() + { + var provider = CreateProvider(typeof(ActivationCronRegistrationTestGrain)); + Assert.True(provider.TryGetConfigurator(GrainType.Create("test"), EmptyGrainProperties, out var configurator)); + + var reminderService = Substitute.For(); + reminderService.GetReminder(Arg.Any(), "cron-activation-registration") + .Returns(Task.FromResult(null)); + reminderService.RegisterOrUpdateReminder( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(Substitute.For())); + + var (grainId, observer) = ConfigureAndCaptureObserver(configurator, reminderService); + + await observer.OnStart(CancellationToken.None); + + _ = reminderService.Received(1).RegisterOrUpdateReminder( + grainId, + "cron-activation-registration", + Arg.Is(schedule => + schedule.Kind == ReminderScheduleKind.Cron + && schedule.CronExpression == "0 9 * * MON-FRI" + && schedule.CronTimeZoneId == null + && schedule.DueTime == null + && schedule.DueAtUtc == null + && schedule.Period == null), + ReminderPriority.High, + MissedReminderAction.FireImmediately); + } + + [Fact] + public async Task OnStart_HandlesMissingReminderService() + { + var provider = CreateProvider(typeof(ActivationIntervalRegistrationTestGrain)); + Assert.True(provider.TryGetConfigurator(GrainType.Create("test"), EmptyGrainProperties, out var configurator)); + + var (_, observer) = ConfigureAndCaptureObserver(configurator, reminderService: null); + + await observer.OnStart(CancellationToken.None); + } + + private static RegisterReminderActivationConfiguratorProvider CreateProvider(Type grainType) + => new(NullLoggerFactory.Instance, _ => grainType); + + private static (GrainId GrainId, ILifecycleObserver Observer) ConfigureAndCaptureObserver( + IConfigureGrainContext configurator, + AdvancedReminderServiceInterface? reminderService) + { + ILifecycleObserver? observer = null; + var lifecycle = Substitute.For(); + lifecycle.Subscribe(Arg.Any(), GrainLifecycleStage.Activate, Arg.Any()) + .Returns(callInfo => + { + observer = callInfo.ArgAt(2); + return Substitute.For(); + }); + + var grainId = GrainId.Create("test", "activation-registration"); + var services = new ServiceCollection(); + if (reminderService is not null) + { + services.AddSingleton(reminderService); + } + + var context = Substitute.For(); + context.GrainId.Returns(grainId); + context.ObservableLifecycle.Returns(lifecycle); + context.ActivationServices.Returns(services.BuildServiceProvider()); + + configurator.Configure(context); + + Assert.NotNull(observer); + return (grainId, observer!); + } +} + +[TestCategory("Reminders")] +public class ReminderOptionsValidatorTests +{ + [Fact] + public void ValidateConfiguration_AcceptsValidOptions() + { + var options = new AdvancedReminderOptions + { + MinimumReminderPeriod = TimeSpan.FromMinutes(1), + InitializationTimeout = TimeSpan.FromSeconds(30), + MissedReminderGracePeriod = TimeSpan.FromSeconds(5), + }; + + var validator = new AdvancedReminderOptionsValidator(NullLogger.Instance, Options.Create(options)); + + validator.ValidateConfiguration(); + } + + [Fact] + public void ValidateConfiguration_RejectsNegativeMinimumPeriod() + { + var validator = CreateValidator(new AdvancedReminderOptions { MinimumReminderPeriod = TimeSpan.FromSeconds(-1) }); + + Assert.Throws(() => validator.ValidateConfiguration()); + } + + [Fact] + public void ValidateConfiguration_RejectsNonPositiveInitializationTimeout() + { + var validator = CreateValidator(new AdvancedReminderOptions { InitializationTimeout = TimeSpan.Zero }); + + Assert.Throws(() => validator.ValidateConfiguration()); + } + + [Fact] + public void ValidateConfiguration_RejectsNonPositiveMissedReminderGracePeriod() + { + var validator = CreateValidator(new AdvancedReminderOptions { MissedReminderGracePeriod = TimeSpan.Zero }); + + Assert.Throws(() => validator.ValidateConfiguration()); + } + + private static AdvancedReminderOptionsValidator CreateValidator(AdvancedReminderOptions options) + => new(NullLogger.Instance, Options.Create(options)); +} + +[TestCategory("Reminders")] +public class ReminderRegistrationExtensionsTests +{ + [Fact] + public async Task RegistryExtension_WithBuilder_DelegatesToScheduleMethod() + { + var registry = Substitute.For(); + var grainId = GrainId.Create("test", "registry-builder"); + var reminder = Substitute.For(); + registry.RegisterOrUpdateReminder( + grainId, + "r", + Arg.Any(), + ReminderPriority.Normal, + MissedReminderAction.Skip) + .Returns(Task.FromResult(reminder)); + + var result = await registry.RegisterOrUpdateReminder(grainId, "r", ReminderCronBuilder.WeekdaysAt(9, 30)); + + Assert.Same(reminder, result); + _ = registry.Received(1).RegisterOrUpdateReminder( + grainId, + "r", + Arg.Is(schedule => + schedule.Kind == ReminderScheduleKind.Cron + && schedule.CronExpression == "30 9 * * MON-FRI" + && schedule.CronTimeZoneId == null), + ReminderPriority.Normal, + MissedReminderAction.Skip); + } + + [Fact] + public async Task RegistryExtension_WithExpressionAndPriority_DelegatesToScheduleMethod() + { + var registry = Substitute.For(); + var grainId = GrainId.Create("test", "registry-expression-priority"); + var reminder = Substitute.For(); + var expression = ReminderCronExpression.Parse("*/5 * * * *"); + registry.RegisterOrUpdateReminder( + grainId, + "r", + Arg.Any(), + ReminderPriority.High, + MissedReminderAction.Notify) + .Returns(Task.FromResult(reminder)); + + var result = await registry.RegisterOrUpdateReminder( + grainId, + "r", + expression, + ReminderPriority.High, + MissedReminderAction.Notify); + + Assert.Same(reminder, result); + _ = registry.Received(1).RegisterOrUpdateReminder( + grainId, + "r", + Arg.Is(schedule => + schedule.Kind == ReminderScheduleKind.Cron + && schedule.CronExpression == "*/5 * * * *" + && schedule.CronTimeZoneId == null), + ReminderPriority.High, + MissedReminderAction.Notify); + } + + [Fact] + public async Task ServiceExtension_WithBuilder_DelegatesToScheduleMethod() + { + var service = Substitute.For(); + var grainId = GrainId.Create("test", "service-builder"); + var reminder = Substitute.For(); + service.RegisterOrUpdateReminder( + grainId, + "r", + Arg.Any(), + ReminderPriority.Normal, + MissedReminderAction.Skip) + .Returns(Task.FromResult(reminder)); + + var result = await service.RegisterOrUpdateReminder(grainId, "r", ReminderCronBuilder.DailyAt(7, 0)); + + Assert.Same(reminder, result); + _ = service.Received(1).RegisterOrUpdateReminder( + grainId, + "r", + Arg.Is(schedule => + schedule.Kind == ReminderScheduleKind.Cron + && schedule.CronExpression == "0 7 * * *" + && schedule.CronTimeZoneId == null), + ReminderPriority.Normal, + MissedReminderAction.Skip); + } + + [Fact] + public async Task ServiceExtension_WithExpressionAndPriority_DelegatesToScheduleMethod() + { + var service = Substitute.For(); + var grainId = GrainId.Create("test", "service-expression-priority"); + var reminder = Substitute.For(); + var expression = ReminderCronExpression.Parse("0 */2 * * * *"); + service.RegisterOrUpdateReminder( + grainId, + "r", + Arg.Any(), + ReminderPriority.Normal, + MissedReminderAction.Skip) + .Returns(Task.FromResult(reminder)); + + var result = await service.RegisterOrUpdateReminder( + grainId, + "r", + expression, + ReminderPriority.Normal, + MissedReminderAction.Skip); + + Assert.Same(reminder, result); + _ = service.Received(1).RegisterOrUpdateReminder( + grainId, + "r", + Arg.Is(schedule => + schedule.Kind == ReminderScheduleKind.Cron + && schedule.CronExpression == "0 */2 * * * *" + && schedule.CronTimeZoneId == null), + ReminderPriority.Normal, + MissedReminderAction.Skip); + } + + [Fact] + public async Task GrainExtension_WithBuilder_DelegatesToRegistry() + { + var grainId = GrainId.Create("test", "grain-builder"); + var registry = Substitute.For(); + var reminder = Substitute.For(); + registry.RegisterOrUpdateReminder( + grainId, + "r", + Arg.Any(), + ReminderPriority.Normal, + MissedReminderAction.Skip) + .Returns(Task.FromResult(reminder)); + var grain = CreateRemindableGrain(grainId, registry); + + var result = await grain.RegisterOrUpdateReminder("r", ReminderCronBuilder.DailyAt(10, 15)); + + Assert.Same(reminder, result); + _ = registry.Received(1).RegisterOrUpdateReminder( + grainId, + "r", + Arg.Is(schedule => + schedule.Kind == ReminderScheduleKind.Cron + && schedule.CronExpression == "15 10 * * *" + && schedule.CronTimeZoneId == null), + ReminderPriority.Normal, + MissedReminderAction.Skip); + } + + [Fact] + public async Task GrainExtension_WithPriorityAndAbsoluteDueUtc_DelegatesToRegistry() + { + var grainId = GrainId.Create("test", "grain-priority-absolute-due"); + var registry = Substitute.For(); + var reminder = Substitute.For(); + var dueAtUtc = new DateTime(2026, 2, 1, 11, 0, 0, DateTimeKind.Utc); + var period = TimeSpan.FromMinutes(1); + registry.RegisterOrUpdateReminder( + grainId, + "r", + Arg.Any(), + ReminderPriority.High, + MissedReminderAction.FireImmediately) + .Returns(Task.FromResult(reminder)); + var grain = CreateRemindableGrain(grainId, registry); + + var result = await grain.RegisterOrUpdateReminder( + "r", + dueAtUtc, + period, + ReminderPriority.High, + MissedReminderAction.FireImmediately); + + Assert.Same(reminder, result); + _ = registry.Received(1).RegisterOrUpdateReminder( + grainId, + "r", + Arg.Is(schedule => + schedule.Kind == ReminderScheduleKind.Interval + && schedule.DueAtUtc == dueAtUtc + && schedule.DueTime == null + && schedule.Period == period + && schedule.CronExpression == null + && schedule.CronTimeZoneId == null), + ReminderPriority.High, + MissedReminderAction.FireImmediately); + } + + [Fact] + public async Task GrainExtension_WithSchedule_DelegatesToRegistry() + { + var grainId = GrainId.Create("test", "grain-schedule"); + var registry = Substitute.For(); + var reminder = Substitute.For(); + var schedule = ReminderSchedule.Cron("15 10 * * *", "Europe/Paris"); + registry.RegisterOrUpdateReminder( + grainId, + "r", + Arg.Any(), + ReminderPriority.Normal, + MissedReminderAction.Skip) + .Returns(Task.FromResult(reminder)); + var grain = CreateRemindableGrain(grainId, registry); + + var result = await grain.RegisterOrUpdateReminder("r", schedule); + + Assert.Same(reminder, result); + _ = registry.Received(1).RegisterOrUpdateReminder( + grainId, + "r", + Arg.Is(value => ReferenceEquals(value, schedule)), + ReminderPriority.Normal, + MissedReminderAction.Skip); + } + + [Fact] + public async Task GrainExtension_WithScheduleAndPriority_DelegatesToRegistry() + { + var grainId = GrainId.Create("test", "grain-schedule-priority"); + var registry = Substitute.For(); + var reminder = Substitute.For(); + var schedule = ReminderSchedule.Interval(TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(2)); + registry.RegisterOrUpdateReminder( + grainId, + "r", + Arg.Any(), + ReminderPriority.High, + MissedReminderAction.Notify) + .Returns(Task.FromResult(reminder)); + var grain = CreateRemindableGrain(grainId, registry); + + var result = await grain.RegisterOrUpdateReminder("r", schedule, ReminderPriority.High, MissedReminderAction.Notify); + + Assert.Same(reminder, result); + _ = registry.Received(1).RegisterOrUpdateReminder( + grainId, + "r", + Arg.Is(value => ReferenceEquals(value, schedule)), + ReminderPriority.High, + MissedReminderAction.Notify); + } + + [Fact] + public async Task GrainExtension_ThrowsWhenGrainIsNotRemindable() + { + var grainId = GrainId.Create("test", "non-remindable"); + var registry = Substitute.For(); + var context = Substitute.For(); + context.GrainId.Returns(grainId); + context.ActivationServices.Returns(new ServiceCollection().AddSingleton(registry).BuildServiceProvider()); + + var grain = Substitute.For(); + grain.GrainContext.Returns(context); + + var exception = await Assert.ThrowsAsync( + async () => await grain.RegisterOrUpdateReminder("r", "*/5 * * * *")); + Assert.Contains(typeof(AdvancedRemindable).FullName!, exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task RegistrationExtensions_ThrowOnNullDependencies() + { + var grainId = GrainId.Create("test", "null-dependencies"); + var expression = ReminderCronExpression.Parse("* * * * *"); + var builder = ReminderCronBuilder.EveryMinute(); + + await Assert.ThrowsAsync( + async () => await ReminderCronRegistrationExtensions.RegisterOrUpdateReminder((IReminderRegistry)null!, grainId, "r", expression)); + await Assert.ThrowsAsync( + async () => await ReminderCronRegistrationExtensions.RegisterOrUpdateReminder((IReminderRegistry)null!, grainId, "r", builder)); + await Assert.ThrowsAsync( + async () => await ReminderCronRegistrationExtensions.RegisterOrUpdateReminder((AdvancedReminderServiceInterface)null!, grainId, "r", expression)); + await Assert.ThrowsAsync( + async () => await ReminderCronRegistrationExtensions.RegisterOrUpdateReminder((AdvancedReminderServiceInterface)null!, grainId, "r", builder)); + } + + private static IGrainBase CreateRemindableGrain(GrainId grainId, IReminderRegistry registry) + { + var services = new ServiceCollection().AddSingleton(registry).BuildServiceProvider(); + var context = Substitute.For(); + context.GrainId.Returns(grainId); + context.ActivationServices.Returns(services); + + var grain = Substitute.For(); + grain.GrainContext.Returns(context); + return grain; + } +} + +[TestCategory("Reminders")] +public class ReminderRegistryValidationTests +{ + [Fact] + public async Task RegisterInterval_RejectsInfiniteDueTime() + { + var registry = CreateRegistry(); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(GrainId.Create("test", "g"), "r", Timeout.InfiniteTimeSpan, TimeSpan.FromMinutes(1))); + } + + [Fact] + public async Task RegisterInterval_RejectsNegativeDueTime() + { + var registry = CreateRegistry(); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(GrainId.Create("test", "g"), "r", TimeSpan.FromSeconds(-1), TimeSpan.FromMinutes(1))); + } + + [Fact] + public async Task RegisterInterval_RejectsInfinitePeriod() + { + var registry = CreateRegistry(); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(GrainId.Create("test", "g"), "r", TimeSpan.Zero, Timeout.InfiniteTimeSpan)); + } + + [Fact] + public async Task RegisterInterval_RejectsNegativePeriod() + { + var registry = CreateRegistry(); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(GrainId.Create("test", "g"), "r", TimeSpan.Zero, TimeSpan.FromSeconds(-1))); + } + + [Fact] + public async Task RegisterInterval_RejectsPeriodBelowMinimum() + { + var registry = CreateRegistry(new AdvancedReminderOptions { MinimumReminderPeriod = TimeSpan.FromMinutes(2) }); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(GrainId.Create("test", "g"), "r", TimeSpan.Zero, TimeSpan.FromMinutes(1))); + } + + [Fact] + public async Task RegisterInterval_RejectsEmptyName() + { + var registry = CreateRegistry(); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(GrainId.Create("test", "g"), "", TimeSpan.Zero, TimeSpan.FromMinutes(1))); + } + + [Fact] + public async Task RegisterInterval_RejectsInvalidPriorityOrAction() + { + var registry = CreateRegistry(); + var grainId = GrainId.Create("test", "g"); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(grainId, "r", TimeSpan.Zero, TimeSpan.FromMinutes(2), (ReminderPriority)255, MissedReminderAction.Skip)); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(grainId, "r", TimeSpan.Zero, TimeSpan.FromMinutes(2), ReminderPriority.Normal, (MissedReminderAction)255)); + } + + [Fact] + public async Task RegisterAbsolute_RejectsNonUtcDueTimestamp() + { + var registry = CreateRegistry(); + var grainId = GrainId.Create("test", "g"); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(grainId, "r", DateTime.Now, TimeSpan.FromMinutes(2))); + } + + [Fact] + public async Task RegisterCron_RejectsEmptyName() + { + var registry = CreateRegistry(); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(GrainId.Create("test", "g"), " ", "*/5 * * * *")); + } + + [Fact] + public async Task RegisterCron_RejectsEmptyExpression() + { + var registry = CreateRegistry(); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(GrainId.Create("test", "g"), "r", " ")); + } + + [Fact] + public async Task RegisterCron_RejectsInvalidExpression() + { + var registry = CreateRegistry(); + + await Assert.ThrowsAnyAsync( + async () => await registry.RegisterOrUpdateReminder(GrainId.Create("test", "g"), "r", "invalid cron")); + } + + [Fact] + public async Task RegisterCron_RejectsInvalidPriorityOrAction() + { + var registry = CreateRegistry(); + var grainId = GrainId.Create("test", "g"); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(grainId, "r", "*/5 * * * *", (ReminderPriority)255, MissedReminderAction.Skip)); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(grainId, "r", "*/5 * * * *", ReminderPriority.Normal, (MissedReminderAction)255)); + } + + [Fact] + public async Task Register_WithValidInputAndMissingService_ThrowsInvalidOperation() + { + var registry = CreateRegistry(); + + await Assert.ThrowsAsync( + async () => await registry.RegisterOrUpdateReminder(GrainId.Create("test", "g"), "r", TimeSpan.Zero, TimeSpan.FromMinutes(2))); + } + + [Fact] + public async Task Register_WithValidInput_DelegatesToReminderService() + { + var service = Substitute.For(); + var reminder = Substitute.For(); + var grainId = GrainId.Create("test", "delegate"); + service.RegisterOrUpdateReminder( + grainId, + "r", + Arg.Is(schedule => + schedule.Kind == ReminderScheduleKind.Interval + && schedule.DueTime == TimeSpan.Zero + && schedule.DueAtUtc == null + && schedule.Period == TimeSpan.FromMinutes(2) + && schedule.CronExpression == null + && schedule.CronTimeZoneId == null), + ReminderPriority.Normal, + MissedReminderAction.Skip) + .Returns(Task.FromResult(reminder)); + + var registry = CreateRegistry(reminderService: service); + + var result = await registry.RegisterOrUpdateReminder(grainId, "r", TimeSpan.Zero, TimeSpan.FromMinutes(2)); + + Assert.Same(reminder, result); + } + + private static ReminderRegistry CreateRegistry(AdvancedReminderOptions? options = null, AdvancedReminderServiceInterface? reminderService = null) + { + var services = new ServiceCollection(); + if (reminderService is not null) + { + services.AddSingleton(reminderService); + } + + return new ReminderRegistry(services.BuildServiceProvider(), Options.Create(options ?? new AdvancedReminderOptions())); + } +} + +[TestCategory("Reminders")] +public class SiloBuilderReminderExtensionsTests +{ + [Fact] + public void AddAdvancedReminders_RegistersAdvancedReminderService() + { + var services = new ServiceCollection(); + + services.AddAdvancedReminders(); + + Assert.Contains(services, descriptor => descriptor.ServiceType == typeof(AdvancedReminderService)); + Assert.Contains(services, descriptor => descriptor.ServiceType == typeof(AdvancedReminderServiceInterface)); + Assert.Contains(services, descriptor => descriptor.ServiceType == typeof(IReminderRegistry)); + Assert.Contains(services, descriptor => descriptor.ServiceType == typeof(IConfigurationValidator)); + } + + [Fact] + public void AddAdvancedReminders_IsIdempotentForAdvancedReminderServiceBinding() + { + var services = new ServiceCollection(); + + services.AddAdvancedReminders(); + services.AddAdvancedReminders(); + + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(AdvancedReminderService)); + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(AdvancedReminderServiceInterface)); + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(IReminderRegistry)); + } + + [Fact] + public void AddAdvancedReminders_BuilderOverload_RegistersAdvancedReminderService() + { + var builder = new TestSiloBuilder(); + + builder.AddAdvancedReminders(); + + Assert.Contains(builder.Services, descriptor => descriptor.ServiceType == typeof(AdvancedReminderService)); + Assert.Contains(builder.Services, descriptor => descriptor.ServiceType == typeof(AdvancedReminderServiceInterface)); + } + + [Fact] + public void AddAdvancedReminders_ConfigureOptions_UpdatesReminderOptions() + { + var services = new ServiceCollection(); + + services.AddAdvancedReminders(options => + { + options.MissedReminderGracePeriod = TimeSpan.FromSeconds(9); + options.MinimumReminderPeriod = TimeSpan.FromSeconds(3); + }); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Equal(TimeSpan.FromSeconds(9), options.MissedReminderGracePeriod); + Assert.Equal(TimeSpan.FromSeconds(3), options.MinimumReminderPeriod); + } + + [Fact] + public void AddAdvancedReminders_BuilderOverloadWithConfigureOptions_UpdatesReminderOptions() + { + var builder = new TestSiloBuilder(); + + builder.AddAdvancedReminders(options => options.MissedReminderGracePeriod = TimeSpan.FromSeconds(11)); + + using var provider = builder.Services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Equal(TimeSpan.FromSeconds(11), options.MissedReminderGracePeriod); + } + + [Fact] + public void UseInMemoryAdvancedReminderService_RegistersInMemoryReminderTable() + { + var builder = new TestSiloBuilder(); + + builder.UseInMemoryAdvancedReminderService(); + + Assert.Contains(builder.Services, descriptor => descriptor.ServiceType == typeof(InMemoryReminderTable)); + Assert.Contains(builder.Services, descriptor => descriptor.ServiceType == typeof(Orleans.AdvancedReminders.IReminderTable)); + } + + [Fact] + public void AddAdvancedReminders_WithoutDurableJobsBackend_FailsValidation() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAdvancedReminders(); + + using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(() => + { + foreach (var validator in provider.GetServices()) + { + validator.ValidateConfiguration(); + } + }); + + Assert.Contains("durable jobs backend", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void UseInMemoryAdvancedReminderService_RegistersDurableJobsBackend() + { + var builder = new TestSiloBuilder(); + + builder.UseInMemoryAdvancedReminderService(); + + Assert.Contains(builder.Services, descriptor => descriptor.ServiceType == typeof(JobShardManager)); + } + + private sealed class TestSiloBuilder(IServiceCollection? services = null) : ISiloBuilder + { + public IServiceCollection Services { get; } = services ?? new ServiceCollection(); + + public IConfiguration Configuration { get; } = new ConfigurationBuilder().Build(); + } +} + +[TestCategory("Reminders")] +public class AdvancedReminderServiceTests +{ + [Fact] + public async Task RegisterOrUpdateReminder_WithCronSchedule_UpsertsAndSchedulesDurableJob() + { + var grainId = GrainId.Create("test", "cron-register"); + var dispatcherGrainId = GrainId.Create("sys", "durable-reminder-dispatcher"); + var reminderTable = Substitute.For(); + reminderTable.UpsertRow(Arg.Any()).Returns("etag-1"); + + var jobManager = Substitute.For(); + ScheduleJobRequest? scheduledRequest = null; + jobManager.ScheduleJobAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var request = callInfo.Arg(); + scheduledRequest = request; + return Task.FromResult(CreateDurableJob(request)); + }); + + var grainFactory = Substitute.For(); + var dispatcher = CreateDispatcherGrain(dispatcherGrainId); + grainFactory.GetGrain(grainId.ToString(), null) + .Returns(dispatcher); + + var service = CreateService(reminderTable, jobManager: jobManager, grainFactory: grainFactory); + + var reminder = await service.RegisterOrUpdateReminder( + grainId, + "cron", + ReminderSchedule.Cron("0 9 * * *"), + ReminderPriority.Normal, + MissedReminderAction.Skip); + + Assert.Equal("cron", reminder.ReminderName); + await reminderTable.Received(1).UpsertRow(Arg.Is(entry => + entry.GrainId == grainId + && entry.ReminderName == "cron" + && entry.Period == TimeSpan.Zero + && entry.CronExpression == "0 9 * * *" + && entry.Priority == ReminderPriority.Normal + && entry.Action == MissedReminderAction.Skip + && entry.NextDueUtc != null)); + await jobManager.Received(1).ScheduleJobAsync(Arg.Any(), Arg.Any()); + var request = Assert.IsType(scheduledRequest); + Assert.Equal("advanced-reminder:cron", request.JobName); + Assert.Equal(dispatcherGrainId, request.Target); + Assert.Equal(grainId.ToString(), request.Metadata!["grain-id"]); + Assert.Equal("cron", request.Metadata["reminder-name"]); + Assert.Equal("etag-1", request.Metadata["etag"]); + } + + [Fact] + public async Task RegisterOrUpdateReminder_WithAbsoluteIntervalSchedule_UpsertsAndSchedulesDurableJob() + { + var grainId = GrainId.Create("test", "absolute-register"); + var dueAtUtc = DateTime.UtcNow.AddMinutes(10); + var reminderTable = Substitute.For(); + reminderTable.UpsertRow(Arg.Any()).Returns("etag-absolute"); + + var jobManager = Substitute.For(); + ScheduleJobRequest? scheduledRequest = null; + jobManager.ScheduleJobAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var request = callInfo.Arg(); + scheduledRequest = request; + return Task.FromResult(CreateDurableJob(request)); + }); + + var dispatcherGrainId = GrainId.Create("sys", "durable-reminder-dispatcher"); + var dispatcher = CreateDispatcherGrain(dispatcherGrainId); + var grainFactory = Substitute.For(); + grainFactory.GetGrain(grainId.ToString(), null) + .Returns(dispatcher); + + var service = CreateService(reminderTable, jobManager: jobManager, grainFactory: grainFactory); + + var reminder = await service.RegisterOrUpdateReminder( + grainId, + "absolute", + ReminderSchedule.Interval(dueAtUtc, TimeSpan.FromMinutes(5)), + ReminderPriority.High, + MissedReminderAction.Notify); + + Assert.Equal("absolute", reminder.ReminderName); + await reminderTable.Received(1).UpsertRow(Arg.Is(entry => + entry.GrainId == grainId + && entry.ReminderName == "absolute" + && entry.StartAt == dueAtUtc + && entry.NextDueUtc == dueAtUtc + && entry.Period == TimeSpan.FromMinutes(5) + && entry.Priority == ReminderPriority.High + && entry.Action == MissedReminderAction.Notify + && string.IsNullOrEmpty(entry.CronExpression))); + var request = Assert.IsType(scheduledRequest); + Assert.Equal("advanced-reminder:absolute", request.JobName); + Assert.Equal("etag-absolute", request.Metadata!["etag"]); + } + + [Fact] + public async Task GetReminder_WhenEntryExists_ReturnsMappedHandle() + { + var grainId = GrainId.Create("test", "single"); + var entry = new ReminderEntry + { + GrainId = grainId, + ReminderName = "r", + ETag = "etag-1", + CronExpression = "0 9 * * *", + CronTimeZoneId = "UTC", + Priority = ReminderPriority.High, + Action = MissedReminderAction.Notify, + }; + var reminderTable = Substitute.For(); + reminderTable.ReadRow(grainId, "r").Returns(Task.FromResult(entry)); + var service = CreateService(reminderTable); + + var result = await service.GetReminder(grainId, "r"); + + var reminder = Assert.IsAssignableFrom(result); + Assert.Equal("r", reminder.ReminderName); + Assert.Equal("0 9 * * *", reminder.CronExpression); + Assert.Equal("UTC", reminder.CronTimeZone); + Assert.Equal(ReminderPriority.High, reminder.Priority); + Assert.Equal(MissedReminderAction.Notify, reminder.Action); + } + + [Fact] + public async Task GetReminders_WhenEntriesExist_ReturnsMappedHandles() + { + var grainId = GrainId.Create("test", "all"); + var reminderTable = Substitute.For(); + reminderTable.ReadRows(grainId).Returns(Task.FromResult(new ReminderTableData( + [ + new ReminderEntry + { + GrainId = grainId, + ReminderName = "interval", + ETag = "etag-a", + Priority = ReminderPriority.Normal, + Action = MissedReminderAction.Skip, + }, + new ReminderEntry + { + GrainId = grainId, + ReminderName = "cron", + ETag = "etag-b", + CronExpression = "*/5 * * * *", + CronTimeZoneId = "UTC", + Priority = ReminderPriority.High, + Action = MissedReminderAction.FireImmediately, + }, + ]))); + var service = CreateService(reminderTable); + + var result = await service.GetReminders(grainId); + + Assert.Collection( + result, + reminder => + { + Assert.Equal("interval", reminder.ReminderName); + Assert.Equal(string.Empty, reminder.CronExpression); + Assert.Equal(ReminderPriority.Normal, reminder.Priority); + Assert.Equal(MissedReminderAction.Skip, reminder.Action); + }, + reminder => + { + Assert.Equal("cron", reminder.ReminderName); + Assert.Equal("*/5 * * * *", reminder.CronExpression); + Assert.Equal("UTC", reminder.CronTimeZone); + Assert.Equal(ReminderPriority.High, reminder.Priority); + Assert.Equal(MissedReminderAction.FireImmediately, reminder.Action); + }); + } + + [Fact] + public async Task UnregisterReminder_WithValidHandle_RemovesReminderUsingETag() + { + var grainId = GrainId.Create("test", "remove-valid"); + var reminderTable = Substitute.For(); + reminderTable.ReadRow(grainId, "r").Returns(Task.FromResult(new ReminderEntry + { + GrainId = grainId, + ReminderName = "r", + ETag = "etag-remove", + })); + reminderTable.RemoveRow(grainId, "r", "etag-remove").Returns(Task.FromResult(true)); + var service = CreateService(reminderTable); + var reminder = await service.GetReminder(grainId, "r"); + + await service.UnregisterReminder(reminder!); + + await reminderTable.Received(1).RemoveRow(grainId, "r", "etag-remove"); + } + + [Fact] + public async Task UnregisterReminder_WithForeignHandle_Throws() + { + var reminderTable = Substitute.For(); + var service = CreateService(reminderTable); + + var exception = await Assert.ThrowsAsync( + async () => await service.UnregisterReminder(Substitute.For())); + + Assert.Equal("reminder", exception.ParamName); + await reminderTable.DidNotReceive().RemoveRow(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UnregisterReminder_WithStaleHandle_RemovesLatestReminderUsingFreshETag() + { + var now = DateTime.UtcNow; + var grainId = GrainId.Create("test", "stale-remove"); + var original = new ReminderEntry + { + GrainId = grainId, + ReminderName = "r", + StartAt = now, + NextDueUtc = now, + Period = TimeSpan.FromMinutes(1), + ETag = "etag-v1", + }; + var reminderTable = new MutableReminderTable(original); + var service = CreateService(reminderTable); + var staleHandle = original.ToIGrainReminder(); + + reminderTable.ReplaceCurrent(Clone(original, etag: "etag-v2")); + + await service.UnregisterReminder(staleHandle); + + Assert.Null(await reminderTable.ReadRow(grainId, "r")); + Assert.Equal(2, reminderTable.RemoveAttempts.Count); + Assert.Equal("etag-v1", reminderTable.RemoveAttempts[0].ETag); + Assert.False(reminderTable.RemoveAttempts[0].Removed); + Assert.Equal("etag-v2", reminderTable.RemoveAttempts[1].ETag); + Assert.True(reminderTable.RemoveAttempts[1].Removed); + } + + [Fact] + public async Task ProcessDueReminderAsync_WhenReminderIsMissing_Returns() + { + var reminderTable = Substitute.For(); + reminderTable.ReadRow(Arg.Any(), Arg.Any()).Returns(Task.FromResult(null!)); + + var service = CreateService(reminderTable); + + await service.ProcessDueReminderAsync(GrainId.Create("test", "missing"), "r", expectedETag: null, CancellationToken.None); + + await reminderTable.Received(1).ReadRow(Arg.Any(), "r"); + } + + [Fact] + public async Task ProcessDueReminderAsync_WhenETagDoesNotMatch_ReturnsWithoutUpsert() + { + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "etag"), + ReminderName = "r", + StartAt = DateTime.UtcNow.AddMinutes(-5), + NextDueUtc = DateTime.UtcNow.AddMinutes(-5), + Period = TimeSpan.FromMinutes(1), + ETag = "current", + }; + var reminderTable = Substitute.For(); + reminderTable.ReadRow(entry.GrainId, entry.ReminderName).Returns(Task.FromResult(entry)); + + var service = CreateService(reminderTable); + + await service.ProcessDueReminderAsync(entry.GrainId, entry.ReminderName, expectedETag: "stale", CancellationToken.None); + + await reminderTable.DidNotReceive().UpsertRow(Arg.Any()); + await reminderTable.DidNotReceive().RemoveRow(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ProcessDueReminderAsync_WhenMissedSkipAndNoFutureSchedule_RemovesReminder() + { + var now = DateTime.UtcNow; + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "remove"), + ReminderName = "r", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-10), + Period = TimeSpan.Zero, + Action = MissedReminderAction.Skip, + ETag = "etag", + }; + var reminderTable = Substitute.For(); + reminderTable.ReadRow(entry.GrainId, entry.ReminderName).Returns(Task.FromResult(entry)); + + var service = CreateService(reminderTable, options: new AdvancedReminderOptions { MissedReminderGracePeriod = TimeSpan.FromSeconds(1) }); + + await service.ProcessDueReminderAsync(entry.GrainId, entry.ReminderName, expectedETag: entry.ETag, CancellationToken.None); + + await reminderTable.Received(1).RemoveRow(entry.GrainId, entry.ReminderName, entry.ETag); + } + + [Fact] + public async Task ProcessDueReminderAsync_WhenMissedNotifyAndNoFutureSchedule_RemovesReminderWithoutCallingGrain() + { + var now = DateTime.UtcNow; + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "notify-remove"), + ReminderName = "notify", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-10), + Period = TimeSpan.Zero, + Action = MissedReminderAction.Notify, + ETag = "etag-notify", + }; + var reminderTable = Substitute.For(); + reminderTable.ReadRow(entry.GrainId, entry.ReminderName).Returns(Task.FromResult(entry)); + + var remindable = Substitute.For(); + var grainFactory = Substitute.For(); + grainFactory.GetGrain(entry.GrainId).Returns(remindable); + + var service = CreateService( + reminderTable, + options: new AdvancedReminderOptions { MissedReminderGracePeriod = TimeSpan.FromSeconds(1) }, + grainFactory: grainFactory); + + await service.ProcessDueReminderAsync(entry.GrainId, entry.ReminderName, expectedETag: entry.ETag, CancellationToken.None); + + await reminderTable.Received(1).RemoveRow(entry.GrainId, entry.ReminderName, entry.ETag); + Assert.Empty(remindable.ReceivedCalls()); + } + + [Fact] + public async Task ProcessDueReminderAsync_WhenMissedSkipAndFutureSchedule_DoesNotCallGrainAndReschedules() + { + var now = DateTime.UtcNow; + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "skip-reschedule"), + ReminderName = "skip", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-4), + Period = TimeSpan.FromMinutes(5), + Action = MissedReminderAction.Skip, + ETag = "etag-skip", + }; + var reminderTable = Substitute.For(); + reminderTable.ReadRow(entry.GrainId, entry.ReminderName).Returns(Task.FromResult(entry)); + reminderTable.UpsertRow(Arg.Any()).Returns("etag-skip-2"); + + var remindable = Substitute.For(); + var dispatcherGrainId = GrainId.Create("sys", "durable-reminder-dispatcher"); + var dispatcher = CreateDispatcherGrain(dispatcherGrainId); + var grainFactory = Substitute.For(); + grainFactory.GetGrain(entry.GrainId).Returns(remindable); + grainFactory.GetGrain(entry.GrainId.ToString(), null).Returns(dispatcher); + + var jobManager = Substitute.For(); + ScheduleJobRequest? scheduledRequest = null; + jobManager.ScheduleJobAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var request = callInfo.Arg(); + scheduledRequest = request; + return Task.FromResult(CreateDurableJob(request)); + }); + + var service = CreateService(reminderTable, options: new AdvancedReminderOptions { MissedReminderGracePeriod = TimeSpan.FromSeconds(1) }, jobManager: jobManager, grainFactory: grainFactory); + + await service.ProcessDueReminderAsync(entry.GrainId, entry.ReminderName, expectedETag: entry.ETag, CancellationToken.None); + + Assert.Empty(remindable.ReceivedCalls()); + await reminderTable.Received(1).UpsertRow(Arg.Is(updated => + updated.LastFireUtc == null + && updated.NextDueUtc > now + && updated.Period == entry.Period)); + await jobManager.Received(1).ScheduleJobAsync(Arg.Any(), Arg.Any()); + Assert.Equal("advanced-reminder:skip", Assert.NotNull(scheduledRequest).JobName); + } + + [Fact] + public async Task ProcessDueReminderAsync_WhenMissedNotifyAndFutureSchedule_DoesNotCallGrainAndReschedules() + { + var now = DateTime.UtcNow; + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "notify-reschedule"), + ReminderName = "notify", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-4), + Period = TimeSpan.FromMinutes(5), + Action = MissedReminderAction.Notify, + ETag = "etag-notify-future", + }; + var reminderTable = Substitute.For(); + reminderTable.ReadRow(entry.GrainId, entry.ReminderName).Returns(Task.FromResult(entry)); + reminderTable.UpsertRow(Arg.Any()).Returns("etag-notify-future-2"); + + var remindable = Substitute.For(); + var dispatcherGrainId = GrainId.Create("sys", "durable-reminder-dispatcher"); + var dispatcher = CreateDispatcherGrain(dispatcherGrainId); + var grainFactory = Substitute.For(); + grainFactory.GetGrain(entry.GrainId).Returns(remindable); + grainFactory.GetGrain(entry.GrainId.ToString(), null).Returns(dispatcher); + + var jobManager = Substitute.For(); + ScheduleJobRequest? scheduledRequest = null; + jobManager.ScheduleJobAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var request = callInfo.Arg(); + scheduledRequest = request; + return Task.FromResult(CreateDurableJob(request)); + }); + + var service = CreateService(reminderTable, options: new AdvancedReminderOptions { MissedReminderGracePeriod = TimeSpan.FromSeconds(1) }, jobManager: jobManager, grainFactory: grainFactory); + + await service.ProcessDueReminderAsync(entry.GrainId, entry.ReminderName, expectedETag: entry.ETag, CancellationToken.None); + + Assert.Empty(remindable.ReceivedCalls()); + await reminderTable.Received(1).UpsertRow(Arg.Is(updated => + updated.LastFireUtc == null + && updated.NextDueUtc > now + && updated.Period == entry.Period)); + await jobManager.Received(1).ScheduleJobAsync(Arg.Any(), Arg.Any()); + Assert.Equal("advanced-reminder:notify", Assert.NotNull(scheduledRequest).JobName); + } + + [Fact] + public async Task ProcessDueReminderAsync_WhenMissedFireImmediatelyAndNoFutureSchedule_FiresThenRemovesReminder() + { + var now = DateTime.UtcNow; + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "fire-remove"), + ReminderName = "fire", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-10), + Period = TimeSpan.Zero, + Action = MissedReminderAction.FireImmediately, + ETag = "etag-fire-remove", + }; + var reminderTable = Substitute.For(); + reminderTable.ReadRow(entry.GrainId, entry.ReminderName).Returns(Task.FromResult(entry)); + + var remindable = Substitute.For(); + var grainFactory = Substitute.For(); + grainFactory.GetGrain(entry.GrainId).Returns(remindable); + var service = CreateService(reminderTable, options: new AdvancedReminderOptions { MissedReminderGracePeriod = TimeSpan.FromSeconds(1) }, grainFactory: grainFactory); + + await service.ProcessDueReminderAsync(entry.GrainId, entry.ReminderName, expectedETag: entry.ETag, CancellationToken.None); + + AssertReminderReceived(remindable, "fire", status => + { + Assert.Equal(TimeSpan.Zero, status.Period); + Assert.True(status.CurrentTickTime >= now); + }); + await reminderTable.Received(1).RemoveRow(entry.GrainId, entry.ReminderName, entry.ETag); + await reminderTable.DidNotReceive().UpsertRow(Arg.Any()); + } + + [Fact] + public async Task ProcessDueReminderAsync_WhenMissedFireImmediatelyAndFutureSchedule_FiresAndReschedules() + { + var now = DateTime.UtcNow; + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "fire-reschedule"), + ReminderName = "fire-future", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-4), + Period = TimeSpan.FromMinutes(5), + Action = MissedReminderAction.FireImmediately, + ETag = "etag-fire-future", + }; + + var reminderTable = Substitute.For(); + reminderTable.ReadRow(entry.GrainId, entry.ReminderName).Returns(Task.FromResult(entry)); + reminderTable.UpsertRow(Arg.Any()).Returns("etag-fire-future-2"); + + var remindable = Substitute.For(); + var dispatcherGrainId = GrainId.Create("sys", "durable-reminder-dispatcher"); + var dispatcher = CreateDispatcherGrain(dispatcherGrainId); + var grainFactory = Substitute.For(); + grainFactory.GetGrain(entry.GrainId).Returns(remindable); + grainFactory.GetGrain(entry.GrainId.ToString(), null).Returns(dispatcher); + + var jobManager = Substitute.For(); + ScheduleJobRequest? scheduledRequest = null; + jobManager.ScheduleJobAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var request = callInfo.Arg(); + scheduledRequest = request; + return Task.FromResult(CreateDurableJob(request)); + }); + + var service = CreateService( + reminderTable, + options: new AdvancedReminderOptions { MissedReminderGracePeriod = TimeSpan.FromSeconds(1) }, + jobManager: jobManager, + grainFactory: grainFactory); + + await service.ProcessDueReminderAsync(entry.GrainId, entry.ReminderName, expectedETag: entry.ETag, CancellationToken.None); + + AssertReminderReceived(remindable, "fire-future", status => + { + Assert.Equal(entry.StartAt, status.FirstTickTime); + Assert.Equal(entry.Period, status.Period); + Assert.True(status.CurrentTickTime >= now); + }); + await reminderTable.Received(1).UpsertRow(Arg.Is(updated => + updated.LastFireUtc != null + && updated.NextDueUtc > now + && updated.Period == entry.Period)); + await reminderTable.DidNotReceive().RemoveRow(entry.GrainId, entry.ReminderName, entry.ETag); + await jobManager.Received(1).ScheduleJobAsync(Arg.Any(), Arg.Any()); + Assert.Equal("advanced-reminder:fire-future", Assert.NotNull(scheduledRequest).JobName); + } + + [Fact] + public async Task ProcessDueReminderAsync_WhenReminderIsRemovedDuringCallback_DoesNotResurrectReminder() + { + var now = DateTime.UtcNow; + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "remove-during-fire"), + ReminderName = "interval", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-1), + Period = TimeSpan.FromMinutes(5), + Action = MissedReminderAction.FireImmediately, + ETag = "etag-remove-during-fire", + }; + var reminderTable = new MutableReminderTable(entry); + var remindable = new CallbackRemindable(() => + { + reminderTable.DeleteCurrent(); + return Task.CompletedTask; + }); + var dispatcherGrainId = GrainId.Create("sys", "durable-reminder-dispatcher"); + var grainFactory = Substitute.For(); + grainFactory.GetGrain(entry.GrainId).Returns(remindable); + var dispatcher = CreateDispatcherGrain(dispatcherGrainId); + grainFactory.GetGrain(entry.GrainId.ToString(), null) + .Returns(dispatcher); + var jobManager = Substitute.For(); + var service = CreateService(reminderTable, jobManager: jobManager, grainFactory: grainFactory); + + await service.ProcessDueReminderAsync(entry.GrainId, entry.ReminderName, expectedETag: entry.ETag, CancellationToken.None); + + Assert.Single(remindable.ReceivedStatuses); + Assert.Null(await reminderTable.ReadRow(entry.GrainId, entry.ReminderName)); + Assert.Equal(0, reminderTable.UpsertCount); + await jobManager.DidNotReceive().ScheduleJobAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ProcessDueReminderAsync_WhenReminderIsUpdatedDuringCallback_DoesNotOverwriteNewSchedule() + { + var now = DateTime.UtcNow; + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "update-during-fire"), + ReminderName = "cron", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-1), + Period = TimeSpan.Zero, + CronExpression = "*/5 * * * *", + CronTimeZoneId = "UTC", + Action = MissedReminderAction.FireImmediately, + ETag = "etag-before-update", + }; + var updated = new ReminderEntry + { + GrainId = entry.GrainId, + ReminderName = entry.ReminderName, + StartAt = now.AddHours(1), + NextDueUtc = now.AddHours(1), + Period = TimeSpan.Zero, + CronExpression = "15 8 * * *", + CronTimeZoneId = ReminderCronSchedule.NormalizeTimeZoneIdForStorage(AdvancedReminderTimeZoneTestHelper.GetUsEasternTimeZone()) ?? "America/New_York", + Priority = ReminderPriority.High, + Action = MissedReminderAction.Notify, + ETag = "etag-after-update", + }; + var reminderTable = new MutableReminderTable(entry); + var remindable = new CallbackRemindable(() => + { + reminderTable.ReplaceCurrent(updated); + return Task.CompletedTask; + }); + var dispatcherGrainId = GrainId.Create("sys", "durable-reminder-dispatcher"); + var grainFactory = Substitute.For(); + grainFactory.GetGrain(entry.GrainId).Returns(remindable); + var dispatcher = CreateDispatcherGrain(dispatcherGrainId); + grainFactory.GetGrain(entry.GrainId.ToString(), null) + .Returns(dispatcher); + var jobManager = Substitute.For(); + var service = CreateService(reminderTable, jobManager: jobManager, grainFactory: grainFactory); + + await service.ProcessDueReminderAsync(entry.GrainId, entry.ReminderName, expectedETag: entry.ETag, CancellationToken.None); + + var current = await reminderTable.ReadRow(entry.GrainId, entry.ReminderName); + Assert.NotNull(current); + Assert.Equal(updated.ETag, current.ETag); + Assert.Equal(updated.CronExpression, current.CronExpression); + Assert.Equal(updated.CronTimeZoneId, current.CronTimeZoneId); + Assert.Equal(updated.NextDueUtc, current.NextDueUtc); + Assert.Equal(updated.Action, current.Action); + Assert.Null(current.LastFireUtc); + Assert.Equal(0, reminderTable.UpsertCount); + await jobManager.DidNotReceive().ScheduleJobAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ProcessDueReminderAsync_WhenReminderTimeZoneChangesBeforeOldJobFires_OldJobNoOps() + { + var current = new ReminderEntry + { + GrainId = GrainId.Create("test", "tz-change"), + ReminderName = "cron", + StartAt = DateTime.UtcNow.AddHours(2), + NextDueUtc = DateTime.UtcNow.AddHours(2), + Period = TimeSpan.Zero, + CronExpression = "0 9 * * *", + CronTimeZoneId = ReminderCronSchedule.NormalizeTimeZoneIdForStorage(AdvancedReminderTimeZoneTestHelper.GetIndiaTimeZone()) ?? "Asia/Kolkata", + Action = MissedReminderAction.FireImmediately, + ETag = "etag-new-timezone", + }; + var reminderTable = new MutableReminderTable(current); + var remindable = new CallbackRemindable(() => Task.CompletedTask); + var grainFactory = Substitute.For(); + grainFactory.GetGrain(current.GrainId).Returns(remindable); + var jobManager = Substitute.For(); + var service = CreateService(reminderTable, jobManager: jobManager, grainFactory: grainFactory); + + await service.ProcessDueReminderAsync(current.GrainId, current.ReminderName, expectedETag: "etag-old-timezone", CancellationToken.None); + + Assert.Empty(remindable.ReceivedStatuses); + Assert.Equal(0, reminderTable.UpsertCount); + Assert.Equal(current.ETag, (await reminderTable.ReadRow(current.GrainId, current.ReminderName))!.ETag); + await jobManager.DidNotReceive().ScheduleJobAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ProcessDueReminderAsync_UsesOriginalGrainIdWhenResolvingRemindable() + { + var now = DateTime.UtcNow; + var entry = new ReminderEntry + { + GrainId = GrainId.Create("custom-remindable-type", "grain-key"), + ReminderName = "typed", + StartAt = now.AddMinutes(-5), + NextDueUtc = now.AddSeconds(-5), + Period = TimeSpan.FromMinutes(1), + Action = MissedReminderAction.FireImmediately, + ETag = "etag-typed", + }; + + var reminderTable = Substitute.For(); + reminderTable.ReadRow(entry.GrainId, entry.ReminderName).Returns(Task.FromResult(entry)); + reminderTable.UpsertRow(Arg.Any()).Returns("etag-typed-2"); + + var remindable = Substitute.For(); + var dispatcher = CreateDispatcherGrain(GrainId.Create("sys", "durable-reminder-dispatcher")); + var grainFactory = Substitute.For(); + grainFactory.GetGrain(entry.GrainId).Returns(remindable); + grainFactory.GetGrain(entry.GrainId.ToString(), null).Returns(dispatcher); + + var jobManager = Substitute.For(); + jobManager.ScheduleJobAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(CreateDurableJob(callInfo.Arg()))); + + var service = CreateService(reminderTable, jobManager: jobManager, grainFactory: grainFactory); + + await service.ProcessDueReminderAsync(entry.GrainId, entry.ReminderName, expectedETag: entry.ETag, CancellationToken.None); + + grainFactory.Received(1).GetGrain(entry.GrainId); + AssertReminderReceived(remindable, "typed", status => Assert.Equal(entry.Period, status.Period)); + } + + [Fact] + public async Task ProcessDueReminderAsync_ForIntervalReminder_FiresAndReschedules() + { + var now = DateTime.UtcNow; + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "interval-due"), + ReminderName = "interval", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-1), + Period = TimeSpan.FromMinutes(2), + Action = MissedReminderAction.FireImmediately, + ETag = "etag-interval", + }; + + var reminderTable = Substitute.For(); + reminderTable.ReadRow(entry.GrainId, entry.ReminderName).Returns(Task.FromResult(entry)); + reminderTable.UpsertRow(Arg.Any()).Returns("etag-interval-2"); + + var remindable = Substitute.For(); + var dispatcherGrainId = GrainId.Create("sys", "durable-reminder-dispatcher"); + var grainFactory = Substitute.For(); + var dispatcher = CreateDispatcherGrain(dispatcherGrainId); + grainFactory.GetGrain(entry.GrainId).Returns(remindable); + grainFactory.GetGrain(entry.GrainId.ToString(), null) + .Returns(dispatcher); + + var jobManager = Substitute.For(); + ScheduleJobRequest? scheduledRequest = null; + jobManager.ScheduleJobAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var request = callInfo.Arg(); + scheduledRequest = request; + return Task.FromResult(CreateDurableJob(request)); + }); + + var service = CreateService(reminderTable, jobManager: jobManager, grainFactory: grainFactory); + + await service.ProcessDueReminderAsync(entry.GrainId, entry.ReminderName, expectedETag: entry.ETag, CancellationToken.None); + + AssertReminderReceived(remindable, "interval", status => + { + Assert.Equal(entry.StartAt, status.FirstTickTime); + Assert.Equal(entry.Period, status.Period); + Assert.True(status.CurrentTickTime >= now); + }); + await reminderTable.Received(1).UpsertRow(Arg.Is(updated => + updated.GrainId == entry.GrainId + && updated.ReminderName == entry.ReminderName + && updated.LastFireUtc != null + && updated.NextDueUtc != null + && updated.NextDueUtc > now + && updated.Period == entry.Period + && updated.CronExpression == entry.CronExpression)); + await jobManager.Received(1).ScheduleJobAsync(Arg.Any(), Arg.Any()); + var request = Assert.IsType(scheduledRequest); + Assert.Equal("advanced-reminder:interval", request.JobName); + Assert.Equal(dispatcherGrainId, request.Target); + } + + [Fact] + public async Task ProcessDueReminderAsync_ForCronReminder_FiresAndReschedulesWithZeroPeriod() + { + var now = DateTime.UtcNow; + var entry = new ReminderEntry + { + GrainId = GrainId.Create("test", "cron-due"), + ReminderName = "cron", + StartAt = now.AddMinutes(-10), + NextDueUtc = now.AddMinutes(-1), + Period = TimeSpan.Zero, + CronExpression = "*/5 * * * *", + Action = MissedReminderAction.FireImmediately, + ETag = "etag-cron", + }; + + var reminderTable = Substitute.For(); + reminderTable.ReadRow(entry.GrainId, entry.ReminderName).Returns(Task.FromResult(entry)); + reminderTable.UpsertRow(Arg.Any()).Returns("etag-cron-2"); + + var remindable = Substitute.For(); + var dispatcherGrainId = GrainId.Create("sys", "durable-reminder-dispatcher"); + var grainFactory = Substitute.For(); + var dispatcher = CreateDispatcherGrain(dispatcherGrainId); + grainFactory.GetGrain(entry.GrainId).Returns(remindable); + grainFactory.GetGrain(entry.GrainId.ToString(), null) + .Returns(dispatcher); + + var jobManager = Substitute.For(); + ScheduleJobRequest? scheduledRequest = null; + jobManager.ScheduleJobAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var request = callInfo.Arg(); + scheduledRequest = request; + return Task.FromResult(CreateDurableJob(request)); + }); + + var service = CreateService(reminderTable, jobManager: jobManager, grainFactory: grainFactory); + + await service.ProcessDueReminderAsync(entry.GrainId, entry.ReminderName, expectedETag: entry.ETag, CancellationToken.None); + + AssertReminderReceived(remindable, "cron", status => + { + Assert.Equal(entry.StartAt, status.FirstTickTime); + Assert.Equal(TimeSpan.Zero, status.Period); + Assert.True(status.CurrentTickTime >= now); + }); + await reminderTable.Received(1).UpsertRow(Arg.Is(updated => + updated.GrainId == entry.GrainId + && updated.ReminderName == entry.ReminderName + && updated.LastFireUtc != null + && updated.NextDueUtc != null + && updated.NextDueUtc > now + && updated.Period == TimeSpan.Zero + && updated.CronExpression == entry.CronExpression)); + await jobManager.Received(1).ScheduleJobAsync(Arg.Any(), Arg.Any()); + var request = Assert.IsType(scheduledRequest); + Assert.Equal("advanced-reminder:cron", request.JobName); + Assert.Equal(dispatcherGrainId, request.Target); + } + + [Fact] + public void TryGetReminderMetadata_ReturnsExpectedValues() + { + var grainId = GrainId.Create("test", "metadata"); + var metadata = new Dictionary(StringComparer.Ordinal) + { + ["grain-id"] = grainId.ToString(), + ["reminder-name"] = "r", + ["etag"] = "etag-1", + }; + + var result = AdvancedReminderService.TryGetReminderMetadata(metadata, out var parsedGrainId, out var reminderName, out var eTag); + + Assert.True(result); + Assert.Equal(grainId, parsedGrainId); + Assert.Equal("r", reminderName); + Assert.Equal("etag-1", eTag); + } + + [Fact] + public void TryGetReminderMetadata_ReturnsFalseWhenRequiredFieldsAreMissing() + { + var metadata = new Dictionary(StringComparer.Ordinal) + { + ["grain-id"] = GrainId.Create("test", "metadata").ToString(), + }; + + var result = AdvancedReminderService.TryGetReminderMetadata(metadata, out _, out _, out _); + + Assert.False(result); + } + + [Fact] + public void TryGetReminderMetadata_ReturnsFalseForNullOrWhitespaceName() + { + var grainId = GrainId.Create("test", "metadata"); + var metadata = new Dictionary(StringComparer.Ordinal) + { + ["grain-id"] = grainId.ToString(), + ["reminder-name"] = " ", + }; + + Assert.False(AdvancedReminderService.TryGetReminderMetadata(null, out _, out _, out _)); + Assert.False(AdvancedReminderService.TryGetReminderMetadata(metadata, out _, out _, out _)); + } + + private static AdvancedReminderService CreateService( + Orleans.AdvancedReminders.IReminderTable reminderTable, + AdvancedReminderOptions? options = null, + ILocalDurableJobManager? jobManager = null, + IGrainFactory? grainFactory = null, + JobShardManager? jobShardManager = null, + TimeProvider? timeProvider = null) + { + jobManager ??= Substitute.For(); + grainFactory ??= Substitute.For(); + jobShardManager ??= new TestJobShardManager(); + return new AdvancedReminderService( + reminderTable, + jobManager, + jobShardManager, + grainFactory, + Options.Create(options ?? new AdvancedReminderOptions()), + NullLogger.Instance, + timeProvider ?? TimeProvider.System); + } + + private static DurableJob CreateDurableJob(ScheduleJobRequest request) + => new() + { + Id = Guid.NewGuid().ToString("N"), + Name = request.JobName, + DueTime = request.DueTime, + TargetGrainId = request.Target, + ShardId = "test-shard", + Metadata = request.Metadata, + }; + + private static IAdvancedReminderDispatcherGrain CreateDispatcherGrain(GrainId grainId) + => new TestAdvancedReminderDispatcherGrain(grainId); + + private static ReminderEntry Clone( + ReminderEntry entry, + string? etag = null, + string? cronExpression = null, + string? cronTimeZoneId = null, + DateTime? nextDueUtc = null, + DateTime? lastFireUtc = null) + => new() + { + GrainId = entry.GrainId, + ReminderName = entry.ReminderName, + StartAt = entry.StartAt, + Period = entry.Period, + ETag = etag ?? entry.ETag, + CronExpression = cronExpression ?? entry.CronExpression, + CronTimeZoneId = cronTimeZoneId ?? entry.CronTimeZoneId, + NextDueUtc = nextDueUtc ?? entry.NextDueUtc, + LastFireUtc = lastFireUtc ?? entry.LastFireUtc, + Priority = entry.Priority, + Action = entry.Action, + }; + + private static void AssertReminderReceived(AdvancedRemindable remindable, string reminderName, Action assertStatus) + { + var receiveCalls = remindable.ReceivedCalls() + .Where(call => call.GetMethodInfo().Name == nameof(AdvancedRemindable.ReceiveReminder)) + .ToArray(); + + var call = Assert.Single(receiveCalls); + var arguments = call.GetArguments(); + Assert.Equal(reminderName, Assert.IsType(arguments[0])); + assertStatus(Assert.IsType(arguments[1])); + } + + private sealed class MutableReminderTable : Orleans.AdvancedReminders.IReminderTable + { + private ReminderEntry? _current; + + public MutableReminderTable(ReminderEntry? current) => _current = current is null ? null : Clone(current); + + public int UpsertCount { get; private set; } + + public List<(string ETag, bool Removed)> RemoveAttempts { get; } = new(); + + public void ReplaceCurrent(ReminderEntry? entry) => _current = entry is null ? null : Clone(entry); + + public void DeleteCurrent() => _current = null; + + public Task ReadRows(GrainId grainId) + => Task.FromResult(_current is not null && _current.GrainId == grainId + ? new ReminderTableData([Clone(_current)]) + : new ReminderTableData()); + + public Task ReadRows(uint begin, uint end) + => Task.FromResult(_current is null ? new ReminderTableData() : new ReminderTableData([Clone(_current)])); + + public Task ReadRow(GrainId grainId, string reminderName) + => Task.FromResult(_current is not null && _current.GrainId == grainId && _current.ReminderName == reminderName + ? Clone(_current) + : null!); + + public Task UpsertRow(ReminderEntry entry) + { + UpsertCount++; + var updated = Clone(entry, etag: $"{entry.ETag}-next"); + _current = updated; + return Task.FromResult(updated.ETag); + } + + public Task RemoveRow(GrainId grainId, string reminderName, string eTag) + { + var removed = _current is not null + && _current.GrainId == grainId + && _current.ReminderName == reminderName + && string.Equals(_current.ETag, eTag, StringComparison.Ordinal); + RemoveAttempts.Add((eTag, removed)); + if (removed) + { + _current = null; + } + + return Task.FromResult(removed); + } + + public Task TestOnlyClearTable() + { + _current = null; + return Task.CompletedTask; + } + } + + private sealed class TestJobShardManager() : JobShardManager(SiloAddress.Zero) + { + public override Task> AssignJobShardsAsync(DateTimeOffset maxDueTime, CancellationToken cancellationToken) + => Task.FromResult(new List()); + + public override Task CreateShardAsync(DateTimeOffset minDueTime, DateTimeOffset maxDueTime, IDictionary metadata, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public override Task UnregisterShardAsync(IJobShard shard, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private sealed class CallbackRemindable(Func onReminder) : AdvancedRemindable + { + public List ReceivedStatuses { get; } = new(); + + public async Task ReceiveReminder(string reminderName, AdvancedTickStatus status) + { + ReceivedStatuses.Add(status); + await onReminder(); + } + } + + private sealed class TestAdvancedReminderDispatcherGrain : IAdvancedReminderDispatcherGrain, IGrainBase + { + public TestAdvancedReminderDispatcherGrain(GrainId grainId) + { + var context = Substitute.For(); + context.GrainId.Returns(grainId); + GrainContext = context; + } + + public IGrainContext GrainContext { get; } + + public Task ExecuteJobAsync(IJobRunContext context, CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/test/Orleans.Core.Tests/Orleans.Core.Tests.csproj b/test/Orleans.Core.Tests/Orleans.Core.Tests.csproj index d8a63bdd99..f9ab9cb850 100644 --- a/test/Orleans.Core.Tests/Orleans.Core.Tests.csproj +++ b/test/Orleans.Core.Tests/Orleans.Core.Tests.csproj @@ -6,6 +6,8 @@ + + diff --git a/test/Orleans.Runtime.Internal.Tests/AdvancedRemindersTest/AdvancedReminderTableTestsBase.cs b/test/Orleans.Runtime.Internal.Tests/AdvancedRemindersTest/AdvancedReminderTableTestsBase.cs new file mode 100644 index 0000000000..2ba429fa7d --- /dev/null +++ b/test/Orleans.Runtime.Internal.Tests/AdvancedRemindersTest/AdvancedReminderTableTestsBase.cs @@ -0,0 +1,236 @@ +#nullable enable +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.Runtime; +using Orleans.TestingHost.Utils; +using TestExtensions; +using UnitTests.MembershipTests; +using Xunit; +using AdvancedReminderEntry = Orleans.AdvancedReminders.ReminderEntry; +using AdvancedReminderTableData = Orleans.AdvancedReminders.ReminderTableData; +using AdvancedReminderTable = Orleans.AdvancedReminders.IReminderTable; +using ReminderPriority = Orleans.AdvancedReminders.Runtime.ReminderPriority; +using MissedReminderAction = Orleans.AdvancedReminders.Runtime.MissedReminderAction; + +namespace UnitTests.AdvancedRemindersTest; + +[Collection(TestEnvironmentFixture.DefaultCollection)] +public abstract class AdvancedReminderTableTestsBase : IAsyncLifetime, IClassFixture +{ + protected readonly TestEnvironmentFixture ClusterFixture; + protected readonly ILoggerFactory loggerFactory; + protected readonly IOptions clusterOptions; + protected readonly ConnectionStringFixture connectionStringFixture; + + private readonly AdvancedReminderTable remindersTable; + + protected const string testDatabaseName = "OrleansAdvancedReminderTest"; + + protected AdvancedReminderTableTestsBase(ConnectionStringFixture fixture, TestEnvironmentFixture clusterFixture, LoggerFilterOptions filters) + { + connectionStringFixture = fixture; + fixture.InitializeConnectionStringAccessor(GetConnectionString); + loggerFactory = TestingUtils.CreateDefaultLoggerFactory($"{GetType()}.log", filters); + ClusterFixture = clusterFixture; + + var serviceId = Guid.NewGuid() + "/advanced-reminders"; + var clusterId = "test-" + serviceId + "/cluster"; + clusterOptions = Options.Create(new ClusterOptions { ClusterId = clusterId, ServiceId = serviceId }); + + remindersTable = CreateRemindersTable(); + } + + public virtual async Task InitializeAsync() + { + using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + await remindersTable.StartAsync(cancellation.Token); + } + + public virtual async Task DisposeAsync() + { + if (SiloInstanceTableTestConstants.DeleteEntriesAfterTest) + { + await remindersTable.TestOnlyClearTable(); + } + } + + protected abstract AdvancedReminderTable CreateRemindersTable(); + protected abstract Task GetConnectionString(); + + protected virtual string? GetAdoInvariant() => null; + + [SkippableFact] + public async Task RemindersTable_DurableSimpleRoundTrip() => await ReminderSimple(); + + [SkippableFact] + public async Task RemindersTable_DurableParallelUpsert() => await RemindersParallelUpsert(); + + [SkippableFact] + public async Task RemindersTable_DurableRangeQueries() => await RemindersRange(iterations: 128); + + protected async Task RemindersParallelUpsert() + { + var upserts = await Task.WhenAll(Enumerable.Range(0, 5).Select(i => + { + var reminder = CreateReminder(MakeTestGrainReference(), i.ToString()); + return Task.WhenAll(Enumerable.Range(1, 5).Select(_ => + RetryHelper.RetryOnExceptionAsync(5, RetryOperation.Sigmoid, () => remindersTable.UpsertRow(reminder)))); + })); + + Assert.DoesNotContain(upserts, values => values.Distinct().Count() != 5); + } + + protected async Task ReminderSimple() + { + var reminder = CreateReminder(MakeTestGrainReference(), "foo/bar\\#b_a_z?"); + await remindersTable.UpsertRow(reminder); + + var readReminder = await remindersTable.ReadRow(reminder.GrainId, reminder.ReminderName); + Assert.NotNull(readReminder); + + var previousETag = reminder.ETag = readReminder.ETag; + + Assert.Equal(readReminder.GrainId, reminder.GrainId); + Assert.Equal(readReminder.Period, reminder.Period); + Assert.Equal(readReminder.ReminderName, reminder.ReminderName); + Assert.Equal(readReminder.StartAt, reminder.StartAt); + Assert.Equal(ReminderPriority.Normal, readReminder.Priority); + Assert.Equal(MissedReminderAction.Skip, readReminder.Action); + + reminder.ETag = await remindersTable.UpsertRow(reminder); + + Assert.False(await remindersTable.RemoveRow(reminder.GrainId, reminder.ReminderName, previousETag)); + Assert.False(await remindersTable.RemoveRow(reminder.GrainId, "missing", reminder.ETag)); + Assert.True(await remindersTable.RemoveRow(reminder.GrainId, reminder.ReminderName, reminder.ETag)); + Assert.False(await remindersTable.RemoveRow(reminder.GrainId, reminder.ReminderName, reminder.ETag)); + } + + protected async Task ReminderCronRoundTrip() + { + var reminder = CreateReminder(MakeTestGrainReference(), "cron_roundtrip"); + reminder.CronExpression = "0 */5 * * * *"; + reminder.Period = TimeSpan.Zero; + + await remindersTable.UpsertRow(reminder); + var readReminder = await remindersTable.ReadRow(reminder.GrainId, reminder.ReminderName); + + Assert.NotNull(readReminder); + Assert.Equal(reminder.CronExpression, readReminder.CronExpression); + Assert.Equal(TimeSpan.Zero, readReminder.Period); + } + + protected async Task ReminderAdaptiveFieldsRoundTrip() + { + var reminder = CreateReminder(MakeTestGrainReference(), "adaptive_roundtrip"); + reminder.CronExpression = "0 */10 * * * *"; + reminder.Period = TimeSpan.Zero; + reminder.NextDueUtc = DateTime.UtcNow.AddMinutes(10); + reminder.LastFireUtc = DateTime.UtcNow.AddMinutes(-2); + reminder.Priority = ReminderPriority.High; + reminder.Action = MissedReminderAction.FireImmediately; + + await remindersTable.UpsertRow(reminder); + var readReminder = await remindersTable.ReadRow(reminder.GrainId, reminder.ReminderName); + + Assert.NotNull(readReminder); + Assert.Equal(reminder.CronExpression, readReminder.CronExpression); + AssertTimestampClose(reminder.NextDueUtc, readReminder.NextDueUtc); + AssertTimestampClose(reminder.LastFireUtc, readReminder.LastFireUtc); + Assert.Equal(reminder.Priority, readReminder.Priority); + Assert.Equal(reminder.Action, readReminder.Action); + } + + protected async Task ReminderCronTimeZoneRoundTrip() + { + var reminder = CreateReminder(MakeTestGrainReference(), "cron_timezone_roundtrip"); + reminder.CronExpression = "0 */5 * * * *"; + reminder.CronTimeZoneId = "Europe/Warsaw"; + reminder.Period = TimeSpan.Zero; + + await remindersTable.UpsertRow(reminder); + var readReminder = await remindersTable.ReadRow(reminder.GrainId, reminder.ReminderName); + + Assert.NotNull(readReminder); + Assert.Equal(reminder.CronExpression, readReminder.CronExpression); + Assert.Equal(reminder.CronTimeZoneId, readReminder.CronTimeZoneId); + + reminder.ETag = readReminder.ETag; + reminder.CronExpression = "0 */10 * * * *"; + reminder.CronTimeZoneId = "America/New_York"; + await remindersTable.UpsertRow(reminder); + + readReminder = await remindersTable.ReadRow(reminder.GrainId, reminder.ReminderName); + Assert.NotNull(readReminder); + Assert.Equal(reminder.CronExpression, readReminder.CronExpression); + Assert.Equal(reminder.CronTimeZoneId, readReminder.CronTimeZoneId); + } + + protected async Task RemindersRange(int iterations = 1000) + { + await Task.WhenAll(Enumerable.Range(1, iterations).Select(async i => + { + var grainRef = MakeTestGrainReference(); + await RetryHelper.RetryOnExceptionAsync(10, RetryOperation.Sigmoid, async () => + { + await remindersTable.UpsertRow(CreateReminder(grainRef, i.ToString())); + return 0; + }); + })); + + var rows = await remindersTable.ReadRows(0, uint.MaxValue); + Assert.Equal(iterations, rows.Reminders.Count); + + rows = await remindersTable.ReadRows(0, 0); + Assert.Equal(iterations, rows.Reminders.Count); + + var reminderHashes = rows.Reminders.Select(r => r.GrainId.GetUniformHashCode()).ToArray(); + await Task.WhenAll(Enumerable.Range(0, iterations).Select(_ => + TestRemindersHashInterval(remindersTable, + (uint)Random.Shared.Next(int.MinValue, int.MaxValue), + (uint)Random.Shared.Next(int.MinValue, int.MaxValue), + reminderHashes))); + } + + private static async Task TestRemindersHashInterval(AdvancedReminderTable reminderTable, uint beginHash, uint endHash, uint[] reminderHashes) + { + var rowsTask = reminderTable.ReadRows(beginHash, endHash); + var expectedHashes = beginHash < endHash + ? reminderHashes.Where(r => r > beginHash && r <= endHash) + : reminderHashes.Where(r => r > beginHash || r <= endHash); + + var expectedSet = new HashSet(expectedHashes); + var returnedSet = new HashSet((await rowsTask).Reminders.Select(r => r.GrainId.GetUniformHashCode())); + + Assert.True(returnedSet.SetEquals(expectedSet)); + } + + private static AdvancedReminderEntry CreateReminder(GrainId grainId, string reminderName) + { + var now = DateTime.UtcNow; + now = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, DateTimeKind.Utc); + return new AdvancedReminderEntry + { + GrainId = grainId, + ReminderName = reminderName, + Period = TimeSpan.FromMinutes(1), + StartAt = now, + }; + } + + private static GrainId MakeTestGrainReference() => LegacyGrainId.GetGrainId(12345, Guid.NewGuid(), "foo/bar\\#baz?"); + + private static void AssertTimestampClose(DateTime? expected, DateTime? actual) + { + Assert.Equal(expected.HasValue, actual.HasValue); + if (!expected.HasValue) + { + return; + } + + var difference = (expected.Value - actual!.Value).Duration(); + Assert.True( + difference <= TimeSpan.FromSeconds(1), + $"Expected timestamps to be within 1 second. Expected: {expected.Value:O}, Actual: {actual.Value:O}, Difference: {difference}."); + } +} diff --git a/test/Orleans.Runtime.Internal.Tests/Orleans.Runtime.Internal.Tests.csproj b/test/Orleans.Runtime.Internal.Tests/Orleans.Runtime.Internal.Tests.csproj index 496f644e38..1097f7c6c5 100644 --- a/test/Orleans.Runtime.Internal.Tests/Orleans.Runtime.Internal.Tests.csproj +++ b/test/Orleans.Runtime.Internal.Tests/Orleans.Runtime.Internal.Tests.csproj @@ -12,6 +12,7 @@ + @@ -26,4 +27,3 @@ -