From aedb97e879cb207f6a023207479b5d0ce4f68b53 Mon Sep 17 00:00:00 2001 From: Grzegorz Russek Date: Fri, 27 Feb 2026 17:28:50 +0100 Subject: [PATCH] Add managed connection pooling and execution locking --- AmalgamationTool/DynamORM.Amalgamation.cs | 507 +++++++++++--- .../ConnectionPoolingAndLockingTests.cs | 153 +++++ DynamORM.Tests/Helpers/FakeProviderFactory.cs | 236 +++++++ DynamORM.Tests/Helpers/PoolingTests.cs | 5 +- DynamORM/DynamicCommand.cs | 112 +-- DynamORM/DynamicConnection.cs | 8 +- DynamORM/DynamicDatabase.cs | 635 ++++++++++++------ DynamORM/DynamicDatabaseOptions.cs | 18 +- DynamORM/DynamicTransaction.cs | 28 +- DynamORM/Helpers/DynamicExecutionReader.cs | 101 +++ docs/quick-start.md | 8 + docs/transactions-and-disposal.md | 91 +++ 12 files changed, 1554 insertions(+), 348 deletions(-) create mode 100644 DynamORM.Tests/Helpers/ConnectionPoolingAndLockingTests.cs create mode 100644 DynamORM.Tests/Helpers/FakeProviderFactory.cs create mode 100644 DynamORM/Helpers/DynamicExecutionReader.cs diff --git a/AmalgamationTool/DynamORM.Amalgamation.cs b/AmalgamationTool/DynamORM.Amalgamation.cs index 55e0de4..f632056 100644 --- a/AmalgamationTool/DynamORM.Amalgamation.cs +++ b/AmalgamationTool/DynamORM.Amalgamation.cs @@ -29,6 +29,7 @@ using System.Runtime.CompilerServices; using System.Runtime.Serialization; using System.Text.RegularExpressions; using System.Text; +using System.Threading; using System; [module: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:FileMayOnlyContainASingleClass", Justification = "This is a generated file which generates all the necessary support classes.")] @@ -1271,14 +1272,21 @@ namespace DynamORM /// The number of rows affected. public int ExecuteNonQuery() { + IDisposable scope = null; try { + scope = _db != null ? _db.AcquireExecutionScope() : null; return PrepareForExecution().ExecuteNonQuery(); } catch (Exception ex) { throw new DynamicQueryException(ex, this); } + finally + { + if (scope != null) + scope.Dispose(); + } } /// Executes the /// against the , @@ -1289,12 +1297,16 @@ namespace DynamORM /// An object. public IDataReader ExecuteReader(CommandBehavior behavior) { + IDisposable scope = null; try { - return PrepareForExecution().ExecuteReader(behavior); + scope = _db != null ? _db.AcquireExecutionScope() : null; + return new DynamicExecutionReader(PrepareForExecution().ExecuteReader(behavior), scope); } catch (Exception ex) { + if (scope != null) + scope.Dispose(); throw new DynamicQueryException(ex, this); } } @@ -1304,12 +1316,16 @@ namespace DynamORM /// An object. public IDataReader ExecuteReader() { + IDisposable scope = null; try { - return PrepareForExecution().ExecuteReader(); + scope = _db != null ? _db.AcquireExecutionScope() : null; + return new DynamicExecutionReader(PrepareForExecution().ExecuteReader(), scope); } catch (Exception ex) { + if (scope != null) + scope.Dispose(); throw new DynamicQueryException(ex, this); } } @@ -1319,14 +1335,21 @@ namespace DynamORM /// The first column of the first row in the result set. public object ExecuteScalar() { + IDisposable scope = null; try { + scope = _db != null ? _db.AcquireExecutionScope() : null; return PrepareForExecution().ExecuteScalar(); } catch (Exception ex) { throw new DynamicQueryException(ex, this); } + finally + { + if (scope != null) + scope.Dispose(); + } } /// Gets the . public IDataParameterCollection Parameters @@ -1384,7 +1407,7 @@ namespace DynamORM IsDisposed = true; - if (_con != null) + if (_con != null && db.CommandsPool != null) { List pool = db.CommandsPool.TryGetValue(_con.Connection); @@ -1436,7 +1459,7 @@ namespace DynamORM /// Returns representation. internal DynamicTransaction BeginTransaction(IsolationLevel? il, object custom, Action disposed) { - return new DynamicTransaction(_db, this, _singleTransaction, il, disposed, null); + return new DynamicTransaction(_db, this, _singleTransaction, il, disposed, custom); } #region IDbConnection Members @@ -1546,6 +1569,13 @@ namespace DynamORM /// Dynamic database is a class responsible for managing database. public class DynamicDatabase : IExtendedDisposable { + private sealed class ConnectionEntry + { + public bool External { get; set; } + public int LeaseCount { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastReleasedUtc { get; set; } + } #region Internal fields and properties private DbProviderFactory _provider; @@ -1553,6 +1583,7 @@ namespace DynamORM private string _connectionString; private bool _singleConnection; private bool _singleTransaction; + private bool _connectionPooling; private string _leftDecorator = "\""; private string _rightDecorator = "\""; private bool _leftDecoratorIsInInvalidMembersChars = true; @@ -1561,7 +1592,9 @@ namespace DynamORM private int? _commandTimeout = null; private long _poolStamp = 0; - private DynamicConnection _tempConn = null; + private readonly object _executionSyncRoot = new object(); + private int _executionOwnerThreadId = -1; + private int _executionLockDepth = 0; /// Provides lock object for this database instance. internal readonly object SyncLock = new object(); @@ -1593,6 +1626,12 @@ namespace DynamORM /// Pool should contain dynamic commands instead of native ones. internal Dictionary> CommandsPool { get; private set; } + /// Gets connection metadata tracked by the database instance. + private Dictionary ConnectionEntries { get; set; } + + /// Gets ambient transaction-bound connections keyed by managed thread identifier. + private Dictionary AmbientTransactionConnections { get; set; } + /// Gets schema columns cache. internal Dictionary> Schema { get; private set; } @@ -1616,6 +1655,18 @@ namespace DynamORM /// Gets or sets command timeout. public int? CommandTimeout { get { return _commandTimeout; } set { _commandTimeout = value; _poolStamp = DateTime.Now.Ticks; } } + /// Gets or sets the preferred number of idle open connections kept in the internal pool. + /// Default value is 32. Applies only when is enabled. + public int ConnectionPoolingKeepOpenCount { get; set; } + + /// Gets or sets the maximum number of connections that may remain managed by the internal pool at once. + /// Default value is 128. When this limit is reached, callers wait for a pooled connection to be released. + public int ConnectionPoolingMaximumOpenCount { get; set; } + + /// Gets or sets how long an idle pooled connection may stay open before it is retired. + /// Default value is one hour. Applies only when is enabled. + public TimeSpan ConnectionPoolingConnectionLifetime { get; set; } + /// Gets the database provider. public DbProviderFactory Provider { get { return _provider; } } @@ -1804,6 +1855,14 @@ namespace DynamORM IsDisposed = false; InitCommon(connection.ConnectionString, options); TransactionPool.Add(connection, new Stack()); + CommandsPool.Add(connection, new List()); + ConnectionEntries.Add(connection, new ConnectionEntry + { + External = true, + LeaseCount = 0, + CreatedUtc = DateTime.UtcNow, + LastReleasedUtc = DateTime.UtcNow, + }); if (!_singleConnection) throw new InvalidOperationException("This constructor accepts only connections with DynamicDatabaseOptions.SingleConnection option."); @@ -1815,10 +1874,16 @@ namespace DynamORM _singleConnection = (options & DynamicDatabaseOptions.SingleConnection) == DynamicDatabaseOptions.SingleConnection; _singleTransaction = (options & DynamicDatabaseOptions.SingleTransaction) == DynamicDatabaseOptions.SingleTransaction; + _connectionPooling = (options & DynamicDatabaseOptions.ConnectionPooling) == DynamicDatabaseOptions.ConnectionPooling; DumpCommands = (options & DynamicDatabaseOptions.DumpCommands) == DynamicDatabaseOptions.DumpCommands; + ConnectionPoolingKeepOpenCount = 32; + ConnectionPoolingMaximumOpenCount = 128; + ConnectionPoolingConnectionLifetime = TimeSpan.FromHours(1); TransactionPool = new Dictionary>(); CommandsPool = new Dictionary>(); + ConnectionEntries = new Dictionary(); + AmbientTransactionConnections = new Dictionary(); Schema = new Dictionary>(); RemainingBuilders = new List(); #if !DYNAMORM_OMMIT_OLDSYNTAX @@ -3356,7 +3421,205 @@ namespace DynamORM #region Connection + private bool UsesExecutionSerialization + { + get { return _singleConnection || _singleTransaction; } + } + private bool UsesManagedPooling + { + get { return _connectionPooling && !_singleConnection; } + } + private IDisposable EnterExecutionScope() + { + if (!UsesExecutionSerialization) + return null; + + int currentThreadId = Thread.CurrentThread.ManagedThreadId; + + lock (_executionSyncRoot) + { + while (_executionLockDepth > 0 && _executionOwnerThreadId != currentThreadId) + Monitor.Wait(_executionSyncRoot); + + _executionOwnerThreadId = currentThreadId; + _executionLockDepth++; + } + return new ExecutionScope(this); + } + private void ExitExecutionScope() + { + if (!UsesExecutionSerialization) + return; + + lock (_executionSyncRoot) + { + if (_executionLockDepth > 0) + _executionLockDepth--; + + if (_executionLockDepth == 0) + { + _executionOwnerThreadId = -1; + Monitor.PulseAll(_executionSyncRoot); + } + } + } + private sealed class ExecutionScope : IDisposable + { + private DynamicDatabase _db; + + public ExecutionScope(DynamicDatabase db) + { + _db = db; + } + public void Dispose() + { + DynamicDatabase db = _db; + if (db == null) + return; + + _db = null; + db.ExitExecutionScope(); + } + } + internal IDisposable AcquireExecutionScope() + { + return EnterExecutionScope(); + } + private bool IsConnectionAmbientForAnotherThread(IDbConnection connection, int currentThreadId) + { + return AmbientTransactionConnections.Any(x => x.Key != currentThreadId && x.Value == connection); + } + private void AddManagedConnection(IDbConnection connection, bool external) + { + TransactionPool.Add(connection, new Stack()); + CommandsPool.Add(connection, new List()); + ConnectionEntries.Add(connection, new ConnectionEntry + { + External = external, + LeaseCount = 0, + CreatedUtc = DateTime.UtcNow, + LastReleasedUtc = DateTime.UtcNow, + }); + } + private IDbConnection CreateManagedConnection() + { + IDbConnection conn = _provider.CreateConnection(); + conn.ConnectionString = _connectionString; + conn.Open(); + AddManagedConnection(conn, false); + return conn; + } + private void EnsureConnectionIsOpen(IDbConnection connection) + { + if (connection != null && connection.State != ConnectionState.Open) + connection.Open(); + } + private void TrimIdleConnectionsUnderLock() + { + DateTime now = DateTime.UtcNow; + int idleCount = ConnectionEntries + .Where(x => !x.Value.External) + .Count(x => x.Value.LeaseCount == 0 && TransactionPool[x.Key].Count == 0 && CommandsPool[x.Key].Count == 0 && !AmbientTransactionConnections.ContainsValue(x.Key)); + + List toRemove = new List(); + + foreach (KeyValuePair item in ConnectionEntries) + { + if (item.Value.External || item.Value.LeaseCount > 0) + continue; + + if (TransactionPool[item.Key].Count > 0 || CommandsPool[item.Key].Count > 0 || AmbientTransactionConnections.ContainsValue(item.Key)) + continue; + + bool expired = ConnectionPoolingConnectionLifetime <= TimeSpan.Zero || now - item.Value.LastReleasedUtc >= ConnectionPoolingConnectionLifetime; + bool abovePreferred = idleCount > ConnectionPoolingKeepOpenCount; + + if (expired || abovePreferred) + { + toRemove.Add(item.Key); + idleCount--; + } + } + foreach (IDbConnection connection in toRemove) + { + ConnectionEntries.Remove(connection); + TransactionPool.Remove(connection); + CommandsPool.Remove(connection); + + if (connection.State == ConnectionState.Open) + connection.Close(); + + connection.Dispose(); + } + } + private IDbConnection GetOrCreateManagedConnectionUnderLock(out bool opened) + { + opened = false; + int currentThreadId = Thread.CurrentThread.ManagedThreadId; + + if (_singleConnection) + { + IDbConnection single = ConnectionEntries.Count == 0 ? CreateManagedConnection() : ConnectionEntries.Keys.First(); + if (single.State != ConnectionState.Open) + { + single.Open(); + opened = true; + } + ConnectionEntries[single].LeaseCount++; + return single; + } + IDbConnection ambient; + if (AmbientTransactionConnections.TryGetValue(currentThreadId, out ambient)) + { + EnsureConnectionIsOpen(ambient); + ConnectionEntries[ambient].LeaseCount++; + return ambient; + } + if (!UsesManagedPooling) + { + IDbConnection conn = CreateManagedConnection(); + opened = true; + ConnectionEntries[conn].LeaseCount++; + return conn; + } + while (true) + { + TrimIdleConnectionsUnderLock(); + + IDbConnection pooled = ConnectionEntries + .Where(x => !x.Value.External && x.Value.LeaseCount == 0) + .Where(x => TransactionPool[x.Key].Count == 0 && CommandsPool[x.Key].Count == 0) + .Where(x => !IsConnectionAmbientForAnotherThread(x.Key, currentThreadId)) + .Select(x => x.Key) + .FirstOrDefault(); + + if (pooled != null) + { + if (pooled.State != ConnectionState.Open) + { + pooled.Open(); + opened = true; + } + ConnectionEntries[pooled].LeaseCount++; + return pooled; + } + if (ConnectionEntries.Count < ConnectionPoolingMaximumOpenCount) + { + IDbConnection conn = CreateManagedConnection(); + opened = true; + ConnectionEntries[conn].LeaseCount++; + return conn; + } + Monitor.Wait(SyncLock); + } + } /// Open managed connection. + /// + /// When is enabled, DynamORM reuses idle managed connections instead of opening and closing a new one for every operation. + /// When a transaction is started through , , or , + /// commands executed on the same thread reuse the same underlying connection until that transaction finishes. + /// Other threads do not join that transaction; they use another managed connection or wait for one to become available. + /// /// Opened connection. public IDbConnection Open() { @@ -3366,32 +3629,8 @@ namespace DynamORM lock (SyncLock) { - if (_tempConn == null) - { - if (TransactionPool.Count == 0 || !_singleConnection) - { - conn = _provider.CreateConnection(); - conn.ConnectionString = _connectionString; - conn.Open(); - opened = true; - - TransactionPool.Add(conn, new Stack()); - CommandsPool.Add(conn, new List()); - } - else - { - conn = TransactionPool.Keys.First(); - - if (conn.State != ConnectionState.Open) - { - conn.Open(); - opened = true; - } - } - ret = new DynamicConnection(this, conn, _singleTransaction); - } - else - ret = _tempConn; + conn = GetOrCreateManagedConnectionUnderLock(out opened); + ret = new DynamicConnection(this, conn, _singleTransaction); } if (opened) ExecuteInitCommands(ret); @@ -3405,40 +3644,46 @@ namespace DynamORM if (connection == null) return; - if (!_singleConnection && connection != null && TransactionPool.ContainsKey(connection)) + lock (SyncLock) { - // Close all commands - if (CommandsPool.ContainsKey(connection)) - { - List tmp = CommandsPool[connection].ToList(); - tmp.ForEach(cmd => cmd.Dispose()); + if (IsDisposed || ConnectionEntries == null || TransactionPool == null || CommandsPool == null) + return; - CommandsPool[connection].Clear(); - } - // Rollback remaining transactions - while (TransactionPool[connection].Count > 0) + ConnectionEntry entry; + if (!ConnectionEntries.TryGetValue(connection, out entry)) + return; + + if (entry.LeaseCount > 0) + entry.LeaseCount--; + + bool hasAmbientOwner = AmbientTransactionConnections.ContainsValue(connection); + bool hasTransactions = TransactionPool.ContainsKey(connection) && TransactionPool[connection].Count > 0; + bool hasCommands = CommandsPool.ContainsKey(connection) && CommandsPool[connection].Count > 0; + + if (_singleConnection || hasAmbientOwner || hasTransactions || hasCommands || entry.LeaseCount > 0) { - IDbTransaction trans = TransactionPool[connection].Pop(); - trans.Rollback(); - trans.Dispose(); + Monitor.PulseAll(SyncLock); + return; } - // Close connection + entry.LastReleasedUtc = DateTime.UtcNow; + + if (UsesManagedPooling && !entry.External) + { + TrimIdleConnectionsUnderLock(); + Monitor.PulseAll(SyncLock); + return; + } + ConnectionEntries.Remove(connection); + TransactionPool.Remove(connection); + CommandsPool.Remove(connection); + if (connection.State == ConnectionState.Open) connection.Close(); - // remove from pools - lock (SyncLock) - { - TransactionPool.Remove(connection); - CommandsPool.Remove(connection); - } - // Set stamp _poolStamp = DateTime.Now.Ticks; - - // Dispose the corpse - connection.Dispose(); - connection = null; + Monitor.PulseAll(SyncLock); } + connection.Dispose(); } /// Gets or sets contains commands executed when connection is opened. public List InitCommands { get; set; } @@ -3457,60 +3702,59 @@ namespace DynamORM #region Transaction /// Begins a global database transaction. - /// Using this method connection is set to single open - /// connection until all transactions are finished. + /// + /// Using this method binds one managed connection to the current thread until all direct database transactions on that thread are finished. + /// Commands executed through the same instance on that thread reuse that transaction-bound connection. + /// Other threads do not participate in that transaction and use another managed connection or wait for one to become available. + /// /// Returns representation. public IDbTransaction BeginTransaction() { - _tempConn = Open() as DynamicConnection; - - return _tempConn.BeginTransaction(null, null, () => - { - Stack t = TransactionPool.TryGetValue(_tempConn.Connection); - - if (t == null | t.Count == 0) - { - _tempConn.Dispose(); - _tempConn = null; - } - }); + return BeginAmbientTransaction(null, null); } /// Begins a database transaction with the specified /// value. + /// Thread ownership and connection binding follow the same rules as . /// One of the values. /// Returns representation. public IDbTransaction BeginTransaction(IsolationLevel il) { - _tempConn = Open() as DynamicConnection; - - return _tempConn.BeginTransaction(il, null, () => - { - Stack t = TransactionPool.TryGetValue(_tempConn.Connection); - - if (t == null | t.Count == 0) - { - _tempConn.Dispose(); - _tempConn = null; - } - }); + return BeginAmbientTransaction(il, null); } /// Begins a database transaction with the specified - /// value. + /// custom provider argument. + /// Thread ownership and connection binding follow the same rules as . /// Custom parameter describing transaction options. /// Returns representation. public IDbTransaction BeginTransaction(object custom) { - _tempConn = Open() as DynamicConnection; + return BeginAmbientTransaction(null, custom); + } + private IDbTransaction BeginAmbientTransaction(IsolationLevel? il, object custom) + { + DynamicConnection connection = Open() as DynamicConnection; + int threadId = Thread.CurrentThread.ManagedThreadId; - return _tempConn.BeginTransaction(null, custom, () => + lock (SyncLock) + AmbientTransactionConnections[threadId] = connection.Connection; + + return connection.BeginTransaction(il, custom, () => { - Stack t = TransactionPool.TryGetValue(_tempConn.Connection); + bool releaseConnection = false; - if (t == null | t.Count == 0) + lock (SyncLock) { - _tempConn.Dispose(); - _tempConn = null; + Stack t = TransactionPool.TryGetValue(connection.Connection); + + if (t == null || t.Count == 0) + { + AmbientTransactionConnections.Remove(threadId); + releaseConnection = true; + Monitor.PulseAll(SyncLock); + } } + if (releaseConnection) + connection.Dispose(); }); } #endregion Transaction @@ -3567,10 +3811,14 @@ namespace DynamORM { TransactionPool.Clear(); CommandsPool.Clear(); + ConnectionEntries.Clear(); + AmbientTransactionConnections.Clear(); RemainingBuilders.Clear(); TransactionPool = null; CommandsPool = null; + ConnectionEntries = null; + AmbientTransactionConnections = null; RemainingBuilders = null; } ClearSchema(); @@ -3578,7 +3826,6 @@ namespace DynamORM _proc.Dispose(); _proc = null; - _tempConn = null; IsDisposed = true; } /// Gets a value indicating whether this instance is disposed. @@ -3595,11 +3842,17 @@ namespace DynamORM None = 0x00000000, /// Only single persistent database connection. + /// Command execution is serialized inside one instance when this option is enabled. SingleConnection = 0x00000001, /// Only one transaction. + /// Command execution is serialized inside one instance when this option is enabled. SingleTransaction = 0x00000002, + /// Use internal connection pooling when connections are not kept as a single shared connection. + /// Pooling reuses idle managed connections and is configured through , , and . + ConnectionPooling = 0x00000004, + /// Database supports top syntax (SELECT TOP x ... FROM ...). SupportTop = 0x00000080, @@ -6902,6 +7155,11 @@ namespace DynamORM /// Commits the database transaction. public void Commit() { + if (_db == null) + { + _isOperational = false; + return; + } lock (_db.SyncLock) { if (_isOperational) @@ -6929,6 +7187,11 @@ namespace DynamORM /// Rolls back a transaction from a pending state. public void Rollback() { + if (_db == null) + { + _isOperational = false; + return; + } lock (_db.SyncLock) { if (_isOperational) @@ -14612,6 +14875,70 @@ namespace DynamORM return resultTable; } } + internal sealed class DynamicExecutionReader : IDataReader + { + private IDataReader _reader; + private IDisposable _scope; + + public DynamicExecutionReader(IDataReader reader, IDisposable scope) + { + _reader = reader; + _scope = scope; + } + public object this[string name] { get { return _reader[name]; } } + public object this[int i] { get { return _reader[i]; } } + public int Depth { get { return _reader.Depth; } } + public bool IsClosed { get { return _reader.IsClosed; } } + public int RecordsAffected { get { return _reader.RecordsAffected; } } + public int FieldCount { get { return _reader.FieldCount; } } + + public void Close() + { + if (_reader != null) + _reader.Close(); + + Dispose(); + } + public void Dispose() + { + IDataReader reader = _reader; + IDisposable scope = _scope; + + _reader = null; + _scope = null; + + if (reader != null) + reader.Dispose(); + + if (scope != null) + scope.Dispose(); + } + public bool GetBoolean(int i) { return _reader.GetBoolean(i); } + public byte GetByte(int i) { return _reader.GetByte(i); } + public long GetBytes(int i, long fieldOffset, byte[] buffer, int bufferoffset, int length) { return _reader.GetBytes(i, fieldOffset, buffer, bufferoffset, length); } + public char GetChar(int i) { return _reader.GetChar(i); } + public long GetChars(int i, long fieldoffset, char[] buffer, int bufferoffset, int length) { return _reader.GetChars(i, fieldoffset, buffer, bufferoffset, length); } + public IDataReader GetData(int i) { return _reader.GetData(i); } + public string GetDataTypeName(int i) { return _reader.GetDataTypeName(i); } + public DateTime GetDateTime(int i) { return _reader.GetDateTime(i); } + public decimal GetDecimal(int i) { return _reader.GetDecimal(i); } + public double GetDouble(int i) { return _reader.GetDouble(i); } + public Type GetFieldType(int i) { return _reader.GetFieldType(i); } + public float GetFloat(int i) { return _reader.GetFloat(i); } + public Guid GetGuid(int i) { return _reader.GetGuid(i); } + public short GetInt16(int i) { return _reader.GetInt16(i); } + public int GetInt32(int i) { return _reader.GetInt32(i); } + public long GetInt64(int i) { return _reader.GetInt64(i); } + public string GetName(int i) { return _reader.GetName(i); } + public int GetOrdinal(string name) { return _reader.GetOrdinal(name); } + public DataTable GetSchemaTable() { return _reader.GetSchemaTable(); } + public string GetString(int i) { return _reader.GetString(i); } + public object GetValue(int i) { return _reader.GetValue(i); } + public int GetValues(object[] values) { return _reader.GetValues(values); } + public bool IsDBNull(int i) { return _reader.IsDBNull(i); } + public bool NextResult() { return _reader.NextResult(); } + public bool Read() { return _reader.Read(); } + } internal sealed class DynamicProcedureDescriptor { public Type ProcedureType { get; set; } diff --git a/DynamORM.Tests/Helpers/ConnectionPoolingAndLockingTests.cs b/DynamORM.Tests/Helpers/ConnectionPoolingAndLockingTests.cs new file mode 100644 index 0000000..d94481b --- /dev/null +++ b/DynamORM.Tests/Helpers/ConnectionPoolingAndLockingTests.cs @@ -0,0 +1,153 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using System; +using System.Data; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace DynamORM.Tests.Helpers +{ + [TestFixture] + public class ConnectionPoolingAndLockingTests + { + private FakeProviderState _state; + private DynamicDatabase _db; + + [TearDown] + public void TearDown() + { + if (_db != null) + _db.Dispose(); + } + + [Test] + public void TestConnectionPoolingReusesIdleConnection() + { + CreateDatabase(DynamicDatabaseOptions.ConnectionPooling); + + using (_db.Open()) { } + using (_db.Open()) { } + + Assert.AreEqual(1, _state.CreatedConnections); + Assert.AreEqual(1, _state.OpenCalls); + Assert.AreEqual(0, _state.CloseCalls); + } + + [Test] + public void TestConnectionPoolingWaitsForReleasedConnectionWhenAtMaximum() + { + CreateDatabase(DynamicDatabaseOptions.ConnectionPooling); + _db.ConnectionPoolingKeepOpenCount = 1; + _db.ConnectionPoolingMaximumOpenCount = 1; + + var first = _db.Open(); + var started = new ManualResetEventSlim(false); + var completed = new ManualResetEventSlim(false); + + var task = Task.Run(() => + { + started.Set(); + using (_db.Open()) { } + completed.Set(); + }); + + Assert.IsTrue(started.Wait(TimeSpan.FromSeconds(2))); + Assert.IsFalse(completed.Wait(TimeSpan.FromMilliseconds(200))); + + first.Dispose(); + + Assert.IsTrue(completed.Wait(TimeSpan.FromSeconds(2))); + task.Wait(TimeSpan.FromSeconds(2)); + Assert.AreEqual(1, _state.CreatedConnections); + } + + [Test] + public void TestConnectionPoolingClosesExpiredIdleConnections() + { + CreateDatabase(DynamicDatabaseOptions.ConnectionPooling); + _db.ConnectionPoolingConnectionLifetime = TimeSpan.Zero; + + using (_db.Open()) { } + using (_db.Open()) { } + + Assert.AreEqual(2, _state.CreatedConnections); + Assert.GreaterOrEqual(_state.CloseCalls, 1); + Assert.GreaterOrEqual(_state.DisposeCalls, 1); + } + + [Test] + public void TestDirectTransactionUsesSameThreadConnectionAndSeparateThreadGetsDifferentOne() + { + CreateDatabase(DynamicDatabaseOptions.ConnectionPooling); + + using (var trans = _db.BeginTransaction()) + { + IDbConnection threadLocalA = _db.Open(); + IDbConnection threadLocalB = _db.Open(); + + try + { + Assert.AreSame(((DynamicConnection)threadLocalA).Connection, ((DynamicConnection)threadLocalB).Connection); + + IDbConnection otherThreadConnection = null; + var task = Task.Run(() => + { + using (var other = _db.Open()) + otherThreadConnection = ((DynamicConnection)other).Connection; + }); + + Assert.IsTrue(task.Wait(TimeSpan.FromSeconds(2))); + Assert.AreNotSame(((DynamicConnection)threadLocalA).Connection, otherThreadConnection); + } + finally + { + threadLocalA.Dispose(); + threadLocalB.Dispose(); + } + } + } + + [TestCase(DynamicDatabaseOptions.SingleConnection)] + [TestCase(DynamicDatabaseOptions.SingleTransaction)] + public void TestSingleModesSerializeCommandExecution(DynamicDatabaseOptions option) + { + CreateDatabase(option); + _state.BlockFirstExecution = true; + _state.AllowExecution.Reset(); + + Task task1 = Task.Run(() => ExecuteFakeCommand()); + Assert.IsTrue(_state.FirstExecutionEntered.Wait(TimeSpan.FromSeconds(2))); + + Task task2 = Task.Run(() => ExecuteFakeCommand()); + Thread.Sleep(200); + + Assert.AreEqual(1, _state.MaxConcurrentExecutions); + + _state.AllowExecution.Set(); + + Assert.IsTrue(Task.WaitAll(new[] { task1, task2 }, TimeSpan.FromSeconds(5))); + Assert.AreEqual(1, _state.MaxConcurrentExecutions); + } + + private void ExecuteFakeCommand() + { + using (var connection = _db.Open()) + using (var command = connection.CreateCommand()) + { + command.SetCommand("SELECT 1;"); + command.ExecuteNonQuery(); + } + } + + private void CreateDatabase(DynamicDatabaseOptions options) + { + _state = new FakeProviderState(); + _db = new DynamicDatabase(new FakeProviderFactory(_state), "fake", options); + } + } +} diff --git a/DynamORM.Tests/Helpers/FakeProviderFactory.cs b/DynamORM.Tests/Helpers/FakeProviderFactory.cs new file mode 100644 index 0000000..a904e74 --- /dev/null +++ b/DynamORM.Tests/Helpers/FakeProviderFactory.cs @@ -0,0 +1,236 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Threading; + +namespace DynamORM.Tests.Helpers +{ + internal sealed class FakeProviderState + { + public int CreatedConnections; + public int OpenCalls; + public int CloseCalls; + public int DisposeCalls; + public int BeginTransactionCalls; + public int ExecuteNonQueryCalls; + public int MaxConcurrentExecutions; + public int CurrentConcurrentExecutions; + public object LastTransactionSeen; + public readonly List Connections = new List(); + public ManualResetEventSlim FirstExecutionEntered = new ManualResetEventSlim(false); + public ManualResetEventSlim AllowExecution = new ManualResetEventSlim(true); + public bool BlockFirstExecution; + private int _blocked; + + public void RecordExecution(IDbTransaction transaction) + { + int current = Interlocked.Increment(ref CurrentConcurrentExecutions); + int snapshot; + while ((snapshot = MaxConcurrentExecutions) < current) + Interlocked.CompareExchange(ref MaxConcurrentExecutions, current, snapshot); + + Interlocked.Increment(ref ExecuteNonQueryCalls); + if (transaction != null) + LastTransactionSeen = transaction; + + if (BlockFirstExecution && Interlocked.CompareExchange(ref _blocked, 1, 0) == 0) + { + FirstExecutionEntered.Set(); + AllowExecution.Wait(TimeSpan.FromSeconds(5)); + } + } + + public void ExitExecution() + { + Interlocked.Decrement(ref CurrentConcurrentExecutions); + } + } + + internal sealed class FakeProviderFactory : DbProviderFactory + { + private readonly FakeProviderState _state; + + public FakeProviderFactory(FakeProviderState state) + { + _state = state; + } + + public override DbConnection CreateConnection() + { + var connection = new FakeProviderConnection(_state); + lock (_state.Connections) + _state.Connections.Add(connection); + Interlocked.Increment(ref _state.CreatedConnections); + return connection; + } + } + + internal sealed class FakeProviderConnection : DbConnection + { + private readonly FakeProviderState _state; + private ConnectionState _connectionState = ConnectionState.Closed; + + public FakeProviderConnection(FakeProviderState state) + { + _state = state; + } + + public override string ConnectionString { get; set; } + public override string Database { get { return "fake"; } } + public override string DataSource { get { return "fake"; } } + public override string ServerVersion { get { return "1.0"; } } + public override ConnectionState State { get { return _connectionState; } } + + public override void ChangeDatabase(string databaseName) { } + + public override void Close() + { + if (_connectionState != ConnectionState.Closed) + { + _connectionState = ConnectionState.Closed; + Interlocked.Increment(ref _state.CloseCalls); + } + } + + public override void Open() + { + if (_connectionState != ConnectionState.Open) + { + _connectionState = ConnectionState.Open; + Interlocked.Increment(ref _state.OpenCalls); + } + } + + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) + { + Interlocked.Increment(ref _state.BeginTransactionCalls); + return new FakeProviderTransaction(this, isolationLevel); + } + + protected override DbCommand CreateDbCommand() + { + return new FakeProviderCommand(_state) { Connection = this }; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + Interlocked.Increment(ref _state.DisposeCalls); + + base.Dispose(disposing); + } + } + + internal sealed class FakeProviderTransaction : DbTransaction + { + private readonly FakeProviderConnection _connection; + private readonly IsolationLevel _isolationLevel; + + public FakeProviderTransaction(FakeProviderConnection connection, IsolationLevel isolationLevel) + { + _connection = connection; + _isolationLevel = isolationLevel; + } + + public override IsolationLevel IsolationLevel { get { return _isolationLevel; } } + protected override DbConnection DbConnection { get { return _connection; } } + public override void Commit() { } + public override void Rollback() { } + } + + internal sealed class FakeProviderCommand : DbCommand + { + private readonly FakeProviderState _state; + private readonly FakeProviderParameterCollection _parameters = new FakeProviderParameterCollection(); + + public FakeProviderCommand(FakeProviderState state) + { + _state = state; + } + + public override string CommandText { get; set; } + public override int CommandTimeout { get; set; } + public override CommandType CommandType { get; set; } + public override bool DesignTimeVisible { get; set; } + public override UpdateRowSource UpdatedRowSource { get; set; } + protected override DbConnection DbConnection { get; set; } + protected override DbParameterCollection DbParameterCollection { get { return _parameters; } } + protected override DbTransaction DbTransaction { get; set; } + + public override void Cancel() { } + public override int ExecuteNonQuery() + { + _state.RecordExecution(DbTransaction); + try + { + return 1; + } + finally + { + _state.ExitExecution(); + } + } + + public override object ExecuteScalar() + { + return ExecuteNonQuery(); + } + + public override void Prepare() { } + + protected override DbParameter CreateDbParameter() + { + return new FakeProviderParameter(); + } + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) + { + throw new NotSupportedException(); + } + } + + internal sealed class FakeProviderParameter : DbParameter + { + public override DbType DbType { get; set; } + public override ParameterDirection Direction { get; set; } + public override bool IsNullable { get; set; } + public override string ParameterName { get; set; } + public override string SourceColumn { get; set; } + public override object Value { get; set; } + public override bool SourceColumnNullMapping { get; set; } + public override int Size { get; set; } + public override void ResetDbType() { } + } + + internal sealed class FakeProviderParameterCollection : DbParameterCollection + { + private readonly List _items = new List(); + + public override int Count { get { return _items.Count; } } + public override object SyncRoot { get { return this; } } + public override int Add(object value) { _items.Add((DbParameter)value); return _items.Count - 1; } + public override void AddRange(Array values) { foreach (object value in values) Add(value); } + public override void Clear() { _items.Clear(); } + public override bool Contains(object value) { return _items.Contains((DbParameter)value); } + public override bool Contains(string value) { return IndexOf(value) >= 0; } + public override void CopyTo(Array array, int index) { _items.ToArray().CopyTo(array, index); } + public override System.Collections.IEnumerator GetEnumerator() { return _items.GetEnumerator(); } + protected override DbParameter GetParameter(int index) { return _items[index]; } + protected override DbParameter GetParameter(string parameterName) { return _items[IndexOf(parameterName)]; } + public override int IndexOf(object value) { return _items.IndexOf((DbParameter)value); } + public override int IndexOf(string parameterName) { return _items.FindIndex(x => x.ParameterName == parameterName); } + public override void Insert(int index, object value) { _items.Insert(index, (DbParameter)value); } + public override void Remove(object value) { _items.Remove((DbParameter)value); } + public override void RemoveAt(int index) { _items.RemoveAt(index); } + public override void RemoveAt(string parameterName) { int i = IndexOf(parameterName); if (i >= 0) _items.RemoveAt(i); } + protected override void SetParameter(int index, DbParameter value) { _items[index] = value; } + protected override void SetParameter(string parameterName, DbParameter value) { _items[IndexOf(parameterName)] = value; } + } +} diff --git a/DynamORM.Tests/Helpers/PoolingTests.cs b/DynamORM.Tests/Helpers/PoolingTests.cs index a5e28c9..40084f6 100644 --- a/DynamORM.Tests/Helpers/PoolingTests.cs +++ b/DynamORM.Tests/Helpers/PoolingTests.cs @@ -92,9 +92,10 @@ namespace DynamORM.Tests.Helpers finally { // Remove for next tests - Database.Dispose(); + if (Database != null) + Database.Dispose(); Database = null; } } } -} \ No newline at end of file +} diff --git a/DynamORM/DynamicCommand.cs b/DynamORM/DynamicCommand.cs index 657b37e..6b71ab9 100644 --- a/DynamORM/DynamicCommand.cs +++ b/DynamORM/DynamicCommand.cs @@ -159,17 +159,24 @@ namespace DynamORM /// Executes an SQL statement against the Connection object of a /// data provider, and returns the number of rows affected. /// The number of rows affected. - public int ExecuteNonQuery() - { - try - { - return PrepareForExecution().ExecuteNonQuery(); - } - catch (Exception ex) - { - throw new DynamicQueryException(ex, this); - } - } + public int ExecuteNonQuery() + { + IDisposable scope = null; + try + { + scope = _db != null ? _db.AcquireExecutionScope() : null; + return PrepareForExecution().ExecuteNonQuery(); + } + catch (Exception ex) + { + throw new DynamicQueryException(ex, this); + } + finally + { + if (scope != null) + scope.Dispose(); + } + } /// Executes the /// against the , @@ -178,49 +185,64 @@ namespace DynamORM /// One of the /// values. /// An object. - public IDataReader ExecuteReader(CommandBehavior behavior) - { - try - { - return PrepareForExecution().ExecuteReader(behavior); - } - catch (Exception ex) - { - throw new DynamicQueryException(ex, this); - } - } + public IDataReader ExecuteReader(CommandBehavior behavior) + { + IDisposable scope = null; + try + { + scope = _db != null ? _db.AcquireExecutionScope() : null; + return new DynamicExecutionReader(PrepareForExecution().ExecuteReader(behavior), scope); + } + catch (Exception ex) + { + if (scope != null) + scope.Dispose(); + throw new DynamicQueryException(ex, this); + } + } /// Executes the /// against the and /// builds an . /// An object. - public IDataReader ExecuteReader() - { - try - { - return PrepareForExecution().ExecuteReader(); - } - catch (Exception ex) - { - throw new DynamicQueryException(ex, this); - } - } + public IDataReader ExecuteReader() + { + IDisposable scope = null; + try + { + scope = _db != null ? _db.AcquireExecutionScope() : null; + return new DynamicExecutionReader(PrepareForExecution().ExecuteReader(), scope); + } + catch (Exception ex) + { + if (scope != null) + scope.Dispose(); + throw new DynamicQueryException(ex, this); + } + } /// Executes the query, and returns the first column of the /// first row in the result set returned by the query. Extra columns or /// rows are ignored. /// The first column of the first row in the result set. - public object ExecuteScalar() - { - try - { - return PrepareForExecution().ExecuteScalar(); - } - catch (Exception ex) - { - throw new DynamicQueryException(ex, this); - } - } + public object ExecuteScalar() + { + IDisposable scope = null; + try + { + scope = _db != null ? _db.AcquireExecutionScope() : null; + return PrepareForExecution().ExecuteScalar(); + } + catch (Exception ex) + { + throw new DynamicQueryException(ex, this); + } + finally + { + if (scope != null) + scope.Dispose(); + } + } /// Gets the . public IDataParameterCollection Parameters @@ -281,7 +303,7 @@ namespace DynamORM IsDisposed = true; - if (_con != null) + if (_con != null && db.CommandsPool != null) { List pool = db.CommandsPool.TryGetValue(_con.Connection); diff --git a/DynamORM/DynamicConnection.cs b/DynamORM/DynamicConnection.cs index a948786..5435f14 100644 --- a/DynamORM/DynamicConnection.cs +++ b/DynamORM/DynamicConnection.cs @@ -60,10 +60,10 @@ namespace DynamORM /// Custom parameter describing transaction options. /// This action is invoked when transaction is disposed. /// Returns representation. - internal DynamicTransaction BeginTransaction(IsolationLevel? il, object custom, Action disposed) - { - return new DynamicTransaction(_db, this, _singleTransaction, il, disposed, null); - } + internal DynamicTransaction BeginTransaction(IsolationLevel? il, object custom, Action disposed) + { + return new DynamicTransaction(_db, this, _singleTransaction, il, disposed, custom); + } #region IDbConnection Members diff --git a/DynamORM/DynamicDatabase.cs b/DynamORM/DynamicDatabase.cs index 4e61f39..c626c6a 100644 --- a/DynamORM/DynamicDatabase.cs +++ b/DynamORM/DynamicDatabase.cs @@ -31,11 +31,12 @@ using System.Collections; using System.Collections.Generic; using System.Data; using System.Data.Common; -using System.Dynamic; -using System.Linq; -using System.Reflection; -using System.Text; -using DynamORM.Builders; +using System.Dynamic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using DynamORM.Builders; using DynamORM.Builders.Extensions; using DynamORM.Builders.Implementation; using DynamORM.Helpers; @@ -45,16 +46,25 @@ using DynamORM.Objects; namespace DynamORM { /// Dynamic database is a class responsible for managing database. - public class DynamicDatabase : IExtendedDisposable - { - #region Internal fields and properties + public class DynamicDatabase : IExtendedDisposable + { + private sealed class ConnectionEntry + { + public bool External { get; set; } + public int LeaseCount { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastReleasedUtc { get; set; } + } + + #region Internal fields and properties private DbProviderFactory _provider; private DynamicProcedureInvoker _proc; private string _connectionString; - private bool _singleConnection; - private bool _singleTransaction; - private string _leftDecorator = "\""; + private bool _singleConnection; + private bool _singleTransaction; + private bool _connectionPooling; + private string _leftDecorator = "\""; private string _rightDecorator = "\""; private bool _leftDecoratorIsInInvalidMembersChars = true; private bool _rightDecoratorIsInInvalidMembersChars = true; @@ -62,10 +72,12 @@ namespace DynamORM private int? _commandTimeout = null; private long _poolStamp = 0; - private DynamicConnection _tempConn = null; - - /// Provides lock object for this database instance. - internal readonly object SyncLock = new object(); + private readonly object _executionSyncRoot = new object(); + private int _executionOwnerThreadId = -1; + private int _executionLockDepth = 0; + + /// Provides lock object for this database instance. + internal readonly object SyncLock = new object(); /// Gets or sets timestamp of last transaction pool or configuration change. /// This property is used to allow commands to determine if @@ -90,11 +102,17 @@ namespace DynamORM } /// Gets pool of connections and transactions. - internal Dictionary> TransactionPool { get; private set; } + internal Dictionary> TransactionPool { get; private set; } /// Gets pool of connections and commands. /// Pool should contain dynamic commands instead of native ones. - internal Dictionary> CommandsPool { get; private set; } + internal Dictionary> CommandsPool { get; private set; } + + /// Gets connection metadata tracked by the database instance. + private Dictionary ConnectionEntries { get; set; } + + /// Gets ambient transaction-bound connections keyed by managed thread identifier. + private Dictionary AmbientTransactionConnections { get; set; } /// Gets schema columns cache. internal Dictionary> Schema { get; private set; } @@ -116,8 +134,20 @@ namespace DynamORM /// Gets database options. public DynamicDatabaseOptions Options { get; private set; } - /// Gets or sets command timeout. - public int? CommandTimeout { get { return _commandTimeout; } set { _commandTimeout = value; _poolStamp = DateTime.Now.Ticks; } } + /// Gets or sets command timeout. + public int? CommandTimeout { get { return _commandTimeout; } set { _commandTimeout = value; _poolStamp = DateTime.Now.Ticks; } } + + /// Gets or sets the preferred number of idle open connections kept in the internal pool. + /// Default value is 32. Applies only when is enabled. + public int ConnectionPoolingKeepOpenCount { get; set; } + + /// Gets or sets the maximum number of connections that may remain managed by the internal pool at once. + /// Default value is 128. When this limit is reached, callers wait for a pooled connection to be released. + public int ConnectionPoolingMaximumOpenCount { get; set; } + + /// Gets or sets how long an idle pooled connection may stay open before it is retired. + /// Default value is one hour. Applies only when is enabled. + public TimeSpan ConnectionPoolingConnectionLifetime { get; set; } /// Gets the database provider. public DbProviderFactory Provider { get { return _provider; } } @@ -313,10 +343,18 @@ namespace DynamORM IsDisposed = false; InitCommon(connection.ConnectionString, options); - TransactionPool.Add(connection, new Stack()); - - if (!_singleConnection) - throw new InvalidOperationException("This constructor accepts only connections with DynamicDatabaseOptions.SingleConnection option."); + TransactionPool.Add(connection, new Stack()); + CommandsPool.Add(connection, new List()); + ConnectionEntries.Add(connection, new ConnectionEntry + { + External = true, + LeaseCount = 0, + CreatedUtc = DateTime.UtcNow, + LastReleasedUtc = DateTime.UtcNow, + }); + + if (!_singleConnection) + throw new InvalidOperationException("This constructor accepts only connections with DynamicDatabaseOptions.SingleConnection option."); } private void InitCommon(string connectionString, DynamicDatabaseOptions options) @@ -324,14 +362,20 @@ namespace DynamORM _connectionString = connectionString; Options = options; - _singleConnection = (options & DynamicDatabaseOptions.SingleConnection) == DynamicDatabaseOptions.SingleConnection; - _singleTransaction = (options & DynamicDatabaseOptions.SingleTransaction) == DynamicDatabaseOptions.SingleTransaction; - DumpCommands = (options & DynamicDatabaseOptions.DumpCommands) == DynamicDatabaseOptions.DumpCommands; - - TransactionPool = new Dictionary>(); - CommandsPool = new Dictionary>(); - Schema = new Dictionary>(); - RemainingBuilders = new List(); + _singleConnection = (options & DynamicDatabaseOptions.SingleConnection) == DynamicDatabaseOptions.SingleConnection; + _singleTransaction = (options & DynamicDatabaseOptions.SingleTransaction) == DynamicDatabaseOptions.SingleTransaction; + _connectionPooling = (options & DynamicDatabaseOptions.ConnectionPooling) == DynamicDatabaseOptions.ConnectionPooling; + DumpCommands = (options & DynamicDatabaseOptions.DumpCommands) == DynamicDatabaseOptions.DumpCommands; + ConnectionPoolingKeepOpenCount = 32; + ConnectionPoolingMaximumOpenCount = 128; + ConnectionPoolingConnectionLifetime = TimeSpan.FromHours(1); + + TransactionPool = new Dictionary>(); + CommandsPool = new Dictionary>(); + ConnectionEntries = new Dictionary(); + AmbientTransactionConnections = new Dictionary(); + Schema = new Dictionary>(); + RemainingBuilders = new List(); #if !DYNAMORM_OMMIT_OLDSYNTAX TablesCache = new Dictionary(); #endif @@ -1966,98 +2010,299 @@ namespace DynamORM #endregion Decorators - #region Connection - - /// Open managed connection. - /// Opened connection. - public IDbConnection Open() - { - IDbConnection conn = null; - DynamicConnection ret = null; - bool opened = false; - - lock (SyncLock) - { - if (_tempConn == null) - { - if (TransactionPool.Count == 0 || !_singleConnection) - { - conn = _provider.CreateConnection(); - conn.ConnectionString = _connectionString; - conn.Open(); - opened = true; - - TransactionPool.Add(conn, new Stack()); - CommandsPool.Add(conn, new List()); - } - else - { - conn = TransactionPool.Keys.First(); - - if (conn.State != ConnectionState.Open) - { - conn.Open(); - opened = true; - } - } - - ret = new DynamicConnection(this, conn, _singleTransaction); - } - else - ret = _tempConn; - } - - if (opened) - ExecuteInitCommands(ret); + #region Connection + + private bool UsesExecutionSerialization + { + get { return _singleConnection || _singleTransaction; } + } + + private bool UsesManagedPooling + { + get { return _connectionPooling && !_singleConnection; } + } + + private IDisposable EnterExecutionScope() + { + if (!UsesExecutionSerialization) + return null; + + int currentThreadId = Thread.CurrentThread.ManagedThreadId; + + lock (_executionSyncRoot) + { + while (_executionLockDepth > 0 && _executionOwnerThreadId != currentThreadId) + Monitor.Wait(_executionSyncRoot); + + _executionOwnerThreadId = currentThreadId; + _executionLockDepth++; + } + + return new ExecutionScope(this); + } + + private void ExitExecutionScope() + { + if (!UsesExecutionSerialization) + return; + + lock (_executionSyncRoot) + { + if (_executionLockDepth > 0) + _executionLockDepth--; + + if (_executionLockDepth == 0) + { + _executionOwnerThreadId = -1; + Monitor.PulseAll(_executionSyncRoot); + } + } + } + + private sealed class ExecutionScope : IDisposable + { + private DynamicDatabase _db; + + public ExecutionScope(DynamicDatabase db) + { + _db = db; + } + + public void Dispose() + { + DynamicDatabase db = _db; + if (db == null) + return; + + _db = null; + db.ExitExecutionScope(); + } + } + + internal IDisposable AcquireExecutionScope() + { + return EnterExecutionScope(); + } + + private bool IsConnectionAmbientForAnotherThread(IDbConnection connection, int currentThreadId) + { + return AmbientTransactionConnections.Any(x => x.Key != currentThreadId && x.Value == connection); + } + + private void AddManagedConnection(IDbConnection connection, bool external) + { + TransactionPool.Add(connection, new Stack()); + CommandsPool.Add(connection, new List()); + ConnectionEntries.Add(connection, new ConnectionEntry + { + External = external, + LeaseCount = 0, + CreatedUtc = DateTime.UtcNow, + LastReleasedUtc = DateTime.UtcNow, + }); + } + + private IDbConnection CreateManagedConnection() + { + IDbConnection conn = _provider.CreateConnection(); + conn.ConnectionString = _connectionString; + conn.Open(); + AddManagedConnection(conn, false); + return conn; + } + + private void EnsureConnectionIsOpen(IDbConnection connection) + { + if (connection != null && connection.State != ConnectionState.Open) + connection.Open(); + } + + private void TrimIdleConnectionsUnderLock() + { + DateTime now = DateTime.UtcNow; + int idleCount = ConnectionEntries + .Where(x => !x.Value.External) + .Count(x => x.Value.LeaseCount == 0 && TransactionPool[x.Key].Count == 0 && CommandsPool[x.Key].Count == 0 && !AmbientTransactionConnections.ContainsValue(x.Key)); + + List toRemove = new List(); + + foreach (KeyValuePair item in ConnectionEntries) + { + if (item.Value.External || item.Value.LeaseCount > 0) + continue; + + if (TransactionPool[item.Key].Count > 0 || CommandsPool[item.Key].Count > 0 || AmbientTransactionConnections.ContainsValue(item.Key)) + continue; + + bool expired = ConnectionPoolingConnectionLifetime <= TimeSpan.Zero || now - item.Value.LastReleasedUtc >= ConnectionPoolingConnectionLifetime; + bool abovePreferred = idleCount > ConnectionPoolingKeepOpenCount; + + if (expired || abovePreferred) + { + toRemove.Add(item.Key); + idleCount--; + } + } + + foreach (IDbConnection connection in toRemove) + { + ConnectionEntries.Remove(connection); + TransactionPool.Remove(connection); + CommandsPool.Remove(connection); + + if (connection.State == ConnectionState.Open) + connection.Close(); + + connection.Dispose(); + } + } + + private IDbConnection GetOrCreateManagedConnectionUnderLock(out bool opened) + { + opened = false; + int currentThreadId = Thread.CurrentThread.ManagedThreadId; + + if (_singleConnection) + { + IDbConnection single = ConnectionEntries.Count == 0 ? CreateManagedConnection() : ConnectionEntries.Keys.First(); + if (single.State != ConnectionState.Open) + { + single.Open(); + opened = true; + } + + ConnectionEntries[single].LeaseCount++; + return single; + } + + IDbConnection ambient; + if (AmbientTransactionConnections.TryGetValue(currentThreadId, out ambient)) + { + EnsureConnectionIsOpen(ambient); + ConnectionEntries[ambient].LeaseCount++; + return ambient; + } + + if (!UsesManagedPooling) + { + IDbConnection conn = CreateManagedConnection(); + opened = true; + ConnectionEntries[conn].LeaseCount++; + return conn; + } + + while (true) + { + TrimIdleConnectionsUnderLock(); + + IDbConnection pooled = ConnectionEntries + .Where(x => !x.Value.External && x.Value.LeaseCount == 0) + .Where(x => TransactionPool[x.Key].Count == 0 && CommandsPool[x.Key].Count == 0) + .Where(x => !IsConnectionAmbientForAnotherThread(x.Key, currentThreadId)) + .Select(x => x.Key) + .FirstOrDefault(); + + if (pooled != null) + { + if (pooled.State != ConnectionState.Open) + { + pooled.Open(); + opened = true; + } + + ConnectionEntries[pooled].LeaseCount++; + return pooled; + } + + if (ConnectionEntries.Count < ConnectionPoolingMaximumOpenCount) + { + IDbConnection conn = CreateManagedConnection(); + opened = true; + ConnectionEntries[conn].LeaseCount++; + return conn; + } + + Monitor.Wait(SyncLock); + } + } + + /// Open managed connection. + /// + /// When is enabled, DynamORM reuses idle managed connections instead of opening and closing a new one for every operation. + /// When a transaction is started through , , or , + /// commands executed on the same thread reuse the same underlying connection until that transaction finishes. + /// Other threads do not join that transaction; they use another managed connection or wait for one to become available. + /// + /// Opened connection. + public IDbConnection Open() + { + IDbConnection conn = null; + DynamicConnection ret = null; + bool opened = false; + + lock (SyncLock) + { + conn = GetOrCreateManagedConnectionUnderLock(out opened); + ret = new DynamicConnection(this, conn, _singleTransaction); + } + + if (opened) + ExecuteInitCommands(ret); return ret; } /// Close connection if we are allowed to. /// Connection to manage. - internal void Close(IDbConnection connection) - { - if (connection == null) - return; - - if (!_singleConnection && connection != null && TransactionPool.ContainsKey(connection)) - { - // Close all commands - if (CommandsPool.ContainsKey(connection)) - { - List tmp = CommandsPool[connection].ToList(); - tmp.ForEach(cmd => cmd.Dispose()); - - CommandsPool[connection].Clear(); - } - - // Rollback remaining transactions - while (TransactionPool[connection].Count > 0) - { - IDbTransaction trans = TransactionPool[connection].Pop(); - trans.Rollback(); - trans.Dispose(); - } - - // Close connection - if (connection.State == ConnectionState.Open) - connection.Close(); - - // remove from pools - lock (SyncLock) - { - TransactionPool.Remove(connection); - CommandsPool.Remove(connection); - } - - // Set stamp - _poolStamp = DateTime.Now.Ticks; - - // Dispose the corpse - connection.Dispose(); - connection = null; - } - } + internal void Close(IDbConnection connection) + { + if (connection == null) + return; + + lock (SyncLock) + { + if (IsDisposed || ConnectionEntries == null || TransactionPool == null || CommandsPool == null) + return; + + ConnectionEntry entry; + if (!ConnectionEntries.TryGetValue(connection, out entry)) + return; + + if (entry.LeaseCount > 0) + entry.LeaseCount--; + + bool hasAmbientOwner = AmbientTransactionConnections.ContainsValue(connection); + bool hasTransactions = TransactionPool.ContainsKey(connection) && TransactionPool[connection].Count > 0; + bool hasCommands = CommandsPool.ContainsKey(connection) && CommandsPool[connection].Count > 0; + + if (_singleConnection || hasAmbientOwner || hasTransactions || hasCommands || entry.LeaseCount > 0) + { + Monitor.PulseAll(SyncLock); + return; + } + + entry.LastReleasedUtc = DateTime.UtcNow; + + if (UsesManagedPooling && !entry.External) + { + TrimIdleConnectionsUnderLock(); + Monitor.PulseAll(SyncLock); + return; + } + + ConnectionEntries.Remove(connection); + TransactionPool.Remove(connection); + CommandsPool.Remove(connection); + + if (connection.State == ConnectionState.Open) + connection.Close(); + + _poolStamp = DateTime.Now.Ticks; + Monitor.PulseAll(SyncLock); + } + + connection.Dispose(); + } /// Gets or sets contains commands executed when connection is opened. public List InitCommands { get; set; } @@ -2076,65 +2321,66 @@ namespace DynamORM #region Transaction - /// Begins a global database transaction. - /// Using this method connection is set to single open - /// connection until all transactions are finished. - /// Returns representation. - public IDbTransaction BeginTransaction() - { - _tempConn = Open() as DynamicConnection; + /// Begins a global database transaction. + /// + /// Using this method binds one managed connection to the current thread until all direct database transactions on that thread are finished. + /// Commands executed through the same instance on that thread reuse that transaction-bound connection. + /// Other threads do not participate in that transaction and use another managed connection or wait for one to become available. + /// + /// Returns representation. + public IDbTransaction BeginTransaction() + { + return BeginAmbientTransaction(null, null); + } - return _tempConn.BeginTransaction(null, null, () => - { - Stack t = TransactionPool.TryGetValue(_tempConn.Connection); + /// Begins a database transaction with the specified + /// value. + /// Thread ownership and connection binding follow the same rules as . + /// One of the values. + /// Returns representation. + public IDbTransaction BeginTransaction(IsolationLevel il) + { + return BeginAmbientTransaction(il, null); + } - if (t == null | t.Count == 0) - { - _tempConn.Dispose(); - _tempConn = null; - } - }); - } - - /// Begins a database transaction with the specified - /// value. - /// One of the values. - /// Returns representation. - public IDbTransaction BeginTransaction(IsolationLevel il) - { - _tempConn = Open() as DynamicConnection; - - return _tempConn.BeginTransaction(il, null, () => - { - Stack t = TransactionPool.TryGetValue(_tempConn.Connection); - - if (t == null | t.Count == 0) - { - _tempConn.Dispose(); - _tempConn = null; - } - }); - } - - /// Begins a database transaction with the specified - /// value. - /// Custom parameter describing transaction options. - /// Returns representation. - public IDbTransaction BeginTransaction(object custom) - { - _tempConn = Open() as DynamicConnection; - - return _tempConn.BeginTransaction(null, custom, () => - { - Stack t = TransactionPool.TryGetValue(_tempConn.Connection); - - if (t == null | t.Count == 0) - { - _tempConn.Dispose(); - _tempConn = null; - } - }); - } + /// Begins a database transaction with the specified + /// custom provider argument. + /// Thread ownership and connection binding follow the same rules as . + /// Custom parameter describing transaction options. + /// Returns representation. + public IDbTransaction BeginTransaction(object custom) + { + return BeginAmbientTransaction(null, custom); + } + + private IDbTransaction BeginAmbientTransaction(IsolationLevel? il, object custom) + { + DynamicConnection connection = Open() as DynamicConnection; + int threadId = Thread.CurrentThread.ManagedThreadId; + + lock (SyncLock) + AmbientTransactionConnections[threadId] = connection.Connection; + + return connection.BeginTransaction(il, custom, () => + { + bool releaseConnection = false; + + lock (SyncLock) + { + Stack t = TransactionPool.TryGetValue(connection.Connection); + + if (t == null || t.Count == 0) + { + AmbientTransactionConnections.Remove(threadId); + releaseConnection = true; + Monitor.PulseAll(SyncLock); + } + } + + if (releaseConnection) + connection.Dispose(); + }); + } #endregion Transaction @@ -2156,8 +2402,8 @@ namespace DynamORM tables = null; #endif - foreach (KeyValuePair> con in TransactionPool) - { + foreach (KeyValuePair> con in TransactionPool) + { // Close all commands if (CommandsPool.ContainsKey(con.Key)) { @@ -2189,25 +2435,28 @@ namespace DynamORM RemainingBuilders.First().Dispose(); // Clear pools - lock (SyncLock) - { - TransactionPool.Clear(); - CommandsPool.Clear(); - RemainingBuilders.Clear(); - - TransactionPool = null; - CommandsPool = null; - RemainingBuilders = null; - } + lock (SyncLock) + { + TransactionPool.Clear(); + CommandsPool.Clear(); + ConnectionEntries.Clear(); + AmbientTransactionConnections.Clear(); + RemainingBuilders.Clear(); + + TransactionPool = null; + CommandsPool = null; + ConnectionEntries = null; + AmbientTransactionConnections = null; + RemainingBuilders = null; + } ClearSchema(); - if (_proc != null) - _proc.Dispose(); - - _proc = null; - _tempConn = null; - IsDisposed = true; - } + if (_proc != null) + _proc.Dispose(); + + _proc = null; + IsDisposed = true; + } /// Gets a value indicating whether this instance is disposed. public bool IsDisposed { get; private set; } diff --git a/DynamORM/DynamicDatabaseOptions.cs b/DynamORM/DynamicDatabaseOptions.cs index a561167..ca7c091 100644 --- a/DynamORM/DynamicDatabaseOptions.cs +++ b/DynamORM/DynamicDatabaseOptions.cs @@ -38,11 +38,17 @@ namespace DynamORM /// No specific options. None = 0x00000000, - /// Only single persistent database connection. - SingleConnection = 0x00000001, - - /// Only one transaction. - SingleTransaction = 0x00000002, + /// Only single persistent database connection. + /// Command execution is serialized inside one instance when this option is enabled. + SingleConnection = 0x00000001, + + /// Only one transaction. + /// Command execution is serialized inside one instance when this option is enabled. + SingleTransaction = 0x00000002, + + /// Use internal connection pooling when connections are not kept as a single shared connection. + /// Pooling reuses idle managed connections and is configured through , , and . + ConnectionPooling = 0x00000004, /// Database supports top syntax (SELECT TOP x ... FROM ...). SupportTop = 0x00000080, @@ -68,4 +74,4 @@ namespace DynamORM /// Debug option allowing to enable command dumps by default. DumpCommands = 0x01000000, } -} \ No newline at end of file +} diff --git a/DynamORM/DynamicTransaction.cs b/DynamORM/DynamicTransaction.cs index 6f0a697..1e43713 100644 --- a/DynamORM/DynamicTransaction.cs +++ b/DynamORM/DynamicTransaction.cs @@ -102,10 +102,16 @@ namespace DynamORM } /// Commits the database transaction. - public void Commit() - { - lock (_db.SyncLock) - { + public void Commit() + { + if (_db == null) + { + _isOperational = false; + return; + } + + lock (_db.SyncLock) + { if (_isOperational) { Stack t = _db.TransactionPool.TryGetValue(_con.Connection); @@ -131,10 +137,16 @@ namespace DynamORM } /// Rolls back a transaction from a pending state. - public void Rollback() - { - lock (_db.SyncLock) - { + public void Rollback() + { + if (_db == null) + { + _isOperational = false; + return; + } + + lock (_db.SyncLock) + { if (_isOperational) { Stack t = _db.TransactionPool.TryGetValue(_con.Connection); diff --git a/DynamORM/Helpers/DynamicExecutionReader.cs b/DynamORM/Helpers/DynamicExecutionReader.cs new file mode 100644 index 0000000..5bbd927 --- /dev/null +++ b/DynamORM/Helpers/DynamicExecutionReader.cs @@ -0,0 +1,101 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. +*/ + +using System; +using System.Data; + +namespace DynamORM.Helpers +{ + internal sealed class DynamicExecutionReader : IDataReader + { + private IDataReader _reader; + private IDisposable _scope; + + public DynamicExecutionReader(IDataReader reader, IDisposable scope) + { + _reader = reader; + _scope = scope; + } + + public object this[string name] { get { return _reader[name]; } } + public object this[int i] { get { return _reader[i]; } } + public int Depth { get { return _reader.Depth; } } + public bool IsClosed { get { return _reader.IsClosed; } } + public int RecordsAffected { get { return _reader.RecordsAffected; } } + public int FieldCount { get { return _reader.FieldCount; } } + + public void Close() + { + if (_reader != null) + _reader.Close(); + + Dispose(); + } + + public void Dispose() + { + IDataReader reader = _reader; + IDisposable scope = _scope; + + _reader = null; + _scope = null; + + if (reader != null) + reader.Dispose(); + + if (scope != null) + scope.Dispose(); + } + + public bool GetBoolean(int i) { return _reader.GetBoolean(i); } + public byte GetByte(int i) { return _reader.GetByte(i); } + public long GetBytes(int i, long fieldOffset, byte[] buffer, int bufferoffset, int length) { return _reader.GetBytes(i, fieldOffset, buffer, bufferoffset, length); } + public char GetChar(int i) { return _reader.GetChar(i); } + public long GetChars(int i, long fieldoffset, char[] buffer, int bufferoffset, int length) { return _reader.GetChars(i, fieldoffset, buffer, bufferoffset, length); } + public IDataReader GetData(int i) { return _reader.GetData(i); } + public string GetDataTypeName(int i) { return _reader.GetDataTypeName(i); } + public DateTime GetDateTime(int i) { return _reader.GetDateTime(i); } + public decimal GetDecimal(int i) { return _reader.GetDecimal(i); } + public double GetDouble(int i) { return _reader.GetDouble(i); } + public Type GetFieldType(int i) { return _reader.GetFieldType(i); } + public float GetFloat(int i) { return _reader.GetFloat(i); } + public Guid GetGuid(int i) { return _reader.GetGuid(i); } + public short GetInt16(int i) { return _reader.GetInt16(i); } + public int GetInt32(int i) { return _reader.GetInt32(i); } + public long GetInt64(int i) { return _reader.GetInt64(i); } + public string GetName(int i) { return _reader.GetName(i); } + public int GetOrdinal(string name) { return _reader.GetOrdinal(name); } + public DataTable GetSchemaTable() { return _reader.GetSchemaTable(); } + public string GetString(int i) { return _reader.GetString(i); } + public object GetValue(int i) { return _reader.GetValue(i); } + public int GetValues(object[] values) { return _reader.GetValues(values); } + public bool IsDBNull(int i) { return _reader.IsDBNull(i); } + public bool NextResult() { return _reader.NextResult(); } + public bool Read() { return _reader.Read(); } + } +} diff --git a/docs/quick-start.md b/docs/quick-start.md index 1600931..01b316c 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -31,6 +31,14 @@ using (var db = new DynamicDatabase( This setup mirrors `DynamORM.Tests/TestsBase.cs`. +If you do not want `SingleConnection`, but still want managed reuse of open connections, enable `DynamicDatabaseOptions.ConnectionPooling` and configure: + +- `db.ConnectionPoolingKeepOpenCount` +- `db.ConnectionPoolingMaximumOpenCount` +- `db.ConnectionPoolingConnectionLifetime` + +Full details are documented in [Transactions and Disposal](transactions-and-disposal.md). + ## First Query (Dynamic API) ```csharp diff --git a/docs/transactions-and-disposal.md b/docs/transactions-and-disposal.md index 694b835..95728ba 100644 --- a/docs/transactions-and-disposal.md +++ b/docs/transactions-and-disposal.md @@ -8,6 +8,7 @@ DynamORM manages connections, command pools, and transaction stacks internally. - `SingleConnection` - `SingleTransaction` +- `ConnectionPooling` - `SupportSchema` - `SupportStoredProcedures` - `SupportNoLock` @@ -23,6 +24,42 @@ var options = DynamicDatabaseOptions.SupportSchema; ``` +## Internal Connection Pooling + +Without `SingleConnection`, DynamORM normally opens and closes a managed connection for each operation. + +That is fine when the provider already has efficient connection pooling. It is not enough for providers that do not. + +`ConnectionPooling` enables internal managed connection reuse: + +```csharp +var options = + DynamicDatabaseOptions.ConnectionPooling | + DynamicDatabaseOptions.SupportLimitOffset | + DynamicDatabaseOptions.SupportSchema; + +using (var db = new DynamicDatabase(factory, connectionString, options)) +{ + db.ConnectionPoolingKeepOpenCount = 32; + db.ConnectionPoolingMaximumOpenCount = 128; + db.ConnectionPoolingConnectionLifetime = TimeSpan.FromHours(1); +} +``` + +Behavior: + +- idle connections are reused instead of opened and closed repeatedly +- up to `ConnectionPoolingKeepOpenCount` idle connections are kept open +- no more than `ConnectionPoolingMaximumOpenCount` managed connections are kept at once +- if the maximum is reached, callers wait until a connection is released +- idle pooled connections older than `ConnectionPoolingConnectionLifetime` are retired even if the preferred idle count has not been exceeded + +Default values: + +- `ConnectionPoolingKeepOpenCount = 32` +- `ConnectionPoolingMaximumOpenCount = 128` +- `ConnectionPoolingConnectionLifetime = 1 hour` + ## Transaction Usage ```csharp @@ -38,6 +75,50 @@ using (var cmd = conn.CreateCommand()) Global transaction mode is also available via `db.BeginTransaction()`. +## Direct Database Transactions and Thread Ownership + +`db.BeginTransaction()` is different from `conn.BeginTransaction()` on an arbitrary opened connection. + +When you call `db.BeginTransaction()`: + +- DynamORM binds one managed connection to the current thread +- commands executed through that `DynamicDatabase` on the same thread reuse that connection and transaction +- other threads do not join that transaction automatically +- other threads use another managed connection or wait for one to become available + +This is important because it prevents unrelated work from another thread from accidentally running inside the transaction started on the original thread. + +Example: + +```csharp +using (var tx = db.BeginTransaction()) +{ + db.Execute("UPDATE sample_users SET code = 'A' WHERE id = 1"); + db.Execute("UPDATE sample_users SET code = 'B' WHERE id = 2"); + tx.Commit(); +} +``` + +All commands above run on the same transaction-bound connection because they execute on the same thread. + +If another thread uses the same `db` instance while that transaction is active, it does not participate in this transaction unless user code explicitly routes work back to the owning thread. + +## Single Connection and Single Transaction Locking + +`SingleConnection` and `SingleTransaction` are now guarded by internal execution serialization. + +That means: + +- only one command executes at a time per `DynamicDatabase` instance when either option is enabled +- if another thread tries to execute a command at the same time, it waits until the current execution finishes +- this reduces the amount of user-side locking needed for providers such as SQLite + +Important limit: + +- a reader keeps the execution slot until the reader is disposed + +So if one thread executes `ExecuteReader()` and holds the reader open, another thread waits until that reader is closed or disposed before executing the next command on the same `DynamicDatabase` instance. + ## Disposal Guarantees Current disposal behavior includes idempotent guards on key objects: @@ -58,8 +139,18 @@ Behavior validated by `DynamORM.Tests/Helpers/PoolingTests.cs`: - Disposing the database invalidates active commands. - Open transactions are rolled back during disposal when not committed. +Behavior validated by `DynamORM.Tests/Helpers/ConnectionPoolingAndLockingTests.cs`: + +- pooled connections are reused +- pooled connections wait when the configured maximum is reached +- expired idle connections are retired +- direct database transactions are isolated to the owning thread +- `SingleConnection` and `SingleTransaction` serialize command execution across threads + ## Practices - Prefer `using` blocks for `DynamicDatabase`, connections, commands, transactions, and builders. - Do not manually re-dispose the same object from multiple ownership paths unless `IsDisposed` is checked. - Keep transaction scope short and explicit. +- Use `db.BeginTransaction()` when you want DynamORM-managed transactional flow across multiple commands on the current thread. +- Use `ConnectionPooling` when the underlying provider does not give you adequate pooling on its own.