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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Aaravsoft.Distributed.Lease" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Aaravsoft.Distributed.Lease" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Aaravsoft.Distributed.Lease" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Aaravsoft.Distributed.Lease --version 1.0.0
                    
#r "nuget: Aaravsoft.Distributed.Lease, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Aaravsoft.Distributed.Lease@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Aaravsoft.Distributed.Lease&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Aaravsoft.Distributed.Lease&version=1.0.0
                    
Install as a Cake Tool

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 RowVersion on a read-only replica before reporting a successful acquire.
  • Deterministic, caller-driven — no background timers, no volatile, no Interlocked. Renewal happens only when you call TryRenewAsync. 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.SqlClient and Microsoft.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, and TryReleaseAsync each 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:

  1. DELETE any rows whose ExpiresAtUtc <= SYSDATETIMEOFFSET() (opportunistic sweep of abandoned leases).
  2. MERGE ... WITH (UPDLOCK, HOLDLOCK, ROWLOCK):
    • Row absentINSERT a new row. You win.
    • Row present and expiredUPDATE with your identity. You take over.
    • Row present and owned by youUPDATE your own ExpiresAtUtc forward. Idempotent re-acquire (handy for retries after a network hiccup).
    • Row present and owned by someone else → no rows output. You lose. TryAcquireAsync returns false.
  3. OUTPUT returns the winning OwnerInstanceId, the new ExpiresAtUtc, and the server-assigned RowVersion.

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.

  • ConfirmedTryAcquireAsync returns true.
  • Not confirmed → the primary row is released and TryAcquireAsync returns false.

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 TryAcquireAsyncTryRenewAsync 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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