Skip to content

Commit 5029893

Browse files
Added cache diagnostics. (#8254)
Co-authored-by: PascalSenn <senn.pasc@gmail.com>
1 parent 2aba476 commit 5029893

File tree

4 files changed

+146
-42
lines changed

4 files changed

+146
-42
lines changed

src/HotChocolate/Language/test/Language.Tests/Parser/GraphQLRequestParserTests.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -796,10 +796,7 @@ private sealed class DocumentCache : IDocumentCache
796796

797797
public void TryAddDocument(string documentId, CachedDocument document)
798798
{
799-
if (!_cache.ContainsKey(documentId))
800-
{
801-
_cache.Add(documentId, document);
802-
}
799+
_cache.TryAdd(documentId, document);
803800
}
804801

805802
public bool TryGetDocument(

src/HotChocolate/Utilities/src/Utilities/Cache.cs

Lines changed: 95 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Collections.Concurrent;
2+
using System.Diagnostics.Metrics;
23

34
namespace HotChocolate.Utilities;
45

@@ -14,6 +15,7 @@ public sealed class Cache<TValue>
1415
private readonly int _capacity;
1516
private readonly CacheEntry?[] _ring;
1617
private readonly ConcurrentDictionary<string, CacheEntry> _map;
18+
private readonly CacheDiagnostics _diagnostics;
1719

1820
// The clock hand is incremented atomically and is used to
1921
// determine which cache entry to try to set a new entry into.
@@ -26,18 +28,25 @@ public sealed class Cache<TValue>
2628
/// <param name="capacity">
2729
/// The maximum number of items that can be stored in this cache.
2830
/// </param>
31+
/// <param name="diagnostics">
32+
/// The diagnostics for the cache.
33+
/// </param>
2934
/// <exception cref="ArgumentOutOfRangeException">
3035
/// Thrown when the capacity is less than 10.
3136
/// </exception>
32-
public Cache(int capacity = 256)
37+
public Cache(int capacity = 256, CacheDiagnostics? diagnostics = null)
3338
{
3439
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 10);
40+
3541
_capacity = capacity;
3642
_ring = new CacheEntry[capacity];
3743
_map = new ConcurrentDictionary<string, CacheEntry>(
3844
concurrencyLevel: Environment.ProcessorCount,
3945
capacity: _capacity,
4046
comparer: StringComparer.Ordinal);
47+
_diagnostics = diagnostics ?? NoOpCacheDiagnostics.Instance;
48+
_diagnostics.RegisterCapacityGauge(() => _capacity);
49+
_diagnostics.RegisterSizeGauge(() => _map.Count);
4150
}
4251

4352
/// <summary>
@@ -67,13 +76,17 @@ public bool TryGet(string key, out TValue? value)
6776
{
6877
if (_map.TryGetValue(key, out var entry))
6978
{
70-
// we mark our entry as used by setting Accessed to 1
79+
// We mark our entry as used by setting Accessed to 1
7180
// this means the entry will be safe from the next eviction.
81+
// Note: Volatile.Write is faster than Interlocked.Exchange, and we accept the
82+
// tiny risk that an in‑flight eviction may still remove this entry.
7283
Volatile.Write(ref entry.Accessed, 1);
84+
_diagnostics.Hit();
7385
value = entry.Value;
7486
return true;
7587
}
7688

89+
_diagnostics.Miss();
7790
value = default;
7891
return false;
7992
}
@@ -116,6 +129,22 @@ public TValue GetOrCreate<TState>(string key, Func<string, TState, TValue> creat
116129
ArgumentNullException.ThrowIfNull(key);
117130
ArgumentNullException.ThrowIfNull(create);
118131

132+
// We first check if the entry is already in the map.
133+
// This is a fast lookup and will be used most of the time.
134+
if (_map.TryGetValue(key, out var entry))
135+
{
136+
// We mark our entry as used by setting Accessed to 1
137+
// this means the entry will be safe from the next eviction.
138+
// Note: Volatile.Write is faster than Interlocked.Exchange, and we accept the
139+
// tiny risk that an in‑flight eviction may still remove this entry.
140+
Volatile.Write(ref entry.Accessed, 1);
141+
_diagnostics.Hit();
142+
return entry.Value;
143+
}
144+
145+
// If we have miss we do a GetOrAdd on the map to get at the end
146+
// the winner in case of contention.
147+
//
119148
// The GetOrAdd of the ConcurrentDictionary is not atomic.
120149
// It is possible that two threads will try to create the same entry
121150
// at the same time.
@@ -130,17 +159,22 @@ public TValue GetOrCreate<TState>(string key, Func<string, TState, TValue> creat
130159
// the overhead of a lock on the dictionary itself.
131160
// The ConcurrentDictionary is vert efficient and does not
132161
// lock the whole dictionary when adding an entry.
133-
var entry = _map.GetOrAdd(
162+
var args = new CacheEntryCreateArgs<TState>(state, create, this);
163+
164+
entry = _map.GetOrAdd(
134165
key,
135166
static (k, arg) =>
136167
{
137-
var value = arg.create(k, arg.state);
138-
return arg.cache.InsertNew(k, value);
168+
arg.Diagnostics.Miss();
169+
var value = arg.Create(k, arg.State);
170+
return arg.Cache.InsertNew(k, value);
139171
},
140-
(state, create, cache: this));
172+
args);
141173

142-
// in the case we did not add a new entry but instead retrieved it
174+
// In the case we did not add a new entry but instead retrieved it
143175
// from the ConcurrentDictionary we need to mark it as recently accessed
176+
// Note: Volatile.Write is faster than Interlocked.Exchange, and we accept the
177+
// tiny risk that an in‑flight eviction may still remove this entry.
144178
Volatile.Write(ref entry.Accessed, 1);
145179
return entry.Value;
146180
}
@@ -157,6 +191,17 @@ private CacheEntry InsertNew(string key, TValue value)
157191
var idx = (int)(handle % (uint)_capacity);
158192
var entry = _ring[idx];
159193

194+
if (++spins > maxSpins && entry is not null)
195+
{
196+
var prev = Interlocked.CompareExchange(ref _ring[idx], newEntry, entry);
197+
if (ReferenceEquals(prev, entry))
198+
{
199+
_map.TryRemove(prev.Key, out _);
200+
_diagnostics.Evict();
201+
return newEntry;
202+
}
203+
}
204+
160205
if (entry is null)
161206
{
162207
// if the current cache slot is empty, we will try to insert
@@ -165,57 +210,35 @@ private CacheEntry InsertNew(string key, TValue value)
165210
{
166211
return newEntry;
167212
}
168-
169-
continue;
170213
}
171-
172-
if (Interlocked.CompareExchange(ref entry.Accessed, 0, 1) == 0)
214+
else if (Interlocked.CompareExchange(ref entry.Accessed, 0, 1) == 0)
173215
{
174216
// If we found a slot that was not recently retrieved, we will try to
175217
// replace it with our new entry. This will only succeed if no other thread
176218
// was able to replace the entry in the meantime. This operation is atomic.
177219
var prev = Interlocked.CompareExchange(ref _ring[idx], newEntry, entry);
178220

179221
// If we were successful in replacing the entry, we will
180-
// then prev is the old entry and we need to remove it from the map.
222+
// then prev is the old entry, and we need to remove it from the map.
181223
// It might be that the old entry was retrieved in the meantime, and we
182224
// accept this small window in which the map might have a dangling reference.
183225
if (ReferenceEquals(prev, entry))
184226
{
185227
_map.TryRemove(prev.Key, out _);
228+
_diagnostics.Evict();
186229
return newEntry;
187230
}
188231
}
189-
190-
if (++spins > maxSpins)
191-
{
192-
entry = _ring[idx]!; // re‑read reference
193-
194-
var oldKey = entry.Key;
195-
196-
// atomic swap
197-
Interlocked.Exchange(ref _ring[idx], newEntry);
198-
199-
_map.TryRemove(oldKey, out _);
200-
return newEntry;
201-
}
202232
}
203233
}
204234

205235
/// <summary>
206236
/// Clears all entries from the cache.
237+
/// The clear might leave the cache in a dirty state.
238+
/// This is acceptable as we are not using a clear in production.
239+
/// It's more a helper for testing.
207240
/// </summary>
208-
public void Clear()
209-
{
210-
_map.Clear();
211-
212-
for (var i = 0; i < _ring.Length; i++)
213-
{
214-
_ring[i] = null;
215-
}
216-
217-
Interlocked.Exchange(ref _hand, 0);
218-
}
241+
public void Clear() => _map.Clear();
219242

220243
/// <summary>
221244
/// Returns all keys in the cache. This method is for testing only.
@@ -234,12 +257,46 @@ internal IEnumerable<string> GetKeys()
234257

235258
private sealed class CacheEntry(string key, TValue value)
236259
{
260+
/// <summary>
261+
/// The key of the entry.
262+
/// </summary>
237263
public readonly string Key = key;
238264

265+
/// <summary>
266+
/// The value of the entry.
267+
/// </summary>
239268
public readonly TValue Value = value;
240269

241-
// 0 = not accessed recently
242-
// 1 = accessed recently
270+
/// <summary>
271+
/// 0 = not accessed recently
272+
/// 1 = accessed recently
273+
/// </summary>
243274
public int Accessed = 1;
244275
}
276+
277+
private readonly struct CacheEntryCreateArgs<TState>(
278+
TState state,
279+
Func<string, TState, TValue> create,
280+
Cache<TValue> cache)
281+
{
282+
/// <summary>
283+
/// The state that is needed to create the value to cache.
284+
/// </summary>
285+
public readonly TState State = state;
286+
287+
/// <summary>
288+
/// The factory to create the value to cache.
289+
/// </summary>
290+
public readonly Func<string, TState, TValue> Create = create;
291+
292+
/// <summary>
293+
/// The cache instance.
294+
/// </summary>
295+
public readonly Cache<TValue> Cache = cache;
296+
297+
/// <summary>
298+
/// The diagnostics for the cache.
299+
/// </summary>
300+
public readonly CacheDiagnostics Diagnostics = cache._diagnostics;
301+
}
245302
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace HotChocolate.Utilities;
2+
3+
/// <summary>
4+
/// The diagnostics for the cache.
5+
/// </summary>
6+
public abstract class CacheDiagnostics
7+
{
8+
/// <summary>
9+
/// Registers a size gauge for the cache.
10+
/// </summary>
11+
/// <param name="sizeProvider">
12+
/// The function that provides the size of the cache.
13+
/// </param>
14+
public abstract void RegisterSizeGauge(Func<long> sizeProvider);
15+
16+
/// <summary>
17+
/// Registers a capacity gauge for the cache.
18+
/// </summary>
19+
/// <param name="sizeProvider">
20+
/// The function that provides the capacity of the cache.
21+
/// </param>
22+
public abstract void RegisterCapacityGauge(Func<long> sizeProvider);
23+
24+
/// <summary>
25+
/// Increments the hit counter.
26+
/// </summary>
27+
public abstract void Hit();
28+
29+
/// <summary>
30+
/// Increments the miss counter.
31+
/// </summary>
32+
public abstract void Miss();
33+
34+
/// <summary>
35+
/// Increments the eviction counter.
36+
/// </summary>
37+
public abstract void Evict();
38+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace HotChocolate.Utilities;
2+
3+
internal sealed class NoOpCacheDiagnostics : CacheDiagnostics
4+
{
5+
public override void RegisterSizeGauge(Func<long> sizeProvider) { }
6+
public override void RegisterCapacityGauge(Func<long> sizeProvider) { }
7+
public override void Hit() { }
8+
public override void Miss() { }
9+
public override void Evict() { }
10+
11+
public static NoOpCacheDiagnostics Instance { get; } = new();
12+
}

0 commit comments

Comments
 (0)