• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Aldaviva / Unfucked / 25357764442

05 May 2026 04:04AM UTC coverage: 47.621% (-0.06%) from 47.68%
25357764442

push

github

Aldaviva
Caching: allow loader to throw any exceptions. Added documentation comments.

676 of 1799 branches covered (37.58%)

5 of 6 new or added lines in 2 files covered. (83.33%)

2 existing lines in 1 file now uncovered.

1191 of 2501 relevant lines covered (47.62%)

187.07 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.64
/Caching/InMemoryCache.cs
1
using System.Collections.Concurrent;
2
using System.Timers;
3
using Timer = System.Timers.Timer;
4

5
namespace Unfucked.Caching;
6

7
/// <summary>Strongly-typed in-memory cache that can encapsulate automatic value loading logic into the cache itself, instead of duplicating it across every single call site. Supports expiration after read or write, and periodic refresh for expired values.</summary>
8
/// <remarks>Inspired by Guava Cache.</remarks>
9
/// <typeparam name="K">Type of the cache key.</typeparam>
10
/// <typeparam name="V">Type of the cached values.</typeparam>
11
public sealed class InMemoryCache<K, V>: Cache<K, V> where K: notnull {
12

13
    /// <inheritdoc />
14
    public event RemovalNotification<K, V>? Removal;
15

16
    private readonly CacheOptions                           options;
17
    private readonly Func<K, ValueTask<V>>?                 defaultLoader;
18
    private readonly ConcurrentDictionary<K, CacheEntry<V>> cache;
19
    private readonly Timer?                                 expirationTimer;
20

21
    private volatile bool isDisposed;
22

23
    /// <summary>Create a new in-memory cache for specific key and value types, with the given optional <paramref name="options"/> and value <paramref name="loader"/>.</summary>
24
    /// <param name="options">Customize the caching behavior, including expiration durations and automatic refreshes.</param>
25
    /// <param name="loader">Cache-wide callback used to generate a cached value when a key is requested that doesn't already have an unexpired value cached. Can be <c>null</c> if values are always manually cached with <see cref="Put"/>, and can be overridden on a per-read basis by supplying a <c>loader</c> callback to <see cref="Get"/>. Also used when automatically refreshing values. Can throw exceptions, which will not be cached.</param>
26
    public InMemoryCache(CacheOptions? options = null, Func<K, ValueTask<V>>? loader = null) {
15✔
27
        this.options = (options ?? new CacheOptions()) with {
15!
28
            ConcurrencyLevel = this.options.ConcurrencyLevel is > 0 and var c ? c : Environment.ProcessorCount,
15✔
29
            InitialCapacity = this.options.InitialCapacity is > 0 and var i ? i : 31
15✔
30
        };
15✔
31

32
        defaultLoader = loader;
15✔
33
        cache         = new ConcurrentDictionary<K, CacheEntry<V>>(this.options.ConcurrencyLevel, this.options.InitialCapacity);
15✔
34

35
        if (this.options.ExpireAfterWrite > TimeSpan.Zero || this.options.ExpireAfterRead > TimeSpan.Zero) {
15✔
36
            TimeSpan expirationScanInterval = this.options.ExpirationScanInterval is { Ticks: > 0 } interval ? interval : TimeSpan.FromMinutes(1);
5✔
37
            expirationTimer         =  new Timer(expirationScanInterval.TotalMilliseconds) { AutoReset = false };
5✔
38
            expirationTimer.Elapsed += ScanForExpirations;
5✔
39
            expirationTimer.Start();
5✔
40
        }
41
    }
15✔
42

43
    /// <inheritdoc />
44
    public long Count => cache.Count;
14✔
45

46
    /// <inheritdoc />
47
    public async Task<V> Get(K key, Func<K, ValueTask<V>>? loader = null) {
48
        CacheEntry<V> cacheEntry = cache.GetOrAdd(key, ValueFactory);
18✔
49
        if (cacheEntry.IsNew) {
18✔
50
            await cacheEntry.ValueLock.WaitAsync().ConfigureAwait(false);
12✔
51
            try {
52
                if (cacheEntry.IsNew) {
12✔
53
                    cacheEntry.Value = await LoadValue(key, loader ?? defaultLoader).ConfigureAwait(false);
12✔
54
                    cacheEntry.LastWritten.Start();
5✔
55
                    cacheEntry.RefreshTimer?.Start();
5✔
56
                    cacheEntry.IsNew = false;
5✔
57
                }
58
            } catch (Exception e) when (e is not OutOfMemoryException) {
12✔
59
                cache.TryRemove(key, out _);
7✔
60
                cacheEntry.Dispose();
7✔
61
                throw;
7✔
62
            } finally {
63
                if (!cacheEntry.IsDisposed) {
12✔
64
                    cacheEntry.ValueLock.Release();
5✔
65
                }
66
            }
67
        } else if (IsExpired(cacheEntry)) {
6✔
68
            V? oldValue = default;
2✔
69
            await cacheEntry.ValueLock.WaitAsync().ConfigureAwait(false);
2✔
70
            try {
71
                if (IsExpired(cacheEntry)) {
2✔
72
                    cacheEntry.RefreshTimer?.Stop();
2!
73
                    try {
74
                        oldValue         = cacheEntry.Value;
2✔
75
                        cacheEntry.Value = await LoadValue(key, loader ?? defaultLoader).ConfigureAwait(false);
2✔
76
                        cacheEntry.LastWritten.Restart();
1✔
77
                    } finally {
1✔
78
                        cacheEntry.RefreshTimer?.Start();
2!
79
                    }
80
                }
81
            } finally {
1✔
82
                cacheEntry.ValueLock.Release();
2✔
83
            }
84

85
            if (oldValue is not null) {
1✔
86
                Removal?.Invoke(this, key, oldValue, RemovalCause.Expired);
1!
87
            }
88
        }
1✔
89

90
        cacheEntry.LastRead.Restart();
10✔
91
        return cacheEntry.Value;
10✔
92
    }
10✔
93

94
    /// <inheritdoc />
95
    public async Task Put(K key, V value) {
96
        CacheEntry<V> cacheEntry = cache.GetOrAdd(key, ValueFactory);
15✔
97
        await cacheEntry.ValueLock.WaitAsync().ConfigureAwait(false);
15✔
98
        V? removedValue = default;
15✔
99
        try {
100
            cacheEntry.RefreshTimer?.Stop();
15!
101
            if (cacheEntry.IsNew) {
15✔
102
                cacheEntry.IsNew = false;
14✔
103
            } else {
104
                removedValue = cacheEntry.Value;
1✔
105
            }
106

107
            cacheEntry.Value = value;
15✔
108
            cacheEntry.LastWritten.Restart();
15✔
109
            cacheEntry.RefreshTimer?.Start();
15!
110
        } finally {
15✔
111
            cacheEntry.ValueLock.Release();
15✔
112
        }
113

114
        if (removedValue is not null) {
15✔
115
            Removal?.Invoke(this, key, removedValue, RemovalCause.Replaced);
15!
116
        }
117
    }
15✔
118

119
    private CacheEntry<V> ValueFactory(K key) {
120
        bool   hasLoader    = defaultLoader != null;
26✔
121
        Timer? refreshTimer = options.RefreshAfterWrite > TimeSpan.Zero && hasLoader ? new Timer(options.RefreshAfterWrite.TotalMilliseconds) { AutoReset = false, Enabled = false } : null;
26✔
122
        var    entry        = new CacheEntry<V>(refreshTimer);
26✔
123

124
        if (entry.RefreshTimer != null) {
26✔
125
            async void refreshEntry(object o, ElapsedEventArgs elapsedEventArgs) {
126
                if (!entry.IsDisposed) {
3!
127
                    try {
128
                        await entry.ValueLock.WaitAsync().ConfigureAwait(false);
3✔
129
                        V oldValue = entry.Value;
3✔
130
                        try {
131
                            entry.Value = await defaultLoader!(key).ConfigureAwait(false);
3✔
132
                            entry.LastWritten.Restart();
3✔
133
                        } catch (Exception e) when (e is not OutOfMemoryException) {
3✔
134
                            // try again next timer execution
UNCOV
135
                        } finally {
×
136
                            entry.RefreshTimer.Start();
3✔
137
                            entry.ValueLock.Release();
3✔
138
                        }
139
                        Removal?.Invoke(this, key, oldValue, RemovalCause.Replaced);
3!
140
                    } catch (ObjectDisposedException) {}
3✔
141
                } else {
142
                    entry.RefreshTimer.Elapsed -= refreshEntry;
×
143
                }
144
            }
3✔
145

146
            entry.RefreshTimer.Elapsed += refreshEntry;
1✔
147
        }
148

149
        return entry;
26✔
150
    }
151

152
    /// <exception cref="System.Collections.Generic.KeyNotFoundException">a value with the key <typeparamref name="K"/> was not found, and no <paramref name="loader"/> was not provided</exception>
153
    private static ValueTask<V> LoadValue(K key, Func<K, ValueTask<V>>? loader) {
154
        if (loader != null) {
14✔
155
            return loader(key);
6✔
156
        } else {
157
            throw KeyNotFoundException(key);
8✔
158
        }
159
    }
160

161
    private static KeyNotFoundException KeyNotFoundException(K key) => new(
8✔
162
        $"Value with key {key} not found in cache, and a loader function was not provided when constructing the {nameof(InMemoryCache<,>)} or getting the value.");
8✔
163

164
    private bool IsExpired(CacheEntry<V> cacheEntry) =>
165
        (options.ExpireAfterWrite > TimeSpan.Zero && options.ExpireAfterWrite <= cacheEntry.LastWritten.Elapsed)
15!
166
        || (options.ExpireAfterRead > TimeSpan.Zero && options.ExpireAfterRead <= cacheEntry.LastRead.Elapsed);
15✔
167

168
    /// <inheritdoc />
169
    public void CleanUp() {
170
        foreach (KeyValuePair<K, CacheEntry<V>> entry in cache.Where(pair => IsExpired(pair.Value))) {
16✔
171
            entry.Value.ValueLock.Wait();
3✔
172
            bool removed = false;
3✔
173
            if (IsExpired(entry.Value)) {
3✔
174
                //this will probably throw a concurrent modification exception
175
                removed = cache.TryRemove(entry.Key, out _);
3✔
176
                if (removed) {
3✔
177
                    entry.Value.Dispose();
3✔
178
                    Removal?.Invoke(this, entry.Key, entry.Value.Value, RemovalCause.Expired);
3✔
179
                }
180
            }
181

182
            if (!removed) {
3!
183
                /*
184
                 * First pass showed entry as expired, but it was concurrently loaded or refreshed before this second pass check, so don't actually remove it.
185
                 * Only release if we didn't remove, because if we removed the entry and its lock were already disposed.
186
                 */
187
                entry.Value.ValueLock.Release();
×
188
            }
189
        }
190
    }
3✔
191

192
    private void ScanForExpirations(object? sender = null, ElapsedEventArgs? e = null) {
193
        CleanUp();
1✔
194
        expirationTimer!.Start();
1✔
UNCOV
195
    }
×
196

197
    /// <inheritdoc />
198
    public void Invalidate(params IEnumerable<K> keys) {
199
        foreach (K key in keys) {
10✔
200
            if (cache.TryRemove(key, out CacheEntry<V>? removedEntry)) {
3✔
201
                Removal?.Invoke(this, key, removedEntry.Value, RemovalCause.Explicit);
3!
202
                removedEntry.Dispose();
3✔
203
            }
204
        }
205
    }
2✔
206

207
    /// <inheritdoc />
208
    public void InvalidateAll() {
209
        KeyValuePair<K, CacheEntry<V>>[] toDispose = cache.ToArray();
16✔
210
        cache.Clear();
16✔
211
        foreach (KeyValuePair<K, CacheEntry<V>> entry in toDispose) {
58✔
212
            if (!isDisposed) {
13✔
213
                Removal?.Invoke(this, entry.Key, entry.Value.Value, RemovalCause.Explicit);
3!
214
            }
215
            entry.Value.Dispose();
13✔
216
        }
217
    }
16✔
218

219
    /// <inheritdoc />
220
    public void Dispose() {
221
        isDisposed = true;
15✔
222
        if (expirationTimer != null) {
15✔
223
            expirationTimer.Elapsed -= ScanForExpirations;
5✔
224
            expirationTimer.Dispose();
5✔
225
        }
226
        InvalidateAll();
15✔
227
    }
15✔
228

229
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc