Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#OS junk files
Thumbs.db
*.DS_Store

#Rider files
**/.idea/**

#Visual Studio files
*.obj
*.exe
Expand Down Expand Up @@ -47,6 +49,10 @@ StyleCop.Cache
#Project files
[Bb]uild/

# vs code files
.vscode/
*.code-workspace

#Nuget Files
*.nupkg
#MA: using nuget package restore!
Expand Down
20 changes: 20 additions & 0 deletions docs/Developing DistributedLock.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,23 @@ Extract the zip archive, and within it copy `zoo_sample.cfg` to `zoo.cfg`.
Add the full path of the extracted directory (the one containing README.md, bin, conf, etc) to `DistributedLock.Tests/credentials/zookeeper.txt` as a single line.

Also, install Java Development Kit (JDK) because ZooKeeper runs on Java.

### Etcd

The easiest way to run etcd tests locally is to install [Docker](https://docs.docker.com/desktop/):

```bash
# Run a single-node etcd cluster
docker run -d --name etcd \
-p 2379:2379 \
-p 2380:2380 \
gcr.io/etcd-development/etcd:v3.6.1 \
/usr/local/bin/etcd \
--advertise-client-urls http://0.0.0.0:2379 \
--listen-client-urls http://0.0.0.0:2379 \
--data-dir /etcd-data

# Or run a 3-node cluster for more robust testing, this is already setup in EtcdClusterSetup
```

The tests will automatically start and stop etcd instances using Docker containers, so you don't need to have etcd running as a service for testing.
74 changes: 74 additions & 0 deletions docs/DistributedLock.Etcd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# DistributedLock.Etcd

[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.Etcd) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.Etcd.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.Etcd/)

The DistributedLock.Etcd package offers distributed locks based on [etcd](https://etcd.io/). For example:

```C#
using dotnet_etcd;
using Medallion.Threading.Etcd;

var client = new EtcdClient("http://localhost:2379");
var @lock = new EtcdLeaseDistributedLock(client, "MyLockName");
await using (await @lock.AcquireAsync())
{
// I have the lock
}
```

## APIs

- The `EtcdLeaseDistributedLock` class implements the `IDistributedLock` interface.
- The `EtcdLeaseDistributedLockProvider` class implements the `IDistributedLockProvider` interface.

## Implementation notes

The `EtcdLeaseDistributedLock` implementation leverages etcd's lease mechanism and distributed lock primitives. It uses etcd's built-in lease functionality to automatically manage lock expiration and renewal.

The implementation creates a lease with a specified duration and uses that lease to acquire a distributed lock. The lease is automatically renewed in the background to ensure the lock remains held until explicitly released. This provides automatic cleanup in case the process holding the lock crashes or becomes unresponsive.

Because of how etcd locks work, the acquire operation cannot truly block. If waiting to acquire a lock that is not available, the implementation will periodically sleep and retry until the lock can be taken or the acquire timeout elapses. Because of this, these classes are maximally efficient when using `TryAcquire` semantics with a timeout of zero.

## Options

In addition to specifying the lock name and etcd client, some additional tuning options are available through the `EtcdLeaseOptionsBuilder`:

- `Duration` determines how long the lease will be initially claimed for (because of auto-renewal, locks can be held for longer). Must be between 15 and 60 seconds, or infinite. Defaults to 30s.
- `RenewalCadence` determines how frequently the hold on the lock will be renewed to the full `Duration`. Defaults to 1/3 of `Duration`. To disable auto-renewal, specify `Timeout.InfiniteTimeSpan`.
- `BusyWaitSleepTime` specifies a range of times that the implementation will sleep between attempts to acquire a lock that is currently held by someone else. A random number in the range will be chosen for each sleep. Lower values increase responsiveness but increase the number of calls made to etcd. The default is [250ms, 1s].

## Example with options

```C#
using dotnet_etcd;
using Medallion.Threading.Etcd;

var client = new EtcdClient("http://localhost:2379");
var @lock = new EtcdLeaseDistributedLock(client, "MyLockName", options =>
{
options.Duration(TimeSpan.FromSeconds(45))
.RenewalCadence(TimeSpan.FromSeconds(15))
.BusyWaitSleepTime(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500));
});

await using (await @lock.AcquireAsync())
{
// I have the lock with custom options
}
```

## Using the provider

```C#
using dotnet_etcd;
using Medallion.Threading.Etcd;

var client = new EtcdClient("http://localhost:2379");
var provider = new EtcdLeaseDistributedLockProvider(client);

var @lock = provider.CreateLock("MyLockName");
await using (await @lock.AcquireAsync())
{
// I have the lock
}
```
2 changes: 2 additions & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@
<PackageVersion Include="System.Data.SqlClient" Version="4.8.6" />
<PackageVersion Include="Moq" Version="4.20.70" />
<PackageVersion Include="System.Threading.AccessControl" Version="8.0.0" Condition="'$(TargetFramework)' != 'net462'" />
<PackageVersion Include="Testcontainers" Version="4.6.0" />
<PackageVersion Include="ZooKeeperNetEx" Version="3.4.12.4" />
<PackageVersion Include="IsExternalInit" Version="1.0.3" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" Condition="'$(TargetFramework)' == 'netstandard2.0' OR '$(TargetFramework)' == 'net462'" />
<PackageVersion Include="System.ValueTuple" Version="4.5.0" Condition="'$(TargetFramework)' == 'net462'" />
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<PackageVersion Include="dotnet-etcd" Version="5.2.1" />
</ItemGroup>
</Project>
30 changes: 3 additions & 27 deletions src/DistributedLock.Core/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"Microsoft.CodeAnalysis.PublicApiAnalyzers": {
"type": "Direct",
"requested": "[3.3.4, )",
"resolved": "3.3.4",
"contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA=="
},
"Microsoft.NETFramework.ReferenceAssemblies": {
"type": "Direct",
"requested": "[1.0.3, )",
Expand Down Expand Up @@ -81,12 +75,6 @@
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"Microsoft.CodeAnalysis.PublicApiAnalyzers": {
"type": "Direct",
"requested": "[3.3.4, )",
"resolved": "3.3.4",
"contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA=="
},
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[8.0.0, )",
Expand Down Expand Up @@ -136,12 +124,6 @@
}
},
".NETStandard,Version=v2.1": {
"Microsoft.CodeAnalysis.PublicApiAnalyzers": {
"type": "Direct",
"requested": "[3.3.4, )",
"resolved": "3.3.4",
"contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA=="
},
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[8.0.0, )",
Expand All @@ -164,17 +146,11 @@
}
},
"net8.0": {
"Microsoft.CodeAnalysis.PublicApiAnalyzers": {
"type": "Direct",
"requested": "[3.3.4, )",
"resolved": "3.3.4",
"contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[8.0.4, )",
"resolved": "8.0.4",
"contentHash": "PZb5nfQ+U19nhnmnR9T1jw+LTmozhuG2eeuzuW5A7DqxD/UXW2ucjmNJqnqOuh8rdPzM3MQXoF8AfFCedJdCUw=="
"requested": "[8.0.19, )",
"resolved": "8.0.19",
"contentHash": "IhHf+zeZiaE5EXRyxILd4qM+Hj9cxV3sa8MpzZgeEhpvaG3a1VEGF6UCaPFLO44Kua3JkLKluE0SWVamS50PlA=="
},
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
Expand Down
65 changes: 65 additions & 0 deletions src/DistributedLock.Etcd/DistributedLock.Etcd.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>netstandard2.1</TargetFrameworks>
<RootNamespace>Medallion.Threading.FileSystem</RootNamespace>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<WarningLevel>4</WarningLevel>
<LangVersion>Latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<PropertyGroup>
<Version>1.0.3</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<Authors>Michael Adelson</Authors>
<Description>Provides a distributed lock implementation based on etcd</Description>
<Copyright>Copyright © 2020 Michael Adelson</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageTags>distributed etcd lock</PackageTags>
<PackageProjectUrl>https://github.com/madelson/DistributedLock</PackageProjectUrl>
<RepositoryUrl>https://github.com/madelson/DistributedLock</RepositoryUrl>
<FileVersion>1.0.0.0</FileVersion>
<PackageReleaseNotes>See https://github.com/madelson/DistributedLock#release-notes</PackageReleaseNotes>
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>..\DistributedLock.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<Optimize>True</Optimize>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<TreatWarningsAsErrors>True</TreatWarningsAsErrors>
<TreatSpecificWarningsAsErrors />
<!-- see https://github.com/dotnet/sdk/issues/2679 -->
<DebugType>embedded</DebugType>
<!-- see https://mitchelsellers.com/blog/article/net-5-deterministic-builds-source-linking -->
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<Optimize>False</Optimize>
<NoWarn>1591</NoWarn>
<DefineConstants>TRACE;DEBUG</DefineConstants>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\DistributedLock.Core\DistributedLock.Core.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="dotnet-etcd" />
<PackageReference Include="Nullable" Condition="'$(TargetFramework)' != 'netstandard2.1'">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All"/>
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" PrivateAssets="All" />
</ItemGroup>

<ItemGroup>
<Using Remove="System.Net.Http"/>
</ItemGroup>

<Import Project="..\FixDistributedLockCoreDependencyVersion.targets" />
</Project>
54 changes: 54 additions & 0 deletions src/DistributedLock.Etcd/EtcdClientWrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using dotnet_etcd.interfaces;
using Etcdserverpb;
using Grpc.Core;
using Medallion.Threading.Internal;
using V3Lockpb;

namespace Medallion.Threading.Etcd;

internal class EtcdClientWrapper
{
private readonly IEtcdClient _etcdClient;
public EtcdClientWrapper(IEtcdClient etcdClient)
{
this._etcdClient = etcdClient ?? throw new ArgumentNullException(nameof(etcdClient));
}

public ValueTask<LeaseGrantResponse> LeaseGrantAsync(LeaseGrantRequest request, CancellationToken cancellationToken)
{
return SyncViaAsync.IsSynchronous
? new ValueTask<LeaseGrantResponse>(this._etcdClient.LeaseGrant(request,
cancellationToken: cancellationToken))
: new ValueTask<LeaseGrantResponse>(
this._etcdClient.LeaseGrantAsync(request, cancellationToken: cancellationToken));
}

public Task LeaseKeepAliveAsync(long leaseId, CancellationToken token)
=> this._etcdClient.LeaseKeepAlive(leaseId, token);

public async ValueTask<LockResponse> LockAsync(LockRequest lockRequest, CancellationToken cancellationToken)
{
var response = SyncViaAsync.IsSynchronous
? this._etcdClient.Lock(lockRequest, cancellationToken: cancellationToken)
: await this._etcdClient.LockAsync(lockRequest, cancellationToken: cancellationToken).ConfigureAwait(false);

if (response == null)
{
throw new RpcException(new Status(StatusCode.Internal, "Lock failed"));
}

return response;
}

public async ValueTask LeaseRevokeAsync(LeaseRevokeRequest leaseRevokeRequest)
{
if (SyncViaAsync.IsSynchronous)
{
this._etcdClient.LeaseRevoke(leaseRevokeRequest);
}
else
{
await this._etcdClient.LeaseRevokeAsync(leaseRevokeRequest).ConfigureAwait(false);
}
}
}
Loading