Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<VersionPrefix>1.1.2</VersionPrefix>
<VersionPrefix>1.2.0</VersionPrefix>
<PackageLicenseExpression>MIT</PackageLicenseExpression>

<!-- Other useful metadata -->
Expand Down
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,35 @@ However, the partition and sort key attribute names are fully configurable via `

---

## πŸ“ˆ Telemetry

This library uses [System.Diagnostics.Metrics](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/metrics) `Meter`s to collect metrics. These metrics can be opted in to be exported to your preferred telemetry system (e.g., OpenTelemetry, console output) using standard dotnet telemetry exporters.

A full list of metric names can be found in the [MetricNames](src/DynamoDb.DistributedLock/Metrics/MetricNames.cs) class.

By default no metrics are exported to your collector, but you can enable them by configuring the `Meter` in your application:

[OpenTelemetry](https://opentelemetry.io/docs/languages/dotnet/)
```csharp
services.AddOpenTelemetry()
.WithMetrics(metrics =>
metrics
// There is only one meter used by this library
// and this constant value refers to its name
// this causes the telemetry system to collect metrics emitted during lock operations
.AddMeter(DynamoDb.DistributedLock.Metrics.MetricNames.MeterName)
// Views can be used to filter out any metrics you do not want to collect
// while still collecting all metrics from the Meter
.AddView(DynamoDb.DistributedLock.Metrics.MetricNames.LockReleaseTimer, MetricStreamConfiguration.Drop)
// Configure your preferred exporter, e.g., OpenTelemetry Protocol (OTLP)
.AddOtlpExporter(options => options.Endpoint = otlpEndpoint)
);
```

Other options for collection of metrics are available, including local development options such as [dotnet-counters](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters) or the [.Net Aspire standalone dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone)

---

## πŸ§ͺ Unit Testing

Unit tests are written with:
Expand All @@ -241,7 +270,6 @@ The library provides `DynamoDbDistributedLockAutoData` to support streamlined te

- ⏱ Lock renewal support
- πŸ” Auto-release logic for expired locks
- πŸ“ˆ Metrics and diagnostics support
- 🎯 Health check integration

---
Expand Down
40 changes: 37 additions & 3 deletions src/DynamoDb.DistributedLock/DynamoDbDistributedLock.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Threading;
using System.Threading.Tasks;
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
using DynamoDb.DistributedLock.Metrics;
using DynamoDb.DistributedLock.Retry;
using Microsoft.Extensions.Options;

Expand All @@ -17,18 +19,33 @@ public class DynamoDbDistributedLock : IDynamoDbDistributedLock
private readonly IAmazonDynamoDB _client;
private readonly DynamoDbLockOptions _options;
private readonly Lazy<IRetryPolicy> _retryPolicy;
private readonly ILockMetrics _lockMetrics;

/// <summary>
/// Initializes a new instance of the <see cref="DynamoDbDistributedLock"/> class.
/// Uses the default <see cref="ILockMetrics"/> instance
/// </summary>
/// <param name="client">The DynamoDB client.</param>
/// <param name="options">Configuration options for the lock.</param>
public DynamoDbDistributedLock(IAmazonDynamoDB client,
IOptions<DynamoDbLockOptions> options)
IOptions<DynamoDbLockOptions> options) : this(client, options, LockMetrics.Default)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="DynamoDbDistributedLock"/> class.
/// </summary>
/// <param name="client">The DynamoDB client.</param>
/// <param name="options">Configuration options for the lock.</param>
/// <param name="lockMetrics">Collects telemetry based on lock operations</param>
public DynamoDbDistributedLock(IAmazonDynamoDB client,
IOptions<DynamoDbLockOptions> options,
ILockMetrics lockMetrics)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
_retryPolicy = new Lazy<IRetryPolicy>(() => new ExponentialBackoffRetryPolicy(_options.Retry));
_lockMetrics = lockMetrics ?? throw new ArgumentNullException(nameof(lockMetrics));
_retryPolicy = new Lazy<IRetryPolicy>(() => new ExponentialBackoffRetryPolicy(_options.Retry, lockMetrics));
}

/// <summary>
Expand All @@ -40,6 +57,7 @@ public DynamoDbDistributedLock(IAmazonDynamoDB client,
/// <returns><c>true</c> if the lock was acquired; otherwise, <c>false</c>.</returns>
public async Task<bool> AcquireLockAsync(string resourceId, string ownerId, CancellationToken cancellationToken = default)
{
using var _ = _lockMetrics.TrackLockAcquire();
var result = await TryAcquireLockInternalAsync(resourceId, ownerId, cancellationToken);
return result.IsSuccess;
}
Expand All @@ -53,6 +71,7 @@ public async Task<bool> AcquireLockAsync(string resourceId, string ownerId, Canc
/// <returns><c>true</c> if the lock was released; <c>false</c> if the lock was not owned by the caller.</returns>
public async Task<bool> ReleaseLockAsync(string resourceId, string ownerId, CancellationToken cancellationToken = default)
{
using var _ = _lockMetrics.TrackLockRelease();
var request = new DeleteItemRequest
{
TableName = _options.TableName,
Expand All @@ -71,12 +90,19 @@ public async Task<bool> ReleaseLockAsync(string resourceId, string ownerId, Canc
try
{
await _client.DeleteItemAsync(request, cancellationToken);
_lockMetrics.LockReleased();
return true; // Lock released
}
catch (ConditionalCheckFailedException)
{
_lockMetrics.LockReleaseFailed("not_owned");
return false; // Lock was held by another process
}
catch
{
_lockMetrics.LockReleaseFailed("unexpected_exception");
throw;
}
}

/// <summary>
Expand All @@ -88,6 +114,7 @@ public async Task<bool> ReleaseLockAsync(string resourceId, string ownerId, Canc
/// <returns>An <see cref="IDistributedLockHandle"/> if the lock was successfully acquired; otherwise, <c>null</c>.</returns>
public async Task<IDistributedLockHandle?> AcquireLockHandleAsync(string resourceId, string ownerId, CancellationToken cancellationToken = default)
{
using var _ = _lockMetrics.TrackLockAcquire();
var result = await TryAcquireLockInternalAsync(resourceId, ownerId, cancellationToken);
return result.IsSuccess ? new DistributedLockHandle(this, resourceId, ownerId, result.ExpiresAt) : null;
}
Expand Down Expand Up @@ -116,16 +143,23 @@ private async Task<LockAcquisitionResult> TryAcquireLockInternalAsync(string res
private async Task<LockAcquisitionResult> TryAcquireLockOnceAsync(string resourceId, string ownerId, bool suppressExceptions, CancellationToken cancellationToken)
{
var (request, expiresAt) = CreatePutItemRequest(resourceId, ownerId);

try
{
await _client.PutItemAsync(request, cancellationToken);
_lockMetrics.LockAcquired();
return new LockAcquisitionResult(true, expiresAt);
}
catch (ConditionalCheckFailedException) when (suppressExceptions)
{
_lockMetrics.LockAcquireFailed("condition_check_failed");
return new LockAcquisitionResult(false, default);
}
catch
{
_lockMetrics.LockAcquireFailed("exception_taking_lock");
throw;
}
}

private (PutItemRequest Request, DateTimeOffset ExpiresAt) CreatePutItemRequest(string resourceId, string ownerId)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Diagnostics.Metrics;
using Amazon.DynamoDBv2;
using Amazon.Extensions.NETCore.Setup;
using DynamoDb.DistributedLock.Metrics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

Expand Down Expand Up @@ -42,6 +44,7 @@ public static IServiceCollection AddDynamoDbDistributedLock(this IServiceCollect
services.Configure(configure);
services.AddAWSService<IAmazonDynamoDB>(awsOptions);
services.AddSingleton<IDynamoDbDistributedLock, DynamoDbDistributedLock>();
services.AddSingleton<ILockMetrics, LockMetrics>(_ => LockMetrics.Default);
return services;
}
}
49 changes: 49 additions & 0 deletions src/DynamoDb.DistributedLock/Metrics/ILockMetrics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System;

namespace DynamoDb.DistributedLock.Metrics;

/// <summary>
/// Defines methods for tracking metrics related to distributed lock operations.
/// </summary>
public interface ILockMetrics
{
// Timers (use with 'using' to record elapsed milliseconds)
/// <summary>
/// Creates a timer to track the duration of a lock acquisition attempt.
/// </summary>
/// <returns>IDisposable that tracks start time based on time created, and publishes end time based on disposal time</returns>
IDisposable TrackLockAcquire();
/// <summary>
/// Creates a timer to track the duration of a lock release attempt.
/// </summary>
/// <returns>IDisposable that tracks start time based on time created, and publishes end time based on disposal time</returns>
IDisposable TrackLockRelease();

// Counters
/// <summary>
/// A lock was successfully acquired.
/// </summary>
void LockAcquired();
/// <summary>
/// A lock was successfully released.
/// </summary>
void LockReleased();
/// <summary>
/// A lock acquisition attempt failed.
/// </summary>
/// <param name="reason">tag value for the reason the failure occured</param>
void LockAcquireFailed(string reason); // e.g., "not_owned", "timeout", "unexpected_exception"
/// <summary>
/// A lock release attempt failed.
/// </summary>
/// <param name="reason">tag value for the reason the failure occured</param>
void LockReleaseFailed(string reason); // e.g., "not_owned", "unexpected_exception"
/// <summary>
/// A retry attempt was made during lock acquisition.
/// </summary>
void RetryAttempt();
/// <summary>
/// All retry attempts were exhausted without acquiring the lock.
/// </summary>
void RetriesExhausted();
}
90 changes: 90 additions & 0 deletions src/DynamoDb.DistributedLock/Metrics/LockMetrics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System;
using System.Diagnostics;
using System.Diagnostics.Metrics;

namespace DynamoDb.DistributedLock.Metrics;

/// <inheritdoc cref="ILockMetrics" />
public sealed class LockMetrics : ILockMetrics, IDisposable
{
private readonly Meter _meter;

private readonly Counter<int> _lockAcquire;
private readonly Counter<int> _lockRelease;
private readonly Counter<int> _lockAcquireFailed;
private readonly Counter<int> _lockReleaseFailed;
private readonly Counter<int> _retriesExhausted;
private readonly Counter<int> _retryAttempt;
private readonly Histogram<double> _lockAcquireTimer;
private readonly Histogram<double> _lockReleaseTimer;

/// <summary>
/// Initializes a new instance of the <see cref="LockMetrics"/> class with the specified <see cref="Meter"/>.
/// </summary>
/// <param name="meter"></param>
public LockMetrics(Meter meter)
{
ArgumentNullException.ThrowIfNull(meter);
_meter = meter;

_lockAcquire = _meter.CreateCounter<int>(MetricNames.LockAcquire, unit: "count");
_lockRelease = _meter.CreateCounter<int>(MetricNames.LockRelease, unit: "count");
_lockAcquireFailed = _meter.CreateCounter<int>(MetricNames.LockAcquireFailed, unit: "count");
_lockReleaseFailed = _meter.CreateCounter<int>(MetricNames.LockReleaseFailed, unit: "count");
_retriesExhausted = _meter.CreateCounter<int>(MetricNames.RetriesExhausted, unit: "count");
_retryAttempt = _meter.CreateCounter<int>(MetricNames.RetryAttempt, unit: "count");
_lockAcquireTimer = _meter.CreateHistogram<double>(MetricNames.LockAcquireTimer, unit: "ms");
_lockReleaseTimer = _meter.CreateHistogram<double>(MetricNames.LockReleaseTimer, unit: "ms");
}

/// <summary>
/// A default instance of <see cref="LockMetrics"/> using a meter with the name defined by <see cref="MetricNames.MeterName"/>.
/// </summary>
public static LockMetrics Default { get; } = new(new Meter(MetricNames.MeterName));

/// <inheritdoc />
public IDisposable TrackLockAcquire() => new TimerScope(_lockAcquireTimer);

/// <inheritdoc />
public IDisposable TrackLockRelease() => new TimerScope(_lockReleaseTimer);

/// <inheritdoc />
public void LockAcquired() => _lockAcquire.Add(1);

/// <inheritdoc />
public void LockReleased() => _lockRelease.Add(1);

/// <inheritdoc />
public void LockAcquireFailed(string reason)
{
var tags = new TagList { { "reason", reason } };
_lockAcquireFailed.Add(1, tags);
}

/// <inheritdoc />
public void LockReleaseFailed(string reason)
{
var tags = new TagList { { "reason", reason } };
_lockReleaseFailed.Add(1, tags);
}

/// <inheritdoc />
public void RetryAttempt() => _retryAttempt.Add(1);

/// <inheritdoc />
public void RetriesExhausted() => _retriesExhausted.Add(1);

/// <inheritdoc />
public void Dispose() => _meter.Dispose();

private readonly struct TimerScope(Histogram<double> hist) : IDisposable
{
private readonly long _start = Stopwatch.GetTimestamp();

public void Dispose()
{
var ms = (Stopwatch.GetTimestamp() - _start) * 1000.0 / Stopwatch.Frequency;
hist.Record(ms);
}
}
}
45 changes: 45 additions & 0 deletions src/DynamoDb.DistributedLock/Metrics/MetricNames.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace DynamoDb.DistributedLock.Metrics;

/// <summary>
/// Names of metrics that are published by DynamoDb.DistributedLock
/// </summary>
public static class MetricNames
{
/// <summary>
/// Name of the Meter used for publishing metrics.
/// </summary>
public const string MeterName = "DynamoDb.DistributedLock";

/// <summary>
/// A lock is successfully released.
/// </summary>
public const string LockRelease = "dynamodb.distributedlock.lock_release";
/// <summary>
/// A lock release operation failed.
/// </summary>
public const string LockReleaseFailed = "dynamodb.distributedlock.lock_release.failed";
/// <summary>
/// A lock is successfully acquired.
/// </summary>
public const string LockAcquire = "dynamodb.distributedlock.lock_acquire";
/// <summary>
/// A lock acquisition operation failed.
/// </summary>
public const string LockAcquireFailed = "dynamodb.distributedlock.lock_acquire.failed";
/// <summary>
/// Retries where attempted, but the maximum number of retries was reached without success.
Copy link

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment contains a grammatical error. 'where' should be 'were' - 'Retries were attempted, but the maximum number of retries was reached without success.'

Suggested change
/// Retries where attempted, but the maximum number of retries was reached without success.
/// Retries were attempted, but the maximum number of retries was reached without success.

Copilot uses AI. Check for mistakes.
/// </summary>
public const string RetriesExhausted = "dynamodb.distributedlock.retries_exhausted";
/// <summary>
/// A lock acquisition retry was attempted after a failure. When retrying an operation, the first attempt is not counted.
/// </summary>
public const string RetryAttempt = "dynamodb.distributedlock.retry_attempt";
/// <summary>
/// Measures the time taken to acquire a lock.
/// </summary>
public const string LockAcquireTimer = "dynamodb.distributedlock.lock_acquire.timer";
/// <summary>
/// Measures the time taken to release a lock.
/// </summary>
public const string LockReleaseTimer = "dynamodb.distributedlock.lock_release.timer";
}
Loading