Skip to content

Commit e76d2b3

Browse files
hybrid cache with GetOrDefault
1 parent e6f0183 commit e76d2b3

File tree

6 files changed

+89
-86
lines changed

6 files changed

+89
-86
lines changed

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

Lines changed: 35 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public static class ServiceProviderKeys
2020
public class DistributedClientCredentialsTokenCache(
2121
[FromKeyedServices(ServiceProviderKeys.DistributedClientCredentialsTokenCache)]HybridCache cache,
2222
TimeProvider time,
23+
ITokenRequestSynchronization synchronization,
2324
IOptions<ClientCredentialsTokenManagementOptions> options,
2425
ILogger<DistributedClientCredentialsTokenCache> logger
2526
)
@@ -38,9 +39,10 @@ public async Task SetAsync(
3839

3940
try
4041
{
41-
var entryOptions = GetHybridCacheEntryOptions(clientName, clientCredentialsToken);
42+
var entryOptions = GetHybridCacheEntryOptions(clientCredentialsToken);
4243

4344
var cacheKey = GenerateCacheKey(_options, clientName, requestParameters);
45+
logger.LogTrace("Caching access token for client: {clientName}. Expiration: {expiration}", clientName, entryOptions.Expiration);
4446
await cache.SetAsync(cacheKey, clientCredentialsToken, entryOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
4547
}
4648
catch (Exception e)
@@ -51,17 +53,14 @@ public async Task SetAsync(
5153
}
5254
}
5355

54-
private HybridCacheEntryOptions GetHybridCacheEntryOptions(string clientName,
55-
ClientCredentialsToken clientCredentialsToken)
56+
private HybridCacheEntryOptions GetHybridCacheEntryOptions(ClientCredentialsToken clientCredentialsToken)
5657
{
5758
var absoluteCacheExpiration = clientCredentialsToken.Expiration.AddSeconds(-_options.CacheLifetimeBuffer);
5859
var relativeCacheExpiration = absoluteCacheExpiration - time.GetUtcNow();
5960
var entryOptions = new HybridCacheEntryOptions()
6061
{
6162
Expiration = relativeCacheExpiration
6263
};
63-
64-
logger.LogTrace("Caching access token for client: {clientName}. Expiration: {expiration}", clientName, absoluteCacheExpiration);
6564
return entryOptions;
6665
}
6766

@@ -78,52 +77,52 @@ public async Task<ClientCredentialsToken> GetOrCreateAsync(
7877

7978
var cacheKey = GenerateCacheKey(_options, clientName, requestParameters);
8079

81-
ClientCredentialsToken token;
80+
ClientCredentialsToken? token;
8281
if (!requestParameters.ForceRenewal)
8382
{
84-
try
85-
{
86-
// We don't need the token to be absolutely fresh, so we can get one from the cache.
87-
// The GetOrCreate pattern unfortunately doesn't allow us to create a cache entry
88-
// that's only valid for as long as it needs to be.
89-
token = await cache.GetOrCreateAsync(
90-
key: cacheKey,
91-
factory: async (ct) =>
92-
{
93-
var result = await factory(clientName, requestParameters, ct).ConfigureAwait(false);
94-
if (result.IsError)
95-
{
96-
// If the token is an error, we throw an exception to prevent the value from being cached
97-
throw new TokenErrorException(result);
98-
}
99-
100-
return result;
101-
},
102-
cancellationToken: cancellationToken);
103-
}
104-
catch (TokenErrorException ex)
105-
{
106-
return ex.Token;
107-
}
83+
// We don't need the token to be absolutely fresh, so we can get one from the cache.
84+
token = await cache.GetOrDefaultAsync<ClientCredentialsToken>(
85+
key: cacheKey,
86+
cancellationToken: cancellationToken);
10887

109-
if (token.Expiration != DateTimeOffset.MinValue)
88+
if (token?.Expiration > DateTimeOffset.MinValue)
11089
{
11190
var absoluteCacheExpiration = token.Expiration.AddSeconds(-_options.CacheLifetimeBuffer);
11291

11392
if (absoluteCacheExpiration > time.GetUtcNow())
11493
{
94+
// It's possible that we have only read the token from L2 cache, not L1 cache.
95+
// just to be sure, write the token also into L1 cache (which should be fast)
96+
// https://github.com/dotnet/extensions/issues/5688#issuecomment-2692247434
97+
var defaultWriteOptions = GetHybridCacheEntryOptions(token);
98+
await cache.SetAsync(cacheKey, token, new HybridCacheEntryOptions()
99+
{
100+
Flags = HybridCacheEntryFlags.DisableDistributedCacheWrite,
101+
Expiration = defaultWriteOptions.Expiration,
102+
LocalCacheExpiration = defaultWriteOptions.LocalCacheExpiration
103+
}, cancellationToken: cancellationToken);
104+
115105
return token;
116106
}
117107
}
118108
}
119-
token = await factory(clientName, requestParameters, cancellationToken).ConfigureAwait(false);
120109

121-
if (!token.IsError)
110+
// Apparently, there's either no value in the cache, or we want a fresh one.
111+
// Since we aren't using GetOrCreate, we'll have to protect against cache stampedes ourselves.
112+
return await synchronization.SynchronizeAsync(cacheKey, async () =>
122113
{
123-
await SetAsync(clientName, token, requestParameters, cancellationToken);
124-
}
114+
token = await factory(clientName, requestParameters, cancellationToken).ConfigureAwait(false);
115+
116+
// Don't cache the token if there's an error.
117+
if (!token.IsError)
118+
{
119+
await SetAsync(clientName, token, requestParameters, cancellationToken);
120+
}
121+
122+
return token;
123+
});
125124

126-
return token;
125+
127126
}
128127

129128

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Microsoft.Extensions.Caching.Hybrid;
55
using Microsoft.Extensions.DependencyInjection;
66
using Microsoft.Extensions.Logging;
7+
using Microsoft.IdentityModel.Tokens;
78

89
namespace Duende.AccessTokenManagement;
910

@@ -18,6 +19,11 @@ public class DistributedDPoPNonceStore : IDPoPNonceStore
1819
private readonly HybridCache _cache;
1920
private readonly ILogger<DistributedDPoPNonceStore> _logger;
2021

22+
private static readonly HybridCacheEntryOptions NonceStoreCacheOptions = new HybridCacheEntryOptions()
23+
{
24+
LocalCacheExpiration = TimeSpan.FromHours(1)
25+
};
26+
2127
/// <summary>
2228
/// ctor
2329
/// </summary>
@@ -56,16 +62,11 @@ public virtual async Task StoreNonceAsync(DPoPNonceContext context, string nonce
5662

5763
var data = nonce;
5864

59-
var cacheExpiration = TimeSpan.FromHours(1);
60-
var entryOptions = new HybridCacheEntryOptions()
61-
{
62-
Expiration = cacheExpiration
63-
};
6465

65-
_logger.LogTrace("Caching DPoP nonce for URL: {url}, method: {method}. Expiration: {expiration}", context.Url, context.Method, cacheExpiration);
66+
_logger.LogTrace("Caching DPoP nonce for URL: {url}, method: {method}. Expiration: {expiration}", context.Url, context.Method, NonceStoreCacheOptions.Expiration);
6667

6768
var cacheKey = GenerateCacheKey(context);
68-
await _cache.SetAsync(cacheKey, data, entryOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
69+
await _cache.SetAsync(cacheKey, data, NonceStoreCacheOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
6970
}
7071

7172

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,11 @@ public static class HybridCacheExtMethods
1717
| HybridCacheEntryFlags.DisableUnderlyingData
1818
};
1919

20-
public static async ValueTask<T?> GetOrDefaultAsync<T>(this HybridCache cache, string key)
20+
public static async ValueTask<T?> GetOrDefaultAsync<T>(this HybridCache cache, string key, CancellationToken cancellationToken = default)
2121
{
2222
return await cache.GetOrCreateAsync<T?>(
2323
key,
2424
null!, // Don't return a value if it's not in the cache. Also, don't write it to the cache
25-
GetOnlyEntryOptions
26-
);
25+
GetOnlyEntryOptions, cancellationToken: cancellationToken);
2726
}
2827
}

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

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,50 +5,10 @@
55
using System.Net;
66
using System.Net.Http.Json;
77
using Duende.IdentityModel.Client;
8-
using Microsoft.Extensions.Caching.Hybrid;
98
using Microsoft.Extensions.DependencyInjection;
109
using RichardSzalay.MockHttp;
1110
namespace Duende.AccessTokenManagement.Tests;
1211

13-
public class HybridCacheTests
14-
{
15-
[Fact]
16-
public async Task Returning_null()
17-
{
18-
var services = new ServiceCollection()
19-
.AddHybridCache()
20-
.Services;
21-
22-
var cache = services.BuildServiceProvider()
23-
.GetService<HybridCache>();
24-
25-
int count = 0;
26-
object item;
27-
28-
try
29-
{
30-
item = await cache.GetOrCreateAsync<object>("key", (_) =>
31-
{
32-
count++;
33-
throw new InvalidOperationException();
34-
return ValueTask.FromResult<object>(null);
35-
});
36-
}
37-
catch (InvalidOperationException)
38-
{
39-
40-
}
41-
item = await cache.GetOrCreateAsync<object>("key", (_) =>
42-
{
43-
count++;
44-
return ValueTask.FromResult<object>(null);
45-
});
46-
47-
item.ShouldBeNull();
48-
count.ShouldBe(2);
49-
}
50-
}
51-
5212
public class BackChannelClientTests(ITestOutputHelper output)
5313
{
5414
[Fact]
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using Microsoft.Extensions.Caching.Hybrid;
2+
using Microsoft.Extensions.DependencyInjection;
3+
4+
namespace Duende.AccessTokenManagement.Tests;
5+
6+
public class HybridCacheExplorationTests
7+
{
8+
[Fact]
9+
public async Task Exception_is_not_written_to_cache()
10+
{
11+
var services = new ServiceCollection()
12+
.AddHybridCache()
13+
.Services;
14+
15+
var cache = services.BuildServiceProvider()
16+
.GetRequiredService<HybridCache>();
17+
18+
int count = 0;
19+
object item;
20+
21+
try
22+
{
23+
item = await cache.GetOrCreateAsync<object>("key", (_) =>
24+
{
25+
count++;
26+
throw new InvalidOperationException();
27+
});
28+
}
29+
catch (InvalidOperationException)
30+
{
31+
32+
}
33+
item = await cache.GetOrCreateAsync<object>("key", (_) =>
34+
{
35+
count++;
36+
return ValueTask.FromResult<object>(null);
37+
});
38+
39+
item.ShouldBeNull();
40+
count.ShouldBe(2);
41+
}
42+
}

identity-model-oidc-client/test/IdentityModel.OidcClient.Tests/DPoP/Framework/DPoP/DPoPServiceCollectionExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public static IServiceCollection ConfigureDPoPTokensForScheme(this IServiceColle
1515

1616
services.AddTransient<DPoPJwtBearerEvents>();
1717
services.AddTransient<DPoPProofValidator>();
18+
services.AddDistributedMemoryCache();
19+
1820
services.AddTransient<IReplayCache, DefaultReplayCache>();
1921

2022
services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>>(new ConfigureJwtBearerOptions(scheme));

0 commit comments

Comments
 (0)