Skip to content

Commit 00552f0

Browse files
Merge pull request #289 from DuendeSoftware/pg/hybrid-cache-duration-failure
Introduce a Token Cache Duration Store
2 parents ae3b4ed + 36a4180 commit 00552f0

File tree

6 files changed

+138
-30
lines changed

6 files changed

+138
-30
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) Duende Software. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
3+
4+
using System.Collections.Concurrent;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace Duende.AccessTokenManagement.Internal;
8+
9+
/// <summary>
10+
/// Store for cache duration auto-tuning state.
11+
/// </summary>
12+
internal sealed class ClientCredentialsCacheDurationStore(
13+
IOptions<ClientCredentialsTokenManagementOptions> options,
14+
TimeProvider time)
15+
{
16+
private readonly ClientCredentialsTokenManagementOptions _options = options.Value;
17+
private readonly ConcurrentDictionary<ClientCredentialsCacheKey, TimeSpan> _cacheDurations = new();
18+
19+
/// <summary>
20+
/// Gets the cache duration for a given cache key, or returns the default value if not found.
21+
/// </summary>
22+
public TimeSpan GetExpiration(ClientCredentialsCacheKey cacheKey)
23+
{
24+
var cacheExpiration = _options.UseCacheAutoTuning
25+
? _cacheDurations.GetValueOrDefault(cacheKey, _options.DefaultCacheLifetime)
26+
: _options.DefaultCacheLifetime;
27+
return cacheExpiration;
28+
}
29+
30+
/// <summary>
31+
/// Sets the cache duration for a given cache key.
32+
/// </summary>
33+
public TimeSpan SetExpiration(ClientCredentialsCacheKey cacheKey, DateTimeOffset expiration)
34+
{
35+
if (!_options.UseCacheAutoTuning
36+
|| expiration == DateTimeOffset.MaxValue)
37+
{
38+
return _options.DefaultCacheLifetime;
39+
}
40+
41+
// Calculate how long this access token should be valid in the cache.
42+
// Note, the expiration time was just calculated by adding time.GetUTcNow() to the token lifetime.
43+
// so for now it's safe to subtract this time from the expiration time.
44+
45+
var calculated = expiration
46+
- time.GetUtcNow()
47+
- TimeSpan.FromSeconds(_options.CacheLifetimeBuffer);
48+
49+
_cacheDurations[cacheKey] = calculated;
50+
51+
return calculated;
52+
}
53+
}

access-token-management/src/AccessTokenManagement/Internal/ClientCredentialsTokenManager.cs

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Copyright (c) Duende Software. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
33

4-
using System.Collections.Concurrent;
54
using Duende.AccessTokenManagement.OTel;
65
using Microsoft.Extensions.Caching.Hybrid;
76
using Microsoft.Extensions.DependencyInjection;
@@ -13,10 +12,12 @@ namespace Duende.AccessTokenManagement.Internal;
1312
internal class ClientCredentialsTokenManager(
1413
AccessTokenManagementMetrics metrics,
1514
IOptions<ClientCredentialsTokenManagementOptions> options,
16-
[FromKeyedServices(ServiceProviderKeys.ClientCredentialsTokenCache)] HybridCache cache,
15+
[FromKeyedServices(ServiceProviderKeys.ClientCredentialsTokenCache)]
16+
HybridCache cache,
1717
TimeProvider time,
1818
IClientCredentialsTokenEndpoint client,
1919
IClientCredentialsCacheKeyGenerator cacheKeyGenerator,
20+
ClientCredentialsCacheDurationStore cacheDurationAutoTuningStore,
2021
ILogger<ClientCredentialsTokenManager> logger
2122
) : IClientCredentialsTokenManager
2223
{
@@ -25,11 +26,6 @@ ILogger<ClientCredentialsTokenManager> logger
2526
// inside the factory.
2627
private const string ThrownInsideFactoryExceptionKey = "Duende.AccessTokenManagement.ThrownInside";
2728

28-
// We're assuming that the cache duration for access tokens will remain (relatively) stable
29-
// First time we acquire an access token, don't yet know how long it will be valid, so we're assuming
30-
// a specific period. However, after that, we'll use the actual expiration time to set the cache duration.
31-
private readonly ConcurrentDictionary<ClientCredentialsCacheKey, TimeSpan> _cacheDurationAutoTuning = new();
32-
3329
private readonly ClientCredentialsTokenManagementOptions _options = options.Value;
3430

3531
public async Task<TokenResult<ClientCredentialsToken>> GetAccessTokenAsync(
@@ -41,9 +37,7 @@ public async Task<TokenResult<ClientCredentialsToken>> GetAccessTokenAsync(
4137

4238
parameters ??= new TokenRequestParameters();
4339

44-
var cacheExpiration = _options.UseCacheAutoTuning
45-
? _cacheDurationAutoTuning.GetValueOrDefault(cacheKey, _options.DefaultCacheLifetime)
46-
: _options.DefaultCacheLifetime;
40+
var cacheExpiration = cacheDurationAutoTuningStore.GetExpiration(cacheKey);
4741

4842
// On force renewal, don't read from the cache, so we always get a new token.
4943
var disableDistributedCacheRead = parameters.ForceTokenRenewal
@@ -75,7 +69,8 @@ public async Task<TokenResult<ClientCredentialsToken>> GetAccessTokenAsync(
7569
{
7670
// This exception is thrown if there was a failure while retrieving an access token. We
7771
// don't want to cache this failure, so we throw an exception to bypass the cache action.
78-
logger.WillNotCacheTokenResultWithError(LogLevel.Debug, clientName, ex.Failure.Error, ex.Failure.ErrorDescription);
72+
logger.WillNotCacheTokenResultWithError(LogLevel.Debug, clientName, ex.Failure.Error,
73+
ex.Failure.ErrorDescription);
7974
return ex.Failure;
8075
}
8176
catch (Exception ex) when (!ex.Data.Contains(ThrownInsideFactoryExceptionKey))
@@ -135,23 +130,14 @@ private async Task<ClientCredentialsToken> RequestToken(ClientCredentialsCacheKe
135130

136131
// See if we need to record how long this access token is valid, to be used the next time
137132
// this access token is used.
138-
if (_options.UseCacheAutoTuning
139-
&& token.Expiration != DateTimeOffset.MaxValue)
140-
{
141-
// Calculate how long this access token should be valid in the cache.
142-
// Note, the expiration time was just calculated by adding time.GetUTcNow() to the token lifetime.
143-
// so for now it's safe to subtract this time from the expiration time.
144-
_cacheDurationAutoTuning[cacheKey] = token.Expiration
145-
- time.GetUtcNow()
146-
- TimeSpan.FromSeconds(_options.CacheLifetimeBuffer);
147-
148-
logger.CachingAccessToken(LogLevel.Debug, clientName, token.Expiration);
149-
}
133+
var cacheDuration = cacheDurationAutoTuningStore.SetExpiration(cacheKey, token.Expiration);
134+
logger.CachingAccessToken(LogLevel.Debug, clientName, cacheDuration);
150135

151136
return token;
152137
}
153138

154-
public async Task DeleteAccessTokenAsync(ClientCredentialsClientName clientName, TokenRequestParameters? parameters = null,
139+
public async Task DeleteAccessTokenAsync(ClientCredentialsClientName clientName,
140+
TokenRequestParameters? parameters = null,
155141
CT ct = default)
156142
{
157143
var cacheKey = cacheKeyGenerator.GenerateKey(clientName, parameters);

access-token-management/src/AccessTokenManagement/Internal/Generated/Microsoft.Gen.Logging/Microsoft.Gen.Logging.LoggingGenerator/Logging.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -973,7 +973,7 @@ public static void AuthorizationServerSuppliedNewNonce(this global::Microsoft.E
973973
/// Logs "Caching access token for client: {ClientName}. Expiration: {Expiration}".
974974
/// </summary>
975975
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.Logging", "9.9.0.0")]
976-
public static void CachingAccessToken(this global::Microsoft.Extensions.Logging.ILogger logger, global::Microsoft.Extensions.Logging.LogLevel logLevel, global::Duende.AccessTokenManagement.ClientCredentialsClientName clientName, global::System.DateTimeOffset expiration)
976+
public static void CachingAccessToken(this global::Microsoft.Extensions.Logging.ILogger logger, global::Microsoft.Extensions.Logging.LogLevel logLevel, global::Duende.AccessTokenManagement.ClientCredentialsClientName clientName, global::System.TimeSpan cacheDuration)
977977
{
978978
if (!logger.IsEnabled(logLevel))
979979
{
@@ -985,7 +985,7 @@ public static void CachingAccessToken(this global::Microsoft.Extensions.Logging
985985
_ = state.ReserveTagSpace(3);
986986
state.TagArray[2] = new("{OriginalFormat}", "Caching access token for client: {ClientName}. Expiration: {Expiration}");
987987
state.TagArray[1] = new("ClientName", clientName.ToString());
988-
state.TagArray[0] = new("Expiration", expiration);
988+
state.TagArray[0] = new("CacheDuration", cacheDuration);
989989

990990
logger.Log(
991991
logLevel,

access-token-management/src/AccessTokenManagement/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public static ClientCredentialsTokenManagementBuilder AddClientCredentialsTokenM
4141
this IServiceCollection services)
4242
{
4343
services.TryAddTransient<IClientCredentialsTokenManager, ClientCredentialsTokenManager>();
44+
services.TryAddSingleton<ClientCredentialsCacheDurationStore>();
4445
services.AddHybridCache();
4546

4647
// Add a default serializer for ClientCredentialsToken

access-token-management/test/AccessTokenManagement.Tests/ClientTokenManagementTests.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,4 +540,65 @@ [new KeyValuePair<string, string>("DPoP-Nonce", "some_nonce")],
540540
DPoPJsonWebKey = The.JsonWebKey
541541
});
542542
}
543+
544+
[Fact]
545+
public async Task Cache_auto_tuning_should_persist_across_transient_manager_instances()
546+
{
547+
var tokenExpiry = (int)TimeSpan.FromDays(7).TotalSeconds;
548+
549+
var fakeCache = new FakeHybridCache();
550+
_services.AddSingleton<HybridCache>(fakeCache);
551+
552+
_services.AddClientCredentialsTokenManagement(options =>
553+
{
554+
options.UseCacheAutoTuning = true;
555+
options.DefaultCacheLifetime = TimeSpan.FromSeconds(30);
556+
options.LocalCacheExpiration = TimeSpan.FromMinutes(10);
557+
options.CacheLifetimeBuffer = 60;
558+
})
559+
.AddClient("test", client => Some.ClientCredentialsClient(client));
560+
561+
_mockHttp.Expect("/connect/token")
562+
.Respond(_ => Some.TokenHttpResponse(Some.Token() with
563+
{
564+
expires_in = tokenExpiry
565+
}));
566+
567+
_services.AddHttpClient(ClientCredentialsTokenManagementDefaults.BackChannelHttpClientName)
568+
.ConfigurePrimaryHttpMessageHandler(() => _mockHttp);
569+
570+
var services = _services.BuildServiceProvider();
571+
572+
// First request with first manager instance
573+
var firstManager = services.GetRequiredService<IClientCredentialsTokenManager>();
574+
var firstToken = await firstManager.GetAccessTokenAsync(ClientCredentialsClientName.Parse("test")).GetToken();
575+
_mockHttp.VerifyNoOutstandingExpectation();
576+
577+
firstToken.Expiration.ShouldBe(The.CurrentDateTime.Add(TimeSpan.FromSeconds(tokenExpiry)));
578+
579+
// Get the cache expiration used for the first request
580+
var firstRequestExpiration = fakeCache.LastOptions?.Expiration;
581+
// The first request doesn't know the token lifetime yet, so it should use DefaultCacheLifetime (30 seconds)
582+
firstRequestExpiration.ShouldBe(TimeSpan.FromSeconds(30));
583+
584+
_mockHttp.Expect("/connect/token")
585+
.Respond(_ => Some.TokenHttpResponse(Some.Token() with
586+
{
587+
expires_in = tokenExpiry
588+
}));
589+
590+
var secondManager = services.GetRequiredService<IClientCredentialsTokenManager>();
591+
var secondToken = await secondManager.GetAccessTokenAsync(ClientCredentialsClientName.Parse("test")).GetToken();
592+
_mockHttp.VerifyNoOutstandingExpectation();
593+
594+
secondToken.Expiration.ShouldBe(The.CurrentDateTime.Add(TimeSpan.FromSeconds(tokenExpiry)));
595+
596+
// Get the cache expiration used for the second request
597+
var secondRequestExpiration = fakeCache.LastOptions?.Expiration;
598+
599+
// Expect the lifetime to be auto-tuned based on the first token's lifetime minus the buffer
600+
var expectedExpiration = TimeSpan.FromSeconds(tokenExpiry) - TimeSpan.FromSeconds(60);
601+
secondRequestExpiration.ShouldBe(expectedExpiration,
602+
"Second request should use the auto-tuned cache duration learned from the first request");
603+
}
543604
}

access-token-management/test/AccessTokenManagement.Tests/Framework/FakeHybridCache.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,27 @@ public class FakeHybridCache : HybridCache
1313

1414
public Action OnGetOrCreate = () => { };
1515

16-
public override async ValueTask<T> GetOrCreateAsync<TState, T>(string key, TState state, Func<TState, CancellationToken, ValueTask<T>> factory, HybridCacheEntryOptions? options = null,
16+
public HybridCacheEntryOptions? LastOptions { get; private set; }
17+
18+
public override async ValueTask<T> GetOrCreateAsync<TState, T>(string key, TState state,
19+
Func<TState, CancellationToken, ValueTask<T>> factory, HybridCacheEntryOptions? options = null,
1720
IEnumerable<string>? tags = null, CancellationToken cancellationToken = new())
1821
{
1922
CacheKey = key;
23+
LastOptions = options;
2024
Interlocked.Increment(ref GetOrCreateCount);
2125
OnGetOrCreate();
2226
return await factory(state, cancellationToken);
2327
}
2428

25-
public override ValueTask SetAsync<T>(string key, T value, HybridCacheEntryOptions? options = null, IEnumerable<string>? tags = null,
29+
public override ValueTask SetAsync<T>(string key, T value, HybridCacheEntryOptions? options = null,
30+
IEnumerable<string>? tags = null,
2631
CancellationToken cancellationToken = new()) =>
2732
throw new NotImplementedException();
2833

29-
public override ValueTask RemoveAsync(string key, CancellationToken cancellationToken = new()) => throw new NotImplementedException();
34+
public override ValueTask RemoveAsync(string key, CancellationToken cancellationToken = new()) =>
35+
throw new NotImplementedException();
3036

31-
public override ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = new()) => throw new NotImplementedException();
37+
public override ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = new()) =>
38+
throw new NotImplementedException();
3239
}

0 commit comments

Comments
 (0)