Skip to content

Get/Return pooled connections #3404

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from

Conversation

mdaigle
Copy link
Contributor

@mdaigle mdaigle commented Jun 9, 2025

Description

This PR adds Get and Return functionality to the new ChannelDbConnectionPool class.
A channel and corresponding channel reader and writer are added to underpin these operations.
An array is added to hold references to all connections managed by the pool.
Logic is included to respect max pool size in all cases.

Not included: pool warm up (including respecting min pool size), pool pruning, transactions, tracing/logs. These will come in follow-up PRs.

Also includes extensive unit test coverage for the implemented functionality. Integration testing is reserved for a later state when more of the pool functionality is available.

Issues

#3356

Testing

Tests focus on the Get and Return flows with special focus on the following:

  • async behavior
  • max pool size respected
  • connections successfully reused
  • requests successfully queued and queue order respected
  • stress testing with many parallel operations

@mdaigle mdaigle force-pushed the dev/mdaigle/get-return-pooled-connections branch from 9d95355 to 73a379b Compare June 9, 2025 20:45
@@ -97,6 +97,8 @@ internal DbConnectionInternal(ConnectionState state, bool hidePassword, bool all

#region Properties

internal DateTime CreateTime => _createTime;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Exposes creation time for load balancing and max connection lifetime enforcement.

Choose a reason for hiding this comment

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

Why to make getter and setter internal ?

It looks like this class DbConnectionInternal is managing its creation time, and this value isn’t exposed to derived classes.

If there’s any chance that this responsibility might shift in the future, or if derived classes may need to update the creation time, I recommend adding a protected method (e.g., UpdateCreationTime(DateTime newTime)) rather than exposing a protected setter or property.

My reasoning is:

Updating creation time should be an explicit, intentional action, not just a simple property assignment. An explicit method makes this clear in the API and signals that it’s a special operation as most of the logic is still in the base.

Let me know your thoughts.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Other classes shouldn't be updating this property. This syntax will only expose a getter for the property: https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/properties#expression-body-definitions

@mdaigle mdaigle changed the title Dev/mdaigle/get return pooled connections Get/Return pooled connections Jun 9, 2025
@mdaigle mdaigle marked this pull request as ready for review June 9, 2025 23:25
@mdaigle mdaigle requested a review from a team as a code owner June 9, 2025 23:25
@mdaigle
Copy link
Contributor Author

mdaigle commented Jun 10, 2025

@roji tagging you here if you have any time to review. Thanks!

Copy link
Contributor

@benrr101 benrr101 left a comment

Choose a reason for hiding this comment

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

In general, it looks good. A bunch of things I'd like addressed, but you know I'll accept valid arguments against fixing them :)

Copy link
Contributor

@paulmedynski paulmedynski left a comment

Choose a reason for hiding this comment

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

I have reviewed the ChannelDbConnectionPool changes. I will look at the tests once we have converged on an implementation. It might be a good idea to host another meeting to walk through the commentary here.

for (var numConnections = _numConnections; numConnections < MaxPoolSize; numConnections = _numConnections)
{
// Note that we purposefully don't use SpinWait for this: https://github.com/dotnet/coreclr/pull/21437
if (Interlocked.CompareExchange(ref _numConnections, numConnections + 1, numConnections) != numConnections)
Copy link
Contributor

Choose a reason for hiding this comment

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

This is complicated enough to warrant an English explanation of what's happening.


return Task.FromResult<DbConnectionInternal?>(newConnection);
}
catch
Copy link
Contributor

Choose a reason for hiding this comment

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

Catch only what is documented to be thrown.

/// </summary>
private static int _instanceCount;

private readonly int _instanceID = Interlocked.Increment(ref _instanceCount);

Choose a reason for hiding this comment

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

Interlocked.Increment(ref _instanceCount);

does it matter if it becomes negative ?

Copy link
Contributor

Choose a reason for hiding this comment

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

it starts at zero and only ever goes up

Copy link
Contributor

Choose a reason for hiding this comment

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

Counters are naturally unsigned, so can we use uint or ulong instead? I'd be proud if those ever wrapped :)

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, should this be _instanceId ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There's no Interlocked.Increment() overload for uint. It doesn't matter if it goes negative. It's only used for identification of unique pools in trace messages.

@@ -20,52 +25,136 @@ namespace Microsoft.Data.SqlClient.ConnectionPool
/// </summary>
internal sealed class ChannelDbConnectionPool : IDbConnectionPool

Choose a reason for hiding this comment

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

ChannelDbConnectionPool

it might be good to add some documentation on how this class works and how this is different than the WaitHandleDbConnectionPool.

}

/// <inheritdoc/>
internal Task<DbConnectionInternal?> OpenNewInternalConnection(DbConnection? owningConnection, DbConnectionOptions userOptions, TimeSpan timeout, bool async, CancellationToken cancellationToken)
Copy link

@imasud00 imasud00 Jun 17, 2025

Choose a reason for hiding this comment

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

OpenNewInternalConnection

could we refactor this code for better readbility.
shared refactor code. Compisition here making this code easy

internal Task<DbConnectionInternal?> OpenNewInternalConnection(DbConnection? owningConnection, DbConnectionOptions userOptions, TimeSpan timeout, bool async, CancellationToken cancellationToken)
{
    while (TryReserveConnectionSlot())
    {
        try
        {
            var connection = CreateAndTrackPhysicalConnection(owningConnection, userOptions);
            return Task.FromResult<DbConnectionInternal?>(connection);
        }
        catch
        {
            ReleaseConnectionSlot();
            SignalWaiters();
            throw;
        }
    }

    return Task.FromResult<DbConnectionInternal?>(null);
}


try
{
// We've managed to increase the open counter, open a physical connection.
Copy link

@imasud00 imasud00 Jun 17, 2025

Choose a reason for hiding this comment

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

We've managed to increase the open counter, open a physical connection.

private bool TryReserveConnectionSlot()
{
    int current;
    while ((current = _numConnections) < MaxPoolSize)
    {
        if (Interlocked.CompareExchange(ref _numConnections, current + 1, current) == current)
        {
            return true;
        }
    }
    return false;
}

Copy link

@imasud00 imasud00 Jun 17, 2025

Choose a reason for hiding this comment

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

private void ReleaseConnectionSlot()
{
    Interlocked.Decrement(ref _numConnections);
}
private void SignalWaiters()
{
    _idleConnectionWriter.TryWrite(null);
}

* throughput high than to queue all of our opens onto a single worker thread. Add an async path
* when this support is added to DbConnectionInternal.
*/
DbConnectionInternal? newConnection = ConnectionFactory.CreatePooledConnection(
Copy link

@imasud00 imasud00 Jun 17, 2025

Choose a reason for hiding this comment

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

ConnectionFactory

private DbConnectionInternal CreateAndTrackPhysicalConnection(DbConnection? owningConnection, DbConnectionOptions userOptions)
{
    DbConnectionInternal? newConnection = ConnectionFactory.CreatePooledConnection(
        this,
        owningConnection,
        PoolGroup.ConnectionOptions,
        PoolGroup.PoolKey,
        userOptions);

    if (newConnection == null)
    {
        throw ADP.InternalError(ADP.InternalErrorCode.CreateObjectReturnedNull);
    }
    if (!newConnection.CanBePooled)
    {
        throw ADP.InternalError(ADP.InternalErrorCode.NewObjectCannotBePooled);
    }

    newConnection.PrePush(null);

    if (!TryTrackConnectionInSlot(newConnection))
    {
        ReleaseConnectionSlot();
        SignalWaiters();
        throw new InvalidOperationException("No available slot to track new connection.");
    }

    return newConnection;
}

// We've managed to increase the open counter, open a physical connection.
var startTime = Stopwatch.GetTimestamp();

/* TODO: This blocks the thread for several network calls!

Choose a reason for hiding this comment

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

Do we have bug that is tracking to make this change ?

Debug.Assert(i < MaxPoolSize, $"Could not find free slot in {_connections} when opening.");
if (i == MaxPoolSize)
{
//TODO: generic exception?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this case, maybe we should close the connection and pretend we never created it

throw ADP.InternalError(ADP.InternalErrorCode.NewObjectCannotBePooled);
}

newConnection.PrePush(null);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

consider wrapping in a more meaningful name

/// </summary>
/// <param name="connection"></param>
/// <returns>Returns true if the connection is live and unexpired, otherwise returns false.</returns>
private bool CheckConnection(DbConnectionInternal connection)

Choose a reason for hiding this comment

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

CheckConnection

CloseExpiredConnection().

// This connection was tracked by the pool, so closing it has opened a free spot in the pool.
// Write a null to the idle connection channel to wake up a waiter, who can now open a new
// connection. Statement order is important since we have synchronous completions on the channel.
_idleConnectionWriter.TryWrite(null);
Copy link

@imasud00 imasud00 Jun 18, 2025

Choose a reason for hiding this comment

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

Instead of CloseConnetion. I will save RemoveConnection (could you add bool dipose which could be true by default or false)

I think the logic should be inversed.

We should first remove this conneciton from the slots and then call dispose(). This ensures that pool get cleared and dispose is best effort even if for any reason dispose throuws an exception we are good that we have cleaned up the slot.

Copy link
Contributor

@benrr101 benrr101 left a comment

Choose a reason for hiding this comment

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

Sorry, needs another round... But the other first round was pretty good!

/// </summary>
internal DateTime CreateTime
{
get;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: these can/should be on the same line

/// </summary>
private static int _instanceCount;

private readonly int _instanceID = Interlocked.Increment(ref _instanceCount);
Copy link
Contributor

Choose a reason for hiding this comment

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

it starts at zero and only ever goes up

/// <inheritdoc />
public DbConnectionPoolIdentity Identity
{
get;
Copy link
Contributor

Choose a reason for hiding this comment

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

Ok I rescind my Nit designation - let's get these on the same line

}

/*
* This is ugly, but async anti-patterns above and below us in the stack necessitate a fresh task to be
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I think your indentation got messed up here

}

/// <inheritdoc/>
internal Task<DbConnectionInternal?> OpenNewInternalConnection(DbConnection? owningConnection, DbConnectionOptions userOptions, TimeSpan timeout, bool async, CancellationToken cancellationToken)
Copy link
Contributor

Choose a reason for hiding this comment

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

Please chomp this biggie of a line :)

@@ -389,7 +500,7 @@ private async Task<DbConnectionInternal> GetInternalConnection(DbConnection owni
try
{
// Continue looping around until we create/retrieve a connection or the timeout expires.
while (true)
while (true && !finalToken.IsCancellationRequested)
Copy link
Contributor

Choose a reason for hiding this comment

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

.... true is completely redundant now, lol

I'd rather have a while(true) than while(!finalToken.IsCancellationRequested). At least I know intuitively that while(true) implies we'll break out somewhere inside. The latter implies we run this code until timeout... And the combination is just, well redundant 😂

It would be nice if there was a way to have the condition of the while be while(connection is null && finalToken.IsCancellationRequested). Which I think is what we were working towards by suggesting a do/while loop - basically make at least one attempt to get a connection, and loop around if we haven't. I'd suggest then making it like:

do {
  finalToken.ThrowIfCancelled();

  // do stuff to set connection
} while(connection is null);

return connection;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the latest refactor should help with this. coming soon!

private readonly ChannelWriter<DbConnectionInternal?> _idleConnectionWriter;

// Counts the total number of open connections tracked by the pool.
private volatile int _numConnections;
Copy link
Contributor

Choose a reason for hiding this comment

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

When will this be different than the size of _connections array?

// We enforce Max Pool Size, so no need to create a bounded channel (which is less efficient)
// On the consuming side, we have the multiplexing write loop but also non-multiplexing Rents
// On the producing side, we have connections being released back into the pool (both multiplexing and not)
var idleChannel = Channel.CreateUnbounded<DbConnectionInternal?>();
Copy link
Contributor

Choose a reason for hiding this comment

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

I agree with the efficiency argument but have we compared the difference in performance for our specific use case implementation for Bounded vs Unbounded Channel. Bounded channel can offload the part of us worrying about Max/Min poolsize, flooding and starvation in exchange for some throughput loss.

/// Tracks all connections currently managed by this pool, whether idle or busy.
/// Only updated rarely - when physical connections are opened/closed - but is read in perf-sensitive contexts.
/// </summary>
private readonly DbConnectionInternal?[] _connections;
Copy link
Contributor

Choose a reason for hiding this comment

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

So in essence _numConnections is doing the reservation part for us. Does the array itself provide any thing that Concurrent Bag/Dictionary/Queue will not provide. SemaphoreSlim does look seem to be the lightest, thread-safe solution here.

Copy link
Contributor

@paulmedynski paulmedynski left a comment

Choose a reason for hiding this comment

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

A new round of comments, questions, and suggestions :)


using System.ComponentModel;


Copy link
Contributor

Choose a reason for hiding this comment

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

Extra blank line.

using System.ComponentModel;


// This class enables the use of the `init` property accessor in .NET framework.
Copy link
Contributor

Choose a reason for hiding this comment

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

/// </summary>
private static int _instanceCount;

private readonly int _instanceID = Interlocked.Increment(ref _instanceCount);
Copy link
Contributor

Choose a reason for hiding this comment

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

Counters are naturally unsigned, so can we use uint or ulong instead? I'd be proud if those ever wrapped :)

PoolGroupOptions = connectionPoolGroup.PoolGroupOptions;
ProviderInfo = connectionPoolProviderInfo;
Identity = identity;
AuthenticationContexts = new();
Copy link
Contributor

Choose a reason for hiding this comment

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

What's our take on initializing fields/properties that don't depend on a constructor body? Do we prefer to do it in each constructor, or do it on the field/property declaration? I prefer the latter. We've got a mix in this class so far (see _instanceID line 40).

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't really have a preference here. Maybe a slight preference towards doing it in the constructor for instance variables, doing it outside the constructor (where possible) for statics. But I think I have more of a preference for not having a preference :)

/// </summary>
private static int _instanceCount;

private readonly int _instanceID = Interlocked.Increment(ref _instanceCount);
Copy link
Contributor

Choose a reason for hiding this comment

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

Also, should this be _instanceId ?

public DbConnectionPoolIdentity Identity
{
get;
private set;
Copy link
Contributor

Choose a reason for hiding this comment

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

Can this be init too?

/// <inheritdoc />
public bool TryGetConnection(DbConnection owningObject, TaskCompletionSource<DbConnectionInternal> taskCompletionSource, DbConnectionOptions userOptions, out DbConnectionInternal? connection)
{
// If taskCompletionSource is null, we are in a sync context.
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you use ? notation on the argument type here, even though it's not specified on the IDbConnectionPool method?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It should work fine, but I made the interface method use ? as well for clarity.


/// <inheritdoc />
public bool TryGetConnection(DbConnection owningObject, TaskCompletionSource<DbConnectionInternal> taskCompletionSource, DbConnectionOptions userOptions, out DbConnectionInternal? connection)
/// <returns>Returns true if a valid idle connection is found, otherwise returns false.</returns>
Copy link
Contributor

Choose a reason for hiding this comment

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

This method doesn't return bool.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants