Allow managing transactions via SQL commands in the update pipeline
- Dominant language
- C#
- Stars
- 14.8k
- Forks
- 3.4k
- PR merge metrics
- PR metrics pending
Description
SaveChanges implicitly starts a transaction around all changes (except where one isn't needed, see #27439). This is done by calling the standard ADO.NET APIs, DbConnection.BeginTransaction and DbTransaction.Commit. Unfortunately, each of these APIs does a roundtrip, so a typical SaveChanges involves 3 roundtrips instead of just one. Some benchmarking:
| Method | DatabaseType | Mean | Error | StdDev | Median | Ratio | RatioSD |
|------------------------------ |------------- |---------:|---------:|---------:|---------:|------:|--------:|
| Without_transaction | Postgresql | 187.4 us | 3.72 us | 6.32 us | 188.0 us | 1.00 | 0.00 |
| With_transaction_via_API | Postgresql | 354.8 us | 11.70 us | 34.51 us | 362.7 us | 1.87 | 0.26 |
| With_transaction_via_commands | Postgresql | 234.4 us | 5.71 us | 16.84 us | 237.1 us | 1.24 | 0.11 |
Benchmark code
```c#
BenchmarkRunner.Run();
public class Benchmark
{
[Params(DatabaseType.Postgresql, DatabaseType.SqlServer)]
public DatabaseType DatabaseType { get; set; }
private DbConnection _connection;
private DbCommand _command;
private async Task Setup()
{
_connection = DatabaseType switch
{
DatabaseType.Postgresql => new NpgsqlConnection("Host=localhost;Username=test;Password=test"),
DatabaseType.SqlServer => new SqlConnection("Server=localhost;Database=test;User=SA;Password=Abcd5678;Connect Timeout=60;ConnectRetryCount=0;Trust Server Certificate=true"),
_ => throw new ArgumentOutOfRangeException()
};
await _connection.OpenAsync();
}
[GlobalSetup(Targets = new[] { nameof(Without_transaction), nameof(With_transaction_via_API) })]
public async Task Setup_command_without_transaction()
{
await Setup();
_command = _connection.CreateCommand();
_command.CommandText = "SELECT 1";
}
[GlobalSetup(Target = nameof(With_transaction_via_commands))]
public async Task Setup_command_with_transaction()
{
await Setup();
_command = _connection.CreateCommand();
_command.CommandText = DatabaseType switch
{
DatabaseType.Postgresql => "BEGIN; SELECT 1; COMMIT",
DatabaseType.SqlServer => "BEGIN TRANSACTION; SELECT 1; COMMIT",
_ => throw new ArgumentOutOfRangeException()
};
}
[Benchmark(Baseline = true)]
public int Without_transaction()
=> (int)_command.ExecuteScalar()!;
[Benchmark]
public int With_transaction_via_API()
{
var tx = _connection.BeginTransaction();
_command.Transaction = tx;
var result = (int)_command.ExecuteScalar()!;
tx.Commit();
return result;
}
[Benchmark]
public int With_transaction_via_commands()
=> (int)_command.ExecuteScalar()!;
}
public enum DatabaseType
{
Postgresql,
SqlServer
}
```
Results for SQL Server are similar, though more extreme: starting a transaction with the API is more than twice as long as doing it via the commands. Note that Npgsql already batches BeginTransaction via the API, but SqlClient does not, so the API imposes 2 roundtrips on Npgsql but 3 on SqlClient (probably part of why the difference is bigger on SqlClient).
We could get around this by managing transactions manually: prepend BEGIN to the first batch, and append COMMIT to the last batch.
* We're not sure if it's always safe/correct bypass the ADO.NET provider APIs like this, since the provider may not be aware of the transaction flow and behavior may change. To be on the safe side, it may be better to implement this in relational but require a provider opt-in.
* Capabilities such as intercepting transactions would no longer be possible, so a user opt-out may also be a good idea.
Originally discussed in #4351
Contributor guide
Assessment
This issue has not been assessed yet.