Skip to content

Commit 260952f

Browse files
authored
Address feedback on PR #54675 (#55295)
- 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
1 parent 78cb980 commit 260952f

File tree

3 files changed

+91
-76
lines changed

3 files changed

+91
-76
lines changed

src/DataProtection/DataProtection/src/Error.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ public static InvalidOperationException KeyRingProvider_DefaultKeyRevoked(Guid i
9898
return new InvalidOperationException(message);
9999
}
100100

101-
public static InvalidOperationException KeyRingProvider_RefreshFailedOnOtherThread()
101+
public static InvalidOperationException KeyRingProvider_RefreshFailedOnOtherThread(Exception? inner)
102102
{
103-
return new InvalidOperationException(Resources.KeyRingProvider_RefreshFailedOnOtherThread);
103+
return new InvalidOperationException(Resources.KeyRingProvider_RefreshFailedOnOtherThread, inner);
104104
}
105105
}

src/DataProtection/DataProtection/src/KeyManagement/KeyRingProvider.cs

Lines changed: 88 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -321,137 +321,152 @@ private IKeyRing GetCurrentKeyRingCoreNew(DateTime utcNow, bool forceRefresh)
321321
// key ring is valid. We do what we can to avoid unnecessary overhead (locking,
322322
// context switching, etc) on this path.
323323

324-
CacheableKeyRing? existingCacheableKeyRing = null;
325-
326324
// Can we return the cached keyring to the caller?
327325
if (!forceRefresh)
328326
{
329-
existingCacheableKeyRing = Volatile.Read(ref _cacheableKeyRing);
330-
if (CacheableKeyRing.IsValid(existingCacheableKeyRing, utcNow))
327+
var cached = Volatile.Read(ref _cacheableKeyRing);
328+
if (CacheableKeyRing.IsValid(cached, utcNow))
331329
{
332-
return existingCacheableKeyRing.KeyRing;
330+
return cached.KeyRing;
333331
}
334332
}
335333

336-
// If work kicked off by a previous caller has completed, we should use those results
337-
// We check this outside the lock to reduce contention in the common case (no task).
338-
// Logically, it would probably make more sense to check this before checking whether
339-
// the cache is valid - there could be a newer value available - but keeping that path
340-
// fast is more important. The next forced refresh or cache expiration will cause the
341-
// new value to be picked up.
342-
var existingTask = Volatile.Read(ref _cacheableKeyRingTask);
343-
if (existingTask is not null && existingTask.IsCompleted)
334+
CacheableKeyRing? existingCacheableKeyRing = null;
335+
Task<CacheableKeyRing>? existingTask = null;
336+
337+
lock (_cacheableKeyRingLockObj)
344338
{
345-
var taskKeyRing = GetKeyRingFromCompletedTask(existingTask, utcNow); // Throws if the task failed
346-
if (taskKeyRing is not null)
339+
// Did another thread acquire the lock first and populate the cache?
340+
// This could have happened if there was a completed in-flight task for the other thread to process.
341+
if (!forceRefresh)
347342
{
348-
return taskKeyRing;
343+
existingCacheableKeyRing = Volatile.Read(ref _cacheableKeyRing);
344+
if (CacheableKeyRing.IsValid(existingCacheableKeyRing, utcNow))
345+
{
346+
return existingCacheableKeyRing.KeyRing;
347+
}
349348
}
350-
}
351349

352-
// The cached keyring hasn't been created or must be refreshed. We'll allow one thread to
353-
// create a task to update the keyring, and all threads will continue to use the existing cached
354-
// keyring while the first thread performs the update. There is an exception: if there
355-
// is no usable existing cached keyring, all callers must block until the keyring exists.
356-
lock (_cacheableKeyRingLockObj)
357-
{
358-
// Update existingTask, in case we're not the first to acquire the lock
359-
existingTask = Volatile.Read(ref _cacheableKeyRingTask);
350+
existingTask = _cacheableKeyRingTask;
360351
if (existingTask is null)
361352
{
362353
// If there's no existing task, make one now
363354
// PERF: Closing over utcNow substantially slows down the fast case (valid cache) in micro-benchmarks
364355
// (closing over `this` for CacheableKeyRingProvider doesn't seem impactful)
365356
existingTask = Task.Factory.StartNew(
366-
utcNow => CacheableKeyRingProvider.GetCacheableKeyRing((DateTime)utcNow!),
357+
utcNowState => CacheableKeyRingProvider.GetCacheableKeyRing((DateTime)utcNowState!),
367358
utcNow,
368-
CancellationToken.None, // GetKeyRingFromCompletedTask will need to react if this becomes cancellable
359+
CancellationToken.None, // GetKeyRingFromCompletedTaskUnsynchronized will need to react if this becomes cancellable
369360
TaskCreationOptions.DenyChildAttach,
370361
TaskScheduler.Default);
371-
Volatile.Write(ref _cacheableKeyRingTask, existingTask);
362+
_cacheableKeyRingTask = existingTask;
363+
}
364+
365+
// This is mostly for the case where existingTask already set, but no harm in checking a fresh one
366+
if (existingTask.IsCompleted)
367+
{
368+
// If work kicked off by a previous caller has completed, we should use those results.
369+
// Logically, it would probably make more sense to check this before checking whether
370+
// the cache is valid - there could be a newer value available - but keeping that path
371+
// fast is more important. The next forced refresh or cache expiration will cause the
372+
// new value to be picked up.
373+
374+
// An unconsumed task result is considered to satisfy forceRefresh. One could quibble that this isn't really
375+
// a forced refresh, but we'll still return a key ring newer than the one the caller was dissatisfied with.
376+
var taskKeyRing = GetKeyRingFromCompletedTaskUnsynchronized(existingTask, utcNow); // Throws if the task failed
377+
Debug.Assert(taskKeyRing is not null, "How did _cacheableKeyRingTask change while we were holding the lock?");
378+
return taskKeyRing;
372379
}
373380
}
374381

382+
// Prefer a stale cached key ring to blocking
375383
if (existingCacheableKeyRing is not null)
376384
{
377-
Debug.Assert(!forceRefresh, "Read cached key ring even though forceRefresh is true");
378-
Debug.Assert(!CacheableKeyRing.IsValid(existingCacheableKeyRing, utcNow), "Should already have returned a valid cached key ring");
385+
Debug.Assert(!forceRefresh, "Consumed cached key ring even though forceRefresh is true");
386+
Debug.Assert(!CacheableKeyRing.IsValid(existingCacheableKeyRing, utcNow), "Should have returned a valid cached key ring above");
379387
return existingCacheableKeyRing.KeyRing;
380388
}
381389

382-
// Since there's no cached key ring we can return, we have to wait. It's not ideal to wait for a task we
383-
// just scheduled, but it makes the code a lot simpler (compared to having a separate, synchronous code path).
384-
// Cleverness: swallow any exceptions - they'll be surfaced by GetKeyRingFromCompletedTask, if appropriate.
390+
// If there's not even a stale cached key ring we can use, we have to wait.
391+
// It's not ideal to wait for a task that was just scheduled, but it makes the code a lot simpler
392+
// (compared to having a separate, synchronous code path).
393+
394+
// The reason we yield the lock and wait for the task instead is to allow racing forceRefresh threads
395+
// to wait for the same task, rather than being sequentialized (and each doing its own refresh).
396+
397+
// Cleverness: swallow any exceptions - they'll be surfaced by GetKeyRingFromCompletedTaskUnsynchronized, if appropriate.
385398
existingTask
386-
.ContinueWith(static _ => { }, TaskScheduler.Default)
399+
.ContinueWith(
400+
static t => _ = t.Exception, // Still observe the exception - just don't throw it
401+
CancellationToken.None,
402+
TaskContinuationOptions.ExecuteSynchronously,
403+
TaskScheduler.Default)
387404
.Wait();
388405

389-
var newKeyRing = GetKeyRingFromCompletedTask(existingTask, utcNow); // Throws if the task failed (winning thread only)
390-
if (newKeyRing is null)
406+
lock (_cacheableKeyRingLockObj)
391407
{
392-
// Another thread won - check whether it cached a new key ring
393-
var newCacheableKeyRing = Volatile.Read(ref _cacheableKeyRing);
394-
if (newCacheableKeyRing is null)
408+
var newKeyRing = GetKeyRingFromCompletedTaskUnsynchronized(existingTask, utcNow); // Throws if the task failed (winning thread only)
409+
if (newKeyRing is null)
395410
{
396-
// There will have been a better exception from the winning thread
397-
throw Error.KeyRingProvider_RefreshFailedOnOtherThread();
411+
// Another thread won - check whether it cached a new key ring
412+
var newCacheableKeyRing = Volatile.Read(ref _cacheableKeyRing);
413+
if (newCacheableKeyRing is null)
414+
{
415+
// There will have been a better exception from the winning thread
416+
throw Error.KeyRingProvider_RefreshFailedOnOtherThread(existingTask.Exception);
417+
}
418+
419+
newKeyRing = newCacheableKeyRing.KeyRing;
398420
}
399421

400-
newKeyRing = newCacheableKeyRing.KeyRing;
422+
return newKeyRing;
401423
}
402-
403-
return newKeyRing;
404424
}
405425

406426
/// <summary>
407-
/// Returns null if another thread already processed the completed task.
408-
/// Otherwise, if the given completed task completed successfully, clears the task
409-
/// and either caches and returns the resulting key ring or throws, according to the
410-
/// successfulness of the task.
427+
/// If the given completed task completed successfully, clears the task and either
428+
/// caches and returns the resulting key ring or throws, according to the successfulness
429+
/// of the task.
411430
/// </summary>
412-
private IKeyRing? GetKeyRingFromCompletedTask(Task<CacheableKeyRing> task, DateTime utcNow)
431+
/// <remarks>
432+
/// Must be called under <see cref="_cacheableKeyRingLockObj"/>.
433+
/// </remarks>
434+
private IKeyRing? GetKeyRingFromCompletedTaskUnsynchronized(Task<CacheableKeyRing> task, DateTime utcNow)
413435
{
414436
Debug.Assert(task.IsCompleted);
437+
Debug.Assert(!task.IsCanceled, "How did a task with no cancellation token get canceled?");
415438

416-
lock (_cacheableKeyRingLockObj)
439+
// If the parameter doesn't match the field, another thread has already consumed the task (and it's reflected in _cacheableKeyRing)
440+
if (!ReferenceEquals(task, _cacheableKeyRingTask))
417441
{
418-
// If the parameter doesn't match the field, another thread has already consumed the task (and it's reflected in _cacheableKeyRing)
419-
if (!ReferenceEquals(task, Volatile.Read(ref _cacheableKeyRingTask)))
420-
{
421-
return null;
422-
}
423-
424-
Volatile.Write(ref _cacheableKeyRingTask, null);
425-
426-
if (task.Status == TaskStatus.RanToCompletion)
427-
{
428-
var newCacheableKeyRing = task.Result;
429-
Volatile.Write(ref _cacheableKeyRing, newCacheableKeyRing);
430-
431-
// An unconsumed task result is considered to satisfy forceRefresh. One could quibble that this isn't really
432-
// a forced refresh, but we'll still return a key ring newer than the one the caller was dissatisfied with.
433-
return newCacheableKeyRing.KeyRing;
434-
}
442+
return null;
443+
}
435444

436-
Debug.Assert(!task.IsCanceled, "How did a task with no cancellation token get canceled?");
437-
Debug.Assert(task.Exception is not null, "Task should have either completed successfully or with an exception");
438-
var exception = task.Exception;
445+
_cacheableKeyRingTask = null;
439446

447+
try
448+
{
449+
var newCacheableKeyRing = task.GetAwaiter().GetResult(); // Call GetResult to throw on failure
450+
Volatile.Write(ref _cacheableKeyRing, newCacheableKeyRing);
451+
return newCacheableKeyRing.KeyRing;
452+
}
453+
catch (Exception e)
454+
{
440455
var existingCacheableKeyRing = Volatile.Read(ref _cacheableKeyRing);
441456
if (existingCacheableKeyRing is not null && !CacheableKeyRing.IsValid(existingCacheableKeyRing, utcNow))
442457
{
443458
// If reading failed, we probably don't want to try again for a little bit, so slightly extend the
444459
// lifetime of the current cache entry
445460
Volatile.Write(ref _cacheableKeyRing, existingCacheableKeyRing.WithTemporaryExtendedLifetime(utcNow));
446461

447-
_logger.ErrorOccurredWhileRefreshingKeyRing(exception); // This one mentions the no-retry window
462+
_logger.ErrorOccurredWhileRefreshingKeyRing(e); // This one mentions the no-retry window
448463
}
449464
else
450465
{
451-
_logger.ErrorOccurredWhileReadingKeyRing(exception);
466+
_logger.ErrorOccurredWhileReadingKeyRing(e);
452467
}
453468

454-
throw exception.InnerExceptions.Count == 1 ? exception.InnerExceptions[0] : exception;
469+
throw;
455470
}
456471
}
457472

src/DataProtection/DataProtection/src/Resources.resx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@
191191
<value>The key ring's default data protection key {0:B} has been revoked.</value>
192192
</data>
193193
<data name="KeyRingProvider_RefreshFailedOnOtherThread" xml:space="preserve">
194-
<value>Another thread failed to refresh the key ring.</value>
194+
<value>Another thread failed to refresh the key ring. Refer to the inner exception for more information.</value>
195195
</data>
196196
<data name="LifetimeMustNotBeNegative" xml:space="preserve">
197197
<value>{0} must not be negative.</value>

0 commit comments

Comments
 (0)