diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..891c617 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/documentation_request.md b/.github/ISSUE_TEMPLATE/documentation_request.md new file mode 100644 index 0000000..ed0806a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation_request.md @@ -0,0 +1,18 @@ +--- +name: Documentation Request +about: Create a report to help us improve our documentation +title: '' +labels: 'Documentation-Request' +assignees: '' + +--- + + +**Section Needing extra clarity** +Which feature section + +**Extra details** +Enter here the section that could be improved, or clarified. + +**Other notes** + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..12e9519 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,36 @@ +name: Update NuGet + +on: [push] + +jobs: + build: + runs-on: windows-latest + + name: Publish Nuget Package + steps: + - uses: actions/checkout@master + - name: Setup .NET Core 2.1 + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 2.1.x + - name: Setup .NET Core 3.1 + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 3.1.x + - name: Setup .NET Core 5.0 + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 5.0.x + - name: Run Tests + run: + dotnet test "./tests/Blend.ActionQueue.Tests/Blend.ActionQueue.Tests.csproj" + - name: Build Package + run: + dotnet build -c Release "./src/Blend.ActionQueue/Blend.ActionQueue.csproj" + - name: Package Release + run: + dotnet pack -c Release --no-build -o out "./src/Blend.ActionQueue/Blend.ActionQueue.csproj" + - name: Publish Nuget to GitHub registry + run: ls .\out\*.nupkg | foreach { dotnet nuget push $_ -s https://nuget.pkg.github.com/blendinteractive/index.json -k $env:GITHUB_TOKEN } + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb336e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +*.user +*.suo +/.vs/ + +packages/ diff --git a/Blend.ActionQueue.sln b/Blend.ActionQueue.sln new file mode 100644 index 0000000..53b466b --- /dev/null +++ b/Blend.ActionQueue.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30907.101 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Blend.ActionQueue", "src\Blend.ActionQueue\Blend.ActionQueue.csproj", "{F8A723BE-F1BA-4235-AAC7-2FA98B002792}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Blend.ActionQueue.Tests", "tests\Blend.ActionQueue.Tests\Blend.ActionQueue.Tests.csproj", "{87835CBE-2C94-4D91-A7F3-8881FB0E730A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F8A723BE-F1BA-4235-AAC7-2FA98B002792}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F8A723BE-F1BA-4235-AAC7-2FA98B002792}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F8A723BE-F1BA-4235-AAC7-2FA98B002792}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F8A723BE-F1BA-4235-AAC7-2FA98B002792}.Release|Any CPU.Build.0 = Release|Any CPU + {87835CBE-2C94-4D91-A7F3-8881FB0E730A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87835CBE-2C94-4D91-A7F3-8881FB0E730A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87835CBE-2C94-4D91-A7F3-8881FB0E730A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87835CBE-2C94-4D91-A7F3-8881FB0E730A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {3233833A-D88F-40DA-A89C-EBCEEE3F5C2F} + EndGlobalSection +EndGlobal diff --git a/Changelog.md b/Changelog.md new file mode 100644 index 0000000..9fb020c --- /dev/null +++ b/Changelog.md @@ -0,0 +1,11 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] - 2021-04-28 +### Added + - Abstract Action Queue diff --git a/Contributors.md b/Contributors.md new file mode 100644 index 0000000..80a698a --- /dev/null +++ b/Contributors.md @@ -0,0 +1,3 @@ +# Contributors + +* [Blend Interactive](https://github.com/BlendInteractive) diff --git a/License b/License new file mode 100644 index 0000000..e8654b0 --- /dev/null +++ b/License @@ -0,0 +1,24 @@ +Copyright (c) 2019, Blend Interactive +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Blend Interactive nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL BLEND INTERACTIVE BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b24778f --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Blend.ActionQueue + +This is a simple, non-durable queue for when you need something like a message queue, but with minimal setup, for small, low-volume messages or actions where persistence is not a requirement. + +For example, you might use this to invalidate caches across servers without holding up the main UI thread while API calls are executed. Or you might send non-critical notifications via this queue, again freeing up the primary UI thread. + +## Usage + +To create a queue, implement `AbstractActionQueue`, where `T` is the type of message you'll be queuing. *Note*: each instance of your queue will be a separate queue and thread, so you may want to ensure your queue is a singleton. + +## Example Implementation + +```csharp + /// + /// This is an example message type where the `Action` to execute is provided in the constructor. + /// + public class ExampleAction + { + private readonly Action action; + + public ExampleAction(Action action) + { + this.action = action; + } + + public void Execute() => action?.Invoke(); + } + + /// + /// This is an example queue which accepts `ExampleAction` as its "message", and executes the Action. + /// To log errors, you would set `OnError` to some kind of error handler `Action`. + /// + public class ExampleQueue : AbstractActionQueue + { + public ExampleQueue(System.Threading.CancellationToken token) : base(token) + { + } + + public Action OnError { get; set; } + + protected override void LogException(Exception ex) => OnError?.Invoke(ex); + + protected override void ProcessItem(ExampleAction item) => item.Execute(); + } +``` + +## Example Usage + +```csharp + var exampleQueue = new ExampleQueue(CancellationToken.None); + + int totalExecutions = 0; + + exampleQueue.QueueAction(new ExampleAction(() => totalExecutions += 2)); + exampleQueue.QueueAction(new ExampleAction(() => totalExecutions += 4)); + exampleQueue.QueueAction(new ExampleAction(() => totalExecutions += 8)); + + // Hopefully adding 3 numbers doesn't take longer than 100ms + Thread.Sleep(100); + Assert.Equal(14, totalExecutions); +``` + +## Handling Errors + +Because `ProcessItem` is being called on a background thread, any errors thrown in the `ProcessItem` will be caught and `LogException` will be called with the exception to pass it through to whatever logging you're using. + +## Caveats + +The queue is backed by a `BlockingCollection` and items are popped off and executed one at a time. In theory, the queue itself should not have any race conditions or other threading issues, but... mutlithreading is hard. + +Keep in mind if using this in an ASP.NET context, you will not be able to rely on things like `HttpContext.Current`, as this is running in a separate thread. + +Each instance of a queue is a separate thread and queue. You'll most likely want each queue type to be a singleton. For example: + +```csharp + // WRONG + new ExampleQueue(CancellationToken.None).QueueAction(new ExampleAction(() => Console.WriteLine("No."))); + new ExampleQueue(CancellationToken.None).QueueAction(new ExampleAction(() => Console.WriteLine("Don't do this."))); + new ExampleQueue(CancellationToken.None).QueueAction(new ExampleAction(() => Console.WriteLine("It's wrong."))); + + // OK + private static readonly ExampleQueue queue = new new ExampleQueue(CancellationToken.None); + + queue.QueueAction(new ExampleAction(() => Console.WriteLine("OK."))); + queue.QueueAction(new ExampleAction(() => Console.WriteLine("Do this."))); + queue.QueueAction(new ExampleAction(() => Console.WriteLine("It's fine."))); +``` + +This queue is not durable. If your application shuts down or restarts with items pending in the queue, those items will be lost. diff --git a/src/Blend.ActionQueue/AbstractActionQueue.cs b/src/Blend.ActionQueue/AbstractActionQueue.cs new file mode 100644 index 0000000..cff5d38 --- /dev/null +++ b/src/Blend.ActionQueue/AbstractActionQueue.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; + +namespace Blend.ActionQueue +{ + public abstract class AbstractActionQueue + { + private readonly BlockingCollection Queue = new BlockingCollection(); + private Thread queueThread; + private CancellationToken token; + + protected abstract void LogException(Exception ex); + protected abstract void ProcessItem(T item); + + public AbstractActionQueue(CancellationToken token) + { + this.token = token; + } + + void StartQueueIfNotStarted() + { + if (queueThread == null || !queueThread.IsAlive) + { + queueThread = new Thread(QueueLoop); + + queueThread.Start(); + } + } + + void QueueLoop() + { + while (!token.IsCancellationRequested) + { + try + { + var actions = Queue.GetConsumingEnumerable(token); + foreach (var action in actions) + { + ProcessItem(action); + } + } + catch (Exception ex) + { + LogException(ex); + } + } + } + + public T QueueAction(T action) + { + StartQueueIfNotStarted(); + Queue.Add(action); + return action; + } + } +} diff --git a/src/Blend.ActionQueue/Blend.ActionQueue.csproj b/src/Blend.ActionQueue/Blend.ActionQueue.csproj new file mode 100644 index 0000000..aaa4620 --- /dev/null +++ b/src/Blend.ActionQueue/Blend.ActionQueue.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + A simple, non-durable queue for when you need something like a message queue, but with minimal setup, for small, low-volume messages or actions where persistence is not a requirement. + 1.0.0 + Initial Release + 1.0.0.0 + 1.0.0.0 + Blend Interactive + https://github.com/blendinteractive/Blend.ActionQueue + https://github.com/blendinteractive/Blend.ActionQueue.git + git + + + diff --git a/tests/Blend.ActionQueue.Tests/Basics.cs b/tests/Blend.ActionQueue.Tests/Basics.cs new file mode 100644 index 0000000..28b9151 --- /dev/null +++ b/tests/Blend.ActionQueue.Tests/Basics.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Xunit; + +namespace Blend.ActionQueue.Tests +{ + public class Basics + { + [Fact] + public void CanQueueAndExecuteItem() + { + var tokenSource = new System.Threading.CancellationTokenSource(); + var queue = new TestActionQueue(tokenSource.Token); + + var message = queue.QueueEmptyTestMessage(); + + message.Wait(); + Assert.True(message.MessageExecuted, "Message has executed"); + + tokenSource.Cancel(); + } + + [Fact] + public void CanQueueMultipleItems() + { + var tokenSource = new CancellationTokenSource(); + var queue = new TestActionQueue(tokenSource.Token); + + var one = queue.QueueEmptyTestMessage(); + one.Wait(); + Assert.True(one.MessageExecuted, "Message one has executed"); + + var two = queue.QueueEmptyTestMessage(); + two.Wait(); + Assert.True(two.MessageExecuted, "Message two has executed"); + + tokenSource.Cancel(); + } + + [Fact] + public void ItemsExecuteInOrder() + { + var tokenSource = new CancellationTokenSource(); + var queue = new TestActionQueue(tokenSource.Token); + + var order = new List(); + + var one = queue.QueueTestMessage(() => { order.Add(1); Thread.Sleep(100); }); + var two = queue.QueueTestMessage(() => { order.Add(2); Thread.Sleep(100); }); + var three = queue.QueueTestMessage(() => { order.Add(3); Thread.Sleep(100); }); + + WaitHandle.WaitAll(new WaitHandle[] { one.DoneSignal, two.DoneSignal, three.DoneSignal }, new TimeSpan(0, 0, 5), false); + + Assert.Equal(3, order.Count); + Assert.Equal(1, order[0]); + Assert.Equal(2, order[1]); + Assert.Equal(3, order[2]); + + tokenSource.Cancel(); + } + + [Fact] + public void ExceptionsCanBeCaughtAndDontStopQueueProcessing() + { + var tokenSource = new CancellationTokenSource(); + var queue = new TestActionQueue(tokenSource.Token); + Exception caughtException = null; + queue.OnException = (ex) => caughtException = ex; + + queue.QueueTestMessage(() => { Thread.Sleep(100); throw new InvalidOperationException("Testing exception"); }); + var followUp = queue.QueueEmptyTestMessage(); + + followUp.Wait(); + + Assert.NotNull(caughtException); + Assert.Equal("Testing exception", caughtException.Message); + Assert.IsType(caughtException); + + Assert.True(followUp.MessageExecuted); + + tokenSource.Cancel(); + } + } +} diff --git a/tests/Blend.ActionQueue.Tests/Blend.ActionQueue.Tests.csproj b/tests/Blend.ActionQueue.Tests/Blend.ActionQueue.Tests.csproj new file mode 100644 index 0000000..2914832 --- /dev/null +++ b/tests/Blend.ActionQueue.Tests/Blend.ActionQueue.Tests.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp3.1 + + false + + + + + + + + + + + + + + diff --git a/tests/Blend.ActionQueue.Tests/Examples/ExampleGenericQueue.cs b/tests/Blend.ActionQueue.Tests/Examples/ExampleGenericQueue.cs new file mode 100644 index 0000000..852642e --- /dev/null +++ b/tests/Blend.ActionQueue.Tests/Examples/ExampleGenericQueue.cs @@ -0,0 +1,36 @@ +using System; + +namespace Blend.ActionQueue.Tests.Examples +{ + /// + /// This is an example message type where the `Action` to execute is provided in the constructor. + /// + public class ExampleAction + { + private readonly Action action; + + public ExampleAction(Action action) + { + this.action = action; + } + + public void Execute() => action?.Invoke(); + } + + /// + /// This is an example queue which accepts `ExampleAction` as its "message", and executes the Action. + /// To log errors, you would set `OnError` to some kind of error handler `Action`. + /// + public class ExampleQueue : AbstractActionQueue + { + public ExampleQueue(System.Threading.CancellationToken token) : base(token) + { + } + + public Action OnError { get; set; } + + protected override void LogException(Exception ex) => OnError?.Invoke(ex); + + protected override void ProcessItem(ExampleAction item) => item.Execute(); + } +} diff --git a/tests/Blend.ActionQueue.Tests/Examples/ExampleTests.cs b/tests/Blend.ActionQueue.Tests/Examples/ExampleTests.cs new file mode 100644 index 0000000..7580661 --- /dev/null +++ b/tests/Blend.ActionQueue.Tests/Examples/ExampleTests.cs @@ -0,0 +1,25 @@ +using System.Threading; +using Xunit; + +namespace Blend.ActionQueue.Tests.Examples +{ + public class ExampleTests + { + [Fact] + public void TestQueueWorks() + { + var exampleQueue = new ExampleQueue(CancellationToken.None); + + int totalExecutions = 0; + + exampleQueue.QueueAction(new ExampleAction(() => totalExecutions += 2)); + exampleQueue.QueueAction(new ExampleAction(() => totalExecutions += 4)); + exampleQueue.QueueAction(new ExampleAction(() => totalExecutions += 8)); + + // Hopefully adding 3 numbers doesn't take longer than 100ms + Thread.Sleep(100); + Assert.Equal(14, totalExecutions); + } + + } +} diff --git a/tests/Blend.ActionQueue.Tests/TestAction.cs b/tests/Blend.ActionQueue.Tests/TestAction.cs new file mode 100644 index 0000000..3c403bd --- /dev/null +++ b/tests/Blend.ActionQueue.Tests/TestAction.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading; + +namespace Blend.ActionQueue.Tests +{ + public class TestAction + { + private readonly Action actionToTake; + + public TestAction(Action actionToTake) + { + this.actionToTake = actionToTake; + } + + public TimeSpan ElapsedExecutionTime { get; private set; } + public bool MessageExecuted { get; private set; } + public ManualResetEvent DoneSignal { get; } = new ManualResetEvent(false); + + public void Execute() + { + DoneSignal.Reset(); + + var stopwatch = new System.Diagnostics.Stopwatch(); + stopwatch.Start(); + + actionToTake?.Invoke(); + + stopwatch.Stop(); + this.ElapsedExecutionTime = stopwatch.Elapsed; + MessageExecuted = true; + DoneSignal.Set(); + } + + public void Wait() => DoneSignal.WaitOne(); + } +} diff --git a/tests/Blend.ActionQueue.Tests/TestActionQueue.cs b/tests/Blend.ActionQueue.Tests/TestActionQueue.cs new file mode 100644 index 0000000..0708615 --- /dev/null +++ b/tests/Blend.ActionQueue.Tests/TestActionQueue.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading; + +namespace Blend.ActionQueue.Tests +{ + public class TestActionQueue : AbstractActionQueue + { + public TestActionQueue(CancellationToken token) : base(token) { } + + public Action OnException { get; set; } + + protected override void LogException(Exception ex) => OnException?.Invoke(ex); + + protected override void ProcessItem(TestAction item) => item.Execute(); + + public TestAction QueueEmptyTestMessage() => this.QueueAction(EmptyTestMessage()); + + public TestAction QueueTestMessage(Action action) => this.QueueAction(new TestAction(action)); + + public static TestAction EmptyTestMessage() => new TestAction(() => { }); + } + + +}