-
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathHttpResponseCache.cs
More file actions
160 lines (144 loc) · 5.34 KB
/
Copy pathHttpResponseCache.cs
File metadata and controls
160 lines (144 loc) · 5.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
using System.Collections.Concurrent;
namespace NpgsqlRest.HttpClientType;
/// <summary>
/// Immutable snapshot of an outbound HTTP type response, suitable for caching and reuse.
/// </summary>
public sealed record CachedHttpResponse(
int StatusCode,
string? Body,
string? ResponseHeaders,
string? ContentType,
bool IsSuccess,
string? ErrorMessage);
/// <summary>
/// In-memory cache of HTTP type responses with stampede protection. A burst of concurrent requests
/// for the same key coalesce into a single outbound call; the rest await the in-flight result.
/// Only successful responses are stored, so a transient upstream failure is never pinned for the TTL.
/// </summary>
public static class HttpResponseCache
{
private sealed class CacheEntry
{
public required CachedHttpResponse Value { get; init; }
public DateTime? ExpirationTime { get; init; }
public bool IsExpired => ExpirationTime.HasValue && DateTime.UtcNow > ExpirationTime.Value;
}
private static readonly ConcurrentDictionary<string, CacheEntry> _cache = new(StringComparer.Ordinal);
// In-flight factory invocations keyed by cache key. The value is a Lazy so that even if
// ConcurrentDictionary.GetOrAdd runs its value-factory more than once under contention, only the
// stored Lazy's task is ever started — guaranteeing a single outbound call per key.
private static readonly ConcurrentDictionary<string, Lazy<Task<CachedHttpResponse>>> _inflight = new(StringComparer.Ordinal);
private static Timer? _cleanupTimer;
private static int _maxEntries = 10_000;
public static void Start(NpgsqlRestOptions options)
{
_maxEntries = options.HttpClientOptions.MaxCacheEntries;
var interval = TimeSpan.FromSeconds(options.HttpClientOptions.CachePruneIntervalSeconds);
_cleanupTimer?.Dispose();
_cleanupTimer = new Timer(_ => CleanupExpiredEntries(), null, interval, interval);
}
public static void Shutdown()
{
_cleanupTimer?.Dispose();
_cleanupTimer = null;
_cache.Clear();
_inflight.Clear();
}
private static void CleanupExpiredEntries()
{
foreach (var kvp in _cache)
{
if (kvp.Value.IsExpired)
{
_cache.TryRemove(kvp.Key, out _);
}
}
}
private static bool TryGet(string key, out CachedHttpResponse value)
{
if (_cache.TryGetValue(key, out var entry))
{
if (entry.IsExpired)
{
_cache.TryRemove(key, out _);
value = null!;
return false;
}
value = entry.Value;
return true;
}
value = null!;
return false;
}
private static void Store(string key, CachedHttpResponse value, TimeSpan? ttl)
{
// Bound memory: once full, don't admit new keys (existing entries still serve and expire).
// Updates to an already-cached key are always allowed.
if (_cache.Count >= _maxEntries && !_cache.ContainsKey(key))
{
return;
}
_cache[key] = new CacheEntry
{
Value = value,
ExpirationTime = ttl.HasValue ? DateTime.UtcNow + ttl.Value : null
};
}
/// <summary>
/// Returns the cached response for <paramref name="key"/> if present and unexpired; otherwise runs
/// <paramref name="factory"/> (the outbound call), stores the result when it is successful, and
/// returns it. Concurrent callers for the same key coalesce into one factory invocation.
/// </summary>
public static async Task<CachedHttpResponse> GetOrCreateAsync(
string key,
TimeSpan? ttl,
Func<CancellationToken, Task<CachedHttpResponse>> factory,
CancellationToken cancellationToken)
{
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
if (TryGet(key, out var cached))
{
return cached;
}
var lazy = _inflight.GetOrAdd(
key,
_ => new Lazy<Task<CachedHttpResponse>>(() => RunFactoryAsync(key, ttl, factory, cancellationToken)));
try
{
return await lazy.Value.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
if (cancellationToken.IsCancellationRequested)
{
throw;
}
// The lead caller (whose token drove the shared run) was cancelled before this waiter.
// Loop and retry — the value may now be cached, or this caller becomes the new lead.
}
}
}
private static async Task<CachedHttpResponse> RunFactoryAsync(
string key,
TimeSpan? ttl,
Func<CancellationToken, Task<CachedHttpResponse>> factory,
CancellationToken cancellationToken)
{
try
{
var result = await factory(cancellationToken).ConfigureAwait(false);
// Success-only: never pin a transient upstream failure for the whole TTL.
if (result.IsSuccess)
{
Store(key, result, ttl);
}
return result;
}
finally
{
_inflight.TryRemove(key, out _);
}
}
}