From 26265163ef03e822178553a1969718d03ced0aad Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 6 Mar 2019 23:42:01 -0600 Subject: [PATCH 01/29] Working on new unified messaging abstraction --- ...undatio - Backup.Extensions.Hosting.csproj | 15 ++++ .../Foundatio.Extensions.Hosting.csproj | 4 +- src/Foundatio/Messaging/IMessagePublisher.cs | 12 +-- src/Foundatio/Messaging/IMessageSubscriber.cs | 89 ++++++++++++++++++- src/Foundatio/Messaging/MessageBusBase.cs | 24 +++-- src/Foundatio/Messaging/NullMessageBus.cs | 8 +- 6 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 src/Foundatio.Extensions.Hosting/Foundatio - Backup.Extensions.Hosting.csproj diff --git a/src/Foundatio.Extensions.Hosting/Foundatio - Backup.Extensions.Hosting.csproj b/src/Foundatio.Extensions.Hosting/Foundatio - Backup.Extensions.Hosting.csproj new file mode 100644 index 000000000..ecad27af2 --- /dev/null +++ b/src/Foundatio.Extensions.Hosting/Foundatio - Backup.Extensions.Hosting.csproj @@ -0,0 +1,15 @@ + + + true + net6.0;net5.0 + + + + + + + + + + + diff --git a/src/Foundatio.Extensions.Hosting/Foundatio.Extensions.Hosting.csproj b/src/Foundatio.Extensions.Hosting/Foundatio.Extensions.Hosting.csproj index ecad27af2..94f3dfb4b 100644 --- a/src/Foundatio.Extensions.Hosting/Foundatio.Extensions.Hosting.csproj +++ b/src/Foundatio.Extensions.Hosting/Foundatio.Extensions.Hosting.csproj @@ -1,10 +1,10 @@  true - net6.0;net5.0 - + + diff --git a/src/Foundatio/Messaging/IMessagePublisher.cs b/src/Foundatio/Messaging/IMessagePublisher.cs index cfb7078fb..2c98c9db0 100644 --- a/src/Foundatio/Messaging/IMessagePublisher.cs +++ b/src/Foundatio/Messaging/IMessagePublisher.cs @@ -4,16 +4,18 @@ namespace Foundatio.Messaging { public interface IMessagePublisher { - Task PublishAsync(Type messageType, object message, MessageOptions options = null, CancellationToken cancellationToken = default); + // extensions for easily publishing just the raw message body, message settings populated from conventions + Task PublishAsync(IMessage message); } public static class MessagePublisherExtensions { - public static Task PublishAsync(this IMessagePublisher publisher, T message, MessageOptions options = null) where T : class { - return publisher.PublishAsync(typeof(T), message, options); + public static Task PublishAsync(this IMessagePublisher publisher, T message) { + var m = new Message(); + return publisher.PublishAsync(m); } - public static Task PublishAsync(this IMessagePublisher publisher, T message, TimeSpan delay, CancellationToken cancellationToken = default) where T : class { - return publisher.PublishAsync(typeof(T), message, new MessageOptions { DeliveryDelay = delay }, cancellationToken); + public static Task PublishAsync(this IMessagePublisher publisher, T message, TimeSpan? delay = null) where T : class { + return publisher.PublishAsync(typeof(T), message, delay); } } } diff --git a/src/Foundatio/Messaging/IMessageSubscriber.cs b/src/Foundatio/Messaging/IMessageSubscriber.cs index e7517d958..731afdbc9 100644 --- a/src/Foundatio/Messaging/IMessageSubscriber.cs +++ b/src/Foundatio/Messaging/IMessageSubscriber.cs @@ -1,13 +1,100 @@ using System; using System.Threading; using System.Threading.Tasks; +using Foundatio.Utility; namespace Foundatio.Messaging { + // conventions to determine message queue based on message type + // conventions for determining default time to live, retries, and deadletter behavior based on message type + // pub/sub you would publish messages and each subscriber would automatically get a unique subscriber id and the messages would go to all of them + // worker you would publish messages and each subscriber would use the same subscriber id and the messages would get round robin'd + // need to figure out how we would handle rpc request / response. need to be able to subscribe for a single message public interface IMessageSubscriber { - Task SubscribeAsync(Func handler, CancellationToken cancellationToken = default) where T : class; + // there will be extensions that allow subscribing via generic message type parameters with and without the message context wrapper + Task SubscribeAsync(Func handler, IMessageSubscriptionOptions options); + } + + public interface IMessageSubscriptionOptions { + // message type for the subscription + Type MessageType { get; } + // topic to use for subscription, if left blank it will be calculated using conventions from the message type + string Topic { get; set; } + // subscription id, for worker queues use the same subscription id for all subscriptions and the messages will be round robin'd + string SubscriptionId { get; set; } + // how many messages should be fetched at a time + int PrefetchSize { get; set; } + // what priority level messages should this subscription receive + int Priority { get; set; } + // how long should the message remain in flight before timing out + TimeSpan TimeToLive { get; set; } + // how messages should be acknowledged + AcknowledgementStrategy AcknowledgementStrategy { get; set; } + } + + public enum AcknowledgementStrategy { + Manual, // consumer needs to do it + Automatic, // auto acknowledge after handler completes successfully and auto reject if handler throws + FireAndForget // acknowledge before handler runs + } + + public interface IMessageSubscription : IDisposable { + // the subscription id + string Id { get; } + // topic that this subscription is listening to + string Topic { get; } + // when was the message created + DateTime CreatedUtc { get; } + } + + public interface IMessage : IMessage where T: class { + T Body { get; } } + public interface IMessage { + // correlation id used in logging + string CorrelationId { get; } + // used for rpc (request/reply) + string ReplyTo { get; } + // message priority + int Priority { get; } + // topic the message will be sent to + string Topic { get; } + // message type, will be converted to string and stored with the message for deserialization + Type MessageType { get; } + // message body + object GetBody(); + // when the message should expire + DateTime ExpiresAtUtc { get; } + // additional data to store with the message + DataDictionary Data { get; } + } + + public interface IMessageContext : IMessage, IMessagePublisher, IDisposable { + // message id + string Id { get; } + // when the message was originally created + DateTime CreatedUtc { get; } + // number of times this message has been delivered + int DeliveryCount { get; } + // acknowledge receipt of message and delete it + Task AcknowledgeAsync(); + // reject the message as not having been successfully processed + Task RejectAsync(); + CancellationToken CancellationToken { get; } + } + + public interface IMessageContext : IMessageContext, IMessage where T: class {} + public static class MessageBusExtensions { + public static async Task SubscribeAsync(this IMessageSubscriber subscriber, Func handler, CancellationToken cancellationToken = default) where T : class { + if (cancellationToken.IsCancellationRequested) + return; + + var result = await subscriber.SubscribeAsync((msg, token) => handler(msg, token)); + if (cancellationToken != CancellationToken.None) + cancellationToken.Register(() => ThreadPool.QueueUserWorkItem(s => result?.Dispose())); + } + public static Task SubscribeAsync(this IMessageSubscriber subscriber, Func handler, CancellationToken cancellationToken = default) where T : class { return subscriber.SubscribeAsync((msg, token) => handler(msg), cancellationToken); } diff --git a/src/Foundatio/Messaging/MessageBusBase.cs b/src/Foundatio/Messaging/MessageBusBase.cs index ba1d3a104..2c42d4148 100644 --- a/src/Foundatio/Messaging/MessageBusBase.cs +++ b/src/Foundatio/Messaging/MessageBusBase.cs @@ -80,9 +80,10 @@ protected virtual Type GetMappedMessageType(string messageType) { } protected virtual Task EnsureTopicSubscriptionAsync(CancellationToken cancellationToken) => Task.CompletedTask; - protected virtual Task SubscribeImplAsync(Func handler, CancellationToken cancellationToken) where T : class { + protected virtual Task SubscribeImplAsync(Func handler) where T : class { + var messageSubscription = new MessageSubscription(); var subscriber = new Subscriber { - CancellationToken = cancellationToken, + CancellationToken = messageSubscription.CancellationToken, Type = typeof(T), Action = (message, token) => { if (message is not T) { @@ -91,21 +92,22 @@ protected virtual Task SubscribeImplAsync(Func ha return Task.CompletedTask; } - return handler((T)message, cancellationToken); + return handler((T)message, messageSubscription.CancellationToken); } }; if (!_subscribers.TryAdd(subscriber.Id, subscriber) && _logger.IsEnabled(LogLevel.Error)) _logger.LogError("Unable to add subscriber {SubscriberId}", subscriber.Id); - return Task.CompletedTask; + return Task.FromResult(messageSubscription); } - public async Task SubscribeAsync(Func handler, CancellationToken cancellationToken = default) where T : class { + public async Task SubscribeAsync(Func handler) where T : class { if (_logger.IsEnabled(LogLevel.Trace)) _logger.LogTrace("Adding subscriber for {MessageType}.", typeof(T).FullName); await EnsureTopicSubscriptionAsync(cancellationToken).AnyContext(); - await SubscribeImplAsync(handler, cancellationToken).AnyContext(); + var messageSubscription = await SubscribeImplAsync(handler).AnyContext(); + return messageSubscription; } protected List GetMessageSubscribers(IMessage message) { @@ -285,4 +287,14 @@ public bool IsAssignableFrom(Type type) { } } } + + public class MessageSubscription : IMessageSubscription { + private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + + public CancellationToken CancellationToken => _cancellationTokenSource.Token; + + public void Dispose() { + _cancellationTokenSource.Cancel(); + } + } } \ No newline at end of file diff --git a/src/Foundatio/Messaging/NullMessageBus.cs b/src/Foundatio/Messaging/NullMessageBus.cs index a7bce4390..ff656ce30 100644 --- a/src/Foundatio/Messaging/NullMessageBus.cs +++ b/src/Foundatio/Messaging/NullMessageBus.cs @@ -10,10 +10,14 @@ public Task PublishAsync(Type messageType, object message, MessageOptions option return Task.CompletedTask; } - public Task SubscribeAsync(Func handler, CancellationToken cancellationToken = default) where T : class { - return Task.CompletedTask; + public Task SubscribeAsync(Func handler) where T : class { + return Task.FromResult(new NullMessageSubscription()); } public void Dispose() {} } + + public class NullMessageSubscription : IMessageSubscription { + public void Dispose() {} + } } From 45487ca373e981fd86a7d6bc7444fe8ae4add6d6 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 6 Mar 2019 23:45:18 -0600 Subject: [PATCH 02/29] Move IMessage interface to publisher for easier review --- src/Foundatio/Messaging/IMessagePublisher.cs | 24 +++++++++++++++++++ src/Foundatio/Messaging/IMessageSubscriber.cs | 23 ------------------ 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/Foundatio/Messaging/IMessagePublisher.cs b/src/Foundatio/Messaging/IMessagePublisher.cs index 2c98c9db0..4631ba7e8 100644 --- a/src/Foundatio/Messaging/IMessagePublisher.cs +++ b/src/Foundatio/Messaging/IMessagePublisher.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Foundatio.Utility; namespace Foundatio.Messaging { public interface IMessagePublisher { @@ -8,6 +9,29 @@ public interface IMessagePublisher { Task PublishAsync(IMessage message); } + public interface IMessage : IMessage where T: class { + T Body { get; } + } + + public interface IMessage { + // correlation id used in logging + string CorrelationId { get; } + // used for rpc (request/reply) + string ReplyTo { get; } + // message priority + int Priority { get; } + // topic the message will be sent to + string Topic { get; } + // message type, will be converted to string and stored with the message for deserialization + Type MessageType { get; } + // message body + object GetBody(); + // when the message should expire + DateTime ExpiresAtUtc { get; } + // additional data to store with the message + DataDictionary Data { get; } + } + public static class MessagePublisherExtensions { public static Task PublishAsync(this IMessagePublisher publisher, T message) { var m = new Message(); diff --git a/src/Foundatio/Messaging/IMessageSubscriber.cs b/src/Foundatio/Messaging/IMessageSubscriber.cs index 731afdbc9..0bc5d677a 100644 --- a/src/Foundatio/Messaging/IMessageSubscriber.cs +++ b/src/Foundatio/Messaging/IMessageSubscriber.cs @@ -46,29 +46,6 @@ public interface IMessageSubscription : IDisposable { DateTime CreatedUtc { get; } } - public interface IMessage : IMessage where T: class { - T Body { get; } - } - - public interface IMessage { - // correlation id used in logging - string CorrelationId { get; } - // used for rpc (request/reply) - string ReplyTo { get; } - // message priority - int Priority { get; } - // topic the message will be sent to - string Topic { get; } - // message type, will be converted to string and stored with the message for deserialization - Type MessageType { get; } - // message body - object GetBody(); - // when the message should expire - DateTime ExpiresAtUtc { get; } - // additional data to store with the message - DataDictionary Data { get; } - } - public interface IMessageContext : IMessage, IMessagePublisher, IDisposable { // message id string Id { get; } From 0ca70c5e6f553214e603968b9ac0427200a4458b Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 7 Mar 2019 00:15:20 -0600 Subject: [PATCH 03/29] Add message queue options interface --- src/Foundatio/Messaging/IMessagePublisher.cs | 22 +++++++++++++++---- src/Foundatio/Messaging/IMessageSubscriber.cs | 22 ++++++------------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/Foundatio/Messaging/IMessagePublisher.cs b/src/Foundatio/Messaging/IMessagePublisher.cs index 4631ba7e8..cf9f8d218 100644 --- a/src/Foundatio/Messaging/IMessagePublisher.cs +++ b/src/Foundatio/Messaging/IMessagePublisher.cs @@ -19,18 +19,32 @@ public interface IMessage { // used for rpc (request/reply) string ReplyTo { get; } // message priority - int Priority { get; } - // topic the message will be sent to - string Topic { get; } + int? Priority { get; } // message type, will be converted to string and stored with the message for deserialization Type MessageType { get; } // message body object GetBody(); // when the message should expire - DateTime ExpiresAtUtc { get; } + DateTime? ExpiresAtUtc { get; } // additional data to store with the message DataDictionary Data { get; } } + + public delegate IMessageQueueOptions GetMessageQueueOptions(IMessage message); + + public interface IMessageQueueOptions { + bool IsDurable { get; } + string QueueName { get; set; } + TimeSpan DefaultTimeToLive { get; set; } + AcknowledgementStrategy AcknowledgementStrategy { get; set; } + // need something for how to handle retries and deadletter + } + + public enum AcknowledgementStrategy { + Manual, // consumer needs to do it + Automatic, // auto acknowledge after handler completes successfully and auto reject if handler throws + FireAndForget // acknowledge before handler runs + } public static class MessagePublisherExtensions { public static Task PublishAsync(this IMessagePublisher publisher, T message) { diff --git a/src/Foundatio/Messaging/IMessageSubscriber.cs b/src/Foundatio/Messaging/IMessageSubscriber.cs index 0bc5d677a..4b0b961ce 100644 --- a/src/Foundatio/Messaging/IMessageSubscriber.cs +++ b/src/Foundatio/Messaging/IMessageSubscriber.cs @@ -17,31 +17,23 @@ public interface IMessageSubscriber { public interface IMessageSubscriptionOptions { // message type for the subscription Type MessageType { get; } - // topic to use for subscription, if left blank it will be calculated using conventions from the message type - string Topic { get; set; } // subscription id, for worker queues use the same subscription id for all subscriptions and the messages will be round robin'd string SubscriptionId { get; set; } // how many messages should be fetched at a time - int PrefetchSize { get; set; } + int? PrefetchSize { get; set; } // what priority level messages should this subscription receive - int Priority { get; set; } + int? Priority { get; set; } // how long should the message remain in flight before timing out - TimeSpan TimeToLive { get; set; } + TimeSpan? TimeToLive { get; set; } // how messages should be acknowledged - AcknowledgementStrategy AcknowledgementStrategy { get; set; } - } - - public enum AcknowledgementStrategy { - Manual, // consumer needs to do it - Automatic, // auto acknowledge after handler completes successfully and auto reject if handler throws - FireAndForget // acknowledge before handler runs + AcknowledgementStrategy? AcknowledgementStrategy { get; set; } } public interface IMessageSubscription : IDisposable { - // the subscription id + // subscription id string Id { get; } - // topic that this subscription is listening to - string Topic { get; } + // name of the queue that this subscription is listening to + string QueueName { get; } // when was the message created DateTime CreatedUtc { get; } } From bef031e6d0129fd3b7fb2cb36e2dccf4cd7c7148 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 7 Mar 2019 00:50:27 -0600 Subject: [PATCH 04/29] Remove priority for now, can be added later --- src/Foundatio/Messaging/IMessageBus.cs | 8 ++++++++ src/Foundatio/Messaging/IMessagePublisher.cs | 16 +++++++++++----- src/Foundatio/Messaging/IMessageSubscriber.cs | 15 --------------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/Foundatio/Messaging/IMessageBus.cs b/src/Foundatio/Messaging/IMessageBus.cs index 95463bf02..132c1cb17 100644 --- a/src/Foundatio/Messaging/IMessageBus.cs +++ b/src/Foundatio/Messaging/IMessageBus.cs @@ -2,6 +2,14 @@ using Foundatio.Utility; namespace Foundatio.Messaging { + // work items go away, queues go away. everything consolidated down to just messaging + // will be easy to handle random messages like you can with work items currently + // conventions to determine message queue based on message type + // conventions for determining default time to live, retries, and deadletter behavior based on message type, whether its a worker type or not + // pub/sub you would publish messages and each subscriber would automatically get a unique subscriber id and the messages would go to all of them + // worker you would publish messages and each subscriber would use the same subscriber id and the messages would get round robin'd + // need to figure out how we would handle rpc request / response. need to be able to subscribe for a single message + public interface IMessageBus : IMessagePublisher, IMessageSubscriber, IDisposable {} public class MessageOptions { diff --git a/src/Foundatio/Messaging/IMessagePublisher.cs b/src/Foundatio/Messaging/IMessagePublisher.cs index cf9f8d218..d1403bf88 100644 --- a/src/Foundatio/Messaging/IMessagePublisher.cs +++ b/src/Foundatio/Messaging/IMessagePublisher.cs @@ -18,8 +18,6 @@ public interface IMessage { string CorrelationId { get; } // used for rpc (request/reply) string ReplyTo { get; } - // message priority - int? Priority { get; } // message type, will be converted to string and stored with the message for deserialization Type MessageType { get; } // message body @@ -30,12 +28,20 @@ public interface IMessage { DataDictionary Data { get; } } - public delegate IMessageQueueOptions GetMessageQueueOptions(IMessage message); + public delegate IMessageQueueOptions GetMessageQueueOptions(Type messageType); public interface IMessageQueueOptions { - bool IsDurable { get; } + // whether messages will survive transport restart + bool IsDurable { get; set; } + // if worker, subscriptions will default to using the same subscription id and + bool IsWorker { get; set; } + // the name of the queue that the messages are stored in string QueueName { get; set; } - TimeSpan DefaultTimeToLive { get; set; } + // how long should the message remain in flight before timing out + TimeSpan TimeToLive { get; set; } + // how many messages should be fetched at a time + int PrefetchSize { get; set; } + // how messages should be acknowledged AcknowledgementStrategy AcknowledgementStrategy { get; set; } // need something for how to handle retries and deadletter } diff --git a/src/Foundatio/Messaging/IMessageSubscriber.cs b/src/Foundatio/Messaging/IMessageSubscriber.cs index 4b0b961ce..0f60b05ed 100644 --- a/src/Foundatio/Messaging/IMessageSubscriber.cs +++ b/src/Foundatio/Messaging/IMessageSubscriber.cs @@ -4,11 +4,6 @@ using Foundatio.Utility; namespace Foundatio.Messaging { - // conventions to determine message queue based on message type - // conventions for determining default time to live, retries, and deadletter behavior based on message type - // pub/sub you would publish messages and each subscriber would automatically get a unique subscriber id and the messages would go to all of them - // worker you would publish messages and each subscriber would use the same subscriber id and the messages would get round robin'd - // need to figure out how we would handle rpc request / response. need to be able to subscribe for a single message public interface IMessageSubscriber { // there will be extensions that allow subscribing via generic message type parameters with and without the message context wrapper Task SubscribeAsync(Func handler, IMessageSubscriptionOptions options); @@ -17,16 +12,6 @@ public interface IMessageSubscriber { public interface IMessageSubscriptionOptions { // message type for the subscription Type MessageType { get; } - // subscription id, for worker queues use the same subscription id for all subscriptions and the messages will be round robin'd - string SubscriptionId { get; set; } - // how many messages should be fetched at a time - int? PrefetchSize { get; set; } - // what priority level messages should this subscription receive - int? Priority { get; set; } - // how long should the message remain in flight before timing out - TimeSpan? TimeToLive { get; set; } - // how messages should be acknowledged - AcknowledgementStrategy? AcknowledgementStrategy { get; set; } } public interface IMessageSubscription : IDisposable { From 41cf7853d5187f5801a4feabbef58cb2a4064c15 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 7 Mar 2019 01:10:31 -0600 Subject: [PATCH 05/29] More changes --- src/Foundatio/Messaging/IMessageBus.cs | 36 ++++++++++++++++--- src/Foundatio/Messaging/IMessagePublisher.cs | 32 ++--------------- src/Foundatio/Messaging/IMessageSubscriber.cs | 11 +++++- 3 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/Foundatio/Messaging/IMessageBus.cs b/src/Foundatio/Messaging/IMessageBus.cs index 132c1cb17..3f12008f9 100644 --- a/src/Foundatio/Messaging/IMessageBus.cs +++ b/src/Foundatio/Messaging/IMessageBus.cs @@ -9,13 +9,39 @@ namespace Foundatio.Messaging { // pub/sub you would publish messages and each subscriber would automatically get a unique subscriber id and the messages would go to all of them // worker you would publish messages and each subscriber would use the same subscriber id and the messages would get round robin'd // need to figure out how we would handle rpc request / response. need to be able to subscribe for a single message + // still need interceptors / middleware to replace queue behaviors public interface IMessageBus : IMessagePublisher, IMessageSubscriber, IDisposable {} - public class MessageOptions { - public string UniqueId { get; set; } - public string CorrelationId { get; set; } - public TimeSpan? DeliveryDelay { get; set; } - public DataDictionary Properties { get; set; } = new DataDictionary(); + public delegate IMessageQueueOptions GetMessageQueueOptions(Type messageType); + + // this is used to get the message type from the string type that is stored in the message properties + // this by default would be the message types full name, but it could be something completely different + // especially if a message is being read that was published by some other non-dotnet library + public interface IMessageTypeConverter { + string ToString(Type messageType); + Type FromString(string messageType); + } + + public interface IMessageQueueOptions { + // whether messages will survive transport restart + bool IsDurable { get; set; } + // if worker, subscriptions will default to using the same subscription id and + bool IsWorker { get; set; } + // the name of the queue that the messages are stored in + string QueueName { get; set; } + // how long should the message remain in flight before timing out + TimeSpan TimeToLive { get; set; } + // how many messages should be fetched at a time + int PrefetchSize { get; set; } + // how messages should be acknowledged + AcknowledgementStrategy AcknowledgementStrategy { get; set; } + // need something for how to handle retries and deadletter + } + + public enum AcknowledgementStrategy { + Manual, // consumer needs to do it + Automatic, // auto acknowledge after handler completes successfully and auto reject if handler throws + FireAndForget // acknowledge before handler runs } } \ No newline at end of file diff --git a/src/Foundatio/Messaging/IMessagePublisher.cs b/src/Foundatio/Messaging/IMessagePublisher.cs index d1403bf88..b3c357828 100644 --- a/src/Foundatio/Messaging/IMessagePublisher.cs +++ b/src/Foundatio/Messaging/IMessagePublisher.cs @@ -7,6 +7,9 @@ namespace Foundatio.Messaging { public interface IMessagePublisher { // extensions for easily publishing just the raw message body, message settings populated from conventions Task PublishAsync(IMessage message); + + // the methods below will be extension methods that call the method above + Task PublishAsync(T message) where T: class; } public interface IMessage : IMessage where T: class { @@ -27,37 +30,8 @@ public interface IMessage { // additional data to store with the message DataDictionary Data { get; } } - - public delegate IMessageQueueOptions GetMessageQueueOptions(Type messageType); - - public interface IMessageQueueOptions { - // whether messages will survive transport restart - bool IsDurable { get; set; } - // if worker, subscriptions will default to using the same subscription id and - bool IsWorker { get; set; } - // the name of the queue that the messages are stored in - string QueueName { get; set; } - // how long should the message remain in flight before timing out - TimeSpan TimeToLive { get; set; } - // how many messages should be fetched at a time - int PrefetchSize { get; set; } - // how messages should be acknowledged - AcknowledgementStrategy AcknowledgementStrategy { get; set; } - // need something for how to handle retries and deadletter - } - - public enum AcknowledgementStrategy { - Manual, // consumer needs to do it - Automatic, // auto acknowledge after handler completes successfully and auto reject if handler throws - FireAndForget // acknowledge before handler runs - } public static class MessagePublisherExtensions { - public static Task PublishAsync(this IMessagePublisher publisher, T message) { - var m = new Message(); - return publisher.PublishAsync(m); - } - public static Task PublishAsync(this IMessagePublisher publisher, T message, TimeSpan? delay = null) where T : class { return publisher.PublishAsync(typeof(T), message, delay); } diff --git a/src/Foundatio/Messaging/IMessageSubscriber.cs b/src/Foundatio/Messaging/IMessageSubscriber.cs index 0f60b05ed..9ad0e6002 100644 --- a/src/Foundatio/Messaging/IMessageSubscriber.cs +++ b/src/Foundatio/Messaging/IMessageSubscriber.cs @@ -4,9 +4,17 @@ using Foundatio.Utility; namespace Foundatio.Messaging { + // save our subscription handlers in memory so that they can be restored if the connection is interupted + public interface IMessageSubscriber { // there will be extensions that allow subscribing via generic message type parameters with and without the message context wrapper Task SubscribeAsync(Func handler, IMessageSubscriptionOptions options); + + // the methods below will be extension methods that call the method above + Task SubscribeAsync(Func handler) where T: class; + Task SubscribeAsync(Func, Task> handler) where T: class; + Task SubscribeAsync(Action handler) where T: class; + Task SubscribeAsync(Action> handler) where T: class; } public interface IMessageSubscriptionOptions { @@ -18,7 +26,7 @@ public interface IMessageSubscription : IDisposable { // subscription id string Id { get; } // name of the queue that this subscription is listening to - string QueueName { get; } + Type MessageType { get; } // when was the message created DateTime CreatedUtc { get; } } @@ -34,6 +42,7 @@ public interface IMessageContext : IMessage, IMessagePublisher, IDisposable { Task AcknowledgeAsync(); // reject the message as not having been successfully processed Task RejectAsync(); + // used to cancel processing of the current message CancellationToken CancellationToken { get; } } From 34c685979d1a28c20256f7327f493599f00b9eb6 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 7 Mar 2019 15:17:08 -0600 Subject: [PATCH 06/29] Minor --- src/Foundatio/Messaging/IMessagePublisher.cs | 2 ++ src/Foundatio/Messaging/IMessageSubscriber.cs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Foundatio/Messaging/IMessagePublisher.cs b/src/Foundatio/Messaging/IMessagePublisher.cs index b3c357828..06cb1502a 100644 --- a/src/Foundatio/Messaging/IMessagePublisher.cs +++ b/src/Foundatio/Messaging/IMessagePublisher.cs @@ -27,6 +27,8 @@ public interface IMessage { object GetBody(); // when the message should expire DateTime? ExpiresAtUtc { get; } + // when the message should be delivered when using delayed delivery + DateTime? DeliverAtUtc { get; } // additional data to store with the message DataDictionary Data { get; } } diff --git a/src/Foundatio/Messaging/IMessageSubscriber.cs b/src/Foundatio/Messaging/IMessageSubscriber.cs index 9ad0e6002..88e000e04 100644 --- a/src/Foundatio/Messaging/IMessageSubscriber.cs +++ b/src/Foundatio/Messaging/IMessageSubscriber.cs @@ -5,7 +5,7 @@ namespace Foundatio.Messaging { // save our subscription handlers in memory so that they can be restored if the connection is interupted - + public interface IMessageSubscriber { // there will be extensions that allow subscribing via generic message type parameters with and without the message context wrapper Task SubscribeAsync(Func handler, IMessageSubscriptionOptions options); @@ -34,6 +34,8 @@ public interface IMessageSubscription : IDisposable { public interface IMessageContext : IMessage, IMessagePublisher, IDisposable { // message id string Id { get; } + // message subscription id that received the message + string SubscriptionId { get; } // when the message was originally created DateTime CreatedUtc { get; } // number of times this message has been delivered From 1a207c547a7509612a8ad4c392a5376e31db6880 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 23 Mar 2019 14:21:40 -0500 Subject: [PATCH 07/29] Some comments --- src/Foundatio/Messaging/IMessagePublisher.cs | 13 +++++++++++-- src/Foundatio/Messaging/IMessageSubscriber.cs | 9 +++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Foundatio/Messaging/IMessagePublisher.cs b/src/Foundatio/Messaging/IMessagePublisher.cs index 06cb1502a..7a64b29ee 100644 --- a/src/Foundatio/Messaging/IMessagePublisher.cs +++ b/src/Foundatio/Messaging/IMessagePublisher.cs @@ -29,8 +29,8 @@ public interface IMessage { DateTime? ExpiresAtUtc { get; } // when the message should be delivered when using delayed delivery DateTime? DeliverAtUtc { get; } - // additional data to store with the message - DataDictionary Data { get; } + // additional message data to store with the message + DataDictionary Headers { get; } } public static class MessagePublisherExtensions { @@ -39,3 +39,12 @@ public static Task PublishAsync(this IMessagePublisher publisher, T message, } } } + +// unified messaging +// lots of metrics / stats on messaging +// unify job / startup actions +// add tags, key/value pairs to metrics +// auto renewing locks +// get rid of queues / queue job +// simplify the way jobs are run + diff --git a/src/Foundatio/Messaging/IMessageSubscriber.cs b/src/Foundatio/Messaging/IMessageSubscriber.cs index 88e000e04..6f0960784 100644 --- a/src/Foundatio/Messaging/IMessageSubscriber.cs +++ b/src/Foundatio/Messaging/IMessageSubscriber.cs @@ -6,6 +6,13 @@ namespace Foundatio.Messaging { // save our subscription handlers in memory so that they can be restored if the connection is interupted + // should we have a transport interface that we implement and then have more concrete things in the public + // interface classes? + public interface IMessageTransport { + Task SubscribeAsync(Func handler, IMessageSubscriptionOptions options); + Task PublishAsync(IMessage message); + } + public interface IMessageSubscriber { // there will be extensions that allow subscribing via generic message type parameters with and without the message context wrapper Task SubscribeAsync(Func handler, IMessageSubscriptionOptions options); @@ -44,6 +51,8 @@ public interface IMessageContext : IMessage, IMessagePublisher, IDisposable { Task AcknowledgeAsync(); // reject the message as not having been successfully processed Task RejectAsync(); + // used to reply to messages that have a replyto specified + Task ReplyAsync(T message) where T: class; // used to cancel processing of the current message CancellationToken CancellationToken { get; } } From e98bde9541698bc2576e710bc01c48a3555625e5 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 8 Apr 2019 11:33:23 -0500 Subject: [PATCH 08/29] Added some more notes for messaging --- src/Foundatio/Messaging/IMessagePublisher.cs | 6 ++++++ src/Foundatio/Messaging/IMessageSubscriber.cs | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/src/Foundatio/Messaging/IMessagePublisher.cs b/src/Foundatio/Messaging/IMessagePublisher.cs index 7a64b29ee..af24cd6c3 100644 --- a/src/Foundatio/Messaging/IMessagePublisher.cs +++ b/src/Foundatio/Messaging/IMessagePublisher.cs @@ -16,6 +16,12 @@ public interface IMessage : IMessage where T: class { T Body { get; } } + public interface IMessageSerializer { + // used to serialize and deserialize messages. Normally just a wrapper for ISerializer, but + // can be used to transform messages to/from different formats from different systems + // needs ability to read and populate messages headers + } + public interface IMessage { // correlation id used in logging string CorrelationId { get; } diff --git a/src/Foundatio/Messaging/IMessageSubscriber.cs b/src/Foundatio/Messaging/IMessageSubscriber.cs index 6f0960784..3ee9cfb3b 100644 --- a/src/Foundatio/Messaging/IMessageSubscriber.cs +++ b/src/Foundatio/Messaging/IMessageSubscriber.cs @@ -57,6 +57,13 @@ public interface IMessageContext : IMessage, IMessagePublisher, IDisposable { CancellationToken CancellationToken { get; } } + public interface IWorkScheduler { + // ability to persist work items and schedule them for execution at a later time + // not sure if it should be specific to messaging or just generically useful + // Should grab items and work very similar to queue (ability to batch dequeue) + // worker probably in different interface so processing can be separate from scheduling. + } + public interface IMessageContext : IMessageContext, IMessage where T: class {} public static class MessageBusExtensions { From 0f0898f88e4ca9406f32cc097afe75da480b8ed0 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 7 May 2019 15:35:21 -0500 Subject: [PATCH 09/29] Flow message type through methods in MessageBusBase --- src/Foundatio/Messaging/InMemoryMessageBus.cs | 28 +++++++++++-------- src/Foundatio/Messaging/MessageBusBase.cs | 21 +++++++------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/Foundatio/Messaging/InMemoryMessageBus.cs b/src/Foundatio/Messaging/InMemoryMessageBus.cs index efeb26854..7f9811340 100644 --- a/src/Foundatio/Messaging/InMemoryMessageBus.cs +++ b/src/Foundatio/Messaging/InMemoryMessageBus.cs @@ -32,10 +32,10 @@ public void ResetMessagesSent() { _messageCounts.Clear(); } - protected override async Task PublishImplAsync(string messageType, object message, MessageOptions options, CancellationToken cancellationToken) { + protected override Task PublishImplAsync(Type messageType, object message, TimeSpan? delay, CancellationToken cancellationToken) { Interlocked.Increment(ref _messagesSent); - _messageCounts.AddOrUpdate(messageType, t => 1, (t, c) => c + 1); - var mappedType = GetMappedMessageType(messageType); + var mappedMessageType = GetMappedMessageType(messageType); + _messageCounts.AddOrUpdate(mappedMessageType, t => 1, (t, c) => c + 1); if (_subscribers.IsEmpty) return; @@ -43,9 +43,9 @@ protected override async Task PublishImplAsync(string messageType, object messag bool isTraceLogLevelEnabled = _logger.IsEnabled(LogLevel.Trace); if (options.DeliveryDelay.HasValue && options.DeliveryDelay.Value > TimeSpan.Zero) { if (isTraceLogLevelEnabled) - _logger.LogTrace("Schedule delayed message: {MessageType} ({Delay}ms)", messageType, options.DeliveryDelay.Value.TotalMilliseconds); - SendDelayedMessage(mappedType, message, options.DeliveryDelay.Value); - return; + _logger.LogTrace("Schedule delayed message: {MessageType} ({Delay}ms)", messageType, delay.Value.TotalMilliseconds); + SendDelayedMessage(messageType, message, delay.Value); + return Task.CompletedTask; } byte[] body = SerializeMessageBody(messageType, message); @@ -55,12 +55,18 @@ protected override async Task PublishImplAsync(string messageType, object messag Data = body }; - try { - await SendMessageToSubscribersAsync(messageData).AnyContext(); - } catch (Exception ex) { - // swallow exceptions from subscriber handlers for the in memory bus - _logger.LogWarning(ex, "Error sending message to subscribers: {ErrorMessage}", ex.Message); + var subscribers = _subscribers.Values.Where(s => s.IsAssignableFrom(messageType)).ToList(); + if (subscribers.Count == 0) { + if (isTraceLogLevelEnabled) + _logger.LogTrace("Done sending message to 0 subscribers for message type {MessageType}.", messageType.Name); + return Task.CompletedTask; } + + if (isTraceLogLevelEnabled) + _logger.LogTrace("Message Publish: {MessageType}", messageType.FullName); + + SendMessageToSubscribers(subscribers, messageType, message.DeepClone()); + return Task.CompletedTask; } } } \ No newline at end of file diff --git a/src/Foundatio/Messaging/MessageBusBase.cs b/src/Foundatio/Messaging/MessageBusBase.cs index 2c42d4148..5fc7f0028 100644 --- a/src/Foundatio/Messaging/MessageBusBase.cs +++ b/src/Foundatio/Messaging/MessageBusBase.cs @@ -29,14 +29,14 @@ public MessageBusBase(TOptions options) { _messageBusDisposedCancellationTokenSource = new CancellationTokenSource(); } - protected virtual Task EnsureTopicCreatedAsync(CancellationToken cancellationToken) => Task.CompletedTask; - protected abstract Task PublishImplAsync(string messageType, object message, MessageOptions options, CancellationToken cancellationToken); - public async Task PublishAsync(Type messageType, object message, MessageOptions options = null, CancellationToken cancellationToken = default) { + protected virtual Task EnsureTopicCreatedAsync(Type messageType, CancellationToken cancellationToken) => Task.CompletedTask; + protected abstract Task PublishImplAsync(Type messageType, object message, TimeSpan? delay, CancellationToken cancellationToken); + public async Task PublishAsync(Type messageType, object message, TimeSpan? delay = null, CancellationToken cancellationToken = default) { if (messageType == null || message == null) return; - await EnsureTopicCreatedAsync(cancellationToken).AnyContext(); - await PublishImplAsync(GetMappedMessageType(messageType), message, options ?? new MessageOptions(), cancellationToken).AnyContext(); + await EnsureTopicCreatedAsync(messageType, cancellationToken).AnyContext(); + await PublishImplAsync(messageType, message, delay, cancellationToken).AnyContext(); } private readonly ConcurrentDictionary _mappedMessageTypesCache = new(); @@ -79,9 +79,8 @@ protected virtual Type GetMappedMessageType(string messageType) { }); } - protected virtual Task EnsureTopicSubscriptionAsync(CancellationToken cancellationToken) => Task.CompletedTask; - protected virtual Task SubscribeImplAsync(Func handler) where T : class { - var messageSubscription = new MessageSubscription(); + protected virtual Task EnsureTopicSubscriptionAsync(CancellationToken cancellationToken) where T : class => Task.CompletedTask; + protected virtual Task SubscribeImplAsync(Func handler, CancellationToken cancellationToken) where T : class { var subscriber = new Subscriber { CancellationToken = messageSubscription.CancellationToken, Type = typeof(T), @@ -105,9 +104,9 @@ protected virtual Task SubscribeImplAsync(Func SubscribeAsync(Func handler) where T : class { if (_logger.IsEnabled(LogLevel.Trace)) _logger.LogTrace("Adding subscriber for {MessageType}.", typeof(T).FullName); - await EnsureTopicSubscriptionAsync(cancellationToken).AnyContext(); - var messageSubscription = await SubscribeImplAsync(handler).AnyContext(); - return messageSubscription; + + await EnsureTopicSubscriptionAsync(cancellationToken).AnyContext(); + await SubscribeImplAsync(handler, cancellationToken).AnyContext(); } protected List GetMessageSubscribers(IMessage message) { From a358b923260190171c26071ffb249acecddc37ea Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 8 May 2019 13:08:30 -0500 Subject: [PATCH 10/29] Adding ITypeNameSerializer to control how message types are converted to / from strings --- .../Messaging/MessageBusTestBase.cs | 2 +- .../Messaging/ITypeNameSerializer.cs | 51 +++++++++++++++++++ src/Foundatio/Messaging/MessageBusBase.cs | 40 ++------------- .../Messaging/SharedMessageBusOptions.cs | 21 ++------ 4 files changed, 62 insertions(+), 52 deletions(-) create mode 100644 src/Foundatio/Messaging/ITypeNameSerializer.cs diff --git a/src/Foundatio.TestHarness/Messaging/MessageBusTestBase.cs b/src/Foundatio.TestHarness/Messaging/MessageBusTestBase.cs index 8ff028c95..49cc1dd10 100644 --- a/src/Foundatio.TestHarness/Messaging/MessageBusTestBase.cs +++ b/src/Foundatio.TestHarness/Messaging/MessageBusTestBase.cs @@ -108,7 +108,7 @@ await messageBus.PublishAsync(new DerivedSimpleMessageA { public virtual async Task CanSendMappedMessageAsync() { var messageBus = GetMessageBus(b => { - b.MessageTypeMappings.Add(nameof(SimpleMessageA), typeof(SimpleMessageA)); + b.TypeNameSerializer = new DefaultTypeNameSerializer(_logger, new Dictionary {{ nameof(SimpleMessageA), typeof(SimpleMessageA) }}); return b; }); if (messageBus == null) diff --git a/src/Foundatio/Messaging/ITypeNameSerializer.cs b/src/Foundatio/Messaging/ITypeNameSerializer.cs new file mode 100644 index 000000000..5a5c9041e --- /dev/null +++ b/src/Foundatio/Messaging/ITypeNameSerializer.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Foundatio.Messaging { + public interface ITypeNameSerializer { + string Serialize(Type type); + Type Deserialize(string typeName); + } + + public class DefaultTypeNameSerializer : ITypeNameSerializer { + private readonly Dictionary _typeNameOverrides; + private readonly ILogger _logger; + + public DefaultTypeNameSerializer(ILogger logger = null, IDictionary typeNameOverrides = null) { + _logger = logger ?? NullLogger.Instance; + _typeNameOverrides = typeNameOverrides != null ? new Dictionary(typeNameOverrides) : new Dictionary(); + } + + private readonly ConcurrentDictionary _knownMessageTypesCache = new ConcurrentDictionary(); + public Type Deserialize(string typeName) { + return _knownMessageTypesCache.GetOrAdd(typeName, newTypeName => { + if (_typeNameOverrides != null && _typeNameOverrides.ContainsKey(newTypeName)) + return _typeNameOverrides[newTypeName]; + + try { + return Type.GetType(newTypeName); + } catch (Exception ex) { + if (_logger.IsEnabled(LogLevel.Warning)) + _logger.LogWarning(ex, "Error getting message type: {MessageType}", newTypeName); + + return null; + } + }); + } + + private readonly ConcurrentDictionary _mappedMessageTypesCache = new ConcurrentDictionary(); + public string Serialize(Type type) { + return _mappedMessageTypesCache.GetOrAdd(type, newType => { + var reversedMap = _typeNameOverrides.ToDictionary(kvp => kvp.Value, kvp => kvp.Key); + if (reversedMap.ContainsKey(newType)) + return reversedMap[newType]; + + return String.Concat(type.FullName, ", ", type.Assembly.GetName().Name); + }); + } + } +} \ No newline at end of file diff --git a/src/Foundatio/Messaging/MessageBusBase.cs b/src/Foundatio/Messaging/MessageBusBase.cs index 5fc7f0028..4f25f90db 100644 --- a/src/Foundatio/Messaging/MessageBusBase.cs +++ b/src/Foundatio/Messaging/MessageBusBase.cs @@ -18,6 +18,7 @@ public abstract class MessageBusBase : IMessageBus, IDisposable where protected readonly TOptions _options; protected readonly ILogger _logger; protected readonly ISerializer _serializer; + protected readonly ITypeNameSerializer _typeNameSerializer; private bool _isDisposed; public MessageBusBase(TOptions options) { @@ -25,6 +26,7 @@ public MessageBusBase(TOptions options) { var loggerFactory = options?.LoggerFactory ?? NullLoggerFactory.Instance; _logger = loggerFactory.CreateLogger(GetType()); _serializer = options.Serializer ?? DefaultSerializer.Instance; + _typeNameSerializer = options.TypeNameSerializer ?? new DefaultTypeNameSerializer(_logger); MessageBusId = _options.Topic + Guid.NewGuid().ToString("N").Substring(10); _messageBusDisposedCancellationTokenSource = new CancellationTokenSource(); } @@ -39,44 +41,12 @@ public async Task PublishAsync(Type messageType, object message, TimeSpan? delay await PublishImplAsync(messageType, message, delay, cancellationToken).AnyContext(); } - private readonly ConcurrentDictionary _mappedMessageTypesCache = new(); protected string GetMappedMessageType(Type messageType) { - return _mappedMessageTypesCache.GetOrAdd(messageType, type => { - var reversedMap = _options.MessageTypeMappings.ToDictionary(kvp => kvp.Value, kvp => kvp.Key); - if (reversedMap.ContainsKey(type)) - return reversedMap[type]; - - return String.Concat(messageType.FullName, ", ", messageType.Assembly.GetName().Name); - }); + return _typeNameSerializer.Serialize(messageType); } - private readonly ConcurrentDictionary _knownMessageTypesCache = new(); - protected virtual Type GetMappedMessageType(string messageType) { - if (String.IsNullOrEmpty(messageType)) - return null; - - return _knownMessageTypesCache.GetOrAdd(messageType, type => { - if (_options.MessageTypeMappings != null && _options.MessageTypeMappings.ContainsKey(type)) - return _options.MessageTypeMappings[type]; - - try { - return Type.GetType(type); - } catch (Exception) { - try { - string[] typeParts = type.Split(','); - if (typeParts.Length >= 2) - type = String.Join(",", typeParts[0], typeParts[1]); - - // try resolve type without version - return Type.GetType(type); - } catch (Exception ex) { - if (_logger.IsEnabled(LogLevel.Warning)) - _logger.LogWarning(ex, "Error getting message body type: {MessageType}", type); - - return null; - } - } - }); + protected Type GetMappedMessageType(string messageType) { + return _typeNameSerializer.Deserialize(messageType); } protected virtual Task EnsureTopicSubscriptionAsync(CancellationToken cancellationToken) where T : class => Task.CompletedTask; diff --git a/src/Foundatio/Messaging/SharedMessageBusOptions.cs b/src/Foundatio/Messaging/SharedMessageBusOptions.cs index b7146a1a6..dd135b2d6 100644 --- a/src/Foundatio/Messaging/SharedMessageBusOptions.cs +++ b/src/Foundatio/Messaging/SharedMessageBusOptions.cs @@ -9,9 +9,9 @@ public class SharedMessageBusOptions : SharedOptions { public string Topic { get; set; } = "messages"; /// - /// Controls which types messages are mapped to. + /// Controls how message types are serialized to/from strings. /// - public Dictionary MessageTypeMappings { get; set; } = new Dictionary(); + public ITypeNameSerializer TypeNameSerializer { get; set; } } public class SharedMessageBusOptionsBuilder : SharedOptionsBuilder @@ -23,20 +23,9 @@ public TBuilder Topic(string topic) { Target.Topic = topic; return (TBuilder)this; } - - public TBuilder MapMessageType(string name) { - if (Target.MessageTypeMappings == null) - Target.MessageTypeMappings = new Dictionary(); - - Target.MessageTypeMappings[name] = typeof(T); - return (TBuilder)this; - } - - public TBuilder MapMessageTypeToClassName() { - if (Target.MessageTypeMappings == null) - Target.MessageTypeMappings = new Dictionary(); - - Target.MessageTypeMappings[typeof(T).Name] = typeof(T); + + public TBuilder TypeNameSerializer(ITypeNameSerializer typeNameSerializer) { + Target.TypeNameSerializer = typeNameSerializer; return (TBuilder)this; } } From 9d0363e367838b39f5a69ba7ac50f7eda8c39778 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 8 May 2019 13:13:40 -0500 Subject: [PATCH 11/29] Some cleanup --- .../Messaging/ITypeNameSerializer.cs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/Foundatio/Messaging/ITypeNameSerializer.cs b/src/Foundatio/Messaging/ITypeNameSerializer.cs index 5a5c9041e..c8622b3c1 100644 --- a/src/Foundatio/Messaging/ITypeNameSerializer.cs +++ b/src/Foundatio/Messaging/ITypeNameSerializer.cs @@ -12,17 +12,19 @@ public interface ITypeNameSerializer { } public class DefaultTypeNameSerializer : ITypeNameSerializer { - private readonly Dictionary _typeNameOverrides; private readonly ILogger _logger; + private readonly Dictionary _typeNameOverrides; + private readonly ConcurrentDictionary _typeNameCache = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _typeCache = new ConcurrentDictionary(); public DefaultTypeNameSerializer(ILogger logger = null, IDictionary typeNameOverrides = null) { _logger = logger ?? NullLogger.Instance; - _typeNameOverrides = typeNameOverrides != null ? new Dictionary(typeNameOverrides) : new Dictionary(); + if (typeNameOverrides != null) + _typeNameOverrides = new Dictionary(typeNameOverrides); } - private readonly ConcurrentDictionary _knownMessageTypesCache = new ConcurrentDictionary(); public Type Deserialize(string typeName) { - return _knownMessageTypesCache.GetOrAdd(typeName, newTypeName => { + return _typeCache.GetOrAdd(typeName, newTypeName => { if (_typeNameOverrides != null && _typeNameOverrides.ContainsKey(newTypeName)) return _typeNameOverrides[newTypeName]; @@ -37,12 +39,13 @@ public Type Deserialize(string typeName) { }); } - private readonly ConcurrentDictionary _mappedMessageTypesCache = new ConcurrentDictionary(); public string Serialize(Type type) { - return _mappedMessageTypesCache.GetOrAdd(type, newType => { - var reversedMap = _typeNameOverrides.ToDictionary(kvp => kvp.Value, kvp => kvp.Key); - if (reversedMap.ContainsKey(newType)) - return reversedMap[newType]; + return _typeNameCache.GetOrAdd(type, newType => { + if (_typeNameOverrides != null) { + var reversedMap = _typeNameOverrides.ToDictionary(kvp => kvp.Value, kvp => kvp.Key); + if (reversedMap.ContainsKey(newType)) + return reversedMap[newType]; + } return String.Concat(type.FullName, ", ", type.Assembly.GetName().Name); }); From cf56b0f94e6d55188467bca35eb409146bc712b6 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 10 May 2019 14:03:09 -0500 Subject: [PATCH 12/29] Progress on new messaging implementation --- src/Foundatio/Messaging/IMessage.cs | 76 +++++ src/Foundatio/Messaging/IMessageBus.cs | 43 +-- src/Foundatio/Messaging/IMessageContext.cs | 116 ++++++++ src/Foundatio/Messaging/IMessagePublisher.cs | 93 +++--- src/Foundatio/Messaging/IMessageStore.cs | 89 ++++++ src/Foundatio/Messaging/IMessageSubscriber.cs | 78 ++--- .../Messaging/IMessageSubscription.cs | 43 +++ src/Foundatio/Messaging/InMemoryMessageBus.cs | 97 ++++-- src/Foundatio/Messaging/MessageBusBase.cs | 275 +++++------------- src/Foundatio/Messaging/NullMessageBus.cs | 12 +- .../Messaging/SharedMessageBusOptions.cs | 19 +- src/Foundatio/Utility/MaintenanceBase.cs | 3 + 12 files changed, 558 insertions(+), 386 deletions(-) create mode 100644 src/Foundatio/Messaging/IMessage.cs create mode 100644 src/Foundatio/Messaging/IMessageContext.cs create mode 100644 src/Foundatio/Messaging/IMessageStore.cs create mode 100644 src/Foundatio/Messaging/IMessageSubscription.cs diff --git a/src/Foundatio/Messaging/IMessage.cs b/src/Foundatio/Messaging/IMessage.cs new file mode 100644 index 000000000..8953a53fd --- /dev/null +++ b/src/Foundatio/Messaging/IMessage.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using Foundatio.Serializer; +using Foundatio.Utility; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Foundatio.Messaging { + public interface IMessage { + // correlation id used in logging + string CorrelationId { get; } + // message type, will be converted to string and stored with the message for deserialization + Type MessageType { get; } + // message body + object GetBody(); + // when the message should expire + DateTime? ExpiresAtUtc { get; } + // when the message should be delivered when using delayed delivery + DateTime? DeliverAtUtc { get; } + // additional message data to store with the message + IReadOnlyDictionary Headers { get; } + } + + public class Message : IMessage { + private Lazy _body; + + public Message(Func getBodyFunc, Type messageType, string coorelationId, DateTime? expiresAtUtc, DateTime? deliverAtUtc, IReadOnlyDictionary headers) { + _body = new Lazy(getBodyFunc); + MessageType = messageType; + CorrelationId = coorelationId; + ExpiresAtUtc = expiresAtUtc; + DeliverAtUtc = deliverAtUtc; + Headers = headers; + } + + public Message(Func getBodyFunc, MessagePublishOptions options) { + _body = new Lazy(getBodyFunc); + CorrelationId = options.CorrelationId; + MessageType = options.MessageType; + ExpiresAtUtc = options.ExpiresAtUtc; + DeliverAtUtc = options.DeliverAtUtc; + Headers = options.Headers; + } + + public string CorrelationId { get; private set; } + public Type MessageType { get; private set; } + public DateTime? ExpiresAtUtc { get; private set; } + public DateTime? DeliverAtUtc { get; private set; } + public IReadOnlyDictionary Headers { get; private set; } + + public object GetBody() { + return _body.Value; + } + } + + public interface IMessage : IMessage where T: class { + T Body { get; } + } + + public class Message : IMessage where T: class { + private readonly IMessage _message; + + public Message(IMessage message) { + _message = message; + } + + public T Body => (T)GetBody(); + + public string CorrelationId => _message.CorrelationId; + public Type MessageType => _message.MessageType; + public DateTime? ExpiresAtUtc => _message.ExpiresAtUtc; + public DateTime? DeliverAtUtc => _message.DeliverAtUtc; + public IReadOnlyDictionary Headers => _message.Headers; + public object GetBody() => _message.GetBody(); + } +} diff --git a/src/Foundatio/Messaging/IMessageBus.cs b/src/Foundatio/Messaging/IMessageBus.cs index 3f12008f9..0bca26468 100644 --- a/src/Foundatio/Messaging/IMessageBus.cs +++ b/src/Foundatio/Messaging/IMessageBus.cs @@ -2,46 +2,7 @@ using Foundatio.Utility; namespace Foundatio.Messaging { - // work items go away, queues go away. everything consolidated down to just messaging - // will be easy to handle random messages like you can with work items currently - // conventions to determine message queue based on message type - // conventions for determining default time to live, retries, and deadletter behavior based on message type, whether its a worker type or not - // pub/sub you would publish messages and each subscriber would automatically get a unique subscriber id and the messages would go to all of them - // worker you would publish messages and each subscriber would use the same subscriber id and the messages would get round robin'd - // need to figure out how we would handle rpc request / response. need to be able to subscribe for a single message - // still need interceptors / middleware to replace queue behaviors - - public interface IMessageBus : IMessagePublisher, IMessageSubscriber, IDisposable {} - - public delegate IMessageQueueOptions GetMessageQueueOptions(Type messageType); - - // this is used to get the message type from the string type that is stored in the message properties - // this by default would be the message types full name, but it could be something completely different - // especially if a message is being read that was published by some other non-dotnet library - public interface IMessageTypeConverter { - string ToString(Type messageType); - Type FromString(string messageType); - } - - public interface IMessageQueueOptions { - // whether messages will survive transport restart - bool IsDurable { get; set; } - // if worker, subscriptions will default to using the same subscription id and - bool IsWorker { get; set; } - // the name of the queue that the messages are stored in - string QueueName { get; set; } - // how long should the message remain in flight before timing out - TimeSpan TimeToLive { get; set; } - // how many messages should be fetched at a time - int PrefetchSize { get; set; } - // how messages should be acknowledged - AcknowledgementStrategy AcknowledgementStrategy { get; set; } - // need something for how to handle retries and deadletter - } - - public enum AcknowledgementStrategy { - Manual, // consumer needs to do it - Automatic, // auto acknowledge after handler completes successfully and auto reject if handler throws - FireAndForget // acknowledge before handler runs + public interface IMessageBus : IMessagePublisher, IMessageSubscriber, IDisposable { + string MessageBusId { get; } } } \ No newline at end of file diff --git a/src/Foundatio/Messaging/IMessageContext.cs b/src/Foundatio/Messaging/IMessageContext.cs new file mode 100644 index 000000000..2b825580b --- /dev/null +++ b/src/Foundatio/Messaging/IMessageContext.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Foundatio.Utility; + +namespace Foundatio.Messaging { + public interface IMessageContext : IMessage, IDisposable { + // message id + string Id { get; } + // message subscription id that received the message + string SubscriptionId { get; } + // when the message was originally created + DateTime PublishedUtc { get; } + // number of times this message has been delivered + int DeliveryCount { get; } + // acknowledge receipt of message and delete it + Task AcknowledgeAsync(); + // reject the message as not having been successfully processed + Task RejectAsync(); + // used to cancel processing of the current message + CancellationToken CancellationToken { get; } + } + + public interface IMessageContext : IMessageContext, IMessage where T: class {} + + public class MessageContext : IMessageContext where T : class { + private readonly IMessageContext _context; + + public MessageContext(IMessageContext context) { + _context = context; + } + + public string Id => _context.Id; + public string SubscriptionId => _context.SubscriptionId; + public DateTime PublishedUtc => _context.PublishedUtc; + public int DeliveryCount => _context.DeliveryCount; + public CancellationToken CancellationToken => _context.CancellationToken; + public string CorrelationId => _context.CorrelationId; + public Type MessageType => _context.MessageType; + public DateTime? ExpiresAtUtc => _context.ExpiresAtUtc; + public DateTime? DeliverAtUtc => _context.DeliverAtUtc; + public IReadOnlyDictionary Headers => _context.Headers; + public T Body => (T)GetBody(); + + public object GetBody() { + return _context.GetBody(); + } + + public Task AcknowledgeAsync() { + return _context.AcknowledgeAsync(); + } + + public Task RejectAsync() { + return _context.RejectAsync(); + } + + public void Dispose() { + _context.Dispose(); + } + } + + public class MessageContext : IMessageContext { + protected readonly IMessage _message; + protected readonly Func _acknowledgeAction; + protected readonly Func _rejectAction; + protected readonly Action _disposeAction; + + public MessageContext(string id, string subscriptionId, DateTime createdUtc, int deliveryCount, + IMessage message, Func acknowledgeAction, Func rejectAction, Action disposeAction, + CancellationToken cancellationToken = default) { + Id = id; + SubscriptionId = subscriptionId; + PublishedUtc = createdUtc; + DeliveryCount = deliveryCount; + _message = message; + _acknowledgeAction = acknowledgeAction; + _rejectAction = rejectAction; + _disposeAction = disposeAction; + CancellationToken = cancellationToken; + } + + public string Id { get; private set; } + public string SubscriptionId { get; private set; } + public DateTime PublishedUtc { get; private set; } + public int DeliveryCount { get; private set; } + public CancellationToken CancellationToken { get; private set; } + public string CorrelationId => _message.CorrelationId; + public Type MessageType => _message.MessageType; + public DateTime? ExpiresAtUtc => _message.ExpiresAtUtc; + public DateTime? DeliverAtUtc => _message.DeliverAtUtc; + public IReadOnlyDictionary Headers => _message.Headers; + + public object GetBody() { + return _message.GetBody(); + } + + public Task AcknowledgeAsync() { + if (_acknowledgeAction == null) + return Task.CompletedTask; + + return _acknowledgeAction(); + } + + public Task RejectAsync() { + if (_rejectAction == null) + return Task.CompletedTask; + + return _rejectAction(); + } + + public void Dispose() { + _disposeAction?.Invoke(); + } + } +} \ No newline at end of file diff --git a/src/Foundatio/Messaging/IMessagePublisher.cs b/src/Foundatio/Messaging/IMessagePublisher.cs index af24cd6c3..66f4d52f8 100644 --- a/src/Foundatio/Messaging/IMessagePublisher.cs +++ b/src/Foundatio/Messaging/IMessagePublisher.cs @@ -5,52 +5,71 @@ namespace Foundatio.Messaging { public interface IMessagePublisher { - // extensions for easily publishing just the raw message body, message settings populated from conventions - Task PublishAsync(IMessage message); - - // the methods below will be extension methods that call the method above - Task PublishAsync(T message) where T: class; + Task PublishAsync(object message, MessagePublishOptions options); } - public interface IMessage : IMessage where T: class { - T Body { get; } - } + public class MessagePublishOptions { + public Type MessageType { get; set; } + public string CorrelationId { get; set; } + public DateTime? ExpiresAtUtc { get; set; } + public DateTime? DeliverAtUtc { get; set; } + public DataDictionary Headers { get; set; } = new DataDictionary(); + public CancellationToken CancellationToken { get; set; } - public interface IMessageSerializer { - // used to serialize and deserialize messages. Normally just a wrapper for ISerializer, but - // can be used to transform messages to/from different formats from different systems - // needs ability to read and populate messages headers - } + public MessagePublishOptions WithMessageType(Type messageType) { + MessageType = messageType; + return this; + } + + public MessagePublishOptions WithCorrelationId(string correlationId) { + CorrelationId = correlationId; + return this; + } + + public MessagePublishOptions WithExpiresAtUtc(DateTime? expiresAtUtc) { + ExpiresAtUtc = expiresAtUtc; + return this; + } + + public MessagePublishOptions WithDeliverAtUtc(DateTime? deliverAtUtc) { + DeliverAtUtc = deliverAtUtc; + return this; + } + + public MessagePublishOptions WithHeaders(DataDictionary headers) { + Headers.AddRange(headers); + return this; + } - public interface IMessage { - // correlation id used in logging - string CorrelationId { get; } - // used for rpc (request/reply) - string ReplyTo { get; } - // message type, will be converted to string and stored with the message for deserialization - Type MessageType { get; } - // message body - object GetBody(); - // when the message should expire - DateTime? ExpiresAtUtc { get; } - // when the message should be delivered when using delayed delivery - DateTime? DeliverAtUtc { get; } - // additional message data to store with the message - DataDictionary Headers { get; } + public MessagePublishOptions WithHeader(string name, object value) { + Headers.Add(name, value); + return this; + } + + public MessagePublishOptions WithCancellationToken(CancellationToken cancellationToken) { + CancellationToken = cancellationToken; + return this; + } } public static class MessagePublisherExtensions { + public static Task PublishAsync(this IMessagePublisher publisher, Type messageType, object message, TimeSpan? delay = null, CancellationToken cancellationToken = default) { + var deliverAtUtc = delay.HasValue ? (DateTime?)DateTime.UtcNow.Add(delay.Value) : null; + return publisher.PublishAsync(message, new MessagePublishOptions().WithMessageType(messageType).WithDeliverAtUtc(deliverAtUtc).WithCancellationToken(cancellationToken)); + } + public static Task PublishAsync(this IMessagePublisher publisher, T message, TimeSpan? delay = null) where T : class { return publisher.PublishAsync(typeof(T), message, delay); } - } -} -// unified messaging -// lots of metrics / stats on messaging -// unify job / startup actions -// add tags, key/value pairs to metrics -// auto renewing locks -// get rid of queues / queue job -// simplify the way jobs are run + public static Task PublishAsync(this IMessagePublisher publisher, T message, MessagePublishOptions options) where T : class { + if (options == null) + options = new MessagePublishOptions(); + + if (options.MessageType == null) + options.MessageType = typeof(T); + return publisher.PublishAsync(message, options); + } + } +} diff --git a/src/Foundatio/Messaging/IMessageStore.cs b/src/Foundatio/Messaging/IMessageStore.cs new file mode 100644 index 000000000..38d8ee42f --- /dev/null +++ b/src/Foundatio/Messaging/IMessageStore.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Foundatio.Utility; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Messaging { + public interface IMessageStore { + Task AddAsync(PersistedMessage message); + Task RemoveAsync(string[] ids); + Task> GetPendingAsync(DateTime? dateUtc = null); + Task RemoveAllAsync(); + } + + public class PersistedMessage { + public string Id { get; set; } + public DateTime CreatedUtc { get; set; } + public string CorrelationId { get; set; } + public string MessageTypeName { get; set; } + public byte[] Body { get; set; } + public DateTime? ExpiresAtUtc { get; set; } + public DateTime? DeliverAtUtc { get; set; } + public DataDictionary Headers { get; set; } + } + + public class InMemoryMessageStore : IMessageStore { + private readonly List _messages = new List(); + private readonly ILogger _logger; + + public InMemoryMessageStore(ILogger logger) { + _logger = logger; + } + + public Task AddAsync(PersistedMessage message) { + _messages.Add(new InMemoryStoredMessage(message)); + return Task.CompletedTask; + } + + public Task> GetPendingAsync(DateTime? dueAfterDateUtc = null) { + var dueDate = dueAfterDateUtc ?? DateTime.UtcNow; + + var dueList = new List(); + foreach (var message in _messages) { + if (!message.IsProcessing && message.Message.DeliverAtUtc < dueDate && message.MarkProcessing()) + dueList.Add(message.Message); + } + + return Task.FromResult>(dueList); + } + + public Task RemoveAllAsync() { + _messages.Clear(); + return Task.CompletedTask; + } + + public Task RemoveAsync(string[] ids) { + _messages.RemoveAll(m => ids.Contains(m.Message.Id)); + return Task.CompletedTask; + } + + protected class InMemoryStoredMessage { + public InMemoryStoredMessage(PersistedMessage message) { + Message = message; + } + + public PersistedMessage Message { get; set; } + public bool IsProcessing { + get { + if (_processing != 0 && _startedProcessing < SystemClock.Now.Subtract(TimeSpan.FromMinutes(1))) + _processing = 0; + + return _processing != 0; + } + } + + private int _processing = 0; + private DateTime _startedProcessing = DateTime.MinValue; + + public bool MarkProcessing() { + var result = Interlocked.Exchange(ref _processing, 1); + _startedProcessing = SystemClock.Now; + + return result == 0; + } + } + } +} \ No newline at end of file diff --git a/src/Foundatio/Messaging/IMessageSubscriber.cs b/src/Foundatio/Messaging/IMessageSubscriber.cs index 3ee9cfb3b..52eee78a4 100644 --- a/src/Foundatio/Messaging/IMessageSubscriber.cs +++ b/src/Foundatio/Messaging/IMessageSubscriber.cs @@ -4,76 +4,36 @@ using Foundatio.Utility; namespace Foundatio.Messaging { - // save our subscription handlers in memory so that they can be restored if the connection is interupted - - // should we have a transport interface that we implement and then have more concrete things in the public - // interface classes? - public interface IMessageTransport { - Task SubscribeAsync(Func handler, IMessageSubscriptionOptions options); - Task PublishAsync(IMessage message); - } - public interface IMessageSubscriber { - // there will be extensions that allow subscribing via generic message type parameters with and without the message context wrapper - Task SubscribeAsync(Func handler, IMessageSubscriptionOptions options); - - // the methods below will be extension methods that call the method above - Task SubscribeAsync(Func handler) where T: class; - Task SubscribeAsync(Func, Task> handler) where T: class; - Task SubscribeAsync(Action handler) where T: class; - Task SubscribeAsync(Action> handler) where T: class; - } - - public interface IMessageSubscriptionOptions { - // message type for the subscription - Type MessageType { get; } + Task SubscribeAsync(MessageSubscriptionOptions options, Func handler); } - public interface IMessageSubscription : IDisposable { - // subscription id - string Id { get; } - // name of the queue that this subscription is listening to - Type MessageType { get; } - // when was the message created - DateTime CreatedUtc { get; } - } + public class MessageSubscriptionOptions { + public Type MessageType { get; set; } + public CancellationToken CancellationToken { get; set; } - public interface IMessageContext : IMessage, IMessagePublisher, IDisposable { - // message id - string Id { get; } - // message subscription id that received the message - string SubscriptionId { get; } - // when the message was originally created - DateTime CreatedUtc { get; } - // number of times this message has been delivered - int DeliveryCount { get; } - // acknowledge receipt of message and delete it - Task AcknowledgeAsync(); - // reject the message as not having been successfully processed - Task RejectAsync(); - // used to reply to messages that have a replyto specified - Task ReplyAsync(T message) where T: class; - // used to cancel processing of the current message - CancellationToken CancellationToken { get; } - } + public MessageSubscriptionOptions WithMessageType(Type messageType) { + MessageType = messageType; + return this; + } - public interface IWorkScheduler { - // ability to persist work items and schedule them for execution at a later time - // not sure if it should be specific to messaging or just generically useful - // Should grab items and work very similar to queue (ability to batch dequeue) - // worker probably in different interface so processing can be separate from scheduling. + public MessageSubscriptionOptions WithCancellationToken(CancellationToken cancellationToken) { + CancellationToken = cancellationToken; + return this; + } } - public interface IMessageContext : IMessageContext, IMessage where T: class {} - public static class MessageBusExtensions { - public static async Task SubscribeAsync(this IMessageSubscriber subscriber, Func handler, CancellationToken cancellationToken = default) where T : class { + public static async Task SubscribeAsync(this IMessageSubscriber subscriber, Func handler, CancellationToken cancellationToken = default) where T : class { if (cancellationToken.IsCancellationRequested) - return; + return new MessageSubscription(typeof(T), () => {}); - var result = await subscriber.SubscribeAsync((msg, token) => handler(msg, token)); + var options = new MessageSubscriptionOptions().WithMessageType(typeof(T)).WithCancellationToken(cancellationToken); + var subscription = await subscriber.SubscribeAsync(options, (msg) => handler((T)msg.GetBody(), msg.CancellationToken)).AnyContext(); if (cancellationToken != CancellationToken.None) - cancellationToken.Register(() => ThreadPool.QueueUserWorkItem(s => result?.Dispose())); + cancellationToken.Register(() => subscription.Dispose()); + + return subscription; } public static Task SubscribeAsync(this IMessageSubscriber subscriber, Func handler, CancellationToken cancellationToken = default) where T : class { diff --git a/src/Foundatio/Messaging/IMessageSubscription.cs b/src/Foundatio/Messaging/IMessageSubscription.cs new file mode 100644 index 000000000..f6e82b3d9 --- /dev/null +++ b/src/Foundatio/Messaging/IMessageSubscription.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Foundatio.Utility; + +namespace Foundatio.Messaging { + public interface IMessageSubscription : IDisposable { + string Id { get; } + string MessageBusId { get; } + Type MessageType { get; } + DateTime CreatedUtc { get; } + bool IsCancelled { get; } + } + + public static class MessageSubscriptionExtensions { + public static bool HandlesMessagesType(this IMessageSubscription subscription, Type type) { + return subscription.MessageType.IsAssignableFrom(type); + } + } + + public class MessageSubscription : IMessageSubscription { + private readonly Action _unsubscribeAction; + + public MessageSubscription(Type messageType, Action unsubscribeAction) { + Id = Guid.NewGuid().ToString("N"); + MessageType = messageType; + CreatedUtc = DateTime.UtcNow; + _unsubscribeAction = unsubscribeAction; + } + + public string Id { get; } + public string MessageBusId { get; } + public Type MessageType { get; } + public DateTime CreatedUtc { get; } + public bool IsCancelled { get; private set; } + + public virtual void Dispose() { + IsCancelled = true; + _unsubscribeAction?.Invoke(); + } + } +} \ No newline at end of file diff --git a/src/Foundatio/Messaging/InMemoryMessageBus.cs b/src/Foundatio/Messaging/InMemoryMessageBus.cs index 7f9811340..9644d26de 100644 --- a/src/Foundatio/Messaging/InMemoryMessageBus.cs +++ b/src/Foundatio/Messaging/InMemoryMessageBus.cs @@ -1,8 +1,13 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Foundatio.Utility; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Foundatio.Messaging { @@ -20,11 +25,11 @@ public InMemoryMessageBus(Builder _messagesSent; public long GetMessagesSent(Type messageType) { - return _messageCounts.TryGetValue(GetMappedMessageType(messageType), out long count) ? count : 0; + return _messageCounts.TryGetValue(_typeNameSerializer.Serialize(messageType), out var count) ? count : 0; } public long GetMessagesSent() { - return _messageCounts.TryGetValue(GetMappedMessageType(typeof(T)), out long count) ? count : 0; + return _messageCounts.TryGetValue(_typeNameSerializer.Serialize(typeof(T)), out var count) ? count : 0; } public void ResetMessagesSent() { @@ -32,41 +37,79 @@ public void ResetMessagesSent() { _messageCounts.Clear(); } - protected override Task PublishImplAsync(Type messageType, object message, TimeSpan? delay, CancellationToken cancellationToken) { + protected override Task PublishImplAsync(byte[] body, MessagePublishOptions options = null) { Interlocked.Increment(ref _messagesSent); - var mappedMessageType = GetMappedMessageType(messageType); - _messageCounts.AddOrUpdate(mappedMessageType, t => 1, (t, c) => c + 1); + var typeName = _typeNameSerializer.Serialize(options.MessageType); + _messageCounts.AddOrUpdate(typeName, t => 1, (t, c) => c + 1); - if (_subscribers.IsEmpty) - return; - - bool isTraceLogLevelEnabled = _logger.IsEnabled(LogLevel.Trace); - if (options.DeliveryDelay.HasValue && options.DeliveryDelay.Value > TimeSpan.Zero) { - if (isTraceLogLevelEnabled) - _logger.LogTrace("Schedule delayed message: {MessageType} ({Delay}ms)", messageType, delay.Value.TotalMilliseconds); - SendDelayedMessage(messageType, message, delay.Value); + if (_subscriptions.Count == 0) return Task.CompletedTask; - } + + _logger.LogTrace("Message Publish: {MessageType}", options.MessageType.FullName); + + SendMessageToSubscribers(body, options); + return Task.CompletedTask; + } + + protected override Task SubscribeImplAsync(MessageSubscriptionOptions options, Func handler) { + var subscriber = new Subscriber(options.MessageType, handler); + return Task.FromResult(subscriber); + } + + protected void SendMessageToSubscribers(byte[] body, MessagePublishOptions options) { + if (body == null) + throw new ArgumentNullException(nameof(body)); + + if (options == null) + throw new ArgumentNullException(nameof(options)); - byte[] body = SerializeMessageBody(messageType, message); - var messageData = new Message(() => DeserializeMessageBody(messageType, body)) { - Type = messageType, - ClrType = mappedType, - Data = body + var createdUtc = SystemClock.UtcNow; + var messageId = Guid.NewGuid().ToString(); + Func getBody = () => { + return _serializer.Deserialize(body, options.MessageType); }; - var subscribers = _subscribers.Values.Where(s => s.IsAssignableFrom(messageType)).ToList(); - if (subscribers.Count == 0) { - if (isTraceLogLevelEnabled) - _logger.LogTrace("Done sending message to 0 subscribers for message type {MessageType}.", messageType.Name); - return Task.CompletedTask; + var subscribers = _subscriptions.ToArray().Where(s => s.IsCancelled == false && s.HandlesMessagesType(options.MessageType)).OfType().ToArray(); + bool isTraceLogLevelEnabled = _logger.IsEnabled(LogLevel.Trace); + if (isTraceLogLevelEnabled) + _logger.LogTrace("Found {SubscriberCount} subscribers for message type {MessageType}.", subscribers.Length, options.MessageType.Name); + + foreach (var subscriber in subscribers) { + Task.Factory.StartNew(async () => { + if (subscriber.IsCancelled) { + if (isTraceLogLevelEnabled) + _logger.LogTrace("The cancelled subscriber action will not be called: {SubscriberId}", subscriber.Id); + + return; + } + + if (isTraceLogLevelEnabled) + _logger.LogTrace("Calling subscriber action: {SubscriberId}", subscriber.Id); + + try { + var message = new Message(getBody, options.MessageType, options.CorrelationId, options.ExpiresAtUtc, options.DeliverAtUtc, options.Headers); + var context = new MessageContext(messageId, subscriber.Id, createdUtc, 1, message, () => Task.CompletedTask, () => Task.CompletedTask, () => {}, options.CancellationToken); + await subscriber.Action(context).AnyContext(); + if (isTraceLogLevelEnabled) + _logger.LogTrace("Finished calling subscriber action: {SubscriberId}", subscriber.Id); + } catch (Exception ex) { + if (_logger.IsEnabled(LogLevel.Warning)) + _logger.LogWarning(ex, "Error sending message to subscriber: {Message}", ex.Message); + } + }); } if (isTraceLogLevelEnabled) - _logger.LogTrace("Message Publish: {MessageType}", messageType.FullName); + _logger.LogTrace("Done enqueueing message to {SubscriberCount} subscribers for message type {MessageType}.", subscribers.Length, options.MessageType.Name); + } - SendMessageToSubscribers(subscribers, messageType, message.DeepClone()); - return Task.CompletedTask; + [DebuggerDisplay("Id: {Id} Type: {MessageType} IsDisposed: {IsDisposed}")] + protected class Subscriber : MessageSubscription { + public Subscriber(Type messageType, Func action) : base(messageType, () => {}) { + Action = action; + } + + public Func Action { get; } } } } \ No newline at end of file diff --git a/src/Foundatio/Messaging/MessageBusBase.cs b/src/Foundatio/Messaging/MessageBusBase.cs index 4f25f90db..84b799067 100644 --- a/src/Foundatio/Messaging/MessageBusBase.cs +++ b/src/Foundatio/Messaging/MessageBusBase.cs @@ -12,216 +12,110 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Foundatio.Messaging { - public abstract class MessageBusBase : IMessageBus, IDisposable where TOptions : SharedMessageBusOptions { - private readonly CancellationTokenSource _messageBusDisposedCancellationTokenSource; - protected readonly ConcurrentDictionary _subscribers = new(); + public abstract class MessageBusBase : MaintenanceBase, IMessageBus, IDisposable where TOptions : SharedMessageBusOptions { + protected readonly List _subscriptions = new List(); protected readonly TOptions _options; - protected readonly ILogger _logger; protected readonly ISerializer _serializer; protected readonly ITypeNameSerializer _typeNameSerializer; + protected readonly IMessageStore _store; private bool _isDisposed; - public MessageBusBase(TOptions options) { + public MessageBusBase(TOptions options) : base(options.LoggerFactory) { _options = options ?? throw new ArgumentNullException(nameof(options)); var loggerFactory = options?.LoggerFactory ?? NullLoggerFactory.Instance; - _logger = loggerFactory.CreateLogger(GetType()); _serializer = options.Serializer ?? DefaultSerializer.Instance; _typeNameSerializer = options.TypeNameSerializer ?? new DefaultTypeNameSerializer(_logger); - MessageBusId = _options.Topic + Guid.NewGuid().ToString("N").Substring(10); - _messageBusDisposedCancellationTokenSource = new CancellationTokenSource(); + _store = options.MessageStore ?? new InMemoryMessageStore(_logger); + MessageBusId = Guid.NewGuid().ToString("N"); + InitializeMaintenance(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); } - protected virtual Task EnsureTopicCreatedAsync(Type messageType, CancellationToken cancellationToken) => Task.CompletedTask; - protected abstract Task PublishImplAsync(Type messageType, object message, TimeSpan? delay, CancellationToken cancellationToken); - public async Task PublishAsync(Type messageType, object message, TimeSpan? delay = null, CancellationToken cancellationToken = default) { - if (messageType == null || message == null) - return; - - await EnsureTopicCreatedAsync(messageType, cancellationToken).AnyContext(); - await PublishImplAsync(messageType, message, delay, cancellationToken).AnyContext(); - } - - protected string GetMappedMessageType(Type messageType) { - return _typeNameSerializer.Serialize(messageType); - } - - protected Type GetMappedMessageType(string messageType) { - return _typeNameSerializer.Deserialize(messageType); - } - - protected virtual Task EnsureTopicSubscriptionAsync(CancellationToken cancellationToken) where T : class => Task.CompletedTask; - protected virtual Task SubscribeImplAsync(Func handler, CancellationToken cancellationToken) where T : class { - var subscriber = new Subscriber { - CancellationToken = messageSubscription.CancellationToken, - Type = typeof(T), - Action = (message, token) => { - if (message is not T) { - if (_logger.IsEnabled(LogLevel.Trace)) - _logger.LogTrace("Unable to call subscriber action: {MessageType} cannot be safely casted to {SubscriberType}", message.GetType(), typeof(T)); - return Task.CompletedTask; - } - - return handler((T)message, messageSubscription.CancellationToken); - } - }; - - if (!_subscribers.TryAdd(subscriber.Id, subscriber) && _logger.IsEnabled(LogLevel.Error)) - _logger.LogError("Unable to add subscriber {SubscriberId}", subscriber.Id); - - return Task.FromResult(messageSubscription); - } - - public async Task SubscribeAsync(Func handler) where T : class { - if (_logger.IsEnabled(LogLevel.Trace)) - _logger.LogTrace("Adding subscriber for {MessageType}.", typeof(T).FullName); - - await EnsureTopicSubscriptionAsync(cancellationToken).AnyContext(); - await SubscribeImplAsync(handler, cancellationToken).AnyContext(); - } + public string MessageBusId { get; protected set; } - protected List GetMessageSubscribers(IMessage message) { - return _subscribers.Values.Where(s => SubscriberHandlesMessage(s, message)).ToList(); - } + protected virtual Task ConfigureMessageType(Type messageType, CancellationToken cancellationToken) => Task.CompletedTask; - protected virtual bool SubscriberHandlesMessage(Subscriber subscriber, IMessage message) { - if (subscriber.Type == typeof(IMessage)) - return true; + protected abstract Task PublishImplAsync(byte[] body, MessagePublishOptions options = null); - var clrType = GetMappedMessageType(message.Type); + public async Task PublishAsync(object message, MessagePublishOptions options) { + if (message == null) + return; - if (subscriber.IsAssignableFrom(clrType)) - return true; + if (options.MessageType == null) + options.MessageType = message.GetType(); - return false; - } + if (options.CancellationToken.IsCancellationRequested) + return; - protected virtual byte[] SerializeMessageBody(string messageType, object body) { - if (body == null) - return new byte[0]; + if (options.ExpiresAtUtc.HasValue && options.ExpiresAtUtc.Value < SystemClock.UtcNow) + return; - return _serializer.SerializeToBytes(body); - } + await ConfigureMessageType(options.MessageType, options.CancellationToken).AnyContext(); + var body = _serializer.SerializeToBytes(message); + + if (options.DeliverAtUtc.HasValue && options.DeliverAtUtc > SystemClock.UtcNow) { + var typeName = _typeNameSerializer.Serialize(options.MessageType); + await _store.AddAsync(new PersistedMessage { + Id = Guid.NewGuid().ToString("N"), + CreatedUtc = SystemClock.UtcNow, + CorrelationId = options.CorrelationId, + MessageTypeName = typeName, + Body = body, + ExpiresAtUtc = options.ExpiresAtUtc, + DeliverAtUtc = options.DeliverAtUtc, + Headers = options.Headers + }); - protected virtual object DeserializeMessageBody(string messageType, byte[] data) { - if (data == null || data.Length == 0) - return null; + ScheduleNextMaintenance(options.DeliverAtUtc.Value); - object body; - try { - var clrType = GetMappedMessageType(messageType); - if (clrType != null) - body = _serializer.Deserialize(data, clrType); - else - body = data; - } catch (Exception ex) { - if (_logger.IsEnabled(LogLevel.Error)) - _logger.LogError(ex, "Error deserializing message body: {Message}", ex.Message); - - return null; + return; } - return body; + await PublishImplAsync(body, options).AnyContext(); } - protected async Task SendMessageToSubscribersAsync(IMessage message) { - bool isTraceLogLevelEnabled = _logger.IsEnabled(LogLevel.Trace); - var subscribers = GetMessageSubscribers(message); + protected abstract Task SubscribeImplAsync(MessageSubscriptionOptions options, Func handler); - if (isTraceLogLevelEnabled) - _logger.LogTrace("Found {SubscriberCount} subscribers for message type {MessageType}.", subscribers.Count, message.Type); + public async Task SubscribeAsync(MessageSubscriptionOptions options, Func handler) { + if (options.MessageType == null) + throw new ArgumentNullException("Options must have a MessageType specified."); - if (subscribers.Count == 0) - return; - - if (message.Data == null || message.Data.Length == 0) { - _logger.LogWarning("Unable to send null message for type {MessageType}", message.Type); - return; - } + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("Adding subscription for {MessageType}.", options.MessageType.FullName); - var body = new Lazy(() => DeserializeMessageBody(message.Type, message.Data)); - - var subscriberHandlers = subscribers.Select(subscriber => { - if (subscriber.CancellationToken.IsCancellationRequested) { - if (_subscribers.TryRemove(subscriber.Id, out _)) { - if (isTraceLogLevelEnabled) - _logger.LogTrace("Removed cancelled subscriber: {SubscriberId}", subscriber.Id); - } else if (isTraceLogLevelEnabled) { - _logger.LogTrace("Unable to remove cancelled subscriber: {SubscriberId}", subscriber.Id); - } - - return Task.CompletedTask; - } - - return Task.Run(async () => { - if (subscriber.CancellationToken.IsCancellationRequested) { - if (isTraceLogLevelEnabled) - _logger.LogTrace("The cancelled subscriber action will not be called: {SubscriberId}", subscriber.Id); - - return; - } - - if (isTraceLogLevelEnabled) - _logger.LogTrace("Calling subscriber action: {SubscriberId}", subscriber.Id); - - if (subscriber.Type == typeof(IMessage)) - await subscriber.Action(message, subscriber.CancellationToken).AnyContext(); - else - await subscriber.Action(body.Value, subscriber.CancellationToken).AnyContext(); - - if (isTraceLogLevelEnabled) - _logger.LogTrace("Finished calling subscriber action: {SubscriberId}", subscriber.Id); - }); - }); + if (options.CancellationToken.IsCancellationRequested) + return null; - try { - await Task.WhenAll(subscriberHandlers.ToArray()); - } catch (Exception ex) { - _logger.LogWarning(ex, "Error sending message to subscribers: {ErrorMessage}", ex.Message); + await ConfigureMessageType(options.MessageType, options.CancellationToken).AnyContext(); + var subscription = await SubscribeImplAsync(options, handler).AnyContext(); + _subscriptions.Add(subscription); - throw; - } - - if (isTraceLogLevelEnabled) - _logger.LogTrace("Done enqueueing message to {SubscriberCount} subscribers for message type {MessageType}.", subscribers.Count, message.Type); + return subscription; } - - protected Task AddDelayedMessageAsync(Type messageType, object message, TimeSpan delay) { - if (message == null) - throw new ArgumentNullException(nameof(message)); - - SendDelayedMessage(messageType, message, delay); - return Task.CompletedTask; + protected bool MessageTypeHasSubscribers(Type messageType) { + var subscribers = _subscriptions.Where(s => s.MessageType.IsAssignableFrom(messageType)).ToList(); + return subscribers.Count == 0; } - protected void SendDelayedMessage(Type messageType, object message, TimeSpan delay) { - if (message == null) - throw new ArgumentNullException(nameof(message)); - - if (delay <= TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(delay)); - - var sendTime = SystemClock.UtcNow.SafeAdd(delay); - Task.Factory.StartNew(async () => { - await SystemClock.SleepSafeAsync(delay, _messageBusDisposedCancellationTokenSource.Token).AnyContext(); + protected override async Task DoMaintenanceAsync() { + var pendingMessages = await _store.GetPendingAsync(SystemClock.UtcNow); + foreach (var pendingMessage in pendingMessages) { + var messageType = _typeNameSerializer.Deserialize(pendingMessage.MessageTypeName); + await PublishImplAsync(pendingMessage.Body, new MessagePublishOptions { + CorrelationId = pendingMessage.CorrelationId, + DeliverAtUtc = pendingMessage.DeliverAtUtc, + ExpiresAtUtc = pendingMessage.ExpiresAtUtc, + MessageType = messageType, + Headers = pendingMessage.Headers + }).AnyContext(); + } - bool isTraceLevelEnabled = _logger.IsEnabled(LogLevel.Trace); - if (_messageBusDisposedCancellationTokenSource.IsCancellationRequested) { - if (isTraceLevelEnabled) - _logger.LogTrace("Discarding delayed message scheduled for {SendTime:O} for type {MessageType}", sendTime, messageType); - return; - } - - if (isTraceLevelEnabled) - _logger.LogTrace("Sending delayed message scheduled for {SendTime:O} for type {MessageType}", sendTime, messageType); + _subscriptions.RemoveAll(s => s.IsCancelled); - await PublishAsync(messageType, message).AnyContext(); - }); + return null; } - public string MessageBusId { get; protected set; } - - public virtual void Dispose() { + public override void Dispose() { if (_isDisposed) { _logger.LogTrace("MessageBus {0} dispose was already called.", MessageBusId); return; @@ -230,40 +124,11 @@ public virtual void Dispose() { _isDisposed = true; _logger.LogTrace("MessageBus {0} dispose", MessageBusId); - _subscribers?.Clear(); - _messageBusDisposedCancellationTokenSource?.Cancel(); - _messageBusDisposedCancellationTokenSource?.Dispose(); - } - [DebuggerDisplay("MessageType: {MessageType} SendTime: {SendTime} Message: {Message}")] - protected class DelayedMessage { - public DateTime SendTime { get; set; } - public Type MessageType { get; set; } - public object Message { get; set; } - } - - [DebuggerDisplay("Id: {Id} Type: {Type} CancellationToken: {CancellationToken}")] - protected class Subscriber { - private readonly ConcurrentDictionary _assignableTypesCache = new(); - - public string Id { get; private set; } = Guid.NewGuid().ToString("N"); - public CancellationToken CancellationToken { get; set; } - public Type Type { get; set; } - public Func Action { get; set; } - - public bool IsAssignableFrom(Type type) { - return _assignableTypesCache.GetOrAdd(type, t => Type.GetTypeInfo().IsAssignableFrom(t)); + if (_subscriptions != null && _subscriptions.Count > 0) { + foreach (var subscription in _subscriptions) + subscription.Dispose(); } } } - - public class MessageSubscription : IMessageSubscription { - private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); - - public CancellationToken CancellationToken => _cancellationTokenSource.Token; - - public void Dispose() { - _cancellationTokenSource.Cancel(); - } - } } \ No newline at end of file diff --git a/src/Foundatio/Messaging/NullMessageBus.cs b/src/Foundatio/Messaging/NullMessageBus.cs index ff656ce30..7d03c2789 100644 --- a/src/Foundatio/Messaging/NullMessageBus.cs +++ b/src/Foundatio/Messaging/NullMessageBus.cs @@ -6,18 +6,16 @@ namespace Foundatio.Messaging { public class NullMessageBus : IMessageBus { public static readonly NullMessageBus Instance = new(); - public Task PublishAsync(Type messageType, object message, MessageOptions options = null, CancellationToken cancellationToken = default) { + public string MessageBusId { get; } = Guid.NewGuid().ToString("N"); + + public Task PublishAsync(object message, MessagePublishOptions options = null) { return Task.CompletedTask; } - public Task SubscribeAsync(Func handler) where T : class { - return Task.FromResult(new NullMessageSubscription()); + public Task SubscribeAsync(MessageSubscriptionOptions options, Func handler) { + return Task.FromResult(new MessageSubscription(options.MessageType, () => {})); } public void Dispose() {} } - - public class NullMessageSubscription : IMessageSubscription { - public void Dispose() {} - } } diff --git a/src/Foundatio/Messaging/SharedMessageBusOptions.cs b/src/Foundatio/Messaging/SharedMessageBusOptions.cs index dd135b2d6..628472e12 100644 --- a/src/Foundatio/Messaging/SharedMessageBusOptions.cs +++ b/src/Foundatio/Messaging/SharedMessageBusOptions.cs @@ -4,29 +4,28 @@ namespace Foundatio.Messaging { public class SharedMessageBusOptions : SharedOptions { /// - /// The topic name + /// Controls how message types are serialized to/from strings. /// - public string Topic { get; set; } = "messages"; + public ITypeNameSerializer TypeNameSerializer { get; set; } /// - /// Controls how message types are serialized to/from strings. + /// Used to store delayed messages. /// - public ITypeNameSerializer TypeNameSerializer { get; set; } + public IMessageStore MessageStore { get; set; } } public class SharedMessageBusOptionsBuilder : SharedOptionsBuilder where TOptions : SharedMessageBusOptions, new() where TBuilder : SharedMessageBusOptionsBuilder { - public TBuilder Topic(string topic) { - if (string.IsNullOrEmpty(topic)) - throw new ArgumentNullException(nameof(topic)); - Target.Topic = topic; - return (TBuilder)this; - } public TBuilder TypeNameSerializer(ITypeNameSerializer typeNameSerializer) { Target.TypeNameSerializer = typeNameSerializer; return (TBuilder)this; } + + public TBuilder MessageStore(IMessageStore messageStore) { + Target.MessageStore = messageStore; + return (TBuilder)this; + } } } \ No newline at end of file diff --git a/src/Foundatio/Utility/MaintenanceBase.cs b/src/Foundatio/Utility/MaintenanceBase.cs index 44e6f8237..ec185d3b2 100644 --- a/src/Foundatio/Utility/MaintenanceBase.cs +++ b/src/Foundatio/Utility/MaintenanceBase.cs @@ -19,6 +19,9 @@ protected void InitializeMaintenance(TimeSpan? dueTime = null, TimeSpan? interva } protected void ScheduleNextMaintenance(DateTime utcDate) { + if (_maintenanceTimer == null) + return; + _maintenanceTimer.ScheduleNext(utcDate); } From 40526459b96a239439706cf422c74540ce81313a Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 10 May 2019 16:15:51 -0500 Subject: [PATCH 13/29] Change from Headers to Properties --- src/Foundatio/Messaging/IMessage.cs | 12 ++++++------ src/Foundatio/Messaging/IMessageContext.cs | 6 +++--- src/Foundatio/Messaging/IMessagePublisher.cs | 11 ++++++----- src/Foundatio/Messaging/IMessageStore.cs | 4 ++-- src/Foundatio/Messaging/InMemoryMessageBus.cs | 2 +- src/Foundatio/Messaging/MessageBusBase.cs | 8 +++++--- 6 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/Foundatio/Messaging/IMessage.cs b/src/Foundatio/Messaging/IMessage.cs index 8953a53fd..66e6260d6 100644 --- a/src/Foundatio/Messaging/IMessage.cs +++ b/src/Foundatio/Messaging/IMessage.cs @@ -18,19 +18,19 @@ public interface IMessage { // when the message should be delivered when using delayed delivery DateTime? DeliverAtUtc { get; } // additional message data to store with the message - IReadOnlyDictionary Headers { get; } + IReadOnlyDictionary Properties { get; } } public class Message : IMessage { private Lazy _body; - public Message(Func getBodyFunc, Type messageType, string coorelationId, DateTime? expiresAtUtc, DateTime? deliverAtUtc, IReadOnlyDictionary headers) { + public Message(Func getBodyFunc, Type messageType, string coorelationId, DateTime? expiresAtUtc, DateTime? deliverAtUtc, IReadOnlyDictionary properties) { _body = new Lazy(getBodyFunc); MessageType = messageType; CorrelationId = coorelationId; ExpiresAtUtc = expiresAtUtc; DeliverAtUtc = deliverAtUtc; - Headers = headers; + Properties = properties; } public Message(Func getBodyFunc, MessagePublishOptions options) { @@ -39,14 +39,14 @@ public Message(Func getBodyFunc, MessagePublishOptions options) { MessageType = options.MessageType; ExpiresAtUtc = options.ExpiresAtUtc; DeliverAtUtc = options.DeliverAtUtc; - Headers = options.Headers; + Properties = options.Properties; } public string CorrelationId { get; private set; } public Type MessageType { get; private set; } public DateTime? ExpiresAtUtc { get; private set; } public DateTime? DeliverAtUtc { get; private set; } - public IReadOnlyDictionary Headers { get; private set; } + public IReadOnlyDictionary Properties { get; private set; } public object GetBody() { return _body.Value; @@ -70,7 +70,7 @@ public Message(IMessage message) { public Type MessageType => _message.MessageType; public DateTime? ExpiresAtUtc => _message.ExpiresAtUtc; public DateTime? DeliverAtUtc => _message.DeliverAtUtc; - public IReadOnlyDictionary Headers => _message.Headers; + public IReadOnlyDictionary Properties => _message.Properties; public object GetBody() => _message.GetBody(); } } diff --git a/src/Foundatio/Messaging/IMessageContext.cs b/src/Foundatio/Messaging/IMessageContext.cs index 2b825580b..e5418654b 100644 --- a/src/Foundatio/Messaging/IMessageContext.cs +++ b/src/Foundatio/Messaging/IMessageContext.cs @@ -10,7 +10,7 @@ public interface IMessageContext : IMessage, IDisposable { string Id { get; } // message subscription id that received the message string SubscriptionId { get; } - // when the message was originally created + // when the message was originally published DateTime PublishedUtc { get; } // number of times this message has been delivered int DeliveryCount { get; } @@ -40,7 +40,7 @@ public MessageContext(IMessageContext context) { public Type MessageType => _context.MessageType; public DateTime? ExpiresAtUtc => _context.ExpiresAtUtc; public DateTime? DeliverAtUtc => _context.DeliverAtUtc; - public IReadOnlyDictionary Headers => _context.Headers; + public IReadOnlyDictionary Properties => _context.Properties; public T Body => (T)GetBody(); public object GetBody() { @@ -89,7 +89,7 @@ public MessageContext(string id, string subscriptionId, DateTime createdUtc, int public Type MessageType => _message.MessageType; public DateTime? ExpiresAtUtc => _message.ExpiresAtUtc; public DateTime? DeliverAtUtc => _message.DeliverAtUtc; - public IReadOnlyDictionary Headers => _message.Headers; + public IReadOnlyDictionary Properties => _message.Properties; public object GetBody() { return _message.GetBody(); diff --git a/src/Foundatio/Messaging/IMessagePublisher.cs b/src/Foundatio/Messaging/IMessagePublisher.cs index 66f4d52f8..975346921 100644 --- a/src/Foundatio/Messaging/IMessagePublisher.cs +++ b/src/Foundatio/Messaging/IMessagePublisher.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Foundatio.Utility; @@ -13,7 +14,7 @@ public class MessagePublishOptions { public string CorrelationId { get; set; } public DateTime? ExpiresAtUtc { get; set; } public DateTime? DeliverAtUtc { get; set; } - public DataDictionary Headers { get; set; } = new DataDictionary(); + public Dictionary Properties { get; set; } = new Dictionary(); public CancellationToken CancellationToken { get; set; } public MessagePublishOptions WithMessageType(Type messageType) { @@ -36,13 +37,13 @@ public MessagePublishOptions WithDeliverAtUtc(DateTime? deliverAtUtc) { return this; } - public MessagePublishOptions WithHeaders(DataDictionary headers) { - Headers.AddRange(headers); + public MessagePublishOptions WithProperties(IDictionary properties) { + Properties.AddRange(properties); return this; } - public MessagePublishOptions WithHeader(string name, object value) { - Headers.Add(name, value); + public MessagePublishOptions WithProperty(string name, string value) { + Properties.Add(name, value); return this; } diff --git a/src/Foundatio/Messaging/IMessageStore.cs b/src/Foundatio/Messaging/IMessageStore.cs index 38d8ee42f..cb62a9969 100644 --- a/src/Foundatio/Messaging/IMessageStore.cs +++ b/src/Foundatio/Messaging/IMessageStore.cs @@ -16,13 +16,13 @@ public interface IMessageStore { public class PersistedMessage { public string Id { get; set; } - public DateTime CreatedUtc { get; set; } + public DateTime PublishedUtc { get; set; } public string CorrelationId { get; set; } public string MessageTypeName { get; set; } public byte[] Body { get; set; } public DateTime? ExpiresAtUtc { get; set; } public DateTime? DeliverAtUtc { get; set; } - public DataDictionary Headers { get; set; } + public IReadOnlyDictionary Properties { get; set; } } public class InMemoryMessageStore : IMessageStore { diff --git a/src/Foundatio/Messaging/InMemoryMessageBus.cs b/src/Foundatio/Messaging/InMemoryMessageBus.cs index 9644d26de..70012b92b 100644 --- a/src/Foundatio/Messaging/InMemoryMessageBus.cs +++ b/src/Foundatio/Messaging/InMemoryMessageBus.cs @@ -87,7 +87,7 @@ protected void SendMessageToSubscribers(byte[] body, MessagePublishOptions optio _logger.LogTrace("Calling subscriber action: {SubscriberId}", subscriber.Id); try { - var message = new Message(getBody, options.MessageType, options.CorrelationId, options.ExpiresAtUtc, options.DeliverAtUtc, options.Headers); + var message = new Message(getBody, options.MessageType, options.CorrelationId, options.ExpiresAtUtc, options.DeliverAtUtc, options.Properties); var context = new MessageContext(messageId, subscriber.Id, createdUtc, 1, message, () => Task.CompletedTask, () => Task.CompletedTask, () => {}, options.CancellationToken); await subscriber.Action(context).AnyContext(); if (isTraceLogLevelEnabled) diff --git a/src/Foundatio/Messaging/MessageBusBase.cs b/src/Foundatio/Messaging/MessageBusBase.cs index 84b799067..3df279815 100644 --- a/src/Foundatio/Messaging/MessageBusBase.cs +++ b/src/Foundatio/Messaging/MessageBusBase.cs @@ -56,13 +56,13 @@ public async Task PublishAsync(object message, MessagePublishOptions options) { var typeName = _typeNameSerializer.Serialize(options.MessageType); await _store.AddAsync(new PersistedMessage { Id = Guid.NewGuid().ToString("N"), - CreatedUtc = SystemClock.UtcNow, + PublishedUtc = SystemClock.UtcNow, CorrelationId = options.CorrelationId, MessageTypeName = typeName, Body = body, ExpiresAtUtc = options.ExpiresAtUtc, DeliverAtUtc = options.DeliverAtUtc, - Headers = options.Headers + Properties = options.Properties }); ScheduleNextMaintenance(options.DeliverAtUtc.Value); @@ -101,12 +101,14 @@ protected bool MessageTypeHasSubscribers(Type messageType) { var pendingMessages = await _store.GetPendingAsync(SystemClock.UtcNow); foreach (var pendingMessage in pendingMessages) { var messageType = _typeNameSerializer.Deserialize(pendingMessage.MessageTypeName); + var properties = new Dictionary(); + properties.AddRange(pendingMessage.Properties); await PublishImplAsync(pendingMessage.Body, new MessagePublishOptions { CorrelationId = pendingMessage.CorrelationId, DeliverAtUtc = pendingMessage.DeliverAtUtc, ExpiresAtUtc = pendingMessage.ExpiresAtUtc, MessageType = messageType, - Headers = pendingMessage.Headers + Properties = properties }).AnyContext(); } From 2d484781ead94ba982e54520b575aa1f0a0eb148 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 10 May 2019 16:23:13 -0500 Subject: [PATCH 14/29] Simplify IMessageStore --- src/Foundatio/Messaging/IMessageStore.cs | 8 +++----- src/Foundatio/Messaging/MessageBusBase.cs | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Foundatio/Messaging/IMessageStore.cs b/src/Foundatio/Messaging/IMessageStore.cs index cb62a9969..67b10c5ad 100644 --- a/src/Foundatio/Messaging/IMessageStore.cs +++ b/src/Foundatio/Messaging/IMessageStore.cs @@ -10,7 +10,7 @@ namespace Foundatio.Messaging { public interface IMessageStore { Task AddAsync(PersistedMessage message); Task RemoveAsync(string[] ids); - Task> GetPendingAsync(DateTime? dateUtc = null); + Task> GetReadyForDeliveryAsync(); Task RemoveAllAsync(); } @@ -38,12 +38,10 @@ public Task AddAsync(PersistedMessage message) { return Task.CompletedTask; } - public Task> GetPendingAsync(DateTime? dueAfterDateUtc = null) { - var dueDate = dueAfterDateUtc ?? DateTime.UtcNow; - + public Task> GetReadyForDeliveryAsync() { var dueList = new List(); foreach (var message in _messages) { - if (!message.IsProcessing && message.Message.DeliverAtUtc < dueDate && message.MarkProcessing()) + if (!message.IsProcessing && message.Message.DeliverAtUtc < SystemClock.UtcNow && message.MarkProcessing()) dueList.Add(message.Message); } diff --git a/src/Foundatio/Messaging/MessageBusBase.cs b/src/Foundatio/Messaging/MessageBusBase.cs index 3df279815..abd9ef7d9 100644 --- a/src/Foundatio/Messaging/MessageBusBase.cs +++ b/src/Foundatio/Messaging/MessageBusBase.cs @@ -98,7 +98,7 @@ protected bool MessageTypeHasSubscribers(Type messageType) { } protected override async Task DoMaintenanceAsync() { - var pendingMessages = await _store.GetPendingAsync(SystemClock.UtcNow); + var pendingMessages = await _store.GetReadyForDeliveryAsync(); foreach (var pendingMessage in pendingMessages) { var messageType = _typeNameSerializer.Deserialize(pendingMessage.MessageTypeName); var properties = new Dictionary(); From cf168d54a30cc22158b4c2094be24ac275e2b191 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 11 May 2019 14:21:00 -0500 Subject: [PATCH 15/29] Limit in memory message sending to 50 at a time --- src/Foundatio/Messaging/InMemoryMessageBus.cs | 10 +- src/Foundatio/Messaging/MessageBusBase.cs | 4 - .../LimitedConcurrencyLevelTaskScheduler.cs | 112 ++++++++++++++++++ 3 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 src/Foundatio/Utility/LimitedConcurrencyLevelTaskScheduler.cs diff --git a/src/Foundatio/Messaging/InMemoryMessageBus.cs b/src/Foundatio/Messaging/InMemoryMessageBus.cs index 70012b92b..4899cf62a 100644 --- a/src/Foundatio/Messaging/InMemoryMessageBus.cs +++ b/src/Foundatio/Messaging/InMemoryMessageBus.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using Foundatio.Utility; @@ -14,10 +12,14 @@ namespace Foundatio.Messaging { public class InMemoryMessageBus : MessageBusBase { private readonly ConcurrentDictionary _messageCounts = new(); private long _messagesSent; + private readonly TaskFactory _taskFactory; public InMemoryMessageBus() : this(o => o) {} - public InMemoryMessageBus(InMemoryMessageBusOptions options) : base(options) { } + public InMemoryMessageBus(InMemoryMessageBusOptions options) : base(options) { + // limit message processing to 50 at a time + _taskFactory = new TaskFactory(new LimitedConcurrencyLevelTaskScheduler(50)); + } public InMemoryMessageBus(Builder config) : this(config(new InMemoryMessageBusOptionsBuilder()).Build()) { } @@ -75,7 +77,7 @@ protected void SendMessageToSubscribers(byte[] body, MessagePublishOptions optio _logger.LogTrace("Found {SubscriberCount} subscribers for message type {MessageType}.", subscribers.Length, options.MessageType.Name); foreach (var subscriber in subscribers) { - Task.Factory.StartNew(async () => { + _taskFactory.StartNew(async () => { if (subscriber.IsCancelled) { if (isTraceLogLevelEnabled) _logger.LogTrace("The cancelled subscriber action will not be called: {SubscriberId}", subscriber.Id); diff --git a/src/Foundatio/Messaging/MessageBusBase.cs b/src/Foundatio/Messaging/MessageBusBase.cs index abd9ef7d9..b33c00795 100644 --- a/src/Foundatio/Messaging/MessageBusBase.cs +++ b/src/Foundatio/Messaging/MessageBusBase.cs @@ -1,9 +1,6 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using Foundatio.Serializer; @@ -22,7 +19,6 @@ public abstract class MessageBusBase : MaintenanceBase, IMessageBus, I public MessageBusBase(TOptions options) : base(options.LoggerFactory) { _options = options ?? throw new ArgumentNullException(nameof(options)); - var loggerFactory = options?.LoggerFactory ?? NullLoggerFactory.Instance; _serializer = options.Serializer ?? DefaultSerializer.Instance; _typeNameSerializer = options.TypeNameSerializer ?? new DefaultTypeNameSerializer(_logger); _store = options.MessageStore ?? new InMemoryMessageStore(_logger); diff --git a/src/Foundatio/Utility/LimitedConcurrencyLevelTaskScheduler.cs b/src/Foundatio/Utility/LimitedConcurrencyLevelTaskScheduler.cs new file mode 100644 index 000000000..c63255e69 --- /dev/null +++ b/src/Foundatio/Utility/LimitedConcurrencyLevelTaskScheduler.cs @@ -0,0 +1,112 @@ +// https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskscheduler + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Foundatio.Utility { + // Provides a task scheduler that ensures a maximum concurrency level while + // running on top of the thread pool. + public class LimitedConcurrencyLevelTaskScheduler : TaskScheduler { + // Indicates whether the current thread is processing work items. + [ThreadStatic] + private static bool _currentThreadIsProcessingItems; + + // The list of tasks to be executed + private readonly LinkedList _tasks = new LinkedList(); // protected by lock(_tasks) + + // The maximum concurrency level allowed by this scheduler. + private readonly int _maxDegreeOfParallelism; + + // Indicates whether the scheduler is currently processing work items. + private int _delegatesQueuedOrRunning = 0; + + // Creates a new instance with the specified degree of parallelism. + public LimitedConcurrencyLevelTaskScheduler(int maxDegreeOfParallelism) { + if (maxDegreeOfParallelism < 1) throw new ArgumentOutOfRangeException("maxDegreeOfParallelism"); + _maxDegreeOfParallelism = maxDegreeOfParallelism; + } + + // Queues a task to the scheduler. + protected sealed override void QueueTask(Task task) { + // Add the task to the list of tasks to be processed. If there aren't enough + // delegates currently queued or running to process tasks, schedule another. + lock (_tasks) { + _tasks.AddLast(task); + if (_delegatesQueuedOrRunning < _maxDegreeOfParallelism) { + ++_delegatesQueuedOrRunning; + NotifyThreadPoolOfPendingWork(); + } + } + } + + // Inform the ThreadPool that there's work to be executed for this scheduler. + private void NotifyThreadPoolOfPendingWork() { + ThreadPool.UnsafeQueueUserWorkItem(_ => { + // Note that the current thread is now processing work items. + // This is necessary to enable inlining of tasks into this thread. + _currentThreadIsProcessingItems = true; + try { + // Process all available items in the queue. + while (true) { + Task item; + lock (_tasks) { + // When there are no more items to be processed, + // note that we're done processing, and get out. + if (_tasks.Count == 0) { + --_delegatesQueuedOrRunning; + break; + } + + // Get the next item from the queue + item = _tasks.First.Value; + _tasks.RemoveFirst(); + } + + // Execute the task we pulled out of the queue + TryExecuteTask(item); + } + } + // We're done processing items on the current thread + finally { _currentThreadIsProcessingItems = false; } + }, null); + } + + // Attempts to execute the specified task on the current thread. + protected sealed override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) { + // If this thread isn't already processing a task, we don't support inlining + if (!_currentThreadIsProcessingItems) return false; + + // If the task was previously queued, remove it from the queue + if (taskWasPreviouslyQueued) + // Try to run the task. + if (TryDequeue(task)) + return TryExecuteTask(task); + else + return false; + else + return TryExecuteTask(task); + } + + // Attempt to remove a previously scheduled task from the scheduler. + protected sealed override bool TryDequeue(Task task) { + lock (_tasks) return _tasks.Remove(task); + } + + // Gets the maximum concurrency level supported by this scheduler. + public sealed override int MaximumConcurrencyLevel { get { return _maxDegreeOfParallelism; } } + + // Gets an enumerable of the tasks currently scheduled on this scheduler. + protected sealed override IEnumerable GetScheduledTasks() { + bool lockTaken = false; + try { + Monitor.TryEnter(_tasks, ref lockTaken); + if (lockTaken) return _tasks; + else throw new NotSupportedException(); + } finally { + if (lockTaken) Monitor.Exit(_tasks); + } + } + } +} \ No newline at end of file From 3bfcadd5a8212ef6a5da299924bd807e3057633b Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 11 May 2019 14:21:19 -0500 Subject: [PATCH 16/29] Cleanup unused namespaces --- src/Foundatio.AppMetrics/AppMetricsClient.cs | 3 +-- .../Startup/IStartupAction.cs | 3 +-- src/Foundatio.MetricsNET/MetricsNETClient.cs | 3 +-- .../Serializer/SerializerTestsBase.cs | 3 +-- .../DeepCloner/Helpers/DeepClonerSafeTypes.cs | 12 +++++------- .../DeepCloner/Helpers/ShallowObjectCloner.cs | 2 -- src/Foundatio/Messaging/IMessage.cs | 4 ---- src/Foundatio/Messaging/IMessageContext.cs | 1 - src/Foundatio/Messaging/IMessageSubscription.cs | 4 ---- src/Foundatio/Messaging/NullMessageBus.cs | 3 +-- src/Foundatio/Messaging/SharedMessageBusOptions.cs | 3 --- src/Foundatio/Metrics/IMetricsClient.cs | 1 - src/Foundatio/Metrics/InMemoryMetricsClient.cs | 3 +-- src/Foundatio/Metrics/MetricTimer.cs | 2 -- src/Foundatio/Metrics/NullMetricsClient.cs | 4 +--- src/Foundatio/Queues/IQueueEntry.cs | 4 +--- src/Foundatio/Utility/ConnectionStringParser.cs | 3 --- src/Foundatio/Utility/IAsyncLifetime.cs | 2 -- src/Foundatio/Utility/OptionsBuilder.cs | 4 +--- src/Foundatio/Utility/SharedOptions.cs | 1 - .../Caching/InMemoryHybridCacheClientTests.cs | 3 +-- tests/Foundatio.Tests/Metrics/StatsDMetricsTests.cs | 4 ---- .../Serializer/JsonNetSerializerTests.cs | 3 +-- .../Serializer/MessagePackSerializerTests.cs | 8 +------- .../Serializer/Utf8JsonSerializerTests.cs | 3 +-- .../Utility/ConnectionStringParserTests.cs | 3 --- 26 files changed, 18 insertions(+), 71 deletions(-) diff --git a/src/Foundatio.AppMetrics/AppMetricsClient.cs b/src/Foundatio.AppMetrics/AppMetricsClient.cs index 4af0cf4be..114ee7469 100644 --- a/src/Foundatio.AppMetrics/AppMetricsClient.cs +++ b/src/Foundatio.AppMetrics/AppMetricsClient.cs @@ -1,5 +1,4 @@ -using System; -using App.Metrics; +using App.Metrics; using App.Metrics.Counter; using App.Metrics.Gauge; using App.Metrics.Timer; diff --git a/src/Foundatio.Extensions.Hosting/Startup/IStartupAction.cs b/src/Foundatio.Extensions.Hosting/Startup/IStartupAction.cs index 7d38f1570..e811149f7 100644 --- a/src/Foundatio.Extensions.Hosting/Startup/IStartupAction.cs +++ b/src/Foundatio.Extensions.Hosting/Startup/IStartupAction.cs @@ -1,5 +1,4 @@ -using System; -using System.Threading; +using System.Threading; using System.Threading.Tasks; namespace Foundatio.Extensions.Hosting.Startup { diff --git a/src/Foundatio.MetricsNET/MetricsNETClient.cs b/src/Foundatio.MetricsNET/MetricsNETClient.cs index f60048086..6ff08bfca 100644 --- a/src/Foundatio.MetricsNET/MetricsNETClient.cs +++ b/src/Foundatio.MetricsNET/MetricsNETClient.cs @@ -1,5 +1,4 @@ -using System; -using Metrics; +using Metrics; namespace Foundatio.Metrics { public class MetricsNETClient : IMetricsClient { diff --git a/src/Foundatio.TestHarness/Serializer/SerializerTestsBase.cs b/src/Foundatio.TestHarness/Serializer/SerializerTestsBase.cs index 3f98aa427..0c9f1d049 100644 --- a/src/Foundatio.TestHarness/Serializer/SerializerTestsBase.cs +++ b/src/Foundatio.TestHarness/Serializer/SerializerTestsBase.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using BenchmarkDotNet.Attributes; using Foundatio.Xunit; using Foundatio.Serializer; diff --git a/src/Foundatio/DeepCloner/Helpers/DeepClonerSafeTypes.cs b/src/Foundatio/DeepCloner/Helpers/DeepClonerSafeTypes.cs index 1e29f3a45..de556a4f4 100644 --- a/src/Foundatio/DeepCloner/Helpers/DeepClonerSafeTypes.cs +++ b/src/Foundatio/DeepCloner/Helpers/DeepClonerSafeTypes.cs @@ -2,15 +2,13 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; using System.Reflection; -namespace Foundatio.Force.DeepCloner.Helpers -{ - /// - /// Safe types are types, which can be copied without real cloning. e.g. simple structs or strings (it is immutable) - /// - internal static class DeepClonerSafeTypes +namespace Foundatio.Force.DeepCloner.Helpers { + /// + /// Safe types are types, which can be copied without real cloning. e.g. simple structs or strings (it is immutable) + /// + internal static class DeepClonerSafeTypes { internal static readonly ConcurrentDictionary KnownTypes = new(); diff --git a/src/Foundatio/DeepCloner/Helpers/ShallowObjectCloner.cs b/src/Foundatio/DeepCloner/Helpers/ShallowObjectCloner.cs index 90e4618f3..6835b0c18 100644 --- a/src/Foundatio/DeepCloner/Helpers/ShallowObjectCloner.cs +++ b/src/Foundatio/DeepCloner/Helpers/ShallowObjectCloner.cs @@ -1,8 +1,6 @@ #define NETCORE using System; using System.Linq.Expressions; -using System.Reflection; -using System.Reflection.Emit; namespace Foundatio.Force.DeepCloner.Helpers { diff --git a/src/Foundatio/Messaging/IMessage.cs b/src/Foundatio/Messaging/IMessage.cs index 66e6260d6..9593db5ba 100644 --- a/src/Foundatio/Messaging/IMessage.cs +++ b/src/Foundatio/Messaging/IMessage.cs @@ -1,9 +1,5 @@ using System; using System.Collections.Generic; -using Foundatio.Serializer; -using Foundatio.Utility; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; namespace Foundatio.Messaging { public interface IMessage { diff --git a/src/Foundatio/Messaging/IMessageContext.cs b/src/Foundatio/Messaging/IMessageContext.cs index e5418654b..3d2685944 100644 --- a/src/Foundatio/Messaging/IMessageContext.cs +++ b/src/Foundatio/Messaging/IMessageContext.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Foundatio.Utility; namespace Foundatio.Messaging { public interface IMessageContext : IMessage, IDisposable { diff --git a/src/Foundatio/Messaging/IMessageSubscription.cs b/src/Foundatio/Messaging/IMessageSubscription.cs index f6e82b3d9..2945d4f86 100644 --- a/src/Foundatio/Messaging/IMessageSubscription.cs +++ b/src/Foundatio/Messaging/IMessageSubscription.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; -using Foundatio.Utility; namespace Foundatio.Messaging { public interface IMessageSubscription : IDisposable { diff --git a/src/Foundatio/Messaging/NullMessageBus.cs b/src/Foundatio/Messaging/NullMessageBus.cs index 7d03c2789..8c291dc95 100644 --- a/src/Foundatio/Messaging/NullMessageBus.cs +++ b/src/Foundatio/Messaging/NullMessageBus.cs @@ -1,5 +1,4 @@ -using System; -using System.Threading; +using System; using System.Threading.Tasks; namespace Foundatio.Messaging { diff --git a/src/Foundatio/Messaging/SharedMessageBusOptions.cs b/src/Foundatio/Messaging/SharedMessageBusOptions.cs index 628472e12..68b5e309c 100644 --- a/src/Foundatio/Messaging/SharedMessageBusOptions.cs +++ b/src/Foundatio/Messaging/SharedMessageBusOptions.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace Foundatio.Messaging { public class SharedMessageBusOptions : SharedOptions { /// diff --git a/src/Foundatio/Metrics/IMetricsClient.cs b/src/Foundatio/Metrics/IMetricsClient.cs index 5d2c55b27..44ec6968d 100644 --- a/src/Foundatio/Metrics/IMetricsClient.cs +++ b/src/Foundatio/Metrics/IMetricsClient.cs @@ -1,6 +1,5 @@ using System; using System.Threading.Tasks; -using Foundatio.Utility; namespace Foundatio.Metrics { [Obsolete("IMetricsClient will be removed, use System.Diagnostics.Metrics.Meter instead.")] diff --git a/src/Foundatio/Metrics/InMemoryMetricsClient.cs b/src/Foundatio/Metrics/InMemoryMetricsClient.cs index da60a6563..283fb8efc 100644 --- a/src/Foundatio/Metrics/InMemoryMetricsClient.cs +++ b/src/Foundatio/Metrics/InMemoryMetricsClient.cs @@ -1,5 +1,4 @@ -using System; -using Foundatio.Caching; +using Foundatio.Caching; namespace Foundatio.Metrics { public class InMemoryMetricsClient : CacheBucketMetricsClientBase { diff --git a/src/Foundatio/Metrics/MetricTimer.cs b/src/Foundatio/Metrics/MetricTimer.cs index ff84b479d..aff483d4e 100644 --- a/src/Foundatio/Metrics/MetricTimer.cs +++ b/src/Foundatio/Metrics/MetricTimer.cs @@ -1,7 +1,5 @@ using System; using System.Diagnostics; -using System.Threading.Tasks; -using Foundatio.Utility; namespace Foundatio.Metrics { public class MetricTimer : IDisposable { diff --git a/src/Foundatio/Metrics/NullMetricsClient.cs b/src/Foundatio/Metrics/NullMetricsClient.cs index 409f4b004..de2134e7e 100644 --- a/src/Foundatio/Metrics/NullMetricsClient.cs +++ b/src/Foundatio/Metrics/NullMetricsClient.cs @@ -1,6 +1,4 @@ -using System.Threading.Tasks; - -namespace Foundatio.Metrics { +namespace Foundatio.Metrics { public class NullMetricsClient : IMetricsClient { public static readonly IMetricsClient Instance = new NullMetricsClient(); public void Counter(string name, int value = 1) {} diff --git a/src/Foundatio/Queues/IQueueEntry.cs b/src/Foundatio/Queues/IQueueEntry.cs index 4d178f622..10b2f39d6 100644 --- a/src/Foundatio/Queues/IQueueEntry.cs +++ b/src/Foundatio/Queues/IQueueEntry.cs @@ -1,6 +1,4 @@ -using System; -using System.Threading.Tasks; -using Foundatio.Utility; +using System.Threading.Tasks; namespace Foundatio.Queues { public interface IQueueEntry { diff --git a/src/Foundatio/Utility/ConnectionStringParser.cs b/src/Foundatio/Utility/ConnectionStringParser.cs index a4ef473f0..4d6dc7e12 100644 --- a/src/Foundatio/Utility/ConnectionStringParser.cs +++ b/src/Foundatio/Utility/ConnectionStringParser.cs @@ -1,11 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; -using System.Linq; using System.Text; using System.Text.RegularExpressions; -using Foundatio.Utility; namespace Foundatio.Utility { public static class ConnectionStringParser { diff --git a/src/Foundatio/Utility/IAsyncLifetime.cs b/src/Foundatio/Utility/IAsyncLifetime.cs index dce9921e2..18c88b540 100644 --- a/src/Foundatio/Utility/IAsyncLifetime.cs +++ b/src/Foundatio/Utility/IAsyncLifetime.cs @@ -1,5 +1,3 @@ -using System; -using System.Runtime.ExceptionServices; using System.Threading.Tasks; namespace Foundatio.Utility { diff --git a/src/Foundatio/Utility/OptionsBuilder.cs b/src/Foundatio/Utility/OptionsBuilder.cs index 3d4399d54..ec79cfa43 100644 --- a/src/Foundatio/Utility/OptionsBuilder.cs +++ b/src/Foundatio/Utility/OptionsBuilder.cs @@ -1,6 +1,4 @@ -using System; - -namespace Foundatio { +namespace Foundatio { public interface IOptionsBuilder { object Target { get; } } diff --git a/src/Foundatio/Utility/SharedOptions.cs b/src/Foundatio/Utility/SharedOptions.cs index 37b564bd1..0ad2e30e3 100644 --- a/src/Foundatio/Utility/SharedOptions.cs +++ b/src/Foundatio/Utility/SharedOptions.cs @@ -1,4 +1,3 @@ -using System; using Foundatio.Serializer; using Microsoft.Extensions.Logging; diff --git a/tests/Foundatio.Tests/Caching/InMemoryHybridCacheClientTests.cs b/tests/Foundatio.Tests/Caching/InMemoryHybridCacheClientTests.cs index da4c4cec6..e56c19e87 100644 --- a/tests/Foundatio.Tests/Caching/InMemoryHybridCacheClientTests.cs +++ b/tests/Foundatio.Tests/Caching/InMemoryHybridCacheClientTests.cs @@ -1,5 +1,4 @@ -using System; -using System.Threading.Tasks; +using System.Threading.Tasks; using Foundatio.Caching; using Foundatio.Messaging; using Microsoft.Extensions.Logging; diff --git a/tests/Foundatio.Tests/Metrics/StatsDMetricsTests.cs b/tests/Foundatio.Tests/Metrics/StatsDMetricsTests.cs index ce9adcdef..d7d53bfdb 100644 --- a/tests/Foundatio.Tests/Metrics/StatsDMetricsTests.cs +++ b/tests/Foundatio.Tests/Metrics/StatsDMetricsTests.cs @@ -1,14 +1,10 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Foundatio.Xunit; using Foundatio.Metrics; using Foundatio.Tests.Utility; using Foundatio.Utility; -using Microsoft.Extensions.Logging; using Xunit; using Xunit.Abstractions; diff --git a/tests/Foundatio.Tests/Serializer/JsonNetSerializerTests.cs b/tests/Foundatio.Tests/Serializer/JsonNetSerializerTests.cs index e20dda87e..1047ed899 100644 --- a/tests/Foundatio.Tests/Serializer/JsonNetSerializerTests.cs +++ b/tests/Foundatio.Tests/Serializer/JsonNetSerializerTests.cs @@ -1,5 +1,4 @@ -using System; -using Foundatio.Serializer; +using Foundatio.Serializer; using Foundatio.TestHarness.Utility; using Microsoft.Extensions.Logging; using Xunit; diff --git a/tests/Foundatio.Tests/Serializer/MessagePackSerializerTests.cs b/tests/Foundatio.Tests/Serializer/MessagePackSerializerTests.cs index 3c072aeff..554c085aa 100644 --- a/tests/Foundatio.Tests/Serializer/MessagePackSerializerTests.cs +++ b/tests/Foundatio.Tests/Serializer/MessagePackSerializerTests.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Exporters.Json; -using BenchmarkDotNet.Loggers; -using BenchmarkDotNet.Reports; -using Foundatio.Serializer; +using Foundatio.Serializer; using Foundatio.TestHarness.Utility; using Microsoft.Extensions.Logging; using Xunit; diff --git a/tests/Foundatio.Tests/Serializer/Utf8JsonSerializerTests.cs b/tests/Foundatio.Tests/Serializer/Utf8JsonSerializerTests.cs index 1c25b2773..96aa6992d 100644 --- a/tests/Foundatio.Tests/Serializer/Utf8JsonSerializerTests.cs +++ b/tests/Foundatio.Tests/Serializer/Utf8JsonSerializerTests.cs @@ -1,5 +1,4 @@ -using System; -using Foundatio.Serializer; +using Foundatio.Serializer; using Foundatio.TestHarness.Utility; using Microsoft.Extensions.Logging; using Xunit; diff --git a/tests/Foundatio.Tests/Utility/ConnectionStringParserTests.cs b/tests/Foundatio.Tests/Utility/ConnectionStringParserTests.cs index 1cf3a6e75..08f7956ba 100644 --- a/tests/Foundatio.Tests/Utility/ConnectionStringParserTests.cs +++ b/tests/Foundatio.Tests/Utility/ConnectionStringParserTests.cs @@ -1,8 +1,5 @@ using System; -using System.Collections.Generic; -using Foundatio.Xunit; using Xunit; -using Xunit.Abstractions; using Foundatio.Utility; namespace Foundatio.Tests.Utility { From ad4a4cc080434e9a1610e2c76e706f80232051e0 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 12 May 2019 13:37:21 -0500 Subject: [PATCH 17/29] Adding new WorkScheduler and other messaging progress --- .../Messaging/MessageBusTestBase.cs | 47 +++++++- src/Foundatio/Messaging/IMessageStore.cs | 42 +++++-- src/Foundatio/Messaging/IMessageSubscriber.cs | 4 +- src/Foundatio/Messaging/MessageBusBase.cs | 27 +++-- src/Foundatio/Utility/SortedQueue.cs | 110 ++++++++++++++++++ src/Foundatio/Utility/SystemClock.cs | 18 ++- src/Foundatio/Utility/WorkScheduler.cs | 80 +++++++++++++ .../Messaging/InMemoryMessageBusTests.cs | 7 +- 8 files changed, 304 insertions(+), 31 deletions(-) create mode 100644 src/Foundatio/Utility/SortedQueue.cs create mode 100644 src/Foundatio/Utility/WorkScheduler.cs diff --git a/src/Foundatio.TestHarness/Messaging/MessageBusTestBase.cs b/src/Foundatio.TestHarness/Messaging/MessageBusTestBase.cs index 49cc1dd10..db3f469bf 100644 --- a/src/Foundatio.TestHarness/Messaging/MessageBusTestBase.cs +++ b/src/Foundatio.TestHarness/Messaging/MessageBusTestBase.cs @@ -136,7 +136,43 @@ await messageBus.PublishAsync(new SimpleMessageA { } public virtual async Task CanSendDelayedMessageAsync() { - const int numConcurrentMessages = 1000; + Log.MinimumLevel = LogLevel.Trace; + var messageBus = GetMessageBus(); + if (messageBus == null) + return; + + try { + var countdown = new AsyncCountdownEvent(1); + await messageBus.SubscribeAsync(msg => { + var msgDelay = TimeSpan.FromMilliseconds(msg.Count); + _logger.LogTrace("Got message delayed by {Delay:g}.", msgDelay); + + Assert.Equal("Hello", msg.Data); + countdown.Signal(); + }); + + var sw = Stopwatch.StartNew(); + var delay = TimeSpan.FromMilliseconds(RandomData.GetInt(250, 1500)); + await messageBus.PublishAsync(new SimpleMessageA { + Data = "Hello", + Count = (int)delay.TotalMilliseconds + }, delay); + _logger.LogTrace("Published message..."); + + await countdown.WaitAsync(TimeSpan.FromSeconds(30)); + sw.Stop(); + + _logger.LogTrace("Got message delayed by {Delay:g} in {Duration:g}", delay, sw.Elapsed); + Assert.Equal(0, countdown.CurrentCount); + Assert.InRange(sw.Elapsed.TotalMilliseconds, 50, 2000); + } finally { + await CleanupMessageBusAsync(messageBus); + } + } + + public virtual async Task CanSendParallelDelayedMessagesAsync() { + Log.MinimumLevel = LogLevel.Trace; + const int numConcurrentMessages = 100; var messageBus = GetMessageBus(); if (messageBus == null) return; @@ -158,17 +194,18 @@ await Run.InParallelAsync(numConcurrentMessages, async i => { await messageBus.PublishAsync(new SimpleMessageA { Data = "Hello", Count = i - }, new MessageOptions { DeliveryDelay = TimeSpan.FromMilliseconds(RandomData.GetInt(0, 100)) }); + }, TimeSpan.FromMilliseconds(RandomData.GetInt(100, 500))); if (i % 500 == 0) _logger.LogTrace("Published 500 messages..."); }); + _logger.LogTrace("Done publishing {Count} messages.", numConcurrentMessages); - await countdown.WaitAsync(TimeSpan.FromSeconds(5)); + await countdown.WaitAsync(TimeSpan.FromSeconds(30)); sw.Stop(); - if (_logger.IsEnabled(LogLevel.Trace)) _logger.LogTrace("Processed {Processed} in {Duration:g}", numConcurrentMessages - countdown.CurrentCount, sw.Elapsed); + _logger.LogTrace("Processed {Processed} in {Duration:g}", numConcurrentMessages - countdown.CurrentCount, sw.Elapsed); Assert.Equal(0, countdown.CurrentCount); - Assert.InRange(sw.Elapsed.TotalMilliseconds, 50, 5000); + Assert.InRange(sw.Elapsed.TotalMilliseconds, 100, 2000); } finally { await CleanupMessageBusAsync(messageBus); } diff --git a/src/Foundatio/Messaging/IMessageStore.cs b/src/Foundatio/Messaging/IMessageStore.cs index 67b10c5ad..46d2169de 100644 --- a/src/Foundatio/Messaging/IMessageStore.cs +++ b/src/Foundatio/Messaging/IMessageStore.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -26,7 +26,7 @@ public class PersistedMessage { } public class InMemoryMessageStore : IMessageStore { - private readonly List _messages = new List(); + private readonly List _messages = new List(); private readonly ILogger _logger; public InMemoryMessageStore(ILogger logger) { @@ -34,17 +34,33 @@ public InMemoryMessageStore(ILogger logger) { } public Task AddAsync(PersistedMessage message) { - _messages.Add(new InMemoryStoredMessage(message)); + _messages.Add(new InMemoryPersistedMessage(message)); return Task.CompletedTask; } public Task> GetReadyForDeliveryAsync() { var dueList = new List(); foreach (var message in _messages) { - if (!message.IsProcessing && message.Message.DeliverAtUtc < SystemClock.UtcNow && message.MarkProcessing()) - dueList.Add(message.Message); + if (message.IsProcessing) + continue; + + if (message.Message.DeliverAtUtc > SystemClock.UtcNow) + continue; + + if (!message.MarkProcessing()) + continue; + + dueList.Add(message.Message); + + if (dueList.Count >= 100) + break; } + if (_messages.Count <= 0) + _logger.LogTrace("No messages ready for delivery."); + else + _logger.LogTrace("Got {Count} / {Total} messages ready for delivery.", dueList.Count, _messages.Count); + return Task.FromResult>(dueList); } @@ -58,18 +74,24 @@ public Task RemoveAsync(string[] ids) { return Task.CompletedTask; } - protected class InMemoryStoredMessage { - public InMemoryStoredMessage(PersistedMessage message) { + protected class InMemoryPersistedMessage { + public InMemoryPersistedMessage(PersistedMessage message) { Message = message; } public PersistedMessage Message { get; set; } public bool IsProcessing { get { - if (_processing != 0 && _startedProcessing < SystemClock.Now.Subtract(TimeSpan.FromMinutes(1))) + if (_processing == 0) + return false; + + // check for timeout + if (SystemClock.UtcNow.Subtract(_startedProcessing) > TimeSpan.FromMinutes(1)) { _processing = 0; - - return _processing != 0; + return false; + } + + return true; } } diff --git a/src/Foundatio/Messaging/IMessageSubscriber.cs b/src/Foundatio/Messaging/IMessageSubscriber.cs index 52eee78a4..78305aca7 100644 --- a/src/Foundatio/Messaging/IMessageSubscriber.cs +++ b/src/Foundatio/Messaging/IMessageSubscriber.cs @@ -36,11 +36,11 @@ public static async Task SubscribeAsync(this IMessageSu return subscription; } - public static Task SubscribeAsync(this IMessageSubscriber subscriber, Func handler, CancellationToken cancellationToken = default) where T : class { + public static Task SubscribeAsync(this IMessageSubscriber subscriber, Func handler, CancellationToken cancellationToken = default) where T : class { return subscriber.SubscribeAsync((msg, token) => handler(msg), cancellationToken); } - public static Task SubscribeAsync(this IMessageSubscriber subscriber, Action handler, CancellationToken cancellationToken = default) where T : class { + public static Task SubscribeAsync(this IMessageSubscriber subscriber, Action handler, CancellationToken cancellationToken = default) where T : class { return subscriber.SubscribeAsync((msg, token) => { handler(msg); return Task.CompletedTask; diff --git a/src/Foundatio/Messaging/MessageBusBase.cs b/src/Foundatio/Messaging/MessageBusBase.cs index b33c00795..b69913663 100644 --- a/src/Foundatio/Messaging/MessageBusBase.cs +++ b/src/Foundatio/Messaging/MessageBusBase.cs @@ -9,21 +9,24 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Foundatio.Messaging { - public abstract class MessageBusBase : MaintenanceBase, IMessageBus, IDisposable where TOptions : SharedMessageBusOptions { + public abstract class MessageBusBase : IMessageBus, IDisposable where TOptions : SharedMessageBusOptions { protected readonly List _subscriptions = new List(); protected readonly TOptions _options; protected readonly ISerializer _serializer; protected readonly ITypeNameSerializer _typeNameSerializer; protected readonly IMessageStore _store; + protected readonly ILogger _logger; private bool _isDisposed; - public MessageBusBase(TOptions options) : base(options.LoggerFactory) { + public MessageBusBase(TOptions options) { _options = options ?? throw new ArgumentNullException(nameof(options)); _serializer = options.Serializer ?? DefaultSerializer.Instance; + var loggerFactory = options.LoggerFactory ?? NullLoggerFactory.Instance; + _logger = loggerFactory.CreateLogger(GetType()); _typeNameSerializer = options.TypeNameSerializer ?? new DefaultTypeNameSerializer(_logger); _store = options.MessageStore ?? new InMemoryMessageStore(_logger); MessageBusId = Guid.NewGuid().ToString("N"); - InitializeMaintenance(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); + SystemClock.ScheduleWork(DoMaintenanceAsync, SystemClock.UtcNow.AddSeconds(1), TimeSpan.FromSeconds(1)); } public string MessageBusId { get; protected set; } @@ -49,6 +52,7 @@ public async Task PublishAsync(object message, MessagePublishOptions options) { var body = _serializer.SerializeToBytes(message); if (options.DeliverAtUtc.HasValue && options.DeliverAtUtc > SystemClock.UtcNow) { + _logger.LogTrace("Storing message scheduled for delivery at {DeliverAt}.", options.DeliverAtUtc.Value); var typeName = _typeNameSerializer.Serialize(options.MessageType); await _store.AddAsync(new PersistedMessage { Id = Guid.NewGuid().ToString("N"), @@ -61,8 +65,6 @@ await _store.AddAsync(new PersistedMessage { Properties = options.Properties }); - ScheduleNextMaintenance(options.DeliverAtUtc.Value); - return; } @@ -93,12 +95,15 @@ protected bool MessageTypeHasSubscribers(Type messageType) { return subscribers.Count == 0; } - protected override async Task DoMaintenanceAsync() { + protected async Task DoMaintenanceAsync() { + _logger.LogTrace("Checking for stored messages that are ready for delivery..."); var pendingMessages = await _store.GetReadyForDeliveryAsync(); foreach (var pendingMessage in pendingMessages) { var messageType = _typeNameSerializer.Deserialize(pendingMessage.MessageTypeName); var properties = new Dictionary(); - properties.AddRange(pendingMessage.Properties); + if (pendingMessage.Properties != null) + properties.AddRange(pendingMessage.Properties); + await PublishImplAsync(pendingMessage.Body, new MessagePublishOptions { CorrelationId = pendingMessage.CorrelationId, DeliverAtUtc = pendingMessage.DeliverAtUtc, @@ -108,12 +113,12 @@ protected bool MessageTypeHasSubscribers(Type messageType) { }).AnyContext(); } - _subscriptions.RemoveAll(s => s.IsCancelled); - - return null; + int removed = _subscriptions.RemoveAll(s => s.IsCancelled); + if (removed > 0) + _logger.LogTrace("Removing {CancelledSubscriptionCount} cancelled subscriptions.", removed); } - public override void Dispose() { + public virtual void Dispose() { if (_isDisposed) { _logger.LogTrace("MessageBus {0} dispose was already called.", MessageBusId); return; diff --git a/src/Foundatio/Utility/SortedQueue.cs b/src/Foundatio/Utility/SortedQueue.cs new file mode 100644 index 000000000..cc3391062 --- /dev/null +++ b/src/Foundatio/Utility/SortedQueue.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace Foundatio.Utility { + public class SortedQueue : IProducerConsumerCollection> + where TKey : IComparable { + + private readonly object _lock = new object(); + private readonly SortedDictionary _sortedDictionary = new SortedDictionary(); + + public SortedQueue() { } + + public SortedQueue(IEnumerable> collection) { + if (collection == null) + throw new ArgumentNullException(nameof(collection)); + + foreach (var kvp in collection) + _sortedDictionary.Add(kvp.Key, kvp.Value); + } + + public void Enqueue(TKey key, TValue value) { + Enqueue(new KeyValuePair(key, value)); + } + + public void Enqueue(KeyValuePair item) { + lock (_lock) + _sortedDictionary.Add(item.Key, item.Value); + } + + public bool TryDequeue(out KeyValuePair item) { + item = default; + + lock (_lock) { + if (_sortedDictionary.Count > 0) { + item = _sortedDictionary.First(); + return _sortedDictionary.Remove(item.Key); + } + } + + return false; + } + + public bool TryPeek(out KeyValuePair item) { + item = default; + + lock (_lock) { + if (_sortedDictionary.Count > 0) { + item = _sortedDictionary.First(); + return true; + } + } + + return false; + } + + public void Clear() { + lock (_lock) + _sortedDictionary.Clear(); + } + + public bool IsEmpty => Count == 0; + + public IEnumerator> GetEnumerator() { + var array = ToArray(); + return array.AsEnumerable().GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() { + return GetEnumerator(); + } + + void ICollection.CopyTo(Array array, int index) { + lock (_lock) + ((ICollection)_sortedDictionary).CopyTo(array, index); + } + + public int Count { + get { + lock (_lock) + return _sortedDictionary.Count; + } + } + + object ICollection.SyncRoot => _lock; + + bool ICollection.IsSynchronized => true; + + public void CopyTo(KeyValuePair[] array, int index) { + lock (_lock) + _sortedDictionary.CopyTo(array, index); + } + + bool IProducerConsumerCollection>.TryAdd(KeyValuePair item) { + Enqueue(item); + return true; + } + + bool IProducerConsumerCollection>.TryTake(out KeyValuePair item) { + return TryDequeue(out item); + } + + public KeyValuePair[] ToArray() { + lock (_lock) + return _sortedDictionary.ToArray(); + } + } +} \ No newline at end of file diff --git a/src/Foundatio/Utility/SystemClock.cs b/src/Foundatio/Utility/SystemClock.cs index 9b92ddc61..d43dc6281 100644 --- a/src/Foundatio/Utility/SystemClock.cs +++ b/src/Foundatio/Utility/SystemClock.cs @@ -11,6 +11,8 @@ public interface ISystemClock { void Sleep(int milliseconds); Task SleepAsync(int milliseconds, CancellationToken ct); TimeSpan TimeZoneOffset(); + void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null); + void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null); } public class RealSystemClock : ISystemClock { @@ -23,6 +25,10 @@ public class RealSystemClock : ISystemClock { public void Sleep(int milliseconds) => Thread.Sleep(milliseconds); public Task SleepAsync(int milliseconds, CancellationToken ct) => Task.Delay(milliseconds, ct); public TimeSpan TimeZoneOffset() => DateTimeOffset.Now.Offset; + public void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) + => WorkScheduler.Instance.Schedule(action, executeAt, interval); + public void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) + => WorkScheduler.Instance.Schedule(action, executeAt, interval); } internal class TestSystemClockImpl : ISystemClock, IDisposable { @@ -43,6 +49,10 @@ public TestSystemClockImpl(ISystemClock originalTime) { public DateTimeOffset OffsetNow() => new(UtcNow().Ticks + TimeZoneOffset().Ticks, TimeZoneOffset()); public DateTimeOffset OffsetUtcNow() => new(UtcNow().Ticks, TimeSpan.Zero); public TimeSpan TimeZoneOffset() => _timeZoneOffset; + public void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) + => WorkScheduler.Instance.Schedule(action, executeAt, interval); + public void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) + => WorkScheduler.Instance.Schedule(action, executeAt, interval); public void SetTimeZoneOffset(TimeSpan offset) => _timeZoneOffset = offset; public void AddTime(TimeSpan amount) => _offset = _offset.Add(amount); @@ -164,9 +174,13 @@ public static ISystemClock Instance { public static void Sleep(int milliseconds) => Instance.Sleep(milliseconds); public static Task SleepAsync(int milliseconds, CancellationToken cancellationToken = default) => Instance.SleepAsync(milliseconds, cancellationToken); - + public static void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) + => Instance.ScheduleWork(action, executeAt, interval); + public static void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) + => Instance.ScheduleWork(action, executeAt, interval); + #region Extensions - + public static void Sleep(TimeSpan delay) => Instance.Sleep(delay); diff --git a/src/Foundatio/Utility/WorkScheduler.cs b/src/Foundatio/Utility/WorkScheduler.cs new file mode 100644 index 000000000..b62f40f59 --- /dev/null +++ b/src/Foundatio/Utility/WorkScheduler.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Foundatio.Utility { + public class WorkScheduler : IDisposable { + public static WorkScheduler Instance = new WorkScheduler(); + + private readonly ILogger _logger; + private bool _isDisposed = false; + private readonly SortedQueue _workItems = new SortedQueue(); + private readonly TaskFactory _taskFactory; + private Task _workLoopTask; + private readonly object _lock = new object(); + + public WorkScheduler(ILoggerFactory loggerFactory = null) { + _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; + // limit scheduled task processing to 50 at a time + _taskFactory = new TaskFactory(new LimitedConcurrencyLevelTaskScheduler(50)); + } + + public void Schedule(Action action, DateTime executeAt, TimeSpan? interval = null) { + EnsureWorkLoopRunning(); + + _workItems.Enqueue(executeAt, new WorkItem { + Action = action, + ExecuteAtUtc = executeAt, + Interval = interval + }); + } + + public void Schedule(Func action, DateTime executeAt, TimeSpan? interval = null) { + Schedule(() => { _ = action(); }, executeAt, interval); + } + + private void EnsureWorkLoopRunning() { + if (_workLoopTask != null) + return; + + lock (_lock) { + if (_workLoopTask != null) + return; + + _workLoopTask = Task.Factory.StartNew(WorkLoop, TaskCreationOptions.LongRunning); + } + } + + private void WorkLoop() { + while (!_isDisposed) { + if (!_workItems.TryPeek(out var kvp) || kvp.Key < SystemClock.UtcNow) { + Thread.Sleep(100); + continue; + } + + while (!_isDisposed && _workItems.TryDequeue(out var i)) { + _ = _taskFactory.StartNew(() => { + var startTime = SystemClock.UtcNow; + i.Value.Action(); + if (i.Value.Interval.HasValue) + Schedule(i.Value.Action, startTime.Add(i.Value.Interval.Value)); + }); + } + } + } + + public void Dispose() { + _isDisposed = true; + _workLoopTask.Wait(5000); + _workLoopTask = null; + } + + private class WorkItem { + public DateTime ExecuteAtUtc { get; set; } + public Action Action { get; set; } + public TimeSpan? Interval { get; set; } + } + } +} \ No newline at end of file diff --git a/tests/Foundatio.Tests/Messaging/InMemoryMessageBusTests.cs b/tests/Foundatio.Tests/Messaging/InMemoryMessageBusTests.cs index d71668849..9566a58a3 100644 --- a/tests/Foundatio.Tests/Messaging/InMemoryMessageBusTests.cs +++ b/tests/Foundatio.Tests/Messaging/InMemoryMessageBusTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using Foundatio.Messaging; using Xunit; @@ -64,6 +64,11 @@ public override Task CanSendDelayedMessageAsync() { return base.CanSendDelayedMessageAsync(); } + [Fact] + public override Task CanSendParallelDelayedMessagesAsync() { + return base.CanSendParallelDelayedMessagesAsync(); + } + [Fact] public override Task CanSubscribeConcurrentlyAsync() { return base.CanSubscribeConcurrentlyAsync(); From 96ce1b04d8bbb77c809e66b0cd567fca16be312c Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 12 May 2019 14:37:55 -0500 Subject: [PATCH 18/29] Adding 1st work scheduler test --- src/Foundatio/Utility/SortedQueue.cs | 16 +++++++ src/Foundatio/Utility/SystemClock.cs | 32 +++++++++----- src/Foundatio/Utility/WorkScheduler.cs | 42 +++++++++++++------ .../Utility/WorkSchedulerTests.cs | 36 ++++++++++++++++ 4 files changed, 103 insertions(+), 23 deletions(-) create mode 100644 tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs diff --git a/src/Foundatio/Utility/SortedQueue.cs b/src/Foundatio/Utility/SortedQueue.cs index cc3391062..04917bc18 100644 --- a/src/Foundatio/Utility/SortedQueue.cs +++ b/src/Foundatio/Utility/SortedQueue.cs @@ -43,6 +43,22 @@ public bool TryDequeue(out KeyValuePair item) { return false; } + public bool TryDequeueIf(out KeyValuePair item, Predicate condition) { + item = default; + + lock (_lock) { + if (_sortedDictionary.Count > 0) { + item = _sortedDictionary.First(); + if (!condition(item.Value)) + return false; + + return _sortedDictionary.Remove(item.Key); + } + } + + return false; + } + public bool TryPeek(out KeyValuePair item) { item = default; diff --git a/src/Foundatio/Utility/SystemClock.cs b/src/Foundatio/Utility/SystemClock.cs index d43dc6281..a3b2a03e0 100644 --- a/src/Foundatio/Utility/SystemClock.cs +++ b/src/Foundatio/Utility/SystemClock.cs @@ -11,8 +11,10 @@ public interface ISystemClock { void Sleep(int milliseconds); Task SleepAsync(int milliseconds, CancellationToken ct); TimeSpan TimeZoneOffset(); - void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null); void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null); + void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null); + void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null); + void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null); } public class RealSystemClock : ISystemClock { @@ -25,10 +27,14 @@ public class RealSystemClock : ISystemClock { public void Sleep(int milliseconds) => Thread.Sleep(milliseconds); public Task SleepAsync(int milliseconds, CancellationToken ct) => Task.Delay(milliseconds, ct); public TimeSpan TimeZoneOffset() => DateTimeOffset.Now.Offset; - public void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) - => WorkScheduler.Instance.Schedule(action, executeAt, interval); public void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) => WorkScheduler.Instance.Schedule(action, executeAt, interval); + public void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) + => WorkScheduler.Instance.Schedule(action, executeAt, interval); + public void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null) + => WorkScheduler.Instance.Schedule(action, delay, interval); + public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) + => WorkScheduler.Instance.Schedule(action, delay, interval); } internal class TestSystemClockImpl : ISystemClock, IDisposable { @@ -49,10 +55,14 @@ public TestSystemClockImpl(ISystemClock originalTime) { public DateTimeOffset OffsetNow() => new(UtcNow().Ticks + TimeZoneOffset().Ticks, TimeZoneOffset()); public DateTimeOffset OffsetUtcNow() => new(UtcNow().Ticks, TimeSpan.Zero); public TimeSpan TimeZoneOffset() => _timeZoneOffset; - public void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) - => WorkScheduler.Instance.Schedule(action, executeAt, interval); - public void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) - => WorkScheduler.Instance.Schedule(action, executeAt, interval); + public void ScheduleWork(Action action, DateTime executeAtUtc, TimeSpan? interval = null) + => WorkScheduler.Instance.Schedule(action, executeAtUtc, interval); + public void ScheduleWork(Func action, DateTime executeAtUtc, TimeSpan? interval = null) + => WorkScheduler.Instance.Schedule(action, executeAtUtc, interval); + public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) + => WorkScheduler.Instance.Schedule(action, delay, interval); + public void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null) + => WorkScheduler.Instance.Schedule(action, delay, interval); public void SetTimeZoneOffset(TimeSpan offset) => _timeZoneOffset = offset; public void AddTime(TimeSpan amount) => _offset = _offset.Add(amount); @@ -78,8 +88,10 @@ public Task SleepAsync(int milliseconds, CancellationToken ct) { return Task.CompletedTask; } - public void Freeze() { - SetFrozenTime(Now()); + public DateTime Freeze() { + var now = Now(); + SetFrozenTime(now); + return now; } public void Unfreeze() { @@ -140,7 +152,7 @@ public class TestSystemClock { public static void SubtractTime(TimeSpan amount) => TestSystemClockImpl.Instance.SubtractTime(amount); public static void UseFakeSleep() => TestSystemClockImpl.Instance.UseFakeSleep(); public static void UseRealSleep() => TestSystemClockImpl.Instance.UseRealSleep(); - public static void Freeze() => TestSystemClockImpl.Instance.Freeze(); + public static DateTime Freeze() => TestSystemClockImpl.Instance.Freeze(); public static void Unfreeze() => TestSystemClockImpl.Instance.Unfreeze(); public static void SetFrozenTime(DateTime time) => TestSystemClockImpl.Instance.SetFrozenTime(time); public static void SetTime(DateTime time, bool freeze = false) => TestSystemClockImpl.Instance.SetTime(time, freeze); diff --git a/src/Foundatio/Utility/WorkScheduler.cs b/src/Foundatio/Utility/WorkScheduler.cs index b62f40f59..2d7b34a08 100644 --- a/src/Foundatio/Utility/WorkScheduler.cs +++ b/src/Foundatio/Utility/WorkScheduler.cs @@ -21,9 +21,25 @@ public WorkScheduler(ILoggerFactory loggerFactory = null) { _taskFactory = new TaskFactory(new LimitedConcurrencyLevelTaskScheduler(50)); } + public void Schedule(Func action, TimeSpan delay, TimeSpan? interval = null) { + Schedule(() => { _ = action(); }, SystemClock.UtcNow.Add(delay), interval); + } + + public void Schedule(Action action, TimeSpan delay, TimeSpan? interval = null) { + Schedule(action, SystemClock.UtcNow.Add(delay), interval); + } + + public void Schedule(Func action, DateTime executeAt, TimeSpan? interval = null) { + Schedule(() => { _ = action(); }, executeAt, interval); + } + public void Schedule(Action action, DateTime executeAt, TimeSpan? interval = null) { EnsureWorkLoopRunning(); + if (executeAt.Kind != DateTimeKind.Utc) + executeAt = executeAt.ToUniversalTime(); + + _logger.LogTrace("Scheduling work due at {ExecuteAt}", executeAt); _workItems.Enqueue(executeAt, new WorkItem { Action = action, ExecuteAtUtc = executeAt, @@ -31,10 +47,6 @@ public void Schedule(Action action, DateTime executeAt, TimeSpan? interval = nul }); } - public void Schedule(Func action, DateTime executeAt, TimeSpan? interval = null) { - Schedule(() => { _ = action(); }, executeAt, interval); - } - private void EnsureWorkLoopRunning() { if (_workLoopTask != null) return; @@ -43,26 +55,30 @@ private void EnsureWorkLoopRunning() { if (_workLoopTask != null) return; + _logger.LogTrace("Starting work loop"); _workLoopTask = Task.Factory.StartNew(WorkLoop, TaskCreationOptions.LongRunning); } } private void WorkLoop() { + _logger.LogTrace("Work loop started"); while (!_isDisposed) { - if (!_workItems.TryPeek(out var kvp) || kvp.Key < SystemClock.UtcNow) { - Thread.Sleep(100); - continue; - } - - while (!_isDisposed && _workItems.TryDequeue(out var i)) { + _logger.LogTrace("Checking for items due after {CurrentTime}", SystemClock.UtcNow); + if (_workItems.TryDequeueIf(out var kvp, i => i.ExecuteAtUtc < SystemClock.UtcNow)) { + _logger.LogTrace("Starting work item due at {DueTime}", kvp.Key); _ = _taskFactory.StartNew(() => { var startTime = SystemClock.UtcNow; - i.Value.Action(); - if (i.Value.Interval.HasValue) - Schedule(i.Value.Action, startTime.Add(i.Value.Interval.Value)); + kvp.Value.Action(); + if (kvp.Value.Interval.HasValue) + Schedule(kvp.Value.Action, startTime.Add(kvp.Value.Interval.Value)); }); + _logger.LogTrace("Work item started"); + } else { + _logger.LogTrace("No work items due"); + Thread.Sleep(100); } } + _logger.LogTrace("Work loop stopped"); } public void Dispose() { diff --git a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs new file mode 100644 index 000000000..45ca7307a --- /dev/null +++ b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Foundatio.AsyncEx; +using Foundatio.Logging.Xunit; +using Foundatio.Tests.Extensions; +using Foundatio.Utility; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Foundatio.Tests.Utility { + public class WorkSchedulerTests : TestWithLoggingBase { + public WorkSchedulerTests(ITestOutputHelper output) : base(output) { } + + [Fact] + public void CanScheduleWork() { + using (TestSystemClock.Install()) { + Log.MinimumLevel = LogLevel.Trace; + var now = TestSystemClock.Freeze(); + var workScheduler = new WorkScheduler(Log); + bool workCompleted = false; + var countdown = new AsyncCountdownEvent(1); + workScheduler.Schedule(() => { workCompleted = true; }, TimeSpan.FromMinutes(5)); + Thread.Sleep(1000); + Assert.False(workCompleted); + TestSystemClock.AddTime(TimeSpan.FromMinutes(6)); + Thread.Sleep(1000); + Assert.True(workCompleted); + } + } + + // long running work item won't block other work items from running + // make sure inserting work items with out of order due times works + } +} From fa07c94f7537662b5e7c71be7f443bd3b5698c30 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 12 May 2019 20:15:27 -0500 Subject: [PATCH 19/29] Minor --- src/Foundatio/Utility/WorkScheduler.cs | 14 +++++++++++--- .../Foundatio.Tests/Utility/WorkSchedulerTests.cs | 2 -- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Foundatio/Utility/WorkScheduler.cs b/src/Foundatio/Utility/WorkScheduler.cs index 2d7b34a08..d1cbd43f4 100644 --- a/src/Foundatio/Utility/WorkScheduler.cs +++ b/src/Foundatio/Utility/WorkScheduler.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Foundatio.AsyncEx; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -14,6 +15,7 @@ public class WorkScheduler : IDisposable { private readonly TaskFactory _taskFactory; private Task _workLoopTask; private readonly object _lock = new object(); + private readonly AutoResetEvent _workItemScheduled = new AutoResetEvent(false); public WorkScheduler(ILoggerFactory loggerFactory = null) { _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; @@ -45,6 +47,7 @@ public void Schedule(Action action, DateTime executeAt, TimeSpan? interval = nul ExecuteAtUtc = executeAt, Interval = interval }); + _workItemScheduled.Set(); } private void EnsureWorkLoopRunning() { @@ -74,8 +77,13 @@ private void WorkLoop() { }); _logger.LogTrace("Work item started"); } else { - _logger.LogTrace("No work items due"); - Thread.Sleep(100); + if (_workItems.TryPeek(out var p)) { + _logger.LogTrace("Next work item due at {DueTime}", p.Key); + _workItemScheduled.WaitOne(p.Key.Subtract(SystemClock.UtcNow)); + } else { + _logger.LogTrace("No work items due"); + _workItemScheduled.WaitOne(TimeSpan.FromMinutes(1)); + } } } _logger.LogTrace("Work loop stopped"); @@ -83,7 +91,7 @@ private void WorkLoop() { public void Dispose() { _isDisposed = true; - _workLoopTask.Wait(5000); + _workLoopTask.Wait(); _workLoopTask = null; } diff --git a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs index 45ca7307a..192a0c6c9 100644 --- a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs +++ b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs @@ -1,9 +1,7 @@ using System; using System.Threading; -using System.Threading.Tasks; using Foundatio.AsyncEx; using Foundatio.Logging.Xunit; -using Foundatio.Tests.Extensions; using Foundatio.Utility; using Microsoft.Extensions.Logging; using Xunit; From 476d7acf14bd729fb723367c67d753c3740c1979 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 12 May 2019 23:40:43 -0500 Subject: [PATCH 20/29] More work scheduler tests --- src/Foundatio/Utility/SystemClock.cs | 33 ++++++++- src/Foundatio/Utility/WorkScheduler.cs | 30 +++++--- .../Utility/WorkSchedulerTests.cs | 68 ++++++++++++++++--- 3 files changed, 108 insertions(+), 23 deletions(-) diff --git a/src/Foundatio/Utility/SystemClock.cs b/src/Foundatio/Utility/SystemClock.cs index a3b2a03e0..af8b25745 100644 --- a/src/Foundatio/Utility/SystemClock.cs +++ b/src/Foundatio/Utility/SystemClock.cs @@ -64,9 +64,21 @@ public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = nul public void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null) => WorkScheduler.Instance.Schedule(action, delay, interval); - public void SetTimeZoneOffset(TimeSpan offset) => _timeZoneOffset = offset; - public void AddTime(TimeSpan amount) => _offset = _offset.Add(amount); - public void SubtractTime(TimeSpan amount) => _offset = _offset.Subtract(amount); + public void SetTimeZoneOffset(TimeSpan offset) { + _timeZoneOffset = offset; + OnChanged(); + } + + public void AddTime(TimeSpan amount) { + _offset = _offset.Add(amount); + OnChanged(); + } + + public void SubtractTime(TimeSpan amount) { + _offset = _offset.Subtract(amount); + OnChanged(); + } + public void UseFakeSleep() => _fakeSleep = true; public void UseRealSleep() => _fakeSleep = false; @@ -110,8 +122,10 @@ public void SetTime(DateTime time, bool freeze = false) { if (time.Kind == DateTimeKind.Utc) { _fixedUtc = time; + OnChanged(); } else if (time.Kind == DateTimeKind.Local) { _fixedUtc = new DateTime(time.Ticks - TimeZoneOffset().Ticks, DateTimeKind.Utc); + OnChanged(); } } else { _fixedUtc = null; @@ -121,11 +135,19 @@ public void SetTime(DateTime time, bool freeze = false) { if (time.Kind == DateTimeKind.Utc) { _offset = now.ToUniversalTime().Subtract(time); + OnChanged(); } else if (time.Kind == DateTimeKind.Local) { _offset = now.Subtract(time); + OnChanged(); } } } + + public event EventHandler Changed; + + private void OnChanged() { + Changed?.Invoke(this, EventArgs.Empty); + } public void Dispose() { if (_originalClock == null) @@ -157,6 +179,11 @@ public class TestSystemClock { public static void SetFrozenTime(DateTime time) => TestSystemClockImpl.Instance.SetFrozenTime(time); public static void SetTime(DateTime time, bool freeze = false) => TestSystemClockImpl.Instance.SetTime(time, freeze); + public static event EventHandler Changed { + add { TestSystemClockImpl.Instance.Changed += value; } + remove { TestSystemClockImpl.Instance.Changed -= value; } + } + public static IDisposable Install() { var testClock = new TestSystemClockImpl(SystemClock.Instance); SystemClock.Instance = testClock; diff --git a/src/Foundatio/Utility/WorkScheduler.cs b/src/Foundatio/Utility/WorkScheduler.cs index d1cbd43f4..57ab429aa 100644 --- a/src/Foundatio/Utility/WorkScheduler.cs +++ b/src/Foundatio/Utility/WorkScheduler.cs @@ -23,6 +23,8 @@ public WorkScheduler(ILoggerFactory loggerFactory = null) { _taskFactory = new TaskFactory(new LimitedConcurrencyLevelTaskScheduler(50)); } + public AutoResetEvent NoWorkItemsDue { get; } = new AutoResetEvent(false); + public void Schedule(Func action, TimeSpan delay, TimeSpan? interval = null) { Schedule(() => { _ = action(); }, SystemClock.UtcNow.Add(delay), interval); } @@ -41,7 +43,8 @@ public void Schedule(Action action, DateTime executeAt, TimeSpan? interval = nul if (executeAt.Kind != DateTimeKind.Utc) executeAt = executeAt.ToUniversalTime(); - _logger.LogTrace("Scheduling work due at {ExecuteAt}", executeAt); + var delay = executeAt.Subtract(SystemClock.UtcNow); + _logger.LogTrace("Scheduling work due at {ExecuteAt} ({Delay:g} from now)", executeAt, delay); _workItems.Enqueue(executeAt, new WorkItem { Action = action, ExecuteAtUtc = executeAt, @@ -59,6 +62,7 @@ private void EnsureWorkLoopRunning() { return; _logger.LogTrace("Starting work loop"); + TestSystemClock.Changed += (s, e) => { _workItemScheduled.Set(); }; _workLoopTask = Task.Factory.StartNew(WorkLoop, TaskCreationOptions.LongRunning); } } @@ -66,24 +70,28 @@ private void EnsureWorkLoopRunning() { private void WorkLoop() { _logger.LogTrace("Work loop started"); while (!_isDisposed) { - _logger.LogTrace("Checking for items due after {CurrentTime}", SystemClock.UtcNow); if (_workItems.TryDequeueIf(out var kvp, i => i.ExecuteAtUtc < SystemClock.UtcNow)) { - _logger.LogTrace("Starting work item due at {DueTime}", kvp.Key); + _logger.LogTrace("Starting work item due at {DueTime} current time {CurrentTime}", kvp.Key, SystemClock.UtcNow); _ = _taskFactory.StartNew(() => { var startTime = SystemClock.UtcNow; kvp.Value.Action(); if (kvp.Value.Interval.HasValue) Schedule(kvp.Value.Action, startTime.Add(kvp.Value.Interval.Value)); }); - _logger.LogTrace("Work item started"); + continue; + } + + NoWorkItemsDue.Set(); + + if (_workItems.TryPeek(out var p)) { + var delay = p.Key.Subtract(SystemClock.UtcNow); + _logger.LogTrace("No work items due, next due at {DueTime} ({Delay:g} from now)", p.Key, delay); + if (delay > TimeSpan.FromMinutes(1)) + delay = TimeSpan.FromMinutes(1); + _workItemScheduled.WaitOne(delay); } else { - if (_workItems.TryPeek(out var p)) { - _logger.LogTrace("Next work item due at {DueTime}", p.Key); - _workItemScheduled.WaitOne(p.Key.Subtract(SystemClock.UtcNow)); - } else { - _logger.LogTrace("No work items due"); - _workItemScheduled.WaitOne(TimeSpan.FromMinutes(1)); - } + _logger.LogTrace("No work items scheduled"); + _workItemScheduled.WaitOne(TimeSpan.FromMinutes(1)); } } _logger.LogTrace("Work loop stopped"); diff --git a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs index 192a0c6c9..4e356d039 100644 --- a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs +++ b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs @@ -15,20 +15,70 @@ public WorkSchedulerTests(ITestOutputHelper output) : base(output) { } public void CanScheduleWork() { using (TestSystemClock.Install()) { Log.MinimumLevel = LogLevel.Trace; - var now = TestSystemClock.Freeze(); + TestSystemClock.Freeze(); var workScheduler = new WorkScheduler(Log); - bool workCompleted = false; - var countdown = new AsyncCountdownEvent(1); - workScheduler.Schedule(() => { workCompleted = true; }, TimeSpan.FromMinutes(5)); - Thread.Sleep(1000); - Assert.False(workCompleted); + var countdown = new CountdownEvent(1); + workScheduler.Schedule(() => { + _logger.LogTrace("Doing work"); + countdown.Signal(); + }, TimeSpan.FromMinutes(5)); + workScheduler.NoWorkItemsDue.WaitOne(); + Assert.Equal(1, countdown.CurrentCount); + _logger.LogTrace("Adding 6 minutes to current time."); TestSystemClock.AddTime(TimeSpan.FromMinutes(6)); - Thread.Sleep(1000); - Assert.True(workCompleted); + workScheduler.NoWorkItemsDue.WaitOne(); + countdown.Wait(); + Assert.Equal(0, countdown.CurrentCount); } } + [Fact] + public void CanScheduleMultipleWorkItems() { + using (TestSystemClock.Install()) { + Log.MinimumLevel = LogLevel.Trace; + var now = TestSystemClock.Freeze(); + var workScheduler = new WorkScheduler(Log); + var countdown = new CountdownEvent(3); + + // schedule work due in 5 minutes + workScheduler.Schedule(() => { + _logger.LogTrace("Doing 5 minute work"); + countdown.Signal(); + }, TimeSpan.FromMinutes(5)); + + // schedule work due in 1 second + workScheduler.Schedule(() => { + _logger.LogTrace("Doing 1 second work"); + countdown.Signal(); + }, TimeSpan.FromSeconds(1)); + + // schedule work that is already past due + workScheduler.Schedule(() => { + _logger.LogTrace("Doing past due work"); + countdown.Signal(); + }, TimeSpan.FromSeconds(-1)); + + // wait until we get signal that no items are currently due + workScheduler.NoWorkItemsDue.WaitOne(); + countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(1)); + Assert.Equal(2, countdown.CurrentCount); + + // verify additional work will not happen until time changes + Assert.False(countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(2))); + + _logger.LogTrace("Adding 1 minute to current time."); + TestSystemClock.AddTime(TimeSpan.FromMinutes(1)); + workScheduler.NoWorkItemsDue.WaitOne(); + countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(1)); + Assert.Equal(1, countdown.CurrentCount); + + _logger.LogTrace("Adding 5 minutes to current time."); + TestSystemClock.AddTime(TimeSpan.FromMinutes(5)); + workScheduler.NoWorkItemsDue.WaitOne(); + countdown.Wait(TimeSpan.FromSeconds(1)); + Assert.Equal(0, countdown.CurrentCount); + } + } // long running work item won't block other work items from running - // make sure inserting work items with out of order due times works } } From ab0d1143fe27e0bc83d8f539fc09b25fb9bdde11 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 13 May 2019 09:55:33 -0500 Subject: [PATCH 21/29] Changing tests to make use of SystemClock --- src/Foundatio/Utility/SystemClock.cs | 37 +++++++++++-------- src/Foundatio/Utility/WorkScheduler.cs | 8 +++- .../Utility/WorkSchedulerTests.cs | 28 +++++++------- 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/src/Foundatio/Utility/SystemClock.cs b/src/Foundatio/Utility/SystemClock.cs index af8b25745..021b3f00c 100644 --- a/src/Foundatio/Utility/SystemClock.cs +++ b/src/Foundatio/Utility/SystemClock.cs @@ -11,10 +11,10 @@ public interface ISystemClock { void Sleep(int milliseconds); Task SleepAsync(int milliseconds, CancellationToken ct); TimeSpan TimeZoneOffset(); - void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null); - void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null); void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null); void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null); + void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null); + void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null); } public class RealSystemClock : ISystemClock { @@ -27,14 +27,14 @@ public class RealSystemClock : ISystemClock { public void Sleep(int milliseconds) => Thread.Sleep(milliseconds); public Task SleepAsync(int milliseconds, CancellationToken ct) => Task.Delay(milliseconds, ct); public TimeSpan TimeZoneOffset() => DateTimeOffset.Now.Offset; - public void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) - => WorkScheduler.Instance.Schedule(action, executeAt, interval); - public void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) - => WorkScheduler.Instance.Schedule(action, executeAt, interval); public void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null) - => WorkScheduler.Instance.Schedule(action, delay, interval); + => WorkScheduler.Default.Schedule(action, delay, interval); public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) - => WorkScheduler.Instance.Schedule(action, delay, interval); + => WorkScheduler.Default.Schedule(action, delay, interval); + public void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) + => WorkScheduler.Default.Schedule(action, executeAt, interval); + public void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) + => WorkScheduler.Default.Schedule(action, executeAt, interval); } internal class TestSystemClockImpl : ISystemClock, IDisposable { @@ -55,14 +55,14 @@ public TestSystemClockImpl(ISystemClock originalTime) { public DateTimeOffset OffsetNow() => new(UtcNow().Ticks + TimeZoneOffset().Ticks, TimeZoneOffset()); public DateTimeOffset OffsetUtcNow() => new(UtcNow().Ticks, TimeSpan.Zero); public TimeSpan TimeZoneOffset() => _timeZoneOffset; - public void ScheduleWork(Action action, DateTime executeAtUtc, TimeSpan? interval = null) - => WorkScheduler.Instance.Schedule(action, executeAtUtc, interval); - public void ScheduleWork(Func action, DateTime executeAtUtc, TimeSpan? interval = null) - => WorkScheduler.Instance.Schedule(action, executeAtUtc, interval); public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) - => WorkScheduler.Instance.Schedule(action, delay, interval); + => WorkScheduler.Default.Schedule(action, delay, interval); public void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null) - => WorkScheduler.Instance.Schedule(action, delay, interval); + => WorkScheduler.Default.Schedule(action, delay, interval); + public void ScheduleWork(Action action, DateTime executeAtUtc, TimeSpan? interval = null) + => WorkScheduler.Default.Schedule(action, executeAtUtc, interval); + public void ScheduleWork(Func action, DateTime executeAtUtc, TimeSpan? interval = null) + => WorkScheduler.Default.Schedule(action, executeAtUtc, interval); public void SetTimeZoneOffset(TimeSpan offset) { _timeZoneOffset = offset; @@ -111,6 +111,7 @@ public void Unfreeze() { } public void SetFrozenTime(DateTime time) { + UseFakeSleep(); SetTime(time, true); } @@ -180,8 +181,8 @@ public class TestSystemClock { public static void SetTime(DateTime time, bool freeze = false) => TestSystemClockImpl.Instance.SetTime(time, freeze); public static event EventHandler Changed { - add { TestSystemClockImpl.Instance.Changed += value; } - remove { TestSystemClockImpl.Instance.Changed -= value; } + add => TestSystemClockImpl.Instance.Changed += value; + remove => TestSystemClockImpl.Instance.Changed -= value; } public static IDisposable Install() { @@ -213,6 +214,10 @@ public static ISystemClock Instance { public static void Sleep(int milliseconds) => Instance.Sleep(milliseconds); public static Task SleepAsync(int milliseconds, CancellationToken cancellationToken = default) => Instance.SleepAsync(milliseconds, cancellationToken); + public static void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) + => Instance.ScheduleWork(action, delay, interval); + public static void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null) + => Instance.ScheduleWork(action, delay, interval); public static void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) => Instance.ScheduleWork(action, executeAt, interval); public static void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) diff --git a/src/Foundatio/Utility/WorkScheduler.cs b/src/Foundatio/Utility/WorkScheduler.cs index 57ab429aa..7c83a642c 100644 --- a/src/Foundatio/Utility/WorkScheduler.cs +++ b/src/Foundatio/Utility/WorkScheduler.cs @@ -7,9 +7,9 @@ namespace Foundatio.Utility { public class WorkScheduler : IDisposable { - public static WorkScheduler Instance = new WorkScheduler(); + public static WorkScheduler Default = new WorkScheduler(); - private readonly ILogger _logger; + private ILogger _logger; private bool _isDisposed = false; private readonly SortedQueue _workItems = new SortedQueue(); private readonly TaskFactory _taskFactory; @@ -23,6 +23,10 @@ public WorkScheduler(ILoggerFactory loggerFactory = null) { _taskFactory = new TaskFactory(new LimitedConcurrencyLevelTaskScheduler(50)); } + public void SetLogger(ILogger logger) { + _logger = logger ?? NullLogger.Instance; + } + public AutoResetEvent NoWorkItemsDue { get; } = new AutoResetEvent(false); public void Schedule(Func action, TimeSpan delay, TimeSpan? interval = null) { diff --git a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs index 4e356d039..6b98ee39d 100644 --- a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs +++ b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs @@ -36,46 +36,48 @@ public void CanScheduleWork() { public void CanScheduleMultipleWorkItems() { using (TestSystemClock.Install()) { Log.MinimumLevel = LogLevel.Trace; - var now = TestSystemClock.Freeze(); - var workScheduler = new WorkScheduler(Log); + TestSystemClock.Freeze(); + WorkScheduler.Default.SetLogger(_logger); var countdown = new CountdownEvent(3); // schedule work due in 5 minutes - workScheduler.Schedule(() => { + SystemClock.ScheduleWork(() => { _logger.LogTrace("Doing 5 minute work"); countdown.Signal(); }, TimeSpan.FromMinutes(5)); // schedule work due in 1 second - workScheduler.Schedule(() => { + SystemClock.ScheduleWork(() => { _logger.LogTrace("Doing 1 second work"); countdown.Signal(); }, TimeSpan.FromSeconds(1)); // schedule work that is already past due - workScheduler.Schedule(() => { + SystemClock.ScheduleWork(() => { _logger.LogTrace("Doing past due work"); countdown.Signal(); }, TimeSpan.FromSeconds(-1)); // wait until we get signal that no items are currently due - workScheduler.NoWorkItemsDue.WaitOne(); - countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(1)); + Assert.True(WorkScheduler.Default.NoWorkItemsDue.WaitOne(), "Wait for all due work items to be scheduled"); + Assert.True(countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(1)), "Wait for past due work to be done"); Assert.Equal(2, countdown.CurrentCount); // verify additional work will not happen until time changes Assert.False(countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(2))); _logger.LogTrace("Adding 1 minute to current time."); - TestSystemClock.AddTime(TimeSpan.FromMinutes(1)); - workScheduler.NoWorkItemsDue.WaitOne(); - countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(1)); + // sleeping for a minute to make 1 second work due + SystemClock.Sleep(TimeSpan.FromMinutes(1)); + Assert.True(WorkScheduler.Default.NoWorkItemsDue.WaitOne()); + Assert.True(countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(1))); Assert.Equal(1, countdown.CurrentCount); _logger.LogTrace("Adding 5 minutes to current time."); - TestSystemClock.AddTime(TimeSpan.FromMinutes(5)); - workScheduler.NoWorkItemsDue.WaitOne(); - countdown.Wait(TimeSpan.FromSeconds(1)); + // sleeping for 5 minutes to make 5 minute work due + SystemClock.Sleep(TimeSpan.FromMinutes(5)); + Assert.True(WorkScheduler.Default.NoWorkItemsDue.WaitOne()); + Assert.True(countdown.Wait(TimeSpan.FromSeconds(1))); Assert.Equal(0, countdown.CurrentCount); } } From 95bbbbbb70a3270ea4930d120ced4420c3a44361 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 13 May 2019 10:43:00 -0500 Subject: [PATCH 22/29] Fix broken test, freeze time and use fake sleep automatically when using test time. --- src/Foundatio/Utility/SystemClock.cs | 14 +++++++ src/Foundatio/Utility/WorkScheduler.cs | 22 +++++++--- .../Utility/WorkSchedulerTests.cs | 40 +++++++++++-------- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/Foundatio/Utility/SystemClock.cs b/src/Foundatio/Utility/SystemClock.cs index 021b3f00c..cd6f44e6d 100644 --- a/src/Foundatio/Utility/SystemClock.cs +++ b/src/Foundatio/Utility/SystemClock.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; namespace Foundatio.Utility { public interface ISystemClock { @@ -186,8 +187,21 @@ public static event EventHandler Changed { } public static IDisposable Install() { + return Install(true, null); + } + + public static IDisposable Install(ILoggerFactory loggerFactory) { + return Install(true, loggerFactory); + } + + public static IDisposable Install(bool freeze, ILoggerFactory loggerFactory) { var testClock = new TestSystemClockImpl(SystemClock.Instance); SystemClock.Instance = testClock; + if (freeze) + testClock.Freeze(); + + if (loggerFactory != null) + WorkScheduler.Default.SetLogger(loggerFactory); return testClock; } diff --git a/src/Foundatio/Utility/WorkScheduler.cs b/src/Foundatio/Utility/WorkScheduler.cs index 7c83a642c..53bed38cf 100644 --- a/src/Foundatio/Utility/WorkScheduler.cs +++ b/src/Foundatio/Utility/WorkScheduler.cs @@ -27,6 +27,10 @@ public void SetLogger(ILogger logger) { _logger = logger ?? NullLogger.Instance; } + public void SetLogger(ILoggerFactory loggerFactory) { + _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; + } + public AutoResetEvent NoWorkItemsDue { get; } = new AutoResetEvent(false); public void Schedule(Func action, TimeSpan delay, TimeSpan? interval = null) { @@ -85,16 +89,24 @@ private void WorkLoop() { continue; } - NoWorkItemsDue.Set(); - - if (_workItems.TryPeek(out var p)) { - var delay = p.Key.Subtract(SystemClock.UtcNow); - _logger.LogTrace("No work items due, next due at {DueTime} ({Delay:g} from now)", p.Key, delay); + if (kvp.Key != default) { + var delay = kvp.Key.Subtract(SystemClock.UtcNow); + _logger.LogTrace("No work items due, next due at {DueTime} ({Delay:g} from now)", kvp.Key, delay); + + // this can happen if items were inserted right after the loop started + if (delay < TimeSpan.Zero) + continue; + + NoWorkItemsDue.Set(); + + // don't delay more than 1 minute + // TODO: Do we really need this? We know when items are enqueued. I think we can trust it and wait the full time. if (delay > TimeSpan.FromMinutes(1)) delay = TimeSpan.FromMinutes(1); _workItemScheduled.WaitOne(delay); } else { _logger.LogTrace("No work items scheduled"); + NoWorkItemsDue.Set(); _workItemScheduled.WaitOne(TimeSpan.FromMinutes(1)); } } diff --git a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs index 6b98ee39d..494162045 100644 --- a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs +++ b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs @@ -13,31 +13,27 @@ public WorkSchedulerTests(ITestOutputHelper output) : base(output) { } [Fact] public void CanScheduleWork() { - using (TestSystemClock.Install()) { - Log.MinimumLevel = LogLevel.Trace; - TestSystemClock.Freeze(); - var workScheduler = new WorkScheduler(Log); + Log.MinimumLevel = LogLevel.Trace; + using (TestSystemClock.Install(Log)) { var countdown = new CountdownEvent(1); - workScheduler.Schedule(() => { + SystemClock.ScheduleWork(() => { _logger.LogTrace("Doing work"); countdown.Signal(); }, TimeSpan.FromMinutes(5)); - workScheduler.NoWorkItemsDue.WaitOne(); + WorkScheduler.Default.NoWorkItemsDue.WaitOne(); Assert.Equal(1, countdown.CurrentCount); _logger.LogTrace("Adding 6 minutes to current time."); - TestSystemClock.AddTime(TimeSpan.FromMinutes(6)); - workScheduler.NoWorkItemsDue.WaitOne(); + SystemClock.Sleep(TimeSpan.FromMinutes(6)); + WorkScheduler.Default.NoWorkItemsDue.WaitOne(); countdown.Wait(); Assert.Equal(0, countdown.CurrentCount); } } [Fact] - public void CanScheduleMultipleWorkItems() { - using (TestSystemClock.Install()) { - Log.MinimumLevel = LogLevel.Trace; - TestSystemClock.Freeze(); - WorkScheduler.Default.SetLogger(_logger); + public void CanScheduleMultipleUnorderedWorkItems() { + Log.MinimumLevel = LogLevel.Trace; + using (TestSystemClock.Install(Log)) { var countdown = new CountdownEvent(3); // schedule work due in 5 minutes @@ -59,28 +55,38 @@ public void CanScheduleMultipleWorkItems() { }, TimeSpan.FromSeconds(-1)); // wait until we get signal that no items are currently due + _logger.LogTrace("Waiting for past due items to be started"); Assert.True(WorkScheduler.Default.NoWorkItemsDue.WaitOne(), "Wait for all due work items to be scheduled"); - Assert.True(countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(1)), "Wait for past due work to be done"); + _logger.LogTrace("Waiting for past due work to be completed"); + // work can be done before we even get here, but wait one to be sure it's done + countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(1)); Assert.Equal(2, countdown.CurrentCount); // verify additional work will not happen until time changes Assert.False(countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(2))); - _logger.LogTrace("Adding 1 minute to current time."); + _logger.LogTrace("Adding 1 minute to current time"); // sleeping for a minute to make 1 second work due SystemClock.Sleep(TimeSpan.FromMinutes(1)); Assert.True(WorkScheduler.Default.NoWorkItemsDue.WaitOne()); - Assert.True(countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(1))); + countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(1)); Assert.Equal(1, countdown.CurrentCount); - _logger.LogTrace("Adding 5 minutes to current time."); + _logger.LogTrace("Adding 5 minutes to current time"); // sleeping for 5 minutes to make 5 minute work due SystemClock.Sleep(TimeSpan.FromMinutes(5)); + _logger.LogTrace("Waiting for no work items due"); Assert.True(WorkScheduler.Default.NoWorkItemsDue.WaitOne()); + _logger.LogTrace("Waiting for countdown to reach zero"); Assert.True(countdown.Wait(TimeSpan.FromSeconds(1))); + _logger.LogTrace("Check that current countdown is zero"); Assert.Equal(0, countdown.CurrentCount); } } + // long running work item won't block other work items from running + // can run with no work scheduled + // work items that throw don't affect other work items + // do we need to do anything for unhandled exceptions or would users just use the normal unhandled exception handler since the tasks are just being run on the normal thread pool } } From 9f770421a3e52578e864ec10f52128210678d3cc Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 13 May 2019 14:03:50 -0500 Subject: [PATCH 23/29] Little more progress --- src/Foundatio/Utility/SystemClock.cs | 53 +++++++++++-------- src/Foundatio/Utility/WorkScheduler.cs | 24 ++++----- .../Utility/WorkSchedulerTests.cs | 12 +++-- 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/src/Foundatio/Utility/SystemClock.cs b/src/Foundatio/Utility/SystemClock.cs index cd6f44e6d..2ec814d02 100644 --- a/src/Foundatio/Utility/SystemClock.cs +++ b/src/Foundatio/Utility/SystemClock.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Foundatio.Utility { public interface ISystemClock { @@ -152,12 +153,7 @@ private void OnChanged() { } public void Dispose() { - if (_originalClock == null) - return; - - var originalClock = Interlocked.Exchange(ref _originalClock, null); - if (originalClock != null) - SystemClock.Instance = originalClock; + SystemClock.SetInstance(_originalClock, null); } public static TestSystemClockImpl Instance { @@ -186,17 +182,13 @@ public static event EventHandler Changed { remove => TestSystemClockImpl.Instance.Changed -= value; } - public static IDisposable Install() { - return Install(true, null); - } - public static IDisposable Install(ILoggerFactory loggerFactory) { return Install(true, loggerFactory); } - public static IDisposable Install(bool freeze, ILoggerFactory loggerFactory) { + public static IDisposable Install(bool freeze = true, ILoggerFactory loggerFactory = null) { var testClock = new TestSystemClockImpl(SystemClock.Instance); - SystemClock.Instance = testClock; + SystemClock.SetInstance(testClock, loggerFactory); if (freeze) testClock.Freeze(); @@ -210,13 +202,32 @@ public static IDisposable Install(bool freeze, ILoggerFactory loggerFactory) { public static class SystemClock { private static AsyncLocal _instance; - public static ISystemClock Instance { - get => _instance?.Value ?? RealSystemClock.Instance; - set { - if (_instance == null) - _instance = new AsyncLocal(); - - _instance.Value = value; + public static ISystemClock Instance => _instance?.Value ?? RealSystemClock.Instance; + + public static void SetInstance(ISystemClock clock, ILoggerFactory loggerFactory) { + var logger = loggerFactory?.CreateLogger("SystemClock") ?? NullLogger.Instance; + _instance = new AsyncLocal(e => { + if (e.ThreadContextChanged) + return; + + if (e.PreviousValue != null && e.CurrentValue != null) { + var diff = e.PreviousValue.Now().Subtract(e.CurrentValue.Now()); + logger.LogTrace("SystemClock instance is being changed by {ThreadId} from {OldTime} to {NewTime} diff {Difference:g}", Thread.CurrentThread.ManagedThreadId, e.PreviousValue?.Now(), e.CurrentValue?.Now(), diff); + } + + if (e.PreviousValue == null) + logger.LogTrace("SystemClock instance is being initially set by {ThreadId} to {NewTime}", Thread.CurrentThread.ManagedThreadId, e.CurrentValue?.Now()); + + if (e.CurrentValue == null) + logger.LogTrace("SystemClock instance is being removed set by {ThreadId} from {OldTime}", Thread.CurrentThread.ManagedThreadId, e.PreviousValue?.Now()); + }); + + if (clock == null || clock is RealSystemClock) { + if (_instance != null) + _instance.Value = null; + _instance = null; + } else { + _instance.Value = clock; } } @@ -237,8 +248,6 @@ public static void ScheduleWork(Action action, DateTime executeAt, TimeSpan? int public static void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) => Instance.ScheduleWork(action, executeAt, interval); - #region Extensions - public static void Sleep(TimeSpan delay) => Instance.Sleep(delay); @@ -251,8 +260,6 @@ public static Task SleepSafeAsync(int milliseconds, CancellationToken cancellati public static Task SleepSafeAsync(TimeSpan delay, CancellationToken cancellationToken = default) => Instance.SleepSafeAsync(delay, cancellationToken); - - #endregion } public static class TimeExtensions { diff --git a/src/Foundatio/Utility/WorkScheduler.cs b/src/Foundatio/Utility/WorkScheduler.cs index 53bed38cf..f48e8ef5b 100644 --- a/src/Foundatio/Utility/WorkScheduler.cs +++ b/src/Foundatio/Utility/WorkScheduler.cs @@ -6,8 +6,12 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Foundatio.Utility { + /// + /// Used for scheduling tasks to be completed in the future. Uses the SystemClock so that making use of this makes it easy to test time sensitive code. + /// This is the same as using the thread pool. Long running tasks should not be scheduled on this. Tasks should generally last no longer than a few seconds. + /// public class WorkScheduler : IDisposable { - public static WorkScheduler Default = new WorkScheduler(); + public static readonly WorkScheduler Default = new WorkScheduler(); private ILogger _logger; private bool _isDisposed = false; @@ -46,8 +50,6 @@ public void Schedule(Func action, DateTime executeAt, TimeSpan? interval = } public void Schedule(Action action, DateTime executeAt, TimeSpan? interval = null) { - EnsureWorkLoopRunning(); - if (executeAt.Kind != DateTimeKind.Utc) executeAt = executeAt.ToUniversalTime(); @@ -58,6 +60,8 @@ public void Schedule(Action action, DateTime executeAt, TimeSpan? interval = nul ExecuteAtUtc = executeAt, Interval = interval }); + + EnsureWorkLoopRunning(); _workItemScheduled.Set(); } @@ -88,25 +92,15 @@ private void WorkLoop() { }); continue; } + + NoWorkItemsDue.Set(); if (kvp.Key != default) { var delay = kvp.Key.Subtract(SystemClock.UtcNow); _logger.LogTrace("No work items due, next due at {DueTime} ({Delay:g} from now)", kvp.Key, delay); - - // this can happen if items were inserted right after the loop started - if (delay < TimeSpan.Zero) - continue; - - NoWorkItemsDue.Set(); - - // don't delay more than 1 minute - // TODO: Do we really need this? We know when items are enqueued. I think we can trust it and wait the full time. - if (delay > TimeSpan.FromMinutes(1)) - delay = TimeSpan.FromMinutes(1); _workItemScheduled.WaitOne(delay); } else { _logger.LogTrace("No work items scheduled"); - NoWorkItemsDue.Set(); _workItemScheduled.WaitOne(TimeSpan.FromMinutes(1)); } } diff --git a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs index 494162045..27a1ca580 100644 --- a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs +++ b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs @@ -14,6 +14,7 @@ public WorkSchedulerTests(ITestOutputHelper output) : base(output) { } [Fact] public void CanScheduleWork() { Log.MinimumLevel = LogLevel.Trace; + _logger.LogTrace("Starting test on thread {ThreadId} time {Time}", Thread.CurrentThread.ManagedThreadId, DateTime.Now); using (TestSystemClock.Install(Log)) { var countdown = new CountdownEvent(1); SystemClock.ScheduleWork(() => { @@ -28,11 +29,13 @@ public void CanScheduleWork() { countdown.Wait(); Assert.Equal(0, countdown.CurrentCount); } + _logger.LogTrace("Ending test on thread {ThreadId} time {Time}", Thread.CurrentThread.ManagedThreadId, DateTime.Now); } [Fact] public void CanScheduleMultipleUnorderedWorkItems() { Log.MinimumLevel = LogLevel.Trace; + _logger.LogTrace("Starting test on thread {ThreadId} time {Time}", Thread.CurrentThread.ManagedThreadId, DateTime.Now); using (TestSystemClock.Install(Log)) { var countdown = new CountdownEvent(3); @@ -63,8 +66,8 @@ public void CanScheduleMultipleUnorderedWorkItems() { Assert.Equal(2, countdown.CurrentCount); // verify additional work will not happen until time changes - Assert.False(countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(2))); - + countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(2)); + _logger.LogTrace("Adding 1 minute to current time"); // sleeping for a minute to make 1 second work due SystemClock.Sleep(TimeSpan.FromMinutes(1)); @@ -82,11 +85,14 @@ public void CanScheduleMultipleUnorderedWorkItems() { _logger.LogTrace("Check that current countdown is zero"); Assert.Equal(0, countdown.CurrentCount); } + _logger.LogTrace("Ending test on thread {ThreadId} time {Time}", Thread.CurrentThread.ManagedThreadId, DateTime.Now); } - // long running work item won't block other work items from running + // long running work item won't block other work items from running, this is bad usage, but we should make sure it works. // can run with no work scheduled // work items that throw don't affect other work items // do we need to do anything for unhandled exceptions or would users just use the normal unhandled exception handler since the tasks are just being run on the normal thread pool + // test overall performance, what is the throughput of this scheduler? Should be good since it's just using the normal thread pool, but we may want to increase max concurrent or base it on ThreadPool.GetMaxThreads to avoid thread starvation solely coming from Foundatio. + // verify multiple tests manipulating the systemclock don't affect each other } } From 6a5421c0160509e19241ba5d85ef31f687d90e19 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 15 May 2019 00:53:27 -0500 Subject: [PATCH 24/29] Some work scheduler test changes --- src/Foundatio/Utility/SystemClock.cs | 4 ++- src/Foundatio/Utility/WorkScheduler.cs | 1 - .../Utility/WorkSchedulerTests.cs | 28 +++++++++---------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/Foundatio/Utility/SystemClock.cs b/src/Foundatio/Utility/SystemClock.cs index 2ec814d02..5b5fa0c37 100644 --- a/src/Foundatio/Utility/SystemClock.cs +++ b/src/Foundatio/Utility/SystemClock.cs @@ -83,7 +83,8 @@ public void SubtractTime(TimeSpan amount) { public void UseFakeSleep() => _fakeSleep = true; public void UseRealSleep() => _fakeSleep = false; - + public bool IsTimeFrozen => _fakeSleep = _fixedUtc != null; + public void Sleep(int milliseconds) { if (!_fakeSleep) { Thread.Sleep(milliseconds); @@ -176,6 +177,7 @@ public class TestSystemClock { public static void Unfreeze() => TestSystemClockImpl.Instance.Unfreeze(); public static void SetFrozenTime(DateTime time) => TestSystemClockImpl.Instance.SetFrozenTime(time); public static void SetTime(DateTime time, bool freeze = false) => TestSystemClockImpl.Instance.SetTime(time, freeze); + public static bool IsTimeFrozen => TestSystemClockImpl.Instance.IsTimeFrozen; public static event EventHandler Changed { add => TestSystemClockImpl.Instance.Changed += value; diff --git a/src/Foundatio/Utility/WorkScheduler.cs b/src/Foundatio/Utility/WorkScheduler.cs index f48e8ef5b..8b710ee68 100644 --- a/src/Foundatio/Utility/WorkScheduler.cs +++ b/src/Foundatio/Utility/WorkScheduler.cs @@ -1,7 +1,6 @@ using System; using System.Threading; using System.Threading.Tasks; -using Foundatio.AsyncEx; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; diff --git a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs index 27a1ca580..3ead804fa 100644 --- a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs +++ b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs @@ -37,24 +37,26 @@ public void CanScheduleMultipleUnorderedWorkItems() { Log.MinimumLevel = LogLevel.Trace; _logger.LogTrace("Starting test on thread {ThreadId} time {Time}", Thread.CurrentThread.ManagedThreadId, DateTime.Now); using (TestSystemClock.Install(Log)) { - var countdown = new CountdownEvent(3); + var work1Event = new ManualResetEvent(false); + var work2Event = new ManualResetEvent(false); + var work3Event = new ManualResetEvent(false); // schedule work due in 5 minutes SystemClock.ScheduleWork(() => { _logger.LogTrace("Doing 5 minute work"); - countdown.Signal(); + work1Event.Set(); }, TimeSpan.FromMinutes(5)); // schedule work due in 1 second SystemClock.ScheduleWork(() => { _logger.LogTrace("Doing 1 second work"); - countdown.Signal(); + work2Event.Set(); }, TimeSpan.FromSeconds(1)); // schedule work that is already past due SystemClock.ScheduleWork(() => { _logger.LogTrace("Doing past due work"); - countdown.Signal(); + work3Event.Set(); }, TimeSpan.FromSeconds(-1)); // wait until we get signal that no items are currently due @@ -62,28 +64,24 @@ public void CanScheduleMultipleUnorderedWorkItems() { Assert.True(WorkScheduler.Default.NoWorkItemsDue.WaitOne(), "Wait for all due work items to be scheduled"); _logger.LogTrace("Waiting for past due work to be completed"); // work can be done before we even get here, but wait one to be sure it's done - countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(1)); - Assert.Equal(2, countdown.CurrentCount); - + Assert.True(work3Event.WaitOne(TimeSpan.FromSeconds(1))); + // verify additional work will not happen until time changes - countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(2)); - + Assert.False(work2Event.WaitOne(TimeSpan.FromSeconds(1))); + _logger.LogTrace("Adding 1 minute to current time"); // sleeping for a minute to make 1 second work due SystemClock.Sleep(TimeSpan.FromMinutes(1)); Assert.True(WorkScheduler.Default.NoWorkItemsDue.WaitOne()); - countdown.WaitHandle.WaitOne(TimeSpan.FromSeconds(1)); - Assert.Equal(1, countdown.CurrentCount); + Assert.True(work2Event.WaitOne(TimeSpan.FromSeconds(1))); _logger.LogTrace("Adding 5 minutes to current time"); // sleeping for 5 minutes to make 5 minute work due SystemClock.Sleep(TimeSpan.FromMinutes(5)); _logger.LogTrace("Waiting for no work items due"); Assert.True(WorkScheduler.Default.NoWorkItemsDue.WaitOne()); - _logger.LogTrace("Waiting for countdown to reach zero"); - Assert.True(countdown.Wait(TimeSpan.FromSeconds(1))); - _logger.LogTrace("Check that current countdown is zero"); - Assert.Equal(0, countdown.CurrentCount); + _logger.LogTrace("Waiting for 5 minute work to be completes"); + Assert.True(work1Event.WaitOne(TimeSpan.FromSeconds(1))); } _logger.LogTrace("Ending test on thread {ThreadId} time {Time}", Thread.CurrentThread.ManagedThreadId, DateTime.Now); } From c8889b330691f5e04550175edcb3cb79e3ab057a Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 31 May 2019 15:19:34 -0500 Subject: [PATCH 25/29] Progress --- .../Caching/CacheClientTestsBase.cs | 9 +- src/Foundatio/Messaging/IMessageSubscriber.cs | 30 +- src/Foundatio/Messaging/MessageBusBase.cs | 6 +- src/Foundatio/Messaging/NullMessageBus.cs | 4 + src/Foundatio/SystemClock/ISystemClock.cs | 17 ++ src/Foundatio/SystemClock/ITestSystemClock.cs | 11 + src/Foundatio/SystemClock/RealSystemClock.cs | 21 ++ src/Foundatio/SystemClock/SystemClock.cs | 98 ++++++ src/Foundatio/SystemClock/TestSystemClock.cs | 26 ++ .../SystemClock/TestSystemClockImpl.cs | 79 +++++ .../{Utility => SystemClock}/WorkScheduler.cs | 51 ++-- src/Foundatio/Utility/SystemClock.cs | 283 ------------------ tests/Foundatio.Tests/Jobs/JobTests.cs | 12 +- .../Utility/SystemClockTests.cs | 25 +- .../Utility/WorkSchedulerTests.cs | 28 +- 15 files changed, 341 insertions(+), 359 deletions(-) create mode 100644 src/Foundatio/SystemClock/ISystemClock.cs create mode 100644 src/Foundatio/SystemClock/ITestSystemClock.cs create mode 100644 src/Foundatio/SystemClock/RealSystemClock.cs create mode 100644 src/Foundatio/SystemClock/SystemClock.cs create mode 100644 src/Foundatio/SystemClock/TestSystemClock.cs create mode 100644 src/Foundatio/SystemClock/TestSystemClockImpl.cs rename src/Foundatio/{Utility => SystemClock}/WorkScheduler.cs (73%) delete mode 100644 src/Foundatio/Utility/SystemClock.cs diff --git a/src/Foundatio.TestHarness/Caching/CacheClientTestsBase.cs b/src/Foundatio.TestHarness/Caching/CacheClientTestsBase.cs index ec9af67eb..b6c8a1dd8 100644 --- a/src/Foundatio.TestHarness/Caching/CacheClientTestsBase.cs +++ b/src/Foundatio.TestHarness/Caching/CacheClientTestsBase.cs @@ -411,15 +411,12 @@ public virtual async Task CanSetMinMaxExpirationAsync() { using (cache) { await cache.RemoveAllAsync(); - using (TestSystemClock.Install()) { - var now = DateTime.UtcNow; - TestSystemClock.SetFrozenTime(now); - - var expires = DateTime.MaxValue - now.AddDays(1); + using (var clock = TestSystemClock.Install()) { + var expires = DateTime.MaxValue - clock.Now().AddDays(1); Assert.True(await cache.SetAsync("test1", 1, expires)); Assert.False(await cache.SetAsync("test2", 1, DateTime.MinValue)); Assert.True(await cache.SetAsync("test3", 1, DateTime.MaxValue)); - Assert.True(await cache.SetAsync("test4", 1, DateTime.MaxValue - now.AddDays(-1))); + Assert.True(await cache.SetAsync("test4", 1, DateTime.MaxValue - clock.Now().AddDays(-1))); Assert.Equal(1, (await cache.GetAsync("test1")).Value); Assert.InRange((await cache.GetExpirationAsync("test1")).Value, expires.Subtract(TimeSpan.FromSeconds(10)), expires); diff --git a/src/Foundatio/Messaging/IMessageSubscriber.cs b/src/Foundatio/Messaging/IMessageSubscriber.cs index 78305aca7..065af4083 100644 --- a/src/Foundatio/Messaging/IMessageSubscriber.cs +++ b/src/Foundatio/Messaging/IMessageSubscriber.cs @@ -4,12 +4,14 @@ using Foundatio.Utility; namespace Foundatio.Messaging { - public interface IMessageSubscriber { + public interface IMessageSubscriber : IDisposable { Task SubscribeAsync(MessageSubscriptionOptions options, Func handler); + Task ReceiveAsync(MessageReceiveOptions options); } public class MessageSubscriptionOptions { public Type MessageType { get; set; } + public int PrefetchCount { get; set; } = 1; public CancellationToken CancellationToken { get; set; } public MessageSubscriptionOptions WithMessageType(Type messageType) { @@ -17,12 +19,38 @@ public MessageSubscriptionOptions WithMessageType(Type messageType) { return this; } + public MessageSubscriptionOptions WithPrefetchCount(int prefetchCount) { + PrefetchCount = prefetchCount; + return this; + } + public MessageSubscriptionOptions WithCancellationToken(CancellationToken cancellationToken) { CancellationToken = cancellationToken; return this; } } + public class MessageReceiveOptions { + public Type MessageType { get; set; } + public TimeSpan Timeout { get; set; } + public CancellationToken CancellationToken { get; set; } + + public MessageReceiveOptions WithMessageType(Type messageType) { + MessageType = messageType; + return this; + } + + public MessageReceiveOptions WithTimeout(TimeSpan timeout) { + Timeout = timeout; + return this; + } + + public MessageReceiveOptions WithCancellationToken(CancellationToken cancellationToken) { + CancellationToken = cancellationToken; + return this; + } + } + public static class MessageBusExtensions { public static async Task SubscribeAsync(this IMessageSubscriber subscriber, Func handler, CancellationToken cancellationToken = default) where T : class { if (cancellationToken.IsCancellationRequested) diff --git a/src/Foundatio/Messaging/MessageBusBase.cs b/src/Foundatio/Messaging/MessageBusBase.cs index b69913663..4820e56e6 100644 --- a/src/Foundatio/Messaging/MessageBusBase.cs +++ b/src/Foundatio/Messaging/MessageBusBase.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Foundatio.Messaging { - public abstract class MessageBusBase : IMessageBus, IDisposable where TOptions : SharedMessageBusOptions { + public abstract class MessageBusBase : IMessageBus where TOptions : SharedMessageBusOptions { protected readonly List _subscriptions = new List(); protected readonly TOptions _options; protected readonly ISerializer _serializer; @@ -90,6 +90,10 @@ public async Task SubscribeAsync(MessageSubscriptionOption return subscription; } + public Task ReceiveAsync(MessageReceiveOptions options) { + throw new NotImplementedException(); + } + protected bool MessageTypeHasSubscribers(Type messageType) { var subscribers = _subscriptions.Where(s => s.MessageType.IsAssignableFrom(messageType)).ToList(); return subscribers.Count == 0; diff --git a/src/Foundatio/Messaging/NullMessageBus.cs b/src/Foundatio/Messaging/NullMessageBus.cs index 8c291dc95..775ba9382 100644 --- a/src/Foundatio/Messaging/NullMessageBus.cs +++ b/src/Foundatio/Messaging/NullMessageBus.cs @@ -15,6 +15,10 @@ public Task SubscribeAsync(MessageSubscriptionOptions opti return Task.FromResult(new MessageSubscription(options.MessageType, () => {})); } + public Task ReceiveAsync(MessageReceiveOptions options) { + return Task.FromResult(null); + } + public void Dispose() {} } } diff --git a/src/Foundatio/SystemClock/ISystemClock.cs b/src/Foundatio/SystemClock/ISystemClock.cs new file mode 100644 index 000000000..b44c8b1d2 --- /dev/null +++ b/src/Foundatio/SystemClock/ISystemClock.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Foundatio.Utility { + public interface ISystemClock { + DateTime Now(); + DateTime UtcNow(); + DateTimeOffset OffsetNow(); + DateTimeOffset OffsetUtcNow(); + void Sleep(int milliseconds); + Task SleepAsync(int milliseconds, CancellationToken ct); + TimeSpan TimeZoneOffset(); + void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null); + void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null); + } +} \ No newline at end of file diff --git a/src/Foundatio/SystemClock/ITestSystemClock.cs b/src/Foundatio/SystemClock/ITestSystemClock.cs new file mode 100644 index 000000000..9f675da71 --- /dev/null +++ b/src/Foundatio/SystemClock/ITestSystemClock.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading; + +namespace Foundatio.Utility { + public interface ITestSystemClock : ISystemClock, IDisposable { + void AddTime(TimeSpan amount); + void SetTime(DateTime time, TimeSpan? timeZoneOffset = null); + WaitHandle NoScheduledWorkItemsDue { get; } + event EventHandler Changed; + } +} \ No newline at end of file diff --git a/src/Foundatio/SystemClock/RealSystemClock.cs b/src/Foundatio/SystemClock/RealSystemClock.cs new file mode 100644 index 000000000..4d653f819 --- /dev/null +++ b/src/Foundatio/SystemClock/RealSystemClock.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Foundatio.Utility { + public class RealSystemClock : ISystemClock { + public static readonly RealSystemClock Instance = new RealSystemClock(); + + public DateTime Now() => DateTime.Now; + public DateTime UtcNow() => DateTime.UtcNow; + public DateTimeOffset OffsetNow() => DateTimeOffset.Now; + public DateTimeOffset OffsetUtcNow() => DateTimeOffset.UtcNow; + public void Sleep(int milliseconds) => Thread.Sleep(milliseconds); + public Task SleepAsync(int milliseconds, CancellationToken ct) => Task.Delay(milliseconds, ct); + public TimeSpan TimeZoneOffset() => DateTimeOffset.Now.Offset; + public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) + => WorkScheduler.Default.Schedule(action, delay, interval); + public void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) + => WorkScheduler.Default.Schedule(action, executeAt, interval); + } +} \ No newline at end of file diff --git a/src/Foundatio/SystemClock/SystemClock.cs b/src/Foundatio/SystemClock/SystemClock.cs new file mode 100644 index 000000000..452373633 --- /dev/null +++ b/src/Foundatio/SystemClock/SystemClock.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Foundatio.Utility { + public static class SystemClock { + private static AsyncLocal _instance; + + public static ISystemClock Instance => _instance?.Value ?? RealSystemClock.Instance; + + public static void SetInstance(ISystemClock clock, ILoggerFactory loggerFactory) { + var logger = loggerFactory?.CreateLogger("SystemClock") ?? NullLogger.Instance; + _instance = new AsyncLocal(e => { + if (e.ThreadContextChanged) + return; + + if (e.PreviousValue != null && e.CurrentValue != null) { + var diff = e.PreviousValue.Now().Subtract(e.CurrentValue.Now()); + logger.LogTrace("SystemClock instance is being changed by {ThreadId} from {OldTime} to {NewTime} diff {Difference:g}", Thread.CurrentThread.ManagedThreadId, e.PreviousValue?.Now(), e.CurrentValue?.Now(), diff); + } + + if (e.PreviousValue == null) + logger.LogTrace("SystemClock instance is being initially set by {ThreadId} to {NewTime}", Thread.CurrentThread.ManagedThreadId, e.CurrentValue?.Now()); + + if (e.CurrentValue == null) + logger.LogTrace("SystemClock instance is being removed set by {ThreadId} from {OldTime}", Thread.CurrentThread.ManagedThreadId, e.PreviousValue?.Now()); + }); + + if (clock == null || clock is RealSystemClock) { + if (_instance != null) + _instance.Value = null; + _instance = null; + } else { + _instance.Value = clock; + } + } + + public static DateTime Now => Instance.Now(); + public static DateTime UtcNow => Instance.UtcNow(); + public static DateTimeOffset OffsetNow => Instance.OffsetNow(); + public static DateTimeOffset OffsetUtcNow => Instance.OffsetUtcNow(); + public static TimeSpan TimeZoneOffset => Instance.TimeZoneOffset(); + public static void Sleep(int milliseconds) => Instance.Sleep(milliseconds); + + public static Task SleepAsync(int milliseconds, CancellationToken cancellationToken = default) + => Instance.SleepAsync(milliseconds, cancellationToken); + + public static void Sleep(TimeSpan delay) + => Instance.Sleep(delay); + + public static Task SleepAsync(TimeSpan delay, CancellationToken cancellationToken = default) + => Instance.SleepAsync(delay, cancellationToken); + + public static Task SleepSafeAsync(int milliseconds, CancellationToken cancellationToken = default) { + return Instance.SleepSafeAsync(milliseconds, cancellationToken); + } + + public static Task SleepSafeAsync(TimeSpan delay, CancellationToken cancellationToken = default) + => Instance.SleepSafeAsync(delay, cancellationToken); + + public static void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null) + => Instance.ScheduleWork(action, delay, interval); + + public static void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) + => Instance.ScheduleWork(action, delay, interval); + + public static void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) + => Instance.ScheduleWork(action, executeAt, interval); + + public static void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) + => Instance.ScheduleWork(action, executeAt, interval); + } + + public static class SystemClockExtensions { + public static void Sleep(this ISystemClock clock, TimeSpan delay) + => clock.Sleep((int)delay.TotalMilliseconds); + + public static Task SleepAsync(this ISystemClock clock, TimeSpan delay, CancellationToken cancellationToken = default) + => clock.SleepAsync((int)delay.TotalMilliseconds, cancellationToken); + + public static async Task SleepSafeAsync(this ISystemClock clock, int milliseconds, CancellationToken cancellationToken = default) { + try { + await clock.SleepAsync(milliseconds, cancellationToken).AnyContext(); + } catch (OperationCanceledException) {} + } + + public static Task SleepSafeAsync(this ISystemClock clock, TimeSpan delay, CancellationToken cancellationToken = default) + => clock.SleepSafeAsync((int)delay.TotalMilliseconds, cancellationToken); + + public static void ScheduleWork(this ISystemClock clock, Func action, TimeSpan delay, TimeSpan? interval = null) => + clock.ScheduleWork(() => { _ = action(); }, delay, interval); + + public static void ScheduleWork(this ISystemClock clock, Func action, DateTime executeAt, TimeSpan? interval = null) => + clock.ScheduleWork(() => { _ = action(); }, executeAt, interval); + } +} \ No newline at end of file diff --git a/src/Foundatio/SystemClock/TestSystemClock.cs b/src/Foundatio/SystemClock/TestSystemClock.cs new file mode 100644 index 000000000..2e9ae074a --- /dev/null +++ b/src/Foundatio/SystemClock/TestSystemClock.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Utility { + public class TestSystemClock { + public static void AddTime(TimeSpan amount) => TestSystemClockImpl.Instance.AddTime(amount); + public static void SetTime(DateTime time, TimeSpan? timeZoneOffset = null) => TestSystemClockImpl.Instance.SetTime(time, timeZoneOffset); + + public static event EventHandler Changed { + add => TestSystemClockImpl.Instance.Changed += value; + remove => TestSystemClockImpl.Instance.Changed -= value; + } + + public static ITestSystemClock Create(ILoggerFactory loggerFactory = null) { + var testClock = new TestSystemClockImpl(SystemClock.Instance, loggerFactory); + return testClock; + } + + public static ITestSystemClock Install(ILoggerFactory loggerFactory = null) { + var testClock = new TestSystemClockImpl(SystemClock.Instance, loggerFactory); + SystemClock.SetInstance(testClock, loggerFactory); + + return testClock; + } + } +} \ No newline at end of file diff --git a/src/Foundatio/SystemClock/TestSystemClockImpl.cs b/src/Foundatio/SystemClock/TestSystemClockImpl.cs new file mode 100644 index 000000000..7db4f28cb --- /dev/null +++ b/src/Foundatio/SystemClock/TestSystemClockImpl.cs @@ -0,0 +1,79 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Foundatio.Utility { + internal class TestSystemClockImpl : ITestSystemClock { + private DateTimeOffset _time = DateTimeOffset.Now; + private readonly ISystemClock _originalClock; + private readonly WorkScheduler _workScheduler; + + public TestSystemClockImpl(ILoggerFactory loggerFactory) { + loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + var logger = loggerFactory.CreateLogger("Foundatio.Utility.SystemClock"); + _workScheduler = new WorkScheduler(this, logger); + } + + public TestSystemClockImpl(ISystemClock originalTime, ILoggerFactory loggerFactory) : this(loggerFactory) { + _originalClock = originalTime; + } + + public DateTime UtcNow() => _time.UtcDateTime; + public DateTime Now() => _time.DateTime; + public DateTimeOffset OffsetNow() => _time; + public DateTimeOffset OffsetUtcNow() => new DateTimeOffset(UtcNow().Ticks, TimeSpan.Zero); + public TimeSpan TimeZoneOffset() => _time.Offset; + public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) + => _workScheduler.Schedule(action, delay, interval); + public void ScheduleWork(Action action, DateTime executeAtUtc, TimeSpan? interval = null) + => _workScheduler.Schedule(action, executeAtUtc, interval); + + public void AddTime(TimeSpan amount) { + _time = _time.Add(amount); + OnChanged(); + } + + public void SetTime(DateTime time, TimeSpan? timeZoneOffset = null) { + if (timeZoneOffset.HasValue) + _time = new DateTimeOffset(time.ToUniversalTime(), timeZoneOffset.Value); + else + _time = new DateTimeOffset(time); + OnChanged(); + } + + public WaitHandle NoScheduledWorkItemsDue => _workScheduler.NoWorkItemsDue; + + public void Sleep(int milliseconds) { + AddTime(TimeSpan.FromMilliseconds(milliseconds)); + Thread.Sleep(1); + } + + public Task SleepAsync(int milliseconds, CancellationToken ct) { + Sleep(milliseconds); + return Task.CompletedTask; + } + + public event EventHandler Changed; + public WorkScheduler Scheduler => _workScheduler; + + private void OnChanged() { + Changed?.Invoke(this, EventArgs.Empty); + } + + public void Dispose() { + if (_originalClock != null) + SystemClock.SetInstance(_originalClock, null); + } + + public static TestSystemClockImpl Instance { + get { + if (!(SystemClock.Instance is TestSystemClockImpl testClock)) + throw new ArgumentException("You must first install TestSystemClock using TestSystemClock.Install"); + + return testClock; + } + } + } +} \ No newline at end of file diff --git a/src/Foundatio/Utility/WorkScheduler.cs b/src/Foundatio/SystemClock/WorkScheduler.cs similarity index 73% rename from src/Foundatio/Utility/WorkScheduler.cs rename to src/Foundatio/SystemClock/WorkScheduler.cs index 8b710ee68..f4ca7e219 100644 --- a/src/Foundatio/Utility/WorkScheduler.cs +++ b/src/Foundatio/SystemClock/WorkScheduler.cs @@ -10,7 +10,7 @@ namespace Foundatio.Utility { /// This is the same as using the thread pool. Long running tasks should not be scheduled on this. Tasks should generally last no longer than a few seconds. /// public class WorkScheduler : IDisposable { - public static readonly WorkScheduler Default = new WorkScheduler(); + public static readonly WorkScheduler Default = new WorkScheduler(SystemClock.Instance); private ILogger _logger; private bool _isDisposed = false; @@ -19,40 +19,27 @@ public class WorkScheduler : IDisposable { private Task _workLoopTask; private readonly object _lock = new object(); private readonly AutoResetEvent _workItemScheduled = new AutoResetEvent(false); + private readonly ISystemClock _clock; + private readonly AutoResetEvent _noWorkItemsDue = new AutoResetEvent(false); - public WorkScheduler(ILoggerFactory loggerFactory = null) { - _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; + public WorkScheduler(ISystemClock clock, ILogger logger = null) { + _clock = clock; + _logger = logger ?? NullLogger.Instance; // limit scheduled task processing to 50 at a time _taskFactory = new TaskFactory(new LimitedConcurrencyLevelTaskScheduler(50)); } - - public void SetLogger(ILogger logger) { - _logger = logger ?? NullLogger.Instance; - } - - public void SetLogger(ILoggerFactory loggerFactory) { - _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; - } - - public AutoResetEvent NoWorkItemsDue { get; } = new AutoResetEvent(false); - - public void Schedule(Func action, TimeSpan delay, TimeSpan? interval = null) { - Schedule(() => { _ = action(); }, SystemClock.UtcNow.Add(delay), interval); - } + + public WaitHandle NoWorkItemsDue => _noWorkItemsDue; public void Schedule(Action action, TimeSpan delay, TimeSpan? interval = null) { - Schedule(action, SystemClock.UtcNow.Add(delay), interval); - } - - public void Schedule(Func action, DateTime executeAt, TimeSpan? interval = null) { - Schedule(() => { _ = action(); }, executeAt, interval); + Schedule(action, _clock.UtcNow().Add(delay), interval); } public void Schedule(Action action, DateTime executeAt, TimeSpan? interval = null) { if (executeAt.Kind != DateTimeKind.Utc) executeAt = executeAt.ToUniversalTime(); - var delay = executeAt.Subtract(SystemClock.UtcNow); + var delay = executeAt.Subtract(_clock.UtcNow()); _logger.LogTrace("Scheduling work due at {ExecuteAt} ({Delay:g} from now)", executeAt, delay); _workItems.Enqueue(executeAt, new WorkItem { Action = action, @@ -73,7 +60,9 @@ private void EnsureWorkLoopRunning() { return; _logger.LogTrace("Starting work loop"); - TestSystemClock.Changed += (s, e) => { _workItemScheduled.Set(); }; + if (_clock is ITestSystemClock testClock) + testClock.Changed += (s, e) => { _workItemScheduled.Set(); }; + _workLoopTask = Task.Factory.StartNew(WorkLoop, TaskCreationOptions.LongRunning); } } @@ -81,21 +70,21 @@ private void EnsureWorkLoopRunning() { private void WorkLoop() { _logger.LogTrace("Work loop started"); while (!_isDisposed) { - if (_workItems.TryDequeueIf(out var kvp, i => i.ExecuteAtUtc < SystemClock.UtcNow)) { - _logger.LogTrace("Starting work item due at {DueTime} current time {CurrentTime}", kvp.Key, SystemClock.UtcNow); + if (_workItems.TryDequeueIf(out var kvp, i => i.ExecuteAtUtc < _clock.UtcNow())) { + _logger.LogTrace("Starting work item due at {DueTime} current time {CurrentTime}", kvp.Key, _clock.UtcNow()); _ = _taskFactory.StartNew(() => { - var startTime = SystemClock.UtcNow; + var startTime = _clock.UtcNow(); kvp.Value.Action(); if (kvp.Value.Interval.HasValue) Schedule(kvp.Value.Action, startTime.Add(kvp.Value.Interval.Value)); }); continue; } - - NoWorkItemsDue.Set(); - + + _noWorkItemsDue.Set(); + if (kvp.Key != default) { - var delay = kvp.Key.Subtract(SystemClock.UtcNow); + var delay = kvp.Key.Subtract(_clock.UtcNow()); _logger.LogTrace("No work items due, next due at {DueTime} ({Delay:g} from now)", kvp.Key, delay); _workItemScheduled.WaitOne(delay); } else { diff --git a/src/Foundatio/Utility/SystemClock.cs b/src/Foundatio/Utility/SystemClock.cs deleted file mode 100644 index 5b5fa0c37..000000000 --- a/src/Foundatio/Utility/SystemClock.cs +++ /dev/null @@ -1,283 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Foundatio.Utility { - public interface ISystemClock { - DateTime Now(); - DateTime UtcNow(); - DateTimeOffset OffsetNow(); - DateTimeOffset OffsetUtcNow(); - void Sleep(int milliseconds); - Task SleepAsync(int milliseconds, CancellationToken ct); - TimeSpan TimeZoneOffset(); - void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null); - void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null); - void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null); - void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null); - } - - public class RealSystemClock : ISystemClock { - public static readonly RealSystemClock Instance = new(); - - public DateTime Now() => DateTime.Now; - public DateTime UtcNow() => DateTime.UtcNow; - public DateTimeOffset OffsetNow() => DateTimeOffset.Now; - public DateTimeOffset OffsetUtcNow() => DateTimeOffset.UtcNow; - public void Sleep(int milliseconds) => Thread.Sleep(milliseconds); - public Task SleepAsync(int milliseconds, CancellationToken ct) => Task.Delay(milliseconds, ct); - public TimeSpan TimeZoneOffset() => DateTimeOffset.Now.Offset; - public void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null) - => WorkScheduler.Default.Schedule(action, delay, interval); - public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) - => WorkScheduler.Default.Schedule(action, delay, interval); - public void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) - => WorkScheduler.Default.Schedule(action, executeAt, interval); - public void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) - => WorkScheduler.Default.Schedule(action, executeAt, interval); - } - - internal class TestSystemClockImpl : ISystemClock, IDisposable { - private DateTime? _fixedUtc = null; - private TimeSpan _offset = TimeSpan.Zero; - private TimeSpan _timeZoneOffset = DateTimeOffset.Now.Offset; - private bool _fakeSleep = false; - private ISystemClock _originalClock; - - public TestSystemClockImpl() {} - - public TestSystemClockImpl(ISystemClock originalTime) { - _originalClock = originalTime; - } - - public DateTime UtcNow() => (_fixedUtc ?? DateTime.UtcNow).Add(_offset); - public DateTime Now() => new(UtcNow().Ticks + TimeZoneOffset().Ticks, DateTimeKind.Local); - public DateTimeOffset OffsetNow() => new(UtcNow().Ticks + TimeZoneOffset().Ticks, TimeZoneOffset()); - public DateTimeOffset OffsetUtcNow() => new(UtcNow().Ticks, TimeSpan.Zero); - public TimeSpan TimeZoneOffset() => _timeZoneOffset; - public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) - => WorkScheduler.Default.Schedule(action, delay, interval); - public void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null) - => WorkScheduler.Default.Schedule(action, delay, interval); - public void ScheduleWork(Action action, DateTime executeAtUtc, TimeSpan? interval = null) - => WorkScheduler.Default.Schedule(action, executeAtUtc, interval); - public void ScheduleWork(Func action, DateTime executeAtUtc, TimeSpan? interval = null) - => WorkScheduler.Default.Schedule(action, executeAtUtc, interval); - - public void SetTimeZoneOffset(TimeSpan offset) { - _timeZoneOffset = offset; - OnChanged(); - } - - public void AddTime(TimeSpan amount) { - _offset = _offset.Add(amount); - OnChanged(); - } - - public void SubtractTime(TimeSpan amount) { - _offset = _offset.Subtract(amount); - OnChanged(); - } - - public void UseFakeSleep() => _fakeSleep = true; - public void UseRealSleep() => _fakeSleep = false; - public bool IsTimeFrozen => _fakeSleep = _fixedUtc != null; - - public void Sleep(int milliseconds) { - if (!_fakeSleep) { - Thread.Sleep(milliseconds); - return; - } - - AddTime(TimeSpan.FromMilliseconds(milliseconds)); - Thread.Sleep(1); - } - - public Task SleepAsync(int milliseconds, CancellationToken ct) { - if (!_fakeSleep) - return Task.Delay(milliseconds, ct); - - Sleep(milliseconds); - return Task.CompletedTask; - } - - public DateTime Freeze() { - var now = Now(); - SetFrozenTime(now); - return now; - } - - public void Unfreeze() { - SetTime(Now()); - } - - public void SetFrozenTime(DateTime time) { - UseFakeSleep(); - SetTime(time, true); - } - - public void SetTime(DateTime time, bool freeze = false) { - var now = DateTime.Now; - if (freeze) { - if (time.Kind == DateTimeKind.Unspecified) - time = time.ToUniversalTime(); - - if (time.Kind == DateTimeKind.Utc) { - _fixedUtc = time; - OnChanged(); - } else if (time.Kind == DateTimeKind.Local) { - _fixedUtc = new DateTime(time.Ticks - TimeZoneOffset().Ticks, DateTimeKind.Utc); - OnChanged(); - } - } else { - _fixedUtc = null; - - if (time.Kind == DateTimeKind.Unspecified) - time = time.ToUniversalTime(); - - if (time.Kind == DateTimeKind.Utc) { - _offset = now.ToUniversalTime().Subtract(time); - OnChanged(); - } else if (time.Kind == DateTimeKind.Local) { - _offset = now.Subtract(time); - OnChanged(); - } - } - } - - public event EventHandler Changed; - - private void OnChanged() { - Changed?.Invoke(this, EventArgs.Empty); - } - - public void Dispose() { - SystemClock.SetInstance(_originalClock, null); - } - - public static TestSystemClockImpl Instance { - get { - if (!(SystemClock.Instance is TestSystemClockImpl testClock)) - throw new ArgumentException("You must first install TestSystemClock using TestSystemClock.Install"); - - return testClock; - } - } - } - - public class TestSystemClock { - public static void SetTimeZoneOffset(TimeSpan offset) => TestSystemClockImpl.Instance.SetTimeZoneOffset(offset); - public static void AddTime(TimeSpan amount) => TestSystemClockImpl.Instance.AddTime(amount); - public static void SubtractTime(TimeSpan amount) => TestSystemClockImpl.Instance.SubtractTime(amount); - public static void UseFakeSleep() => TestSystemClockImpl.Instance.UseFakeSleep(); - public static void UseRealSleep() => TestSystemClockImpl.Instance.UseRealSleep(); - public static DateTime Freeze() => TestSystemClockImpl.Instance.Freeze(); - public static void Unfreeze() => TestSystemClockImpl.Instance.Unfreeze(); - public static void SetFrozenTime(DateTime time) => TestSystemClockImpl.Instance.SetFrozenTime(time); - public static void SetTime(DateTime time, bool freeze = false) => TestSystemClockImpl.Instance.SetTime(time, freeze); - public static bool IsTimeFrozen => TestSystemClockImpl.Instance.IsTimeFrozen; - - public static event EventHandler Changed { - add => TestSystemClockImpl.Instance.Changed += value; - remove => TestSystemClockImpl.Instance.Changed -= value; - } - - public static IDisposable Install(ILoggerFactory loggerFactory) { - return Install(true, loggerFactory); - } - - public static IDisposable Install(bool freeze = true, ILoggerFactory loggerFactory = null) { - var testClock = new TestSystemClockImpl(SystemClock.Instance); - SystemClock.SetInstance(testClock, loggerFactory); - if (freeze) - testClock.Freeze(); - - if (loggerFactory != null) - WorkScheduler.Default.SetLogger(loggerFactory); - - return testClock; - } - } - - public static class SystemClock { - private static AsyncLocal _instance; - - public static ISystemClock Instance => _instance?.Value ?? RealSystemClock.Instance; - - public static void SetInstance(ISystemClock clock, ILoggerFactory loggerFactory) { - var logger = loggerFactory?.CreateLogger("SystemClock") ?? NullLogger.Instance; - _instance = new AsyncLocal(e => { - if (e.ThreadContextChanged) - return; - - if (e.PreviousValue != null && e.CurrentValue != null) { - var diff = e.PreviousValue.Now().Subtract(e.CurrentValue.Now()); - logger.LogTrace("SystemClock instance is being changed by {ThreadId} from {OldTime} to {NewTime} diff {Difference:g}", Thread.CurrentThread.ManagedThreadId, e.PreviousValue?.Now(), e.CurrentValue?.Now(), diff); - } - - if (e.PreviousValue == null) - logger.LogTrace("SystemClock instance is being initially set by {ThreadId} to {NewTime}", Thread.CurrentThread.ManagedThreadId, e.CurrentValue?.Now()); - - if (e.CurrentValue == null) - logger.LogTrace("SystemClock instance is being removed set by {ThreadId} from {OldTime}", Thread.CurrentThread.ManagedThreadId, e.PreviousValue?.Now()); - }); - - if (clock == null || clock is RealSystemClock) { - if (_instance != null) - _instance.Value = null; - _instance = null; - } else { - _instance.Value = clock; - } - } - - public static DateTime Now => Instance.Now(); - public static DateTime UtcNow => Instance.UtcNow(); - public static DateTimeOffset OffsetNow => Instance.OffsetNow(); - public static DateTimeOffset OffsetUtcNow => Instance.OffsetUtcNow(); - public static TimeSpan TimeZoneOffset => Instance.TimeZoneOffset(); - public static void Sleep(int milliseconds) => Instance.Sleep(milliseconds); - public static Task SleepAsync(int milliseconds, CancellationToken cancellationToken = default) - => Instance.SleepAsync(milliseconds, cancellationToken); - public static void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) - => Instance.ScheduleWork(action, delay, interval); - public static void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null) - => Instance.ScheduleWork(action, delay, interval); - public static void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) - => Instance.ScheduleWork(action, executeAt, interval); - public static void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) - => Instance.ScheduleWork(action, executeAt, interval); - - public static void Sleep(TimeSpan delay) - => Instance.Sleep(delay); - - public static Task SleepAsync(TimeSpan delay, CancellationToken cancellationToken = default) - => Instance.SleepAsync(delay, cancellationToken); - - public static Task SleepSafeAsync(int milliseconds, CancellationToken cancellationToken = default) { - return Instance.SleepSafeAsync(milliseconds, cancellationToken); - } - - public static Task SleepSafeAsync(TimeSpan delay, CancellationToken cancellationToken = default) - => Instance.SleepSafeAsync(delay, cancellationToken); - } - - public static class TimeExtensions { - public static void Sleep(this ISystemClock time, TimeSpan delay) - => time.Sleep((int)delay.TotalMilliseconds); - - public static Task SleepAsync(this ISystemClock time, TimeSpan delay, CancellationToken cancellationToken = default) - => time.SleepAsync((int)delay.TotalMilliseconds, cancellationToken); - - public static async Task SleepSafeAsync(this ISystemClock time, int milliseconds, CancellationToken cancellationToken = default) { - try { - await time.SleepAsync(milliseconds, cancellationToken).AnyContext(); - } catch (OperationCanceledException) {} - } - - public static Task SleepSafeAsync(this ISystemClock time, TimeSpan delay, CancellationToken cancellationToken = default) - => time.SleepSafeAsync((int)delay.TotalMilliseconds, cancellationToken); - } -} \ No newline at end of file diff --git a/tests/Foundatio.Tests/Jobs/JobTests.cs b/tests/Foundatio.Tests/Jobs/JobTests.cs index ec352e7c9..a61f450a2 100644 --- a/tests/Foundatio.Tests/Jobs/JobTests.cs +++ b/tests/Foundatio.Tests/Jobs/JobTests.cs @@ -137,11 +137,9 @@ public async Task CanRunThrottledJobs() { [Fact] public async Task CanRunJobsWithInterval() { - using (TestSystemClock.Install()) { + using (var clock = TestSystemClock.Install()) { var time = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - - TestSystemClock.SetFrozenTime(time); - TestSystemClock.UseFakeSleep(); + clock.SetTime(time); var job = new HelloWorldJob(Log); var interval = TimeSpan.FromHours(.75); @@ -155,11 +153,9 @@ public async Task CanRunJobsWithInterval() { [Fact] public async Task CanRunJobsWithIntervalBetweenFailingJob() { - using (TestSystemClock.Install()) { + using (var clock = TestSystemClock.Install()) { var time = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - - TestSystemClock.SetFrozenTime(time); - TestSystemClock.UseFakeSleep(); + clock.SetTime(time); var job = new FailingJob(Log); var interval = TimeSpan.FromHours(.75); diff --git a/tests/Foundatio.Tests/Utility/SystemClockTests.cs b/tests/Foundatio.Tests/Utility/SystemClockTests.cs index 12513ba01..509bf88db 100644 --- a/tests/Foundatio.Tests/Utility/SystemClockTests.cs +++ b/tests/Foundatio.Tests/Utility/SystemClockTests.cs @@ -12,9 +12,9 @@ public SystemClockTests(ITestOutputHelper output) : base(output) {} [Fact] public void CanGetTime() { - using (TestSystemClock.Install()) { + using (var clock = TestSystemClock.Install()) { var now = DateTime.UtcNow; - TestSystemClock.SetFrozenTime(now); + clock.SetTime(now); Assert.Equal(now, SystemClock.UtcNow); Assert.Equal(now.ToLocalTime(), SystemClock.Now); Assert.Equal(now, SystemClock.OffsetUtcNow); @@ -25,14 +25,12 @@ public void CanGetTime() { [Fact] public void CanSleep() { - using (TestSystemClock.Install()) { + using (var clock = TestSystemClock.Install()) { var sw = Stopwatch.StartNew(); SystemClock.Sleep(250); sw.Stop(); Assert.InRange(sw.ElapsedMilliseconds, 225, 400); - TestSystemClock.UseFakeSleep(); - var now = SystemClock.UtcNow; sw.Restart(); SystemClock.Sleep(1000); @@ -47,15 +45,13 @@ public void CanSleep() { [Fact] public async Task CanSleepAsync() { - using (TestSystemClock.Install()) { + using (var clock = TestSystemClock.Install()) { var sw = Stopwatch.StartNew(); await SystemClock.SleepAsync(250); sw.Stop(); Assert.InRange(sw.ElapsedMilliseconds, 225, 3000); - TestSystemClock.UseFakeSleep(); - var now = SystemClock.UtcNow; sw.Restart(); await SystemClock.SleepAsync(1000); @@ -70,11 +66,10 @@ public async Task CanSleepAsync() { [Fact] public void CanSetTimeZone() { - using (TestSystemClock.Install()) { + using (var clock = TestSystemClock.Install()) { var utcNow = DateTime.UtcNow; var now = new DateTime(utcNow.AddHours(1).Ticks, DateTimeKind.Local); - TestSystemClock.SetFrozenTime(utcNow); - TestSystemClock.SetTimeZoneOffset(TimeSpan.FromHours(1)); + clock.SetTime(utcNow); Assert.Equal(utcNow, SystemClock.UtcNow); Assert.Equal(utcNow, SystemClock.OffsetUtcNow); @@ -86,10 +81,10 @@ public void CanSetTimeZone() { [Fact] public void CanSetLocalFixedTime() { - using (TestSystemClock.Install()) { + using (var clock = TestSystemClock.Install()) { var now = DateTime.Now; var utcNow = now.ToUniversalTime(); - TestSystemClock.SetFrozenTime(now); + clock.SetTime(now); Assert.Equal(now, SystemClock.Now); Assert.Equal(now, SystemClock.OffsetNow); @@ -101,10 +96,10 @@ public void CanSetLocalFixedTime() { [Fact] public void CanSetUtcFixedTime() { - using (TestSystemClock.Install()) { + using (var clock = TestSystemClock.Install()) { var utcNow = DateTime.UtcNow; var now = utcNow.ToLocalTime(); - TestSystemClock.SetFrozenTime(utcNow); + clock.SetTime(utcNow); Assert.Equal(now, SystemClock.Now); Assert.Equal(now, SystemClock.OffsetNow); diff --git a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs index 3ead804fa..4a54813ea 100644 --- a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs +++ b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs @@ -15,17 +15,17 @@ public WorkSchedulerTests(ITestOutputHelper output) : base(output) { } public void CanScheduleWork() { Log.MinimumLevel = LogLevel.Trace; _logger.LogTrace("Starting test on thread {ThreadId} time {Time}", Thread.CurrentThread.ManagedThreadId, DateTime.Now); - using (TestSystemClock.Install(Log)) { + using (var clock = TestSystemClock.Install(Log)) { var countdown = new CountdownEvent(1); SystemClock.ScheduleWork(() => { _logger.LogTrace("Doing work"); countdown.Signal(); }, TimeSpan.FromMinutes(5)); - WorkScheduler.Default.NoWorkItemsDue.WaitOne(); + clock.NoScheduledWorkItemsDue.WaitOne(TimeSpan.FromMilliseconds(100)); Assert.Equal(1, countdown.CurrentCount); _logger.LogTrace("Adding 6 minutes to current time."); SystemClock.Sleep(TimeSpan.FromMinutes(6)); - WorkScheduler.Default.NoWorkItemsDue.WaitOne(); + clock.NoScheduledWorkItemsDue.WaitOne(TimeSpan.FromMilliseconds(100)); countdown.Wait(); Assert.Equal(0, countdown.CurrentCount); } @@ -36,51 +36,51 @@ public void CanScheduleWork() { public void CanScheduleMultipleUnorderedWorkItems() { Log.MinimumLevel = LogLevel.Trace; _logger.LogTrace("Starting test on thread {ThreadId} time {Time}", Thread.CurrentThread.ManagedThreadId, DateTime.Now); - using (TestSystemClock.Install(Log)) { + using (var clock = TestSystemClock.Install(Log)) { var work1Event = new ManualResetEvent(false); var work2Event = new ManualResetEvent(false); var work3Event = new ManualResetEvent(false); // schedule work due in 5 minutes - SystemClock.ScheduleWork(() => { + clock.ScheduleWork(() => { _logger.LogTrace("Doing 5 minute work"); work1Event.Set(); }, TimeSpan.FromMinutes(5)); // schedule work due in 1 second - SystemClock.ScheduleWork(() => { + clock.ScheduleWork(() => { _logger.LogTrace("Doing 1 second work"); work2Event.Set(); }, TimeSpan.FromSeconds(1)); // schedule work that is already past due - SystemClock.ScheduleWork(() => { + clock.ScheduleWork(() => { _logger.LogTrace("Doing past due work"); work3Event.Set(); }, TimeSpan.FromSeconds(-1)); // wait until we get signal that no items are currently due _logger.LogTrace("Waiting for past due items to be started"); - Assert.True(WorkScheduler.Default.NoWorkItemsDue.WaitOne(), "Wait for all due work items to be scheduled"); + Assert.True(clock.NoScheduledWorkItemsDue.WaitOne(), "Wait for all due work items to be scheduled"); _logger.LogTrace("Waiting for past due work to be completed"); // work can be done before we even get here, but wait one to be sure it's done Assert.True(work3Event.WaitOne(TimeSpan.FromSeconds(1))); // verify additional work will not happen until time changes - Assert.False(work2Event.WaitOne(TimeSpan.FromSeconds(1))); + Assert.False(work2Event.WaitOne(TimeSpan.FromMilliseconds(100))); _logger.LogTrace("Adding 1 minute to current time"); // sleeping for a minute to make 1 second work due - SystemClock.Sleep(TimeSpan.FromMinutes(1)); - Assert.True(WorkScheduler.Default.NoWorkItemsDue.WaitOne()); + clock.Sleep(TimeSpan.FromMinutes(1)); + Assert.True(clock.NoScheduledWorkItemsDue.WaitOne()); Assert.True(work2Event.WaitOne(TimeSpan.FromSeconds(1))); _logger.LogTrace("Adding 5 minutes to current time"); // sleeping for 5 minutes to make 5 minute work due - SystemClock.Sleep(TimeSpan.FromMinutes(5)); + clock.Sleep(TimeSpan.FromMinutes(5)); _logger.LogTrace("Waiting for no work items due"); - Assert.True(WorkScheduler.Default.NoWorkItemsDue.WaitOne()); - _logger.LogTrace("Waiting for 5 minute work to be completes"); + Assert.True(clock.NoScheduledWorkItemsDue.WaitOne()); + _logger.LogTrace("Waiting for 5 minute work to be completed"); Assert.True(work1Event.WaitOne(TimeSpan.FromSeconds(1))); } _logger.LogTrace("Ending test on thread {ThreadId} time {Time}", Thread.CurrentThread.ManagedThreadId, DateTime.Now); From d99451ce4c5af4eb6683af1da6306840b67b2162 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 3 Jun 2019 13:50:04 -0500 Subject: [PATCH 26/29] More systemclock changes --- .../Caching/CacheClientTestsBase.cs | 4 +- src/Foundatio/SystemClock/ISystemClock.cs | 12 +- src/Foundatio/SystemClock/ITestSystemClock.cs | 2 +- src/Foundatio/SystemClock/RealSystemClock.cs | 12 +- src/Foundatio/SystemClock/SystemClock.cs | 20 +-- .../SystemClock/TestSystemClockImpl.cs | 31 +++-- src/Foundatio/SystemClock/WorkScheduler.cs | 12 +- tests/Foundatio.Tests/Jobs/JobTests.cs | 4 +- .../Utility/SystemClockTests.cs | 131 ++++++++---------- 9 files changed, 110 insertions(+), 118 deletions(-) diff --git a/src/Foundatio.TestHarness/Caching/CacheClientTestsBase.cs b/src/Foundatio.TestHarness/Caching/CacheClientTestsBase.cs index b6c8a1dd8..4a9f32212 100644 --- a/src/Foundatio.TestHarness/Caching/CacheClientTestsBase.cs +++ b/src/Foundatio.TestHarness/Caching/CacheClientTestsBase.cs @@ -412,11 +412,11 @@ public virtual async Task CanSetMinMaxExpirationAsync() { await cache.RemoveAllAsync(); using (var clock = TestSystemClock.Install()) { - var expires = DateTime.MaxValue - clock.Now().AddDays(1); + var expires = DateTime.MaxValue - clock.Now.AddDays(1); Assert.True(await cache.SetAsync("test1", 1, expires)); Assert.False(await cache.SetAsync("test2", 1, DateTime.MinValue)); Assert.True(await cache.SetAsync("test3", 1, DateTime.MaxValue)); - Assert.True(await cache.SetAsync("test4", 1, DateTime.MaxValue - clock.Now().AddDays(-1))); + Assert.True(await cache.SetAsync("test4", 1, DateTime.MaxValue - clock.Now.AddDays(-1))); Assert.Equal(1, (await cache.GetAsync("test1")).Value); Assert.InRange((await cache.GetExpirationAsync("test1")).Value, expires.Subtract(TimeSpan.FromSeconds(10)), expires); diff --git a/src/Foundatio/SystemClock/ISystemClock.cs b/src/Foundatio/SystemClock/ISystemClock.cs index b44c8b1d2..5641decb6 100644 --- a/src/Foundatio/SystemClock/ISystemClock.cs +++ b/src/Foundatio/SystemClock/ISystemClock.cs @@ -4,13 +4,13 @@ namespace Foundatio.Utility { public interface ISystemClock { - DateTime Now(); - DateTime UtcNow(); - DateTimeOffset OffsetNow(); - DateTimeOffset OffsetUtcNow(); + DateTime Now { get; } + DateTime UtcNow { get; } + DateTimeOffset OffsetNow { get; } + DateTimeOffset OffsetUtcNow { get; } void Sleep(int milliseconds); - Task SleepAsync(int milliseconds, CancellationToken ct); - TimeSpan TimeZoneOffset(); + Task SleepAsync(int milliseconds, CancellationToken ct = default); + TimeSpan Offset { get; } void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null); void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null); } diff --git a/src/Foundatio/SystemClock/ITestSystemClock.cs b/src/Foundatio/SystemClock/ITestSystemClock.cs index 9f675da71..86251e3c7 100644 --- a/src/Foundatio/SystemClock/ITestSystemClock.cs +++ b/src/Foundatio/SystemClock/ITestSystemClock.cs @@ -4,7 +4,7 @@ namespace Foundatio.Utility { public interface ITestSystemClock : ISystemClock, IDisposable { void AddTime(TimeSpan amount); - void SetTime(DateTime time, TimeSpan? timeZoneOffset = null); + void SetTime(DateTime time, TimeSpan? offset = null); WaitHandle NoScheduledWorkItemsDue { get; } event EventHandler Changed; } diff --git a/src/Foundatio/SystemClock/RealSystemClock.cs b/src/Foundatio/SystemClock/RealSystemClock.cs index 4d653f819..7b0026253 100644 --- a/src/Foundatio/SystemClock/RealSystemClock.cs +++ b/src/Foundatio/SystemClock/RealSystemClock.cs @@ -6,13 +6,13 @@ namespace Foundatio.Utility { public class RealSystemClock : ISystemClock { public static readonly RealSystemClock Instance = new RealSystemClock(); - public DateTime Now() => DateTime.Now; - public DateTime UtcNow() => DateTime.UtcNow; - public DateTimeOffset OffsetNow() => DateTimeOffset.Now; - public DateTimeOffset OffsetUtcNow() => DateTimeOffset.UtcNow; + public DateTime Now => DateTime.Now; + public DateTime UtcNow => DateTime.UtcNow; + public DateTimeOffset OffsetNow => DateTimeOffset.Now; + public DateTimeOffset OffsetUtcNow => DateTimeOffset.UtcNow; public void Sleep(int milliseconds) => Thread.Sleep(milliseconds); - public Task SleepAsync(int milliseconds, CancellationToken ct) => Task.Delay(milliseconds, ct); - public TimeSpan TimeZoneOffset() => DateTimeOffset.Now.Offset; + public Task SleepAsync(int milliseconds, CancellationToken ct = default) => Task.Delay(milliseconds, ct); + public TimeSpan Offset => DateTimeOffset.Now.Offset; public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) => WorkScheduler.Default.Schedule(action, delay, interval); public void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) diff --git a/src/Foundatio/SystemClock/SystemClock.cs b/src/Foundatio/SystemClock/SystemClock.cs index 452373633..971ecfe55 100644 --- a/src/Foundatio/SystemClock/SystemClock.cs +++ b/src/Foundatio/SystemClock/SystemClock.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Foundatio.Utility { - public static class SystemClock { + public class SystemClock { private static AsyncLocal _instance; public static ISystemClock Instance => _instance?.Value ?? RealSystemClock.Instance; @@ -17,15 +17,15 @@ public static void SetInstance(ISystemClock clock, ILoggerFactory loggerFactory) return; if (e.PreviousValue != null && e.CurrentValue != null) { - var diff = e.PreviousValue.Now().Subtract(e.CurrentValue.Now()); - logger.LogTrace("SystemClock instance is being changed by {ThreadId} from {OldTime} to {NewTime} diff {Difference:g}", Thread.CurrentThread.ManagedThreadId, e.PreviousValue?.Now(), e.CurrentValue?.Now(), diff); + var diff = e.PreviousValue.Now.Subtract(e.CurrentValue.Now); + logger.LogTrace("SystemClock instance is being changed by {ThreadId} from {OldTime} to {NewTime} diff {Difference:g}", Thread.CurrentThread.ManagedThreadId, e.PreviousValue?.Now, e.CurrentValue?.Now, diff); } if (e.PreviousValue == null) - logger.LogTrace("SystemClock instance is being initially set by {ThreadId} to {NewTime}", Thread.CurrentThread.ManagedThreadId, e.CurrentValue?.Now()); + logger.LogTrace("SystemClock instance is being initially set by {ThreadId} to {NewTime}", Thread.CurrentThread.ManagedThreadId, e.CurrentValue?.Now); if (e.CurrentValue == null) - logger.LogTrace("SystemClock instance is being removed set by {ThreadId} from {OldTime}", Thread.CurrentThread.ManagedThreadId, e.PreviousValue?.Now()); + logger.LogTrace("SystemClock instance is being removed set by {ThreadId} from {OldTime}", Thread.CurrentThread.ManagedThreadId, e.PreviousValue?.Now); }); if (clock == null || clock is RealSystemClock) { @@ -37,11 +37,11 @@ public static void SetInstance(ISystemClock clock, ILoggerFactory loggerFactory) } } - public static DateTime Now => Instance.Now(); - public static DateTime UtcNow => Instance.UtcNow(); - public static DateTimeOffset OffsetNow => Instance.OffsetNow(); - public static DateTimeOffset OffsetUtcNow => Instance.OffsetUtcNow(); - public static TimeSpan TimeZoneOffset => Instance.TimeZoneOffset(); + public static DateTime Now => Instance.Now; + public static DateTime UtcNow => Instance.UtcNow; + public static DateTimeOffset OffsetNow => Instance.OffsetNow; + public static DateTimeOffset OffsetUtcNow => Instance.OffsetUtcNow; + public static TimeSpan TimeZoneOffset => Instance.Offset; public static void Sleep(int milliseconds) => Instance.Sleep(milliseconds); public static Task SleepAsync(int milliseconds, CancellationToken cancellationToken = default) diff --git a/src/Foundatio/SystemClock/TestSystemClockImpl.cs b/src/Foundatio/SystemClock/TestSystemClockImpl.cs index 7db4f28cb..0c720da3e 100644 --- a/src/Foundatio/SystemClock/TestSystemClockImpl.cs +++ b/src/Foundatio/SystemClock/TestSystemClockImpl.cs @@ -6,7 +6,8 @@ namespace Foundatio.Utility { internal class TestSystemClockImpl : ITestSystemClock { - private DateTimeOffset _time = DateTimeOffset.Now; + private DateTime _utcTime = DateTime.UtcNow; + private TimeSpan _offset = DateTimeOffset.Now.Offset; private readonly ISystemClock _originalClock; private readonly WorkScheduler _workScheduler; @@ -20,26 +21,32 @@ public TestSystemClockImpl(ISystemClock originalTime, ILoggerFactory loggerFacto _originalClock = originalTime; } - public DateTime UtcNow() => _time.UtcDateTime; - public DateTime Now() => _time.DateTime; - public DateTimeOffset OffsetNow() => _time; - public DateTimeOffset OffsetUtcNow() => new DateTimeOffset(UtcNow().Ticks, TimeSpan.Zero); - public TimeSpan TimeZoneOffset() => _time.Offset; + public DateTime UtcNow => _utcTime; + public DateTime Now => new DateTime(_utcTime.Add(_offset).Ticks, DateTimeKind.Local); + public DateTimeOffset OffsetNow => new DateTimeOffset(Now.Ticks, _offset); + public DateTimeOffset OffsetUtcNow => new DateTimeOffset(_utcTime); + public TimeSpan Offset => _offset; public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) => _workScheduler.Schedule(action, delay, interval); public void ScheduleWork(Action action, DateTime executeAtUtc, TimeSpan? interval = null) => _workScheduler.Schedule(action, executeAtUtc, interval); public void AddTime(TimeSpan amount) { - _time = _time.Add(amount); + _utcTime = _utcTime.Add(amount); OnChanged(); } - public void SetTime(DateTime time, TimeSpan? timeZoneOffset = null) { - if (timeZoneOffset.HasValue) - _time = new DateTimeOffset(time.ToUniversalTime(), timeZoneOffset.Value); + public void SetTime(DateTime time, TimeSpan? offset = null) { + if (time.Kind == DateTimeKind.Local) + _utcTime = time.ToUniversalTime(); + else if (time.Kind == DateTimeKind.Unspecified) + _utcTime = new DateTime(time.Ticks, DateTimeKind.Utc); else - _time = new DateTimeOffset(time); + _utcTime = time; + + if (offset.HasValue) + _offset = offset.Value; + OnChanged(); } @@ -50,7 +57,7 @@ public void Sleep(int milliseconds) { Thread.Sleep(1); } - public Task SleepAsync(int milliseconds, CancellationToken ct) { + public Task SleepAsync(int milliseconds, CancellationToken ct = default) { Sleep(milliseconds); return Task.CompletedTask; } diff --git a/src/Foundatio/SystemClock/WorkScheduler.cs b/src/Foundatio/SystemClock/WorkScheduler.cs index f4ca7e219..767b731fe 100644 --- a/src/Foundatio/SystemClock/WorkScheduler.cs +++ b/src/Foundatio/SystemClock/WorkScheduler.cs @@ -32,14 +32,14 @@ public WorkScheduler(ISystemClock clock, ILogger logger = null) { public WaitHandle NoWorkItemsDue => _noWorkItemsDue; public void Schedule(Action action, TimeSpan delay, TimeSpan? interval = null) { - Schedule(action, _clock.UtcNow().Add(delay), interval); + Schedule(action, _clock.UtcNow.Add(delay), interval); } public void Schedule(Action action, DateTime executeAt, TimeSpan? interval = null) { if (executeAt.Kind != DateTimeKind.Utc) executeAt = executeAt.ToUniversalTime(); - var delay = executeAt.Subtract(_clock.UtcNow()); + var delay = executeAt.Subtract(_clock.UtcNow); _logger.LogTrace("Scheduling work due at {ExecuteAt} ({Delay:g} from now)", executeAt, delay); _workItems.Enqueue(executeAt, new WorkItem { Action = action, @@ -70,10 +70,10 @@ private void EnsureWorkLoopRunning() { private void WorkLoop() { _logger.LogTrace("Work loop started"); while (!_isDisposed) { - if (_workItems.TryDequeueIf(out var kvp, i => i.ExecuteAtUtc < _clock.UtcNow())) { - _logger.LogTrace("Starting work item due at {DueTime} current time {CurrentTime}", kvp.Key, _clock.UtcNow()); + if (_workItems.TryDequeueIf(out var kvp, i => i.ExecuteAtUtc < _clock.UtcNow)) { + _logger.LogTrace("Starting work item due at {DueTime} current time {CurrentTime}", kvp.Key, _clock.UtcNow); _ = _taskFactory.StartNew(() => { - var startTime = _clock.UtcNow(); + var startTime = _clock.UtcNow; kvp.Value.Action(); if (kvp.Value.Interval.HasValue) Schedule(kvp.Value.Action, startTime.Add(kvp.Value.Interval.Value)); @@ -84,7 +84,7 @@ private void WorkLoop() { _noWorkItemsDue.Set(); if (kvp.Key != default) { - var delay = kvp.Key.Subtract(_clock.UtcNow()); + var delay = kvp.Key.Subtract(_clock.UtcNow); _logger.LogTrace("No work items due, next due at {DueTime} ({Delay:g} from now)", kvp.Key, delay); _workItemScheduled.WaitOne(delay); } else { diff --git a/tests/Foundatio.Tests/Jobs/JobTests.cs b/tests/Foundatio.Tests/Jobs/JobTests.cs index a61f450a2..746bd42ec 100644 --- a/tests/Foundatio.Tests/Jobs/JobTests.cs +++ b/tests/Foundatio.Tests/Jobs/JobTests.cs @@ -92,7 +92,7 @@ public async Task CanRunMultipleInstances() { [Fact] public async Task CanCancelContinuousJobs() { - using (TestSystemClock.Install()) { + using (var clock = TestSystemClock.Install()) { var job = new HelloWorldJob(Log); var timeoutCancellationTokenSource = new CancellationTokenSource(100); await job.RunContinuousAsync(TimeSpan.FromSeconds(1), 5, timeoutCancellationTokenSource.Token); @@ -101,7 +101,7 @@ public async Task CanCancelContinuousJobs() { timeoutCancellationTokenSource = new CancellationTokenSource(500); var runnerTask = new JobRunner(job, Log, instanceCount: 5, iterationLimit: 10000, interval: TimeSpan.FromMilliseconds(1)).RunAsync(timeoutCancellationTokenSource.Token); - await SystemClock.SleepAsync(TimeSpan.FromSeconds(1)); + await clock.SleepAsync(TimeSpan.FromSeconds(1)); await runnerTask; } } diff --git a/tests/Foundatio.Tests/Utility/SystemClockTests.cs b/tests/Foundatio.Tests/Utility/SystemClockTests.cs index 509bf88db..fb79e5f04 100644 --- a/tests/Foundatio.Tests/Utility/SystemClockTests.cs +++ b/tests/Foundatio.Tests/Utility/SystemClockTests.cs @@ -11,101 +11,86 @@ public class SystemClockTests : TestWithLoggingBase { public SystemClockTests(ITestOutputHelper output) : base(output) {} [Fact] - public void CanGetTime() { + public void CanSetTime() { using (var clock = TestSystemClock.Install()) { - var now = DateTime.UtcNow; + var now = DateTime.Now; + clock.SetTime(now); + Assert.Equal(now, clock.Now); + Assert.Equal(DateTimeOffset.Now.Offset, clock.Offset); + Assert.Equal(now.ToUniversalTime(), clock.UtcNow); + Assert.Equal(now.ToLocalTime(), clock.Now); + Assert.Equal(now.ToUniversalTime(), clock.OffsetUtcNow); + + now = DateTime.UtcNow; clock.SetTime(now); - Assert.Equal(now, SystemClock.UtcNow); - Assert.Equal(now.ToLocalTime(), SystemClock.Now); - Assert.Equal(now, SystemClock.OffsetUtcNow); - Assert.Equal(now.ToLocalTime(), SystemClock.OffsetNow); - Assert.Equal(DateTimeOffset.Now.Offset, SystemClock.TimeZoneOffset); + Assert.Equal(now, clock.Now); + Assert.Equal(DateTimeOffset.Now.Offset, clock.Offset); + Assert.Equal(now.ToUniversalTime(), clock.UtcNow); + Assert.Equal(now.ToLocalTime(), clock.Now); + Assert.Equal(now.ToUniversalTime(), clock.OffsetUtcNow); } } [Fact] - public void CanSleep() { + public void CanSetTimeWithOffset() { using (var clock = TestSystemClock.Install()) { - var sw = Stopwatch.StartNew(); - SystemClock.Sleep(250); - sw.Stop(); - Assert.InRange(sw.ElapsedMilliseconds, 225, 400); - - var now = SystemClock.UtcNow; - sw.Restart(); - SystemClock.Sleep(1000); - sw.Stop(); - var afterSleepNow = SystemClock.UtcNow; - - Assert.InRange(sw.ElapsedMilliseconds, 0, 30); - Assert.True(afterSleepNow > now); - Assert.InRange(afterSleepNow.Subtract(now).TotalMilliseconds, 950, 1100); + var now = DateTimeOffset.Now; + clock.SetTime(now.LocalDateTime, now.Offset); + Assert.Equal(now, clock.OffsetNow); + Assert.Equal(now.Offset, clock.Offset); + Assert.Equal(now.UtcDateTime, clock.UtcNow); + Assert.Equal(now.DateTime, clock.Now); + Assert.Equal(now.ToUniversalTime(), clock.OffsetUtcNow); + + clock.SetTime(now.UtcDateTime, now.Offset); + Assert.Equal(now, clock.OffsetNow); + Assert.Equal(now.Offset, clock.Offset); + Assert.Equal(now.UtcDateTime, clock.UtcNow); + Assert.Equal(now.DateTime, clock.Now); + Assert.Equal(now.ToUniversalTime(), clock.OffsetUtcNow); + + now = new DateTimeOffset(now.DateTime, TimeSpan.FromHours(1)); + clock.SetTime(now.LocalDateTime, now.Offset); + Assert.Equal(now, clock.OffsetNow); + Assert.Equal(now.Offset, clock.Offset); + Assert.Equal(now.UtcDateTime, clock.UtcNow); + Assert.Equal(now.DateTime, clock.Now); + Assert.Equal(now.ToUniversalTime(), clock.OffsetUtcNow); } } [Fact] - public async Task CanSleepAsync() { - using (var clock = TestSystemClock.Install()) { - var sw = Stopwatch.StartNew(); - await SystemClock.SleepAsync(250); - sw.Stop(); - - Assert.InRange(sw.ElapsedMilliseconds, 225, 3000); - - var now = SystemClock.UtcNow; - sw.Restart(); - await SystemClock.SleepAsync(1000); - sw.Stop(); - var afterSleepNow = SystemClock.UtcNow; - - Assert.InRange(sw.ElapsedMilliseconds, 0, 30); - Assert.True(afterSleepNow > now); - Assert.InRange(afterSleepNow.Subtract(now).TotalMilliseconds, 950, 5000); - } + public void CanRealSleep() { + var clock = new RealSystemClock(); + var sw = Stopwatch.StartNew(); + clock.Sleep(250); + sw.Stop(); + Assert.InRange(sw.ElapsedMilliseconds, 100, 500); } [Fact] - public void CanSetTimeZone() { + public void CanTestSleep() { using (var clock = TestSystemClock.Install()) { - var utcNow = DateTime.UtcNow; - var now = new DateTime(utcNow.AddHours(1).Ticks, DateTimeKind.Local); - clock.SetTime(utcNow); - - Assert.Equal(utcNow, SystemClock.UtcNow); - Assert.Equal(utcNow, SystemClock.OffsetUtcNow); - Assert.Equal(now, SystemClock.Now); - Assert.Equal(new DateTimeOffset(now.Ticks, TimeSpan.FromHours(1)), SystemClock.OffsetNow); - Assert.Equal(TimeSpan.FromHours(1), SystemClock.TimeZoneOffset); + var startTime = clock.UtcNow; + clock.Sleep(250); + Assert.Equal(250, clock.UtcNow.Subtract(startTime).TotalMilliseconds); } } - [Fact] - public void CanSetLocalFixedTime() { - using (var clock = TestSystemClock.Install()) { - var now = DateTime.Now; - var utcNow = now.ToUniversalTime(); - clock.SetTime(now); - - Assert.Equal(now, SystemClock.Now); - Assert.Equal(now, SystemClock.OffsetNow); - Assert.Equal(utcNow, SystemClock.UtcNow); - Assert.Equal(utcNow, SystemClock.OffsetUtcNow); - Assert.Equal(DateTimeOffset.Now.Offset, SystemClock.TimeZoneOffset); - } + public async Task CanRealSleepAsync() { + var clock = new RealSystemClock(); + var sw = Stopwatch.StartNew(); + await clock.SleepAsync(250); + sw.Stop(); + Assert.InRange(sw.ElapsedMilliseconds, 100, 500); } [Fact] - public void CanSetUtcFixedTime() { + public async Task CanTestSleepAsync() { using (var clock = TestSystemClock.Install()) { - var utcNow = DateTime.UtcNow; - var now = utcNow.ToLocalTime(); - clock.SetTime(utcNow); - - Assert.Equal(now, SystemClock.Now); - Assert.Equal(now, SystemClock.OffsetNow); - Assert.Equal(utcNow, SystemClock.UtcNow); - Assert.Equal(utcNow, SystemClock.OffsetUtcNow); - Assert.Equal(DateTimeOffset.Now.Offset, SystemClock.TimeZoneOffset); + var startTime = clock.UtcNow; + await clock.SleepAsync(250); + Assert.Equal(250, clock.UtcNow.Subtract(startTime).TotalMilliseconds); } } } From 1d66777bd9b4b1400c1b3cb7922ea2e6e3cdfc6b Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 4 Jun 2019 13:37:00 -0500 Subject: [PATCH 27/29] More changes to system clock to allow timers --- src/Foundatio/Messaging/MessageBusBase.cs | 7 +- src/Foundatio/SystemClock/ISystemClock.cs | 8 +- src/Foundatio/SystemClock/RealSystemClock.cs | 21 +++-- src/Foundatio/SystemClock/SystemClock.cs | 37 ++++---- src/Foundatio/SystemClock/TestSystemClock.cs | 78 ++++++++++++++--- .../SystemClock/TestSystemClockImpl.cs | 86 ------------------- src/Foundatio/SystemClock/WorkScheduler.cs | 60 +++++++++---- src/Foundatio/Utility/SharedOptions.cs | 7 ++ .../Utility/SystemClockTests.cs | 8 +- .../Utility/WorkSchedulerTests.cs | 8 +- 10 files changed, 177 insertions(+), 143 deletions(-) delete mode 100644 src/Foundatio/SystemClock/TestSystemClockImpl.cs diff --git a/src/Foundatio/Messaging/MessageBusBase.cs b/src/Foundatio/Messaging/MessageBusBase.cs index 4820e56e6..56d5e4d27 100644 --- a/src/Foundatio/Messaging/MessageBusBase.cs +++ b/src/Foundatio/Messaging/MessageBusBase.cs @@ -17,6 +17,8 @@ public abstract class MessageBusBase : IMessageBus where TOptions : Sh protected readonly IMessageStore _store; protected readonly ILogger _logger; private bool _isDisposed; + protected readonly ISystemClock _clock; + protected readonly ITimer _maintenanceTimer; public MessageBusBase(TOptions options) { _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -26,7 +28,8 @@ public MessageBusBase(TOptions options) { _typeNameSerializer = options.TypeNameSerializer ?? new DefaultTypeNameSerializer(_logger); _store = options.MessageStore ?? new InMemoryMessageStore(_logger); MessageBusId = Guid.NewGuid().ToString("N"); - SystemClock.ScheduleWork(DoMaintenanceAsync, SystemClock.UtcNow.AddSeconds(1), TimeSpan.FromSeconds(1)); + _clock = options.SystemClock ?? SystemClock.Instance; + _maintenanceTimer = _clock.Timer(DoMaintenanceAsync, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); } public string MessageBusId { get; protected set; } @@ -132,6 +135,8 @@ public virtual void Dispose() { _logger.LogTrace("MessageBus {0} dispose", MessageBusId); + _maintenanceTimer?.Dispose(); + if (_subscriptions != null && _subscriptions.Count > 0) { foreach (var subscription in _subscriptions) subscription.Dispose(); diff --git a/src/Foundatio/SystemClock/ISystemClock.cs b/src/Foundatio/SystemClock/ISystemClock.cs index 5641decb6..5315ec43c 100644 --- a/src/Foundatio/SystemClock/ISystemClock.cs +++ b/src/Foundatio/SystemClock/ISystemClock.cs @@ -11,7 +11,11 @@ public interface ISystemClock { void Sleep(int milliseconds); Task SleepAsync(int milliseconds, CancellationToken ct = default); TimeSpan Offset { get; } - void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null); - void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null); + void Schedule(Action action, TimeSpan dueTime); + ITimer Timer(Action action, TimeSpan dueTime, TimeSpan period); + } + + public interface ITimer : IDisposable { + bool Change(TimeSpan dueTime, TimeSpan period); } } \ No newline at end of file diff --git a/src/Foundatio/SystemClock/RealSystemClock.cs b/src/Foundatio/SystemClock/RealSystemClock.cs index 7b0026253..5b5e59ccc 100644 --- a/src/Foundatio/SystemClock/RealSystemClock.cs +++ b/src/Foundatio/SystemClock/RealSystemClock.cs @@ -1,11 +1,20 @@ using System; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Foundatio.Utility { public class RealSystemClock : ISystemClock { - public static readonly RealSystemClock Instance = new RealSystemClock(); - + private readonly WorkScheduler _workScheduler; + + public RealSystemClock(ILoggerFactory loggerFactory) { + loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + var logger = loggerFactory.CreateLogger(); + _workScheduler = new WorkScheduler(this, logger); + } + public DateTime Now => DateTime.Now; public DateTime UtcNow => DateTime.UtcNow; public DateTimeOffset OffsetNow => DateTimeOffset.Now; @@ -13,9 +22,9 @@ public class RealSystemClock : ISystemClock { public void Sleep(int milliseconds) => Thread.Sleep(milliseconds); public Task SleepAsync(int milliseconds, CancellationToken ct = default) => Task.Delay(milliseconds, ct); public TimeSpan Offset => DateTimeOffset.Now.Offset; - public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) - => WorkScheduler.Default.Schedule(action, delay, interval); - public void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) - => WorkScheduler.Default.Schedule(action, executeAt, interval); + public void Schedule(Action action, TimeSpan dueTime) + => _workScheduler.Schedule(action, dueTime); + public ITimer Timer(Action action, TimeSpan dueTime, TimeSpan period) + => _workScheduler.Timer(action, dueTime, period); } } \ No newline at end of file diff --git a/src/Foundatio/SystemClock/SystemClock.cs b/src/Foundatio/SystemClock/SystemClock.cs index 971ecfe55..59d00feae 100644 --- a/src/Foundatio/SystemClock/SystemClock.cs +++ b/src/Foundatio/SystemClock/SystemClock.cs @@ -7,8 +7,9 @@ namespace Foundatio.Utility { public class SystemClock { private static AsyncLocal _instance; + private static readonly ISystemClock _realClock = new RealSystemClock(null); - public static ISystemClock Instance => _instance?.Value ?? RealSystemClock.Instance; + public static ISystemClock Instance => _instance?.Value ?? _realClock; public static void SetInstance(ISystemClock clock, ILoggerFactory loggerFactory) { var logger = loggerFactory?.CreateLogger("SystemClock") ?? NullLogger.Instance; @@ -60,17 +61,17 @@ public static Task SleepSafeAsync(int milliseconds, CancellationToken cancellati public static Task SleepSafeAsync(TimeSpan delay, CancellationToken cancellationToken = default) => Instance.SleepSafeAsync(delay, cancellationToken); - public static void ScheduleWork(Func action, TimeSpan delay, TimeSpan? interval = null) - => Instance.ScheduleWork(action, delay, interval); + public static void Schedule(Func action, TimeSpan dueTime) + => Instance.Schedule(action, dueTime); - public static void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) - => Instance.ScheduleWork(action, delay, interval); + public static void Schedule(Action action, TimeSpan dueTime) + => Instance.Schedule(action, dueTime); - public static void ScheduleWork(Func action, DateTime executeAt, TimeSpan? interval = null) - => Instance.ScheduleWork(action, executeAt, interval); + public static void Schedule(Func action, DateTime executeAt) + => Instance.Schedule(action, executeAt); - public static void ScheduleWork(Action action, DateTime executeAt, TimeSpan? interval = null) - => Instance.ScheduleWork(action, executeAt, interval); + public static void Schedule(Action action, DateTime executeAt) + => Instance.Schedule(action, executeAt); } public static class SystemClockExtensions { @@ -86,13 +87,19 @@ public static async Task SleepSafeAsync(this ISystemClock clock, int millisecond } catch (OperationCanceledException) {} } - public static Task SleepSafeAsync(this ISystemClock clock, TimeSpan delay, CancellationToken cancellationToken = default) - => clock.SleepSafeAsync((int)delay.TotalMilliseconds, cancellationToken); + public static Task SleepSafeAsync(this ISystemClock clock, TimeSpan dueTime, CancellationToken cancellationToken = default) + => clock.SleepSafeAsync((int)dueTime.TotalMilliseconds, cancellationToken); - public static void ScheduleWork(this ISystemClock clock, Func action, TimeSpan delay, TimeSpan? interval = null) => - clock.ScheduleWork(() => { _ = action(); }, delay, interval); + public static void Schedule(this ISystemClock clock, Action action, DateTime executeAt) => + clock.Schedule(action, executeAt.Subtract(clock.UtcNow)); + + public static void Schedule(this ISystemClock clock, Func action, TimeSpan dueTime) => + clock.Schedule(() => { _ = action(); }, dueTime); - public static void ScheduleWork(this ISystemClock clock, Func action, DateTime executeAt, TimeSpan? interval = null) => - clock.ScheduleWork(() => { _ = action(); }, executeAt, interval); + public static void Schedule(this ISystemClock clock, Func action, DateTime executeAt) => + clock.Schedule(() => { _ = action(); }, executeAt); + + public static ITimer Timer(this ISystemClock clock, Func action, TimeSpan dueTime, TimeSpan period) => + clock.Timer(() => { _ = action(); }, dueTime, period); } } \ No newline at end of file diff --git a/src/Foundatio/SystemClock/TestSystemClock.cs b/src/Foundatio/SystemClock/TestSystemClock.cs index 2e9ae074a..3984f6343 100644 --- a/src/Foundatio/SystemClock/TestSystemClock.cs +++ b/src/Foundatio/SystemClock/TestSystemClock.cs @@ -1,23 +1,81 @@ using System; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Foundatio.Utility { - public class TestSystemClock { - public static void AddTime(TimeSpan amount) => TestSystemClockImpl.Instance.AddTime(amount); - public static void SetTime(DateTime time, TimeSpan? timeZoneOffset = null) => TestSystemClockImpl.Instance.SetTime(time, timeZoneOffset); + internal class TestSystemClock : ITestSystemClock { + private DateTime _utcTime = DateTime.UtcNow; + private TimeSpan _offset = DateTimeOffset.Now.Offset; + private readonly ISystemClock _originalClock; + private readonly WorkScheduler _workScheduler; - public static event EventHandler Changed { - add => TestSystemClockImpl.Instance.Changed += value; - remove => TestSystemClockImpl.Instance.Changed -= value; + public TestSystemClock(ILoggerFactory loggerFactory) { + loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + var logger = loggerFactory.CreateLogger("Foundatio.Utility.SystemClock"); + _workScheduler = new WorkScheduler(this, logger); } - public static ITestSystemClock Create(ILoggerFactory loggerFactory = null) { - var testClock = new TestSystemClockImpl(SystemClock.Instance, loggerFactory); - return testClock; + public TestSystemClock(ISystemClock originalTime, ILoggerFactory loggerFactory) : this(loggerFactory) { + _originalClock = originalTime; + } + + public DateTime UtcNow => _utcTime; + public DateTime Now => new DateTime(_utcTime.Add(_offset).Ticks, DateTimeKind.Local); + public DateTimeOffset OffsetNow => new DateTimeOffset(Now.Ticks, _offset); + public DateTimeOffset OffsetUtcNow => new DateTimeOffset(_utcTime); + public TimeSpan Offset => _offset; + public void Schedule(Action action, TimeSpan dueTime) + => _workScheduler.Schedule(action, dueTime); + public ITimer Timer(Action action, TimeSpan dueTime, TimeSpan period) + => _workScheduler.Timer(action, dueTime, period); + + public void AddTime(TimeSpan amount) { + _utcTime = _utcTime.Add(amount); + OnChanged(); + } + + public void SetTime(DateTime time, TimeSpan? offset = null) { + if (time.Kind == DateTimeKind.Local) + _utcTime = time.ToUniversalTime(); + else if (time.Kind == DateTimeKind.Unspecified) + _utcTime = new DateTime(time.Ticks, DateTimeKind.Utc); + else + _utcTime = time; + + if (offset.HasValue) + _offset = offset.Value; + + OnChanged(); + } + + public WaitHandle NoScheduledWorkItemsDue => _workScheduler.NoWorkItemsDue; + + public void Sleep(int milliseconds) { + AddTime(TimeSpan.FromMilliseconds(milliseconds)); + Thread.Sleep(1); + } + + public Task SleepAsync(int milliseconds, CancellationToken ct = default) { + Sleep(milliseconds); + return Task.CompletedTask; + } + + public event EventHandler Changed; + public WorkScheduler Scheduler => _workScheduler; + + private void OnChanged() { + Changed?.Invoke(this, EventArgs.Empty); + } + + public void Dispose() { + if (_originalClock != null) + SystemClock.SetInstance(_originalClock, null); } public static ITestSystemClock Install(ILoggerFactory loggerFactory = null) { - var testClock = new TestSystemClockImpl(SystemClock.Instance, loggerFactory); + var testClock = new TestSystemClock(SystemClock.Instance, loggerFactory); SystemClock.SetInstance(testClock, loggerFactory); return testClock; diff --git a/src/Foundatio/SystemClock/TestSystemClockImpl.cs b/src/Foundatio/SystemClock/TestSystemClockImpl.cs deleted file mode 100644 index 0c720da3e..000000000 --- a/src/Foundatio/SystemClock/TestSystemClockImpl.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Foundatio.Utility { - internal class TestSystemClockImpl : ITestSystemClock { - private DateTime _utcTime = DateTime.UtcNow; - private TimeSpan _offset = DateTimeOffset.Now.Offset; - private readonly ISystemClock _originalClock; - private readonly WorkScheduler _workScheduler; - - public TestSystemClockImpl(ILoggerFactory loggerFactory) { - loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; - var logger = loggerFactory.CreateLogger("Foundatio.Utility.SystemClock"); - _workScheduler = new WorkScheduler(this, logger); - } - - public TestSystemClockImpl(ISystemClock originalTime, ILoggerFactory loggerFactory) : this(loggerFactory) { - _originalClock = originalTime; - } - - public DateTime UtcNow => _utcTime; - public DateTime Now => new DateTime(_utcTime.Add(_offset).Ticks, DateTimeKind.Local); - public DateTimeOffset OffsetNow => new DateTimeOffset(Now.Ticks, _offset); - public DateTimeOffset OffsetUtcNow => new DateTimeOffset(_utcTime); - public TimeSpan Offset => _offset; - public void ScheduleWork(Action action, TimeSpan delay, TimeSpan? interval = null) - => _workScheduler.Schedule(action, delay, interval); - public void ScheduleWork(Action action, DateTime executeAtUtc, TimeSpan? interval = null) - => _workScheduler.Schedule(action, executeAtUtc, interval); - - public void AddTime(TimeSpan amount) { - _utcTime = _utcTime.Add(amount); - OnChanged(); - } - - public void SetTime(DateTime time, TimeSpan? offset = null) { - if (time.Kind == DateTimeKind.Local) - _utcTime = time.ToUniversalTime(); - else if (time.Kind == DateTimeKind.Unspecified) - _utcTime = new DateTime(time.Ticks, DateTimeKind.Utc); - else - _utcTime = time; - - if (offset.HasValue) - _offset = offset.Value; - - OnChanged(); - } - - public WaitHandle NoScheduledWorkItemsDue => _workScheduler.NoWorkItemsDue; - - public void Sleep(int milliseconds) { - AddTime(TimeSpan.FromMilliseconds(milliseconds)); - Thread.Sleep(1); - } - - public Task SleepAsync(int milliseconds, CancellationToken ct = default) { - Sleep(milliseconds); - return Task.CompletedTask; - } - - public event EventHandler Changed; - public WorkScheduler Scheduler => _workScheduler; - - private void OnChanged() { - Changed?.Invoke(this, EventArgs.Empty); - } - - public void Dispose() { - if (_originalClock != null) - SystemClock.SetInstance(_originalClock, null); - } - - public static TestSystemClockImpl Instance { - get { - if (!(SystemClock.Instance is TestSystemClockImpl testClock)) - throw new ArgumentException("You must first install TestSystemClock using TestSystemClock.Install"); - - return testClock; - } - } - } -} \ No newline at end of file diff --git a/src/Foundatio/SystemClock/WorkScheduler.cs b/src/Foundatio/SystemClock/WorkScheduler.cs index 767b731fe..96f42b9e5 100644 --- a/src/Foundatio/SystemClock/WorkScheduler.cs +++ b/src/Foundatio/SystemClock/WorkScheduler.cs @@ -10,9 +10,7 @@ namespace Foundatio.Utility { /// This is the same as using the thread pool. Long running tasks should not be scheduled on this. Tasks should generally last no longer than a few seconds. /// public class WorkScheduler : IDisposable { - public static readonly WorkScheduler Default = new WorkScheduler(SystemClock.Instance); - - private ILogger _logger; + private readonly ILogger _logger; private bool _isDisposed = false; private readonly SortedQueue _workItems = new SortedQueue(); private readonly TaskFactory _taskFactory; @@ -31,20 +29,30 @@ public WorkScheduler(ISystemClock clock, ILogger logger = null) { public WaitHandle NoWorkItemsDue => _noWorkItemsDue; - public void Schedule(Action action, TimeSpan delay, TimeSpan? interval = null) { - Schedule(action, _clock.UtcNow.Add(delay), interval); + public ITimer Timer(Action action, TimeSpan dueTime, TimeSpan period) { + var executeAt = _clock.UtcNow.Add(dueTime); + if (executeAt.Kind != DateTimeKind.Utc) + executeAt = executeAt.ToUniversalTime(); + + _logger.LogTrace("Scheduling work due at {ExecuteAt} ({DueTime:g} from now)", executeAt, dueTime); + var workItem = new WorkItem(this) { Action = action, ExecuteAtUtc = executeAt, Period = period }; + _workItems.Enqueue(executeAt, workItem); + + EnsureWorkLoopRunning(); + _workItemScheduled.Set(); + + return workItem; } - public void Schedule(Action action, DateTime executeAt, TimeSpan? interval = null) { + public void Schedule(Action action, TimeSpan dueTime) { + var executeAt = _clock.UtcNow.Add(dueTime); if (executeAt.Kind != DateTimeKind.Utc) executeAt = executeAt.ToUniversalTime(); - var delay = executeAt.Subtract(_clock.UtcNow); - _logger.LogTrace("Scheduling work due at {ExecuteAt} ({Delay:g} from now)", executeAt, delay); - _workItems.Enqueue(executeAt, new WorkItem { + _logger.LogTrace("Scheduling work due at {ExecuteAt} ({DueTime:g} from now)", executeAt, dueTime); + _workItems.Enqueue(executeAt, new WorkItem(this) { Action = action, - ExecuteAtUtc = executeAt, - Interval = interval + ExecuteAtUtc = executeAt }); EnsureWorkLoopRunning(); @@ -75,8 +83,8 @@ private void WorkLoop() { _ = _taskFactory.StartNew(() => { var startTime = _clock.UtcNow; kvp.Value.Action(); - if (kvp.Value.Interval.HasValue) - Schedule(kvp.Value.Action, startTime.Add(kvp.Value.Interval.Value)); + if (kvp.Value.Period.HasValue) + Schedule(kvp.Value.Action, kvp.Value.Period.Value); }); continue; } @@ -101,10 +109,32 @@ public void Dispose() { _workLoopTask = null; } - private class WorkItem { + private class WorkItem : ITimer { + private readonly WorkScheduler _workScheduler; + + public WorkItem(WorkScheduler scheduler) { + _workScheduler = scheduler; + } + public DateTime ExecuteAtUtc { get; set; } public Action Action { get; set; } - public TimeSpan? Interval { get; set; } + public TimeSpan? Period { get; set; } + public bool IsCancelled { get; set; } + + public bool Change(TimeSpan dueTime, TimeSpan period) { + if (IsCancelled) + return false; + + IsCancelled = true; + + var workItem = _workScheduler.Timer(Action, dueTime, period); + // TODO: Figure out how to make it so the original ITimer instance can still have access to the currently scheduled workitem + return true; + } + + public void Dispose() { + IsCancelled = true; + } } } } \ No newline at end of file diff --git a/src/Foundatio/Utility/SharedOptions.cs b/src/Foundatio/Utility/SharedOptions.cs index 0ad2e30e3..e8490e040 100644 --- a/src/Foundatio/Utility/SharedOptions.cs +++ b/src/Foundatio/Utility/SharedOptions.cs @@ -1,10 +1,12 @@ using Foundatio.Serializer; +using Foundatio.Utility; using Microsoft.Extensions.Logging; namespace Foundatio { public class SharedOptions { public ISerializer Serializer { get; set; } public ILoggerFactory LoggerFactory { get; set; } + public ISystemClock SystemClock { get; set; } } public class SharedOptionsBuilder : OptionsBuilder @@ -19,5 +21,10 @@ public TBuilder LoggerFactory(ILoggerFactory loggerFactory) { Target.LoggerFactory = loggerFactory; return (TBuilder)this; } + + public TBuilder SystemClock(ISystemClock systemClock) { + Target.SystemClock = systemClock; + return (TBuilder)this; + } } } diff --git a/tests/Foundatio.Tests/Utility/SystemClockTests.cs b/tests/Foundatio.Tests/Utility/SystemClockTests.cs index fb79e5f04..a4adaa157 100644 --- a/tests/Foundatio.Tests/Utility/SystemClockTests.cs +++ b/tests/Foundatio.Tests/Utility/SystemClockTests.cs @@ -61,7 +61,7 @@ public void CanSetTimeWithOffset() { [Fact] public void CanRealSleep() { - var clock = new RealSystemClock(); + var clock = new RealSystemClock(Log); var sw = Stopwatch.StartNew(); clock.Sleep(250); sw.Stop(); @@ -70,7 +70,7 @@ public void CanRealSleep() { [Fact] public void CanTestSleep() { - using (var clock = TestSystemClock.Install()) { + using (var clock = TestSystemClock.Install(Log)) { var startTime = clock.UtcNow; clock.Sleep(250); Assert.Equal(250, clock.UtcNow.Subtract(startTime).TotalMilliseconds); @@ -78,7 +78,7 @@ public void CanTestSleep() { } [Fact] public async Task CanRealSleepAsync() { - var clock = new RealSystemClock(); + var clock = new RealSystemClock(Log); var sw = Stopwatch.StartNew(); await clock.SleepAsync(250); sw.Stop(); @@ -87,7 +87,7 @@ public async Task CanRealSleepAsync() { [Fact] public async Task CanTestSleepAsync() { - using (var clock = TestSystemClock.Install()) { + using (var clock = TestSystemClock.Install(Log)) { var startTime = clock.UtcNow; await clock.SleepAsync(250); Assert.Equal(250, clock.UtcNow.Subtract(startTime).TotalMilliseconds); diff --git a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs index 4a54813ea..d98582d5f 100644 --- a/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs +++ b/tests/Foundatio.Tests/Utility/WorkSchedulerTests.cs @@ -17,7 +17,7 @@ public void CanScheduleWork() { _logger.LogTrace("Starting test on thread {ThreadId} time {Time}", Thread.CurrentThread.ManagedThreadId, DateTime.Now); using (var clock = TestSystemClock.Install(Log)) { var countdown = new CountdownEvent(1); - SystemClock.ScheduleWork(() => { + SystemClock.Schedule(() => { _logger.LogTrace("Doing work"); countdown.Signal(); }, TimeSpan.FromMinutes(5)); @@ -42,19 +42,19 @@ public void CanScheduleMultipleUnorderedWorkItems() { var work3Event = new ManualResetEvent(false); // schedule work due in 5 minutes - clock.ScheduleWork(() => { + clock.Schedule(() => { _logger.LogTrace("Doing 5 minute work"); work1Event.Set(); }, TimeSpan.FromMinutes(5)); // schedule work due in 1 second - clock.ScheduleWork(() => { + clock.Schedule(() => { _logger.LogTrace("Doing 1 second work"); work2Event.Set(); }, TimeSpan.FromSeconds(1)); // schedule work that is already past due - clock.ScheduleWork(() => { + clock.Schedule(() => { _logger.LogTrace("Doing past due work"); work3Event.Set(); }, TimeSpan.FromSeconds(-1)); From 41ffe3541b5dd5cbc2fe4631a562088ae7db71ff Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 11 Jun 2019 20:00:14 -0500 Subject: [PATCH 28/29] Update deps and fix some tests --- Foundatio.sln | 2 ++ src/Foundatio/Messaging/scenarios.md | 18 ++++++++++++++++++ .../Utility/SystemClockTests.cs | 3 ++- 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/Foundatio/Messaging/scenarios.md diff --git a/Foundatio.sln b/Foundatio.sln index f4bb98303..505f12cc4 100644 --- a/Foundatio.sln +++ b/Foundatio.sln @@ -14,6 +14,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution src\Directory.Build.props = src\Directory.Build.props samples\Directory.Build.props = samples\Directory.Build.props NuGet.config = NuGet.config + global.json = global.json + Dockerfile = Dockerfile README.md = README.md EndProjectSection EndProject diff --git a/src/Foundatio/Messaging/scenarios.md b/src/Foundatio/Messaging/scenarios.md new file mode 100644 index 000000000..3039bb0e0 --- /dev/null +++ b/src/Foundatio/Messaging/scenarios.md @@ -0,0 +1,18 @@ +- Multiple receivers (pub/sub) + - Fire and forget + - Message acknowledgement +- Worker queues + - Single Worker + - Round robin workers +- Delayed delivery + - Can schedule delivery, messages are persisted to a message store and a background task polls for messages that are due and then sends them out +- Message persistence + - Not all messages need to be persisted and guaranteed delivery +- Message subscriptions are push based with prefetch count setting which should greatly improve throughput +- Can either use generic method overloads or use options to change the message type or topic the message is being published to +- Can subscribe to multiple message types by controlling the message topic instead of using the default topic per .net type +- Request/response + - Publishes message and then does a single message receive on a topic that is for that exact request and waits the specified amount of time +- Receive message (pull model) + - Equivalent of current worker queues pulling a single message at a time + - Ability to receive a batch of messages diff --git a/tests/Foundatio.Tests/Utility/SystemClockTests.cs b/tests/Foundatio.Tests/Utility/SystemClockTests.cs index a4adaa157..6fa845e4d 100644 --- a/tests/Foundatio.Tests/Utility/SystemClockTests.cs +++ b/tests/Foundatio.Tests/Utility/SystemClockTests.cs @@ -21,9 +21,10 @@ public void CanSetTime() { Assert.Equal(now.ToLocalTime(), clock.Now); Assert.Equal(now.ToUniversalTime(), clock.OffsetUtcNow); + // set using utc now = DateTime.UtcNow; clock.SetTime(now); - Assert.Equal(now, clock.Now); + Assert.Equal(now, clock.UtcNow); Assert.Equal(DateTimeOffset.Now.Offset, clock.Offset); Assert.Equal(now.ToUniversalTime(), clock.UtcNow); Assert.Equal(now.ToLocalTime(), clock.Now); From 8cf4d1eca73866d37a191d82a1648d613c7bf58d Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 6 May 2022 15:35:23 -0500 Subject: [PATCH 29/29] Minor --- src/Foundatio/Messaging/Envelope.cs | 80 +++++++++++++++++++ src/Foundatio/Messaging/IMessage.cs | 72 ----------------- src/Foundatio/Messaging/IMessageSubscriber.cs | 4 + 3 files changed, 84 insertions(+), 72 deletions(-) create mode 100644 src/Foundatio/Messaging/Envelope.cs delete mode 100644 src/Foundatio/Messaging/IMessage.cs diff --git a/src/Foundatio/Messaging/Envelope.cs b/src/Foundatio/Messaging/Envelope.cs new file mode 100644 index 000000000..969119492 --- /dev/null +++ b/src/Foundatio/Messaging/Envelope.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; + +namespace Foundatio.Messaging2 { + public interface IEnvelope { + // trace parent id used for distributed tracing + string TraceParentId { get; } + // message type + string MessageType { get; } + // message body + object GetMessage(); + // number of attempts to deliver the message + int Attempts { get; } + // when the message was originally sent + DateTime SentAtUtc { get; } + // when the message should expire + DateTime? ExpiresAtUtc { get; } + // when the message should be delivered when using delayed delivery + DateTime? DeliverAtUtc { get; } + // additional message data to store with the message + IReadOnlyDictionary Properties { get; } + } + + public class Envelope : IEnvelope { + private Lazy _message; + + public Envelope(Func getMessageFunc, string messageType, string coorelationId, DateTime? expiresAtUtc, DateTime? deliverAtUtc, IReadOnlyDictionary properties) { + _message = new Lazy(getMessageFunc); + MessageType = messageType; + TraceParentId = coorelationId; + ExpiresAtUtc = expiresAtUtc; + DeliverAtUtc = deliverAtUtc; + Properties = properties; + } + + public Message(Func getMessageFunc, MessagePublishOptions options) { + _message = new Lazy(getMessageFunc); + TraceParentId = options.CorrelationId; + MessageType = options.MessageType; + ExpiresAtUtc = options.ExpiresAtUtc; + DeliverAtUtc = options.DeliverAtUtc; + Properties = options.Properties; + } + + public string TraceParentId { get; private set; } + public string MessageType { get; private set; } + public int Attempts { get; private set; } + public DateTime SentAtUtc { get; private set; } + public DateTime? ExpiresAtUtc { get; private set; } + public DateTime? DeliverAtUtc { get; private set; } + public IReadOnlyDictionary Properties { get; private set; } + + public object GetMessage() { + return _message.Value; + } + } + + public interface IEnvelope : IEnvelope where T: class { + T Message { get; } + } + + public class Envelope : IEnvelope where T: class { + private readonly IEnvelope _envolope; + + public Envelope(IEnvelope message) { + _envolope = message; + } + + public T Message => (T)GetMessage(); + + public string TraceParentId => _envolope.TraceParentId; + public string MessageType => _envolope.MessageType; + public int Attempts => _envolope.Attempts; + public DateTime SentAtUtc => _envolope.SentAtUtc; + public DateTime? ExpiresAtUtc => _envolope.ExpiresAtUtc; + public DateTime? DeliverAtUtc => _envolope.DeliverAtUtc; + public IReadOnlyDictionary Properties => _envolope.Properties; + public object GetMessage() => _envolope.GetMessage(); + } +} diff --git a/src/Foundatio/Messaging/IMessage.cs b/src/Foundatio/Messaging/IMessage.cs deleted file mode 100644 index 9593db5ba..000000000 --- a/src/Foundatio/Messaging/IMessage.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Foundatio.Messaging { - public interface IMessage { - // correlation id used in logging - string CorrelationId { get; } - // message type, will be converted to string and stored with the message for deserialization - Type MessageType { get; } - // message body - object GetBody(); - // when the message should expire - DateTime? ExpiresAtUtc { get; } - // when the message should be delivered when using delayed delivery - DateTime? DeliverAtUtc { get; } - // additional message data to store with the message - IReadOnlyDictionary Properties { get; } - } - - public class Message : IMessage { - private Lazy _body; - - public Message(Func getBodyFunc, Type messageType, string coorelationId, DateTime? expiresAtUtc, DateTime? deliverAtUtc, IReadOnlyDictionary properties) { - _body = new Lazy(getBodyFunc); - MessageType = messageType; - CorrelationId = coorelationId; - ExpiresAtUtc = expiresAtUtc; - DeliverAtUtc = deliverAtUtc; - Properties = properties; - } - - public Message(Func getBodyFunc, MessagePublishOptions options) { - _body = new Lazy(getBodyFunc); - CorrelationId = options.CorrelationId; - MessageType = options.MessageType; - ExpiresAtUtc = options.ExpiresAtUtc; - DeliverAtUtc = options.DeliverAtUtc; - Properties = options.Properties; - } - - public string CorrelationId { get; private set; } - public Type MessageType { get; private set; } - public DateTime? ExpiresAtUtc { get; private set; } - public DateTime? DeliverAtUtc { get; private set; } - public IReadOnlyDictionary Properties { get; private set; } - - public object GetBody() { - return _body.Value; - } - } - - public interface IMessage : IMessage where T: class { - T Body { get; } - } - - public class Message : IMessage where T: class { - private readonly IMessage _message; - - public Message(IMessage message) { - _message = message; - } - - public T Body => (T)GetBody(); - - public string CorrelationId => _message.CorrelationId; - public Type MessageType => _message.MessageType; - public DateTime? ExpiresAtUtc => _message.ExpiresAtUtc; - public DateTime? DeliverAtUtc => _message.DeliverAtUtc; - public IReadOnlyDictionary Properties => _message.Properties; - public object GetBody() => _message.GetBody(); - } -} diff --git a/src/Foundatio/Messaging/IMessageSubscriber.cs b/src/Foundatio/Messaging/IMessageSubscriber.cs index 065af4083..d4a43dff0 100644 --- a/src/Foundatio/Messaging/IMessageSubscriber.cs +++ b/src/Foundatio/Messaging/IMessageSubscriber.cs @@ -4,6 +4,10 @@ using Foundatio.Utility; namespace Foundatio.Messaging { + public interface IHandle where T: class { + Task Handle(IMessageContext context); + } + public interface IMessageSubscriber : IDisposable { Task SubscribeAsync(MessageSubscriptionOptions options, Func handler); Task ReceiveAsync(MessageReceiveOptions options);