Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,30 @@ jobs:
--no-restore --configuration Release
--framework net${{ matrix.dotnet == '10.0.x' && '10.0' || matrix.dotnet == '9.0.x' && '9.0' || '8.0' }}
--logger "console;verbosity=normal"

test-oracle:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
dotnet: ['8.0.x', '9.0.x', '10.0.x']

steps:
- uses: actions/checkout@v4

- name: Setup .NET ${{ matrix.dotnet }}
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ matrix.dotnet }}

- name: Restore
run: dotnet restore

- name: Test (Oracle)
# Oracle Free container startup can take several minutes on cold-pull; long timeout.
timeout-minutes: 30
run: >
dotnet test tests/EntityFrameworkCore.Locking.Oracle.Tests
--no-restore --configuration Release
--framework net${{ matrix.dotnet == '10.0.x' && '10.0' || matrix.dotnet == '9.0.x' && '9.0' || '8.0' }}
--logger "console;verbosity=normal"
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dotnet test tests/EntityFrameworkCore.Locking.Tests
dotnet test tests/EntityFrameworkCore.Locking.PostgreSQL.Tests
dotnet test tests/EntityFrameworkCore.Locking.MySql.Tests
dotnet test tests/EntityFrameworkCore.Locking.SqlServer.Tests
dotnet test tests/EntityFrameworkCore.Locking.Oracle.Tests

# Run a single test by name
dotnet test tests/EntityFrameworkCore.Locking.PostgreSQL.Tests --filter "FullyQualifiedName~TestClassName.MethodName"
Expand All @@ -31,7 +32,7 @@ dotnet test

## Architecture

The library is split into a provider-agnostic core and three database provider projects. Consumers reference only the provider project they need.
The library is split into a provider-agnostic core and four database provider projects (PostgreSQL, MySQL, SQL Server, Oracle). Consumers reference only the provider project they need.

### Row-level locking flow

Expand All @@ -51,7 +52,7 @@ The library is split into a provider-agnostic core and three database provider p

### Provider structure

Each of the three provider projects (`EntityFrameworkCore.Locking.PostgreSQL`, `.MySql`, `.SqlServer`) contains the same seven-file shape:
Each of the four provider projects (`EntityFrameworkCore.Locking.PostgreSQL`, `.MySql`, `.SqlServer`, `.Oracle`) contains the same seven-file shape:

| File | Role |
|------|------|
Expand All @@ -72,6 +73,8 @@ Each of the three provider projects (`EntityFrameworkCore.Locking.PostgreSQL`, `
- `ForUpdate` / `ForShare` are incompatible with set operations (`Union`/`Except`/`Intersect`) and `AsSplitQuery()`.
- PostgreSQL key hashing: advisory lock string keys are hashed via `XxHash32` combined with namespace prefix `0x45464C4B_00000000L` to produce a `bigint`.
- SQL Server uses table hints (`WITH (UPDLOCK, ROWLOCK)`) injected into the `FROM` clause, not trailing clauses like PostgreSQL/MySQL.
- Oracle has no row-level `FOR SHARE` — only `FOR UPDATE` variants. `ForShare()` throws `LockingConfigurationException`. `WAIT n` takes integer seconds; sub-second timeouts are rounded up to 1 second.
- Oracle advisory locks use the integer-id overload of `DBMS_LOCK.REQUEST`/`RELEASE` (session-scoped, `release_on_commit => FALSE`). The string key is hashed via `XxHash32` into the user id range `[0, 2^30-1]` with a namespace prefix. `DBMS_LOCK.ALLOCATE_UNIQUE` is intentionally avoided — it performs an implicit commit that would break transaction neutrality. Requires `GRANT EXECUTE ON DBMS_LOCK` to the app user; without the grant, ORA-06550 is translated to `LockingConfigurationException`.

### Build configuration

Expand All @@ -98,5 +101,6 @@ Each provider test project (`PostgreSQL.Tests`, `MySql.Tests`, `SqlServer.Tests`
- **PostgreSQL**: `ForNoKeyUpdate`/`ForKeyShare` lock modes, `Except`/`Intersect` shape validation, `ForShare` skip-locked, advisory lock blocking/registry/cancellation tests.
- **MySQL**: `WaitTimeout` overridden to `TimeSpan.FromSeconds(1)` (innodb second-granularity); `DistributedLockAcquireTimeout` overridden to `TimeSpan.FromSeconds(1)` (GET_LOCK); long-key hashing test.
- **SqlServer**: `WaitTimeout` overridden to `TimeSpan.FromMilliseconds(500)`; `ForShare` throws `LockingConfigurationException`; cancellation test.
- **Oracle**: `WaitTimeout` and `DistributedLockAcquireTimeout` overridden to `TimeSpan.FromSeconds(1)` (integer-second granularity for both `FOR UPDATE WAIT` and `DBMS_LOCK.REQUEST`). Fixture grants `EXECUTE ON DBMS_LOCK`.

When adding a new integration test: if the behavior is identical across all three providers, add it to the appropriate base class. If it tests provider-specific SQL, exception mapping, or lock semantics, add it directly to that provider's test file.
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
<PackageVersion Include="Testcontainers.PostgreSql" Version="3.10.0" />
<PackageVersion Include="Testcontainers.MySql" Version="3.10.0" />
<PackageVersion Include="Testcontainers.MsSql" Version="3.10.0" />
<PackageVersion Include="Testcontainers.Oracle" Version="3.10.0" />
<!-- Provider versions for test projects — latest 8.x patches with no known CVEs -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.14" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10" />
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.14" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageVersion Include="Oracle.EntityFrameworkCore" Version="8.21.121" />
</ItemGroup>
</Project>
42 changes: 31 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# EntityFrameworkCore.Locking

Pessimistic locking for EF Core. Supports PostgreSQL, MySQL, and SQL Server.
Pessimistic locking for EF Core. Supports PostgreSQL, MySQL, SQL Server, and Oracle.

- **Row-level locks** — `ForUpdate()` / `ForShare()` LINQ extension methods scoped to a transaction
- **Distributed locks** — `AcquireDistributedLockAsync()` session-scoped advisory locks, no transaction required
Expand All @@ -11,6 +11,7 @@ Pessimistic locking for EF Core. Supports PostgreSQL, MySQL, and SQL Server.
dotnet add package EntityFrameworkCore.Locking.PostgreSQL
dotnet add package EntityFrameworkCore.Locking.MySql
dotnet add package EntityFrameworkCore.Locking.SqlServer
dotnet add package EntityFrameworkCore.Locking.Oracle
```

## Setup
Expand All @@ -32,6 +33,11 @@ services.AddDbContext<AppDbContext>(o =>
services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(connectionString)
.UseLocking());

// Oracle
services.AddDbContext<AppDbContext>(o =>
o.UseOracle(connectionString)
.UseLocking());
```

## Usage
Expand Down Expand Up @@ -168,6 +174,7 @@ Keys are plain strings, up to **255 characters**. The library handles provider-s
- **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.
- **MySQL** — passed as-is for keys ≤ 64 UTF-8 bytes; longer keys are SHA-256 hashed to `lock:<hex58>` (64 chars). The `lock:` prefix is reserved.
- **SQL Server** — passed as-is (max 255 chars, enforced upstream).
- **Oracle** — hashed via `XxHash32` into the `DBMS_LOCK` user-id range `[0, 2^30-1]` with a namespace prefix. `DBMS_LOCK.ALLOCATE_UNIQUE` is deliberately avoided because it performs an implicit commit that would break transaction neutrality when the advisory lock is acquired inside an open EF transaction.

### Provider-specific behavior

Expand All @@ -177,6 +184,8 @@ Keys are plain strings, up to **255 characters**. The library handles provider-s
| Timeout | `SET LOCAL lock_timeout` (ms) | `GET_LOCK(@key, seconds)` — rounded up to 1 s | `@LockTimeout` ms |
| Cancellation | Driver-level (best-effort) | `KILL QUERY` side-channel | Attention signal |

**Oracle** uses the integer-id overload of `DBMS_LOCK.REQUEST` with `release_on_commit => FALSE` and timeout in integer seconds (rounded up to 1 s). Cancellation is best-effort via driver cancel. Requires `GRANT EXECUTE ON DBMS_LOCK` to the application user. **Transaction-neutral** — unlike `DBMS_LOCK.ALLOCATE_UNIQUE`, the id overload does not perform an implicit commit, so acquiring an advisory lock inside an open EF transaction does not commit pending DML.

**MySQL timeout precision:** `GET_LOCK` timeout is in whole seconds. Sub-second timeouts are rounded up to 1 second.

**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.
Expand Down Expand Up @@ -255,28 +264,38 @@ catch (LockingConfigurationException ex)

## Provider limitations

| Feature | PostgreSQL | MySQL | SQL Server |
|---------|-----------|-------|-----------|
| `ForUpdate` | ✓ | ✓ | ✓ |
| `ForShare` | ✓ | ✓ | ✗ |
| `ForNoKeyUpdate` | ✓ | ✗ | ✗ |
| `ForKeyShare` | ✓ | ✗ | ✗ |
| `SkipLocked` | ✓ | ✓ | ✓ (via `READPAST`) |
| `NoWait` | ✓ | ✓ | ✓ |
| Wait with timeout | ✓ (ms) | ✓ (ceil to 1s) | ✓ (ms) |
| Feature | PostgreSQL | MySQL | SQL Server | Oracle |
|---------|-----------|-------|-----------|--------|
| `ForUpdate` | ✓ | ✓ | ✓ | ✓ |
| `ForShare` | ✓ | ✓ | ✗ | ✗ |
| `ForNoKeyUpdate` | ✓ | ✗ | ✗ | ✗ |
| `ForKeyShare` | ✓ | ✗ | ✗ | ✗ |
| `SkipLocked` | ✓ | ✓ | ✓ (via `READPAST`) | ✓ |
| `NoWait` | ✓ | ✓ | ✓ | ✓ |
| Wait with timeout | ✓ (ms) | ✓ (ceil to 1s) | ✓ (ms) | ✓ (ceil to 1s) |

`ForNoKeyUpdate` and `ForKeyShare` are PostgreSQL-only extension methods available when the `EntityFrameworkCore.Locking.PostgreSQL` package is installed. Using `ForShare` on SQL Server throws `LockingConfigurationException`.
`ForNoKeyUpdate` and `ForKeyShare` are PostgreSQL-only extension methods available when the `EntityFrameworkCore.Locking.PostgreSQL` package is installed. Using `ForShare` on SQL Server or Oracle throws `LockingConfigurationException` — Oracle has no row-level shared lock (only table-level `LOCK TABLE ... IN SHARE MODE`).

**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.

**MySQL timeout precision:** MySQL's `innodb_lock_wait_timeout` is in whole seconds. Sub-second timeouts are rounded up to 1 second.

**Oracle timeout precision:** Oracle's `FOR UPDATE WAIT n` and `DBMS_LOCK.REQUEST` both take integer seconds. Sub-second timeouts are rounded up to 1 second.

**Oracle advisory lock prerequisite:** `AcquireDistributedLockAsync` uses `DBMS_LOCK`, which requires an explicit grant. Run once as a DBA:
```sql
GRANT EXECUTE ON DBMS_LOCK TO <app_user>;
```
Without this grant, calls throw `LockingConfigurationException` (surfaced as ORA-06550 / PLS-00201).

## Unsupported query shapes

`UNION`, `EXCEPT`, `INTERSECT` with locking throw `LockingConfigurationException` at query execution time. Use per-query locks on individual queries before combining results.

`AsSplitQuery()` combined with locking throws `LockingConfigurationException` — use regular `Include()` instead (on PostgreSQL, `FOR UPDATE OF` is emitted automatically to handle outer joins).

**Oracle additionally rejects** `FOR UPDATE` on queries with collection `Include`, `Skip`/`Take` pagination, `DISTINCT`, `GROUP BY`, set operations, or analytic functions (ORA-02014, translated to `LockingConfigurationException`). Simplify the query shape to a single-table `SELECT`, or acquire the lock in a preceding statement before running the complex query.

## Supported database versions

| Database | Minimum version | Notes |
Expand All @@ -285,6 +304,7 @@ catch (LockingConfigurationException ex)
| MySQL | **8.0** | `FOR SHARE`, `SKIP LOCKED`, and `NOWAIT` were introduced in MySQL 8.0.1. MySQL 5.7 is not supported. |
| 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). |
| 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. |
| Oracle | **19c** | `SELECT ... FOR UPDATE [NOWAIT \| WAIT n \| SKIP LOCKED]` and `DBMS_LOCK` have been stable for many releases. Oracle Database Free (23c) is the recommended dev/test image. |

## Target frameworks

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ public class SqlGenerationBenchmarks
private PostgresBenchmarkDbContext _pg = null!;
private MySqlBenchmarkDbContext _my = null!;
private SqlServerBenchmarkDbContext _ms = null!;
private OracleBenchmarkDbContext _or = null!;

[GlobalSetup]
public void Setup()
{
_pg = new PostgresBenchmarkDbContext();
_my = new MySqlBenchmarkDbContext();
_ms = new SqlServerBenchmarkDbContext();
_or = new OracleBenchmarkDbContext();

// Warm the EF Core compiled-query cache so we measure VisitSelect overhead,
// not the one-time query compilation cost.
Expand All @@ -27,6 +29,8 @@ public void Setup()
_ = _my.Items.Where(x => x.Id > 0).ForUpdate().ToQueryString();
_ = _ms.Items.Where(x => x.Id > 0).ToQueryString();
_ = _ms.Items.Where(x => x.Id > 0).ForUpdate().ToQueryString();
_ = _or.Items.Where(x => x.Id > 0).ToQueryString();
_ = _or.Items.Where(x => x.Id > 0).ForUpdate().ToQueryString();
}

[Benchmark(Baseline = true)]
Expand Down Expand Up @@ -63,11 +67,22 @@ public string MySql_ForUpdate_MultipleTags() =>
public string SqlServer_ForUpdate_MultipleTags() =>
_ms.Items.Where(x => x.Id > 0).TagWith("custom-tag-a").TagWith("custom-tag-b").ForUpdate().ToQueryString();

[Benchmark]
public string Oracle_NoLock() => _or.Items.Where(x => x.Id > 0).ToQueryString();

[Benchmark]
public string Oracle_ForUpdate() => _or.Items.Where(x => x.Id > 0).ForUpdate().ToQueryString();

[Benchmark]
public string Oracle_ForUpdate_MultipleTags() =>
_or.Items.Where(x => x.Id > 0).TagWith("custom-tag-a").TagWith("custom-tag-b").ForUpdate().ToQueryString();

[GlobalCleanup]
public void Cleanup()
{
_pg.Dispose();
_my.Dispose();
_ms.Dispose();
_or.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using EntityFrameworkCore.Locking.Benchmarks.Entities;
using EntityFrameworkCore.Locking.Oracle;
using Microsoft.EntityFrameworkCore;

namespace EntityFrameworkCore.Locking.Benchmarks.Contexts;

public sealed class OracleBenchmarkDbContext : DbContext
{
public DbSet<BenchmarkEntity> Items => Set<BenchmarkEntity>();

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
optionsBuilder.UseOracle("User Id=bench;Password=bench;Data Source=//localhost:1521/FREEPDB1").UseLocking();

protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<BenchmarkEntity>().ToTable("benchmark_entities");
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Oracle.EntityFrameworkCore" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\EntityFrameworkCore.Locking\EntityFrameworkCore.Locking.csproj" />
<ProjectReference Include="..\..\src\EntityFrameworkCore.Locking.PostgreSQL\EntityFrameworkCore.Locking.PostgreSQL.csproj" />
<ProjectReference Include="..\..\src\EntityFrameworkCore.Locking.MySql\EntityFrameworkCore.Locking.MySql.csproj" />
<ProjectReference Include="..\..\src\EntityFrameworkCore.Locking.SqlServer\EntityFrameworkCore.Locking.SqlServer.csproj" />
<ProjectReference Include="..\..\src\EntityFrameworkCore.Locking.Oracle\EntityFrameworkCore.Locking.Oracle.csproj" />
</ItemGroup>
</Project>
Loading
Loading