Skip to content

Resource locks

Mirko Da Corte edited this page Aug 25, 2026 · 3 revisions

Resource locks coordinate your application's work on its own resources across every application instance connected to the database, with the same server side lease lock Scrinium uses for its db context lock: an atomic acquisition, a lease renewed in background with a lost lease signal, and holders that die unblocking the resource on their own.

Acquiring a lock

One call on the db context:

public async Task<bool> TryProcessOrderAsync(IDbContext dbContext, string orderId)
{
    await using var lease = await dbContext.TryAcquireResourceLockAsync("orders", orderId);
    if (lease is null)
        return false;   // another holder owns the resource

    // ... the work, observing lease.LeaseLostToken if it runs long ...
    return true;
}

TryAcquireResourceLockAsync(resourceNamespace, resourceId, mode?, leaseDuration?) acquires atomically on the server: an exclusive acquisition is decided by one single write, among concurrent acquirers from any process; a shared one runs the same write, retried a bounded number of times on the rare first-insert collision (see the note under Shared holders). It returns the active lease, or null when a live lease denies the acquisition: try semantics, like the migration starts — you decide whether to fail, retry, or skip.

Mode Admits Denied by
ResourceLockMode.Exclusive (default) A single holder. Any live lease, exclusive or shared.
ResourceLockMode.Shared Any number of holders, each with its own lease. A live exclusive lease.
public async Task ReadCatalogAsync(IDbContext dbContext, string catalogId)
{
    await using var lease = await dbContext.TryAcquireResourceLockAsync(
        "catalogs", catalogId, ResourceLockMode.Shared);
    if (lease is null)
        return;   // an exclusive holder owns the catalog

    // ... read work, coexisting with the other shared holders ...
}

An expired lease never denies: the acquisition takes it over. IsResourceLockedAsync(resourceNamespace, resourceId) reports whether a live lease — exclusive or shared — holds a resource right now.

A read-only context denies resource locks entirely (InvalidOperationException): acquiring writes the lock collection of a database owned by another application. Coordinate through a db context your application can write.

Namespaces and identity

A lock is identified by a resource id inside a namespace you choose — one namespace per kind of lock, so different kinds never collide on the same id. Each locked resource is one lease document in the same collection as the db context lock (DbContextOptions.DbLockCollectionName, default "_db_lock"), with the plain string id namespace/resourceId. The namespace must not contain / — the separator — or the call throws ArgumentException; the resource id, being the last segment, is free.

Scope Excluded
The same namespace and resource id, every application instance on that database Yes — same lease document.
Another resource id, or another namespace No — different document.
The db context lock No — its document is keyed by the context's bare Identifier (an identifier containing / could alias an application lock: keep it out of that shape).
Instances configured with a different DbLockCollectionName No — different collection.

There is nothing to set up: no model map, no repository, no index builder. The collection sits outside the engine's access limitations, being coordination infrastructure — see Db context lock.

The lease

The returned IResourceLockLease is the same lease object of the db context lock:

  • it renews itself in background, at a fifth of the lease duration, for as long as the lease object lives — the work never outlives its lock silently;
  • LeaseLostToken is cancelled when the lease can't be assumed alive anymore (the lock was taken over, or renewals kept failing for the whole lease duration): long running work should observe it and abort;
  • disposing it releases the holder right away — for a shared lease, removing its entry and deleting the lock document when it was the last one.

The lease duration (default ResourceLock.DefaultLeaseDuration, 10 minutes; at least ResourceLock.MinLeaseDuration) does not have to cover the work, which the renewals cover: it is how long the resource stays locked if the process dies before releasing.

public async Task RebuildReportAsync(IDbContext dbContext, string reportId)
{
    //a work whose process could die mid-way: the resource unlocks after 30 minutes
    await using var lease = await dbContext.TryAcquireResourceLockAsync(
        "reports", reportId, leaseDuration: TimeSpan.FromMinutes(30));
    if (lease is null)
        return;

    // ...
}

Warning. Dispose the lease as soon as the work ends — the await using above. The background renewals keep it alive for as long as the lease object lives, so a lease never disposed holds the resource against every instance until this process exits.

Shared holders

Shared mode is built on per-holder leases: the lock document carries one entry per holder, each with its own expiration, renewed by its own holder. So:

  • a holder that dies expires alone — the others keep working, and once no live lease remains the resource opens to exclusive acquisitions;
  • expired entries left by dead holders are dropped by the next acquisition or release of the same resource, in the same write;
  • the release of the last holder deletes the document, opening the resource right away.

Note. Shared acquisitions racing on the very first lease of a resource can collide on its insert; the loser retries onto the created document a bounded number of times. Under that extreme contention an acquisition can report null without an exclusive holder existing — like any denial of a try API, retry or fail as your flow requires.

When a holder dies

Nothing renews its lease, the lease expires within its duration, and the next acquisition of the resource takes the document over — no manual repair, no cleanup task. The abandoned documents that no acquisition ever reclaims are garbage collected by the server: the first claim through the engine ensures a TTL index on the lock collection (ResourceLock.AbandonedDocumentsTtlIndexName; a creation failure is logged as a warning without denying the claim), deleting expired documents ResourceLock.AbandonedDocumentRetention (1 day) after their expiration, so the collection stays bounded whatever the number of resources you lock.

The lock object

The facades above are the everyday surface. The engine also exposes the lock as an object — Engine.GetResourceLock(resourceNamespace, resourceId), an IResourceLock, the same interface as the db context lock — for holding it across repeated operations on one resource, or for the claim/resume flow that lets one process reserve a lock and another one work under it:

public async Task HoldRepeatedOperationsAsync(IDbContext dbContext, string orderId)
{
    IResourceLock orderLock = dbContext.Engine.GetResourceLock("orders", orderId);

    if (await orderLock.IsLockedAsync())
        return;

    await using var lease = await orderLock.TryAcquireAsync();
    if (lease is null)
        return;

    // ...
}

TryAcquireAsync(mode?, leaseDuration?) is the one-call acquisition behind the facade; TryClaimAsync / TryResumeClaimAsync / TryReleaseAsync and TryGetAmbientLease are the claim-then-resume machinery, described on Db context lock where Scrinium itself uses them.


Next: Db context lock for the lock Scrinium claims on its own works, Exclusive access for the in-process denial the locks don't do.

Clone this wiki locally