Distributed locks provide a mechanism to ensure that only one instance of your service can execute a critical section of code at a time, even when running multiple instances in a distributed environment. This is essential for preventing race conditions, duplicate processing, and ensuring data consistency across your microservices. Since these microservices are essentially distributed systems to perform locking we need a centralized system to keep track of locks. We use JetStream streams for this.
The distributed locking system in cloops.microservices is built on top of NATS JetStream Key-Value stores. It provides:
- Automatic lock renewal: Locks are automatically renewed in the background to prevent expiration during long-running operations
- Automatic cleanup: Locks are automatically released when the handle is disposed
- Retry mechanism: Built-in retry logic with jittered backoff when acquiring locks
- Expired lock detection: Automatically detects and steals expired locks from crashed instances
The simplest way to use distributed locks is to inject ICloopsNatsClient into your service and call AcquireDistributedLockAsync:
using CLOOPS.NATS;
using Microsoft.Extensions.Hosting;
namespace your.namespace.services.background;
public class ScheduledTaskService : BackgroundService
{
private readonly ICloopsNatsClient _client;
private readonly ILogger<ScheduledTaskService> _logger;
public ScheduledTaskService(ICloopsNatsClient client, ILogger<ScheduledTaskService> logger)
{
_client = client;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var handle = await _client.AcquireDistributedLockAsync(
"my-resource-key",
TimeSpan.FromMilliseconds(500),
ct: stoppingToken
);
if (handle is null)
{
// Lock acquisition failed - another instance holds it
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
continue;
}
await using (handle)
{
try
{
// Critical section - only this instance can execute this code
await PerformScheduledTask(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during scheduled task execution");
}
}
// Lock is automatically released here when handle is disposed
}
}
private async Task PerformScheduledTask(CancellationToken ct)
{
// Your critical code here
await Task.Delay(1000, ct);
}
}Task<DistributedLockHandle?> AcquireDistributedLockAsync(
string key,
TimeSpan? timeout = null,
string? ownerId = null,
CancellationToken ct = default
)-
key(required): A unique identifier for the lock. This should be descriptive and unique for the resource you're protecting (e.g.,"cljps.scheduling","database.migration"). -
timeout(optional): Maximum time to spend trying to acquire the lock. Defaults to1.5 * AcquireRetryMaxDelay(approximately 750ms). If the lock cannot be acquired within this time, the method returnsnull. -
ownerId(optional): An identifier for the instance acquiring the lock. Defaults to"{MachineName}:{ProcessId}". Useful for debugging and monitoring which instance holds a lock. -
ct(optional): Cancellation token to cancel the lock acquisition operation.
DistributedLockHandle?: Returns aDistributedLockHandleif the lock was successfully acquired, ornullif the lock could not be acquired within the timeout period.
- The lock handle implements
IAsyncDisposableand must be disposed usingawait usingto ensure proper cleanup - If you don't dispose the handle, the lock will eventually expire (default: 20 seconds), but it's best practice to always dispose it explicitly
- The lock is automatically renewed in the background while you hold the handle
Use distributed locks to ensure only one instance runs scheduled tasks (e.g., cron jobs):
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var cronExpression = Util.GetCronExpression("0 */5 * * * *"); // Every 5 minutes
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Wait until next scheduled time
if (!await cronExpression.AwaitUntilNextOccurrenceAsync(
_logger,
"0 */5 * * * *",
"ScheduledTaskService",
stoppingToken))
{
continue;
}
// Try to acquire lock
var handle = await _client.AcquireDistributedLockAsync(
"scheduled.task.key",
TimeSpan.FromMilliseconds(500),
ct: stoppingToken
);
if (handle is null)
{
// Another instance is running the task
_logger.LogDebug("Scheduled task already running on another instance");
continue;
}
await using (handle)
{
try
{
await RunScheduledTask(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing scheduled task");
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}If you need to retry acquiring a lock with exponential backoff:
private async Task<bool> TryAcquireLockWithRetry(string lockKey, CancellationToken ct)
{
var maxRetries = 5;
var baseDelay = TimeSpan.FromSeconds(1);
for (int attempt = 0; attempt < maxRetries; attempt++)
{
var handle = await _client.AcquireDistributedLockAsync(
lockKey,
TimeSpan.FromMilliseconds(500),
ct: ct
);
if (handle is not null)
{
// Store handle for later use or use it immediately
return true;
}
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt));
_logger.LogWarning("Failed to acquire lock {LockKey} on attempt {Attempt}, retrying in {Delay}ms",
lockKey, attempt + 1, delay.TotalMilliseconds);
await Task.Delay(delay, ct);
}
return false;
}For operations that may take a long time, the lock will automatically renew itself:
var handle = await _client.AcquireDistributedLockAsync(
"long.running.operation",
TimeSpan.FromSeconds(5), // Longer timeout for initial acquisition
ct: stoppingToken
);
if (handle is null)
{
_logger.LogWarning("Could not acquire lock for long-running operation");
return;
}
await using (handle)
{
// This operation might take 10 minutes
// The lock will automatically renew every 10 seconds
// Lock lease duration is 20 seconds, so we have a 10-second safety margin
await ProcessLargeDataset(stoppingToken);
}You can acquire multiple locks independently:
var lock1 = await _client.AcquireDistributedLockAsync("resource-a", ct: ct);
var lock2 = await _client.AcquireDistributedLockAsync("resource-b", ct: ct);
if (lock1 is null || lock2 is null)
{
// Release any acquired locks
if (lock1 is not null) await lock1.DisposeAsync();
if (lock2 is not null) await lock2.DisposeAsync();
_logger.LogWarning("Could not acquire all required locks");
return;
}
await using (lock1)
await using (lock2)
{
// Both resources are locked
await ProcessWithMultipleResources(ct);
}
⚠️ Warning: Be careful with multiple locks to avoid deadlocks. Always acquire locks in the same order across your codebase, or use a timeout and release strategy.
- The system attempts to create a lock entry in the NATS KV store
- If the lock already exists, it checks if it's expired
- If expired, it attempts to "steal" the lock using compare-and-swap (CAS) operations
- If not expired, it retries with jittered backoff
- The process continues until the lock is acquired or the timeout is reached
- Once acquired, the lock handle automatically starts a background renewal loop
- The lock is renewed every 10 seconds (default
RenewInterval) - The lock lease duration is 20 seconds (default
LeaseDuration) - This provides a 10-second safety margin in case of network issues
- Locks are automatically released when the
DistributedLockHandleis disposed - The release uses compare-and-swap to ensure you only release your own lock
- If the lock has already been stolen or expired, the release is treated as successful
- Locks have a lease duration (default: 20 seconds)
- If an instance crashes while holding a lock, the lock will expire after the lease duration
- Other instances can then acquire the lock by detecting the expiration and stealing it
The distributed lock system uses the following default configuration (via KvDistributedLockOptions):
- Bucket Name:
"locks"(NATS KV bucket name) - Lease Duration:
20 seconds(how long the lock is valid before expiration) - Renew Interval:
10 seconds(how often to renew the lock) - Acquire Retry Base Delay:
50ms(minimum delay between retry attempts) - Acquire Retry Max Delay:
500ms(maximum delay between retry attempts) - Owner ID:
"{MachineName}:{ProcessId}"(default identifier for the lock owner)
These defaults are suitable for most use cases. The automatic renewal ensures that long-running operations won't lose their locks, while the retry mechanism with jitter helps prevent thundering herd problems.
-
Always use
await using: Always dispose lock handles usingawait usingto ensure proper cleanup:var handle = await _client.AcquireDistributedLockAsync("key", ct: ct); if (handle is null) return; await using (handle) { // Your critical code }
-
Handle null returns: Always check if the lock acquisition returned
nulland handle it appropriately:if (handle is null) { // Another instance has the lock // Decide: retry, skip, or fail return; }
-
Use descriptive lock keys: Use clear, hierarchical names for lock keys:
// Good "cljps.scheduling" "database.migration.v1" "cache.warmup.users" // Bad "lock1" "temp" "x"
-
Set appropriate timeouts: Use longer timeouts for operations that may take time to acquire the lock:
// Quick operation - short timeout var handle = await _client.AcquireDistributedLockAsync( "quick.task", TimeSpan.FromMilliseconds(500), ct: ct ); // Important operation - longer timeout var handle = await _client.AcquireDistributedLockAsync( "critical.migration", TimeSpan.FromSeconds(5), ct: ct );
-
Use owner IDs for debugging: Specify custom owner IDs to make it easier to identify which instance holds a lock:
var handle = await _client.AcquireDistributedLockAsync( "scheduled.task", ownerId: $"{Environment.MachineName}:scheduler-service", ct: ct );
-
Respect cancellation tokens: Always pass cancellation tokens to allow graceful shutdown:
var handle = await _client.AcquireDistributedLockAsync( "task", ct: stoppingToken // Allows service to shut down gracefully );
-
Don't hold locks too long: While locks auto-renew, try to keep critical sections as short as possible to reduce contention.
-
Handle exceptions in critical sections: Wrap your critical code in try-catch to ensure the lock is released even if an exception occurs:
await using (handle) { try { await CriticalOperation(ct); } catch (Exception ex) { _logger.LogError(ex, "Error in critical operation"); // Lock will still be released by dispose } }
- Check if another instance is holding the lock: Another instance might be running and holding the lock
- Check NATS connectivity: Ensure your NATS connection is working properly
- Increase timeout: The default timeout might be too short for your use case
- Check for expired locks: If an instance crashed, wait for the lease duration (20 seconds) before the lock can be acquired
- Check network connectivity: Network issues can prevent lock renewal
- Check NATS server health: Ensure the NATS server is running and accessible
- Review lease duration: The default 20-second lease might be too short for very long operations
- Verify lock key uniqueness: Ensure all instances use the same lock key
- Check lock acquisition logic: Ensure you're checking for
nulland handling it correctly - Review timeout values: Very short timeouts might cause all instances to fail to acquire the lock
- NATS KV - Underlying KV store, OCC patterns, and when to use raw KV vs locks
- Services - Learn about background services where distributed locks are commonly used
- Making API Calls - See how
ICloopsNatsClientis used in other contexts - NATS Client Documentation - Detailed technical documentation on the underlying NATS client