Skip to content
1 change: 1 addition & 0 deletions DbExceptionClassifier/Common/IDbExceptionClassifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding IsDeadlockError as a new abstract interface member is a source/binary breaking change for any external IDbExceptionClassifier implementations. If this package is meant to be extensible, consider providing a default interface implementation returning false (net8 supports DIMs) to preserve compatibility, or ensure the package versioning reflects a breaking change.

Suggested change
public bool IsDeadlockError(DbException exception);
public bool IsDeadlockError(DbException exception) => false;

Copilot uses AI. Check for mistakes.
}
}
8 changes: 7 additions & 1 deletion DbExceptionClassifier/MySQL/MySQLExceptionClassifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,10 @@ public bool IsMaxLengthExceededError(DbException exception)
var errorCode = GetErrorCode(exception);
return errorCode == MySqlErrorCode.DataTooLong;
}
}

public bool IsDeadlockError(DbException exception)
{
var errorCode = GetErrorCode(exception);
return errorCode is MySqlErrorCode.LockDeadlock or MySqlErrorCode.XARBDeadlock;
}
}
5 changes: 4 additions & 1 deletion DbExceptionClassifier/Oracle/OracleExceptionClassifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 };
}

public bool IsDeadlockError(DbException exception) => exception is OracleException { Number: DeadLock };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
public bool IsDeadlockError(DbException exception) => exception is PostgresException { SqlState: PostgresErrorCodes.DeadlockDetected };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 };
}
public bool IsDeadlockError(DbException exception) => exception is SqlException { Number: Deadlock };
}
4 changes: 3 additions & 1 deletion DbExceptionClassifier/Sqlite/SqliteExceptionClassifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
}
3 changes: 2 additions & 1 deletion EntityFramework.Exceptions/Common/ExceptionFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ internal static Exception Create<T>(ExceptionProcessorInterceptor<T>.DatabaseErr
ExceptionProcessorInterceptor<T>.DatabaseError.NumericOverflow => new NumericOverflowException("Numeric overflow", exception.InnerException, entries),
ExceptionProcessorInterceptor<T>.DatabaseError.ReferenceConstraint => new ReferenceConstraintException("Reference constraint violation", exception.InnerException, entries),
ExceptionProcessorInterceptor<T>.DatabaseError.UniqueConstraint => new UniqueConstraintException("Unique constraint violation", exception.InnerException, entries),
ExceptionProcessorInterceptor<T>.DatabaseError.Deadlock => new DeadlockException("Deadlock", exception.InnerException, entries),
_ => null,
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ protected internal enum DatabaseError
CannotInsertNull,
MaxLength,
NumericOverflow,
ReferenceConstraint
ReferenceConstraint,
Deadlock,
}

/// <inheritdoc />
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -152,4 +154,4 @@ private void SetConstraintDetails(DbContext context, ReferenceConstraintExceptio
exception.SchemaQualifiedTableName = match.SchemaQualifiedTableName;
}
}
}
}
25 changes: 24 additions & 1 deletion EntityFramework.Exceptions/Common/Exceptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,27 @@ public ReferenceConstraintException(string message, Exception innerException, IR
public string ConstraintName { get; internal set; }
public IReadOnlyList<string> ConstraintProperties { get; internal set; }
public string SchemaQualifiedTableName { get; internal set; }
}
}

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<EntityEntry> entries) : base(message, entries)
{
}

public DeadlockException(string message, Exception innerException, IReadOnlyList<EntityEntry> entries) : base(message, innerException, entries)
{
}
}
34 changes: 31 additions & 3 deletions EntityFramework.Exceptions/Tests/DatabaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public virtual async Task UniqueColumnViolationSameNamesIndexesInDifferentSchema
{
Name = "Rope Access"
});

SameNameIndexesContext.IncidentCategories.Add(new EFExceptionSchema.Entities.Incidents.Category
{
Name = "Rope Access"
Expand Down Expand Up @@ -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<string>(nameof(Product.Id), uniqueConstraintException.ConstraintProperties);
Assert.Contains<string>(nameof(Product.Id), uniqueConstraintException.ConstraintProperties);
}
}

Expand Down Expand Up @@ -348,6 +348,34 @@ public async Task NotHandledViolationReThrowsOriginalException()
await Assert.ThrowsAsync<DbUpdateException>(() => 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;

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

await controlContext.Products.Where(c => c.Id == id2)
.ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test21"));

await Assert.ThrowsAsync<DeadlockException>(() => 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()
{
CleanupContext();
Expand All @@ -360,4 +388,4 @@ protected void CleanupContext()
entityEntry.State = EntityState.Detached;
}
}
}
}
7 changes: 3 additions & 4 deletions EntityFramework.Exceptions/Tests/DemoContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Customer> Customers { get; set; }
public DbSet<Product> Products { get; set; }
public DbSet<ProductSale> ProductSales { get; set; }
Expand Down Expand Up @@ -55,4 +54,4 @@ public class Customer
{
public int Id { get; set; }
public string Fullname { get; set; }
}
}
57 changes: 53 additions & 4 deletions EntityFramework.Exceptions/Tests/OracleTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,6 +13,53 @@ public class OracleTests : DatabaseTests, IClassFixture<OracleTestContextFixture
public OracleTests(OracleTestContextFixture fixture) : base(fixture.DemoContext)
{
}

[Fact]
public override 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;

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<DeadlockException>(() => 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;
}
}
}

public class OracleTestContextFixture : DemoContextFixture<OracleContainer>
Expand All @@ -20,9 +69,9 @@ static OracleTestContextFixture()
Container = new OracleBuilder().Build();
}

protected override DbContextOptionsBuilder<DemoContext> BuildDemoContextOptions(DbContextOptionsBuilder<DemoContext> builder, string connectionString)
protected override DbContextOptionsBuilder<DemoContext> BuildDemoContextOptions(DbContextOptionsBuilder<DemoContext> 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();
}
}
22 changes: 20 additions & 2 deletions EntityFramework.Exceptions/Tests/SqliteTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -52,16 +54,32 @@ public override Task NumericOverflowViolationThrowsNumericOverflowExceptionThrou
{
return Task.CompletedTask;
}

[Fact]
public override async Task Deadlock()
{
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();

Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BeginTransactionAsync() on DemoContext doesn’t acquire a lock in SQLite until a statement is executed. As written, transaction1 is never used, so the controlContext update may succeed and the test will fail/flap. Execute at least one read/write statement inside transaction1 (and keep it uncommitted) before running the conflicting controlContext ExecuteUpdateAsync, or use an explicit locking transaction mode to reliably trigger SQLITE_LOCKED_SHAREDCACHE.

Suggested change
// Execute a write operation within transaction1 to acquire a SQLite lock
await DemoContext.Products
.Where(c => c.Id == product.Id)
.ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test11"));

Copilot uses AI. Check for mistakes.
await Assert.ThrowsAsync<DeadlockException>(() => controlContext.Products
.Where(c => c.Id == product.Id)
.ExecuteUpdateAsync(c => c.SetProperty(p => p.Name, "Test12")));
}
}

public class SqliteDemoContextFixture : DemoContextFixture<IContainer>
{
private const string ConnectionString = "DataSource=file::memory:?cache=shared";

protected override DbContextOptionsBuilder<DemoContext> BuildDemoContextOptions(DbContextOptionsBuilder<DemoContext> builder, string connectionString)
protected override DbContextOptionsBuilder<DemoContext> BuildDemoContextOptions(DbContextOptionsBuilder<DemoContext> 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();
}
}
Loading