Aaravsoft.Distributed.Lease
1.0.0
dotnet add package Aaravsoft.Distributed.Lease --version 1.0.0
NuGet\Install-Package Aaravsoft.Distributed.Lease -Version 1.0.0
<PackageReference Include="Aaravsoft.Distributed.Lease" Version="1.0.0" />
<PackageVersion Include="Aaravsoft.Distributed.Lease" Version="1.0.0" />
<PackageReference Include="Aaravsoft.Distributed.Lease" />
paket add Aaravsoft.Distributed.Lease --version 1.0.0
#r "nuget: Aaravsoft.Distributed.Lease, 1.0.0"
#:package Aaravsoft.Distributed.Lease@1.0.0
#addin nuget:?package=Aaravsoft.Distributed.Lease&version=1.0.0
#tool nuget:?package=Aaravsoft.Distributed.Lease&version=1.0.0
Aaravsoft.Distributed.Lease
A durable, SQL Server–backed distributed lease (leader-election / mutual-exclusion) library for .NET.
Use it when you have multiple instances of the same service — background workers, cron-style jobs, replicas of a hosted service — and you need exactly one of them to do a piece of work at a time, even across process restarts, container recycles, connection drops, and SQL Server failovers.
What problem does it solve?
Common approaches to "run this only on one instance" have real drawbacks:
| Approach | Problem |
|---|---|
sp_getapplock |
Tied to a SQL session. If the connection drops, the lock disappears immediately — even mid-work. Doesn't survive failover. |
Redis SETNX / Redlock |
Adds a new infrastructure dependency and its own failure modes. |
| "Just deploy one instance" | No HA, no rolling upgrades without downtime. |
| Hand-rolled row locking | Easy to get wrong (stolen locks, orphaned rows, clock skew, replica lag). |
Aaravsoft.Distributed.Lease gives you a row-based lease in your existing SQL Server:
- One row per named lease. The row is the lock.
- Ownership is proven by a caller-supplied
OwnerInstanceId— a stolen or stale caller cannot renew or release someone else's lease. - Every row carries an
ExpiresAtUtc. If the holder crashes, the next caller reclaims it after expiry — no manual cleanup, no watchdog service. - Optional read-replica confirmation ensures your acquisition is durably replicated before you start working, so you don't do the job twice after an async-replication failover.
If you already run SQL Server / Azure SQL, you can adopt this without adding any new infrastructure.
Features
- Durable — lease survives connection drops, app restarts, container recycles, and SQL failovers.
- Ownership-safe — renew and release are filtered by
OwnerInstanceId; a foreign caller cannot steal or delete an active lease. - Self-healing — expired rows are swept opportunistically on every acquisition. No background GC job required.
- Atomic acquisition — a single
MERGE ... WITH (UPDLOCK, HOLDLOCK, ROWLOCK)handles insert, takeover, and re-entrant refresh in one round-trip. - Optional replica confirmation — for Availability Group / geo-replicated Azure SQL setups, the library can verify your acquired row's
RowVersionon a read-only replica before reporting a successful acquire. - Deterministic, caller-driven — no background timers, no
volatile, noInterlocked. Renewal happens only when you callTryRenewAsync. Easy to reason about, easy to test. - Idempotent schema provisioning — one call and the table exists (or is left alone).
- Zero external dependencies beyond ADO.NET — only
Microsoft.Data.SqlClientandMicrosoft.Extensions.Logging.Abstractions.
Requirements
- .NET 10 (or newer)
- SQL Server 2016+ / Azure SQL / Azure SQL Managed Instance
- CREATE TABLE permission on one schema (once, at deployment time)
Installation
dotnet add package Aaravsoft.Distributed.Lease
Getting started
1. Provision the lease table (once per database)
using Aaravsoft.Distributed.Lease.Providers.SqlServer;
await SqlLeaseSchema.EnsureCreatedAsync(
connectionString: connString,
schema: "lease",
table: "Leases");
This is idempotent — safe to call on every app start. If you'd rather run DDL out-of-band, use SqlLeaseSchema.GetCreateScript(...) to get the SQL text.
2. Acquire a lease and do work
using Aaravsoft.Distributed.Lease.Abstractions;
using Aaravsoft.Distributed.Lease.Providers.SqlServer;
ILease lease = new SqlLease(
leaseName: "invoice-batch-job",
ownerInstanceId: Guid.NewGuid().ToString("N"),
connectionString: connString,
options: new SqlLeaseOptions
{
LeaseDuration = TimeSpan.FromSeconds(30),
});
if (!await lease.TryAcquireAsync(ct))
{
// Another instance owns the lease. Retry later.
return;
}
try
{
// Critical section — exactly one instance across the cluster is here.
await DoWorkAsync(ct);
}
finally
{
await lease.TryReleaseAsync(ct);
}
TryReleaseAsync deletes the row, filtered by OwnerInstanceId, so it will never delete a row belonging to someone else. If the process crashes before it runs, the row will be swept on the next acquire attempt after ExpiresAtUtc passes.
3. Renew during long-running work
The lease is valid for LeaseDuration. For work that lasts longer than that, renew periodically:
if (!await lease.TryAcquireAsync(ct))
throw new InvalidOperationException("Could not acquire lease.");
try
{
while (!ct.IsCancellationRequested)
{
await ProcessNextBatchAsync(ct);
if (!await lease.TryRenewAsync(ct))
throw new InvalidOperationException("Lease was lost; another owner has taken over.");
}
}
finally
{
await lease.TryReleaseAsync(ct);
}
TryRenewAsync returns false only if the row is gone, has expired, or is owned by someone else — you should stop immediately in that case.
4. Integration with IHostedService / BackgroundService
Typical worker pattern:
public sealed class MyWorker(ILease lease, IMyJob job, ILogger<MyWorker> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
if (!await lease.TryAcquireAsync(ct))
{
await Task.Delay(TimeSpan.FromSeconds(5), ct);
continue; // stand by
}
try { await job.RunAsync(ct); }
catch (Exception ex) { log.LogError(ex, "Job failed"); }
finally { await lease.TryReleaseAsync(ct); }
}
}
}
DI registration (register as transient/scoped — each SqlLease instance represents one owner of one named lease):
services.AddTransient<ILease>(sp => new SqlLease(
leaseName: "my-worker",
ownerInstanceId: Environment.MachineName + ":" + Environment.ProcessId,
connectionString: cfg.GetConnectionString("Sql")!,
options: new SqlLeaseOptions { LeaseDuration = TimeSpan.FromSeconds(30) },
logger: sp.GetRequiredService<ILogger<SqlLease>>()));
API surface
namespace Aaravsoft.Distributed.Lease.Abstractions;
public interface ILease
{
string OwnerInstanceId { get; }
string LeaseName { get; }
DateTimeOffset? ExpiresAtUtc { get; }
Task<bool> TryAcquireAsync(CancellationToken cancellationToken = default);
Task<bool> TryRenewAsync(CancellationToken cancellationToken = default);
Task<bool> TryReleaseAsync(CancellationToken cancellationToken = default);
}
That's the whole public API. SqlLease is the SQL Server implementation of ILease. Depending on ILease in your code keeps you provider-agnostic.
ExpiresAtUtc is updated on every successful TryAcquireAsync / TryRenewAsync and reflects the server-side expiry of the row you currently own. It is null before the first successful acquire.
Return semantics
| Method | Returns | Meaning |
|---|---|---|
TryAcquireAsync |
true |
You are the current owner. ExpiresAtUtc is set. |
TryAcquireAsync |
false |
Another owner currently holds an active lease, or the replica did not confirm the write in time. |
TryRenewAsync |
true |
Lease was extended. ExpiresAtUtc is advanced. |
TryRenewAsync |
false |
Row is gone, expired, or is owned by someone else. Stop working. |
TryReleaseAsync |
true |
The row you owned was deleted. |
TryReleaseAsync |
false |
Nothing to release (already expired/taken over). Safe to ignore. |
Configuration — SqlLeaseOptions
| Property | Default | Description |
|---|---|---|
SchemaName |
lease |
Schema hosting the lease table. |
TableName |
Leases |
Table name. |
LeaseDuration |
30 s | How long a lease is valid before it must be renewed. |
Metadata |
null |
Optional free-form string persisted on the row (useful for diagnostics). |
ReadReplicaConnectionString |
null |
When set, acquisitions are confirmed on this read-only replica before TryAcquireAsync returns true. |
ReplicaConfirmationTimeout |
5 s | Max wait for the replica to observe the acquired RowVersion. On timeout the primary row is released and TryAcquireAsync returns false. |
ReplicaPollInterval |
250 ms | Poll cadence while waiting for replica confirmation. |
Choosing LeaseDuration
- Long enough to comfortably outlast one "unit of work" or one heartbeat cycle.
- Short enough that if the holder crashes, standby instances don't wait too long to take over.
- Typical values: 15–60 seconds.
OwnerInstanceId guidance
OwnerInstanceId is caller-supplied. Good choices:
- Per-process GUID:
Guid.NewGuid().ToString("N")— simplest, always unique. - Host + process:
$"{Environment.MachineName}:{Environment.ProcessId}"— friendlier in logs. - Anything else that is stable for the lifetime of the caller and unique across contenders.
Do not reuse the same OwnerInstanceId across independent instances — that would let two instances renew the same lease.
Schema
[lease].[Leases]
LeaseName NVARCHAR(200) PK
OwnerInstanceId NVARCHAR(128) NOT NULL
AcquiredAtUtc DATETIMEOFFSET(7) NOT NULL
RenewedAtUtc DATETIMEOFFSET(7) NOT NULL
ExpiresAtUtc DATETIMEOFFSET(7) NOT NULL
Metadata NVARCHAR(1024) NULL
RowVersion ROWVERSION NOT NULL -- server-managed
Indexes: PK_Leases (LeaseName), IX_Leases_ExpiresAtUtc (ExpiresAtUtc).
You can customize the schema and table name via SqlLeaseOptions — the same table can host any number of named leases (LeaseName is the discriminator).
How it works
The library does not spin up any background timers.
TryAcquireAsync,TryRenewAsync, andTryReleaseAsynceach perform exactly one SQL round-trip when you call them. This keeps behavior deterministic and easy to test.
Acquisition
A single batch runs on the primary:
DELETEany rows whoseExpiresAtUtc <= SYSDATETIMEOFFSET()(opportunistic sweep of abandoned leases).MERGE ... WITH (UPDLOCK, HOLDLOCK, ROWLOCK):- Row absent →
INSERTa new row. You win. - Row present and expired →
UPDATEwith your identity. You take over. - Row present and owned by you →
UPDATEyour ownExpiresAtUtcforward. Idempotent re-acquire (handy for retries after a network hiccup). - Row present and owned by someone else → no rows output. You lose.
TryAcquireAsyncreturnsfalse.
- Row absent →
OUTPUTreturns the winningOwnerInstanceId, the newExpiresAtUtc, and the server-assignedRowVersion.
The lock hints make the read-and-write atomic, so two callers competing for the same expired lease cannot both win.
Optional read-replica confirmation
If you set ReadReplicaConnectionString, after acquisition on the primary the library polls the replica (WITH (READUNCOMMITTED)) until it observes a RowVersion byte-equal to the one just written on the primary — or ReplicaConfirmationTimeout elapses.
- Confirmed →
TryAcquireAsyncreturnstrue. - Not confirmed → the primary row is released and
TryAcquireAsyncreturnsfalse.
This protects against a subtle problem in async-replication topologies (Azure SQL geo-replication, AlwaysOn secondaries):
You acquire on the primary → start work → a failover happens before the change replicates → another instance reads stale state from the new primary and also acquires → both do the work.
With replica confirmation TryAcquireAsync returns false in that window and lets a subsequent poll try again.
RowVersion is used because it is server-generated, monotonically increasing, and byte-comparable — the strongest available guarantee that the replica has observed the exact write that acquired the lease.
Renewal
TryRenewAsync runs a single filtered UPDATE:
UPDATE ... SET ExpiresAtUtc = now + LeaseDuration, RenewedAtUtc = now
WHERE LeaseName = @name AND OwnerInstanceId = @owner AND ExpiresAtUtc > now
It only succeeds if the row still exists, still belongs to you, and hasn't expired. Otherwise it returns false.
Release
TryReleaseAsync runs:
DELETE ... WHERE LeaseName = @name AND OwnerInstanceId = @owner
The OwnerInstanceId filter means a stale caller cannot delete a row that has since been reclaimed by someone else. Always call it from a finally block after a successful acquire.
Operational considerations
Clock skew. All time comparisons use SYSDATETIMEOFFSET() on the SQL server, not on the client. Client clock drift cannot cause leases to expire early or late.
Failover. Because the lease is a row (not a session), an in-flight SQL failover doesn't lose your lease. Your next renewal reconnects and continues.
Connection pooling. Each call opens and closes its own SqlConnection. Pooling makes this cheap.
Long-running work. Call TryRenewAsync at an interval well under LeaseDuration (e.g. renew every 10 s with a 30 s lease). If a renew returns false, stop — someone else owns the lease now.
Multiple leases in one table. Yes. LeaseName is the primary key; use as many distinct names as you like.
Diagnostics. Pass an ILogger<SqlLease> to the constructor. Acquisition failures, replica-confirmation timeouts, and release faults are logged.
FAQ
Is this a fair lock?
No. There is no queue. Contenders poll and whoever wins the next MERGE gets it. If you need fair ordering, add it above this library.
Can I use it as a leader election primitive?
Yes. Have every instance loop TryAcquireAsync → TryRenewAsync on a schedule. The current owner is the current leader.
Does it need sysadmin or elevated permissions?
No. It only needs SELECT, INSERT, UPDATE, DELETE on the lease table, plus CREATE TABLE in the target schema if you call EnsureCreatedAsync.
Does the library protect me from writing to shared data if I've lost the lease?
No — nothing can. Check TryRenewAsync between units of work and abort if it returns false.
Can I use this with EF Core / Dapper? Yes. The library uses ADO.NET directly for its own operations, but it doesn't touch your application's data access at all.
Can I use it against non-SQL-Server databases?
Not today. The ILease abstraction is provider-agnostic, but only the SQL Server provider ships in this package.
License
See LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Data.SqlClient (>= 5.2.2)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 102 | 8/29/2026 |