{"id":49279461,"url":"https://github.com/mnbuhl/efcore-locking","last_synced_at":"2026-04-25T18:01:13.203Z","repository":{"id":352404669,"uuid":"1214444204","full_name":"mnbuhl/efcore-locking","owner":"mnbuhl","description":" Pessimistic row-level locking and distributed advisory locks for EF Core — ForUpdate() / ForShare()","archived":false,"fork":false,"pushed_at":"2026-04-19T12:37:41.000Z","size":282,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-19T13:25:17.366Z","etag":null,"topics":["csharp","distributed-lock","dotnet","efcore","entity-framework-core","mysql","pessimistic-locking","postgresql","sqlserver"],"latest_commit_sha":null,"homepage":"https://www.nuget.org/packages/EntityFrameworkCore.Locking","language":"C#","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mnbuhl.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-18T15:31:19.000Z","updated_at":"2026-04-19T12:35:02.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/mnbuhl/efcore-locking","commit_stats":null,"previous_names":["mnbuhl/efcore-locking"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/mnbuhl/efcore-locking","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mnbuhl%2Fefcore-locking","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mnbuhl%2Fefcore-locking/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mnbuhl%2Fefcore-locking/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mnbuhl%2Fefcore-locking/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mnbuhl","download_url":"https://codeload.github.com/mnbuhl/efcore-locking/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mnbuhl%2Fefcore-locking/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32271243,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-25T09:15:33.318Z","status":"ssl_error","status_checked_at":"2026-04-25T09:15:31.997Z","response_time":59,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["csharp","distributed-lock","dotnet","efcore","entity-framework-core","mysql","pessimistic-locking","postgresql","sqlserver"],"created_at":"2026-04-25T18:01:12.621Z","updated_at":"2026-04-25T18:01:13.197Z","avatar_url":"https://github.com/mnbuhl.png","language":"C#","funding_links":[],"categories":[],"sub_categories":[],"readme":"# EntityFrameworkCore.Locking\n\nPessimistic locking for EF Core. Supports PostgreSQL, MySQL, and SQL Server.\n\n- **Row-level locks** — `ForUpdate()` / `ForShare()` LINQ extension methods scoped to a transaction\n- **Distributed locks** — `AcquireDistributedLockAsync()` session-scoped advisory locks, no transaction required\n\n## Installation\n\n```\ndotnet add package EntityFrameworkCore.Locking.PostgreSQL\ndotnet add package EntityFrameworkCore.Locking.MySql\ndotnet add package EntityFrameworkCore.Locking.SqlServer\n```\n\n## Setup\n\nCall `.UseLocking()` after your provider's `Use*()` call:\n\n```csharp\n// PostgreSQL\nservices.AddDbContext\u003cAppDbContext\u003e(o =\u003e\n    o.UseNpgsql(connectionString)\n     .UseLocking());\n\n// MySQL\nservices.AddDbContext\u003cAppDbContext\u003e(o =\u003e\n    o.UseMySql(connectionString, serverVersion)\n     .UseLocking());\n\n// SQL Server\nservices.AddDbContext\u003cAppDbContext\u003e(o =\u003e\n    o.UseSqlServer(connectionString)\n     .UseLocking());\n```\n\n## Usage\n\nAll locking queries **require an active transaction**.\n\n```csharp\nawait using var tx = await ctx.Database.BeginTransactionAsync();\n\n// Basic exclusive lock (FOR UPDATE / WITH (UPDLOCK, HOLDLOCK, ROWLOCK))\nvar product = await ctx.Products\n    .Where(p =\u003e p.Id == id)\n    .ForUpdate()\n    .FirstOrDefaultAsync();\n\n// Skip rows already locked by another transaction\nvar available = await ctx.Products\n    .Where(p =\u003e p.Status == \"pending\")\n    .ForUpdate(LockBehavior.SkipLocked)\n    .ToListAsync();\n\n// Fail immediately if lock cannot be acquired\nvar row = await ctx.Products\n    .Where(p =\u003e p.Id == id)\n    .ForUpdate(LockBehavior.NoWait)\n    .FirstOrDefaultAsync();\n\n// Wait up to 500ms for the lock\nvar row = await ctx.Products\n    .Where(p =\u003e p.Id == id)\n    .ForUpdate(LockBehavior.Wait, TimeSpan.FromMilliseconds(500))\n    .FirstOrDefaultAsync();\n\n// Shared lock (PostgreSQL and MySQL only)\nvar row = await ctx.Products\n    .Where(p =\u003e p.Id == id)\n    .ForShare()\n    .FirstOrDefaultAsync();\n\nawait tx.CommitAsync();\n```\n\n### PostgreSQL-only: ForNoKeyUpdate and ForKeyShare\n\nThese modes are available when using the `EntityFrameworkCore.Locking.PostgreSQL` package:\n\n```csharp\n// FOR NO KEY UPDATE — blocks writers but allows FOR KEY SHARE (FK lookups)\nvar row = await ctx.Products\n    .Where(p =\u003e p.Id == id)\n    .ForNoKeyUpdate()\n    .FirstOrDefaultAsync();\n\n// FOR KEY SHARE — minimal shared lock, only blocks FOR UPDATE\n// Useful for FK-referencing queries that should not block non-key updates\nvar row = await ctx.Products\n    .Where(p =\u003e p.Id == id)\n    .ForKeyShare()\n    .FirstOrDefaultAsync();\n```\n\n### Include with locking (PostgreSQL)\n\nPostgreSQL automatically scopes the lock to the root table when a collection `Include` is present (emits `FOR UPDATE OF \"t\"`), so you can use `Include` directly without `AsSplitQuery()`:\n\n```csharp\n// Works — FOR UPDATE OF \"p\" is emitted automatically\nvar product = await ctx.Products\n    .Include(p =\u003e p.OrderLines)\n    .Where(p =\u003e p.Id == id)\n    .ForUpdate()\n    .FirstOrDefaultAsync();\n```\n\n### Queue processing pattern\n\nA common use of `ForUpdate(LockBehavior.SkipLocked)` is a worker queue where multiple consumers race to claim items:\n\n```csharp\nawait using var tx = await ctx.Database.BeginTransactionAsync();\n\nvar item = await ctx.Jobs\n    .Where(j =\u003e j.Status == \"pending\")\n    .OrderBy(j =\u003e j.CreatedAt)\n    .ForUpdate(LockBehavior.SkipLocked)\n    .FirstOrDefaultAsync();\n\nif (item is null)\n    return; // all items claimed by other workers\n\nitem.Status = \"processing\";\nawait ctx.SaveChangesAsync();\nawait tx.CommitAsync();\n```\n\n## Distributed locks\n\nDistributed (advisory) locks let you coordinate across processes without tying the lock to a database row or transaction. They are session-scoped — the lock is held until you dispose the handle, or until the connection drops.\n\nNo transaction is required.\n\n```csharp\n// Acquire — blocks until available (optional timeout)\nawait using var handle = await ctx.Database.AcquireDistributedLockAsync(\"invoice:generate\");\n// ... critical section ...\n// lock released automatically on dispose\n\n// With a timeout — throws LockTimeoutException if not acquired within 5 s\nawait using var handle = await ctx.Database.AcquireDistributedLockAsync(\n    \"report:daily\", TimeSpan.FromSeconds(5));\n\n// With cancellation token\nawait using var handle = await ctx.Database.AcquireDistributedLockAsync(\n    \"report:daily\", timeout: null, cancellationToken: ct);\n\n// TryAcquire — returns null immediately if already held\nvar handle = await ctx.Database.TryAcquireDistributedLockAsync(\"invoice:generate\");\nif (handle is null)\n    return Results.Conflict(\"Another process is generating the invoice.\");\nawait using (handle) { /* critical section */ }\n\n// Synchronous variants are also available\nusing var handle = ctx.Database.AcquireDistributedLock(\"report:daily\");\nvar handle = ctx.Database.TryAcquireDistributedLock(\"report:daily\");\n\n// Check support at runtime\nif (ctx.Database.SupportsDistributedLocks()) { ... }\n```\n\n### Lock keys\n\nKeys are plain strings, up to **255 characters**. The library handles provider-specific encoding internally:\n\n- **PostgreSQL** — hashed to a `bigint` via XxHash32 with a namespace prefix (`\"EFLK\"`); the hash is computed in-process so no extra round-trip is needed.\n- **MySQL** — passed as-is for keys ≤ 64 UTF-8 bytes; longer keys are SHA-256 hashed to `lock:\u003chex58\u003e` (64 chars). The `lock:` prefix is reserved.\n- **SQL Server** — passed as-is (max 255 chars, enforced upstream).\n\n### Provider-specific behavior\n\n| Feature | PostgreSQL | MySQL | SQL Server |\n|---------|-----------|-------|-----------|\n| Native primitive | `pg_advisory_lock` | `GET_LOCK` | `sp_getapplock @LockOwner='Session'` |\n| Timeout | `SET LOCAL lock_timeout` (ms) | `GET_LOCK(@key, seconds)` — rounded up to 1 s | `@LockTimeout` ms |\n| Cancellation | Driver-level (best-effort) | `KILL QUERY` side-channel | Attention signal |\n\n**MySQL timeout precision:** `GET_LOCK` timeout is in whole seconds. Sub-second timeouts are rounded up to 1 second.\n\n**Cancellation caveat:** advisory lock SQL is a blocking database call. Cancellation sends a cancel signal to the driver; if the driver does not honor it before the timeout fires, the call completes via timeout. Always combine a `timeout` with the `CancellationToken` for bounded waits.\n\n### Exception handling\n\n```csharp\ntry\n{\n    await using var handle = await ctx.Database.AcquireDistributedLockAsync(\n        \"report:daily\", TimeSpan.FromSeconds(5));\n}\ncatch (LockTimeoutException)\n{\n    // Not acquired within the timeout\n}\ncatch (LockAlreadyHeldException ex)\n{\n    // Same DbContext + connection attempted to acquire the same key twice\n    // ex.Key contains the key name\n}\ncatch (LockingConfigurationException)\n{\n    // Provider does not support distributed locks, or UseLocking() was not called\n}\n```\n\n`LockAlreadyHeldException` is thrown synchronously before any database call when the same `(DbContext, connection, key)` triple is already registered. Acquiring the same key from two **different** `DbContext` instances on different connections will block (or return `null` for `TryAcquire`) as expected.\n\n## Lock modes and behaviors\n\n| Method | Generated SQL |\n|--------|--------------|\n| `ForUpdate()` | `FOR UPDATE` / `WITH (UPDLOCK, HOLDLOCK, ROWLOCK)` |\n| `ForUpdate(LockBehavior.NoWait)` | `FOR UPDATE NOWAIT` / `SET LOCK_TIMEOUT 0` |\n| `ForUpdate(LockBehavior.SkipLocked)` | `FOR UPDATE SKIP LOCKED` (PG/MySQL) / `WITH (UPDLOCK, ROWLOCK, READPAST)` (SQL Server) |\n| `ForUpdate(LockBehavior.Wait, timeout)` | `SET LOCAL lock_timeout = '500ms'` (PG) / `SET SESSION innodb_lock_wait_timeout` (MySQL) / `SET LOCK_TIMEOUT 500` (SQL Server) |\n| `ForShare()` | `FOR SHARE` (PostgreSQL/MySQL only) |\n| `ForNoKeyUpdate()` | `FOR NO KEY UPDATE` (PostgreSQL only) |\n| `ForKeyShare()` | `FOR KEY SHARE` (PostgreSQL only) |\n\n## Exception handling\n\nLock failures throw typed exceptions from `EntityFrameworkCore.Locking.Exceptions`:\n\n```csharp\ntry\n{\n    var row = await ctx.Products\n        .Where(p =\u003e p.Id == id)\n        .ForUpdate(LockBehavior.NoWait)\n        .FirstOrDefaultAsync();\n}\ncatch (LockTimeoutException ex)\n{\n    // Lock could not be acquired (NOWAIT or timeout exceeded)\n}\ncatch (DeadlockException ex)\n{\n    // Deadlock detected — retry the transaction\n}\ncatch (LockingConfigurationException ex)\n{\n    // Programmer error: missing transaction, unsupported query shape,\n    // or unsupported lock mode for this provider\n}\n```\n\n**Exception hierarchy:**\n- `LockingException` (base)\n  - `LockAcquisitionFailedException`\n    - `LockTimeoutException` — timeout or NOWAIT failure\n    - `DeadlockException` — deadlock victim\n    - `LockAlreadyHeldException` — same key acquired twice on the same connection (distributed locks)\n  - `LockingConfigurationException` — programmer error (missing transaction, unsupported query shape, provider not configured)\n\n## Provider limitations\n\n| Feature | PostgreSQL | MySQL | SQL Server |\n|---------|-----------|-------|-----------|\n| `ForUpdate` | ✓ | ✓ | ✓ |\n| `ForShare` | ✓ | ✓ | ✗ |\n| `ForNoKeyUpdate` | ✓ | ✗ | ✗ |\n| `ForKeyShare` | ✓ | ✗ | ✗ |\n| `SkipLocked` | ✓ | ✓ | ✓ (via `READPAST`) |\n| `NoWait` | ✓ | ✓ | ✓ |\n| Wait with timeout | ✓ (ms) | ✓ (ceil to 1s) | ✓ (ms) |\n\n`ForNoKeyUpdate` and `ForKeyShare` are PostgreSQL-only extension methods available when the `EntityFrameworkCore.Locking.PostgreSQL` package is installed. Using `ForShare` on SQL Server throws `LockingConfigurationException`.\n\n**SQL Server `SkipLocked` limitation:** SQL Server uses `WITH (UPDLOCK, ROWLOCK, READPAST)` instead of `SKIP LOCKED`. `READPAST` only skips rows held under row-level or page-level locks — rows under a table-level lock are blocked rather than skipped. For typical queue-processing workloads this behaves identically to `SKIP LOCKED` on PostgreSQL/MySQL.\n\n**MySQL timeout precision:** MySQL's `innodb_lock_wait_timeout` is in whole seconds. Sub-second timeouts are rounded up to 1 second.\n\n## Unsupported query shapes\n\n`UNION`, `EXCEPT`, `INTERSECT` with locking throw `LockingConfigurationException` at query execution time. Use per-query locks on individual queries before combining results.\n\n`AsSplitQuery()` combined with locking throws `LockingConfigurationException` — use regular `Include()` instead (on PostgreSQL, `FOR UPDATE OF` is emitted automatically to handle outer joins).\n\n## Supported database versions\n\n| Database | Minimum version | Notes |\n|----------|----------------|-------|\n| PostgreSQL | **14** | Default minimum for Npgsql 8.x. PG 12+ works if you call `.SetPostgresVersion(12, 0)` in `UseNpgsql`. All locking features (`FOR NO KEY UPDATE`, `FOR KEY SHARE`, `SKIP LOCKED`, `NOWAIT`) have been available since PG 9.3/9.5. |\n| MySQL | **8.0** | `FOR SHARE`, `SKIP LOCKED`, and `NOWAIT` were introduced in MySQL 8.0.1. MySQL 5.7 is not supported. |\n| MariaDB | **10.6** | `SKIP LOCKED` requires 10.6+. `NOWAIT` requires 10.3+. `ForShare` emits `LOCK IN SHARE MODE` (MariaDB does not support the `FOR SHARE` syntax). |\n| SQL Server | **2019** | All hints (`UPDLOCK`, `HOLDLOCK`, `ROWLOCK`, `READPAST`) and `SET LOCK_TIMEOUT` are available on all supported versions. Azure SQL Database is also supported. |\n\n## Target frameworks\n\n`net8.0`, `net9.0`, `net10.0`\n\n## Benchmarks\n\nThe `benchmarks/` directory contains BenchmarkDotNet benchmarks measuring the overhead added by the locking SQL generator and interceptor across all three providers.\n\n```bash\ndotnet run -c Release --project benchmarks/EntityFrameworkCore.Locking.Benchmarks -- --version=\u003cx.y.z\u003e\n```\n\nThe `--version` argument is required and labels the results folder (`benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v\u003cx.y.z\u003e/`). Additional BenchmarkDotNet arguments (e.g. `--filter '*SqlGeneration*'`) can be appended after.\n\n## License\n\n[MIT](LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmnbuhl%2Fefcore-locking","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmnbuhl%2Fefcore-locking","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmnbuhl%2Fefcore-locking/lists"}