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" };