Repository navigation
Change how the key ring cache is updated - #54675
Conversation
I wanted to address two concerns: 1. If there is a cached key ring, but it is expired, the first thread to discover this will _synchronously_ refresh the cache - other threads will continue to use the old value. 2. If a key ring refresh is forced, it will always hit the backing repository, even if several threads want a refresh at the same time. With this change, _all_ key ring updates are computed on a thread-pool thread and callers block exactly when there is no cached key ring for them to fall back on (first run, for example) or if they are forcing a refresh (an in-flight refresh is considered satisfactory). Moving this work to a background thread will give us more room to be generous with retries when (e.g.) Azure KeyVault is unreachable or a file is locked. The old behavior can be restored using the appcontext switch `Microsoft.AspNetCore.DataProtection.KeyManagement.DisableAsyncKeyRingUpdate`. This is a safety valve and not part of configuration - we'll remove the switch and the old code path in the next release if nothing blows up. Micro-benchmarking (on 9.0 on x64) shows that the new version has comparable performance in the common case of finding an unexpired key ring in the cache.
| mockCacheableKeyRingProvider.Setup(o => o.GetCacheableKeyRing(updatedKeyRingTime)) | ||
| .Returns<DateTimeOffset>(dto => | ||
| { | ||
| // at this point we're inside the critical section - spawn the background thread now |
There was a problem hiding this comment.
This test was unsalvageable because it depended upon the lambda being evaluated under a particular lock. MultipleThreadsSeeExpiredCachedValue attempts to provide analogous coverage.
captainsafia
left a comment
There was a problem hiding this comment.
Change seems sound overall! Would definitely want other eyes on all the multi-threaded logic as well.
|
Thanks @captainsafia and @BrennanConroy! This was a complicated one. |
| if (newCacheableKeyRing is null) | ||
| { | ||
| // There will have been a better exception from the winning thread | ||
| throw Error.KeyRingProvider_RefreshFailedOnOtherThread(); |
There was a problem hiding this comment.
The exception must have already happened at this point. Why can't we capture it and throw it from any thread that fails to retrieve the KeyRing due to the exception?
There was a problem hiding this comment.
I didn't think it would be helpful to print the underlying exception once per affected task, even if we were to add a note that it was being re-reported from elsewhere.
There was a problem hiding this comment.
I still think it's more helpful than throwing a generic exception. We don't have to log it each time.
There was a problem hiding this comment.
As a compromise, I've attached existingTask.Exception as the inner exception.
- Take locks earlier since they're going to be taken anyway - Observe exceptions on background tasks - Rethrow task exceptions in the standard way - Remove redundant Volatiles - Attach an inner exception on losing threads
* Fix KeyRingProvider thread pool starvation on forced refresh The cold-start starvation fix (#66683/#66737) left a second variant: when a valid key ring is cached and a caller forces a refresh (e.g. during the two-minute startup auto-refresh window an incoming cookie names a key not in the current ring), GetCurrentKeyRingCoreNew scheduled the refresh onto TaskScheduler.Default and blocked on Task.Wait(). With every thread-pool thread parked on that wait, the queued refresh could not run, causing thread pool starvation and a multi-second freeze. See #66380. Unify the invariant that already protects cold start: any caller that must block for a value produces it inline on its own thread, so no caller ever blocks on TaskScheduler.Default work. Non-forced callers with a stale ring still dispatch the async refresh and return the stale ring without waiting, preserving the perf win from #54675. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec35c6be-ab9b-47d1-9563-d7a61490ebb2 * add debug assert * Harden forced-refresh path per PR review Addresses two issues raised in review of the forced-refresh starvation fix: - On inline refresh failure, only extend the lifetime of a ring that is already stale. A forced refresh can fail while the cached ring is still valid; previously WithTemporaryExtendedLifetime would then cut that valid ring's expiration to 2 minutes and swap its expiration token for CancellationToken.None, causing key changes to be ignored until the shortened lifetime elapsed. This now mirrors the guard in GetKeyRingFromCompletedTaskUnsynchronized. - After publishing an inline forced-refresh result, discard any async refresh task still in flight so its older result cannot later be consumed and clobber the fresher ring. The discarded task's exception is observed to avoid an unobserved TaskScheduler.UnobservedTaskException. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d900e559-5b83-4d0c-b179-e1cc2cda9792 * add regression tests --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec35c6be-ab9b-47d1-9563-d7a61490ebb2 Copilot-Session: d900e559-5b83-4d0c-b179-e1cc2cda9792
…#68001) * Fix KeyRingProvider thread pool starvation on forced refresh The cold-start starvation fix (#66683/#66737) left a second variant: when a valid key ring is cached and a caller forces a refresh (e.g. during the two-minute startup auto-refresh window an incoming cookie names a key not in the current ring), GetCurrentKeyRingCoreNew scheduled the refresh onto TaskScheduler.Default and blocked on Task.Wait(). With every thread-pool thread parked on that wait, the queued refresh could not run, causing thread pool starvation and a multi-second freeze. See #66380. Unify the invariant that already protects cold start: any caller that must block for a value produces it inline on its own thread, so no caller ever blocks on TaskScheduler.Default work. Non-forced callers with a stale ring still dispatch the async refresh and return the stale ring without waiting, preserving the perf win from #54675. * add debug assert * Harden forced-refresh path per PR review Addresses two issues raised in review of the forced-refresh starvation fix: - On inline refresh failure, only extend the lifetime of a ring that is already stale. A forced refresh can fail while the cached ring is still valid; previously WithTemporaryExtendedLifetime would then cut that valid ring's expiration to 2 minutes and swap its expiration token for CancellationToken.None, causing key changes to be ignored until the shortened lifetime elapsed. This now mirrors the guard in GetKeyRingFromCompletedTaskUnsynchronized. - After publishing an inline forced-refresh result, discard any async refresh task still in flight so its older result cannot later be consumed and clobber the fresher ring. The discarded task's exception is observed to avoid an unobserved TaskScheduler.UnobservedTaskException. * add regression tests --------- (cherry picked from commit a973708) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec35c6be-ab9b-47d1-9563-d7a61490ebb2 Copilot-Session: d900e559-5b83-4d0c-b179-e1cc2cda9792
I wanted to address two concerns:
With this change, all key ring updates are computed on a thread-pool thread and callers block exactly when there is no cached key ring for them to fall back on (first run, for example) or if they are forcing a refresh (an in-flight refresh is considered satisfactory).
Moving this work to a background thread will give us more room to be generous with retries when (e.g.) Azure KeyVault is unreachable or a file is locked.
The old behavior can be restored using the appcontext switch
Microsoft.AspNetCore.DataProtection.KeyManagement.DisableAsyncKeyRingUpdate. This is a safety valve and not part of configuration - we'll remove the switch and the old code path in the next release if nothing blows up.Micro-benchmarking (on 9.0 on x64) shows that the new version has comparable performance in the common case of finding an unexpired key ring in the cache.