Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions src/Npgsql/NpgsqlConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -560,9 +560,23 @@ public override ConnectionState State
/// <returns>A <see cref="NpgsqlTransaction"/> object representing the new transaction.</returns>
/// <remarks>Nested transactions are not supported.</remarks>
public new NpgsqlTransaction BeginTransaction(IsolationLevel level)
=> BeginTransaction(async: false, level, CancellationToken.None).GetAwaiter().GetResult();
=> BeginTransaction(level, NpgsqlTransactionOptions.None);

async ValueTask<NpgsqlTransaction> BeginTransaction(bool async, IsolationLevel level, CancellationToken cancellationToken)
/// <summary>
/// Begins a database transaction with the specified isolation level and options.
/// </summary>
/// <param name="level">The isolation level under which the transaction should run.</param>
/// <param name="options">Npgsql-specific options under which the transaction should run, e.g. read-only or deferrable.</param>
/// <returns>A <see cref="NpgsqlTransaction"/> object representing the new transaction.</returns>
/// <remarks>
/// Nested transactions are not supported.
/// Note that there's no single-argument <c>options</c>-only overload: combined with the existing <see cref="IsolationLevel"/>
/// overloads, that would make calls like <c>BeginTransaction(default)</c> ambiguous.
/// </remarks>
public NpgsqlTransaction BeginTransaction(IsolationLevel level, NpgsqlTransactionOptions options)
=> BeginTransaction(async: false, level, options, CancellationToken.None).GetAwaiter().GetResult();

async ValueTask<NpgsqlTransaction> BeginTransaction(bool async, IsolationLevel level, NpgsqlTransactionOptions options, CancellationToken cancellationToken)
{
if (level == IsolationLevel.Chaos)
ThrowHelper.ThrowNotSupportedException($"Unsupported IsolationLevel: {nameof(IsolationLevel.Chaos)}");
Expand All @@ -581,7 +595,7 @@ async ValueTask<NpgsqlTransaction> BeginTransaction(bool async, IsolationLevel l
using var _ = connector.StartUserAction(cancellationToken);

connector.Transaction ??= new NpgsqlTransaction(connector);
connector.Transaction.Init(level);
await connector.Transaction.Init(async, level, options, cancellationToken).ConfigureAwait(false);
return connector.Transaction;
}

Expand Down Expand Up @@ -625,7 +639,20 @@ protected override async ValueTask<DbTransaction> BeginDbTransactionAsync(Isolat
/// Nested transactions are not supported.
/// </remarks>
public new ValueTask<NpgsqlTransaction> BeginTransactionAsync(IsolationLevel level, CancellationToken cancellationToken = default)
=> BeginTransaction(async: true, level, cancellationToken);
=> BeginTransactionAsync(level, NpgsqlTransactionOptions.None, cancellationToken);

/// <summary>
/// Asynchronously begins a database transaction with the specified isolation level and options.
/// </summary>
/// <param name="level">The isolation level under which the transaction should run.</param>
/// <param name="options">Npgsql-specific options under which the transaction should run, e.g. read-only or deferrable.</param>
/// <param name="cancellationToken">
/// An optional token to cancel the asynchronous operation. The default value is <see cref="CancellationToken.None"/>.
/// </param>
/// <returns>A task whose <see cref="ValueTask{T}.Result"/> property is an object representing the new transaction.</returns>
/// <remarks>Nested transactions are not supported.</remarks>
public ValueTask<NpgsqlTransaction> BeginTransactionAsync(IsolationLevel level, NpgsqlTransactionOptions options, CancellationToken cancellationToken = default)
=> BeginTransaction(async: true, level, options, cancellationToken);

/// <summary>
/// Enlist transaction.
Expand Down
86 changes: 63 additions & 23 deletions src/Npgsql/NpgsqlTransaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Data;
using System.Data.Common;
using System.Diagnostics;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -76,37 +77,75 @@ internal NpgsqlTransaction(NpgsqlConnector connector)
_transactionLogger = connector.TransactionLogger;
}

internal void Init(IsolationLevel isolationLevel = DefaultIsolationLevel)
internal Task Init(bool async, IsolationLevel isolationLevel, NpgsqlTransactionOptions options, CancellationToken cancellationToken = default)
{
Debug.Assert(isolationLevel != IsolationLevel.Chaos);

if (!_connector.DatabaseInfo.SupportsTransactions)
return;
return Task.CompletedTask;

switch (isolationLevel)
{
case IsolationLevel.RepeatableRead:
case IsolationLevel.Snapshot:
_connector.PrependInternalMessage(PregeneratedMessages.BeginTransRepeatableRead, 2);
break;
case IsolationLevel.Serializable:
_connector.PrependInternalMessage(PregeneratedMessages.BeginTransSerializable, 2);
break;
case IsolationLevel.ReadUncommitted:
// PG doesn't really support ReadUncommitted, it's the same as ReadCommitted. But we still
// send as if.
_connector.PrependInternalMessage(PregeneratedMessages.BeginTransReadUncommitted, 2);
break;
case IsolationLevel.ReadCommitted:
_connector.PrependInternalMessage(PregeneratedMessages.BeginTransReadCommitted, 2);
break;
case IsolationLevel.Unspecified:
if (isolationLevel == IsolationLevel.Unspecified)
isolationLevel = DefaultIsolationLevel;
goto case DefaultIsolationLevel;
default:
throw new NotSupportedException("Isolation level not supported: " + isolationLevel);

if (options == NpgsqlTransactionOptions.None)
{
// Fast path: no Npgsql-specific options were requested, so we can use a pregenerated BEGIN message, avoiding any allocations.
switch (isolationLevel)
{
case IsolationLevel.RepeatableRead:
case IsolationLevel.Snapshot:
_connector.PrependInternalMessage(PregeneratedMessages.BeginTransRepeatableRead, 2);
break;
case IsolationLevel.Serializable:
_connector.PrependInternalMessage(PregeneratedMessages.BeginTransSerializable, 2);
break;
case IsolationLevel.ReadUncommitted:
// PG doesn't really support ReadUncommitted, it's the same as ReadCommitted. But we still
// send as if.
_connector.PrependInternalMessage(PregeneratedMessages.BeginTransReadUncommitted, 2);
break;
case IsolationLevel.ReadCommitted:
_connector.PrependInternalMessage(PregeneratedMessages.BeginTransReadCommitted, 2);
break;
default:
throw new NotSupportedException("Isolation level not supported: " + isolationLevel);
}

FinishInit(isolationLevel);
return Task.CompletedTask;
}

return InitWithOptions(async, isolationLevel, options, cancellationToken);

async Task InitWithOptions(bool async, IsolationLevel isolationLevel, NpgsqlTransactionOptions options, CancellationToken cancellationToken)
{
var isolationLevelText = isolationLevel switch
{
IsolationLevel.RepeatableRead or IsolationLevel.Snapshot => "REPEATABLE READ",
IsolationLevel.Serializable => "SERIALIZABLE",
// PG doesn't really support ReadUncommitted, it's the same as ReadCommitted. But we still send as if.
IsolationLevel.ReadUncommitted => "READ UNCOMMITTED",
IsolationLevel.ReadCommitted => "READ COMMITTED",
_ => throw new NotSupportedException("Isolation level not supported: " + isolationLevel)
};

var sb = new StringBuilder("BEGIN TRANSACTION ISOLATION LEVEL ").Append(isolationLevelText);
if ((options & NpgsqlTransactionOptions.ReadOnly) != 0)
sb.Append(" READ ONLY");
if ((options & NpgsqlTransactionOptions.Deferrable) != 0)
sb.Append(" DEFERRABLE");

// Unlike the isolation levels above, these options can be combined in many ways, making it impractical to pregenerate
// messages for all combinations; the BEGIN statement is written out and sent like a regular (prepended) query instead.
await _connector.WriteQuery(sb.ToString(), async, cancellationToken).ConfigureAwait(false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(from @vonzshik) you can just do a synchronous write here, assuming that there will always be enough space in the buffer; we already make that assumption above when we call PrependInternalMessage. At that point everything here is sync and you can also inline the local method.

_connector.PendingPrependedResponses += 2;

FinishInit(isolationLevel);
}
Comment thread
bjornharrtell marked this conversation as resolved.
}

void FinishInit(IsolationLevel isolationLevel)
{
_connector.TransactionStatus = TransactionStatus.Pending;
_isolationLevel = isolationLevel;
IsDisposed = false;
Expand All @@ -116,6 +155,7 @@ internal void Init(IsolationLevel isolationLevel = DefaultIsolationLevel)

#endregion


#region Commit

/// <summary>
Expand Down
31 changes: 31 additions & 0 deletions src/Npgsql/NpgsqlTransactionOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;

namespace Npgsql;

#pragma warning disable RS0016

/// <summary>
/// Specifies additional, Npgsql-specific options to apply when beginning a transaction, avoiding the need for an additional
/// roundtrip to the server (e.g. via <c>SET TRANSACTION</c>).
/// </summary>
[Flags]
public enum NpgsqlTransactionOptions
{
/// <summary>
/// No additional options are set.
/// </summary>
None = 0,

/// <summary>
/// The transaction is read-only; no data modifications can be made. Corresponds to <c>READ ONLY</c> in <c>BEGIN</c>.
/// </summary>
ReadOnly = 1,

/// <summary>
/// The transaction can be deferred. This only has an effect when the transaction is both <see cref="ReadOnly"/> and
/// <see cref="System.Data.IsolationLevel.Serializable"/>, in which case it allows the database to wait for a point in time where
/// no conflicts can occur before starting the transaction, avoiding the overhead associated with serializable transactions.
/// Corresponds to <c>DEFERRABLE</c> in <c>BEGIN</c>.
/// </summary>
Deferrable = 2,
}
2 changes: 2 additions & 0 deletions src/Npgsql/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ static Microsoft.Extensions.DependencyInjection.NpgsqlServiceCollectionExtension
*REMOVED*override Npgsql.NpgsqlLargeObjectStream.Position.get -> long
*REMOVED*override Npgsql.NpgsqlLargeObjectStream.Position.set -> void
*REMOVED*override Npgsql.NpgsqlLargeObjectStream.Read(byte[]! buffer, int offset, int count) -> int
Npgsql.NpgsqlConnection.BeginTransaction(System.Data.IsolationLevel level, Npgsql.NpgsqlTransactionOptions options) -> Npgsql.NpgsqlTransaction!
Npgsql.NpgsqlConnection.BeginTransactionAsync(System.Data.IsolationLevel level, Npgsql.NpgsqlTransactionOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Npgsql.NpgsqlTransaction!>
*REMOVED*override Npgsql.NpgsqlLargeObjectStream.ReadAsync(byte[]! buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task<int>!
*REMOVED*override Npgsql.NpgsqlLargeObjectStream.Seek(long offset, System.IO.SeekOrigin origin) -> long
*REMOVED*override Npgsql.NpgsqlLargeObjectStream.SetLength(long value) -> void
Expand Down
49 changes: 49 additions & 0 deletions test/Npgsql.Tests/TransactionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,55 @@ public async Task IsolationLevel_Chaos_is_unsupported()
Assert.That(() => conn.BeginTransaction(IsolationLevel.Chaos), Throws.Exception.TypeOf<NotSupportedException>());
}

[Test, IssueLink("https://github.com/npgsql/npgsql/issues/867")]
public async Task ReadOnly_option()
{
await using var conn = await OpenConnectionAsync();
await using (var tx = conn.BeginTransaction(IsolationLevel.Unspecified, NpgsqlTransactionOptions.ReadOnly))
{
Assert.That(conn.ExecuteScalar("SHOW transaction_read_only"), Is.EqualTo("on"));
Assert.That(() => conn.ExecuteNonQuery("CREATE TABLE not_allowed ()"),
Throws.Exception.TypeOf<PostgresException>()
.With.Property(nameof(PostgresException.SqlState)).EqualTo(PostgresErrorCodes.ReadOnlySqlTransaction));
await tx.RollbackAsync();
}

await using (var tx = conn.BeginTransaction(IsolationLevel.Unspecified, NpgsqlTransactionOptions.None))
Assert.That(conn.ExecuteScalar("SHOW transaction_read_only"), Is.EqualTo("off"));
}

[Test, IssueLink("https://github.com/npgsql/npgsql/issues/867")]
public async Task ReadOnly_option_async()
{
await using var conn = await OpenConnectionAsync();
await using var tx = await conn.BeginTransactionAsync(IsolationLevel.Unspecified, NpgsqlTransactionOptions.ReadOnly);
Assert.That(await conn.ExecuteScalarAsync("SHOW transaction_read_only"), Is.EqualTo("on"));
}

[Test, IssueLink("https://github.com/npgsql/npgsql/issues/867")]
public async Task ReadOnly_and_Deferrable_options_with_isolation_level()
{
await using var conn = await OpenConnectionAsync();
await using var tx = conn.BeginTransaction(
IsolationLevel.Serializable, NpgsqlTransactionOptions.ReadOnly | NpgsqlTransactionOptions.Deferrable);

Assert.That(conn.ExecuteScalar("SHOW TRANSACTION ISOLATION LEVEL"), Is.EqualTo("serializable"));
Assert.That(conn.ExecuteScalar("SHOW transaction_read_only"), Is.EqualTo("on"));
Assert.That(conn.ExecuteScalar("SHOW transaction_deferrable"), Is.EqualTo("on"));
}

[Test, IssueLink("https://github.com/npgsql/npgsql/issues/867")]
public async Task ReadOnly_and_Deferrable_options_with_isolation_level_async()
{
await using var conn = await OpenConnectionAsync();
await using var tx = await conn.BeginTransactionAsync(
IsolationLevel.Serializable, NpgsqlTransactionOptions.ReadOnly | NpgsqlTransactionOptions.Deferrable);

Assert.That(await conn.ExecuteScalarAsync("SHOW TRANSACTION ISOLATION LEVEL"), Is.EqualTo("serializable"));
Assert.That(await conn.ExecuteScalarAsync("SHOW transaction_read_only"), Is.EqualTo("on"));
Assert.That(await conn.ExecuteScalarAsync("SHOW transaction_deferrable"), Is.EqualTo("on"));
}

[Test, Description("Rollback of an already rolled back transaction")]
public async Task Rollback_twice()
{
Expand Down
Loading