Distributed locks
Package: Ruvio.AspNetCore.DistributedLock on NuGet · Source on GitHub
Distributed mutual exclusion on Ruvio. A lock is one key holding a random owner token, and every step is a single server command that only the owner can win:
| Step | Command |
|---|---|
| acquire | SET key token NX PX lease |
| renew | SET key token IFEQ token PX lease |
| release | DELEX key IFEQ token |
Release and renewal can never touch a lock another owner took after yours expired, which is
the bug a plain DEL has. This needs a Ruvio server with SET … IFEQ and DELEX.
services.AddRuvioDistributedLock(options => options.KeyPrefix = "myapp:lock:");
await using IRuvioLock? held = await locks.TryAcquireAsync( "nightly-report", lease: TimeSpan.FromSeconds(30), wait: TimeSpan.FromSeconds(5));
if (held is null) return; // someone else runs it
await RunReportAsync(held.LockLost); // stops if the lock is lost mid-runTryAcquireAsyncreturns null when the lock stayed taken;AcquireAsyncthrowsTimeoutException.- With
AutoRenew(default) the lease is renewed every third of its length. If a whole lease passes without a successful renewal, or a renewal finds another owner,IsHeldturns false andLockLostis cancelled; pass that token to the guarded work. - Disposing releases the lock; a failed release is swallowed because the lease expires on its own.
RuvioLockOptions.TimeProviderdrives the renewal timer and the acquisition wait. Tests can pass aFakeTimeProviderto run a lease through its life without waiting; the lease itself still expires on the server’s clock.
ASP.NET Core: [RuvioLock] for endpoints
Section titled “ASP.NET Core: [RuvioLock] for endpoints”Why. Some endpoints must never run twice at once for the same thing: charging an order,
numbering an invoice, importing a file, closing a day. A lock or SemaphoreSlim only protects
one instance. With two pods, or a double click that reaches a different pod, both requests run.
[RuvioLock] moves the guard into Ruvio so it holds across every instance, and removes the
acquire / try-finally / renew / map-to-HTTP boilerplate.
builder.Services.AddRuvioDistributedLock(); // needs an IRuvioClient registeredapp.UseRouting();app.UseAuthentication();app.UseRuvioLock(); // after routing and auth
// minimal APIapp.MapPost("/orders/{id}/pay", PayAsync).WithMetadata(new RuvioLockAttribute("order:{id}"));
// controller[RuvioLock("nightly-import", leaseSeconds: 120, WaitMilliseconds = 0)][HttpPost("import")]public Task<IActionResult> Import(CancellationToken ct) => ...; // ct is cancelled if the lock is lost| Behaviour | Detail |
|---|---|
| Name | Fixed ("nightly-import") or template: {id} = route value, then query value; {user} = user name. An unresolved placeholder answers 400. |
| Taken | Waits WaitMilliseconds (default 0), then answers RejectStatusCode (default 409) with Retry-After; your code does not run. |
| Running | The lease is renewed automatically; the lock is released when the response finishes, also when the endpoint throws or the client disconnects. |
| Lock lost | HttpContext.RequestAborted is replaced by a token that also fires when the lock is lost, so EF Core, HttpClient and your code stop instead of running next to the new owner. |
| Server unreachable | Fails closed with 503 instead of running unguarded. |
| Cost | Endpoints without the attribute return the next delegate’s task directly: no allocation. |
Telemetry
Section titled “Telemetry”Meter and ActivitySource Ruvio.AspNetCore.DistributedLock (same shape as the rate-limiting
package): counters ruvio.lock.acquired, ruvio.lock.rejected (contention), ruvio.lock.lost
(unhealthy server link) and ruvio.lock.errors, plus histogram ruvio.lock.wait_duration (ms).
Fixed names are tagged resource; templated names are not, to keep cardinality bounded.
builder.Services.AddOpenTelemetry().WithMetrics(m => m.AddMeter(RuvioLockTelemetry.Name));Limits
Section titled “Limits”- One lock lives on one server. If a failover loses the key, two owners can overlap for up to a lease. Work that must never overlap should also check a version or fencing value on the resource it writes. This package does not issue fencing tokens: handing out an increasing number and taking the lock are two commands, so they cannot be made atomic here.
- Locks are not reentrant. Acquiring a held resource again from the same process waits like any caller.
- Lease times are wall-clock server TTLs; keep the lease well above the longest pause your process can have.