-
Notifications
You must be signed in to change notification settings - Fork 330
Extract pool blocking-period error state into BlockingPeriodErrorState #4395
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
base: main
Are you sure you want to change the base?
Changes from all commits
b7dbf34
2a4a1f4
3517c19
b3de8e7
5a4ae39
6ba4b82
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,250 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
| // See the LICENSE file in the project root for more information. | ||
|
|
||
| using System; | ||
| using System.Threading; | ||
| using Microsoft.Data.Common; | ||
| using Microsoft.Data.SqlClient.Internal; | ||
|
|
||
| #nullable enable | ||
|
|
||
| namespace Microsoft.Data.SqlClient.ConnectionPool | ||
| { | ||
| /// <summary> | ||
| /// Encapsulates a connection pool's blocking-period error state: cached exception, exponential | ||
| /// backoff timer, and synchronization. Kept as a separate class so the pool's | ||
| /// connection-acquisition path remains focused on capacity/queue concerns and stays | ||
| /// decoupled from the (independent) rate limiting policy. | ||
| /// </summary> | ||
| internal sealed class BlockingPeriodErrorState : IDisposable | ||
| { | ||
| // Backoff interval used the first time the pool enters the blocking period, and the | ||
| // value the backoff resets to on a successful create or Clear(). | ||
| private static readonly TimeSpan InitialWait = TimeSpan.FromSeconds(5); | ||
|
|
||
| // Upper bound the exponential backoff is capped at; further failures never wait longer. | ||
| private static readonly TimeSpan MaxWait = TimeSpan.FromSeconds(60); | ||
|
|
||
| // Identifier of the owning pool, included in trace events for diagnostics. | ||
| private readonly int _ownerPoolId; | ||
|
|
||
| // Optional callback invoked (under _lock) when the state enters the blocking period; | ||
| // Must be cheap and non-reentrant. Must not throw. If null, no action is taken. | ||
| private readonly Action? _onEnter; | ||
|
|
||
| // Optional callback invoked (under _lock) when the state leaves the blocking period | ||
| // via the exit timer or Clear(). Must be cheap and non-reentrant. Must not throw. | ||
| // If null, no action is taken. | ||
| private readonly Action? _onExit; | ||
|
|
||
| // Time source used to create the exit timer; overridable so tests can drive scheduling | ||
| // deterministically. Defaults to TimeProvider.System. | ||
| private readonly TimeProvider _timeProvider; | ||
|
|
||
| // Guards the mutable error state (_cachedException, _exitTimer, _nextWait, _disposed). | ||
| private readonly object _lock = new(); | ||
|
|
||
| // Non-null while the pool is in the blocking period. Doubles as the "has error" | ||
| // flag, so callers don't need a separate bool. Volatile so other threads observe | ||
| // entry/exit transitions without acquiring _lock. | ||
| private volatile Exception? _cachedException; | ||
|
|
||
| // True from the moment Enter() activates the blocking period until Clear()/Dispose() | ||
| // fully resets it. The exit timer clears _cachedException but leaves this set so a | ||
| // later Clear() still resets the backoff. Volatile so Clear() can take a | ||
| // lock-free path when there is nothing to do. This allows Clear() to be called on hot paths. | ||
| private volatile bool _inElevatedState; | ||
|
|
||
| // The armed exit timer that ends the current blocking period; null when no period is | ||
| // active. Replaced (and the old one disposed) each time Enter() is called. | ||
| private ITimer? _exitTimer; | ||
|
|
||
| // The backoff interval to use for the next Enter(); doubles per failure up to MaxWait | ||
| // and resets to InitialWait on a successful create or Clear(). | ||
| private TimeSpan _nextWait = InitialWait; | ||
|
|
||
| // True once Dispose() has run, so repeated disposal and post-teardown work are no-ops. | ||
| private bool _disposed; | ||
|
|
||
| /// <summary> | ||
| /// Creates a new instance. | ||
| /// </summary> | ||
| /// <param name="ownerPoolId">Identifier of the owning pool, used in trace events.</param> | ||
| /// <param name="onEnter">Optional callback invoked (while holding the internal lock) after | ||
| /// the state transitions into the blocking period. Used by the legacy wait-handle pool to | ||
| /// signal its error wait handle. Because it runs under a lock, it must be cheap and | ||
| /// non-reentrant.</param> | ||
| /// <param name="onExit">Optional callback invoked (while holding the internal lock) after the | ||
| /// state transitions out of the blocking period via the exit timer or <see cref="Clear"/>. | ||
| /// Because it runs under a lock, it must be cheap and non-reentrant.</param> | ||
| /// <param name="timeProvider">The time provider used to create the exit timer. Defaults to | ||
| /// <see cref="TimeProvider.System"/>. Inject a test double (e.g. | ||
| /// <c>Microsoft.Extensions.Time.Testing.FakeTimeProvider</c>) in unit tests to | ||
| /// control timer scheduling deterministically.</param> | ||
| internal BlockingPeriodErrorState(int ownerPoolId, Action? onEnter = null, Action? onExit = null, TimeProvider? timeProvider = null) | ||
| { | ||
| _ownerPoolId = ownerPoolId; | ||
| _onEnter = onEnter; | ||
| _onExit = onExit; | ||
| _timeProvider = timeProvider ?? TimeProvider.System; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// True while the pool is in the blocking period. Subsequent acquisition attempts | ||
| /// should fast-fail with the cached exception. | ||
| /// </summary> | ||
| internal bool HasError => _cachedException is not null; | ||
|
|
||
| /// <summary> | ||
| /// Throws the cached error if the pool is currently in the blocking period. | ||
| /// </summary> | ||
| internal void ThrowIfActive() | ||
| { | ||
| Exception? cached = _cachedException; | ||
| if (cached is null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| // Clone SqlExceptions so stack traces are not shared across callers; other | ||
| // exception types are rethrown as-is. | ||
| throw cached is SqlException sqlEx ? sqlEx.InternalClone() : cached; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was the existing cloning logic |
||
| } | ||
|
|
||
| /// <summary> | ||
| /// Enters the blocking period, caching the supplied exception and scheduling a timer | ||
| /// to exit the period after the current backoff interval. Subsequent failures double | ||
| /// the backoff up to <see cref="MaxWait"/>. | ||
| /// </summary> | ||
| internal void Enter(Exception ex) | ||
| { | ||
| TimeSpan wait; | ||
| ITimer? oldTimer; | ||
|
|
||
| // If we call this, we're already in an exception path. Prefer correctness over performance. | ||
| lock (_lock) | ||
| { | ||
| _inElevatedState = true; | ||
| _cachedException = ex; | ||
| wait = _nextWait; | ||
|
|
||
| ITimer newTimer = ADP.UnsafeCreateTimer( | ||
| _timeProvider, | ||
| ExitCallback, | ||
| this, | ||
| wait, | ||
| wait); | ||
| oldTimer = _exitTimer; | ||
| _exitTimer = newTimer; | ||
|
|
||
| // Bump the backoff for the next failure, capped at MaxWait. FR-008. | ||
| TimeSpan doubled = _nextWait + _nextWait; | ||
| _nextWait = doubled > MaxWait ? MaxWait : doubled; | ||
|
|
||
| // Signal the enter event while still holding the lock so the external signal order | ||
| // (onEnter/onExit) can never diverge from the internal state transitions under | ||
| // concurrent Enter/Clear/exit-timer activity. The callbacks are expected to be | ||
| // cheap, non-reentrant operations. | ||
| _onEnter?.Invoke(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard against misbehaving callbacks and Dispose() with try/catch that swallows. Same for Clear(), ExitCallback(), and our Dispose(). |
||
| } | ||
|
|
||
| oldTimer?.Dispose(); | ||
|
|
||
| SqlClientEventSource.Log.TryPoolerTraceEvent( | ||
| "<prov.DbConnectionPool.EnterErrorState|RES|CPOOL> {0}, Entering blocking period for {1}ms.", | ||
| _ownerPoolId, | ||
| (int)wait.TotalMilliseconds); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Clears the cached error state, disposes the exit timer, and resets the backoff to | ||
| /// its initial value. | ||
| /// </summary> | ||
| internal void Clear() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's definitely some overlap between Clear, ExitCallback, and Dispose. I'm having trouble understanding why they aren't all the same. It seems like _cachedException, _inElevatedState, _exitTimer, and _nextWait could all live/die together in a helper class. |
||
| { | ||
| // Fast path: the create flow calls Clear() after every successful create, so avoid | ||
| // taking the lock in the common (no-error) case where there is nothing to reset. | ||
| if (!_inElevatedState) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| ITimer? oldTimer; | ||
|
|
||
| lock (_lock) | ||
| { | ||
| _cachedException = null; | ||
| _nextWait = InitialWait; | ||
| _inElevatedState = false; | ||
| oldTimer = _exitTimer; | ||
| _exitTimer = null; | ||
|
|
||
| // Signal and trace under the lock so the exit signal is ordered consistently | ||
| // with the state transition relative to concurrent Enter/exit-timer callbacks. | ||
| _onExit?.Invoke(); | ||
| } | ||
|
Comment on lines
+175
to
+186
|
||
|
|
||
| oldTimer?.Dispose(); | ||
|
|
||
| SqlClientEventSource.Log.TryPoolerTraceEvent( | ||
| "<prov.DbConnectionPool.ClearErrorState|RES|CPOOL> {0}, Error state cleared.", _ownerPoolId); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Timer callback that exits the blocking period by clearing the cached exception, | ||
| /// allowing the next caller to attempt a fresh connection creation. The current | ||
| /// backoff is left intact so that, if the next attempt fails, the backoff continues | ||
| /// to grow rather than resetting. The backoff is reset only on <see cref="Clear"/>. | ||
| /// </summary> | ||
| private static void ExitCallback(BlockingPeriodErrorState state) | ||
| { | ||
| ITimer? oldTimer; | ||
|
|
||
| lock (state._lock) | ||
| { | ||
| state._cachedException = null; | ||
| oldTimer = state._exitTimer; | ||
| state._exitTimer = null; | ||
|
|
||
| // Signal under the lock so the exit signal is ordered consistently | ||
| // with the state transition relative to concurrent Enter/Clear callbacks. | ||
| try { | ||
| state._onExit?.Invoke(); | ||
| } catch { | ||
| // Ignore exceptions from the exit event. | ||
| } | ||
| } | ||
|
|
||
| oldTimer?.Dispose(); | ||
|
|
||
| SqlClientEventSource.Log.TryPoolerTraceEvent( | ||
| "<prov.DbConnectionPool.ExitErrorStateCallback|RES|CPOOL> {0}, Exiting blocking period.", state._ownerPoolId); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Disposes the instance, releasing the exit timer if one is active. Clears the | ||
| /// error state so that any waiting callers do not observe a stale exception after | ||
| /// the owning pool is torn down. | ||
| /// </summary> | ||
| public void Dispose() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like to put |
||
| { | ||
| ITimer? timerToDispose; | ||
| lock (_lock) | ||
| { | ||
| if (_disposed) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| _cachedException = null; | ||
| _inElevatedState = false; | ||
| timerToDispose = _exitTimer; | ||
| _exitTimer = null; | ||
| } | ||
|
|
||
| timerToDispose?.Dispose(); | ||
| _disposed = true; | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this something we only want suppressed in tests? When would legit driver code want to fake an enum value? I suppose we may have some existing debt in this area.