using System; using System.Data; using System.Data.Common; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Npgsql.Internal; using Npgsql.Logging; namespace Npgsql; /// /// Represents a transaction to be made in a PostgreSQL database. This class cannot be inherited. /// public sealed class NpgsqlTransaction : DbTransaction { #region Fields and Properties /// /// Specifies the object associated with the transaction. /// /// The object associated with the transaction. public new NpgsqlConnection? Connection { get { CheckDisposed(); return _connector?.Connection; } } // Note that with ambient transactions, it's possible for a transaction to be pending after its connection // is already closed. So we capture the connector and perform everything directly on it. NpgsqlConnector _connector; /// /// Specifies the object associated with the transaction. /// /// The object associated with the transaction. protected override DbConnection? DbConnection => Connection; /// /// If true, the transaction has been committed/rolled back, but not disposed. /// internal bool IsCompleted => _connector is null || _connector.TransactionStatus == TransactionStatus.Idle; internal bool IsDisposed; Exception? _disposeReason; /// /// Specifies the isolation level for this transaction. /// /// The isolation level for this transaction. The default is . public override IsolationLevel IsolationLevel { get { CheckReady(); return _isolationLevel; } } IsolationLevel _isolationLevel; static readonly NpgsqlLogger Log = NpgsqlLogManager.CreateLogger(nameof(NpgsqlTransaction)); const IsolationLevel DefaultIsolationLevel = IsolationLevel.ReadCommitted; #endregion #region Initialization internal NpgsqlTransaction(NpgsqlConnector connector) => _connector = connector; internal void Init(IsolationLevel isolationLevel = DefaultIsolationLevel) { Debug.Assert(isolationLevel != IsolationLevel.Chaos); if (!_connector.DatabaseInfo.SupportsTransactions) return; Log.Debug($"Beginning transaction with isolation level {isolationLevel}", _connector.Id); 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: isolationLevel = DefaultIsolationLevel; goto case DefaultIsolationLevel; default: throw new NotSupportedException("Isolation level not supported: " + isolationLevel); } _connector.TransactionStatus = TransactionStatus.Pending; _isolationLevel = isolationLevel; IsDisposed = false; } #endregion #region Commit /// /// Commits the database transaction. /// public override void Commit() => Commit(false).GetAwaiter().GetResult(); async Task Commit(bool async, CancellationToken cancellationToken = default) { CheckReady(); if (!_connector.DatabaseInfo.SupportsTransactions) return; using (_connector.StartUserAction(cancellationToken)) { Log.Debug("Committing transaction", _connector.Id); await _connector.ExecuteInternalCommand(PregeneratedMessages.CommitTransaction, async, cancellationToken); } } /// /// Commits the database transaction. /// /// /// An optional token to cancel the asynchronous operation. The default value is . /// #if NETSTANDARD2_0 public Task CommitAsync(CancellationToken cancellationToken = default) #else public override Task CommitAsync(CancellationToken cancellationToken = default) #endif { using (NoSynchronizationContextScope.Enter()) return Commit(true, cancellationToken); } #endregion #region Rollback /// /// Rolls back a transaction from a pending state. /// public override void Rollback() => Rollback(false).GetAwaiter().GetResult(); async Task Rollback(bool async, CancellationToken cancellationToken = default) { CheckReady(); if (!_connector.DatabaseInfo.SupportsTransactions) return; using (_connector.StartUserAction(cancellationToken)) await _connector.Rollback(async, cancellationToken); } /// /// Rolls back a transaction from a pending state. /// /// /// An optional token to cancel the asynchronous operation. The default value is . /// #if NETSTANDARD2_0 public Task RollbackAsync(CancellationToken cancellationToken = default) #else public override Task RollbackAsync(CancellationToken cancellationToken = default) #endif { using (NoSynchronizationContextScope.Enter()) return Rollback(true, cancellationToken); } #endregion #region Savepoints /// /// Creates a transaction save point. /// /// The name of the savepoint. /// /// This method does not cause a database roundtrip to be made. The savepoint creation statement will instead be sent along with /// the next command. /// #if NET5_0_OR_GREATER public override void Save(string name) #else public void Save(string name) #endif { if (name == null) throw new ArgumentNullException(nameof(name)); if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name can't be empty", nameof(name)); CheckReady(); if (!_connector.DatabaseInfo.SupportsTransactions) return; // Note that creating a savepoint doesn't actually send anything to the backend (only prepends), so strictly speaking we don't // have to start a user action. However, we do this for consistency as if we did (for the checks and exceptions) using var _ = _connector.StartUserAction(); Log.Debug($"Creating savepoint {name}", _connector.Id); if (RequiresQuoting(name)) name = $"\"{name.Replace("\"", "\"\"")}\""; // Note: savepoint names are PostgreSQL identifiers, and so limited by default to 63 characters. // Since we are prepending, we assume below that the statement will always fit in the buffer. _connector.WriteBuffer.WriteByte(FrontendMessageCode.Query); _connector.WriteBuffer.WriteInt32( sizeof(int) + // Message length (including self excluding code) _connector.TextEncoding.GetByteCount("SAVEPOINT ") + _connector.TextEncoding.GetByteCount(name) + sizeof(byte)); // Null terminator _connector.WriteBuffer.WriteString("SAVEPOINT "); _connector.WriteBuffer.WriteString(name); _connector.WriteBuffer.WriteByte(0); _connector.PendingPrependedResponses += 2; } /// /// Creates a transaction save point. /// /// The name of the savepoint. /// /// An optional token to cancel the asynchronous operation. The default value is . /// /// /// This method does not cause a database roundtrip to be made, and will therefore always complete synchronously. /// The savepoint creation statement will instead be sent along with the next command. /// #if NET5_0_OR_GREATER public override Task SaveAsync(string name, CancellationToken cancellationToken = default) #else public Task SaveAsync(string name, CancellationToken cancellationToken = default) #endif { Save(name); return Task.CompletedTask; } async Task Rollback(string name, bool async, CancellationToken cancellationToken = default) { if (name == null) throw new ArgumentNullException(nameof(name)); if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name can't be empty", nameof(name)); CheckReady(); if (!_connector.DatabaseInfo.SupportsTransactions) return; using (_connector.StartUserAction(cancellationToken)) { Log.Debug($"Rolling back savepoint {name}", _connector.Id); if (RequiresQuoting(name)) name = $"\"{name.Replace("\"", "\"\"")}\""; await _connector.ExecuteInternalCommand($"ROLLBACK TO SAVEPOINT {name}", async, cancellationToken); } } /// /// Rolls back a transaction from a pending savepoint state. /// /// The name of the savepoint. #if NET5_0_OR_GREATER public override void Rollback(string name) #else public void Rollback(string name) #endif => Rollback(name, false).GetAwaiter().GetResult(); /// /// Rolls back a transaction from a pending savepoint state. /// /// The name of the savepoint. /// /// An optional token to cancel the asynchronous operation. The default value is . /// #if NET5_0_OR_GREATER public override Task RollbackAsync(string name, CancellationToken cancellationToken = default) #else public Task RollbackAsync(string name, CancellationToken cancellationToken = default) #endif { using (NoSynchronizationContextScope.Enter()) return Rollback(name, true, cancellationToken); } async Task Release(string name, bool async, CancellationToken cancellationToken = default) { if (name == null) throw new ArgumentNullException(nameof(name)); if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name can't be empty", nameof(name)); CheckReady(); if (!_connector.DatabaseInfo.SupportsTransactions) return; using (_connector.StartUserAction(cancellationToken)) { Log.Debug($"Releasing savepoint {name}", _connector.Id); if (RequiresQuoting(name)) name = $"\"{name.Replace("\"", "\"\"")}\""; await _connector.ExecuteInternalCommand($"RELEASE SAVEPOINT {name}", async, cancellationToken); } } /// /// Releases a transaction from a pending savepoint state. /// /// The name of the savepoint. #if NET5_0_OR_GREATER public override void Release(string name) => Release(name, false).GetAwaiter().GetResult(); #else public void Release(string name) => Release(name, false).GetAwaiter().GetResult(); #endif /// /// Releases a transaction from a pending savepoint state. /// /// The name of the savepoint. /// /// An optional token to cancel the asynchronous operation. The default value is . /// #if NET5_0_OR_GREATER public override Task ReleaseAsync(string name, CancellationToken cancellationToken = default) #else public Task ReleaseAsync(string name, CancellationToken cancellationToken = default) #endif { using (NoSynchronizationContextScope.Enter()) return Release(name, true, cancellationToken); } /// /// Indicates whether this transaction supports database savepoints. /// #if NET5_0_OR_GREATER public override bool SupportsSavepoints #else public bool SupportsSavepoints #endif { get => _connector.DatabaseInfo.SupportsTransactions; } #endregion #region Dispose /// /// Disposes the transaction, rolling it back if it is still pending. /// protected override void Dispose(bool disposing) { if (IsDisposed) return; if (disposing) { if (!IsCompleted) { try { _connector.CloseOngoingOperations(async: false).GetAwaiter().GetResult(); Rollback(); } catch (Exception ex) { Debug.Assert(_connector.IsBroken); Log.Error("Exception while disposing a transaction", ex, _connector.Id); } } IsDisposed = true; _connector?.Connection?.EndBindingScope(ConnectorBindingScope.Transaction); } } /// /// Disposes the transaction, rolling it back if it is still pending. /// #if NETSTANDARD2_0 public ValueTask DisposeAsync() #else public override ValueTask DisposeAsync() #endif { if (!IsDisposed) { if (!IsCompleted) { using (NoSynchronizationContextScope.Enter()) return DisposeAsyncInternal(); } IsDisposed = true; _connector?.Connection?.EndBindingScope(ConnectorBindingScope.Transaction); } return default; async ValueTask DisposeAsyncInternal() { // We're disposing, so no cancellation token try { await _connector.CloseOngoingOperations(async: true); await Rollback(async: true); } catch (Exception ex) { Debug.Assert(_connector.IsBroken); Log.Error("Exception while disposing a transaction", ex, _connector.Id); } IsDisposed = true; _connector?.Connection?.EndBindingScope(ConnectorBindingScope.Transaction); } } /// /// Disposes the transaction, without rolling back. Used only in special circumstances, e.g. when /// the connection is broken. /// internal void DisposeImmediately(Exception? disposeReason) { IsDisposed = true; _disposeReason = disposeReason; } #endregion #region Checks void CheckReady() { CheckDisposed(); if (IsCompleted) throw new InvalidOperationException("This NpgsqlTransaction has completed; it is no longer usable."); } void CheckDisposed() { if (IsDisposed) throw new ObjectDisposedException(typeof(NpgsqlTransaction).Name, _disposeReason); } static bool RequiresQuoting(string identifier) { Debug.Assert(identifier.Length > 0); var first = identifier[0]; if (first != '_' && !char.IsLower(first)) return true; foreach (var c in identifier.AsSpan(1)) if (c != '_' && c != '$' && !char.IsLower(c) && !char.IsDigit(c)) return true; return false; } #endregion #region Misc /// /// Unbinds transaction from the connector. /// Should be called before the connector is returned to the pool. /// internal void UnbindIfNecessary() { // We're closing the connection, but transaction is not yet disposed // We have to unbind the transaction from the connector, otherwise there could be a concurrency issues // See #3306 if (!IsDisposed) { if (_connector.UnboundTransaction is { IsDisposed: true } previousTransaction) { previousTransaction._connector = _connector; _connector.Transaction = previousTransaction; } else _connector.Transaction = null; _connector.UnboundTransaction = this; _connector = null!; } } #endregion }