-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add thread-safe message scheduling and related tests (#1638)
Introduce `ScheduledMediumMessageQueue` for thread-safe scheduling of messages. Updated `Dispatcher` to use the new queue and modified the scheduling logic for improved reliability. Added extensive unit tests to ensure correctness of message scheduling and publishing behavior under various scenarios.
- Loading branch information
Showing
6 changed files
with
309 additions
and
22 deletions.
There are no files selected for viewing
88 changes: 88 additions & 0 deletions
88
src/DotNetCore.CAP/Internal/ScheduledMediumMessageQueue.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Linq; | ||
using System.Runtime.CompilerServices; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using DotNetCore.CAP.Persistence; | ||
|
||
namespace DotNetCore.CAP.Internal; | ||
|
||
public class ScheduledMediumMessageQueue | ||
{ | ||
private readonly SortedSet<(long, MediumMessage)> _queue = new(Comparer<(long, MediumMessage)>.Create((a, b) => | ||
{ | ||
int result = a.Item1.CompareTo(b.Item1); | ||
return result == 0 ? String.Compare(a.Item2.DbId, b.Item2.DbId, StringComparison.Ordinal) : result; | ||
})); | ||
|
||
private readonly SemaphoreSlim _semaphore = new(0); | ||
private readonly object _lock = new(); | ||
|
||
public void Enqueue(MediumMessage message, long sendTime) | ||
{ | ||
lock (_lock) | ||
{ | ||
_queue.Add((sendTime, message)); | ||
} | ||
|
||
_semaphore.Release(); | ||
} | ||
|
||
public int Count | ||
{ | ||
get | ||
{ | ||
lock (_lock) | ||
{ | ||
return _queue.Count; | ||
} | ||
} | ||
} | ||
|
||
public IEnumerable<MediumMessage> UnorderedItems | ||
{ | ||
get | ||
{ | ||
lock (_lock) | ||
{ | ||
return _queue.Select(x => x.Item2).ToList(); | ||
} | ||
} | ||
} | ||
|
||
public async IAsyncEnumerable<MediumMessage> GetConsumingEnumerable([EnumeratorCancellation] CancellationToken cancellationToken = default) | ||
{ | ||
while (!cancellationToken.IsCancellationRequested) | ||
{ | ||
await _semaphore.WaitAsync(cancellationToken); | ||
|
||
(long, MediumMessage)? nextItem = null; | ||
|
||
lock (_lock) | ||
{ | ||
if (_queue.Count > 0) | ||
{ | ||
var topMessage = _queue.First(); | ||
var timeLeft = topMessage.Item1 - DateTime.Now.Ticks; | ||
if (timeLeft < 500000) // 50ms | ||
{ | ||
nextItem = topMessage; | ||
_queue.Remove(topMessage); | ||
} | ||
} | ||
} | ||
|
||
if (nextItem is not null) | ||
{ | ||
yield return nextItem.Value.Item2; | ||
} | ||
else | ||
{ | ||
// Re-release the semaphore if no item is ready yet | ||
_semaphore.Release(); | ||
await Task.Delay(50, cancellationToken); | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,179 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Linq; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using DotNetCore.CAP.Internal; | ||
using DotNetCore.CAP.Messages; | ||
using DotNetCore.CAP.Persistence; | ||
using DotNetCore.CAP.Processor; | ||
using DotNetCore.CAP.Test.Helpers; | ||
using FluentAssertions; | ||
using Microsoft.Extensions.Logging; | ||
using Microsoft.Extensions.Options; | ||
using NSubstitute; | ||
using Xunit; | ||
|
||
namespace DotNetCore.CAP.Test; | ||
|
||
public class DispatcherTests | ||
{ | ||
private readonly ILogger<Dispatcher> _logger; | ||
private readonly ISubscribeExecutor _executor; | ||
private readonly IDataStorage _storage; | ||
|
||
public DispatcherTests() | ||
{ | ||
_logger = Substitute.For<ILogger<Dispatcher>>(); | ||
_executor = Substitute.For<ISubscribeExecutor>(); | ||
_storage = Substitute.For<IDataStorage>(); | ||
} | ||
|
||
[Fact] | ||
public async Task EnqueueToPublish_ShouldInvokeSend_WhenParallelSendDisabled() | ||
{ | ||
// Arrange | ||
var sender = new TestThreadSafeMessageSender(); | ||
var options = Options.Create(new CapOptions | ||
{ | ||
EnableSubscriberParallelExecute = true, | ||
EnablePublishParallelSend = false, | ||
SubscriberParallelExecuteThreadCount = 2, | ||
SubscriberParallelExecuteBufferFactor = 2 | ||
}); | ||
|
||
var dispatcher = new Dispatcher(_logger, sender, options, _executor, _storage); | ||
|
||
using var cts = new CancellationTokenSource(); | ||
var messageId = "testId"; | ||
|
||
// Act | ||
await dispatcher.Start(cts.Token); | ||
await dispatcher.EnqueueToPublish(CreateTestMessage(messageId)); | ||
await cts.CancelAsync(); | ||
|
||
// Assert | ||
sender.Count.Should().Be(1); | ||
sender.ReceivedMessages.First().DbId.Should().Be(messageId); | ||
} | ||
|
||
[Fact] | ||
public async Task EnqueueToPublish_ShouldBeThreadSafe_WhenParallelSendDisabled() | ||
{ | ||
// Arrange | ||
var sender = new TestThreadSafeMessageSender(); | ||
var options = Options.Create(new CapOptions | ||
{ | ||
EnableSubscriberParallelExecute = true, | ||
EnablePublishParallelSend = false, | ||
SubscriberParallelExecuteThreadCount = 2, | ||
SubscriberParallelExecuteBufferFactor = 2 | ||
}); | ||
var dispatcher = new Dispatcher(_logger, sender, options, _executor, _storage); | ||
|
||
using var cts = new CancellationTokenSource(); | ||
var messages = Enumerable.Range(1, 100) | ||
.Select(i => CreateTestMessage(i.ToString())) | ||
.ToArray(); | ||
|
||
// Act | ||
await dispatcher.Start(cts.Token); | ||
|
||
var tasks = messages | ||
.Select(msg => Task.Run(() => dispatcher.EnqueueToPublish(msg), CancellationToken.None)); | ||
await Task.WhenAll(tasks); | ||
await cts.CancelAsync(); | ||
|
||
// Assert | ||
sender.Count.Should().Be(100); | ||
var receivedMessages = sender.ReceivedMessages.Select(m => m.DbId).Order().ToList(); | ||
var expected = messages.Select(m => m.DbId).Order().ToList(); | ||
expected.Should().Equal(receivedMessages); | ||
} | ||
|
||
[Fact] | ||
public async Task EnqueueToScheduler_ShouldBeThreadSafe_WhenDelayLessThenMinute() | ||
{ | ||
// Arrange | ||
var sender = new TestThreadSafeMessageSender(); | ||
var options = Options.Create(new CapOptions | ||
{ | ||
EnableSubscriberParallelExecute = true, | ||
EnablePublishParallelSend = false, | ||
SubscriberParallelExecuteThreadCount = 2, | ||
SubscriberParallelExecuteBufferFactor = 2 | ||
}); | ||
var dispatcher = new Dispatcher(_logger, sender, options, _executor, _storage); | ||
|
||
using var cts = new CancellationTokenSource(); | ||
var messages = Enumerable.Range(1, 10000) | ||
.Select(i => CreateTestMessage(i.ToString())) | ||
.ToArray(); | ||
|
||
// Act | ||
await dispatcher.Start(cts.Token); | ||
var dateTime = DateTime.Now.AddSeconds(1); | ||
await Parallel.ForEachAsync(messages, CancellationToken.None, | ||
async (m, ct) => { await dispatcher.EnqueueToScheduler(m, dateTime); }); | ||
|
||
await Task.Delay(1500, CancellationToken.None); | ||
|
||
await cts.CancelAsync(); | ||
|
||
// Assert | ||
sender.Count.Should().Be(10000); | ||
|
||
var receivedMessages = sender.ReceivedMessages.Select(m => m.DbId).Order().ToList(); | ||
var expected = messages.Select(m => m.DbId).Order().ToList(); | ||
expected.Should().Equal(receivedMessages); | ||
} | ||
|
||
[Fact] | ||
public async Task EnqueueToScheduler_ShouldSendMessagesInCorrectOrder_WhenEarlierMessageIsSentLater() | ||
{ | ||
// Arrange | ||
var sender = new TestThreadSafeMessageSender(); | ||
var options = Options.Create(new CapOptions | ||
{ | ||
EnableSubscriberParallelExecute = true, | ||
EnablePublishParallelSend = false, | ||
SubscriberParallelExecuteThreadCount = 2, | ||
SubscriberParallelExecuteBufferFactor = 2 | ||
}); | ||
var dispatcher = new Dispatcher(_logger, sender, options, _executor, _storage); | ||
|
||
using var cts = new CancellationTokenSource(); | ||
var messages = Enumerable.Range(1, 3) | ||
.Select(i => CreateTestMessage(i.ToString())) | ||
.ToArray(); | ||
|
||
// Act | ||
await dispatcher.Start(cts.Token); | ||
var dateTime = DateTime.Now; | ||
|
||
await dispatcher.EnqueueToScheduler(messages[0], dateTime.AddSeconds(1)); | ||
await dispatcher.EnqueueToScheduler(messages[1], dateTime.AddMilliseconds(200)); | ||
await dispatcher.EnqueueToScheduler(messages[2], dateTime.AddMilliseconds(100)); | ||
|
||
await Task.Delay(1200, CancellationToken.None); | ||
await cts.CancelAsync(); | ||
|
||
// Assert | ||
sender.ReceivedMessages.Select(m => m.DbId).Should().Equal(["3", "2", "1"]); | ||
} | ||
|
||
|
||
private MediumMessage CreateTestMessage(string id = "1") | ||
{ | ||
return new MediumMessage() | ||
{ | ||
DbId = id, | ||
Origin = new Message( | ||
headers: new Dictionary<string, string>() | ||
{ | ||
{ "cap-msg-id", id } | ||
}, | ||
value: new MessageValue("[email protected]", "User")) | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.