diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1b9ddbb..859f98e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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"
diff --git a/CLAUDE.md b/CLAUDE.md
index cdf23e9..3468897 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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"
@@ -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
@@ -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 |
|------|------|
@@ -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
@@ -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.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 38d54fb..fc8c0f4 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -14,11 +14,13 @@
+
+
diff --git a/README.md b/README.md
index 94ec782..bea6bf1 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
@@ -32,6 +33,11 @@ services.AddDbContext(o =>
services.AddDbContext(o =>
o.UseSqlServer(connectionString)
.UseLocking());
+
+// Oracle
+services.AddDbContext(o =>
+ o.UseOracle(connectionString)
+ .UseLocking());
```
## Usage
@@ -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:` (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
@@ -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.
@@ -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 ;
+```
+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 |
@@ -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
diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/Benchmarks/SqlGenerationBenchmarks.cs b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/Benchmarks/SqlGenerationBenchmarks.cs
index 4a02ff9..e85e2d3 100644
--- a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/Benchmarks/SqlGenerationBenchmarks.cs
+++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/Benchmarks/SqlGenerationBenchmarks.cs
@@ -11,6 +11,7 @@ public class SqlGenerationBenchmarks
private PostgresBenchmarkDbContext _pg = null!;
private MySqlBenchmarkDbContext _my = null!;
private SqlServerBenchmarkDbContext _ms = null!;
+ private OracleBenchmarkDbContext _or = null!;
[GlobalSetup]
public void Setup()
@@ -18,6 +19,7 @@ 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.
@@ -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)]
@@ -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();
}
}
diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/Contexts/OracleBenchmarkDbContext.cs b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/Contexts/OracleBenchmarkDbContext.cs
new file mode 100644
index 0000000..590f6a3
--- /dev/null
+++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/Contexts/OracleBenchmarkDbContext.cs
@@ -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 Items => Set();
+
+ 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().ToTable("benchmark_entities");
+}
diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/EntityFrameworkCore.Locking.Benchmarks.csproj b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/EntityFrameworkCore.Locking.Benchmarks.csproj
index e361b2d..af8ec06 100644
--- a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/EntityFrameworkCore.Locking.Benchmarks.csproj
+++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/EntityFrameworkCore.Locking.Benchmarks.csproj
@@ -14,6 +14,7 @@
+
@@ -21,5 +22,6 @@
+
diff --git a/efcore-locking.sln b/efcore-locking.sln
index f9df9b0..8579d2f 100644
--- a/efcore-locking.sln
+++ b/efcore-locking.sln
@@ -24,6 +24,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Locking
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Locking.SqlServer", "src\EntityFrameworkCore.Locking.SqlServer\EntityFrameworkCore.Locking.SqlServer.csproj", "{A1B2C3D4-0003-0000-0000-000000000003}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Locking.Oracle", "src\EntityFrameworkCore.Locking.Oracle\EntityFrameworkCore.Locking.Oracle.csproj", "{A1B2C3D4-0004-0000-0000-000000000004}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{212685A3-FE9D-4703-AEDD-913F04360B1D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{A1B2C3D4-0010-0000-0000-000000000010}"
@@ -42,6 +44,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Locking
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Locking.SqlServer.Tests", "tests\EntityFrameworkCore.Locking.SqlServer.Tests\EntityFrameworkCore.Locking.SqlServer.Tests.csproj", "{F89BC55F-6B44-453E-9DF3-D7FA25E9028E}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Locking.Oracle.Tests", "tests\EntityFrameworkCore.Locking.Oracle.Tests\EntityFrameworkCore.Locking.Oracle.Tests.csproj", "{A1B2C3D4-0005-0000-0000-000000000005}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Locking.Tests", "tests\EntityFrameworkCore.Locking.Tests\EntityFrameworkCore.Locking.Tests.csproj", "{77FA75BB-ABFE-4D4D-80C2-C8C8457BCFE4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Locking.Tests.Infrastructure", "tests\EntityFrameworkCore.Locking.Tests.Infrastructure\EntityFrameworkCore.Locking.Tests.Infrastructure.csproj", "{72960A78-B6CC-4E2B-8939-F22BD11B5BFB}"
@@ -104,6 +108,30 @@ Global
{A1B2C3D4-0003-0000-0000-000000000003}.Release|x64.Build.0 = Release|Any CPU
{A1B2C3D4-0003-0000-0000-000000000003}.Release|x86.ActiveCfg = Release|Any CPU
{A1B2C3D4-0003-0000-0000-000000000003}.Release|x86.Build.0 = Release|Any CPU
+ {A1B2C3D4-0004-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-0004-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-0004-0000-0000-000000000004}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-0004-0000-0000-000000000004}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-0004-0000-0000-000000000004}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-0004-0000-0000-000000000004}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1B2C3D4-0004-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-0004-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-0004-0000-0000-000000000004}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-0004-0000-0000-000000000004}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-0004-0000-0000-000000000004}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-0004-0000-0000-000000000004}.Release|x86.Build.0 = Release|Any CPU
+ {A1B2C3D4-0005-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-0005-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-0005-0000-0000-000000000005}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-0005-0000-0000-000000000005}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-0005-0000-0000-000000000005}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-0005-0000-0000-000000000005}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1B2C3D4-0005-0000-0000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-0005-0000-0000-000000000005}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-0005-0000-0000-000000000005}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-0005-0000-0000-000000000005}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-0005-0000-0000-000000000005}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-0005-0000-0000-000000000005}.Release|x86.Build.0 = Release|Any CPU
{3424F5EC-C43C-4A5C-8BF6-10D08D75F0BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3424F5EC-C43C-4A5C-8BF6-10D08D75F0BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3424F5EC-C43C-4A5C-8BF6-10D08D75F0BB}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -209,9 +237,11 @@ Global
{A1B2C3D4-0001-0000-0000-000000000001} = {73D87BF9-EFDE-4A76-A86A-AE48FB034008}
{A1B2C3D4-0002-0000-0000-000000000002} = {73D87BF9-EFDE-4A76-A86A-AE48FB034008}
{A1B2C3D4-0003-0000-0000-000000000003} = {73D87BF9-EFDE-4A76-A86A-AE48FB034008}
+ {A1B2C3D4-0004-0000-0000-000000000004} = {73D87BF9-EFDE-4A76-A86A-AE48FB034008}
{3424F5EC-C43C-4A5C-8BF6-10D08D75F0BB} = {212685A3-FE9D-4703-AEDD-913F04360B1D}
{FED780A2-A4F9-4B71-A4BC-C90B08874714} = {212685A3-FE9D-4703-AEDD-913F04360B1D}
{F89BC55F-6B44-453E-9DF3-D7FA25E9028E} = {212685A3-FE9D-4703-AEDD-913F04360B1D}
+ {A1B2C3D4-0005-0000-0000-000000000005} = {212685A3-FE9D-4703-AEDD-913F04360B1D}
{77FA75BB-ABFE-4D4D-80C2-C8C8457BCFE4} = {212685A3-FE9D-4703-AEDD-913F04360B1D}
{72960A78-B6CC-4E2B-8939-F22BD11B5BFB} = {212685A3-FE9D-4703-AEDD-913F04360B1D}
{A1B2C3D4-0011-0000-0000-000000000011} = {A1B2C3D4-0010-0000-0000-000000000010}
diff --git a/src/EntityFrameworkCore.Locking.Oracle/EntityFrameworkCore.Locking.Oracle.csproj b/src/EntityFrameworkCore.Locking.Oracle/EntityFrameworkCore.Locking.Oracle.csproj
new file mode 100644
index 0000000..f790f57
--- /dev/null
+++ b/src/EntityFrameworkCore.Locking.Oracle/EntityFrameworkCore.Locking.Oracle.csproj
@@ -0,0 +1,18 @@
+
+
+ EntityFrameworkCore.Locking.Oracle
+ Oracle provider for EntityFrameworkCore.Locking.
+ false
+
+
+
+
+ $(NoWarn);EF1001
+
+
+
+
+
+
+
+
diff --git a/src/EntityFrameworkCore.Locking.Oracle/OracleAdvisoryLockProvider.cs b/src/EntityFrameworkCore.Locking.Oracle/OracleAdvisoryLockProvider.cs
new file mode 100644
index 0000000..2732388
--- /dev/null
+++ b/src/EntityFrameworkCore.Locking.Oracle/OracleAdvisoryLockProvider.cs
@@ -0,0 +1,251 @@
+using System.Data;
+using System.Data.Common;
+using System.IO.Hashing;
+using System.Text;
+using EntityFrameworkCore.Locking.Abstractions;
+using EntityFrameworkCore.Locking.Exceptions;
+using EntityFrameworkCore.Locking.Internal;
+using Microsoft.EntityFrameworkCore;
+
+namespace EntityFrameworkCore.Locking.Oracle;
+
+///
+/// Oracle advisory lock provider using DBMS_LOCK.REQUEST / DBMS_LOCK.RELEASE with a numeric
+/// user lock id. Session-scoped (release_on_commit = FALSE) — the lock survives transactions
+/// and is released on explicit RELEASE or session end.
+///
+/// Why not DBMS_LOCK.ALLOCATE_UNIQUE? That procedure performs an implicit commit (it may
+/// insert into DBMS_LOCK_ALLOCATED), which breaks transaction neutrality: any pending DML on
+/// the caller's EF transaction would be committed prematurely and a later rollback would not
+/// undo those writes. We therefore use the integer-id overload of REQUEST/RELEASE, which does
+/// not commit. User lock ids are reserved for application use in the range [0, 1073741823];
+/// ids ≥ 1073741824 are reserved for Oracle. We hash the string key via XxHash32 into the
+/// user range with a namespace prefix to minimise cross-library collisions.
+///
+/// Return codes from DBMS_LOCK.REQUEST / DBMS_LOCK.RELEASE:
+/// 0 = success
+/// 1 = timeout
+/// 2 = deadlock
+/// 3 = parameter error
+/// 4 = already own lock (REQUEST) / don't own lock (RELEASE)
+/// 5 = illegal lock handle
+///
+/// Prerequisite: the database user must have EXECUTE privilege on DBMS_LOCK:
+/// GRANT EXECUTE ON DBMS_LOCK TO <user>;
+/// Without this grant, calls surface as ORA-06550 and are translated to LockingConfigurationException.
+///
+/// Timeout granularity: DBMS_LOCK.REQUEST takes integer seconds; sub-second timeouts round up to 1 second.
+///
+internal sealed class OracleAdvisoryLockProvider : IAdvisoryLockProvider
+{
+ // DBMS_LOCK.X_MODE = 6 (exclusive).
+ private const int ExclusiveMode = 6;
+
+ // MAXWAIT sentinel (32767 seconds) — effectively "wait indefinitely" for DBMS_LOCK.REQUEST.
+ private const int MaxWait = 32767;
+
+ // User lock id range is [0, 1073741823] = [0, 2^30 - 1]. Hash output is masked into this range.
+ private const int UserLockIdMax = 0x3FFFFFFF;
+
+ // Namespace prefix "EFLK" (0x45464C4B) mixed into the hash input so our ids are unlikely to
+ // collide with other libraries using DBMS_LOCK.REQUEST against the same instance.
+ private const string NamespacePrefix = "EFLK:";
+
+ private static int ComputeLockId(string key)
+ {
+ var bytes = Encoding.UTF8.GetBytes(NamespacePrefix + key);
+ var hash = XxHash32.HashToUInt32(bytes);
+ return (int)(hash & UserLockIdMax);
+ }
+
+ // Parameter-only PL/SQL — no ALLOCATE_UNIQUE, no implicit commit. Uses the integer-id
+ // overload of DBMS_LOCK.REQUEST/RELEASE.
+ private const string AcquirePlSql =
+ "DECLARE rc INTEGER;\n"
+ + "BEGIN\n"
+ + " rc := DBMS_LOCK.REQUEST(id => :id, lockmode => :mode, timeout => :timeout, release_on_commit => FALSE);\n"
+ + " :rc := rc;\n"
+ + "END;";
+
+ private const string ReleasePlSql =
+ "DECLARE rc INTEGER;\n" + "BEGIN\n" + " rc := DBMS_LOCK.RELEASE(id => :id);\n" + " :rc := rc;\n" + "END;";
+
+ public async Task AcquireAsync(
+ DbContext context,
+ DbConnection connection,
+ string key,
+ TimeSpan? timeout,
+ CancellationToken ct
+ )
+ {
+ var lockId = ComputeLockId(key);
+ var timeoutSeconds = ToTimeoutSeconds(timeout);
+
+ await using var cmd = BuildAcquireCommand(connection, lockId, timeoutSeconds);
+ await using var reg = ct.Register(static state => ((DbCommand)state!).Cancel(), cmd);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+
+ var rc = GetReturnCode(cmd);
+ MapReturnCode(rc, key, ct);
+ return BuildHandle(context, connection, key, lockId);
+ }
+
+ public async Task TryAcquireAsync(
+ DbContext context,
+ DbConnection connection,
+ string key,
+ CancellationToken ct
+ )
+ {
+ var lockId = ComputeLockId(key);
+
+ // DBMS_LOCK.REQUEST with timeout=0 is the canonical try-acquire form.
+ await using var cmd = BuildAcquireCommand(connection, lockId, timeoutSeconds: 0);
+ await using var reg = ct.Register(static state => ((DbCommand)state!).Cancel(), cmd);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+
+ var rc = GetReturnCode(cmd);
+ if (rc == 1)
+ return null; // timeout — lock held by another session
+ MapReturnCode(rc, key, ct);
+ return BuildHandle(context, connection, key, lockId);
+ }
+
+ public IDistributedLockHandle Acquire(DbContext context, DbConnection connection, string key, TimeSpan? timeout)
+ {
+ var lockId = ComputeLockId(key);
+ var timeoutSeconds = ToTimeoutSeconds(timeout);
+
+ using var cmd = BuildAcquireCommand(connection, lockId, timeoutSeconds);
+ cmd.ExecuteNonQuery();
+
+ var rc = GetReturnCode(cmd);
+ MapReturnCode(rc, key, ct: default);
+ return BuildHandle(context, connection, key, lockId);
+ }
+
+ public IDistributedLockHandle? TryAcquire(DbContext context, DbConnection connection, string key)
+ {
+ var lockId = ComputeLockId(key);
+
+ using var cmd = BuildAcquireCommand(connection, lockId, timeoutSeconds: 0);
+ cmd.ExecuteNonQuery();
+
+ var rc = GetReturnCode(cmd);
+ if (rc == 1)
+ return null;
+ MapReturnCode(rc, key, ct: default);
+ return BuildHandle(context, connection, key, lockId);
+ }
+
+ private static DbCommand BuildAcquireCommand(DbConnection connection, int lockId, int timeoutSeconds)
+ {
+ var cmd = connection.CreateCommand();
+ cmd.CommandText = AcquirePlSql;
+ AddParam(cmd, "id", DbType.Int32, lockId);
+ AddParam(cmd, "mode", DbType.Int32, ExclusiveMode);
+ AddParam(cmd, "timeout", DbType.Int32, timeoutSeconds);
+ AddOutParam(cmd, "rc", DbType.Int32);
+ return cmd;
+ }
+
+ private static DbCommand BuildReleaseCommand(DbConnection connection, int lockId)
+ {
+ var cmd = connection.CreateCommand();
+ cmd.CommandText = ReleasePlSql;
+ AddParam(cmd, "id", DbType.Int32, lockId);
+ AddOutParam(cmd, "rc", DbType.Int32);
+ return cmd;
+ }
+
+ private static int GetReturnCode(DbCommand cmd)
+ {
+ var value = cmd.Parameters["rc"].Value;
+ if (value is null or DBNull)
+ return -1;
+ return Convert.ToInt32(value);
+ }
+
+ private static void MapReturnCode(int code, string key, CancellationToken ct)
+ {
+ switch (code)
+ {
+ case 0:
+ case 4: // already own lock — treat as success (idempotent acquire)
+ return;
+ case 1:
+ throw new LockTimeoutException($"Timed out waiting for distributed lock '{key}'.");
+ case 2:
+ throw new DeadlockException($"Deadlock detected acquiring distributed lock '{key}'.");
+ case 3:
+ throw new LockingConfigurationException(
+ $"DBMS_LOCK.REQUEST returned parameter error (code 3) for key '{key}'."
+ );
+ case 5:
+ throw new LockingConfigurationException(
+ $"DBMS_LOCK.REQUEST returned illegal lock handle (code 5) for key '{key}'."
+ );
+ default:
+ if (ct.IsCancellationRequested)
+ throw new OperationCanceledException(ct);
+ throw new LockAcquisitionFailedException(
+ $"DBMS_LOCK.REQUEST returned unexpected code {code} for key '{key}'."
+ );
+ }
+ }
+
+ private static IDistributedLockHandle BuildHandle(
+ DbContext context,
+ DbConnection connection,
+ string key,
+ int lockId
+ )
+ {
+ async Task ReleaseAsync(CancellationToken ct)
+ {
+ DistributedLockRegistry.Unregister(context, connection, key);
+ await using var cmd = BuildReleaseCommand(connection, lockId);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+ }
+
+ void ReleaseSync()
+ {
+ DistributedLockRegistry.Unregister(context, connection, key);
+ using var cmd = BuildReleaseCommand(connection, lockId);
+ cmd.ExecuteNonQuery();
+ }
+
+ return new DistributedLockHandle(key, connection, openedByConnection: false, ReleaseAsync, ReleaseSync);
+ }
+
+ private static int ToTimeoutSeconds(TimeSpan? timeout)
+ {
+ if (timeout is null)
+ return MaxWait;
+ var seconds = (long)Math.Ceiling(timeout.Value.TotalSeconds);
+ if (seconds < 1)
+ seconds = 1;
+ if (seconds > MaxWait)
+ seconds = MaxWait;
+ return (int)seconds;
+ }
+
+ private static void AddParam(DbCommand cmd, string name, DbType type, object value)
+ {
+ var p = cmd.CreateParameter();
+ p.ParameterName = name;
+ p.DbType = type;
+ p.Direction = ParameterDirection.Input;
+ p.Value = value;
+ cmd.Parameters.Add(p);
+ }
+
+ private static void AddOutParam(DbCommand cmd, string name, DbType type)
+ {
+ var p = cmd.CreateParameter();
+ p.ParameterName = name;
+ p.DbType = type;
+ p.Direction = ParameterDirection.Output;
+ cmd.Parameters.Add(p);
+ }
+}
diff --git a/src/EntityFrameworkCore.Locking.Oracle/OracleExceptionTranslator.cs b/src/EntityFrameworkCore.Locking.Oracle/OracleExceptionTranslator.cs
new file mode 100644
index 0000000..e29abc7
--- /dev/null
+++ b/src/EntityFrameworkCore.Locking.Oracle/OracleExceptionTranslator.cs
@@ -0,0 +1,57 @@
+using EntityFrameworkCore.Locking.Abstractions;
+using EntityFrameworkCore.Locking.Exceptions;
+using Oracle.ManagedDataAccess.Client;
+
+namespace EntityFrameworkCore.Locking.Oracle;
+
+///
+/// Translates Oracle exceptions to typed locking exceptions.
+/// ORA-00054 resource busy (NOWAIT or WAIT timeout) -> LockTimeoutException
+/// ORA-30006 resource busy; acquire with WAIT timeout expired -> LockTimeoutException
+/// ORA-00060 deadlock detected -> DeadlockException
+/// ORA-02014 cannot select FOR UPDATE from view with DISTINCT/GROUP BY/etc -> LockingConfigurationException
+/// ORA-06550/PLS-00201 identifier 'DBMS_LOCK' must be declared -> LockingConfigurationException
+/// (DBMS_LOCK requires an explicit GRANT EXECUTE — not granted to PUBLIC by default).
+///
+public sealed class OracleExceptionTranslator : IExceptionTranslator
+{
+ public LockingException? Translate(Exception exception)
+ {
+ var oraEx = FindOracleException(exception);
+
+ if (oraEx is null)
+ return null;
+
+ return oraEx.Number switch
+ {
+ 54 => new LockTimeoutException("Oracle resource busy (NOWAIT specified or wait timeout expired).", oraEx),
+ 30006 => new LockTimeoutException("Oracle resource busy; WAIT timeout expired.", oraEx),
+ 60 => new DeadlockException("Oracle deadlock detected.", oraEx),
+ 2014 => new LockingConfigurationException(
+ "Oracle does not allow SELECT ... FOR UPDATE on queries with DISTINCT, GROUP BY, "
+ + "set operations, analytic functions, pagination (OFFSET/FETCH or ROW_NUMBER), "
+ + "or collection Include expansions. Simplify the query shape or acquire the lock "
+ + "in a preceding statement.",
+ oraEx
+ ),
+ 6550 => new LockingConfigurationException(
+ "Oracle PL/SQL compilation error. If this is from a DBMS_LOCK call, "
+ + "the database user needs EXECUTE privilege on DBMS_LOCK: "
+ + "GRANT EXECUTE ON DBMS_LOCK TO ;",
+ oraEx
+ ),
+ _ => null,
+ };
+ }
+
+ private static OracleException? FindOracleException(Exception? exception)
+ {
+ while (exception is not null)
+ {
+ if (exception is OracleException oe)
+ return oe;
+ exception = exception.InnerException;
+ }
+ return null;
+ }
+}
diff --git a/src/EntityFrameworkCore.Locking.Oracle/OracleLockSqlGenerator.cs b/src/EntityFrameworkCore.Locking.Oracle/OracleLockSqlGenerator.cs
new file mode 100644
index 0000000..2cdfb4f
--- /dev/null
+++ b/src/EntityFrameworkCore.Locking.Oracle/OracleLockSqlGenerator.cs
@@ -0,0 +1,37 @@
+using EntityFrameworkCore.Locking.Abstractions;
+using EntityFrameworkCore.Locking.Exceptions;
+
+namespace EntityFrameworkCore.Locking.Oracle;
+
+///
+/// Generates Oracle FOR UPDATE SQL fragments.
+/// Oracle supports FOR UPDATE, FOR UPDATE NOWAIT, FOR UPDATE SKIP LOCKED, and FOR UPDATE WAIT {n}.
+/// Oracle does NOT support a row-level shared lock (FOR SHARE) — only table-level LOCK TABLE IN SHARE MODE.
+/// Timeout granularity for WAIT is whole seconds only; sub-second timeouts are rounded up to 1 second.
+///
+public sealed class OracleLockSqlGenerator : ILockSqlGenerator
+{
+ public string GenerateLockClause(LockOptions options)
+ {
+ if (options.Mode != LockMode.ForUpdate)
+ throw new LockingConfigurationException(
+ $"Oracle does not support lock mode {options.Mode}. Only ForUpdate is supported "
+ + "(Oracle has no row-level shared lock; use ForUpdate)."
+ );
+
+ return options.Behavior switch
+ {
+ LockBehavior.Wait when options.Timeout.HasValue => $"FOR UPDATE WAIT {WaitSeconds(options.Timeout.Value)}",
+ LockBehavior.Wait => "FOR UPDATE",
+ LockBehavior.SkipLocked => "FOR UPDATE SKIP LOCKED",
+ LockBehavior.NoWait => "FOR UPDATE NOWAIT",
+ _ => throw new LockingConfigurationException($"Unsupported lock behavior: {options.Behavior}"),
+ };
+ }
+
+ public bool SupportsLockOptions(LockOptions options) => options.Mode == LockMode.ForUpdate;
+
+ public string? GeneratePreStatementSql(LockOptions options) => null;
+
+ private static long WaitSeconds(TimeSpan timeout) => Math.Max(1L, (long)Math.Ceiling(timeout.TotalSeconds));
+}
diff --git a/src/EntityFrameworkCore.Locking.Oracle/OracleLockingProvider.cs b/src/EntityFrameworkCore.Locking.Oracle/OracleLockingProvider.cs
new file mode 100644
index 0000000..af69fe2
--- /dev/null
+++ b/src/EntityFrameworkCore.Locking.Oracle/OracleLockingProvider.cs
@@ -0,0 +1,11 @@
+using EntityFrameworkCore.Locking.Abstractions;
+
+namespace EntityFrameworkCore.Locking.Oracle;
+
+internal sealed class OracleLockingProvider : ILockingProvider
+{
+ public ILockSqlGenerator RowLockGenerator { get; } = new OracleLockSqlGenerator();
+ public IExceptionTranslator ExceptionTranslator { get; } = new OracleExceptionTranslator();
+ public IAdvisoryLockProvider AdvisoryLockProvider { get; } = new OracleAdvisoryLockProvider();
+ public string ProviderName => "Oracle";
+}
diff --git a/src/EntityFrameworkCore.Locking.Oracle/OracleLockingQuerySqlGenerator.cs b/src/EntityFrameworkCore.Locking.Oracle/OracleLockingQuerySqlGenerator.cs
new file mode 100644
index 0000000..09568ae
--- /dev/null
+++ b/src/EntityFrameworkCore.Locking.Oracle/OracleLockingQuerySqlGenerator.cs
@@ -0,0 +1,50 @@
+using System.Linq.Expressions;
+using EntityFrameworkCore.Locking.Abstractions;
+using EntityFrameworkCore.Locking.Internal;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Query;
+using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
+using Microsoft.EntityFrameworkCore.Storage;
+using Oracle.EntityFrameworkCore.Query.Sql.Internal;
+
+namespace EntityFrameworkCore.Locking.Oracle;
+
+///
+/// Extends OracleQuerySqlGenerator to append FOR UPDATE clauses when LockContext carries active lock options.
+///
+internal sealed class OracleLockingQuerySqlGenerator : OracleQuerySqlGenerator
+{
+ private readonly ILockSqlGenerator _lockSqlGenerator;
+
+ public OracleLockingQuerySqlGenerator(
+ QuerySqlGeneratorDependencies dependencies,
+ IRelationalTypeMappingSource typeMappingSource,
+ OracleSQLCompatibility oracleSQLCompatibility,
+ ILockSqlGenerator lockSqlGenerator
+ )
+ : base(dependencies, typeMappingSource, oracleSQLCompatibility)
+ {
+ _lockSqlGenerator = lockSqlGenerator;
+ }
+
+ protected override Expression VisitSelect(SelectExpression selectExpression)
+ {
+ var result = base.VisitSelect(selectExpression);
+
+ var lockOptions = LockContext.Current;
+
+ if (lockOptions is null || !selectExpression.Tags.Contains(LockTagConstants.BuildTag(lockOptions)))
+ return result;
+
+ UnsafeShapeDetector.ThrowIfUnsafe(selectExpression);
+
+ var clause = _lockSqlGenerator.GenerateLockClause(lockOptions!);
+ if (clause is not null)
+ {
+ Sql.AppendLine();
+ Sql.Append(clause);
+ }
+
+ return result;
+ }
+}
diff --git a/src/EntityFrameworkCore.Locking.Oracle/OracleLockingQuerySqlGeneratorFactory.cs b/src/EntityFrameworkCore.Locking.Oracle/OracleLockingQuerySqlGeneratorFactory.cs
new file mode 100644
index 0000000..4be963a
--- /dev/null
+++ b/src/EntityFrameworkCore.Locking.Oracle/OracleLockingQuerySqlGeneratorFactory.cs
@@ -0,0 +1,35 @@
+using EntityFrameworkCore.Locking.Abstractions;
+using Microsoft.EntityFrameworkCore.Query;
+using Microsoft.EntityFrameworkCore.Storage;
+using Oracle.EntityFrameworkCore.Infrastructure.Internal;
+
+namespace EntityFrameworkCore.Locking.Oracle;
+
+internal sealed class OracleLockingQuerySqlGeneratorFactory : IQuerySqlGeneratorFactory
+{
+ private readonly QuerySqlGeneratorDependencies _dependencies;
+ private readonly IRelationalTypeMappingSource _typeMappingSource;
+ private readonly IOracleOptions _oracleOptions;
+ private readonly ILockSqlGenerator _lockSqlGenerator;
+
+ public OracleLockingQuerySqlGeneratorFactory(
+ QuerySqlGeneratorDependencies dependencies,
+ IRelationalTypeMappingSource typeMappingSource,
+ IOracleOptions oracleOptions,
+ ILockSqlGenerator lockSqlGenerator
+ )
+ {
+ _dependencies = dependencies;
+ _typeMappingSource = typeMappingSource;
+ _oracleOptions = oracleOptions;
+ _lockSqlGenerator = lockSqlGenerator;
+ }
+
+ public QuerySqlGenerator Create() =>
+ new OracleLockingQuerySqlGenerator(
+ _dependencies,
+ _typeMappingSource,
+ _oracleOptions.OracleSQLCompatibility,
+ _lockSqlGenerator
+ );
+}
diff --git a/src/EntityFrameworkCore.Locking.Oracle/OracleLockingServiceCollectionExtensions.cs b/src/EntityFrameworkCore.Locking.Oracle/OracleLockingServiceCollectionExtensions.cs
new file mode 100644
index 0000000..e3181c4
--- /dev/null
+++ b/src/EntityFrameworkCore.Locking.Oracle/OracleLockingServiceCollectionExtensions.cs
@@ -0,0 +1,35 @@
+using EntityFrameworkCore.Locking.Internal;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Query;
+
+namespace EntityFrameworkCore.Locking.Oracle;
+
+public static class OracleLockingServiceCollectionExtensions
+{
+ ///
+ /// Adds row-level locking support for Oracle. Call after UseOracle().
+ ///
+ public static DbContextOptionsBuilder UseLocking(
+ this DbContextOptionsBuilder optionsBuilder
+ )
+ where TContext : DbContext
+ {
+ ((DbContextOptionsBuilder)optionsBuilder).UseLocking();
+ return optionsBuilder;
+ }
+
+ ///
+ /// Adds row-level locking support for Oracle. Call after UseOracle().
+ ///
+ public static DbContextOptionsBuilder UseLocking(this DbContextOptionsBuilder optionsBuilder)
+ {
+ var extension = new LockingOptionsExtension(new OracleLockingProvider());
+ ((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension(extension);
+
+ optionsBuilder.ReplaceService();
+ optionsBuilder.AddInterceptors(new LockingValidationInterceptor(), new DistributedLockCleanupInterceptor());
+
+ return optionsBuilder;
+ }
+}
diff --git a/src/EntityFrameworkCore.Locking/InternalsVisibleTo.cs b/src/EntityFrameworkCore.Locking/InternalsVisibleTo.cs
index 1c7694e..663de2e 100644
--- a/src/EntityFrameworkCore.Locking/InternalsVisibleTo.cs
+++ b/src/EntityFrameworkCore.Locking/InternalsVisibleTo.cs
@@ -5,6 +5,7 @@
[assembly: InternalsVisibleTo("EntityFrameworkCore.Locking.PostgreSQL")]
[assembly: InternalsVisibleTo("EntityFrameworkCore.Locking.MySql")]
[assembly: InternalsVisibleTo("EntityFrameworkCore.Locking.SqlServer")]
+[assembly: InternalsVisibleTo("EntityFrameworkCore.Locking.Oracle")]
// Benchmark assemblies
[assembly: InternalsVisibleTo("EntityFrameworkCore.Locking.Benchmarks")]
@@ -14,3 +15,4 @@
[assembly: InternalsVisibleTo("EntityFrameworkCore.Locking.PostgreSQL.Tests")]
[assembly: InternalsVisibleTo("EntityFrameworkCore.Locking.MySql.Tests")]
[assembly: InternalsVisibleTo("EntityFrameworkCore.Locking.SqlServer.Tests")]
+[assembly: InternalsVisibleTo("EntityFrameworkCore.Locking.Oracle.Tests")]
diff --git a/tests/EntityFrameworkCore.Locking.Oracle.Tests/ConcurrencyTests.cs b/tests/EntityFrameworkCore.Locking.Oracle.Tests/ConcurrencyTests.cs
new file mode 100644
index 0000000..5f142f5
--- /dev/null
+++ b/tests/EntityFrameworkCore.Locking.Oracle.Tests/ConcurrencyTests.cs
@@ -0,0 +1,27 @@
+using EntityFrameworkCore.Locking.Oracle.Tests.Fixtures;
+using EntityFrameworkCore.Locking.Tests.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace EntityFrameworkCore.Locking.Oracle.Tests;
+
+[Collection("Oracle")]
+public class ConcurrencyTests(OracleFixture fixture) : ConcurrencyTestsBase
+{
+ protected override TestDbContext CreateContext() =>
+ new(new DbContextOptionsBuilder().UseOracle(fixture.ConnectionString).UseLocking().Options);
+
+ protected override Task ResetDatabaseAsync(TestDbContext ctx) =>
+ ctx.Database.ExecuteSqlRawAsync(
+ """
+ BEGIN
+ EXECUTE IMMEDIATE 'DELETE FROM "OrderLines"';
+ EXECUTE IMMEDIATE 'DELETE FROM "Products"';
+ EXECUTE IMMEDIATE 'DELETE FROM "Categories"';
+ END;
+ """
+ );
+
+ // Oracle FOR UPDATE WAIT takes integer seconds — sub-second timeouts round up to 1s.
+ protected override TimeSpan WaitTimeout => TimeSpan.FromSeconds(1);
+}
diff --git a/tests/EntityFrameworkCore.Locking.Oracle.Tests/DistributedLockIntegrationTests.cs b/tests/EntityFrameworkCore.Locking.Oracle.Tests/DistributedLockIntegrationTests.cs
new file mode 100644
index 0000000..09b9e3f
--- /dev/null
+++ b/tests/EntityFrameworkCore.Locking.Oracle.Tests/DistributedLockIntegrationTests.cs
@@ -0,0 +1,45 @@
+using AwesomeAssertions;
+using EntityFrameworkCore.Locking.Oracle.Tests.Fixtures;
+using EntityFrameworkCore.Locking.Tests.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace EntityFrameworkCore.Locking.Oracle.Tests;
+
+[Collection("Oracle")]
+public class DistributedLockIntegrationTests(OracleFixture fixture) : DistributedLockIntegrationTestsBase
+{
+ protected override TestDbContext CreateContext() =>
+ new(new DbContextOptionsBuilder().UseOracle(fixture.ConnectionString).UseLocking().Options);
+
+ // DBMS_LOCK.REQUEST timeout is integer-second granularity.
+ protected override TimeSpan DistributedLockAcquireTimeout => TimeSpan.FromSeconds(1);
+
+ ///
+ /// Regression: DBMS_LOCK.ALLOCATE_UNIQUE performs an implicit commit. We use the integer-id
+ /// overload of DBMS_LOCK.REQUEST/RELEASE to avoid that, so acquiring an advisory lock inside
+ /// an open EF transaction must leave pending DML rollback-able.
+ ///
+ [Fact]
+ public async Task Acquire_InsideOpenTransaction_DoesNotCommitPendingDml()
+ {
+ await using var ctx = CreateContext();
+ await using var tx = await ctx.Database.BeginTransactionAsync();
+
+ var category = new Category { Name = "PendingRollback" };
+ ctx.Categories.Add(category);
+ await ctx.SaveChangesAsync();
+
+ await using (await ctx.Database.AcquireDistributedLockAsync("tx-neutral-key"))
+ {
+ // Lock acquired while transaction is still open with uncommitted INSERT.
+ }
+
+ await tx.RollbackAsync();
+
+ await using var verifyCtx = CreateContext();
+ (await verifyCtx.Categories.CountAsync(c => c.Name == "PendingRollback"))
+ .Should()
+ .Be(0, "rollback must undo the INSERT — DBMS_LOCK.REQUEST must not have committed");
+ }
+}
diff --git a/tests/EntityFrameworkCore.Locking.Oracle.Tests/EntityFrameworkCore.Locking.Oracle.Tests.csproj b/tests/EntityFrameworkCore.Locking.Oracle.Tests/EntityFrameworkCore.Locking.Oracle.Tests.csproj
new file mode 100644
index 0000000..762c739
--- /dev/null
+++ b/tests/EntityFrameworkCore.Locking.Oracle.Tests/EntityFrameworkCore.Locking.Oracle.Tests.csproj
@@ -0,0 +1,16 @@
+
+
+ $(NoWarn);EF1001
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/EntityFrameworkCore.Locking.Oracle.Tests/Fixtures/OracleFixture.cs b/tests/EntityFrameworkCore.Locking.Oracle.Tests/Fixtures/OracleFixture.cs
new file mode 100644
index 0000000..9290430
--- /dev/null
+++ b/tests/EntityFrameworkCore.Locking.Oracle.Tests/Fixtures/OracleFixture.cs
@@ -0,0 +1,65 @@
+using Testcontainers.Oracle;
+using Xunit;
+
+namespace EntityFrameworkCore.Locking.Oracle.Tests.Fixtures;
+
+///
+/// xUnit fixture booting an Oracle Database Free container via Testcontainers.
+/// Uses gvenzl/oracle-free:23-slim-faststart — smaller/faster than the official image and suitable for CI.
+/// Grants EXECUTE ON DBMS_LOCK to the app user so DBMS_LOCK-based advisory locks work;
+/// without this grant, calls surface as ORA-06550.
+///
+/// We build the connection string ourselves targeting the FREEPDB1 pluggable database because
+/// Testcontainers.Oracle's default connection string resolves to a service the listener does
+/// not register under these images (ORA-12514). FREEPDB1 is where the gvenzl entrypoint
+/// creates the APP_USER when WithUsername is set.
+///
+public sealed class OracleFixture : IAsyncLifetime
+{
+ private const string AppUsername = "testuser";
+ private const string AppPassword = "testuser";
+ private const string AdminPassword = "oracle";
+ private const int OraclePort = 1521;
+ private const string PluggableDatabase = "FREEPDB1";
+
+ private readonly OracleContainer _container = new OracleBuilder()
+ .WithImage("gvenzl/oracle-free:23-slim-faststart")
+ .WithUsername(AppUsername)
+ .WithPassword(AppPassword)
+ .WithEnvironment("ORACLE_PASSWORD", AdminPassword)
+ .Build();
+
+ public string ConnectionString =>
+ $"User Id={AppUsername};Password={AppPassword};"
+ + $"Data Source=//{_container.Hostname}:{_container.GetMappedPublicPort(OraclePort)}/{PluggableDatabase}";
+
+ public async Task InitializeAsync()
+ {
+ await _container.StartAsync();
+ await GrantDbmsLockExecuteAsync();
+ }
+
+ public Task DisposeAsync() => _container.DisposeAsync().AsTask();
+
+ ///
+ /// Grants EXECUTE ON SYS.DBMS_LOCK to the app user by running sqlplus inside the container
+ /// as SYS AS SYSDBA. Running inside the container avoids listener/service-name resolution
+ /// issues and doesn't require WITH GRANT OPTION (SYSTEM would fail with ORA-01031).
+ ///
+ private async Task GrantDbmsLockExecuteAsync()
+ {
+ var grantUser = AppUsername.ToUpperInvariant();
+ var script = $"GRANT EXECUTE ON SYS.DBMS_LOCK TO {grantUser};\nEXIT;\n";
+ var result = await _container.ExecAsync([
+ "sh",
+ "-c",
+ $"echo \"{script.Replace("\"", "\\\"")}\" | sqlplus -s 'SYS/{AdminPassword}@//localhost:{OraclePort}/{PluggableDatabase} AS SYSDBA'",
+ ]);
+
+ if (result.ExitCode != 0 || result.Stdout.Contains("ORA-") || result.Stderr.Contains("ORA-"))
+ throw new InvalidOperationException(
+ $"Failed to grant EXECUTE ON DBMS_LOCK to {grantUser}.\n"
+ + $"ExitCode: {result.ExitCode}\nStdout: {result.Stdout}\nStderr: {result.Stderr}"
+ );
+ }
+}
diff --git a/tests/EntityFrameworkCore.Locking.Oracle.Tests/IntegrationTests.OracleUnsupportedShapes.cs b/tests/EntityFrameworkCore.Locking.Oracle.Tests/IntegrationTests.OracleUnsupportedShapes.cs
new file mode 100644
index 0000000..8a19116
--- /dev/null
+++ b/tests/EntityFrameworkCore.Locking.Oracle.Tests/IntegrationTests.OracleUnsupportedShapes.cs
@@ -0,0 +1,81 @@
+using AwesomeAssertions;
+using EntityFrameworkCore.Locking.Exceptions;
+using EntityFrameworkCore.Locking.Tests.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace EntityFrameworkCore.Locking.Oracle.Tests;
+
+///
+/// Oracle rejects SELECT ... FOR UPDATE on query shapes that include collection joins
+/// (LEFT JOIN for Include) or analytic functions / ROW_NUMBER (pagination via Skip/Take),
+/// raising ORA-02014. The OracleExceptionTranslator maps this to LockingConfigurationException.
+/// These overrides replace the positive-path assertions in the shared base with Oracle-specific
+/// limitation assertions.
+///
+public partial class IntegrationTests
+{
+ public override async Task ForUpdate_WithIncludeCollection_LoadsOrderLines()
+ {
+ await using var ctx = CreateContext();
+ var (_, id) = await SeedAsync(ctx);
+ ctx.OrderLines.Add(
+ new OrderLine
+ {
+ ProductId = id,
+ Quantity = 1,
+ UnitPrice = 1m,
+ }
+ );
+ await ctx.SaveChangesAsync();
+
+ await using var tx = await ctx.Database.BeginTransactionAsync();
+ Func act = async () =>
+ await ctx.Products.Include(p => p.OrderLines).Where(p => p.Id == id).ForUpdate().FirstOrDefaultAsync();
+
+ await act.Should().ThrowAsync();
+ await tx.RollbackAsync();
+ }
+
+ public override async Task ForUpdate_WithMultipleIncludes_LocksRootTable()
+ {
+ await using var ctx = CreateContext();
+ var (_, id) = await SeedAsync(ctx);
+
+ await using var tx = await ctx.Database.BeginTransactionAsync();
+ Func act = async () =>
+ await ctx
+ .Products.Include(p => p.Category)
+ .Include(p => p.OrderLines)
+ .Where(p => p.Id == id)
+ .ForUpdate()
+ .FirstOrDefaultAsync();
+
+ await act.Should().ThrowAsync();
+ await tx.RollbackAsync();
+ }
+
+ public override async Task ForUpdate_WithOrderByAndTake_LocksPage()
+ {
+ await using var ctx = CreateContext();
+ await SeedAsync(ctx);
+
+ await using var tx = await ctx.Database.BeginTransactionAsync();
+ Func act = async () => await ctx.Products.OrderBy(p => p.Price).Take(2).ForUpdate().ToListAsync();
+
+ await act.Should().ThrowAsync();
+ await tx.RollbackAsync();
+ }
+
+ public override async Task ForUpdate_WithSkipAndTake_LocksCorrectPage()
+ {
+ await using var ctx = CreateContext();
+ await SeedAsync(ctx);
+
+ await using var tx = await ctx.Database.BeginTransactionAsync();
+ Func act = async () => await ctx.Products.OrderBy(p => p.Price).Skip(2).Take(2).ForUpdate().ToListAsync();
+
+ await act.Should().ThrowAsync();
+ await tx.RollbackAsync();
+ }
+}
diff --git a/tests/EntityFrameworkCore.Locking.Oracle.Tests/IntegrationTests.cs b/tests/EntityFrameworkCore.Locking.Oracle.Tests/IntegrationTests.cs
new file mode 100644
index 0000000..5467ae3
--- /dev/null
+++ b/tests/EntityFrameworkCore.Locking.Oracle.Tests/IntegrationTests.cs
@@ -0,0 +1,38 @@
+using EntityFrameworkCore.Locking.Oracle;
+using EntityFrameworkCore.Locking.Oracle.Tests.Fixtures;
+using EntityFrameworkCore.Locking.Tests.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace EntityFrameworkCore.Locking.Oracle.Tests;
+
+[Collection("Oracle")]
+public partial class IntegrationTests(OracleFixture fixture) : IntegrationTestsBase
+{
+ protected override TestDbContext CreateContext() =>
+ new(new DbContextOptionsBuilder().UseOracle(fixture.ConnectionString).UseLocking().Options);
+
+ protected override (TestDbContext ctx, SqlCapture capture) CreateContextWithCapture()
+ {
+ var capture = new SqlCapture();
+ var ctx = new TestDbContext(
+ new DbContextOptionsBuilder()
+ .UseOracle(fixture.ConnectionString)
+ .UseLocking()
+ .AddInterceptors(capture)
+ .Options
+ );
+ return (ctx, capture);
+ }
+
+ protected override Task ResetDatabaseAsync(TestDbContext ctx) =>
+ ctx.Database.ExecuteSqlRawAsync(
+ """
+ BEGIN
+ EXECUTE IMMEDIATE 'DELETE FROM "OrderLines"';
+ EXECUTE IMMEDIATE 'DELETE FROM "Products"';
+ EXECUTE IMMEDIATE 'DELETE FROM "Categories"';
+ END;
+ """
+ );
+}
diff --git a/tests/EntityFrameworkCore.Locking.Oracle.Tests/OracleCollection.cs b/tests/EntityFrameworkCore.Locking.Oracle.Tests/OracleCollection.cs
new file mode 100644
index 0000000..aa1358f
--- /dev/null
+++ b/tests/EntityFrameworkCore.Locking.Oracle.Tests/OracleCollection.cs
@@ -0,0 +1,7 @@
+using EntityFrameworkCore.Locking.Oracle.Tests.Fixtures;
+using Xunit;
+
+namespace EntityFrameworkCore.Locking.Oracle.Tests;
+
+[CollectionDefinition("Oracle")]
+public sealed class OracleCollection : ICollectionFixture { }
diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs
index ecd30ce..f29d11e 100644
--- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs
+++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs
@@ -29,7 +29,7 @@ public async Task ForUpdate_WithInclude_LoadsNavigationAndLocks()
}
[Fact]
- public async Task ForUpdate_WithIncludeCollection_LoadsOrderLines()
+ public virtual async Task ForUpdate_WithIncludeCollection_LoadsOrderLines()
{
await using var ctx = CreateContext();
var (_, id) = await SeedAsync(ctx);
@@ -57,7 +57,7 @@ public async Task ForUpdate_WithIncludeCollection_LoadsOrderLines()
}
[Fact]
- public async Task ForUpdate_WithMultipleIncludes_LocksRootTable()
+ public virtual async Task ForUpdate_WithMultipleIncludes_LocksRootTable()
{
await using var ctx = CreateContext();
var (_, id) = await SeedAsync(ctx, categoryName: "Multi");
@@ -105,7 +105,7 @@ public async Task ForUpdate_FilteredByRelation_LocksMatchingRows()
// --- Pagination ---
[Fact]
- public async Task ForUpdate_WithOrderByAndTake_LocksPage()
+ public virtual async Task ForUpdate_WithOrderByAndTake_LocksPage()
{
await using var ctx = CreateContext();
var cat = new Category { Name = "Page" };
@@ -137,7 +137,7 @@ public async Task ForUpdate_WithOrderByAndTake_LocksPage()
}
[Fact]
- public async Task ForUpdate_WithSkipAndTake_LocksCorrectPage()
+ public virtual async Task ForUpdate_WithSkipAndTake_LocksCorrectPage()
{
await using var ctx = CreateContext();
var cat = new Category { Name = "Skip" };