LibSharp 5.0.0
dotnet add package LibSharp --version 5.0.0
NuGet\Install-Package LibSharp -Version 5.0.0
<PackageReference Include="LibSharp" Version="5.0.0" />
<PackageVersion Include="LibSharp" Version="5.0.0" />
<PackageReference Include="LibSharp" />
paket add LibSharp --version 5.0.0
#r "nuget: LibSharp, 5.0.0"
#:package LibSharp@5.0.0
#addin nuget:?package=LibSharp&version=5.0.0
#tool nuget:?package=LibSharp&version=5.0.0
LibSharp
Introduction
A library of C# core components that enhance the standard library. Supports .NET 8.0, .NET 9.0, .NET 10.0.
The public API ships nullable reference type annotations. The library is trim- and Native AOT-compatible in full: it is built with IsAotCompatible, so the trim and Native AOT analysers run over the whole assembly, and it emits no trimming or AOT warnings to consumers.
- Source code: https://github.com/danylofitel/LibSharp.
- NuGet package: https://www.nuget.org/packages/LibSharp.
Installation
dotnet add package LibSharp
LibSharp consists of the following namespaces:
- Common - contains extension methods for standard .NET types, as well as commonly used utilities and value types.
- Collections - contains extension methods for standard .NET library collections, as well as additional collection types.
- Caching - contains classes that enable in-memory value caching with custom time-to-live. Both synchronous and asynchronous versions are available.
- Threading - contains an async-compatible lock and utilities for controlling action invocation frequency.
Performance Benchmarks
BenchmarkDotNet setup and benchmark scripts are available in https://github.com/danylofitel/LibSharp/blob/main/benchmarks/README.md.
Components and Usage
Common
Common namespace contains:
- The static class
Argumentfor convenient validation of public function arguments. - Extension methods for built-in types such as
string,int,DateTime,Func, andRegex. Optional<T>— a value type that wraps an optional value.Result<T, TError>— a discriminated union value type for success/error outcomes.
using LibSharp.Common;
using System.Net;
using System.Text.RegularExpressions;
public static async Task CommonExamples(string stringParam, long longParam, object objectParam, CancellationToken cancellationToken)
{
// Argument validation — the parameter name is captured automatically (CallerArgumentExpression);
// pass it explicitly only when you want a different name.
Argument.EqualTo(stringParam, "Hello world");
Argument.NotEqualTo(stringParam, "Hello");
Argument.GreaterThan(longParam, -1L);
Argument.GreaterThanOrEqualTo(longParam, 0L);
Argument.LessThan(longParam, 100L);
Argument.LessThanOrEqualTo(longParam, 99L);
Argument.NotNull(stringParam);
Argument.NotNullOrEmpty(stringParam);
Argument.NotNullOrWhiteSpace(stringParam);
Argument.OfType(objectParam, typeof(List<string>));
// Optional<T> — wraps a value that may or may not be present
Optional<int> empty = Optional<int>.Empty; // same as default(Optional<int>)
bool hasValue = empty.HasValue; // false
int fallback = empty.GetValueOrDefault(-1); // -1
Optional<int> present = new Optional<int>(42);
hasValue = present.HasValue; // true
int optValue = present.Value; // 42
bool got = present.TryGetValue(out int v); // true, v == 42
Optional<int> implicitlyWrapped = 7; // implicit conversion from T
string label = present.Match(x => $"has {x}", () => "none");// project both cases -> "has 42"
Optional<string> mapped = present.Map(x => x.ToString()); // Optional<string> "42"
Optional<int> bound = present.Bind( // chain another Optional
x => x > 0 ? new Optional<int>(x * 2) : default); // Optional<int> 84
// Result<T, TError> — discriminated union for success/error outcomes
Result<int, string> success = Result<int, string>.Ok(42);
bool isSuccess = success.IsSuccess; // true
int successValue = success.Value; // 42
Result<int, string> failure = Result<int, string>.Fail("not found");
bool isError = failure.IsError; // true
string errorMessage = failure.Error; // "not found"
int valueOrDefault = failure.GetValueOrDefault(-1); // -1
string outcome = success.Match(x => $"ok: {x}", e => $"error: {e}"); // "ok: 42"
Result<string, string> okMapped = success.Map(x => x.ToString()); // Ok("42")
Result<int, int> errMapped = failure.MapError(e => e.Length); // Fail(9)
Result<int, string> chained = success.Bind(x => x >= 0 // chain another Result
? Result<int, string>.Ok(x + 1)
: Result<int, string>.Fail("negative")); // Ok(43)
// DateTime extensions
DateTime fromEpochMilliseconds = longParam.FromEpochMilliseconds();
DateTime fromEpochSeconds = longParam.FromEpochSeconds();
long epochMilliseconds = DateTime.UtcNow.ToEpochMilliseconds();
long epochSeconds = DateTime.UtcNow.ToEpochSeconds();
// Func extensions — run an async operation with a cooperative timeout
Func<CancellationToken, Task<int>> task = async ct =>
{
// Example operation that observes cancellation
await Task.Delay(TimeSpan.FromSeconds(10), ct);
return 99;
};
// The caller is released when the timeout elapses whether or not the operation cooperates.
// An elapsed timeout throws TimeoutException; a cancelled token throws OperationCanceledException.
int taskResult = await task.RunWithTimeout(TimeSpan.FromSeconds(1), cancellationToken: cancellationToken);
// Int extensions
bool convertedFromInt = 200.TryConvertToEnum<HttpStatusCode>(out HttpStatusCode statusCode);
// String extensions
bool convertedFromString = "OK".TryConvertToEnum<HttpStatusCode>(out HttpStatusCode statusCode2);
string base64Encoded = stringParam.Base64Encode();
string base64Decoded = base64Encoded.Base64Decode();
string reversed = stringParam.Reverse();
string truncated = stringParam.Truncate(10);
string textElementTruncated = stringParam.TruncateTextElements(10);
// Regex extensions — safe wrappers that catch RegexMatchTimeoutException
Regex regex = new Regex(pattern: "\\s+brown\\s+", options: RegexOptions.None, matchTimeout: TimeSpan.FromSeconds(1));
bool isMatch = regex.TryIsMatch("the quick brown fox", out bool isMatchTimedOut);
Match match = regex.TryMatch("the quick brown fox", out bool matchTimedOut);
string replaced = regex.TryReplace("the quick brown fox", " red ", out bool replaceTimedOut);
// Type extensions
IComparer<int> intComparer = TypeExtensions.GetDefaultComparer<int>();
}
Collections
MinPriorityQueue<T> and MaxPriorityQueue<T> predate and differ from .NET 6's System.Collections.Generic.PriorityQueue<TElement, TPriority>. The BCL type pairs each element with a separate priority and exposes only enqueue/dequeue/peek. These order the element itself through an IComparer<T> or Comparison<T>, and are full collections: they implement ICollection<T> and IReadOnlyCollection<T>, so they support Contains, Remove, CopyTo and can be passed to any API taking a read-only collection. Prefer the BCL type when a separate priority is the natural model and you only push and pop; prefer these when the element carries its own ordering, or when you need the collection operations.
Collections namespace contains extension methods for ICollection, IDictionary, IEnumerable, and IAsyncEnumerable interfaces, plus ConcurrentHashSet<T>, MinPriorityQueue<T>, and MaxPriorityQueue<T> collections.
using LibSharp.Collections;
public static async Task CollectionsExamples(CancellationToken cancellationToken)
{
// ICollection extensions
ICollection<int> collection = new List<int>(); // []
collection.AddRange(Enumerable.Range(0, 10)); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
// IDictionary extensions
IDictionary<string, string> dictionary = new Dictionary<string, string>();
_ = dictionary.AddOrUpdate(
"key",
"addedValue",
(key, existingValue) => "updatedValue");
_ = dictionary.AddOrUpdate(
"key",
key => "addedValue",
(key, existingValue) => "updatedValue");
_ = dictionary.AddOrUpdate(
"key",
(key, argument) => "addedValue" + argument,
(key, existingValue, argument) => "updatedValue" + argument,
"argument");
_ = dictionary.GetOrAdd(
"key",
"addedValue");
_ = dictionary.GetOrAdd(
"key",
keyValue => "addedValue");
_ = dictionary.GetOrAdd(
"key",
(keyValue, argument) => "addedValue" + argument,
"argument");
IDictionary<string, string> newCopy = dictionary.Copy();
IDictionary<string, string> destination = new Dictionary<string, string>();
IDictionary<string, string> result = dictionary.CopyTo(destination);
// IEnumerable extensions
List<List<int>> chunks = Enumerable.Range(0, 10).Chunk(20, item => item).ToList();
// Grouped by total weight ≤ 20: [ [0, 1, 2, 3, 4, 5], [6, 7], [8, 9] ]
IEnumerable<int> enumerable = Enumerable.Range(0, 100).Concat(Enumerable.Range(0, 100)).ToList();
int firstIndex = enumerable.FirstIndexOf(x => x == 51); // 51
int lastIndex = enumerable.LastIndexOf(x => x == 51); // 151
int[] shuffled = enumerable.Shuffle();
// IAsyncEnumerable extensions
IAsyncEnumerable<int> asyncEnumerable = GetNumbersAsync();
List<List<int>> asyncChunks = await CollectAsync(asyncEnumerable.Chunk(20, item => item), cancellationToken);
// Grouped by total weight ≤ 20: [ [0, 1, 2, 3, 4, 5], [6, 7], [8, 9], ... ]
int asyncFirstIndex = await asyncEnumerable.FirstIndexOfAsync(x => x == 51, cancellationToken);
int asyncLastIndex = await asyncEnumerable.LastIndexOfAsync(x => x == 51, cancellationToken);
// ConcurrentHashSet<T> — thread-safe hash set implementing ISet<T> and IReadOnlySet<T>
ConcurrentHashSet<int> set = new ConcurrentHashSet<int>();
bool added = set.Add(1); // true
added = set.Add(1); // false — already present
bool contains = set.Contains(1); // true
bool removed = set.Remove(1); // true
// Set algebra operations (not atomic at the collection level)
set.UnionWith(new[] { 2, 3 });
set.IntersectWith(new[] { 2, 4 });
set.ExceptWith(new[] { 4 });
bool subset = set.IsSubsetOf(new[] { 1, 2, 3 });
bool equal = set.SetEquals(new[] { 2 });
// Min priority queue
MinPriorityQueue<int> minPq = new MinPriorityQueue<int>();
minPq.Enqueue(2);
minPq.Enqueue(1);
minPq.Enqueue(3);
_ = minPq.Peek(); // 1 — smallest element, not removed
_ = minPq.Dequeue(); // 1
_ = minPq.Dequeue(); // 2
_ = minPq.Dequeue(); // 3
bool minHasValue = minPq.TryPeek(out int minPeeked);
bool minRemoved = minPq.TryDequeue(out int minDequeued);
// Max priority queue
MaxPriorityQueue<int> maxPq = new MaxPriorityQueue<int>();
maxPq.Enqueue(2);
maxPq.Enqueue(1);
maxPq.Enqueue(3);
_ = maxPq.Peek(); // 3 — largest element, not removed
_ = maxPq.Dequeue(); // 3
_ = maxPq.Dequeue(); // 2
_ = maxPq.Dequeue(); // 1
bool maxHasValue = maxPq.TryPeek(out int maxPeeked);
bool maxRemoved = maxPq.TryDequeue(out int maxDequeued);
}
private static async IAsyncEnumerable<int> GetNumbersAsync()
{
for (int i = 0; i < 200; i++)
{
await Task.Yield();
yield return i;
}
}
private static async Task<List<T>> CollectAsync<T>(IAsyncEnumerable<T> source, CancellationToken cancellationToken)
{
List<T> results = new List<T>();
await foreach (T item in source.WithCancellation(cancellationToken))
{
results.Add(item);
}
return results;
}
Threading
Threading namespace contains an async-compatible mutual exclusion lock and utilities for controlling how frequently an action can fire. ThrottledAction and DebouncedAction accept an optional TimeProvider (defaulting to TimeProvider.System), so their timing can be driven deterministically with a FakeTimeProvider in tests.
using LibSharp.Threading;
public static async Task ThreadingExamples(CancellationToken cancellationToken)
{
// AsyncLock — async-compatible mutual exclusion lock (not re-entrant)
using AsyncLock asyncLock = new AsyncLock();
using (AsyncLock.Handle handle = await asyncLock.AcquireAsync(cancellationToken))
{
// Only one caller can be inside this block at a time
}
// DebouncedAction — fires only after a quiet period since the last invocation
using DebouncedAction debounced = new DebouncedAction(
() => Console.WriteLine("Fired"),
delay: TimeSpan.FromMilliseconds(300));
debounced.Invoke(); // timer starts
debounced.Invoke(); // timer resets
debounced.Invoke(); // timer resets again — action fires 300 ms after this last call
// Important: do not call debounced.Dispose() from inside its callback.
// Dispose waits for callback completion and can deadlock in that pattern.
// ThrottledAction — executes at most once per interval
ThrottledAction throttled = new ThrottledAction(
() => Console.WriteLine("Fired"),
interval: TimeSpan.FromSeconds(1));
throttled.Invoke(); // executes immediately
throttled.Invoke(); // ignored — within the 1-second window
await Task.Delay(TimeSpan.FromSeconds(1));
throttled.Invoke(); // executes again — window has expired
}
Caching
Caching namespace contains a number of classes for thread-safe lazy initialization and caching of in-memory values.
Notes:
ILazyAsync<T>is the common contract of every asynchronously produced value here —HasValueplusGetValueAsync. The lazies implement it, andIValueCacheAsync<T>extends it withExpiration, so code that only needs a value can acceptILazyAsync<T>and take a lazy, a cache or a proactive cache alike.- Every
GetValueAsyncreturnsValueTask<T>rather thanTask<T>, so a cache hit costs no allocation. See Awaiting a ValueTask below for the rules that come with it. - All caches accept an optional
TimeProvider(defaulting toTimeProvider.System). Pass aFakeTimeProviderin tests to drive expiration and background refresh deterministically, without real delays. - Some of the classes implement
IDisposableinterface and should be correctly disposed. - Be cautious when caching types that implement
IDisposableinterface as the values will not be automatically disposed by the caches. PublicationOnlyimplementations dispose values that lose the publication race, when those values implementIAsyncDisposableorIDisposable. The losing value is identified exactly by the compare-exchange that publishes the winner, so it is known never to have reached a caller and nothing else could release it. PassdisposeDroppedValues: falseto opt out when the factory returns values that share an owned resource or are owned elsewhere. The published value is never disposed for you.PublicationOnlyimplementations may run multiple factories concurrently and publish the first successful result.- Async lazy and initializer methods throw
InvalidOperationExceptionif a factory returns a nullTask.
Quick selection guide:
- Use
LazyAsyncExecutionAndPublication<T>when you want to provide the factory in the constructor and allow at most one in-flight async initialization. - Use
LazyAsyncPublicationOnly<T>when duplicate concurrent factory executions are acceptable and you want the first successful result to win. - Use
Initializer<T>/InitializerAsync*<T>when the value should still be initialized once, but the factory is only known at call time. - Use
ValueCache<T>/ValueCacheAsync<T>when you need one cached value that expires and refreshes over time. - Use
KeyValueCache<TKey, TValue>/KeyValueCacheAsync<TKey, TValue>when you need the same expiration/refresh behavior per key, and the set of keys is limited. - Use
ProactiveAsyncCache<T>when refresh should happen in the background before expiry instead of on-demand by the next reader.
Awaiting a ValueTask
Every asynchronous read in this namespace — on the caches, the lazies and the initializers — returns ValueTask<T>. A read that hits a cached value completes synchronously and allocates nothing, which is why the type is used; the cost is that a ValueTask is not as forgiving as a Task.
Await the result exactly once, and never concurrently. To do anything else with it — store it, await it twice, or hand it to Task.WhenAll — call AsTask() first, which converts it into an ordinary Task<T> with none of those restrictions.
using LibSharp.Caching;
public static async Task ValueTaskUsageExample(IValueCacheAsync<int> cache, CancellationToken cancellationToken)
{
// Normal use: await the result once, immediately. No allocation when the value is cached.
int value = await cache.GetValueAsync(cancellationToken);
// Anything else needs AsTask() first: storing it, awaiting it twice, or combining it.
Task<int> first = cache.GetValueAsync(cancellationToken).AsTask();
Task<int> second = cache.GetValueAsync(cancellationToken).AsTask();
int[] values = await Task.WhenAll(first, second);
}
The factory delegates you supply still take and return Task<T>. They do the real work and never complete synchronously, so a ValueTask there would add friction for no benefit.
Lazy
Two different implementations of async lazy values are available — LazyAsyncPublicationOnly and LazyAsyncExecutionAndPublication. Those are async versions of System.Lazy class with LazyThreadSafetyMode.PublicationOnly and LazyThreadSafetyMode.ExecutionAndPublication modes respectively. They differ in how concurrent callers are handled: ExecutionAndPublication shares a single factory execution between them, while PublicationOnly lets each run its own and keeps whichever value is published first. Neither is IDisposable, and both implement ILazyAsync<T>.
LazyAsyncExecutionAndPublication runs at most one in-flight factory and retries after failed or canceled attempts. LazyAsyncPublicationOnly may execute multiple concurrent factories, but only the first successfully published value is retained; the losing racers' values are disposed for you if they are disposable, unless you pass disposeDroppedValues: false.
using LibSharp.Caching;
public static async Task LazyAsyncPublicationOnlyExample(Func<CancellationToken, Task<int>> factory, CancellationToken cancellationToken)
{
LazyAsyncPublicationOnly<int> lazy = new LazyAsyncPublicationOnly<int>(factory);
bool hasValue = lazy.HasValue; // false
int value = await lazy.GetValueAsync(cancellationToken); // factory invoked
hasValue = lazy.HasValue; // true
value = await lazy.GetValueAsync(cancellationToken); // factory not invoked
hasValue = lazy.HasValue; // true
}
public static async Task LazyAsyncExecutionAndPublicationExample(Func<CancellationToken, Task<int>> factory, CancellationToken cancellationToken)
{
LazyAsyncExecutionAndPublication<int> lazy = new LazyAsyncExecutionAndPublication<int>(factory);
bool hasValue = lazy.HasValue; // false
int value = await lazy.GetValueAsync(cancellationToken); // factory invoked
hasValue = lazy.HasValue; // true
value = await lazy.GetValueAsync(cancellationToken); // factory not invoked
hasValue = lazy.HasValue; // true
}
Initializers
Initializers in LibSharp are equivalents of lazy types, with the only difference being that the value factory is provided at lazy initialization time instead of creation time. They also enable cases where different factories can be used to initialize the value, where only one will succeed at setting the value.
InitializerAsyncExecutionAndPublication runs at most one in-flight factory and retries after failed or canceled attempts. InitializerAsyncPublicationOnly may execute multiple concurrent factories, but only the first successfully published value is retained.
using LibSharp.Caching;
public static void InitializerExample(Func<int> factory)
{
Initializer<int> initializer = new Initializer<int>();
bool hasValue = initializer.HasValue; // false
int value = initializer.GetValue(factory); // factory invoked
hasValue = initializer.HasValue; // true
value = initializer.GetValue(factory); // factory not invoked
hasValue = initializer.HasValue; // true
}
public static async Task InitializerAsyncPublicationOnlyExample(Func<CancellationToken, Task<int>> factory, CancellationToken cancellationToken)
{
InitializerAsyncPublicationOnly<int> initializer = new InitializerAsyncPublicationOnly<int>();
bool hasValue = initializer.HasValue; // false
int value = await initializer.GetValueAsync(factory, cancellationToken); // factory invoked
hasValue = initializer.HasValue; // true
value = await initializer.GetValueAsync(factory, cancellationToken); // factory not invoked
hasValue = initializer.HasValue; // true
}
public static async Task InitializerAsyncExecutionAndPublicationExample(Func<CancellationToken, Task<int>> factory, CancellationToken cancellationToken)
{
InitializerAsyncExecutionAndPublication<int> initializer = new InitializerAsyncExecutionAndPublication<int>();
bool hasValue = initializer.HasValue; // false
int value = await initializer.GetValueAsync(factory, cancellationToken); // factory invoked
hasValue = initializer.HasValue; // true
value = await initializer.GetValueAsync(factory, cancellationToken); // factory not invoked
hasValue = initializer.HasValue; // true
}
Value Caches
Value caches are lazy types that automatically refresh the value when it expires. It is possible to either provide an exact time-to-live value or a custom function to determine expiration of a value (useful, for example, for in-memory caching of tokens with known expiration time). It is also possible to provide either a factory method for creation of a new value or a factory for updating the existing value.
Note that ValueCacheAsync guarantees LazyThreadSafetyMode.ExecutionAndPublication behavior and implements IDisposable.
using LibSharp.Caching;
public static void ValueCacheExample(Func<int> factory)
{
ValueCache<int> cache = new ValueCache<int>(factory, TimeSpan.FromMilliseconds(1));
bool hasValue = cache.HasValue; // false
int value = cache.GetValue(); // factory invoked
hasValue = cache.HasValue; // true
Thread.Sleep(10);
value = cache.GetValue(); // factory invoked again — TTL expired
hasValue = cache.HasValue; // true
}
public static async Task ValueCacheAsyncExample(Func<CancellationToken, Task<int>> factory, CancellationToken cancellationToken)
{
using ValueCacheAsync<int> cache = new ValueCacheAsync<int>(factory, TimeSpan.FromMilliseconds(1));
bool hasValue = cache.HasValue; // false
int value = await cache.GetValueAsync(cancellationToken); // factory invoked
hasValue = cache.HasValue; // true
await Task.Delay(10);
value = await cache.GetValueAsync(cancellationToken); // factory invoked again — TTL expired
hasValue = cache.HasValue; // true
}
Key-Value Caches
Key-value caches allow caching and automatically refreshing multiple values within a single data structure.
Entries are never evicted, so a key-value cache is only suitable for a bounded key space. Count reports how many distinct keys are held, including those whose value has expired, which makes it the measure to watch when confirming that the key space really is bounded. Reading it takes every bucket lock of the underlying ConcurrentDictionary, so sample it periodically rather than per request. It is deliberately on the concrete types rather than the interfaces, following MemoryCache/IMemoryCache.
using LibSharp.Caching;
public static void KeyValueCacheExample(Func<string, int> factory)
{
KeyValueCache<string, int> cache = new KeyValueCache<string, int>(factory, TimeSpan.FromMinutes(1));
int valueA = cache.GetValue("a"); // factory invoked for "a"
int valueB = cache.GetValue("b"); // factory invoked for "b"
valueA = cache.GetValue("a"); // factory not invoked
valueB = cache.GetValue("b"); // factory not invoked
}
public static async Task KeyValueCacheAsyncExample(Func<string, CancellationToken, Task<int>> factory, CancellationToken cancellationToken)
{
using KeyValueCacheAsync<string, int> cache = new KeyValueCacheAsync<string, int>(factory, TimeSpan.FromMinutes(1));
int valueA = await cache.GetValueAsync("a", cancellationToken); // factory invoked for "a"
int valueB = await cache.GetValueAsync("b", cancellationToken); // factory invoked for "b"
valueA = await cache.GetValueAsync("a", cancellationToken); // factory not invoked
valueB = await cache.GetValueAsync("b", cancellationToken); // factory not invoked
}
Proactive Async Cache
ProactiveAsyncCache is an async cache that proactively refreshes its value in the background before it expires. It starts a background loop that re-fetches the value at a configurable interval. A pre-fetch offset allows refresh to happen before expiration, reducing the chance that callers need to wait for the factory.
using LibSharp.Caching;
public static async Task ProactiveAsyncCacheExample(Func<CancellationToken, Task<int>> factory, CancellationToken cancellationToken)
{
// Default options: background loop starts automatically, stale reads disabled
await using ProactiveAsyncCache<int> cache = new ProactiveAsyncCache<int>(
factory,
refreshInterval: TimeSpan.FromMinutes(5),
preFetchOffset: TimeSpan.FromSeconds(30));
bool hasValue = cache.HasValue; // false — until first background fetch completes
int value = await cache.GetValueAsync(cancellationToken); // waits for background fetch if not yet complete
hasValue = cache.HasValue; // true
value = await cache.GetValueAsync(cancellationToken); // returns cached value
}
public static async Task ProactiveAsyncCacheWithOptionsExample(Func<CancellationToken, Task<int>> factory, CancellationToken cancellationToken)
{
// Anything beyond the two intervals is configured through the options object, so new
// settings can be added later without breaking existing callers.
await using ProactiveAsyncCache<int> cache = new ProactiveAsyncCache<int>(
factory,
new ProactiveAsyncCacheOptions
{
RefreshInterval = TimeSpan.FromMinutes(5),
PreFetchOffset = TimeSpan.FromSeconds(30),
// Serve the previous value while a refresh runs, but only for up to two minutes
// past its expiration. Beyond that readers wait, so a dependency that stays down
// surfaces as an exception instead of an ever-older value.
StaleReads = StaleReadPolicy.ServeStaleUpTo(TimeSpan.FromMinutes(2)),
// Bound a single factory call. Also bounds DisposeAsync, which otherwise waits as
// long as the factory takes.
FetchTimeout = TimeSpan.FromSeconds(10),
});
int value = await cache.GetValueAsync(cancellationToken);
// A failing background refresh is otherwise invisible when stale reads are enabled:
// callers keep receiving a value and see no error. These report what is actually going on.
Exception? lastError = cache.LastRefreshException; // null while healthy
int failures = cache.ConsecutiveRefreshFailures; // 0 while healthy
DateTime? producedAt = cache.LastSuccessfulRefresh; // age of what is being served
}
public static async Task ProactiveAsyncCacheStaleReadPolicyExample(Func<CancellationToken, Task<int>> factory, CancellationToken cancellationToken)
{
// Three choices for what happens when a read arrives and the value has expired:
//
// StaleReadPolicy.Wait wait for a fresh value (the default)
// StaleReadPolicy.ServeStale serve the old value however old it is
// StaleReadPolicy.ServeStaleUpTo(span) serve it up to `span` past expiration, then wait
//
// The bound is measured from expiration, so the oldest value a reader can receive is
// RefreshInterval + the bound.
await using ProactiveAsyncCache<int> cache = new ProactiveAsyncCache<int>(
factory,
new ProactiveAsyncCacheOptions
{
RefreshInterval = TimeSpan.FromMinutes(5),
StaleReads = StaleReadPolicy.ServeStale,
});
int value = await cache.GetValueAsync(cancellationToken); // never blocks after the first fetch
}
public static async Task ProactiveAsyncCacheWithIdleTimeoutExample(Func<CancellationToken, Task<int>> factory, CancellationToken cancellationToken)
{
await using ProactiveAsyncCache<int> cache = new ProactiveAsyncCache<int>(
factory,
new ProactiveAsyncCacheOptions
{
RefreshInterval = TimeSpan.FromMinutes(5),
PreFetchOffset = TimeSpan.FromSeconds(30),
// Stop refreshing in the background once the cache falls out of use.
IdleTimeout = TimeSpan.FromHours(1),
});
int value = await cache.GetValueAsync(cancellationToken);
// After an hour with no call to GetValueAsync the background loop suspends itself and stops
// invoking the factory; it holds no timer and consumes no CPU while suspended. The next read
// resumes it, paying for at most one on-demand fetch if the cached value has since expired.
// Only GetValueAsync counts as activity — HasValue and Expiration do not. Disposal is still
// required: an idle cache is dormant, not collected.
}
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 5.0.0 | 50 | 9/8/2026 |
| 4.0.0 | 358 | 7/4/2026 |
| 3.0.0 | 165 | 4/1/2026 |
| 3.0.0-beta.1 | 78 | 3/31/2026 |
| 2.0.4 | 91 | 3/30/2026 |
| 2.0.3 | 97 | 3/27/2026 |
| 2.0.2 | 92 | 3/23/2026 |
| 2.0.1 | 90 | 3/22/2026 |
| 2.0.0 | 137 | 3/11/2026 |
| 1.1.6 | 305 | 9/26/2024 |
| 1.1.5 | 195 | 8/27/2024 |
| 1.1.4 | 221 | 6/9/2024 |
| 1.1.3 | 219 | 2/24/2024 |
| 1.1.2 | 190 | 2/24/2024 |
| 1.1.1 | 221 | 2/23/2024 |
| 1.1.0 | 187 | 2/21/2024 |
| 1.0.0 | 204 | 2/19/2024 |
# Changelog
- 5.0.0
- `Caching`
- **Breaking:** `LazyAsyncPublicationOnly<T>` and `InitializerAsyncPublicationOnly<T>` now dispose values that lose the publication race, when those values implement `IAsyncDisposable` or `IDisposable`. Concurrent callers each run a factory and only the first value published is kept; the rest were previously dropped without being disposed and without ever reaching a caller, so nothing could release them — a leak the caller was powerless to fix, and the reason these types carried a blanket warning against disposable values. The loser is identified exactly rather than heuristically: the compare-exchange that publishes the winner returns the value already in place, so a dropped value is known never to have been handed out. Where `T` is a reference type, a shared instance returned to every racer is never disposed, because identity is checked first; no such check is possible for a value type, which is copied per racer. Pass `disposeDroppedValues: false` to the constructor to turn this off, for a factory whose values share an owned resource or are owned elsewhere. The published value is still never disposed by these types
- `LazyAsyncPublicationOnly<T>` and `InitializerAsyncPublicationOnly<T>` now honour an already-cancelled `CancellationToken` instead of leaving cancellation to the factory. A factory that ignores the token it is handed previously produced a value and returned it, so the two publication-only types were the only implementations of `ILazyAsync<T>` and `IInitializerAsync<T>` that did not throw, contradicting their own documented `OperationCanceledException`. A read served from an already-published value still returns it, since it does no waiting
- **Breaking:** added `ILazyAsync<T>`, the shape common to every asynchronously produced value here — `HasValue` plus `GetValueAsync` — and `IValueCacheAsync<T>` now derives from it, adding only `Expiration`. `LazyAsyncExecutionAndPublication<T>` and `LazyAsyncPublicationOnly<T>` implement it, so they are no longer the only cache-family types with no interface, and code that just needs a value can accept `ILazyAsync<T>` and take a lazy, a cache or a proactive cache alike. Moving `GetValueAsync` onto the base interface is a binary break: callers compiled against 4.0.0 reference it on `IValueCacheAsync<T>` directly and must be recompiled. Source is unaffected
- **Breaking:** `GetValueAsync` now returns `ValueTask<T>` instead of `Task<T>` on `IValueCacheAsync<T>`, `IKeyValueCacheAsync<TKey, TValue>`, `IInitializerAsync<T>` and all their implementations. A cache read usually completes synchronously, and that path no longer allocates. Await the result at most once, never concurrently, and call `AsTask()` before storing it or handing it to `Task.WhenAll`. The factory delegates deliberately still take and return `Task<T>`: they perform the real work and never complete synchronously, so a value task there would be caller friction for no gain
- **Breaking:** `ProactiveAsyncCache<T>` is now configured through `ProactiveAsyncCacheOptions` instead of constructor parameters. The old positional constructor is gone; a three-argument convenience overload `(valueFactory, refreshInterval, preFetchOffset)` remains for the common case and will never gain further parameters. Settings live on the options object so that adding one later is not a binary breaking change, which an optional constructor parameter always is
- **Breaking:** `allowStaleReads` is replaced by `StaleReadPolicy`, a closed set of cases rather than a flag: `Wait` (the default), `ServeStale`, and `ServeStaleUpTo(maxStaleness)`. The bound caps how long past expiration a value may still be served; beyond it readers wait, so a persistent failure reaches them as an exception instead of hiding behind an ever-older value. A `TimeSpan` on one case is why this is a type and not an enum: an enum plus a separate maximum-age setting would permit combinations that mean nothing
- Added `FetchTimeout` to `ProactiveAsyncCacheOptions`, bounding a single value factory invocation. An overrun is reported as `TimeoutException` and recorded as a failure. It also bounds `DisposeAsync`, which otherwise waits as long as the factory takes, and only helps if the factory honours its token
- `ProactiveAsyncCache<T>.DisposeAsync` now cancels with `CancelAsync` rather than `Cancel`. Cancellation callbacks the value factory registered no longer run inline on the disposing thread, so a slow or re-entrant callback cannot stall the caller
- Added `IdleTimeout` to `ProactiveAsyncCacheOptions`. When set, the background refresh loop suspends itself once `GetValueAsync` has not been called for that long, holding no timer and consuming no CPU, and the next read resumes it immediately. Only `GetValueAsync` counts as activity, not `HasValue` or `Expiration`
- **Breaking:** a caller's `CancellationToken` no longer cancels the shared refresh in `ValueCacheAsync<T>` (and therefore `KeyValueCacheAsync<TKey, TValue>`). Previously the value factory ran on whichever caller's token happened to trigger it, so one caller giving up cancelled a refresh other callers were waiting on and discarded work the cache was about to publish. The factory now runs on a token scoped to the cache instance, cancelled only by `Dispose()`, and a caller's token cancels that caller's wait alone. Concurrent callers now share a single factory invocation instead of serialising through a lock
- **Breaking:** `LazyAsyncExecutionAndPublication<T>` and `InitializerAsyncExecutionAndPublication<T>` are no longer `IDisposable`. They held an `AsyncLock` purely to serialise initialization; they now publish a shared initialization task instead, so they own nothing that needs releasing. Remove any `using` around them. As with the caches, the factory no longer receives the caller's token — it runs with `CancellationToken.None`, and a caller's token cancels that caller's wait alone. Concurrent callers still share exactly one factory execution, and faulted or cancelled attempts are still not cached
- `ValueCacheAsync<T>` no longer holds a lock across the value factory: it publishes a shared refresh task the way `ProactiveAsyncCache<T>` does. A factory that synchronously re-enters the cache now joins that refresh instead of deadlocking; one that awaits the nested read still deadlocks
- Fixed an already-cancelled `CancellationToken` being ignored by `GetValueAsync` on `ValueCacheAsync<T>` and `ProactiveAsyncCache<T>` when the shared fetch had already completed. Only a read that actually waits is cancelled: a cache hit is still served, as is a read the stale-read policy satisfies, since neither waits for anything
- Argument validation and disposal checks in `GetValueAsync` now throw synchronously rather than returning a faulted task, following the convention for `ValueTask`-returning members
- Added `Count` to `KeyValueCache<TKey, TValue>` and `KeyValueCacheAsync<TKey, TValue>`, reporting the number of entries held. Deliberately on the concrete types rather than the interfaces, following `MemoryCache`/`IMemoryCache`: the number means different things for evicting and non-evicting implementations, may be expensive or unavailable for a remote one, and reading it takes every bucket lock of the underlying `ConcurrentDictionary`. Since nothing is evicted, it counts entries whose value has expired, which makes it the measure to watch when confirming a key space is bounded
- Added `LastRefreshException`, `ConsecutiveRefreshFailures` and `LastSuccessfulRefresh` to `ProactiveAsyncCache<T>`. A failing background refresh was previously invisible: with `allowStaleReads` enabled callers keep receiving an arbitrarily old value and never see an error, so nothing could tell a health check that the value had stopped being updated. The failure state is cleared by the next successful refresh
- `ProactiveAsyncCache<T>` no longer retries a failing value factory on every read. A faulted fetch is a completed task, so previously each subsequent read started a fresh factory call with no delay at all: a dependency failing fast turned a multi-minute refresh interval into one call per read, against a service already under strain. The last failure is now recorded, and reads within the retry window replay the stored exception instead of calling the factory. With `allowStaleReads` the stale value is served instead. A successful fetch clears the record. The background loop is exempt, since it already paces its own retries
- `Initializer<T>` now throws `InvalidOperationException` when the value factory reads the initializer it is initializing. The re-entrant read previously found no published value and called the factory again, recursing until the stack overflowed and took the process with it — a crash no caller could catch. Matches the behaviour already given to `ValueCache<T>` and `KeyValueCache<TKey, TValue>`
- `ValueCache<T>` and `KeyValueCache<TKey, TValue>` now throw `InvalidOperationException` when the value factory reads the cache it is refreshing, the way `Lazy<T>` reports recursive initialization. Previously the re-entrant read found no published value and called the factory again, recursing until the stack overflowed and took the process with it. Re-entering `KeyValueCache` for a different key is still allowed
- `KeyValueCache<TKey, TValue>` and `KeyValueCacheAsync<TKey, TValue>` no longer allocate a delegate on every read. The `GetOrAdd` factory captured `this`, and Roslyn only caches closure-free lambdas, so one was allocated per call rather than per insert
- `Collections`
- `IPriorityQueue<T>` now also implements `IReadOnlyCollection<T>`, so a queue can be passed to any API that takes one. `ICollection<T>` does not derive from `IReadOnlyCollection<T>`, so this was previously impossible without copying. The interface re-declares `Count` with `new`, which is required: inheriting the member from both bases otherwise makes reading `Count` through the interface a compile error (CS0229), and that ambiguity is why the base class library never made `ICollection<T>` derive from `IReadOnlyCollection<T>`. Implementers need do nothing — a single public `Count` satisfies all three declarations
- `CollectionExtensions.AddRange` now defers to `List{T}.AddRange` when the target is a `List<T>`, which sizes the backing array once from the source's count instead of regrowing it as items arrive. Measured 1216 to 456 bytes for 100 items added to an empty list, matching a direct call to `List<T>.AddRange`
- Fixed `Chunk` silently ignoring its weight budget after a `NaN` item weight. `NaN` passed both range guards, since `NaN < 0` and `NaN > chunkWeight` are each false, and then poisoned the running total so that no later comparison against the budget was ever true. Non-finite item weights are now rejected. Affected both the `IEnumerable<T>` and `IAsyncEnumerable<T>` overloads
- `DictionaryExtensions.Copy` now preserves the source dictionary's comparer where it can be recovered. A `Dictionary<string, T>` built with `StringComparer.OrdinalIgnoreCase` previously copied into one using default equality, so the copy resolved lookups differently from the original
- `DictionaryExtensions.GetOrAdd` and `AddOrUpdate` no longer allocate a closure per call in their `factoryArgument` overloads, which is the allocation those overloads exist to avoid
- `MinPriorityQueue<T>` and `MaxPriorityQueue<T>` now build a heap from a collection in O(n) rather than enqueuing each element in O(n log n)
- `ConcurrentHashSet<T>` no longer copies the entire set to enumerate it. `GetEnumerator` and the subset, superset and equality checks read `ConcurrentDictionary.Keys`, which takes every bucket lock and allocates a full copy on each call; they now enumerate the dictionary directly, which is lock-free. Enumeration is consequently a live view rather than a snapshot: elements added or removed after it begins may or may not be observed, matching `ConcurrentDictionary`
- **Breaking:** `MinPriorityQueue<T>` and `MaxPriorityQueue<T>` now implement the non-generic `ICollection` members explicitly, so `SyncRoot`, `IsSynchronized` and `CopyTo(Array, int)` are no longer on the public surface. The interface is still implemented, so legacy interop is unaffected; reach those members through `((ICollection)queue)`. `SyncRoot` and `IsSynchronized` are the .NET 1.x synchronization pattern, which these types never honoured — nothing locks on `SyncRoot` — and `List<T>` hides them the same way
- **Breaking:** `CopyTo` on both queues now reports `ArgumentOutOfRangeException.ParamName` as the actual parameter name rather than the literal `"Array offset"`, which matched no parameter and so could never be matched by a caller filtering on it
- Fixed `MinPriorityQueue<T>` and `MaxPriorityQueue<T>` failing to shrink correctly beyond roughly 536 million elements. The quarter-full test multiplied the count by four, which overflows to a negative value at that size, passing the test and then throwing from the copy because the count no longer fitted the smaller array
- Fixed the priority queue enumerators advancing their index on every `MoveNext` call after the end of the collection. Repeated calls returned `false` as required, but the index grew without bound and would eventually overflow and read outside the heap
- `MinPriorityQueue<T>` and `MaxPriorityQueue<T>` now throw `InvalidOperationException` when `Current` is read before the first `MoveNext`, instead of silently returning `default(T)` from the heap's unused slot
- `Common`
- **Breaking:** `TypeExtensions.GetDefaultComparer<T>` now throws `InvalidOperationException` rather than `ArgumentException` when `T` implements neither `IComparable<T>` nor `IComparable`. The method takes no arguments, so there was nothing for an `ArgumentException` to name. It also resolves the check once per closed generic type instead of running two reflection calls on every invocation, which every priority queue constructor paid. Constructing a queue over a non-comparable element type surfaces the same new exception
- Added `Optional<T>.Empty`, the named form of `default(Optional<T>)`, so the absent state is discoverable and reads clearly at a call site. An optional holding `null` remains a distinct state that does not compare equal to it
- Fixed `IntExtensions.TryConvertToEnum<T>` throwing `ArgumentException` for any enum whose underlying type is not `int`. It used `Enum.IsDefined(Type, object)`, which requires the boxed value to carry the enum's exact underlying type, so a `byte`- or `long`-backed enum threw instead of reporting whether the value was defined. The value is now converted with a range check first, because `Enum.ToObject` truncates rather than rejecting: 300 becomes 44 for a byte-backed enum
- `StringExtensions.Reverse` now writes each text element straight into the result with `string.Create`, allocating only the string it returns instead of a cluster index array, a `StringBuilder` and its buffer. Measured 216 to 48 bytes for an 11-character string
- `StringExtensions.TruncateTextElements` no longer indexes every text element in the string. It walks only as far as the limit, so a string within the limit allocates nothing at all where it previously allocated an index array proportional to the whole string
- `IntExtensions.TryConvertToEnum<T>` no longer boxes the converted value, and resolves the enum's underlying type once per closed generic type rather than on every call. Measured 24 bytes to zero
- **Breaking:** `FuncExtensions.RunWithTimeout` now actually enforces its timeout. It previously handed the work a token that fired at the deadline and then awaited the work unconditionally, so work that ignored the token ran on and the call never returned. The caller is now released when the deadline passes regardless, and abandoned work is left running with its faults observed
- **Breaking:** `RunWithTimeout` reports an elapsed timeout as `TimeoutException` rather than `OperationCanceledException`, so a timeout can be told apart from the caller cancelling. This holds even when the work honours its token and throws on the way out
- **Breaking:** `RunWithTimeout` takes an optional `TimeProvider`, so the timeout can be driven deterministically like every other timing-sensitive type in the library. `CancellationToken` moved to last to keep the conventional parameter order, which is a compile-time break for positional callers
- `RunWithTimeout` rejects a null task from the factory with `InvalidOperationException` instead of failing with `NullReferenceException`
- **Breaking:** removed `XmlSerializationExtensions` and its `SerializeToXml` / `DeserializeFromXml` methods. They were the library's only dependency on `XmlSerializer`, and with it the only barrier to a clean trimming and Native AOT story, the only process-lifetime static cache (serializer instances, which root their generated assemblies and block collectible assembly unloading), and the only public API outside this library's concurrency, caching and collections remit. Call `XmlSerializer` directly, or use a serializer of your choosing. One detail worth carrying over if you do: these helpers read with `DtdProcessing.Prohibit`, which a bare `XmlReader` does not default to, and which is what closes the XML external entity hole
- The library is now built with `IsAotCompatible`, so the trim and Native AOT analysers run over the whole assembly. With `XmlSerializationExtensions` removed nothing in the library is incompatible, so it carries no `RequiresUnreferencedCode` or `RequiresDynamicCode` annotations at all and emits no trimming or AOT warnings to consumers
- `Argument.NotNull`, `NotNullOrEmpty`, `NotNullOrWhiteSpace`, `OfType` and the four comparison checks now take a nullable argument and carry `[NotNull]`. Passing a nullable previously warned at the call site, and `NotNull<T>` rejected the type argument outright under its non-nullable `class` constraint, so null-checking a nullable required `Argument.NotNull(x!)` — a null-forgiving operator to call the null check. They now also narrow the argument to non-null for the code that follows, removing the suppressions callers had to write afterwards. `EqualTo` and `NotEqualTo` accept a nullable but deliberately do not narrow, since null is a legal value for both
- `Threading`
- Fixed `DebouncedAction.Dispose()` deadlocking permanently when called from inside the debounced action. `Dispose` waits for the in-flight callback to finish, which in that case is the caller itself. It now detects the call coming from the callback's own thread and returns without waiting
- **Breaking:** `AsyncLock.AcquireAsync` now returns `ValueTask<Handle>` instead of `Task<Handle>`, and an uncontended acquisition now allocates nothing at all. `Handle` carried a heap-allocated releaser to keep `Dispose` idempotent across copies of the struct; it now carries a version stamping the acquisition it came from, which a release must match. A copy disposed twice, or a handle left over from an earlier acquisition, no longer matches and does nothing, as before. Measured 32 bytes to zero
- 4.0.0
- Enabled nullable reference type annotations across the entire public API; `TryGet*` methods and out parameters are now annotated (e.g. `[MaybeNullWhen(false)]`), and nullable inputs such as optional `Encoding`/`XmlReaderSettings` arguments are marked accordingly
- `Caching`
- Updated `ProactiveAsyncCache<T>` to never throw exceptions from `DisposeAsync()`
- `ValueCache<T>`, `ValueCacheAsync<T>`, `KeyValueCache<TKey, TValue>`, `KeyValueCacheAsync<TKey, TValue>`, and `ProactiveAsyncCache<T>` now accept an optional `TimeProvider` (defaulting to `TimeProvider.System`) so expiration and background refresh can be driven deterministically in tests
- `Collections`
- Renamed extension classes to drop the `I` prefix: `IEnumerableExtensions` → `EnumerableExtensions`, `ICollectionExtensions` → `CollectionExtensions`, `IDictionaryExtensions` → `DictionaryExtensions`, `IAsyncEnumerableExtensions` → `AsyncEnumerableExtensions` (extension methods called via instance syntax are unaffected; static-style calls must use the new names)
- `MinPriorityQueue<T>` and `MaxPriorityQueue<T>`: `Contains` and `Remove` now use element equality (`EqualityComparer<T>.Default`) instead of the ordering comparer, so they honour the `ICollection<T>` contract (reverses the 3.0.0 change; ordering still uses the comparer)
- `ConcurrentHashSet<T>` now constrains `T` to `notnull` (it is backed by `ConcurrentDictionary`, which never permitted null elements); `IDictionaryExtensions.Copy` likewise constrains its key to `notnull`
- `DictionaryExtensions` and the key-value caches now validate keys without boxing value-type keys
- `Common`
- `Optional<T>` no longer implements `IEquatable<T>`; it now implements only `IEquatable<Optional<T>>`, so equality is defined between two optionals. A bare value still compares equal via the new implicit conversion, but a value typed as `object` never does
- Added an implicit conversion from `T` to `Optional<T>` (always produces a present optional, even for `null`)
- Added `Match`, `Map`, and `Bind` to `Optional<T>`
- Added `Match`, `Map`, `MapError`, and `Bind` to `Result<T, TError>`
- Removed the `Argument.NotNull(object, string)` overload; calling `NotNull` on a non-nullable value type is now a compile error instead of a silent no-op (the reference-type generic overload is retained)
- `Argument` methods now capture the argument name automatically via `[CallerArgumentExpression]`, so the `name` parameter is optional; existing calls that pass it explicitly still compile
- `XmlSerializationExtensions.SerializeToXml` / `DeserializeFromXml` are now annotated with `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]` to reflect that `XmlSerializer` is incompatible with trimming and Native AOT
- `Threading`
- `ThrottledAction` and `DebouncedAction` now accept an optional `TimeProvider` (defaulting to `TimeProvider.System`) so the throttle interval and debounce timer can be driven deterministically in tests
- `ThrottledAction` now clamps its interval-to-ticks conversion so an extreme interval near `TimeSpan.MaxValue` cannot overflow into a negative value and defeat throttling
- 3.0.0
- `Caching`
- Async cache/lazy/initializer factories now reject null task returns with a deliberate exception instead of failing with `NullReferenceException`
- `ProactiveAsyncCache<T>` no longer implements `IDisposable`; use `await using` / `DisposeAsync()` instead
- `ProactiveAsyncCache` no longer supports `refreshTimeout` and `onBackgroundRefreshError` parameters, and now always auto-starts in constructor
- `Collections`
- Added `ConcurrentHashSet`
- Added weighted `Chunk` extension method for `IAsyncEnumerable<T>`
- Added `TryPeek` and `TryDequeue` to `IPriorityQueue<T>`, `MinPriorityQueue<T>`, and `MaxPriorityQueue<T>`
- `MinPriorityQueue<T>` and `MaxPriorityQueue<T>`: `Contains` and `Remove` now use the queue's comparer instead of `object.Equals`, making them consistent with the ordering relation
- `Common`
- Added `Result`
- Renamed `Box` to `Optional`; null values are now allowed
- `Optional<T>.GetHashCode` now differentiates between an empty optional and an optional wrapping `null`
- Added `StringExtensions.TruncateTextElements` for text-element-aware truncation
- `DateTimeExtensions` epoch conversions now use Unix-time floor semantics instead of rounding fractional units
- `TypeExtensions.GetDefaultComparer` now supports types implementing non-generic `IComparable`
- Regex extensions now return a `bool` indicating whether the regex match timed out
- `FuncExtensions.RunWithTimeout`: timeout must now be strictly greater than zero
- `XmlSerializationExtensions`: `XmlSerializer` instances are now cached per type to avoid repeated dynamic assembly generation
- `Threading`
- Added `AsyncLock`, `DebouncedAction`, and `ThrottledAction`
- 2.0.4
- Improved disposal of async caches in edge cases
- 2.0.3
- `ProactiveAsyncCache` now calculates retry delay based on the refresh interval and pre-fetch offset
- Minor bug fixes and improvements
- 2.0.2
- `ProactiveAsyncCache` now supports an optional `refreshTimeout` parameter
- Minor bug fixes and improvements
- 2.0.1
- `ProactiveAsyncCache` now supports stale reads mode
- `ProactiveAsyncCache` now accepts an action to handle failed background refreshes
- 2.0.0
- Dropped support for .NET Standard 2.0, .NET Standard 2.1, .NET 5.0, .NET 6.0, and .NET 7.0
- Added support for .NET 10.0
- Removed `DateTimeExtensions.UnixEpoch`
- Added `ProactiveAsyncCache`
- Added `TryConvertToEnum` extension method for `string`
- Bug fixes and thread safety improvements
- 1.1.6
- Added `TryConvertToEnum` extension method for `int`
- 1.1.5
- Added Regex extension methods that handle regex timeouts gracefully
- Added `Func` extension methods that run asynchronous operations with a timeout
- 1.1.4
- Added support for .NET 9.0
- 1.1.3
- Updated NuGet package tags and description
- 1.1.2
- Added constructors to `KeyValueCache` and `KeyValueCacheAsync` that accept separate factories for creates and updates
- Added the ability to specify a custom expiration function
- 1.1.1
- Changed the return type of the `Shuffle` extension method from `IEnumerable<T>` to `T[]`
- Fixed the signature of `SerializeToXml` so it can be invoked as an extension method
- All `IDisposable` types now throw `ObjectDisposedException` when a member is accessed after disposal
- 1.1.0
- Added support for .NET Standard 2.0, .NET Standard 2.1, .NET 5.0, .NET 6.0, and .NET 7.0
- 1.0.0
- Initial release targeting .NET 8.0