├── DynamoLock ├── ILockTableProvisioner.cs ├── ILocalLockTracker.cs ├── IHeartbeatDispatcher.cs ├── IDistributedLockManager.cs ├── DynamoLock.csproj ├── ServiceCollectionExtensions.cs ├── LocalLockTracker.cs ├── LockTableProvisioner.cs ├── HeartbeatDispatcher.cs └── DynamoDbLockManager.cs ├── Docker.Testify ├── PortsInUseException.cs ├── Docker.Testify.csproj └── DockerSetup.cs ├── DynamoLock.Tests ├── DynamoLock.Tests.csproj ├── DynamoDbDockerSetup.cs └── DynamoDbLockerManagerTests.cs ├── LICENSE.md ├── DynamoLock.sln ├── README.md └── .gitignore /DynamoLock/ILockTableProvisioner.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace DynamoLock 4 | { 5 | public interface ILockTableProvisioner 6 | { 7 | Task Provision(); 8 | } 9 | } -------------------------------------------------------------------------------- /DynamoLock/ILocalLockTracker.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace DynamoLock 4 | { 5 | public interface ILocalLockTracker 6 | { 7 | void Add(string id); 8 | void Remove(string id); 9 | void Clear(); 10 | ICollection GetSnapshot(); 11 | } 12 | } -------------------------------------------------------------------------------- /DynamoLock/IHeartbeatDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading; 5 | 6 | namespace DynamoLock 7 | { 8 | public interface IHeartbeatDispatcher 9 | { 10 | void Start(string nodeId, TimeSpan heartbeat, long leaseTime); 11 | void Stop(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Docker.Testify/PortsInUseException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Docker.Testify 6 | { 7 | public class PortsInUseException : Exception 8 | { 9 | public PortsInUseException() 10 | : base("Ports in range are not available") 11 | { 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /DynamoLock/IDistributedLockManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace DynamoLock 6 | { 7 | public interface IDistributedLockManager 8 | { 9 | Task AcquireLock(string Id); 10 | 11 | Task ReleaseLock(string Id); 12 | 13 | Task Start(); 14 | 15 | Task Stop(); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Docker.Testify/Docker.Testify.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /DynamoLock.Tests/DynamoLock.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.1 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Daniel Gerlag 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /DynamoLock/DynamoLock.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | true 6 | https://github.com/danielgerlag/DynamoLock 7 | https://github.com/danielgerlag/DynamoLock/blob/master/LICENSE.md 8 | Daniel Gerlag 9 | 10 | https://github.com/danielgerlag/DynamoLock.git 11 | git 12 | Distributed lock manager 13 | DynamoLock is a client library for .Net Standard that implements a distributed lock manager on top of Amazon DynamoDB. 14 | 1.0.2 15 | 1.0.2.0 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /DynamoLock/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Amazon.DynamoDBv2; 5 | using Amazon.Runtime; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Extensions.Logging.Abstractions; 9 | 10 | namespace DynamoLock 11 | { 12 | public static class ServiceCollectionExtensions 13 | { 14 | public static void AddDynamoLockManager(this IServiceCollection serviceCollection, AWSCredentials credentials, AmazonDynamoDBConfig config, string tableName) 15 | { 16 | serviceCollection.AddSingleton(); 17 | serviceCollection.AddSingleton(sp => new HeartbeatDispatcher(credentials, config, sp.GetService(), tableName, sp.GetService() ?? new NullLoggerFactory())); 18 | serviceCollection.AddSingleton(sp => new LockTableProvisioner(credentials, config, tableName, sp.GetService() ?? new NullLoggerFactory())); 19 | serviceCollection.AddSingleton(sp => new DynamoDbLockManager(credentials, config, tableName, sp.GetService(), sp.GetService(), sp.GetService(), sp.GetService() ?? new NullLoggerFactory())); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /DynamoLock.Tests/DynamoDbDockerSetup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net; 4 | using System.Text; 5 | using Amazon.DynamoDBv2; 6 | using Amazon.Runtime; 7 | using Docker.Testify; 8 | using Xunit; 9 | 10 | namespace DynamoLock.Tests 11 | { 12 | public class DynamoDbDockerSetup : DockerSetup 13 | { 14 | public static string ConnectionString { get; set; } 15 | 16 | public static AWSCredentials Credentials => new EnvironmentVariablesAWSCredentials(); 17 | 18 | public override string ImageName => @"amazon/dynamodb-local"; 19 | public override int InternalPort => 8000; 20 | 21 | public override void PublishConnectionInfo() 22 | { 23 | ConnectionString = $"http://localhost:{ExternalPort}"; 24 | } 25 | 26 | public override bool TestReady() 27 | { 28 | try 29 | { 30 | AmazonDynamoDBConfig clientConfig = new AmazonDynamoDBConfig 31 | { 32 | ServiceURL = $"http://localhost:{ExternalPort}" 33 | }; 34 | AmazonDynamoDBClient client = new AmazonDynamoDBClient(clientConfig); 35 | var resp = client.ListTablesAsync().Result; 36 | 37 | return resp.HttpStatusCode == HttpStatusCode.OK; 38 | } 39 | catch 40 | { 41 | return false; 42 | } 43 | 44 | } 45 | } 46 | 47 | [CollectionDefinition("DynamoDb collection")] 48 | public class DynamoDbCollection : ICollectionFixture 49 | { 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /DynamoLock/LocalLockTracker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading; 5 | 6 | namespace DynamoLock 7 | { 8 | public class LocalLockTracker : ILocalLockTracker 9 | { 10 | private readonly List _localLocks = new List(); 11 | private readonly AutoResetEvent _mutex = new AutoResetEvent(true); 12 | 13 | public void Add(string id) 14 | { 15 | _mutex.WaitOne(); 16 | 17 | try 18 | { 19 | _localLocks.Add(id); 20 | } 21 | finally 22 | { 23 | _mutex.Set(); 24 | } 25 | } 26 | 27 | public void Remove(string id) 28 | { 29 | _mutex.WaitOne(); 30 | 31 | try 32 | { 33 | _localLocks.Remove(id); 34 | } 35 | finally 36 | { 37 | _mutex.Set(); 38 | } 39 | } 40 | 41 | public void Clear() 42 | { 43 | _mutex.WaitOne(); 44 | try 45 | { 46 | _localLocks.Clear(); 47 | } 48 | finally 49 | { 50 | _mutex.Set(); 51 | } 52 | } 53 | 54 | public ICollection GetSnapshot() 55 | { 56 | _mutex.WaitOne(); 57 | try 58 | { 59 | return _localLocks.ToArray(); 60 | } 61 | finally 62 | { 63 | _mutex.Set(); 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /DynamoLock.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.28307.168 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DynamoLock", "DynamoLock\DynamoLock.csproj", "{C20C6C20-35A2-45A8-9FD7-64A409ED87EC}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Docker.Testify", "Docker.Testify\Docker.Testify.csproj", "{BE66FA2C-8624-415F-88DC-DC05ADF081B0}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DynamoLock.Tests", "DynamoLock.Tests\DynamoLock.Tests.csproj", "{84FA283C-0139-4475-914B-BEF52E5047E1}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{5B57EFAA-0344-4DFC-91F7-6ACC38154C27}" 13 | ProjectSection(SolutionItems) = preProject 14 | LICENSE.md = LICENSE.md 15 | README.md = README.md 16 | EndProjectSection 17 | EndProject 18 | Global 19 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 20 | Debug|Any CPU = Debug|Any CPU 21 | Release|Any CPU = Release|Any CPU 22 | EndGlobalSection 23 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 24 | {C20C6C20-35A2-45A8-9FD7-64A409ED87EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {C20C6C20-35A2-45A8-9FD7-64A409ED87EC}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {C20C6C20-35A2-45A8-9FD7-64A409ED87EC}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {C20C6C20-35A2-45A8-9FD7-64A409ED87EC}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {BE66FA2C-8624-415F-88DC-DC05ADF081B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {BE66FA2C-8624-415F-88DC-DC05ADF081B0}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {BE66FA2C-8624-415F-88DC-DC05ADF081B0}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {BE66FA2C-8624-415F-88DC-DC05ADF081B0}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {84FA283C-0139-4475-914B-BEF52E5047E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {84FA283C-0139-4475-914B-BEF52E5047E1}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {84FA283C-0139-4475-914B-BEF52E5047E1}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {84FA283C-0139-4475-914B-BEF52E5047E1}.Release|Any CPU.Build.0 = Release|Any CPU 36 | EndGlobalSection 37 | GlobalSection(SolutionProperties) = preSolution 38 | HideSolutionNode = FALSE 39 | EndGlobalSection 40 | GlobalSection(ExtensibilityGlobals) = postSolution 41 | SolutionGuid = {24170E17-383B-41D1-8CEA-1A2A4B8CFC21} 42 | EndGlobalSection 43 | EndGlobal 44 | -------------------------------------------------------------------------------- /DynamoLock.Tests/DynamoDbLockerManagerTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Amazon.DynamoDBv2; 5 | using Microsoft.Extensions.Logging.Abstractions; 6 | using Xunit; 7 | 8 | namespace DynamoLock.Tests 9 | { 10 | [Collection("DynamoDb collection")] 11 | public class DynamoDbLockerManagerTests 12 | { 13 | DynamoDbDockerSetup _dockerSetup; 14 | private IDistributedLockManager _subject; 15 | private TimeSpan _heartbeat = TimeSpan.FromSeconds(2); 16 | private long _leaseTime = 3; 17 | 18 | public DynamoDbLockerManagerTests(DynamoDbDockerSetup dockerSetup) 19 | { 20 | _dockerSetup = dockerSetup; 21 | var cfg = new AmazonDynamoDBConfig { ServiceURL = DynamoDbDockerSetup.ConnectionString }; 22 | var provisioner = new LockTableProvisioner(DynamoDbDockerSetup.Credentials, cfg, "lock-tests", new NullLoggerFactory()); 23 | var tracker = new LocalLockTracker(); 24 | var heartbeatDispatcher = new HeartbeatDispatcher(DynamoDbDockerSetup.Credentials, cfg, tracker, "lock-tests", new NullLoggerFactory()); 25 | _subject = new DynamoDbLockManager(DynamoDbDockerSetup.Credentials, cfg, "lock-tests", provisioner, heartbeatDispatcher, tracker, new NullLoggerFactory(), _leaseTime, _heartbeat); 26 | } 27 | 28 | [Fact] 29 | public async void should_lock_resource() 30 | { 31 | var lockId = Guid.NewGuid().ToString(); 32 | 33 | await _subject.Start(); 34 | var first = await _subject.AcquireLock(lockId); 35 | var second = await _subject.AcquireLock(lockId); 36 | await _subject.Stop(); 37 | 38 | Assert.True(first); 39 | Assert.False(second); 40 | } 41 | 42 | [Fact] 43 | public async void should_release_lock() 44 | { 45 | var lockId = Guid.NewGuid().ToString(); 46 | 47 | await _subject.Start(); 48 | var first = await _subject.AcquireLock(lockId); 49 | await _subject.ReleaseLock(lockId); 50 | var second = await _subject.AcquireLock(lockId); 51 | await _subject.Stop(); 52 | 53 | Assert.True(first); 54 | Assert.True(second); 55 | } 56 | 57 | [Fact] 58 | public async void should_renew_lock_when_heartbeat_active() 59 | { 60 | var lockId = Guid.NewGuid().ToString(); 61 | 62 | await _subject.Start(); 63 | var first = await _subject.AcquireLock(lockId); 64 | await Task.Delay(TimeSpan.FromSeconds(_leaseTime + 2)); 65 | var second = await _subject.AcquireLock(lockId); 66 | await _subject.Stop(); 67 | 68 | Assert.True(first); 69 | Assert.False(second); 70 | } 71 | 72 | [Fact] 73 | public async void should_expire_lock_when_heartbeat_inactive() 74 | { 75 | var lockId = Guid.NewGuid().ToString(); 76 | 77 | await _subject.Start(); 78 | var first = await _subject.AcquireLock(lockId); 79 | await _subject.Stop(); 80 | await Task.Delay(TimeSpan.FromSeconds(_leaseTime + 2)); 81 | await _subject.Start(); 82 | var second = await _subject.AcquireLock(lockId); 83 | await _subject.Stop(); 84 | 85 | Assert.True(first); 86 | Assert.True(second); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # DynamoLock 3 | 4 | DynamoLock is a client library for .Net Standard that implements a distributed lock manager on top of Amazon DynamoDB. 5 | 6 | 7 | ## Installing 8 | 9 | Using Nuget package console 10 | ``` 11 | PM> Install-Package DynamoLock 12 | ``` 13 | Using .NET CLI 14 | ``` 15 | dotnet add package DynamoLock 16 | ``` 17 | 18 | 19 | ## How it works 20 | 21 | When you successfully acquire a lock, an entry is written to a table in DynamoDB that indicates the owner of the lock and the expiry time. 22 | The default lease time is 30 seconds, each node will also send a heartbeat every 10 seconds that will renew any active leases for 30 seconds from the time of the heartbeat. 23 | Once the expiry time has elapsed or the owning node releases the lock, it becomes available again. 24 | 25 | ## Usage 26 | 27 | 1. Construct a singleton of DynamoDbLockManager, using one of the overloaded constructors 28 | 29 | ```c# 30 | using DynamoLock; 31 | ... 32 | var lockManager = DynamoDbLockManager(new EnvironmentVariablesAWSCredentials(), RegionEndpoint.USWest2, NullLoggerFactory.Instance); 33 | ``` 34 | 35 | ```c# 36 | using DynamoLock; 37 | ... 38 | var lockManager = DynamoDbLockManager(dynamoClient, NullLoggerFactory.Instance); 39 | ``` 40 | 41 | Alternatively, an extension method to the IoC abstraction of `IServiceCollection` is provided that will add an implementation of `IDistributedLockManager` to your IoC container. 42 | ```c# 43 | using DynamoLock; 44 | ... 45 | 46 | services.AddDynamoLockManager(new EnvironmentVariablesAWSCredentials(), new AmazonDynamoDBConfig() { RegionEndpoint = RegionEndpoint.USWest2 }, "lock_table"); 47 | 48 | ``` 49 | 50 | 2. Then inject `IDistributedLockManager` into your classes via your IoC container of choice. 51 | 3. When your application starts up, you will also need to call the `.Start` method in order to provision the table and start the background heartbeat service. 52 | ```c# 53 | IDistributedLockManager lockerManager; 54 | ... 55 | lockManager.Start(); 56 | ``` 57 | This will automatically provision the table in DynamoDB if it does not exist and will enable sending a heartbeat every 10 seconds for any locks that have been acquired with the local manager, and renew their leases for a further 30 seconds, until `ReleaseLock` is called for a given resource. 58 | The table will be created with through put units of 1 by default, you can change these values on the AWS console after the fact. 59 | 60 | 4. Use the `AcquireLock` and `ReleaseLock` methods to manage your distributed locks. 61 | 62 | ```c# 63 | IDistributedLockManager lockerManager; 64 | ... 65 | var success = await lockManager.AcquireLock("my-lock-id"); 66 | ... 67 | await lockManager.ReleaseLock("my-lock-id"); 68 | ``` 69 | 70 | `AcquireLock` will return `false` if the lock is already in use and `true` if it successfully acquired the lock. 71 | It will start with an initial lease of 30 seconds, and a background thread will renew all locally controlled leases every 10 seconds, until `ReleaseLock` is called or the application ends and the leases expire naturally. 72 | 73 | ### Notes 74 | 75 | It is recommended that you keep the clocks of all participating nodes in sync using an NTP implementation 76 | 77 | * https://en.wikipedia.org/wiki/Network_Time_Protocol 78 | * https://aws.amazon.com/blogs/aws/keeping-time-with-amazon-time-sync-service/ 79 | 80 | ## Authors 81 | * **Daniel Gerlag** - daniel@gerlag.ca 82 | 83 | ## License 84 | 85 | This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) file for details 86 | -------------------------------------------------------------------------------- /DynamoLock/LockTableProvisioner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using Amazon.DynamoDBv2; 6 | using Amazon.DynamoDBv2.Model; 7 | using Amazon.Runtime; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace DynamoLock 11 | { 12 | public class LockTableProvisioner : ILockTableProvisioner 13 | { 14 | private readonly ILogger _logger; 15 | private readonly IAmazonDynamoDB _client; 16 | private readonly string _tableName; 17 | 18 | public LockTableProvisioner(AWSCredentials credentials, AmazonDynamoDBConfig config, string tableName, ILoggerFactory logFactory) 19 | { 20 | _logger = logFactory.CreateLogger(); 21 | _client = new AmazonDynamoDBClient(credentials, config); 22 | _tableName = tableName; 23 | } 24 | 25 | public LockTableProvisioner(IAmazonDynamoDB dynamoClient, string tableName, ILoggerFactory logFactory) 26 | { 27 | _logger = logFactory.CreateLogger(); 28 | _client = dynamoClient; 29 | _tableName = tableName; 30 | } 31 | 32 | public async Task Provision() 33 | { 34 | try 35 | { 36 | var poll = await _client.DescribeTableAsync(_tableName); 37 | } 38 | catch (ResourceNotFoundException) 39 | { 40 | _logger.LogInformation($"Creating lock table {_tableName}"); 41 | await CreateTable(); 42 | await SetPurgeTTL(); 43 | } 44 | } 45 | 46 | private async Task CreateTable() 47 | { 48 | var createRequest = new CreateTableRequest(_tableName, new List() 49 | { 50 | new KeySchemaElement("id", KeyType.HASH) 51 | }) 52 | { 53 | AttributeDefinitions = new List() 54 | { 55 | new AttributeDefinition("id", ScalarAttributeType.S) 56 | }, 57 | ProvisionedThroughput = new ProvisionedThroughput() 58 | { 59 | ReadCapacityUnits = 1, 60 | WriteCapacityUnits = 1 61 | } 62 | //BillingMode = BillingMode.PAY_PER_REQUEST 63 | }; 64 | 65 | var createResponse = await _client.CreateTableAsync(createRequest); 66 | 67 | int i = 0; 68 | bool created = false; 69 | while ((i < 20) && (!created)) 70 | { 71 | try 72 | { 73 | await Task.Delay(1000); 74 | var poll = await _client.DescribeTableAsync(_tableName); 75 | created = (poll.Table.TableStatus == TableStatus.ACTIVE); 76 | i++; 77 | } 78 | catch (ResourceNotFoundException) 79 | { 80 | } 81 | } 82 | } 83 | 84 | private async Task SetPurgeTTL() 85 | { 86 | var request = new UpdateTimeToLiveRequest() 87 | { 88 | TableName = _tableName, 89 | TimeToLiveSpecification = new TimeToLiveSpecification() 90 | { 91 | AttributeName = "purge_time", 92 | Enabled = true 93 | } 94 | }; 95 | 96 | await _client.UpdateTimeToLiveAsync(request); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /DynamoLock/HeartbeatDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Amazon.DynamoDBv2; 7 | using Amazon.DynamoDBv2.Model; 8 | using Amazon.Runtime; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace DynamoLock 12 | { 13 | public class HeartbeatDispatcher : IHeartbeatDispatcher 14 | { 15 | private readonly ILogger _logger; 16 | private readonly IAmazonDynamoDB _client; 17 | private readonly ILocalLockTracker _lockTracker; 18 | private readonly string _tableName; 19 | 20 | private TimeSpan _interval; 21 | private string _nodeId; 22 | private long _leaseTime; 23 | private Task _heartbeatTask; 24 | private CancellationTokenSource _cancellationTokenSource; 25 | 26 | public HeartbeatDispatcher(AWSCredentials credentials, AmazonDynamoDBConfig config, ILocalLockTracker lockTracker, string tableName, ILoggerFactory logFactory) 27 | { 28 | _logger = logFactory.CreateLogger(); 29 | _client = new AmazonDynamoDBClient(credentials, config); 30 | _tableName = tableName; 31 | _lockTracker = lockTracker; 32 | } 33 | 34 | public HeartbeatDispatcher(IAmazonDynamoDB dynamoClient, ILocalLockTracker lockTracker, string tableName, ILoggerFactory logFactory) 35 | { 36 | _logger = logFactory.CreateLogger(); 37 | _client = dynamoClient; 38 | _tableName = tableName; 39 | _lockTracker = lockTracker; 40 | } 41 | 42 | public void Start(string nodeId, TimeSpan interval, long leaseTime) 43 | { 44 | _nodeId = nodeId; 45 | _interval = interval; 46 | _leaseTime = leaseTime; 47 | 48 | if (_cancellationTokenSource != null) 49 | { 50 | _cancellationTokenSource.Cancel(); 51 | _heartbeatTask?.Wait(); 52 | } 53 | 54 | _cancellationTokenSource = new CancellationTokenSource(); 55 | _heartbeatTask = new Task(SendHeartbeat); 56 | _heartbeatTask.Start(); 57 | } 58 | 59 | public void Stop() 60 | { 61 | _cancellationTokenSource?.Cancel(); 62 | _heartbeatTask?.Wait(); 63 | } 64 | 65 | private async void SendHeartbeat() 66 | { 67 | while (!_cancellationTokenSource.IsCancellationRequested) 68 | { 69 | try 70 | { 71 | await Task.Delay(_interval, _cancellationTokenSource.Token); 72 | 73 | foreach (var item in _lockTracker.GetSnapshot()) 74 | { 75 | var req = new PutItemRequest 76 | { 77 | TableName = _tableName, 78 | Item = new Dictionary 79 | { 80 | { "id", new AttributeValue(item) }, 81 | { "lock_owner", new AttributeValue(_nodeId) }, 82 | { 83 | "expires", new AttributeValue() 84 | { 85 | N = Convert.ToString(new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds() + _leaseTime) 86 | } 87 | }, 88 | { 89 | "purge_time", new AttributeValue() 90 | { 91 | N = Convert.ToString(new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds() + (_leaseTime * 10)) 92 | } 93 | } 94 | }, 95 | ConditionExpression = "lock_owner = :node_id", 96 | ExpressionAttributeValues = new Dictionary 97 | { 98 | { ":node_id", new AttributeValue(_nodeId) } 99 | } 100 | }; 101 | 102 | try 103 | { 104 | await _client.PutItemAsync(req, _cancellationTokenSource.Token); 105 | } 106 | catch (ConditionalCheckFailedException) 107 | { 108 | _logger.LogWarning($"Lock not owned anymore when sending heartbeat for {item}"); 109 | } 110 | } 111 | } 112 | catch (Exception ex) 113 | { 114 | _logger.LogError(default(EventId), ex, ex.Message); 115 | } 116 | } 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | [Xx]64/ 19 | [Xx]86/ 20 | [Bb]uild/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | artifacts/ 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Chutzpah Test files 73 | _Chutzpah* 74 | 75 | # Visual C++ cache files 76 | ipch/ 77 | *.aps 78 | *.ncb 79 | *.opendb 80 | *.opensdf 81 | *.sdf 82 | *.cachefile 83 | *.VC.db 84 | 85 | # Visual Studio profiler 86 | *.psess 87 | *.vsp 88 | *.vspx 89 | *.sap 90 | 91 | # TFS 2012 Local Workspace 92 | $tf/ 93 | 94 | # Guidance Automation Toolkit 95 | *.gpState 96 | 97 | # ReSharper is a .NET coding add-in 98 | _ReSharper*/ 99 | *.[Rr]e[Ss]harper 100 | *.DotSettings.user 101 | 102 | # JustCode is a .NET coding add-in 103 | .JustCode 104 | 105 | # TeamCity is a build add-in 106 | _TeamCity* 107 | 108 | # DotCover is a Code Coverage Tool 109 | *.dotCover 110 | 111 | # NCrunch 112 | _NCrunch_* 113 | .*crunch*.local.xml 114 | nCrunchTemp_* 115 | 116 | # MightyMoose 117 | *.mm.* 118 | AutoTest.Net/ 119 | 120 | # Web workbench (sass) 121 | .sass-cache/ 122 | 123 | # Installshield output folder 124 | [Ee]xpress/ 125 | 126 | # DocProject is a documentation generator add-in 127 | DocProject/buildhelp/ 128 | DocProject/Help/*.HxT 129 | DocProject/Help/*.HxC 130 | DocProject/Help/*.hhc 131 | DocProject/Help/*.hhk 132 | DocProject/Help/*.hhp 133 | DocProject/Help/Html2 134 | DocProject/Help/html 135 | 136 | # Click-Once directory 137 | publish/ 138 | 139 | # Publish Web Output 140 | *.[Pp]ublish.xml 141 | *.azurePubxml 142 | 143 | # TODO: Un-comment the next line if you do not want to checkin 144 | # your web deploy settings because they may include unencrypted 145 | # passwords 146 | #*.pubxml 147 | *.publishproj 148 | 149 | # NuGet Packages 150 | *.nupkg 151 | # The packages folder can be ignored because of Package Restore 152 | **/packages/* 153 | # except build/, which is used as an MSBuild target. 154 | !**/packages/build/ 155 | # Uncomment if necessary however generally it will be regenerated when needed 156 | #!**/packages/repositories.config 157 | # NuGet v3's project.json files produces more ignoreable files 158 | *.nuget.props 159 | *.nuget.targets 160 | 161 | # Microsoft Azure Build Output 162 | csx/ 163 | *.build.csdef 164 | 165 | # Microsoft Azure Emulator 166 | ecf/ 167 | rcf/ 168 | 169 | # Microsoft Azure ApplicationInsights config file 170 | ApplicationInsights.config 171 | 172 | # Windows Store app package directory 173 | AppPackages/ 174 | BundleArtifacts/ 175 | 176 | # Visual Studio cache files 177 | # files ending in .cache can be ignored 178 | *.[Cc]ache 179 | # but keep track of directories ending in .cache 180 | !*.[Cc]ache/ 181 | 182 | # Others 183 | ClientBin/ 184 | [Ss]tyle[Cc]op.* 185 | ~$* 186 | *~ 187 | *.dbmdl 188 | *.dbproj.schemaview 189 | *.pfx 190 | *.publishsettings 191 | node_modules/ 192 | orleans.codegen.cs 193 | 194 | # RIA/Silverlight projects 195 | Generated_Code/ 196 | 197 | # Backup & report files from converting an old project file 198 | # to a newer Visual Studio version. Backup files are not needed, 199 | # because we have git ;-) 200 | _UpgradeReport_Files/ 201 | Backup*/ 202 | UpgradeLog*.XML 203 | UpgradeLog*.htm 204 | 205 | # SQL Server files 206 | *.mdf 207 | *.ldf 208 | 209 | # Business Intelligence projects 210 | *.rdl.data 211 | *.bim.layout 212 | *.bim_*.settings 213 | 214 | # Microsoft Fakes 215 | FakesAssemblies/ 216 | 217 | # GhostDoc plugin setting file 218 | *.GhostDoc.xml 219 | 220 | # Node.js Tools for Visual Studio 221 | .ntvs_analysis.dat 222 | 223 | # Visual Studio 6 build log 224 | *.plg 225 | 226 | # Visual Studio 6 workspace options file 227 | *.opt 228 | 229 | # Visual Studio LightSwitch build output 230 | **/*.HTMLClient/GeneratedArtifacts 231 | **/*.DesktopClient/GeneratedArtifacts 232 | **/*.DesktopClient/ModelManifest.xml 233 | **/*.Server/GeneratedArtifacts 234 | **/*.Server/ModelManifest.xml 235 | _Pvt_Extensions 236 | 237 | # LightSwitch generated files 238 | GeneratedArtifacts/ 239 | ModelManifest.xml 240 | 241 | # Paket dependency manager 242 | .paket/paket.exe 243 | 244 | # FAKE - F# Make 245 | .fake/ 246 | *.migration_in_place_backup 247 | .idea/.idea.WorkflowCore/.idea 248 | riderModule.iml 249 | -------------------------------------------------------------------------------- /Docker.Testify/DockerSetup.cs: -------------------------------------------------------------------------------- 1 | using Docker.DotNet; 2 | using Docker.DotNet.Models; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Diagnostics; 6 | using System.IO; 7 | using System.Linq; 8 | using System.Net.NetworkInformation; 9 | using System.Runtime.InteropServices; 10 | using System.Text; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | 14 | namespace Docker.Testify 15 | { 16 | public abstract class DockerSetup : IDisposable 17 | { 18 | public abstract string ImageName { get; } 19 | public virtual string ContainerPrefix => "tests"; 20 | public abstract int InternalPort { get; } 21 | 22 | public virtual string ImageTag => "latest"; 23 | public virtual TimeSpan TimeOut => TimeSpan.FromSeconds(30); 24 | public virtual IList EnvironmentVariables => new List(); 25 | public int ExternalPort { get; } 26 | 27 | public abstract bool TestReady(); 28 | public abstract void PublishConnectionInfo(); 29 | 30 | protected readonly DockerClient docker; 31 | protected string containerId; 32 | 33 | protected DockerSetup() 34 | { 35 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 36 | docker = new DockerClientConfiguration(new Uri("npipe://./pipe/docker_engine")).CreateClient(); 37 | else 38 | docker = new DockerClientConfiguration(new Uri("unix:///var/run/docker.sock")).CreateClient(); 39 | 40 | ExternalPort = GetFreePort(); 41 | 42 | Debug.WriteLine($"Selected port {ExternalPort}"); 43 | 44 | StartContainer().Wait(); 45 | } 46 | 47 | public async Task StartContainer() 48 | { 49 | var hostCfg = new HostConfig(); 50 | var pb = new PortBinding 51 | { 52 | HostIP = "0.0.0.0", 53 | HostPort = ExternalPort.ToString() 54 | }; 55 | 56 | hostCfg.PortBindings = new Dictionary>(); 57 | hostCfg.PortBindings.Add($"{InternalPort}/tcp", new PortBinding[] { pb }); 58 | 59 | await PullImage(ImageName, ImageTag); 60 | 61 | var container = await docker.Containers.CreateContainerAsync(new CreateContainerParameters() 62 | { 63 | Image = $"{ImageName}:{ImageTag}", 64 | Name = $"{ContainerPrefix}-{Guid.NewGuid()}", 65 | HostConfig = hostCfg, 66 | Env = EnvironmentVariables 67 | }); 68 | 69 | Debug.WriteLine("Starting docker container..."); 70 | var started = await docker.Containers.StartContainerAsync(container.ID, new ContainerStartParameters()); 71 | if (started) 72 | { 73 | containerId = container.ID; 74 | PublishConnectionInfo(); 75 | 76 | Debug.WriteLine("Waiting service to start in the docker container..."); 77 | 78 | var ready = false; 79 | var expiryTime = DateTime.Now.Add(TimeOut); 80 | 81 | while ((DateTime.Now < expiryTime) && (!ready)) 82 | { 83 | await Task.Delay(1000); 84 | ready = TestReady(); 85 | } 86 | 87 | if (ready) 88 | { 89 | Debug.WriteLine($"Docker container started: {container.ID}"); 90 | } 91 | else 92 | { 93 | Debug.WriteLine("Docker container timeout waiting for service"); 94 | throw new TimeoutException(); 95 | } 96 | } 97 | else 98 | { 99 | Debug.WriteLine("Docker container failed"); 100 | } 101 | } 102 | 103 | public async Task PullImage(string name, string tag) 104 | { 105 | var images = docker.Images.ListImagesAsync(new ImagesListParameters()).Result; 106 | var exists = images 107 | .Where(x => x.RepoTags != null) 108 | .Any(x => x.RepoTags.Contains($"{name}:{tag}")); 109 | 110 | if (exists) 111 | return; 112 | 113 | Debug.WriteLine($"Pulling docker image {name}:{tag}"); 114 | await docker.Images.CreateImageAsync(new ImagesCreateParameters() { FromImage = name, Tag = tag }, null, new Progress()); 115 | } 116 | 117 | public void Dispose() 118 | { 119 | docker.Containers.KillContainerAsync(containerId, new ContainerKillParameters()).Wait(); 120 | docker.Containers.RemoveContainerAsync(containerId, new ContainerRemoveParameters() { Force = true }).Wait(); 121 | } 122 | 123 | private int GetFreePort() 124 | { 125 | const int startRange = 1000; 126 | const int endRange = 10000; 127 | var ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties(); 128 | var tcpPorts = ipGlobalProperties.GetActiveTcpListeners(); 129 | var udpPorts = ipGlobalProperties.GetActiveUdpListeners(); 130 | 131 | var result = startRange; 132 | 133 | while (((tcpPorts.Any(x => x.Port == result)) || (udpPorts.Any(x => x.Port == result))) && result <= endRange) 134 | result++; 135 | 136 | if (result > endRange) 137 | throw new PortsInUseException(); 138 | 139 | return result; 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /DynamoLock/DynamoDbLockManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Amazon; 6 | using Amazon.DynamoDBv2; 7 | using Amazon.DynamoDBv2.Model; 8 | using Amazon.Runtime; 9 | using Microsoft.Extensions.Logging; 10 | using Microsoft.Extensions.Logging.Abstractions; 11 | 12 | namespace DynamoLock 13 | { 14 | public class DynamoDbLockManager : IDistributedLockManager 15 | { 16 | private readonly ILogger _logger; 17 | private readonly IAmazonDynamoDB _client; 18 | private readonly ILockTableProvisioner _provisioner; 19 | private readonly IHeartbeatDispatcher _heartbeatDispatcher; 20 | private readonly ILocalLockTracker _lockTracker; 21 | private readonly string _tableName; 22 | private readonly string _nodeId = Guid.NewGuid().ToString(); 23 | private readonly long _defaultLeaseTime = 30; 24 | private readonly TimeSpan _heartbeat = TimeSpan.FromSeconds(10); 25 | private readonly long _jitterTolerance = 1; 26 | 27 | 28 | public DynamoDbLockManager(AWSCredentials credentials, AmazonDynamoDBConfig config, string tableName, ILockTableProvisioner provisioner, IHeartbeatDispatcher heartbeatDispatcher, ILocalLockTracker lockTracker, ILoggerFactory logFactory) 29 | { 30 | _logger = logFactory.CreateLogger(); 31 | _client = new AmazonDynamoDBClient(credentials, config); 32 | _tableName = tableName; 33 | _provisioner = provisioner; 34 | _heartbeatDispatcher = heartbeatDispatcher; 35 | _lockTracker = lockTracker; 36 | } 37 | 38 | public DynamoDbLockManager(AWSCredentials credentials, RegionEndpoint region, string tableName, ILoggerFactory logFactory) 39 | { 40 | _logger = logFactory.CreateLogger(); 41 | _client = new AmazonDynamoDBClient(credentials, region); 42 | _tableName = tableName; 43 | _lockTracker = new LocalLockTracker(); 44 | _provisioner = new LockTableProvisioner(credentials, new AmazonDynamoDBConfig() { RegionEndpoint = region }, tableName, logFactory); 45 | _heartbeatDispatcher = new HeartbeatDispatcher(credentials, new AmazonDynamoDBConfig() {RegionEndpoint = region}, _lockTracker, tableName, logFactory); 46 | } 47 | 48 | public DynamoDbLockManager(IAmazonDynamoDB dynamoClient, string tableName, ILoggerFactory logFactory) 49 | { 50 | _logger = logFactory.CreateLogger(); 51 | _client = dynamoClient; 52 | _tableName = tableName; 53 | _lockTracker = new LocalLockTracker(); 54 | _provisioner = new LockTableProvisioner(dynamoClient, tableName, logFactory); 55 | _heartbeatDispatcher = new HeartbeatDispatcher(dynamoClient, _lockTracker, tableName, logFactory); 56 | } 57 | 58 | public DynamoDbLockManager(AWSCredentials credentials, AmazonDynamoDBConfig config, string tableName, ILockTableProvisioner provisioner, IHeartbeatDispatcher heartbeatDispatcher, ILocalLockTracker lockTracker, ILoggerFactory logFactory, long defaultLeaseTime, TimeSpan hearbeat) 59 | : this(credentials, config, tableName, provisioner, heartbeatDispatcher, lockTracker, logFactory) 60 | { 61 | _defaultLeaseTime = defaultLeaseTime; 62 | _heartbeat = hearbeat; 63 | } 64 | 65 | public async Task AcquireLock(string Id) 66 | { 67 | try 68 | { 69 | var req = new PutItemRequest() 70 | { 71 | TableName = _tableName, 72 | Item = new Dictionary 73 | { 74 | { "id", new AttributeValue(Id) }, 75 | { "lock_owner", new AttributeValue(_nodeId) }, 76 | { 77 | "expires", new AttributeValue() 78 | { 79 | N = Convert.ToString(new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds() + _defaultLeaseTime) 80 | } 81 | }, 82 | { 83 | "purge_time", new AttributeValue() 84 | { 85 | N = Convert.ToString(new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds() + (_defaultLeaseTime * 10)) 86 | } 87 | } 88 | }, 89 | ConditionExpression = "attribute_not_exists(id) OR (expires < :expired)", 90 | ExpressionAttributeValues = new Dictionary 91 | { 92 | { ":expired", new AttributeValue() 93 | { 94 | N = Convert.ToString(new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds() + _jitterTolerance) 95 | } 96 | } 97 | } 98 | }; 99 | 100 | var response = await _client.PutItemAsync(req); 101 | 102 | if (response.HttpStatusCode == System.Net.HttpStatusCode.OK) 103 | { 104 | _lockTracker.Add(Id); 105 | return true; 106 | } 107 | } 108 | catch (ConditionalCheckFailedException) 109 | { 110 | } 111 | return false; 112 | } 113 | 114 | public async Task ReleaseLock(string Id) 115 | { 116 | _lockTracker.Remove(Id); 117 | 118 | try 119 | { 120 | var req = new DeleteItemRequest() 121 | { 122 | TableName = _tableName, 123 | Key = new Dictionary 124 | { 125 | { "id", new AttributeValue(Id) } 126 | }, 127 | ConditionExpression = "lock_owner = :node_id", 128 | ExpressionAttributeValues = new Dictionary 129 | { 130 | { ":node_id", new AttributeValue(_nodeId) } 131 | } 132 | 133 | }; 134 | await _client.DeleteItemAsync(req); 135 | } 136 | catch (ConditionalCheckFailedException) 137 | { 138 | } 139 | } 140 | 141 | public async Task Start() 142 | { 143 | await _provisioner.Provision(); 144 | _heartbeatDispatcher.Start(_nodeId, _heartbeat, _defaultLeaseTime); 145 | } 146 | 147 | public Task Stop() 148 | { 149 | _heartbeatDispatcher.Stop(); 150 | _lockTracker.Clear(); 151 | return Task.CompletedTask; 152 | } 153 | } 154 | } 155 | --------------------------------------------------------------------------------