Skip to content

Db context lock

Mirko Da Corte edited this page Sep 15, 2026 · 5 revisions

The db context lock serializes a db context's exclusive works — seeding, migrations and the missing origin references repairs of the admin dashboard — across every application instance connected to the database. It is the cross-instance counterpart of exclusive access, which only binds the flows of one process: two instances of your app starting together seed once, and a migration started on one instance is denied on the others.

You rarely touch it: those works claim it for you. It is the engine-bound instance of the same lease lock primitive your application can use on its own resources — see Resource locks.

What it is

A single lease document per db context, in the collection named by DbContextOptions.DbLockCollectionName (default "_db_lock"), keyed by the context's Identifier (its Identifier option, or the context type name). The lock is exposed as IDbContext.Engine.DbContextLock, an IResourceLock.

Its boundary is that one document, so the lock excludes:

Scope Excluded
The same db context, every application instance on that database Yes — same lease document.
Another db context on the same database No — different key, different document.
The same db context, but instances configured with a different DbLockCollectionName No — different collection.
The application's resource locks, sharing the collection No — their documents are keyed by namespace/resourceId (keep the context Identifier out of that /-separated shape, or it could alias one).

Claiming is one atomic upsert on the server: while a live lease exists the update matches nothing and the insert collides on the document id, so among concurrent claimers — from any process — exactly one wins. Ordinary collection traffic is unaffected: the lock doesn't deny reads or writes to any other flow, on this instance or elsewhere. That denial is exclusive access, and it stays in-process.

The lock collection sits outside the engine's access limitations, being the coordination infrastructure of the works that impose them: the lock stays writable while an exclusive access denies the context's collections, and a dry run writes it for real while its collection writes are simulated.

Claim, lease, and renewal

Three distinct steps, three distinct terms:

  • A claim takes the lock for an owner id and writes an expiration into the document. It survives on its own for the chosen lease duration, with nothing renewing it.
  • Resuming the claim turns it into a lease: an IResourceLockLease that verifies the ownership, extends the expiration, and keeps renewing it in background for as long as the lease object lives. Each resume stamps a fresh lease id, and renewals and releases match owner and lease id together, so a second resume of the same owner invalidates the first lease instead of letting it release the lock under the winner.
  • Disposing the lease releases the lock right away, without waiting for the expiration — guarded by the same owner and lease id, so a lease already taken over releases nothing. A claim never resumed into a lease is released with TryReleaseAsync.

The lease also registers itself on the ambient execution context, so an inner flow finds it instead of claiming again — that's how a migration executed inside a seeding runs under the seeding's lease.

IResourceLockLease.LeaseLostToken is cancelled when the lease can't be assumed alive anymore (see Resource locks). A migration passes it to its steps, so a lost lease cancels them and the operation closes failed rather than writing outside its exclusive window.

When an owner dies

Nothing renews the lease, it expires, and the next claim takes it over — no manual repair, no stuck lock. The lease duration is therefore how long a dead instance blocks the others. Expired documents no claim reclaims are garbage collected by the server-side TTL index — see Resource locks.

The migration operations left open by dead owners are closed on the server by the next migration start: those still New become Cancelled, those Running become Failed. Until then the Admin dashboard doesn't report them as running either — it pairs the open operation with a live lease before saying a migration is in progress, so an orphaned operation leaves the start controls enabled.

Choosing the lease duration

The duration is chosen per operation, defaulting to ResourceLock.DefaultLeaseDuration (10 minutes), and travels in the lease document so that whoever resumes the claim renews on it — the web process claiming a migration start and the background worker executing it are frequently different processes.

It does not have to cover the duration of the work, which keeps the lease renewed while it runs. It has to cover the delay before the background worker picks the job up, and it is what you pay when an instance dies mid-work.

Where you pass it Argument
TryStartMigrationAsync(dryRun, stopAtFirstError, lockLeaseDuration) Lease of the started migration.
SeedIfNeededAsync(lockWaitTimeout, lockLeaseDuration) Lease of this seeding, plus how long it waits for another owner.
SeedDbContexts(lockWaitTimeout, lockLeaseDuration) Forwarded to every context's seeding.
Admin dashboardAdvancedLock lease duration Lease of the migration started from the UI, in minutes.
// a migration expected to sit in the queue a while before a worker takes it
var op = await db.TryStartMigrationAsync(lockLeaseDuration: TimeSpan.FromHours(2));

A duration shorter than ResourceLock.MinLeaseDuration throws ArgumentOutOfRangeException — the renewal interval is a fraction of it. The dashboard control additionally bounds its value to IndexModel.MaxLockLeaseDurationMinutes (24 hours) and requires it positive: the start handler validates it server side and reports a rejected value in the card's feedback line, starting nothing.

Note. Expirations compare instants taken from each instance's own clock. The default duration is generous exactly so that ordinary clock skew between instances is irrelevant; a lease of seconds would not be.

Who claims it

  • Migrations. TryStartMigrationAsync creates the operation and claims the lock with the operation id as owner; a denied claim deletes that operation and returns null. The execution resumes the claim and releases it when the operation closes — an execution that can't resume (the lock was taken over, or released) closes the operation Cancelled without migrating.
  • Dry runs. A dry run claims and holds the lock like any other migration, so it denies other migrations and seedings while it scans — it only skips the in-process exclusive access, since it persists nothing.
  • Seeding. SeedIfNeededAsync claims the lock around its whole flow. While another owner holds it the call waits — see Database seeding.
  • References repairs. TryStartReferencesRepairAsync claims it exactly like a migration start, so a repair, a migration and a seeding never overlap — see Admin dashboard.

Whichever kind claims it, the claim closes the operations left open by dead owners, of every kind: a claim only succeeds when nobody live holds the lock, so any other operation still open is orphaned.

Reading and holding it yourself

Engine.DbContextLock exposes the whole IResourceLock surface:

Member Does
IsLockedAsync() True while a live lease holds the lock. An expired lease locks nothing.
TryAcquireAsync(mode?, leaseDuration?) Claim and renewed lease in one atomic command, for a flow acquiring and working in the same process — see Resource locks. null when a live lease denies.
TryClaimAsync(ownerId, leaseDuration?) Claim the lock atomically. false when another owner holds it.
TryResumeClaimAsync(ownerId) Turn a claim into a renewed lease. null when the claim was released, or taken over by another owner.
TryReleaseAsync(ownerId) Release a claim never resumed into a lease. Owner-guarded, so a lock already taken over stays untouched.
TryGetAmbientLease() The lease an outer section of the current flow resumed or acquired, or null.

Custom maintenance that must run once across instances claims it the same way seeding does, and pairs it with exclusive access to also lock out the flows of this process:

var ownerId = Guid.NewGuid().ToString();
if (!await db.Engine.DbContextLock.TryClaimAsync(ownerId, TimeSpan.FromMinutes(30)))
    return;   // another instance is already doing exclusive work on this context

await using var lease = await db.Engine.DbContextLock.TryResumeClaimAsync(ownerId);
if (lease is null)
    return;

await db.Engine.RunWithExclusiveAccessAsync(async () =>
{
    // ... the maintenance work, observing lease.LeaseLostToken if it runs long ...
});

Warning. Dispose the lease as soon as the work ends — the await using above. Background renewals keep it alive for as long as the lease object lives, so a lease never disposed denies every seeding and migration of that db context, on every instance, indefinitely.

Read-only contexts

Claiming writes the lock collection, so a read-only context has no lock at all: reading Engine.DbContextLock throws InvalidOperationException. Seeding and migrations of that database belong to the application that owns it, and are already denied on a read-only context — so nothing in Scrinium reaches for the lock there.


Next: Resource locks for the same primitive on your application's resources, Exclusive access for the in-process side of the same picture, Migrations and Database seeding for the works the lock serializes.

Clone this wiki locally