From 44856b036a65fc1d659c051b2ba7f9cc3125d624 Mon Sep 17 00:00:00 2001 From: pengweiqhca Date: Fri, 22 Aug 2025 19:29:09 +0800 Subject: [PATCH 1/9] Add IsDeadlockError Fix #88 --- .../Common/IDbExceptionClassifier.cs | 1 + .../MySQL/MySQLExceptionClassifier.cs | 8 +++- .../Oracle/OracleExceptionClassifier.cs | 5 ++- .../PostgreSQLExceptionClassifier.cs | 3 +- .../SqlServer/SqlServerExceptionClassifier.cs | 4 +- .../Sqlite/SqliteExceptionClassifier.cs | 3 +- .../Common/ExceptionFactory.cs | 3 +- .../Common/ExceptionProcessorInterceptor.cs | 10 +++-- .../Common/Exceptions.cs | 29 ++++++++++++- .../Tests/DatabaseTests.cs | 41 +++++++++++++++++-- .../Tests/DemoContext.cs | 7 ++-- .../Tests/OracleTests.cs | 12 ++++-- 12 files changed, 105 insertions(+), 21 deletions(-) diff --git a/DbExceptionClassifier/Common/IDbExceptionClassifier.cs b/DbExceptionClassifier/Common/IDbExceptionClassifier.cs index ea15a31..86c30cd 100644 --- a/DbExceptionClassifier/Common/IDbExceptionClassifier.cs +++ b/DbExceptionClassifier/Common/IDbExceptionClassifier.cs @@ -9,5 +9,6 @@ public interface IDbExceptionClassifier public bool IsNumericOverflowError(DbException exception); public bool IsUniqueConstraintError(DbException exception); public bool IsMaxLengthExceededError(DbException exception); + public bool IsDeadlockError(DbException exception) => false; } } diff --git a/DbExceptionClassifier/MySQL/MySQLExceptionClassifier.cs b/DbExceptionClassifier/MySQL/MySQLExceptionClassifier.cs index 4372d39..03714eb 100644 --- a/DbExceptionClassifier/MySQL/MySQLExceptionClassifier.cs +++ b/DbExceptionClassifier/MySQL/MySQLExceptionClassifier.cs @@ -59,4 +59,10 @@ public bool IsMaxLengthExceededError(DbException exception) var errorCode = GetErrorCode(exception); return errorCode == MySqlErrorCode.DataTooLong; } -} \ No newline at end of file + + public bool IsDeadlockError(DbException exception) + { + var errorCode = GetErrorCode(exception); + return errorCode is MySqlErrorCode.LockDeadlock or MySqlErrorCode.XARBDeadlock; + } +} diff --git a/DbExceptionClassifier/Oracle/OracleExceptionClassifier.cs b/DbExceptionClassifier/Oracle/OracleExceptionClassifier.cs index f1974f9..f1449bb 100644 --- a/DbExceptionClassifier/Oracle/OracleExceptionClassifier.cs +++ b/DbExceptionClassifier/Oracle/OracleExceptionClassifier.cs @@ -9,6 +9,7 @@ public class OracleExceptionClassifier : IDbExceptionClassifier private const int CannotInsertNull = 1400; private const int CannotUpdateToNull = 1407; private const int UniqueConstraintViolation = 1; + private const int DeadLock = 60; private const int IntegrityConstraintViolation = 2291; private const int ChildRecordFound = 2292; private const int NumericOverflow = 1438; @@ -23,4 +24,6 @@ public class OracleExceptionClassifier : IDbExceptionClassifier public bool IsUniqueConstraintError(DbException exception) => exception is OracleException { Number: UniqueConstraintViolation }; public bool IsMaxLengthExceededError(DbException exception) => exception is OracleException { Number: NumericOrValueError }; -} \ No newline at end of file + + public bool IsDeadlockError(DbException exception) => exception is OracleException { Number: DeadLock }; +} diff --git a/DbExceptionClassifier/PostgreSQL/PostgreSQLExceptionClassifier.cs b/DbExceptionClassifier/PostgreSQL/PostgreSQLExceptionClassifier.cs index 12cad2e..44e04e7 100644 --- a/DbExceptionClassifier/PostgreSQL/PostgreSQLExceptionClassifier.cs +++ b/DbExceptionClassifier/PostgreSQL/PostgreSQLExceptionClassifier.cs @@ -11,4 +11,5 @@ public class PostgreSQLExceptionClassifier : IDbExceptionClassifier public bool IsNumericOverflowError(DbException exception) => exception is PostgresException { SqlState: PostgresErrorCodes.NumericValueOutOfRange }; public bool IsUniqueConstraintError(DbException exception) => exception is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }; public bool IsMaxLengthExceededError(DbException exception) => exception is PostgresException { SqlState: PostgresErrorCodes.StringDataRightTruncation }; -} \ No newline at end of file + public bool IsDeadlockError(DbException exception) => exception is PostgresException { SqlState: "40P01" }; +} diff --git a/DbExceptionClassifier/SqlServer/SqlServerExceptionClassifier.cs b/DbExceptionClassifier/SqlServer/SqlServerExceptionClassifier.cs index ad859a4..d0d8c40 100644 --- a/DbExceptionClassifier/SqlServer/SqlServerExceptionClassifier.cs +++ b/DbExceptionClassifier/SqlServer/SqlServerExceptionClassifier.cs @@ -8,6 +8,7 @@ public class SqlServerExceptionClassifier : IDbExceptionClassifier { private const int ReferenceConstraint = 547; private const int CannotInsertNull = 515; + private const int Deadlock = 1205; private const int CannotInsertDuplicateKeyUniqueIndex = 2601; private const int CannotInsertDuplicateKeyUniqueConstraint = 2627; private const int ArithmeticOverflow = 8115; @@ -21,4 +22,5 @@ public class SqlServerExceptionClassifier : IDbExceptionClassifier public bool IsNumericOverflowError(DbException exception) => exception is SqlException { Number: ArithmeticOverflow }; public bool IsUniqueConstraintError(DbException exception) => exception is SqlException { Number: CannotInsertDuplicateKeyUniqueConstraint or CannotInsertDuplicateKeyUniqueIndex }; public bool IsMaxLengthExceededError(DbException exception) => exception is SqlException { Number: StringOrBinaryDataWouldBeTruncated or StringOrBinaryDataWouldBeTruncated2019 }; -} \ No newline at end of file + public bool IsDeadlockError(DbException exception) => exception is SqlException { Number: Deadlock }; +} diff --git a/DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs b/DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs index 0a3a6fd..c4fd148 100644 --- a/DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs +++ b/DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs @@ -19,4 +19,5 @@ public bool IsUniqueConstraintError(DbException exception) => exception is Sqlit }; public bool IsMaxLengthExceededError(DbException exception) => exception is SqliteException { SqliteExtendedErrorCode: SQLITE_TOOBIG }; -} \ No newline at end of file + public bool IsDeadlockError(DbException exception)=> exception is SqliteException { SqliteExtendedErrorCode: SQLITE_LOCKED_SHAREDCACHE }; +} diff --git a/EntityFramework.Exceptions/Common/ExceptionFactory.cs b/EntityFramework.Exceptions/Common/ExceptionFactory.cs index 7900ea7..14979d4 100644 --- a/EntityFramework.Exceptions/Common/ExceptionFactory.cs +++ b/EntityFramework.Exceptions/Common/ExceptionFactory.cs @@ -16,7 +16,8 @@ internal static Exception Create(ExceptionProcessorInterceptor.DatabaseErr ExceptionProcessorInterceptor.DatabaseError.NumericOverflow => new NumericOverflowException("Numeric overflow", exception.InnerException, entries), ExceptionProcessorInterceptor.DatabaseError.ReferenceConstraint => new ReferenceConstraintException("Reference constraint violation", exception.InnerException, entries), ExceptionProcessorInterceptor.DatabaseError.UniqueConstraint => new UniqueConstraintException("Unique constraint violation", exception.InnerException, entries), + ExceptionProcessorInterceptor.DatabaseError.DeadLock => new DeadlockException("Deadlock", exception.InnerException, entries), _ => null, }; } -} \ No newline at end of file +} diff --git a/EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs b/EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs index 92f906e..b5f9cfb 100644 --- a/EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs +++ b/EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs @@ -23,7 +23,8 @@ protected internal enum DatabaseError CannotInsertNull, MaxLength, NumericOverflow, - ReferenceConstraint + ReferenceConstraint, + DeadLock, } /// @@ -59,6 +60,7 @@ public void CommandFailed(DbCommand command, CommandErrorEventData eventData) if (exceptionClassifier.IsCannotInsertNullError(dbException)) return DatabaseError.CannotInsertNull; if (exceptionClassifier.IsUniqueConstraintError(dbException)) return DatabaseError.UniqueConstraint; if (exceptionClassifier.IsReferenceConstraintError(dbException)) return DatabaseError.ReferenceConstraint; + if (exceptionClassifier.IsDeadlockError(dbException)) return DatabaseError.DeadLock; return null; } @@ -94,9 +96,9 @@ private void SetConstraintDetails(DbContext context, UniqueConstraintException e { var indexes = context.Model.GetEntityTypes().SelectMany(x => x.GetDeclaredIndexes().Where(index => index.IsUnique)); - var mappedIndexes = indexes.SelectMany(index => index.GetMappedTableIndexes(), + var mappedIndexes = indexes.SelectMany(index => index.GetMappedTableIndexes(), (index, tableIndex) => new IndexDetails(tableIndex.Name, tableIndex.Table.SchemaQualifiedName, index.Properties)); - + var primaryKeys = context.Model.GetEntityTypes().SelectMany(x => { var primaryKey = x.FindPrimaryKey(); @@ -152,4 +154,4 @@ private void SetConstraintDetails(DbContext context, ReferenceConstraintExceptio exception.SchemaQualifiedTableName = match.SchemaQualifiedTableName; } } -} \ No newline at end of file +} diff --git a/EntityFramework.Exceptions/Common/Exceptions.cs b/EntityFramework.Exceptions/Common/Exceptions.cs index 5a7a3ed..d575d5b 100644 --- a/EntityFramework.Exceptions/Common/Exceptions.cs +++ b/EntityFramework.Exceptions/Common/Exceptions.cs @@ -127,4 +127,31 @@ public ReferenceConstraintException(string message, Exception innerException, IR public string ConstraintName { get; internal set; } public IReadOnlyList ConstraintProperties { get; internal set; } public string SchemaQualifiedTableName { get; internal set; } -} \ No newline at end of file +} + +public class DeadlockException : DbUpdateException +{ + public DeadlockException() + { + } + + public DeadlockException(string message) : base(message) + { + } + + public DeadlockException(string message, Exception innerException) : base(message, innerException) + { + } + + public DeadlockException(string message, IReadOnlyList entries) : base(message, entries) + { + } + + public DeadlockException(string message, Exception innerException, IReadOnlyList entries) : base(message, innerException, entries) + { + } + + public string ConstraintName { get; internal set; } + public IReadOnlyList ConstraintProperties { get; internal set; } + public string SchemaQualifiedTableName { get; internal set; } +} diff --git a/EntityFramework.Exceptions/Tests/DatabaseTests.cs b/EntityFramework.Exceptions/Tests/DatabaseTests.cs index 4ab981b..04a35ac 100644 --- a/EntityFramework.Exceptions/Tests/DatabaseTests.cs +++ b/EntityFramework.Exceptions/Tests/DatabaseTests.cs @@ -71,7 +71,7 @@ public virtual async Task UniqueColumnViolationSameNamesIndexesInDifferentSchema { Name = "Rope Access" }); - + SameNameIndexesContext.IncidentCategories.Add(new EFExceptionSchema.Entities.Incidents.Category { Name = "Rope Access" @@ -109,7 +109,7 @@ public virtual async Task PrimaryKeyViolationThrowsUniqueConstraintException() Assert.False(string.IsNullOrEmpty(uniqueConstraintException.ConstraintName)); Assert.False(string.IsNullOrEmpty(uniqueConstraintException.SchemaQualifiedTableName)); Assert.NotEmpty(uniqueConstraintException.ConstraintProperties); - Assert.Contains(nameof(Product.Id), uniqueConstraintException.ConstraintProperties); + Assert.Contains(nameof(Product.Id), uniqueConstraintException.ConstraintProperties); } } @@ -348,6 +348,41 @@ public async Task NotHandledViolationReThrowsOriginalException() await Assert.ThrowsAsync(() => DemoContext.SaveChangesAsync()); } + [Fact] + public virtual async Task Deadlock() + { + var p1 = DemoContext.Products.Add(new() { Name = "Test1" }); + var p2 = DemoContext.Products.Add(new() { Name = "Test2" }); + + await DemoContext.SaveChangesAsync(); + + var id1 = p1.Entity.Id; + var id2 = p2.Entity.Id; + + using var controlContext = new DemoContext(DemoContext.Options); + using var transaction1 = DemoContext.Database.BeginTransactionAsync(); + using var transaction2 = controlContext.Database.BeginTransactionAsync(); + + await DemoContext.Products.Where(c => c.Id == id1) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test11")); + + await Assert.ThrowsAsync(async () => + { + await controlContext.Products.Where(c => c.Id == id2) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test21")); + + var task1 = Task.Run(() => DemoContext.Products.Where(c => c.Id == id2) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test22"))); + + await Task.Delay(100); + + var task2 = controlContext.Products.Where(c => c.Id == id1) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test12")); + + await Task.WhenAll(task1, task2); + }); + } + public virtual void Dispose() { CleanupContext(); @@ -360,4 +395,4 @@ protected void CleanupContext() entityEntry.State = EntityState.Detached; } } -} \ No newline at end of file +} diff --git a/EntityFramework.Exceptions/Tests/DemoContext.cs b/EntityFramework.Exceptions/Tests/DemoContext.cs index e17c492..d59b700 100644 --- a/EntityFramework.Exceptions/Tests/DemoContext.cs +++ b/EntityFramework.Exceptions/Tests/DemoContext.cs @@ -5,10 +5,9 @@ namespace EntityFramework.Exceptions.Tests; public class DemoContext : DbContext { - public DemoContext(DbContextOptions options) : base(options) - { - } + public DemoContext(DbContextOptions options) : base(options) => Options = options; + public DbContextOptions Options { get; } public DbSet Customers { get; set; } public DbSet Products { get; set; } public DbSet ProductSales { get; set; } @@ -55,4 +54,4 @@ public class Customer { public int Id { get; set; } public string Fullname { get; set; } -} \ No newline at end of file +} diff --git a/EntityFramework.Exceptions/Tests/OracleTests.cs b/EntityFramework.Exceptions/Tests/OracleTests.cs index cf7ec8d..a99489b 100644 --- a/EntityFramework.Exceptions/Tests/OracleTests.cs +++ b/EntityFramework.Exceptions/Tests/OracleTests.cs @@ -11,6 +11,12 @@ public class OracleTests : DatabaseTests, IClassFixture @@ -20,9 +26,9 @@ static OracleTestContextFixture() Container = new OracleBuilder().Build(); } - protected override DbContextOptionsBuilder BuildDemoContextOptions(DbContextOptionsBuilder builder, string connectionString) + protected override DbContextOptionsBuilder BuildDemoContextOptions(DbContextOptionsBuilder builder, string connectionString) => builder.UseOracle(connectionString).UseExceptionProcessor(); - protected override DbContextOptionsBuilder BuildSameNameIndexesContextOptions(DbContextOptionsBuilder builder, string connectionString) + protected override DbContextOptionsBuilder BuildSameNameIndexesContextOptions(DbContextOptionsBuilder builder, string connectionString) => builder.UseOracle(connectionString).UseExceptionProcessor(); -} \ No newline at end of file +} From 0371af4ff6f5ae2bc8bd76db9a751d19b099f0a0 Mon Sep 17 00:00:00 2001 From: pengweiqhca Date: Fri, 22 Aug 2025 19:47:33 +0800 Subject: [PATCH 2/9] Use PostgresErrorCodes --- .../PostgreSQL/PostgreSQLExceptionClassifier.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DbExceptionClassifier/PostgreSQL/PostgreSQLExceptionClassifier.cs b/DbExceptionClassifier/PostgreSQL/PostgreSQLExceptionClassifier.cs index 44e04e7..7fd52cc 100644 --- a/DbExceptionClassifier/PostgreSQL/PostgreSQLExceptionClassifier.cs +++ b/DbExceptionClassifier/PostgreSQL/PostgreSQLExceptionClassifier.cs @@ -11,5 +11,5 @@ public class PostgreSQLExceptionClassifier : IDbExceptionClassifier public bool IsNumericOverflowError(DbException exception) => exception is PostgresException { SqlState: PostgresErrorCodes.NumericValueOutOfRange }; public bool IsUniqueConstraintError(DbException exception) => exception is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }; public bool IsMaxLengthExceededError(DbException exception) => exception is PostgresException { SqlState: PostgresErrorCodes.StringDataRightTruncation }; - public bool IsDeadlockError(DbException exception) => exception is PostgresException { SqlState: "40P01" }; + public bool IsDeadlockError(DbException exception) => exception is PostgresException { SqlState: PostgresErrorCodes.DeadlockDetected }; } From f428eac461281e32c7be6a7e5c098254843e9cb1 Mon Sep 17 00:00:00 2001 From: pengweiqhca Date: Fri, 22 Aug 2025 19:47:57 +0800 Subject: [PATCH 3/9] Remove unused properties. --- EntityFramework.Exceptions/Common/Exceptions.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/EntityFramework.Exceptions/Common/Exceptions.cs b/EntityFramework.Exceptions/Common/Exceptions.cs index d575d5b..1123368 100644 --- a/EntityFramework.Exceptions/Common/Exceptions.cs +++ b/EntityFramework.Exceptions/Common/Exceptions.cs @@ -150,8 +150,4 @@ public DeadlockException(string message, IReadOnlyList entries) : b public DeadlockException(string message, Exception innerException, IReadOnlyList entries) : base(message, innerException, entries) { } - - public string ConstraintName { get; internal set; } - public IReadOnlyList ConstraintProperties { get; internal set; } - public string SchemaQualifiedTableName { get; internal set; } } From cdf9494a9f40b1be0f1114f8008cf25696a942ce Mon Sep 17 00:00:00 2001 From: pengweiqhca Date: Wed, 27 Aug 2025 21:12:58 +0800 Subject: [PATCH 4/9] SQLite no deadlock --- .../Sqlite/SqliteExceptionClassifier.cs | 1 - EntityFramework.Exceptions/Tests/SqliteTests.cs | 10 ++++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs b/DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs index c4fd148..cafd539 100644 --- a/DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs +++ b/DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs @@ -19,5 +19,4 @@ public bool IsUniqueConstraintError(DbException exception) => exception is Sqlit }; public bool IsMaxLengthExceededError(DbException exception) => exception is SqliteException { SqliteExtendedErrorCode: SQLITE_TOOBIG }; - public bool IsDeadlockError(DbException exception)=> exception is SqliteException { SqliteExtendedErrorCode: SQLITE_LOCKED_SHAREDCACHE }; } diff --git a/EntityFramework.Exceptions/Tests/SqliteTests.cs b/EntityFramework.Exceptions/Tests/SqliteTests.cs index 58ebb86..372036c 100644 --- a/EntityFramework.Exceptions/Tests/SqliteTests.cs +++ b/EntityFramework.Exceptions/Tests/SqliteTests.cs @@ -52,16 +52,22 @@ public override Task NumericOverflowViolationThrowsNumericOverflowExceptionThrou { return Task.CompletedTask; } + + [Fact(Skip = "Skipping as SQLite no deadlock.")] + public override Task Deadlock() + { + return Task.CompletedTask; + } } public class SqliteDemoContextFixture : DemoContextFixture { private const string ConnectionString = "DataSource=file::memory:?cache=shared"; - protected override DbContextOptionsBuilder BuildDemoContextOptions(DbContextOptionsBuilder builder, string connectionString) + protected override DbContextOptionsBuilder BuildDemoContextOptions(DbContextOptionsBuilder builder, string connectionString) => builder.UseSqlite(ConnectionString).UseExceptionProcessor(); - protected override DbContextOptionsBuilder BuildSameNameIndexesContextOptions(DbContextOptionsBuilder builder, string connectionString) + protected override DbContextOptionsBuilder BuildSameNameIndexesContextOptions(DbContextOptionsBuilder builder, string connectionString) => builder.UseSqlite(ConnectionString).UseExceptionProcessor(); } } From 2175fe7734df84bc3ad865d87b9e759afba7f542 Mon Sep 17 00:00:00 2001 From: pengweiqhca Date: Wed, 27 Aug 2025 21:13:28 +0800 Subject: [PATCH 5/9] Fix test failed. --- .../Tests/DatabaseTests.cs | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/EntityFramework.Exceptions/Tests/DatabaseTests.cs b/EntityFramework.Exceptions/Tests/DatabaseTests.cs index 04a35ac..9d9a2ae 100644 --- a/EntityFramework.Exceptions/Tests/DatabaseTests.cs +++ b/EntityFramework.Exceptions/Tests/DatabaseTests.cs @@ -360,27 +360,20 @@ public virtual async Task Deadlock() var id2 = p2.Entity.Id; using var controlContext = new DemoContext(DemoContext.Options); - using var transaction1 = DemoContext.Database.BeginTransactionAsync(); - using var transaction2 = controlContext.Database.BeginTransactionAsync(); + using var transaction1 = await DemoContext.Database.BeginTransactionAsync(); + using var transaction2 = await controlContext.Database.BeginTransactionAsync(); await DemoContext.Products.Where(c => c.Id == id1) .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test11")); - await Assert.ThrowsAsync(async () => - { - await controlContext.Products.Where(c => c.Id == id2) - .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test21")); - - var task1 = Task.Run(() => DemoContext.Products.Where(c => c.Id == id2) - .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test22"))); - - await Task.Delay(100); + await controlContext.Products.Where(c => c.Id == id2) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test21")); - var task2 = controlContext.Products.Where(c => c.Id == id1) - .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test12")); - - await Task.WhenAll(task1, task2); - }); + await Assert.ThrowsAsync(() => Task.WhenAll(Task.Run(() => DemoContext.Products + .Where(c => c.Id == id2) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test22"))), controlContext.Products + .Where(c => c.Id == id1) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test12")))); } public virtual void Dispose() From 415916e5f6a42a506cf041e1e3fe9ca450fb6219 Mon Sep 17 00:00:00 2001 From: Giorgi Dalakishvili Date: Thu, 25 Sep 2025 16:53:57 +0400 Subject: [PATCH 6/9] SQLite deadlock test --- .../Common/IDbExceptionClassifier.cs | 2 +- .../Sqlite/SqliteExceptionClassifier.cs | 2 ++ .../Tests/DatabaseTests.cs | 6 +++--- .../Tests/SqliteTests.cs | 18 +++++++++++++++--- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/DbExceptionClassifier/Common/IDbExceptionClassifier.cs b/DbExceptionClassifier/Common/IDbExceptionClassifier.cs index 86c30cd..e3175a6 100644 --- a/DbExceptionClassifier/Common/IDbExceptionClassifier.cs +++ b/DbExceptionClassifier/Common/IDbExceptionClassifier.cs @@ -9,6 +9,6 @@ public interface IDbExceptionClassifier public bool IsNumericOverflowError(DbException exception); public bool IsUniqueConstraintError(DbException exception); public bool IsMaxLengthExceededError(DbException exception); - public bool IsDeadlockError(DbException exception) => false; + public bool IsDeadlockError(DbException exception); } } diff --git a/DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs b/DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs index cafd539..a8633e1 100644 --- a/DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs +++ b/DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs @@ -19,4 +19,6 @@ public bool IsUniqueConstraintError(DbException exception) => exception is Sqlit }; public bool IsMaxLengthExceededError(DbException exception) => exception is SqliteException { SqliteExtendedErrorCode: SQLITE_TOOBIG }; + + public bool IsDeadlockError(DbException exception) => exception is SqliteException { SqliteExtendedErrorCode: SQLITE_LOCKED_SHAREDCACHE}; } diff --git a/EntityFramework.Exceptions/Tests/DatabaseTests.cs b/EntityFramework.Exceptions/Tests/DatabaseTests.cs index 9d9a2ae..8ed50b4 100644 --- a/EntityFramework.Exceptions/Tests/DatabaseTests.cs +++ b/EntityFramework.Exceptions/Tests/DatabaseTests.cs @@ -359,9 +359,9 @@ public virtual async Task Deadlock() var id1 = p1.Entity.Id; var id2 = p2.Entity.Id; - using var controlContext = new DemoContext(DemoContext.Options); - using var transaction1 = await DemoContext.Database.BeginTransactionAsync(); - using var transaction2 = await controlContext.Database.BeginTransactionAsync(); + await using var controlContext = new DemoContext(DemoContext.Options); + await using var transaction1 = await DemoContext.Database.BeginTransactionAsync(); + await using var transaction2 = await controlContext.Database.BeginTransactionAsync(); await DemoContext.Products.Where(c => c.Id == id1) .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test11")); diff --git a/EntityFramework.Exceptions/Tests/SqliteTests.cs b/EntityFramework.Exceptions/Tests/SqliteTests.cs index 372036c..1d6c6a1 100644 --- a/EntityFramework.Exceptions/Tests/SqliteTests.cs +++ b/EntityFramework.Exceptions/Tests/SqliteTests.cs @@ -1,8 +1,10 @@ using DotNet.Testcontainers.Containers; +using EntityFramework.Exceptions.Common; using EntityFramework.Exceptions.Sqlite; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using SQLitePCL; +using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; using Xunit; @@ -53,10 +55,20 @@ public override Task NumericOverflowViolationThrowsNumericOverflowExceptionThrou return Task.CompletedTask; } - [Fact(Skip = "Skipping as SQLite no deadlock.")] - public override Task Deadlock() + [Fact] + public override async Task Deadlock() { - return Task.CompletedTask; + var product = new Product { Name = "Test1" }; + DemoContext.Products.Add(product); + + await DemoContext.SaveChangesAsync(); + + await using var controlContext = new DemoContext(DemoContext.Options); + await using var transaction1 = await DemoContext.Database.BeginTransactionAsync(); + + await Assert.ThrowsAsync(() => controlContext.Products + .Where(c => c.Id == product.Id) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test12"))); } } From b317a069d11550705a6a493fa0f41dd5e38cf6bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 22:17:14 +0000 Subject: [PATCH 7/9] Implement Oracle deadlock test using cross-update pattern Oracle can trigger deadlocks (ORA-00060) using the classic cross-update pattern. Unlike PostgreSQL/SQL Server which roll back the victim's entire transaction, Oracle only rolls back the victim's statement. This requires using Task.WhenAny instead of Task.WhenAll, then explicitly rolling back the victim's transaction to release earlier locks and unblock the other session. https://claude.ai/code/session_01WusLPsCyEsDpSbp2Nm6Tkx --- .../Tests/OracleTests.cs | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/EntityFramework.Exceptions/Tests/OracleTests.cs b/EntityFramework.Exceptions/Tests/OracleTests.cs index a99489b..6be7e27 100644 --- a/EntityFramework.Exceptions/Tests/OracleTests.cs +++ b/EntityFramework.Exceptions/Tests/OracleTests.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System.Linq; +using System.Threading.Tasks; +using EntityFramework.Exceptions.Common; using EntityFramework.Exceptions.Oracle; using Microsoft.EntityFrameworkCore; using Testcontainers.Oracle; @@ -12,10 +14,51 @@ public OracleTests(OracleTestContextFixture fixture) : base(fixture.DemoContext) { } - [Fact(Skip = "Skipping as oracle can't trigger deadlock.")] - public override Task Deadlock() + [Fact] + public override async Task Deadlock() { - return Task.CompletedTask; + var p1 = DemoContext.Products.Add(new() { Name = "Test1" }); + var p2 = DemoContext.Products.Add(new() { Name = "Test2" }); + await DemoContext.SaveChangesAsync(); + + var id1 = p1.Entity.Id; + var id2 = p2.Entity.Id; + + await using var controlContext = new DemoContext(DemoContext.Options); + await using var transaction1 = await DemoContext.Database.BeginTransactionAsync(); + await using var transaction2 = await controlContext.Database.BeginTransactionAsync(); + + // Each transaction locks one row + await DemoContext.Products.Where(c => c.Id == id1) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test11")); + await controlContext.Products.Where(c => c.Id == id2) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test21")); + + // Start both cross-updates concurrently to create a deadlock cycle + var task1 = Task.Run(() => DemoContext.Products + .Where(c => c.Id == id2) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test22"))); + var task2 = Task.Run(() => controlContext.Products + .Where(c => c.Id == id1) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test12"))); + + // Oracle only rolls back the victim's statement, not its transaction, + // so the non-victim remains blocked. Use WhenAny to catch the victim first. + var completedTask = await Task.WhenAny(task1, task2); + await Assert.ThrowsAsync(() => completedTask); + + // Roll back the victim's transaction to release its earlier locks + // and unblock the other session. + if (completedTask == task1) + { + await transaction1.RollbackAsync(); + await task2; + } + else + { + await transaction2.RollbackAsync(); + await task1; + } } } From 1d8660eb4959af754733d382aff22ed14fa9425a Mon Sep 17 00:00:00 2001 From: Giorgi Dalakishvili Date: Thu, 19 Feb 2026 02:48:29 +0400 Subject: [PATCH 8/9] Update EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Common/ExceptionProcessorInterceptor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs b/EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs index b5f9cfb..503dcfc 100644 --- a/EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs +++ b/EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs @@ -24,7 +24,7 @@ protected internal enum DatabaseError MaxLength, NumericOverflow, ReferenceConstraint, - DeadLock, + Deadlock, } /// From b9432797e0e33d92befb9e886edb8f0be93d053b Mon Sep 17 00:00:00 2001 From: Giorgi Dalakishvili Date: Thu, 19 Feb 2026 12:30:59 +0400 Subject: [PATCH 9/9] DeadLock -> Deadlock --- EntityFramework.Exceptions/Common/ExceptionFactory.cs | 2 +- .../Common/ExceptionProcessorInterceptor.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/EntityFramework.Exceptions/Common/ExceptionFactory.cs b/EntityFramework.Exceptions/Common/ExceptionFactory.cs index 14979d4..0f369dd 100644 --- a/EntityFramework.Exceptions/Common/ExceptionFactory.cs +++ b/EntityFramework.Exceptions/Common/ExceptionFactory.cs @@ -16,7 +16,7 @@ internal static Exception Create(ExceptionProcessorInterceptor.DatabaseErr ExceptionProcessorInterceptor.DatabaseError.NumericOverflow => new NumericOverflowException("Numeric overflow", exception.InnerException, entries), ExceptionProcessorInterceptor.DatabaseError.ReferenceConstraint => new ReferenceConstraintException("Reference constraint violation", exception.InnerException, entries), ExceptionProcessorInterceptor.DatabaseError.UniqueConstraint => new UniqueConstraintException("Unique constraint violation", exception.InnerException, entries), - ExceptionProcessorInterceptor.DatabaseError.DeadLock => new DeadlockException("Deadlock", exception.InnerException, entries), + ExceptionProcessorInterceptor.DatabaseError.Deadlock => new DeadlockException("Deadlock", exception.InnerException, entries), _ => null, }; } diff --git a/EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs b/EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs index 503dcfc..50e2e9a 100644 --- a/EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs +++ b/EntityFramework.Exceptions/Common/ExceptionProcessorInterceptor.cs @@ -60,7 +60,7 @@ public void CommandFailed(DbCommand command, CommandErrorEventData eventData) if (exceptionClassifier.IsCannotInsertNullError(dbException)) return DatabaseError.CannotInsertNull; if (exceptionClassifier.IsUniqueConstraintError(dbException)) return DatabaseError.UniqueConstraint; if (exceptionClassifier.IsReferenceConstraintError(dbException)) return DatabaseError.ReferenceConstraint; - if (exceptionClassifier.IsDeadlockError(dbException)) return DatabaseError.DeadLock; + if (exceptionClassifier.IsDeadlockError(dbException)) return DatabaseError.Deadlock; return null; }