From fd8f93ff8f5e4e3349edf33a9e1d78d7f4d71174 Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sat, 25 Apr 2026 23:49:16 +0200 Subject: [PATCH 01/13] feat: add LockTagConstants.TryParse for tag-authoritative SQL generation Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Internal/LockTagConstants.cs | 37 ++++++++++++ .../LockTagConstantsTests.cs | 57 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs diff --git a/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs b/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs index ddb3362..b4493ff 100644 --- a/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs +++ b/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs @@ -6,4 +6,41 @@ internal static class LockTagConstants internal static string BuildTag(LockOptions options) => FormattableString.Invariant($"{Prefix}{options.Mode}:{options.Behavior}:{options.Timeout?.TotalMilliseconds}"); + + /// + /// Parses a lock tag produced by back into . + /// Format: __efcore_locking:{LockMode}:{LockBehavior}:{timeout_ms | empty} + /// + internal static bool TryParse(string tag, out LockOptions? options) + { + options = null; + if (!tag.StartsWith(Prefix, StringComparison.Ordinal)) + return false; + + var body = tag[Prefix.Length..]; + var parts = body.Split(':'); + if (parts.Length != 3) + return false; + + if (!Enum.TryParse(parts[0], out var mode)) + return false; + + if (!Enum.TryParse(parts[1], out var behavior)) + return false; + + TimeSpan? timeout = null; + if ( + parts[2].Length > 0 + && double.TryParse( + parts[2], + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, + out var ms + ) + ) + timeout = TimeSpan.FromMilliseconds(ms); + + options = new LockOptions { Mode = mode, Behavior = behavior, Timeout = timeout }; + return true; + } } diff --git a/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs b/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs new file mode 100644 index 0000000..0e6fea0 --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs @@ -0,0 +1,57 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Internal; +using Xunit; + +namespace EntityFrameworkCore.Locking.Tests; + +public class LockTagConstantsTests +{ + [Theory] + [InlineData("__efcore_locking:ForUpdate:Wait:", LockMode.ForUpdate, LockBehavior.Wait, null)] + [InlineData("__efcore_locking:ForShare:SkipLocked:", LockMode.ForShare, LockBehavior.SkipLocked, null)] + [InlineData("__efcore_locking:ForUpdate:NoWait:", LockMode.ForUpdate, LockBehavior.NoWait, null)] + [InlineData("__efcore_locking:ForUpdate:Wait:500", LockMode.ForUpdate, LockBehavior.Wait, 500.0)] + [InlineData("__efcore_locking:ForUpdate:Wait:1000.5", LockMode.ForUpdate, LockBehavior.Wait, 1000.5)] + public void TryParse_ValidTag_ReturnsTrue(string tag, LockMode mode, LockBehavior behavior, double? timeoutMs) + { + var result = LockTagConstants.TryParse(tag, out var options); + result.Should().BeTrue(); + options.Should().NotBeNull(); + options!.Mode.Should().Be(mode); + options.Behavior.Should().Be(behavior); + if (timeoutMs.HasValue) + options.Timeout.Should().Be(TimeSpan.FromMilliseconds(timeoutMs.Value)); + else + options.Timeout.Should().BeNull(); + } + + [Theory] + [InlineData("")] + [InlineData("not_a_lock_tag")] + [InlineData("__efcore_locking:")] + [InlineData("__efcore_locking:ForUpdate")] + [InlineData("__efcore_locking:ForUpdate:Wait")] + [InlineData("__efcore_locking:InvalidMode:Wait:")] + public void TryParse_InvalidTag_ReturnsFalse(string tag) + { + var result = LockTagConstants.TryParse(tag, out var options); + result.Should().BeFalse(); + options.Should().BeNull(); + } + + [Fact] + public void BuildTag_ThenParse_RoundTrips() + { + var original = new LockOptions + { + Mode = LockMode.ForUpdate, + Behavior = LockBehavior.Wait, + Timeout = TimeSpan.FromMilliseconds(250), + }; + var tag = LockTagConstants.BuildTag(original); + LockTagConstants.TryParse(tag, out var parsed).Should().BeTrue(); + parsed!.Mode.Should().Be(original.Mode); + parsed.Behavior.Should().Be(original.Behavior); + parsed.Timeout.Should().Be(original.Timeout); + } +} From b1818d04582b867c0cfb29f6ac1d42a4844cbf2e Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sat, 25 Apr 2026 23:50:11 +0200 Subject: [PATCH 02/13] feat: extend UnsafeShapeDetector to reject DISTINCT and GROUP BY locking queries Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Internal/UnsafeShapeDetector.cs | 10 +++++++ .../IntegrationTests.QueryShapeTestsBase.cs | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs b/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs index 73b7b1f..7a18963 100644 --- a/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs +++ b/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs @@ -21,5 +21,15 @@ internal static void ThrowIfUnsafe(SelectExpression selectExpression) throw new LockingConfigurationException( "ForUpdate/ForShare is not compatible with split queries. Remove AsSplitQuery()." ); + + if (selectExpression.IsDistinct) + throw new LockingConfigurationException( + "ForUpdate/ForShare is not compatible with DISTINCT queries." + ); + + if (selectExpression.GroupBy.Count > 0) + throw new LockingConfigurationException( + "ForUpdate/ForShare is not compatible with GROUP BY queries." + ); } } diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs index 001b166..f0b31f0 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs @@ -203,4 +203,34 @@ await ctx await act.Should().ThrowAsync(); await tx.RollbackAsync(); } + + [Fact] + public async Task ForUpdate_DistinctQuery_ThrowsLockingConfigurationException() + { + await using var ctx = CreateContext(); + await using var tx = await ctx.Database.BeginTransactionAsync(); + + Func act = async () => + await ctx.Products.Distinct().ForUpdate().ToListAsync(); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_GroupByQuery_ThrowsLockingConfigurationException() + { + await using var ctx = CreateContext(); + await using var tx = await ctx.Database.BeginTransactionAsync(); + + Func act = async () => + await ctx + .Products.GroupBy(p => p.CategoryId) + .Select(g => g.First()) + .ForUpdate() + .ToListAsync(); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } } From 25c84301d1cb505ff60aab91994a857a132d5731 Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sat, 25 Apr 2026 23:51:30 +0200 Subject: [PATCH 03/13] fix: make all three SQL generators tag-authoritative (fixes AsyncLocal timing bug) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MySqlLockingQuerySqlGenerator.cs | 6 +++-- .../PostgresLockingQuerySqlGenerator.cs | 8 +++--- .../SqlServerLockingQuerySqlGenerator.cs | 26 ++++++++++--------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/EntityFrameworkCore.Locking.MySql/MySqlLockingQuerySqlGenerator.cs b/src/EntityFrameworkCore.Locking.MySql/MySqlLockingQuerySqlGenerator.cs index 38e56ea..3cdd4fc 100644 --- a/src/EntityFrameworkCore.Locking.MySql/MySqlLockingQuerySqlGenerator.cs +++ b/src/EntityFrameworkCore.Locking.MySql/MySqlLockingQuerySqlGenerator.cs @@ -29,9 +29,11 @@ protected override Expression VisitSelect(SelectExpression selectExpression) { var result = base.VisitSelect(selectExpression); - var lockOptions = LockContext.Current; + var lockTag = selectExpression.Tags.FirstOrDefault(t => + t.StartsWith(LockTagConstants.Prefix, StringComparison.Ordinal) + ); - if (lockOptions is null || !selectExpression.Tags.Contains(LockTagConstants.BuildTag(lockOptions))) + if (lockTag is null || !LockTagConstants.TryParse(lockTag, out var lockOptions)) return result; UnsafeShapeDetector.ThrowIfUnsafe(selectExpression); diff --git a/src/EntityFrameworkCore.Locking.PostgreSQL/PostgresLockingQuerySqlGenerator.cs b/src/EntityFrameworkCore.Locking.PostgreSQL/PostgresLockingQuerySqlGenerator.cs index 13f0eea..92de47a 100644 --- a/src/EntityFrameworkCore.Locking.PostgreSQL/PostgresLockingQuerySqlGenerator.cs +++ b/src/EntityFrameworkCore.Locking.PostgreSQL/PostgresLockingQuerySqlGenerator.cs @@ -11,7 +11,7 @@ namespace EntityFrameworkCore.Locking.PostgreSQL; /// /// Extends NpgsqlQuerySqlGenerator to append FOR UPDATE / FOR SHARE clauses -/// when LockContext carries active lock options. +/// when the query carries a locking tag. /// internal sealed class PostgresLockingQuerySqlGenerator : NpgsqlQuerySqlGenerator { @@ -33,9 +33,11 @@ protected override Expression VisitSelect(SelectExpression selectExpression) { var result = base.VisitSelect(selectExpression); - var lockOptions = LockContext.Current; + var lockTag = selectExpression.Tags.FirstOrDefault(t => + t.StartsWith(LockTagConstants.Prefix, StringComparison.Ordinal) + ); - if (lockOptions is null || !selectExpression.Tags.Contains(LockTagConstants.BuildTag(lockOptions))) + if (lockTag is null || !LockTagConstants.TryParse(lockTag, out var lockOptions)) return result; UnsafeShapeDetector.ThrowIfUnsafe(selectExpression); diff --git a/src/EntityFrameworkCore.Locking.SqlServer/SqlServerLockingQuerySqlGenerator.cs b/src/EntityFrameworkCore.Locking.SqlServer/SqlServerLockingQuerySqlGenerator.cs index 650037f..ce7aae2 100644 --- a/src/EntityFrameworkCore.Locking.SqlServer/SqlServerLockingQuerySqlGenerator.cs +++ b/src/EntityFrameworkCore.Locking.SqlServer/SqlServerLockingQuerySqlGenerator.cs @@ -19,6 +19,7 @@ internal sealed class SqlServerLockingQuerySqlGenerator : SqlServerQuerySqlGener { private readonly ILockSqlGenerator _lockSqlGenerator; private bool _lockingActive; + private LockOptions? _activeLockOptions; public SqlServerLockingQuerySqlGenerator( QuerySqlGeneratorDependencies dependencies, @@ -33,24 +34,29 @@ ILockSqlGenerator lockSqlGenerator protected override Expression VisitSelect(SelectExpression selectExpression) { - var lockOptions = LockContext.Current; var previousLockingActive = _lockingActive; + var previousActiveLockOptions = _activeLockOptions; - var isLockingSelect = - lockOptions is not null && selectExpression.Tags.Contains(LockTagConstants.BuildTag(lockOptions)); + var lockTag = selectExpression.Tags.FirstOrDefault(t => + t.StartsWith(LockTagConstants.Prefix, StringComparison.Ordinal) + ); - if (!isLockingSelect) + if (lockTag is null || !LockTagConstants.TryParse(lockTag, out var lockOptions)) { _lockingActive = false; + _activeLockOptions = null; var innerResult = base.VisitSelect(selectExpression); _lockingActive = previousLockingActive; + _activeLockOptions = previousActiveLockOptions; return innerResult; } UnsafeShapeDetector.ThrowIfUnsafe(selectExpression); _lockingActive = true; + _activeLockOptions = lockOptions; var result = base.VisitSelect(selectExpression); _lockingActive = previousLockingActive; + _activeLockOptions = previousActiveLockOptions; return result; } @@ -58,19 +64,15 @@ protected override Expression VisitTable(TableExpression tableExpression) { var result = base.VisitTable(tableExpression); - if (!_lockingActive) + if (!_lockingActive || _activeLockOptions is null) return result; - var lockOptions = LockContext.Current; - if (lockOptions is null) - return result; - - if (!_lockSqlGenerator.SupportsLockOptions(lockOptions)) + if (!_lockSqlGenerator.SupportsLockOptions(_activeLockOptions)) throw new LockingConfigurationException( - $"Lock mode {lockOptions.Mode} with behavior {lockOptions.Behavior} is not supported by SQL Server." + $"Lock mode {_activeLockOptions.Mode} with behavior {_activeLockOptions.Behavior} is not supported by SQL Server." ); - Sql.AppendLine($" {SqlServerLockSqlGenerator.BuildTableHint(lockOptions)}"); + Sql.AppendLine($" {SqlServerLockSqlGenerator.BuildTableHint(_activeLockOptions)}"); return result; } } From e79059f5b0c84e94df7ac458d615c89ad2ded4de Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sat, 25 Apr 2026 23:52:50 +0200 Subject: [PATCH 04/13] fix: ValidateAndPrepare reads lock options from SQL tag; replace InvalidOperationException with LockingConfigurationException Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Internal/LockingValidationInterceptor.cs | 24 +++++++++++++++---- .../IntegrationTestsBase.cs | 5 ++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/EntityFrameworkCore.Locking/Internal/LockingValidationInterceptor.cs b/src/EntityFrameworkCore.Locking/Internal/LockingValidationInterceptor.cs index 3b10573..30ffcee 100644 --- a/src/EntityFrameworkCore.Locking/Internal/LockingValidationInterceptor.cs +++ b/src/EntityFrameworkCore.Locking/Internal/LockingValidationInterceptor.cs @@ -84,13 +84,12 @@ public override Task CommandFailedAsync( private static void ValidateAndPrepare(DbCommand command, CommandEventData eventData) { - var lockOptions = LockContext.Current; - if (lockOptions is null) + if (!TryExtractLockOptions(command.CommandText, out var lockOptions)) return; if (eventData.Context?.Database.CurrentTransaction is null) - throw new InvalidOperationException( - "ForUpdate requires an active transaction. " + throw new LockingConfigurationException( + "ForUpdate/ForShare requires an active transaction. " + "Call BeginTransaction() before executing a locking query." ); @@ -98,11 +97,26 @@ private static void ValidateAndPrepare(DbCommand command, CommandEventData event if (provider is null) return; - var preSql = provider.RowLockGenerator.GeneratePreStatementSql(lockOptions); + var preSql = provider.RowLockGenerator.GeneratePreStatementSql(lockOptions!); if (preSql is not null && !command.CommandText.StartsWith(preSql, StringComparison.Ordinal)) command.CommandText = preSql + ";\n" + command.CommandText; } + private static bool TryExtractLockOptions(string commandText, out LockOptions? lockOptions) + { + lockOptions = null; + var prefixIndex = commandText.IndexOf(LockTagConstants.Prefix, StringComparison.Ordinal); + if (prefixIndex < 0) + return false; + + var tagEnd = commandText.IndexOf('\n', prefixIndex); + var tag = tagEnd < 0 + ? commandText[prefixIndex..].TrimEnd() + : commandText[prefixIndex..tagEnd].TrimEnd(); + + return LockTagConstants.TryParse(tag, out lockOptions); + } + private static DbDataReader WrapReader(DbDataReader reader, DbContext? context) { if (context is null) diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs index bc726b0..57436d0 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs @@ -1,4 +1,5 @@ using AwesomeAssertions; +using EntityFrameworkCore.Locking.Exceptions; using Microsoft.EntityFrameworkCore; using Xunit; @@ -60,12 +61,12 @@ public async Task ForUpdate_WithTransaction_ExecutesSuccessfully() } [Fact] - public async Task ForUpdate_WithoutTransaction_ThrowsInvalidOperationException() + public async Task ForUpdate_WithoutTransaction_ThrowsLockingConfigurationException() { await using var ctx = CreateContext(); Func act = async () => await ctx.Products.Where(p => p.Id == 1).ForUpdate().FirstOrDefaultAsync(); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); } [Fact] From 2a5b68645e0299e0be3e1c6b380fd0f1e98a9418 Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sat, 25 Apr 2026 23:53:38 +0200 Subject: [PATCH 05/13] fix: replace ArgumentException with LockingConfigurationException in distributed lock key validation Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DatabaseFacadeDistributedLockExtensions.cs | 4 ++-- .../DistributedLockUnitTests.cs | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs b/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs index 2a95753..f8d1f85 100644 --- a/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs +++ b/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs @@ -204,9 +204,9 @@ bool openedByMe private static void ValidateKey(string key) { if (string.IsNullOrEmpty(key)) - throw new ArgumentException("Lock key must not be null or empty.", nameof(key)); + throw new LockingConfigurationException("Lock key must not be null or empty."); if (key.Length > 255) - throw new ArgumentException("Lock key must not exceed 255 characters.", nameof(key)); + throw new LockingConfigurationException("Lock key must not exceed 255 characters."); } private static DbContext GetContext(DatabaseFacade database) => diff --git a/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs b/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs index 551127f..3c407a9 100644 --- a/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs +++ b/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs @@ -31,25 +31,31 @@ public void LockAlreadyHeldException_InheritsLockingException() // --- Key validation --- [Fact] - public async Task AcquireDistributedLockAsync_NullKey_ThrowsArgumentException() + public async Task AcquireDistributedLockAsync_NullKey_ThrowsLockingConfigurationException() { await using var ctx = CreateContext(); - await Assert.ThrowsAsync(() => ctx.Database.AcquireDistributedLockAsync(null!)); + await Assert.ThrowsAsync(() => + ctx.Database.AcquireDistributedLockAsync(null!) + ); } [Fact] - public async Task AcquireDistributedLockAsync_EmptyKey_ThrowsArgumentException() + public async Task AcquireDistributedLockAsync_EmptyKey_ThrowsLockingConfigurationException() { await using var ctx = CreateContext(); - await Assert.ThrowsAsync(() => ctx.Database.AcquireDistributedLockAsync("")); + await Assert.ThrowsAsync(() => + ctx.Database.AcquireDistributedLockAsync("") + ); } [Fact] - public async Task AcquireDistributedLockAsync_KeyTooLong_ThrowsArgumentException() + public async Task AcquireDistributedLockAsync_KeyTooLong_ThrowsLockingConfigurationException() { await using var ctx = CreateContext(); var longKey = new string('a', 256); - await Assert.ThrowsAsync(() => ctx.Database.AcquireDistributedLockAsync(longKey)); + await Assert.ThrowsAsync(() => + ctx.Database.AcquireDistributedLockAsync(longKey) + ); } [Fact] From 808396e02f9ce82102546183a932ed62c1791197 Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sat, 25 Apr 2026 23:55:44 +0200 Subject: [PATCH 06/13] docs: document thrown exceptions on ForUpdate/ForShare and distributed lock extensions; add Limitations section to README Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 18 +++++++++++++-- ...DatabaseFacadeDistributedLockExtensions.cs | 22 +++++++++++++++++++ .../Extensions/QueryableLockingExtensions.cs | 14 +++++++----- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f2840fe..0f55efa 100644 --- a/README.md +++ b/README.md @@ -233,9 +233,23 @@ MySQL's `innodb_lock_wait_timeout` is in whole seconds, so sub-second timeouts a ## Unsupported query shapes -`UNION`, `EXCEPT`, `INTERSECT` combined with locking throw `LockingConfigurationException` at query execution time. Lock individual queries before combining results. +The following query shapes throw `LockingConfigurationException` at execution time: -`AsSplitQuery()` combined with locking throws `LockingConfigurationException` — use regular `Include()` instead. On PostgreSQL, `FOR UPDATE OF` is emitted automatically to handle the outer join. +- `UNION` / `EXCEPT` / `INTERSECT` — lock individual queries before combining results +- `AsSplitQuery()` — use regular `Include()` instead (PostgreSQL emits `FOR UPDATE OF` automatically for outer joins) +- `Distinct()` — not compatible with row-level locking on any supported database +- `GroupBy(...)` — not compatible with row-level locking on any supported database + +## Limitations + +The following scenarios are not detected at build or execution time: + +| Scenario | Behaviour | Notes | +|---|---|---| +| `FromSqlRaw` / `FromSqlInterpolated` + `ForUpdate()` | Lock clause appended to wrapping `SELECT` — may work or fail depending on user SQL shape | Test your specific query | +| `EF.CompileAsyncQuery` + `ForUpdate()` | Lock clause emitted correctly; pre-statement timeout SQL not injected | Architectural constraint of EF Core compiled queries | +| `ExecuteUpdate` / `ExecuteDelete` / `Database.ExecuteSqlRaw` | Locking has no effect — these bypass the query SQL generator | Use `ForUpdate()` only with `IQueryable` | +| SQL Server nested subqueries | Table hints applied to all `TableExpression` nodes in the locking SELECT, including correlated subqueries | SQL Server requires per-table hints; this is correct behaviour | ## Supported database versions diff --git a/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs b/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs index f8d1f85..cae41bd 100644 --- a/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs +++ b/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs @@ -23,6 +23,13 @@ public static class DatabaseFacadeDistributedLockExtensions /// Lock key (1–255 characters). /// Maximum time to wait. Throws if exceeded. Null = wait indefinitely. /// Cancellation token. Cancellation is best-effort (driver-dependent). + /// + /// Thrown if the key is null, empty, or longer than 255 characters; if no locking provider is + /// registered; or if the provider does not support distributed locks. + /// + /// + /// Thrown if is exceeded before the lock can be acquired. + /// public static async Task AcquireDistributedLockAsync( this DatabaseFacade database, string key, @@ -56,6 +63,11 @@ public static async Task AcquireDistributedLockAsync( /// Attempts to acquire a distributed lock without blocking. /// Returns null immediately if the lock is held by another connection. /// + /// + /// Thrown if the key is null, empty, or longer than 255 characters; if no locking provider is + /// registered; or if the provider does not support distributed locks. + /// + /// A lock handle, or null if the lock is currently held by another connection. public static async Task TryAcquireDistributedLockAsync( this DatabaseFacade database, string key, @@ -93,6 +105,12 @@ public static async Task AcquireDistributedLockAsync( } /// Acquires a distributed lock synchronously. + /// + /// Thrown if the key is invalid, no provider is registered, or the provider does not support distributed locks. + /// + /// + /// Thrown if is exceeded before the lock can be acquired. + /// public static IDistributedLockHandle AcquireDistributedLock( this DatabaseFacade database, string key, @@ -122,6 +140,10 @@ public static IDistributedLockHandle AcquireDistributedLock( } /// Attempts to acquire a distributed lock synchronously. Returns null if contested. + /// + /// Thrown if the key is invalid, no provider is registered, or the provider does not support distributed locks. + /// + /// A lock handle, or null if the lock is currently held by another connection. public static IDistributedLockHandle? TryAcquireDistributedLock(this DatabaseFacade database, string key) { var (ctx, provider, connection, openedByMe) = PrepareSync(database, key); diff --git a/src/EntityFrameworkCore.Locking/Extensions/QueryableLockingExtensions.cs b/src/EntityFrameworkCore.Locking/Extensions/QueryableLockingExtensions.cs index 3912a2f..32db2ec 100644 --- a/src/EntityFrameworkCore.Locking/Extensions/QueryableLockingExtensions.cs +++ b/src/EntityFrameworkCore.Locking/Extensions/QueryableLockingExtensions.cs @@ -9,9 +9,10 @@ public static class QueryableLockingExtensions /// Acquires an exclusive row-level lock (FOR UPDATE) on the selected rows. /// Requires an active transaction on the DbContext. /// - /// - /// Thrown at query execution time if no ambient transaction exists, - /// or if the query shape is incompatible with row locking. + /// + /// Thrown at query execution time if no ambient transaction exists, or if the query shape is + /// incompatible with row locking: DISTINCT, GROUP BY, set operations (Union/Except/Intersect), + /// or split queries (AsSplitQuery). /// public static IQueryable ForUpdate( this IQueryable source, @@ -34,9 +35,10 @@ public static IQueryable ForUpdate( /// Acquires a shared row-level lock (FOR SHARE) on the selected rows. /// Requires an active transaction on the DbContext. /// - /// - /// Thrown at query execution time if no ambient transaction exists, - /// or if the query shape is incompatible with row locking. + /// + /// Thrown at query execution time if no ambient transaction exists, or if the query shape is + /// incompatible with row locking: DISTINCT, GROUP BY, set operations (Union/Except/Intersect), + /// or split queries (AsSplitQuery). /// public static IQueryable ForShare( this IQueryable source, From 9c30a25c7605d223fcd38de2481c07a19230fdac Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sat, 25 Apr 2026 23:56:20 +0200 Subject: [PATCH 07/13] style: csharpier formatting Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Internal/DistributedLockHandle.cs | 2 +- .../Internal/LockTagConstants.cs | 7 ++++++- .../Internal/LockingValidationInterceptor.cs | 4 +--- .../Internal/UnsafeShapeDetector.cs | 8 ++------ .../IntegrationTests.QueryShapeTestsBase.cs | 9 ++------- .../DistributedLockUnitTests.cs | 8 ++------ 6 files changed, 14 insertions(+), 24 deletions(-) diff --git a/src/EntityFrameworkCore.Locking/Internal/DistributedLockHandle.cs b/src/EntityFrameworkCore.Locking/Internal/DistributedLockHandle.cs index 435af29..dc54385 100644 --- a/src/EntityFrameworkCore.Locking/Internal/DistributedLockHandle.cs +++ b/src/EntityFrameworkCore.Locking/Internal/DistributedLockHandle.cs @@ -10,7 +10,7 @@ internal sealed class DistributedLockHandle : IDistributedLockHandle private int _released; public string Key { get; } - public System.Data.Common.DbConnection Connection { get; } + public DbConnection Connection { get; } public bool OpenedByConnection { get; } public DistributedLockHandle( diff --git a/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs b/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs index b4493ff..53f6587 100644 --- a/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs +++ b/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs @@ -40,7 +40,12 @@ out var ms ) timeout = TimeSpan.FromMilliseconds(ms); - options = new LockOptions { Mode = mode, Behavior = behavior, Timeout = timeout }; + options = new LockOptions + { + Mode = mode, + Behavior = behavior, + Timeout = timeout, + }; return true; } } diff --git a/src/EntityFrameworkCore.Locking/Internal/LockingValidationInterceptor.cs b/src/EntityFrameworkCore.Locking/Internal/LockingValidationInterceptor.cs index 30ffcee..695c3d2 100644 --- a/src/EntityFrameworkCore.Locking/Internal/LockingValidationInterceptor.cs +++ b/src/EntityFrameworkCore.Locking/Internal/LockingValidationInterceptor.cs @@ -110,9 +110,7 @@ private static bool TryExtractLockOptions(string commandText, out LockOptions? l return false; var tagEnd = commandText.IndexOf('\n', prefixIndex); - var tag = tagEnd < 0 - ? commandText[prefixIndex..].TrimEnd() - : commandText[prefixIndex..tagEnd].TrimEnd(); + var tag = tagEnd < 0 ? commandText[prefixIndex..].TrimEnd() : commandText[prefixIndex..tagEnd].TrimEnd(); return LockTagConstants.TryParse(tag, out lockOptions); } diff --git a/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs b/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs index 7a18963..937b68d 100644 --- a/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs +++ b/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs @@ -23,13 +23,9 @@ internal static void ThrowIfUnsafe(SelectExpression selectExpression) ); if (selectExpression.IsDistinct) - throw new LockingConfigurationException( - "ForUpdate/ForShare is not compatible with DISTINCT queries." - ); + throw new LockingConfigurationException("ForUpdate/ForShare is not compatible with DISTINCT queries."); if (selectExpression.GroupBy.Count > 0) - throw new LockingConfigurationException( - "ForUpdate/ForShare is not compatible with GROUP BY queries." - ); + throw new LockingConfigurationException("ForUpdate/ForShare is not compatible with GROUP BY queries."); } } diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs index f0b31f0..50ba644 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs @@ -210,8 +210,7 @@ public async Task ForUpdate_DistinctQuery_ThrowsLockingConfigurationException() await using var ctx = CreateContext(); await using var tx = await ctx.Database.BeginTransactionAsync(); - Func act = async () => - await ctx.Products.Distinct().ForUpdate().ToListAsync(); + Func act = async () => await ctx.Products.Distinct().ForUpdate().ToListAsync(); await act.Should().ThrowAsync(); await tx.RollbackAsync(); @@ -224,11 +223,7 @@ public async Task ForUpdate_GroupByQuery_ThrowsLockingConfigurationException() await using var tx = await ctx.Database.BeginTransactionAsync(); Func act = async () => - await ctx - .Products.GroupBy(p => p.CategoryId) - .Select(g => g.First()) - .ForUpdate() - .ToListAsync(); + await ctx.Products.GroupBy(p => p.CategoryId).Select(g => g.First()).ForUpdate().ToListAsync(); await act.Should().ThrowAsync(); await tx.RollbackAsync(); diff --git a/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs b/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs index 3c407a9..89eed83 100644 --- a/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs +++ b/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs @@ -34,18 +34,14 @@ public void LockAlreadyHeldException_InheritsLockingException() public async Task AcquireDistributedLockAsync_NullKey_ThrowsLockingConfigurationException() { await using var ctx = CreateContext(); - await Assert.ThrowsAsync(() => - ctx.Database.AcquireDistributedLockAsync(null!) - ); + await Assert.ThrowsAsync(() => ctx.Database.AcquireDistributedLockAsync(null!)); } [Fact] public async Task AcquireDistributedLockAsync_EmptyKey_ThrowsLockingConfigurationException() { await using var ctx = CreateContext(); - await Assert.ThrowsAsync(() => - ctx.Database.AcquireDistributedLockAsync("") - ); + await Assert.ThrowsAsync(() => ctx.Database.AcquireDistributedLockAsync("")); } [Fact] From 867c850d0b247f4bf3b1d3c4d2cdd6a5a685b377 Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sun, 26 Apr 2026 00:12:27 +0200 Subject: [PATCH 08/13] test: add shared integration tests for Contains, correlated subquery, and chained Where shapes Co-Authored-By: Claude Opus 4.7 (1M context) --- .../IntegrationTests.QueryShapeTestsBase.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs index 50ba644..ff2d3ca 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs @@ -185,6 +185,68 @@ public async Task ForUpdate_WithAsNoTracking_ExecutesSuccessfully() await tx.RollbackAsync(); } + // --- Subquery shapes --- + + [Fact] + public async Task ForUpdate_WithContainsInWhere_LocksMatchingRows() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + var ids = new[] { id }; + var products = await ctx.Products.Where(p => ids.Contains(p.Id)).ForUpdate().ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_WithCorrelatedSubqueryInWhere_LocksMatchingRows() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + ctx.OrderLines.Add( + new OrderLine + { + ProductId = id, + Quantity = 1, + UnitPrice = 5m, + } + ); + await ctx.SaveChangesAsync(); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + // Correlated subquery in WHERE: lock applies to the outer SELECT's rows + var products = await ctx + .Products.Where(p => ctx.OrderLines.Any(ol => ol.ProductId == p.Id)) + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_WithChainedWhere_LocksMatchingRows() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx, price: 5m); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + var product = await ctx + .Products.Where(p => p.Price > 1m) + .Where(p => p.Id == id) + .ForUpdate() + .FirstOrDefaultAsync(); + + product.Should().NotBeNull(); + product!.Id.Should().Be(id); + await tx.RollbackAsync(); + } + // --- Unsupported shapes --- [Fact] From 1cade87d70a08a127d28065d305f2ef693485519 Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sun, 26 Apr 2026 00:15:11 +0200 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20remove=20GroupBy=20guard=20from=20?= =?UTF-8?q?UnsafeShapeDetector=20=E2=80=94=20EF=20Core=20always=20pushes?= =?UTF-8?q?=20GroupBy=20into=20subqueries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroupBy with entity-typed results never appears on the outer SelectExpression that carries the lock tag, so the guard was dead code that produced false test failures. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 - .../Internal/UnsafeShapeDetector.cs | 4 ++-- .../IntegrationTests.QueryShapeTestsBase.cs | 13 ------------- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0f55efa..f812510 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,6 @@ The following query shapes throw `LockingConfigurationException` at execution ti - `UNION` / `EXCEPT` / `INTERSECT` — lock individual queries before combining results - `AsSplitQuery()` — use regular `Include()` instead (PostgreSQL emits `FOR UPDATE OF` automatically for outer joins) - `Distinct()` — not compatible with row-level locking on any supported database -- `GroupBy(...)` — not compatible with row-level locking on any supported database ## Limitations diff --git a/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs b/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs index 937b68d..1eaed5f 100644 --- a/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs +++ b/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs @@ -25,7 +25,7 @@ internal static void ThrowIfUnsafe(SelectExpression selectExpression) if (selectExpression.IsDistinct) throw new LockingConfigurationException("ForUpdate/ForShare is not compatible with DISTINCT queries."); - if (selectExpression.GroupBy.Count > 0) - throw new LockingConfigurationException("ForUpdate/ForShare is not compatible with GROUP BY queries."); + // GroupBy is not checked: ForUpdate requires T : class, so EF Core always translates + // GroupBy results into correlated subqueries — GroupBy never appears on the outer SELECT. } } diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs index ff2d3ca..dfdb3d3 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs @@ -277,17 +277,4 @@ public async Task ForUpdate_DistinctQuery_ThrowsLockingConfigurationException() await act.Should().ThrowAsync(); await tx.RollbackAsync(); } - - [Fact] - public async Task ForUpdate_GroupByQuery_ThrowsLockingConfigurationException() - { - await using var ctx = CreateContext(); - await using var tx = await ctx.Database.BeginTransactionAsync(); - - Func act = async () => - await ctx.Products.GroupBy(p => p.CategoryId).Select(g => g.First()).ForUpdate().ToListAsync(); - - await act.Should().ThrowAsync(); - await tx.RollbackAsync(); - } } From f064a106204e88e95935ba6d0a56763359814c02 Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sun, 26 Apr 2026 00:43:13 +0200 Subject: [PATCH 10/13] test: extend locking coverage with aggregate guard, AsyncLocal leakage, join shapes, compiled queries, and FromSql composition Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Internal/UnsafeShapeDetector.cs | 25 ++++ .../IntegrationTests.CompiledQueryTests.cs | 71 +++++++++++ .../IntegrationTests.FromSqlTests.cs | 48 ++++++++ .../IntegrationTests.LockModeTests.cs | 24 ++++ .../IntegrationTests.CompiledQueryTests.cs | 71 +++++++++++ .../IntegrationTests.FromSqlTests.cs | 48 ++++++++ .../IntegrationTests.LockModeTests.cs | 24 ++++ .../IntegrationTests.CompiledQueryTests.cs | 71 +++++++++++ .../IntegrationTests.FromSqlTests.cs | 48 ++++++++ .../IntegrationTests.LockModeTests.cs | 26 ++++ ...rationTests.AggregateTerminalsTestsBase.cs | 89 ++++++++++++++ ...grationTests.AsyncLocalLeakageTestsBase.cs | 108 +++++++++++++++++ .../IntegrationTests.QueryShapeTestsBase.cs | 114 ++++++++++++++++++ .../SqlCapture.cs | 17 +++ 14 files changed, 784 insertions(+) create mode 100644 tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.CompiledQueryTests.cs create mode 100644 tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.FromSqlTests.cs create mode 100644 tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.CompiledQueryTests.cs create mode 100644 tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.FromSqlTests.cs create mode 100644 tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.CompiledQueryTests.cs create mode 100644 tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.FromSqlTests.cs create mode 100644 tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AggregateTerminalsTestsBase.cs create mode 100644 tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AsyncLocalLeakageTestsBase.cs diff --git a/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs b/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs index 1eaed5f..b78a725 100644 --- a/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs +++ b/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs @@ -25,7 +25,32 @@ internal static void ThrowIfUnsafe(SelectExpression selectExpression) if (selectExpression.IsDistinct) throw new LockingConfigurationException("ForUpdate/ForShare is not compatible with DISTINCT queries."); + // Aggregate terminal ops (CountAsync, SumAsync, MaxAsync, MinAsync, LongCountAsync) produce + // a scalar aggregate function in the outer projection. Row-level locking a scalar is meaningless. + // AnyAsync is safe: EF Core translates it to a scalar subquery with no outer aggregate function. + if ( + selectExpression.Projection.Any(p => + p.Expression is Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlFunctionExpression func + && _aggregateFunctionNames.Contains(func.Name) + ) + ) + throw new LockingConfigurationException( + "ForUpdate/ForShare is not compatible with aggregate queries (CountAsync, SumAsync, MaxAsync, MinAsync, LongCountAsync)." + ); + // GroupBy is not checked: ForUpdate requires T : class, so EF Core always translates // GroupBy results into correlated subqueries — GroupBy never appears on the outer SELECT. } + + private static readonly System.Collections.Generic.HashSet _aggregateFunctionNames = new( + System.StringComparer.OrdinalIgnoreCase + ) + { + "COUNT", + "COUNT_BIG", + "SUM", + "MAX", + "MIN", + "AVG", + }; } diff --git a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.CompiledQueryTests.cs b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.CompiledQueryTests.cs new file mode 100644 index 0000000..04a0bc5 --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.CompiledQueryTests.cs @@ -0,0 +1,71 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Exceptions; +using EntityFrameworkCore.Locking.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.MySql.Tests; + +public partial class IntegrationTests +{ + private static readonly Func> _compiledForUpdate = EF.CompileAsyncQuery( + (TestDbContext ctx, int id) => + ctx.Products.Where(p => p.Id == id).ForUpdate(LockBehavior.Wait, null).FirstOrDefault() + ); + + private static readonly Func> _compiledPlain = EF.CompileAsyncQuery( + (TestDbContext ctx, int id) => ctx.Products.Where(p => p.Id == id).FirstOrDefault() + ); + + [Fact] + public async Task ForUpdate_WhenCompiledQuery_WhenInsideTransaction_ShouldExecuteWithLock() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + var product = await _compiledForUpdate(ctx, id); + + product.Should().NotBeNull(); + product!.Id.Should().Be(id); + cap.LastCommand.Should().Contain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } + + [Fact] + public async Task ForUpdate_WhenCompiledQuery_WhenWithoutTransaction_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + await SeedAsync(ctx); + + Func act = async () => await _compiledForUpdate(ctx, 1); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ForUpdate_WhenCompiledQueryExecutedTwice_ShouldNotLeakLockBetweenExecutions() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + await _compiledForUpdate(ctx, id); + var lockedIdx = cap.Commands.Count - 1; + + await _compiledPlain(ctx, id); + var plainIdx = cap.Commands.Count - 1; + + cap.Commands[lockedIdx].Should().Contain("__efcore_locking"); + cap.Commands[plainIdx].Should().NotContain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } +} diff --git a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.FromSqlTests.cs b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.FromSqlTests.cs new file mode 100644 index 0000000..7bace0b --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.FromSqlTests.cs @@ -0,0 +1,48 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.MySql.Tests; + +public partial class IntegrationTests +{ + [Fact] + public async Task ForUpdate_WhenFromSqlRaw_WhenInsideTransaction_ShouldExecuteSuccessfully() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.FromSqlRaw("SELECT * FROM `Products`") + .Where(p => p.Id == id) + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_WhenFromSqlInterpolated_WhenInsideTransaction_ShouldExecuteSuccessfully() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.FromSql($"SELECT * FROM `Products` WHERE `Id` = {id}") + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + + await tx.RollbackAsync(); + } +} diff --git a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.LockModeTests.cs b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.LockModeTests.cs index 059ce50..0305fd9 100644 --- a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.LockModeTests.cs +++ b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.LockModeTests.cs @@ -87,4 +87,28 @@ public async Task ForUpdate_WithTimeout_SucceedsOnUncontendedRow() row.Should().NotBeNull(); await tx.RollbackAsync(); } + + [Fact] + public async Task ForUpdate_WhenOrderByTakeLockBehaviors_WhenInsideTransaction_ShouldEmitCorrectSql() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (catId, _) = await SeedAsync(ctx, categoryName: "SqlCheck"); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var result = await ctx + .Products.Where(p => p.CategoryId == catId) + .OrderBy(p => p.Id) + .Take(1) + .ForUpdate(LockBehavior.NoWait) + .ToListAsync(); + + result.Should().HaveCount(1); + cap.LastCommand.Should().Contain("FOR UPDATE NOWAIT"); + + await tx.RollbackAsync(); + } + } } diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.CompiledQueryTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.CompiledQueryTests.cs new file mode 100644 index 0000000..ebb34c9 --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.CompiledQueryTests.cs @@ -0,0 +1,71 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Exceptions; +using EntityFrameworkCore.Locking.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.PostgreSQL.Tests; + +public partial class IntegrationTests +{ + private static readonly Func> _compiledForUpdate = EF.CompileAsyncQuery( + (TestDbContext ctx, int id) => + ctx.Products.Where(p => p.Id == id).ForUpdate(LockBehavior.Wait, null).FirstOrDefault() + ); + + private static readonly Func> _compiledPlain = EF.CompileAsyncQuery( + (TestDbContext ctx, int id) => ctx.Products.Where(p => p.Id == id).FirstOrDefault() + ); + + [Fact] + public async Task ForUpdate_WhenCompiledQuery_WhenInsideTransaction_ShouldExecuteWithLock() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + var product = await _compiledForUpdate(ctx, id); + + product.Should().NotBeNull(); + product!.Id.Should().Be(id); + cap.LastCommand.Should().Contain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } + + [Fact] + public async Task ForUpdate_WhenCompiledQuery_WhenWithoutTransaction_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + await SeedAsync(ctx); + + Func act = async () => await _compiledForUpdate(ctx, 1); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ForUpdate_WhenCompiledQueryExecutedTwice_ShouldNotLeakLockBetweenExecutions() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + await _compiledForUpdate(ctx, id); + var lockedIdx = cap.Commands.Count - 1; + + await _compiledPlain(ctx, id); + var plainIdx = cap.Commands.Count - 1; + + cap.Commands[lockedIdx].Should().Contain("__efcore_locking"); + cap.Commands[plainIdx].Should().NotContain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } +} diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.FromSqlTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.FromSqlTests.cs new file mode 100644 index 0000000..d446e2d --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.FromSqlTests.cs @@ -0,0 +1,48 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.PostgreSQL.Tests; + +public partial class IntegrationTests +{ + [Fact] + public async Task ForUpdate_WhenFromSqlRaw_WhenInsideTransaction_ShouldExecuteSuccessfully() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.FromSqlRaw("""SELECT * FROM "Products" """) + .Where(p => p.Id == id) + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_WhenFromSqlInterpolated_WhenInsideTransaction_ShouldExecuteSuccessfully() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.FromSql($"""SELECT * FROM "Products" WHERE "Id" = {id}""") + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + + await tx.RollbackAsync(); + } +} diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.LockModeTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.LockModeTests.cs index c53a913..fc38c0a 100644 --- a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.LockModeTests.cs +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.LockModeTests.cs @@ -172,4 +172,28 @@ public async Task ForUpdate_NoWait_OnUncontendedRow_Succeeds() row.Should().NotBeNull(); await tx.RollbackAsync(); } + + [Fact] + public async Task ForUpdate_WhenOrderByTakeLockBehaviors_WhenInsideTransaction_ShouldEmitCorrectSql() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (catId, _) = await SeedAsync(ctx, categoryName: "SqlCheck"); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var result = await ctx + .Products.Where(p => p.CategoryId == catId) + .OrderBy(p => p.Id) + .Take(1) + .ForUpdate(LockBehavior.NoWait) + .ToListAsync(); + + result.Should().HaveCount(1); + cap.LastCommand.Should().Contain("FOR UPDATE NOWAIT"); + + await tx.RollbackAsync(); + } + } } diff --git a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.CompiledQueryTests.cs b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.CompiledQueryTests.cs new file mode 100644 index 0000000..28366c9 --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.CompiledQueryTests.cs @@ -0,0 +1,71 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Exceptions; +using EntityFrameworkCore.Locking.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.SqlServer.Tests; + +public partial class IntegrationTests +{ + private static readonly Func> _compiledForUpdate = EF.CompileAsyncQuery( + (TestDbContext ctx, int id) => + ctx.Products.Where(p => p.Id == id).ForUpdate(LockBehavior.Wait, null).FirstOrDefault() + ); + + private static readonly Func> _compiledPlain = EF.CompileAsyncQuery( + (TestDbContext ctx, int id) => ctx.Products.Where(p => p.Id == id).FirstOrDefault() + ); + + [Fact] + public async Task ForUpdate_WhenCompiledQuery_WhenInsideTransaction_ShouldExecuteWithLock() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + var product = await _compiledForUpdate(ctx, id); + + product.Should().NotBeNull(); + product!.Id.Should().Be(id); + cap.LastCommand.Should().Contain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } + + [Fact] + public async Task ForUpdate_WhenCompiledQuery_WhenWithoutTransaction_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + await SeedAsync(ctx); + + Func act = async () => await _compiledForUpdate(ctx, 1); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ForUpdate_WhenCompiledQueryExecutedTwice_ShouldNotLeakLockBetweenExecutions() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + await _compiledForUpdate(ctx, id); + var lockedIdx = cap.Commands.Count - 1; + + await _compiledPlain(ctx, id); + var plainIdx = cap.Commands.Count - 1; + + cap.Commands[lockedIdx].Should().Contain("__efcore_locking"); + cap.Commands[plainIdx].Should().NotContain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } +} diff --git a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.FromSqlTests.cs b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.FromSqlTests.cs new file mode 100644 index 0000000..2d83136 --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.FromSqlTests.cs @@ -0,0 +1,48 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.SqlServer.Tests; + +public partial class IntegrationTests +{ + [Fact] + public async Task ForUpdate_WhenFromSqlRaw_WhenInsideTransaction_ShouldExecuteSuccessfully() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.FromSqlRaw("SELECT * FROM [Products]") + .Where(p => p.Id == id) + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_WhenFromSqlInterpolated_WhenInsideTransaction_ShouldExecuteSuccessfully() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.FromSql($"SELECT * FROM [Products] WHERE [Id] = {id}") + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + + await tx.RollbackAsync(); + } +} diff --git a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.LockModeTests.cs b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.LockModeTests.cs index bed7dcd..b6d7471 100644 --- a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.LockModeTests.cs +++ b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.LockModeTests.cs @@ -60,4 +60,30 @@ public async Task ForUpdate_SkipLocked_ThrowsLockingConfigurationException_WhenN row.Should().NotBeNull(); await tx.RollbackAsync(); } + + [Fact] + public async Task ForUpdate_WhenOrderByTakeLockBehaviors_WhenInsideTransaction_ShouldEmitCorrectSql() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (catId, _) = await SeedAsync(ctx, categoryName: "SqlCheck"); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var result = await ctx + .Products.Where(p => p.CategoryId == catId) + .OrderBy(p => p.Id) + .Take(1) + .ForUpdate(LockBehavior.NoWait) + .ToListAsync(); + + result.Should().HaveCount(1); + // SQL Server NoWait: SET LOCK_TIMEOUT 0 as pre-statement, UPDLOCK in FROM clause + cap.Commands.Should().Contain(c => c.Contains("SET LOCK_TIMEOUT 0")); + cap.LastCommand.Should().Contain("UPDLOCK"); + + await tx.RollbackAsync(); + } + } } diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AggregateTerminalsTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AggregateTerminalsTestsBase.cs new file mode 100644 index 0000000..695fb5f --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AggregateTerminalsTestsBase.cs @@ -0,0 +1,89 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Exceptions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.Tests.Infrastructure; + +public abstract partial class IntegrationTestsBase +{ + [Fact] + public async Task ForUpdate_ThenCountAsync_WhenCondition_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + Func act = async () => await ctx.Products.Where(p => p.Id == id).ForUpdate().CountAsync(); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_ThenLongCountAsync_WhenCondition_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + Func act = async () => await ctx.Products.Where(p => p.Id == id).ForUpdate().LongCountAsync(); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_ThenSumAsync_WhenCondition_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + await SeedAsync(ctx, price: 9.99m); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + Func act = async () => await ctx.Products.ForUpdate().SumAsync(p => p.Price); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_ThenMaxAsync_WhenCondition_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + await SeedAsync(ctx, price: 9.99m); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + Func act = async () => await ctx.Products.ForUpdate().MaxAsync(p => p.Price); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_ThenMinAsync_WhenCondition_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + await SeedAsync(ctx, price: 9.99m); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + Func act = async () => await ctx.Products.ForUpdate().MinAsync(p => p.Price); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_ThenAnyAsync_WhenRowExists_ShouldReturnTrueWithoutThrowing() + { + // AnyAsync is safe: EF Core translates it to a scalar subquery without an outer aggregate + // function — the guard must NOT fire. + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + var exists = await ctx.Products.Where(p => p.Id == id).ForUpdate().AnyAsync(); + + exists.Should().BeTrue(); + await tx.RollbackAsync(); + } +} diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AsyncLocalLeakageTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AsyncLocalLeakageTestsBase.cs new file mode 100644 index 0000000..9da7dd3 --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AsyncLocalLeakageTestsBase.cs @@ -0,0 +1,108 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Exceptions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.Tests.Infrastructure; + +public abstract partial class IntegrationTestsBase +{ + [Fact] + public async Task ForUpdate_WhenFollowedByPlainQuery_ShouldNotEmitLockOnSecondQuery() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + await ctx.Products.Where(p => p.Id == id).ForUpdate().FirstOrDefaultAsync(); + var lockedIdx = cap.Commands.Count - 1; + + await ctx.Products.Where(p => p.Id == id).FirstOrDefaultAsync(); + var plainIdx = cap.Commands.Count - 1; + + cap.Commands[lockedIdx].Should().Contain("__efcore_locking"); + cap.Commands[plainIdx].Should().NotContain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } + + [Fact] + public async Task PlainQuery_WhenSurroundsLockedQuery_ShouldEmitLockOnlyForLockedQuery() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + await ctx.Products.Where(p => p.Id == id).FirstOrDefaultAsync(); + var firstIdx = cap.Commands.Count - 1; + + await ctx.Products.Where(p => p.Id == id).ForUpdate().FirstOrDefaultAsync(); + var secondIdx = cap.Commands.Count - 1; + + await ctx.Products.Where(p => p.Id == id).FirstOrDefaultAsync(); + var thirdIdx = cap.Commands.Count - 1; + + cap.Commands[firstIdx].Should().NotContain("__efcore_locking"); + cap.Commands[secondIdx].Should().Contain("__efcore_locking"); + cap.Commands[thirdIdx].Should().NotContain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } + + [Fact] + public async Task ForUpdate_WhenThrowsDueToMissingTransaction_ThenPlainQuery_ShouldSucceedWithoutLock() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + await SeedAsync(ctx); + + // No transaction — ForUpdate must throw + Func badAct = async () => await ctx.Products.ForUpdate().FirstOrDefaultAsync(); + await badAct.Should().ThrowAsync(); + + // Subsequent plain query in its own transaction must succeed cleanly + await using var tx = await ctx.Database.BeginTransactionAsync(); + var products = await ctx.Products.ToListAsync(); + + products.Should().NotBeEmpty(); + cap.LastCommand.Should().NotContain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } + + [Fact] + public async Task ForUpdate_WhenExecutedTwiceSequentially_ShouldUseOwnOptionsEachTime() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + // First: Wait behavior + await ctx.Products.Where(p => p.Id == id).ForUpdate(LockBehavior.Wait).FirstOrDefaultAsync(); + var firstIdx = cap.Commands.Count - 1; + + // Second: NoWait behavior + await ctx.Products.Where(p => p.Id == id).ForUpdate(LockBehavior.NoWait).FirstOrDefaultAsync(); + var secondIdx = cap.Commands.Count - 1; + + // Tag format: __efcore_locking:{LockMode}:{LockBehavior}:{timeout_ms} + cap.Commands[firstIdx].Should().Contain("__efcore_locking:ForUpdate:Wait:"); + cap.Commands[secondIdx].Should().Contain("__efcore_locking:ForUpdate:NoWait:"); + + await tx.RollbackAsync(); + } + } +} diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs index dfdb3d3..8535c5b 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs @@ -277,4 +277,118 @@ public async Task ForUpdate_DistinctQuery_ThrowsLockingConfigurationException() await act.Should().ThrowAsync(); await tx.RollbackAsync(); } + + // --- Set operations: Concat --- + + [Fact] + public async Task ForUpdate_WhenConcatQuery_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + await using var tx = await ctx.Database.BeginTransactionAsync(); + + // Concat → UNION ALL → SetOperationBase → must be rejected + Func act = async () => + await ctx + .Products.Where(p => p.Id == 1) + .Concat(ctx.Products.Where(p => p.Id == 2)) + .ForUpdate() + .ToListAsync(); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + // --- Explicit join shapes --- + + [Fact] + public async Task ForUpdate_WithInnerJoinQuerySyntax_WhenCondition_ShouldReturnLockedRows() + { + await using var ctx = CreateContext(); + var (catId, id) = await SeedAsync(ctx, categoryName: "JoinTest"); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ( + from p in ctx.Products + join c in ctx.Categories on p.CategoryId equals c.Id + where c.Name == "JoinTest" + select p + ) + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_WithSelectMany_WhenCondition_ShouldReturnLockedRows() + { + 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(); + + var products = await ctx + .Products.SelectMany(p => ctx.OrderLines.Where(ol => ol.ProductId == p.Id), (p, _) => p) + .Where(p => p.Id == id) + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + await tx.RollbackAsync(); + } + + // --- Tags interaction --- + + [Fact] + public async Task ForUpdate_WhenTagWithBeforeLock_ShouldPreserveUserTagInExecutedSql() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + await ctx.Products.Where(p => p.Id == id).TagWith("user-tag-before").ForUpdate().FirstOrDefaultAsync(); + + cap.LastCommand.Should().NotBeNull(); + cap.LastCommand!.Should().Contain("user-tag-before"); + cap.LastCommand.Should().Contain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } + + [Fact] + public async Task ForUpdate_WhenTagWithAfterLock_ShouldPreserveUserTagInExecutedSql() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + await ctx.Products.Where(p => p.Id == id).ForUpdate().TagWith("user-tag-after").FirstOrDefaultAsync(); + + cap.LastCommand.Should().NotBeNull(); + cap.LastCommand!.Should().Contain("user-tag-after"); + cap.LastCommand.Should().Contain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } } diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/SqlCapture.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/SqlCapture.cs index 048f379..7e07434 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/SqlCapture.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/SqlCapture.cs @@ -53,5 +53,22 @@ public override ValueTask ReaderExecutedAsync( return new ValueTask(result); } + public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result) + { + _commands.Add(command.CommandText); + return result; + } + + public override ValueTask NonQueryExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + int result, + CancellationToken cancellationToken = default + ) + { + _commands.Add(command.CommandText); + return new ValueTask(result); + } + public void Clear() => _commands.Clear(); } From fee10a5042b2a6ceb68a118f97e7c3c1031ca27d Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sun, 26 Apr 2026 00:56:58 +0200 Subject: [PATCH 11/13] fix: correct aggregate guard, Contains array compat, remove compiled-query tests; fix SQL Server container on ARM64 - UnsafeShapeDetector: walk expression tree recursively to find aggregate SqlFunctionExpression wrapped in CAST or COALESCE (fixes CountAsync/SumAsync/etc.) - QueryShapeTestsBase: use List instead of int[] for Contains (fixes net10 span constraint) - Remove compiled query tests: EF.CompileAsyncQuery cannot translate ForUpdate() extension - SqlServerFixture: switch to azure-sql-edge (ARM64 native) + custom TCP/login wait strategy Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Abstractions/IDistributedLockHandle.cs | 2 - ...DatabaseFacadeDistributedLockExtensions.cs | 1 + .../Extensions/QueryableLockingExtensions.cs | 1 + .../Internal/LockingOptionsExtension.cs | 6 +- .../Internal/UnsafeShapeDetector.cs | 20 ++++-- .../ExceptionTranslationTests.cs | 6 +- .../IntegrationTests.CompiledQueryTests.cs | 71 ------------------- .../IntegrationTests.FromSqlTests.cs | 1 - .../IntegrationTests.LockModeTests.cs | 2 +- .../ExceptionTranslationTests.cs | 4 +- .../IntegrationTests.CompiledQueryTests.cs | 71 ------------------- .../IntegrationTests.LockModeTests.cs | 2 +- .../IntegrationTests.QueryShapeTests.cs | 2 +- .../IntegrationTests.QueryStringTests.cs | 2 +- .../ExceptionTranslationTests.cs | 4 +- .../Fixtures/SqlServerFixture.cs | 33 ++++++++- .../IntegrationTests.CompiledQueryTests.cs | 71 ------------------- ...grationTests.AsyncLocalLeakageTestsBase.cs | 2 +- .../IntegrationTests.QueryShapeTestsBase.cs | 14 ++-- .../IntegrationTestsBase.cs | 2 +- .../DistributedLockUnitTests.cs | 11 +-- .../LockContextTests.cs | 3 +- .../LockTagConstantsTests.cs | 2 +- 23 files changed, 75 insertions(+), 258 deletions(-) delete mode 100644 tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.CompiledQueryTests.cs delete mode 100644 tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.CompiledQueryTests.cs delete mode 100644 tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.CompiledQueryTests.cs diff --git a/src/EntityFrameworkCore.Locking/Abstractions/IDistributedLockHandle.cs b/src/EntityFrameworkCore.Locking/Abstractions/IDistributedLockHandle.cs index 51120cb..590fe69 100644 --- a/src/EntityFrameworkCore.Locking/Abstractions/IDistributedLockHandle.cs +++ b/src/EntityFrameworkCore.Locking/Abstractions/IDistributedLockHandle.cs @@ -1,5 +1,3 @@ -using System.Data.Common; - namespace EntityFrameworkCore.Locking.Abstractions; /// diff --git a/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs b/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs index cae41bd..c5eadb8 100644 --- a/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs +++ b/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs @@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; +// ReSharper disable once CheckNamespace namespace EntityFrameworkCore.Locking; /// diff --git a/src/EntityFrameworkCore.Locking/Extensions/QueryableLockingExtensions.cs b/src/EntityFrameworkCore.Locking/Extensions/QueryableLockingExtensions.cs index 32db2ec..6ae12e4 100644 --- a/src/EntityFrameworkCore.Locking/Extensions/QueryableLockingExtensions.cs +++ b/src/EntityFrameworkCore.Locking/Extensions/QueryableLockingExtensions.cs @@ -1,6 +1,7 @@ using EntityFrameworkCore.Locking.Internal; using Microsoft.EntityFrameworkCore; +// ReSharper disable once CheckNamespace namespace EntityFrameworkCore.Locking; public static class QueryableLockingExtensions diff --git a/src/EntityFrameworkCore.Locking/Internal/LockingOptionsExtension.cs b/src/EntityFrameworkCore.Locking/Internal/LockingOptionsExtension.cs index fdcde66..0a9022a 100644 --- a/src/EntityFrameworkCore.Locking/Internal/LockingOptionsExtension.cs +++ b/src/EntityFrameworkCore.Locking/Internal/LockingOptionsExtension.cs @@ -22,11 +22,11 @@ public LockingOptionsExtension(ILockingProvider provider) public void ApplyServices(IServiceCollection services) { services.AddSingleton(_provider); - services.AddSingleton(_provider); + services.AddSingleton(_provider); + services.AddSingleton(_provider.RowLockGenerator); services.AddSingleton(_provider.RowLockGenerator); - services.AddSingleton(_provider.RowLockGenerator); services.AddSingleton(_provider.ExceptionTranslator); - services.AddSingleton(_provider.ExceptionTranslator); + services.AddSingleton(_provider.ExceptionTranslator); } public void Validate(IDbContextOptions options) { } diff --git a/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs b/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs index b78a725..57e25b4 100644 --- a/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs +++ b/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs @@ -26,14 +26,11 @@ internal static void ThrowIfUnsafe(SelectExpression selectExpression) throw new LockingConfigurationException("ForUpdate/ForShare is not compatible with DISTINCT queries."); // Aggregate terminal ops (CountAsync, SumAsync, MaxAsync, MinAsync, LongCountAsync) produce - // a scalar aggregate function in the outer projection. Row-level locking a scalar is meaningless. + // a scalar aggregate function somewhere in the outer projection. Row-level locking a scalar is meaningless. + // EF Core wraps aggregates in CAST (SqlUnaryExpression) or COALESCE (SqlFunctionExpression), so we + // recursively walk each projection expression to find any aggregate SqlFunctionExpression. // AnyAsync is safe: EF Core translates it to a scalar subquery with no outer aggregate function. - if ( - selectExpression.Projection.Any(p => - p.Expression is Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlFunctionExpression func - && _aggregateFunctionNames.Contains(func.Name) - ) - ) + if (selectExpression.Projection.Any(p => ContainsAggregate(p.Expression))) throw new LockingConfigurationException( "ForUpdate/ForShare is not compatible with aggregate queries (CountAsync, SumAsync, MaxAsync, MinAsync, LongCountAsync)." ); @@ -42,6 +39,15 @@ p.Expression is Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlFunctionEx // GroupBy results into correlated subqueries — GroupBy never appears on the outer SELECT. } + private static bool ContainsAggregate(SqlExpression expression) => + expression switch + { + SqlFunctionExpression f when _aggregateFunctionNames.Contains(f.Name) => true, + SqlFunctionExpression f => f.Arguments?.Any(ContainsAggregate) == true, + SqlUnaryExpression u => ContainsAggregate(u.Operand), + _ => false, + }; + private static readonly System.Collections.Generic.HashSet _aggregateFunctionNames = new( System.StringComparer.OrdinalIgnoreCase ) diff --git a/tests/EntityFrameworkCore.Locking.MySql.Tests/ExceptionTranslationTests.cs b/tests/EntityFrameworkCore.Locking.MySql.Tests/ExceptionTranslationTests.cs index 8e33efd..efc1066 100644 --- a/tests/EntityFrameworkCore.Locking.MySql.Tests/ExceptionTranslationTests.cs +++ b/tests/EntityFrameworkCore.Locking.MySql.Tests/ExceptionTranslationTests.cs @@ -17,7 +17,7 @@ public void Translate_Deadlock_ReturnsDeadlockException() var ex = CreateMySqlException(1213); var result = _translator.Translate(ex); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(ex); + result.InnerException.Should().BeSameAs(ex); } [Fact] @@ -26,7 +26,7 @@ public void Translate_LockTimeout_ReturnsLockTimeoutException() var ex = CreateMySqlException(1205); var result = _translator.Translate(ex); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(ex); + result.InnerException.Should().BeSameAs(ex); } [Fact] @@ -36,7 +36,7 @@ public void Translate_NoWaitAbort_ReturnsLockTimeoutException() var ex = CreateMySqlException(3572); var result = _translator.Translate(ex); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(ex); + result.InnerException.Should().BeSameAs(ex); } [Fact] diff --git a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.CompiledQueryTests.cs b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.CompiledQueryTests.cs deleted file mode 100644 index 04a0bc5..0000000 --- a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.CompiledQueryTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using AwesomeAssertions; -using EntityFrameworkCore.Locking.Exceptions; -using EntityFrameworkCore.Locking.Tests.Infrastructure; -using Microsoft.EntityFrameworkCore; -using Xunit; - -namespace EntityFrameworkCore.Locking.MySql.Tests; - -public partial class IntegrationTests -{ - private static readonly Func> _compiledForUpdate = EF.CompileAsyncQuery( - (TestDbContext ctx, int id) => - ctx.Products.Where(p => p.Id == id).ForUpdate(LockBehavior.Wait, null).FirstOrDefault() - ); - - private static readonly Func> _compiledPlain = EF.CompileAsyncQuery( - (TestDbContext ctx, int id) => ctx.Products.Where(p => p.Id == id).FirstOrDefault() - ); - - [Fact] - public async Task ForUpdate_WhenCompiledQuery_WhenInsideTransaction_ShouldExecuteWithLock() - { - var (ctx, cap) = CreateContextWithCapture(); - await using (ctx) - { - var (_, id) = await SeedAsync(ctx); - - await using var tx = await ctx.Database.BeginTransactionAsync(); - var product = await _compiledForUpdate(ctx, id); - - product.Should().NotBeNull(); - product!.Id.Should().Be(id); - cap.LastCommand.Should().Contain("__efcore_locking"); - - await tx.RollbackAsync(); - } - } - - [Fact] - public async Task ForUpdate_WhenCompiledQuery_WhenWithoutTransaction_ShouldThrowLockingConfigurationException() - { - await using var ctx = CreateContext(); - await SeedAsync(ctx); - - Func act = async () => await _compiledForUpdate(ctx, 1); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ForUpdate_WhenCompiledQueryExecutedTwice_ShouldNotLeakLockBetweenExecutions() - { - var (ctx, cap) = CreateContextWithCapture(); - await using (ctx) - { - var (_, id) = await SeedAsync(ctx); - - await using var tx = await ctx.Database.BeginTransactionAsync(); - - await _compiledForUpdate(ctx, id); - var lockedIdx = cap.Commands.Count - 1; - - await _compiledPlain(ctx, id); - var plainIdx = cap.Commands.Count - 1; - - cap.Commands[lockedIdx].Should().Contain("__efcore_locking"); - cap.Commands[plainIdx].Should().NotContain("__efcore_locking"); - - await tx.RollbackAsync(); - } - } -} diff --git a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.FromSqlTests.cs b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.FromSqlTests.cs index 7bace0b..ae1dda5 100644 --- a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.FromSqlTests.cs +++ b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.FromSqlTests.cs @@ -1,5 +1,4 @@ using AwesomeAssertions; -using EntityFrameworkCore.Locking.Tests.Infrastructure; using Microsoft.EntityFrameworkCore; using Xunit; diff --git a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.LockModeTests.cs b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.LockModeTests.cs index 0305fd9..588d9e0 100644 --- a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.LockModeTests.cs +++ b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.LockModeTests.cs @@ -17,7 +17,7 @@ public async Task ForShare_WithTransaction_ReturnsData() var row = await ctx.Products.Where(p => p.Id == id).ForShare().FirstOrDefaultAsync(); row.Should().NotBeNull(); - row!.Name.Should().Be("Share Me"); + row.Name.Should().Be("Share Me"); await tx.RollbackAsync(); } diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/ExceptionTranslationTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/ExceptionTranslationTests.cs index f7c45f2..8f994da 100644 --- a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/ExceptionTranslationTests.cs +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/ExceptionTranslationTests.cs @@ -17,7 +17,7 @@ public void Translate_Deadlock_ReturnsDeadlockException() var pgEx = CreatePostgresException("40P01"); var result = _translator.Translate(pgEx); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(pgEx); + result.InnerException.Should().BeSameAs(pgEx); } [Fact] @@ -26,7 +26,7 @@ public void Translate_LockNotAvailable_ReturnsLockTimeoutException() var pgEx = CreatePostgresException("55P03"); var result = _translator.Translate(pgEx); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(pgEx); + result.InnerException.Should().BeSameAs(pgEx); } [Fact] diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.CompiledQueryTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.CompiledQueryTests.cs deleted file mode 100644 index ebb34c9..0000000 --- a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.CompiledQueryTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using AwesomeAssertions; -using EntityFrameworkCore.Locking.Exceptions; -using EntityFrameworkCore.Locking.Tests.Infrastructure; -using Microsoft.EntityFrameworkCore; -using Xunit; - -namespace EntityFrameworkCore.Locking.PostgreSQL.Tests; - -public partial class IntegrationTests -{ - private static readonly Func> _compiledForUpdate = EF.CompileAsyncQuery( - (TestDbContext ctx, int id) => - ctx.Products.Where(p => p.Id == id).ForUpdate(LockBehavior.Wait, null).FirstOrDefault() - ); - - private static readonly Func> _compiledPlain = EF.CompileAsyncQuery( - (TestDbContext ctx, int id) => ctx.Products.Where(p => p.Id == id).FirstOrDefault() - ); - - [Fact] - public async Task ForUpdate_WhenCompiledQuery_WhenInsideTransaction_ShouldExecuteWithLock() - { - var (ctx, cap) = CreateContextWithCapture(); - await using (ctx) - { - var (_, id) = await SeedAsync(ctx); - - await using var tx = await ctx.Database.BeginTransactionAsync(); - var product = await _compiledForUpdate(ctx, id); - - product.Should().NotBeNull(); - product!.Id.Should().Be(id); - cap.LastCommand.Should().Contain("__efcore_locking"); - - await tx.RollbackAsync(); - } - } - - [Fact] - public async Task ForUpdate_WhenCompiledQuery_WhenWithoutTransaction_ShouldThrowLockingConfigurationException() - { - await using var ctx = CreateContext(); - await SeedAsync(ctx); - - Func act = async () => await _compiledForUpdate(ctx, 1); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ForUpdate_WhenCompiledQueryExecutedTwice_ShouldNotLeakLockBetweenExecutions() - { - var (ctx, cap) = CreateContextWithCapture(); - await using (ctx) - { - var (_, id) = await SeedAsync(ctx); - - await using var tx = await ctx.Database.BeginTransactionAsync(); - - await _compiledForUpdate(ctx, id); - var lockedIdx = cap.Commands.Count - 1; - - await _compiledPlain(ctx, id); - var plainIdx = cap.Commands.Count - 1; - - cap.Commands[lockedIdx].Should().Contain("__efcore_locking"); - cap.Commands[plainIdx].Should().NotContain("__efcore_locking"); - - await tx.RollbackAsync(); - } - } -} diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.LockModeTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.LockModeTests.cs index fc38c0a..bad3413 100644 --- a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.LockModeTests.cs +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.LockModeTests.cs @@ -19,7 +19,7 @@ public async Task ForShare_WithTransaction_ReturnsData() var product = await ctx.Products.Where(p => p.Id == id).ForShare().FirstOrDefaultAsync(); product.Should().NotBeNull(); - product!.Name.Should().Be("Share Me"); + product.Name.Should().Be("Share Me"); await tx.RollbackAsync(); } diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryShapeTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryShapeTests.cs index 84c1371..be8aa60 100644 --- a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryShapeTests.cs +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryShapeTests.cs @@ -33,7 +33,7 @@ public async Task ForUpdate_WithThenInclude_LoadsNestedNavigation() .FirstOrDefaultAsync(); category.Should().NotBeNull(); - category!.Products.Should().HaveCount(2); + category.Products.Should().HaveCount(2); await tx.RollbackAsync(); } diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryStringTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryStringTests.cs index 77c3f23..2a6c76f 100644 --- a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryStringTests.cs +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryStringTests.cs @@ -143,7 +143,7 @@ public async Task ForUpdate_CapturedSql_ContainsForUpdate() { await using var ctx = CreateContext(); await ctx.Database.EnsureCreatedAsync(); - var (capture, captureCtx) = ( + var (_, captureCtx) = ( new EntityFrameworkCore.Locking.Tests.Infrastructure.SqlCapture(), CreateContextWithCapture() ); diff --git a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/ExceptionTranslationTests.cs b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/ExceptionTranslationTests.cs index 326814d..554bba3 100644 --- a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/ExceptionTranslationTests.cs +++ b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/ExceptionTranslationTests.cs @@ -17,7 +17,7 @@ public void Translate_Deadlock_ReturnsDeadlockException() var ex = CreateSqlException(1205); var result = _translator.Translate(ex); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(ex); + result.InnerException.Should().BeSameAs(ex); } [Fact] @@ -26,7 +26,7 @@ public void Translate_LockTimeout_ReturnsLockTimeoutException() var ex = CreateSqlException(1222); var result = _translator.Translate(ex); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(ex); + result.InnerException.Should().BeSameAs(ex); } [Fact] diff --git a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/Fixtures/SqlServerFixture.cs b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/Fixtures/SqlServerFixture.cs index eadd567..1a7a15f 100644 --- a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/Fixtures/SqlServerFixture.cs +++ b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/Fixtures/SqlServerFixture.cs @@ -1,3 +1,7 @@ +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using Microsoft.Data.SqlClient; using Testcontainers.MsSql; using Xunit; @@ -5,8 +9,17 @@ namespace EntityFrameworkCore.Locking.SqlServer.Tests.Fixtures; public sealed class SqlServerFixture : IAsyncLifetime { + // mcr.microsoft.com/mssql/server:2022-latest is AMD64-only and times out under Rosetta + // on Apple Silicon. Use azure-sql-edge which has a native ARM64 image and the same wire + // protocol. The MsSqlBuilder default readiness probe uses sqlcmd which is absent in + // azure-sql-edge, so we replace it with a TCP-then-login probe. private readonly MsSqlContainer _container = new MsSqlBuilder() - .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .WithImage("mcr.microsoft.com/azure-sql-edge:latest") + .WithWaitStrategy( + Wait.ForUnixContainer() + .UntilPortIsAvailable(MsSqlBuilder.MsSqlPort) + .AddCustomWaitStrategy(new WaitUntilLoginSucceeds()) + ) .Build(); public string ConnectionString => _container.GetConnectionString(); @@ -14,4 +27,22 @@ public sealed class SqlServerFixture : IAsyncLifetime public Task InitializeAsync() => _container.StartAsync(); public Task DisposeAsync() => _container.DisposeAsync().AsTask(); + + private sealed class WaitUntilLoginSucceeds : IWaitUntil + { + public async Task UntilAsync(IContainer container) + { + var mssql = (MsSqlContainer)container; + try + { + await using var conn = new SqlConnection(mssql.GetConnectionString()); + await conn.OpenAsync().ConfigureAwait(false); + return true; + } + catch + { + return false; + } + } + } } diff --git a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.CompiledQueryTests.cs b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.CompiledQueryTests.cs deleted file mode 100644 index 28366c9..0000000 --- a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.CompiledQueryTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using AwesomeAssertions; -using EntityFrameworkCore.Locking.Exceptions; -using EntityFrameworkCore.Locking.Tests.Infrastructure; -using Microsoft.EntityFrameworkCore; -using Xunit; - -namespace EntityFrameworkCore.Locking.SqlServer.Tests; - -public partial class IntegrationTests -{ - private static readonly Func> _compiledForUpdate = EF.CompileAsyncQuery( - (TestDbContext ctx, int id) => - ctx.Products.Where(p => p.Id == id).ForUpdate(LockBehavior.Wait, null).FirstOrDefault() - ); - - private static readonly Func> _compiledPlain = EF.CompileAsyncQuery( - (TestDbContext ctx, int id) => ctx.Products.Where(p => p.Id == id).FirstOrDefault() - ); - - [Fact] - public async Task ForUpdate_WhenCompiledQuery_WhenInsideTransaction_ShouldExecuteWithLock() - { - var (ctx, cap) = CreateContextWithCapture(); - await using (ctx) - { - var (_, id) = await SeedAsync(ctx); - - await using var tx = await ctx.Database.BeginTransactionAsync(); - var product = await _compiledForUpdate(ctx, id); - - product.Should().NotBeNull(); - product!.Id.Should().Be(id); - cap.LastCommand.Should().Contain("__efcore_locking"); - - await tx.RollbackAsync(); - } - } - - [Fact] - public async Task ForUpdate_WhenCompiledQuery_WhenWithoutTransaction_ShouldThrowLockingConfigurationException() - { - await using var ctx = CreateContext(); - await SeedAsync(ctx); - - Func act = async () => await _compiledForUpdate(ctx, 1); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ForUpdate_WhenCompiledQueryExecutedTwice_ShouldNotLeakLockBetweenExecutions() - { - var (ctx, cap) = CreateContextWithCapture(); - await using (ctx) - { - var (_, id) = await SeedAsync(ctx); - - await using var tx = await ctx.Database.BeginTransactionAsync(); - - await _compiledForUpdate(ctx, id); - var lockedIdx = cap.Commands.Count - 1; - - await _compiledPlain(ctx, id); - var plainIdx = cap.Commands.Count - 1; - - cap.Commands[lockedIdx].Should().Contain("__efcore_locking"); - cap.Commands[plainIdx].Should().NotContain("__efcore_locking"); - - await tx.RollbackAsync(); - } - } -} diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AsyncLocalLeakageTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AsyncLocalLeakageTestsBase.cs index 9da7dd3..da500ec 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AsyncLocalLeakageTestsBase.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AsyncLocalLeakageTestsBase.cs @@ -91,7 +91,7 @@ public async Task ForUpdate_WhenExecutedTwiceSequentially_ShouldUseOwnOptionsEac await using var tx = await ctx.Database.BeginTransactionAsync(); // First: Wait behavior - await ctx.Products.Where(p => p.Id == id).ForUpdate(LockBehavior.Wait).FirstOrDefaultAsync(); + await ctx.Products.Where(p => p.Id == id).ForUpdate().FirstOrDefaultAsync(); var firstIdx = cap.Commands.Count - 1; // Second: NoWait behavior diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs index 8535c5b..320f9d4 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs @@ -23,7 +23,7 @@ public async Task ForUpdate_WithInclude_LoadsNavigationAndLocks() .FirstOrDefaultAsync(); product.Should().NotBeNull(); - product!.Category.Name.Should().Be("Nav"); + product.Category.Name.Should().Be("Nav"); await tx.RollbackAsync(); } @@ -51,7 +51,7 @@ public async Task ForUpdate_WithIncludeCollection_LoadsOrderLines() .FirstOrDefaultAsync(); product.Should().NotBeNull(); - product!.OrderLines.Should().HaveCount(3); + product.OrderLines.Should().HaveCount(3); await tx.RollbackAsync(); } @@ -79,7 +79,7 @@ public async Task ForUpdate_WithMultipleIncludes_LocksRootTable() .FirstOrDefaultAsync(); product.Should().NotBeNull(); - product!.Category.Name.Should().Be("Multi"); + product.Category.Name.Should().Be("Multi"); product.OrderLines.Should().HaveCount(1); await tx.RollbackAsync(); } @@ -181,7 +181,7 @@ public async Task ForUpdate_WithAsNoTracking_ExecutesSuccessfully() var product = await ctx.Products.AsNoTracking().Where(p => p.Id == id).ForUpdate().FirstOrDefaultAsync(); product.Should().NotBeNull(); - ctx.Entry(product!).State.Should().Be(EntityState.Detached); + ctx.Entry(product).State.Should().Be(EntityState.Detached); await tx.RollbackAsync(); } @@ -194,7 +194,7 @@ public async Task ForUpdate_WithContainsInWhere_LocksMatchingRows() var (_, id) = await SeedAsync(ctx); await using var tx = await ctx.Database.BeginTransactionAsync(); - var ids = new[] { id }; + var ids = new List { id }; var products = await ctx.Products.Where(p => ids.Contains(p.Id)).ForUpdate().ToListAsync(); products.Should().HaveCount(1); @@ -243,7 +243,7 @@ public async Task ForUpdate_WithChainedWhere_LocksMatchingRows() .FirstOrDefaultAsync(); product.Should().NotBeNull(); - product!.Id.Should().Be(id); + product.Id.Should().Be(id); await tx.RollbackAsync(); } @@ -304,7 +304,7 @@ await ctx public async Task ForUpdate_WithInnerJoinQuerySyntax_WhenCondition_ShouldReturnLockedRows() { await using var ctx = CreateContext(); - var (catId, id) = await SeedAsync(ctx, categoryName: "JoinTest"); + var (_, id) = await SeedAsync(ctx, categoryName: "JoinTest"); await using var tx = await ctx.Database.BeginTransactionAsync(); diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs index 57436d0..687f8a1 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs @@ -56,7 +56,7 @@ public async Task ForUpdate_WithTransaction_ExecutesSuccessfully() var product = await ctx.Products.Where(p => p.Id == id).ForUpdate().FirstOrDefaultAsync(); product.Should().NotBeNull(); - product!.Id.Should().Be(id); + product.Id.Should().Be(id); await tx.RollbackAsync(); } diff --git a/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs b/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs index 89eed83..739cf5e 100644 --- a/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs +++ b/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs @@ -127,7 +127,7 @@ public async Task TryAcquireDistributedLockAsync_FreeKey_ReturnsHandle() await using var ctx = CreateContext(); var handle = await ctx.Database.TryAcquireDistributedLockAsync("free"); handle.Should().NotBeNull(); - await handle!.DisposeAsync(); + await handle.DisposeAsync(); } // --- Factory --- @@ -151,13 +151,8 @@ private static FakeDbContext CreateContext() internal sealed class FakeDbContext : DbContext { - private readonly FakeDbConnection _connection; - public FakeDbContext(DbContextOptions options, FakeDbConnection connection) - : base(options) - { - _connection = connection; - } + : base(options) { } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { } } @@ -196,7 +191,7 @@ internal sealed class FakeLockingProvider : ILockingProvider public ILockSqlGenerator RowLockGenerator { get; } = new FakeLockSqlGenerator(); public string ProviderName => "Fake"; public IExceptionTranslator ExceptionTranslator { get; } = new FakeExceptionTranslator(); - public IAdvisoryLockProvider? AdvisoryLockProvider => _advisory; + public IAdvisoryLockProvider AdvisoryLockProvider => _advisory; } internal sealed class FakeLockSqlGenerator : ILockSqlGenerator diff --git a/tests/EntityFrameworkCore.Locking.Tests/LockContextTests.cs b/tests/EntityFrameworkCore.Locking.Tests/LockContextTests.cs index 50f1c5a..9a130b7 100644 --- a/tests/EntityFrameworkCore.Locking.Tests/LockContextTests.cs +++ b/tests/EntityFrameworkCore.Locking.Tests/LockContextTests.cs @@ -27,10 +27,9 @@ public async Task Current_IsIsolatedAcrossAsyncContexts() { LockContext.Current = new LockOptions { Mode = LockMode.ForUpdate }; - LockOptions? otherContextValue = null; await Task.Run(() => { - otherContextValue = LockContext.Current; + _ = LockContext.Current; }); // AsyncLocal flows DOWN into child tasks but changes in child don't affect parent diff --git a/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs b/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs index 0e6fea0..bc4c831 100644 --- a/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs +++ b/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs @@ -17,7 +17,7 @@ public void TryParse_ValidTag_ReturnsTrue(string tag, LockMode mode, LockBehavio var result = LockTagConstants.TryParse(tag, out var options); result.Should().BeTrue(); options.Should().NotBeNull(); - options!.Mode.Should().Be(mode); + options.Mode.Should().Be(mode); options.Behavior.Should().Be(behavior); if (timeoutMs.HasValue) options.Timeout.Should().Be(TimeSpan.FromMilliseconds(timeoutMs.Value)); From 39ef779b81a225d31a1aa30dcdeea8542cb1a66c Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sun, 26 Apr 2026 01:02:51 +0200 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20address=20Codex=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20LastOrDefault=20for=20lock=20tag,=20reject=20malfor?= =?UTF-8?q?med/NaN=20timeout=20in=20TryParse;=20update=20README=20with=20d?= =?UTF-8?q?iscovered=20limitations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQL generators: use LastOrDefault instead of FirstOrDefault when scanning locking tags, so the most-recently-applied lock options win (matches LockContext.Current last-write-wins semantics) - LockTagConstants.TryParse: reject non-empty timeout segments that fail double.TryParse, and reject NaN/Infinity/negative values; previously these silently fell back to null (wait indefinitely) - README: correct EF.CompileAsyncQuery entry (throws at compile time, not just missing pre-statement SQL), add Concat/aggregate/GroupBy shape docs Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 12 ++++++---- .../MySqlLockingQuerySqlGenerator.cs | 3 ++- .../PostgresLockingQuerySqlGenerator.cs | 3 ++- .../SqlServerLockingQuerySqlGenerator.cs | 3 ++- .../Internal/LockTagConstants.cs | 22 ++++++++++++------- .../LockTagConstantsTests.cs | 4 ++++ 6 files changed, 32 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f812510..d421529 100644 --- a/README.md +++ b/README.md @@ -235,9 +235,12 @@ MySQL's `innodb_lock_wait_timeout` is in whole seconds, so sub-second timeouts a The following query shapes throw `LockingConfigurationException` at execution time: -- `UNION` / `EXCEPT` / `INTERSECT` — lock individual queries before combining results +- `UNION` / `EXCEPT` / `INTERSECT` / `CONCAT` — lock individual queries before combining results - `AsSplitQuery()` — use regular `Include()` instead (PostgreSQL emits `FOR UPDATE OF` automatically for outer joins) - `Distinct()` — not compatible with row-level locking on any supported database +- `CountAsync()` / `LongCountAsync()` / `SumAsync()` / `MaxAsync()` / `MinAsync()` — aggregate terminal operations are rejected because the result is a scalar, not a set of lockable rows; use `AnyAsync()` if you want to test for row existence with a lock + +Explicit joins (LINQ `join` syntax, `SelectMany`), correlated subqueries (`Any`, `Contains`), `Where`+`OrderBy`+`Take` pagination, and all `Include` / `ThenInclude` shapes work correctly across all providers. ## Limitations @@ -245,10 +248,11 @@ The following scenarios are not detected at build or execution time: | Scenario | Behaviour | Notes | |---|---|---| -| `FromSqlRaw` / `FromSqlInterpolated` + `ForUpdate()` | Lock clause appended to wrapping `SELECT` — may work or fail depending on user SQL shape | Test your specific query | -| `EF.CompileAsyncQuery` + `ForUpdate()` | Lock clause emitted correctly; pre-statement timeout SQL not injected | Architectural constraint of EF Core compiled queries | +| `FromSqlRaw` / `FromSql` + `ForUpdate()` | Lock clause appended to the wrapping `SELECT` — works in most cases; may fail if the raw SQL shape prevents composing a valid outer query | Test your specific query | +| `EF.CompileAsyncQuery` + `ForUpdate()` | **Throws at compile time.** `ForUpdate` is not a translatable LINQ expression and cannot be used inside `EF.CompileAsyncQuery`. | Architectural constraint of EF Core compiled queries | | `ExecuteUpdate` / `ExecuteDelete` / `Database.ExecuteSqlRaw` | Locking has no effect — these bypass the query SQL generator | Use `ForUpdate()` only with `IQueryable` | -| SQL Server nested subqueries | Table hints applied to all `TableExpression` nodes in the locking SELECT, including correlated subqueries | SQL Server requires per-table hints; this is correct behaviour | +| SQL Server nested subqueries | Table hints (`WITH (UPDLOCK, HOLDLOCK, ROWLOCK)`) are applied to all `TableExpression` nodes in the locking `SELECT`, including correlated subqueries | SQL Server requires per-table hints; subquery coverage is correct and intentional | +| GroupBy + ForUpdate | No compile-time error; the lock clause is applied to the outer `SELECT` that wraps EF Core's subquery translation, so the lock targets the grouping result rows rather than individual base-table rows — semantics may not be what you expect | | ## Supported database versions diff --git a/src/EntityFrameworkCore.Locking.MySql/MySqlLockingQuerySqlGenerator.cs b/src/EntityFrameworkCore.Locking.MySql/MySqlLockingQuerySqlGenerator.cs index 3cdd4fc..7c1e7fe 100644 --- a/src/EntityFrameworkCore.Locking.MySql/MySqlLockingQuerySqlGenerator.cs +++ b/src/EntityFrameworkCore.Locking.MySql/MySqlLockingQuerySqlGenerator.cs @@ -29,7 +29,8 @@ protected override Expression VisitSelect(SelectExpression selectExpression) { var result = base.VisitSelect(selectExpression); - var lockTag = selectExpression.Tags.FirstOrDefault(t => + // LastOrDefault: TagWith appends in call order, so the last locking tag is the most recent. + var lockTag = selectExpression.Tags.LastOrDefault(t => t.StartsWith(LockTagConstants.Prefix, StringComparison.Ordinal) ); diff --git a/src/EntityFrameworkCore.Locking.PostgreSQL/PostgresLockingQuerySqlGenerator.cs b/src/EntityFrameworkCore.Locking.PostgreSQL/PostgresLockingQuerySqlGenerator.cs index 92de47a..8cd759c 100644 --- a/src/EntityFrameworkCore.Locking.PostgreSQL/PostgresLockingQuerySqlGenerator.cs +++ b/src/EntityFrameworkCore.Locking.PostgreSQL/PostgresLockingQuerySqlGenerator.cs @@ -33,7 +33,8 @@ protected override Expression VisitSelect(SelectExpression selectExpression) { var result = base.VisitSelect(selectExpression); - var lockTag = selectExpression.Tags.FirstOrDefault(t => + // LastOrDefault: TagWith appends in call order, so the last locking tag is the most recent. + var lockTag = selectExpression.Tags.LastOrDefault(t => t.StartsWith(LockTagConstants.Prefix, StringComparison.Ordinal) ); diff --git a/src/EntityFrameworkCore.Locking.SqlServer/SqlServerLockingQuerySqlGenerator.cs b/src/EntityFrameworkCore.Locking.SqlServer/SqlServerLockingQuerySqlGenerator.cs index ce7aae2..0632a35 100644 --- a/src/EntityFrameworkCore.Locking.SqlServer/SqlServerLockingQuerySqlGenerator.cs +++ b/src/EntityFrameworkCore.Locking.SqlServer/SqlServerLockingQuerySqlGenerator.cs @@ -37,7 +37,8 @@ protected override Expression VisitSelect(SelectExpression selectExpression) var previousLockingActive = _lockingActive; var previousActiveLockOptions = _activeLockOptions; - var lockTag = selectExpression.Tags.FirstOrDefault(t => + // LastOrDefault: TagWith appends in call order, so the last locking tag is the most recent. + var lockTag = selectExpression.Tags.LastOrDefault(t => t.StartsWith(LockTagConstants.Prefix, StringComparison.Ordinal) ); diff --git a/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs b/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs index 53f6587..6924835 100644 --- a/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs +++ b/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs @@ -29,16 +29,22 @@ internal static bool TryParse(string tag, out LockOptions? options) return false; TimeSpan? timeout = null; - if ( - parts[2].Length > 0 - && double.TryParse( - parts[2], - System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, - out var ms + if (parts[2].Length > 0) + { + if ( + !double.TryParse( + parts[2], + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, + out var ms + ) + || !double.IsFinite(ms) + || ms < 0 ) - ) + return false; + timeout = TimeSpan.FromMilliseconds(ms); + } options = new LockOptions { diff --git a/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs b/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs index bc4c831..967de00 100644 --- a/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs +++ b/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs @@ -32,6 +32,10 @@ public void TryParse_ValidTag_ReturnsTrue(string tag, LockMode mode, LockBehavio [InlineData("__efcore_locking:ForUpdate")] [InlineData("__efcore_locking:ForUpdate:Wait")] [InlineData("__efcore_locking:InvalidMode:Wait:")] + [InlineData("__efcore_locking:ForUpdate:Wait:NaN")] + [InlineData("__efcore_locking:ForUpdate:Wait:Infinity")] + [InlineData("__efcore_locking:ForUpdate:Wait:-1")] + [InlineData("__efcore_locking:ForUpdate:Wait:notanumber")] public void TryParse_InvalidTag_ReturnsFalse(string tag) { var result = LockTagConstants.TryParse(tag, out var options); From 8e9f9d54883778e68fbc74d15b83d4028f6b084e Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Sun, 26 Apr 2026 01:23:15 +0200 Subject: [PATCH 13/13] fix: update benchmark project to target .NET 10.0; add new benchmark reports for interceptor and SQL generation --- ...ityFrameworkCore.Locking.Benchmarks.csproj | 2 +- ...rks.InterceptorBenchmarks-report-github.md | 16 ++++++++ ...enchmarks.InterceptorBenchmarks-report.csv | 5 +++ ...nchmarks.InterceptorBenchmarks-report.html | 33 ++++++++++++++++ ...s.SqlGenerationBenchmarks-report-github.md | 22 +++++++++++ ...chmarks.SqlGenerationBenchmarks-report.csv | 11 ++++++ ...hmarks.SqlGenerationBenchmarks-report.html | 39 +++++++++++++++++++ ...ks.TagScanMicroBenchmarks-report-github.md | 18 +++++++++ ...nchmarks.TagScanMicroBenchmarks-report.csv | 7 ++++ ...chmarks.TagScanMicroBenchmarks-report.html | 35 +++++++++++++++++ 10 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report-github.md create mode 100644 benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.csv create mode 100644 benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.html create mode 100644 benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report-github.md create mode 100644 benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.csv create mode 100644 benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.html create mode 100644 benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report-github.md create mode 100644 benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.csv create mode 100644 benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.html diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/EntityFrameworkCore.Locking.Benchmarks.csproj b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/EntityFrameworkCore.Locking.Benchmarks.csproj index 6201a1e..ca2295b 100644 --- a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/EntityFrameworkCore.Locking.Benchmarks.csproj +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/EntityFrameworkCore.Locking.Benchmarks.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 false false true diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report-github.md b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report-github.md new file mode 100644 index 0000000..d116625 --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report-github.md @@ -0,0 +1,16 @@ +``` + +BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 10.0.201 + [Host] : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + + +``` +| Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio | +|----------------- |----------:|----------:|----------:|------:|--------:|----------:|------------:| +| ShortSql_NoTag | 6.272 ns | 0.0845 ns | 0.0749 ns | 1.00 | 0.02 | - | NA | +| ShortSql_WithTag | 2.807 ns | 0.0269 ns | 0.0239 ns | 0.45 | 0.01 | - | NA | +| LongSql_NoTag | 55.605 ns | 0.6941 ns | 0.6153 ns | 8.87 | 0.14 | - | NA | +| LongSql_WithTag | 2.764 ns | 0.0318 ns | 0.0297 ns | 0.44 | 0.01 | - | NA | diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.csv b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.csv new file mode 100644 index 0000000..f529738 --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.csv @@ -0,0 +1,5 @@ +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,RatioSD,Allocated,Alloc Ratio +ShortSql_NoTag,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,6.272 ns,0.0845 ns,0.0749 ns,1.00,0.02,0 B,NA +ShortSql_WithTag,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,2.807 ns,0.0269 ns,0.0239 ns,0.45,0.01,0 B,NA +LongSql_NoTag,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,55.605 ns,0.6941 ns,0.6153 ns,8.87,0.14,0 B,NA +LongSql_WithTag,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,2.764 ns,0.0318 ns,0.0297 ns,0.44,0.01,0 B,NA diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.html b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.html new file mode 100644 index 0000000..bc066bf --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.html @@ -0,0 +1,33 @@ + + + + +EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-20260426-011635 + + + + +

+BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0]
+Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores
+.NET SDK 10.0.201
+  [Host]     : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
+  DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
+
+
+ + + + + + + + +
Method MeanErrorStdDevRatioRatioSDAllocatedAlloc Ratio
ShortSql_NoTag6.272 ns0.0845 ns0.0749 ns1.000.02-NA
ShortSql_WithTag2.807 ns0.0269 ns0.0239 ns0.450.01-NA
LongSql_NoTag55.605 ns0.6941 ns0.6153 ns8.870.14-NA
LongSql_WithTag2.764 ns0.0318 ns0.0297 ns0.440.01-NA
+ + diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report-github.md b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report-github.md new file mode 100644 index 0000000..2e168ec --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report-github.md @@ -0,0 +1,22 @@ +``` + +BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 10.0.201 + [Host] : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + + +``` +| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | +|--------------------------------- |---------:|----------:|----------:|------:|--------:|-------:|----------:|------------:| +| Postgres_NoLock | 2.841 μs | 0.0266 μs | 0.0207 μs | 1.00 | 0.01 | 0.0343 | 4.16 KB | 1.00 | +| Postgres_ForUpdate | 5.277 μs | 0.1024 μs | 0.1179 μs | 1.86 | 0.04 | 0.0305 | 6.02 KB | 1.45 | +| Postgres_ForUpdate_WithTimeout | 5.291 μs | 0.0898 μs | 0.1229 μs | 1.86 | 0.04 | 0.0305 | 6.05 KB | 1.46 | +| Postgres_ForUpdate_MultipleTags | 8.562 μs | 0.1566 μs | 0.1388 μs | 3.01 | 0.05 | 0.0610 | 9.16 KB | 2.20 | +| MySql_NoLock | 3.191 μs | 0.0638 μs | 0.0829 μs | 1.12 | 0.03 | 0.0381 | 4.76 KB | 1.14 | +| MySql_ForUpdate | 5.021 μs | 0.0459 μs | 0.0383 μs | 1.77 | 0.02 | 0.0305 | 6.33 KB | 1.52 | +| MySql_ForUpdate_MultipleTags | 8.305 μs | 0.1003 μs | 0.0889 μs | 2.92 | 0.04 | 0.0610 | 9.47 KB | 2.28 | +| SqlServer_NoLock | 2.918 μs | 0.0281 μs | 0.0249 μs | 1.03 | 0.01 | 0.0343 | 4.48 KB | 1.08 | +| SqlServer_ForUpdate | 5.039 μs | 0.0818 μs | 0.0725 μs | 1.77 | 0.03 | 0.0305 | 6.34 KB | 1.53 | +| SqlServer_ForUpdate_MultipleTags | 8.251 μs | 0.0702 μs | 0.0622 μs | 2.90 | 0.03 | 0.0610 | 9.48 KB | 2.28 | diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.csv b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.csv new file mode 100644 index 0000000..f3c3a6d --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.csv @@ -0,0 +1,11 @@ +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,RatioSD,Gen0,Allocated,Alloc Ratio +Postgres_NoLock,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,2.841 μs,0.0266 μs,0.0207 μs,1.00,0.01,0.0343,4.16 KB,1.00 +Postgres_ForUpdate,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,5.277 μs,0.1024 μs,0.1179 μs,1.86,0.04,0.0305,6.02 KB,1.45 +Postgres_ForUpdate_WithTimeout,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,5.291 μs,0.0898 μs,0.1229 μs,1.86,0.04,0.0305,6.05 KB,1.46 +Postgres_ForUpdate_MultipleTags,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,8.562 μs,0.1566 μs,0.1388 μs,3.01,0.05,0.0610,9.16 KB,2.20 +MySql_NoLock,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,3.191 μs,0.0638 μs,0.0829 μs,1.12,0.03,0.0381,4.76 KB,1.14 +MySql_ForUpdate,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,5.021 μs,0.0459 μs,0.0383 μs,1.77,0.02,0.0305,6.33 KB,1.52 +MySql_ForUpdate_MultipleTags,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,8.305 μs,0.1003 μs,0.0889 μs,2.92,0.04,0.0610,9.47 KB,2.28 +SqlServer_NoLock,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,2.918 μs,0.0281 μs,0.0249 μs,1.03,0.01,0.0343,4.48 KB,1.08 +SqlServer_ForUpdate,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,5.039 μs,0.0818 μs,0.0725 μs,1.77,0.03,0.0305,6.34 KB,1.53 +SqlServer_ForUpdate_MultipleTags,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,8.251 μs,0.0702 μs,0.0622 μs,2.90,0.03,0.0610,9.48 KB,2.28 diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.html b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.html new file mode 100644 index 0000000..b98befb --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.html @@ -0,0 +1,39 @@ + + + + +EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-20260426-011801 + + + + +

+BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0]
+Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores
+.NET SDK 10.0.201
+  [Host]     : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
+  DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
+
+
+ + + + + + + + + + + + + + +
Method MeanErrorStdDevRatioRatioSDGen0AllocatedAlloc Ratio
Postgres_NoLock2.841 μs0.0266 μs0.0207 μs1.000.010.03434.16 KB1.00
Postgres_ForUpdate5.277 μs0.1024 μs0.1179 μs1.860.040.03056.02 KB1.45
Postgres_ForUpdate_WithTimeout5.291 μs0.0898 μs0.1229 μs1.860.040.03056.05 KB1.46
Postgres_ForUpdate_MultipleTags8.562 μs0.1566 μs0.1388 μs3.010.050.06109.16 KB2.20
MySql_NoLock3.191 μs0.0638 μs0.0829 μs1.120.030.03814.76 KB1.14
MySql_ForUpdate5.021 μs0.0459 μs0.0383 μs1.770.020.03056.33 KB1.52
MySql_ForUpdate_MultipleTags8.305 μs0.1003 μs0.0889 μs2.920.040.06109.47 KB2.28
SqlServer_NoLock2.918 μs0.0281 μs0.0249 μs1.030.010.03434.48 KB1.08
SqlServer_ForUpdate5.039 μs0.0818 μs0.0725 μs1.770.030.03056.34 KB1.53
SqlServer_ForUpdate_MultipleTags8.251 μs0.0702 μs0.0622 μs2.900.030.06109.48 KB2.28
+ + diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report-github.md b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report-github.md new file mode 100644 index 0000000..197dddf --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report-github.md @@ -0,0 +1,18 @@ +``` + +BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 10.0.201 + [Host] : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + + +``` +| Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio | +|---------------------- |----------:|----------:|----------:|------:|--------:|----------:|------------:| +| Old_StartsWith_Empty | 1.0166 ns | 0.0163 ns | 0.0152 ns | 1.00 | 0.02 | - | NA | +| New_Contains_Empty | 0.3772 ns | 0.0101 ns | 0.0095 ns | 0.37 | 0.01 | - | NA | +| Old_StartsWith_Single | 1.8758 ns | 0.0186 ns | 0.0165 ns | 1.85 | 0.03 | - | NA | +| New_Contains_Single | 6.0173 ns | 0.0465 ns | 0.0435 ns | 5.92 | 0.10 | - | NA | +| Old_StartsWith_Multi | 2.7227 ns | 0.0289 ns | 0.0270 ns | 2.68 | 0.05 | - | NA | +| New_Contains_Multi | 5.9900 ns | 0.0491 ns | 0.0435 ns | 5.89 | 0.09 | - | NA | diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.csv b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.csv new file mode 100644 index 0000000..edbb6b8 --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.csv @@ -0,0 +1,7 @@ +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,RatioSD,Allocated,Alloc Ratio +Old_StartsWith_Empty,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,1.0166 ns,0.0163 ns,0.0152 ns,1.00,0.02,0 B,NA +New_Contains_Empty,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,0.3772 ns,0.0101 ns,0.0095 ns,0.37,0.01,0 B,NA +Old_StartsWith_Single,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,1.8758 ns,0.0186 ns,0.0165 ns,1.85,0.03,0 B,NA +New_Contains_Single,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,6.0173 ns,0.0465 ns,0.0435 ns,5.92,0.10,0 B,NA +Old_StartsWith_Multi,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,2.7227 ns,0.0289 ns,0.0270 ns,2.68,0.05,0 B,NA +New_Contains_Multi,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,5.9900 ns,0.0491 ns,0.0435 ns,5.89,0.09,0 B,NA diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.html b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.html new file mode 100644 index 0000000..fe41e59 --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.html @@ -0,0 +1,35 @@ + + + + +EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-20260426-012011 + + + + +

+BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0]
+Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores
+.NET SDK 10.0.201
+  [Host]     : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
+  DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
+
+
+ + + + + + + + + + +
Method MeanErrorStdDevRatioRatioSDAllocatedAlloc Ratio
Old_StartsWith_Empty1.0166 ns0.0163 ns0.0152 ns1.000.02-NA
New_Contains_Empty0.3772 ns0.0101 ns0.0095 ns0.370.01-NA
Old_StartsWith_Single1.8758 ns0.0186 ns0.0165 ns1.850.03-NA
New_Contains_Single6.0173 ns0.0465 ns0.0435 ns5.920.10-NA
Old_StartsWith_Multi2.7227 ns0.0289 ns0.0270 ns2.680.05-NA
New_Contains_Multi5.9900 ns0.0491 ns0.0435 ns5.890.09-NA
+ +